Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ Workflow marketplace, expanded setup wizard, and broad Pi/workflow engine fixes.
- **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).
- Chat hydration shows newest messages instead of oldest (#1532).
- `GET /api/workflows/:name` now resolves home-scoped (`~/.archon/workflows/`) workflows that were previously invisible to the Web UI builder (#1405).
- `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).
- `archon workflow run` propagates `$ARTIFACTS_DIR`, `$LOG_DIR`, `$BASE_BRANCH` to script-node subprocesses (#1640).
- `archon-assist` now runs in the live checkout (`worktree.enabled: false`) — closes #1546 (#1555).
- Bundled `opus[1m]` implement nodes now set `provider: claude` explicitly (#1622).
Expand Down
2 changes: 2 additions & 0 deletions packages/docs-web/src/content/docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,8 @@ curl http://localhost:3090/api/workflows
Query parameters:
- `cwd` (optional) -- Working directory to discover project-specific workflows

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.

Returns `{ workflows: [...], errors?: [...] }`. The `errors` array contains any YAML parsing failures encountered during discovery.

#### Get a Workflow
Expand Down
12 changes: 6 additions & 6 deletions packages/server/src/routes/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1760,7 +1760,7 @@ export function registerApiRoutes(
registerOpenApiRoute(getWorkflowsRoute, async c => {
try {
const cwd = c.req.query('cwd');
let workingDir = cwd;
let workingDir: string | undefined = cwd;

// Validate caller-supplied cwd against registered codebase paths
if (cwd) {
Expand All @@ -1775,11 +1775,11 @@ export function registerApiRoutes(
}
}

if (!workingDir) {
return c.json({ workflows: [] });
}

const result = await discoverWorkflowsWithConfig(workingDir, loadConfig);
// No project context (no cwd query param and no registered codebases) —
// pass null to discovery so it returns bundled + home-scoped workflows.
// This avoids a misleading empty state on first run, before any project
// is registered, when bundled defaults are present
const result = await discoverWorkflowsWithConfig(workingDir ?? null, loadConfig);
return c.json({
workflows: result.workflows.map(ws => ({ workflow: ws.workflow, source: ws.source })),
errors: result.errors.length > 0 ? result.errors : undefined,
Expand Down
26 changes: 25 additions & 1 deletion packages/server/src/routes/api.workflows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ function createTestApp(): OpenAPIHono {
return new OpenAPIHono({ defaultHook: validationErrorHook });
}

const mockDiscoverWorkflows = mock(async (_cwd: string) => ({
const mockDiscoverWorkflows = mock(async (_cwd: string | null) => ({
workflows: [makeTestWorkflowWithSource({ name: 'deploy', description: 'Deploy app' }, 'bundled')],
errors: [
{ filename: '/tmp/.archon/workflows/bad.md', error: 'invalid', errorType: 'parse_error' },
Expand Down Expand Up @@ -120,6 +120,30 @@ describe('GET /api/workflows', () => {
expect(body.errors).toBeDefined();
expect(Array.isArray(body.errors)).toBe(true);
});

test('falls back to null cwd when no cwd query and no codebases registered', async () => {
const app = createTestApp();
registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager);

// No registered codebases → handler should call discovery with null cwd
// so bundled + home-scoped workflows still surface.
mockListCodebases.mockImplementationOnce(async () => []);

const response = await app.request('/api/workflows');
expect(response.status).toBe(200);

const body = (await response.json()) as {
workflows: Array<{ workflow: { name: string }; source: string }>;
};

// Discovery is invoked with null (not skipped), so bundled defaults can surface.
expect(mockDiscoverWorkflows).toHaveBeenLastCalledWith(null, expect.any(Function));
// The mocked discovery returns one bundled workflow regardless of cwd, so the
// response is non-empty — proving the handler no longer short-circuits on no-cwd.
expect(Array.isArray(body.workflows)).toBe(true);
expect(body.workflows.length).toBeGreaterThan(0);
expect(body.workflows[0]?.source).toBe('bundled');
});
});

describe('POST /api/workflows/validate', () => {
Expand Down
20 changes: 18 additions & 2 deletions packages/web/src/components/workflows/WorkflowList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,8 +174,24 @@ export function WorkflowList(): React.ReactElement {
{/* Workflow grid */}
{!hasWorkflows ? (
<div className="text-sm text-text-secondary">
No workflows found. Add workflow definitions to{' '}
<code className="text-xs bg-surface-inset px-1 py-0.5 rounded">.archon/workflows/</code>
{localProjectId ? (
<>
No workflows found in this project. Add workflow definitions to{' '}
<code className="text-xs bg-surface-inset px-1 py-0.5 rounded">
.archon/workflows/
</code>{' '}
in the project root.
</>
) : (
<>
No workflows are available. Bundled defaults should appear here automatically; if
they do not, check that{' '}
<code className="text-xs bg-surface-inset px-1 py-0.5 rounded">
defaults.loadDefaultWorkflows
</code>{' '}
is enabled in your config.
</>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)}
</div>
) : filteredWorkflows.length === 0 ? (
<div className="text-sm text-text-secondary py-8 text-center">
Expand Down
45 changes: 44 additions & 1 deletion packages/workflows/src/loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import { registerBuiltinProviders, clearRegistry } from '@archon/providers';
clearRegistry();
registerBuiltinProviders();

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

Expand Down Expand Up @@ -2512,4 +2512,47 @@ nodes:
expect(mockLogger.warn).toHaveBeenCalled();
});
});

describe('discoverWorkflows with null cwd (no project context)', () => {
it('skips project scope and returns no project-source workflows', async () => {
// When no codebase is registered the LIST endpoint passes null so bundled
// + home scopes can still surface. Discovery must not attempt to read a
// cwd-derived path and must not produce project-source entries.
const result = await discoverWorkflows(null, { loadDefaults: false });

// loadDefaults:false skips bundled and a clean test env has no home-
// scoped workflows, so the full result must be empty — without this the
// test would pass even if a stray project-path read were silently injected.
expect(result.workflows).toHaveLength(0);

const projectSourced = result.workflows.filter(w => w.source === 'project');
expect(projectSourced).toHaveLength(0);

// No project-step file/dir read errors — we never tried to access a project path.
const readErrors = result.errors.filter(e => e.errorType === 'read_error');
expect(readErrors).toHaveLength(0);
});

it('still loads bundled defaults when loadDefaults:true and cwd is null', async () => {
const result = await discoverWorkflows(null, { loadDefaults: true });

// No project-source entries (project step skipped).
const projectSourced = result.workflows.filter(w => w.source === 'project');
expect(projectSourced).toHaveLength(0);

// Bundled-source entries must surface — without this assertion the test
// would silently pass even if the bundled-defaults loader regressed.
const bundledSourced = result.workflows.filter(w => w.source === 'bundled');
expect(bundledSourced.length).toBeGreaterThan(0);
});

it('discoverWorkflowsWithConfig does not call loadConfig when cwd is null', async () => {
// The per-project config opt-out must not be evaluated when there is no
// project context — running loadConfig with no cwd would silently apply
// home-dir or working-dir defaults to a request that has neither.
const mockLoadConfig = mock(async () => ({ defaults: { loadDefaultWorkflows: true } }));
await discoverWorkflowsWithConfig(null, mockLoadConfig);
expect(mockLoadConfig).not.toHaveBeenCalled();
});
});
});
41 changes: 30 additions & 11 deletions packages/workflows/src/workflow-discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,8 @@ function loadBundledWorkflows(): DirLoadResult {
* 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'`.
* Skipped when `cwd` is `null` (no project context — e.g. fresh deployment
* where no codebase has been registered yet).
*
* When running as a compiled binary, bundled defaults are loaded from embedded
* content. In source/dev mode they're loaded from the filesystem.
Expand All @@ -201,7 +203,7 @@ function loadBundledWorkflows(): DirLoadResult {
* location is not read — users must migrate manually.
*/
export async function discoverWorkflows(
cwd: string,
cwd: string | null,
options?: { loadDefaults?: boolean }
): Promise<WorkflowLoadResult> {
// Map of filename -> workflow+source for deduplication
Expand Down Expand Up @@ -274,7 +276,18 @@ export async function discoverWorkflows(
}
}

// 3. Load from repo's workflow folder (overrides app defaults AND home scope by exact filename)
// 3. Load from repo's workflow folder (overrides app defaults AND home scope by exact filename).
// Skipped when cwd is null — surfaces bundled + home scopes only, which is the right answer
// for callers without a project context (e.g. UI listing workflows before any codebase is registered).
if (cwd === null) {
const workflows = Array.from(workflowsByFile.values());
getLog().info(
{ count: workflows.length, errorCount: allErrors.length, scope: 'no_project_context' },
'workflows_discovery_completed'
);
return { workflows, errors: allErrors };
}

const [workflowFolder] = archonPaths.getWorkflowFolderSearchPaths();
const workflowPath = join(cwd, workflowFolder);

Expand Down Expand Up @@ -353,20 +366,26 @@ export async function discoverWorkflows(
* Wraps discoverWorkflows with the standard pattern: try loadConfig to read
* defaults.loadDefaultWorkflows, fall back to true on config load failure.
* Logs config failures at warn level for observability.
*
* When `cwd` is `null` (no project context), `loadConfig` is not invoked and
* `loadDefaults` keeps its initial value of `true`. The per-project opt-out
* is a project-scoped setting; without a project there is no config to read.
*/
export async function discoverWorkflowsWithConfig(
cwd: string,
cwd: string | null,
loadConfig: (cwd: string) => Promise<{ defaults?: { loadDefaultWorkflows?: boolean } }>
): Promise<WorkflowLoadResult> {
let loadDefaults = true;
try {
const cfg = await loadConfig(cwd);
loadDefaults = cfg.defaults?.loadDefaultWorkflows ?? true;
} catch (error) {
getLog().warn(
{ err: error as Error, cwd },
'config_load_failed_using_default_workflow_discovery'
);
if (cwd !== null) {
try {
const cfg = await loadConfig(cwd);
loadDefaults = cfg.defaults?.loadDefaultWorkflows ?? true;
} catch (error) {
getLog().warn(
{ err: error as Error, cwd },
'config_load_failed_using_default_workflow_discovery'
);
}
}
return discoverWorkflows(cwd, { loadDefaults });
}
Loading