Skip to content
Open
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
67 changes: 56 additions & 11 deletions packages/server/src/routes/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
getDefaultWorkflowsPath,
getArchonWorkspacesPath,
getHomeCommandsPath,
getHomeWorkflowsPath,
getRunArtifactsPath,
getArchonHome,
isDocker,
Expand Down Expand Up @@ -155,6 +156,9 @@ function jsonError(description: string): {
}

const cwdQuerySchema = z.object({ cwd: z.string().optional() });
const workflowTargetQuerySchema = cwdQuerySchema.extend({
source: z.enum(['project', 'global']).optional(),
});

const getWorkflowsRoute = createRoute({
method: 'get',
Expand Down Expand Up @@ -220,7 +224,7 @@ const saveWorkflowRoute = createRoute({
summary: 'Save (create or update) a workflow',
request: {
params: z.object({ name: z.string() }),
query: cwdQuerySchema,
query: workflowTargetQuerySchema,
body: { content: { 'application/json': { schema: saveWorkflowBodySchema } }, required: true },
},
responses: {
Expand All @@ -240,7 +244,7 @@ const deleteWorkflowRoute = createRoute({
summary: 'Delete a user-defined workflow',
request: {
params: z.object({ name: z.string() }),
query: cwdQuerySchema,
query: workflowTargetQuerySchema,
},
responses: {
200: {
Expand Down Expand Up @@ -2260,7 +2264,29 @@ export function registerApiRoutes(
}
}

// 2. Fall back to bundled defaults (binary: embedded map; dev: also check filesystem)
// 2. Try home-scoped workflows (~/.archon/workflows). List discovery
// includes these as source:global, so the detail/edit endpoint must
// resolve them too.
const globalFilePath = join(getHomeWorkflowsPath(), filename);
try {
const content = await readFile(globalFilePath, 'utf-8');
const result = parseWorkflow(content, filename);
if (result.error) {
return apiError(c, 500, `Global workflow is invalid: ${result.error.error}`);
}
return c.json({
workflow: result.workflow,
filename,
source: 'global' as WorkflowSource,
});
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
getLog().error({ err, name }, 'workflow.fetch_global_failed');
return apiError(c, 500, 'Failed to read global workflow');
}
}

// 3. Fall back to bundled defaults (binary: embedded map; dev: also check filesystem)
if (Object.hasOwn(BUNDLED_WORKFLOWS, name)) {
const bundledContent = BUNDLED_WORKFLOWS[name];
const result = parseWorkflow(bundledContent, filename);
Expand Down Expand Up @@ -2306,9 +2332,16 @@ export function registerApiRoutes(
return apiError(c, 400, 'Invalid workflow name');
}

const targetSource = c.req.query('source');
if (targetSource && targetSource !== 'project' && targetSource !== 'global') {
return apiError(c, 400, 'Invalid workflow source');
}

const cwd = c.req.query('cwd');
let workingDir = cwd;
if (cwd) {
if (targetSource === 'global') {
workingDir = undefined;
} else if (cwd) {
if (!(await validateCwd(cwd))) {
return apiError(c, 400, 'Invalid cwd: must match a registered codebase path');
}
Expand Down Expand Up @@ -2338,15 +2371,18 @@ export function registerApiRoutes(
}

try {
const [workflowFolder] = getWorkflowFolderSearchPaths();
const dirPath = join(workingDir, workflowFolder);
const source: WorkflowSource = targetSource === 'global' ? 'global' : 'project';
const dirPath =
source === 'global'
? getHomeWorkflowsPath()
: join(workingDir, getWorkflowFolderSearchPaths()[0]);
await mkdir(dirPath, { recursive: true });
const filePath = join(dirPath, `${name}.yaml`);
await writeFile(filePath, yamlContent, 'utf-8');
return c.json({
workflow: parsed.workflow,
filename: `${name}.yaml`,
source: 'project' as WorkflowSource,
source,
});
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
Expand All @@ -2362,14 +2398,21 @@ export function registerApiRoutes(
return apiError(c, 400, 'Invalid workflow name');
}

const targetSource = c.req.query('source');
if (targetSource && targetSource !== 'project' && targetSource !== 'global') {
return apiError(c, 400, 'Invalid workflow source');
}

// Refuse to delete bundled defaults
if (Object.hasOwn(BUNDLED_WORKFLOWS, name)) {
if (targetSource !== 'global' && Object.hasOwn(BUNDLED_WORKFLOWS, name)) {
return apiError(c, 400, `Cannot delete bundled default workflow: ${name}`);
}

const cwd = c.req.query('cwd');
let workingDir = cwd;
if (cwd) {
if (targetSource === 'global') {
workingDir = undefined;
} else if (cwd) {
if (!(await validateCwd(cwd))) {
return apiError(c, 400, 'Invalid cwd: must match a registered codebase path');
}
Expand All @@ -2381,8 +2424,10 @@ export function registerApiRoutes(
workingDir = getArchonHome();
}

const [workflowFolder] = getWorkflowFolderSearchPaths();
const filePath = join(workingDir, workflowFolder, `${name}.yaml`);
const filePath =
targetSource === 'global'
? join(getHomeWorkflowsPath(), `${name}.yaml`)
: join(workingDir, getWorkflowFolderSearchPaths()[0], `${name}.yaml`);

try {
await unlink(filePath);
Expand Down
74 changes: 73 additions & 1 deletion packages/server/src/routes/api.workflows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, test, expect, mock } from 'bun:test';
import { OpenAPIHono } from '@hono/zod-openapi';
import type { ConversationLockManager } from '@archon/core';
import type { WebAdapter } from '../adapters/web';
import { mkdir, rm, writeFile } from 'fs/promises';
import { mkdir, readFile, rm, writeFile } from 'fs/promises';
import { join } from 'path';
import { tmpdir } from 'os';
import { validationErrorHook } from './openapi-defaults';
Expand Down Expand Up @@ -253,6 +253,37 @@ describe('GET /api/workflows/:name', () => {
}
});

test('returns global workflow with source:global when file exists in ARCHON_HOME', async () => {
const testArchonHome = join(tmpdir(), `archon-home-get-global-${Date.now()}`);
const workflowDir = join(testArchonHome, 'workflows');
process.env.ARCHON_HOME = testArchonHome;
await mkdir(workflowDir, { recursive: true });
await writeFile(
join(workflowDir, 'global-workflow.yaml'),
'name: global-workflow\ndescription: Global workflow\nnodes:\n - id: plan\n command: plan\n'
);

try {
const app = createTestApp();
registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager);

mockListCodebases.mockImplementationOnce(async () => []);
const response = await app.request('/api/workflows/global-workflow');
expect(response.status).toBe(200);
const body = (await response.json()) as {
source: string;
filename: string;
workflow: { name: string };
};
expect(body.source).toBe('global');
expect(body.filename).toBe('global-workflow.yaml');
expect(body.workflow).toBeDefined();
} finally {
delete process.env.ARCHON_HOME;
await rm(testArchonHome, { recursive: true, force: true });
}
});

test('returns WorkflowDefinition shape with expected top-level fields', async () => {
const app = createTestApp();
registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager);
Expand Down Expand Up @@ -405,6 +436,47 @@ describe('PUT /api/workflows/:name', () => {
await rm(testDir, { recursive: true, force: true });
}
});

test('saves valid workflow to ARCHON_HOME workflows when source=global', async () => {
const testArchonHome = join(tmpdir(), `archon-home-put-global-${Date.now()}`);
process.env.ARCHON_HOME = testArchonHome;

try {
const app = createTestApp();
registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager);

const response = await app.request('/api/workflows/global-workflow?source=global', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
definition: {
name: 'global-workflow',
description: 'Global workflow',
nodes: [{ id: 'plan', command: 'plan' }],
},
}),
});

expect(response.status).toBe(200);
const body = (await response.json()) as {
workflow: { name: string };
filename: string;
source: string;
};
expect(body.workflow).toBeDefined();
expect(body.filename).toBe('global-workflow.yaml');
expect(body.source).toBe('global');

const saved = await readFile(
join(testArchonHome, 'workflows', 'global-workflow.yaml'),
'utf-8'
);
expect(saved).toContain('name: global-workflow');
} finally {
delete process.env.ARCHON_HOME;
await rm(testArchonHome, { recursive: true, force: true });
}
});
});

describe('DELETE /api/workflows/:name', () => {
Expand Down
10 changes: 6 additions & 4 deletions packages/web/src/components/workflows/WorkflowBuilder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useSearchParams, useNavigate } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { ReactFlowProvider, useNodesState, useEdgesState, useViewport } from '@xyflow/react';
import type { Edge } from '@xyflow/react';
import type { WorkflowDefinition } from '@/lib/api';
import type { WorkflowDefinition, WorkflowSource } from '@/lib/api';

import { useProject } from '@/contexts/ProjectContext';
import {
Expand Down Expand Up @@ -129,6 +129,7 @@ function WorkflowBuilderInner(): React.ReactElement {
const [workflowDescription, setWorkflowDescription] = useState('');
const [provider, setProvider] = useState<string | undefined>(undefined);
const [model, setModel] = useState<string | undefined>(undefined);
const [workflowSource, setWorkflowSource] = useState<WorkflowSource | undefined>(undefined);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [validationErrors, setValidationErrors] = useState<string[]>([]);

Expand Down Expand Up @@ -201,11 +202,12 @@ function WorkflowBuilderInner(): React.ReactElement {
const loadWorkflow = useCallback(
async (name: string): Promise<void> => {
try {
const { workflow } = await getWorkflow(name, cwd);
const { workflow, source } = await getWorkflow(name, cwd);
setWorkflowName(workflow.name);
setWorkflowDescription(workflow.description);
setProvider(workflow.provider);
setModel(workflow.model);
setWorkflowSource(source);
setValidationErrors([]);

const { nodes: rfNodes, edges: rfEdges } = dagNodesToReactFlow(workflow.nodes);
Expand Down Expand Up @@ -296,15 +298,15 @@ function WorkflowBuilderInner(): React.ReactElement {
return;
}
setValidationErrors([]);
await saveWorkflow(workflowName.trim(), def, cwd);
await saveWorkflow(workflowName.trim(), def, cwd, workflowSource);
setHasUnsavedChanges(false);
} catch (err) {
const error = err instanceof Error ? err : new Error('Unknown error');
console.error('[workflow-builder] workflow.save_failed', { workflowName, cwd, error });
setValidationErrors([`Save failed: ${error.message}`]);
setValidationPanelOpen(true);
}
}, [buildDefinition, workflowName, cwd]);
}, [buildDefinition, workflowName, cwd, workflowSource]);

const handleRun = useCallback(async (): Promise<void> => {
if (!workflowName.trim() || hasUnsavedChanges) return;
Expand Down
16 changes: 12 additions & 4 deletions packages/web/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -424,9 +424,13 @@ export async function getWorkflow(name: string, cwd?: string): Promise<GetWorkfl
export async function saveWorkflow(
name: string,
definition: WorkflowDefinition,
cwd?: string
cwd?: string,
source?: WorkflowSource
): Promise<GetWorkflowResponse> {
const params = cwd ? `?cwd=${encodeURIComponent(cwd)}` : '';
const query = new URLSearchParams();
if (cwd) query.set('cwd', cwd);
if (source === 'global') query.set('source', source);
const params = query.toString() ? `?${query.toString()}` : '';
return fetchJSON(`/api/workflows/${encodeURIComponent(name)}${params}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
Expand All @@ -436,9 +440,13 @@ export async function saveWorkflow(

export async function deleteWorkflow(
name: string,
cwd?: string
cwd?: string,
source?: WorkflowSource
): Promise<{ deleted: boolean; name: string }> {
const params = cwd ? `?cwd=${encodeURIComponent(cwd)}` : '';
const query = new URLSearchParams();
if (cwd) query.set('cwd', cwd);
if (source === 'global') query.set('source', source);
const params = query.toString() ? `?${query.toString()}` : '';
return fetchJSON(`/api/workflows/${encodeURIComponent(name)}${params}`, {
method: 'DELETE',
});
Expand Down
5 changes: 5 additions & 0 deletions packages/web/src/lib/command-categories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ function findCategory(name: string): string {
*/
export function categorizeCommands(commands: CommandEntry[]): CommandCategory[] {
const projectCommands = commands.filter(c => c.source === 'project');
const globalCommands = commands.filter(c => c.source === 'global');
const bundledCommands = commands.filter(c => c.source === 'bundled');

// Group bundled commands by category
Expand All @@ -87,6 +88,10 @@ export function categorizeCommands(commands: CommandEntry[]): CommandCategory[]
result.push({ name: 'Project', commands: projectCommands });
}

if (globalCommands.length > 0) {
result.push({ name: 'Global', commands: globalCommands });
}

// Named categories in definition order, then Utilities last
const orderedNames = CATEGORY_PREFIXES.map(c => c.category);
for (const name of orderedNames) {
Expand Down