@sweny-ai/core 0.1.172 → 0.1.174

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/journal.d.ts CHANGED
@@ -2,10 +2,23 @@
2
2
  * Run journal (#363): an append-only log of one workflow run, enough to resume
3
3
  * a killed or failed run without re-running the nodes that already finished.
4
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.
5
+ * File: `.sweny/runs/<run-id>/journal.ndjson` (mode 0600), one JSON record per
6
+ * line. Every record carries the format version (`v`), a sequence number (`seq`)
7
+ * and an authentication code (`h`, HMAC-SHA256 of the record without `h`, under
8
+ * a per-run key). Each append is written and fsync'd before the executor moves
9
+ * on, so a record on disk is a fact.
10
+ *
11
+ * Integrity: the key is 32 random bytes made when the run starts and kept
12
+ * OUTSIDE the workspace, in the user's sweny state dir
13
+ * (`$SWENY_STATE_DIR`, else `$XDG_STATE_HOME/sweny`, else
14
+ * `~/.local/state/sweny`), under `run-keys/` (dir 0700, file 0600). Anything
15
+ * that can write the workspace but not that dir (an agent confined to the
16
+ * workspace, a checked-out branch, an artifact) cannot mint a record resume
17
+ * accepts. Both sandboxes deny reading the key dir; an unsandboxed agent
18
+ * running as the same OS user can still read it, which is why resume also
19
+ * checks the record sequence (see {@link buildResumePlan}) and every replayed
20
+ * route against the workflow's real edges. A resume on another machine needs
21
+ * the same state dir.
9
22
  *
10
23
  * Records, in run order:
11
24
  * run:start workflow / instruction / input / tool hashes, harness id, input (redacted)
@@ -17,8 +30,12 @@
17
30
  * output:applied the write returned (key + the ids it produced)
18
31
  * node:end final result of the visit and the write-stage counters
19
32
  * route the edge taken after the visit (null = the run ended)
33
+ * usage spend of one agent attempt (cumulative; live while it runs, final when it returns)
20
34
  * run:end success | failed | crashed
21
35
  *
36
+ * Spend: a resume seeds the run budget with every attempt's journaled usage,
37
+ * so a crash never resets the whole-run ceiling.
38
+ *
22
39
  * Recovery: a torn or corrupt tail (power loss mid-append) is truncated back to
23
40
  * the last valid record. Corruption in the middle of the file is refused.
24
41
  *
@@ -35,9 +52,11 @@
35
52
  * (the process died between the API call and the receipt) is looked up on the
36
53
  * provider by a marker embedded in the body before anything is re-sent.
37
54
  */
38
- import type { Logger, NodeResult, Skill, Workflow } from "./types.js";
55
+ import type { Logger, NodeResult, NodeUsage, Skill, Workflow } from "./types.js";
39
56
  import type { SafeOutputIntent, WriteStageState } from "./safe-outputs.js";
40
- export declare const JOURNAL_SCHEMA_VERSION = 1;
57
+ import { type Spend } from "./budget.js";
58
+ /** v2: records are authenticated with a per-run HMAC key (v1 used a public checksum and is refused). */
59
+ export declare const JOURNAL_SCHEMA_VERSION = 2;
41
60
  export declare const JOURNAL_DIR: string;
42
61
  export declare const JOURNAL_FILE = "journal.ndjson";
43
62
  /** Journals kept on disk; the oldest beyond this are deleted when a new run starts. */
@@ -55,12 +74,39 @@ export declare function workflowHashOf(workflow: Workflow): string;
55
74
  export declare function instructionHash(sources: Record<string, {
56
75
  content: string;
57
76
  }>): 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;
77
+ /**
78
+ * Fingerprint of everything that decides what the run's tools do: per skill its
79
+ * id, name, description, category, instruction, config field names and env var
80
+ * NAMES, every tool's name, description, input schema, access class and a
81
+ * digest of its handler source, the MCP server's type, command, args, url,
82
+ * env var NAMES and header NAMES, and its tool aliases; plus the sweny version
83
+ * (which pins built-in skill code) and the harness. Secret values never enter
84
+ * it: MCP env and header values are dropped, and args are redacted first, so
85
+ * rotating a token neither changes the fingerprint nor leaves a hash of it.
86
+ */
87
+ export declare function toolsHash(skills: Map<string, Skill>, swenyVersion?: string, harnessId?: string, secrets?: string[]): string;
88
+ /** sweny's per-user state dir: `$SWENY_STATE_DIR`, else `$XDG_STATE_HOME/sweny`, else `~/.local/state/sweny`. */
89
+ export declare function swenyStateDir(env?: Record<string, string | undefined>): string;
90
+ /** Where run keys live. Never inside the workspace. */
91
+ export declare function runKeyDir(env?: Record<string, string | undefined>): string;
92
+ /** The key file for one run in one workspace (the workspace path is hashed, not stored). */
93
+ export declare function runKeyFile(cwd: string, runId: string, keyDir?: string): string;
94
+ /** Thrown when a journal's key is missing or unreadable: its records cannot be checked. */
95
+ export declare class JournalKeyError extends Error {
96
+ constructor(file: string, why: string);
97
+ }
98
+ /** Read a run key. Throws {@link JournalKeyError}. */
99
+ export declare function loadRunKey(file: string): Buffer;
60
100
  /** True for keys like `GITHUB_TOKEN`, `apiKey`, `client-secret`, `Authorization`. */
61
101
  export declare function isSecretKey(key: string): boolean;
62
102
  /** Values of secret-named env vars / config entries, longest first (8+ chars, so short words never match). */
63
103
  export declare function collectSecretValues(...maps: Array<Record<string, string | undefined> | undefined>): string[];
104
+ /**
105
+ * The secret values a run can see: secret-named env vars, plus skill config
106
+ * fields (resolved from their env vars) whose config key looks secret. The
107
+ * same set the journal redacts, for anything else that prints node output.
108
+ */
109
+ export declare function runSecretValues(skills: Iterable<Skill> | undefined, env?: Record<string, string | undefined>): string[];
64
110
  /**
65
111
  * Deep copy of `value` with secrets replaced by `[redacted]`: string values
66
112
  * under secret-looking keys, known token shapes, and any occurrence of a value
@@ -121,10 +167,14 @@ interface OutputRecord extends JournalRecord {
121
167
  output?: unknown;
122
168
  recovered?: boolean;
123
169
  }
124
- /** Thrown when the journal was written by a newer sweny. */
170
+ /** Thrown when the journal was written by a newer sweny, or by an older one without record authentication. */
125
171
  export declare class JournalVersionError extends Error {
126
172
  constructor(version: unknown);
127
173
  }
174
+ /** Thrown when the journal's records describe something the executor never writes: edited, spliced or forged. */
175
+ export declare class JournalIntegrityError extends Error {
176
+ constructor(message: string);
177
+ }
128
178
  /** Thrown at resume when what the run depends on changed since it was journaled (and `force` is off). */
129
179
  export declare class JournalMismatchError extends Error {
130
180
  readonly what: string[];
@@ -139,16 +189,25 @@ export interface JournalRead {
139
189
  records: JournalRecord[];
140
190
  /** Bytes dropped from a torn / corrupt tail (0 when the file was clean). */
141
191
  truncatedBytes: number;
142
- /** Set when an invalid record sits before valid ones: not a torn tail, so not repairable. */
192
+ /**
193
+ * Set when an invalid record sits before valid ones (not a torn tail), or when
194
+ * a whole record fails authentication (tampering, never a torn write). Not repairable.
195
+ */
143
196
  corruptAtLine?: number;
197
+ /** The line whose record failed authentication, when that is why the journal is refused. */
198
+ forgedAtLine?: number;
144
199
  }
145
200
  /**
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.
201
+ * Read a journal. With `repair`, a torn tail is truncated on disk back to the
202
+ * last valid record. Records are authenticated with the run's key (`key`, else
203
+ * loaded from the state dir for the run the path names). Throws
204
+ * {@link JournalVersionError} for another format and {@link JournalKeyError}
205
+ * when the key is missing.
149
206
  */
150
207
  export declare function readJournal(file: string, opts?: {
151
208
  repair?: boolean;
209
+ key?: Buffer;
210
+ keyDir?: string;
152
211
  }): JournalRead;
153
212
  export type VisitAction = "replay" | "write-stage" | "rerun";
154
213
  export interface PlannedVisit {
@@ -182,8 +241,25 @@ export interface ResumePlan {
182
241
  ends: Map<string, EndRecord>;
183
242
  intents: Map<string, OutputRecord>;
184
243
  receipts: Map<string, OutputRecord>;
244
+ /** Spend every earlier attempt journaled: the run budget starts here on resume. */
245
+ priorSpend: Spend;
185
246
  }
186
- /** Work out what a resume replays and where it picks up. */
247
+ /**
248
+ * Check that the records are a sequence the executor can write: one run:start
249
+ * first; nothing but run:resume after run:end; a visit starts only where the
250
+ * last route pointed (or the entry), or re-starts an unfinished or failed
251
+ * visit after a resume; checkpoints, writes, usage and node:end only inside
252
+ * the open visit; one route per finished visit, from that visit's node.
253
+ * Throws {@link JournalIntegrityError}.
254
+ */
255
+ export declare function checkRecordSequence(records: JournalRecord[]): void;
256
+ /**
257
+ * Check a journal against the workflow it is resumed with: every visited node
258
+ * exists, and every journaled route is a real edge that its `max_iterations`
259
+ * still allowed. Returns the first problem, or undefined.
260
+ */
261
+ export declare function checkJournalAgainstWorkflow(records: JournalRecord[], workflow: Workflow): string | undefined;
262
+ /** Work out what a resume replays and where it picks up. Throws {@link JournalIntegrityError} for an impossible sequence. */
187
263
  export declare function buildResumePlan(records: JournalRecord[]): ResumePlan;
188
264
  /**
189
265
  * Nodes a resume would run again that may repeat a write outside safe outputs:
@@ -233,7 +309,16 @@ export interface ExecutionJournal {
233
309
  wrapWrites(node: string, iteration: number, skills: Map<string, Skill>): Map<string, Skill>;
234
310
  nodeEnd(node: string, iteration: number, result: NodeResult, writeState: WriteStageState): void;
235
311
  route(from: string, to: string | null): void;
312
+ /**
313
+ * Spend of one agent attempt, cumulative for the attempt: `final` when the
314
+ * attempt returned, otherwise a live report (the journal may throttle those).
315
+ */
316
+ usage?(node: string, iteration: number, attempt: number, usage: NodeUsage, final: boolean): void;
317
+ /** Spend earlier attempts of this run already journaled (resume); the run budget starts from it. */
318
+ priorSpend?(): Spend | undefined;
236
319
  }
320
+ /** Minimum gap between two live usage records of one attempt. Final reports are always written. */
321
+ export declare const USAGE_JOURNAL_INTERVAL_MS = 2000;
237
322
  /** Test seam: called before each append; a throw simulates the process dying there. */
238
323
  export interface JournalFaults {
239
324
  beforeAppend?(record: JournalRecord): void;
@@ -251,6 +336,8 @@ export interface RunJournalOptions {
251
336
  faults?: JournalFaults;
252
337
  /** Journals kept on disk (default {@link JOURNAL_KEEP}). */
253
338
  keep?: number;
339
+ /** Where run keys live (default {@link runKeyDir}). Never inside the workspace. */
340
+ keyDir?: string;
254
341
  }
255
342
  export interface ResumeJournalOptions extends RunJournalOptions {
256
343
  read: JournalRead;
@@ -265,8 +352,8 @@ export declare function journalDir(cwd: string, runId: string): string;
265
352
  export declare function listJournalRuns(cwd?: string): string[];
266
353
  /** Exact run id, or a unique prefix. */
267
354
  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;
355
+ /** Delete the oldest journals (and their keys) beyond `keep`, never `except`. Never throws. */
356
+ export declare function pruneJournals(cwd: string, keep?: number, except?: string, keyDir?: string): number;
270
357
  export declare class RunJournal implements ExecutionJournal {
271
358
  readonly runId: string;
272
359
  readonly dir: string;
@@ -279,8 +366,14 @@ export declare class RunJournal implements ExecutionJournal {
279
366
  private seq;
280
367
  private dead;
281
368
  private began;
369
+ private closed;
282
370
  private secrets;
283
371
  private consumed;
372
+ /** Per-run HMAC key; lives only here and in the key file outside the workspace. */
373
+ private key;
374
+ private readonly keyFile;
375
+ /** Last live usage record per attempt: time and spend, for throttling. */
376
+ private lastUsage;
284
377
  private constructor();
285
378
  /** A journal for a new run. Nothing touches disk until the executor calls `begin`. */
286
379
  static create(opts: RunJournalOptions): RunJournal;
@@ -289,6 +382,8 @@ export declare class RunJournal implements ExecutionJournal {
289
382
  /** False once an append failed: the rest of the run is not journaled. */
290
383
  get active(): boolean;
291
384
  private takeLock;
385
+ /** The run dir is private (0700); its parents are created as usual. */
386
+ private makeDir;
292
387
  private releaseLock;
293
388
  private append;
294
389
  private closeFd;
@@ -299,7 +394,9 @@ export declare class RunJournal implements ExecutionJournal {
299
394
  checkpoint(node: string, iteration: number, cp: JournalCheckpoint): void;
300
395
  nodeEnd(node: string, iteration: number, result: NodeResult, writeState: WriteStageState): void;
301
396
  route(from: string, to: string | null): void;
302
- /** Close the run: `run:end`, then release the lock. Safe to call twice. */
397
+ usage(node: string, iteration: number, attempt: number, usage: NodeUsage, final: boolean): void;
398
+ priorSpend(): Spend | undefined;
399
+ /** Close the run: `run:end`, then release the lock. Safe to call twice (one run:end). */
303
400
  end(status: "success" | "failed" | "crashed"): void;
304
401
  wrapWrites(node: string, iteration: number, skills: Map<string, Skill>): Map<string, Skill>;
305
402
  private wrapTool;