spl-lang 0.6.0 → 1.0.0

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/README.md CHANGED
@@ -1,106 +1,77 @@
1
- # spl-lang 0.3
2
-
3
- The Semantic Prompt Language interpreter. Requires Node.js 20+.
4
-
5
- See the [project guide](../README.md), [language specification](../LANG-PRD.md),
6
- [EBNF](docs/syntax.ebnf), and [documentation site](../docs/index.html).
7
-
8
- ## Run from a checkout
9
-
10
- ```sh
11
- npm install
12
- node dist/cli.js login
13
- node dist/cli.js examples/hi.spl
14
- ```
15
-
16
- `npm install` runs the TypeScript build. Installed packages expose `spl` and `spl-lang`.
17
- Authentication is needed only for live generation and explicit agents; raw tools and replay
18
- work without Copilot. Existing Copilot/CLI credentials and supported GitHub token environment
19
- variables are reused. `spl login` otherwise runs device authentication and saves credentials
20
- under `~/.spl` (override with `SPL_HOME`); `spl logout` removes those saved credentials.
21
-
22
- ## Language contract
23
-
24
- - **Exact tools:** `read_file(path)`, `write_file(path, content)`, `web_fetch(url)`,
25
- `web_search(query)`, `shell(command)` never call a model.
26
- - **Generation:** `?` and `think` have no tools or MCP access.
27
- - **Agency:** `agent` explicitly enables model-directed runtime and configured MCP tools.
28
- - **Validation:** expression-valued `schema` uses strict synchronous JSON Schema draft-07;
29
- `fmt` supports `integer`, `float`, `array`, `object`, `text`, and `boolean`.
30
- Non-text formats require strict JSON of the selected type; `float` accepts any finite
31
- number, including whole numbers, and `object` requires a dict, not a list.
32
- `fmt='json'` still accepts any JSON value. Prose and code fences are not silently extracted.
33
- - **Repairs:** `repairs=0..5` retries only malformed/invalid generation, not transport errors
34
- or external actions. Agents require `repairs=0`. Use `assert` for business rules.
35
- - **Guidance:** `enforce`/`forbid` and `len` influence prompts, not runtime guarantees.
36
- - **Capabilities:** nested `restrict fs/net/exec/mcp` clauses intersect. Shell command
37
- inspection is best effort; MCP servers are trusted services, not OS-sandboxed.
38
- - **Bindings:** every block has a lexical child scope. `let` declares/redeclares locally
39
- and shadows outer names. Reads and assignments search outward for the first declaration;
40
- child locals, loop variables and caught failures do not leak to parents.
41
- - **Expressions:** blocks and conditionals yield their final expression, otherwise `None`.
42
- `{}` remains an empty dict. Functions still require explicit `return`.
43
- - **Logging:** `log(...)` defaults to info. `log{level=debug}(...)` selects error/info/debug;
44
- bare level names, strings and expressions are accepted and checked at runtime.
45
- - **Runtime inputs:** `$env.NAME` / `$env['NAME']` reads process environment strings;
46
- `$args[i]` reads repeated CLI `--arg` strings in order (empty list by default).
47
- Missing environment names and out-of-range argument indexes return `None`.
48
- Both are read-only snapshots, including through aliases, and are not automatically persisted.
49
-
50
- Common generation modifiers are `len`, `fmt`, `reasoning`, `show`, `schema`, `repairs`,
51
- and `model`. Values are evaluated once, then validated before calling the model.
52
- `think` and `agent` default to medium reasoning; `?` uses the model default.
53
- CLI `--model` overrides per-call `model`, which overrides the header directive.
54
-
55
- ## Record/replay
56
-
57
- ```sh
58
- node dist/cli.js examples/triage-issues.spl --record triage.jsonl
59
- node dist/cli.js examples/triage-issues.spl --replay triage.jsonl --silent
60
- ```
61
-
62
- Record creates a new file and rejects agents. Replay performs exact, ordered request matching
63
- without starting Copilot/MCP. Local reads are permitted; writes, shell, network, agents,
64
- and persistent stores are blocked. Recordings include prompts and raw responses and must
65
- be protected like logs. Mismatched, missing, or unused entries fail explicitly.
66
-
67
- ## CLI
68
-
69
- ```text
70
- spl script.spl [--model id] [--mcp file.json] [--cwd dir] [--timeout seconds]
71
- [--store file.json | --store-module module]
72
- [--record file.jsonl | --replay file.jsonl]
73
- [--log-level error|info|debug] [--silent] [--check]
74
- [--arg value ...]
75
- ```
76
-
77
- Results are printed on stdout, logs on stderr. Full prompts/responses and statement traces
78
- appear at every non-silent log level. Exit codes are `0` success, `1` script failure,
79
- `2` syntax/usage error, and `70` internal error. `--check` does not execute modifier expressions
80
- or compile schemas. See [CLI and MCP configuration](../docs/cli.html).
81
-
82
- ## Tests and embedding
83
-
84
- ```sh
85
- npm test
86
- ```
87
-
88
- The default suite is offline. In PowerShell, opt into authenticated live checks with
89
- `$env:SPL_LIVE_TESTS='1'; npm test`, then remove the environment variable.
90
-
91
- The package exports `parse`, `run`, `Logger`, `CopilotBackend`, `RecordingBackend`,
92
- `ReplayBackend`, variable stores, authentication helpers, and related types.
93
- Call `close()` on the backend after running; replay also checks for unused entries there.
94
- Custom backends must honor the request kind: only `agent` may execute tools.
95
- Embedding callers can pass `args: ['first', 'second']` in `RunOptions`; omitted
96
- arguments default to `[]`, not the host's `process.argv`. Each run snapshots `process.env`.
97
- See [embedding](../docs/internals.html#embedding).
98
-
99
- ## Migration
100
-
101
- Use `enforce` and `forbid` for scoped prompt guidance; `instruct` and `avoid` are not accepted.
102
- 0.3 removes `think{level=...}`, tool-command syntax, and `let` before
103
- compound/member/index assignments. Use `reasoning`, ordinary raw functions, and explicit agents instead.
104
- Blocks create lexical child scopes; replace inner `let` with assignment when an outer
105
- binding must be updated. See the [migration table](../README.md#breaking-changes-in-03).
106
- `fmt='object'` is now object-only; use `fmt='array'` for lists or `fmt='json'` for any JSON value.
1
+ # spl-lang
2
+
3
+ The SPL interpreter and CLI. Runs Markdown workflows that mix Python-like `spl` fences with
4
+ natural-language blockquotes. Requires Node.js 20+. See the
5
+ [documentation](https://dgawlik.github.io/semantic-prompt-language/) for the full guide.
6
+
7
+ ## Run
8
+
9
+ ```sh
10
+ npm install -g spl-lang # or, in this folder: npm install && node dist/cli.js
11
+ spl login
12
+ spl examples/hi.md
13
+ ```
14
+
15
+ `spl login` reuses Copilot CLI, `gh` or `GH_TOKEN`/`GITHUB_TOKEN` credentials, or runs GitHub's device
16
+ flow. Workflows without model calls, `--check` and replay work without signing in.
17
+
18
+ ```text
19
+ spl workflow.md [--arg value ...] [--model id] [--cwd dir] [--mcp file.json] [--timeout s]
20
+ [--store file.json | --store-module module]
21
+ [--trace file.jsonl | --record file.jsonl | --replay file.jsonl]
22
+ [--log-level error|info|debug] [--silent] [--check]
23
+ spl workflow.md --checkpoint run.jsonl
24
+ spl workflow.md --resume run.jsonl [--answer-for ID (--answer text | --answer-file file)]
25
+ spl login | logout
26
+ ```
27
+
28
+ The result goes to stdout and logs to stderr. Exit codes: `0` success, `1` script failure,
29
+ `2` syntax or usage error, `3` waiting for human input, `70` internal error.
30
+ See [CLI & runtime](../docs-template/CLI.md).
31
+
32
+ ## Language in brief
33
+
34
+ ```spl
35
+ let Rating = {'type': 'object', 'properties': {'score': {'type': 'integer'}}, 'required': ['score']}
36
+ let review = read_file('review.txt')
37
+ let rating = ask(schema=Rating, repairs=2) 'Rate this review from 1 to 5: {review}'
38
+ assert 1 <= rating.score and rating.score <= 5 'score out of range'
39
+ restrict './out' {
40
+ write_file('./out/rating.json', json_stringify(rating))
41
+ }
42
+ success rating
43
+ ```
44
+
45
+ * Exact tools `read_file`, `write_file`, `web_fetch`, `web_search` and `shell` never call a model.
46
+ * `ask` and `think` generate values without tools; `agent` can act, within `restrict` scopes.
47
+ * Blockquotes are translated into SPL by the model and run in the same scope.
48
+ * `fmt`, `schema`, `repairs` and `assert` check output; `enforce`/`forbid` only guide prompts.
49
+
50
+ See [Language](../docs-template/Language.md), [Natural language & models](../docs-template/LLM-commands.md)
51
+ and [Guards & failures](../docs-template/Safety.md).
52
+
53
+ ## Embedding
54
+
55
+ ```js
56
+ import { parseMarkdown, run, Logger, CopilotBackend } from 'spl-lang';
57
+
58
+ const logger = new Logger('silent');
59
+ const backend = new CopilotBackend({ logger, cwd: process.cwd(), timeoutMs: 300_000 });
60
+ try {
61
+ const result = await run(parseMarkdown(source), { backend, logger, args: ['first'] });
62
+ console.log(result);
63
+ } finally {
64
+ await backend.close();
65
+ }
66
+ ```
67
+
68
+ Also exported: `parse` (raw SPL), `TraceBackend`, `ReplayBackend`, `RecordingBackend`, variable stores,
69
+ `Checkpoint`, authentication helpers and types. See [Embedding](../docs-template/CLI.md#embedding).
70
+
71
+ ## Tests
72
+
73
+ ```sh
74
+ npm test
75
+ ```
76
+
77
+ Tests are offline. In PowerShell, `$env:SPL_LIVE_TESTS='1'; npm test` adds authenticated live tests.
package/dist/ast.d.ts CHANGED
@@ -79,6 +79,10 @@ export type Expr = {
79
79
  t: 'log';
80
80
  level: Expr;
81
81
  args: Expr[];
82
+ } | {
83
+ t: 'human';
84
+ prompt: Expr;
85
+ line: number;
82
86
  } | {
83
87
  t: 'ask' | 'think' | 'agent';
84
88
  mods: Record<string, Expr>;
@@ -142,6 +146,9 @@ export type Stmt = Base & ({
142
146
  t: 'continue';
143
147
  } | {
144
148
  t: 'pass';
149
+ } | {
150
+ t: 'natural';
151
+ text: string;
145
152
  } | {
146
153
  t: 'def';
147
154
  name: string;
@@ -185,5 +192,8 @@ export interface Program {
185
192
  directives: Directives;
186
193
  body: Stmt[];
187
194
  lines: string[];
195
+ markdown?: {
196
+ preamble?: string;
197
+ };
188
198
  }
189
199
  export {};
@@ -0,0 +1,59 @@
1
+ export interface CheckpointContext {
2
+ source: string;
3
+ runtime: string;
4
+ cwd: string;
5
+ args?: readonly string[];
6
+ configuration?: unknown;
7
+ }
8
+ export interface HumanRequest {
9
+ run_id: string;
10
+ request_id: string;
11
+ question: string;
12
+ checkpoint: string;
13
+ line: number;
14
+ }
15
+ export declare class CheckpointError extends Error {
16
+ readonly kind: 'usage' | 'infrastructure';
17
+ constructor(message: string, kind?: 'usage' | 'infrastructure');
18
+ }
19
+ export declare class SuspensionSignal {
20
+ readonly request: HumanRequest;
21
+ constructor(request: HumanRequest);
22
+ }
23
+ export declare function checkpointFingerprint(value: unknown): string;
24
+ /** A single-writer effect history. Only sealed human-input boundaries can resume. */
25
+ export declare class Checkpoint {
26
+ readonly file: string;
27
+ private readonly fd;
28
+ private readonly lock;
29
+ private readonly header;
30
+ private readonly events;
31
+ private position;
32
+ private readonly replayLength;
33
+ private sequence;
34
+ private observedEnvironment;
35
+ private pending?;
36
+ private closed;
37
+ private poisoned;
38
+ private terminal;
39
+ private constructor();
40
+ static create(file: string, context: CheckpointContext): Checkpoint;
41
+ static resume(file: string, context: CheckpointContext): Checkpoint;
42
+ private static acquire;
43
+ get runId(): string;
44
+ get args(): readonly string[];
45
+ get replaying(): boolean;
46
+ get request(): HumanRequest | undefined;
47
+ private pendingRequest;
48
+ answer(requestId: string, value: string): void;
49
+ private write;
50
+ private append;
51
+ observeEnvironment(snapshot: unknown): void;
52
+ effect<T>(request: unknown, perform: () => Promise<T>, valid: (value: unknown) => value is T): Promise<T>;
53
+ human(question: string, line: number): string;
54
+ finish(status: 'success' | 'failure'): void;
55
+ assertConsumed(): void;
56
+ validateSource(source: string, args?: readonly string[]): void;
57
+ private assertActive;
58
+ close(): void;
59
+ }
@@ -0,0 +1,382 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import { Failure } from './errors.js';
5
+ import { isTraceError } from './recording.js';
6
+ export class CheckpointError extends Error {
7
+ kind;
8
+ constructor(message, kind = 'usage') {
9
+ super(message);
10
+ this.kind = kind;
11
+ this.name = 'CheckpointError';
12
+ }
13
+ }
14
+ export class SuspensionSignal {
15
+ request;
16
+ constructor(request) {
17
+ this.request = request;
18
+ }
19
+ }
20
+ const record = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
21
+ const strings = (value) => Array.isArray(value) && value.every((item) => typeof item === 'string');
22
+ const integer = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
23
+ function canonical(value) {
24
+ if (Array.isArray(value))
25
+ return `[${value.map(canonical).join(',')}]`;
26
+ if (record(value))
27
+ return `{${Object.keys(value).filter((key) => value[key] !== undefined).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`;
28
+ const json = JSON.stringify(value);
29
+ if (json === undefined)
30
+ throw new CheckpointError('checkpoint identity contains an unsupported value', 'infrastructure');
31
+ return json;
32
+ }
33
+ export function checkpointFingerprint(value) {
34
+ return createHash('sha256').update(canonical(value)).digest('hex');
35
+ }
36
+ function environmentFingerprint() {
37
+ return checkpointFingerprint(Object.fromEntries(Object.entries(process.env).filter(([, value]) => value !== undefined)));
38
+ }
39
+ function identity(context) {
40
+ return {
41
+ source: checkpointFingerprint(context.source.replace(/\r\n/g, '\n')),
42
+ runtime: context.runtime,
43
+ cwd: fs.realpathSync.native(path.resolve(context.cwd)),
44
+ configuration: checkpointFingerprint(context.configuration ?? null),
45
+ };
46
+ }
47
+ function parseHeader(value) {
48
+ if (!record(value) || value.format !== 'spl-checkpoint' || value.version !== 1
49
+ || typeof value.run_id !== 'string' || !value.run_id
50
+ || typeof value.source !== 'string' || typeof value.runtime !== 'string'
51
+ || typeof value.cwd !== 'string' || !strings(value.args) || typeof value.configuration !== 'string') {
52
+ throw new CheckpointError('unsupported or invalid checkpoint header');
53
+ }
54
+ return {
55
+ format: value.format, version: value.version, run_id: value.run_id, source: value.source,
56
+ runtime: value.runtime, cwd: value.cwd, args: value.args, configuration: value.configuration,
57
+ };
58
+ }
59
+ function parseEvent(value) {
60
+ if (record(value)) {
61
+ switch (value.type) {
62
+ case 'intent':
63
+ if (integer(value.sequence) && typeof value.request === 'string') {
64
+ return { type: value.type, sequence: value.sequence, request: value.request };
65
+ }
66
+ break;
67
+ case 'outcome':
68
+ if (integer(value.sequence) && record(value.outcome)) {
69
+ if (Object.hasOwn(value.outcome, 'value') && !Object.hasOwn(value.outcome, 'error')) {
70
+ return { type: value.type, sequence: value.sequence, outcome: { value: value.outcome.value } };
71
+ }
72
+ if (!Object.hasOwn(value.outcome, 'value') && isTraceError(value.outcome.error)) {
73
+ return { type: value.type, sequence: value.sequence, outcome: { error: value.outcome.error } };
74
+ }
75
+ }
76
+ break;
77
+ case 'environment':
78
+ if (typeof value.fingerprint === 'string')
79
+ return { type: value.type, fingerprint: value.fingerprint };
80
+ break;
81
+ case 'question':
82
+ if (typeof value.request_id === 'string' && value.request_id && typeof value.question === 'string' && integer(value.line)) {
83
+ return { type: value.type, request_id: value.request_id, question: value.question, line: value.line };
84
+ }
85
+ break;
86
+ case 'answer':
87
+ if (typeof value.request_id === 'string' && typeof value.value === 'string') {
88
+ return { type: value.type, request_id: value.request_id, value: value.value };
89
+ }
90
+ break;
91
+ case 'terminal':
92
+ if (value.status === 'success' || value.status === 'failure')
93
+ return { type: value.type, status: value.status };
94
+ }
95
+ }
96
+ throw new CheckpointError('invalid checkpoint event');
97
+ }
98
+ function validateHistory(events) {
99
+ let sequence = 0;
100
+ let intent;
101
+ let pending;
102
+ let terminal = false;
103
+ let environment = false;
104
+ const requests = new Set();
105
+ for (const event of events) {
106
+ if (terminal)
107
+ throw new CheckpointError('events after terminal checkpoint state');
108
+ if (intent !== undefined && event.type !== 'outcome')
109
+ throw new CheckpointError('checkpoint has an unresolved effect');
110
+ if (pending && event.type !== 'answer')
111
+ throw new CheckpointError('checkpoint has an unanswered question');
112
+ switch (event.type) {
113
+ case 'intent':
114
+ if (event.sequence !== sequence++)
115
+ throw new CheckpointError('invalid checkpoint effect sequence');
116
+ intent = event.sequence;
117
+ break;
118
+ case 'outcome':
119
+ if (intent === undefined || intent !== event.sequence)
120
+ throw new CheckpointError('outcome without matching checkpoint intent');
121
+ intent = undefined;
122
+ break;
123
+ case 'question':
124
+ if (requests.has(event.request_id))
125
+ throw new CheckpointError('duplicate checkpoint request ID');
126
+ requests.add(event.request_id);
127
+ pending = event;
128
+ break;
129
+ case 'answer':
130
+ if (!pending || pending.request_id !== event.request_id)
131
+ throw new CheckpointError('answer without matching checkpoint question');
132
+ pending = undefined;
133
+ break;
134
+ case 'environment':
135
+ if (environment)
136
+ throw new CheckpointError('duplicate checkpoint environment');
137
+ environment = true;
138
+ if (event.fingerprint !== environmentFingerprint())
139
+ throw new CheckpointError('checkpoint environment mismatch');
140
+ break;
141
+ case 'terminal':
142
+ terminal = true;
143
+ }
144
+ }
145
+ if (terminal)
146
+ throw new CheckpointError('checkpoint run is already terminal');
147
+ if (intent !== undefined)
148
+ throw new CheckpointError('checkpoint has an unresolved effect; manual recovery required');
149
+ if (!pending)
150
+ throw new CheckpointError('checkpoint run was interrupted; manual recovery required');
151
+ return pending;
152
+ }
153
+ function ioMessage(error) {
154
+ return error instanceof Error ? error.message : String(error);
155
+ }
156
+ /** A single-writer effect history. Only sealed human-input boundaries can resume. */
157
+ export class Checkpoint {
158
+ file;
159
+ fd;
160
+ lock;
161
+ header;
162
+ events;
163
+ position = 0;
164
+ replayLength;
165
+ sequence;
166
+ observedEnvironment = false;
167
+ pending;
168
+ closed = false;
169
+ poisoned = false;
170
+ terminal = false;
171
+ constructor(file, fd, lock, header, events, pending) {
172
+ this.file = file;
173
+ this.fd = fd;
174
+ this.lock = lock;
175
+ this.header = header;
176
+ this.events = events;
177
+ this.pending = pending;
178
+ this.replayLength = events.length;
179
+ this.sequence = events.filter((event) => event.type === 'intent').length;
180
+ }
181
+ static create(file, context) {
182
+ const resolved = path.resolve(file);
183
+ const lock = this.acquire(resolved);
184
+ let fd;
185
+ try {
186
+ const header = {
187
+ format: 'spl-checkpoint', version: 1, run_id: randomUUID(),
188
+ ...identity(context), args: [...(context.args ?? [])],
189
+ };
190
+ fd = fs.openSync(resolved, 'wx', 0o600);
191
+ const checkpoint = new Checkpoint(resolved, fd, lock, header, []);
192
+ checkpoint.write(header);
193
+ return checkpoint;
194
+ }
195
+ catch (error) {
196
+ if (fd !== undefined)
197
+ fs.closeSync(fd);
198
+ fs.closeSync(lock);
199
+ fs.unlinkSync(`${resolved}.lock`);
200
+ if (error instanceof CheckpointError)
201
+ throw error;
202
+ throw new CheckpointError(`cannot create checkpoint: ${ioMessage(error)}`);
203
+ }
204
+ }
205
+ static resume(file, context) {
206
+ const resolved = path.resolve(file);
207
+ const lock = this.acquire(resolved);
208
+ let fd;
209
+ try {
210
+ const text = fs.readFileSync(resolved, 'utf8');
211
+ if (!text.endsWith('\n'))
212
+ throw new CheckpointError('truncated checkpoint; manual recovery required');
213
+ const lines = text.slice(0, -1).split('\n');
214
+ const header = parseHeader(JSON.parse(lines.shift() ?? ''));
215
+ const expected = identity(context);
216
+ for (const key of ['source', 'runtime', 'cwd', 'configuration']) {
217
+ if (expected[key] !== header[key])
218
+ throw new CheckpointError(`checkpoint ${key} mismatch`);
219
+ }
220
+ if (context.args !== undefined && canonical(context.args) !== canonical(header.args)) {
221
+ throw new CheckpointError('checkpoint script arguments mismatch');
222
+ }
223
+ const events = lines.map((line) => parseEvent(JSON.parse(line)));
224
+ const pending = validateHistory(events);
225
+ fd = fs.openSync(resolved, 'a');
226
+ return new Checkpoint(resolved, fd, lock, header, events, pending);
227
+ }
228
+ catch (error) {
229
+ if (fd !== undefined)
230
+ fs.closeSync(fd);
231
+ fs.closeSync(lock);
232
+ fs.unlinkSync(`${resolved}.lock`);
233
+ if (error instanceof CheckpointError)
234
+ throw error;
235
+ throw new CheckpointError(`cannot resume checkpoint: ${ioMessage(error)}`);
236
+ }
237
+ }
238
+ static acquire(file) {
239
+ try {
240
+ return fs.openSync(`${file}.lock`, 'wx', 0o600);
241
+ }
242
+ catch (error) {
243
+ throw new CheckpointError(`cannot lock checkpoint (another writer or stale lock may exist): ${ioMessage(error)}`);
244
+ }
245
+ }
246
+ get runId() { return this.header.run_id; }
247
+ get args() { return [...this.header.args]; }
248
+ get replaying() { return this.position < this.replayLength; }
249
+ get request() {
250
+ return this.pending ? { run_id: this.runId, checkpoint: this.file, ...this.pendingRequest(this.pending) } : undefined;
251
+ }
252
+ pendingRequest(question) {
253
+ return { request_id: question.request_id, question: question.question, line: question.line };
254
+ }
255
+ answer(requestId, value) {
256
+ this.assertActive();
257
+ if (!this.pending || this.pending.request_id !== requestId) {
258
+ throw new CheckpointError('answer does not match the pending request ID; use request_id from the latest waiting_for_input response, not run_id');
259
+ }
260
+ this.append({ type: 'answer', request_id: requestId, value });
261
+ this.pending = undefined;
262
+ }
263
+ write(value) {
264
+ this.assertActive();
265
+ try {
266
+ fs.writeFileSync(this.fd, JSON.stringify(value) + '\n');
267
+ fs.fsyncSync(this.fd);
268
+ }
269
+ catch (error) {
270
+ this.poisoned = true;
271
+ throw new CheckpointError(`cannot durably save checkpoint: ${ioMessage(error)}`, 'infrastructure');
272
+ }
273
+ }
274
+ append(event) {
275
+ this.write(event);
276
+ this.events.push(event);
277
+ }
278
+ observeEnvironment(snapshot) {
279
+ this.assertActive();
280
+ if (this.observedEnvironment)
281
+ return;
282
+ const event = { type: 'environment', fingerprint: checkpointFingerprint(snapshot) };
283
+ if (this.replaying) {
284
+ if (canonical(this.events[this.position++]) !== canonical(event))
285
+ throw new CheckpointError('checkpoint environment history mismatch');
286
+ }
287
+ else {
288
+ this.append(event);
289
+ }
290
+ this.observedEnvironment = true;
291
+ }
292
+ async effect(request, perform, valid) {
293
+ this.assertActive();
294
+ const key = canonical(request);
295
+ if (this.replaying) {
296
+ const intent = this.events[this.position++];
297
+ const outcome = this.events[this.position++];
298
+ if (intent?.type !== 'intent' || intent.request !== key || outcome?.type !== 'outcome' || outcome.sequence !== intent.sequence) {
299
+ throw new CheckpointError('checkpoint effect history mismatch');
300
+ }
301
+ if ('error' in outcome.outcome) {
302
+ const error = outcome.outcome.error;
303
+ throw new Failure(error.status_code, error.reason);
304
+ }
305
+ if (!valid(outcome.outcome.value))
306
+ throw new CheckpointError('invalid recorded checkpoint effect result');
307
+ return structuredClone(outcome.outcome.value);
308
+ }
309
+ if (this.pending)
310
+ throw new CheckpointError('cannot execute before answering the pending question');
311
+ const sequence = this.sequence++;
312
+ this.append({ type: 'intent', sequence, request: key });
313
+ let outcome;
314
+ try {
315
+ const value = await perform();
316
+ if (!valid(value))
317
+ throw new CheckpointError('invalid live checkpoint effect result', 'infrastructure');
318
+ outcome = { value };
319
+ }
320
+ catch (error) {
321
+ if (!(error instanceof Failure))
322
+ throw error;
323
+ outcome = { error: { status_code: error.statusCode, reason: error.reason } };
324
+ }
325
+ this.append({ type: 'outcome', sequence, outcome });
326
+ if ('error' in outcome)
327
+ throw new Failure(outcome.error.status_code, outcome.error.reason);
328
+ if (!valid(outcome.value))
329
+ throw new CheckpointError('invalid checkpoint effect result', 'infrastructure');
330
+ return outcome.value;
331
+ }
332
+ human(question, line) {
333
+ this.assertActive();
334
+ if (this.replaying) {
335
+ const event = this.events[this.position++];
336
+ const answer = this.events[this.position++];
337
+ if (event?.type !== 'question' || event.question !== question || event.line !== line
338
+ || answer?.type !== 'answer' || answer.request_id !== event.request_id) {
339
+ throw new CheckpointError('checkpoint human-input history mismatch');
340
+ }
341
+ return answer.value;
342
+ }
343
+ if (this.pending)
344
+ throw new CheckpointError('cannot execute before answering the pending question');
345
+ const event = { type: 'question', request_id: randomUUID(), question, line };
346
+ this.append(event);
347
+ this.pending = event;
348
+ throw new SuspensionSignal({ run_id: this.runId, checkpoint: this.file, ...this.pendingRequest(event) });
349
+ }
350
+ finish(status) {
351
+ this.assertConsumed();
352
+ this.append({ type: 'terminal', status });
353
+ this.terminal = true;
354
+ }
355
+ assertConsumed() {
356
+ if (this.replaying || this.pending)
357
+ throw new CheckpointError('checkpoint ended with unused history or pending input');
358
+ }
359
+ validateSource(source, args) {
360
+ this.assertActive();
361
+ if (checkpointFingerprint(source.replace(/\r\n/g, '\n')) !== this.header.source)
362
+ throw new CheckpointError('checkpoint source mismatch');
363
+ if (args !== undefined && canonical(args) !== canonical(this.header.args))
364
+ throw new CheckpointError('checkpoint script arguments mismatch');
365
+ }
366
+ assertActive() {
367
+ if (this.closed || this.poisoned || this.terminal)
368
+ throw new CheckpointError('checkpoint is closed, terminal, or unusable', 'infrastructure');
369
+ }
370
+ close() {
371
+ if (this.closed)
372
+ return;
373
+ this.closed = true;
374
+ try {
375
+ fs.closeSync(this.fd);
376
+ }
377
+ finally {
378
+ fs.closeSync(this.lock);
379
+ fs.unlinkSync(`${this.file}.lock`);
380
+ }
381
+ }
382
+ }