@sweny-ai/core 0.1.163 → 0.1.165

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,307 @@
1
+ /**
2
+ * Run journal (#363): an append-only log of one workflow run, enough to resume
3
+ * a killed or failed run without re-running the nodes that already finished.
4
+ *
5
+ * File: `.sweny/runs/<run-id>/journal.ndjson`, one JSON record per line. Every
6
+ * record carries the format version (`v`), a sequence number (`seq`) and a
7
+ * checksum (`h`, sha256 of the record without `h`). Each append is written and
8
+ * fsync'd before the executor moves on, so a record on disk is a fact.
9
+ *
10
+ * Records, in run order:
11
+ * run:start workflow / instruction / input / tool hashes, harness id, input (redacted)
12
+ * run:resume one per `sweny workflow resume`
13
+ * node:start a node visit began (node + iteration)
14
+ * node:checkpoint the agent finished a node with outputs, its result and
15
+ * write intents, written before the write stage runs
16
+ * output:intent a write is about to be applied (idempotency key + tool)
17
+ * output:applied the write returned (key + the ids it produced)
18
+ * node:end final result of the visit and the write-stage counters
19
+ * route the edge taken after the visit (null = the run ended)
20
+ * run:end success | failed | crashed
21
+ *
22
+ * Recovery: a torn or corrupt tail (power loss mid-append) is truncated back to
23
+ * the last valid record. Corruption in the middle of the file is refused.
24
+ *
25
+ * Privacy: the journal is local and holds what the run needs to continue (node
26
+ * data, eval verdicts, safe-output intents). It never holds environment values:
27
+ * secret-looking keys, known token shapes, and the values of secret env vars
28
+ * and skill credentials are replaced with `[redacted]` before anything is
29
+ * written. Tool call inputs and outputs are not journaled.
30
+ *
31
+ * Writes: the write stage's skill handlers are wrapped. Each write gets an
32
+ * idempotency key (node, iteration, tool, canonical arguments), an
33
+ * `output:intent` before the call and an `output:applied` after it. On resume a
34
+ * key with a receipt is not re-applied, and a key with an intent but no receipt
35
+ * (the process died between the API call and the receipt) is looked up on the
36
+ * provider by a marker embedded in the body before anything is re-sent.
37
+ */
38
+ import type { Logger, NodeResult, Skill, Workflow } from "./types.js";
39
+ import type { SafeOutputIntent, WriteStageState } from "./safe-outputs.js";
40
+ export declare const JOURNAL_SCHEMA_VERSION = 1;
41
+ export declare const JOURNAL_DIR: string;
42
+ export declare const JOURNAL_FILE = "journal.ndjson";
43
+ /** Journals kept on disk; the oldest beyond this are deleted when a new run starts. */
44
+ export declare const JOURNAL_KEEP = 20;
45
+ export declare const REDACTED = "[redacted]";
46
+ /** sha256 (hex) of `value` as canonical JSON: keys sorted, undefined dropped. Same as run history's workflow hash. */
47
+ export declare function canonicalHash(value: unknown): string;
48
+ /**
49
+ * Hash of the workflow a journal is bound to: the canonical workflow plus its
50
+ * effective `spec_version` (#469), so a file that only changes spec version
51
+ * (and therefore migrations) is a different workflow for resume.
52
+ */
53
+ export declare function workflowHashOf(workflow: Workflow): string;
54
+ /** Hash of what the nodes were told: every resolved instruction, rule and context Source. */
55
+ export declare function instructionHash(sources: Record<string, {
56
+ content: string;
57
+ }>): string;
58
+ /** Hash of the tools the run could call: skill ids, tool names and access, MCP presence, sweny version. */
59
+ export declare function toolsHash(skills: Map<string, Skill>, swenyVersion?: string, harnessId?: string): string;
60
+ /** True for keys like `GITHUB_TOKEN`, `apiKey`, `client-secret`, `Authorization`. */
61
+ export declare function isSecretKey(key: string): boolean;
62
+ /** Values of secret-named env vars / config entries, longest first (8+ chars, so short words never match). */
63
+ export declare function collectSecretValues(...maps: Array<Record<string, string | undefined> | undefined>): string[];
64
+ /**
65
+ * Deep copy of `value` with secrets replaced by `[redacted]`: string values
66
+ * under secret-looking keys, known token shapes, and any occurrence of a value
67
+ * in `secrets`. Numbers and booleans are kept (`inputTokens` is a count).
68
+ */
69
+ export declare function redact(value: unknown, secrets?: string[]): {
70
+ value: unknown;
71
+ redacted: boolean;
72
+ };
73
+ export interface JournalRecord {
74
+ v: number;
75
+ seq: number;
76
+ type: string;
77
+ at: string;
78
+ [field: string]: unknown;
79
+ }
80
+ export interface RunStartRecord extends JournalRecord {
81
+ type: "run:start";
82
+ run_id: string;
83
+ workflow_id: string;
84
+ workflow_entry: string;
85
+ workflow_file?: string;
86
+ workflow_hash: string;
87
+ instruction_hash: string;
88
+ input_hash: string;
89
+ tools_hash: string;
90
+ harness?: {
91
+ id: string;
92
+ };
93
+ sweny_version?: string;
94
+ input: unknown;
95
+ input_redacted: boolean;
96
+ }
97
+ export interface WriteStateSnapshot {
98
+ counts: Array<[string, number]>;
99
+ total: number;
100
+ seen: string[];
101
+ }
102
+ interface CheckpointRecord extends JournalRecord {
103
+ node: string;
104
+ iteration: number;
105
+ result: NodeResult;
106
+ intents: SafeOutputIntent[];
107
+ agent_failed: boolean;
108
+ attempt: number;
109
+ }
110
+ interface EndRecord extends JournalRecord {
111
+ node: string;
112
+ iteration: number;
113
+ result: NodeResult;
114
+ write_state: WriteStateSnapshot;
115
+ }
116
+ interface OutputRecord extends JournalRecord {
117
+ node: string;
118
+ iteration: number;
119
+ key: string;
120
+ tool: string;
121
+ output?: unknown;
122
+ recovered?: boolean;
123
+ }
124
+ /** Thrown when the journal was written by a newer sweny. */
125
+ export declare class JournalVersionError extends Error {
126
+ constructor(version: unknown);
127
+ }
128
+ /** Thrown at resume when what the run depends on changed since it was journaled (and `force` is off). */
129
+ export declare class JournalMismatchError extends Error {
130
+ readonly what: string[];
131
+ constructor(what: string[]);
132
+ }
133
+ /** Thrown when another live process holds the run's journal. */
134
+ export declare class JournalLockedError extends Error {
135
+ constructor(pid: number);
136
+ }
137
+ export interface JournalRead {
138
+ file: string;
139
+ records: JournalRecord[];
140
+ /** Bytes dropped from a torn / corrupt tail (0 when the file was clean). */
141
+ truncatedBytes: number;
142
+ /** Set when an invalid record sits before valid ones: not a torn tail, so not repairable. */
143
+ corruptAtLine?: number;
144
+ }
145
+ /**
146
+ * Read a journal. With `repair`, a torn or corrupt tail is truncated on disk
147
+ * back to the last valid record. Throws {@link JournalVersionError} for a
148
+ * newer format.
149
+ */
150
+ export declare function readJournal(file: string, opts?: {
151
+ repair?: boolean;
152
+ }): JournalRead;
153
+ export type VisitAction = "replay" | "write-stage" | "rerun";
154
+ export interface PlannedVisit {
155
+ node: string;
156
+ iteration: number;
157
+ action: VisitAction;
158
+ /** Final status in the journal, for replayed visits. */
159
+ status?: NodeResult["status"];
160
+ /** Route taken after the visit: a node id, null for run end, undefined when not journaled. */
161
+ next?: string | null;
162
+ /** Writes the checkpointed write stage already applied (receipts in the journal). */
163
+ applied: number;
164
+ /** Writes with an intent but no receipt: checked on the provider before anything is re-sent. */
165
+ unconfirmed: number;
166
+ }
167
+ export interface ResumePlan {
168
+ runId: string;
169
+ start: RunStartRecord;
170
+ /** 1 for the first run, +1 per resume so far. */
171
+ attempts: number;
172
+ /** Last `run:end` status, if the journal has one. */
173
+ lastStatus?: string;
174
+ visits: PlannedVisit[];
175
+ /** Node the resumed run continues at, if it was routed to but never started. */
176
+ freshNode?: string;
177
+ /** Every journaled visit is complete and the last route ended the run. */
178
+ finished: boolean;
179
+ /** Write-stage counters after the last replayed visit. */
180
+ writeState?: WriteStateSnapshot;
181
+ checkpoints: Map<string, CheckpointRecord>;
182
+ ends: Map<string, EndRecord>;
183
+ intents: Map<string, OutputRecord>;
184
+ receipts: Map<string, OutputRecord>;
185
+ }
186
+ /** Work out what a resume replays and where it picks up. */
187
+ export declare function buildResumePlan(records: JournalRecord[]): ResumePlan;
188
+ /**
189
+ * Nodes a resume would run again that may repeat a write outside safe outputs:
190
+ * the visit had started, was not read-only (no `permissions: read`, no
191
+ * `outputs`, not a dry run), and has no checkpoint. Its agent could have
192
+ * called a write tool, pushed, or edited files before the crash.
193
+ */
194
+ export declare function mayRepeatWrites(plan: ResumePlan, workflow: Workflow, input: unknown): string[];
195
+ /** Short, single-token marker the provider's search can find. */
196
+ export declare function markerToken(key: string): string;
197
+ /** What the executor tells the journal once sources are resolved. */
198
+ export interface JournalBeginInfo {
199
+ workflow: Workflow;
200
+ input: unknown;
201
+ sources: Record<string, {
202
+ content: string;
203
+ }>;
204
+ skills: Map<string, Skill>;
205
+ config: Record<string, string>;
206
+ writeState: WriteStageState;
207
+ harnessId?: string;
208
+ }
209
+ export interface JournalCheckpoint {
210
+ result: NodeResult;
211
+ intents: SafeOutputIntent[];
212
+ agentRunFailed: boolean;
213
+ attempt: number;
214
+ }
215
+ export type JournalReplay = {
216
+ kind: "complete";
217
+ result: NodeResult;
218
+ next?: string | null;
219
+ } | ({
220
+ kind: "checkpoint";
221
+ } & JournalCheckpoint);
222
+ /**
223
+ * The executor's view of a journal (`ExecuteOptions.journal`). Every hook is
224
+ * best effort except at resume, where `begin` refuses a changed run.
225
+ */
226
+ export interface ExecutionJournal {
227
+ begin(info: JournalBeginInfo): void;
228
+ /** A visit an earlier attempt finished (or checkpointed); undefined = run it. */
229
+ replay(node: string, iteration: number): JournalReplay | undefined;
230
+ nodeStart(node: string, iteration: number): void;
231
+ checkpoint(node: string, iteration: number, cp: JournalCheckpoint): void;
232
+ /** The skill map the write stage applies through: write tools journaled and made idempotent. */
233
+ wrapWrites(node: string, iteration: number, skills: Map<string, Skill>): Map<string, Skill>;
234
+ nodeEnd(node: string, iteration: number, result: NodeResult, writeState: WriteStageState): void;
235
+ route(from: string, to: string | null): void;
236
+ }
237
+ /** Test seam: called before each append; a throw simulates the process dying there. */
238
+ export interface JournalFaults {
239
+ beforeAppend?(record: JournalRecord): void;
240
+ }
241
+ export interface RunJournalOptions {
242
+ runId: string;
243
+ /** Base directory; the journal goes in `<cwd>/.sweny/runs/<run-id>/`. Default: process.cwd(). */
244
+ cwd?: string;
245
+ /** Workflow file the run came from, so `resume` can load it again. */
246
+ workflowFile?: string;
247
+ swenyVersion?: string;
248
+ /** Environment whose secret-named values are redacted. Never written. */
249
+ env?: Record<string, string | undefined>;
250
+ logger?: Logger;
251
+ faults?: JournalFaults;
252
+ /** Journals kept on disk (default {@link JOURNAL_KEEP}). */
253
+ keep?: number;
254
+ }
255
+ export interface ResumeJournalOptions extends RunJournalOptions {
256
+ read: JournalRead;
257
+ plan: ResumePlan;
258
+ /** Resume even though the workflow, instructions, input or tools changed. */
259
+ force?: boolean;
260
+ /** Re-send writes whose outcome cannot be confirmed on the provider. */
261
+ allowRepeatWrites?: boolean;
262
+ }
263
+ export declare function journalDir(cwd: string, runId: string): string;
264
+ /** Run ids with a journal on disk, oldest first. */
265
+ export declare function listJournalRuns(cwd?: string): string[];
266
+ /** Exact run id, or a unique prefix. */
267
+ export declare function findJournalRun(ref: string, cwd?: string): string | undefined;
268
+ /** Delete the oldest journals beyond `keep`, never `except`. Never throws. */
269
+ export declare function pruneJournals(cwd: string, keep?: number, except?: string): number;
270
+ export declare class RunJournal implements ExecutionJournal {
271
+ readonly runId: string;
272
+ readonly dir: string;
273
+ readonly file: string;
274
+ private readonly cwd;
275
+ private readonly opts;
276
+ private readonly logger;
277
+ private readonly resume?;
278
+ private fd;
279
+ private seq;
280
+ private dead;
281
+ private began;
282
+ private secrets;
283
+ private consumed;
284
+ private constructor();
285
+ /** A journal for a new run. Nothing touches disk until the executor calls `begin`. */
286
+ static create(opts: RunJournalOptions): RunJournal;
287
+ /** A journal that continues `opts.read` (already repaired) under the resume plan. */
288
+ static openForResume(opts: ResumeJournalOptions): RunJournal;
289
+ /** False once an append failed: the rest of the run is not journaled. */
290
+ get active(): boolean;
291
+ private takeLock;
292
+ private releaseLock;
293
+ private append;
294
+ private closeFd;
295
+ private clean;
296
+ begin(info: JournalBeginInfo): void;
297
+ replay(node: string, iteration: number): JournalReplay | undefined;
298
+ nodeStart(node: string, iteration: number): void;
299
+ checkpoint(node: string, iteration: number, cp: JournalCheckpoint): void;
300
+ nodeEnd(node: string, iteration: number, result: NodeResult, writeState: WriteStageState): void;
301
+ route(from: string, to: string | null): void;
302
+ /** Close the run: `run:end`, then release the lock. Safe to call twice. */
303
+ end(status: "success" | "failed" | "crashed"): void;
304
+ wrapWrites(node: string, iteration: number, skills: Map<string, Skill>): Map<string, Skill>;
305
+ private wrapTool;
306
+ }
307
+ export {};