@sweny-ai/core 0.1.185 → 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.
- package/dist/cli/comment-output.d.ts +4 -1
- package/dist/cli/comment-output.js +3 -1
- package/dist/cli/main.js +17 -1
- package/dist/cli/run-answers.d.ts +162 -0
- package/dist/cli/run-answers.js +451 -0
- package/dist/cli/run-history.d.ts +10 -2
- package/dist/cli/run-history.js +17 -5
- package/dist/cli/run-output.d.ts +2 -2
- package/dist/cli/run-output.js +5 -3
- package/dist/cli/runs.d.ts +42 -3
- package/dist/cli/runs.js +262 -45
- package/dist/cli/terminal.d.ts +9 -0
- package/dist/cli/terminal.js +22 -0
- package/package.json +1 -1
package/dist/cli/runs.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `sweny runs` and `sweny runs diff`: read-only views over `.sweny/runs/`.
|
|
3
|
-
*
|
|
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 { findRun, listRuns } from "./run-history.js";
|
|
9
|
+
import { compareAnswers, readRunAnswers, } from "./run-answers.js";
|
|
6
10
|
import { formatCost, formatReceiptDuration, formatTokenCount } from "./run-output.js";
|
|
7
11
|
import { createPaint } from "./style.js";
|
|
8
|
-
import { colorEnabled } from "./terminal.js";
|
|
9
|
-
import { GLYPHS } from "../theme.js";
|
|
12
|
+
import { colorEnabled, glyphsFor, sanitizeTerminal, supportsUnicode } from "./terminal.js";
|
|
10
13
|
// ── Small formatters ────────────────────────────────────────────
|
|
11
14
|
export function formatAge(startedAt, nowMs = Date.now()) {
|
|
12
15
|
const s = Math.max(0, Math.round((nowMs - Date.parse(startedAt)) / 1000));
|
|
@@ -20,8 +23,14 @@ export function formatAge(startedAt, nowMs = Date.now()) {
|
|
|
20
23
|
return `${h}h ago`;
|
|
21
24
|
return `${Math.floor(h / 24)}d ago`;
|
|
22
25
|
}
|
|
23
|
-
|
|
24
|
-
const
|
|
26
|
+
/** Own keys only: a status of `constructor` from a forged record must not find Object.prototype. */
|
|
27
|
+
const icon = (icons, status) => Object.hasOwn(icons, status) ? icons[status] : "?";
|
|
28
|
+
const iconsFor = (g) => ({
|
|
29
|
+
success: g.success,
|
|
30
|
+
failed: g.failure,
|
|
31
|
+
crashed: g.failure,
|
|
32
|
+
skipped: g.skipped,
|
|
33
|
+
});
|
|
25
34
|
/** Status text in its role color: success, error, or muted for skipped. */
|
|
26
35
|
function paint(text, status, color) {
|
|
27
36
|
if (!color)
|
|
@@ -38,8 +47,49 @@ const costText = (n) => (n === null ? "-" : formatCost(n));
|
|
|
38
47
|
function pad(s, w, right = false) {
|
|
39
48
|
return right ? s.padStart(w) : s.padEnd(w);
|
|
40
49
|
}
|
|
50
|
+
// ── History records are untrusted ───────────────────────────────
|
|
51
|
+
const finite = (v) => (typeof v === "number" && Number.isFinite(v) ? v : 0);
|
|
52
|
+
const finiteOrNull = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
|
|
53
|
+
/**
|
|
54
|
+
* A copy of a history record that is safe to print. `.sweny/runs/` is writable by an agent, so every
|
|
55
|
+
* string read from a record goes through {@link sanitizeTerminal} (control sequences stripped, length
|
|
56
|
+
* capped) and every number is coerced to a finite number. Only for display: matching and `--json`
|
|
57
|
+
* use the record as read.
|
|
58
|
+
*/
|
|
59
|
+
export function printableRun(r) {
|
|
60
|
+
const t = (v, max = 64) => sanitizeTerminal(v, max);
|
|
61
|
+
return {
|
|
62
|
+
...r,
|
|
63
|
+
run_id: t(r.run_id),
|
|
64
|
+
workflow_id: t(r.workflow_id),
|
|
65
|
+
workflow_hash: t(r.workflow_hash),
|
|
66
|
+
started_at: t(r.started_at),
|
|
67
|
+
status: t(r.status, 16),
|
|
68
|
+
duration_ms: finite(r.duration_ms),
|
|
69
|
+
nodes: r.nodes.map((n) => ({
|
|
70
|
+
id: t(n.id),
|
|
71
|
+
status: t(n.status, 16),
|
|
72
|
+
duration_ms: finite(n.duration_ms),
|
|
73
|
+
tool_calls: finite(n.tool_calls),
|
|
74
|
+
tokens: finiteOrNull(n.tokens),
|
|
75
|
+
cost_usd: finiteOrNull(n.cost_usd),
|
|
76
|
+
retries: finite(n.retries),
|
|
77
|
+
})),
|
|
78
|
+
routes: r.routes.map((e) => ({ from: t(e.from), to: t(e.to) })),
|
|
79
|
+
totals: {
|
|
80
|
+
nodes_total: finite(r.totals.nodes_total),
|
|
81
|
+
nodes_ok: finite(r.totals.nodes_ok),
|
|
82
|
+
nodes_skipped: finite(r.totals.nodes_skipped),
|
|
83
|
+
tool_calls: finite(r.totals.tool_calls),
|
|
84
|
+
tokens: finiteOrNull(r.totals.tokens),
|
|
85
|
+
cost_usd: finiteOrNull(r.totals.cost_usd),
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
41
89
|
// ── sweny runs ──────────────────────────────────────────────────
|
|
42
|
-
export function formatRunsTable(
|
|
90
|
+
export function formatRunsTable(records, color, nowMs = Date.now(), unicode = true) {
|
|
91
|
+
const ICON = iconsFor(glyphsFor(unicode));
|
|
92
|
+
const runs = records.map(printableRun);
|
|
43
93
|
if (runs.length === 0)
|
|
44
94
|
return " No runs recorded yet. Run `sweny workflow run <file>` to start a history.\n";
|
|
45
95
|
const rows = runs.map((r) => ({
|
|
@@ -47,7 +97,7 @@ export function formatRunsTable(runs, color, nowMs = Date.now()) {
|
|
|
47
97
|
cells: [
|
|
48
98
|
r.run_id,
|
|
49
99
|
r.workflow_id,
|
|
50
|
-
`${ICON
|
|
100
|
+
`${icon(ICON, r.status)} ${r.status}`,
|
|
51
101
|
formatReceiptDuration(r.duration_ms),
|
|
52
102
|
tokensText(r.totals?.tokens ?? null),
|
|
53
103
|
costText(r.totals?.cost_usd ?? null),
|
|
@@ -82,41 +132,180 @@ const deltaTokens = (a, b) => `${sign(b - a)}${formatTokenCount(Math.abs(b - a))
|
|
|
82
132
|
const deltaCost = (a, b) => `${sign(b - a)}${formatCost(Math.abs(b - a))}`;
|
|
83
133
|
const deltaCount = (a, b) => `${sign(b - a)}${Math.abs(b - a)}`;
|
|
84
134
|
/** `label a → b (+delta)` for a numeric metric; null when unchanged. */
|
|
85
|
-
function metric(label, a, b, show, delta) {
|
|
135
|
+
function metric(label, a, b, show, delta, arrow = "→") {
|
|
86
136
|
if (a === b)
|
|
87
137
|
return null;
|
|
88
138
|
if (a === null || b === null)
|
|
89
|
-
return `${label} ${a === null ? "-" : show(a)}
|
|
90
|
-
return `${label} ${show(a)}
|
|
139
|
+
return `${label} ${a === null ? "-" : show(a)} ${arrow} ${b === null ? "-" : show(b)}`;
|
|
140
|
+
return `${label} ${show(a)} ${arrow} ${show(b)} (${delta(a, b)})`;
|
|
91
141
|
}
|
|
92
|
-
function nodeChanges(a, b) {
|
|
142
|
+
function nodeChanges(a, b, arrow) {
|
|
93
143
|
return [
|
|
94
|
-
metric("duration", a.duration_ms, b.duration_ms, fmtDur, deltaDuration),
|
|
95
|
-
metric("tokens", a.tokens, b.tokens, formatTokenCount, deltaTokens),
|
|
96
|
-
metric("cost", a.cost_usd, b.cost_usd, formatCost, deltaCost),
|
|
97
|
-
metric("tools", a.tool_calls, b.tool_calls, String, deltaCount),
|
|
98
|
-
metric("retries", a.retries, b.retries, String, deltaCount),
|
|
144
|
+
metric("duration", a.duration_ms, b.duration_ms, fmtDur, deltaDuration, arrow),
|
|
145
|
+
metric("tokens", a.tokens, b.tokens, formatTokenCount, deltaTokens, arrow),
|
|
146
|
+
metric("cost", a.cost_usd, b.cost_usd, formatCost, deltaCost, arrow),
|
|
147
|
+
metric("tools", a.tool_calls, b.tool_calls, String, deltaCount, arrow),
|
|
148
|
+
metric("retries", a.retries, b.retries, String, deltaCount, arrow),
|
|
99
149
|
].filter((x) => x !== null);
|
|
100
150
|
}
|
|
101
|
-
const routeKey = (r) => `${r.from}
|
|
102
|
-
|
|
151
|
+
const routeKey = (r) => `${r.from}\u0000${r.to}`;
|
|
152
|
+
// ── Answers section ─────────────────────────────────────────────
|
|
153
|
+
/** Items listed per kind (added, resolved, changed) in one list before "... and N more". The JSON keeps all. */
|
|
154
|
+
const MAX_ITEMS_SHOWN = 10;
|
|
155
|
+
const MAX_FIELDS_SHOWN = 3;
|
|
156
|
+
const VALUE_MAX = 56;
|
|
157
|
+
/** One printable line (an answer is untrusted text): see {@link sanitizeTerminal}. */
|
|
158
|
+
const oneLine = (s) => sanitizeTerminal(s, 10_000);
|
|
159
|
+
function clip(s, max, g) {
|
|
160
|
+
return s.length > max ? s.slice(0, max - 1) + g.ellipsis : s;
|
|
161
|
+
}
|
|
162
|
+
function showValue(v, g) {
|
|
163
|
+
if (v === undefined)
|
|
164
|
+
return "(none)";
|
|
165
|
+
const text = typeof v === "string" ? v : typeof v === "number" || typeof v === "boolean" ? String(v) : JSON.stringify(v);
|
|
166
|
+
return clip(oneLine(text), VALUE_MAX, g);
|
|
167
|
+
}
|
|
168
|
+
/** Lines in `b` not in `a` (added) and in `a` not in `b` (removed), as multisets. */
|
|
169
|
+
function lineDelta(a, b) {
|
|
170
|
+
const left = new Map();
|
|
171
|
+
for (const l of a.split("\n"))
|
|
172
|
+
left.set(l, (left.get(l) ?? 0) + 1);
|
|
173
|
+
let added = 0;
|
|
174
|
+
for (const l of b.split("\n")) {
|
|
175
|
+
const n = left.get(l) ?? 0;
|
|
176
|
+
if (n > 0)
|
|
177
|
+
left.set(l, n - 1);
|
|
178
|
+
else
|
|
179
|
+
added++;
|
|
180
|
+
}
|
|
181
|
+
let removed = 0;
|
|
182
|
+
for (const n of left.values())
|
|
183
|
+
removed += n;
|
|
184
|
+
return { added, removed };
|
|
185
|
+
}
|
|
186
|
+
/** `before -> after`, or `+X -Y lines` when either side is a long or multi-line string. */
|
|
187
|
+
function valueChange(before, after, g) {
|
|
188
|
+
// Stored text is cut at ANSWER_STRING_MAX; the change is past the cut (found by digest).
|
|
189
|
+
if (typeof before === "string" && before === after)
|
|
190
|
+
return "changed past the stored excerpt";
|
|
191
|
+
const long = (v) => typeof v === "string" && (v.includes("\n") || v.length > VALUE_MAX);
|
|
192
|
+
if (typeof before === "string" && typeof after === "string" && (long(before) || long(after))) {
|
|
193
|
+
const d = lineDelta(before, after);
|
|
194
|
+
return `+${d.added} -${d.removed} lines`;
|
|
195
|
+
}
|
|
196
|
+
return `${showValue(before, g)} ${g.arrow} ${showValue(after, g)}`;
|
|
197
|
+
}
|
|
198
|
+
const isChangedEntry = (e) => e.kind === "collection" ? e.added.length + e.resolved.length + e.changed.length > 0 : e.status !== "unchanged";
|
|
199
|
+
function countsHead(e, g) {
|
|
200
|
+
return [
|
|
201
|
+
e.added.length ? `${e.added.length} added` : "",
|
|
202
|
+
e.resolved.length ? `${e.resolved.length} resolved` : "",
|
|
203
|
+
e.changed.length ? `${e.changed.length} changed` : "",
|
|
204
|
+
e.unchanged ? `${e.unchanged} unchanged` : "",
|
|
205
|
+
]
|
|
206
|
+
.filter(Boolean)
|
|
207
|
+
.join(` ${g.sep} `);
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* The answers block. Marker colors keep the palette's meanings: `+` a new item
|
|
211
|
+
* is `warning` (look at this), `-` a resolved one is `success`, `~` a changed
|
|
212
|
+
* one is `info`; counts and unchanged nodes are `muted`.
|
|
213
|
+
*/
|
|
214
|
+
function formatAnswersSection(cmp, color, g) {
|
|
215
|
+
const p = createPaint(color);
|
|
216
|
+
const out = [" answers"];
|
|
217
|
+
const items = (marker, paintMarker, list, word) => {
|
|
218
|
+
for (const it of list.slice(0, MAX_ITEMS_SHOWN)) {
|
|
219
|
+
out.push(` ${paintMarker(marker)} ${clip(oneLine(it.label), 72, g)}`);
|
|
220
|
+
}
|
|
221
|
+
if (list.length > MAX_ITEMS_SHOWN) {
|
|
222
|
+
out.push(p.muted(` ${g.ellipsis} and ${list.length - MAX_ITEMS_SHOWN} more ${word}`));
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
const fields = (list) => {
|
|
226
|
+
if (list.length === 0)
|
|
227
|
+
return;
|
|
228
|
+
const w = Math.max(...list.map((f) => f.field.length));
|
|
229
|
+
for (const f of list.slice(0, MAX_FIELDS_SHOWN)) {
|
|
230
|
+
out.push(` ${pad(f.field, w)} ${valueChange(f.before, f.after, g)}`);
|
|
231
|
+
}
|
|
232
|
+
if (list.length > MAX_FIELDS_SHOWN)
|
|
233
|
+
out.push(p.muted(` ${g.ellipsis} and ${list.length - MAX_FIELDS_SHOWN} more fields`));
|
|
234
|
+
};
|
|
235
|
+
const nodes = [...new Set(cmp.entries.map((e) => e.node))];
|
|
236
|
+
for (const node of nodes) {
|
|
237
|
+
const changed = cmp.entries.filter((e) => e.node === node && isChangedEntry(e));
|
|
238
|
+
if (changed.length === 0) {
|
|
239
|
+
out.push(` ${node} ${p.muted("unchanged")}`);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
out.push(` ${node}`);
|
|
243
|
+
const w = Math.max(...changed.map((e) => e.field.length));
|
|
244
|
+
for (const e of changed) {
|
|
245
|
+
if (e.kind === "collection") {
|
|
246
|
+
out.push(` ${pad(e.field, w)} ${p.muted(countsHead(e, g))}`);
|
|
247
|
+
items("+", p.warning, e.added, "added");
|
|
248
|
+
items("-", p.success, e.resolved, "resolved");
|
|
249
|
+
for (const c of e.changed.slice(0, MAX_ITEMS_SHOWN)) {
|
|
250
|
+
out.push(` ${p.info("~")} ${clip(oneLine(c.label), 72, g)}`);
|
|
251
|
+
fields(c.fields);
|
|
252
|
+
}
|
|
253
|
+
if (e.changed.length > MAX_ITEMS_SHOWN) {
|
|
254
|
+
out.push(p.muted(` ${g.ellipsis} and ${e.changed.length - MAX_ITEMS_SHOWN} more changed`));
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
else if (e.status === "added") {
|
|
258
|
+
out.push(` ${pad(e.field, w)} ${p.warning("added")} ${showValue(e.after, g)}`);
|
|
259
|
+
}
|
|
260
|
+
else if (e.status === "resolved") {
|
|
261
|
+
out.push(` ${pad(e.field, w)} ${p.success("resolved")} ${p.muted(`(was ${showValue(e.before, g)})`)}`);
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
out.push(` ${pad(e.field, w)} ${valueChange(e.before, e.after, g)}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
for (const n of cmp.not_compared) {
|
|
269
|
+
out.push(` ${n.node} ${p.muted(`not compared (answer in ${n.only_in} only)`)}`);
|
|
270
|
+
}
|
|
271
|
+
if (nodes.length === 0 && cmp.not_compared.length === 0)
|
|
272
|
+
out.push(p.muted(" no declared outputs to compare"));
|
|
273
|
+
return out;
|
|
274
|
+
}
|
|
275
|
+
const countsLine = (c) => `${c.added} added, ${c.resolved} resolved, ${c.changed} changed, ${c.unchanged} unchanged`;
|
|
276
|
+
/** The last line of the diff: plain text, no color, no glyphs, so a log or a grep always has the answer. */
|
|
277
|
+
function answersFinalLine(cmp, a, b) {
|
|
278
|
+
if (cmp.available)
|
|
279
|
+
return ` answers: ${countsLine(cmp.counts)}`;
|
|
280
|
+
if (cmp.reason === "different_workflows")
|
|
281
|
+
return " answers: not compared (different workflows)";
|
|
282
|
+
const ids = cmp.missing.map((m) => (m === "a" ? a.run_id : b.run_id)).join(" and ");
|
|
283
|
+
return ` answers: not recorded for ${ids} (runs made with --no-history, or by an older sweny, have none)`;
|
|
284
|
+
}
|
|
285
|
+
export function formatRunDiff(rawA, rawB, color, opts = {}) {
|
|
286
|
+
const a = printableRun(rawA);
|
|
287
|
+
const b = printableRun(rawB);
|
|
288
|
+
const g = glyphsFor(opts.unicode ?? true);
|
|
289
|
+
const ICON = iconsFor(g);
|
|
103
290
|
const dim = createPaint(color).muted;
|
|
104
291
|
const out = [];
|
|
105
|
-
const hdr = (tag, r) => ` ${tag} ${r.run_id} ${r.workflow_id} ${paint(`${ICON
|
|
292
|
+
const hdr = (tag, r) => ` ${tag} ${r.run_id} ${r.workflow_id} ${paint(`${icon(ICON, r.status)} ${r.status}`, r.status, color)} ${formatReceiptDuration(r.duration_ms)}`;
|
|
106
293
|
out.push(hdr("a", a), hdr("b", b), "");
|
|
294
|
+
if (opts.answers?.available)
|
|
295
|
+
out.push(...formatAnswersSection(opts.answers, color, g), "");
|
|
107
296
|
const short = (h) => h.slice(0, 7);
|
|
108
297
|
out.push(a.workflow_hash === b.workflow_hash
|
|
109
298
|
? ` workflow unchanged (${short(a.workflow_hash)})`
|
|
110
|
-
: ` workflow changed ${short(a.workflow_hash)}
|
|
299
|
+
: ` workflow changed ${short(a.workflow_hash)} ${g.arrow} ${short(b.workflow_hash)}`);
|
|
111
300
|
if (a.workflow_id !== b.workflow_id)
|
|
112
|
-
out.push(` id ${a.workflow_id}
|
|
301
|
+
out.push(` id ${a.workflow_id} ${g.arrow} ${b.workflow_id}`);
|
|
113
302
|
out.push(a.status === b.status
|
|
114
|
-
? ` status ${ICON
|
|
115
|
-
: ` status ${ICON
|
|
303
|
+
? ` status ${icon(ICON, b.status)} ${b.status} (same)`
|
|
304
|
+
: ` status ${icon(ICON, a.status)} ${a.status} ${g.arrow} ${paint(`${icon(ICON, b.status)} ${b.status}`, b.status, color)}`);
|
|
116
305
|
const tot = [
|
|
117
|
-
metric("duration ", a.duration_ms, b.duration_ms, formatReceiptDuration, deltaDuration),
|
|
118
|
-
metric("tokens ", a.totals.tokens, b.totals.tokens, formatTokenCount, deltaTokens),
|
|
119
|
-
metric("cost ", a.totals.cost_usd, b.totals.cost_usd, formatCost, deltaCost),
|
|
306
|
+
metric("duration ", a.duration_ms, b.duration_ms, formatReceiptDuration, deltaDuration, g.arrow),
|
|
307
|
+
metric("tokens ", a.totals.tokens, b.totals.tokens, formatTokenCount, deltaTokens, g.arrow),
|
|
308
|
+
metric("cost ", a.totals.cost_usd, b.totals.cost_usd, formatCost, deltaCost, g.arrow),
|
|
120
309
|
];
|
|
121
310
|
for (const t of tot)
|
|
122
311
|
if (t)
|
|
@@ -130,18 +319,21 @@ export function formatRunDiff(a, b, color) {
|
|
|
130
319
|
const na = aNodes.get(id);
|
|
131
320
|
const nb = bNodes.get(id);
|
|
132
321
|
if (na && !nb) {
|
|
133
|
-
out.push(` ${pad(id, w)} ${dim("removed")} (was ${ICON
|
|
322
|
+
out.push(` ${pad(id, w)} ${dim("removed")} (was ${icon(ICON, na.status)} ${na.status})`);
|
|
134
323
|
}
|
|
135
324
|
else if (!na && nb) {
|
|
136
|
-
out.push(` ${pad(id, w)} ${paint(`added ${ICON
|
|
325
|
+
out.push(` ${pad(id, w)} ${paint(`added ${icon(ICON, nb.status)} ${nb.status}`, nb.status, color)}`);
|
|
137
326
|
}
|
|
138
327
|
else if (na && nb) {
|
|
139
|
-
const st = na.status === nb.status
|
|
140
|
-
|
|
328
|
+
const st = na.status === nb.status
|
|
329
|
+
? icon(ICON, nb.status)
|
|
330
|
+
: `${icon(ICON, na.status)} ${g.arrow} ${paint(icon(ICON, nb.status), nb.status, color)}`;
|
|
331
|
+
const ch = nodeChanges(na, nb, g.arrow);
|
|
141
332
|
out.push(` ${pad(id, w)} ${st} ${ch.length ? ch.join(" ") : dim("no change")}`);
|
|
142
333
|
}
|
|
143
334
|
}
|
|
144
335
|
out.push("", " routes");
|
|
336
|
+
const show = (key) => key.split("\u0000").join(` ${g.arrow} `);
|
|
145
337
|
const ar = new Set(a.routes.map(routeKey));
|
|
146
338
|
const br = new Set(b.routes.map(routeKey));
|
|
147
339
|
const onlyA = [...ar].filter((r) => !br.has(r));
|
|
@@ -149,9 +341,11 @@ export function formatRunDiff(a, b, color) {
|
|
|
149
341
|
if (onlyA.length === 0 && onlyB.length === 0)
|
|
150
342
|
out.push(" identical");
|
|
151
343
|
for (const r of onlyA)
|
|
152
|
-
out.push(` - ${r} ${dim("(only in a)")}`);
|
|
344
|
+
out.push(` - ${show(r)} ${dim("(only in a)")}`);
|
|
153
345
|
for (const r of onlyB)
|
|
154
|
-
out.push(` + ${r} ${dim("(only in b)")}`);
|
|
346
|
+
out.push(` + ${show(r)} ${dim("(only in b)")}`);
|
|
347
|
+
if (opts.answers)
|
|
348
|
+
out.push("", answersFinalLine(opts.answers, a, b));
|
|
155
349
|
return out.join("\n") + "\n";
|
|
156
350
|
}
|
|
157
351
|
/** 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. */
|
|
@@ -162,9 +356,9 @@ export function pickRunsForDiff(runs, refs, workflowId) {
|
|
|
162
356
|
const a = findRun(runs, refs[0]);
|
|
163
357
|
const b = findRun(runs, refs[1]);
|
|
164
358
|
if (!a)
|
|
165
|
-
return { error: `No run matches "${refs[0]}". Run \`sweny runs\` to list ids.` };
|
|
359
|
+
return { error: `No run matches "${sanitizeTerminal(refs[0], 64)}". Run \`sweny runs\` to list ids.` };
|
|
166
360
|
if (!b)
|
|
167
|
-
return { error: `No run matches "${refs[1]}". Run \`sweny runs\` to list ids.` };
|
|
361
|
+
return { error: `No run matches "${sanitizeTerminal(refs[1], 64)}". Run \`sweny runs\` to list ids.` };
|
|
168
362
|
return { a, b };
|
|
169
363
|
}
|
|
170
364
|
// No --workflow: the newest workflow that has at least two runs to compare.
|
|
@@ -174,10 +368,37 @@ export function pickRunsForDiff(runs, refs, workflowId) {
|
|
|
174
368
|
const wf = workflowId ?? runs.find((r) => (counts.get(r.workflow_id) ?? 0) >= 2)?.workflow_id ?? runs[0]?.workflow_id;
|
|
175
369
|
const same = wf ? runs.filter((r) => r.workflow_id === wf) : [];
|
|
176
370
|
if (same.length < 2) {
|
|
177
|
-
return {
|
|
371
|
+
return {
|
|
372
|
+
error: wf
|
|
373
|
+
? `Need two runs of "${sanitizeTerminal(wf, 64)}" to diff; found ${same.length}. Run it again, then \`sweny runs diff\`.`
|
|
374
|
+
: "No runs recorded yet. Run `sweny workflow run <file>` twice, then `sweny runs diff`.",
|
|
375
|
+
};
|
|
178
376
|
}
|
|
179
377
|
return { a: same[1], b: same[0] };
|
|
180
378
|
}
|
|
379
|
+
/**
|
|
380
|
+
* Body of `sweny runs diff`. Returns the exit code (0 diffed, 1 nothing to diff).
|
|
381
|
+
*
|
|
382
|
+
* `--json` prints `{ schema_version: 1, a: RunRecord, b: RunRecord, answers }` and never ANSI.
|
|
383
|
+
* `answers` is `{ available: true, counts, entries, not_compared }` or
|
|
384
|
+
* `{ available: false, reason: "different_workflows" | "missing_answers", missing? }`.
|
|
385
|
+
*/
|
|
386
|
+
export function runRunsDiff(refs, opts, io) {
|
|
387
|
+
const env = io.env ?? process.env;
|
|
388
|
+
const picked = pickRunsForDiff(listRuns(io.cwd), refs, opts.workflow);
|
|
389
|
+
if ("error" in picked) {
|
|
390
|
+
io.stderr.write(createPaint(colorEnabled(io.stderr, env)).error(` ${picked.error}`) + "\n");
|
|
391
|
+
return 1;
|
|
392
|
+
}
|
|
393
|
+
const { a, b } = picked;
|
|
394
|
+
const answers = compareAnswers(readRunAnswers(a.run_id, io.cwd), readRunAnswers(b.run_id, io.cwd));
|
|
395
|
+
if (opts.json) {
|
|
396
|
+
io.stdout.write(JSON.stringify({ schema_version: 1, a, b, answers }, null, 2) + "\n");
|
|
397
|
+
return 0;
|
|
398
|
+
}
|
|
399
|
+
io.stdout.write("\n" + formatRunDiff(a, b, colorEnabled(io.stdout, env), { answers, unicode: supportsUnicode(env) }) + "\n");
|
|
400
|
+
return 0;
|
|
401
|
+
}
|
|
181
402
|
// ── Command wiring ──────────────────────────────────────────────
|
|
182
403
|
export function registerRunsCommand(program) {
|
|
183
404
|
const runsCmd = program
|
|
@@ -201,22 +422,18 @@ export function registerRunsCommand(program) {
|
|
|
201
422
|
process.stdout.write(JSON.stringify(runs, null, 2) + "\n");
|
|
202
423
|
return;
|
|
203
424
|
}
|
|
204
|
-
process.stdout.write("\n" + formatRunsTable(runs, colorEnabled(process.stdout)) + "\n");
|
|
425
|
+
process.stdout.write("\n" + formatRunsTable(runs, colorEnabled(process.stdout), Date.now(), supportsUnicode()) + "\n");
|
|
205
426
|
});
|
|
206
427
|
runsCmd
|
|
207
428
|
.command("diff [a] [b]")
|
|
208
|
-
.description("Compare two runs (default: the last two
|
|
429
|
+
.description("Compare two runs: what changed in the answers, then how they ran (default: the last two of a workflow)")
|
|
209
430
|
.option("--workflow <id>", "Workflow to pick the default pair from")
|
|
431
|
+
.option("--json", "Output { schema_version, a, b, answers } as JSON")
|
|
210
432
|
.action((a, b, opts) => {
|
|
211
433
|
const refs = [a, b].filter((x) => x !== undefined);
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
console.error(createPaint(colorEnabled(process.stderr)).error(` ${picked.error}`));
|
|
216
|
-
process.exitCode = 1;
|
|
217
|
-
return;
|
|
218
|
-
}
|
|
219
|
-
process.stdout.write("\n" + formatRunDiff(picked.a, picked.b, colorEnabled(process.stdout)) + "\n");
|
|
434
|
+
const code = runRunsDiff(refs, opts, { stdout: process.stdout, stderr: process.stderr });
|
|
435
|
+
if (code !== 0)
|
|
436
|
+
process.exitCode = code;
|
|
220
437
|
});
|
|
221
438
|
return runsCmd;
|
|
222
439
|
}
|
package/dist/cli/terminal.d.ts
CHANGED
|
@@ -42,6 +42,15 @@ export declare function ticketBoxFor(unicode: boolean): TicketBox;
|
|
|
42
42
|
export declare function spinnerFramesFor(unicode: boolean): readonly string[];
|
|
43
43
|
/** The glyphs for the current process. */
|
|
44
44
|
export declare function glyphs(env?: Env): GlyphSet;
|
|
45
|
+
/**
|
|
46
|
+
* Text that came from a file an agent can write (run history, say), made safe to print: terminal
|
|
47
|
+
* control sequences are removed whole (CSI such as `ESC[2J`, OSC such as the `ESC]8;;url` hyperlink
|
|
48
|
+
* and title sequences, DCS/PM/APC strings, lone ESC pairs, their 8-bit C1 forms), then any other
|
|
49
|
+
* C0/C1 control character, bidi override and line separator is dropped or becomes a space; runs of
|
|
50
|
+
* whitespace collapse to one space. Printable Unicode is kept. `max` caps the length (default 200,
|
|
51
|
+
* `...` marks a cut).
|
|
52
|
+
*/
|
|
53
|
+
export declare function sanitizeTerminal(text: unknown, max?: number): string;
|
|
45
54
|
/** Replace single-column Unicode drawing characters with their ASCII stand-ins. */
|
|
46
55
|
export declare function toAsciiDrawing(text: string): string;
|
|
47
56
|
export {};
|
package/dist/cli/terminal.js
CHANGED
|
@@ -80,6 +80,28 @@ export function spinnerFramesFor(unicode) {
|
|
|
80
80
|
export function glyphs(env = process.env) {
|
|
81
81
|
return glyphsFor(supportsUnicode(env));
|
|
82
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* Text that came from a file an agent can write (run history, say), made safe to print: terminal
|
|
85
|
+
* control sequences are removed whole (CSI such as `ESC[2J`, OSC such as the `ESC]8;;url` hyperlink
|
|
86
|
+
* and title sequences, DCS/PM/APC strings, lone ESC pairs, their 8-bit C1 forms), then any other
|
|
87
|
+
* C0/C1 control character, bidi override and line separator is dropped or becomes a space; runs of
|
|
88
|
+
* whitespace collapse to one space. Printable Unicode is kept. `max` caps the length (default 200,
|
|
89
|
+
* `...` marks a cut).
|
|
90
|
+
*/
|
|
91
|
+
export function sanitizeTerminal(text, max = 200) {
|
|
92
|
+
const s = String(text ?? "")
|
|
93
|
+
// OSC (ESC ] / 0x9d) up to BEL or ST; DCS, SOS, PM, APC (ESC P X ^ _ / 0x90 0x98 0x9e 0x9f) up to ST
|
|
94
|
+
.replace(/(?:\x1b\]|\x9d)[^\x07\x1b\x9c]*(?:\x07|\x1b\\|\x9c)?/g, "")
|
|
95
|
+
.replace(/(?:\x1b[PX^_]|[\x90\x98\x9e\x9f])[^\x1b\x9c]*(?:\x1b\\|\x9c)?/g, "")
|
|
96
|
+
// CSI (ESC [ / 0x9b): parameters, intermediates, final byte
|
|
97
|
+
.replace(/(?:\x1b\[|\x9b)[0-?]*[ -/]*[@-~]?/g, "")
|
|
98
|
+
// any other ESC pair
|
|
99
|
+
.replace(/\x1b[ -~]?/g, "")
|
|
100
|
+
.replace(/[\u2028\u2029\s]+/g, " ")
|
|
101
|
+
.replace(/[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, "")
|
|
102
|
+
.trim();
|
|
103
|
+
return s.length > max ? s.slice(0, Math.max(0, max - 3)) + "..." : s;
|
|
104
|
+
}
|
|
83
105
|
function truthy(v) {
|
|
84
106
|
return v !== undefined && v !== "" && v !== "0" && v.toLowerCase() !== "false";
|
|
85
107
|
}
|