mirror of
https://github.com/coleam00/Archon
synced 2026-04-21 13:37:41 +00:00
feat(paths,workflows): unify ~/.archon/{workflows,commands,scripts} + drop globalSearchPath (closes #1136) (#1315)
* feat(paths,workflows): unify ~/.archon/{workflows,commands,scripts} + drop globalSearchPath
Collapses the awkward `~/.archon/.archon/workflows/` convention to a direct
`~/.archon/workflows/` child (matching `workspaces/`, `archon.db`, etc.), adds
home-scoped commands and scripts with the same loading story, and kills the
opt-in `globalSearchPath` parameter so every call site gets home-scope for free.
Closes #1136 (supersedes @jonasvanderhaegen's tactical fix — the bug was the
primitive itself: an easy-to-forget parameter that five of six call sites on
dev dropped).
Primitive changes:
- Home paths are direct children of `~/.archon/`. New helpers in `@archon/paths`:
`getHomeWorkflowsPath()`, `getHomeCommandsPath()`, `getHomeScriptsPath()`,
and `getLegacyHomeWorkflowsPath()` (detection-only for migration).
- `discoverWorkflowsWithConfig(cwd, loadConfig)` reads home-scope internally.
The old `{ globalSearchPath }` option is removed. Chat command handler, Web
UI workflow picker, orchestrator resolve path — all inherit home-scope for
free without maintainer patches at every new site.
- `discoverScriptsForCwd(cwd)` merges home + repo scripts (repo wins on name
collision). dag-executor and validator use it; the hardcoded
`resolve(cwd, '.archon', 'scripts')` single-scope path is gone.
- Command resolution is now walked-by-basename in each scope. `loadCommand`
and `resolveCommand` walk 1 subfolder deep and match by `.md` basename, so
`.archon/commands/triage/review.md` resolves as `review` — closes the
latent bug where subfolder commands were listed but unresolvable.
- All three (`workflows/`, `commands/`, `scripts/`) enforce a 1-level
subfolder cap (matches the existing `defaults/` convention). Deeper
nesting is silently skipped.
- `WorkflowSource` gains `'global'` alongside `'bundled'` and `'project'`.
Web UI node palette shows a dedicated "Global (~/.archon/commands/)"
section; badges updated.
Migration (clean cut — no fallback read):
- First use after upgrade: if `~/.archon/.archon/workflows/` exists, Archon
logs a one-time WARN per process with the exact `mv` command:
`mv ~/.archon/.archon/workflows ~/.archon/workflows && rmdir ~/.archon/.archon`
The legacy path is NOT read — users migrate manually. Rollback caveat
noted in CHANGELOG.
Tests:
- `@archon/paths/archon-paths.test.ts`: new helper tests (default HOME,
ARCHON_HOME override, Docker), plus regression guards for the double-`.archon/`
path.
- `@archon/workflows/loader.test.ts`: home-scoped workflows, precedence,
subfolder 1-depth cap, legacy-path deprecation warning fires exactly once
per process.
- `@archon/workflows/validator.test.ts`: home-scoped commands + subfolder
resolution.
- `@archon/workflows/script-discovery.test.ts`: depth cap + merge semantics
(repo wins, home-missing tolerance).
- Existing CLI + orchestrator tests updated to drop `globalSearchPath`
assertions.
E2E smoke (verified locally, before cleanup):
- `.archon/workflows/e2e-home-scope.yaml` + scratch repo at /tmp
- Home-scoped workflow discovered from an unrelated git repo
- Home-scoped script (`~/.archon/scripts/*.ts`) executes inside a script node
- 1-level subfolder workflow (`~/.archon/workflows/triage/*.yaml`) listed
- Legacy path warning fires with actionable `mv` command; workflows there
are NOT loaded
Docs: `CLAUDE.md`, `docs-web/guides/global-workflows.md` (full rewrite for
three-type scope + subfolder convention + migration), `docs-web/reference/
configuration.md` (directory tree), `docs-web/reference/cli.md`,
`docs-web/guides/authoring-workflows.md`.
Co-authored-by: Jonas Vanderhaegen <7755555+jonasvanderhaegen@users.noreply.github.com>
* test(script-discovery): normalize path separators in mocks for Windows
The 4 new tests in `scanScriptDir depth cap` and `discoverScriptsForCwd —
merge repo + home with repo winning` compared incoming mock paths with
hardcoded forward-slash strings (`if (path === '/scripts/triage')`). On
Windows, `path.join('/scripts', 'triage')` produces `\scripts\triage`, so
those branches never matched, readdir returned `[]`, and the tests failed.
Added a `norm()` helper at module scope and wrapped the incoming `path`
argument in every `mockImplementation` before comparing. Stored paths go
through `normalizeSep()` in production code, so the existing equality
assertions on `script.path` remain OS-independent.
Fixes Windows CI job `test (windows-latest)` on PR #1315.
* address review feedback: home-scope error handling, depth cap, and tests
Critical fixes:
- api.ts: add `maxDepth: 1` to all 3 findMarkdownFilesRecursive calls in
GET /api/commands (bundled/home/project). Without this the UI palette
surfaced commands from deep subfolders that the executor (capped at 1)
could not resolve — silent "command not found" at runtime.
- validator.ts: wrap home-scope findMarkdownFilesRecursive and
resolveCommandInDir calls in try/catch so EACCES/EPERM on
~/.archon/commands/ doesn't crash the validator with a raw filesystem
error. ENOENT still returns [] via the underlying helper.
Error handling fixes:
- workflow-discovery.ts: maybeWarnLegacyHomePath now sets the
"warned-once" flag eagerly before `await access()`, so concurrent
discovery calls (server startup with parallel codebase resolution)
can't double-warn. Non-ENOENT probe errors (EACCES/EPERM) now log at
WARN instead of DEBUG so permission issues on the legacy dir are
visible in default operation.
- dag-executor.ts: wrap discoverScriptsForCwd in its own try/catch so
an EACCES on ~/.archon/scripts/ routes through safeSendMessage /
logNodeError with a dedicated "failed to discover scripts" message
instead of being mis-attributed by the outer catch's
"permission denied (check cwd permissions)" branch.
Tests:
- load-command-prompt.test.ts (new): 6 tests covering the executor's
command resolution hot path — home-scope resolves when repo misses,
repo shadows home, 1-level subfolder resolvable by basename, 2-level
rejected, not-found, empty-file. Runs in its own bun test batch.
- archon-paths.test.ts: add getHomeScriptsPath describe block to match
the existing getHomeCommandsPath / getHomeWorkflowsPath coverage.
Comment clarity:
- workflow-discovery.ts: MAX_DISCOVERY_DEPTH comment now leads with the
actual value (1) before describing what 0 would mean.
- script-discovery.ts: copy the "routing ambiguity" rationale from
MAX_DISCOVERY_DEPTH to MAX_SCRIPT_DISCOVERY_DEPTH.
Cleanup:
- Remove .archon/workflows/e2e-home-scope.yaml — one-off smoke test that
would ship permanently in every project's workflow list. Equivalent
coverage exists in loader.test.ts.
Addresses all blocking and important feedback from the multi-agent
review on PR #1315.
---------
Co-authored-by: Jonas Vanderhaegen <7755555+jonasvanderhaegen@users.noreply.github.com>
This commit is contained in:
parent
cc78071ff6
commit
7be4d0a35e
30 changed files with 1203 additions and 273 deletions
|
|
@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
### Added
|
||||
|
||||
- **Home-scoped commands at `~/.archon/commands/`** — personal command helpers now reusable across every repo. Resolution precedence: `<repoRoot>/.archon/commands/` > `~/.archon/commands/` > bundled defaults. Surfaced in the Web UI workflow-builder node palette under a dedicated "Global (~/.archon/commands/)" section.
|
||||
- **Home-scoped scripts at `~/.archon/scripts/`** — personal Bun/uv scripts now reusable across every repo. Script nodes (`script: my-helper`) resolve via `<repoRoot>/.archon/scripts/` first, then `~/.archon/scripts/`. Repo-scoped scripts with the same name override home-scoped ones silently; within a single scope, duplicate basenames across extensions still throw (unchanged from prior behavior).
|
||||
- **1-level subfolder support for workflows, commands, and scripts.** Files can live one folder deep under their respective `.archon/` root (e.g. `.archon/workflows/triage/foo.yaml`) and resolve by name or filename regardless of subfolder. Matches the existing `defaults/` convention. Deeper nesting is ignored silently — see docs for the full convention.
|
||||
- **`'global'` variant on `WorkflowSource`** — workflows at `~/.archon/workflows/` and commands at `~/.archon/commands/` now render with a distinct source label (no longer coerced to `'project'`). Web UI badges updated.
|
||||
- **`getHomeWorkflowsPath()`, `getHomeCommandsPath()`, `getHomeScriptsPath()`, `getLegacyHomeWorkflowsPath()`** helpers in `@archon/paths`, exported for both internal discovery and external callers that want to target the home scope directly.
|
||||
- **`discoverScriptsForCwd(cwd)`** in `@archon/workflows/script-discovery` — merges home-scoped + repo-scoped scripts with repo winning on name collisions. Used by the DAG executor and validator; callers no longer need to know about the two-scope shape.
|
||||
- **Three-path env model with operator-visible log lines.** The CLI and server now load env vars from `~/.archon/.env` (user scope) and `<cwd>/.archon/.env` (repo scope, overrides user) at boot, both with `override: true`. A new `[archon] loaded N keys from <path>` line is emitted per source (only when N > 0). `[archon] stripped N keys from <cwd> (...)` now also prints when stripCwdEnv removes target-repo env keys, replacing the misleading `[dotenv@17.3.1] injecting env (0) from .env` preamble that always reported 0. The `quiet: true` flag suppresses dotenv's own output. (#1302)
|
||||
- **`archon setup --scope home|project` and `--force` flags.** Default is `--scope home` (writes `~/.archon/.env`). `--scope project` targets `<cwd>/.archon/.env` instead. `--force` overwrites the target wholesale rather than merging; a timestamped backup is still written. (#1303)
|
||||
- **Merge-only setup writes with timestamped backups.** `archon setup` now reads the existing target file, preserves non-empty values, carries user-added custom keys forward, and writes a `<target>.archon-backup-<ISO-ts>` before every rewrite. Fixes silent PostgreSQL→SQLite downgrade and silent token loss on re-run. (#1303)
|
||||
|
|
@ -28,6 +34,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
### Changed
|
||||
|
||||
- **Home-scoped workflow location moved to `~/.archon/workflows/`** (was `~/.archon/.archon/workflows/` — a double-nested path left over from reusing the repo-relative discovery helper for home scope). The new path sits next to `~/.archon/workspaces/`, `archon.db`, and `config.yaml`, matching the rest of the `~/.archon/` convention. If Archon detects workflows at the old location, it emits a one-time WARN per process with the exact migration command: `mv ~/.archon/.archon/workflows ~/.archon/workflows && rmdir ~/.archon/.archon`. The old path is no longer read — users must migrate manually (clean cut, no deprecation window). Rollback caveat: if you downgrade after migrating, move the directory back to the old location.
|
||||
- **Workflow discovery no longer takes a `globalSearchPath` option.** `discoverWorkflows()` and `discoverWorkflowsWithConfig()` now consult `~/.archon/workflows/` automatically — every caller gets home-scoped discovery for free. Previously-missed call sites in the chat command handler (`command-handler.ts`), the Web UI workflow picker (`api.ts GET /api/workflows`), and the orchestrator's single-codebase resolve path now see home-scoped workflows without needing a maintainer patch at every new call site. Closes #1136; supersedes that PR (credits @jonasvanderhaegen for surfacing the bug class).
|
||||
- **Dashboard nav tab** now shows a numeric count of running workflows instead of a binary pulse dot. Reads from the existing `/api/dashboard/runs` `counts.running` field; same 10s polling interval.
|
||||
- **Workflow run destructive actions** (Abandon, Cancel, Delete, Reject) now use a proper confirmation dialog matching the codebase-delete UX, replacing the browser's native `window.confirm()` popups. Each dialog includes context-appropriate copy describing what the action does to the run record.
|
||||
|
||||
|
|
@ -41,6 +49,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
### Removed
|
||||
|
||||
- **`globalSearchPath` option** from `discoverWorkflows()` and `discoverWorkflowsWithConfig()`. Callers that previously passed `{ globalSearchPath: getArchonHome() }` should drop the argument; home-scoped discovery is now automatic.
|
||||
- **`@anthropic-ai/claude-agent-sdk/embed` import** — the Bun `with { type: 'file' }` asset-embedding path and its `$bunfs` extraction logic. The embed was a bundler-dependent optimization that failed silently when Bun couldn't produce a usable virtual FS path (#1210, #1087); it is replaced by explicit binary-path resolution.
|
||||
|
||||
### Fixed
|
||||
|
|
|
|||
12
CLAUDE.md
12
CLAUDE.md
|
|
@ -719,9 +719,15 @@ async function createSession(conversationId: string, codebaseId: string) {
|
|||
- Opt-out: Set `defaults.loadDefaultCommands: false` or `defaults.loadDefaultWorkflows: false` in `.archon/config.yaml`
|
||||
- **After adding, removing, or editing a default file, run `bun run generate:bundled`** to refresh the embedded bundle. `bun run validate` (and CI) run `check:bundled` and will fail loudly if the generated file is stale.
|
||||
|
||||
**Global workflows** (user-level, applies to every project):
|
||||
- Path: `~/.archon/.archon/workflows/` (or `$ARCHON_HOME/.archon/workflows/`)
|
||||
- Load priority: bundled < global < repo-specific (repo overrides global by filename)
|
||||
**Home-scoped ("global") workflows, commands, and scripts** (user-level, applies to every project):
|
||||
- Workflows: `~/.archon/workflows/` (or `$ARCHON_HOME/workflows/`)
|
||||
- Commands: `~/.archon/commands/` (or `$ARCHON_HOME/commands/`)
|
||||
- Scripts: `~/.archon/scripts/` (or `$ARCHON_HOME/scripts/`)
|
||||
- Source label: `source: 'global'` on workflows and commands (scripts don't have a source label)
|
||||
- Load priority: bundled < global < project (repo overrides global by filename or script name)
|
||||
- Subfolders: supported 1 level deep (e.g. `~/.archon/workflows/triage/foo.yaml`). Deeper nesting is ignored silently.
|
||||
- Discovery is automatic — `discoverWorkflowsWithConfig(cwd, loadConfig)` and `discoverScriptsForCwd(cwd)` both read home-scoped paths unconditionally; no caller option needed
|
||||
- **Migration from pre-0.x `~/.archon/.archon/workflows/`**: if Archon detects files at the old location it emits a one-time WARN with the exact `mv` command and does NOT load from there. Move with: `mv ~/.archon/.archon/workflows ~/.archon/workflows && rmdir ~/.archon/.archon`
|
||||
- See the docs site at `packages/docs-web/` for details
|
||||
|
||||
### Error Handling
|
||||
|
|
|
|||
|
|
@ -310,7 +310,7 @@ describe('workflowListCommand', () => {
|
|||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Found 1 workflow(s)'));
|
||||
});
|
||||
|
||||
it('passes globalSearchPath to discoverWorkflowsWithConfig', async () => {
|
||||
it('calls discoverWorkflowsWithConfig with (cwd, loadConfig) — home scope is internal', async () => {
|
||||
const { discoverWorkflowsWithConfig } = await import('@archon/workflows/workflow-discovery');
|
||||
(discoverWorkflowsWithConfig as ReturnType<typeof mock>).mockResolvedValueOnce({
|
||||
workflows: [],
|
||||
|
|
@ -319,11 +319,9 @@ describe('workflowListCommand', () => {
|
|||
|
||||
await workflowListCommand('/test/path');
|
||||
|
||||
expect(discoverWorkflowsWithConfig).toHaveBeenCalledWith(
|
||||
'/test/path',
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ globalSearchPath: '/home/test/.archon' })
|
||||
);
|
||||
// After the globalSearchPath refactor, discovery reads ~/.archon/workflows/
|
||||
// on every call with no option — every caller inherits home-scope for free.
|
||||
expect(discoverWorkflowsWithConfig).toHaveBeenCalledWith('/test/path', expect.any(Function));
|
||||
});
|
||||
|
||||
it('should throw error when discoverWorkflows fails', async () => {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
} from '@archon/core';
|
||||
import { WORKFLOW_EVENT_TYPES, type WorkflowEventType } from '@archon/workflows/store';
|
||||
import { configureIsolation, getIsolationProvider } from '@archon/isolation';
|
||||
import { createLogger, getArchonHome } from '@archon/paths';
|
||||
import { createLogger } from '@archon/paths';
|
||||
import { createWorkflowDeps } from '@archon/core/workflows/store-adapter';
|
||||
import { discoverWorkflowsWithConfig } from '@archon/workflows/workflow-discovery';
|
||||
import { resolveWorkflowName } from '@archon/workflows/router';
|
||||
|
|
@ -119,9 +119,9 @@ function renderWorkflowEvent(event: WorkflowEmitterEvent, verbose: boolean): voi
|
|||
*/
|
||||
async function loadWorkflows(cwd: string): Promise<WorkflowLoadResult> {
|
||||
try {
|
||||
return await discoverWorkflowsWithConfig(cwd, loadConfig, {
|
||||
globalSearchPath: getArchonHome(),
|
||||
});
|
||||
// Home-scoped workflows at ~/.archon/workflows/ are discovered automatically —
|
||||
// no option needed since the discovery helper reads them unconditionally.
|
||||
return await discoverWorkflowsWithConfig(cwd, loadConfig);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
throw new Error(
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import { formatToolCall } from '@archon/workflows/utils/tool-formatter';
|
|||
import { classifyAndFormatError } from '../utils/error-formatter';
|
||||
import { toError } from '../utils/error';
|
||||
import { getAgentProvider, getProviderCapabilities } from '@archon/providers';
|
||||
import { getArchonHome, getArchonWorkspacesPath } from '@archon/paths';
|
||||
import { getArchonWorkspacesPath } from '@archon/paths';
|
||||
import { syncArchonToWorktree } from '../utils/worktree-sync';
|
||||
import { syncWorkspace, toRepoPath } from '@archon/git';
|
||||
import type { WorkspaceSyncResult } from '@archon/git';
|
||||
|
|
@ -388,9 +388,9 @@ async function discoverAllWorkflows(conversation: Conversation): Promise<Discove
|
|||
let config: MergedConfig | undefined;
|
||||
|
||||
try {
|
||||
const result = await discoverWorkflowsWithConfig(getArchonWorkspacesPath(), loadConfig, {
|
||||
globalSearchPath: getArchonHome(),
|
||||
});
|
||||
// Home-scoped workflows at ~/.archon/workflows/ are discovered automatically
|
||||
// by discoverWorkflowsWithConfig — no option needed.
|
||||
const result = await discoverWorkflowsWithConfig(getArchonWorkspacesPath(), loadConfig);
|
||||
workflows = [...result.workflows];
|
||||
allErrors.push(...result.errors);
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -1153,10 +1153,11 @@ describe('orchestrator-agent handleMessage', () => {
|
|||
|
||||
await handleMessage(platform, 'chat-456', 'help');
|
||||
|
||||
// Discovery is called positionally with (cwd, loadConfig) — no options arg.
|
||||
// Home-scoped workflows (~/.archon/workflows/) are discovered internally.
|
||||
expect(mockDiscoverWorkflows).toHaveBeenCalledWith(
|
||||
'/home/test/.archon/workspaces',
|
||||
expect.any(Function),
|
||||
{ globalSearchPath: '/home/test/.archon' }
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -7,11 +7,18 @@ import { join, basename } from 'path';
|
|||
/**
|
||||
* Recursively find all .md files in a directory and its subdirectories.
|
||||
* Skips hidden directories and node_modules.
|
||||
*
|
||||
* `maxDepth` caps how many folders deep the walk descends. Default is
|
||||
* `Infinity` (no cap) so callers that copy arbitrary subtrees (e.g.
|
||||
* `packages/core/src/handlers/clone.ts`) preserve existing behavior.
|
||||
*/
|
||||
export async function findMarkdownFilesRecursive(
|
||||
rootPath: string,
|
||||
relativePath = ''
|
||||
relativePath = '',
|
||||
options?: { maxDepth?: number }
|
||||
): Promise<{ commandName: string; relativePath: string }[]> {
|
||||
const maxDepth = options?.maxDepth ?? Infinity;
|
||||
const currentDepth = relativePath ? relativePath.split(/[/\\]/).filter(Boolean).length : 0;
|
||||
const results: { commandName: string; relativePath: string }[] = [];
|
||||
const fullPath = join(rootPath, relativePath);
|
||||
|
||||
|
|
@ -23,7 +30,12 @@ export async function findMarkdownFilesRecursive(
|
|||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const subResults = await findMarkdownFilesRecursive(rootPath, join(relativePath, entry.name));
|
||||
if (currentDepth >= maxDepth) continue;
|
||||
const subResults = await findMarkdownFilesRecursive(
|
||||
rootPath,
|
||||
join(relativePath, entry.name),
|
||||
options
|
||||
);
|
||||
results.push(...subResults);
|
||||
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||
results.push({
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ Workflows live in `.archon/workflows/` relative to the working directory:
|
|||
|
||||
Archon discovers workflows recursively - subdirectories are fine. If a workflow file fails to load (syntax error, validation failure), it's skipped and the error is reported via `/workflow list`.
|
||||
|
||||
> **Global workflows:** For workflows that apply to every project, place them in `~/.archon/.archon/workflows/`. Global workflows are overridden by same-named repo workflows. See [Global Workflows](/guides/global-workflows/).
|
||||
> **Global workflows:** For workflows that apply to every project, place them in `~/.archon/workflows/`. Global workflows are overridden by same-named repo workflows. See [Global Workflows](/guides/global-workflows/).
|
||||
|
||||
> **CLI vs Server:** The CLI reads workflow files from wherever you run it (sees uncommitted changes). The server reads from the workspace clone at `~/.archon/workspaces/owner/repo/`, which only syncs from the remote before worktree creation. If you edit a workflow locally but don't push, the server won't see it.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
title: Global Workflows
|
||||
description: Define user-level workflows that apply to every project on your machine.
|
||||
title: Global Workflows, Commands, and Scripts
|
||||
description: Define user-level workflows, commands, and scripts that apply to every project on your machine.
|
||||
category: guides
|
||||
area: workflows
|
||||
audience: [user]
|
||||
|
|
@ -9,45 +9,62 @@ sidebar:
|
|||
order: 8
|
||||
---
|
||||
|
||||
Workflows placed in `~/.archon/.archon/workflows/` are loaded globally -- they appear in
|
||||
every project's `workflow list` and can be invoked from any repository.
|
||||
Workflows placed in `~/.archon/workflows/`, commands in `~/.archon/commands/`, and scripts in `~/.archon/scripts/` are loaded globally -- they appear in every project and can be invoked from any repository. Workflows and commands carry the `source: 'global'` label in the Web UI node palette; scripts resolve under the same repo-wins-over-home precedence.
|
||||
|
||||
## Path
|
||||
## Paths
|
||||
|
||||
```
|
||||
~/.archon/.archon/workflows/
|
||||
~/.archon/workflows/
|
||||
~/.archon/commands/
|
||||
~/.archon/scripts/
|
||||
```
|
||||
|
||||
Or, if you have set `ARCHON_HOME`:
|
||||
|
||||
```
|
||||
$ARCHON_HOME/.archon/workflows/
|
||||
$ARCHON_HOME/workflows/
|
||||
$ARCHON_HOME/commands/
|
||||
$ARCHON_HOME/scripts/
|
||||
```
|
||||
|
||||
Create the directory if it does not exist:
|
||||
Create the directories if they do not exist:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.archon/.archon/workflows
|
||||
mkdir -p ~/.archon/workflows ~/.archon/commands ~/.archon/scripts
|
||||
```
|
||||
|
||||
> **Note on location.** These are direct children of `~/.archon/` -- same level as `workspaces/`, `archon.db`, and `config.yaml`. Earlier Archon versions stored global workflows at `~/.archon/.archon/workflows/`; see [Migrating from the old path](#migrating-from-the-old-path) below.
|
||||
|
||||
## Subfolders (1 level deep)
|
||||
|
||||
Each directory supports one level of subfolders for grouping, matching the existing `defaults/` convention. Deeper nesting is ignored silently.
|
||||
|
||||
```
|
||||
~/.archon/workflows/
|
||||
├── my-review.yaml # ✅ top-level file
|
||||
├── triage/ # ✅ 1-level subfolder (grouping)
|
||||
│ └── weekly-cleanup.yaml # ✅ resolvable as `weekly-cleanup`
|
||||
└── team/personal/too-deep.yaml # ❌ ignored — 2 levels down
|
||||
```
|
||||
|
||||
Resolution is by **filename without extension** (for commands) or **exact filename** (for workflows), regardless of which subfolder the file lives in. Duplicate basenames within the same scope are a user error -- keep each name unique within `~/.archon/commands/` (or `<repoRoot>/.archon/commands/`), across whatever subfolders you use.
|
||||
|
||||
## Load Priority
|
||||
|
||||
1. **Bundled defaults** (lowest priority)
|
||||
2. **Global workflows** -- `~/.archon/.archon/workflows/` (override bundled by filename)
|
||||
3. **Repo-specific workflows** -- `.archon/workflows/` (override global by filename)
|
||||
1. **Bundled defaults** (lowest priority) -- the `archon-*` workflows/commands embedded in the Archon binary.
|
||||
2. **Global / home-scoped** -- `~/.archon/workflows/`, `~/.archon/commands/`, `~/.archon/scripts/` (override bundled by filename).
|
||||
3. **Repo-specific** -- `<repoRoot>/.archon/workflows/`, `<repoRoot>/.archon/commands/`, `<repoRoot>/.archon/scripts/` (override global by filename).
|
||||
|
||||
If a global workflow has the same filename as a bundled default, the global version wins. If a repo-specific workflow has the same filename as a global one, the repo-specific version wins.
|
||||
Same-named files at a higher scope win. A repo can override a personal helper by dropping a file with the same name in its own `.archon/workflows/`, `.archon/commands/`, or `.archon/scripts/`.
|
||||
|
||||
## Practical Examples
|
||||
|
||||
Global workflows are useful for personal standards that you want enforced everywhere, regardless of the project.
|
||||
|
||||
### Personal Code Review
|
||||
|
||||
A workflow that runs your preferred review checklist on every project:
|
||||
|
||||
```yaml
|
||||
# ~/.archon/.archon/workflows/my-review.yaml
|
||||
# ~/.archon/workflows/my-review.yaml
|
||||
name: my-review
|
||||
description: Personal code review with my standards
|
||||
model: sonnet
|
||||
|
|
@ -65,7 +82,7 @@ nodes:
|
|||
A workflow that runs project-agnostic checks:
|
||||
|
||||
```yaml
|
||||
# ~/.archon/.archon/workflows/lint-check.yaml
|
||||
# ~/.archon/workflows/lint-check.yaml
|
||||
name: lint-check
|
||||
description: Check for common code quality issues across any project
|
||||
|
||||
|
|
@ -84,7 +101,7 @@ nodes:
|
|||
A simple workflow for understanding unfamiliar codebases:
|
||||
|
||||
```yaml
|
||||
# ~/.archon/.archon/workflows/explain.yaml
|
||||
# ~/.archon/workflows/explain.yaml
|
||||
name: explain
|
||||
description: Quick explanation of a codebase or module
|
||||
model: haiku
|
||||
|
|
@ -98,38 +115,64 @@ nodes:
|
|||
Topic: $ARGUMENTS
|
||||
```
|
||||
|
||||
### Personal Command Helpers
|
||||
|
||||
Commands placed in `~/.archon/commands/` are available to every workflow on the machine. Useful for prompts you reuse across projects.
|
||||
|
||||
```markdown
|
||||
<!-- ~/.archon/commands/review-checklist.md -->
|
||||
Review the uncommitted changes in the current worktree.
|
||||
Check for:
|
||||
- Error handling gaps
|
||||
- Missing tests
|
||||
- Surprising API shapes
|
||||
- Unnecessary cleverness
|
||||
Be terse. Report findings grouped by file.
|
||||
```
|
||||
|
||||
A workflow in any repo can then reference it:
|
||||
|
||||
```yaml
|
||||
nodes:
|
||||
- id: review
|
||||
command: review-checklist
|
||||
```
|
||||
|
||||
## Syncing with Dotfiles
|
||||
|
||||
If you manage your configuration with a dotfiles repository, you can include your global workflows:
|
||||
If you manage your configuration with a dotfiles repository, you can include your global content:
|
||||
|
||||
```bash
|
||||
# In your dotfiles repo
|
||||
dotfiles/
|
||||
└── archon/
|
||||
└── .archon/
|
||||
└── workflows/
|
||||
├── my-review.yaml
|
||||
└── explain.yaml
|
||||
├── workflows/
|
||||
│ ├── my-review.yaml
|
||||
│ └── explain.yaml
|
||||
└── commands/
|
||||
└── review-checklist.md
|
||||
```
|
||||
|
||||
Then symlink during dotfiles setup:
|
||||
|
||||
```bash
|
||||
ln -sf ~/dotfiles/archon/.archon/workflows ~/.archon/.archon/workflows
|
||||
ln -sf ~/dotfiles/archon/workflows ~/.archon/workflows
|
||||
ln -sf ~/dotfiles/archon/commands ~/.archon/commands
|
||||
```
|
||||
|
||||
Or copy them as part of your dotfiles install script:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.archon/.archon/workflows
|
||||
cp ~/dotfiles/archon/.archon/workflows/*.yaml ~/.archon/.archon/workflows/
|
||||
mkdir -p ~/.archon/workflows ~/.archon/commands
|
||||
cp ~/dotfiles/archon/workflows/*.yaml ~/.archon/workflows/
|
||||
cp ~/dotfiles/archon/commands/*.md ~/.archon/commands/
|
||||
```
|
||||
|
||||
This way your personal workflows travel with you across machines.
|
||||
This way your personal workflows and commands travel with you across machines.
|
||||
|
||||
## CLI Support
|
||||
## CLI and Web Support
|
||||
|
||||
Both the CLI and the server discover global workflows automatically:
|
||||
Both the CLI, the server, and the Web UI discover home-scoped content automatically -- no flag, no config option.
|
||||
|
||||
```bash
|
||||
# Lists bundled + global + repo-specific workflows
|
||||
|
|
@ -139,14 +182,26 @@ archon workflow list
|
|||
archon workflow run my-review
|
||||
```
|
||||
|
||||
In the Web UI workflow builder, commands from `~/.archon/commands/` appear under a **Global (~/.archon/commands/)** section in the node palette, distinct from project and bundled entries.
|
||||
|
||||
## Migrating from the old path
|
||||
|
||||
Pre-refactor versions of Archon stored global workflows at `~/.archon/.archon/workflows/` (with an extra nested `.archon/`). That location is no longer read. If you have workflows there, Archon emits a one-time deprecation warning on first use telling you the exact migration command:
|
||||
|
||||
```bash
|
||||
mv ~/.archon/.archon/workflows ~/.archon/workflows && rmdir ~/.archon/.archon
|
||||
```
|
||||
|
||||
Run it once; the warning stops firing on subsequent invocations. There was no prior home-scoped commands location, so `~/.archon/commands/` is new capability -- nothing to migrate.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Workflow Not Appearing in List
|
||||
|
||||
1. **Check the path** -- The directory must be exactly `~/.archon/.archon/workflows/` (note the double `.archon`). The first `.archon` is the Archon home directory, the second is the standard config directory structure within it.
|
||||
1. **Check the path** -- The directory must be exactly `~/.archon/workflows/` (a direct child of `~/.archon/`, not the old double-nested `~/.archon/.archon/workflows/`).
|
||||
|
||||
```bash
|
||||
ls ~/.archon/.archon/workflows/
|
||||
ls ~/.archon/workflows/
|
||||
```
|
||||
|
||||
2. **Check file extension** -- Workflow files must end in `.yaml` or `.yml`.
|
||||
|
|
@ -159,4 +214,4 @@ archon workflow run my-review
|
|||
|
||||
4. **Check for name conflicts** -- If a repo-specific workflow has the same filename, it overrides the global one. The global version will not appear when you are in that repo.
|
||||
|
||||
5. **Check ARCHON_HOME** -- If you have set `ARCHON_HOME` to a custom path, global workflows must be at `$ARCHON_HOME/.archon/workflows/`, not `~/.archon/.archon/workflows/`.
|
||||
5. **Check ARCHON_HOME** -- If you have set `ARCHON_HOME` to a custom path, global workflows must be at `$ARCHON_HOME/workflows/`, not `~/.archon/workflows/`.
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ archon workflow list --cwd /path/to/repo
|
|||
archon workflow list --cwd /path/to/repo --json
|
||||
```
|
||||
|
||||
Discovers workflows from `.archon/workflows/` (recursive), `~/.archon/.archon/workflows/` (global), and bundled defaults. See [Global Workflows](/guides/global-workflows/).
|
||||
Discovers workflows from `.archon/workflows/` (recursive), `~/.archon/workflows/` (global, home-scoped), and bundled defaults. See [Global Workflows](/guides/global-workflows/).
|
||||
|
||||
**Flags:**
|
||||
|
||||
|
|
|
|||
|
|
@ -22,10 +22,15 @@ Archon supports a layered configuration system with sensible defaults, optional
|
|||
│ ├── worktrees/ # Git worktrees for this project
|
||||
│ ├── artifacts/ # Workflow artifacts
|
||||
│ └── logs/ # Workflow execution logs
|
||||
├── workflows/ # Home-scoped workflows (source: 'global')
|
||||
├── commands/ # Home-scoped commands (source: 'global')
|
||||
├── scripts/ # Home-scoped scripts (runtime: bun | uv)
|
||||
├── archon.db # SQLite database (when DATABASE_URL not set)
|
||||
└── config.yaml # Global configuration (optional)
|
||||
```
|
||||
|
||||
Home-scoped `workflows/`, `commands/`, and `scripts/` apply to every project on the machine. Repo-local files at `<repoRoot>/.archon/{workflows,commands,scripts}/` override them by filename (or script name). Each directory supports one level of subfolders for grouping; deeper nesting is ignored. See [Global Workflows](/guides/global-workflows/) for details and dotfiles-sync examples.
|
||||
|
||||
### Repository-Level (.archon/)
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@ import {
|
|||
getArchonWorkspacesPath,
|
||||
getArchonWorktreesPath,
|
||||
getArchonConfigPath,
|
||||
getHomeWorkflowsPath,
|
||||
getHomeCommandsPath,
|
||||
getHomeScriptsPath,
|
||||
getLegacyHomeWorkflowsPath,
|
||||
getCommandFolderSearchPaths,
|
||||
getWorkflowFolderSearchPaths,
|
||||
expandTilde,
|
||||
|
|
@ -223,6 +227,87 @@ describe('archon-paths', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('getHomeWorkflowsPath', () => {
|
||||
test('returns ~/.archon/workflows by default (direct child of ~/.archon/)', () => {
|
||||
delete process.env.ARCHON_HOME;
|
||||
delete process.env.ARCHON_DOCKER;
|
||||
expect(getHomeWorkflowsPath()).toBe(join(homedir(), '.archon', 'workflows'));
|
||||
});
|
||||
|
||||
test('returns /.archon/workflows in Docker', () => {
|
||||
process.env.ARCHON_DOCKER = 'true';
|
||||
expect(getHomeWorkflowsPath()).toBe(join('/', '.archon', 'workflows'));
|
||||
});
|
||||
|
||||
test('uses ARCHON_HOME when set', () => {
|
||||
delete process.env.ARCHON_DOCKER;
|
||||
process.env.ARCHON_HOME = '/custom/archon';
|
||||
expect(getHomeWorkflowsPath()).toBe(join('/custom/archon', 'workflows'));
|
||||
});
|
||||
|
||||
test('no double `.archon/` nesting — must sit next to workspaces/ and worktrees/', () => {
|
||||
// Regression guard: the old location was ~/.archon/.archon/workflows/.
|
||||
// New location must NOT reintroduce the double-nested path.
|
||||
delete process.env.ARCHON_HOME;
|
||||
delete process.env.ARCHON_DOCKER;
|
||||
expect(getHomeWorkflowsPath()).not.toContain(join('.archon', '.archon'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHomeCommandsPath', () => {
|
||||
test('returns ~/.archon/commands by default', () => {
|
||||
delete process.env.ARCHON_HOME;
|
||||
delete process.env.ARCHON_DOCKER;
|
||||
expect(getHomeCommandsPath()).toBe(join(homedir(), '.archon', 'commands'));
|
||||
});
|
||||
|
||||
test('returns /.archon/commands in Docker', () => {
|
||||
process.env.ARCHON_DOCKER = 'true';
|
||||
expect(getHomeCommandsPath()).toBe(join('/', '.archon', 'commands'));
|
||||
});
|
||||
|
||||
test('uses ARCHON_HOME when set', () => {
|
||||
delete process.env.ARCHON_DOCKER;
|
||||
process.env.ARCHON_HOME = '/custom/archon';
|
||||
expect(getHomeCommandsPath()).toBe(join('/custom/archon', 'commands'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHomeScriptsPath', () => {
|
||||
test('returns ~/.archon/scripts by default', () => {
|
||||
delete process.env.ARCHON_HOME;
|
||||
delete process.env.ARCHON_DOCKER;
|
||||
expect(getHomeScriptsPath()).toBe(join(homedir(), '.archon', 'scripts'));
|
||||
});
|
||||
|
||||
test('returns /.archon/scripts in Docker', () => {
|
||||
process.env.ARCHON_DOCKER = 'true';
|
||||
expect(getHomeScriptsPath()).toBe(join('/', '.archon', 'scripts'));
|
||||
});
|
||||
|
||||
test('uses ARCHON_HOME when set', () => {
|
||||
delete process.env.ARCHON_DOCKER;
|
||||
process.env.ARCHON_HOME = '/custom/archon';
|
||||
expect(getHomeScriptsPath()).toBe(join('/custom/archon', 'scripts'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLegacyHomeWorkflowsPath', () => {
|
||||
// This helper only exists so discovery can DETECT files at the old location
|
||||
// and emit a deprecation warning. It is not a fallback read path.
|
||||
test('returns ~/.archon/.archon/workflows (the retired location)', () => {
|
||||
delete process.env.ARCHON_HOME;
|
||||
delete process.env.ARCHON_DOCKER;
|
||||
expect(getLegacyHomeWorkflowsPath()).toBe(join(homedir(), '.archon', '.archon', 'workflows'));
|
||||
});
|
||||
|
||||
test('honors ARCHON_HOME so migration detection works in custom setups', () => {
|
||||
delete process.env.ARCHON_DOCKER;
|
||||
process.env.ARCHON_HOME = '/custom/archon';
|
||||
expect(getLegacyHomeWorkflowsPath()).toBe(join('/custom/archon', '.archon', 'workflows'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAppArchonBasePath', () => {
|
||||
test('returns repo root .archon path in local development', () => {
|
||||
delete process.env.ARCHON_DOCKER;
|
||||
|
|
|
|||
|
|
@ -96,6 +96,49 @@ export function getArchonConfigPath(): string {
|
|||
return join(getArchonHome(), 'config.yaml');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the home-scoped workflows directory (`~/.archon/workflows/`).
|
||||
* Workflows placed here are discovered from every repo and apply globally —
|
||||
* overridden per-filename by the same name under `<repoRoot>/.archon/workflows/`.
|
||||
*
|
||||
* Direct child of `~/.archon/`, matching the convention for `workspaces/`,
|
||||
* `archon.db`, `config.yaml`, etc. Replaces the prior `~/.archon/.archon/workflows/`
|
||||
* location which was an artifact of reusing the repo-relative discovery helper.
|
||||
*/
|
||||
export function getHomeWorkflowsPath(): string {
|
||||
return join(getArchonHome(), 'workflows');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the home-scoped commands directory (`~/.archon/commands/`).
|
||||
* Commands placed here are resolvable from every repo and apply globally —
|
||||
* overridden per-filename by the same name under `<repoRoot>/.archon/commands/`.
|
||||
* Command resolution precedence: repo > home > bundled.
|
||||
*/
|
||||
export function getHomeCommandsPath(): string {
|
||||
return join(getArchonHome(), 'commands');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the home-scoped scripts directory (`~/.archon/scripts/`).
|
||||
* Scripts placed here are available to every workflow's `script:` nodes —
|
||||
* overridden per-name by the same name under `<repoRoot>/.archon/scripts/`.
|
||||
* Script resolution precedence: repo > home.
|
||||
*/
|
||||
export function getHomeScriptsPath(): string {
|
||||
return join(getArchonHome(), 'scripts');
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy home-scoped workflows directory (`~/.archon/.archon/workflows/`).
|
||||
* Retained only so discovery can DETECT files there and emit a one-time
|
||||
* deprecation warning pointing at the migration command. Archon no longer
|
||||
* reads workflows from this path — it's a signal, not a source.
|
||||
*/
|
||||
export function getLegacyHomeWorkflowsPath(): string {
|
||||
return join(getArchonHome(), '.archon', 'workflows');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the home-scope archon env file path (~/.archon/.env).
|
||||
* This is the archon-owned env location loaded by every entry point.
|
||||
|
|
@ -153,11 +196,21 @@ export function getWorkflowFolderSearchPaths(): string[] {
|
|||
/**
|
||||
* Recursively find all .md files in a directory and its subdirectories.
|
||||
* Skips hidden directories and node_modules.
|
||||
*
|
||||
* `maxDepth` caps how many folders deep the walk descends. Depth is counted as
|
||||
* the number of folder boundaries between `rootPath` and the file — so at
|
||||
* `maxDepth: 1`, files at `rootPath/file.md` (depth 0) and `rootPath/group/file.md`
|
||||
* (depth 1) are included, but `rootPath/group/sub/file.md` (depth 2) is not.
|
||||
* Default is `Infinity` (no cap) for backwards compatibility with callers that
|
||||
* want to copy arbitrary subtrees (e.g. clone handlers).
|
||||
*/
|
||||
export async function findMarkdownFilesRecursive(
|
||||
rootPath: string,
|
||||
relativePath = ''
|
||||
relativePath = '',
|
||||
options?: { maxDepth?: number }
|
||||
): Promise<{ commandName: string; relativePath: string }[]> {
|
||||
const maxDepth = options?.maxDepth ?? Infinity;
|
||||
const currentDepth = relativePath ? relativePath.split(/[/\\]/).filter(Boolean).length : 0;
|
||||
const results: { commandName: string; relativePath: string }[] = [];
|
||||
const fullPath = join(rootPath, relativePath);
|
||||
|
||||
|
|
@ -176,7 +229,15 @@ export async function findMarkdownFilesRecursive(
|
|||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const subResults = await findMarkdownFilesRecursive(rootPath, join(relativePath, entry.name));
|
||||
// Skip descending if we're already at the depth cap — files at deeper
|
||||
// levels are silently ignored (matches the convention that `.archon/*/`
|
||||
// folders support one level of grouping like `defaults/`).
|
||||
if (currentDepth >= maxDepth) continue;
|
||||
const subResults = await findMarkdownFilesRecursive(
|
||||
rootPath,
|
||||
join(relativePath, entry.name),
|
||||
options
|
||||
);
|
||||
results.push(...subResults);
|
||||
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||
results.push({
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ export {
|
|||
getArchonConfigPath,
|
||||
getArchonEnvPath,
|
||||
getRepoArchonEnvPath,
|
||||
getHomeWorkflowsPath,
|
||||
getHomeCommandsPath,
|
||||
getHomeScriptsPath,
|
||||
getLegacyHomeWorkflowsPath,
|
||||
getCommandFolderSearchPaths,
|
||||
getWorkflowFolderSearchPaths,
|
||||
getAppArchonBasePath,
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import {
|
|||
getDefaultCommandsPath,
|
||||
getDefaultWorkflowsPath,
|
||||
getArchonWorkspacesPath,
|
||||
getHomeCommandsPath,
|
||||
getRunArtifactsPath,
|
||||
getArchonHome,
|
||||
isDocker,
|
||||
|
|
@ -139,7 +140,7 @@ if (BUNDLED_IS_BINARY) {
|
|||
}
|
||||
}
|
||||
|
||||
type WorkflowSource = 'project' | 'bundled';
|
||||
type WorkflowSource = 'project' | 'bundled' | 'global';
|
||||
|
||||
// =========================================================================
|
||||
// OpenAPI route configs (module-scope — pure config, no runtime dependencies)
|
||||
|
|
@ -2298,7 +2299,7 @@ export function registerApiRoutes(
|
|||
if (codebases.length > 0) workingDir = codebases[0].default_cwd;
|
||||
}
|
||||
|
||||
// Collect commands: project-defined override bundled (same name wins)
|
||||
// Collect commands: precedence bundled < global < project (repo-defined wins).
|
||||
const commandMap = new Map<string, WorkflowSource>();
|
||||
|
||||
// 1. Seed with bundled defaults
|
||||
|
|
@ -2306,11 +2307,17 @@ export function registerApiRoutes(
|
|||
commandMap.set(name, 'bundled');
|
||||
}
|
||||
|
||||
// maxDepth: 1 matches the executor's resolver (resolveCommand /
|
||||
// loadCommandPrompt) — without this cap, the UI palette would surface
|
||||
// commands buried in deep subfolders that the executor silently can't
|
||||
// resolve at runtime.
|
||||
const COMMAND_LIST_DEPTH = { maxDepth: 1 };
|
||||
|
||||
// 2. If not binary build, also check filesystem defaults
|
||||
if (!isBinaryBuild()) {
|
||||
try {
|
||||
const defaultsPath = getDefaultCommandsPath();
|
||||
const files = await findMarkdownFilesRecursive(defaultsPath);
|
||||
const files = await findMarkdownFilesRecursive(defaultsPath, '', COMMAND_LIST_DEPTH);
|
||||
for (const { commandName } of files) {
|
||||
commandMap.set(commandName, 'bundled');
|
||||
}
|
||||
|
|
@ -2322,13 +2329,27 @@ export function registerApiRoutes(
|
|||
}
|
||||
}
|
||||
|
||||
// 3. Project-defined commands override bundled
|
||||
// 3. Home-scoped commands (~/.archon/commands/) override bundled
|
||||
try {
|
||||
const homeCommandsPath = getHomeCommandsPath();
|
||||
const files = await findMarkdownFilesRecursive(homeCommandsPath, '', COMMAND_LIST_DEPTH);
|
||||
for (const { commandName } of files) {
|
||||
commandMap.set(commandName, 'global');
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
getLog().error({ err }, 'commands.list_home_failed');
|
||||
}
|
||||
// ENOENT: home commands dir not created yet — not an error
|
||||
}
|
||||
|
||||
// 4. Project-defined commands override bundled AND global
|
||||
if (workingDir) {
|
||||
const searchPaths = getCommandFolderSearchPaths();
|
||||
for (const folder of searchPaths) {
|
||||
const dirPath = join(workingDir, folder);
|
||||
try {
|
||||
const files = await findMarkdownFilesRecursive(dirPath);
|
||||
const files = await findMarkdownFilesRecursive(dirPath, '', COMMAND_LIST_DEPTH);
|
||||
for (const { commandName } of files) {
|
||||
commandMap.set(commandName, 'project');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,8 +17,13 @@ export const workflowLoadErrorSchema = z
|
|||
})
|
||||
.openapi('WorkflowLoadError');
|
||||
|
||||
/** Workflow source — project-defined or bundled default. */
|
||||
export const workflowSourceSchema = z.enum(['project', 'bundled']).openapi('WorkflowSource');
|
||||
/**
|
||||
* Workflow source — project-defined, bundled default, or home-scoped (global).
|
||||
* Precedence for same-named entries: `bundled` < `global` < `project`.
|
||||
*/
|
||||
export const workflowSourceSchema = z
|
||||
.enum(['project', 'bundled', 'global'])
|
||||
.openapi('WorkflowSource');
|
||||
|
||||
/** A workflow entry in the list response, including its source. */
|
||||
export const workflowListEntrySchema = z
|
||||
|
|
|
|||
|
|
@ -119,9 +119,9 @@ export function CommandPicker({
|
|||
<span
|
||||
className={cn(
|
||||
'text-[9px] font-medium px-1.5 py-0.5 rounded shrink-0',
|
||||
cmd.source === 'project'
|
||||
? 'bg-node-command/20 text-node-command'
|
||||
: 'bg-surface-inset text-text-tertiary'
|
||||
cmd.source === 'project' && 'bg-node-command/20 text-node-command',
|
||||
cmd.source === 'global' && 'bg-node-loop/20 text-node-loop',
|
||||
cmd.source === 'bundled' && 'bg-surface-inset text-text-tertiary'
|
||||
)}
|
||||
>
|
||||
{cmd.source}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ export function NodePalette(): React.ReactElement {
|
|||
};
|
||||
|
||||
const bundled = commands?.filter((c: CommandEntry) => c.source === 'bundled') ?? [];
|
||||
const global = commands?.filter((c: CommandEntry) => c.source === 'global') ?? [];
|
||||
const project = commands?.filter((c: CommandEntry) => c.source === 'project') ?? [];
|
||||
|
||||
return (
|
||||
|
|
@ -89,6 +90,27 @@ export function NodePalette(): React.ReactElement {
|
|||
</>
|
||||
)}
|
||||
|
||||
{global.length > 0 && (
|
||||
<>
|
||||
<h4 className="text-[10px] font-medium text-text-tertiary uppercase tracking-wide mt-2 mb-1">
|
||||
Global (~/.archon/commands/)
|
||||
</h4>
|
||||
{global.map((cmd: CommandEntry) => (
|
||||
<div
|
||||
key={cmd.name}
|
||||
draggable
|
||||
onDragStart={(e): void => {
|
||||
onDragStart(e, 'command', cmd.name);
|
||||
}}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-md border border-border hover:border-accent hover:bg-accent/5 cursor-grab text-xs text-text-primary mb-1"
|
||||
>
|
||||
<span className="text-[10px] text-text-tertiary font-medium">CMD</span>
|
||||
<span className="truncate">{cmd.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{bundled.length > 0 && (
|
||||
<>
|
||||
<h4 className="text-[10px] font-medium text-text-tertiary uppercase tracking-wide mt-2 mb-1">
|
||||
|
|
|
|||
2
packages/web/src/lib/api.generated.d.ts
vendored
2
packages/web/src/lib/api.generated.d.ts
vendored
|
|
@ -2348,7 +2348,7 @@ export interface components {
|
|||
nodes: components['schemas']['DagNode'][];
|
||||
};
|
||||
/** @enum {string} */
|
||||
WorkflowSource: 'project' | 'bundled';
|
||||
WorkflowSource: 'project' | 'bundled' | 'global';
|
||||
WorkflowListEntry: {
|
||||
workflow: components['schemas']['WorkflowDefinition'];
|
||||
source: components['schemas']['WorkflowSource'];
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
"./test-utils": "./src/test-utils.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test src/dag-executor.test.ts && bun test src/loader.test.ts && bun test src/logger.test.ts && bun test src/condition-evaluator.test.ts && bun test src/event-emitter.test.ts && bun test src/executor-shared.test.ts && bun test src/executor.test.ts && bun test src/executor-preamble.test.ts && bun test src/defaults/ src/model-validation.test.ts src/router.test.ts src/utils/ src/hooks.test.ts && bun test src/validation-parser.test.ts src/schemas.test.ts src/command-validation.test.ts && bun test src/validator.test.ts && bun test src/script-discovery.test.ts && bun test src/runtime-check.test.ts && bun test src/script-node-deps.test.ts",
|
||||
"test": "bun test src/dag-executor.test.ts && bun test src/loader.test.ts && bun test src/logger.test.ts && bun test src/condition-evaluator.test.ts && bun test src/event-emitter.test.ts && bun test src/executor-shared.test.ts && bun test src/load-command-prompt.test.ts && bun test src/executor.test.ts && bun test src/executor-preamble.test.ts && bun test src/defaults/ src/model-validation.test.ts src/router.test.ts src/utils/ src/hooks.test.ts && bun test src/validation-parser.test.ts src/schemas.test.ts src/command-validation.test.ts && bun test src/validator.test.ts && bun test src/script-discovery.test.ts && bun test src/runtime-check.test.ts && bun test src/script-node-deps.test.ts",
|
||||
"type-check": "bun x tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -5,9 +5,8 @@
|
|||
* Independent nodes within the same layer run concurrently via Promise.allSettled.
|
||||
* Captures all assistant output regardless of streaming mode for $node_id.output substitution.
|
||||
*/
|
||||
import { resolve } from 'path';
|
||||
import { execFileAsync } from '@archon/git';
|
||||
import { discoverScripts } from './script-discovery';
|
||||
import { discoverScriptsForCwd } from './script-discovery';
|
||||
import type {
|
||||
IWorkflowPlatform,
|
||||
WorkflowMessageMetadata,
|
||||
|
|
@ -1317,13 +1316,48 @@ async function executeScriptNode(
|
|||
args = ['run', ...withFlags, 'python', '-c', finalScript];
|
||||
}
|
||||
} else {
|
||||
// Named script — look up in .archon/scripts/ directory
|
||||
const scriptsDir = resolve(cwd, '.archon', 'scripts');
|
||||
const scripts = await discoverScripts(scriptsDir);
|
||||
// Named script — look up across repo and home scopes.
|
||||
// Precedence: <cwd>/.archon/scripts/ > ~/.archon/scripts/ (repo wins).
|
||||
// Wrap discovery in its own try/catch so a permission error on ~/.archon/scripts/
|
||||
// isn't mis-attributed by the outer catch's "permission denied (check cwd
|
||||
// permissions)" branch — that branch is for execFileAsync EACCES.
|
||||
let scripts: Awaited<ReturnType<typeof discoverScriptsForCwd>>;
|
||||
try {
|
||||
scripts = await discoverScriptsForCwd(cwd);
|
||||
} catch (discoveryErr) {
|
||||
const err = discoveryErr as Error;
|
||||
const errorMsg = `Script node '${node.id}': failed to discover scripts — ${err.message}`;
|
||||
getLog().error({ err, nodeId: node.id, cwd }, 'script_discovery_failed');
|
||||
await safeSendMessage(platform, conversationId, errorMsg, nodeContext);
|
||||
await logNodeError(logDir, workflowRun.id, node.id, errorMsg);
|
||||
|
||||
emitter.emit({
|
||||
type: 'node_failed',
|
||||
runId: workflowRun.id,
|
||||
nodeId: node.id,
|
||||
nodeName: node.id,
|
||||
error: errorMsg,
|
||||
});
|
||||
deps.store
|
||||
.createWorkflowEvent({
|
||||
workflow_run_id: workflowRun.id,
|
||||
event_type: 'node_failed',
|
||||
step_name: node.id,
|
||||
data: { error: errorMsg, type: 'script' },
|
||||
})
|
||||
.catch((dbErr: Error) => {
|
||||
getLog().error(
|
||||
{ err: dbErr, workflowRunId: workflowRun.id, eventType: 'node_failed' },
|
||||
'workflow_event_persist_failed'
|
||||
);
|
||||
});
|
||||
|
||||
return { state: 'failed', output: '', error: errorMsg };
|
||||
}
|
||||
const scriptDef = scripts.get(finalScript);
|
||||
|
||||
if (!scriptDef) {
|
||||
const errorMsg = `Script node '${node.id}': named script '${finalScript}' not found in .archon/scripts/`;
|
||||
const errorMsg = `Script node '${node.id}': named script '${finalScript}' not found in .archon/scripts/ or ~/.archon/scripts/`;
|
||||
getLog().error({ nodeId: node.id, scriptName: finalScript }, 'script_not_found');
|
||||
await safeSendMessage(platform, conversationId, errorMsg, nodeContext);
|
||||
await logNodeError(logDir, workflowRun.id, node.id, errorMsg);
|
||||
|
|
|
|||
|
|
@ -149,12 +149,22 @@ export async function loadCommandPrompt(
|
|||
config = { defaults: { loadDefaultCommands: true } };
|
||||
}
|
||||
|
||||
// Use command folder paths with optional configured folder
|
||||
// Use command folder paths with optional configured folder.
|
||||
// Each scope is walked 1 subfolder deep so `triage/review.md` resolves as
|
||||
// `review` — matching the workflows/scripts convention. Resolution
|
||||
// precedence: repo > home (~/.archon/commands/) > bundled/app defaults.
|
||||
const searchPaths = archonPaths.getCommandFolderSearchPaths(configuredFolder);
|
||||
const resolvedSearchPaths: string[] = [
|
||||
...searchPaths.map(folder => join(cwd, folder)),
|
||||
archonPaths.getHomeCommandsPath(),
|
||||
];
|
||||
|
||||
// Search repo paths first
|
||||
for (const folder of searchPaths) {
|
||||
const filePath = join(cwd, folder, `${commandName}.md`);
|
||||
for (const dir of resolvedSearchPaths) {
|
||||
const entries = await archonPaths.findMarkdownFilesRecursive(dir, '', { maxDepth: 1 });
|
||||
const match = entries.find(e => e.commandName === commandName);
|
||||
if (!match) continue;
|
||||
|
||||
const filePath = join(dir, match.relativePath);
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf-8');
|
||||
if (!content.trim()) {
|
||||
|
|
@ -165,13 +175,10 @@ export async function loadCommandPrompt(
|
|||
message: `Command file is empty: ${commandName}.md`,
|
||||
};
|
||||
}
|
||||
getLog().debug({ commandName, folder }, 'command_loaded');
|
||||
getLog().debug({ commandName, filePath }, 'command_loaded');
|
||||
return { success: true, content };
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException;
|
||||
if (err.code === 'ENOENT') {
|
||||
continue;
|
||||
}
|
||||
if (err.code === 'EACCES') {
|
||||
getLog().error({ commandName, filePath }, 'command_file_permission_denied');
|
||||
return {
|
||||
|
|
@ -180,7 +187,9 @@ export async function loadCommandPrompt(
|
|||
message: `Permission denied reading command: ${commandName}.md`,
|
||||
};
|
||||
}
|
||||
// Other unexpected errors
|
||||
// Other unexpected errors (ENOENT shouldn't happen since the walk just found it,
|
||||
// but if the file was deleted between walk and read we fall through to 'not found'
|
||||
// with a log.)
|
||||
getLog().error({ err, commandName, filePath }, 'command_file_read_error');
|
||||
return {
|
||||
success: false,
|
||||
|
|
@ -190,7 +199,7 @@ export async function loadCommandPrompt(
|
|||
}
|
||||
}
|
||||
|
||||
// If not found in repo and app defaults enabled, search app defaults
|
||||
// If not found in repo/home and app defaults enabled, search app defaults
|
||||
const loadDefaultCommands = config.defaults?.loadDefaultCommands ?? true;
|
||||
if (loadDefaultCommands) {
|
||||
if (isBinaryBuild()) {
|
||||
|
|
@ -202,29 +211,37 @@ export async function loadCommandPrompt(
|
|||
}
|
||||
getLog().debug({ commandName }, 'command_bundled_not_found');
|
||||
} else {
|
||||
// Bun: load from filesystem
|
||||
// Bun: load from filesystem (walk 1 level deep so `defaults/archon-*.md` resolves)
|
||||
const appDefaultsPath = archonPaths.getDefaultCommandsPath();
|
||||
const filePath = join(appDefaultsPath, `${commandName}.md`);
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf-8');
|
||||
if (!content.trim()) {
|
||||
getLog().error({ commandName }, 'command_app_default_empty');
|
||||
return {
|
||||
success: false,
|
||||
reason: 'empty_file',
|
||||
message: `App default command file is empty: ${commandName}.md`,
|
||||
};
|
||||
const entries = await archonPaths.findMarkdownFilesRecursive(appDefaultsPath, '', {
|
||||
maxDepth: 1,
|
||||
});
|
||||
const match = entries.find(e => e.commandName === commandName);
|
||||
if (match) {
|
||||
const filePath = join(appDefaultsPath, match.relativePath);
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf-8');
|
||||
if (!content.trim()) {
|
||||
getLog().error({ commandName }, 'command_app_default_empty');
|
||||
return {
|
||||
success: false,
|
||||
reason: 'empty_file',
|
||||
message: `App default command file is empty: ${commandName}.md`,
|
||||
};
|
||||
}
|
||||
getLog().debug({ commandName }, 'command_loaded_app_defaults');
|
||||
return { success: true, content };
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException;
|
||||
if (err.code !== 'ENOENT') {
|
||||
getLog().warn({ err, commandName }, 'command_app_default_read_error');
|
||||
} else {
|
||||
getLog().debug({ commandName }, 'command_app_default_not_found');
|
||||
}
|
||||
// Fall through to not found
|
||||
}
|
||||
getLog().debug({ commandName }, 'command_loaded_app_defaults');
|
||||
return { success: true, content };
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException;
|
||||
if (err.code !== 'ENOENT') {
|
||||
getLog().warn({ err, commandName }, 'command_app_default_read_error');
|
||||
} else {
|
||||
getLog().debug({ commandName }, 'command_app_default_not_found');
|
||||
}
|
||||
// Fall through to not found
|
||||
} else {
|
||||
getLog().debug({ commandName }, 'command_app_default_not_found');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
115
packages/workflows/src/load-command-prompt.test.ts
Normal file
115
packages/workflows/src/load-command-prompt.test.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { describe, it, expect, mock, beforeEach, afterEach } from 'bun:test';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import * as realPaths from '@archon/paths';
|
||||
|
||||
// Mock only the logger so test output stays clean. All other @archon/paths
|
||||
// exports (findMarkdownFilesRecursive, getHomeCommandsPath, etc.) use real
|
||||
// implementations — loadCommandPrompt exercises them against a tmp dir set
|
||||
// via ARCHON_HOME below.
|
||||
const mockLogFn = mock(() => {});
|
||||
const mockLogger = {
|
||||
info: mockLogFn,
|
||||
warn: mockLogFn,
|
||||
error: mockLogFn,
|
||||
debug: mockLogFn,
|
||||
trace: mockLogFn,
|
||||
fatal: mockLogFn,
|
||||
child: mock(() => mockLogger),
|
||||
bindings: mock(() => ({ module: 'test' })),
|
||||
isLevelEnabled: mock(() => true),
|
||||
level: 'info',
|
||||
};
|
||||
mock.module('@archon/paths', () => ({
|
||||
...realPaths,
|
||||
createLogger: mock(() => mockLogger),
|
||||
}));
|
||||
|
||||
import { loadCommandPrompt } from './executor-shared';
|
||||
import type { WorkflowDeps } from './deps';
|
||||
|
||||
// Minimal deps stub — loadCommandPrompt only calls loadConfig.
|
||||
function makeDeps(loadDefaultCommands = true): WorkflowDeps {
|
||||
return {
|
||||
loadConfig: async () => ({ defaults: { loadDefaultCommands } }),
|
||||
} as unknown as WorkflowDeps;
|
||||
}
|
||||
|
||||
describe('loadCommandPrompt — home-scope resolution', () => {
|
||||
let archonHome: string;
|
||||
let repoRoot: string;
|
||||
let prevArchonHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
prevArchonHome = process.env.ARCHON_HOME;
|
||||
// Separate tmp dirs for home and repo so they don't collide.
|
||||
archonHome = mkdtempSync(join(tmpdir(), 'archon-home-'));
|
||||
repoRoot = mkdtempSync(join(tmpdir(), 'archon-repo-'));
|
||||
process.env.ARCHON_HOME = archonHome;
|
||||
mkdirSync(join(archonHome, 'commands'), { recursive: true });
|
||||
mkdirSync(join(repoRoot, '.archon', 'commands'), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (prevArchonHome === undefined) delete process.env.ARCHON_HOME;
|
||||
else process.env.ARCHON_HOME = prevArchonHome;
|
||||
rmSync(archonHome, { recursive: true, force: true });
|
||||
rmSync(repoRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('resolves a command from ~/.archon/commands/ when repo has none', async () => {
|
||||
writeFileSync(join(archonHome, 'commands', 'personal-helper.md'), 'Personal helper body');
|
||||
|
||||
const result = await loadCommandPrompt(makeDeps(false), repoRoot, 'personal-helper');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) expect(result.content).toBe('Personal helper body');
|
||||
});
|
||||
|
||||
it('repo command shadows home command with the same name', async () => {
|
||||
writeFileSync(join(archonHome, 'commands', 'shared.md'), 'HOME version');
|
||||
writeFileSync(join(repoRoot, '.archon', 'commands', 'shared.md'), 'REPO version');
|
||||
|
||||
const result = await loadCommandPrompt(makeDeps(false), repoRoot, 'shared');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) expect(result.content).toBe('REPO version');
|
||||
});
|
||||
|
||||
it('resolves a home command inside a 1-level subfolder by basename', async () => {
|
||||
mkdirSync(join(archonHome, 'commands', 'triage'), { recursive: true });
|
||||
writeFileSync(join(archonHome, 'commands', 'triage', 'review.md'), 'Review body');
|
||||
|
||||
const result = await loadCommandPrompt(makeDeps(false), repoRoot, 'review');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) expect(result.content).toBe('Review body');
|
||||
});
|
||||
|
||||
it('does NOT resolve home commands buried >1 level deep', async () => {
|
||||
mkdirSync(join(archonHome, 'commands', 'a', 'b'), { recursive: true });
|
||||
writeFileSync(join(archonHome, 'commands', 'a', 'b', 'too-deep.md'), 'too deep');
|
||||
|
||||
const result = await loadCommandPrompt(makeDeps(false), repoRoot, 'too-deep');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) expect(result.reason).toBe('not_found');
|
||||
});
|
||||
|
||||
it('returns not_found when neither repo nor home has the command (defaults off)', async () => {
|
||||
const result = await loadCommandPrompt(makeDeps(false), repoRoot, 'missing');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) expect(result.reason).toBe('not_found');
|
||||
});
|
||||
|
||||
it('surfaces empty_file for a zero-byte home command', async () => {
|
||||
writeFileSync(join(archonHome, 'commands', 'blank.md'), '');
|
||||
|
||||
const result = await loadCommandPrompt(makeDeps(false), repoRoot, 'blank');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) expect(result.reason).toBe('empty_file');
|
||||
});
|
||||
});
|
||||
|
|
@ -598,82 +598,224 @@ nodes:
|
|||
});
|
||||
});
|
||||
|
||||
describe('globalSearchPath loading', () => {
|
||||
it('should load workflows from globalSearchPath and merge with local', async () => {
|
||||
const globalDir = join(
|
||||
tmpdir(),
|
||||
`global-test-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
);
|
||||
const globalWorkflowDir = join(globalDir, '.archon', 'workflows');
|
||||
const localWorkflowDir = join(testDir, '.archon', 'workflows');
|
||||
describe('home-scoped workflows (~/.archon/workflows/)', () => {
|
||||
// Home-scope is read unconditionally by discovery — no caller option. Tests
|
||||
// redirect `getArchonHome()` to a temp dir via the `ARCHON_HOME` env var so
|
||||
// they don't touch the user's real `~/.archon/`.
|
||||
let homeDir: string;
|
||||
const originalArchonHome = process.env.ARCHON_HOME;
|
||||
const originalArchonDocker = process.env.ARCHON_DOCKER;
|
||||
|
||||
await mkdir(globalWorkflowDir, { recursive: true });
|
||||
await mkdir(localWorkflowDir, { recursive: true });
|
||||
beforeEach(async () => {
|
||||
homeDir = join(tmpdir(), `home-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
await mkdir(homeDir, { recursive: true });
|
||||
process.env.ARCHON_HOME = homeDir;
|
||||
delete process.env.ARCHON_DOCKER;
|
||||
// The deprecation warning uses a module-scoped flag; reset between tests
|
||||
// so each case is independent.
|
||||
const { resetLegacyHomeWarningForTests } = await import('./workflow-discovery');
|
||||
resetLegacyHomeWarningForTests();
|
||||
mockLogger.warn.mockClear();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
await rm(homeDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (originalArchonHome === undefined) {
|
||||
delete process.env.ARCHON_HOME;
|
||||
} else {
|
||||
process.env.ARCHON_HOME = originalArchonHome;
|
||||
}
|
||||
if (originalArchonDocker === undefined) {
|
||||
delete process.env.ARCHON_DOCKER;
|
||||
} else {
|
||||
process.env.ARCHON_DOCKER = originalArchonDocker;
|
||||
}
|
||||
});
|
||||
|
||||
it('loads home-scoped workflows from ~/.archon/workflows/ and merges with repo', async () => {
|
||||
const homeWorkflowDir = join(homeDir, 'workflows');
|
||||
const repoWorkflowDir = join(testDir, '.archon', 'workflows');
|
||||
await mkdir(homeWorkflowDir, { recursive: true });
|
||||
await mkdir(repoWorkflowDir, { recursive: true });
|
||||
|
||||
await writeFile(
|
||||
join(globalWorkflowDir, 'global-wf.yaml'),
|
||||
'name: global-workflow\ndescription: From global\nnodes:\n - id: foo\n command: foo\n'
|
||||
join(homeWorkflowDir, 'home-wf.yaml'),
|
||||
'name: home-workflow\ndescription: From home\nnodes:\n - id: foo\n command: foo\n'
|
||||
);
|
||||
await writeFile(
|
||||
join(localWorkflowDir, 'local-wf.yaml'),
|
||||
'name: local-workflow\ndescription: From local\nnodes:\n - id: bar\n command: bar\n'
|
||||
join(repoWorkflowDir, 'repo-wf.yaml'),
|
||||
'name: repo-workflow\ndescription: From repo\nnodes:\n - id: bar\n command: bar\n'
|
||||
);
|
||||
|
||||
const result = await discoverWorkflows(testDir, {
|
||||
loadDefaults: false,
|
||||
globalSearchPath: globalDir,
|
||||
});
|
||||
|
||||
const result = await discoverWorkflows(testDir, { loadDefaults: false });
|
||||
const names = result.workflows.map(w => w.workflow.name);
|
||||
expect(names).toContain('global-workflow');
|
||||
expect(names).toContain('local-workflow');
|
||||
|
||||
await rm(globalDir, { recursive: true, force: true });
|
||||
expect(names).toContain('home-workflow');
|
||||
expect(names).toContain('repo-workflow');
|
||||
});
|
||||
|
||||
it('should allow local workflows to override global by filename', async () => {
|
||||
const globalDir = join(
|
||||
tmpdir(),
|
||||
`global-test-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
it("classifies home-scoped workflows as source: 'global'", async () => {
|
||||
const homeWorkflowDir = join(homeDir, 'workflows');
|
||||
await mkdir(homeWorkflowDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(homeWorkflowDir, 'only-home.yaml'),
|
||||
'name: only-home\ndescription: From home\nnodes:\n - id: n\n command: c\n'
|
||||
);
|
||||
const globalWorkflowDir = join(globalDir, '.archon', 'workflows');
|
||||
const localWorkflowDir = join(testDir, '.archon', 'workflows');
|
||||
|
||||
await mkdir(globalWorkflowDir, { recursive: true });
|
||||
await mkdir(localWorkflowDir, { recursive: true });
|
||||
const result = await discoverWorkflows(testDir, { loadDefaults: false });
|
||||
const entry = result.workflows.find(w => w.workflow.name === 'only-home');
|
||||
expect(entry?.source).toBe('global');
|
||||
});
|
||||
|
||||
it('repo workflow overrides home workflow with the same filename', async () => {
|
||||
const homeWorkflowDir = join(homeDir, 'workflows');
|
||||
const repoWorkflowDir = join(testDir, '.archon', 'workflows');
|
||||
await mkdir(homeWorkflowDir, { recursive: true });
|
||||
await mkdir(repoWorkflowDir, { recursive: true });
|
||||
|
||||
await writeFile(
|
||||
join(globalWorkflowDir, 'shared.yaml'),
|
||||
'name: global-version\ndescription: Global version\nnodes:\n - id: global\n command: global\n'
|
||||
join(homeWorkflowDir, 'shared.yaml'),
|
||||
'name: home-version\ndescription: Home version\nnodes:\n - id: h\n command: c\n'
|
||||
);
|
||||
await writeFile(
|
||||
join(localWorkflowDir, 'shared.yaml'),
|
||||
'name: local-version\ndescription: Local override\nnodes:\n - id: local\n command: local\n'
|
||||
join(repoWorkflowDir, 'shared.yaml'),
|
||||
'name: repo-version\ndescription: Repo override\nnodes:\n - id: r\n command: c\n'
|
||||
);
|
||||
|
||||
const result = await discoverWorkflows(testDir, {
|
||||
loadDefaults: false,
|
||||
globalSearchPath: globalDir,
|
||||
});
|
||||
|
||||
// Local should override global by filename
|
||||
const result = await discoverWorkflows(testDir, { loadDefaults: false });
|
||||
const shared = result.workflows.find(
|
||||
w => w.workflow.name === 'global-version' || w.workflow.name === 'local-version'
|
||||
w => w.workflow.name === 'home-version' || w.workflow.name === 'repo-version'
|
||||
);
|
||||
expect(shared?.workflow.name).toBe('local-version');
|
||||
|
||||
await rm(globalDir, { recursive: true, force: true });
|
||||
expect(shared?.workflow.name).toBe('repo-version');
|
||||
expect(shared?.source).toBe('project');
|
||||
});
|
||||
|
||||
it('should handle missing globalSearchPath gracefully', async () => {
|
||||
const result = await discoverWorkflows(testDir, {
|
||||
loadDefaults: false,
|
||||
globalSearchPath: '/nonexistent/path',
|
||||
});
|
||||
|
||||
// Should not throw, just return whatever local workflows exist
|
||||
it('silently skips when ~/.archon/workflows/ does not exist', async () => {
|
||||
// homeDir exists but no workflows/ subdirectory — should not error.
|
||||
const result = await discoverWorkflows(testDir, { loadDefaults: false });
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('supports 1-level subfolders under ~/.archon/workflows/ (e.g. triage/foo.yaml)', async () => {
|
||||
const homeWorkflowDir = join(homeDir, 'workflows', 'triage');
|
||||
await mkdir(homeWorkflowDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(homeWorkflowDir, 'grouped.yaml'),
|
||||
'name: grouped-workflow\ndescription: In a subfolder\nnodes:\n - id: n\n command: c\n'
|
||||
);
|
||||
|
||||
const result = await discoverWorkflows(testDir, { loadDefaults: false });
|
||||
const entry = result.workflows.find(w => w.workflow.name === 'grouped-workflow');
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry?.source).toBe('global');
|
||||
});
|
||||
|
||||
it('does NOT descend past 1 level of subfolders (rejects workflows/a/b/foo.yaml)', async () => {
|
||||
const nestedDir = join(homeDir, 'workflows', 'a', 'b');
|
||||
await mkdir(nestedDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(nestedDir, 'too-deep.yaml'),
|
||||
'name: too-deep\ndescription: Nested too deep\nnodes:\n - id: n\n command: c\n'
|
||||
);
|
||||
|
||||
const result = await discoverWorkflows(testDir, { loadDefaults: false });
|
||||
const entry = result.workflows.find(w => w.workflow.name === 'too-deep');
|
||||
expect(entry).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('legacy ~/.archon/.archon/workflows/ deprecation warning', () => {
|
||||
let homeDir: string;
|
||||
const originalArchonHome = process.env.ARCHON_HOME;
|
||||
const originalArchonDocker = process.env.ARCHON_DOCKER;
|
||||
|
||||
beforeEach(async () => {
|
||||
homeDir = join(tmpdir(), `legacy-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
await mkdir(homeDir, { recursive: true });
|
||||
process.env.ARCHON_HOME = homeDir;
|
||||
delete process.env.ARCHON_DOCKER;
|
||||
const { resetLegacyHomeWarningForTests } = await import('./workflow-discovery');
|
||||
resetLegacyHomeWarningForTests();
|
||||
mockLogger.warn.mockClear();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
await rm(homeDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (originalArchonHome === undefined) {
|
||||
delete process.env.ARCHON_HOME;
|
||||
} else {
|
||||
process.env.ARCHON_HOME = originalArchonHome;
|
||||
}
|
||||
if (originalArchonDocker === undefined) {
|
||||
delete process.env.ARCHON_DOCKER;
|
||||
} else {
|
||||
process.env.ARCHON_DOCKER = originalArchonDocker;
|
||||
}
|
||||
});
|
||||
|
||||
it('emits a WARN with the migration command when the legacy path exists', async () => {
|
||||
const legacyDir = join(homeDir, '.archon', 'workflows');
|
||||
await mkdir(legacyDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(legacyDir, 'stranded.yaml'),
|
||||
'name: stranded\ndescription: At the old path\nnodes:\n - id: n\n command: c\n'
|
||||
);
|
||||
|
||||
await discoverWorkflows(testDir, { loadDefaults: false });
|
||||
|
||||
const warnCalls = mockLogger.warn.mock.calls;
|
||||
const legacyWarn = warnCalls.find(call => call[1] === 'workflow.legacy_home_path_detected');
|
||||
expect(legacyWarn).toBeDefined();
|
||||
expect(legacyWarn?.[0]).toMatchObject({
|
||||
legacyPath: legacyDir,
|
||||
newPath: join(homeDir, 'workflows'),
|
||||
moveCommand: expect.stringContaining('mv'),
|
||||
});
|
||||
});
|
||||
|
||||
it('does NOT load workflows from the legacy path (clean cut)', async () => {
|
||||
const legacyDir = join(homeDir, '.archon', 'workflows');
|
||||
await mkdir(legacyDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(legacyDir, 'stranded.yaml'),
|
||||
'name: stranded\ndescription: At the old path\nnodes:\n - id: n\n command: c\n'
|
||||
);
|
||||
|
||||
const result = await discoverWorkflows(testDir, { loadDefaults: false });
|
||||
const stranded = result.workflows.find(w => w.workflow.name === 'stranded');
|
||||
expect(stranded).toBeUndefined();
|
||||
});
|
||||
|
||||
it('warns exactly once per process, even across multiple discovery calls', async () => {
|
||||
const legacyDir = join(homeDir, '.archon', 'workflows');
|
||||
await mkdir(legacyDir, { recursive: true });
|
||||
|
||||
await discoverWorkflows(testDir, { loadDefaults: false });
|
||||
await discoverWorkflows(testDir, { loadDefaults: false });
|
||||
await discoverWorkflows(testDir, { loadDefaults: false });
|
||||
|
||||
const warnCalls = mockLogger.warn.mock.calls.filter(
|
||||
call => call[1] === 'workflow.legacy_home_path_detected'
|
||||
);
|
||||
expect(warnCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not emit the warning when the legacy path is absent', async () => {
|
||||
// No legacy directory created — warning should not fire.
|
||||
await discoverWorkflows(testDir, { loadDefaults: false });
|
||||
|
||||
const warnCalls = mockLogger.warn.mock.calls.filter(
|
||||
call => call[1] === 'workflow.legacy_home_path_detected'
|
||||
);
|
||||
expect(warnCalls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('discoverWorkflowsWithConfig', () => {
|
||||
|
|
@ -704,31 +846,48 @@ nodes:
|
|||
expect(archonWorkflow).toBeDefined();
|
||||
});
|
||||
|
||||
it('should pass globalSearchPath through to discoverWorkflows', async () => {
|
||||
const { discoverWorkflowsWithConfig } = await import('./workflow-discovery');
|
||||
const globalDir = join(
|
||||
it('surfaces home-scoped workflows without any option — discovery reads ~/.archon/workflows/ internally', async () => {
|
||||
const { discoverWorkflowsWithConfig, resetLegacyHomeWarningForTests } =
|
||||
await import('./workflow-discovery');
|
||||
resetLegacyHomeWarningForTests();
|
||||
|
||||
const homeDir = join(
|
||||
tmpdir(),
|
||||
`global-test-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
`home-test-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
);
|
||||
const globalWorkflowDir = join(globalDir, '.archon', 'workflows');
|
||||
await mkdir(globalWorkflowDir, { recursive: true });
|
||||
const homeWorkflowDir = join(homeDir, 'workflows');
|
||||
await mkdir(homeWorkflowDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(globalWorkflowDir, 'global-only.yaml'),
|
||||
'name: global-only\ndescription: From global\nnodes:\n - id: foo\n command: foo\n'
|
||||
join(homeWorkflowDir, 'home-only.yaml'),
|
||||
'name: home-only\ndescription: From home\nnodes:\n - id: foo\n command: foo\n'
|
||||
);
|
||||
|
||||
const mockLoadConfig = mock(async () => ({
|
||||
defaults: { loadDefaultWorkflows: false },
|
||||
}));
|
||||
const originalArchonHome = process.env.ARCHON_HOME;
|
||||
const originalArchonDocker = process.env.ARCHON_DOCKER;
|
||||
process.env.ARCHON_HOME = homeDir;
|
||||
delete process.env.ARCHON_DOCKER;
|
||||
try {
|
||||
const mockLoadConfig = mock(async () => ({
|
||||
defaults: { loadDefaultWorkflows: false },
|
||||
}));
|
||||
|
||||
const result = await discoverWorkflowsWithConfig(testDir, mockLoadConfig, {
|
||||
globalSearchPath: globalDir,
|
||||
});
|
||||
|
||||
const names = result.workflows.map(w => w.workflow.name);
|
||||
expect(names).toContain('global-only');
|
||||
|
||||
await rm(globalDir, { recursive: true, force: true });
|
||||
const result = await discoverWorkflowsWithConfig(testDir, mockLoadConfig);
|
||||
const entry = result.workflows.find(w => w.workflow.name === 'home-only');
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry?.source).toBe('global');
|
||||
} finally {
|
||||
if (originalArchonHome === undefined) {
|
||||
delete process.env.ARCHON_HOME;
|
||||
} else {
|
||||
process.env.ARCHON_HOME = originalArchonHome;
|
||||
}
|
||||
if (originalArchonDocker === undefined) {
|
||||
delete process.env.ARCHON_DOCKER;
|
||||
} else {
|
||||
process.env.ARCHON_DOCKER = originalArchonDocker;
|
||||
}
|
||||
await rm(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -92,8 +92,15 @@ export type WorkflowExecutionResult =
|
|||
// WorkflowLoadError / WorkflowLoadResult — workflow discovery results
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Workflow origin — bundled default or project-defined. */
|
||||
export type WorkflowSource = 'bundled' | 'project';
|
||||
/**
|
||||
* Workflow origin:
|
||||
* - `bundled` — embedded in the Archon binary / bundled defaults
|
||||
* - `global` — user-level, discovered at `~/.archon/workflows/` (applies to every repo)
|
||||
* - `project` — repo-local, discovered at `<repoRoot>/.archon/workflows/`
|
||||
*
|
||||
* Precedence for same-named files: `bundled` < `global` < `project`.
|
||||
*/
|
||||
export type WorkflowSource = 'bundled' | 'global' | 'project';
|
||||
|
||||
/** A workflow definition paired with its discovery source. */
|
||||
export interface WorkflowWithSource {
|
||||
|
|
|
|||
|
|
@ -18,9 +18,19 @@ const mockLogger = {
|
|||
debug: mock(() => undefined),
|
||||
trace: mock(() => undefined),
|
||||
};
|
||||
mock.module('@archon/paths', () => ({ createLogger: mock(() => mockLogger) }));
|
||||
let mockHomeScriptsPath = '/home/scripts';
|
||||
mock.module('@archon/paths', () => ({
|
||||
createLogger: mock(() => mockLogger),
|
||||
getHomeScriptsPath: mock(() => mockHomeScriptsPath),
|
||||
}));
|
||||
|
||||
import { discoverScripts, getDefaultScripts } from './script-discovery';
|
||||
import { discoverScripts, discoverScriptsForCwd, getDefaultScripts } from './script-discovery';
|
||||
|
||||
// On Windows, path.join produces backslashes (e.g. `\scripts\triage`). The
|
||||
// mocks below key on forward-slash paths for readability, so normalize before
|
||||
// comparing. Production paths are stored via normalizeSep(), so assertions on
|
||||
// stored paths remain forward-slash on every OS.
|
||||
const norm = (p: string): string => p.replaceAll('\\', '/');
|
||||
|
||||
describe('discoverScripts', () => {
|
||||
beforeEach(() => {
|
||||
|
|
@ -159,6 +169,106 @@ describe('discoverScripts', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('scanScriptDir depth cap', () => {
|
||||
// Scripts are discovered 1 level deep (matches the workflows/commands
|
||||
// convention). `defaults/` style subfolders are fine; nested subfolders are not.
|
||||
beforeEach(() => {
|
||||
mockReaddir.mockReset();
|
||||
mockStat.mockReset();
|
||||
});
|
||||
|
||||
test('allows files in a 1-level subfolder', async () => {
|
||||
mockReaddir.mockImplementation(async (path: string) => {
|
||||
const p = norm(path);
|
||||
if (p === '/scripts') return ['triage', 'top.ts'];
|
||||
if (p === '/scripts/triage') return ['helper.py'];
|
||||
return [];
|
||||
});
|
||||
mockStat.mockImplementation(async (path: string) => ({
|
||||
isDirectory: () => norm(path) === '/scripts/triage',
|
||||
}));
|
||||
|
||||
const result = await discoverScripts('/scripts');
|
||||
expect(result.has('top')).toBe(true);
|
||||
expect(result.has('helper')).toBe(true);
|
||||
});
|
||||
|
||||
test('does NOT descend into nested subfolders (cap at depth 1)', async () => {
|
||||
mockReaddir.mockImplementation(async (path: string) => {
|
||||
const p = norm(path);
|
||||
if (p === '/scripts') return ['level-one'];
|
||||
if (p === '/scripts/level-one') return ['level-two'];
|
||||
if (p === '/scripts/level-one/level-two') return ['too-deep.ts'];
|
||||
return [];
|
||||
});
|
||||
mockStat.mockImplementation(async (path: string) => {
|
||||
const p = norm(path);
|
||||
return {
|
||||
isDirectory: () => p === '/scripts/level-one' || p === '/scripts/level-one/level-two',
|
||||
};
|
||||
});
|
||||
|
||||
const result = await discoverScripts('/scripts');
|
||||
expect(result.has('too-deep')).toBe(false);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('discoverScriptsForCwd — merge repo + home with repo winning', () => {
|
||||
beforeEach(() => {
|
||||
mockReaddir.mockReset();
|
||||
mockStat.mockReset();
|
||||
mockHomeScriptsPath = '/home/scripts';
|
||||
});
|
||||
|
||||
test('merges scripts from ~/.archon/scripts and <cwd>/.archon/scripts', async () => {
|
||||
mockReaddir.mockImplementation(async (path: string) => {
|
||||
const p = norm(path);
|
||||
if (p === '/home/scripts') return ['home-only.ts'];
|
||||
if (p === '/repo/.archon/scripts') return ['repo-only.py'];
|
||||
return [];
|
||||
});
|
||||
mockStat.mockResolvedValue({ isDirectory: () => false });
|
||||
|
||||
const result = await discoverScriptsForCwd('/repo');
|
||||
expect(result.has('home-only')).toBe(true);
|
||||
expect(result.has('repo-only')).toBe(true);
|
||||
expect(result.size).toBe(2);
|
||||
});
|
||||
|
||||
test('repo-scoped script overrides same-named home script', async () => {
|
||||
mockReaddir.mockImplementation(async (path: string) => {
|
||||
const p = norm(path);
|
||||
if (p === '/home/scripts') return ['shared.ts'];
|
||||
if (p === '/repo/.archon/scripts') return ['shared.ts'];
|
||||
return [];
|
||||
});
|
||||
mockStat.mockResolvedValue({ isDirectory: () => false });
|
||||
|
||||
const result = await discoverScriptsForCwd('/repo');
|
||||
expect(result.size).toBe(1);
|
||||
// Stored paths are normalized to forward slashes via normalizeSep() in
|
||||
// script-discovery.ts, so this assertion is OS-independent.
|
||||
expect(result.get('shared')!.path).toBe('/repo/.archon/scripts/shared.ts');
|
||||
});
|
||||
|
||||
test('tolerates missing home dir (new user, no personal scripts yet)', async () => {
|
||||
mockReaddir.mockImplementation(async (path: string) => {
|
||||
const p = norm(path);
|
||||
if (p === '/home/scripts') {
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||
}
|
||||
if (p === '/repo/.archon/scripts') return ['only-repo.ts'];
|
||||
return [];
|
||||
});
|
||||
mockStat.mockResolvedValue({ isDirectory: () => false });
|
||||
|
||||
const result = await discoverScriptsForCwd('/repo');
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.has('only-repo')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDefaultScripts', () => {
|
||||
test('returns an empty Map', () => {
|
||||
const defaults = getDefaultScripts();
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
*/
|
||||
import { readdir, stat } from 'fs/promises';
|
||||
import { join, basename, extname } from 'path';
|
||||
import { createLogger } from '@archon/paths';
|
||||
import { createLogger, getHomeScriptsPath } from '@archon/paths';
|
||||
|
||||
/** Normalize path separators to forward slashes for cross-platform consistency */
|
||||
function normalizeSep(p: string): string {
|
||||
|
|
@ -46,12 +46,24 @@ function getRuntimeForExtension(ext: string): ScriptRuntime | undefined {
|
|||
}
|
||||
|
||||
/**
|
||||
* Recursively scan a directory and return all script files with their names, paths, and runtimes.
|
||||
* Skips files with unknown extensions. Throws on duplicate script names.
|
||||
* Maximum subfolder depth we descend into when scanning scripts.
|
||||
*
|
||||
* `1` matches the workflows/commands convention: allow one level of
|
||||
* grouping (e.g. `.archon/scripts/triage/foo.ts`) but no nested folders.
|
||||
* We stop at 1 deliberately — deeper nesting has never been part of the
|
||||
* documented convention and adds no organizational value, just routing
|
||||
* ambiguity when two basenames collide across folders.
|
||||
*/
|
||||
const MAX_SCRIPT_DISCOVERY_DEPTH = 1;
|
||||
|
||||
/**
|
||||
* Scan a directory for script files, descending at most `MAX_SCRIPT_DISCOVERY_DEPTH`
|
||||
* folders deep. Skips files with unknown extensions. Throws on duplicate script names.
|
||||
*/
|
||||
async function scanScriptDir(
|
||||
dirPath: string,
|
||||
scripts: Map<string, ScriptDefinition>
|
||||
scripts: Map<string, ScriptDefinition>,
|
||||
depth = 0
|
||||
): Promise<void> {
|
||||
let entries: string[];
|
||||
try {
|
||||
|
|
@ -79,7 +91,10 @@ async function scanScriptDir(
|
|||
}
|
||||
|
||||
if (entryStat.isDirectory()) {
|
||||
await scanScriptDir(entryPath, scripts);
|
||||
// 1-depth cap: allow one level of grouping (e.g. `.archon/scripts/triage/foo.ts`)
|
||||
// but stop there. Matches the workflows/commands convention — no nested folders.
|
||||
if (depth >= MAX_SCRIPT_DISCOVERY_DEPTH) continue;
|
||||
await scanScriptDir(entryPath, scripts, depth + 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -109,7 +124,7 @@ async function scanScriptDir(
|
|||
/**
|
||||
* Discover scripts from a directory (expected to be .archon/scripts/ or equivalent).
|
||||
* Returns a Map of script name -> ScriptDefinition.
|
||||
* Throws if duplicate script names are found across different extensions.
|
||||
* Throws if duplicate script names are found across different extensions within the directory.
|
||||
* Returns an empty Map if the directory does not exist.
|
||||
*/
|
||||
export async function discoverScripts(dir: string): Promise<Map<string, ScriptDefinition>> {
|
||||
|
|
@ -119,6 +134,33 @@ export async function discoverScripts(dir: string): Promise<Map<string, ScriptDe
|
|||
return scripts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover scripts across all scopes for a given repo cwd.
|
||||
*
|
||||
* Resolution order (repo wins on same-name collision — matches the
|
||||
* workflows/commands precedence):
|
||||
* 1. `<cwd>/.archon/scripts/` — repo-scoped (`source: 'project'` equivalent)
|
||||
* 2. `~/.archon/scripts/` — home-scoped (`source: 'global'` equivalent)
|
||||
*
|
||||
* Within a single scope, duplicate basenames across extensions still throw
|
||||
* (matches `discoverScripts` behavior). Across scopes, the repo-level entry
|
||||
* silently overrides the home-level one.
|
||||
*/
|
||||
export async function discoverScriptsForCwd(cwd: string): Promise<Map<string, ScriptDefinition>> {
|
||||
const homeScripts = await discoverScripts(getHomeScriptsPath());
|
||||
const repoScripts = await discoverScripts(join(cwd, '.archon', 'scripts'));
|
||||
|
||||
// Start with home, overlay repo (repo wins)
|
||||
const merged = new Map<string, ScriptDefinition>(homeScripts);
|
||||
for (const [name, def] of repoScripts) {
|
||||
if (merged.has(name)) {
|
||||
getLog().debug({ name }, 'script.repo_overrides_home');
|
||||
}
|
||||
merged.set(name, def);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns bundled default scripts (empty — no bundled scripts for now).
|
||||
* Follows the bundled-defaults.ts pattern for future extensibility.
|
||||
|
|
|
|||
|
|
@ -290,6 +290,61 @@ describe('discoverAvailableCommands', () => {
|
|||
const without = await discoverAvailableCommands(tmpDir, { loadDefaultCommands: false });
|
||||
expect(withDefaults.length).toBeGreaterThanOrEqual(without.length);
|
||||
});
|
||||
|
||||
// --- Home-scoped commands (~/.archon/commands/) — new capability
|
||||
describe('home-scoped commands', () => {
|
||||
let homeDir: string;
|
||||
const originalArchonHome = process.env.ARCHON_HOME;
|
||||
const originalArchonDocker = process.env.ARCHON_DOCKER;
|
||||
|
||||
beforeEach(async () => {
|
||||
homeDir = await mkdtemp(join(tmpdir(), 'validator-home-'));
|
||||
process.env.ARCHON_HOME = homeDir;
|
||||
delete process.env.ARCHON_DOCKER;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(homeDir, { recursive: true, force: true });
|
||||
if (originalArchonHome === undefined) {
|
||||
delete process.env.ARCHON_HOME;
|
||||
} else {
|
||||
process.env.ARCHON_HOME = originalArchonHome;
|
||||
}
|
||||
if (originalArchonDocker === undefined) {
|
||||
delete process.env.ARCHON_DOCKER;
|
||||
} else {
|
||||
process.env.ARCHON_DOCKER = originalArchonDocker;
|
||||
}
|
||||
});
|
||||
|
||||
async function createHomeCommand(name: string, content = '# Home helper'): Promise<void> {
|
||||
const dir = join(homeDir, 'commands');
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(join(dir, `${name}.md`), content);
|
||||
}
|
||||
|
||||
test('discovers commands placed at ~/.archon/commands/', async () => {
|
||||
await createHomeCommand('my-personal-helper');
|
||||
const commands = await discoverAvailableCommands(tmpDir, { loadDefaultCommands: false });
|
||||
expect(commands).toContain('my-personal-helper');
|
||||
});
|
||||
|
||||
test('resolveCommand (via validateCommand) finds home-scoped commands when repo has none', async () => {
|
||||
await createHomeCommand('only-in-home');
|
||||
const result = await validateCommand('only-in-home', tmpDir, { loadDefaultCommands: false });
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
test('repo command overrides home command with the same name', async () => {
|
||||
await createHomeCommand('shared', '# Home version');
|
||||
await createCommandFile('shared', '# Repo version');
|
||||
// Both resolve but the repo wins — validator only asserts existence, so the
|
||||
// strong behavioral assertion lives in the executor-shared loadCommand tests.
|
||||
// Here we just confirm that having both doesn't error.
|
||||
const result = await validateCommand('shared', tmpDir, { loadDefaultCommands: false });
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
createLogger,
|
||||
getCommandFolderSearchPaths,
|
||||
getDefaultCommandsPath,
|
||||
getHomeCommandsPath,
|
||||
findMarkdownFilesRecursive,
|
||||
} from '@archon/paths';
|
||||
import { execFileAsync } from '@archon/git';
|
||||
|
|
@ -32,7 +33,7 @@ function getLog(): ReturnType<typeof createLogger> {
|
|||
import { isScriptNode } from './schemas';
|
||||
import type { WorkflowDefinition, DagNode } from './schemas';
|
||||
import type { ScriptRuntime } from './script-discovery';
|
||||
import { discoverScripts } from './script-discovery';
|
||||
import { discoverScriptsForCwd } from './script-discovery';
|
||||
import { isInlineScript } from './executor-shared';
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -141,17 +142,33 @@ export async function discoverAvailableCommands(
|
|||
): Promise<string[]> {
|
||||
const names = new Set<string>();
|
||||
|
||||
// Repo search paths (findMarkdownFilesRecursive returns [] for ENOENT)
|
||||
// Each scope is walked 1 subfolder deep (matches the workflows/scripts
|
||||
// discovery convention — supports `defaults/` grouping, rejects deeper nesting).
|
||||
|
||||
// 1. Repo search paths
|
||||
const searchPaths = getCommandFolderSearchPaths(config?.commandFolder);
|
||||
for (const folder of searchPaths) {
|
||||
const dirPath = join(cwd, folder);
|
||||
const files = await findMarkdownFilesRecursive(dirPath);
|
||||
const files = await findMarkdownFilesRecursive(dirPath, '', { maxDepth: 1 });
|
||||
for (const { commandName } of files) {
|
||||
names.add(commandName);
|
||||
}
|
||||
}
|
||||
|
||||
// Bundled defaults
|
||||
// 2. Home-scoped commands (~/.archon/commands/) — personal helpers reusable across repos.
|
||||
// ENOENT already returns []; we only catch other errors (EACCES/EPERM/EIO) so a broken
|
||||
// home-scope doesn't take down repo/bundled discovery.
|
||||
const homePath = getHomeCommandsPath();
|
||||
try {
|
||||
const homeCommands = await findMarkdownFilesRecursive(homePath, '', { maxDepth: 1 });
|
||||
for (const { commandName } of homeCommands) {
|
||||
names.add(commandName);
|
||||
}
|
||||
} catch (err) {
|
||||
getLog().warn({ err, path: homePath }, 'commands.home_discovery_failed');
|
||||
}
|
||||
|
||||
// 3. Bundled defaults
|
||||
const loadDefaults = config?.loadDefaultCommands !== false;
|
||||
if (loadDefaults) {
|
||||
if (isBinaryBuild()) {
|
||||
|
|
@ -160,7 +177,7 @@ export async function discoverAvailableCommands(
|
|||
}
|
||||
} else {
|
||||
const defaultsPath = getDefaultCommandsPath();
|
||||
const files = await findMarkdownFilesRecursive(defaultsPath);
|
||||
const files = await findMarkdownFilesRecursive(defaultsPath, '', { maxDepth: 1 });
|
||||
for (const { commandName } of files) {
|
||||
names.add(commandName);
|
||||
}
|
||||
|
|
@ -170,25 +187,58 @@ export async function discoverAvailableCommands(
|
|||
return [...names].sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a command name to a file path within a single directory, walking at
|
||||
* most 1 subfolder deep. Returns the first `.md` file whose basename matches
|
||||
* `commandName`, or `null` if nothing matches.
|
||||
*
|
||||
* Within a single scope, if two files in different subfolders share a basename
|
||||
* (e.g. `triage/review.md` and `team/review.md`), the earlier match by the
|
||||
* deterministic walk order wins — duplicates within a scope are a user error.
|
||||
*/
|
||||
async function resolveCommandInDir(rootDir: string, commandName: string): Promise<string | null> {
|
||||
const entries = await findMarkdownFilesRecursive(rootDir, '', { maxDepth: 1 });
|
||||
const match = entries.find(e => e.commandName === commandName);
|
||||
return match ? join(rootDir, match.relativePath) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a command file can be resolved via the standard search paths.
|
||||
* Returns the resolved path if found, null otherwise.
|
||||
*
|
||||
* Resolution precedence (first hit wins):
|
||||
* 1. Repo-local — `<cwd>/.archon/commands/` and configured folders
|
||||
* 2. Home-scoped — `~/.archon/commands/` (personal helpers, reusable across repos)
|
||||
* 3. Bundled defaults — embedded in the binary or the app's defaults folder
|
||||
*/
|
||||
async function resolveCommand(
|
||||
commandName: string,
|
||||
cwd: string,
|
||||
config?: ValidationConfig
|
||||
): Promise<string | null> {
|
||||
// Repo search paths
|
||||
// Each scope is walked 1 subfolder deep by basename — so `triage/review.md`
|
||||
// is resolvable as `review`. This matches the workflows/scripts discovery
|
||||
// convention and makes the listed commands in `discoverAvailableCommands`
|
||||
// actually resolvable.
|
||||
|
||||
// 1. Repo search paths
|
||||
const searchPaths = getCommandFolderSearchPaths(config?.commandFolder);
|
||||
for (const folder of searchPaths) {
|
||||
const filePath = join(cwd, folder, `${commandName}.md`);
|
||||
if (await fileExists(filePath)) {
|
||||
return filePath;
|
||||
}
|
||||
const resolved = await resolveCommandInDir(join(cwd, folder), commandName);
|
||||
if (resolved) return resolved;
|
||||
}
|
||||
|
||||
// Bundled defaults
|
||||
// 2. Home-scoped commands (~/.archon/commands/).
|
||||
// ENOENT on the home dir already returns null; only wrap for other errors so a
|
||||
// broken home-scope doesn't prevent bundled-default resolution.
|
||||
try {
|
||||
const homeResolved = await resolveCommandInDir(getHomeCommandsPath(), commandName);
|
||||
if (homeResolved) return homeResolved;
|
||||
} catch (err) {
|
||||
getLog().warn({ err, commandName }, 'commands.home_resolve_failed');
|
||||
}
|
||||
|
||||
// 3. Bundled defaults
|
||||
const loadDefaults = config?.loadDefaultCommands !== false;
|
||||
if (loadDefaults) {
|
||||
if (isBinaryBuild()) {
|
||||
|
|
@ -196,10 +246,8 @@ async function resolveCommand(
|
|||
return `[bundled:${commandName}]`;
|
||||
}
|
||||
} else {
|
||||
const defaultsPath = join(getDefaultCommandsPath(), `${commandName}.md`);
|
||||
if (await fileExists(defaultsPath)) {
|
||||
return defaultsPath;
|
||||
}
|
||||
const defaultsResolved = await resolveCommandInDir(getDefaultCommandsPath(), commandName);
|
||||
if (defaultsResolved) return defaultsResolved;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -436,22 +484,23 @@ export async function validateWorkflowResources(
|
|||
if (isScriptNode(node)) {
|
||||
const script = node.script;
|
||||
|
||||
// Named script: validate file exists in .archon/scripts/
|
||||
// Named script: validate file exists in repo or home scope.
|
||||
// Precedence mirrors dag-executor: repo > home. Subfolders up to 1 level deep
|
||||
// are searched by discoverScriptsForCwd, matching the workflows/commands convention.
|
||||
if (!isInlineScript(script)) {
|
||||
const scriptsDir = resolve(cwd, '.archon', 'scripts');
|
||||
const extensions = node.runtime === 'uv' ? ['.py'] : ['.ts', '.js'];
|
||||
const existsResults = await Promise.all(
|
||||
extensions.map(ext => fileExists(join(scriptsDir, `${script}${ext}`)))
|
||||
);
|
||||
const scriptExists = existsResults.some(Boolean);
|
||||
const scripts = await discoverScriptsForCwd(cwd);
|
||||
const entry = scripts.get(script);
|
||||
const scriptExists =
|
||||
entry !== undefined &&
|
||||
(node.runtime === 'uv' ? entry.runtime === 'uv' : entry.runtime === 'bun');
|
||||
|
||||
if (!scriptExists) {
|
||||
issues.push({
|
||||
level: 'error',
|
||||
nodeId: node.id,
|
||||
field: 'script',
|
||||
message: `Named script '${script}' not found in .archon/scripts/`,
|
||||
hint: `Create .archon/scripts/${script}.${node.runtime === 'uv' ? 'py' : 'ts'} with your script code`,
|
||||
message: `Named script '${script}' not found in .archon/scripts/ or ~/.archon/scripts/`,
|
||||
hint: `Create .archon/scripts/${script}.${node.runtime === 'uv' ? 'py' : 'ts'} with your script code (or place at ~/.archon/scripts/ to share across repos)`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -568,19 +617,19 @@ export interface ScriptValidationResult {
|
|||
}
|
||||
|
||||
/**
|
||||
* Discover all script names from .archon/scripts/ in the given cwd.
|
||||
* Returns a list of { name, path, runtime } entries.
|
||||
* Discover all script names from the repo and home scopes.
|
||||
* Returns a list of { name, path, runtime } entries. Repo-scoped scripts
|
||||
* silently override same-named home-scoped entries.
|
||||
*/
|
||||
export async function discoverAvailableScripts(
|
||||
cwd: string
|
||||
): Promise<{ name: string; path: string; runtime: ScriptRuntime }[]> {
|
||||
const scriptsDir = resolve(cwd, '.archon', 'scripts');
|
||||
try {
|
||||
const scripts = await discoverScripts(scriptsDir);
|
||||
const scripts = await discoverScriptsForCwd(cwd);
|
||||
return [...scripts.values()].map(s => ({ name: s.name, path: s.path, runtime: s.runtime }));
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
getLog().warn({ err, scriptsDir }, 'script_discovery_failed');
|
||||
getLog().warn({ err, cwd }, 'script_discovery_failed');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
@ -593,28 +642,21 @@ export async function validateScript(
|
|||
cwd: string
|
||||
): Promise<ScriptValidationResult> {
|
||||
const issues: ValidationIssue[] = [];
|
||||
const scriptsDir = resolve(cwd, '.archon', 'scripts');
|
||||
|
||||
// Find the script file (any supported extension)
|
||||
const allExtensions = ['.ts', '.js', '.py'];
|
||||
let foundPath: string | null = null;
|
||||
let detectedRuntime: ScriptRuntime | null = null;
|
||||
// Look up across repo + home scopes (repo wins). discoverScriptsForCwd handles
|
||||
// both 1-depth subfolders and the repo/home precedence.
|
||||
const scripts = await discoverScriptsForCwd(cwd);
|
||||
const entry = scripts.get(scriptName);
|
||||
|
||||
for (const ext of allExtensions) {
|
||||
const candidate = join(scriptsDir, `${scriptName}${ext}`);
|
||||
if (await fileExists(candidate)) {
|
||||
foundPath = candidate;
|
||||
detectedRuntime = ext === '.py' ? 'uv' : 'bun';
|
||||
break;
|
||||
}
|
||||
}
|
||||
const foundPath = entry?.path ?? null;
|
||||
const detectedRuntime = entry?.runtime ?? null;
|
||||
|
||||
if (!foundPath || !detectedRuntime) {
|
||||
issues.push({
|
||||
level: 'error',
|
||||
field: 'file',
|
||||
message: `Script '${scriptName}' not found in .archon/scripts/`,
|
||||
hint: `Create .archon/scripts/${scriptName}.ts (bun) or .archon/scripts/${scriptName}.py (uv)`,
|
||||
message: `Script '${scriptName}' not found in .archon/scripts/ or ~/.archon/scripts/`,
|
||||
hint: `Create .archon/scripts/${scriptName}.ts (bun) or .archon/scripts/${scriptName}.py (uv). Place at ~/.archon/scripts/ to share across repos.`,
|
||||
});
|
||||
return { scriptName, valid: false, issues };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,15 @@
|
|||
* full discoverWorkflows entry point.
|
||||
*
|
||||
* Imports parseWorkflow from loader.ts (parsing concern stays there).
|
||||
*
|
||||
* Scopes (precedence lowest → highest):
|
||||
* 1. `bundled` — embedded in the Archon binary (or read from the app's
|
||||
* defaults folder in source mode).
|
||||
* 2. `global` — home-scoped at `~/.archon/workflows/`. Applies to every
|
||||
* repo; discovered automatically (no caller option needed).
|
||||
* 3. `project` — repo-local at `<cwd>/.archon/workflows/`.
|
||||
*
|
||||
* Same-named files at a higher scope override those at lower scopes.
|
||||
*/
|
||||
import { readFile, readdir, access, stat } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
|
@ -27,16 +36,64 @@ function getLog(): ReturnType<typeof createLogger> {
|
|||
return cachedLog;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time deprecation warning for the pre-refactor `~/.archon/.archon/workflows/`
|
||||
* location. Scoped to the process so the warning fires exactly once regardless
|
||||
* of how many times discovery runs.
|
||||
*
|
||||
* The legacy path is ONLY probed for detection — workflows placed there are not
|
||||
* read. Users migrate manually via the `mv` command printed in the warning.
|
||||
* Exported so tests can reset it between cases.
|
||||
*/
|
||||
let hasWarnedLegacyHomePath = false;
|
||||
export function resetLegacyHomeWarningForTests(): void {
|
||||
hasWarnedLegacyHomePath = false;
|
||||
}
|
||||
|
||||
async function maybeWarnLegacyHomePath(): Promise<void> {
|
||||
if (hasWarnedLegacyHomePath) return;
|
||||
// Set the flag eagerly so concurrent discovery calls (e.g. parallel codebase
|
||||
// resolution at server startup) can't both pass the guard and double-warn.
|
||||
hasWarnedLegacyHomePath = true;
|
||||
|
||||
const legacyPath = archonPaths.getLegacyHomeWorkflowsPath();
|
||||
const newPath = archonPaths.getHomeWorkflowsPath();
|
||||
try {
|
||||
await access(legacyPath);
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException;
|
||||
if (err.code === 'ENOENT') return; // happy path — legacy location not in use
|
||||
// EACCES/EPERM/EIO: directory exists but we can't read it. Surface at WARN
|
||||
// so the user sees it — silent debug would hide a real permission issue.
|
||||
getLog().warn({ err, legacyPath }, 'workflow.legacy_home_path_probe_error');
|
||||
return;
|
||||
}
|
||||
// Legacy directory exists — surface an actionable migration hint exactly once.
|
||||
const moveCommand = `mv "${legacyPath}" "${newPath}" && rmdir "${join(archonPaths.getArchonHome(), '.archon')}"`;
|
||||
getLog().warn({ legacyPath, newPath, moveCommand }, 'workflow.legacy_home_path_detected');
|
||||
}
|
||||
|
||||
interface DirLoadResult {
|
||||
workflows: Map<string, WorkflowDefinition>;
|
||||
errors: WorkflowLoadError[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load workflows from a directory (recursively includes subdirectories).
|
||||
* Maximum subfolder depth we descend into when discovering workflows/commands/scripts.
|
||||
*
|
||||
* `1` allows one level of grouping (e.g. `.archon/workflows/defaults/foo.yaml`);
|
||||
* `0` would mean only files at the root. We stop at 1 deliberately — deeper
|
||||
* nesting has never been part of the documented convention and adds no
|
||||
* organizational value, just routing ambiguity.
|
||||
*/
|
||||
const MAX_DISCOVERY_DEPTH = 1;
|
||||
|
||||
/**
|
||||
* Load workflows from a directory, descending at most `MAX_DISCOVERY_DEPTH`
|
||||
* folders deep. Files deeper than the cap are silently skipped.
|
||||
* Failures are per-file: one broken file does not abort loading the rest.
|
||||
*/
|
||||
async function loadWorkflowsFromDir(dirPath: string): Promise<DirLoadResult> {
|
||||
async function loadWorkflowsFromDir(dirPath: string, depth = 0): Promise<DirLoadResult> {
|
||||
const workflows = new Map<string, WorkflowDefinition>();
|
||||
const errors: WorkflowLoadError[] = [];
|
||||
|
||||
|
|
@ -50,8 +107,11 @@ async function loadWorkflowsFromDir(dirPath: string): Promise<DirLoadResult> {
|
|||
const entryStat = await stat(entryPath);
|
||||
|
||||
if (entryStat.isDirectory()) {
|
||||
// Recursively load from subdirectories
|
||||
const subResult = await loadWorkflowsFromDir(entryPath);
|
||||
// Only descend if we're still within the depth cap. Past the cap,
|
||||
// subdirectories are ignored (same convention as the paths-package
|
||||
// `findMarkdownFilesRecursive` depth cap).
|
||||
if (depth >= MAX_DISCOVERY_DEPTH) continue;
|
||||
const subResult = await loadWorkflowsFromDir(entryPath, depth + 1);
|
||||
for (const [filename, workflow] of subResult.workflows) {
|
||||
workflows.set(filename, workflow);
|
||||
}
|
||||
|
|
@ -125,17 +185,24 @@ function loadBundledWorkflows(): DirLoadResult {
|
|||
}
|
||||
|
||||
/**
|
||||
* Discover and load workflows from codebase
|
||||
* Loads from both app's bundled defaults and repo's workflow folder.
|
||||
* Repo workflows override app defaults by exact filename match.
|
||||
* Discover and load workflows from codebase.
|
||||
*
|
||||
* When running as a compiled binary, defaults are loaded from the bundled
|
||||
* content embedded at compile time. When running with Bun, defaults are
|
||||
* loaded from the filesystem.
|
||||
* Loads three scopes in order (later overrides earlier by filename):
|
||||
* 1. Bundled defaults (unless `options.loadDefaults === false`).
|
||||
* 2. Home-scoped `~/.archon/workflows/` — classified as `source: 'global'`.
|
||||
* No caller option: every caller gets home-scoped discovery for free.
|
||||
* 3. Repo-scoped `<cwd>/.archon/workflows/` — classified as `source: 'project'`.
|
||||
*
|
||||
* When running as a compiled binary, bundled defaults are loaded from embedded
|
||||
* content. In source/dev mode they're loaded from the filesystem.
|
||||
*
|
||||
* Migration: if the retired `~/.archon/.archon/workflows/` path exists, the
|
||||
* first call per process logs a WARN with the exact `mv` command. The legacy
|
||||
* location is not read — users must migrate manually.
|
||||
*/
|
||||
export async function discoverWorkflows(
|
||||
cwd: string,
|
||||
options?: { globalSearchPath?: string; loadDefaults?: boolean }
|
||||
options?: { loadDefaults?: boolean }
|
||||
): Promise<WorkflowLoadResult> {
|
||||
// Map of filename -> workflow+source for deduplication
|
||||
const workflowsByFile = new Map<string, WorkflowWithSource>();
|
||||
|
|
@ -182,36 +249,32 @@ export async function discoverWorkflows(
|
|||
}
|
||||
}
|
||||
|
||||
// 2. Load from global search path (e.g., ~/.archon/.archon/workflows/ for orchestrator)
|
||||
if (options?.globalSearchPath) {
|
||||
const [globalWorkflowFolder] = archonPaths.getWorkflowFolderSearchPaths();
|
||||
const globalWorkflowPath = join(options.globalSearchPath, globalWorkflowFolder);
|
||||
getLog().debug({ globalWorkflowPath }, 'searching_global_workflows');
|
||||
try {
|
||||
await access(globalWorkflowPath);
|
||||
const globalResult = await loadWorkflowsFromDir(globalWorkflowPath);
|
||||
for (const [filename, workflow] of globalResult.workflows) {
|
||||
if (workflowsByFile.has(filename)) {
|
||||
getLog().debug({ filename }, 'global_workflow_overrides_default');
|
||||
}
|
||||
// NOTE: Global workflows (~/.archon/.archon/workflows/) are classified as 'project'
|
||||
// rather than a separate 'global' source. This is an intentional scope decision for
|
||||
// the initial source badge feature — a 'global' source variant can be added later.
|
||||
workflowsByFile.set(filename, { workflow, source: 'project' });
|
||||
}
|
||||
allErrors.push(...globalResult.errors);
|
||||
getLog().info({ count: globalResult.workflows.size }, 'global_workflows_loaded');
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException;
|
||||
if (err.code !== 'ENOENT') {
|
||||
getLog().warn({ err, globalWorkflowPath }, 'global_workflows_access_error');
|
||||
} else {
|
||||
getLog().debug({ globalWorkflowPath }, 'global_workflows_not_found');
|
||||
// 2. Load home-scoped workflows from ~/.archon/workflows/. No caller option —
|
||||
// discovery is responsible for surfacing home-scoped content everywhere.
|
||||
await maybeWarnLegacyHomePath();
|
||||
const homeWorkflowPath = archonPaths.getHomeWorkflowsPath();
|
||||
getLog().debug({ homeWorkflowPath }, 'searching_home_workflows');
|
||||
try {
|
||||
await access(homeWorkflowPath);
|
||||
const homeResult = await loadWorkflowsFromDir(homeWorkflowPath);
|
||||
for (const [filename, workflow] of homeResult.workflows) {
|
||||
if (workflowsByFile.has(filename)) {
|
||||
getLog().debug({ filename }, 'home_workflow_overrides_bundled');
|
||||
}
|
||||
workflowsByFile.set(filename, { workflow, source: 'global' });
|
||||
}
|
||||
allErrors.push(...homeResult.errors);
|
||||
getLog().info({ count: homeResult.workflows.size }, 'home_workflows_loaded');
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException;
|
||||
if (err.code !== 'ENOENT') {
|
||||
getLog().warn({ err, homeWorkflowPath }, 'home_workflows_access_error');
|
||||
} else {
|
||||
getLog().debug({ homeWorkflowPath }, 'home_workflows_not_found');
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Load from repo's workflow folder (overrides app defaults by exact filename)
|
||||
// 3. Load from repo's workflow folder (overrides app defaults AND home scope by exact filename)
|
||||
const [workflowFolder] = archonPaths.getWorkflowFolderSearchPaths();
|
||||
const workflowPath = join(cwd, workflowFolder);
|
||||
|
||||
|
|
@ -221,7 +284,7 @@ export async function discoverWorkflows(
|
|||
await access(workflowPath);
|
||||
const repoResult = await loadWorkflowsFromDir(workflowPath);
|
||||
|
||||
// Repo workflows override app defaults by exact filename match.
|
||||
// Repo workflows override bundled AND home scope by exact filename match.
|
||||
// Preserve 'bundled' source for workflows loaded from the defaults/ subdirectory
|
||||
// that were already registered as bundled in step 1.
|
||||
for (const [filename, workflow] of repoResult.workflows) {
|
||||
|
|
@ -233,7 +296,10 @@ export async function discoverWorkflows(
|
|||
workflowsByFile.set(filename, { workflow, source: 'bundled' });
|
||||
} else {
|
||||
if (existing) {
|
||||
getLog().debug({ filename }, 'repo_workflow_overrides_default');
|
||||
getLog().debug(
|
||||
{ filename, overriddenSource: existing.source },
|
||||
'repo_workflow_overrides_lower_scope'
|
||||
);
|
||||
}
|
||||
workflowsByFile.set(filename, { workflow, source: 'project' });
|
||||
}
|
||||
|
|
@ -290,8 +356,7 @@ export async function discoverWorkflows(
|
|||
*/
|
||||
export async function discoverWorkflowsWithConfig(
|
||||
cwd: string,
|
||||
loadConfig: (cwd: string) => Promise<{ defaults?: { loadDefaultWorkflows?: boolean } }>,
|
||||
options?: { globalSearchPath?: string }
|
||||
loadConfig: (cwd: string) => Promise<{ defaults?: { loadDefaultWorkflows?: boolean } }>
|
||||
): Promise<WorkflowLoadResult> {
|
||||
let loadDefaults = true;
|
||||
try {
|
||||
|
|
@ -303,5 +368,5 @@ export async function discoverWorkflowsWithConfig(
|
|||
'config_load_failed_using_default_workflow_discovery'
|
||||
);
|
||||
}
|
||||
return discoverWorkflows(cwd, { ...options, loadDefaults });
|
||||
return discoverWorkflows(cwd, { loadDefaults });
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue