@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.
Files changed (50) hide show
  1. package/dist/composites/fly-deploy.d.ts +1 -1
  2. package/dist/composites/fly-deploy.d.ts.map +1 -1
  3. package/dist/emulator-freshness-cli.d.ts +11 -0
  4. package/dist/emulator-freshness-cli.d.ts.map +1 -0
  5. package/dist/emulator-freshness.d.ts +38 -0
  6. package/dist/emulator-freshness.d.ts.map +1 -0
  7. package/dist/index.d.ts +1 -0
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/op/activities/emulator-images.d.ts +20 -0
  10. package/dist/op/activities/emulator-images.d.ts.map +1 -0
  11. package/dist/op/activities/flaps.d.ts +1 -1
  12. package/dist/op/activities/flaps.d.ts.map +1 -1
  13. package/dist/op/activities/index.d.ts +4 -0
  14. package/dist/op/activities/index.d.ts.map +1 -1
  15. package/dist/op/activities/machines-contract.d.ts +36 -0
  16. package/dist/op/activities/machines-contract.d.ts.map +1 -0
  17. package/dist/op/activities/sprites-contract.d.ts +45 -0
  18. package/dist/op/activities/sprites-contract.d.ts.map +1 -0
  19. package/dist/op/activities/sprites-emulator.d.ts +29 -0
  20. package/dist/op/activities/sprites-emulator.d.ts.map +1 -0
  21. package/dist/op/activities/sprites-fake.d.ts +62 -0
  22. package/dist/op/activities/sprites-fake.d.ts.map +1 -0
  23. package/dist/op/activities/sprites.d.ts +195 -0
  24. package/dist/op/activities/sprites.d.ts.map +1 -0
  25. package/dist/plugin.d.ts.map +1 -1
  26. package/package.json +6 -2
  27. package/src/composites/fly-deploy.ts +1 -1
  28. package/src/emulator-freshness-cli.ts +49 -0
  29. package/src/emulator-freshness.test.ts +86 -0
  30. package/src/emulator-freshness.ts +87 -0
  31. package/src/index.ts +15 -0
  32. package/src/op/activities/emulator-images.ts +21 -0
  33. package/src/op/activities/flaps.test.ts +2 -1
  34. package/src/op/activities/flaps.ts +3 -2
  35. package/src/op/activities/index.ts +53 -0
  36. package/src/op/activities/machines-contract.docker.integration.test.ts +72 -0
  37. package/src/op/activities/machines-contract.test.ts +49 -0
  38. package/src/op/activities/machines-contract.ts +73 -0
  39. package/src/op/activities/sprites-contract.docker.integration.test.ts +74 -0
  40. package/src/op/activities/sprites-contract.test.ts +60 -0
  41. package/src/op/activities/sprites-contract.ts +61 -0
  42. package/src/op/activities/sprites-emulator.ts +46 -0
  43. package/src/op/activities/sprites-fake.ts +314 -0
  44. package/src/op/activities/sprites.docker.integration.test.ts +99 -0
  45. package/src/op/activities/sprites.integration.test.ts +158 -0
  46. package/src/op/activities/sprites.real.test.ts +56 -0
  47. package/src/op/activities/sprites.test.ts +296 -0
  48. package/src/op/activities/sprites.ts +527 -0
  49. package/src/plugin.ts +13 -0
  50. package/src/skills/chant-fly-sprites.md +104 -0
@@ -0,0 +1,60 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { readFileSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { dirname, join } from "node:path";
5
+ import { SPRITES_CONTRACT, normalizeEndpoint, contractKeys } from "./sprites-contract";
6
+
7
+ describe("SPRITES_CONTRACT", () => {
8
+ test("covers every sprite activity that calls the Sprites API", () => {
9
+ const activities = new Set(SPRITES_CONTRACT.map((e) => e.activity));
10
+ // The six ./sprites.ts activities that make an HTTP/WS call.
11
+ expect(activities).toEqual(
12
+ new Set([
13
+ "spriteCreate",
14
+ "spriteExec",
15
+ "spriteCheckpoint",
16
+ "listCheckpoints",
17
+ "spriteRestore",
18
+ "spriteDestroy",
19
+ ]),
20
+ );
21
+ });
22
+
23
+ test("every entry has a v1 path and a known method", () => {
24
+ for (const e of SPRITES_CONTRACT) {
25
+ expect(e.path.startsWith("/v1/sprites")).toBe(true);
26
+ expect(["GET", "POST", "DELETE", "WS"]).toContain(e.method);
27
+ }
28
+ });
29
+
30
+ test("normalizeEndpoint collapses param names and maps WS→GET", () => {
31
+ // {cp} and {cid} must compare equal (spritzer spells it {cid}).
32
+ expect(normalizeEndpoint("POST", "/v1/sprites/{id}/checkpoints/{cp}/restore")).toBe(
33
+ normalizeEndpoint("POST", "/v1/sprites/{id}/checkpoints/{cid}/restore"),
34
+ );
35
+ // The exec WebSocket is registered as a GET on the emulator.
36
+ expect(normalizeEndpoint("WS", "/v1/sprites/{id}/exec")).toBe("GET /v1/sprites/{}/exec");
37
+ });
38
+
39
+ test("contractKeys is the deduped normalized set", () => {
40
+ const keys = contractKeys();
41
+ expect(keys.has("POST /v1/sprites")).toBe(true);
42
+ expect(keys.has("DELETE /v1/sprites/{}")).toBe(true);
43
+ expect(keys.size).toBe(SPRITES_CONTRACT.length);
44
+ });
45
+
46
+ test("every contract path segment appears in the sprites.ts source (drift anchor)", () => {
47
+ // Anchors the hand-authored contract to the activity implementations: if an
48
+ // activity's endpoint path changes, a segment goes missing here and the
49
+ // contract must be updated in the same change.
50
+ const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "sprites.ts"), "utf-8");
51
+ const segments = new Set(
52
+ SPRITES_CONTRACT.flatMap((e) =>
53
+ e.path.split("/").filter((s) => s.length > 0 && !s.startsWith("{")),
54
+ ),
55
+ );
56
+ for (const seg of segments) {
57
+ expect(src, `path segment "${seg}" from the contract is absent from sprites.ts`).toContain(seg);
58
+ }
59
+ });
60
+ });
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Hand-authored Fly Sprites API contract (#808 T3).
3
+ *
4
+ * The Fly Machines API ships a machine-readable OpenAPI (docs.machines.dev) that
5
+ * the fly resource surface generates + drift-checks against. The Sprites API
6
+ * (api.sprites.dev) ships no such spec at any conventional path, so there is
7
+ * nothing to diff automatically. This module is the manual stand-in: the exact
8
+ * endpoint set the fly sprite activities (./sprites.ts) depend on, maintained by
9
+ * hand from https://docs.sprites.dev.
10
+ *
11
+ * It anchors two fidelity checks:
12
+ * 1. Coverage — every endpoint here must be served by the pinned spritzer
13
+ * emulator (its `/_spritzer/health` enumerates implemented paths). If an
14
+ * activity ever calls something spritzer doesn't model, the docker-gated
15
+ * contract test fails instead of silently passing against a partial fake.
16
+ * 2. Drift anchor — when the Sprites API changes, a human updates this file;
17
+ * reviewers see exactly which activity/endpoint moved.
18
+ *
19
+ * Path params are written with names matching ./sprites.ts (`{id}`, `{cp}`);
20
+ * the coverage check normalizes param names before comparing, since spritzer
21
+ * spells the checkpoint id `{cid}`.
22
+ */
23
+
24
+ /** One endpoint the fly sprite activities call. */
25
+ export interface SpritesEndpoint {
26
+ /** HTTP method, or "WS" for the control-WebSocket exec channel. */
27
+ method: "GET" | "POST" | "DELETE" | "WS";
28
+ /** Path template under the Sprites base, e.g. `/v1/sprites/{id}/checkpoint`. */
29
+ path: string;
30
+ /** The fly activity that calls it (./sprites.ts export). */
31
+ activity: string;
32
+ }
33
+
34
+ /**
35
+ * The Sprites endpoints ./sprites.ts depends on. Keep in sync with the activity
36
+ * implementations — the unit test asserts every sprite activity is represented.
37
+ */
38
+ export const SPRITES_CONTRACT: readonly SpritesEndpoint[] = [
39
+ { method: "POST", path: "/v1/sprites", activity: "spriteCreate" },
40
+ { method: "WS", path: "/v1/sprites/{id}/exec", activity: "spriteExec" },
41
+ { method: "POST", path: "/v1/sprites/{id}/checkpoint", activity: "spriteCheckpoint" },
42
+ { method: "GET", path: "/v1/sprites/{id}/checkpoints", activity: "listCheckpoints" },
43
+ { method: "POST", path: "/v1/sprites/{id}/checkpoints/{cp}/restore", activity: "spriteRestore" },
44
+ { method: "DELETE", path: "/v1/sprites/{id}", activity: "spriteDestroy" },
45
+ ] as const;
46
+
47
+ /**
48
+ * Normalize a `METHOD path` key for comparison across sources: collapse every
49
+ * `{param}` to `{}` (so `{cp}` and `{cid}` match) and treat the WebSocket exec
50
+ * channel as a `GET` (spritzer registers it as `GET .../exec`).
51
+ */
52
+ export function normalizeEndpoint(method: string, path: string): string {
53
+ const m = method === "WS" ? "GET" : method.toUpperCase();
54
+ const p = path.replace(/\{[^}]+\}/g, "{}");
55
+ return `${m} ${p}`;
56
+ }
57
+
58
+ /** The contract as a set of normalized `METHOD path` keys. */
59
+ export function contractKeys(): Set<string> {
60
+ return new Set(SPRITES_CONTRACT.map((e) => normalizeEndpoint(e.method, e.path)));
61
+ }
@@ -0,0 +1,46 @@
1
+ import { emulatorLifecycle } from "@intentius/chant/op";
2
+ import { SPRITZER_IMAGE } from "./emulator-images";
3
+
4
+ export interface SpritesUpArgs {
5
+ /** Container name. Default: `chant-spritzer`. */
6
+ name?: string;
7
+ /** Host port mapped to the emulator's `:4290`. Default: `4290`. */
8
+ port?: number;
9
+ /** Image. Default: the pinned spritzer image ({@link SPRITZER_IMAGE}). */
10
+ image?: string;
11
+ /** Readiness timeout in ms. Default: `60000`. */
12
+ timeoutMs?: number;
13
+ /** Health poll interval in ms. Default: `2000`. */
14
+ intervalMs?: number;
15
+ }
16
+
17
+ export interface SpritesDownArgs {
18
+ /** Container name to remove. Default: `chant-spritzer`. */
19
+ name?: string;
20
+ }
21
+
22
+ // spritzer is a stateful fake of the Fly Sprites API — a plain 200 on its health
23
+ // endpoint means ready. The local target for the sprite activities; point them
24
+ // there with SPRITES_BASE_URL. Shared lifecycle: emulatorLifecycle (the same
25
+ // helper that boots mudflaps for fly).
26
+ const spritzer = emulatorLifecycle({
27
+ name: "chant-spritzer",
28
+ image: SPRITZER_IMAGE,
29
+ containerPort: 4290,
30
+ healthPath: "/_spritzer/health",
31
+ });
32
+
33
+ export const spritesExistsCommand = spritzer.existsCommand;
34
+ export const spritesRmCommand = spritzer.rmCommand;
35
+ export const spritesHealthUrl = spritzer.healthUrl;
36
+ /** The Sprites endpoint URL (what the sprite activities' `SPRITES_BASE_URL`/`endpoint` points at). */
37
+ export const spritesEndpoint = spritzer.endpoint;
38
+ export const spritesRunCommand = (args: SpritesUpArgs = {}): string => spritzer.runCommand(args);
39
+
40
+ /** Boot a local spritzer (Fly Sprites API emulator) in Docker and return its endpoint. */
41
+ export const spritesUp = (args: SpritesUpArgs = {}, signal?: AbortSignal): Promise<{ endpoint: string }> =>
42
+ spritzer.up(args, signal);
43
+
44
+ /** Stop and remove the local spritzer container (no-op if already gone). */
45
+ export const spritesDown = (args: SpritesDownArgs = {}, signal?: AbortSignal): Promise<void> =>
46
+ spritzer.down(args, signal);
@@ -0,0 +1,314 @@
1
+ /**
2
+ * In-process Sprites fake (#762, #766, S7) — a fake of the faithful Sprites API
3
+ * surface for offline, Docker-free integration tests.
4
+ *
5
+ * It models the lifecycle state machine and checkpoint/restore semantics, not
6
+ * real Firecracker VMs or real code execution (that fidelity is out of scope).
7
+ * A sprite's filesystem is a `Record<string, string>`; `exec` runs a small
8
+ * scripted interpreter that can write/modify an `fs` key so checkpoint/restore
9
+ * is observable; `checkpoint` deep-copies `fs` under a version id; `restore`
10
+ * replaces `fs` with that copy. Started in-process by a test and reached via
11
+ * `SPRITES_BASE_URL`, so the same activities that hit real Sprites hit the fake.
12
+ *
13
+ * The wire surface matches the released `spritzer:0.3.1` image so the CI
14
+ * fake-based test and the docker test exercise the same protocol:
15
+ * - `exec` over the control WebSocket with `[StreamID][payload]` binary framing;
16
+ * - `POST /checkpoint` (singular) returning NDJSON progress + a `complete` event;
17
+ * - `GET /checkpoints` returning a bare array of `{ id, comment, create_time, is_auto }`;
18
+ * - `POST /checkpoints/{id}/restore` returning NDJSON.
19
+ * Checkpoint ids are server versions (`v1`, `v2`, ...).
20
+ */
21
+
22
+ import { createServer, type Server, type IncomingMessage, type ServerResponse } from "node:http";
23
+ import type { Duplex } from "node:stream";
24
+ import { WebSocketServer, type WebSocket } from "ws";
25
+
26
+ type SpriteStatus = "starting" | "running" | "paused" | "destroyed";
27
+
28
+ interface StoredCheckpoint {
29
+ id: string;
30
+ comment: string;
31
+ create_time: string;
32
+ is_auto: boolean;
33
+ fs: Record<string, string>;
34
+ }
35
+
36
+ interface SpriteState {
37
+ id: string;
38
+ status: SpriteStatus;
39
+ url: string;
40
+ /** Filesystem model: path → contents. */
41
+ fs: Record<string, string>;
42
+ /** Checkpoints in creation order; each holds a full copy of `fs` at that time. */
43
+ checkpoints: StoredCheckpoint[];
44
+ /** Monotonic version counter for `v<N>` ids. */
45
+ version: number;
46
+ policy?: unknown;
47
+ }
48
+
49
+ interface ExecResult {
50
+ stdout: string;
51
+ stderr: string;
52
+ exitCode: number;
53
+ }
54
+
55
+ /**
56
+ * Run one command against a sprite's `fs`. Not a real shell: it recognizes a
57
+ * small set of forms so a test (and the example `guarded-task` Op) can write a
58
+ * key, then overwrite/fail it, and prove restore rewinds. Segments split on
59
+ * `;` run in order; the exit code is the last segment's (shell `;` semantics).
60
+ */
61
+ export function fakeExec(sprite: SpriteState, cmd: string): ExecResult {
62
+ let stdout = "";
63
+ let stderr = "";
64
+ let exitCode = 0;
65
+
66
+ for (const raw of cmd.split(";")) {
67
+ const seg = raw.trim();
68
+ if (!seg) continue;
69
+
70
+ let m: RegExpMatchArray | null;
71
+ if ((m = seg.match(/^echo\s+(.+?)\s*>\s*(\S+)$/))) {
72
+ sprite.fs[m[2]] = unquote(m[1]);
73
+ exitCode = 0;
74
+ } else if ((m = seg.match(/^echo\s+(.+)$/))) {
75
+ stdout += `${unquote(m[1])}\n`;
76
+ exitCode = 0;
77
+ } else if ((m = seg.match(/^cat\s+(\S+)$/))) {
78
+ stdout += sprite.fs[m[1]] ?? "";
79
+ exitCode = 0;
80
+ } else if ((m = seg.match(/^rm\s+(?:-f\s+)?(\S+)$/))) {
81
+ delete sprite.fs[m[1]];
82
+ exitCode = 0;
83
+ } else if (seg === "false") {
84
+ exitCode = 1;
85
+ } else if (seg === "true") {
86
+ exitCode = 0;
87
+ } else if (seg === "./risky.sh") {
88
+ // A scripted failing job: mutates the workspace, then exits non-zero, so
89
+ // the example guarded-task Op demonstrates checkpoint-as-compensation.
90
+ sprite.fs["/work/output"] = "partial-corrupt";
91
+ stderr += "risky.sh: failed\n";
92
+ exitCode = 1;
93
+ } else {
94
+ // Unknown command → echo it back (a no-op success), never real execution.
95
+ stdout += `${seg}\n`;
96
+ exitCode = 0;
97
+ }
98
+ }
99
+
100
+ return { stdout, stderr, exitCode };
101
+ }
102
+
103
+ function unquote(s: string): string {
104
+ const t = s.trim();
105
+ if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
106
+ return t.slice(1, -1);
107
+ }
108
+ return t;
109
+ }
110
+
111
+ /** A full copy of an `fs` map. Values are strings, so a shallow spread copies them. */
112
+ function copyFs(fs: Record<string, string>): Record<string, string> {
113
+ return { ...fs };
114
+ }
115
+
116
+ // Stream ids for the non-PTY exec framing (mirror sprites.ts).
117
+ const STREAM_STDOUT = 1;
118
+ const STREAM_STDERR = 2;
119
+ const STREAM_EXIT = 3;
120
+
121
+ function frame(stream: number, payload: Buffer): Buffer {
122
+ return Buffer.concat([Buffer.of(stream), payload]);
123
+ }
124
+
125
+ async function readBody(req: IncomingMessage): Promise<unknown> {
126
+ const chunks: Buffer[] = [];
127
+ for await (const c of req) chunks.push(c as Buffer);
128
+ if (chunks.length === 0) return undefined;
129
+ const text = Buffer.concat(chunks).toString("utf8");
130
+ if (!text) return undefined;
131
+ try {
132
+ return JSON.parse(text);
133
+ } catch {
134
+ return undefined;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Start the in-process Sprites fake on an ephemeral port. Returns its base
140
+ * `url` (feed it to `SPRITES_BASE_URL`) and a `close()`.
141
+ */
142
+ export function createSpritesFake(): Promise<{ url: string; close(): Promise<void> }> {
143
+ const sprites = new Map<string, SpriteState>();
144
+
145
+ const server: Server = createServer((req, res) => {
146
+ void handle(req, res).catch((err) => {
147
+ res.writeHead(500, { "content-type": "application/json" });
148
+ res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
149
+ });
150
+ });
151
+
152
+ // The control WebSocket exec endpoint. noServer + a manual `upgrade` handler so
153
+ // the same http server serves both the REST/NDJSON routes and the exec socket.
154
+ const wss = new WebSocketServer({ noServer: true });
155
+
156
+ server.on("upgrade", (req: IncomingMessage, socket: Duplex, head: Buffer) => {
157
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
158
+ const m = url.pathname.match(/^\/v1\/sprites\/([^/]+)\/exec\/?$/);
159
+ if (!m) {
160
+ socket.destroy();
161
+ return;
162
+ }
163
+ const id = decodeURIComponent(m[1]);
164
+ wss.handleUpgrade(req, socket, head, (ws) => runExec(ws, sprites.get(id), url));
165
+ });
166
+
167
+ function runExec(ws: WebSocket, sprite: SpriteState | undefined, url: URL): void {
168
+ if (!sprite || sprite.status === "destroyed") {
169
+ ws.send(frame(STREAM_STDERR, Buffer.from(`no sprite\n`)));
170
+ ws.send(frame(STREAM_EXIT, Buffer.of(127)));
171
+ ws.close();
172
+ return;
173
+ }
174
+ // argv arrives as repeated `cmd` params; reconstruct the script the small
175
+ // interpreter understands (the tokens round-trip for the space-separated
176
+ // command forms the example Ops use).
177
+ const argv = url.searchParams.getAll("cmd");
178
+ const script = argv.join(" ");
179
+ const result = fakeExec(sprite, script);
180
+ if (result.stdout) ws.send(frame(STREAM_STDOUT, Buffer.from(result.stdout)));
181
+ if (result.stderr) ws.send(frame(STREAM_STDERR, Buffer.from(result.stderr)));
182
+ ws.send(frame(STREAM_EXIT, Buffer.of(result.exitCode & 0xff)));
183
+ ws.close();
184
+ }
185
+
186
+ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
187
+ const method = req.method ?? "GET";
188
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
189
+ const path = url.pathname;
190
+ const host = req.headers.host ?? "localhost";
191
+ const send = (status: number, body: unknown): void => {
192
+ res.writeHead(status, { "content-type": "application/json" });
193
+ res.end(JSON.stringify(body ?? {}));
194
+ };
195
+ // NDJSON progress stream: an `info` line then a terminal `complete` line.
196
+ const sendNdjson = (status: number, events: Array<Record<string, unknown>>): void => {
197
+ res.writeHead(status, { "content-type": "application/x-ndjson" });
198
+ res.end(events.map((e) => JSON.stringify(e)).join("\n") + "\n");
199
+ };
200
+
201
+ // POST /v1/sprites — create.
202
+ if (method === "POST" && /^\/v1\/sprites\/?$/.test(path)) {
203
+ const body = ((await readBody(req)) ?? {}) as { name?: string; policy?: unknown };
204
+ const id = body.name;
205
+ if (!id) return send(400, { error: "name is required" });
206
+ const sprite: SpriteState = {
207
+ id,
208
+ status: "running",
209
+ url: `http://${host}/s/${encodeURIComponent(id)}`,
210
+ fs: {},
211
+ checkpoints: [],
212
+ version: 0,
213
+ policy: body.policy,
214
+ };
215
+ sprites.set(id, sprite);
216
+ return send(201, { id: sprite.id, url: sprite.url });
217
+ }
218
+
219
+ const m = path.match(/^\/v1\/sprites\/([^/]+)(\/checkpoint|\/checkpoints(?:\/([^/]+)(\/restore)?)?)?\/?$/);
220
+ if (m) {
221
+ const id = decodeURIComponent(m[1]);
222
+ const sub = m[2];
223
+ const cpId = m[3] ? decodeURIComponent(m[3]) : undefined;
224
+ const isRestore = Boolean(m[4]);
225
+ const sprite = sprites.get(id);
226
+ if (!sprite || sprite.status === "destroyed") return send(404, { error: `no sprite ${id}` });
227
+
228
+ // POST /v1/sprites/{id}/checkpoint — snapshot fs under a new version id.
229
+ if (method === "POST" && sub === "/checkpoint") {
230
+ const body = ((await readBody(req)) ?? {}) as { comment?: string };
231
+ sprite.version += 1;
232
+ const cp: StoredCheckpoint = {
233
+ id: `v${sprite.version}`,
234
+ comment: body.comment ?? "",
235
+ create_time: new Date().toISOString(),
236
+ is_auto: false,
237
+ fs: copyFs(sprite.fs),
238
+ };
239
+ sprite.checkpoints.push(cp);
240
+ // Mirror real Sprites: `{type, data}` progress lines carrying the
241
+ // version id in the message text, not a structured field.
242
+ return sendNdjson(200, [
243
+ { type: "info", data: "Creating checkpoint..." },
244
+ { type: "info", data: "Checkpoint created successfully" },
245
+ { type: "info", data: ` ID: ${cp.id}` },
246
+ { type: "complete", data: `Checkpoint ${cp.id} created successfully` },
247
+ ]);
248
+ }
249
+
250
+ // GET /v1/sprites/{id}/checkpoints — bare array (auto excluded).
251
+ if (method === "GET" && sub === "/checkpoints" && !cpId) {
252
+ return send(
253
+ 200,
254
+ sprite.checkpoints
255
+ .filter((c) => !c.is_auto)
256
+ .map((c) => ({ id: c.id, comment: c.comment, create_time: c.create_time, is_auto: c.is_auto })),
257
+ );
258
+ }
259
+
260
+ // GET /v1/sprites/{id}/checkpoints/{cp} — a single checkpoint.
261
+ if (method === "GET" && cpId && !isRestore) {
262
+ const cp = sprite.checkpoints.find((c) => c.id === cpId);
263
+ if (!cp) return send(404, { error: `no checkpoint ${cpId} for sprite ${id}` });
264
+ return send(200, { id: cp.id, comment: cp.comment, create_time: cp.create_time });
265
+ }
266
+
267
+ // POST /v1/sprites/{id}/checkpoints/{cp}/restore — replace fs with the snapshot.
268
+ if (method === "POST" && cpId && isRestore) {
269
+ const cp = sprite.checkpoints.find((c) => c.id === cpId);
270
+ if (!cp) return send(404, { error: `no checkpoint ${cpId} for sprite ${id}` });
271
+ sprite.fs = copyFs(cp.fs);
272
+ sprite.status = "running";
273
+ return sendNdjson(200, [
274
+ { type: "info", data: `Restoring checkpoint ${cpId}...` },
275
+ { type: "complete", data: `Checkpoint ${cpId} restored successfully` },
276
+ ]);
277
+ }
278
+
279
+ // DELETE /v1/sprites/{id}
280
+ if (method === "DELETE" && !sub) {
281
+ sprite.status = "destroyed";
282
+ return send(200, {});
283
+ }
284
+
285
+ // GET /v1/sprites/{id} — inspection (fs + checkpoint ids), used by tests/verify.
286
+ if (method === "GET" && !sub) {
287
+ return send(200, {
288
+ id: sprite.id,
289
+ status: sprite.status,
290
+ url: sprite.url,
291
+ fs: sprite.fs,
292
+ checkpoints: sprite.checkpoints.map((c) => c.id),
293
+ });
294
+ }
295
+ }
296
+
297
+ return send(404, { error: `not found: ${method} ${path}` });
298
+ }
299
+
300
+ return new Promise((resolve) => {
301
+ server.listen(0, "127.0.0.1", () => {
302
+ const addr = server.address();
303
+ const port = typeof addr === "object" && addr ? addr.port : 0;
304
+ resolve({
305
+ url: `http://127.0.0.1:${port}`,
306
+ close: () =>
307
+ new Promise<void>((res, rej) => {
308
+ wss.close();
309
+ server.close((err) => (err ? rej(err) : res()));
310
+ }),
311
+ });
312
+ });
313
+ });
314
+ }
@@ -0,0 +1,99 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from "vitest";
2
+ import {
3
+ loadActivities,
4
+ runOpLocally,
5
+ OpRunFailure,
6
+ phase,
7
+ spriteCreate,
8
+ spriteExec,
9
+ spriteCheckpoint,
10
+ spriteRestore,
11
+ spriteDestroy,
12
+ type ActivityFn,
13
+ type ActivityProfile,
14
+ type OpConfig,
15
+ } from "@intentius/chant/op";
16
+ import { spritesUp, spritesDown } from "./sprites-emulator";
17
+
18
+ // The checkpoint-as-compensation loop against the REAL spritzer image
19
+ // (ghcr.io/intentius/spritzer), booted via `spritesUp` — the twin of fly's
20
+ // mudflaps integration test. Proves the image is wire-compatible with the
21
+ // in-process fake the CI test uses. Docker required; deterministically skipped
22
+ // in CI (GitHub runners have Docker, so relying on absence would pull the image
23
+ // on every run). Run locally, or opt in with SPRITES_DOCKER=1.
24
+
25
+ const CONTAINER = "chant-spritzer-it";
26
+ const PORT = 4292;
27
+ const PROFILES: Record<string, ActivityProfile> = {
28
+ longInfra: { startToCloseTimeout: "5m", retry: { maximumAttempts: 3, initialInterval: "50ms", backoffCoefficient: 1 } },
29
+ fastIdempotent: { startToCloseTimeout: "5m", retry: { maximumAttempts: 2, initialInterval: "50ms", backoffCoefficient: 1 } },
30
+ };
31
+
32
+ let available = false;
33
+ let endpoint = "";
34
+ let activities: Map<string, ActivityFn>;
35
+ let prevBaseUrl: string | undefined;
36
+
37
+ async function inspect(id: string): Promise<{ status: string; fs: Record<string, string>; checkpoints: string[] }> {
38
+ const res = await fetch(`${endpoint}/v1/sprites/${id}`);
39
+ return (await res.json()) as { status: string; fs: Record<string, string>; checkpoints: string[] };
40
+ }
41
+
42
+ beforeAll(async () => {
43
+ if (process.env.CI && !process.env.SPRITES_DOCKER) {
44
+ available = false;
45
+ return;
46
+ }
47
+ try {
48
+ const up = await spritesUp({ name: CONTAINER, port: PORT, timeoutMs: 30_000 });
49
+ endpoint = up.endpoint;
50
+ prevBaseUrl = process.env.SPRITES_BASE_URL;
51
+ process.env.SPRITES_BASE_URL = endpoint;
52
+ activities = await loadActivities(["fly"]);
53
+ available = true;
54
+ } catch {
55
+ available = false;
56
+ }
57
+ }, 60_000);
58
+
59
+ afterAll(async () => {
60
+ if (prevBaseUrl === undefined) delete process.env.SPRITES_BASE_URL;
61
+ else process.env.SPRITES_BASE_URL = prevBaseUrl;
62
+ if (available) await spritesDown({ name: CONTAINER });
63
+ });
64
+
65
+ describe("sprites against the live spritzer image (#786)", () => {
66
+ test("guarded-task: Run fails, onFailure Restore rewinds the sprite to the checkpoint", async (ctx) => {
67
+ if (!available) ctx.skip();
68
+ const op: OpConfig = {
69
+ name: "guarded-task",
70
+ overview: "checkpoint, run a risky step, restore on failure",
71
+ taskQueue: "sprites",
72
+ phases: [
73
+ phase("Create", [spriteCreate({ name: "guard-1" })]),
74
+ phase("Seed", [spriteExec({ id: "guard-1", cmd: "echo good > /state" })]),
75
+ phase("Checkpoint", [spriteCheckpoint({ id: "guard-1", comment: "pre-run" })]),
76
+ phase("Run", [spriteExec({ id: "guard-1", cmd: "echo bad > /state; false" })]),
77
+ phase("Destroy", [spriteDestroy({ id: "guard-1" })]),
78
+ ],
79
+ onFailure: [phase("Restore", [spriteRestore({ id: "guard-1", comment: "pre-run" })])],
80
+ };
81
+
82
+ let failure: OpRunFailure | undefined;
83
+ try {
84
+ await runOpLocally(op, activities, PROFILES);
85
+ } catch (err) {
86
+ failure = err as OpRunFailure;
87
+ }
88
+ expect(failure).toBeInstanceOf(OpRunFailure); // the risky Run failed
89
+ const records = failure!.result.records;
90
+ expect(records.find((r) => r.phase === "Run")?.status).toBe("fail");
91
+ // Compensation ran against the live image.
92
+ const restore = records.find((r) => r.phase === "Restore");
93
+ expect(restore?.fn).toBe("spriteRestore");
94
+ expect(restore?.status).toBe("ok");
95
+
96
+ const state = await inspect("guard-1");
97
+ expect(state.fs["/state"]).toBe("good"); // rewound past the "bad" write
98
+ });
99
+ });