@intentius/chant-lexicon-fly 0.89.0 → 0.91.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 (75) hide show
  1. package/dist/components/capability-plugin.d.ts +1 -0
  2. package/dist/components/capability-plugin.d.ts.map +1 -1
  3. package/dist/components/fly-release.d.ts +148 -0
  4. package/dist/components/fly-release.d.ts.map +1 -0
  5. package/dist/components/index.d.ts +2 -0
  6. package/dist/components/index.d.ts.map +1 -1
  7. package/dist/describe-resources.d.ts +11 -0
  8. package/dist/describe-resources.d.ts.map +1 -1
  9. package/dist/index.d.ts +3 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/integrity.json +4 -4
  12. package/dist/manifest.json +1 -1
  13. package/dist/op/activities/emulator-images.d.ts +13 -0
  14. package/dist/op/activities/emulator-images.d.ts.map +1 -1
  15. package/dist/op/activities/fly-apply.d.ts +8 -22
  16. package/dist/op/activities/fly-apply.d.ts.map +1 -1
  17. package/dist/op/activities/index.d.ts +6 -2
  18. package/dist/op/activities/index.d.ts.map +1 -1
  19. package/dist/op/activities/machine-release.d.ts +176 -0
  20. package/dist/op/activities/machine-release.d.ts.map +1 -0
  21. package/dist/op/activities/machines-contract.d.ts +8 -0
  22. package/dist/op/activities/machines-contract.d.ts.map +1 -1
  23. package/dist/op/activities/machines-fake.d.ts +53 -0
  24. package/dist/op/activities/machines-fake.d.ts.map +1 -0
  25. package/dist/op/activities/sprite-services.d.ts +158 -0
  26. package/dist/op/activities/sprite-services.d.ts.map +1 -0
  27. package/dist/op/activities/sprites-contract.d.ts +17 -1
  28. package/dist/op/activities/sprites-contract.d.ts.map +1 -1
  29. package/dist/op/activities/sprites-emulator.d.ts +12 -0
  30. package/dist/op/activities/sprites-emulator.d.ts.map +1 -1
  31. package/dist/op/activities/sprites-fake.d.ts +4 -0
  32. package/dist/op/activities/sprites-fake.d.ts.map +1 -1
  33. package/dist/op/activities/sprites.d.ts +53 -0
  34. package/dist/op/activities/sprites.d.ts.map +1 -1
  35. package/dist/op/builders.d.ts +33 -1
  36. package/dist/op/builders.d.ts.map +1 -1
  37. package/dist/release-metadata.d.ts +48 -0
  38. package/dist/release-metadata.d.ts.map +1 -0
  39. package/dist/release-store.d.ts +36 -0
  40. package/dist/release-store.d.ts.map +1 -0
  41. package/dist/skills/chant-fly-ops.md +33 -0
  42. package/dist/skills/chant-fly-sprites.md +4 -2
  43. package/package.json +2 -2
  44. package/src/components/capability-plugin.ts +11 -3
  45. package/src/components/fly-release.test.ts +244 -0
  46. package/src/components/fly-release.ts +341 -0
  47. package/src/components/index.ts +16 -0
  48. package/src/describe-resources.test.ts +18 -3
  49. package/src/describe-resources.ts +69 -5
  50. package/src/index.ts +21 -0
  51. package/src/op/activities/emulator-images.ts +14 -0
  52. package/src/op/activities/fly-apply.ts +15 -3
  53. package/src/op/activities/index.ts +57 -0
  54. package/src/op/activities/machine-release.integration.test.ts +113 -0
  55. package/src/op/activities/machine-release.test.ts +138 -0
  56. package/src/op/activities/machine-release.ts +407 -0
  57. package/src/op/activities/machines-contract.docker.integration.test.ts +10 -1
  58. package/src/op/activities/machines-contract.test.ts +11 -1
  59. package/src/op/activities/machines-contract.ts +17 -0
  60. package/src/op/activities/machines-fake.ts +165 -0
  61. package/src/op/activities/sprite-config.test.ts +19 -0
  62. package/src/op/activities/sprite-services.docker.integration.test.ts +87 -0
  63. package/src/op/activities/sprite-services.test.ts +263 -0
  64. package/src/op/activities/sprite-services.ts +311 -0
  65. package/src/op/activities/sprites-contract.test.ts +15 -4
  66. package/src/op/activities/sprites-contract.ts +24 -6
  67. package/src/op/activities/sprites-emulator.ts +52 -1
  68. package/src/op/activities/sprites-fake.ts +57 -8
  69. package/src/op/activities/sprites.test.ts +102 -0
  70. package/src/op/activities/sprites.ts +108 -7
  71. package/src/op/builders.ts +53 -1
  72. package/src/release-metadata.ts +72 -0
  73. package/src/release-store.ts +70 -0
  74. package/src/skills/chant-fly-ops.md +33 -0
  75. package/src/skills/chant-fly-sprites.md +4 -2
@@ -0,0 +1,87 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from "vitest";
2
+ import { spriteCreate, spriteDelete, spriteUrl } from "./sprites";
3
+ import { spriteServiceCreate, spriteServiceStop, spriteServiceStart } from "./sprite-services";
4
+ import { spritesContainerUp, spritesContainerDown } from "./sprites-emulator";
5
+
6
+ // Acceptance (#2711) against the REAL spritzer image in container exec mode
7
+ // (SPRITZER_EXEC=container, SPRITZER_RUNTIME=docker — INTENTIUS/spritzer#22):
8
+ // create a sprite, create a service running `python3 -m http.server 8080`,
9
+ // wait for its URL to answer, stop it (the URL stops answering), start it
10
+ // again (the URL answers again), delete the sprite. `sprite-services.test.ts`
11
+ // runs the same scenario against the in-process fake, no Docker. Docker
12
+ // required; deterministically skipped in CI (GitHub runners have Docker, so
13
+ // relying on absence would pull the image on every run) — run locally, or
14
+ // opt in with SPRITES_DOCKER=1, same convention as the other docker tests
15
+ // here.
16
+ //
17
+ // Cleanup: the sprite container spritzer makes (`spritzer-<name>`) is removed
18
+ // by `spriteDelete` before spritzer itself comes down; `docker ps -a` after
19
+ // this suite should show neither.
20
+
21
+ const CONTAINER = "chant-spritzer-container-it";
22
+ const PORT = 4294;
23
+ const SPRITE = "chant-2711-web";
24
+
25
+ let available = false;
26
+ let endpoint = "";
27
+ let prevBaseUrl: string | undefined;
28
+
29
+ beforeAll(async () => {
30
+ if (process.env.CI && !process.env.SPRITES_DOCKER) {
31
+ available = false;
32
+ return;
33
+ }
34
+ try {
35
+ const up = await spritesContainerUp({ name: CONTAINER, port: PORT, timeoutMs: 60_000 });
36
+ endpoint = up.endpoint;
37
+ prevBaseUrl = process.env.SPRITES_BASE_URL;
38
+ process.env.SPRITES_BASE_URL = endpoint;
39
+ available = true;
40
+ } catch {
41
+ available = false;
42
+ }
43
+ }, 90_000);
44
+
45
+ afterAll(async () => {
46
+ if (prevBaseUrl === undefined) delete process.env.SPRITES_BASE_URL;
47
+ else process.env.SPRITES_BASE_URL = prevBaseUrl;
48
+ if (available) {
49
+ // Idempotent: already deleted by the test on the happy path. A failed
50
+ // test still leaves no sprite container behind.
51
+ await spriteDelete({ id: SPRITE }).catch(() => {});
52
+ await spritesContainerDown({ name: CONTAINER });
53
+ }
54
+ }, 30_000);
55
+
56
+ describe("sprite services against real spritzer 0.6.0, container mode (#2711)", () => {
57
+ test("create → url waits and answers → stop → url stops answering → start → answers again → delete", async (ctx) => {
58
+ if (!available) ctx.skip();
59
+
60
+ await spriteCreate({ name: SPRITE });
61
+
62
+ await spriteServiceCreate({
63
+ id: SPRITE,
64
+ name: "web",
65
+ cmd: "python3",
66
+ args: ["-m", "http.server", "8080"],
67
+ http_port: 8080,
68
+ durationMs: 1000,
69
+ });
70
+
71
+ const { url } = await spriteUrl({ id: SPRITE, path: "/", timeoutMs: 20_000, intervalMs: 500 });
72
+ expect(url).toContain(SPRITE);
73
+
74
+ await spriteServiceStop({ id: SPRITE, name: "web" });
75
+ // The process is gone; nothing answers on the sprite URL any more.
76
+ const afterStop = await fetch(`${url}/`);
77
+ expect(afterStop.status).toBe(503);
78
+
79
+ await spriteServiceStart({ id: SPRITE, name: "web" });
80
+ const { url: url2 } = await spriteUrl({ id: SPRITE, path: "/", timeoutMs: 20_000, intervalMs: 500 });
81
+ expect(url2).toBe(url);
82
+
83
+ await spriteDelete({ id: SPRITE });
84
+ const afterDelete = await fetch(`${endpoint}/v1/sprites/${SPRITE}`);
85
+ expect(afterDelete.status).toBe(404);
86
+ }, 120_000);
87
+ });
@@ -0,0 +1,263 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from "vitest";
2
+ import type { SpritesHttp } from "./sprites";
3
+ import { spriteCreate as createImpl } from "./sprites";
4
+ import { createSpritesFake } from "./sprites-fake";
5
+ import {
6
+ spriteServiceCreate,
7
+ spriteServiceGet,
8
+ spriteServiceList,
9
+ spriteServiceStart,
10
+ spriteServiceStop,
11
+ spriteServiceDelete,
12
+ spriteServiceLogs,
13
+ spriteServiceCreateBody,
14
+ parseServiceLogNdjson,
15
+ } from "./sprite-services";
16
+
17
+ // A recording HTTP stub: captures every call and answers from a callback.
18
+ function recorder(answer: (method: string, url: string, body?: unknown) => { status: number; text: string }) {
19
+ const calls: Array<{ method: string; url: string; body?: unknown }> = [];
20
+ const http: SpritesHttp = async (method, url, body) => {
21
+ calls.push({ method, url, body });
22
+ return answer(method, url, body);
23
+ };
24
+ return { http, calls };
25
+ }
26
+
27
+ const running = (name: string, extra: Record<string, unknown> = {}) =>
28
+ JSON.stringify({ name, cmd: "run", state: { name, status: "running", pid: 4321 }, ...extra });
29
+
30
+ // ── Pure helpers ────────────────────────────────────────────────────────────
31
+
32
+ describe("spriteServiceCreateBody", () => {
33
+ test("omits undefined optional fields", () => {
34
+ expect(spriteServiceCreateBody({ id: "s", name: "web", cmd: "run-web" })).toEqual({ cmd: "run-web" });
35
+ });
36
+
37
+ test("includes every field that is set", () => {
38
+ expect(
39
+ spriteServiceCreateBody({
40
+ id: "s",
41
+ name: "web",
42
+ cmd: "python3",
43
+ args: ["-m", "http.server", "8080"],
44
+ env: { PORT: "8080" },
45
+ dir: "/app",
46
+ needs: ["db"],
47
+ http_port: 8080,
48
+ }),
49
+ ).toEqual({
50
+ cmd: "python3",
51
+ args: ["-m", "http.server", "8080"],
52
+ env: { PORT: "8080" },
53
+ dir: "/app",
54
+ needs: ["db"],
55
+ http_port: 8080,
56
+ });
57
+ });
58
+ });
59
+
60
+ describe("parseServiceLogNdjson", () => {
61
+ test("keeps stdout/stderr data in order, drops the complete marker", () => {
62
+ const text = [
63
+ JSON.stringify({ type: "started", data: "web started (pid 1)" }),
64
+ JSON.stringify({ type: "stdout", data: "Serving on 0.0.0.0:8080" }),
65
+ JSON.stringify({ type: "complete", data: "ignored" }),
66
+ ].join("\n");
67
+ expect(parseServiceLogNdjson(text)).toEqual(["web started (pid 1)", "Serving on 0.0.0.0:8080"]);
68
+ });
69
+
70
+ test("skips blank and unparseable lines", () => {
71
+ expect(parseServiceLogNdjson('\n{"type":"stdout","data":"a"}\n\nnot json\n')).toEqual(["a"]);
72
+ });
73
+ });
74
+
75
+ // ── Activity request shapes (injected SpritesHttp) ─────────────────────────
76
+
77
+ describe("spriteServiceCreate", () => {
78
+ test("PUTs .../services/{name} with ?duration, then GETs the definitive state", async () => {
79
+ const { http, calls } = recorder((method) => ({
80
+ status: method === "PUT" ? 200 : 200,
81
+ text: method === "PUT" ? '{"type":"complete"}' : running("web", { http_port: 8080 }),
82
+ }));
83
+ const res = await spriteServiceCreate(
84
+ { id: "task-1", name: "web", cmd: "python3", args: ["-m", "http.server", "8080"], http_port: 8080, endpoint: "http://x" },
85
+ undefined,
86
+ http,
87
+ );
88
+ expect(res.state.status).toBe("running");
89
+ expect(calls[0].method).toBe("PUT");
90
+ expect(calls[0].url).toBe("http://x/v1/sprites/task-1/services/web?duration=500ms");
91
+ expect(calls[0].body).toEqual({ cmd: "python3", args: ["-m", "http.server", "8080"], http_port: 8080 });
92
+ expect(calls[1].method).toBe("GET");
93
+ expect(calls[1].url).toBe("http://x/v1/sprites/task-1/services/web");
94
+ });
95
+
96
+ test("a custom durationMs is passed through as ms", async () => {
97
+ const { http, calls } = recorder(() => ({ status: 200, text: running("web") }));
98
+ await spriteServiceCreate({ id: "t", name: "web", cmd: "x", durationMs: 2000, endpoint: "http://x" }, undefined, http);
99
+ expect(calls[0].url).toBe("http://x/v1/sprites/t/services/web?duration=2000ms");
100
+ });
101
+
102
+ test("throws when the resulting state is failed", async () => {
103
+ const { http } = recorder((method) => ({
104
+ status: 200,
105
+ text: method === "PUT"
106
+ ? '{"type":"error","data":"boom"}'
107
+ : JSON.stringify({ name: "web", cmd: "x", state: { name: "web", status: "failed", error: "exec: not found" } }),
108
+ }));
109
+ await expect(
110
+ spriteServiceCreate({ id: "t", name: "web", cmd: "nope", endpoint: "http://x" }, undefined, http),
111
+ ).rejects.toThrow(/failed to start.*exec: not found/);
112
+ });
113
+
114
+ test("throws on a non-2xx PUT", async () => {
115
+ const { http } = recorder(() => ({ status: 400, text: "bad request" }));
116
+ await expect(
117
+ spriteServiceCreate({ id: "t", name: "web", cmd: "x", endpoint: "http://x" }, undefined, http),
118
+ ).rejects.toThrow(/create failed \(400\)/);
119
+ });
120
+ });
121
+
122
+ describe("spriteServiceGet", () => {
123
+ test("GETs .../services/{name}", async () => {
124
+ const { http, calls } = recorder(() => ({ status: 200, text: running("web") }));
125
+ const res = await spriteServiceGet({ id: "t", name: "web", endpoint: "http://x" }, undefined, http);
126
+ expect(res.state.status).toBe("running");
127
+ expect(calls[0].url).toBe("http://x/v1/sprites/t/services/web");
128
+ });
129
+ });
130
+
131
+ describe("spriteServiceList", () => {
132
+ test("GETs .../services and returns the array", async () => {
133
+ const list = [JSON.parse(running("web")), JSON.parse(running("db"))];
134
+ const { http, calls } = recorder(() => ({ status: 200, text: JSON.stringify(list) }));
135
+ const res = await spriteServiceList({ id: "t", endpoint: "http://x" }, undefined, http);
136
+ expect(res).toEqual(list);
137
+ expect(calls[0].url).toBe("http://x/v1/sprites/t/services");
138
+ });
139
+ });
140
+
141
+ describe("spriteServiceStart / spriteServiceStop", () => {
142
+ test("start POSTs .../start?duration and re-GETs", async () => {
143
+ const { http, calls } = recorder((method) => ({
144
+ status: 200,
145
+ text: method === "POST" ? '{"type":"complete"}' : running("web"),
146
+ }));
147
+ const res = await spriteServiceStart({ id: "t", name: "web", endpoint: "http://x" }, undefined, http);
148
+ expect(res.state.status).toBe("running");
149
+ expect(calls[0].url).toBe("http://x/v1/sprites/t/services/web/start?duration=500ms");
150
+ });
151
+
152
+ test("stop POSTs .../stop and re-GETs", async () => {
153
+ const { http, calls } = recorder((method) => ({
154
+ status: 200,
155
+ text: method === "POST"
156
+ ? '{"type":"complete"}'
157
+ : JSON.stringify({ name: "web", cmd: "x", state: { name: "web", status: "stopped" } }),
158
+ }));
159
+ const res = await spriteServiceStop({ id: "t", name: "web", endpoint: "http://x" }, undefined, http);
160
+ expect(res.state.status).toBe("stopped");
161
+ expect(calls[0].url).toBe("http://x/v1/sprites/t/services/web/stop");
162
+ });
163
+
164
+ test("stop passes ?timeout when timeoutMs is set", async () => {
165
+ const { http, calls } = recorder((method) => ({
166
+ status: 200,
167
+ text: method === "POST" ? "{}" : running("web"),
168
+ }));
169
+ await spriteServiceStop({ id: "t", name: "web", timeoutMs: 3000, endpoint: "http://x" }, undefined, http);
170
+ expect(calls[0].url).toBe("http://x/v1/sprites/t/services/web/stop?timeout=3000ms");
171
+ });
172
+ });
173
+
174
+ describe("spriteServiceDelete", () => {
175
+ test("DELETEs .../services/{name}", async () => {
176
+ const { http, calls } = recorder(() => ({ status: 200, text: "{}" }));
177
+ await spriteServiceDelete({ id: "t", name: "web", endpoint: "http://x" }, undefined, http);
178
+ expect(calls[0].method).toBe("DELETE");
179
+ expect(calls[0].url).toBe("http://x/v1/sprites/t/services/web");
180
+ });
181
+
182
+ test("a 404 is idempotent, not an error", async () => {
183
+ const { http } = recorder(() => ({ status: 404, text: "gone" }));
184
+ await expect(spriteServiceDelete({ id: "t", name: "web", endpoint: "http://x" }, undefined, http)).resolves.toEqual({});
185
+ });
186
+ });
187
+
188
+ describe("spriteServiceLogs", () => {
189
+ test("GETs .../logs and returns the parsed tail", async () => {
190
+ const { http, calls } = recorder(() => ({
191
+ status: 200,
192
+ text: [JSON.stringify({ type: "stdout", data: "line 1" }), JSON.stringify({ type: "complete" })].join("\n"),
193
+ }));
194
+ const res = await spriteServiceLogs({ id: "t", name: "web", endpoint: "http://x" }, undefined, http);
195
+ expect(res).toEqual({ lines: ["line 1"] });
196
+ expect(calls[0].url).toBe("http://x/v1/sprites/t/services/web/logs");
197
+ });
198
+
199
+ test("passes ?lines when set", async () => {
200
+ const { http, calls } = recorder(() => ({ status: 200, text: "" }));
201
+ await spriteServiceLogs({ id: "t", name: "web", lines: 5, endpoint: "http://x" }, undefined, http);
202
+ expect(calls[0].url).toBe("http://x/v1/sprites/t/services/web/logs?lines=5");
203
+ });
204
+ });
205
+
206
+ // ── End to end against the in-process fake (S7, no Docker) ────────────────
207
+ //
208
+ // The acceptance scenario from #2711, offline: create a sprite, create a
209
+ // service with http_port, list/get it, stop and start it, delete it. The
210
+ // docker-gated `sprite-services.docker.integration.test.ts` runs the same
211
+ // scenario against real spritzer 0.6.0 in container mode.
212
+
213
+ let fake: { url: string; close(): Promise<void> };
214
+ let prevBaseUrl: string | undefined;
215
+
216
+ beforeAll(async () => {
217
+ fake = await createSpritesFake();
218
+ prevBaseUrl = process.env.SPRITES_BASE_URL;
219
+ process.env.SPRITES_BASE_URL = fake.url;
220
+ });
221
+
222
+ afterAll(async () => {
223
+ if (prevBaseUrl === undefined) delete process.env.SPRITES_BASE_URL;
224
+ else process.env.SPRITES_BASE_URL = prevBaseUrl;
225
+ await fake?.close();
226
+ });
227
+
228
+ describe("sprite services against the in-process fake (#2711)", () => {
229
+ test("create, get, list, stop, start, delete", async () => {
230
+ await createImpl({ name: "svc-fake-1" });
231
+
232
+ const created = await spriteServiceCreate({
233
+ id: "svc-fake-1",
234
+ name: "web",
235
+ cmd: "python3",
236
+ args: ["-m", "http.server", "8080"],
237
+ http_port: 8080,
238
+ });
239
+ expect(created.state.status).toBe("running");
240
+
241
+ const got = await spriteServiceGet({ id: "svc-fake-1", name: "web" });
242
+ expect(got.name).toBe("web");
243
+ expect(got.http_port).toBe(8080);
244
+
245
+ const list = await spriteServiceList({ id: "svc-fake-1" });
246
+ expect(list.map((s) => s.name)).toEqual(["web"]);
247
+
248
+ const stopped = await spriteServiceStop({ id: "svc-fake-1", name: "web" });
249
+ expect(stopped.state.status).toBe("stopped");
250
+
251
+ const restarted = await spriteServiceStart({ id: "svc-fake-1", name: "web" });
252
+ expect(restarted.state.status).toBe("running");
253
+
254
+ const logs = await spriteServiceLogs({ id: "svc-fake-1", name: "web" });
255
+ expect(logs.lines.length).toBeGreaterThan(0);
256
+
257
+ await spriteServiceDelete({ id: "svc-fake-1", name: "web" });
258
+ await expect(spriteServiceGet({ id: "svc-fake-1", name: "web" })).rejects.toThrow(/get failed \(404\)/);
259
+
260
+ // A second delete is idempotent.
261
+ await expect(spriteServiceDelete({ id: "svc-fake-1", name: "web" })).resolves.toEqual({});
262
+ });
263
+ });
@@ -0,0 +1,311 @@
1
+ /**
2
+ * Sprite Services activities (#2711) — imperative create/get/list/start/stop/
3
+ * delete/logs over a Sprite's background services, the surface a box's door,
4
+ * hud and chud run as long-lived services through (`sprite-env services`
5
+ * inside the sprite, `/v1/sprites/{id}/services/...` outside). Shared by the
6
+ * Sprites API, wisp (arugula-salad/wisp) and spritzer 0.6.0's container mode
7
+ * (INTENTIUS/spritzer#22).
8
+ *
9
+ * This is the imperative twin of `sprite-config.ts`'s `spriteApplyServices` —
10
+ * that one reconciles a whole desired service *set* (additive create-or-update,
11
+ * optionally started, no delete). These activities are the single-service
12
+ * primitives underneath it: create-and-start one service and watch it come
13
+ * up, inspect or list what's running, stop/start one by name, delete it, and
14
+ * read its log tail. An Op that provisions a box one service at a time (or
15
+ * needs delete, which `spriteApplyServices` doesn't expose) uses these
16
+ * directly; `spriteApplyServices` stays the batch-reconcile convenience on
17
+ * top of the same wire shapes.
18
+ *
19
+ * `spriteServiceCreate` is a `PUT` that both defines the service and starts
20
+ * it, streaming NDJSON progress (`started`, `stdout`, `stderr`, `exit`,
21
+ * `complete`) for a bounded `?duration` window (default 500ms — enough to
22
+ * surface an immediate failure, e.g. a missing binary, without holding the
23
+ * step open for spritzer's own 5s default). The stream is read to completion
24
+ * either way; the definitive result comes from a follow-up `GET` of the
25
+ * service (`spriteServiceGet`'s own logic), which throws if its `state.status`
26
+ * is `"failed"`. `spriteServiceStart` follows the same create-then-GET shape
27
+ * (`PUT` already started it; `start` is for a stopped service).
28
+ */
29
+
30
+ import { resolveSpritesEndpoint, defaultSpritesHttp, type SpritesHttp } from "./sprites";
31
+
32
+ // ── URL building (pure) ─────────────────────────────────────────────────────
33
+
34
+ const servicesUrl = (base: string, id: string): string => `${base}/v1/sprites/${encodeURIComponent(id)}/services`;
35
+ const serviceUrl = (base: string, id: string, name: string): string =>
36
+ `${servicesUrl(base, id)}/${encodeURIComponent(name)}`;
37
+ const serviceLogsUrl = (base: string, id: string, name: string): string => `${serviceUrl(base, id, name)}/logs`;
38
+
39
+ function safeJson(text: string): unknown {
40
+ try {
41
+ return JSON.parse(text);
42
+ } catch {
43
+ return undefined;
44
+ }
45
+ }
46
+
47
+ // ── Shapes (wisp's guest API shapes, per spritzer's services.go) ───────────
48
+
49
+ /** A service's live half. */
50
+ export interface SpriteServiceState {
51
+ name: string;
52
+ /** `stopped` | `running` | `stopping` | `failed`. */
53
+ status: string;
54
+ pid?: number;
55
+ started_at?: string;
56
+ error?: string;
57
+ restart_count?: number;
58
+ }
59
+
60
+ /** The `GET`/`PUT` response shape: the definition with its state beside it. */
61
+ export interface SpriteService {
62
+ name: string;
63
+ cmd: string;
64
+ args?: string[];
65
+ env?: Record<string, string>;
66
+ dir?: string;
67
+ /** Names of services that must be running first. */
68
+ needs?: string[];
69
+ /** Route the sprite's public URL to this port. Only one service may set it. */
70
+ http_port?: number;
71
+ state: SpriteServiceState;
72
+ }
73
+
74
+ export interface SpriteServiceCreateArgs {
75
+ /** Target sprite id. */
76
+ id: string;
77
+ /** Service name — the key every later call addresses it by. */
78
+ name: string;
79
+ cmd: string;
80
+ args?: string[];
81
+ env?: Record<string, string>;
82
+ dir?: string;
83
+ needs?: string[];
84
+ http_port?: number;
85
+ /**
86
+ * How long spritzer keeps streaming NDJSON after the start action
87
+ * succeeds, ms (`?duration`). Default: `500`. A crash past this window is
88
+ * caught by a later `spriteServiceGet`/`spriteUrl`, not this call.
89
+ */
90
+ durationMs?: number;
91
+ endpoint?: string;
92
+ token?: string;
93
+ }
94
+
95
+ export interface SpriteServiceGetArgs {
96
+ id: string;
97
+ name: string;
98
+ endpoint?: string;
99
+ token?: string;
100
+ }
101
+
102
+ export interface SpriteServiceListArgs {
103
+ id: string;
104
+ endpoint?: string;
105
+ token?: string;
106
+ }
107
+
108
+ export interface SpriteServiceStartArgs {
109
+ id: string;
110
+ name: string;
111
+ /** Same as `spriteServiceCreate`'s `durationMs`. Default: `500`. */
112
+ durationMs?: number;
113
+ endpoint?: string;
114
+ token?: string;
115
+ }
116
+
117
+ export interface SpriteServiceStopArgs {
118
+ id: string;
119
+ name: string;
120
+ /** Grace period before SIGKILL, ms (`?timeout`). Default: spritzer's own (10s). */
121
+ timeoutMs?: number;
122
+ endpoint?: string;
123
+ token?: string;
124
+ }
125
+
126
+ export interface SpriteServiceDeleteArgs {
127
+ id: string;
128
+ name: string;
129
+ endpoint?: string;
130
+ token?: string;
131
+ }
132
+
133
+ export interface SpriteServiceLogsArgs {
134
+ id: string;
135
+ name: string;
136
+ /** Tail length. Default: spritzer's own (100). */
137
+ lines?: number;
138
+ endpoint?: string;
139
+ token?: string;
140
+ }
141
+
142
+ export interface SpriteServiceLogsResult {
143
+ /** The tail, one entry per log line, oldest first. */
144
+ lines: string[];
145
+ }
146
+
147
+ /** Build the `PUT` create-or-update body. Pure. */
148
+ export function spriteServiceCreateBody(args: SpriteServiceCreateArgs): Record<string, unknown> {
149
+ return {
150
+ cmd: args.cmd,
151
+ ...(args.args !== undefined ? { args: args.args } : {}),
152
+ ...(args.env !== undefined ? { env: args.env } : {}),
153
+ ...(args.dir !== undefined ? { dir: args.dir } : {}),
154
+ ...(args.needs !== undefined ? { needs: args.needs } : {}),
155
+ ...(args.http_port !== undefined ? { http_port: args.http_port } : {}),
156
+ };
157
+ }
158
+
159
+ /**
160
+ * Parse a service log NDJSON body (`{type, data, timestamp, ...}` per line,
161
+ * terminated by a `complete` event) into a plain line tail — the `data` text
162
+ * of every non-`complete` event, in stream order. Pure.
163
+ */
164
+ export function parseServiceLogNdjson(text: string): string[] {
165
+ const lines: string[] = [];
166
+ for (const raw of text.split("\n")) {
167
+ const t = raw.trim();
168
+ if (!t) continue;
169
+ const obj = safeJson(t) as { type?: string; data?: string } | undefined;
170
+ if (!obj || obj.type === "complete") continue;
171
+ if (typeof obj.data === "string") lines.push(obj.data);
172
+ }
173
+ return lines;
174
+ }
175
+
176
+ // ── Activities ───────────────────────────────────────────────────────────────
177
+
178
+ /**
179
+ * Create (or replace) and start a service. `PUT /v1/sprites/{id}/services/{name}`,
180
+ * streamed as NDJSON for `?duration` (mapped from `durationMs`). The stream is
181
+ * read to completion, then a `GET` fetches the definitive state; a `"failed"`
182
+ * status throws with the agent's own error text.
183
+ */
184
+ export async function spriteServiceCreate(
185
+ args: SpriteServiceCreateArgs,
186
+ signal?: AbortSignal,
187
+ http: SpritesHttp = defaultSpritesHttp(args.token),
188
+ ): Promise<SpriteService> {
189
+ const base = resolveSpritesEndpoint(args);
190
+ const durationMs = args.durationMs ?? 500;
191
+ const url = `${serviceUrl(base, args.id, args.name)}?duration=${durationMs}ms`;
192
+ const res = await http("PUT", url, spriteServiceCreateBody(args), undefined, signal);
193
+ if (res.status >= 300) {
194
+ throw new Error(`sprite ${args.id} service ${args.name} create failed (${res.status}): ${res.text}`);
195
+ }
196
+ const svc = await spriteServiceGet({ id: args.id, name: args.name, endpoint: args.endpoint, token: args.token }, signal, http);
197
+ if (svc.state.status === "failed") {
198
+ throw new Error(`sprite ${args.id} service ${args.name} failed to start: ${svc.state.error ?? "unknown error"}`);
199
+ }
200
+ console.log(`service: sprite/${args.id}/${args.name} ${svc.state.status} (${base})`);
201
+ return svc;
202
+ }
203
+
204
+ /** Get one service. `GET /v1/sprites/{id}/services/{name}`. */
205
+ export async function spriteServiceGet(
206
+ args: SpriteServiceGetArgs,
207
+ signal?: AbortSignal,
208
+ http: SpritesHttp = defaultSpritesHttp(args.token),
209
+ ): Promise<SpriteService> {
210
+ const base = resolveSpritesEndpoint(args);
211
+ const res = await http("GET", serviceUrl(base, args.id, args.name), undefined, undefined, signal);
212
+ if (res.status >= 300) {
213
+ throw new Error(`sprite ${args.id} service ${args.name} get failed (${res.status}): ${res.text}`);
214
+ }
215
+ return (safeJson(res.text) as SpriteService | undefined) ?? ({ name: args.name, cmd: "", state: { name: args.name, status: "stopped" } } as SpriteService);
216
+ }
217
+
218
+ /** List every service on the sprite. `GET /v1/sprites/{id}/services`. */
219
+ export async function spriteServiceList(
220
+ args: SpriteServiceListArgs,
221
+ signal?: AbortSignal,
222
+ http: SpritesHttp = defaultSpritesHttp(args.token),
223
+ ): Promise<SpriteService[]> {
224
+ const base = resolveSpritesEndpoint(args);
225
+ const res = await http("GET", servicesUrl(base, args.id), undefined, undefined, signal);
226
+ if (res.status >= 300) throw new Error(`sprite ${args.id} service list failed (${res.status}): ${res.text}`);
227
+ const parsed = safeJson(res.text);
228
+ return Array.isArray(parsed) ? (parsed as SpriteService[]) : [];
229
+ }
230
+
231
+ /**
232
+ * Start a stopped service. `POST /v1/sprites/{id}/services/{name}/start`,
233
+ * streamed as NDJSON for `?duration`, then a `GET` for the definitive state
234
+ * (same shape as `spriteServiceCreate`).
235
+ */
236
+ export async function spriteServiceStart(
237
+ args: SpriteServiceStartArgs,
238
+ signal?: AbortSignal,
239
+ http: SpritesHttp = defaultSpritesHttp(args.token),
240
+ ): Promise<SpriteService> {
241
+ const base = resolveSpritesEndpoint(args);
242
+ const durationMs = args.durationMs ?? 500;
243
+ const url = `${serviceUrl(base, args.id, args.name)}/start?duration=${durationMs}ms`;
244
+ const res = await http("POST", url, undefined, undefined, signal);
245
+ if (res.status >= 300) {
246
+ throw new Error(`sprite ${args.id} service ${args.name} start failed (${res.status}): ${res.text}`);
247
+ }
248
+ const svc = await spriteServiceGet({ id: args.id, name: args.name, endpoint: args.endpoint, token: args.token }, signal, http);
249
+ if (svc.state.status === "failed") {
250
+ throw new Error(`sprite ${args.id} service ${args.name} failed to start: ${svc.state.error ?? "unknown error"}`);
251
+ }
252
+ console.log(`service: sprite/${args.id}/${args.name} ${svc.state.status} (${base})`);
253
+ return svc;
254
+ }
255
+
256
+ /**
257
+ * Stop a running service (SIGTERM, then SIGKILL after the grace period).
258
+ * `POST /v1/sprites/{id}/services/{name}/stop`.
259
+ */
260
+ export async function spriteServiceStop(
261
+ args: SpriteServiceStopArgs,
262
+ signal?: AbortSignal,
263
+ http: SpritesHttp = defaultSpritesHttp(args.token),
264
+ ): Promise<SpriteService> {
265
+ const base = resolveSpritesEndpoint(args);
266
+ const qs = args.timeoutMs !== undefined ? `?timeout=${args.timeoutMs}ms` : "";
267
+ const url = `${serviceUrl(base, args.id, args.name)}/stop${qs}`;
268
+ const res = await http("POST", url, undefined, undefined, signal);
269
+ if (res.status >= 300) {
270
+ throw new Error(`sprite ${args.id} service ${args.name} stop failed (${res.status}): ${res.text}`);
271
+ }
272
+ const svc = await spriteServiceGet({ id: args.id, name: args.name, endpoint: args.endpoint, token: args.token }, signal, http);
273
+ console.log(`service: sprite/${args.id}/${args.name} ${svc.state.status} (${base})`);
274
+ return svc;
275
+ }
276
+
277
+ /**
278
+ * Delete a service, stopping it first (idempotent; a 404 means it is already
279
+ * gone). `DELETE /v1/sprites/{id}/services/{name}`.
280
+ */
281
+ export async function spriteServiceDelete(
282
+ args: SpriteServiceDeleteArgs,
283
+ signal?: AbortSignal,
284
+ http: SpritesHttp = defaultSpritesHttp(args.token),
285
+ ): Promise<Record<string, never>> {
286
+ const base = resolveSpritesEndpoint(args);
287
+ const res = await http("DELETE", serviceUrl(base, args.id, args.name), undefined, undefined, signal);
288
+ if (res.status >= 300 && res.status !== 404) {
289
+ throw new Error(`sprite ${args.id} service ${args.name} delete failed (${res.status}): ${res.text}`);
290
+ }
291
+ console.log(`deleted: sprite/${args.id}/${args.name}`);
292
+ return {};
293
+ }
294
+
295
+ /**
296
+ * Read a service's log tail. `GET /v1/sprites/{id}/services/{name}/logs`,
297
+ * parsed from NDJSON to a plain line array (`parseServiceLogNdjson`).
298
+ */
299
+ export async function spriteServiceLogs(
300
+ args: SpriteServiceLogsArgs,
301
+ signal?: AbortSignal,
302
+ http: SpritesHttp = defaultSpritesHttp(args.token),
303
+ ): Promise<SpriteServiceLogsResult> {
304
+ const base = resolveSpritesEndpoint(args);
305
+ const qs = args.lines !== undefined ? `?lines=${args.lines}` : "";
306
+ const res = await http("GET", `${serviceLogsUrl(base, args.id, args.name)}${qs}`, undefined, undefined, signal);
307
+ if (res.status >= 300) {
308
+ throw new Error(`sprite ${args.id} service ${args.name} logs failed (${res.status}): ${res.text}`);
309
+ }
310
+ return { lines: parseServiceLogNdjson(res.text) };
311
+ }