Skip to content

Commit e72ff32

Browse files
committed
fix(server/workflows): resolve project workflows with .yml extension
`GET /api/workflows/:name` and `DELETE /api/workflows/:name` constructed the on-disk filename as `${name}.yaml` and only tried that one extension. The discovery scanner in `workflow-discovery.ts` (the source for `GET /api/workflows`) accepts both `.yaml` and `.yml`, so a project that named its workflow `phase-0-spike.yml` would appear in the list endpoint but 404 on the detail endpoint and could not be deleted via the API. This is the same scope-mismatch class as the trailing-newline / case-sensitivity bugs that crop up whenever two endpoints derive paths independently — fix it once at the read site rather than forcing every author to rename files. Changes: - GET /api/workflows/:name now probes `${name}.yaml` then `${name}.yml` in each scope (project, home, default). Extracted as a small inline helper so the same logic isn't duplicated three times. - DELETE /api/workflows/:name probes both extensions before returning 404. - PUT (save) is unchanged: writes continue to use `.yaml` as the canonical extension for newly-created workflows. Existing `.yml` files remain readable and deletable. - Bundled defaults are still keyed on `${name}.yaml` (the suffix is a synthetic in-memory key there, not a filesystem path). Tests: - Added regression: `phase-0-spike.yml` in `<cwd>/.archon/workflows/` returns 200 with `source: project` and `filename: phase-0-spike.yml`. - All 36 tests in `api.workflows.test.ts` pass (was 35). - `tsc --noEmit` on `packages/server` is clean.
1 parent 7bdf931 commit e72ff32

2 files changed

Lines changed: 103 additions & 54 deletions

File tree

packages/server/src/routes/api.ts

Lines changed: 74 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -2254,6 +2254,25 @@ export function registerApiRoutes(
22542254
return apiError(c, 400, 'Invalid workflow name');
22552255
}
22562256

2257+
// Workflow files may use `.yaml` or `.yml` (the discovery scanner accepts
2258+
// both — `workflow-discovery.ts`'s readdir filter at line 119). Mirror
2259+
// that here so the by-name lookup doesn't 404 on `.yml` files like
2260+
// `phase-0-spike.yml` that the list endpoint already returns.
2261+
const tryReadWorkflowAt = async (
2262+
dir: string
2263+
): Promise<{ filename: string; content: string } | null> => {
2264+
for (const ext of ['yaml', 'yml']) {
2265+
const filename = `${name}.${ext}`;
2266+
try {
2267+
const content = await readFile(join(dir, filename), 'utf-8');
2268+
return { filename, content };
2269+
} catch (err) {
2270+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
2271+
}
2272+
}
2273+
return null;
2274+
};
2275+
22572276
try {
22582277
const cwd = c.req.query('cwd');
22592278
let workingDir = cwd;
@@ -2266,82 +2285,80 @@ export function registerApiRoutes(
22662285
if (codebases.length > 0) workingDir = codebases[0].default_cwd;
22672286
}
22682287

2269-
const filename = `${name}.yaml`;
2270-
22712288
// 1. Try user-defined workflow in cwd.
22722289
if (workingDir) {
22732290
const [workflowFolder] = getWorkflowFolderSearchPaths();
2274-
const filePath = join(workingDir, workflowFolder, filename);
22752291
try {
2276-
const content = await readFile(filePath, 'utf-8');
2277-
const result = parseWorkflow(content, filename);
2278-
if (result.error) {
2279-
return apiError(c, 500, `Workflow file is invalid: ${result.error.error}`);
2292+
const hit = await tryReadWorkflowAt(join(workingDir, workflowFolder));
2293+
if (hit) {
2294+
const result = parseWorkflow(hit.content, hit.filename);
2295+
if (result.error) {
2296+
return apiError(c, 500, `Workflow file is invalid: ${result.error.error}`);
2297+
}
2298+
return c.json({
2299+
workflow: result.workflow,
2300+
filename: hit.filename,
2301+
source: 'project' as WorkflowSource,
2302+
});
22802303
}
2281-
return c.json({
2282-
workflow: result.workflow,
2283-
filename,
2284-
source: 'project' as WorkflowSource,
2285-
});
22862304
} catch (err) {
2287-
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
2288-
getLog().error({ err, name }, 'workflow.fetch_failed');
2289-
return apiError(c, 500, 'Failed to read workflow');
2290-
}
2305+
getLog().error({ err, name }, 'workflow.fetch_failed');
2306+
return apiError(c, 500, 'Failed to read workflow');
22912307
}
22922308
}
22932309

22942310
// 2. Fall back to home-scoped workflow (`~/.archon/workflows/`).
22952311
// Mirrors the discovery order in `discoverWorkflowsWithConfig`.
2296-
{
2297-
const homeFilePath = join(getHomeWorkflowsPath(), filename);
2298-
try {
2299-
const content = await readFile(homeFilePath, 'utf-8');
2300-
const result = parseWorkflow(content, filename);
2312+
try {
2313+
const hit = await tryReadWorkflowAt(getHomeWorkflowsPath());
2314+
if (hit) {
2315+
const result = parseWorkflow(hit.content, hit.filename);
23012316
if (result.error) {
23022317
return apiError(c, 500, `Home workflow file is invalid: ${result.error.error}`);
23032318
}
23042319
return c.json({
23052320
workflow: result.workflow,
2306-
filename,
2321+
filename: hit.filename,
23072322
source: 'global' as WorkflowSource,
23082323
});
2309-
} catch (err) {
2310-
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
2311-
getLog().error({ err, name }, 'workflow.fetch_home_failed');
2312-
return apiError(c, 500, 'Failed to read home-scoped workflow');
2313-
}
23142324
}
2325+
} catch (err) {
2326+
getLog().error({ err, name }, 'workflow.fetch_home_failed');
2327+
return apiError(c, 500, 'Failed to read home-scoped workflow');
23152328
}
23162329

23172330
// 3. Fall back to bundled defaults.
23182331
if (Object.hasOwn(BUNDLED_WORKFLOWS, name)) {
2332+
const bundledFilename = `${name}.yaml`;
23192333
const bundledContent = BUNDLED_WORKFLOWS[name];
2320-
const result = parseWorkflow(bundledContent, filename);
2334+
const result = parseWorkflow(bundledContent, bundledFilename);
23212335
if (result.error) {
23222336
return apiError(c, 500, `Bundled workflow is invalid: ${result.error.error}`);
23232337
}
2324-
return c.json({ workflow: result.workflow, filename, source: 'bundled' as WorkflowSource });
2338+
return c.json({
2339+
workflow: result.workflow,
2340+
filename: bundledFilename,
2341+
source: 'bundled' as WorkflowSource,
2342+
});
23252343
}
23262344

23272345
if (!isBinaryBuild()) {
2328-
const defaultFilePath = join(getDefaultWorkflowsPath(), filename);
23292346
try {
2330-
const content = await readFile(defaultFilePath, 'utf-8');
2331-
const result = parseWorkflow(content, filename);
2332-
if (result.error) {
2333-
return apiError(c, 500, `Default workflow is invalid: ${result.error.error}`);
2347+
const hit = await tryReadWorkflowAt(getDefaultWorkflowsPath());
2348+
if (hit) {
2349+
const result = parseWorkflow(hit.content, hit.filename);
2350+
if (result.error) {
2351+
return apiError(c, 500, `Default workflow is invalid: ${result.error.error}`);
2352+
}
2353+
return c.json({
2354+
workflow: result.workflow,
2355+
filename: hit.filename,
2356+
source: 'bundled' as WorkflowSource,
2357+
});
23342358
}
2335-
return c.json({
2336-
workflow: result.workflow,
2337-
filename,
2338-
source: 'bundled' as WorkflowSource,
2339-
});
23402359
} catch (err) {
2341-
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
2342-
getLog().error({ err, name }, 'workflow.fetch_default_failed');
2343-
return apiError(c, 500, 'Failed to read default workflow');
2344-
}
2360+
getLog().error({ err, name }, 'workflow.fetch_default_failed');
2361+
return apiError(c, 500, 'Failed to read default workflow');
23452362
}
23462363
}
23472364

@@ -2452,21 +2469,24 @@ export function registerApiRoutes(
24522469
workingDir = getArchonHome();
24532470
}
24542471

2455-
const filePath =
2472+
const dir =
24562473
targetSource === 'global'
2457-
? join(getHomeWorkflowsPath(), `${name}.yaml`)
2458-
: join(workingDir, getWorkflowFolderSearchPaths()[0], `${name}.yaml`);
2474+
? getHomeWorkflowsPath()
2475+
: join(workingDir, getWorkflowFolderSearchPaths()[0]);
24592476

2460-
try {
2461-
await unlink(filePath);
2462-
return c.json({ deleted: true, name });
2463-
} catch (err) {
2464-
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
2465-
return apiError(c, 404, `Workflow not found: ${name}`);
2477+
// Try both `.yaml` and `.yml` extensions to match discovery semantics.
2478+
for (const ext of ['yaml', 'yml']) {
2479+
const filePath = join(dir, `${name}.${ext}`);
2480+
try {
2481+
await unlink(filePath);
2482+
return c.json({ deleted: true, name });
2483+
} catch (err) {
2484+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') continue;
2485+
getLog().error({ err, name }, 'workflow.delete_failed');
2486+
return apiError(c, 500, 'Failed to delete workflow');
24662487
}
2467-
getLog().error({ err, name }, 'workflow.delete_failed');
2468-
return apiError(c, 500, 'Failed to delete workflow');
24692488
}
2489+
return apiError(c, 404, `Workflow not found: ${name}`);
24702490
});
24712491

24722492
// GET /api/commands - List available command names for the workflow node palette

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,35 @@ describe('GET /api/workflows/:name', () => {
277277
}
278278
});
279279

280+
test('returns project workflow when file uses .yml extension (matches discovery)', async () => {
281+
const testDir = join(tmpdir(), `wf-yml-test-${Date.now()}`);
282+
const workflowDir = join(testDir, '.archon', 'workflows');
283+
await mkdir(workflowDir, { recursive: true });
284+
await writeFile(
285+
join(workflowDir, 'phase-0-spike.yml'),
286+
'name: phase-0-spike\ndescription: Spike\nnodes:\n - id: plan\n command: plan\n'
287+
);
288+
289+
try {
290+
const app = createTestApp();
291+
registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager);
292+
293+
mockListCodebases.mockImplementationOnce(async () => [{ default_cwd: testDir }]);
294+
const response = await app.request(`/api/workflows/phase-0-spike?cwd=${testDir}`);
295+
expect(response.status).toBe(200);
296+
const body = (await response.json()) as {
297+
source: string;
298+
filename: string;
299+
workflow: { name: string };
300+
};
301+
expect(body.source).toBe('project');
302+
expect(body.filename).toBe('phase-0-spike.yml');
303+
expect(body.workflow).toBeDefined();
304+
} finally {
305+
await rm(testDir, { recursive: true, force: true });
306+
}
307+
});
308+
280309
test('returns home-scoped workflow with source:global when project/bundled miss', async () => {
281310
const tmpHome = join(tmpdir(), `wf-home-test-${Date.now()}`);
282311
const homeWorkflowsDir = join(tmpHome, 'workflows');

0 commit comments

Comments
 (0)