@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.
@@ -4,7 +4,8 @@
4
4
  * `--comment-file`, and any runner (the GitHub Action, GitLab, a script) posts it.
5
5
  *
6
6
  * METADATA ONLY. Node names, statuses, counts, and durations. Never prompts,
7
- * tool inputs, node outputs, errors, or model prose.
7
+ * tool inputs, node outputs, errors, or model prose. The optional "since last
8
+ * run" line carries counts of added and resolved answers, never their text.
8
9
  */
9
10
  import type { ExecutionTrace, NodeResult, Workflow } from "../types.js";
10
11
  import { type RunSummary } from "./run-output.js";
@@ -15,6 +16,8 @@ export interface RunCommentOptions {
15
16
  durationsMs?: Record<string, number>;
16
17
  /** Run id; the ticket shows its short hash. */
17
18
  runId?: string;
19
+ /** Pre-rendered "since last run" markdown line (run-answers.ts formatSinceLastMarkdown): counts only. */
20
+ sinceLast?: string | null;
18
21
  }
19
22
  /** Hidden marker the poster searches for to update the comment in place. */
20
23
  export declare function runCommentMarker(workflowId: string): string;
@@ -4,7 +4,8 @@
4
4
  * `--comment-file`, and any runner (the GitHub Action, GitLab, a script) posts it.
5
5
  *
6
6
  * METADATA ONLY. Node names, statuses, counts, and durations. Never prompts,
7
- * tool inputs, node outputs, errors, or model prose.
7
+ * tool inputs, node outputs, errors, or model prose. The optional "since last
8
+ * run" line carries counts of added and resolved answers, never their text.
8
9
  */
9
10
  import { workspaceRoot, writeFileNoFollow } from "../safe-file.js";
10
11
  import { toMermaidBlock } from "../mermaid.js";
@@ -40,6 +41,7 @@ export function formatRunComment(workflow, results, summary, opts = {}) {
40
41
  "",
41
42
  ticketBlock(workflow, summary, opts.runId),
42
43
  "",
44
+ ...(opts.sinceLast ? [opts.sinceLast, ""] : []),
43
45
  toMermaidBlock(workflow, { state, trace: opts.trace }),
44
46
  "",
45
47
  "<details>",
package/dist/cli/main.js CHANGED
@@ -35,6 +35,7 @@ import { nonInteractiveUsage, runNew } from "./new.js";
35
35
  import { formatFinalMarkdown, formatFinalOutput, formatSavedOutputLine, resolveFinalOutput, shouldPrintFinalOutput, writeFinalOutput, } from "./final-output.js";
36
36
  import { buildRunRecord, createNodeTimer, historyDisabled, newRunId, recordRun } from "./run-history.js";
37
37
  import { registerRunsCommand } from "./runs.js";
38
+ import { formatSinceLast, formatSinceLastMarkdown, recordRunAnswers } from "./run-answers.js";
38
39
  import { registerTryCommand } from "./try.js";
39
40
  import { JournalLockedError, JournalMismatchError, RunJournal, runSecretValues } from "../journal.js";
40
41
  import { journalDisabled, prepareResume, RESUME_SHARED_RUN_FLAGS, WORKFLOW_RESUME_OPTIONS, } from "./resume.js";
@@ -1053,6 +1054,16 @@ resume) {
1053
1054
  const wfHasFailed = [...results.values()].some((r) => r.status === "failed");
1054
1055
  journal?.end(wfHasFailed ? "failed" : "success");
1055
1056
  recordHistory(results, trace, false);
1057
+ // What the run answered (declared output fields, redacted), beside the history record, then a
1058
+ // comparison with the previous run of this workflow for the "since last run" line.
1059
+ const sinceLast = historyDisabled(options.history, fileConfig["history"])
1060
+ ? null
1061
+ : recordRunAnswers({
1062
+ runId,
1063
+ workflow,
1064
+ results,
1065
+ secrets: runSecretValues([...skills.values(), ...builtinSkills], process.env),
1066
+ });
1056
1067
  // Close the cloud lifecycle session BEFORE the JSON early-exit so
1057
1068
  // every workflow run reports a terminal status, regardless of how
1058
1069
  // the CLI returns (json, mermaid, or normal stdout).
@@ -1069,6 +1080,7 @@ resume) {
1069
1080
  trace,
1070
1081
  durationsMs: Object.fromEntries(nodeTimer.durations),
1071
1082
  runId,
1083
+ sinceLast: sinceLast ? formatSinceLastMarkdown(sinceLast) : null,
1072
1084
  });
1073
1085
  }
1074
1086
  if (isJson) {
@@ -1094,7 +1106,7 @@ resume) {
1094
1106
  // Run receipt (metadata only) + optional $GITHUB_STEP_SUMMARY.
1095
1107
  runLogger.flush();
1096
1108
  const receipt = summarizeRun(results, wfDurationMs, false, trace);
1097
- writeStepSummary(workflow, results, receipt, trace);
1109
+ writeStepSummary(workflow, results, receipt, trace, process.env, sinceLast ? formatSinceLastMarkdown(sinceLast) : null);
1098
1110
  // The answer, above the receipt: the terminal node's result, or the failed node's error.
1099
1111
  // Also saved to .sweny/runs/<id>/output.md (local only; --no-history skips the file).
1100
1112
  // Redacted with the journal's redactor; in CI printed only with --show-output.
@@ -1117,12 +1129,16 @@ resume) {
1117
1129
  // The receipt: a ticket on a TTY, one plain line in CI and pipes.
1118
1130
  if (wfHasFailed) {
1119
1131
  await writeReceipt(receipt, { workflow: workflow.id, runId, stream: process.stderr });
1132
+ if (sinceLast)
1133
+ console.error(createPaint(colorEnabled(process.stderr)).muted(` ${formatSinceLast(sinceLast)}`));
1120
1134
  if (journal?.active)
1121
1135
  console.error(c.subtle(` resume with: sweny workflow resume ${runId}\n`));
1122
1136
  process.exit(1);
1123
1137
  return;
1124
1138
  }
1125
1139
  await writeReceipt(receipt, { workflow: workflow.id, runId, stream: process.stdout });
1140
+ if (sinceLast)
1141
+ console.log(createPaint(colorEnabled(process.stdout)).muted(` ${formatSinceLast(sinceLast)}`));
1126
1142
  process.exit(0);
1127
1143
  }
1128
1144
  catch (err) {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Marketplace install — fetch workflows from swenyai/workflows and
2
+ * Marketplace install — fetch workflows from swenyai/marketplace and
3
3
  * adapt them to the user's .sweny.yml providers.
4
4
  *
5
5
  * Pure functions for fetch/mismatch/adapt; file writes delegate to
@@ -7,8 +7,13 @@
7
7
  */
8
8
  import type { Skill, SkillCategory, Workflow, Claude, Logger } from "../types.js";
9
9
  import type { FileConfig } from "./config-file.js";
10
- export declare const MARKETPLACE_REPO = "swenyai/workflows";
11
- export declare const MARKETPLACE_RAW_BASE = "https://raw.githubusercontent.com/swenyai/workflows/main";
10
+ export declare const MARKETPLACE_REPO = "swenyai/marketplace";
11
+ export declare const MARKETPLACE_BRANCH = "main";
12
+ export declare const MARKETPLACE_RAW_BASE = "https://raw.githubusercontent.com/swenyai/marketplace/main";
13
+ /** One recursive tree call lists every workflow file; the marketplace publishes no index of its own. */
14
+ export declare const MARKETPLACE_TREE_URL = "https://api.github.com/repos/swenyai/marketplace/git/trees/main?recursive=1";
15
+ /** Directories under `workflows/` in the marketplace repo, in lookup order (official wins on an id clash). */
16
+ export declare const MARKETPLACE_SOURCES: readonly ["official", "community"];
12
17
  export interface MarketplaceEntry {
13
18
  id: string;
14
19
  name: string;
@@ -23,7 +28,13 @@ export interface FetchedWorkflow {
23
28
  id: string;
24
29
  yaml: string;
25
30
  }
31
+ /** Fetch `workflows/official/<id>.yml`, then `workflows/community/<id>.yml`, from swenyai/marketplace. */
26
32
  export declare function fetchMarketplaceWorkflow(id: string): Promise<FetchedWorkflow>;
33
+ /**
34
+ * Build the browse list from the marketplace repo itself: one GitHub tree call
35
+ * to list `workflows/{official,community}/*.yml`, then each file's name,
36
+ * description and skills. Files that fail to fetch or parse are skipped.
37
+ */
27
38
  export declare function fetchMarketplaceIndex(opts?: {
28
39
  timeoutMs?: number;
29
40
  }): Promise<MarketplaceEntry[]>;
@@ -90,7 +101,7 @@ export interface InstallResult {
90
101
  alreadyExists?: boolean;
91
102
  }
92
103
  /**
93
- * Fetch a workflow from swenyai/workflows, adapt if provider mismatch is detected
104
+ * Fetch a workflow from swenyai/marketplace, adapt if provider mismatch is detected
94
105
  * AND an agent is available, then write files idempotently.
95
106
  *
96
107
  * When `overwrite` is false (default) and the workflow file already exists,
@@ -1,6 +1,6 @@
1
1
  // packages/core/src/cli/marketplace.ts
2
2
  /**
3
- * Marketplace install — fetch workflows from swenyai/workflows and
3
+ * Marketplace install — fetch workflows from swenyai/marketplace and
4
4
  * adapt them to the user's .sweny.yml providers.
5
5
  *
6
6
  * Pure functions for fetch/mismatch/adapt; file writes delegate to
@@ -13,67 +13,109 @@ import { DagRenderer } from "./renderer.js";
13
13
  import { validateParsed } from "../loader.js";
14
14
  import { loadConfigFile } from "./config-file.js";
15
15
  import { extractSkillsFromYaml, collectCredentialsForSkills, writeSwenyYmlIfMissing, appendMissingEnvKeys, writeWorkflowFile, } from "./new.js";
16
- export const MARKETPLACE_REPO = "swenyai/workflows";
17
- export const MARKETPLACE_RAW_BASE = `https://raw.githubusercontent.com/${MARKETPLACE_REPO}/main`;
18
- export async function fetchMarketplaceWorkflow(id) {
19
- const url = `${MARKETPLACE_RAW_BASE}/workflows/${id}.yml`;
20
- let res;
21
- try {
22
- res = await fetch(url);
23
- }
24
- catch {
25
- const err = new Error(`Could not reach github.com - check your connection`);
26
- err.kind = "network";
27
- throw err;
28
- }
29
- if (res.status === 404) {
30
- const err = new Error(`Workflow "${id}" not found in ${MARKETPLACE_REPO}.`);
31
- err.kind = "not-found";
32
- throw err;
33
- }
16
+ export const MARKETPLACE_REPO = "swenyai/marketplace";
17
+ export const MARKETPLACE_BRANCH = "main";
18
+ export const MARKETPLACE_RAW_BASE = `https://raw.githubusercontent.com/${MARKETPLACE_REPO}/${MARKETPLACE_BRANCH}`;
19
+ /** One recursive tree call lists every workflow file; the marketplace publishes no index of its own. */
20
+ export const MARKETPLACE_TREE_URL = `https://api.github.com/repos/${MARKETPLACE_REPO}/git/trees/${MARKETPLACE_BRANCH}?recursive=1`;
21
+ /** Directories under `workflows/` in the marketplace repo, in lookup order (official wins on an id clash). */
22
+ export const MARKETPLACE_SOURCES = ["official", "community"];
23
+ const WORKFLOW_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
24
+ const WORKFLOW_PATH_RE = new RegExp(`^workflows/(${MARKETPLACE_SOURCES.join("|")})/([A-Za-z0-9][A-Za-z0-9_-]*)\\.yml$`);
25
+ function fetchError(kind, message) {
26
+ const err = new Error(message);
27
+ err.kind = kind;
28
+ return err;
29
+ }
30
+ /** Throw a typed FetchError for any non-OK response (404 is left to the caller). */
31
+ function assertOk(res) {
32
+ if (res.ok)
33
+ return;
34
34
  if (res.status === 403 && res.headers.get("X-RateLimit-Remaining") === "0") {
35
+ const err = fetchError("rate-limit", `GitHub rate limit hit. Set GITHUB_TOKEN to raise the limit, or retry later.`);
35
36
  const reset = res.headers.get("X-RateLimit-Reset");
36
- const err = new Error(`GitHub rate limit hit. Set GITHUB_TOKEN to raise the limit, or retry later.`);
37
- err.kind = "rate-limit";
38
37
  if (reset)
39
38
  err.retryAfter = parseInt(reset, 10);
40
39
  throw err;
41
40
  }
42
- if (!res.ok) {
43
- const err = new Error(`Fetch failed with status ${res.status}`);
44
- err.kind = "unknown";
45
- throw err;
46
- }
47
- return { id, yaml: await res.text() };
41
+ throw fetchError("unknown", `Fetch failed with status ${res.status}`);
48
42
  }
49
- export async function fetchMarketplaceIndex(opts = {}) {
50
- const url = `${MARKETPLACE_RAW_BASE}/index.json`;
51
- let res;
43
+ async function httpGet(url, init) {
52
44
  try {
53
- res = await fetch(url, opts.timeoutMs ? { signal: AbortSignal.timeout(opts.timeoutMs) } : undefined);
45
+ return await (init ? fetch(url, init) : fetch(url));
54
46
  }
55
47
  catch {
56
- const err = new Error(`Could not reach github.com`);
57
- err.kind = "network";
58
- throw err;
48
+ throw fetchError("network", `Could not reach github.com - check your connection`);
59
49
  }
60
- if (res.status === 404) {
61
- const err = new Error(`Marketplace index not found`);
62
- err.kind = "not-found";
63
- throw err;
50
+ }
51
+ /** Fetch `workflows/official/<id>.yml`, then `workflows/community/<id>.yml`, from swenyai/marketplace. */
52
+ export async function fetchMarketplaceWorkflow(id) {
53
+ if (!WORKFLOW_ID_RE.test(id)) {
54
+ throw fetchError("not-found", `Workflow "${id}" not found in ${MARKETPLACE_REPO}.`);
64
55
  }
65
- if (!res.ok) {
66
- const err = new Error(`Fetch failed with status ${res.status}`);
67
- err.kind = "unknown";
68
- throw err;
56
+ for (const source of MARKETPLACE_SOURCES) {
57
+ const res = await httpGet(`${MARKETPLACE_RAW_BASE}/workflows/${source}/${id}.yml`);
58
+ if (res.status === 404)
59
+ continue;
60
+ assertOk(res);
61
+ return { id, yaml: await res.text() };
69
62
  }
70
- const raw = (await res.json());
71
- if (!Array.isArray(raw)) {
72
- const err = new Error(`Marketplace index is not an array`);
73
- err.kind = "bad-yaml";
74
- throw err;
63
+ throw fetchError("not-found", `Workflow "${id}" not found in ${MARKETPLACE_REPO}.`);
64
+ }
65
+ /**
66
+ * Build the browse list from the marketplace repo itself: one GitHub tree call
67
+ * to list `workflows/{official,community}/*.yml`, then each file's name,
68
+ * description and skills. Files that fail to fetch or parse are skipped.
69
+ */
70
+ export async function fetchMarketplaceIndex(opts = {}) {
71
+ const signal = opts.timeoutMs ? AbortSignal.timeout(opts.timeoutMs) : undefined;
72
+ const headers = { Accept: "application/vnd.github+json" };
73
+ if (process.env.GITHUB_TOKEN)
74
+ headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
75
+ const treeRes = await httpGet(MARKETPLACE_TREE_URL, { headers, ...(signal ? { signal } : {}) });
76
+ if (treeRes.status === 404)
77
+ throw fetchError("not-found", `Marketplace repo ${MARKETPLACE_REPO} not found`);
78
+ assertOk(treeRes);
79
+ const body = (await treeRes.json());
80
+ if (!body || !Array.isArray(body.tree)) {
81
+ throw fetchError("bad-yaml", `Marketplace tree listing is malformed`);
82
+ }
83
+ const found = [];
84
+ const seen = new Set();
85
+ for (const item of body.tree) {
86
+ if (item.type !== "blob" || typeof item.path !== "string")
87
+ continue;
88
+ const m = WORKFLOW_PATH_RE.exec(item.path);
89
+ if (m)
90
+ found.push({ source: m[1], id: m[2] });
75
91
  }
76
- return raw;
92
+ // Official first, then community; the first source to claim an id wins.
93
+ found.sort((a, b) => MARKETPLACE_SOURCES.indexOf(a.source) -
94
+ MARKETPLACE_SOURCES.indexOf(b.source));
95
+ const entries = await Promise.all(found.map(async ({ source, id }) => {
96
+ if (seen.has(id))
97
+ return null;
98
+ seen.add(id);
99
+ try {
100
+ const res = await fetch(`${MARKETPLACE_RAW_BASE}/workflows/${source}/${id}.yml`, signal ? { signal } : undefined);
101
+ if (!res.ok)
102
+ return null;
103
+ const yaml = await res.text();
104
+ const parsed = parseYaml(yaml);
105
+ if (!parsed || typeof parsed !== "object")
106
+ return null;
107
+ return {
108
+ id,
109
+ name: typeof parsed.name === "string" && parsed.name ? parsed.name : id,
110
+ description: typeof parsed.description === "string" ? parsed.description : "",
111
+ skills: extractSkillsFromYaml(yaml),
112
+ };
113
+ }
114
+ catch {
115
+ return null;
116
+ }
117
+ }));
118
+ return entries.filter((e) => e !== null);
77
119
  }
78
120
  const CATEGORY_TO_CONFIG_KEY = {
79
121
  git: "source-control-provider",
@@ -177,7 +219,7 @@ export async function adaptWorkflowInteractive(workflow, options) {
177
219
  }
178
220
  }
179
221
  /**
180
- * Fetch a workflow from swenyai/workflows, adapt if provider mismatch is detected
222
+ * Fetch a workflow from swenyai/marketplace, adapt if provider mismatch is detected
181
223
  * AND an agent is available, then write files idempotently.
182
224
  *
183
225
  * When `overwrite` is false (default) and the workflow file already exists,
package/dist/cli/new.d.ts CHANGED
@@ -145,7 +145,7 @@ export declare function nonInteractiveUsage(): string;
145
145
  * Flow:
146
146
  * 1. Detect git remote (used to infer source-control provider)
147
147
  * 2. Pick one of five branches:
148
- * - `__marketplace` → browse published workflows at swenyai/workflows, then delegate to `installMarketplaceWorkflow`
148
+ * - `__marketplace` → browse published workflows at swenyai/marketplace, then delegate to `installMarketplaceWorkflow`
149
149
  * - A built-in `WORKFLOW_TEMPLATES` entry → write that workflow file
150
150
  * - `__custom` → LLM generates a workflow from a user description
151
151
  * - `__e2e` → delegate to the e2e browser-testing wizard in `./e2e.ts`
package/dist/cli/new.js CHANGED
@@ -523,7 +523,7 @@ function cancel() {
523
523
  * Flow:
524
524
  * 1. Detect git remote (used to infer source-control provider)
525
525
  * 2. Pick one of five branches:
526
- * - `__marketplace` → browse published workflows at swenyai/workflows, then delegate to `installMarketplaceWorkflow`
526
+ * - `__marketplace` → browse published workflows at swenyai/marketplace, then delegate to `installMarketplaceWorkflow`
527
527
  * - A built-in `WORKFLOW_TEMPLATES` entry → write that workflow file
528
528
  * - `__custom` → LLM generates a workflow from a user description
529
529
  * - `__e2e` → delegate to the e2e browser-testing wizard in `./e2e.ts`
@@ -669,7 +669,7 @@ export async function runNew(options) {
669
669
  {
670
670
  value: "__marketplace",
671
671
  label: "Browse marketplace",
672
- hint: "install a published workflow from swenyai/workflows",
672
+ hint: "install a published workflow from swenyai/marketplace",
673
673
  },
674
674
  ]
675
675
  : []),
@@ -0,0 +1,162 @@
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 type { NodeResult, Workflow } from "../types.js";
22
+ export declare const RUN_ANSWERS_SCHEMA_VERSION = 1;
23
+ /** Strings longer than this are cut (with an ellipsis) before they are stored. */
24
+ export declare const ANSWER_STRING_MAX = 2000;
25
+ export interface RunAnswers {
26
+ schema_version: number;
27
+ run_id: string;
28
+ workflow_id: string;
29
+ /** node id -> declared output fields (redacted). Only nodes that succeeded and produced a field. */
30
+ nodes: Record<string, Record<string, unknown>>;
31
+ /**
32
+ * Strings longer than {@link ANSWER_STRING_MAX} are cut in `nodes` (display only). This mirrors
33
+ * `nodes` and holds the sha256 of each cut string's FULL text, so a change past the cut still
34
+ * compares as changed. Absent when nothing was cut.
35
+ */
36
+ digests?: Record<string, Record<string, unknown>>;
37
+ }
38
+ export interface BuildRunAnswersInput {
39
+ runId: string;
40
+ workflow: Workflow;
41
+ results: Map<string, NodeResult>;
42
+ /** Secret values to redact (the same set the journal and output.md use). */
43
+ secrets?: string[];
44
+ }
45
+ /**
46
+ * Declared fields of each successful node, deep, redacted (values and keys). A failed or skipped
47
+ * node records nothing, so the diff never reads a failure as "everything resolved".
48
+ */
49
+ export declare function buildRunAnswers(i: BuildRunAnswersInput): RunAnswers;
50
+ /** Relative path: `.sweny/runs/<id>/answers.json`. */
51
+ export declare function answersRelPath(runId: string): string;
52
+ /** Write answers.json (mode 0600; the run dir ignores itself in git). Returns the relative path, or null. Never throws. */
53
+ export declare function writeRunAnswers(answers: RunAnswers, cwd?: string): string | null;
54
+ /**
55
+ * Read a run's answers. Null when the id is not a generated run id (checked before any path is
56
+ * built), the file is missing, oversized, reached through a link, corrupt, an unknown schema
57
+ * version, or carries a different run id than the one asked for.
58
+ */
59
+ export declare function readRunAnswers(runId: string, cwd?: string): RunAnswers | null;
60
+ /** Fields that name an item, in priority order. */
61
+ export declare const IDENTITY_KEYS: readonly ["id", "key", "fingerprint", "title", "name"];
62
+ /**
63
+ * "Is this the same finding as that one?" The rule, in order:
64
+ * 1. An object item is identified by the first of `id`, `key`, `fingerprint`,
65
+ * `title`, `name` that holds a non-empty string or a number, normalized
66
+ * (trimmed, lowercased, whitespace collapsed).
67
+ * 2. An object with none of those is identified by its whole content (a
68
+ * short hash of its canonical JSON): any edit reads as resolved + added.
69
+ * 3. A plain string, number or boolean item is identified by its normalized value.
70
+ * Two items of one list with the same identity pair up in order.
71
+ */
72
+ export declare function itemIdentity(item: unknown): string;
73
+ export interface DiffCounts {
74
+ added: number;
75
+ resolved: number;
76
+ changed: number;
77
+ unchanged: number;
78
+ }
79
+ export interface DiffItem {
80
+ /** Normalized identity (see {@link itemIdentity}). */
81
+ id: string;
82
+ /** Human label: the raw identity value, else the first string field. */
83
+ label: string;
84
+ value: unknown;
85
+ }
86
+ export interface FieldChange {
87
+ field: string;
88
+ /** Absent when the field is new on this item. */
89
+ before?: unknown;
90
+ /** Absent when the field was dropped from this item. */
91
+ after?: unknown;
92
+ }
93
+ export interface ChangedItem {
94
+ id: string;
95
+ /** Label from the newer run. */
96
+ label: string;
97
+ fields: FieldChange[];
98
+ }
99
+ export interface CollectionEntry {
100
+ node: string;
101
+ field: string;
102
+ kind: "collection";
103
+ added: DiffItem[];
104
+ resolved: DiffItem[];
105
+ changed: ChangedItem[];
106
+ unchanged: number;
107
+ }
108
+ export interface ValueEntry {
109
+ node: string;
110
+ field: string;
111
+ kind: "value";
112
+ status: "added" | "resolved" | "changed" | "unchanged";
113
+ before?: unknown;
114
+ after?: unknown;
115
+ }
116
+ export type AnswerEntry = CollectionEntry | ValueEntry;
117
+ export interface AnswerDiff {
118
+ available: true;
119
+ counts: DiffCounts;
120
+ entries: AnswerEntry[];
121
+ /** Nodes with answers in only one run: not compared, never counted as added or resolved. */
122
+ not_compared: Array<{
123
+ node: string;
124
+ only_in: "a" | "b";
125
+ }>;
126
+ }
127
+ export type AnswersComparison = AnswerDiff | {
128
+ available: false;
129
+ reason: "different_workflows";
130
+ } | {
131
+ available: false;
132
+ reason: "missing_answers";
133
+ missing: Array<"a" | "b">;
134
+ };
135
+ /**
136
+ * Compare an older run's answers (a) with a newer one's (b), node by node and
137
+ * field by field. A list field (all objects, or all scalars) is diffed item by
138
+ * item using {@link itemIdentity}; any other field is one value. Cut strings
139
+ * compare by the digest of their full text.
140
+ */
141
+ export declare function compareAnswers(a: RunAnswers | null, b: RunAnswers | null): AnswersComparison;
142
+ export interface SinceLast {
143
+ /** The CURRENT run's own workflow id, from the loaded workflow, never from history. */
144
+ workflow_id: string;
145
+ /** The run this one was compared with. */
146
+ previous_run_id: string;
147
+ counts: DiffCounts;
148
+ /** Nodes with answers in only one of the two runs (failed, skipped or no output): not compared. */
149
+ not_compared: number;
150
+ }
151
+ /**
152
+ * Compare a recorded run with the run of the same workflow just before it. `workflowId` comes from
153
+ * the caller's loaded workflow; history only picks WHICH earlier run, never what is printed.
154
+ * Null when there is no earlier run, either run has no answers, or anything fails.
155
+ */
156
+ export declare function sinceLastRun(runId: string, workflowId: string, cwd?: string): SinceLast | null;
157
+ /** Save this run's answers, then compare with the previous run. Never throws. */
158
+ export declare function recordRunAnswers(i: BuildRunAnswersInput, cwd?: string): SinceLast | null;
159
+ /** Plain text, one line: for the terminal and CI logs. */
160
+ export declare function formatSinceLast(s: SinceLast): string;
161
+ /** Markdown, one line: for the step summary and the PR comment. */
162
+ export declare function formatSinceLastMarkdown(s: SinceLast): string;