@intentius/chant-lexicon-fly 0.16.0 → 0.18.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.
Files changed (65) hide show
  1. package/README.md +2 -2
  2. package/dist/composites/fly-deploy.d.ts +1 -1
  3. package/dist/composites/fly-deploy.d.ts.map +1 -1
  4. package/dist/coverage.d.ts +15 -0
  5. package/dist/coverage.d.ts.map +1 -0
  6. package/dist/emulator-freshness-cli.d.ts +11 -0
  7. package/dist/emulator-freshness-cli.d.ts.map +1 -0
  8. package/dist/emulator-freshness.d.ts +38 -0
  9. package/dist/emulator-freshness.d.ts.map +1 -0
  10. package/dist/index.d.ts +1 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/mcp/context-tools.d.ts +15 -0
  13. package/dist/mcp/context-tools.d.ts.map +1 -0
  14. package/dist/op/activities/emulator-images.d.ts +20 -0
  15. package/dist/op/activities/emulator-images.d.ts.map +1 -0
  16. package/dist/op/activities/flaps.d.ts +1 -1
  17. package/dist/op/activities/flaps.d.ts.map +1 -1
  18. package/dist/op/activities/index.d.ts +4 -0
  19. package/dist/op/activities/index.d.ts.map +1 -1
  20. package/dist/op/activities/machines-contract.d.ts +36 -0
  21. package/dist/op/activities/machines-contract.d.ts.map +1 -0
  22. package/dist/op/activities/sprites-contract.d.ts +45 -0
  23. package/dist/op/activities/sprites-contract.d.ts.map +1 -0
  24. package/dist/op/activities/sprites-emulator.d.ts +29 -0
  25. package/dist/op/activities/sprites-emulator.d.ts.map +1 -0
  26. package/dist/op/activities/sprites-fake.d.ts +62 -0
  27. package/dist/op/activities/sprites-fake.d.ts.map +1 -0
  28. package/dist/op/activities/sprites.d.ts +195 -0
  29. package/dist/op/activities/sprites.d.ts.map +1 -0
  30. package/dist/plugin.d.ts.map +1 -1
  31. package/dist/reference-catalog.d.ts +21 -0
  32. package/dist/reference-catalog.d.ts.map +1 -0
  33. package/package.json +6 -2
  34. package/src/composites/fly-deploy.ts +1 -1
  35. package/src/coverage.ts +49 -0
  36. package/src/emulator-freshness-cli.ts +49 -0
  37. package/src/emulator-freshness.test.ts +86 -0
  38. package/src/emulator-freshness.ts +87 -0
  39. package/src/index.ts +15 -0
  40. package/src/mcp/context-tools.test.ts +27 -0
  41. package/src/mcp/context-tools.ts +120 -0
  42. package/src/op/activities/emulator-images.ts +21 -0
  43. package/src/op/activities/flaps.test.ts +2 -1
  44. package/src/op/activities/flaps.ts +3 -2
  45. package/src/op/activities/index.ts +53 -0
  46. package/src/op/activities/machines-contract.docker.integration.test.ts +72 -0
  47. package/src/op/activities/machines-contract.test.ts +49 -0
  48. package/src/op/activities/machines-contract.ts +73 -0
  49. package/src/op/activities/sprites-contract.docker.integration.test.ts +74 -0
  50. package/src/op/activities/sprites-contract.test.ts +60 -0
  51. package/src/op/activities/sprites-contract.ts +61 -0
  52. package/src/op/activities/sprites-emulator.ts +46 -0
  53. package/src/op/activities/sprites-fake.ts +314 -0
  54. package/src/op/activities/sprites.docker.integration.test.ts +99 -0
  55. package/src/op/activities/sprites.integration.test.ts +158 -0
  56. package/src/op/activities/sprites.real.test.ts +56 -0
  57. package/src/op/activities/sprites.test.ts +296 -0
  58. package/src/op/activities/sprites.ts +527 -0
  59. package/src/plugin.ts +28 -5
  60. package/src/reference-catalog.test.ts +50 -0
  61. package/src/reference-catalog.ts +39 -0
  62. package/src/skills/chant-fly-patterns.md +2 -2
  63. package/src/skills/chant-fly-sprites.md +104 -0
  64. package/src/skills/chant-fly.md +2 -2
  65. package/src/generated/.gitkeep +0 -0
@@ -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
+ });
@@ -0,0 +1,158 @@
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 { createSpritesFake } from "./sprites-fake";
17
+
18
+ // End-to-end against the in-process fake (S7) — no Docker, runs in CI. The
19
+ // activities resolve by name through `loadActivities(["fly"])` and reach
20
+ // the fake via `SPRITES_BASE_URL`. Fast profiles so retry loops run in ms.
21
+
22
+ const PROFILES: Record<string, ActivityProfile> = {
23
+ longInfra: { startToCloseTimeout: "5m", retry: { maximumAttempts: 3, initialInterval: "1ms", backoffCoefficient: 1 } },
24
+ fastIdempotent: { startToCloseTimeout: "5m", retry: { maximumAttempts: 2, initialInterval: "1ms", backoffCoefficient: 1 } },
25
+ };
26
+
27
+ let fake: { url: string; close(): Promise<void> };
28
+ let activities: Map<string, ActivityFn>;
29
+ let prevBaseUrl: string | undefined;
30
+
31
+ beforeAll(async () => {
32
+ fake = await createSpritesFake();
33
+ // Prove endpoint override via the env (S3): the same Op targets the fake with
34
+ // no code change. Activities read SPRITES_BASE_URL when no `endpoint` arg is set.
35
+ prevBaseUrl = process.env.SPRITES_BASE_URL;
36
+ process.env.SPRITES_BASE_URL = fake.url;
37
+ activities = await loadActivities(["fly"]);
38
+ });
39
+
40
+ afterAll(async () => {
41
+ if (prevBaseUrl === undefined) delete process.env.SPRITES_BASE_URL;
42
+ else process.env.SPRITES_BASE_URL = prevBaseUrl;
43
+ await fake?.close();
44
+ });
45
+
46
+ /** Read a sprite's live state (fs + checkpoints) from the fake. */
47
+ async function inspect(id: string): Promise<{ status: string; fs: Record<string, string>; checkpoints: string[] }> {
48
+ const res = await fetch(`${fake.url}/v1/sprites/${id}`);
49
+ return (await res.json()) as { status: string; fs: Record<string, string>; checkpoints: string[] };
50
+ }
51
+
52
+ describe("sprite activities resolve by name (S2)", () => {
53
+ test("loadActivities([\"fly\"]) exposes the five sprite activities", () => {
54
+ for (const fn of ["spriteCreate", "spriteExec", "spriteCheckpoint", "spriteRestore", "spriteDestroy"]) {
55
+ expect(typeof activities.get(fn)).toBe("function");
56
+ }
57
+ });
58
+ });
59
+
60
+ describe("agent-task happy path", () => {
61
+ test("Create → Checkpoint → Run → Verify → Destroy runs green end to end", async () => {
62
+ const op: OpConfig = {
63
+ name: "agent-task",
64
+ overview: "happy path",
65
+ taskQueue: "sprites",
66
+ phases: [
67
+ phase("Create", [spriteCreate({ name: "task-1" })]),
68
+ phase("Checkpoint", [spriteCheckpoint({ id: "task-1", comment: "pre-run" })]),
69
+ phase("Run", [spriteExec({ id: "task-1", cmd: "echo hello > /work/output" })]),
70
+ phase("Verify", [spriteExec({ id: "task-1", cmd: "cat /work/output" })]),
71
+ phase("Destroy", [spriteDestroy({ id: "task-1" })]),
72
+ ],
73
+ };
74
+ const result = await runOpLocally(op, activities, PROFILES);
75
+ expect(result.ok).toBe(true);
76
+ expect(result.records.map((r) => r.fn)).toEqual([
77
+ "spriteCreate",
78
+ "spriteCheckpoint",
79
+ "spriteExec",
80
+ "spriteExec",
81
+ "spriteDestroy",
82
+ ]);
83
+ expect(result.records.every((r) => r.status === "ok")).toBe(true);
84
+ });
85
+
86
+ test("exec mutates the sprite fs (checkpoint/restore observability)", async () => {
87
+ const op: OpConfig = {
88
+ name: "agent-write",
89
+ overview: "state observability",
90
+ phases: [
91
+ phase("Create", [spriteCreate({ name: "obs-1" })]),
92
+ phase("Run", [spriteExec({ id: "obs-1", cmd: "echo hello > /work/output" })]),
93
+ ],
94
+ };
95
+ await runOpLocally(op, activities, PROFILES);
96
+ const state = await inspect("obs-1");
97
+ expect(state.fs).toEqual({ "/work/output": "hello" });
98
+ });
99
+ });
100
+
101
+ describe("guarded-task — checkpoint-as-compensation (S5)", () => {
102
+ test("Run fails → onFailure Restore runs → fs is rewound to the pre-run checkpoint", async () => {
103
+ const op: OpConfig = {
104
+ name: "guarded-task",
105
+ overview: "checkpoint, run a risky step, restore on failure",
106
+ taskQueue: "sprites",
107
+ phases: [
108
+ phase("Create", [spriteCreate({ name: "guard-1" })]),
109
+ phase("Seed", [spriteExec({ id: "guard-1", cmd: "echo good > /state" })]),
110
+ phase("Checkpoint", [spriteCheckpoint({ id: "guard-1", comment: "pre-run" })]),
111
+ // Overwrites the good state, then fails — the risky phase.
112
+ phase("Run", [spriteExec({ id: "guard-1", cmd: "echo bad > /state; false" })]),
113
+ phase("Destroy", [spriteDestroy({ id: "guard-1" })]),
114
+ ],
115
+ onFailure: [phase("Restore", [spriteRestore({ id: "guard-1", comment: "pre-run" })])],
116
+ };
117
+
118
+ let failure: OpRunFailure | undefined;
119
+ try {
120
+ await runOpLocally(op, activities, PROFILES);
121
+ } catch (err) {
122
+ failure = err as OpRunFailure;
123
+ }
124
+
125
+ // The Op failed at the risky Run and never reached Destroy.
126
+ expect(failure).toBeInstanceOf(OpRunFailure);
127
+ const records = failure!.result.records;
128
+ const run = records.find((r) => r.phase === "Run");
129
+ expect(run?.status).toBe("fail");
130
+ expect(records.some((r) => r.phase === "Destroy")).toBe(false);
131
+
132
+ // Compensation ran: onFailure Restore executed and succeeded.
133
+ const restore = records.find((r) => r.phase === "Restore");
134
+ expect(restore?.fn).toBe("spriteRestore");
135
+ expect(restore?.status).toBe("ok");
136
+
137
+ // The environment is the transaction: fs is back to the checkpoint state.
138
+ const state = await inspect("guard-1");
139
+ expect(state.fs).toEqual({ "/state": "good" });
140
+ expect(state.status).toBe("running");
141
+ });
142
+
143
+ test("control: without onFailure the risky write is NOT rewound (fs stays corrupt)", async () => {
144
+ const op: OpConfig = {
145
+ name: "unguarded-task",
146
+ overview: "no compensation",
147
+ phases: [
148
+ phase("Create", [spriteCreate({ name: "unguard-1" })]),
149
+ phase("Seed", [spriteExec({ id: "unguard-1", cmd: "echo good > /state" })]),
150
+ phase("Checkpoint", [spriteCheckpoint({ id: "unguard-1", comment: "pre-run" })]),
151
+ phase("Run", [spriteExec({ id: "unguard-1", cmd: "echo bad > /state; false" })]),
152
+ ],
153
+ };
154
+ await expect(runOpLocally(op, activities, PROFILES)).rejects.toBeInstanceOf(OpRunFailure);
155
+ const state = await inspect("unguard-1");
156
+ expect(state.fs).toEqual({ "/state": "bad" });
157
+ });
158
+ });
@@ -0,0 +1,56 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { spriteCreate, spriteExec, spriteDestroy, resolveSpritesEndpoint } from "./sprites";
3
+
4
+ // Real Sprites smoke test (#766), gated on `SPRITES_API_TOKEN`. Runs the WS exec
5
+ // client against the live control WebSocket at api.sprites.dev: create a
6
+ // uniquely-named sprite, exec `echo hi` (assert stdout + exit 0), exec
7
+ // `sh -c "exit 7"` (assert the exit code surfaces as 7), then destroy in a
8
+ // finally. Skips cleanly when the token is absent. The token is read from the
9
+ // environment only — never hardcode it.
10
+
11
+ const TOKEN = process.env.SPRITES_API_TOKEN;
12
+ const BASE = resolveSpritesEndpoint();
13
+
14
+ /** Poll the sprite until it reports `running` (or give up after `timeoutMs`). */
15
+ async function waitRunning(name: string, timeoutMs = 90_000): Promise<void> {
16
+ const deadline = Date.now() + timeoutMs;
17
+ while (Date.now() < deadline) {
18
+ const res = await fetch(`${BASE}/v1/sprites/${encodeURIComponent(name)}`, {
19
+ headers: { authorization: `Bearer ${TOKEN}` },
20
+ });
21
+ if (res.ok) {
22
+ const b = (await res.json().catch(() => ({}))) as { status?: string };
23
+ if (b.status === "running") return;
24
+ }
25
+ await new Promise((r) => setTimeout(r, 2000));
26
+ }
27
+ }
28
+
29
+ describe("real Sprites exec over the control WebSocket (gated on SPRITES_API_TOKEN)", () => {
30
+ test.skipIf(!TOKEN)(
31
+ "echo hi → exit 0; sh -c \"exit 7\" → exit 7",
32
+ async () => {
33
+ const name = `chant-it-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
34
+ try {
35
+ await spriteCreate({ name });
36
+ await waitRunning(name);
37
+
38
+ const ok = await spriteExec({ id: name, cmd: "echo hi" });
39
+ expect(ok.exitCode).toBe(0);
40
+ expect(ok.stdout).toContain("hi");
41
+
42
+ // A non-zero exit throws; capture the surfaced exit code.
43
+ let code: number | undefined;
44
+ try {
45
+ await spriteExec({ id: name, cmd: 'sh -c "exit 7"' });
46
+ } catch (err) {
47
+ code = Number((err as Error).message.match(/exited (\d+)/)?.[1]);
48
+ }
49
+ expect(code).toBe(7);
50
+ } finally {
51
+ await spriteDestroy({ id: name }).catch(() => {});
52
+ }
53
+ },
54
+ 120_000,
55
+ );
56
+ });