diff --git a/packages/web/src/components/workflows/WorkflowCard.tsx b/packages/web/src/components/workflows/WorkflowCard.tsx index 10ed0cd23e..5bc805965e 100644 --- a/packages/web/src/components/workflows/WorkflowCard.tsx +++ b/packages/web/src/components/workflows/WorkflowCard.tsx @@ -54,8 +54,13 @@ export function WorkflowCard({ }: WorkflowCardProps): React.ReactElement { const parsed = parseWorkflowDescription(workflow.description ?? ''); const displayName = getWorkflowDisplayName(workflow.name); - const category = getWorkflowCategory(workflow.name, workflow.description ?? ''); - const tags = getWorkflowTags(workflow.name, parsed); + const wf = workflow as Record; + const category = getWorkflowCategory( + workflow.name, + workflow.description ?? '', + wf.category as string | undefined + ); + const tags = getWorkflowTags(workflow.name, parsed, wf.tags as string[] | undefined); const iconName = getWorkflowIconName(workflow.name, category); const CARD_ICON = ICON_MAP[iconName]; diff --git a/packages/web/src/components/workflows/WorkflowList.tsx b/packages/web/src/components/workflows/WorkflowList.tsx index ed837f7929..91b1bde169 100644 --- a/packages/web/src/components/workflows/WorkflowList.tsx +++ b/packages/web/src/components/workflows/WorkflowList.tsx @@ -106,7 +106,11 @@ export function WorkflowList(): React.ReactElement { } // Category filter if (activeCategory !== 'All') { - const cat = getWorkflowCategory(wf.name, wf.description ?? ''); + const cat = getWorkflowCategory( + wf.name, + wf.description ?? '', + (wf as Record).category as string | undefined + ); if (cat !== activeCategory) return false; } return true; diff --git a/packages/web/src/lib/workflow-metadata.test.ts b/packages/web/src/lib/workflow-metadata.test.ts index 18af743267..656bbc390d 100644 --- a/packages/web/src/lib/workflow-metadata.test.ts +++ b/packages/web/src/lib/workflow-metadata.test.ts @@ -174,6 +174,18 @@ describe('getWorkflowCategory', () => { expect(getWorkflowCategory('archon-assist', 'General help')).toBe('Development'); expect(getWorkflowCategory('archon-idea-to-pr', 'From idea to PR')).toBe('Development'); }); + + test('uses explicit category when provided', () => { + expect(getWorkflowCategory('my-gitlab-workflow', 'Does GitLab things', 'Automation')).toBe( + 'Automation' + ); + }); + + test('falls back to inference when explicit category is invalid', () => { + expect(getWorkflowCategory('archon-smart-pr-review', 'Review PR', 'InvalidCategory')).toBe( + 'Code Review' + ); + }); }); describe('getWorkflowTags', () => { @@ -200,6 +212,31 @@ describe('getWorkflowTags', () => { const githubCount = tags.filter(t => t === 'GitHub').length; expect(githubCount).toBeLessThanOrEqual(1); }); + + test('uses explicit tags when provided', () => { + const parsed = parseWorkflowDescription('A GitLab workflow'); + const tags = getWorkflowTags('review-gitlab-mr', parsed, ['GitLab', 'Review']); + expect(tags).toEqual(['GitLab', 'Review']); + }); + + test('falls back to inference when no explicit tags', () => { + const parsed = parseWorkflowDescription('Does: review PR on GitHub'); + const tags = getWorkflowTags('archon-pr-review', parsed, undefined); + expect(tags).toContain('GitHub'); + expect(tags).toContain('Review'); + }); + + test('deduplicates explicit tags', () => { + const parsed = parseWorkflowDescription('anything'); + const tags = getWorkflowTags('test', parsed, ['GitLab', 'GitLab', 'Review']); + expect(tags).toEqual(['GitLab', 'Review']); + }); + + test('explicit empty array suppresses inference', () => { + const parsed = parseWorkflowDescription('Does: review PR on GitHub'); + const tags = getWorkflowTags('archon-pr-review', parsed, []); + expect(tags).toEqual([]); + }); }); describe('getWorkflowIconName', () => { diff --git a/packages/web/src/lib/workflow-metadata.ts b/packages/web/src/lib/workflow-metadata.ts index e3ab01191d..9049613ca1 100644 --- a/packages/web/src/lib/workflow-metadata.ts +++ b/packages/web/src/lib/workflow-metadata.ts @@ -119,9 +119,18 @@ export const CATEGORIES: WorkflowCategory[] = [ /** * Derive a category from the workflow name and description. - * Uses word-boundary checks for short tokens to avoid false positives. + * If the workflow defines an explicit `category` in YAML that matches a known + * category, it is used directly. Otherwise, the category is inferred from keywords. */ -export function getWorkflowCategory(name: string, description: string): WorkflowCategory { +export function getWorkflowCategory( + name: string, + description: string, + explicitCategory?: string +): WorkflowCategory { + if (explicitCategory && CATEGORIES.includes(explicitCategory as WorkflowCategory)) { + return explicitCategory as WorkflowCategory; + } + const lower = `${name} ${description}`.toLowerCase(); // Code Review @@ -163,8 +172,18 @@ export function getWorkflowCategory(name: string, description: string): Workflow /** * Derive tags from the workflow name and parsed description. + * If the workflow defines explicit `tags` in YAML, those are used directly. + * Otherwise, tags are inferred from keywords in the name and description. */ -export function getWorkflowTags(name: string, parsed: ParsedDescription): string[] { +export function getWorkflowTags( + name: string, + parsed: ParsedDescription, + explicitTags?: string[] +): string[] { + if (explicitTags !== undefined) { + return [...new Set(explicitTags)]; + } + const tags: string[] = []; const text = `${name} ${parsed.raw}`.toLowerCase(); diff --git a/packages/workflows/src/loader.ts b/packages/workflows/src/loader.ts index 0fd93cce1f..622a023760 100644 --- a/packages/workflows/src/loader.ts +++ b/packages/workflows/src/loader.ts @@ -335,6 +335,19 @@ export function parseWorkflow(content: string, filename: string): ParseResult { } } + // Parse optional tags — warn on non-strings + const tags = Array.isArray(raw.tags) + ? raw.tags.filter((t: unknown) => { + if (typeof t !== 'string') { + getLog().warn({ filename, value: t }, 'non_string_tag_filtered'); + return false; + } + return true; + }) + : undefined; + + const category = typeof raw.category === 'string' ? raw.category : undefined; + return { workflow: { name: raw.name, @@ -345,6 +358,8 @@ export function parseWorkflow(content: string, filename: string): ParseResult { webSearchMode, additionalDirectories, interactive, + tags, + category, nodes: dagNodes, }, error: null, diff --git a/packages/workflows/src/schemas/workflow.ts b/packages/workflows/src/schemas/workflow.ts index 008ef19a8f..b42b086afa 100644 --- a/packages/workflows/src/schemas/workflow.ts +++ b/packages/workflows/src/schemas/workflow.ts @@ -40,6 +40,8 @@ export const workflowBaseSchema = z.object({ fallbackModel: z.string().min(1).optional(), betas: z.array(z.string().min(1)).nonempty("'betas' must be a non-empty array").optional(), sandbox: sandboxSettingsSchema.optional(), + tags: z.array(z.string().min(1)).optional(), + category: z.string().min(1).optional(), }); export type WorkflowBase = z.infer;