diff --git a/packages/server/src/routes/api.ts b/packages/server/src/routes/api.ts index 928a8f35cd..f044d65a0d 100644 --- a/packages/server/src/routes/api.ts +++ b/packages/server/src/routes/api.ts @@ -37,6 +37,7 @@ import { getDefaultWorkflowsPath, getArchonWorkspacesPath, getHomeCommandsPath, + getHomeWorkflowsPath, getRunArtifactsPath, getArchonHome, isDocker, @@ -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', @@ -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: { @@ -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: { @@ -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); @@ -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'); } @@ -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)); @@ -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'); } @@ -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); diff --git a/packages/server/src/routes/api.workflows.test.ts b/packages/server/src/routes/api.workflows.test.ts index e50b252640..72430da713 100644 --- a/packages/server/src/routes/api.workflows.test.ts +++ b/packages/server/src/routes/api.workflows.test.ts @@ -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'; @@ -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); @@ -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', () => { diff --git a/packages/web/src/components/workflows/WorkflowBuilder.tsx b/packages/web/src/components/workflows/WorkflowBuilder.tsx index 674b081d8e..ebdc06b349 100644 --- a/packages/web/src/components/workflows/WorkflowBuilder.tsx +++ b/packages/web/src/components/workflows/WorkflowBuilder.tsx @@ -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 { @@ -129,6 +129,7 @@ function WorkflowBuilderInner(): React.ReactElement { const [workflowDescription, setWorkflowDescription] = useState(''); const [provider, setProvider] = useState(undefined); const [model, setModel] = useState(undefined); + const [workflowSource, setWorkflowSource] = useState(undefined); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [validationErrors, setValidationErrors] = useState([]); @@ -201,11 +202,12 @@ function WorkflowBuilderInner(): React.ReactElement { const loadWorkflow = useCallback( async (name: string): Promise => { 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); @@ -296,7 +298,7 @@ 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'); @@ -304,7 +306,7 @@ function WorkflowBuilderInner(): React.ReactElement { setValidationErrors([`Save failed: ${error.message}`]); setValidationPanelOpen(true); } - }, [buildDefinition, workflowName, cwd]); + }, [buildDefinition, workflowName, cwd, workflowSource]); const handleRun = useCallback(async (): Promise => { if (!workflowName.trim() || hasUnsavedChanges) return; diff --git a/packages/web/src/lib/api.ts b/packages/web/src/lib/api.ts index 566449e89a..d68d4e664c 100644 --- a/packages/web/src/lib/api.ts +++ b/packages/web/src/lib/api.ts @@ -424,9 +424,13 @@ export async function getWorkflow(name: string, cwd?: string): Promise { - 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' }, @@ -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', }); diff --git a/packages/web/src/lib/command-categories.ts b/packages/web/src/lib/command-categories.ts index b02bfb94f4..b4b2e0e2ea 100644 --- a/packages/web/src/lib/command-categories.ts +++ b/packages/web/src/lib/command-categories.ts @@ -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 @@ -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) {