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
157 changes: 157 additions & 0 deletions integration/summarise_perf_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
// 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 { ensureDir } from "@std/fs";
import { join } from "@std/path";
import { stringify as stringifyYaml } from "@std/yaml";
import { YamlWorkflowRunRepository } from "../src/infrastructure/persistence/yaml_workflow_run_repository.ts";

// Regression test for swamp-club#240: pin the mtime pre-filter on
// `findAllGlobalSince`. Counts the number of files actually parsed vs the
// number of files in the fixture; if the pre-filter ever stops working, the
// parse count would jump back to N_TOTAL and this test would fail with a
// concrete, deterministic error. Wall-clock is intentionally NOT asserted —
// it's flaky across CI runners and we don't need it: the parse count is the
// thing we actually care about.

const N_TOTAL = 500;
const N_IN_WINDOW = 10;

async function withTempDir(fn: (dir: string) => Promise<void>): Promise<void> {
const tempDir = await Deno.makeTempDir({ prefix: "swamp-issue-240-" });
try {
await fn(tempDir);
} finally {
if (Deno.build.os === "windows") {
// Best-effort: EBUSY can fire when V8 hasn't GC'd native handles yet.
await Deno.remove(tempDir, { recursive: true }).catch(() => {});
} else {
await Deno.remove(tempDir, { recursive: true });
}
}
}

async function seedWorkflowRuns(
repoDir: string,
cutoff: Date,
): Promise<{ inWindow: number; outOfWindow: number }> {
const workflowId = "550e8400-e29b-41d4-a716-446655440000";
const runsDir = join(repoDir, ".swamp", "workflow-runs", workflowId);
await ensureDir(runsDir);

const now = Date.now();
const oldDate = new Date(cutoff.getTime() - 7 * 24 * 60 * 60 * 1000);
let inWindow = 0;
let outOfWindow = 0;

for (let i = 0; i < N_TOTAL; i++) {
const isFresh = i < N_IN_WINDOW;
const startedAt = isFresh
? new Date(now - 1000 * 60)
: new Date(cutoff.getTime() - 1000 * 60 * 60 * (i + 1));
const runId = crypto.randomUUID();
const data = {
id: runId,
workflowId,
workflowName: "synthetic",
status: "succeeded",
startedAt: startedAt.toISOString(),
completedAt: new Date(startedAt.getTime() + 1000).toISOString(),
jobs: [{
jobName: "job1",
status: "succeeded",
steps: [{ stepName: "step1", status: "succeeded" }],
}],
tags: {},
};
const path = join(runsDir, `workflow-run-${runId}.yaml`);
await Deno.writeTextFile(path, stringifyYaml(data));
if (isFresh) {
inWindow++;
} else {
// Stamp mtime in the past so the mtime pre-filter rejects this file
// without parsing it. Mirrors what swamp itself produces — old runs
// aren't re-saved, so their mtime stays at last-completion time.
await Deno.utime(path, oldDate, oldDate);
outOfWindow++;
}
}

return { inWindow, outOfWindow };
}

/**
* Counts every Deno.readTextFile call against the YAML run files. Wraps the
* builtin so we don't need to plumb instrumentation through the repo class.
*/
function instrumentReadCounter(): { count: () => number; restore: () => void } {
const original = Deno.readTextFile.bind(Deno);
let parses = 0;
Deno.readTextFile = ((
path: string | URL,
options?: Deno.ReadFileOptions,
) => {
const p = typeof path === "string" ? path : path.pathname;
if (p.includes("workflow-run-") && p.endsWith(".yaml")) {
parses++;
}
return original(path, options);
}) as typeof Deno.readTextFile;
return {
count: () => parses,
restore: () => {
Deno.readTextFile = original;
},
};
}

Deno.test(
"swamp-club#240: findAllGlobalSince mtime-pre-filter skips parse for out-of-window runs",
async () => {
await withTempDir(async (repoDir) => {
const cutoff = new Date(Date.now() - 60 * 60 * 1000); // 1 hour ago
const seeded = await seedWorkflowRuns(repoDir, cutoff);
assertEquals(seeded.inWindow, N_IN_WINDOW);
assertEquals(seeded.outOfWindow, N_TOTAL - N_IN_WINDOW);

const repo = new YamlWorkflowRunRepository(repoDir);
const counter = instrumentReadCounter();
try {
const results = await repo.findAllGlobalSince(cutoff);

// Correctness: only in-window runs come back.
assertEquals(results.length, N_IN_WINDOW);

// Performance: the parse count stays bounded by in-window count.
// Allow a small constant for any directory-walk artifacts. If the
// pre-filter regresses, this jumps to N_TOTAL.
const parses = counter.count();
const budget = N_IN_WINDOW + 5;
if (parses > budget) {
throw new Error(
`mtime pre-filter regressed: parsed ${parses} files (budget ${budget}, fixture ${N_TOTAL})`,
);
}
} finally {
counter.restore();
}
});
},
);
13 changes: 13 additions & 0 deletions src/cli/commands/summarise.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
summarise,
} from "../../libswamp/mod.ts";
import { createSummariseRenderer } from "../../presentation/renderers/summarise.ts";
import { UserError } from "../../domain/errors.ts";

/**
* `swamp summarise`
Expand All @@ -54,10 +55,21 @@ export const summariseCommand = new Command()
.option("--since <duration:string>", "Time window (e.g. 1h, 1d, 7d, 1w)", {
default: "7d",
})
.option(
"--limit <count:number>",
"Cap per-group run details (counts still reflect all matching runs)",
)
.action(async function (options) {
const ctx = createContext(options as GlobalOptions, ["summarise"]);
ctx.logger.debug`Generating activity summary`;

if (
options.limit !== undefined &&
(options.limit <= 0 || !Number.isInteger(options.limit))
) {
throw new UserError("--limit must be a positive integer");
}

const { repoContext } = await requireInitializedRepoReadOnly({
repoDir: resolveRepoDir(options.repoDir),
outputMode: ctx.outputMode,
Expand All @@ -79,6 +91,7 @@ export const summariseCommand = new Command()
summarise(libCtx, deps, {
since: cutoffDate,
sinceLabel: options.since,
limit: options.limit,
}),
renderer.handlers(),
);
Expand Down
63 changes: 63 additions & 0 deletions src/cli/commands/summarise_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// 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 { initializeLogging } from "../../infrastructure/logging/logger.ts";

// Import models barrel to trigger self-registration
import "../../domain/models/models.ts";

// Initialize logging for tests
await initializeLogging({});

Deno.test("summariseCommand module loads", async () => {
const { summariseCommand } = await import("./summarise.ts");
assertEquals(summariseCommand.getName(), "summarise");
});

Deno.test("summariseCommand has correct description", async () => {
const { summariseCommand } = await import("./summarise.ts");
assertEquals(
summariseCommand.getDescription(),
"Show a high-level overview of repo activity (method executions, workflows, data)",
);
});

Deno.test("summariseCommand has --since option with default 7d", async () => {
const { summariseCommand } = await import("./summarise.ts");
const options = summariseCommand.getOptions();
const since = options.find((o) => o.name === "since");
assertEquals(since !== undefined, true);
assertEquals(since!.default, "7d");
});

Deno.test("summariseCommand has --limit option (opt-in, no default)", async () => {
const { summariseCommand } = await import("./summarise.ts");
const options = summariseCommand.getOptions();
const limit = options.find((o) => o.name === "limit");
assertEquals(limit !== undefined, true);
// Default is unlimited — no `default` set on the option, preserving the
// pre-issue-240 JSON shape for callers who don't opt in.
assertEquals(limit!.default, undefined);
});

Deno.test("summariseCommand exposes the summarize alias", async () => {
const { summariseCommand } = await import("./summarise.ts");
assertEquals(summariseCommand.getAliases().includes("summarize"), true);
});
13 changes: 13 additions & 0 deletions src/domain/models/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,19 @@ export interface OutputRepository {
{ output: ModelOutput; type: ModelType; method: string }[]
>;

/**
* Finds all outputs across all types whose `startedAt` is at or after the
* given cutoff. Prefer this over `findAllGlobal()` when the caller has a
* time bound; implementations may short-circuit the underlying scan and skip
* work that `findAllGlobal()` would do unconditionally.
*
* @param cutoff - Earliest `startedAt` to include (inclusive)
* @returns Array of outputs whose `startedAt >= cutoff`
*/
findAllGlobalSince(
cutoff: Date,
): Promise<{ output: ModelOutput; type: ModelType; method: string }[]>;

/**
* Saves an output.
*
Expand Down
Loading
Loading