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
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,10 @@ worktree: # Optional: pin isolation behavior regardless o
# like triage/reporting. true = must use a worktree;
# CLI --no-worktree hard-errors. Omit to let the
# caller decide (current default = worktree).
tags: [GitLab, Review] # Optional: explicit Web UI filter tags. Overrides the
# keyword-based tag inference. An empty list (`tags: []`)
# suppresses inference and shows no tags. Omit to fall
# back to inferred tags (the default).

# Required for DAG-based
nodes:
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/components/workflows/WorkflowCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export function WorkflowCard({
const parsed = parseWorkflowDescription(workflow.description ?? '');
const displayName = getWorkflowDisplayName(workflow.name);
const category = getWorkflowCategory(workflow.name, workflow.description ?? '');
const tags = getWorkflowTags(workflow.name, parsed);
const tags = getWorkflowTags(workflow.name, parsed, workflow.tags);
const iconName = getWorkflowIconName(workflow.name, category);
const CARD_ICON = ICON_MAP[iconName];

Expand Down
5 changes: 5 additions & 0 deletions packages/web/src/lib/api.generated.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2345,6 +2345,10 @@ export interface components {
args?: string[];
};
};
worktree?: {
enabled?: boolean;
};
tags?: string[];
nodes: components['schemas']['DagNode'][];
};
/** @enum {string} */
Expand Down Expand Up @@ -2561,6 +2565,7 @@ export interface components {
runningWorkflows: number;
version?: string;
is_docker: boolean;
activePlatforms?: string[];
};
UpdateCheckResponse: {
updateAvailable: boolean;
Expand Down
25 changes: 25 additions & 0 deletions packages/web/src/lib/workflow-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,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']);
});
Comment thread
lraphael marked this conversation as resolved.

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', () => {
Expand Down
12 changes: 11 additions & 1 deletion packages/web/src/lib/workflow-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,8 +163,18 @@ export function getWorkflowCategory(name: string, description: string): Workflow

/**
* Derive tags from the workflow name and parsed description.
* If `explicitTags` is provided (including an empty array), those are used
* verbatim (deduplicated) and inference is skipped.
*/
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();

Expand Down
66 changes: 66 additions & 0 deletions packages/workflows/src/loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,72 @@ describe('Workflow Loader', () => {
expect(result.workflows[0].workflow.worktree).toBeUndefined();
});

it('should parse explicit tags array', async () => {
const workflowDir = join(testDir, '.archon', 'workflows');
await mkdir(workflowDir, { recursive: true });
const yaml = `name: review-mr\ndescription: GitLab MR review\ntags: [GitLab, Review]\nnodes:\n - id: n\n prompt: p\n`;
await writeFile(join(workflowDir, 'review-mr.yaml'), yaml);
const result = await discoverWorkflows(testDir, { loadDefaults: false });
expect(result.workflows[0].workflow.tags).toEqual(['GitLab', 'Review']);
});

it('should omit tags when not present', async () => {
const workflowDir = join(testDir, '.archon', 'workflows');
await mkdir(workflowDir, { recursive: true });
const yaml = `name: test\ndescription: no tags\nnodes:\n - id: n\n prompt: p\n`;
await writeFile(join(workflowDir, 'test.yaml'), yaml);
const result = await discoverWorkflows(testDir, { loadDefaults: false });
expect(result.workflows[0].workflow.tags).toBeUndefined();
});

it('should preserve explicit empty tags array (suppresses inference)', async () => {
const workflowDir = join(testDir, '.archon', 'workflows');
await mkdir(workflowDir, { recursive: true });
const yaml = `name: test\ndescription: no tags wanted\ntags: []\nnodes:\n - id: n\n prompt: p\n`;
await writeFile(join(workflowDir, 'test.yaml'), yaml);
const result = await discoverWorkflows(testDir, { loadDefaults: false });
expect(result.workflows[0].workflow.tags).toEqual([]);
});

it('should trim and dedupe tags', async () => {
const workflowDir = join(testDir, '.archon', 'workflows');
await mkdir(workflowDir, { recursive: true });
const yaml = `name: test\ndescription: messy tags\ntags: ["GitLab", "GitLab ", " GitLab ", "Review"]\nnodes:\n - id: n\n prompt: p\n`;
await writeFile(join(workflowDir, 'test.yaml'), yaml);
const result = await discoverWorkflows(testDir, { loadDefaults: false });
expect(result.workflows[0].workflow.tags).toEqual(['GitLab', 'Review']);
});

it('should filter non-string tag entries', async () => {
const workflowDir = join(testDir, '.archon', 'workflows');
await mkdir(workflowDir, { recursive: true });
// YAML coerces unquoted scalars: 123 → number, null → null
const yaml = `name: test\ndescription: mixed\ntags:\n - GitLab\n - 123\n - null\n - Review\nnodes:\n - id: n\n prompt: p\n`;
await writeFile(join(workflowDir, 'test.yaml'), yaml);
const result = await discoverWorkflows(testDir, { loadDefaults: false });
expect(result.workflows[0].workflow.tags).toEqual(['GitLab', 'Review']);
});

it('should reduce all-blank tags to empty array (still suppresses inference)', async () => {
const workflowDir = join(testDir, '.archon', 'workflows');
await mkdir(workflowDir, { recursive: true });
const yaml = `name: test\ndescription: blanks\ntags: ["", " "]\nnodes:\n - id: n\n prompt: p\n`;
await writeFile(join(workflowDir, 'test.yaml'), yaml);
const result = await discoverWorkflows(testDir, { loadDefaults: false });
expect(result.workflows[0].workflow.tags).toEqual([]);
});

it('should ignore tags when not an array', async () => {
const workflowDir = join(testDir, '.archon', 'workflows');
await mkdir(workflowDir, { recursive: true });
// Authoring mistake: scalar instead of list — discarded, workflow still loads
const yaml = `name: test\ndescription: scalar tags\ntags: GitLab\nnodes:\n - id: n\n prompt: p\n`;
await writeFile(join(workflowDir, 'test.yaml'), yaml);
const result = await discoverWorkflows(testDir, { loadDefaults: false });
expect(result.workflows).toHaveLength(1);
expect(result.workflows[0].workflow.tags).toBeUndefined();
});

it('should parse valid DAG workflow YAML', async () => {
const workflowDir = join(testDir, '.archon', 'workflows');
await mkdir(workflowDir, { recursive: true });
Expand Down
20 changes: 20 additions & 0 deletions packages/workflows/src/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,25 @@ export function parseWorkflow(content: string, filename: string): ParseResult {
}
}

// Parse optional tags — type-narrow, trim, and dedupe so authors can't
// ship ["GitLab", "GitLab ", "gitlab"] as three distinct values.
// An explicit empty array is preserved (suppresses keyword inference in the
// UI); an absent or invalid block leaves `tags` undefined (falls back to
// inference). Same warn-and-ignore pattern as the worktree block above.
let tags: string[] | undefined;
if (Array.isArray(raw.tags)) {
tags = [
...new Set(
raw.tags
.filter((t): t is string => typeof t === 'string')
.map(t => t.trim())
.filter(t => t.length > 0)
),
];
} else if (raw.tags !== undefined) {
getLog().warn({ filename, value: raw.tags }, 'invalid_tags_block_ignored');
}

return {
workflow: {
name: raw.name,
Expand All @@ -373,6 +392,7 @@ export function parseWorkflow(content: string, filename: string): ParseResult {
interactive,
nodes: dagNodes,
...(worktreePolicy ? { worktree: worktreePolicy } : {}),
...(tags !== undefined ? { tags } : {}),
},
error: null,
};
Expand Down
1 change: 1 addition & 0 deletions packages/workflows/src/schemas/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export const workflowBaseSchema = z.object({
betas: z.array(z.string().min(1)).nonempty("'betas' must be a non-empty array").optional(),
sandbox: sandboxSettingsSchema.optional(),
worktree: workflowWorktreePolicySchema.optional(),
tags: z.array(z.string().min(1)).optional(),
});

export type WorkflowBase = z.infer<typeof workflowBaseSchema>;
Expand Down
Loading