@intentius/chant-lexicon-fly 0.124.30 → 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,224 @@
1
+ /**
2
+ * A sprite's services as resources a `ConvergeOp` observes and converges
3
+ * (#2778).
4
+ *
5
+ * A box runs its app, hud, door and site as services a supervisor keeps
6
+ * running: sprite-env on a sprite, and a stand-in with the same command line
7
+ * elsewhere. No lexicon declares them, so a ConvergeOp can't observe them
8
+ * through an environment. It can through an observer step:
9
+ *
10
+ * - `spriteServicesObserve` reads the supervisor's list and probes each
11
+ * declared service's health URL, and returns one verdict per service,
12
+ * `in-sync`, `drifted` or `unknown`: the `ResourceObservation` a
13
+ * `ConvergeOp({ observe })` evaluates its rules against, once per service.
14
+ * - `spriteServiceRestart` restarts one service through the supervisor and
15
+ * waits for its health URL. It is the step of the Op a rule dispatches; the
16
+ * service is the one the rule fired for, which the dispatched run reads
17
+ * from `CHANT_CONVERGE_RESOURCE`.
18
+ *
19
+ * Both work in one of two ways. With no sprite `id`, they run inside the
20
+ * sprite and call `sprite-env services` (found on PATH or in /.sprite/bin,
21
+ * or at `spriteEnv`). With an `id`, they go through the Sprites API
22
+ * (`spriteServiceList`, `spriteServiceStop` and `spriteServiceStart`).
23
+ *
24
+ * The declared services come from `services`, from a `servicesFile`, or,
25
+ * with `box: true`, from the `services` of the box block of the workspace
26
+ * member whose directory holds the working directory (#2880), the list
27
+ * `chant workspace status --json` prints as `members[].box.services`.
28
+ */
29
+ import { execFile } from "node:child_process";
30
+ import { existsSync, readFileSync } from "node:fs";
31
+ import { join, resolve } from "node:path";
32
+ import { promisify } from "node:util";
33
+ import { boxServices } from "./box-services.js";
34
+ import { spriteServiceList, spriteServiceStart, spriteServiceStop } from "./sprite-services.js";
35
+ import { defaultSpritesHttp } from "./sprites.js";
36
+ import { logProgress } from "./progress.js";
37
+ const execFileAsync = promisify(execFile);
38
+ /**
39
+ * The variable a ConvergeOp's dispatched run reads its resource from
40
+ * (core's `CONVERGE_RESOURCE_ENV`). Spelled out here so the lexicon runs
41
+ * against a core that predates the export.
42
+ */
43
+ const CONVERGE_RESOURCE_ENV = "CHANT_CONVERGE_RESOURCE";
44
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
45
+ /** The services declared inline and in `servicesFile`, inline first. */
46
+ export function declaredServices(args, cwd = process.cwd()) {
47
+ const out = [...(args.services ?? [])];
48
+ if (args.servicesFile) {
49
+ const parsed = JSON.parse(readFileSync(resolve(cwd, args.servicesFile), "utf8"));
50
+ if (!Array.isArray(parsed.services))
51
+ throw new Error(`${args.servicesFile} has no "services" array`);
52
+ for (const s of parsed.services) {
53
+ if (!s || typeof s.name !== "string")
54
+ throw new Error(`${args.servicesFile}: a service has no name`);
55
+ out.push(s);
56
+ }
57
+ }
58
+ return out;
59
+ }
60
+ /**
61
+ * The declared services: with `box: true`, the box block's (#2880), and
62
+ * otherwise those of {@link declaredServices}. `box` in place of the other
63
+ * two, never beside them.
64
+ */
65
+ export async function resolveDeclaredServices(args, cwd = process.cwd()) {
66
+ if (!args.box)
67
+ return declaredServices(args, cwd);
68
+ if (args.services || args.servicesFile)
69
+ throw new Error("box: true reads the services from the box block, so it takes no services or servicesFile beside it");
70
+ return (await boxServices(cwd)).map((s) => ({ name: s.name, ...(s.health ? { health: s.health } : {}), ...(s.optional ? { optional: true } : {}) }));
71
+ }
72
+ /** The sprite-env binary, or null when there is none. */
73
+ export function findSpriteEnv(explicit, env = process.env) {
74
+ if (explicit)
75
+ return explicit;
76
+ for (const dir of (env.PATH ?? "").split(":").concat("/.sprite/bin")) {
77
+ if (dir && existsSync(join(dir, "sprite-env")))
78
+ return join(dir, "sprite-env");
79
+ }
80
+ return null;
81
+ }
82
+ /**
83
+ * `sprite-env services list` as name to state. A sprite's sprite-env prints
84
+ * JSON (an array of services with `state.status`); a stand-in may print a
85
+ * tab-separated table whose second column is the state. Pure.
86
+ */
87
+ export function parseServicesList(text) {
88
+ const out = new Map();
89
+ const trimmed = text.trim();
90
+ if (!trimmed)
91
+ return out;
92
+ if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
93
+ const parsed = JSON.parse(trimmed);
94
+ const list = Array.isArray(parsed) ? parsed : (parsed.services ?? []);
95
+ for (const s of list) {
96
+ if (typeof s?.name !== "string")
97
+ continue;
98
+ const status = typeof s.state?.status === "string" ? s.state.status : typeof s.status === "string" ? s.status : null;
99
+ out.set(s.name, status);
100
+ }
101
+ return out;
102
+ }
103
+ for (const line of trimmed.split("\n")) {
104
+ const cols = line.split("\t");
105
+ if (!cols[0])
106
+ continue;
107
+ out.set(cols[0].trim(), cols[1]?.trim() || null);
108
+ }
109
+ return out;
110
+ }
111
+ async function listServices(via, signal, http) {
112
+ if (via.id) {
113
+ const list = await spriteServiceList({ id: via.id, endpoint: via.endpoint, token: via.token }, signal, http ?? defaultSpritesHttp(via.token));
114
+ return new Map(list.map((s) => [s.name, s.state?.status ?? null]));
115
+ }
116
+ const bin = findSpriteEnv(via.spriteEnv);
117
+ if (!bin)
118
+ throw new Error("no sprite-env on PATH or in /.sprite/bin, and no sprite id to reach the Sprites API with");
119
+ const { stdout } = await execFileAsync(bin, ["services", "list"], { signal, timeout: 60000, maxBuffer: 16 * 1024 * 1024 });
120
+ return parseServicesList(stdout);
121
+ }
122
+ /** One GET of a health URL: true on 200. */
123
+ export async function probeHealth(url, signal) {
124
+ try {
125
+ const timeout = AbortSignal.timeout(3000);
126
+ const res = await fetch(url, { signal: signal ? AbortSignal.any([signal, timeout]) : timeout, redirect: "manual" });
127
+ await res.arrayBuffer().catch(() => undefined);
128
+ return res.status === 200;
129
+ }
130
+ catch {
131
+ return false;
132
+ }
133
+ }
134
+ const DOWN = new Set(["stopped", "failed", "stopping"]);
135
+ /**
136
+ * Observe the declared services: one verdict each, for a ConvergeOp's rules.
137
+ * A service the supervisor doesn't have, or has stopped, is drifted; one
138
+ * with a health URL is in sync when the URL answers 200 within `probes`
139
+ * tries and drifted when it doesn't; one without is in sync while it runs.
140
+ * When the supervisor can't be read, every service is unknown, and unknown
141
+ * never remediates.
142
+ */
143
+ export async function spriteServicesObserve(args, signal, http) {
144
+ const declared = await resolveDeclaredServices(args);
145
+ const probes = Math.max(1, args.probes ?? 3);
146
+ const interval = args.probeIntervalMs ?? 1000;
147
+ let states;
148
+ try {
149
+ states = await listServices(args, signal, http);
150
+ }
151
+ catch (err) {
152
+ const why = err instanceof Error ? err.message.split("\n")[0] : String(err);
153
+ return {
154
+ resources: declared.filter((s) => !s.optional).map((s) => ({ name: s.name, status: "unknown", detail: `the supervisor could not be read: ${why}` })),
155
+ skipped: declared.filter((s) => s.optional).map((s) => s.name),
156
+ };
157
+ }
158
+ const resources = [];
159
+ const skipped = [];
160
+ for (const service of declared) {
161
+ if (!states.has(service.name)) {
162
+ if (service.optional)
163
+ skipped.push(service.name);
164
+ else
165
+ resources.push({ name: service.name, status: "drifted", detail: "declared, but the supervisor has no such service" });
166
+ continue;
167
+ }
168
+ const state = states.get(service.name) ?? null;
169
+ if (state !== null && DOWN.has(state)) {
170
+ resources.push({ name: service.name, status: "drifted", detail: state });
171
+ continue;
172
+ }
173
+ if (service.health) {
174
+ let ok = false;
175
+ for (let i = 0; i < probes && !ok; i++) {
176
+ if (i > 0)
177
+ await sleep(interval);
178
+ ok = await probeHealth(service.health, signal);
179
+ }
180
+ resources.push(ok
181
+ ? { name: service.name, status: "in-sync", detail: `${state ?? "listed"}, ${service.health} answers 200` }
182
+ : { name: service.name, status: "drifted", detail: `${state ?? "listed"}, ${service.health} does not answer 200 after ${probes} tries` });
183
+ continue;
184
+ }
185
+ resources.push(state === "running"
186
+ ? { name: service.name, status: "in-sync", detail: "running" }
187
+ : { name: service.name, status: "unknown", detail: `${state ?? "no state"}, and no health URL to probe` });
188
+ }
189
+ return { resources, skipped };
190
+ }
191
+ /**
192
+ * Restart one service through its supervisor and wait for its health URL.
193
+ * The service is `name`, or the resource the ConvergeOp's rule fired for.
194
+ * Throws when neither names one, when the supervisor refuses, or when the
195
+ * service does not answer its health URL within `waitMs`.
196
+ */
197
+ export async function spriteServiceRestart(args, signal, http) {
198
+ const name = args.name ?? process.env[CONVERGE_RESOURCE_ENV];
199
+ if (!name) {
200
+ throw new Error(`spriteServiceRestart: no service named: pass \`name\`, or run the Op from a ConvergeOp rule, which sets ${CONVERGE_RESOURCE_ENV}`);
201
+ }
202
+ const health = args.health ?? (args.services || args.servicesFile || args.box ? (await resolveDeclaredServices(args)).find((s) => s.name === name)?.health : undefined);
203
+ if (args.id) {
204
+ const client = http ?? defaultSpritesHttp(args.token);
205
+ await spriteServiceStop({ id: args.id, name, endpoint: args.endpoint, token: args.token }, signal, client);
206
+ await spriteServiceStart({ id: args.id, name, endpoint: args.endpoint, token: args.token }, signal, client);
207
+ }
208
+ else {
209
+ const bin = findSpriteEnv(args.spriteEnv);
210
+ if (!bin)
211
+ throw new Error("no sprite-env on PATH or in /.sprite/bin, and no sprite id to reach the Sprites API with");
212
+ await execFileAsync(bin, ["services", "restart", name], { signal, timeout: 120000 });
213
+ }
214
+ logProgress(`restarted: ${name}`);
215
+ if (!health)
216
+ return { name, healthy: null };
217
+ const until = Date.now() + (args.waitMs ?? 60000);
218
+ do {
219
+ if (await probeHealth(health, signal))
220
+ return { name, healthy: true };
221
+ await sleep(500);
222
+ } while (Date.now() < until && !signal?.aborted);
223
+ throw new Error(`${name} was restarted and does not answer ${health}`);
224
+ }
@@ -0,0 +1,174 @@
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
+ * and hud 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
+ import { resolveSpritesEndpoint, defaultSpritesHttp } from "./sprites.js";
30
+ import { logProgress } from "./progress.js";
31
+ // ── URL building (pure) ─────────────────────────────────────────────────────
32
+ const servicesUrl = (base, id) => `${base}/v1/sprites/${encodeURIComponent(id)}/services`;
33
+ const serviceUrl = (base, id, name) => `${servicesUrl(base, id)}/${encodeURIComponent(name)}`;
34
+ const serviceLogsUrl = (base, id, name) => `${serviceUrl(base, id, name)}/logs`;
35
+ function safeJson(text) {
36
+ try {
37
+ return JSON.parse(text);
38
+ }
39
+ catch {
40
+ return undefined;
41
+ }
42
+ }
43
+ /** Build the `PUT` create-or-update body. Pure. */
44
+ export function spriteServiceCreateBody(args) {
45
+ return {
46
+ cmd: args.cmd,
47
+ ...(args.args !== undefined ? { args: args.args } : {}),
48
+ ...(args.env !== undefined ? { env: args.env } : {}),
49
+ ...(args.dir !== undefined ? { dir: args.dir } : {}),
50
+ ...(args.needs !== undefined ? { needs: args.needs } : {}),
51
+ ...(args.http_port !== undefined ? { http_port: args.http_port } : {}),
52
+ };
53
+ }
54
+ /**
55
+ * Parse a service log NDJSON body (`{type, data, timestamp, ...}` per line,
56
+ * terminated by a `complete` event) into a plain line tail — the `data` text
57
+ * of every non-`complete` event, in stream order. Pure.
58
+ */
59
+ export function parseServiceLogNdjson(text) {
60
+ const lines = [];
61
+ for (const raw of text.split("\n")) {
62
+ const t = raw.trim();
63
+ if (!t)
64
+ continue;
65
+ const obj = safeJson(t);
66
+ if (!obj || obj.type === "complete")
67
+ continue;
68
+ if (typeof obj.data === "string")
69
+ lines.push(obj.data);
70
+ }
71
+ return lines;
72
+ }
73
+ // ── Activities ───────────────────────────────────────────────────────────────
74
+ /**
75
+ * Create (or replace) and start a service. `PUT /v1/sprites/{id}/services/{name}`,
76
+ * streamed as NDJSON for `?duration` (mapped from `durationMs`). The stream is
77
+ * read to completion, then a `GET` fetches the definitive state; a `"failed"`
78
+ * status throws with the agent's own error text.
79
+ */
80
+ export async function spriteServiceCreate(args, signal, http = defaultSpritesHttp(args.token)) {
81
+ const base = resolveSpritesEndpoint(args);
82
+ const durationMs = args.durationMs ?? 500;
83
+ const url = `${serviceUrl(base, args.id, args.name)}?duration=${durationMs}ms`;
84
+ const res = await http("PUT", url, spriteServiceCreateBody(args), undefined, signal);
85
+ if (res.status >= 300) {
86
+ throw new Error(`sprite ${args.id} service ${args.name} create failed (${res.status}): ${res.text}`);
87
+ }
88
+ const svc = await spriteServiceGet({ id: args.id, name: args.name, endpoint: args.endpoint, token: args.token }, signal, http);
89
+ if (svc.state.status === "failed") {
90
+ throw new Error(`sprite ${args.id} service ${args.name} failed to start: ${svc.state.error ?? "unknown error"}`);
91
+ }
92
+ logProgress(`service: sprite/${args.id}/${args.name} ${svc.state.status} (${base})`);
93
+ return svc;
94
+ }
95
+ /** Get one service. `GET /v1/sprites/{id}/services/{name}`. */
96
+ export async function spriteServiceGet(args, signal, http = defaultSpritesHttp(args.token)) {
97
+ const base = resolveSpritesEndpoint(args);
98
+ const res = await http("GET", serviceUrl(base, args.id, args.name), undefined, undefined, signal);
99
+ if (res.status >= 300) {
100
+ throw new Error(`sprite ${args.id} service ${args.name} get failed (${res.status}): ${res.text}`);
101
+ }
102
+ return safeJson(res.text) ?? { name: args.name, cmd: "", state: { name: args.name, status: "stopped" } };
103
+ }
104
+ /** List every service on the sprite. `GET /v1/sprites/{id}/services`. */
105
+ export async function spriteServiceList(args, signal, http = defaultSpritesHttp(args.token)) {
106
+ const base = resolveSpritesEndpoint(args);
107
+ const res = await http("GET", servicesUrl(base, args.id), undefined, undefined, signal);
108
+ if (res.status >= 300)
109
+ throw new Error(`sprite ${args.id} service list failed (${res.status}): ${res.text}`);
110
+ const parsed = safeJson(res.text);
111
+ return Array.isArray(parsed) ? parsed : [];
112
+ }
113
+ /**
114
+ * Start a stopped service. `POST /v1/sprites/{id}/services/{name}/start`,
115
+ * streamed as NDJSON for `?duration`, then a `GET` for the definitive state
116
+ * (same shape as `spriteServiceCreate`).
117
+ */
118
+ export async function spriteServiceStart(args, signal, http = defaultSpritesHttp(args.token)) {
119
+ const base = resolveSpritesEndpoint(args);
120
+ const durationMs = args.durationMs ?? 500;
121
+ const url = `${serviceUrl(base, args.id, args.name)}/start?duration=${durationMs}ms`;
122
+ const res = await http("POST", url, undefined, undefined, signal);
123
+ if (res.status >= 300) {
124
+ throw new Error(`sprite ${args.id} service ${args.name} start failed (${res.status}): ${res.text}`);
125
+ }
126
+ const svc = await spriteServiceGet({ id: args.id, name: args.name, endpoint: args.endpoint, token: args.token }, signal, http);
127
+ if (svc.state.status === "failed") {
128
+ throw new Error(`sprite ${args.id} service ${args.name} failed to start: ${svc.state.error ?? "unknown error"}`);
129
+ }
130
+ logProgress(`service: sprite/${args.id}/${args.name} ${svc.state.status} (${base})`);
131
+ return svc;
132
+ }
133
+ /**
134
+ * Stop a running service (SIGTERM, then SIGKILL after the grace period).
135
+ * `POST /v1/sprites/{id}/services/{name}/stop`.
136
+ */
137
+ export async function spriteServiceStop(args, signal, http = defaultSpritesHttp(args.token)) {
138
+ const base = resolveSpritesEndpoint(args);
139
+ const qs = args.timeoutMs !== undefined ? `?timeout=${args.timeoutMs}ms` : "";
140
+ const url = `${serviceUrl(base, args.id, args.name)}/stop${qs}`;
141
+ const res = await http("POST", url, undefined, undefined, signal);
142
+ if (res.status >= 300) {
143
+ throw new Error(`sprite ${args.id} service ${args.name} stop failed (${res.status}): ${res.text}`);
144
+ }
145
+ const svc = await spriteServiceGet({ id: args.id, name: args.name, endpoint: args.endpoint, token: args.token }, signal, http);
146
+ logProgress(`service: sprite/${args.id}/${args.name} ${svc.state.status} (${base})`);
147
+ return svc;
148
+ }
149
+ /**
150
+ * Delete a service, stopping it first (idempotent; a 404 means it is already
151
+ * gone). `DELETE /v1/sprites/{id}/services/{name}`.
152
+ */
153
+ export async function spriteServiceDelete(args, signal, http = defaultSpritesHttp(args.token)) {
154
+ const base = resolveSpritesEndpoint(args);
155
+ const res = await http("DELETE", serviceUrl(base, args.id, args.name), undefined, undefined, signal);
156
+ if (res.status >= 300 && res.status !== 404) {
157
+ throw new Error(`sprite ${args.id} service ${args.name} delete failed (${res.status}): ${res.text}`);
158
+ }
159
+ logProgress(`deleted: sprite/${args.id}/${args.name}`);
160
+ return {};
161
+ }
162
+ /**
163
+ * Read a service's log tail. `GET /v1/sprites/{id}/services/{name}/logs`,
164
+ * parsed from NDJSON to a plain line array (`parseServiceLogNdjson`).
165
+ */
166
+ export async function spriteServiceLogs(args, signal, http = defaultSpritesHttp(args.token)) {
167
+ const base = resolveSpritesEndpoint(args);
168
+ const qs = args.lines !== undefined ? `?lines=${args.lines}` : "";
169
+ const res = await http("GET", `${serviceLogsUrl(base, args.id, args.name)}${qs}`, undefined, undefined, signal);
170
+ if (res.status >= 300) {
171
+ throw new Error(`sprite ${args.id} service ${args.name} logs failed (${res.status}): ${res.text}`);
172
+ }
173
+ return { lines: parseServiceLogNdjson(res.text) };
174
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Sprite keep-alive Tasks activities (#847) — a hold that prevents a Sprite from
3
+ * pausing while a session runs. While at least one task exists the Sprite stays
4
+ * active; the hold is refreshed on an interval and released on exit.
5
+ *
6
+ * Per the Sprites docs a task carries an `expire` (seconds or a duration string
7
+ * like `"5m"`/`"1h"`) with a **1-hour max per task**, so a session longer than
8
+ * that must refresh. The recommended shape is a short expiry refreshed on a
9
+ * shorter interval — 5-minute expiry / 60-second refresh — released on exit.
10
+ *
11
+ * These are the three primitives, not a magic looping hold: a phased Op creates
12
+ * the task around a session (`spriteTaskCreate`) and releases it after (in the
13
+ * happy path and in `onFailure`). A worker whose session can outlast the 1-hour
14
+ * cap wraps its own run in a `spriteTaskRefresh` loop (that ambient loop lives in
15
+ * the long-running caller, not a single serializable activity — a phased Op has
16
+ * no single step to hang it on). `spriteTaskRelease` is idempotent so a crash
17
+ * still frees the Sprite (and the task auto-expires if the release never lands).
18
+ *
19
+ * The task REST path is provisional (S6, #766); it mirrors the other endpoints'
20
+ * `/v1/sprites/{id}/...` shape. URL building is a pure helper so the path can
21
+ * move without touching callers.
22
+ */
23
+ import { resolveSpritesEndpoint, defaultSpritesHttp } from "./sprites.js";
24
+ /** The task REST base for a sprite. Pure. */
25
+ export function spriteTasksUrl(base, id) {
26
+ return `${base}/v1/sprites/${encodeURIComponent(id)}/tasks`;
27
+ }
28
+ export function spriteTaskUrl(base, id, name) {
29
+ return `${spriteTasksUrl(base, id)}/${encodeURIComponent(name)}`;
30
+ }
31
+ /** Create a keep-alive task. `POST /v1/sprites/{id}/tasks`. */
32
+ export async function spriteTaskCreate(args, signal, http = defaultSpritesHttp(args.token)) {
33
+ const base = resolveSpritesEndpoint(args);
34
+ const body = { name: args.name, ...(args.expire !== undefined ? { expire: args.expire } : {}) };
35
+ const res = await http("POST", spriteTasksUrl(base, args.id), body, undefined, signal);
36
+ if (res.status >= 300)
37
+ throw new Error(`sprite ${args.id} task create failed (${res.status}): ${res.text}`);
38
+ return { name: args.name };
39
+ }
40
+ /** Refresh a task's expiry. `PUT /v1/sprites/{id}/tasks/{name}`. */
41
+ export async function spriteTaskRefresh(args, signal, http = defaultSpritesHttp(args.token)) {
42
+ const base = resolveSpritesEndpoint(args);
43
+ const body = args.expire !== undefined ? { expire: args.expire } : undefined;
44
+ const res = await http("PUT", spriteTaskUrl(base, args.id, args.name), body, undefined, signal);
45
+ if (res.status >= 300)
46
+ throw new Error(`sprite ${args.id} task refresh failed (${res.status}): ${res.text}`);
47
+ return { name: args.name };
48
+ }
49
+ /** Release a task (idempotent; a 404 means already gone). `DELETE /v1/sprites/{id}/tasks/{name}`. */
50
+ export async function spriteTaskRelease(args, signal, http = defaultSpritesHttp(args.token)) {
51
+ const base = resolveSpritesEndpoint(args);
52
+ const res = await http("DELETE", spriteTaskUrl(base, args.id, args.name), undefined, undefined, signal);
53
+ if (res.status >= 300 && res.status !== 404) {
54
+ throw new Error(`sprite ${args.id} task release failed (${res.status}): ${res.text}`);
55
+ }
56
+ return {};
57
+ }
@@ -0,0 +1,87 @@
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 (lifecycle, filesystem, config, tasks)
9
+ * depend on, maintained by 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
+ * #2711 adds the imperative Services activities (`./sprite-services.ts`).
24
+ * Several of them share a physical endpoint with `spriteApplyServices`'s own
25
+ * calls (one `PUT`/one `GET .../services`/one `.../start` — the reconcile
26
+ * activity and the single-service primitives are two callers of the same
27
+ * REST surface), so one row lists every activity that calls it, comma
28
+ * separated, rather than one row per activity — `contractKeys()` stays one
29
+ * key per physical endpoint. `spriteServiceDelete` and `spriteServiceLogs`
30
+ * are deliberately absent: spritzer's interpreter mode (the coverage test's
31
+ * oracle) doesn't implement them, only its container-mode agent does, which
32
+ * has no per-verb entry in `/_spritzer/health` (real.go reports the whole
33
+ * `/v1/sprites/{id}/services/...` proxy as one wildcard line — nothing to
34
+ * normalize-compare against a specific verb). They're exercised instead by
35
+ * `sprite-services.docker.integration.e2e.test.ts` against real container-mode
36
+ * spritzer. `spriteUrl` is absent for the same reason, plus its target
37
+ * (`/s/{name}/...`) isn't under `/v1/sprites` at all.
38
+ */
39
+ /**
40
+ * The Sprites endpoints the fly sprite activities depend on, across the lifecycle
41
+ * (./sprites.ts), filesystem (./sprite-fs.ts), config reconcile
42
+ * (./sprite-config.ts), and keep-alive tasks (./sprite-tasks.ts) modules. Keep in
43
+ * sync with the activity implementations — the unit test asserts every sprite
44
+ * activity is represented, and the coverage test asserts the pinned spritzer
45
+ * serves each one.
46
+ */
47
+ export const SPRITES_CONTRACT = [
48
+ // Lifecycle (./sprites.ts)
49
+ { method: "POST", path: "/v1/sprites", activity: "spriteCreate" },
50
+ { method: "WS", path: "/v1/sprites/{id}/exec", activity: "spriteExec" },
51
+ { method: "POST", path: "/v1/sprites/{id}/checkpoint", activity: "spriteCheckpoint" },
52
+ { method: "GET", path: "/v1/sprites/{id}/checkpoints", activity: "listCheckpoints" },
53
+ { method: "POST", path: "/v1/sprites/{id}/checkpoints/{cp}/restore", activity: "spriteRestore" },
54
+ { method: "DELETE", path: "/v1/sprites/{id}", activity: "spriteDestroy, spriteDelete" },
55
+ // Filesystem (./sprite-fs.ts)
56
+ { method: "PUT", path: "/v1/sprites/{id}/fs/write", activity: "spriteWriteFile" },
57
+ { method: "GET", path: "/v1/sprites/{id}/fs/read", activity: "spriteReadFile" },
58
+ { method: "GET", path: "/v1/sprites/{id}/fs/list", activity: "spriteListDir" },
59
+ { method: "DELETE", path: "/v1/sprites/{id}/fs/delete", activity: "spriteRemove" },
60
+ // Network policy (./sprite-config.ts)
61
+ { method: "GET", path: "/v1/sprites/{id}/policy/network", activity: "spriteApplyNetworkPolicy" },
62
+ { method: "POST", path: "/v1/sprites/{id}/policy/network", activity: "spriteApplyNetworkPolicy" },
63
+ // Services (./sprite-config.ts, ./sprite-services.ts)
64
+ { method: "GET", path: "/v1/sprites/{id}/services", activity: "spriteApplyServices, spriteServiceList" },
65
+ { method: "GET", path: "/v1/sprites/{id}/services/{svc}", activity: "spriteServiceGet" },
66
+ { method: "PUT", path: "/v1/sprites/{id}/services/{svc}", activity: "spriteApplyServices, spriteServiceCreate" },
67
+ { method: "POST", path: "/v1/sprites/{id}/services/{svc}/start", activity: "spriteApplyServices, spriteServiceStart" },
68
+ { method: "POST", path: "/v1/sprites/{id}/services/{svc}/stop", activity: "spriteServiceStop" },
69
+ // Keep-alive tasks (./sprite-tasks.ts)
70
+ { method: "POST", path: "/v1/sprites/{id}/tasks", activity: "spriteTaskCreate" },
71
+ { method: "PUT", path: "/v1/sprites/{id}/tasks/{name}", activity: "spriteTaskRefresh" },
72
+ { method: "DELETE", path: "/v1/sprites/{id}/tasks/{name}", activity: "spriteTaskRelease" },
73
+ ];
74
+ /**
75
+ * Normalize a `METHOD path` key for comparison across sources: collapse every
76
+ * `{param}` to `{}` (so `{cp}` and `{cid}` match) and treat the WebSocket exec
77
+ * channel as a `GET` (spritzer registers it as `GET .../exec`).
78
+ */
79
+ export function normalizeEndpoint(method, path) {
80
+ const m = method === "WS" ? "GET" : method.toUpperCase();
81
+ const p = path.replace(/\{[^}]+\}/g, "{}");
82
+ return `${m} ${p}`;
83
+ }
84
+ /** The contract as a set of normalized `METHOD path` keys. */
85
+ export function contractKeys() {
86
+ return new Set(SPRITES_CONTRACT.map((e) => normalizeEndpoint(e.method, e.path)));
87
+ }
@@ -0,0 +1,72 @@
1
+ import { emulatorLifecycle } from "@intentius/chant/op";
2
+ import { SPRITZER_IMAGE, SPRITZER_CONTAINER_IMAGE } from "./emulator-images.js";
3
+ // spritzer is a stateful fake of the Fly Sprites API — a plain 200 on its health
4
+ // endpoint means ready. The local target for the sprite activities; point them
5
+ // there with SPRITES_BASE_URL. Shared lifecycle: emulatorLifecycle (the same
6
+ // helper that boots mudflaps for fly).
7
+ export const SPRITZER_SPEC = {
8
+ name: "chant-spritzer",
9
+ image: SPRITZER_IMAGE,
10
+ containerPort: 4290,
11
+ healthPath: "/_spritzer/health",
12
+ upstream: { repo: "intentius/spritzer" },
13
+ };
14
+ /** The Sprites half of fly's emulator capability (#1345). */
15
+ export const SPRITZER_EMULATOR = {
16
+ spec: SPRITZER_SPEC,
17
+ env: (endpoint) => ({ SPRITES_BASE_URL: endpoint }),
18
+ };
19
+ const spritzer = emulatorLifecycle(SPRITZER_SPEC);
20
+ export const spritesExistsCommand = spritzer.existsCommand;
21
+ export const spritesRmCommand = spritzer.rmCommand;
22
+ export const spritesHealthUrl = spritzer.healthUrl;
23
+ /** The Sprites endpoint URL (what the sprite activities' `SPRITES_BASE_URL`/`endpoint` points at). */
24
+ export const spritesEndpoint = spritzer.endpoint;
25
+ export const spritesRunCommand = (args = {}) => spritzer.runCommand(args);
26
+ /** Boot a local spritzer (Fly Sprites API emulator) in Docker and return its endpoint. */
27
+ export const spritesUp = (args = {}, signal) => spritzer.up(args, signal);
28
+ /** Stop and remove the local spritzer container (no-op if already gone). */
29
+ export const spritesDown = (args = {}, signal) => spritzer.down(args, signal);
30
+ // ── Container exec mode (#2711, INTENTIUS/spritzer#22) ─────────────────────────
31
+ //
32
+ // A second, distinct spritzer lifecycle: `SPRITZER_EXEC=container` with the
33
+ // Docker runtime, so each sprite it makes is a real container — the mode the
34
+ // Services CRUD activities (`./sprite-services.ts`) and `spriteUrl` need (real
35
+ // processes, a real sprite URL), and that `SPRITZER_SPEC` above (interpreter
36
+ // mode, the default everything else tests against) does not run. The Docker
37
+ // socket is mounted read-write so spritzer can create/exec/delete sibling
38
+ // sprite containers through it (docker-outside-of-docker); no port publishing
39
+ // is needed for the sprites themselves — spritzer reaches them by exec, and
40
+ // proxies their URL (`/s/{name}/...`) through the one published port.
41
+ /** Container exec mode spritzer, pinned to {@link SPRITZER_CONTAINER_IMAGE} (0.6.0, the first release with this mode). */
42
+ export const SPRITZER_CONTAINER_SPEC = {
43
+ name: "chant-spritzer-container",
44
+ image: SPRITZER_CONTAINER_IMAGE,
45
+ containerPort: 4290,
46
+ healthPath: "/_spritzer/health",
47
+ runArgs: [
48
+ // The image runs as `nonroot` by default, which cannot open a
49
+ // group/other-unwritable host socket — root inside the container is
50
+ // still an unprivileged Linux user account relative to the host/Docker
51
+ // Desktop VM, no different from any other `docker run` that mounts the
52
+ // socket.
53
+ "--user",
54
+ "root",
55
+ "-e",
56
+ "SPRITZER_EXEC=container",
57
+ "-e",
58
+ "SPRITZER_RUNTIME=docker",
59
+ "-v",
60
+ "/var/run/docker.sock:/var/run/docker.sock",
61
+ ],
62
+ upstream: { repo: "intentius/spritzer" },
63
+ };
64
+ const spritzerContainer = emulatorLifecycle(SPRITZER_CONTAINER_SPEC);
65
+ export const spritesContainerRunCommand = (args = {}) => spritzerContainer.runCommand(args);
66
+ export const spritesContainerExistsCommand = spritzerContainer.existsCommand;
67
+ export const spritesContainerRmCommand = spritzerContainer.rmCommand;
68
+ export const spritesContainerHealthUrl = spritzerContainer.healthUrl;
69
+ /** Boot a local spritzer in container exec mode (Docker runtime) and return its endpoint. */
70
+ export const spritesContainerUp = (args = {}, signal) => spritzerContainer.up(args, signal);
71
+ /** Stop and remove the local container-exec-mode spritzer (no-op if already gone). Sprite containers it made are the caller's to clean up (see the integration test). */
72
+ export const spritesContainerDown = (args = {}, signal) => spritzerContainer.down(args, signal);