@intentius/chant-lexicon-fly 0.124.31 → 0.124.32

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 (83) hide show
  1. package/dist/codegen/docs-cli.js +6 -0
  2. package/dist/codegen/docs.js +143 -0
  3. package/dist/codegen/generate-cli.js +26 -0
  4. package/dist/codegen/generate-lexicon.js +38 -0
  5. package/dist/codegen/generate-typescript.js +45 -0
  6. package/dist/codegen/generate.js +82 -0
  7. package/dist/codegen/naming.js +33 -0
  8. package/dist/codegen/package.js +35 -0
  9. package/dist/components/capability-plugin.js +26 -0
  10. package/dist/components/fly-release.js +335 -0
  11. package/dist/components/index.js +10 -0
  12. package/dist/components/run-agent.js +133 -0
  13. package/dist/composites/catalog.js +176 -0
  14. package/dist/composites/fly-deploy.js +91 -0
  15. package/dist/composites/fly-otel-collector.js +66 -0
  16. package/dist/composites/fly-site.js +80 -0
  17. package/dist/coverage.js +29 -0
  18. package/dist/describe-resources.js +443 -0
  19. package/dist/detect.js +63 -0
  20. package/dist/export-resources.js +81 -0
  21. package/dist/generated/index.js +44 -0
  22. package/dist/generated/runtime.js +4 -0
  23. package/dist/import/generator.js +208 -0
  24. package/dist/import/live-export.js +81 -0
  25. package/dist/import/parser.js +157 -0
  26. package/dist/index.js +40 -0
  27. package/dist/integrity.json +3 -3
  28. package/dist/lint/audit-catalog.js +28 -0
  29. package/dist/lint/audit-lineage.js +5 -0
  30. package/dist/lint/post-synth/fly-helpers.js +24 -0
  31. package/dist/lint/post-synth/fly010-machine-requires-image.js +33 -0
  32. package/dist/lint/post-synth/fly011-mount-references-declared-volume.js +58 -0
  33. package/dist/lint/post-synth/index.js +6 -0
  34. package/dist/lint/rules/guest-sizing.js +92 -0
  35. package/dist/lint/rules/index.js +12 -0
  36. package/dist/lint/rules/no-secret-literals.js +58 -0
  37. package/dist/lint/rules/valid-region.js +52 -0
  38. package/dist/lsp/completions.js +12 -0
  39. package/dist/lsp/hover.js +12 -0
  40. package/dist/manifest.json +1 -1
  41. package/dist/mcp/context-tools.js +113 -0
  42. package/dist/okf/types/Machine.md +1 -1
  43. package/dist/op/activities/box-services.js +88 -0
  44. package/dist/op/activities/emulator-images.js +32 -0
  45. package/dist/op/activities/flaps.js +34 -0
  46. package/dist/op/activities/fly-apply.js +911 -0
  47. package/dist/op/activities/fly-release-step.js +21 -0
  48. package/dist/op/activities/fly-rollback-step.js +22 -0
  49. package/dist/op/activities/index.js +53 -0
  50. package/dist/op/activities/machine-release.js +236 -0
  51. package/dist/op/activities/machines-contract.js +81 -0
  52. package/dist/op/activities/machines-fake.js +203 -0
  53. package/dist/op/activities/machines-local-cli.js +85 -0
  54. package/dist/op/activities/machines-local.js +395 -0
  55. package/dist/op/activities/progress.js +10 -0
  56. package/dist/op/activities/sprite-config.js +409 -0
  57. package/dist/op/activities/sprite-fs.js +131 -0
  58. package/dist/op/activities/sprite-service-converge.js +224 -0
  59. package/dist/op/activities/sprite-services.js +174 -0
  60. package/dist/op/activities/sprite-tasks.js +57 -0
  61. package/dist/op/activities/sprites-contract.js +87 -0
  62. package/dist/op/activities/sprites-emulator.js +72 -0
  63. package/dist/op/activities/sprites-fake.js +547 -0
  64. package/dist/op/activities/sprites.js +515 -0
  65. package/dist/op/activity-contracts.js +82 -0
  66. package/dist/op/builders.js +157 -0
  67. package/dist/ownership.js +29 -0
  68. package/dist/package-cli.js +20 -0
  69. package/dist/plugin.js +298 -0
  70. package/dist/pseudo.js +26 -0
  71. package/dist/reference-catalog.js +37 -0
  72. package/dist/release-metadata.js +59 -0
  73. package/dist/release-store.js +46 -0
  74. package/dist/serializer.js +299 -0
  75. package/dist/spec/fetch.js +32 -0
  76. package/dist/spec/parse.js +260 -0
  77. package/dist/teardown.js +194 -0
  78. package/dist/types/index.d.ts +33 -2
  79. package/dist/validate-cli.js +4 -0
  80. package/dist/validate.js +33 -0
  81. package/package.json +11 -11
  82. package/src/codegen/package.ts +2 -2
  83. package/src/generated/index.d.ts +33 -2
@@ -0,0 +1,547 @@
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
+ import { createServer } from "node:http";
22
+ import { WebSocketServer } from "ws";
23
+ /**
24
+ * Run one command against a sprite's `fs`. Not a real shell: it recognizes a
25
+ * small set of forms so a test (and the example `guarded-task` Op) can write a
26
+ * key, then overwrite/fail it, and prove restore rewinds. Segments split on
27
+ * `;` run in order; the exit code is the last segment's (shell `;` semantics).
28
+ *
29
+ * `opts.env`/`opts.dir` (#2765) are the `env`/`dir` exec options after they
30
+ * cross the wire (`env` as `KEY=VALUE` strings, mirroring what the query
31
+ * params carry) — `env` and `pwd` below echo them back so a test can prove
32
+ * they reached the command. Real spritzer only wires `env`/`dir` through in
33
+ * container exec mode (real processes); its interpreter mode — what
34
+ * `fakeExec` otherwise mirrors — has no notion of either, so this pair of
35
+ * forms is fake-only, proven separately from the container-mode docker test.
36
+ */
37
+ export function fakeExec(sprite, cmd, opts = {}) {
38
+ let stdout = "";
39
+ let stderr = "";
40
+ let exitCode = 0;
41
+ let delayMs;
42
+ for (const raw of cmd.split(";")) {
43
+ const seg = raw.trim();
44
+ if (!seg)
45
+ continue;
46
+ let m;
47
+ if ((m = seg.match(/^cat\s+(\S+)\s*>\s*(\S+)$/))) {
48
+ // Copy a file: `cat SRC > DEST`. Lets an Op stage input with spriteWriteFile,
49
+ // process it with exec, then read the result with spriteReadFile.
50
+ sprite.fs[m[2]] = sprite.fs[m[1]] ?? "";
51
+ exitCode = 0;
52
+ }
53
+ else if ((m = seg.match(/^echo\s+(.+?)\s*>\s*(\S+)$/))) {
54
+ sprite.fs[m[2]] = unquote(m[1]);
55
+ exitCode = 0;
56
+ }
57
+ else if ((m = seg.match(/^echo\s+(.+)$/))) {
58
+ stdout += `${unquote(m[1])}\n`;
59
+ exitCode = 0;
60
+ }
61
+ else if ((m = seg.match(/^cat\s+(\S+)$/))) {
62
+ stdout += sprite.fs[m[1]] ?? "";
63
+ exitCode = 0;
64
+ }
65
+ else if ((m = seg.match(/^rm\s+(?:-f\s+)?(\S+)$/))) {
66
+ delete sprite.fs[m[1]];
67
+ exitCode = 0;
68
+ }
69
+ else if (seg === "false") {
70
+ exitCode = 1;
71
+ }
72
+ else if (seg === "true") {
73
+ exitCode = 0;
74
+ }
75
+ else if (seg === "env") {
76
+ // Prints the passed env, sorted `KEY=VALUE` lines, like the real command.
77
+ stdout += [...(opts.env ?? [])].sort().map((kv) => `${kv}\n`).join("");
78
+ exitCode = 0;
79
+ }
80
+ else if (seg === "pwd") {
81
+ stdout += `${opts.dir ?? "/"}\n`;
82
+ exitCode = 0;
83
+ }
84
+ else if ((m = seg.match(/^sleep\s+(\d+)$/))) {
85
+ // Fake-only timing hook (#2765): the argument is milliseconds, not
86
+ // seconds like the real command — just enough to hold the exec open
87
+ // past a short `timeoutMs` without a slow test.
88
+ exitCode = 0;
89
+ delayMs = Number(m[1]);
90
+ }
91
+ else if (seg === "./risky.sh") {
92
+ // A scripted failing job: mutates the workspace, then exits non-zero, so
93
+ // the example guarded-task Op demonstrates checkpoint-as-compensation.
94
+ sprite.fs["/work/output"] = "partial-corrupt";
95
+ stderr += "risky.sh: failed\n";
96
+ exitCode = 1;
97
+ }
98
+ else {
99
+ // Unknown command → echo it back (a no-op success), never real execution.
100
+ stdout += `${seg}\n`;
101
+ exitCode = 0;
102
+ }
103
+ }
104
+ return { stdout, stderr, exitCode, ...(delayMs !== undefined ? { delayMs } : {}) };
105
+ }
106
+ function unquote(s) {
107
+ const t = s.trim();
108
+ if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
109
+ return t.slice(1, -1);
110
+ }
111
+ return t;
112
+ }
113
+ /** A full copy of an `fs` map. Values are strings, so a shallow spread copies them. */
114
+ function copyFs(fs) {
115
+ return { ...fs };
116
+ }
117
+ // Stream ids for the non-PTY exec framing (mirror sprites.ts).
118
+ const STREAM_STDOUT = 1;
119
+ const STREAM_STDERR = 2;
120
+ const STREAM_EXIT = 3;
121
+ function frame(stream, payload) {
122
+ return Buffer.concat([Buffer.of(stream), payload]);
123
+ }
124
+ async function readBody(req) {
125
+ const chunks = [];
126
+ for await (const c of req)
127
+ chunks.push(c);
128
+ if (chunks.length === 0)
129
+ return undefined;
130
+ const text = Buffer.concat(chunks).toString("utf8");
131
+ if (!text)
132
+ return undefined;
133
+ try {
134
+ return JSON.parse(text);
135
+ }
136
+ catch {
137
+ return undefined;
138
+ }
139
+ }
140
+ /** Read the request body as raw text (for the filesystem write endpoint). */
141
+ async function readRawBody(req) {
142
+ const chunks = [];
143
+ for await (const c of req)
144
+ chunks.push(c);
145
+ return Buffer.concat(chunks).toString("utf8");
146
+ }
147
+ /**
148
+ * Immediate children of `dir` in a flat `path → contents` map: a key
149
+ * `${dir}/name` is a file; `${dir}/name/...` contributes the dir `name` once.
150
+ * Pure — the filesystem-list model for the fake.
151
+ */
152
+ export function fakeListDir(fs, dir) {
153
+ const prefix = dir.replace(/\/+$/, "") + "/";
154
+ const files = new Map();
155
+ const dirs = new Set();
156
+ for (const [key, val] of Object.entries(fs)) {
157
+ if (!key.startsWith(prefix))
158
+ continue;
159
+ const rest = key.slice(prefix.length);
160
+ if (!rest)
161
+ continue;
162
+ const slash = rest.indexOf("/");
163
+ if (slash === -1)
164
+ files.set(rest, val.length);
165
+ else
166
+ dirs.add(rest.slice(0, slash));
167
+ }
168
+ return [
169
+ ...[...dirs].sort().map((name) => ({ name, type: "dir" })),
170
+ ...[...files.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([name, size]) => ({ name, type: "file", size })),
171
+ ];
172
+ }
173
+ /**
174
+ * Start the in-process Sprites fake on an ephemeral port. Returns its base
175
+ * `url` (feed it to `SPRITES_BASE_URL`) and a `close()`.
176
+ */
177
+ export function createSpritesFake() {
178
+ const sprites = new Map();
179
+ const server = createServer((req, res) => {
180
+ void handle(req, res).catch((err) => {
181
+ res.writeHead(500, { "content-type": "application/json" });
182
+ res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
183
+ });
184
+ });
185
+ // The control WebSocket exec endpoint. noServer + a manual `upgrade` handler so
186
+ // the same http server serves both the REST/NDJSON routes and the exec socket.
187
+ const wss = new WebSocketServer({ noServer: true });
188
+ server.on("upgrade", (req, socket, head) => {
189
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
190
+ const m = url.pathname.match(/^\/v1\/sprites\/([^/]+)\/exec\/?$/);
191
+ if (!m) {
192
+ socket.destroy();
193
+ return;
194
+ }
195
+ const id = decodeURIComponent(m[1]);
196
+ wss.handleUpgrade(req, socket, head, (ws) => runExec(ws, sprites.get(id), url));
197
+ });
198
+ function runExec(ws, sprite, url) {
199
+ if (!sprite || sprite.status === "destroyed") {
200
+ ws.send(frame(STREAM_STDERR, Buffer.from(`no sprite\n`)));
201
+ ws.send(frame(STREAM_EXIT, Buffer.of(127)));
202
+ ws.close();
203
+ return;
204
+ }
205
+ // argv arrives as repeated `cmd` params; reconstruct the script the small
206
+ // interpreter understands (the tokens round-trip for the space-separated
207
+ // command forms the example Ops use). `dir`/`env` (#2765) mirror
208
+ // spriteExecWsUrl's encoding: `dir` a single param, `env` repeated
209
+ // `KEY=VALUE` params.
210
+ const argv = url.searchParams.getAll("cmd");
211
+ const script = argv.join(" ");
212
+ const dir = url.searchParams.get("dir") ?? undefined;
213
+ const env = url.searchParams.getAll("env");
214
+ const result = fakeExec(sprite, script, { dir, env });
215
+ const send = () => {
216
+ // The client may already have aborted/timed out and closed its end;
217
+ // sending on a closed socket would throw, so this is a plain no-op.
218
+ if (ws.readyState !== ws.OPEN)
219
+ return;
220
+ if (result.stdout)
221
+ ws.send(frame(STREAM_STDOUT, Buffer.from(result.stdout)));
222
+ if (result.stderr)
223
+ ws.send(frame(STREAM_STDERR, Buffer.from(result.stderr)));
224
+ ws.send(frame(STREAM_EXIT, Buffer.of(result.exitCode & 0xff)));
225
+ ws.close();
226
+ };
227
+ if (result.delayMs)
228
+ setTimeout(send, result.delayMs);
229
+ else
230
+ send();
231
+ }
232
+ async function handle(req, res) {
233
+ const method = req.method ?? "GET";
234
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
235
+ const path = url.pathname;
236
+ const host = req.headers.host ?? "localhost";
237
+ const send = (status, body) => {
238
+ res.writeHead(status, { "content-type": "application/json" });
239
+ res.end(JSON.stringify(body ?? {}));
240
+ };
241
+ // No-body reply (real Sprites' 204s carry no content and no body): #2719.
242
+ const sendEmpty = (status) => {
243
+ res.writeHead(status);
244
+ res.end();
245
+ };
246
+ // NDJSON progress stream: an `info` line then a terminal `complete` line.
247
+ const sendNdjson = (status, events) => {
248
+ res.writeHead(status, { "content-type": "application/x-ndjson" });
249
+ res.end(events.map((e) => JSON.stringify(e)).join("\n") + "\n");
250
+ };
251
+ // POST /v1/sprites — create.
252
+ if (method === "POST" && /^\/v1\/sprites\/?$/.test(path)) {
253
+ const body = ((await readBody(req)) ?? {});
254
+ const id = body.name;
255
+ if (!id)
256
+ return send(400, { error: "name is required" });
257
+ const sprite = {
258
+ id,
259
+ status: "running",
260
+ url: `http://${host}/s/${encodeURIComponent(id)}`,
261
+ fs: {},
262
+ checkpoints: [],
263
+ version: 0,
264
+ policy: body.policy,
265
+ netPolicy: [],
266
+ services: {},
267
+ tasks: {},
268
+ };
269
+ sprites.set(id, sprite);
270
+ return send(201, { id: sprite.id, url: sprite.url });
271
+ }
272
+ // Keep-alive tasks: /v1/sprites/{id}/tasks[/{name}].
273
+ const tm = path.match(/^\/v1\/sprites\/([^/]+)\/tasks(?:\/([^/]+))?\/?$/);
274
+ if (tm) {
275
+ const id = decodeURIComponent(tm[1]);
276
+ const name = tm[2] ? decodeURIComponent(tm[2]) : undefined;
277
+ const sprite = sprites.get(id);
278
+ if (!sprite || sprite.status === "destroyed")
279
+ return send(404, { error: `no sprite ${id}` });
280
+ if (method === "GET" && !name)
281
+ return send(200, Object.values(sprite.tasks));
282
+ if (method === "POST" && !name) {
283
+ const b = ((await readBody(req)) ?? {});
284
+ if (!b.name)
285
+ return send(400, { error: "name is required" });
286
+ sprite.tasks[b.name] = { name: b.name, expire: b.expire };
287
+ return send(201, { name: b.name, expires_at: "2026-01-01T00:00:00Z" });
288
+ }
289
+ if (method === "PUT" && name) {
290
+ const b = ((await readBody(req)) ?? {});
291
+ const t = sprite.tasks[name];
292
+ if (!t)
293
+ return send(404, { error: `no task ${name}` });
294
+ t.expire = b.expire ?? t.expire;
295
+ return send(200, { name, expires_at: "2026-01-01T00:00:00Z" });
296
+ }
297
+ if (method === "DELETE" && name) {
298
+ if (!(name in sprite.tasks))
299
+ return send(404, { error: `no task ${name}` });
300
+ delete sprite.tasks[name];
301
+ return send(204, {});
302
+ }
303
+ return send(404, { error: `not found: ${method} ${path}` });
304
+ }
305
+ // Network policy: GET/POST /v1/sprites/{id}/policy/network (whole-object replace).
306
+ const pm = path.match(/^\/v1\/sprites\/([^/]+)\/policy\/network\/?$/);
307
+ if (pm) {
308
+ const id = decodeURIComponent(pm[1]);
309
+ const sprite = sprites.get(id);
310
+ if (!sprite || sprite.status === "destroyed")
311
+ return send(404, { error: `no sprite ${id}` });
312
+ if (method === "GET")
313
+ return send(200, { rules: sprite.netPolicy });
314
+ if (method === "POST") {
315
+ const body = ((await readBody(req)) ?? {});
316
+ sprite.netPolicy = body.rules ?? [];
317
+ // 204 with no body — matches every official SDK and wisp (#2719).
318
+ return sendEmpty(204);
319
+ }
320
+ return send(404, { error: `not found: ${method} ${path}` });
321
+ }
322
+ // Services: /v1/sprites/{id}/services[/{svc}[/start|stop|restart|logs]].
323
+ const svcm = path.match(/^\/v1\/sprites\/([^/]+)\/services(?:\/([^/]+)(\/start|\/stop|\/restart|\/logs)?)?\/?$/);
324
+ if (svcm) {
325
+ const id = decodeURIComponent(svcm[1]);
326
+ const svc = svcm[2] ? decodeURIComponent(svcm[2]) : undefined;
327
+ const action = svcm[3];
328
+ const sprite = sprites.get(id);
329
+ if (!sprite || sprite.status === "destroyed")
330
+ return send(404, { error: `no sprite ${id}` });
331
+ // GET /services — list.
332
+ if (method === "GET" && !svc)
333
+ return send(200, Object.values(sprite.services));
334
+ // GET /services/{svc}/logs — the tail as NDJSON (#2711).
335
+ if (method === "GET" && svc && action === "/logs") {
336
+ const s = sprite.services[svc];
337
+ if (!s)
338
+ return send(404, { error: `no service ${svc}` });
339
+ const n = Number(url.searchParams.get("lines"));
340
+ const tail = Number.isFinite(n) && n > 0 ? s.logs.slice(-n) : s.logs;
341
+ return sendNdjson(200, [...tail.map((data) => ({ type: "stdout", data })), { type: "complete", data: `${svc} log tail` }]);
342
+ }
343
+ if (svc && !action) {
344
+ // GET /services/{svc}
345
+ if (method === "GET") {
346
+ const s = sprite.services[svc];
347
+ return s ? send(200, s) : send(404, { error: `no service ${svc}` });
348
+ }
349
+ // PUT /services/{svc} — create-or-update, then start (#2711: real
350
+ // Sprites/spritzer's PUT both defines and starts the service).
351
+ if (method === "PUT") {
352
+ const b = ((await readBody(req)) ?? {});
353
+ const prev = sprite.services[svc];
354
+ const started = new Date().toISOString();
355
+ sprite.services[svc] = {
356
+ name: svc,
357
+ cmd: b.cmd,
358
+ args: b.args,
359
+ env: b.env,
360
+ dir: b.dir,
361
+ needs: b.needs,
362
+ http_port: b.http_port,
363
+ state: { name: svc, pid: 4321, status: "running", started_at: started },
364
+ logs: [...(prev?.logs ?? []), `${svc} started (pid 4321)`],
365
+ };
366
+ return send(200, sprite.services[svc]);
367
+ }
368
+ // DELETE /services/{svc} — idempotent; a 404 means already gone (#2711).
369
+ if (method === "DELETE") {
370
+ if (!(svc in sprite.services))
371
+ return send(404, { error: `no service ${svc}` });
372
+ delete sprite.services[svc];
373
+ return send(200, {});
374
+ }
375
+ }
376
+ // POST /services/{svc}/start|stop|restart — NDJSON, flips status.
377
+ if (method === "POST" && svc && (action === "/start" || action === "/stop" || action === "/restart")) {
378
+ const s = sprite.services[svc];
379
+ if (!s)
380
+ return send(404, { error: `no service ${svc}` });
381
+ const stopped = action === "/stop";
382
+ s.state = { name: svc, pid: stopped ? 0 : 4321, status: stopped ? "stopped" : "running", started_at: new Date().toISOString() };
383
+ s.logs.push(`${svc} ${stopped ? "stopping" : "started"}`);
384
+ return sendNdjson(200, [
385
+ { type: stopped ? "stopping" : "started", data: `${svc} ${stopped ? "stopping" : "started"}` },
386
+ { type: "complete", data: `${svc} ${action.slice(1)} complete` },
387
+ ]);
388
+ }
389
+ return send(404, { error: `not found: ${method} ${path}` });
390
+ }
391
+ // The sprite URL proxy: /s/{id}[/...] (#2711). Real spritzer routes this to
392
+ // whatever listens on the `http_port` service's port; the fake has no real
393
+ // process, so it answers 200 when such a service is `running` and 503
394
+ // ("nothing is answering", matching spritzer's real error) otherwise —
395
+ // enough for `spriteUrl`'s wait-for-200 and the stop/start round trip.
396
+ const sm = path.match(/^\/s\/([^/]+)(?:\/.*)?$/);
397
+ if (sm) {
398
+ const id = decodeURIComponent(sm[1]);
399
+ const sprite = sprites.get(id);
400
+ if (!sprite || sprite.status === "destroyed")
401
+ return send(404, { error: `no sprite ${id}` });
402
+ const serving = Object.values(sprite.services).find((s) => s.http_port !== undefined && s.state.status === "running");
403
+ if (serving)
404
+ return send(200, { ok: true, service: serving.name });
405
+ res.writeHead(503, { "content-type": "application/json", "retry-after": "2" });
406
+ res.end(JSON.stringify({ error: `sprite ${id}: nothing is answering on its url port` }));
407
+ return;
408
+ }
409
+ // Filesystem API: /v1/sprites/{id}/fs/{read|write|list|delete}. read/write
410
+ // move raw bytes; list/delete use query params + JSON/empty responses.
411
+ const fsm = path.match(/^\/v1\/sprites\/([^/]+)\/fs\/(read|write|list|delete)\/?$/);
412
+ if (fsm) {
413
+ const id = decodeURIComponent(fsm[1]);
414
+ const op = fsm[2];
415
+ const sprite = sprites.get(id);
416
+ if (!sprite || sprite.status === "destroyed")
417
+ return send(404, { error: `no sprite ${id}` });
418
+ const p = url.searchParams.get("path") ?? "";
419
+ if (!p)
420
+ return send(400, { error: "path is required" });
421
+ const sendRaw = (status, body) => {
422
+ res.writeHead(status, { "content-type": "application/octet-stream" });
423
+ res.end(body);
424
+ };
425
+ if (method === "PUT" && op === "write") {
426
+ sprite.fs[p] = await readRawBody(req);
427
+ return send(200, {});
428
+ }
429
+ if (method === "GET" && op === "read") {
430
+ const content = sprite.fs[p];
431
+ if (content === undefined)
432
+ return send(404, { error: `no file ${p}` });
433
+ return sendRaw(200, content);
434
+ }
435
+ if (method === "GET" && op === "list") {
436
+ return send(200, fakeListDir(sprite.fs, p));
437
+ }
438
+ if (method === "DELETE" && op === "delete") {
439
+ const recursive = url.searchParams.get("recursive") === "true";
440
+ if (recursive) {
441
+ const prefix = p.replace(/\/+$/, "");
442
+ let removed = 0;
443
+ for (const key of Object.keys(sprite.fs)) {
444
+ if (key === prefix || key.startsWith(prefix + "/")) {
445
+ delete sprite.fs[key];
446
+ removed += 1;
447
+ }
448
+ }
449
+ return removed > 0 ? send(200, {}) : send(404, { error: `no path ${p}` });
450
+ }
451
+ if (!(p in sprite.fs))
452
+ return send(404, { error: `no file ${p}` });
453
+ delete sprite.fs[p];
454
+ return send(200, {});
455
+ }
456
+ return send(404, { error: `not found: ${method} ${path}` });
457
+ }
458
+ const m = path.match(/^\/v1\/sprites\/([^/]+)(\/checkpoint|\/checkpoints(?:\/([^/]+)(\/restore)?)?)?\/?$/);
459
+ if (m) {
460
+ const id = decodeURIComponent(m[1]);
461
+ const sub = m[2];
462
+ const cpId = m[3] ? decodeURIComponent(m[3]) : undefined;
463
+ const isRestore = Boolean(m[4]);
464
+ const sprite = sprites.get(id);
465
+ if (!sprite || sprite.status === "destroyed")
466
+ return send(404, { error: `no sprite ${id}` });
467
+ // POST /v1/sprites/{id}/checkpoint — snapshot fs under a new version id.
468
+ if (method === "POST" && sub === "/checkpoint") {
469
+ const body = ((await readBody(req)) ?? {});
470
+ sprite.version += 1;
471
+ const cp = {
472
+ id: `v${sprite.version}`,
473
+ comment: body.comment ?? "",
474
+ create_time: new Date().toISOString(),
475
+ is_auto: false,
476
+ fs: copyFs(sprite.fs),
477
+ };
478
+ sprite.checkpoints.push(cp);
479
+ // Mirror real Sprites: `{type, data}` progress lines carrying the
480
+ // version id in the message text, not a structured field.
481
+ return sendNdjson(200, [
482
+ { type: "info", data: "Creating checkpoint..." },
483
+ { type: "info", data: "Checkpoint created successfully" },
484
+ { type: "info", data: ` ID: ${cp.id}` },
485
+ { type: "complete", data: `Checkpoint ${cp.id} created successfully` },
486
+ ]);
487
+ }
488
+ // GET /v1/sprites/{id}/checkpoints — bare array (auto excluded).
489
+ if (method === "GET" && sub === "/checkpoints" && !cpId) {
490
+ return send(200, sprite.checkpoints
491
+ .filter((c) => !c.is_auto)
492
+ .map((c) => ({ id: c.id, comment: c.comment, create_time: c.create_time, is_auto: c.is_auto })));
493
+ }
494
+ // GET /v1/sprites/{id}/checkpoints/{cp} — a single checkpoint.
495
+ if (method === "GET" && cpId && !isRestore) {
496
+ const cp = sprite.checkpoints.find((c) => c.id === cpId);
497
+ if (!cp)
498
+ return send(404, { error: `no checkpoint ${cpId} for sprite ${id}` });
499
+ return send(200, { id: cp.id, comment: cp.comment, create_time: cp.create_time });
500
+ }
501
+ // POST /v1/sprites/{id}/checkpoints/{cp}/restore — replace fs with the snapshot.
502
+ if (method === "POST" && cpId && isRestore) {
503
+ const cp = sprite.checkpoints.find((c) => c.id === cpId);
504
+ if (!cp)
505
+ return send(404, { error: `no checkpoint ${cpId} for sprite ${id}` });
506
+ sprite.fs = copyFs(cp.fs);
507
+ sprite.status = "running";
508
+ return sendNdjson(200, [
509
+ { type: "info", data: `Restoring checkpoint ${cpId}...` },
510
+ { type: "complete", data: `Checkpoint ${cpId} restored successfully` },
511
+ ]);
512
+ }
513
+ // DELETE /v1/sprites/{id}
514
+ if (method === "DELETE" && !sub) {
515
+ sprite.status = "destroyed";
516
+ return send(200, {});
517
+ }
518
+ // GET /v1/sprites/{id} — inspection (fs + checkpoint ids + config), used by tests/verify.
519
+ if (method === "GET" && !sub) {
520
+ return send(200, {
521
+ id: sprite.id,
522
+ status: sprite.status,
523
+ url: sprite.url,
524
+ fs: sprite.fs,
525
+ checkpoints: sprite.checkpoints.map((c) => c.id),
526
+ netPolicy: sprite.netPolicy,
527
+ services: sprite.services,
528
+ tasks: sprite.tasks,
529
+ });
530
+ }
531
+ }
532
+ return send(404, { error: `not found: ${method} ${path}` });
533
+ }
534
+ return new Promise((resolve) => {
535
+ server.listen(0, "127.0.0.1", () => {
536
+ const addr = server.address();
537
+ const port = typeof addr === "object" && addr ? addr.port : 0;
538
+ resolve({
539
+ url: `http://127.0.0.1:${port}`,
540
+ close: () => new Promise((res, rej) => {
541
+ wss.close();
542
+ server.close((err) => (err ? rej(err) : res()));
543
+ }),
544
+ });
545
+ });
546
+ });
547
+ }