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
25 changes: 23 additions & 2 deletions src/cli/commands/workflow_run_search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import {
createWorkflowId,
createWorkflowRunId,
} from "../../domain/workflows/workflow_id.ts";
import { parseDuration } from "./data_search.ts";
import { parseDuration, parseTags } from "./data_search.ts";

// deno-lint-ignore no-explicit-any
type AnyOptions = any;
Expand Down Expand Up @@ -85,6 +85,7 @@ function toRunData(run: WorkflowRun, path?: string): WorkflowRunData {
function toSearchItem(run: WorkflowRun): WorkflowHistorySearchItem {
const startTime = run.startedAt?.getTime();
const endTime = run.completedAt?.getTime();
const tags = Object.keys(run.tags).length > 0 ? { ...run.tags } : undefined;

return {
runId: run.id,
Expand All @@ -94,6 +95,7 @@ function toSearchItem(run: WorkflowRun): WorkflowHistorySearchItem {
startedAt: run.startedAt?.toISOString(),
completedAt: run.completedAt?.toISOString(),
duration: startTime && endTime ? endTime - startTime : undefined,
tags,
};
}

Expand Down Expand Up @@ -123,13 +125,14 @@ interface RunSearchFilterOptions {
since?: string;
status?: string;
workflow?: string;
tags?: Record<string, string>;
limit?: number;
}

/**
* Applies structured filters to workflow run search items.
*/
function applyFilters(
export function applyFilters(
runs: WorkflowHistorySearchItem[],
options: RunSearchFilterOptions,
): WorkflowHistorySearchItem[] {
Expand All @@ -156,6 +159,13 @@ function applyFilters(
);
}

if (options.tags) {
const tagEntries = Object.entries(options.tags);
filtered = filtered.filter((r) =>
tagEntries.every(([k, v]) => r.tags?.[k] === v)
);
}

if (options.limit !== undefined) {
filtered = filtered.slice(0, options.limit);
}
Expand Down Expand Up @@ -197,12 +207,18 @@ export async function workflowRunSearchAction(
return bTime - aTime;
});

// Parse --tag values into Record<string, string>
const parsedTags = options.tag
? parseTags(options.tag as string[])
: undefined;

// Convert to search items and apply structured filters
let searchItems = allRuns.map(toSearchItem);
searchItems = applyFilters(searchItems, {
since: options.since as string | undefined,
status: options.status as string | undefined,
workflow: options.workflow as string | undefined,
tags: parsedTags,
limit: options.limit as number | undefined,
});

Expand Down Expand Up @@ -263,5 +279,10 @@ export const workflowRunSearchCommand = new Command()
"--workflow <name:string>",
"Filter by workflow name",
)
.option(
"--tag <tag:string>",
"Filter by tag (KEY=VALUE), can be repeated",
{ collect: true },
)
.option("--limit <n:number>", "Max results", { default: 50 })
.action(workflowRunSearchAction);
87 changes: 87 additions & 0 deletions src/cli/commands/workflow_run_search_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// Swamp, an Automation Framework
// Copyright (C) 2026 System Initiative, Inc.
//
// This file is part of Swamp.
//
// Swamp is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License version 3
// as published by the Free Software Foundation, with the Swamp
// Extension and Definition Exception (found in the "COPYING-EXCEPTION"
// file).
//
// Swamp is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with Swamp. If not, see <https://www.gnu.org/licenses/>.

import { assertEquals } from "@std/assert";
import { applyFilters } from "./workflow_run_search.ts";
import type { WorkflowHistorySearchItem } from "../../presentation/output/workflow_history_search_output.tsx";

function makeItem(
overrides: Partial<WorkflowHistorySearchItem> = {},
): WorkflowHistorySearchItem {
return {
runId: crypto.randomUUID(),
workflowId: crypto.randomUUID(),
workflowName: "test-workflow",
status: "succeeded",
startedAt: new Date().toISOString(),
...overrides,
};
}

Deno.test("applyFilters with --tag filters by matching tags (AND logic)", () => {
const items = [
makeItem({ tags: { env: "prod", region: "us-east-1" } }),
makeItem({ tags: { env: "staging", region: "us-east-1" } }),
makeItem({ tags: { env: "prod", region: "eu-west-1" } }),
];

const result = applyFilters(items, {
tags: { env: "prod", region: "us-east-1" },
});

assertEquals(result.length, 1);
assertEquals(result[0].tags?.env, "prod");
assertEquals(result[0].tags?.region, "us-east-1");
});

Deno.test("applyFilters with --tag single tag filter", () => {
const items = [
makeItem({ tags: { env: "prod" } }),
makeItem({ tags: { env: "staging" } }),
makeItem({ tags: { env: "prod", team: "platform" } }),
];

const result = applyFilters(items, { tags: { env: "prod" } });

assertEquals(result.length, 2);
});

Deno.test("applyFilters with --tag items without tags do not match", () => {
const items = [
makeItem({ tags: { env: "prod" } }),
makeItem({}),
makeItem({ tags: undefined }),
];

const result = applyFilters(items, { tags: { env: "prod" } });

assertEquals(result.length, 1);
assertEquals(result[0].tags?.env, "prod");
});

Deno.test("applyFilters without --tag returns all items", () => {
const items = [
makeItem({ tags: { env: "prod" } }),
makeItem({}),
];

const result = applyFilters(items, {});

assertEquals(result.length, 2);
});
8 changes: 6 additions & 2 deletions src/domain/workflows/execution_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -762,8 +762,12 @@ export class WorkflowExecutionService {
await evaluatedWorkflowRepo.save(evaluatedWorkflow);
}

// Create workflow run
const run = WorkflowRun.create(workflow);
// Create workflow run with merged tags (runtime tags take precedence)
const mergedTags: Record<string, string> = {
...(workflow.tags ?? {}),
...(options?.runtimeTags ?? {}),
};
const run = WorkflowRun.create(workflow, mergedTags);

// Register run file sink target for the workflow log output
const workflowLogPath = join(
Expand Down
26 changes: 23 additions & 3 deletions src/domain/workflows/workflow_run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,19 @@ export const WorkflowRunSchema = z.object({
completedAt: z.string().datetime().optional(),
jobs: z.array(JobRunSchema),
logFile: z.string().optional(),
tags: z.record(z.string(), z.string()).default({}),
});

/**
* Type representing workflow run data.
* Type representing workflow run data (output — tags always present).
*/
export type WorkflowRunData = z.infer<typeof WorkflowRunSchema>;

/**
* Type representing workflow run input data (tags optional for backward compat).
*/
export type WorkflowRunInput = z.input<typeof WorkflowRunSchema>;

/**
* StepRun tracks the execution state of a single step.
*/
Expand Down Expand Up @@ -355,12 +361,16 @@ export class WorkflowRun implements TriggerEvaluationContext {
private _completedAt: Date | undefined,
private _jobs: JobRun[],
private _logFile: string | undefined,
private readonly _tags: Record<string, string>,
) {}

/**
* Creates a new WorkflowRun from a workflow, initializing all jobs and steps as pending.
*/
static create(workflow: Workflow): WorkflowRun {
static create(
workflow: Workflow,
tags?: Record<string, string>,
): WorkflowRun {
const id = crypto.randomUUID();
const jobs = workflow.jobs.map((job) =>
JobRun.pending(
Expand All @@ -378,13 +388,14 @@ export class WorkflowRun implements TriggerEvaluationContext {
undefined,
jobs,
undefined,
tags ?? {},
);
}

/**
* Reconstructs a WorkflowRun from persisted data.
*/
static fromData(data: WorkflowRunData): WorkflowRun {
static fromData(data: WorkflowRunInput): WorkflowRun {
const validated = WorkflowRunSchema.parse(data);
const jobs = validated.jobs.map((j) => JobRun.fromData(j));

Expand All @@ -397,6 +408,7 @@ export class WorkflowRun implements TriggerEvaluationContext {
validated.completedAt ? new Date(validated.completedAt) : undefined,
jobs,
validated.logFile,
validated.tags,
);
}

Expand All @@ -423,6 +435,13 @@ export class WorkflowRun implements TriggerEvaluationContext {
return this._logFile;
}

/**
* Gets the tags associated with this run.
*/
get tags(): Readonly<Record<string, string>> {
return this._tags;
}

/**
* Sets the log file path for this run.
*/
Expand Down Expand Up @@ -473,6 +492,7 @@ export class WorkflowRun implements TriggerEvaluationContext {
startedAt: this._startedAt?.toISOString(),
completedAt: this._completedAt?.toISOString(),
jobs: this._jobs.map((j) => j.toData()),
tags: { ...this._tags },
};
if (this._logFile) {
data.logFile = this._logFile;
Expand Down
67 changes: 67 additions & 0 deletions src/domain/workflows/workflow_run_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,3 +335,70 @@ Deno.test("WorkflowRun.fromData and toData roundtrip correctly", () => {
assertEquals(restored.status, original.status);
assertEquals(restored.jobs.length, original.jobs.length);
});

// WorkflowRun tags tests

Deno.test("WorkflowRun.create with tags stores them", () => {
const workflow = createTestWorkflow();
const tags = { env: "prod", region: "us-east-1" };
const run = WorkflowRun.create(workflow, tags);

assertEquals(run.tags, { env: "prod", region: "us-east-1" });
});

Deno.test("WorkflowRun.create without tags defaults to empty", () => {
const workflow = createTestWorkflow();
const run = WorkflowRun.create(workflow);

assertEquals(run.tags, {});
});

Deno.test("WorkflowRun.toData includes tags when present", () => {
const workflow = createTestWorkflow();
const run = WorkflowRun.create(workflow, { env: "staging" });
run.start();

const data = run.toData();
assertEquals(data.tags, { env: "staging" });
});

Deno.test("WorkflowRun.toData includes empty tags when none set", () => {
const workflow = createTestWorkflow();
const run = WorkflowRun.create(workflow);
run.start();

const data = run.toData();
assertEquals(data.tags, {});
});

Deno.test("WorkflowRun.fromData and toData roundtrip preserves tags", () => {
const workflow = createTestWorkflow();
const tags = { env: "prod", team: "platform" };
const original = WorkflowRun.create(workflow, tags);
original.start();

const data = original.toData();
const restored = WorkflowRun.fromData(data);

assertEquals(restored.tags, { env: "prod", team: "platform" });
});

Deno.test("WorkflowRun.fromData with missing tags defaults to empty", () => {
const data = {
id: "550e8400-e29b-41d4-a716-446655440001",
workflowId: "550e8400-e29b-41d4-a716-446655440000",
workflowName: "test-workflow",
status: "running" as const,
startedAt: new Date().toISOString(),
jobs: [
{
jobName: "job1",
status: "succeeded" as const,
steps: [{ stepName: "step1", status: "succeeded" as const }],
},
],
};

const run = WorkflowRun.fromData(data);
assertEquals(run.tags, {});
});
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,37 @@ Deno.test("YamlWorkflowRunRepository.deleteAllByWorkflowId returns 0 for no runs
});
});

Deno.test("YamlWorkflowRunRepository save/load roundtrip preserves tags", async () => {
await withTempDir(async (dir) => {
const repo = new YamlWorkflowRunRepository(dir);
const workflow = createTestWorkflow();
const tags = { env: "prod", region: "us-east-1" };
const run = WorkflowRun.create(workflow, tags);
run.start();

await repo.save(workflow.id, run);
const loaded = await repo.findById(workflow.id, run.id);

assertNotEquals(loaded, null);
assertEquals(loaded!.tags, { env: "prod", region: "us-east-1" });
});
});

Deno.test("YamlWorkflowRunRepository save/load roundtrip with no tags", async () => {
await withTempDir(async (dir) => {
const repo = new YamlWorkflowRunRepository(dir);
const workflow = createTestWorkflow();
const run = WorkflowRun.create(workflow);
run.start();

await repo.save(workflow.id, run);
const loaded = await repo.findById(workflow.id, run.id);

assertNotEquals(loaded, null);
assertEquals(loaded!.tags, {});
});
});

Deno.test("YamlWorkflowRunRepository.deleteAllByWorkflowId does not affect other workflows", async () => {
await withTempDir(async (dir) => {
const repo = new YamlWorkflowRunRepository(dir);
Expand Down
Loading