@sweny-ai/core 0.1.129 → 0.1.131
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/main.js +31 -1
- package/dist/cli/new.d.ts +3 -4
- package/dist/cli/new.js +15 -6
- package/dist/cli/run-history.d.ts +85 -0
- package/dist/cli/run-history.js +219 -0
- package/dist/cli/runs.d.ts +17 -0
- package/dist/cli/runs.js +216 -0
- package/package.json +1 -1
package/dist/cli/main.js
CHANGED
|
@@ -27,6 +27,8 @@ import * as readline from "node:readline";
|
|
|
27
27
|
import { loadDotenv, loadConfigFile, applyAgentFileConfig } from "./config-file.js";
|
|
28
28
|
import { buildCredentialMap } from "./credentials.js";
|
|
29
29
|
import { nonInteractiveUsage, runNew } from "./new.js";
|
|
30
|
+
import { buildRunRecord, createNodeTimer, historyDisabled, newRunId, recordRun } from "./run-history.js";
|
|
31
|
+
import { registerRunsCommand } from "./runs.js";
|
|
30
32
|
import { runE2eRun, runWithWallClockBudget, DEFAULT_WORKFLOW_TIMEOUT_MS } from "./e2e.js";
|
|
31
33
|
import { createVerboseToolObserver } from "./verbose-observer.js";
|
|
32
34
|
import { createRunLogger, renderReceiptLine, summarizeRun, writeStepSummary, WORKFLOW_RUN_DESCRIPTION, WORKFLOW_RUN_OPTIONS, } from "./run-output.js";
|
|
@@ -884,7 +886,31 @@ export async function workflowRunAction(file, options) {
|
|
|
884
886
|
if (wfCloudHandle?.dashboardUrl && !isJson) {
|
|
885
887
|
console.log(c.subtle(` cloud: ${wfCloudHandle.dashboardUrl}`));
|
|
886
888
|
}
|
|
887
|
-
|
|
889
|
+
// Run history (#388): metadata-only record under .sweny/runs/, written once at run end.
|
|
890
|
+
const nodeTimer = createNodeTimer();
|
|
891
|
+
const runId = newRunId(runStart);
|
|
892
|
+
let historyRecorded = false;
|
|
893
|
+
const recordHistory = (results, trace, crashed) => {
|
|
894
|
+
if (historyRecorded || historyDisabled(options.history, fileConfig["history"]))
|
|
895
|
+
return;
|
|
896
|
+
historyRecorded = true;
|
|
897
|
+
try {
|
|
898
|
+
recordRun(buildRunRecord({
|
|
899
|
+
runId,
|
|
900
|
+
workflow,
|
|
901
|
+
startedAtMs: runStart,
|
|
902
|
+
durationMs: Date.now() - runStart,
|
|
903
|
+
results,
|
|
904
|
+
trace,
|
|
905
|
+
nodeDurations: nodeTimer.durations,
|
|
906
|
+
crashed,
|
|
907
|
+
}));
|
|
908
|
+
}
|
|
909
|
+
catch {
|
|
910
|
+
// history must never fail a run
|
|
911
|
+
}
|
|
912
|
+
};
|
|
913
|
+
const observer = composeObservers(wfProgressObserver, nodeTimer.observer, options.verbose ? createVerboseToolObserver() : undefined, options.stream ? createStreamObserver() : undefined, createCloudStreamObserver(config, wfCloudHandle));
|
|
888
914
|
// #325: this is the primary run path (GitHub Action / CLI / MCP all land
|
|
889
915
|
// here), and previously never passed signal/timeoutMs/max_steps to
|
|
890
916
|
// execute() at all: a wedged node (hung tool call, runaway model turn)
|
|
@@ -912,6 +938,7 @@ export async function workflowRunAction(file, options) {
|
|
|
912
938
|
}), wfTimeoutMs, `Workflow ${workflow.name}`);
|
|
913
939
|
const wfDurationMs = Date.now() - runStart;
|
|
914
940
|
const wfHasFailed = [...results.values()].some((r) => r.status === "failed");
|
|
941
|
+
recordHistory(results, trace, false);
|
|
915
942
|
// Close the cloud lifecycle session BEFORE the JSON early-exit so
|
|
916
943
|
// every workflow run reports a terminal status, regardless of how
|
|
917
944
|
// the CLI returns (json, mermaid, or normal stdout).
|
|
@@ -957,6 +984,7 @@ export async function workflowRunAction(file, options) {
|
|
|
957
984
|
const crashMsg = err instanceof Error ? err.message : String(err);
|
|
958
985
|
console.error(chalk.red(`\n Error: ${crashMsg}\n`));
|
|
959
986
|
runLogger.flush();
|
|
987
|
+
recordHistory(nodeTimer.lastResults, undefined, true);
|
|
960
988
|
console.error(` ${renderReceiptLine(summarizeRun(new Map(), Date.now() - runStart, true), isTTY)}\n`);
|
|
961
989
|
// Finalize the cloud run as failed (covers thrown errors, incl.
|
|
962
990
|
// RouteEvaluationError). Without this a crashed workflow run stays
|
|
@@ -1032,6 +1060,8 @@ const workflowRunCmd = workflowCmd
|
|
|
1032
1060
|
for (const [flags, description] of WORKFLOW_RUN_OPTIONS) {
|
|
1033
1061
|
workflowRunCmd.option(flags, description);
|
|
1034
1062
|
}
|
|
1063
|
+
workflowRunCmd.option("--no-history", "Do not record this run in .sweny/runs/ (or set `history: off` in .sweny.yml)");
|
|
1064
|
+
registerRunsCommand(program);
|
|
1035
1065
|
workflowCmd
|
|
1036
1066
|
.command("diagram <file>")
|
|
1037
1067
|
.description("Render a workflow as a Mermaid diagram (raw .mmd by default; .md output auto-fences)")
|
package/dist/cli/new.d.ts
CHANGED
|
@@ -120,11 +120,10 @@ export declare function writeWorkflowFile(cwd: string, id: string, yaml: string,
|
|
|
120
120
|
exists?: boolean;
|
|
121
121
|
path: string;
|
|
122
122
|
};
|
|
123
|
-
/**
|
|
124
|
-
* Make sure `.env` is gitignored: create `.gitignore` if missing, append
|
|
125
|
-
* `.env` if absent. Returns what happened.
|
|
126
|
-
*/
|
|
123
|
+
/** Make sure `.env` is gitignored. */
|
|
127
124
|
export declare function ensureGitignoreEnv(cwd: string): "created" | "appended" | "present";
|
|
125
|
+
/** Make sure local run history (`.sweny/runs/`) is gitignored. */
|
|
126
|
+
export declare function ensureGitignoreRuns(cwd: string): "created" | "appended" | "present";
|
|
128
127
|
/** Usage printed (exit 2) when `sweny new` has no terminal and no --yes. */
|
|
129
128
|
export declare function nonInteractiveUsage(): string;
|
|
130
129
|
/**
|
package/dist/cli/new.js
CHANGED
|
@@ -443,22 +443,30 @@ export function writeWorkflowFile(cwd, id, yaml, options) {
|
|
|
443
443
|
return { written: true, path: target };
|
|
444
444
|
}
|
|
445
445
|
/**
|
|
446
|
-
* Make sure
|
|
447
|
-
*
|
|
446
|
+
* Make sure `line` is in `.gitignore`: create the file if missing, append the
|
|
447
|
+
* line if absent. Returns what happened.
|
|
448
448
|
*/
|
|
449
|
-
|
|
449
|
+
function ensureGitignoreLine(cwd, line) {
|
|
450
450
|
const gitignorePath = path.join(cwd, ".gitignore");
|
|
451
451
|
if (!fs.existsSync(gitignorePath)) {
|
|
452
|
-
fs.writeFileSync(gitignorePath,
|
|
452
|
+
fs.writeFileSync(gitignorePath, `${line}\n`, "utf-8");
|
|
453
453
|
return "created";
|
|
454
454
|
}
|
|
455
455
|
const content = fs.readFileSync(gitignorePath, "utf-8");
|
|
456
|
-
if (content.split(/\r?\n/).some((
|
|
456
|
+
if (content.split(/\r?\n/).some((l) => l.trim() === line))
|
|
457
457
|
return "present";
|
|
458
458
|
const sep = content.length === 0 || content.endsWith("\n") ? "" : "\n";
|
|
459
|
-
fs.appendFileSync(gitignorePath, `${sep}
|
|
459
|
+
fs.appendFileSync(gitignorePath, `${sep}${line}\n`, "utf-8");
|
|
460
460
|
return "appended";
|
|
461
461
|
}
|
|
462
|
+
/** Make sure `.env` is gitignored. */
|
|
463
|
+
export function ensureGitignoreEnv(cwd) {
|
|
464
|
+
return ensureGitignoreLine(cwd, ".env");
|
|
465
|
+
}
|
|
466
|
+
/** Make sure local run history (`.sweny/runs/`) is gitignored. */
|
|
467
|
+
export function ensureGitignoreRuns(cwd) {
|
|
468
|
+
return ensureGitignoreLine(cwd, ".sweny/runs/");
|
|
469
|
+
}
|
|
462
470
|
/** The command to show in next steps: `npx` users have no `sweny` on PATH. */
|
|
463
471
|
function cliCommand() {
|
|
464
472
|
return process.env.npm_command === "exec" ? "npx @sweny-ai/core" : "sweny";
|
|
@@ -784,6 +792,7 @@ export async function runNew(options) {
|
|
|
784
792
|
p.log.success("Created .gitignore with .env");
|
|
785
793
|
else if (ignoreResult === "appended")
|
|
786
794
|
p.log.success("Added .env to .gitignore");
|
|
795
|
+
ensureGitignoreRuns(cwd);
|
|
787
796
|
// 5. Next steps
|
|
788
797
|
const requiredCreds = credentials.filter((c) => !c.optional);
|
|
789
798
|
const credUrls = requiredCreds.filter((c) => c.url).map((c) => ` ${c.key}: ${c.url}`);
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local run history for `sweny workflow run`: one small JSON record per run
|
|
3
|
+
* under `.sweny/runs/`, read back by `sweny runs` and `sweny runs diff`.
|
|
4
|
+
*
|
|
5
|
+
* METADATA ONLY. A record holds ids, statuses, durations, counts, token and
|
|
6
|
+
* cost totals, and routing edges. It never holds prompts, node outputs, tool
|
|
7
|
+
* inputs/outputs, error text, or environment values.
|
|
8
|
+
*
|
|
9
|
+
* `schema_version` is the compatibility contract: a future resume journal
|
|
10
|
+
* builds on this format, so readers skip records with a version they do not
|
|
11
|
+
* know instead of guessing.
|
|
12
|
+
*/
|
|
13
|
+
import type { ExecutionEvent, ExecutionTrace, NodeResult, Workflow } from "../types.js";
|
|
14
|
+
export declare const RUN_HISTORY_SCHEMA_VERSION = 1;
|
|
15
|
+
export declare const RUN_HISTORY_DIR: string;
|
|
16
|
+
export declare const RUN_HISTORY_KEEP = 200;
|
|
17
|
+
export type RunStatus = "success" | "failed" | "crashed";
|
|
18
|
+
export interface RunNodeRecord {
|
|
19
|
+
id: string;
|
|
20
|
+
status: "success" | "failed" | "skipped";
|
|
21
|
+
duration_ms: number;
|
|
22
|
+
tool_calls: number;
|
|
23
|
+
/** input + output tokens; null when the node reported no usage. */
|
|
24
|
+
tokens: number | null;
|
|
25
|
+
/** SDK-reported cost; null when none was reported (never estimated). */
|
|
26
|
+
cost_usd: number | null;
|
|
27
|
+
retries: number;
|
|
28
|
+
}
|
|
29
|
+
export interface RunTotals {
|
|
30
|
+
nodes_total: number;
|
|
31
|
+
nodes_ok: number;
|
|
32
|
+
nodes_skipped: number;
|
|
33
|
+
tool_calls: number;
|
|
34
|
+
tokens: number | null;
|
|
35
|
+
cost_usd: number | null;
|
|
36
|
+
}
|
|
37
|
+
export interface RunRecord {
|
|
38
|
+
schema_version: number;
|
|
39
|
+
run_id: string;
|
|
40
|
+
workflow_id: string;
|
|
41
|
+
workflow_hash: string;
|
|
42
|
+
started_at: string;
|
|
43
|
+
duration_ms: number;
|
|
44
|
+
status: RunStatus;
|
|
45
|
+
nodes: RunNodeRecord[];
|
|
46
|
+
routes: Array<{
|
|
47
|
+
from: string;
|
|
48
|
+
to: string;
|
|
49
|
+
}>;
|
|
50
|
+
totals: RunTotals;
|
|
51
|
+
}
|
|
52
|
+
/** sha256 (hex) of the workflow as canonical JSON: keys sorted, undefined dropped. */
|
|
53
|
+
export declare function hashWorkflow(workflow: Workflow): string;
|
|
54
|
+
/** Sortable id: `YYYYMMDD-HHMMSS-<6 hex>` in UTC, so filename order is run order. */
|
|
55
|
+
export declare function newRunId(startedAtMs: number, rand?: () => string): string;
|
|
56
|
+
export interface NodeTimer {
|
|
57
|
+
observer: (event: ExecutionEvent) => void;
|
|
58
|
+
durations: Map<string, number>;
|
|
59
|
+
/** Last result seen per node: lets a crashed run still record the nodes that finished. */
|
|
60
|
+
lastResults: Map<string, NodeResult>;
|
|
61
|
+
}
|
|
62
|
+
export declare function createNodeTimer(now?: () => number): NodeTimer;
|
|
63
|
+
export interface BuildRunRecordInput {
|
|
64
|
+
runId: string;
|
|
65
|
+
workflow: Workflow;
|
|
66
|
+
startedAtMs: number;
|
|
67
|
+
durationMs: number;
|
|
68
|
+
results: Map<string, NodeResult>;
|
|
69
|
+
trace?: ExecutionTrace;
|
|
70
|
+
nodeDurations?: Map<string, number>;
|
|
71
|
+
crashed?: boolean;
|
|
72
|
+
}
|
|
73
|
+
export declare function buildRunRecord(i: BuildRunRecordInput): RunRecord;
|
|
74
|
+
/** Atomic write: temp file in the same directory, fsync, rename. Returns the path, or null on failure. Never throws. */
|
|
75
|
+
export declare function writeRunRecord(record: RunRecord, cwd?: string): string | null;
|
|
76
|
+
/** Delete the oldest records beyond `keep`. Returns the number removed. Never throws. */
|
|
77
|
+
export declare function pruneRuns(cwd?: string, keep?: number): number;
|
|
78
|
+
/** History is on unless `--no-history` (options.history === false) or `.sweny.yml` has `history: off`. */
|
|
79
|
+
export declare function historyDisabled(optionHistory: unknown, configHistory: unknown): boolean;
|
|
80
|
+
/** Write one record then prune. Never throws; warns on stderr if the write failed. */
|
|
81
|
+
export declare function recordRun(record: RunRecord, cwd?: string): boolean;
|
|
82
|
+
/** All readable records, newest first. Corrupt files and unknown schema versions are skipped. */
|
|
83
|
+
export declare function listRuns(cwd?: string): RunRecord[];
|
|
84
|
+
/** Exact run id, or a unique prefix. */
|
|
85
|
+
export declare function findRun(runs: RunRecord[], ref: string): RunRecord | undefined;
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local run history for `sweny workflow run`: one small JSON record per run
|
|
3
|
+
* under `.sweny/runs/`, read back by `sweny runs` and `sweny runs diff`.
|
|
4
|
+
*
|
|
5
|
+
* METADATA ONLY. A record holds ids, statuses, durations, counts, token and
|
|
6
|
+
* cost totals, and routing edges. It never holds prompts, node outputs, tool
|
|
7
|
+
* inputs/outputs, error text, or environment values.
|
|
8
|
+
*
|
|
9
|
+
* `schema_version` is the compatibility contract: a future resume journal
|
|
10
|
+
* builds on this format, so readers skip records with a version they do not
|
|
11
|
+
* know instead of guessing.
|
|
12
|
+
*/
|
|
13
|
+
import crypto from "node:crypto";
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { summarizeRun } from "./run-output.js";
|
|
17
|
+
export const RUN_HISTORY_SCHEMA_VERSION = 1;
|
|
18
|
+
export const RUN_HISTORY_DIR = path.join(".sweny", "runs");
|
|
19
|
+
export const RUN_HISTORY_KEEP = 200;
|
|
20
|
+
// ── Hash + ids ──────────────────────────────────────────────────
|
|
21
|
+
function canonicalize(value) {
|
|
22
|
+
if (Array.isArray(value))
|
|
23
|
+
return value.map(canonicalize);
|
|
24
|
+
if (value && typeof value === "object") {
|
|
25
|
+
const out = {};
|
|
26
|
+
for (const k of Object.keys(value).sort()) {
|
|
27
|
+
const v = value[k];
|
|
28
|
+
if (v !== undefined)
|
|
29
|
+
out[k] = canonicalize(v);
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
/** sha256 (hex) of the workflow as canonical JSON: keys sorted, undefined dropped. */
|
|
36
|
+
export function hashWorkflow(workflow) {
|
|
37
|
+
return crypto
|
|
38
|
+
.createHash("sha256")
|
|
39
|
+
.update(JSON.stringify(canonicalize(workflow)))
|
|
40
|
+
.digest("hex");
|
|
41
|
+
}
|
|
42
|
+
const RUN_ID_RE = /^\d{8}-\d{6}-[0-9a-f]{6}$/;
|
|
43
|
+
/** Sortable id: `YYYYMMDD-HHMMSS-<6 hex>` in UTC, so filename order is run order. */
|
|
44
|
+
export function newRunId(startedAtMs, rand = () => crypto.randomBytes(3).toString("hex")) {
|
|
45
|
+
const d = new Date(startedAtMs);
|
|
46
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
47
|
+
return (`${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}-` +
|
|
48
|
+
`${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}-${rand()}`);
|
|
49
|
+
}
|
|
50
|
+
export function createNodeTimer(now = Date.now) {
|
|
51
|
+
const entered = new Map();
|
|
52
|
+
const durations = new Map();
|
|
53
|
+
const lastResults = new Map();
|
|
54
|
+
return {
|
|
55
|
+
durations,
|
|
56
|
+
lastResults,
|
|
57
|
+
observer(event) {
|
|
58
|
+
if (event.type === "node:enter")
|
|
59
|
+
entered.set(event.node, now());
|
|
60
|
+
else if (event.type === "node:exit") {
|
|
61
|
+
const start = entered.get(event.node);
|
|
62
|
+
if (start !== undefined)
|
|
63
|
+
durations.set(event.node, (durations.get(event.node) ?? 0) + (now() - start));
|
|
64
|
+
lastResults.set(event.node, event.result);
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
// ── Build the record ────────────────────────────────────────────
|
|
70
|
+
const isNum = (v) => typeof v === "number" && Number.isFinite(v);
|
|
71
|
+
export function buildRunRecord(i) {
|
|
72
|
+
const summary = summarizeRun(i.results, i.durationMs, i.crashed);
|
|
73
|
+
const retries = new Map();
|
|
74
|
+
for (const s of i.trace?.steps ?? []) {
|
|
75
|
+
if (s.retryAttempt !== undefined)
|
|
76
|
+
retries.set(s.node, Math.max(retries.get(s.node) ?? 0, s.retryAttempt));
|
|
77
|
+
}
|
|
78
|
+
const nodes = [...i.results].map(([id, r]) => {
|
|
79
|
+
const u = r.usage;
|
|
80
|
+
const hasTokens = !!u && (isNum(u.inputTokens) || isNum(u.outputTokens));
|
|
81
|
+
const tokens = hasTokens
|
|
82
|
+
? (isNum(u.inputTokens) ? u.inputTokens : 0) + (isNum(u.outputTokens) ? u.outputTokens : 0)
|
|
83
|
+
: null;
|
|
84
|
+
return {
|
|
85
|
+
id,
|
|
86
|
+
status: r.status,
|
|
87
|
+
duration_ms: Math.max(0, Math.round(i.nodeDurations?.get(id) ?? 0)),
|
|
88
|
+
tool_calls: r.toolCalls?.length ?? 0,
|
|
89
|
+
tokens,
|
|
90
|
+
cost_usd: u && isNum(u.costUsd) ? u.costUsd : null,
|
|
91
|
+
retries: retries.get(id) ?? 0,
|
|
92
|
+
};
|
|
93
|
+
});
|
|
94
|
+
return {
|
|
95
|
+
schema_version: RUN_HISTORY_SCHEMA_VERSION,
|
|
96
|
+
run_id: i.runId,
|
|
97
|
+
workflow_id: i.workflow.id,
|
|
98
|
+
workflow_hash: hashWorkflow(i.workflow),
|
|
99
|
+
started_at: new Date(i.startedAtMs).toISOString(),
|
|
100
|
+
duration_ms: Math.max(0, Math.round(i.durationMs)),
|
|
101
|
+
status: i.crashed ? "crashed" : summary.ok ? "success" : "failed",
|
|
102
|
+
nodes,
|
|
103
|
+
routes: (i.trace?.edges ?? []).map((e) => ({ from: e.from, to: e.to })),
|
|
104
|
+
totals: {
|
|
105
|
+
nodes_total: summary.nodesTotal,
|
|
106
|
+
nodes_ok: summary.nodesOk,
|
|
107
|
+
nodes_skipped: summary.nodesSkipped,
|
|
108
|
+
tool_calls: summary.toolCalls,
|
|
109
|
+
tokens: summary.tokens ?? null,
|
|
110
|
+
cost_usd: summary.costUsd ?? null,
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
// ── Write, prune, read ──────────────────────────────────────────
|
|
115
|
+
/** Atomic write: temp file in the same directory, fsync, rename. Returns the path, or null on failure. Never throws. */
|
|
116
|
+
export function writeRunRecord(record, cwd = process.cwd()) {
|
|
117
|
+
const dir = path.join(cwd, RUN_HISTORY_DIR);
|
|
118
|
+
const final = path.join(dir, `${record.run_id}.json`);
|
|
119
|
+
const tmp = path.join(dir, `.${record.run_id}.json.${process.pid}.tmp`);
|
|
120
|
+
try {
|
|
121
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
122
|
+
const fd = fs.openSync(tmp, "w");
|
|
123
|
+
try {
|
|
124
|
+
fs.writeSync(fd, JSON.stringify(record, null, 2) + "\n");
|
|
125
|
+
fs.fsyncSync(fd);
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
fs.closeSync(fd);
|
|
129
|
+
}
|
|
130
|
+
fs.renameSync(tmp, final);
|
|
131
|
+
return final;
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
try {
|
|
135
|
+
fs.rmSync(tmp, { force: true });
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
// nothing to clean
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/** Delete the oldest records beyond `keep`. Returns the number removed. Never throws. */
|
|
144
|
+
export function pruneRuns(cwd = process.cwd(), keep = RUN_HISTORY_KEEP) {
|
|
145
|
+
try {
|
|
146
|
+
const dir = path.join(cwd, RUN_HISTORY_DIR);
|
|
147
|
+
const files = fs
|
|
148
|
+
.readdirSync(dir)
|
|
149
|
+
.filter((f) => f.endsWith(".json") && RUN_ID_RE.test(f.slice(0, -5)))
|
|
150
|
+
.sort();
|
|
151
|
+
const extra = files.slice(0, Math.max(0, files.length - keep));
|
|
152
|
+
for (const f of extra)
|
|
153
|
+
fs.rmSync(path.join(dir, f), { force: true });
|
|
154
|
+
return extra.length;
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return 0;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/** History is on unless `--no-history` (options.history === false) or `.sweny.yml` has `history: off`. */
|
|
161
|
+
export function historyDisabled(optionHistory, configHistory) {
|
|
162
|
+
if (optionHistory === false)
|
|
163
|
+
return true;
|
|
164
|
+
return typeof configHistory === "string" && ["off", "false", "no", "0"].includes(configHistory.trim().toLowerCase());
|
|
165
|
+
}
|
|
166
|
+
/** Write one record then prune. Never throws; warns on stderr if the write failed. */
|
|
167
|
+
export function recordRun(record, cwd = process.cwd()) {
|
|
168
|
+
const written = writeRunRecord(record, cwd);
|
|
169
|
+
if (!written) {
|
|
170
|
+
process.stderr.write(` ⚠ could not write run history to ${RUN_HISTORY_DIR}\n`);
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
pruneRuns(cwd);
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
function isRecord(v) {
|
|
177
|
+
const r = v;
|
|
178
|
+
return (!!r &&
|
|
179
|
+
typeof r === "object" &&
|
|
180
|
+
r.schema_version === RUN_HISTORY_SCHEMA_VERSION &&
|
|
181
|
+
typeof r.run_id === "string" &&
|
|
182
|
+
typeof r.workflow_id === "string" &&
|
|
183
|
+
typeof r.started_at === "string" &&
|
|
184
|
+
Array.isArray(r.nodes) &&
|
|
185
|
+
Array.isArray(r.routes) &&
|
|
186
|
+
!!r.totals &&
|
|
187
|
+
typeof r.totals === "object");
|
|
188
|
+
}
|
|
189
|
+
/** All readable records, newest first. Corrupt files and unknown schema versions are skipped. */
|
|
190
|
+
export function listRuns(cwd = process.cwd()) {
|
|
191
|
+
const dir = path.join(cwd, RUN_HISTORY_DIR);
|
|
192
|
+
let files;
|
|
193
|
+
try {
|
|
194
|
+
files = fs.readdirSync(dir).filter((f) => f.endsWith(".json") && !f.startsWith("."));
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return [];
|
|
198
|
+
}
|
|
199
|
+
const out = [];
|
|
200
|
+
for (const f of files) {
|
|
201
|
+
try {
|
|
202
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(dir, f), "utf-8"));
|
|
203
|
+
if (isRecord(parsed))
|
|
204
|
+
out.push(parsed);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
// corrupt record: skip
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return out.sort((a, b) => a.started_at === b.started_at ? b.run_id.localeCompare(a.run_id) : b.started_at.localeCompare(a.started_at));
|
|
211
|
+
}
|
|
212
|
+
/** Exact run id, or a unique prefix. */
|
|
213
|
+
export function findRun(runs, ref) {
|
|
214
|
+
const exact = runs.find((r) => r.run_id === ref);
|
|
215
|
+
if (exact)
|
|
216
|
+
return exact;
|
|
217
|
+
const matches = runs.filter((r) => r.run_id.startsWith(ref));
|
|
218
|
+
return matches.length === 1 ? matches[0] : undefined;
|
|
219
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sweny runs` and `sweny runs diff`: read-only views over `.sweny/runs/`.
|
|
3
|
+
* Metadata only. Color only when `color` is true (TTY).
|
|
4
|
+
*/
|
|
5
|
+
import type { Command } from "commander";
|
|
6
|
+
import { type RunRecord } from "./run-history.js";
|
|
7
|
+
export declare function formatAge(startedAt: string, nowMs?: number): string;
|
|
8
|
+
export declare function formatRunsTable(runs: RunRecord[], color: boolean, nowMs?: number): string;
|
|
9
|
+
export declare function formatRunDiff(a: RunRecord, b: RunRecord, color: boolean): string;
|
|
10
|
+
/** Pick two runs: explicit refs, or the newest two runs of one workflow (--workflow, else the newest workflow with two or more runs). a = older, b = newer. */
|
|
11
|
+
export declare function pickRunsForDiff(runs: RunRecord[], refs: string[], workflowId?: string): {
|
|
12
|
+
a: RunRecord;
|
|
13
|
+
b: RunRecord;
|
|
14
|
+
} | {
|
|
15
|
+
error: string;
|
|
16
|
+
};
|
|
17
|
+
export declare function registerRunsCommand(program: Command): Command;
|
package/dist/cli/runs.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sweny runs` and `sweny runs diff`: read-only views over `.sweny/runs/`.
|
|
3
|
+
* Metadata only. Color only when `color` is true (TTY).
|
|
4
|
+
*/
|
|
5
|
+
import chalk from "chalk";
|
|
6
|
+
import { findRun, listRuns } from "./run-history.js";
|
|
7
|
+
import { formatCost, formatReceiptDuration, formatTokenCount } from "./run-output.js";
|
|
8
|
+
// ── Small formatters ────────────────────────────────────────────
|
|
9
|
+
export function formatAge(startedAt, nowMs = Date.now()) {
|
|
10
|
+
const s = Math.max(0, Math.round((nowMs - Date.parse(startedAt)) / 1000));
|
|
11
|
+
if (s < 60)
|
|
12
|
+
return "just now";
|
|
13
|
+
const m = Math.floor(s / 60);
|
|
14
|
+
if (m < 60)
|
|
15
|
+
return `${m}m ago`;
|
|
16
|
+
const h = Math.floor(m / 60);
|
|
17
|
+
if (h < 48)
|
|
18
|
+
return `${h}h ago`;
|
|
19
|
+
return `${Math.floor(h / 24)}d ago`;
|
|
20
|
+
}
|
|
21
|
+
const ICON = { success: "✓", failed: "✗", crashed: "✗", skipped: "−" };
|
|
22
|
+
function paint(text, status, color) {
|
|
23
|
+
if (!color)
|
|
24
|
+
return text;
|
|
25
|
+
if (status === "success")
|
|
26
|
+
return chalk.green(text);
|
|
27
|
+
if (status === "skipped")
|
|
28
|
+
return chalk.dim(text);
|
|
29
|
+
return chalk.red(text);
|
|
30
|
+
}
|
|
31
|
+
const tokensText = (n) => (n === null ? "-" : formatTokenCount(n));
|
|
32
|
+
const costText = (n) => (n === null ? "-" : formatCost(n));
|
|
33
|
+
function pad(s, w, right = false) {
|
|
34
|
+
return right ? s.padStart(w) : s.padEnd(w);
|
|
35
|
+
}
|
|
36
|
+
// ── sweny runs ──────────────────────────────────────────────────
|
|
37
|
+
export function formatRunsTable(runs, color, nowMs = Date.now()) {
|
|
38
|
+
if (runs.length === 0)
|
|
39
|
+
return " No runs recorded yet. Run `sweny workflow run <file>` to start a history.\n";
|
|
40
|
+
const rows = runs.map((r) => ({
|
|
41
|
+
status: r.status,
|
|
42
|
+
cells: [
|
|
43
|
+
r.run_id,
|
|
44
|
+
r.workflow_id,
|
|
45
|
+
`${ICON[r.status] ?? "?"} ${r.status}`,
|
|
46
|
+
formatReceiptDuration(r.duration_ms),
|
|
47
|
+
tokensText(r.totals?.tokens ?? null),
|
|
48
|
+
costText(r.totals?.cost_usd ?? null),
|
|
49
|
+
formatAge(r.started_at, nowMs),
|
|
50
|
+
],
|
|
51
|
+
}));
|
|
52
|
+
const head = ["ID", "WORKFLOW", "STATUS", "DURATION", "TOKENS", "COST", "AGE"];
|
|
53
|
+
const rightAligned = new Set([3, 4, 5]);
|
|
54
|
+
const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => r.cells[i].length)));
|
|
55
|
+
const line = (cells, status) => " " +
|
|
56
|
+
cells
|
|
57
|
+
.map((c, i) => {
|
|
58
|
+
let t = pad(c, widths[i], rightAligned.has(i));
|
|
59
|
+
if (i === 2 && status)
|
|
60
|
+
t = paint(t, status, color);
|
|
61
|
+
return t;
|
|
62
|
+
})
|
|
63
|
+
.join(" ")
|
|
64
|
+
.trimEnd();
|
|
65
|
+
const headLine = line(head);
|
|
66
|
+
return [color ? chalk.dim(headLine) : headLine, ...rows.map((r) => line(r.cells, r.status))].join("\n") + "\n";
|
|
67
|
+
}
|
|
68
|
+
// ── sweny runs diff ─────────────────────────────────────────────
|
|
69
|
+
const sign = (n) => (n > 0 ? "+" : n < 0 ? "-" : "");
|
|
70
|
+
/** Like the receipt duration, but keeps one decimal under 10s so small deltas stay visible. */
|
|
71
|
+
function fmtDur(ms) {
|
|
72
|
+
return ms >= 1000 && ms < 10_000 ? `${Math.round(ms / 100) / 10}s` : formatReceiptDuration(ms);
|
|
73
|
+
}
|
|
74
|
+
const deltaDuration = (a, b) => `${sign(b - a)}${fmtDur(Math.abs(b - a))}`;
|
|
75
|
+
const deltaTokens = (a, b) => `${sign(b - a)}${formatTokenCount(Math.abs(b - a))}`;
|
|
76
|
+
const deltaCost = (a, b) => `${sign(b - a)}${formatCost(Math.abs(b - a))}`;
|
|
77
|
+
const deltaCount = (a, b) => `${sign(b - a)}${Math.abs(b - a)}`;
|
|
78
|
+
/** `label a → b (+delta)` for a numeric metric; null when unchanged. */
|
|
79
|
+
function metric(label, a, b, show, delta) {
|
|
80
|
+
if (a === b)
|
|
81
|
+
return null;
|
|
82
|
+
if (a === null || b === null)
|
|
83
|
+
return `${label} ${a === null ? "-" : show(a)} → ${b === null ? "-" : show(b)}`;
|
|
84
|
+
return `${label} ${show(a)} → ${show(b)} (${delta(a, b)})`;
|
|
85
|
+
}
|
|
86
|
+
function nodeChanges(a, b) {
|
|
87
|
+
return [
|
|
88
|
+
metric("duration", a.duration_ms, b.duration_ms, fmtDur, deltaDuration),
|
|
89
|
+
metric("tokens", a.tokens, b.tokens, formatTokenCount, deltaTokens),
|
|
90
|
+
metric("cost", a.cost_usd, b.cost_usd, formatCost, deltaCost),
|
|
91
|
+
metric("tools", a.tool_calls, b.tool_calls, String, deltaCount),
|
|
92
|
+
metric("retries", a.retries, b.retries, String, deltaCount),
|
|
93
|
+
].filter((x) => x !== null);
|
|
94
|
+
}
|
|
95
|
+
const routeKey = (r) => `${r.from} → ${r.to}`;
|
|
96
|
+
export function formatRunDiff(a, b, color) {
|
|
97
|
+
const dim = (s) => (color ? chalk.dim(s) : s);
|
|
98
|
+
const out = [];
|
|
99
|
+
const hdr = (tag, r) => ` ${tag} ${r.run_id} ${r.workflow_id} ${paint(`${ICON[r.status] ?? "?"} ${r.status}`, r.status, color)} ${formatReceiptDuration(r.duration_ms)}`;
|
|
100
|
+
out.push(hdr("a", a), hdr("b", b), "");
|
|
101
|
+
const short = (h) => h.slice(0, 7);
|
|
102
|
+
out.push(a.workflow_hash === b.workflow_hash
|
|
103
|
+
? ` workflow unchanged (${short(a.workflow_hash)})`
|
|
104
|
+
: ` workflow changed ${short(a.workflow_hash)} → ${short(b.workflow_hash)}`);
|
|
105
|
+
if (a.workflow_id !== b.workflow_id)
|
|
106
|
+
out.push(` id ${a.workflow_id} → ${b.workflow_id}`);
|
|
107
|
+
out.push(a.status === b.status
|
|
108
|
+
? ` status ${ICON[b.status]} ${b.status} (same)`
|
|
109
|
+
: ` status ${ICON[a.status]} ${a.status} → ${paint(`${ICON[b.status]} ${b.status}`, b.status, color)}`);
|
|
110
|
+
const tot = [
|
|
111
|
+
metric("duration ", a.duration_ms, b.duration_ms, formatReceiptDuration, deltaDuration),
|
|
112
|
+
metric("tokens ", a.totals.tokens, b.totals.tokens, formatTokenCount, deltaTokens),
|
|
113
|
+
metric("cost ", a.totals.cost_usd, b.totals.cost_usd, formatCost, deltaCost),
|
|
114
|
+
];
|
|
115
|
+
for (const t of tot)
|
|
116
|
+
if (t)
|
|
117
|
+
out.push(` ${t}`);
|
|
118
|
+
out.push("", " nodes");
|
|
119
|
+
const aNodes = new Map(a.nodes.map((n) => [n.id, n]));
|
|
120
|
+
const bNodes = new Map(b.nodes.map((n) => [n.id, n]));
|
|
121
|
+
const ids = [...new Set([...b.nodes.map((n) => n.id), ...a.nodes.map((n) => n.id)])];
|
|
122
|
+
const w = Math.max(4, ...ids.map((i) => i.length));
|
|
123
|
+
for (const id of ids) {
|
|
124
|
+
const na = aNodes.get(id);
|
|
125
|
+
const nb = bNodes.get(id);
|
|
126
|
+
if (na && !nb) {
|
|
127
|
+
out.push(` ${pad(id, w)} ${dim("removed")} (was ${ICON[na.status]} ${na.status})`);
|
|
128
|
+
}
|
|
129
|
+
else if (!na && nb) {
|
|
130
|
+
out.push(` ${pad(id, w)} ${paint(`added ${ICON[nb.status]} ${nb.status}`, nb.status, color)}`);
|
|
131
|
+
}
|
|
132
|
+
else if (na && nb) {
|
|
133
|
+
const st = na.status === nb.status ? ICON[nb.status] : `${ICON[na.status]} → ${paint(ICON[nb.status], nb.status, color)}`;
|
|
134
|
+
const ch = nodeChanges(na, nb);
|
|
135
|
+
out.push(` ${pad(id, w)} ${st} ${ch.length ? ch.join(" ") : dim("no change")}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
out.push("", " routes");
|
|
139
|
+
const ar = new Set(a.routes.map(routeKey));
|
|
140
|
+
const br = new Set(b.routes.map(routeKey));
|
|
141
|
+
const onlyA = [...ar].filter((r) => !br.has(r));
|
|
142
|
+
const onlyB = [...br].filter((r) => !ar.has(r));
|
|
143
|
+
if (onlyA.length === 0 && onlyB.length === 0)
|
|
144
|
+
out.push(" identical");
|
|
145
|
+
for (const r of onlyA)
|
|
146
|
+
out.push(` - ${r} ${dim("(only in a)")}`);
|
|
147
|
+
for (const r of onlyB)
|
|
148
|
+
out.push(` + ${r} ${dim("(only in b)")}`);
|
|
149
|
+
return out.join("\n") + "\n";
|
|
150
|
+
}
|
|
151
|
+
/** Pick two runs: explicit refs, or the newest two runs of one workflow (--workflow, else the newest workflow with two or more runs). a = older, b = newer. */
|
|
152
|
+
export function pickRunsForDiff(runs, refs, workflowId) {
|
|
153
|
+
if (refs.length === 1)
|
|
154
|
+
return { error: "Pass two run ids, or none to diff the last two runs of a workflow." };
|
|
155
|
+
if (refs.length === 2) {
|
|
156
|
+
const a = findRun(runs, refs[0]);
|
|
157
|
+
const b = findRun(runs, refs[1]);
|
|
158
|
+
if (!a)
|
|
159
|
+
return { error: `No run matches "${refs[0]}". Run \`sweny runs\` to list ids.` };
|
|
160
|
+
if (!b)
|
|
161
|
+
return { error: `No run matches "${refs[1]}". Run \`sweny runs\` to list ids.` };
|
|
162
|
+
return { a, b };
|
|
163
|
+
}
|
|
164
|
+
// No --workflow: the newest workflow that has at least two runs to compare.
|
|
165
|
+
const counts = new Map();
|
|
166
|
+
for (const r of runs)
|
|
167
|
+
counts.set(r.workflow_id, (counts.get(r.workflow_id) ?? 0) + 1);
|
|
168
|
+
const wf = workflowId ?? runs.find((r) => (counts.get(r.workflow_id) ?? 0) >= 2)?.workflow_id ?? runs[0]?.workflow_id;
|
|
169
|
+
const same = wf ? runs.filter((r) => r.workflow_id === wf) : [];
|
|
170
|
+
if (same.length < 2) {
|
|
171
|
+
return { error: wf ? `Need two runs of "${wf}" to diff; found ${same.length}.` : "No runs recorded yet." };
|
|
172
|
+
}
|
|
173
|
+
return { a: same[1], b: same[0] };
|
|
174
|
+
}
|
|
175
|
+
// ── Command wiring ──────────────────────────────────────────────
|
|
176
|
+
export function registerRunsCommand(program) {
|
|
177
|
+
const runsCmd = program
|
|
178
|
+
.command("runs")
|
|
179
|
+
.description("List recent workflow runs recorded in .sweny/runs/ (metadata only)")
|
|
180
|
+
.option("--workflow <id>", "Only runs of this workflow")
|
|
181
|
+
.option("--limit <n>", "Max runs to show", "20")
|
|
182
|
+
.option("--json", "Output run records as JSON")
|
|
183
|
+
.action((opts) => {
|
|
184
|
+
const limit = Number(opts.limit ?? "20");
|
|
185
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
186
|
+
console.error(chalk.red(" --limit must be a positive integer"));
|
|
187
|
+
process.exitCode = 1;
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
let runs = listRuns();
|
|
191
|
+
if (opts.workflow)
|
|
192
|
+
runs = runs.filter((r) => r.workflow_id === opts.workflow);
|
|
193
|
+
runs = runs.slice(0, limit);
|
|
194
|
+
if (opts.json) {
|
|
195
|
+
process.stdout.write(JSON.stringify(runs, null, 2) + "\n");
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
process.stdout.write("\n" + formatRunsTable(runs, process.stdout.isTTY ?? false) + "\n");
|
|
199
|
+
});
|
|
200
|
+
runsCmd
|
|
201
|
+
.command("diff [a] [b]")
|
|
202
|
+
.description("Compare two runs (default: the last two runs of the same workflow)")
|
|
203
|
+
.option("--workflow <id>", "Workflow to pick the default pair from")
|
|
204
|
+
.action((a, b, opts) => {
|
|
205
|
+
const refs = [a, b].filter((x) => x !== undefined);
|
|
206
|
+
const runs = listRuns();
|
|
207
|
+
const picked = pickRunsForDiff(runs, refs, opts.workflow);
|
|
208
|
+
if ("error" in picked) {
|
|
209
|
+
console.error(chalk.red(` ${picked.error}`));
|
|
210
|
+
process.exitCode = 1;
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
process.stdout.write("\n" + formatRunDiff(picked.a, picked.b, process.stdout.isTTY ?? false) + "\n");
|
|
214
|
+
});
|
|
215
|
+
return runsCmd;
|
|
216
|
+
}
|