@sweny-ai/core 0.1.184 → 0.1.186

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.
@@ -0,0 +1,451 @@
1
+ /**
2
+ * What a run answered, kept so the next run can say what changed.
3
+ *
4
+ * `sweny workflow run` saves `.sweny/runs/<run-id>/answers.json` beside the
5
+ * output.md: each successful node's DECLARED output fields (the node's
6
+ * `output` schema is the contract; with no schema, only a string `summary`).
7
+ * Local only, mode 0600, redacted with the run journal's redactor before
8
+ * anything is stored. The run record itself (run-history.ts) stays metadata
9
+ * only; answers live in their own file so that contract is untouched.
10
+ *
11
+ * Everything under `.sweny/runs/` is writable by an agent, so it is untrusted:
12
+ * ids are validated against the generator's pattern before any path is built,
13
+ * the embedded id must match the one asked for, and the PUBLIC line (PR
14
+ * comment, step summary) carries only the current workflow's own id and
15
+ * integer counts, never text read from history.
16
+ *
17
+ * `compareAnswers` is the diff behind `sweny runs diff` and the "since last
18
+ * run" line. Identity rules (what counts as "the same finding") are explicit
19
+ * and tested; see {@link itemIdentity}.
20
+ */
21
+ import crypto from "node:crypto";
22
+ import fs from "node:fs";
23
+ import path from "node:path";
24
+ import { redact } from "../journal.js";
25
+ import { writeFileNoFollow } from "../safe-file.js";
26
+ import { RUN_HISTORY_DIR, RUN_ID_RE, listRuns } from "./run-history.js";
27
+ export const RUN_ANSWERS_SCHEMA_VERSION = 1;
28
+ /** Strings longer than this are cut (with an ellipsis) before they are stored. */
29
+ export const ANSWER_STRING_MAX = 2000;
30
+ const ANSWERS_READ_MAX_BYTES = 4 * 1024 * 1024;
31
+ const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
32
+ // ── Build, write, read ──────────────────────────────────────────
33
+ const has = (o, k) => Object.hasOwn(o, k);
34
+ const sha = (s) => crypto.createHash("sha256").update(s).digest("hex");
35
+ /**
36
+ * Keep only what the schema declares, deep: `findings[].title` keeps `title` and nothing else. An
37
+ * object with no declared properties is dropped (nothing in it was declared); scalars are kept.
38
+ * Undefined means "drop".
39
+ */
40
+ function pick(value, schema) {
41
+ const sch = isObj(schema) ? schema : undefined;
42
+ if (Array.isArray(value)) {
43
+ const items = sch && isObj(sch.items) ? sch.items : undefined;
44
+ return value.map((v) => pick(v, items)).filter((v) => v !== undefined);
45
+ }
46
+ if (isObj(value)) {
47
+ const props = sch && isObj(sch.properties) ? sch.properties : undefined;
48
+ if (!props)
49
+ return undefined;
50
+ return Object.fromEntries(Object.keys(props)
51
+ .filter((k) => has(value, k))
52
+ .map((k) => [k, pick(value[k], props[k])])
53
+ .filter(([, v]) => v !== undefined));
54
+ }
55
+ return value;
56
+ }
57
+ /** Secrets in keys too (`redact` scrubs values and keeps keys). */
58
+ function redactKeys(value, secrets) {
59
+ if (Array.isArray(value))
60
+ return value.map((v) => redactKeys(v, secrets));
61
+ if (!isObj(value))
62
+ return value;
63
+ const seen = new Map();
64
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => {
65
+ let name = redact(k, secrets).value;
66
+ const n = (seen.get(name) ?? 0) + 1;
67
+ seen.set(name, n);
68
+ if (n > 1)
69
+ name = `${name}#${n}`;
70
+ return [name, redactKeys(v, secrets)];
71
+ }));
72
+ }
73
+ /** [display value, digest tree]: strings over the cap are cut, and their full-text sha256 goes in the tree. */
74
+ function clipTree(value) {
75
+ if (typeof value === "string") {
76
+ return value.length > ANSWER_STRING_MAX
77
+ ? [value.slice(0, ANSWER_STRING_MAX) + "…", sha(value)]
78
+ : [value, undefined];
79
+ }
80
+ if (Array.isArray(value)) {
81
+ const pairs = value.map(clipTree);
82
+ return [pairs.map((p) => p[0]), pairs.some((p) => p[1] !== undefined) ? pairs.map((p) => p[1] ?? null) : undefined];
83
+ }
84
+ if (isObj(value)) {
85
+ const pairs = Object.entries(value).map(([k, v]) => [k, clipTree(v)]);
86
+ const digs = pairs.filter(([, p]) => p[1] !== undefined).map(([k, p]) => [k, p[1]]);
87
+ return [
88
+ Object.fromEntries(pairs.map(([k, p]) => [k, p[0]])),
89
+ digs.length > 0 ? Object.fromEntries(digs) : undefined,
90
+ ];
91
+ }
92
+ return [value, undefined];
93
+ }
94
+ /**
95
+ * Declared fields of each successful node, deep, redacted (values and keys). A failed or skipped
96
+ * node records nothing, so the diff never reads a failure as "everything resolved".
97
+ */
98
+ export function buildRunAnswers(i) {
99
+ const secrets = i.secrets ?? [];
100
+ const nodes = [];
101
+ const digests = [];
102
+ for (const [id, r] of i.results) {
103
+ if (r.status !== "success")
104
+ continue;
105
+ const data = r.data ?? {};
106
+ const schema = has(i.workflow.nodes, id) ? i.workflow.nodes[id]?.output : undefined;
107
+ const props = isObj(schema) ? schema.properties : undefined;
108
+ let fields;
109
+ if (isObj(props) && Object.keys(props).length > 0) {
110
+ fields = pick(data, schema);
111
+ }
112
+ else if (typeof data.summary === "string" && data.summary.trim() !== "") {
113
+ fields = { summary: data.summary };
114
+ }
115
+ if (!isObj(fields) || Object.keys(fields).length === 0)
116
+ continue;
117
+ const clean = redactKeys(redact(fields, secrets).value, secrets);
118
+ const [shown, digs] = clipTree(clean);
119
+ nodes.push([id, shown]);
120
+ if (digs !== undefined)
121
+ digests.push([id, digs]);
122
+ }
123
+ return {
124
+ schema_version: RUN_ANSWERS_SCHEMA_VERSION,
125
+ run_id: i.runId,
126
+ workflow_id: i.workflow.id,
127
+ nodes: Object.fromEntries(nodes),
128
+ ...(digests.length > 0 ? { digests: Object.fromEntries(digests) } : {}),
129
+ };
130
+ }
131
+ /** Relative path: `.sweny/runs/<id>/answers.json`. */
132
+ export function answersRelPath(runId) {
133
+ return path.posix.join(...RUN_HISTORY_DIR.split(path.sep), runId, "answers.json");
134
+ }
135
+ /** Write answers.json (mode 0600; the run dir ignores itself in git). Returns the relative path, or null. Never throws. */
136
+ export function writeRunAnswers(answers, cwd = process.cwd()) {
137
+ try {
138
+ if (!RUN_ID_RE.test(answers.run_id))
139
+ return null;
140
+ const rel = answersRelPath(answers.run_id);
141
+ const file = path.join(cwd, rel);
142
+ writeFileNoFollow(path.join(path.dirname(file), ".gitignore"), "*\n", { root: cwd, mode: 0o644 });
143
+ writeFileNoFollow(file, JSON.stringify(answers, null, 2) + "\n", { root: cwd, mode: 0o600 });
144
+ return rel;
145
+ }
146
+ catch {
147
+ return null;
148
+ }
149
+ }
150
+ /**
151
+ * Read a run's answers. Null when the id is not a generated run id (checked before any path is
152
+ * built), the file is missing, oversized, reached through a link, corrupt, an unknown schema
153
+ * version, or carries a different run id than the one asked for.
154
+ */
155
+ export function readRunAnswers(runId, cwd = process.cwd()) {
156
+ try {
157
+ if (typeof runId !== "string" || !RUN_ID_RE.test(runId))
158
+ return null;
159
+ const rel = answersRelPath(runId);
160
+ const file = path.join(cwd, rel);
161
+ // No link on the path: the real path must be exactly where the id says it is.
162
+ if (fs.realpathSync(file) !== path.join(fs.realpathSync(cwd), rel))
163
+ return null;
164
+ const st = fs.lstatSync(file);
165
+ if (!st.isFile() || st.size > ANSWERS_READ_MAX_BYTES)
166
+ return null;
167
+ const parsed = JSON.parse(fs.readFileSync(file, "utf-8"));
168
+ if (!isObj(parsed) ||
169
+ parsed.schema_version !== RUN_ANSWERS_SCHEMA_VERSION ||
170
+ parsed.run_id !== runId ||
171
+ typeof parsed.workflow_id !== "string" ||
172
+ !isObj(parsed.nodes) ||
173
+ !Object.values(parsed.nodes).every(isObj) ||
174
+ (parsed.digests !== undefined && (!isObj(parsed.digests) || !Object.values(parsed.digests).every(isObj)))) {
175
+ return null;
176
+ }
177
+ return parsed;
178
+ }
179
+ catch {
180
+ return null;
181
+ }
182
+ }
183
+ // ── Identity ────────────────────────────────────────────────────
184
+ /** Fields that name an item, in priority order. */
185
+ export const IDENTITY_KEYS = ["id", "key", "fingerprint", "title", "name"];
186
+ const normalize = (s) => s.trim().toLowerCase().replace(/\s+/g, " ");
187
+ function canonical(value) {
188
+ const walk = (v) => {
189
+ if (Array.isArray(v))
190
+ return v.map(walk);
191
+ if (isObj(v)) {
192
+ return Object.fromEntries(Object.keys(v)
193
+ .sort()
194
+ .filter((k) => v[k] !== undefined)
195
+ .map((k) => [k, walk(v[k])]));
196
+ }
197
+ return v;
198
+ };
199
+ return JSON.stringify(walk(value));
200
+ }
201
+ const isScalar = (v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean";
202
+ /** The raw (un-normalized) identity value of an object item, or undefined. */
203
+ function identityRaw(item) {
204
+ for (const k of IDENTITY_KEYS) {
205
+ const v = has(item, k) ? item[k] : undefined;
206
+ if (typeof v === "string" && v.trim() !== "")
207
+ return v;
208
+ if (typeof v === "number" && Number.isFinite(v))
209
+ return String(v);
210
+ }
211
+ return undefined;
212
+ }
213
+ /**
214
+ * "Is this the same finding as that one?" The rule, in order:
215
+ * 1. An object item is identified by the first of `id`, `key`, `fingerprint`,
216
+ * `title`, `name` that holds a non-empty string or a number, normalized
217
+ * (trimmed, lowercased, whitespace collapsed).
218
+ * 2. An object with none of those is identified by its whole content (a
219
+ * short hash of its canonical JSON): any edit reads as resolved + added.
220
+ * 3. A plain string, number or boolean item is identified by its normalized value.
221
+ * Two items of one list with the same identity pair up in order.
222
+ */
223
+ export function itemIdentity(item) {
224
+ if (isObj(item)) {
225
+ const raw = identityRaw(item);
226
+ if (raw !== undefined)
227
+ return normalize(raw);
228
+ return "content:" + crypto.createHash("sha256").update(canonical(item)).digest("hex").slice(0, 12);
229
+ }
230
+ return normalize(String(item));
231
+ }
232
+ function itemLabel(item) {
233
+ if (!isObj(item))
234
+ return String(item);
235
+ const raw = identityRaw(item);
236
+ if (raw !== undefined)
237
+ return raw;
238
+ for (const v of Object.values(item))
239
+ if (typeof v === "string" && v.trim() !== "")
240
+ return v;
241
+ return canonical(item);
242
+ }
243
+ /** A list of objects, or a list of scalars. Mixed lists are compared as one opaque value. */
244
+ function isCollection(v) {
245
+ return Array.isArray(v) && (v.every(isObj) || v.every(isScalar));
246
+ }
247
+ /** The digest subtree for `key` (an object key or array index), if any. */
248
+ function sub(d, key) {
249
+ if (Array.isArray(d) && typeof key === "number")
250
+ return d[key] ?? undefined;
251
+ if (isObj(d) && typeof key === "string" && has(d, key))
252
+ return d[key];
253
+ return undefined;
254
+ }
255
+ /** Equal stored text AND equal digests: equal full values. */
256
+ const sameHeld = (a, b) => canonical(a.v) === canonical(b.v) && canonical(a.d ?? null) === canonical(b.d ?? null);
257
+ function indexByIdentity(items) {
258
+ const m = new Map();
259
+ for (const it of items) {
260
+ const id = itemIdentity(it.v);
261
+ const list = m.get(id);
262
+ if (list)
263
+ list.push(it);
264
+ else
265
+ m.set(id, [it]);
266
+ }
267
+ return m;
268
+ }
269
+ function fieldChanges(before, after) {
270
+ if (!isObj(before.v) || !isObj(after.v))
271
+ return [{ field: "value", before: before.v, after: after.v }];
272
+ const b = before.v;
273
+ const a = after.v;
274
+ const out = [];
275
+ for (const k of [...new Set([...Object.keys(b), ...Object.keys(a)])].sort()) {
276
+ const had = has(b, k) && b[k] !== undefined;
277
+ const hasNow = has(a, k) && a[k] !== undefined;
278
+ if (had && hasNow) {
279
+ if (!sameHeld({ v: b[k], d: sub(before.d, k) }, { v: a[k], d: sub(after.d, k) })) {
280
+ out.push({ field: k, before: b[k], after: a[k] });
281
+ }
282
+ }
283
+ else if (had)
284
+ out.push({ field: k, before: b[k] });
285
+ else if (hasNow)
286
+ out.push({ field: k, after: a[k] });
287
+ }
288
+ return out;
289
+ }
290
+ const heldItems = (h) => h && Array.isArray(h.v) ? h.v.map((v, i) => ({ v, d: sub(h.d, i) })) : [];
291
+ function compareCollection(node, field, a, b) {
292
+ const ai = indexByIdentity(heldItems(a));
293
+ const bi = indexByIdentity(heldItems(b));
294
+ const e = { node, field, kind: "collection", added: [], resolved: [], changed: [], unchanged: 0 };
295
+ for (const [id, bItems] of bi) {
296
+ const aItems = ai.get(id) ?? [];
297
+ bItems.forEach((item, n) => {
298
+ if (n >= aItems.length) {
299
+ e.added.push({ id, label: itemLabel(item.v), value: item.v });
300
+ }
301
+ else if (sameHeld(aItems[n], item)) {
302
+ e.unchanged++;
303
+ }
304
+ else {
305
+ e.changed.push({ id, label: itemLabel(item.v), fields: fieldChanges(aItems[n], item) });
306
+ }
307
+ });
308
+ }
309
+ for (const [id, aItems] of ai) {
310
+ const bCount = bi.get(id)?.length ?? 0;
311
+ aItems.slice(bCount).forEach((item) => e.resolved.push({ id, label: itemLabel(item.v), value: item.v }));
312
+ }
313
+ return e;
314
+ }
315
+ function compareField(node, field, a, b) {
316
+ if ((a === undefined || isCollection(a.v)) &&
317
+ (b === undefined || isCollection(b.v)) &&
318
+ (a !== undefined || b !== undefined)) {
319
+ const e = compareCollection(node, field, a, b);
320
+ return e.added.length + e.resolved.length + e.changed.length + e.unchanged === 0 ? null : e;
321
+ }
322
+ if (!a)
323
+ return { node, field, kind: "value", status: "added", after: b?.v };
324
+ if (!b)
325
+ return { node, field, kind: "value", status: "resolved", before: a.v };
326
+ if (sameHeld(a, b))
327
+ return { node, field, kind: "value", status: "unchanged" };
328
+ return { node, field, kind: "value", status: "changed", before: a.v, after: b.v };
329
+ }
330
+ /** node -> field -> value + digests. Maps, so a node or field named `constructor` or `__proto__` is just a name. */
331
+ function heldByNode(r) {
332
+ const out = new Map();
333
+ for (const [node, fields] of Object.entries(r.nodes)) {
334
+ const digs = r.digests && has(r.digests, node) && isObj(r.digests[node]) ? r.digests[node] : undefined;
335
+ out.set(node, new Map(Object.entries(fields).map(([f, v]) => [f, { v, d: digs ? sub(digs, f) : undefined }])));
336
+ }
337
+ return out;
338
+ }
339
+ /**
340
+ * Compare an older run's answers (a) with a newer one's (b), node by node and
341
+ * field by field. A list field (all objects, or all scalars) is diffed item by
342
+ * item using {@link itemIdentity}; any other field is one value. Cut strings
343
+ * compare by the digest of their full text.
344
+ */
345
+ export function compareAnswers(a, b) {
346
+ if (!a || !b) {
347
+ const missing = [];
348
+ if (!a)
349
+ missing.push("a");
350
+ if (!b)
351
+ missing.push("b");
352
+ return { available: false, reason: "missing_answers", missing };
353
+ }
354
+ if (a.workflow_id !== b.workflow_id)
355
+ return { available: false, reason: "different_workflows" };
356
+ const am = heldByNode(a);
357
+ const bm = heldByNode(b);
358
+ const counts = { added: 0, resolved: 0, changed: 0, unchanged: 0 };
359
+ const entries = [];
360
+ const not_compared = [];
361
+ for (const node of new Set([...bm.keys(), ...am.keys()])) {
362
+ const an = am.get(node);
363
+ const bn = bm.get(node);
364
+ if (!an || !bn) {
365
+ not_compared.push({ node, only_in: an ? "a" : "b" });
366
+ continue;
367
+ }
368
+ for (const field of new Set([...bn.keys(), ...an.keys()])) {
369
+ const e = compareField(node, field, an.get(field), bn.get(field));
370
+ if (!e)
371
+ continue;
372
+ entries.push(e);
373
+ if (e.kind === "collection") {
374
+ counts.added += e.added.length;
375
+ counts.resolved += e.resolved.length;
376
+ counts.changed += e.changed.length;
377
+ counts.unchanged += e.unchanged;
378
+ }
379
+ else {
380
+ counts[e.status]++;
381
+ }
382
+ }
383
+ }
384
+ return { available: true, counts, entries, not_compared };
385
+ }
386
+ /**
387
+ * Compare a recorded run with the run of the same workflow just before it. `workflowId` comes from
388
+ * the caller's loaded workflow; history only picks WHICH earlier run, never what is printed.
389
+ * Null when there is no earlier run, either run has no answers, or anything fails.
390
+ */
391
+ export function sinceLastRun(runId, workflowId, cwd = process.cwd()) {
392
+ try {
393
+ if (!RUN_ID_RE.test(runId))
394
+ return null;
395
+ const previous = listRuns(cwd).find(
396
+ // newest first
397
+ (r) => r.workflow_id === workflowId && r.run_id !== runId && r.run_id < runId);
398
+ if (!previous)
399
+ return null;
400
+ const cmp = compareAnswers(readRunAnswers(previous.run_id, cwd), readRunAnswers(runId, cwd));
401
+ if (!cmp.available)
402
+ return null;
403
+ return {
404
+ workflow_id: workflowId,
405
+ previous_run_id: previous.run_id,
406
+ counts: cmp.counts,
407
+ not_compared: cmp.not_compared.length,
408
+ };
409
+ }
410
+ catch {
411
+ return null;
412
+ }
413
+ }
414
+ /** Save this run's answers, then compare with the previous run. Never throws. */
415
+ export function recordRunAnswers(i, cwd = process.cwd()) {
416
+ try {
417
+ if (!writeRunAnswers(buildRunAnswers(i), cwd))
418
+ return null;
419
+ return sinceLastRun(i.runId, i.workflow.id, cwd);
420
+ }
421
+ catch {
422
+ return null;
423
+ }
424
+ }
425
+ // The public line is built from a fixed template, a validated id and integers: nothing else.
426
+ /** A workflow id that is safe in a shell word and a markdown code span. Anything else drops the flag. */
427
+ const SAFE_WORKFLOW_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
428
+ const count = (n) => (typeof n === "number" && Number.isSafeInteger(n) && n >= 0 ? n : 0);
429
+ const diffCommand = (s) => typeof s.workflow_id === "string" && SAFE_WORKFLOW_ID.test(s.workflow_id)
430
+ ? `sweny runs diff --workflow ${s.workflow_id}`
431
+ : "sweny runs diff";
432
+ /** `2 added, 1 resolved, 1 changed` from the nonzero counts (`no changes` when none), then the not-compared nodes. */
433
+ function countsText(s) {
434
+ const c = s.counts ?? {};
435
+ const parts = [
436
+ count(c.added) ? `${count(c.added)} added` : "",
437
+ count(c.resolved) ? `${count(c.resolved)} resolved` : "",
438
+ count(c.changed) ? `${count(c.changed)} changed` : "",
439
+ ].filter(Boolean);
440
+ const text = parts.length > 0 ? parts.join(", ") : "no changes";
441
+ const skipped = count(s.not_compared);
442
+ return skipped ? `${text}, ${skipped} not compared: failed or no output` : text;
443
+ }
444
+ /** Plain text, one line: for the terminal and CI logs. */
445
+ export function formatSinceLast(s) {
446
+ return `since last run: ${countsText(s)} (${diffCommand(s)})`;
447
+ }
448
+ /** Markdown, one line: for the step summary and the PR comment. */
449
+ export function formatSinceLastMarkdown(s) {
450
+ return `Since last run: ${countsText(s)}. Compare: \`${diffCommand(s)}\``;
451
+ }
@@ -4,7 +4,8 @@
4
4
  *
5
5
  * METADATA ONLY. A record holds ids, statuses, durations, counts, token and
6
6
  * cost totals, and routing edges. It never holds prompts, node outputs, tool
7
- * inputs/outputs, error text, or environment values.
7
+ * inputs/outputs, error text, or environment values. What a run answered is
8
+ * kept apart, in `<run-id>/answers.json` (run-answers.ts).
8
9
  *
9
10
  * `schema_version` is the compatibility contract: a future resume journal
10
11
  * builds on this format, so readers skip records with a version they do not
@@ -60,6 +61,8 @@ export interface RunRecord {
60
61
  }
61
62
  /** sha256 (hex) of the workflow as canonical JSON: keys sorted, undefined dropped. */
62
63
  export declare function hashWorkflow(workflow: Workflow): string;
64
+ /** The shape `newRunId` generates. Anything else is not a run id. */
65
+ export declare const RUN_ID_RE: RegExp;
63
66
  /** Sortable id: `YYYYMMDD-HHMMSS-<6 hex>` in UTC, so filename order is run order. */
64
67
  export declare function newRunId(startedAtMs: number, rand?: () => string): string;
65
68
  export interface NodeTimer {
@@ -88,7 +91,12 @@ export declare function pruneRuns(cwd?: string, keep?: number): number;
88
91
  export declare function historyDisabled(optionHistory: unknown, configHistory: unknown): boolean;
89
92
  /** Write one record then prune. Never throws; warns on stderr if the write failed. */
90
93
  export declare function recordRun(record: RunRecord, cwd?: string): boolean;
91
- /** All readable records, newest first. Corrupt files and unknown schema versions are skipped. */
94
+ /**
95
+ * All readable records, newest first. Corrupt files and unknown schema versions
96
+ * are skipped. History is agent-writable, so a record is trusted only when its
97
+ * run_id has the generated shape, matches its filename, and is not repeated:
98
+ * a planted copy of another run's id is dropped, not merged.
99
+ */
92
100
  export declare function listRuns(cwd?: string): RunRecord[];
93
101
  /** Exact run id, or a unique prefix. */
94
102
  export declare function findRun(runs: RunRecord[], ref: string): RunRecord | undefined;
@@ -4,7 +4,8 @@
4
4
  *
5
5
  * METADATA ONLY. A record holds ids, statuses, durations, counts, token and
6
6
  * cost totals, and routing edges. It never holds prompts, node outputs, tool
7
- * inputs/outputs, error text, or environment values.
7
+ * inputs/outputs, error text, or environment values. What a run answered is
8
+ * kept apart, in `<run-id>/answers.json` (run-answers.ts).
8
9
  *
9
10
  * `schema_version` is the compatibility contract: a future resume journal
10
11
  * builds on this format, so readers skip records with a version they do not
@@ -40,7 +41,8 @@ export function hashWorkflow(workflow) {
40
41
  .update(JSON.stringify(canonicalize(workflow)))
41
42
  .digest("hex");
42
43
  }
43
- const RUN_ID_RE = /^\d{8}-\d{6}-[0-9a-f]{6}$/;
44
+ /** The shape `newRunId` generates. Anything else is not a run id. */
45
+ export const RUN_ID_RE = /^\d{8}-\d{6}-[0-9a-f]{6}$/;
44
46
  /** Sortable id: `YYYYMMDD-HHMMSS-<6 hex>` in UTC, so filename order is run order. */
45
47
  export function newRunId(startedAtMs, rand = () => crypto.randomBytes(3).toString("hex")) {
46
48
  const d = new Date(startedAtMs);
@@ -195,7 +197,12 @@ function isRecord(v) {
195
197
  !!r.totals &&
196
198
  typeof r.totals === "object");
197
199
  }
198
- /** All readable records, newest first. Corrupt files and unknown schema versions are skipped. */
200
+ /**
201
+ * All readable records, newest first. Corrupt files and unknown schema versions
202
+ * are skipped. History is agent-writable, so a record is trusted only when its
203
+ * run_id has the generated shape, matches its filename, and is not repeated:
204
+ * a planted copy of another run's id is dropped, not merged.
205
+ */
199
206
  export function listRuns(cwd = process.cwd()) {
200
207
  const dir = path.join(cwd, RUN_HISTORY_DIR);
201
208
  let files;
@@ -209,14 +216,19 @@ export function listRuns(cwd = process.cwd()) {
209
216
  for (const f of files) {
210
217
  try {
211
218
  const parsed = JSON.parse(fs.readFileSync(path.join(dir, f), "utf-8"));
212
- if (isRecord(parsed))
219
+ if (isRecord(parsed) && RUN_ID_RE.test(parsed.run_id) && f === `${parsed.run_id}.json`)
213
220
  out.push(parsed);
214
221
  }
215
222
  catch {
216
223
  // corrupt record: skip
217
224
  }
218
225
  }
219
- 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));
226
+ const seen = new Map();
227
+ for (const r of out)
228
+ seen.set(r.run_id, (seen.get(r.run_id) ?? 0) + 1);
229
+ return out
230
+ .filter((r) => seen.get(r.run_id) === 1)
231
+ .sort((a, b) => a.started_at === b.started_at ? b.run_id.localeCompare(a.run_id) : b.started_at.localeCompare(a.started_at));
220
232
  }
221
233
  /** Exact run id, or a unique prefix. */
222
234
  export function findRun(runs, ref) {
@@ -106,13 +106,13 @@ export declare function renderReceiptLine(s: RunSummary, color: boolean): string
106
106
  /** Per-node Mermaid status from run results. */
107
107
  export declare function nodeStates(results: Map<string, NodeResult>): Record<string, NodeStatus>;
108
108
  /** Markdown for `$GITHUB_STEP_SUMMARY`: receipt + Mermaid DAG in the brand classDefs. */
109
- export declare function formatStepSummary(workflow: Workflow, results: Map<string, NodeResult>, summary: RunSummary, trace?: ExecutionTrace): string;
109
+ export declare function formatStepSummary(workflow: Workflow, results: Map<string, NodeResult>, summary: RunSummary, trace?: ExecutionTrace, sinceLast?: string | null): string;
110
110
  /**
111
111
  * Append the step summary to the file named by GITHUB_STEP_SUMMARY.
112
112
  * Runner-agnostic: does nothing when the variable is unset or empty.
113
113
  * Never throws; returns true only when something was written.
114
114
  */
115
- export declare function writeStepSummary(workflow: Workflow, results: Map<string, NodeResult>, summary: RunSummary, trace?: ExecutionTrace, env?: NodeJS.ProcessEnv): boolean;
115
+ export declare function writeStepSummary(workflow: Workflow, results: Map<string, NodeResult>, summary: RunSummary, trace?: ExecutionTrace, env?: NodeJS.ProcessEnv, sinceLast?: string | null): boolean;
116
116
  export interface RunLogger extends Logger {
117
117
  /** Write any warnings held back while a node line was live. */
118
118
  flush(): void;
@@ -211,13 +211,15 @@ export function nodeStates(results) {
211
211
  return state;
212
212
  }
213
213
  /** Markdown for `$GITHUB_STEP_SUMMARY`: receipt + Mermaid DAG in the brand classDefs. */
214
- export function formatStepSummary(workflow, results, summary, trace) {
214
+ export function formatStepSummary(workflow, results, summary, trace, sinceLast) {
215
215
  const state = nodeStates(results);
216
216
  return [
217
217
  `## ${summary.ok ? "✅" : "❌"} ${workflow.name}`,
218
218
  "",
219
219
  `\`${formatReceipt(summary)}\``,
220
220
  "",
221
+ // The "since last run" line (run-answers.ts formatSinceLastMarkdown): counts only, never node outputs.
222
+ ...(sinceLast ? [sinceLast, ""] : []),
221
223
  toMermaidBlock(workflow, { state, trace }),
222
224
  "",
223
225
  "",
@@ -228,14 +230,14 @@ export function formatStepSummary(workflow, results, summary, trace) {
228
230
  * Runner-agnostic: does nothing when the variable is unset or empty.
229
231
  * Never throws; returns true only when something was written.
230
232
  */
231
- export function writeStepSummary(workflow, results, summary, trace, env = process.env) {
233
+ export function writeStepSummary(workflow, results, summary, trace, env = process.env, sinceLast) {
232
234
  // The operator's value only: never one a workspace `.env` introduced.
233
235
  const file = trustedEnvValue(env, "GITHUB_STEP_SUMMARY");
234
236
  if (!file)
235
237
  return false;
236
238
  try {
237
239
  // Never through a link, in case the path sits in the agent-writable workspace.
238
- writeFileNoFollow(file, formatStepSummary(workflow, results, summary, trace), {
240
+ writeFileNoFollow(file, formatStepSummary(workflow, results, summary, trace, sinceLast), {
239
241
  ...workspaceRoot(file),
240
242
  append: true,
241
243
  mkdirs: false,
@@ -1,12 +1,29 @@
1
1
  /**
2
2
  * `sweny runs` and `sweny runs diff`: read-only views over `.sweny/runs/`.
3
- * Metadata only. Color only when `color` is true (TTY).
3
+ * `runs` lists metadata. `runs diff` compares the metadata of two runs and,
4
+ * first, what changed in their answers (run-answers.ts). Color only when
5
+ * `color` is true (TTY and no NO_COLOR); the glyph set follows `unicode`.
6
+ * The diff always ends on one plain line with the answer counts.
4
7
  */
5
8
  import type { Command } from "commander";
6
9
  import { type RunRecord } from "./run-history.js";
10
+ import { type AnswersComparison } from "./run-answers.js";
7
11
  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;
12
+ /**
13
+ * A copy of a history record that is safe to print. `.sweny/runs/` is writable by an agent, so every
14
+ * string read from a record goes through {@link sanitizeTerminal} (control sequences stripped, length
15
+ * capped) and every number is coerced to a finite number. Only for display: matching and `--json`
16
+ * use the record as read.
17
+ */
18
+ export declare function printableRun(r: RunRecord): RunRecord;
19
+ export declare function formatRunsTable(records: RunRecord[], color: boolean, nowMs?: number, unicode?: boolean): string;
20
+ export interface RunDiffOptions {
21
+ /** Answer comparison for the pair; when set the diff leads with it and ends on its summary line. */
22
+ answers?: AnswersComparison;
23
+ /** Unicode glyphs (default) or the ASCII set. */
24
+ unicode?: boolean;
25
+ }
26
+ export declare function formatRunDiff(rawA: RunRecord, rawB: RunRecord, color: boolean, opts?: RunDiffOptions): string;
10
27
  /** 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
28
  export declare function pickRunsForDiff(runs: RunRecord[], refs: string[], workflowId?: string): {
12
29
  a: RunRecord;
@@ -14,4 +31,26 @@ export declare function pickRunsForDiff(runs: RunRecord[], refs: string[], workf
14
31
  } | {
15
32
  error: string;
16
33
  };
34
+ type OutStream = {
35
+ isTTY?: boolean;
36
+ write(chunk: string): unknown;
37
+ };
38
+ export interface RunsDiffIo {
39
+ cwd?: string;
40
+ env?: Record<string, string | undefined>;
41
+ stdout: OutStream;
42
+ stderr: OutStream;
43
+ }
44
+ /**
45
+ * Body of `sweny runs diff`. Returns the exit code (0 diffed, 1 nothing to diff).
46
+ *
47
+ * `--json` prints `{ schema_version: 1, a: RunRecord, b: RunRecord, answers }` and never ANSI.
48
+ * `answers` is `{ available: true, counts, entries, not_compared }` or
49
+ * `{ available: false, reason: "different_workflows" | "missing_answers", missing? }`.
50
+ */
51
+ export declare function runRunsDiff(refs: string[], opts: {
52
+ workflow?: string;
53
+ json?: boolean;
54
+ }, io: RunsDiffIo): number;
17
55
  export declare function registerRunsCommand(program: Command): Command;
56
+ export {};