Skip to content

Commit 3d290d8

Browse files
authored
fix(server,workflows,web): surface bundled defaults on /api/workflows when no project context (#1618)
* fix(server,workflows,web): surface bundled defaults on /api/workflows when no project context (#1173) GET /api/workflows short-circuits to an empty array when there is no `cwd` query param and no registered codebases. The handler never reaches discovery, so bundled defaults are not surfaced and the UI renders a misleading "Add workflow definitions to .archon/workflows/" empty state on first run — even though the bundled YAML files are present on disk. This change: - Threads `cwd: string | null` through `discoverWorkflows` and `discoverWorkflowsWithConfig`. When `cwd` is `null` the discovery function loads bundled + home scopes and skips the project step cleanly (no path-join with an empty cwd, no read-error noise). - Removes the early-return in the GET handler. When no project context exists, it now calls `discoverWorkflowsWithConfig(null, ...)` so the response carries the bundled set instead of `[]`. - Distinguishes the empty-state copy in `WorkflowList` so the rare case where the list is genuinely empty reads correctly. With a project selected: "No workflows found in this project. Add workflow definitions to .archon/workflows/ in the project root." Without a project: "No workflows are available. Bundled defaults should appear here automatically; if they do not, check that `defaults.loadDefaultWorkflows` is enabled in your config." Tests cover both the API (new `falls back to null cwd when no cwd query and no codebases registered` case in `api.workflows.test.ts`) and the discovery layer (new `discoverWorkflows with null cwd` block in `loader.test.ts` asserting no project-source entries and no project-step read errors). * test(workflows): assert bundled defaults surface when cwd is null The second test in the null-cwd discovery group only verified that project-source workflows are absent. That assertion would still pass if the bundled-defaults loader silently regressed. Add an explicit `bundled` source-label assertion so the test catches that regression directly. * fix(workflows): address review on #1618 - api.md: document cwd-omitted behavior so the empty-state case is discoverable - workflow-discovery.ts: docstring explains loadDefaults default rather than just naming the skipped branch - api.workflows.test.ts: mockDiscoverWorkflows accepts string | null to match the wider signature - api.ts: drop trailing period inside the multi-line inline comment - CHANGELOG.md: add the #1173 Fixed entry under [Unreleased] - loader.test.ts: add a regression test that asserts loadConfig is not invoked when cwd is null * test(workflows): tighten null-cwd assertions + drop rot-prone refs Address Wirasm's polish review on #1618: - loader.test.ts: assert result.workflows.length === 0 in the loadDefaults:false case so the test no longer passes if bundled defaults are accidentally loaded. - loader.test.ts: drop the inline (issue #1173) reference and the workflow-discovery.ts file+line pointer from the two comments that risk rotting on future refactor. - api.workflows.test.ts: drop the inline (issue #1173) reference. - CHANGELOG: append positive framing to the #1173 line so the entry reads as "what works now" not just "what no longer breaks".
1 parent 7aafcfd commit 3d290d8

7 files changed

Lines changed: 126 additions & 21 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ Workflow marketplace, expanded setup wizard, and broad Pi/workflow engine fixes.
3838
- **Pi concurrency + error surfacing**: SDK error messages now surface to the user instead of being masked, and Pi concurrency is capped to prevent cascade failures (#1572).
3939
- Chat hydration shows newest messages instead of oldest (#1532).
4040
- `GET /api/workflows/:name` now resolves home-scoped (`~/.archon/workflows/`) workflows that were previously invisible to the Web UI builder (#1405).
41+
- `GET /api/workflows` no longer returns an empty array when no `cwd` query param is provided and no codebases are registered — bundled and home-scoped workflows now surface correctly on first run, making the workflow picker functional on first launch before any project is registered (#1173).
4142
- `archon workflow run` propagates `$ARTIFACTS_DIR`, `$LOG_DIR`, `$BASE_BRANCH` to script-node subprocesses (#1640).
4243
- `archon-assist` now runs in the live checkout (`worktree.enabled: false`) — closes #1546 (#1555).
4344
- Bundled `opus[1m]` implement nodes now set `provider: claude` explicitly (#1622).

packages/docs-web/src/content/docs/reference/api.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,8 @@ curl http://localhost:3090/api/workflows
204204
Query parameters:
205205
- `cwd` (optional) -- Working directory to discover project-specific workflows
206206

207+
When `cwd` is omitted, Archon returns bundled default workflows and any from `~/.archon/workflows/` (home-scoped). Project-specific workflows require either the `cwd` query param or a registered codebase, so the endpoint is useful on first launch before any project is registered.
208+
207209
Returns `{ workflows: [...], errors?: [...] }`. The `errors` array contains any YAML parsing failures encountered during discovery.
208210

209211
#### Get a Workflow

packages/server/src/routes/api.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1760,7 +1760,7 @@ export function registerApiRoutes(
17601760
registerOpenApiRoute(getWorkflowsRoute, async c => {
17611761
try {
17621762
const cwd = c.req.query('cwd');
1763-
let workingDir = cwd;
1763+
let workingDir: string | undefined = cwd;
17641764

17651765
// Validate caller-supplied cwd against registered codebase paths
17661766
if (cwd) {
@@ -1775,11 +1775,11 @@ export function registerApiRoutes(
17751775
}
17761776
}
17771777

1778-
if (!workingDir) {
1779-
return c.json({ workflows: [] });
1780-
}
1781-
1782-
const result = await discoverWorkflowsWithConfig(workingDir, loadConfig);
1778+
// No project context (no cwd query param and no registered codebases) —
1779+
// pass null to discovery so it returns bundled + home-scoped workflows.
1780+
// This avoids a misleading empty state on first run, before any project
1781+
// is registered, when bundled defaults are present
1782+
const result = await discoverWorkflowsWithConfig(workingDir ?? null, loadConfig);
17831783
return c.json({
17841784
workflows: result.workflows.map(ws => ({ workflow: ws.workflow, source: ws.source })),
17851785
errors: result.errors.length > 0 ? result.errors : undefined,

packages/server/src/routes/api.workflows.test.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ function createTestApp(): OpenAPIHono {
1313
return new OpenAPIHono({ defaultHook: validationErrorHook });
1414
}
1515

16-
const mockDiscoverWorkflows = mock(async (_cwd: string) => ({
16+
const mockDiscoverWorkflows = mock(async (_cwd: string | null) => ({
1717
workflows: [makeTestWorkflowWithSource({ name: 'deploy', description: 'Deploy app' }, 'bundled')],
1818
errors: [
1919
{ filename: '/tmp/.archon/workflows/bad.md', error: 'invalid', errorType: 'parse_error' },
@@ -120,6 +120,30 @@ describe('GET /api/workflows', () => {
120120
expect(body.errors).toBeDefined();
121121
expect(Array.isArray(body.errors)).toBe(true);
122122
});
123+
124+
test('falls back to null cwd when no cwd query and no codebases registered', async () => {
125+
const app = createTestApp();
126+
registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager);
127+
128+
// No registered codebases → handler should call discovery with null cwd
129+
// so bundled + home-scoped workflows still surface.
130+
mockListCodebases.mockImplementationOnce(async () => []);
131+
132+
const response = await app.request('/api/workflows');
133+
expect(response.status).toBe(200);
134+
135+
const body = (await response.json()) as {
136+
workflows: Array<{ workflow: { name: string }; source: string }>;
137+
};
138+
139+
// Discovery is invoked with null (not skipped), so bundled defaults can surface.
140+
expect(mockDiscoverWorkflows).toHaveBeenLastCalledWith(null, expect.any(Function));
141+
// The mocked discovery returns one bundled workflow regardless of cwd, so the
142+
// response is non-empty — proving the handler no longer short-circuits on no-cwd.
143+
expect(Array.isArray(body.workflows)).toBe(true);
144+
expect(body.workflows.length).toBeGreaterThan(0);
145+
expect(body.workflows[0]?.source).toBe('bundled');
146+
});
123147
});
124148

125149
describe('POST /api/workflows/validate', () => {

packages/web/src/components/workflows/WorkflowList.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,8 +174,24 @@ export function WorkflowList(): React.ReactElement {
174174
{/* Workflow grid */}
175175
{!hasWorkflows ? (
176176
<div className="text-sm text-text-secondary">
177-
No workflows found. Add workflow definitions to{' '}
178-
<code className="text-xs bg-surface-inset px-1 py-0.5 rounded">.archon/workflows/</code>
177+
{localProjectId ? (
178+
<>
179+
No workflows found in this project. Add workflow definitions to{' '}
180+
<code className="text-xs bg-surface-inset px-1 py-0.5 rounded">
181+
.archon/workflows/
182+
</code>{' '}
183+
in the project root.
184+
</>
185+
) : (
186+
<>
187+
No workflows are available. Bundled defaults should appear here automatically; if
188+
they do not, check that{' '}
189+
<code className="text-xs bg-surface-inset px-1 py-0.5 rounded">
190+
defaults.loadDefaultWorkflows
191+
</code>{' '}
192+
is enabled in your config.
193+
</>
194+
)}
179195
</div>
180196
) : filteredWorkflows.length === 0 ? (
181197
<div className="text-sm text-text-secondary py-8 text-center">

packages/workflows/src/loader.test.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import { registerBuiltinProviders, clearRegistry } from '@archon/providers';
3333
clearRegistry();
3434
registerBuiltinProviders();
3535

36-
import { discoverWorkflows } from './workflow-discovery';
36+
import { discoverWorkflows, discoverWorkflowsWithConfig } from './workflow-discovery';
3737
import { isBashNode, isCancelNode, isLoopNode } from './schemas';
3838
import * as bundledDefaults from './defaults/bundled-defaults';
3939

@@ -2512,4 +2512,47 @@ nodes:
25122512
expect(mockLogger.warn).toHaveBeenCalled();
25132513
});
25142514
});
2515+
2516+
describe('discoverWorkflows with null cwd (no project context)', () => {
2517+
it('skips project scope and returns no project-source workflows', async () => {
2518+
// When no codebase is registered the LIST endpoint passes null so bundled
2519+
// + home scopes can still surface. Discovery must not attempt to read a
2520+
// cwd-derived path and must not produce project-source entries.
2521+
const result = await discoverWorkflows(null, { loadDefaults: false });
2522+
2523+
// loadDefaults:false skips bundled and a clean test env has no home-
2524+
// scoped workflows, so the full result must be empty — without this the
2525+
// test would pass even if a stray project-path read were silently injected.
2526+
expect(result.workflows).toHaveLength(0);
2527+
2528+
const projectSourced = result.workflows.filter(w => w.source === 'project');
2529+
expect(projectSourced).toHaveLength(0);
2530+
2531+
// No project-step file/dir read errors — we never tried to access a project path.
2532+
const readErrors = result.errors.filter(e => e.errorType === 'read_error');
2533+
expect(readErrors).toHaveLength(0);
2534+
});
2535+
2536+
it('still loads bundled defaults when loadDefaults:true and cwd is null', async () => {
2537+
const result = await discoverWorkflows(null, { loadDefaults: true });
2538+
2539+
// No project-source entries (project step skipped).
2540+
const projectSourced = result.workflows.filter(w => w.source === 'project');
2541+
expect(projectSourced).toHaveLength(0);
2542+
2543+
// Bundled-source entries must surface — without this assertion the test
2544+
// would silently pass even if the bundled-defaults loader regressed.
2545+
const bundledSourced = result.workflows.filter(w => w.source === 'bundled');
2546+
expect(bundledSourced.length).toBeGreaterThan(0);
2547+
});
2548+
2549+
it('discoverWorkflowsWithConfig does not call loadConfig when cwd is null', async () => {
2550+
// The per-project config opt-out must not be evaluated when there is no
2551+
// project context — running loadConfig with no cwd would silently apply
2552+
// home-dir or working-dir defaults to a request that has neither.
2553+
const mockLoadConfig = mock(async () => ({ defaults: { loadDefaultWorkflows: true } }));
2554+
await discoverWorkflowsWithConfig(null, mockLoadConfig);
2555+
expect(mockLoadConfig).not.toHaveBeenCalled();
2556+
});
2557+
});
25152558
});

packages/workflows/src/workflow-discovery.ts

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,8 @@ function loadBundledWorkflows(): DirLoadResult {
192192
* 2. Home-scoped `~/.archon/workflows/` — classified as `source: 'global'`.
193193
* No caller option: every caller gets home-scoped discovery for free.
194194
* 3. Repo-scoped `<cwd>/.archon/workflows/` — classified as `source: 'project'`.
195+
* Skipped when `cwd` is `null` (no project context — e.g. fresh deployment
196+
* where no codebase has been registered yet).
195197
*
196198
* When running as a compiled binary, bundled defaults are loaded from embedded
197199
* content. In source/dev mode they're loaded from the filesystem.
@@ -201,7 +203,7 @@ function loadBundledWorkflows(): DirLoadResult {
201203
* location is not read — users must migrate manually.
202204
*/
203205
export async function discoverWorkflows(
204-
cwd: string,
206+
cwd: string | null,
205207
options?: { loadDefaults?: boolean }
206208
): Promise<WorkflowLoadResult> {
207209
// Map of filename -> workflow+source for deduplication
@@ -274,7 +276,18 @@ export async function discoverWorkflows(
274276
}
275277
}
276278

277-
// 3. Load from repo's workflow folder (overrides app defaults AND home scope by exact filename)
279+
// 3. Load from repo's workflow folder (overrides app defaults AND home scope by exact filename).
280+
// Skipped when cwd is null — surfaces bundled + home scopes only, which is the right answer
281+
// for callers without a project context (e.g. UI listing workflows before any codebase is registered).
282+
if (cwd === null) {
283+
const workflows = Array.from(workflowsByFile.values());
284+
getLog().info(
285+
{ count: workflows.length, errorCount: allErrors.length, scope: 'no_project_context' },
286+
'workflows_discovery_completed'
287+
);
288+
return { workflows, errors: allErrors };
289+
}
290+
278291
const [workflowFolder] = archonPaths.getWorkflowFolderSearchPaths();
279292
const workflowPath = join(cwd, workflowFolder);
280293

@@ -353,20 +366,26 @@ export async function discoverWorkflows(
353366
* Wraps discoverWorkflows with the standard pattern: try loadConfig to read
354367
* defaults.loadDefaultWorkflows, fall back to true on config load failure.
355368
* Logs config failures at warn level for observability.
369+
*
370+
* When `cwd` is `null` (no project context), `loadConfig` is not invoked and
371+
* `loadDefaults` keeps its initial value of `true`. The per-project opt-out
372+
* is a project-scoped setting; without a project there is no config to read.
356373
*/
357374
export async function discoverWorkflowsWithConfig(
358-
cwd: string,
375+
cwd: string | null,
359376
loadConfig: (cwd: string) => Promise<{ defaults?: { loadDefaultWorkflows?: boolean } }>
360377
): Promise<WorkflowLoadResult> {
361378
let loadDefaults = true;
362-
try {
363-
const cfg = await loadConfig(cwd);
364-
loadDefaults = cfg.defaults?.loadDefaultWorkflows ?? true;
365-
} catch (error) {
366-
getLog().warn(
367-
{ err: error as Error, cwd },
368-
'config_load_failed_using_default_workflow_discovery'
369-
);
379+
if (cwd !== null) {
380+
try {
381+
const cfg = await loadConfig(cwd);
382+
loadDefaults = cfg.defaults?.loadDefaultWorkflows ?? true;
383+
} catch (error) {
384+
getLog().warn(
385+
{ err: error as Error, cwd },
386+
'config_load_failed_using_default_workflow_discovery'
387+
);
388+
}
370389
}
371390
return discoverWorkflows(cwd, { loadDefaults });
372391
}

0 commit comments

Comments
 (0)