@intentius/chant-lexicon-fly 0.17.0 → 0.18.1
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/composites/fly-deploy.d.ts +1 -1
- package/dist/composites/fly-deploy.d.ts.map +1 -1
- package/dist/emulator-freshness-cli.d.ts +11 -0
- package/dist/emulator-freshness-cli.d.ts.map +1 -0
- package/dist/emulator-freshness.d.ts +38 -0
- package/dist/emulator-freshness.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/op/activities/emulator-images.d.ts +20 -0
- package/dist/op/activities/emulator-images.d.ts.map +1 -0
- package/dist/op/activities/flaps.d.ts +1 -1
- package/dist/op/activities/flaps.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +4 -0
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/machines-contract.d.ts +36 -0
- package/dist/op/activities/machines-contract.d.ts.map +1 -0
- package/dist/op/activities/sprites-contract.d.ts +45 -0
- package/dist/op/activities/sprites-contract.d.ts.map +1 -0
- package/dist/op/activities/sprites-emulator.d.ts +29 -0
- package/dist/op/activities/sprites-emulator.d.ts.map +1 -0
- package/dist/op/activities/sprites-fake.d.ts +62 -0
- package/dist/op/activities/sprites-fake.d.ts.map +1 -0
- package/dist/op/activities/sprites.d.ts +195 -0
- package/dist/op/activities/sprites.d.ts.map +1 -0
- package/dist/plugin.d.ts.map +1 -1
- package/package.json +6 -2
- package/src/composites/fly-deploy.ts +1 -1
- package/src/emulator-freshness-cli.ts +49 -0
- package/src/emulator-freshness.test.ts +86 -0
- package/src/emulator-freshness.ts +87 -0
- package/src/index.ts +15 -0
- package/src/op/activities/emulator-images.ts +21 -0
- package/src/op/activities/flaps.test.ts +2 -1
- package/src/op/activities/flaps.ts +3 -2
- package/src/op/activities/index.ts +53 -0
- package/src/op/activities/machines-contract.docker.integration.test.ts +72 -0
- package/src/op/activities/machines-contract.test.ts +49 -0
- package/src/op/activities/machines-contract.ts +73 -0
- package/src/op/activities/sprites-contract.docker.integration.test.ts +74 -0
- package/src/op/activities/sprites-contract.test.ts +60 -0
- package/src/op/activities/sprites-contract.ts +61 -0
- package/src/op/activities/sprites-emulator.ts +46 -0
- package/src/op/activities/sprites-fake.ts +314 -0
- package/src/op/activities/sprites.docker.integration.test.ts +99 -0
- package/src/op/activities/sprites.integration.test.ts +158 -0
- package/src/op/activities/sprites.real.test.ts +56 -0
- package/src/op/activities/sprites.test.ts +296 -0
- package/src/op/activities/sprites.ts +527 -0
- package/src/plugin.ts +13 -0
- package/src/skills/chant-fly-sprites.md +104 -0
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sprites lifecycle activities (#762, #766) — imperative, checkpointable sandbox
|
|
3
|
+
* primitives ([sprites.dev](https://sprites.dev)) as chant Op activities, wired
|
|
4
|
+
* to the faithful Sprites API.
|
|
5
|
+
*
|
|
6
|
+
* Unlike a resource lexicon, Sprites have no desired state to reconcile: they
|
|
7
|
+
* are runtime-orchestration primitives (the same category as `k3dUp` /
|
|
8
|
+
* `httpCheck`). Most activities are a direct call over an injectable HTTP client
|
|
9
|
+
* (`SpritesHttp`); `spriteExec` is the exception — it speaks the control
|
|
10
|
+
* WebSocket exec protocol (non-PTY stream framing, per superfly/sprites-go), so
|
|
11
|
+
* it opens a `ws` connection instead. Exported pure helpers (endpoint
|
|
12
|
+
* resolution, the exec frame accumulator, NDJSON parsers, the comment picker)
|
|
13
|
+
* keep the logic unit-testable without a socket or an HTTP server.
|
|
14
|
+
*
|
|
15
|
+
* The headline capability is checkpoint-as-compensation (S5): an Op checkpoints
|
|
16
|
+
* before a risky phase and, on failure, `spriteRestore`s the labeled checkpoint
|
|
17
|
+
* instead of unwinding with an inverse action — the environment itself is the
|
|
18
|
+
* transaction.
|
|
19
|
+
*
|
|
20
|
+
* S3: endpoint override via `SPRITES_BASE_URL` (an explicit `endpoint` arg wins,
|
|
21
|
+
* then the env, then the real Sprites base), so the same Op targets real Sprites
|
|
22
|
+
* or the in-process fake with no code change.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import WebSocket from "ws";
|
|
26
|
+
|
|
27
|
+
export const DEFAULT_SPRITES_BASE_URL = "https://api.sprites.dev";
|
|
28
|
+
|
|
29
|
+
// ── Pure helpers (unit-testable without http/ws) ──────────────────────────────
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve the Sprites base URL (S3): an explicit `endpoint` arg wins, then the
|
|
33
|
+
* `SPRITES_BASE_URL` env, then the real-Sprites default. The trailing slash is
|
|
34
|
+
* stripped so `${base}/v1/...` never doubles up. Pure — mirrors fly's
|
|
35
|
+
* `resolveEndpoint`.
|
|
36
|
+
*/
|
|
37
|
+
export function resolveSpritesEndpoint(
|
|
38
|
+
args: { endpoint?: string } = {},
|
|
39
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
40
|
+
): string {
|
|
41
|
+
const base = args.endpoint || env.SPRITES_BASE_URL || DEFAULT_SPRITES_BASE_URL;
|
|
42
|
+
return base.replace(/\/$/, "");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const spritesUrl = (base: string): string => `${base}/v1/sprites`;
|
|
46
|
+
const spriteUrl = (base: string, id: string): string => `${spritesUrl(base)}/${encodeURIComponent(id)}`;
|
|
47
|
+
// Create uses REST JSON; exec is the control WebSocket below; checkpoints are NDJSON.
|
|
48
|
+
const spriteCheckpointUrl = (base: string, id: string): string => `${spriteUrl(base, id)}/checkpoint`;
|
|
49
|
+
const spriteCheckpointsUrl = (base: string, id: string): string => `${spriteUrl(base, id)}/checkpoints`;
|
|
50
|
+
const spriteCheckpointRestoreUrl = (base: string, id: string, cp: string): string =>
|
|
51
|
+
`${spriteCheckpointsUrl(base, id)}/${encodeURIComponent(cp)}/restore`;
|
|
52
|
+
|
|
53
|
+
function safeJson(text: string): unknown {
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(text);
|
|
56
|
+
} catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── Exec stream framing (non-PTY control WebSocket) ────────────────────────────
|
|
62
|
+
//
|
|
63
|
+
// Every WebSocket message is a binary frame `[StreamID:1 byte][payload]`. The
|
|
64
|
+
// client with no stdin sends a single `[4]` (StreamStdinEOF); the server writes
|
|
65
|
+
// stdout as `[1]<bytes>`, stderr as `[2]<bytes>`, then `[3]<exitcodebyte>` and
|
|
66
|
+
// closes. See superfly/sprites-go websocket.go/exec.go.
|
|
67
|
+
export const STREAM_STDIN = 0;
|
|
68
|
+
export const STREAM_STDOUT = 1;
|
|
69
|
+
export const STREAM_STDERR = 2;
|
|
70
|
+
export const STREAM_EXIT = 3;
|
|
71
|
+
export const STREAM_STDIN_EOF = 4;
|
|
72
|
+
|
|
73
|
+
function toBytes(data: unknown): Uint8Array {
|
|
74
|
+
if (data instanceof Uint8Array) return data;
|
|
75
|
+
if (data instanceof ArrayBuffer) return new Uint8Array(data);
|
|
76
|
+
if (Array.isArray(data)) return Buffer.concat(data.map((d) => toBytes(d) as Buffer));
|
|
77
|
+
if (typeof data === "string") return new TextEncoder().encode(data);
|
|
78
|
+
return new Uint8Array(0);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Accumulate a stream of exec frames into `{ stdout, stderr, exitCode }`. Pure
|
|
83
|
+
* and socket-free so the framing is unit-testable: feed `[1]"hi\n"`, `[3]\x00`
|
|
84
|
+
* and get `{ stdout: "hi\n", exitCode: 0 }`. Per-stream payloads are collected
|
|
85
|
+
* as bytes and decoded once, so a multi-byte character split across frames is
|
|
86
|
+
* preserved. The exit code is the first byte of the `[3]` frame (0 when absent).
|
|
87
|
+
*/
|
|
88
|
+
export function accumulateExecFrames(frames: Iterable<Uint8Array>): SpriteExecResult {
|
|
89
|
+
const outChunks: Uint8Array[] = [];
|
|
90
|
+
const errChunks: Uint8Array[] = [];
|
|
91
|
+
let exitCode = 0;
|
|
92
|
+
for (const frame of frames) {
|
|
93
|
+
if (!frame || frame.length === 0) continue;
|
|
94
|
+
const stream = frame[0];
|
|
95
|
+
const payload = frame.subarray(1);
|
|
96
|
+
if (stream === STREAM_STDOUT) outChunks.push(payload);
|
|
97
|
+
else if (stream === STREAM_STDERR) errChunks.push(payload);
|
|
98
|
+
else if (stream === STREAM_EXIT) exitCode = payload.length > 0 ? payload[0] : 0;
|
|
99
|
+
}
|
|
100
|
+
const dec = new TextDecoder();
|
|
101
|
+
return {
|
|
102
|
+
stdout: dec.decode(concat(outChunks)),
|
|
103
|
+
stderr: dec.decode(concat(errChunks)),
|
|
104
|
+
exitCode,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function concat(chunks: Uint8Array[]): Uint8Array {
|
|
109
|
+
if (chunks.length === 0) return new Uint8Array(0);
|
|
110
|
+
if (chunks.length === 1) return chunks[0];
|
|
111
|
+
return Buffer.concat(chunks.map((c) => Buffer.from(c.buffer, c.byteOffset, c.byteLength)));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Tokenize a command string into an argv, respecting single/double quotes so
|
|
116
|
+
* `sh -c "exit 7"` becomes `["sh", "-c", "exit 7"]`. Each element is sent as a
|
|
117
|
+
* `cmd` query param; `path` is `argv[0]`. Pure.
|
|
118
|
+
*/
|
|
119
|
+
export function splitCommand(cmd: string): string[] {
|
|
120
|
+
const argv: string[] = [];
|
|
121
|
+
let cur = "";
|
|
122
|
+
let has = false;
|
|
123
|
+
let inSingle = false;
|
|
124
|
+
let inDouble = false;
|
|
125
|
+
for (const ch of cmd) {
|
|
126
|
+
if (inSingle) {
|
|
127
|
+
if (ch === "'") inSingle = false;
|
|
128
|
+
else cur += ch;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (inDouble) {
|
|
132
|
+
if (ch === '"') inDouble = false;
|
|
133
|
+
else cur += ch;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (ch === "'") {
|
|
137
|
+
inSingle = true;
|
|
138
|
+
has = true;
|
|
139
|
+
} else if (ch === '"') {
|
|
140
|
+
inDouble = true;
|
|
141
|
+
has = true;
|
|
142
|
+
} else if (ch === " " || ch === "\t" || ch === "\n") {
|
|
143
|
+
if (has) {
|
|
144
|
+
argv.push(cur);
|
|
145
|
+
cur = "";
|
|
146
|
+
has = false;
|
|
147
|
+
}
|
|
148
|
+
} else {
|
|
149
|
+
cur += ch;
|
|
150
|
+
has = true;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (has) argv.push(cur);
|
|
154
|
+
return argv;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Build the `wss://.../exec?cmd=...&path=...&stdin=false&cc=true` URL for a
|
|
159
|
+
* command (http→ws, https→wss). Pure.
|
|
160
|
+
*/
|
|
161
|
+
export function spriteExecWsUrl(base: string, id: string, cmd: string): string {
|
|
162
|
+
const wsBase = base.replace(/^http(s?):\/\//i, (_m, s: string) => `ws${s}://`);
|
|
163
|
+
const argv = splitCommand(cmd);
|
|
164
|
+
const params = new URLSearchParams();
|
|
165
|
+
for (const a of argv) params.append("cmd", a);
|
|
166
|
+
params.set("path", argv[0] ?? "");
|
|
167
|
+
params.set("stdin", "false");
|
|
168
|
+
params.set("cc", "true");
|
|
169
|
+
return `${wsBase}/v1/sprites/${encodeURIComponent(id)}/exec?${params.toString()}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── Checkpoint NDJSON + comment picker ─────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Parse a checkpoint create NDJSON body (line-delimited JSON progress events)
|
|
176
|
+
* and capture the created version id. Real Sprites tags each event with a
|
|
177
|
+
* `type` and a human `data` string — the version id rides inside the text
|
|
178
|
+
* (" ID: v1" and "Checkpoint v1 created successfully"), not a structured
|
|
179
|
+
* field. The in-process fake mirrors that. An older shape (`{event, id}`) is
|
|
180
|
+
* still honored so a structured id always wins. Pure; blank/unparseable lines
|
|
181
|
+
* are skipped; `checkpointId` is "" when the stream carries no id.
|
|
182
|
+
*/
|
|
183
|
+
export function parseCheckpointNdjson(text: string): SpriteCheckpointResult {
|
|
184
|
+
let structuredId = "";
|
|
185
|
+
let textId = "";
|
|
186
|
+
for (const line of text.split("\n")) {
|
|
187
|
+
const t = line.trim();
|
|
188
|
+
if (!t) continue;
|
|
189
|
+
const obj = safeJson(t) as
|
|
190
|
+
| { event?: string; type?: string; id?: string; data?: string }
|
|
191
|
+
| undefined;
|
|
192
|
+
if (!obj) continue;
|
|
193
|
+
const kind = obj.event ?? obj.type;
|
|
194
|
+
// Structured id (older emulator shape) — always preferred when present.
|
|
195
|
+
if (kind === "complete" && typeof obj.id === "string" && obj.id) structuredId = obj.id;
|
|
196
|
+
// Otherwise mine the version id out of the message text. The " ID: v1"
|
|
197
|
+
// detail line and the "Checkpoint v1 created successfully" completion line
|
|
198
|
+
// both carry it; either is enough.
|
|
199
|
+
if (typeof obj.data === "string") {
|
|
200
|
+
const fromId = obj.data.match(/(?:^|\s)ID:\s*(\S+)/);
|
|
201
|
+
if (fromId) textId = fromId[1];
|
|
202
|
+
const fromComplete = obj.data.match(/Checkpoint\s+(\S+)\s+created/i);
|
|
203
|
+
if (fromComplete) textId = fromComplete[1];
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return { checkpointId: structuredId || textId };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Pick the newest checkpoint whose `comment` matches, so a comment-tagged
|
|
211
|
+
* restore rewinds to the most recent labeled snapshot. Newest is by
|
|
212
|
+
* `create_time`; array order breaks ties (the list is chronological). Pure.
|
|
213
|
+
*/
|
|
214
|
+
export function pickCheckpointByComment(list: Checkpoint[], comment: string): Checkpoint | undefined {
|
|
215
|
+
return newestOf(list.filter((c) => c.comment === comment));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function newestOf(list: Checkpoint[]): Checkpoint | undefined {
|
|
219
|
+
let best: Checkpoint | undefined;
|
|
220
|
+
for (const c of list) {
|
|
221
|
+
if (!best || compareCreateTime(c, best) >= 0) best = c;
|
|
222
|
+
}
|
|
223
|
+
return best;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function compareCreateTime(a: Checkpoint, b: Checkpoint): number {
|
|
227
|
+
const ta = Date.parse(a.create_time);
|
|
228
|
+
const tb = Date.parse(b.create_time);
|
|
229
|
+
if (Number.isNaN(ta) || Number.isNaN(tb)) return 0;
|
|
230
|
+
return ta - tb;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ── Activity contracts (the stable interface the Ops/emulator target) ──────────
|
|
234
|
+
|
|
235
|
+
export interface SpriteCreateArgs {
|
|
236
|
+
/** Caller-chosen name, used as the sprite `id` (S4). Every later activity keys on it. */
|
|
237
|
+
name: string;
|
|
238
|
+
/** Base image for the sandbox. */
|
|
239
|
+
image?: string;
|
|
240
|
+
/** Sandbox size (vCPU/memory class). */
|
|
241
|
+
size?: string;
|
|
242
|
+
/** Network / execution policy passed through to the sprite. */
|
|
243
|
+
policy?: unknown;
|
|
244
|
+
/** Endpoint override (S3). Default: `SPRITES_BASE_URL`, else real Sprites. */
|
|
245
|
+
endpoint?: string;
|
|
246
|
+
/** Bearer token. Default: `SPRITES_API_TOKEN`. The fake ignores it. */
|
|
247
|
+
token?: string;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export interface SpriteCreateResult {
|
|
251
|
+
id: string;
|
|
252
|
+
url: string;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export interface SpriteExecArgs {
|
|
256
|
+
/** Target sprite id (the `name` passed to `spriteCreate`). */
|
|
257
|
+
id: string;
|
|
258
|
+
/** Command to run inside the sprite (tokenized into argv, quotes respected). */
|
|
259
|
+
cmd: string;
|
|
260
|
+
/** Per-exec timeout in ms. */
|
|
261
|
+
timeoutMs?: number;
|
|
262
|
+
endpoint?: string;
|
|
263
|
+
token?: string;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export interface SpriteExecResult {
|
|
267
|
+
stdout: string;
|
|
268
|
+
stderr: string;
|
|
269
|
+
exitCode: number;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export interface SpriteCheckpointArgs {
|
|
273
|
+
id: string;
|
|
274
|
+
/** Caller-chosen checkpoint comment (S4); a comment-tagged `spriteRestore` matches it. */
|
|
275
|
+
comment?: string;
|
|
276
|
+
endpoint?: string;
|
|
277
|
+
token?: string;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export interface SpriteCheckpointResult {
|
|
281
|
+
/** The server checkpoint version (e.g. `v3`) captured from the `complete` event. */
|
|
282
|
+
checkpointId: string;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export interface Checkpoint {
|
|
286
|
+
id: string;
|
|
287
|
+
comment: string;
|
|
288
|
+
create_time: string;
|
|
289
|
+
is_auto: boolean;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export interface SpriteRestoreArgs {
|
|
293
|
+
id: string;
|
|
294
|
+
/** Explicit checkpoint id (e.g. `v3`); wins over `comment`. */
|
|
295
|
+
checkpoint?: string;
|
|
296
|
+
/** Restore the newest checkpoint carrying this comment. */
|
|
297
|
+
comment?: string;
|
|
298
|
+
endpoint?: string;
|
|
299
|
+
token?: string;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export interface ListCheckpointsArgs {
|
|
303
|
+
id: string;
|
|
304
|
+
endpoint?: string;
|
|
305
|
+
token?: string;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export interface SpriteDestroyArgs {
|
|
309
|
+
id: string;
|
|
310
|
+
endpoint?: string;
|
|
311
|
+
token?: string;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Build the `POST /v1/sprites` body. Pure. */
|
|
315
|
+
export function spriteCreateBody(args: SpriteCreateArgs): Record<string, unknown> {
|
|
316
|
+
return {
|
|
317
|
+
name: args.name,
|
|
318
|
+
...(args.image !== undefined ? { image: args.image } : {}),
|
|
319
|
+
...(args.size !== undefined ? { size: args.size } : {}),
|
|
320
|
+
...(args.policy !== undefined ? { policy: args.policy } : {}),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Parse the create response; the caller-chosen `name` is the id fallback (S4). Pure. */
|
|
325
|
+
export function parseCreateResponse(text: string, name: string): SpriteCreateResult {
|
|
326
|
+
const b = safeJson(text) as { id?: string; url?: string } | undefined;
|
|
327
|
+
return { id: b?.id ?? name, url: b?.url ?? "" };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ── HTTP ───────────────────────────────────────────────────────────────────────
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Injectable HTTP client — mirrors fly's `FlyHttp`. Tests inject a fake; the
|
|
334
|
+
* default hits `fetch`. Used by create/destroy/checkpoint/list/restore; exec
|
|
335
|
+
* goes over the control WebSocket instead.
|
|
336
|
+
*/
|
|
337
|
+
export type SpritesHttp = (
|
|
338
|
+
method: string,
|
|
339
|
+
url: string,
|
|
340
|
+
body?: unknown,
|
|
341
|
+
headers?: Record<string, string>,
|
|
342
|
+
signal?: AbortSignal,
|
|
343
|
+
) => Promise<{ status: number; text: string }>;
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Default `fetch`-based client. Sends `Authorization: Bearer <token>` when a
|
|
347
|
+
* token is set (real Sprites); the fake ignores it. The token defaults to
|
|
348
|
+
* `SPRITES_API_TOKEN` at call time. `fetchImpl` is injectable for tests.
|
|
349
|
+
*/
|
|
350
|
+
export function defaultSpritesHttp(token?: string, fetchImpl: typeof fetch = fetch): SpritesHttp {
|
|
351
|
+
return async (method, url, body, headers, signal) => {
|
|
352
|
+
const h: Record<string, string> = { ...headers };
|
|
353
|
+
if (body !== undefined) h["content-type"] = "application/json";
|
|
354
|
+
const tok = token ?? process.env.SPRITES_API_TOKEN;
|
|
355
|
+
if (tok) h["authorization"] = `Bearer ${tok}`;
|
|
356
|
+
const res = await fetchImpl(url, {
|
|
357
|
+
method,
|
|
358
|
+
headers: Object.keys(h).length ? h : undefined,
|
|
359
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
360
|
+
signal,
|
|
361
|
+
});
|
|
362
|
+
return { status: res.status, text: await res.text() };
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ── Activities (ActivityFn: (args, signal?) => Promise<unknown>) ──────────────
|
|
367
|
+
|
|
368
|
+
/** Create a sprite with the caller-chosen `name` as its id (S4). `POST /v1/sprites`. */
|
|
369
|
+
export async function spriteCreate(
|
|
370
|
+
args: SpriteCreateArgs,
|
|
371
|
+
signal?: AbortSignal,
|
|
372
|
+
http: SpritesHttp = defaultSpritesHttp(args.token),
|
|
373
|
+
): Promise<SpriteCreateResult> {
|
|
374
|
+
const base = resolveSpritesEndpoint(args);
|
|
375
|
+
const res = await http("POST", spritesUrl(base), spriteCreateBody(args), undefined, signal);
|
|
376
|
+
if (res.status >= 300) throw new Error(`sprite ${args.name} create failed (${res.status}): ${res.text}`);
|
|
377
|
+
const result = parseCreateResponse(res.text, args.name);
|
|
378
|
+
console.log(`created: sprite/${result.id} (${base})`);
|
|
379
|
+
return result;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Run a command in the sprite over the control WebSocket (non-PTY stream
|
|
384
|
+
* framing, per superfly/sprites-go). Connects to `wss://.../exec`, sends a
|
|
385
|
+
* single `[4]` (stdin EOF), accumulates stdout/stderr frames, and reads the
|
|
386
|
+
* exit code from the `[3]` frame. A non-zero exit is a failed activity (it
|
|
387
|
+
* throws) so a risky step fails its phase and triggers `onFailure`
|
|
388
|
+
* compensation (S5).
|
|
389
|
+
*/
|
|
390
|
+
export async function spriteExec(args: SpriteExecArgs, signal?: AbortSignal): Promise<SpriteExecResult> {
|
|
391
|
+
const base = resolveSpritesEndpoint(args);
|
|
392
|
+
const url = spriteExecWsUrl(base, args.id, args.cmd);
|
|
393
|
+
const token = args.token ?? process.env.SPRITES_API_TOKEN;
|
|
394
|
+
const headers: Record<string, string> = {};
|
|
395
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
396
|
+
|
|
397
|
+
const result = await new Promise<SpriteExecResult>((resolve, reject) => {
|
|
398
|
+
const frames: Uint8Array[] = [];
|
|
399
|
+
const ws = new WebSocket(url, { headers });
|
|
400
|
+
let settled = false;
|
|
401
|
+
const finish = (fn: () => void): void => {
|
|
402
|
+
if (settled) return;
|
|
403
|
+
settled = true;
|
|
404
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
405
|
+
fn();
|
|
406
|
+
};
|
|
407
|
+
const onAbort = (): void => {
|
|
408
|
+
try {
|
|
409
|
+
ws.terminate();
|
|
410
|
+
} catch {
|
|
411
|
+
/* already closed */
|
|
412
|
+
}
|
|
413
|
+
finish(() => reject(new Error(`sprite ${args.id} exec aborted`)));
|
|
414
|
+
};
|
|
415
|
+
if (signal) {
|
|
416
|
+
if (signal.aborted) return onAbort();
|
|
417
|
+
signal.addEventListener("abort", onAbort);
|
|
418
|
+
}
|
|
419
|
+
ws.on("open", () => {
|
|
420
|
+
// No stdin: signal EOF immediately (belt and braces with stdin=false).
|
|
421
|
+
ws.send(Uint8Array.of(STREAM_STDIN_EOF));
|
|
422
|
+
});
|
|
423
|
+
ws.on("message", (data) => {
|
|
424
|
+
const bytes = toBytes(data);
|
|
425
|
+
frames.push(bytes);
|
|
426
|
+
if (bytes.length > 0 && bytes[0] === STREAM_EXIT) {
|
|
427
|
+
try {
|
|
428
|
+
ws.close();
|
|
429
|
+
} catch {
|
|
430
|
+
/* closing already */
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
ws.on("error", (err) => finish(() => reject(err)));
|
|
435
|
+
ws.on("close", () => finish(() => resolve(accumulateExecFrames(frames))));
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
if (result.exitCode !== 0) {
|
|
439
|
+
throw new Error(
|
|
440
|
+
`sprite ${args.id} exec "${args.cmd}" exited ${result.exitCode}: ${result.stderr || result.stdout}`,
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
return result;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Checkpoint the sprite. `POST /v1/sprites/{id}/checkpoint` (singular); the
|
|
448
|
+
* `comment` key is omitted when empty. The response is an NDJSON progress
|
|
449
|
+
* stream; the created version id is mined from the message text (see
|
|
450
|
+
* `parseCheckpointNdjson`).
|
|
451
|
+
*/
|
|
452
|
+
export async function spriteCheckpoint(
|
|
453
|
+
args: SpriteCheckpointArgs,
|
|
454
|
+
signal?: AbortSignal,
|
|
455
|
+
http: SpritesHttp = defaultSpritesHttp(args.token),
|
|
456
|
+
): Promise<SpriteCheckpointResult> {
|
|
457
|
+
const base = resolveSpritesEndpoint(args);
|
|
458
|
+
const body = args.comment ? { comment: args.comment } : undefined;
|
|
459
|
+
const res = await http("POST", spriteCheckpointUrl(base, args.id), body, undefined, signal);
|
|
460
|
+
if (res.status >= 300) throw new Error(`sprite ${args.id} checkpoint failed (${res.status}): ${res.text}`);
|
|
461
|
+
const result = parseCheckpointNdjson(res.text);
|
|
462
|
+
console.log(`checkpoint: sprite/${args.id} @${result.checkpointId} (${base})`);
|
|
463
|
+
return result;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* List a sprite's checkpoints. `GET /v1/sprites/{id}/checkpoints` → a bare array
|
|
468
|
+
* `[{ id, comment, create_time, is_auto }]` (auto checkpoints excluded by
|
|
469
|
+
* default on the server).
|
|
470
|
+
*/
|
|
471
|
+
export async function listCheckpoints(
|
|
472
|
+
args: ListCheckpointsArgs,
|
|
473
|
+
signal?: AbortSignal,
|
|
474
|
+
http: SpritesHttp = defaultSpritesHttp(args.token),
|
|
475
|
+
): Promise<Checkpoint[]> {
|
|
476
|
+
const base = resolveSpritesEndpoint(args);
|
|
477
|
+
const res = await http("GET", spriteCheckpointsUrl(base, args.id), undefined, undefined, signal);
|
|
478
|
+
if (res.status >= 300) throw new Error(`sprite ${args.id} list checkpoints failed (${res.status}): ${res.text}`);
|
|
479
|
+
const parsed = safeJson(res.text);
|
|
480
|
+
return Array.isArray(parsed) ? (parsed as Checkpoint[]) : [];
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Restore the sprite to a checkpoint (S5, the compensation path). Resolution
|
|
485
|
+
* order: an explicit `checkpoint` id wins; otherwise the newest checkpoint
|
|
486
|
+
* carrying `comment`; otherwise the newest checkpoint overall. Restore is
|
|
487
|
+
* `POST /v1/sprites/{id}/checkpoints/{cp}/restore` and returns an NDJSON stream.
|
|
488
|
+
*/
|
|
489
|
+
export async function spriteRestore(
|
|
490
|
+
args: SpriteRestoreArgs,
|
|
491
|
+
signal?: AbortSignal,
|
|
492
|
+
http: SpritesHttp = defaultSpritesHttp(args.token),
|
|
493
|
+
): Promise<Record<string, never>> {
|
|
494
|
+
const base = resolveSpritesEndpoint(args);
|
|
495
|
+
let target = args.checkpoint;
|
|
496
|
+
if (!target) {
|
|
497
|
+
const list = await listCheckpoints({ ...args }, signal, http);
|
|
498
|
+
const picked =
|
|
499
|
+
args.comment !== undefined ? pickCheckpointByComment(list, args.comment) : newestOf(list);
|
|
500
|
+
if (!picked) {
|
|
501
|
+
const which = args.comment !== undefined ? `comment "${args.comment}"` : "any checkpoint";
|
|
502
|
+
throw new Error(`sprite ${args.id} restore: no checkpoint matching ${which}`);
|
|
503
|
+
}
|
|
504
|
+
target = picked.id;
|
|
505
|
+
}
|
|
506
|
+
const res = await http("POST", spriteCheckpointRestoreUrl(base, args.id, target), undefined, undefined, signal);
|
|
507
|
+
if (res.status >= 300) {
|
|
508
|
+
throw new Error(`sprite ${args.id} restore to "${target}" failed (${res.status}): ${res.text}`);
|
|
509
|
+
}
|
|
510
|
+
console.log(`restored: sprite/${args.id} to ${target} (${base})`);
|
|
511
|
+
return {};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** Destroy the sprite (idempotent; a 404 means it is already gone). `DELETE /v1/sprites/{id}`. */
|
|
515
|
+
export async function spriteDestroy(
|
|
516
|
+
args: SpriteDestroyArgs,
|
|
517
|
+
signal?: AbortSignal,
|
|
518
|
+
http: SpritesHttp = defaultSpritesHttp(args.token),
|
|
519
|
+
): Promise<Record<string, never>> {
|
|
520
|
+
const base = resolveSpritesEndpoint(args);
|
|
521
|
+
const res = await http("DELETE", spriteUrl(base, args.id), undefined, undefined, signal);
|
|
522
|
+
if (res.status >= 300 && res.status !== 404) {
|
|
523
|
+
throw new Error(`sprite ${args.id} destroy failed (${res.status}): ${res.text}`);
|
|
524
|
+
}
|
|
525
|
+
console.log(`destroyed: sprite/${args.id} (${base})`);
|
|
526
|
+
return {};
|
|
527
|
+
}
|
package/src/plugin.ts
CHANGED
|
@@ -133,6 +133,19 @@ export const flyPlugin: LexiconPlugin = {
|
|
|
133
133
|
},
|
|
134
134
|
],
|
|
135
135
|
},
|
|
136
|
+
{
|
|
137
|
+
file: "chant-fly-sprites.md",
|
|
138
|
+
name: "chant-fly-sprites",
|
|
139
|
+
description: "Run an agent task in a Sprite as a chant Op — create, exec, checkpoint, restore, destroy, with checkpoint-as-compensation",
|
|
140
|
+
triggers: [
|
|
141
|
+
{ type: "context" as const, value: "sprite" },
|
|
142
|
+
{ type: "context" as const, value: "sprites.dev" },
|
|
143
|
+
{ type: "context" as const, value: "agent sandbox" },
|
|
144
|
+
{ type: "context" as const, value: "checkpoint compensation" },
|
|
145
|
+
],
|
|
146
|
+
parameters: [],
|
|
147
|
+
examples: [],
|
|
148
|
+
},
|
|
136
149
|
]),
|
|
137
150
|
|
|
138
151
|
mcpTools() {
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
---
|
|
2
|
+
skill: chant-temporal-sprites
|
|
3
|
+
description: Run an agent task in a Sprite as a chant Op — create, exec, checkpoint, restore, and destroy, with checkpoint-as-compensation
|
|
4
|
+
user-invocable: true
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Run an Agent Task in a Sprite
|
|
8
|
+
|
|
9
|
+
[Sprites](https://sprites.dev) are stateful, checkpointable sandboxes. Unlike a resource lexicon, a Sprite has no desired state to reconcile: it is a runtime-orchestration primitive, the same category as `k3dUp` or `httpCheck`. So the sprite lifecycle lives in chant's Op and activity layer, not in a declarative resource type.
|
|
10
|
+
|
|
11
|
+
This is the direct-API, Op-driven way to drive a Sprite: a structured, replayable activity sequence that a chant Op can checkpoint and roll back. It sits alongside the Sprites SDKs and CLI rather than replacing them, and it is not an MCP wrapper.
|
|
12
|
+
|
|
13
|
+
## The five activities
|
|
14
|
+
|
|
15
|
+
Each activity is a direct REST call over an injectable HTTP client, imported from `@intentius/chant-lexicon-fly` (Sprites are a Fly product, so they live in the fly lexicon alongside Machines):
|
|
16
|
+
|
|
17
|
+
| Activity | What it does |
|
|
18
|
+
|----------|--------------|
|
|
19
|
+
| `spriteCreate` | Create a sandbox. The caller-chosen `name` becomes the sprite `id` that every later activity keys on |
|
|
20
|
+
| `spriteExec` | Run a command inside the sprite. A non-zero exit throws, so the phase fails and any `onFailure` compensation runs |
|
|
21
|
+
| `spriteCheckpoint` | Snapshot the sprite under a caller-chosen `label` |
|
|
22
|
+
| `spriteRestore` | Rewind the sprite to a labeled checkpoint |
|
|
23
|
+
| `spriteDestroy` | Destroy the sprite (idempotent; an already-gone sprite is a no-op) |
|
|
24
|
+
|
|
25
|
+
The sprite `id` and the checkpoint `label` are static strings the Op author writes, so nothing has to be threaded from a prior phase's output.
|
|
26
|
+
|
|
27
|
+
## The happy path
|
|
28
|
+
|
|
29
|
+
Compose the activities into an Op as phases:
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import { Op, phase } from "@intentius/chant-lexicon-temporal";
|
|
33
|
+
import { spriteCreate, spriteCheckpoint, spriteExec, spriteDestroy }
|
|
34
|
+
from "@intentius/chant-lexicon-fly";
|
|
35
|
+
|
|
36
|
+
export default Op({
|
|
37
|
+
name: "agent-task",
|
|
38
|
+
overview: "Create a sprite, checkpoint, run the task, verify, destroy",
|
|
39
|
+
taskQueue: "sprites",
|
|
40
|
+
phases: [
|
|
41
|
+
phase("Create", [spriteCreate({ name: "task-1", image: "sprites/base:latest" })]),
|
|
42
|
+
phase("Checkpoint", [spriteCheckpoint({ id: "task-1", label: "pre-run" })]),
|
|
43
|
+
phase("Run", [spriteExec({ id: "task-1", cmd: "echo hello > /work/output" })]),
|
|
44
|
+
phase("Verify", [spriteExec({ id: "task-1", cmd: "cat /work/output" })]),
|
|
45
|
+
phase("Destroy", [spriteDestroy({ id: "task-1" })]),
|
|
46
|
+
],
|
|
47
|
+
});
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
There is no `build` phase and no serialized plan: the activities run in sequence. Run it with `chant run agent-task`.
|
|
51
|
+
|
|
52
|
+
## Checkpoint-as-compensation
|
|
53
|
+
|
|
54
|
+
The reason Sprites map onto chant Ops so well is rollback. A VM checkpoint is a fast transactional boundary. An Op checkpoints before a risky phase and, on failure, restores the labeled checkpoint instead of running an inverse action. The environment itself is the transaction, so there is nothing to unwind by hand.
|
|
55
|
+
|
|
56
|
+
Put the `spriteRestore` in the Op's `onFailure`, referencing the same label the `Checkpoint` phase wrote:
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
import { Op, phase } from "@intentius/chant-lexicon-temporal";
|
|
60
|
+
import { spriteCreate, spriteCheckpoint, spriteExec, spriteDestroy, spriteRestore }
|
|
61
|
+
from "@intentius/chant-lexicon-fly";
|
|
62
|
+
|
|
63
|
+
export default Op({
|
|
64
|
+
name: "guarded-task",
|
|
65
|
+
overview: "Checkpoint, run a risky step, restore on failure",
|
|
66
|
+
taskQueue: "sprites",
|
|
67
|
+
phases: [
|
|
68
|
+
phase("Create", [spriteCreate({ name: "task-1" })]),
|
|
69
|
+
phase("Checkpoint", [spriteCheckpoint({ id: "task-1", label: "pre-run" })]),
|
|
70
|
+
phase("Run", [spriteExec({ id: "task-1", cmd: "./risky.sh" })]),
|
|
71
|
+
phase("Destroy", [spriteDestroy({ id: "task-1" })]),
|
|
72
|
+
],
|
|
73
|
+
onFailure: [
|
|
74
|
+
phase("Restore", [spriteRestore({ id: "task-1", checkpoint: "pre-run" })]),
|
|
75
|
+
],
|
|
76
|
+
});
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
When the `Run` phase's command exits non-zero, `spriteExec` throws, the phase fails, and the Op-level `onFailure` `Restore` rewinds the sprite to its `pre-run` checkpoint.
|
|
80
|
+
|
|
81
|
+
## Targeting the emulator or real Sprites
|
|
82
|
+
|
|
83
|
+
The activities resolve their endpoint in this order: an explicit `endpoint` arg, then `SPRITES_BASE_URL`, then the real Sprites base. The same Op targets an emulator or real Sprites with no code change. The default `fetch` client adds `Authorization: Bearer ${SPRITES_API_TOKEN}` when a token is set; the emulator ignores it.
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
# Point at a self-hosted or in-process emulator.
|
|
87
|
+
export SPRITES_BASE_URL=http://127.0.0.1:9000
|
|
88
|
+
chant run agent-task
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
# Real Sprites: drop the override and set a token.
|
|
93
|
+
unset SPRITES_BASE_URL
|
|
94
|
+
export SPRITES_API_TOKEN=...
|
|
95
|
+
chant run agent-task
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The offline, Docker-free emulator that CI runs against is `createSpritesFake()` in this lexicon (`src/op/activities/sprites-fake.ts`); the activities and their tests live alongside it in `sprites.ts`. The local-emulator flow is the one to develop against.
|
|
99
|
+
|
|
100
|
+
The real Sprites REST surface is provisional (S6, tracked in #766): the endpoint constants may still move to match the official API. The activity input and output contracts (the `Args` and `Result` shapes shown above) are the stable interface the Ops and the emulator are written against, so build your Ops on those.
|
|
101
|
+
|
|
102
|
+
## Where it fits
|
|
103
|
+
|
|
104
|
+
The runnable starter is [`examples/sprites-agent-task`](../../examples/sprites-agent-task), which ships both Ops above. Run `chant run agent-task` for the happy path and `chant run guarded-task` to watch the checkpoint-as-compensation rollback. `guarded-task` exits non-zero on purpose: the `Run` phase fails, the `onFailure` `Restore` runs, and the sprite is back at `pre-run`.
|