spl-lang 0.5.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 +77 -106
- package/dist/ast.d.ts +10 -0
- package/dist/checkpoint.d.ts +59 -0
- package/dist/checkpoint.js +382 -0
- package/dist/cli.js +176 -35
- package/dist/copilot.js +45 -5
- package/dist/index.d.ts +4 -2
- package/dist/index.js +4 -2
- package/dist/interpreter.d.ts +6 -1
- package/dist/interpreter.js +150 -18
- package/dist/lexer.d.ts +2 -0
- package/dist/lexer.js +1 -1
- package/dist/llm.d.ts +4 -1
- package/dist/markdown.d.ts +18 -0
- package/dist/markdown.js +109 -0
- package/dist/parser.d.ts +1 -0
- package/dist/parser.js +49 -15
- package/dist/preamble.d.ts +2 -0
- package/dist/preamble.js +38 -0
- package/dist/recording.d.ts +32 -1
- package/dist/recording.js +94 -19
- package/package.json +4 -2
- package/docs/syntax.ebnf +0 -97
package/README.md
CHANGED
|
@@ -1,106 +1,77 @@
|
|
|
1
|
-
# spl-lang
|
|
2
|
-
|
|
3
|
-
The
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
and
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
|
|
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
|
+
}
|