@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.
- package/dist/codegen/docs-cli.js +6 -0
- package/dist/codegen/docs.js +143 -0
- package/dist/codegen/generate-cli.js +26 -0
- package/dist/codegen/generate-lexicon.js +38 -0
- package/dist/codegen/generate-typescript.js +45 -0
- package/dist/codegen/generate.js +82 -0
- package/dist/codegen/naming.js +33 -0
- package/dist/codegen/package.js +35 -0
- package/dist/components/capability-plugin.js +26 -0
- package/dist/components/fly-release.js +335 -0
- package/dist/components/index.js +10 -0
- package/dist/components/run-agent.js +133 -0
- package/dist/composites/catalog.js +176 -0
- package/dist/composites/fly-deploy.js +91 -0
- package/dist/composites/fly-otel-collector.js +66 -0
- package/dist/composites/fly-site.js +80 -0
- package/dist/coverage.js +29 -0
- package/dist/describe-resources.js +443 -0
- package/dist/detect.js +63 -0
- package/dist/export-resources.js +81 -0
- package/dist/generated/index.js +44 -0
- package/dist/generated/runtime.js +4 -0
- package/dist/import/generator.js +208 -0
- package/dist/import/live-export.js +81 -0
- package/dist/import/parser.js +157 -0
- package/dist/index.js +40 -0
- package/dist/integrity.json +3 -3
- package/dist/lint/audit-catalog.js +28 -0
- package/dist/lint/audit-lineage.js +5 -0
- package/dist/lint/post-synth/fly-helpers.js +24 -0
- package/dist/lint/post-synth/fly010-machine-requires-image.js +33 -0
- package/dist/lint/post-synth/fly011-mount-references-declared-volume.js +58 -0
- package/dist/lint/post-synth/index.js +6 -0
- package/dist/lint/rules/guest-sizing.js +92 -0
- package/dist/lint/rules/index.js +12 -0
- package/dist/lint/rules/no-secret-literals.js +58 -0
- package/dist/lint/rules/valid-region.js +52 -0
- package/dist/lsp/completions.js +12 -0
- package/dist/lsp/hover.js +12 -0
- package/dist/manifest.json +1 -1
- package/dist/mcp/context-tools.js +113 -0
- package/dist/okf/types/Machine.md +1 -1
- package/dist/op/activities/box-services.js +88 -0
- package/dist/op/activities/emulator-images.js +32 -0
- package/dist/op/activities/flaps.js +34 -0
- package/dist/op/activities/fly-apply.js +911 -0
- package/dist/op/activities/fly-release-step.js +21 -0
- package/dist/op/activities/fly-rollback-step.js +22 -0
- package/dist/op/activities/index.js +53 -0
- package/dist/op/activities/machine-release.js +236 -0
- package/dist/op/activities/machines-contract.js +81 -0
- package/dist/op/activities/machines-fake.js +203 -0
- package/dist/op/activities/machines-local-cli.js +85 -0
- package/dist/op/activities/machines-local.js +395 -0
- package/dist/op/activities/progress.js +10 -0
- package/dist/op/activities/sprite-config.js +409 -0
- package/dist/op/activities/sprite-fs.js +131 -0
- package/dist/op/activities/sprite-service-converge.js +224 -0
- package/dist/op/activities/sprite-services.js +174 -0
- package/dist/op/activities/sprite-tasks.js +57 -0
- package/dist/op/activities/sprites-contract.js +87 -0
- package/dist/op/activities/sprites-emulator.js +72 -0
- package/dist/op/activities/sprites-fake.js +547 -0
- package/dist/op/activities/sprites.js +515 -0
- package/dist/op/activity-contracts.js +82 -0
- package/dist/op/builders.js +157 -0
- package/dist/ownership.js +29 -0
- package/dist/package-cli.js +20 -0
- package/dist/plugin.js +298 -0
- package/dist/pseudo.js +26 -0
- package/dist/reference-catalog.js +37 -0
- package/dist/release-metadata.js +59 -0
- package/dist/release-store.js +46 -0
- package/dist/serializer.js +299 -0
- package/dist/spec/fetch.js +32 -0
- package/dist/spec/parse.js +260 -0
- package/dist/teardown.js +194 -0
- package/dist/types/index.d.ts +33 -2
- package/dist/validate-cli.js +4 -0
- package/dist/validate.js +33 -0
- package/package.json +11 -11
- package/src/codegen/package.ts +2 -2
- package/src/generated/index.d.ts +33 -2
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sprites lifecycle activities (#762, #766) — imperative, checkpointable sandbox
|
|
3
|
+
* primitives ([sprites.dev](https://sprites.dev)) as chant Op activities, wired
|
|
4
|
+
* to the faithful Sprites API.
|
|
5
|
+
*
|
|
6
|
+
* Unlike a resource lexicon, Sprites have no desired state to reconcile: they
|
|
7
|
+
* are runtime-orchestration primitives (the same category as `k3dUp` /
|
|
8
|
+
* `httpCheck`). Most activities are a direct call over an injectable HTTP client
|
|
9
|
+
* (`SpritesHttp`); `spriteExec` is the exception — it speaks the control
|
|
10
|
+
* WebSocket exec protocol (non-PTY stream framing, per superfly/sprites-go), so
|
|
11
|
+
* it opens a `ws` connection instead. Exported pure helpers (endpoint
|
|
12
|
+
* resolution, the exec frame accumulator, NDJSON parsers, the comment picker)
|
|
13
|
+
* keep the logic unit-testable without a socket or an HTTP server.
|
|
14
|
+
*
|
|
15
|
+
* The headline capability is checkpoint-as-compensation (S5): an Op checkpoints
|
|
16
|
+
* before a risky phase and, on failure, `spriteRestore`s the labeled checkpoint
|
|
17
|
+
* instead of unwinding with an inverse action — the environment itself is the
|
|
18
|
+
* transaction.
|
|
19
|
+
*
|
|
20
|
+
* S3: endpoint override via `SPRITES_BASE_URL` (an explicit `endpoint` arg wins,
|
|
21
|
+
* then the env, then the real Sprites base), so the same Op targets real Sprites
|
|
22
|
+
* or the in-process fake with no code change.
|
|
23
|
+
*
|
|
24
|
+
* #2711: the studio and wisp's own docs call these `SPRITES_API_URL` and
|
|
25
|
+
* `SPRITE_TOKEN` rather than `SPRITES_BASE_URL`/`SPRITES_API_TOKEN`. Both
|
|
26
|
+
* pairs are accepted — `resolveSpritesEndpoint`/`resolveSpritesToken` read the
|
|
27
|
+
* alias when the original name is unset, with the original always winning
|
|
28
|
+
* when both are present, so an existing deployment is never surprised by the
|
|
29
|
+
* alias taking over.
|
|
30
|
+
*/
|
|
31
|
+
// `ws` is loaded inside spriteExec, not at the top. It is a CommonJS package
|
|
32
|
+
// that requires "events", and a sandboxed build bundles the fly lexicon into
|
|
33
|
+
// ESM, where that require throws. The top-level import put it on the path of
|
|
34
|
+
// every project that imports the lexicon and needs the run fallback (#2613).
|
|
35
|
+
import { sleep } from "@intentius/chant/op";
|
|
36
|
+
import { logProgress } from "./progress.js";
|
|
37
|
+
export const DEFAULT_SPRITES_BASE_URL = "https://api.sprites.dev";
|
|
38
|
+
// ── Pure helpers (unit-testable without http/ws) ──────────────────────────────
|
|
39
|
+
/**
|
|
40
|
+
* Resolve the Sprites base URL (S3): an explicit `endpoint` arg wins, then the
|
|
41
|
+
* `SPRITES_BASE_URL` env, then the real-Sprites default. The trailing slash is
|
|
42
|
+
* stripped so `${base}/v1/...` never doubles up. Pure — mirrors fly's
|
|
43
|
+
* `resolveEndpoint`.
|
|
44
|
+
*/
|
|
45
|
+
export function resolveSpritesEndpoint(args = {}, env = process.env) {
|
|
46
|
+
const base = args.endpoint || env.SPRITES_BASE_URL || env.SPRITES_API_URL || DEFAULT_SPRITES_BASE_URL;
|
|
47
|
+
return base.replace(/\/$/, "");
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Resolve the bearer token (#2711): an explicit `token` arg wins, then
|
|
51
|
+
* `SPRITES_API_TOKEN`, then its alias `SPRITE_TOKEN`. Pure — mirrors
|
|
52
|
+
* `resolveSpritesEndpoint`.
|
|
53
|
+
*/
|
|
54
|
+
export function resolveSpritesToken(token = undefined, env = process.env) {
|
|
55
|
+
return token ?? env.SPRITES_API_TOKEN ?? env.SPRITE_TOKEN;
|
|
56
|
+
}
|
|
57
|
+
const spritesUrl = (base) => `${base}/v1/sprites`;
|
|
58
|
+
const spriteResourceUrl = (base, id) => `${spritesUrl(base)}/${encodeURIComponent(id)}`;
|
|
59
|
+
// Create uses REST JSON; exec is the control WebSocket below; checkpoints are NDJSON.
|
|
60
|
+
const spriteCheckpointUrl = (base, id) => `${spriteResourceUrl(base, id)}/checkpoint`;
|
|
61
|
+
const spriteCheckpointsUrl = (base, id) => `${spriteResourceUrl(base, id)}/checkpoints`;
|
|
62
|
+
const spriteCheckpointRestoreUrl = (base, id, cp) => `${spriteCheckpointsUrl(base, id)}/${encodeURIComponent(cp)}/restore`;
|
|
63
|
+
function safeJson(text) {
|
|
64
|
+
try {
|
|
65
|
+
return JSON.parse(text);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// ── Exec stream framing (non-PTY control WebSocket) ────────────────────────────
|
|
72
|
+
//
|
|
73
|
+
// Every WebSocket message is a binary frame `[StreamID:1 byte][payload]`. The
|
|
74
|
+
// client with no stdin sends a single `[4]` (StreamStdinEOF); the server writes
|
|
75
|
+
// stdout as `[1]<bytes>`, stderr as `[2]<bytes>`, then `[3]<exitcodebyte>` and
|
|
76
|
+
// closes. See superfly/sprites-go websocket.go/exec.go.
|
|
77
|
+
export const STREAM_STDIN = 0;
|
|
78
|
+
export const STREAM_STDOUT = 1;
|
|
79
|
+
export const STREAM_STDERR = 2;
|
|
80
|
+
export const STREAM_EXIT = 3;
|
|
81
|
+
export const STREAM_STDIN_EOF = 4;
|
|
82
|
+
function toBytes(data) {
|
|
83
|
+
if (data instanceof Uint8Array)
|
|
84
|
+
return data;
|
|
85
|
+
if (data instanceof ArrayBuffer)
|
|
86
|
+
return new Uint8Array(data);
|
|
87
|
+
if (Array.isArray(data))
|
|
88
|
+
return Buffer.concat(data.map((d) => toBytes(d)));
|
|
89
|
+
if (typeof data === "string")
|
|
90
|
+
return new TextEncoder().encode(data);
|
|
91
|
+
return new Uint8Array(0);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Accumulate a stream of exec frames into `{ stdout, stderr, exitCode }`. Pure
|
|
95
|
+
* and socket-free so the framing is unit-testable: feed `[1]"hi\n"`, `[3]\x00`
|
|
96
|
+
* and get `{ stdout: "hi\n", exitCode: 0 }`. Per-stream payloads are collected
|
|
97
|
+
* as bytes and decoded once, so a multi-byte character split across frames is
|
|
98
|
+
* preserved. The exit code is the first byte of the `[3]` frame (0 when absent).
|
|
99
|
+
*/
|
|
100
|
+
export function accumulateExecFrames(frames) {
|
|
101
|
+
const outChunks = [];
|
|
102
|
+
const errChunks = [];
|
|
103
|
+
let exitCode = 0;
|
|
104
|
+
for (const frame of frames) {
|
|
105
|
+
if (!frame || frame.length === 0)
|
|
106
|
+
continue;
|
|
107
|
+
const stream = frame[0];
|
|
108
|
+
const payload = frame.subarray(1);
|
|
109
|
+
if (stream === STREAM_STDOUT)
|
|
110
|
+
outChunks.push(payload);
|
|
111
|
+
else if (stream === STREAM_STDERR)
|
|
112
|
+
errChunks.push(payload);
|
|
113
|
+
else if (stream === STREAM_EXIT)
|
|
114
|
+
exitCode = payload.length > 0 ? payload[0] : 0;
|
|
115
|
+
}
|
|
116
|
+
const dec = new TextDecoder();
|
|
117
|
+
return {
|
|
118
|
+
stdout: dec.decode(concat(outChunks)),
|
|
119
|
+
stderr: dec.decode(concat(errChunks)),
|
|
120
|
+
exitCode,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
function concat(chunks) {
|
|
124
|
+
if (chunks.length === 0)
|
|
125
|
+
return new Uint8Array(0);
|
|
126
|
+
if (chunks.length === 1)
|
|
127
|
+
return chunks[0];
|
|
128
|
+
return Buffer.concat(chunks.map((c) => Buffer.from(c.buffer, c.byteOffset, c.byteLength)));
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Tokenize a command string into an argv, respecting single/double quotes so
|
|
132
|
+
* `sh -c "exit 7"` becomes `["sh", "-c", "exit 7"]`. Each element is sent as a
|
|
133
|
+
* `cmd` query param; `path` is `argv[0]`. Pure.
|
|
134
|
+
*/
|
|
135
|
+
export function splitCommand(cmd) {
|
|
136
|
+
const argv = [];
|
|
137
|
+
let cur = "";
|
|
138
|
+
let has = false;
|
|
139
|
+
let inSingle = false;
|
|
140
|
+
let inDouble = false;
|
|
141
|
+
for (const ch of cmd) {
|
|
142
|
+
if (inSingle) {
|
|
143
|
+
if (ch === "'")
|
|
144
|
+
inSingle = false;
|
|
145
|
+
else
|
|
146
|
+
cur += ch;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (inDouble) {
|
|
150
|
+
if (ch === '"')
|
|
151
|
+
inDouble = false;
|
|
152
|
+
else
|
|
153
|
+
cur += ch;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (ch === "'") {
|
|
157
|
+
inSingle = true;
|
|
158
|
+
has = true;
|
|
159
|
+
}
|
|
160
|
+
else if (ch === '"') {
|
|
161
|
+
inDouble = true;
|
|
162
|
+
has = true;
|
|
163
|
+
}
|
|
164
|
+
else if (ch === " " || ch === "\t" || ch === "\n") {
|
|
165
|
+
if (has) {
|
|
166
|
+
argv.push(cur);
|
|
167
|
+
cur = "";
|
|
168
|
+
has = false;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
cur += ch;
|
|
173
|
+
has = true;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (has)
|
|
177
|
+
argv.push(cur);
|
|
178
|
+
return argv;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Build the `wss://.../exec?cmd=...&path=...&stdin=false&cc=true[&dir=...][&env=K=V]*`
|
|
182
|
+
* URL for a command (http→ws, https→wss). `dir` and `env` (#2765) are sent the
|
|
183
|
+
* way wisp's and spritzer's real exec API take them: `dir` is a single query
|
|
184
|
+
* param, and each `env` entry is its own repeated `env=KEY=VALUE` param — see
|
|
185
|
+
* wisp's `optsFromValues` (`Dir: q.Get("dir")`, `Env: q["env"]`) and spritzer's
|
|
186
|
+
* container-mode wrapping (`--dir`/`--env` per `real.go`). Pure.
|
|
187
|
+
*/
|
|
188
|
+
export function spriteExecWsUrl(base, id, cmd, opts = {}) {
|
|
189
|
+
const wsBase = base.replace(/^http(s?):\/\//i, (_m, s) => `ws${s}://`);
|
|
190
|
+
const argv = splitCommand(cmd);
|
|
191
|
+
const params = new URLSearchParams();
|
|
192
|
+
for (const a of argv)
|
|
193
|
+
params.append("cmd", a);
|
|
194
|
+
params.set("path", argv[0] ?? "");
|
|
195
|
+
params.set("stdin", "false");
|
|
196
|
+
params.set("cc", "true");
|
|
197
|
+
if (opts.dir)
|
|
198
|
+
params.set("dir", opts.dir);
|
|
199
|
+
if (opts.env) {
|
|
200
|
+
for (const [k, v] of Object.entries(opts.env))
|
|
201
|
+
params.append("env", `${k}=${v}`);
|
|
202
|
+
}
|
|
203
|
+
return `${wsBase}/v1/sprites/${encodeURIComponent(id)}/exec?${params.toString()}`;
|
|
204
|
+
}
|
|
205
|
+
// ── Checkpoint NDJSON + comment picker ─────────────────────────────────────────
|
|
206
|
+
/**
|
|
207
|
+
* Parse a checkpoint create NDJSON body (line-delimited JSON progress events)
|
|
208
|
+
* and capture the created version id. Real Sprites tags each event with a
|
|
209
|
+
* `type` and a human `data` string — the version id rides inside the text
|
|
210
|
+
* (" ID: v1" and "Checkpoint v1 created successfully"), not a structured
|
|
211
|
+
* field. The in-process fake mirrors that. An older shape (`{event, id}`) is
|
|
212
|
+
* still honored so a structured id always wins. Pure; blank/unparseable lines
|
|
213
|
+
* are skipped; `checkpointId` is "" when the stream carries no id.
|
|
214
|
+
*/
|
|
215
|
+
export function parseCheckpointNdjson(text) {
|
|
216
|
+
let structuredId = "";
|
|
217
|
+
let textId = "";
|
|
218
|
+
for (const line of text.split("\n")) {
|
|
219
|
+
const t = line.trim();
|
|
220
|
+
if (!t)
|
|
221
|
+
continue;
|
|
222
|
+
const obj = safeJson(t);
|
|
223
|
+
if (!obj)
|
|
224
|
+
continue;
|
|
225
|
+
const kind = obj.event ?? obj.type;
|
|
226
|
+
// Structured id (older emulator shape) — always preferred when present.
|
|
227
|
+
if (kind === "complete" && typeof obj.id === "string" && obj.id)
|
|
228
|
+
structuredId = obj.id;
|
|
229
|
+
// Otherwise mine the version id out of the message text. The " ID: v1"
|
|
230
|
+
// detail line and the "Checkpoint v1 created successfully" completion line
|
|
231
|
+
// both carry it; either is enough.
|
|
232
|
+
if (typeof obj.data === "string") {
|
|
233
|
+
const fromId = obj.data.match(/(?:^|\s)ID:\s*(\S+)/);
|
|
234
|
+
if (fromId)
|
|
235
|
+
textId = fromId[1];
|
|
236
|
+
const fromComplete = obj.data.match(/Checkpoint\s+(\S+)\s+created/i);
|
|
237
|
+
if (fromComplete)
|
|
238
|
+
textId = fromComplete[1];
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return { checkpointId: structuredId || textId };
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Pick the newest checkpoint whose `comment` matches, so a comment-tagged
|
|
245
|
+
* restore rewinds to the most recent labeled snapshot. Newest is by
|
|
246
|
+
* `create_time`; array order breaks ties (the list is chronological). Pure.
|
|
247
|
+
*/
|
|
248
|
+
export function pickCheckpointByComment(list, comment) {
|
|
249
|
+
return newestOf(list.filter((c) => c.comment === comment));
|
|
250
|
+
}
|
|
251
|
+
function newestOf(list) {
|
|
252
|
+
let best;
|
|
253
|
+
for (const c of list) {
|
|
254
|
+
if (!best || compareCreateTime(c, best) >= 0)
|
|
255
|
+
best = c;
|
|
256
|
+
}
|
|
257
|
+
return best;
|
|
258
|
+
}
|
|
259
|
+
function compareCreateTime(a, b) {
|
|
260
|
+
const ta = Date.parse(a.create_time);
|
|
261
|
+
const tb = Date.parse(b.create_time);
|
|
262
|
+
if (Number.isNaN(ta) || Number.isNaN(tb))
|
|
263
|
+
return 0;
|
|
264
|
+
return ta - tb;
|
|
265
|
+
}
|
|
266
|
+
/** Build the `POST /v1/sprites` body. Pure. */
|
|
267
|
+
export function spriteCreateBody(args) {
|
|
268
|
+
return {
|
|
269
|
+
name: args.name,
|
|
270
|
+
...(args.image !== undefined ? { image: args.image } : {}),
|
|
271
|
+
...(args.size !== undefined ? { size: args.size } : {}),
|
|
272
|
+
...(args.policy !== undefined ? { policy: args.policy } : {}),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
/** Parse the create response; the caller-chosen `name` is the id fallback (S4). Pure. */
|
|
276
|
+
export function parseCreateResponse(text, name) {
|
|
277
|
+
const b = safeJson(text);
|
|
278
|
+
return { id: b?.id ?? name, url: b?.url ?? "" };
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Default `fetch`-based client. Sends `Authorization: Bearer <token>` when a
|
|
282
|
+
* token is set (real Sprites); the fake ignores it. The token defaults to
|
|
283
|
+
* `SPRITES_API_TOKEN` at call time. `fetchImpl` is injectable for tests.
|
|
284
|
+
*/
|
|
285
|
+
export function defaultSpritesHttp(token, fetchImpl = fetch) {
|
|
286
|
+
return async (method, url, body, headers, signal) => {
|
|
287
|
+
const h = { ...headers };
|
|
288
|
+
if (body !== undefined)
|
|
289
|
+
h["content-type"] = "application/json";
|
|
290
|
+
const tok = resolveSpritesToken(token);
|
|
291
|
+
if (tok)
|
|
292
|
+
h["authorization"] = `Bearer ${tok}`;
|
|
293
|
+
const res = await fetchImpl(url, {
|
|
294
|
+
method,
|
|
295
|
+
headers: Object.keys(h).length ? h : undefined,
|
|
296
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
297
|
+
signal,
|
|
298
|
+
});
|
|
299
|
+
return { status: res.status, text: await res.text() };
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
// ── Activities (ActivityFn: (args, signal?) => Promise<unknown>) ──────────────
|
|
303
|
+
/** Create a sprite with the caller-chosen `name` as its id (S4). `POST /v1/sprites`. */
|
|
304
|
+
export async function spriteCreate(args, signal, http = defaultSpritesHttp(args.token)) {
|
|
305
|
+
const base = resolveSpritesEndpoint(args);
|
|
306
|
+
const res = await http("POST", spritesUrl(base), spriteCreateBody(args), undefined, signal);
|
|
307
|
+
if (res.status >= 300)
|
|
308
|
+
throw new Error(`sprite ${args.name} create failed (${res.status}): ${res.text}`);
|
|
309
|
+
const result = parseCreateResponse(res.text, args.name);
|
|
310
|
+
logProgress(`created: sprite/${result.id} (${base})`);
|
|
311
|
+
return result;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Run a command in the sprite over the control WebSocket (non-PTY stream
|
|
315
|
+
* framing, per superfly/sprites-go). Connects to `wss://.../exec` (`dir`/`env`
|
|
316
|
+
* riding on the URL — #2765), sends a single `[4]` (stdin EOF), accumulates
|
|
317
|
+
* stdout/stderr frames, and reads the exit code from the `[3]` frame. A
|
|
318
|
+
* non-zero exit is a failed activity (it throws) so a risky step fails its
|
|
319
|
+
* phase and triggers `onFailure` compensation (S5).
|
|
320
|
+
*
|
|
321
|
+
* `timeoutMs` (#2765) bounds the whole exec: a timer set at connect time
|
|
322
|
+
* terminates the WebSocket and rejects with a timeout error once it elapses
|
|
323
|
+
* without the command finishing. Previously declared on `SpriteExecArgs` and
|
|
324
|
+
* never read.
|
|
325
|
+
*/
|
|
326
|
+
export async function spriteExec(args, signal) {
|
|
327
|
+
const base = resolveSpritesEndpoint(args);
|
|
328
|
+
const url = spriteExecWsUrl(base, args.id, args.cmd, { env: args.env, dir: args.dir });
|
|
329
|
+
const token = resolveSpritesToken(args.token);
|
|
330
|
+
const headers = {};
|
|
331
|
+
if (token)
|
|
332
|
+
headers.Authorization = `Bearer ${token}`;
|
|
333
|
+
const { default: WebSocket } = await import("ws");
|
|
334
|
+
const result = await new Promise((resolve, reject) => {
|
|
335
|
+
const frames = [];
|
|
336
|
+
const ws = new WebSocket(url, { headers });
|
|
337
|
+
let settled = false;
|
|
338
|
+
let timer;
|
|
339
|
+
const finish = (fn) => {
|
|
340
|
+
if (settled)
|
|
341
|
+
return;
|
|
342
|
+
settled = true;
|
|
343
|
+
if (signal)
|
|
344
|
+
signal.removeEventListener("abort", onAbort);
|
|
345
|
+
if (timer !== undefined)
|
|
346
|
+
clearTimeout(timer);
|
|
347
|
+
fn();
|
|
348
|
+
};
|
|
349
|
+
const onAbort = () => {
|
|
350
|
+
try {
|
|
351
|
+
ws.terminate();
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
/* already closed */
|
|
355
|
+
}
|
|
356
|
+
finish(() => reject(new Error(`sprite ${args.id} exec aborted`)));
|
|
357
|
+
};
|
|
358
|
+
if (signal) {
|
|
359
|
+
if (signal.aborted)
|
|
360
|
+
return onAbort();
|
|
361
|
+
signal.addEventListener("abort", onAbort);
|
|
362
|
+
}
|
|
363
|
+
if (args.timeoutMs !== undefined) {
|
|
364
|
+
timer = setTimeout(() => {
|
|
365
|
+
try {
|
|
366
|
+
ws.terminate();
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
/* already closed */
|
|
370
|
+
}
|
|
371
|
+
finish(() => reject(new Error(`sprite ${args.id} exec "${args.cmd}" timed out after ${args.timeoutMs}ms`)));
|
|
372
|
+
}, args.timeoutMs);
|
|
373
|
+
}
|
|
374
|
+
ws.on("open", () => {
|
|
375
|
+
// No stdin: signal EOF immediately (belt and braces with stdin=false).
|
|
376
|
+
ws.send(Uint8Array.of(STREAM_STDIN_EOF));
|
|
377
|
+
});
|
|
378
|
+
ws.on("message", (data) => {
|
|
379
|
+
const bytes = toBytes(data);
|
|
380
|
+
frames.push(bytes);
|
|
381
|
+
if (bytes.length > 0 && bytes[0] === STREAM_EXIT) {
|
|
382
|
+
try {
|
|
383
|
+
ws.close();
|
|
384
|
+
}
|
|
385
|
+
catch {
|
|
386
|
+
/* closing already */
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
ws.on("error", (err) => finish(() => reject(err)));
|
|
391
|
+
ws.on("close", () => finish(() => resolve(accumulateExecFrames(frames))));
|
|
392
|
+
});
|
|
393
|
+
if (result.exitCode !== 0) {
|
|
394
|
+
throw new Error(`sprite ${args.id} exec "${args.cmd}" exited ${result.exitCode}: ${result.stderr || result.stdout}`);
|
|
395
|
+
}
|
|
396
|
+
return result;
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Checkpoint the sprite. `POST /v1/sprites/{id}/checkpoint` (singular); the
|
|
400
|
+
* `comment` key is omitted when empty. The response is an NDJSON progress
|
|
401
|
+
* stream; the created version id is mined from the message text (see
|
|
402
|
+
* `parseCheckpointNdjson`).
|
|
403
|
+
*/
|
|
404
|
+
export async function spriteCheckpoint(args, signal, http = defaultSpritesHttp(args.token)) {
|
|
405
|
+
const base = resolveSpritesEndpoint(args);
|
|
406
|
+
const body = args.comment ? { comment: args.comment } : undefined;
|
|
407
|
+
const res = await http("POST", spriteCheckpointUrl(base, args.id), body, undefined, signal);
|
|
408
|
+
if (res.status >= 300)
|
|
409
|
+
throw new Error(`sprite ${args.id} checkpoint failed (${res.status}): ${res.text}`);
|
|
410
|
+
const result = parseCheckpointNdjson(res.text);
|
|
411
|
+
logProgress(`checkpoint: sprite/${args.id} @${result.checkpointId} (${base})`);
|
|
412
|
+
return result;
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* List a sprite's checkpoints. `GET /v1/sprites/{id}/checkpoints` → a bare array
|
|
416
|
+
* `[{ id, comment, create_time, is_auto }]` (auto checkpoints excluded by
|
|
417
|
+
* default on the server).
|
|
418
|
+
*/
|
|
419
|
+
export async function listCheckpoints(args, signal, http = defaultSpritesHttp(args.token)) {
|
|
420
|
+
const base = resolveSpritesEndpoint(args);
|
|
421
|
+
const res = await http("GET", spriteCheckpointsUrl(base, args.id), undefined, undefined, signal);
|
|
422
|
+
if (res.status >= 300)
|
|
423
|
+
throw new Error(`sprite ${args.id} list checkpoints failed (${res.status}): ${res.text}`);
|
|
424
|
+
const parsed = safeJson(res.text);
|
|
425
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Restore the sprite to a checkpoint (S5, the compensation path). Resolution
|
|
429
|
+
* order: an explicit `checkpoint` id wins; otherwise the newest checkpoint
|
|
430
|
+
* carrying `comment`; otherwise the newest checkpoint overall. Restore is
|
|
431
|
+
* `POST /v1/sprites/{id}/checkpoints/{cp}/restore` and returns an NDJSON stream.
|
|
432
|
+
*/
|
|
433
|
+
export async function spriteRestore(args, signal, http = defaultSpritesHttp(args.token)) {
|
|
434
|
+
const base = resolveSpritesEndpoint(args);
|
|
435
|
+
let target = args.checkpoint;
|
|
436
|
+
if (!target) {
|
|
437
|
+
const list = await listCheckpoints({ ...args }, signal, http);
|
|
438
|
+
const picked = args.comment !== undefined ? pickCheckpointByComment(list, args.comment) : newestOf(list);
|
|
439
|
+
if (!picked) {
|
|
440
|
+
const which = args.comment !== undefined ? `comment "${args.comment}"` : "any checkpoint";
|
|
441
|
+
throw new Error(`sprite ${args.id} restore: no checkpoint matching ${which}`);
|
|
442
|
+
}
|
|
443
|
+
target = picked.id;
|
|
444
|
+
}
|
|
445
|
+
const res = await http("POST", spriteCheckpointRestoreUrl(base, args.id, target), undefined, undefined, signal);
|
|
446
|
+
if (res.status >= 300) {
|
|
447
|
+
throw new Error(`sprite ${args.id} restore to "${target}" failed (${res.status}): ${res.text}`);
|
|
448
|
+
}
|
|
449
|
+
logProgress(`restored: sprite/${args.id} to ${target} (${base})`);
|
|
450
|
+
return {};
|
|
451
|
+
}
|
|
452
|
+
/** Destroy the sprite (idempotent; a 404 means it is already gone). `DELETE /v1/sprites/{id}`. */
|
|
453
|
+
export async function spriteDestroy(args, signal, http = defaultSpritesHttp(args.token)) {
|
|
454
|
+
const base = resolveSpritesEndpoint(args);
|
|
455
|
+
const res = await http("DELETE", spriteResourceUrl(base, args.id), undefined, undefined, signal);
|
|
456
|
+
if (res.status >= 300 && res.status !== 404) {
|
|
457
|
+
throw new Error(`sprite ${args.id} destroy failed (${res.status}): ${res.text}`);
|
|
458
|
+
}
|
|
459
|
+
logProgress(`destroyed: sprite/${args.id} (${base})`);
|
|
460
|
+
return {};
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Delete the sprite (#2711) — the same idempotent `DELETE /v1/sprites/{id}` as
|
|
464
|
+
* `spriteDestroy`, under the name spritzer, wisp and the other lexicons'
|
|
465
|
+
* delete activities (`awsDelete`, `gcpDelete`, `azDelete`) use. A literal
|
|
466
|
+
* alias, not a reimplementation, so the two names can never drift: an Op
|
|
467
|
+
* authored against either resolves to the same call. `spriteDestroy` stays —
|
|
468
|
+
* it is the name every existing Op, example and the `op-verb-class` registry
|
|
469
|
+
* already depend on.
|
|
470
|
+
*/
|
|
471
|
+
export const spriteDelete = spriteDestroy;
|
|
472
|
+
/**
|
|
473
|
+
* Resolve the sprite's URL (#2711) — `spriteCreate` returns it too, but
|
|
474
|
+
* nothing until now exposed it on its own (for a later phase that only has
|
|
475
|
+
* the sprite `id`) or waited on it. `GET /v1/sprites/{id}` for the URL; with
|
|
476
|
+
* `path` set, polls `GET {url}{path}` until `status` (default any 2xx) or
|
|
477
|
+
* `timeoutMs` runs out — the box's door/hud services take a moment to
|
|
478
|
+
* bind their `http_port` after `spriteServiceStart`.
|
|
479
|
+
*/
|
|
480
|
+
export async function spriteUrl(args, signal, http = defaultSpritesHttp(args.token)) {
|
|
481
|
+
const base = resolveSpritesEndpoint(args);
|
|
482
|
+
const res = await http("GET", spriteResourceUrl(base, args.id), undefined, undefined, signal);
|
|
483
|
+
if (res.status >= 300)
|
|
484
|
+
throw new Error(`sprite ${args.id} url lookup failed (${res.status}): ${res.text}`);
|
|
485
|
+
const url = safeJson(res.text)?.url;
|
|
486
|
+
if (!url)
|
|
487
|
+
throw new Error(`sprite ${args.id} has no url`);
|
|
488
|
+
if (args.path !== undefined) {
|
|
489
|
+
const timeoutMs = args.timeoutMs ?? 10000;
|
|
490
|
+
const intervalMs = args.intervalMs ?? 500;
|
|
491
|
+
const target = `${url}${args.path}`;
|
|
492
|
+
const deadline = Date.now() + timeoutMs;
|
|
493
|
+
let lastErr = "";
|
|
494
|
+
for (;;) {
|
|
495
|
+
if (signal?.aborted)
|
|
496
|
+
throw new Error(`sprite ${args.id} url wait aborted`);
|
|
497
|
+
try {
|
|
498
|
+
const check = await http("GET", target, undefined, undefined, signal);
|
|
499
|
+
const ok = args.status !== undefined ? check.status === args.status : check.status >= 200 && check.status < 300;
|
|
500
|
+
if (ok) {
|
|
501
|
+
logProgress(`url: sprite/${args.id} answers on ${target} (${check.status})`);
|
|
502
|
+
return { url };
|
|
503
|
+
}
|
|
504
|
+
lastErr = `status ${check.status} (want ${args.status ?? "2xx"})`;
|
|
505
|
+
}
|
|
506
|
+
catch (e) {
|
|
507
|
+
lastErr = e instanceof Error ? e.message : String(e);
|
|
508
|
+
}
|
|
509
|
+
if (Date.now() >= deadline)
|
|
510
|
+
throw new Error(`sprite ${args.id} url ${target} did not answer: ${lastErr}`);
|
|
511
|
+
await sleep(intervalMs, signal);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return { url };
|
|
515
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Activity contracts for this lexicon's `op/activities` (chant #2101, #2843).
|
|
3
|
+
*
|
|
4
|
+
* Resolved by convention at `@intentius/chant-lexicon-fly/op/activity-contracts`:
|
|
5
|
+
* `loadActivityContracts` (`packages/core/src/op/activity-contract-registry.ts`)
|
|
6
|
+
* merges what it finds here into the map core's OPS012 and OPS013 validate
|
|
7
|
+
* every Op step against.
|
|
8
|
+
*
|
|
9
|
+
* Only the activities a step's output is referenced from are covered.
|
|
10
|
+
* `spriteServicesObserve` is the observer of a `ConvergeOp({ observe })`
|
|
11
|
+
* (#2778), whose Converge step reads the observer's whole output, so OPS013
|
|
12
|
+
* needs its return schema; without one, every project declaring such a
|
|
13
|
+
* ConvergeOp failed `chant build` and `chant lint`. `spriteServiceRestart` is
|
|
14
|
+
* the step of the Op such a ConvergeOp's rule runs, and its result is what a
|
|
15
|
+
* later step would read. `spriteApplyServices` is covered for its box form
|
|
16
|
+
* (#2880): `box`, `only`, `start` and `restart`, and the per-service actions
|
|
17
|
+
* its result names. The other Sprites activities have no contract yet,
|
|
18
|
+
* which OPS012 skips rather than flags.
|
|
19
|
+
*
|
|
20
|
+
* Each `args` schema mirrors the `*Args` interface in
|
|
21
|
+
* `./activities/sprite-service-converge.ts` or `./activities/sprite-config.ts`
|
|
22
|
+
* and each `returns` schema the `*Result`. Authored with `z.strictObject(...)`, so a misspelled key fails
|
|
23
|
+
* the build instead of vanishing.
|
|
24
|
+
*/
|
|
25
|
+
import { z } from "zod";
|
|
26
|
+
import { activityContract } from "@intentius/chant/op";
|
|
27
|
+
const declaredService = z.strictObject({
|
|
28
|
+
name: z.string(),
|
|
29
|
+
health: z.string().optional(),
|
|
30
|
+
optional: z.boolean().optional(),
|
|
31
|
+
});
|
|
32
|
+
/** `Via`: the sprite for the Sprites API, or `sprite-env` inside the sprite. */
|
|
33
|
+
const via = {
|
|
34
|
+
id: z.string().optional(),
|
|
35
|
+
spriteEnv: z.string().optional(),
|
|
36
|
+
endpoint: z.string().optional(),
|
|
37
|
+
token: z.string().optional(),
|
|
38
|
+
};
|
|
39
|
+
export const spriteServicesObserveContract = activityContract("spriteServicesObserve", z.strictObject({
|
|
40
|
+
...via,
|
|
41
|
+
services: z.array(declaredService).optional(),
|
|
42
|
+
servicesFile: z.string().optional(),
|
|
43
|
+
box: z.boolean().optional(),
|
|
44
|
+
probes: z.number().optional(),
|
|
45
|
+
probeIntervalMs: z.number().optional(),
|
|
46
|
+
}), z.object({
|
|
47
|
+
resources: z.array(z.object({ name: z.string(), status: z.enum(["in-sync", "drifted", "unknown"]), detail: z.string() })),
|
|
48
|
+
skipped: z.array(z.string()),
|
|
49
|
+
}));
|
|
50
|
+
export const spriteServiceRestartContract = activityContract("spriteServiceRestart", z.strictObject({
|
|
51
|
+
...via,
|
|
52
|
+
name: z.string().optional(),
|
|
53
|
+
health: z.string().optional(),
|
|
54
|
+
services: z.array(declaredService).optional(),
|
|
55
|
+
servicesFile: z.string().optional(),
|
|
56
|
+
box: z.boolean().optional(),
|
|
57
|
+
waitMs: z.number().optional(),
|
|
58
|
+
}), z.object({ name: z.string(), healthy: z.boolean().nullable() }));
|
|
59
|
+
const serviceSpec = z.strictObject({
|
|
60
|
+
name: z.string(),
|
|
61
|
+
cmd: z.string(),
|
|
62
|
+
args: z.array(z.string()).optional(),
|
|
63
|
+
env: z.record(z.string(), z.string()).optional(),
|
|
64
|
+
dir: z.string().optional(),
|
|
65
|
+
needs: z.array(z.string()).optional(),
|
|
66
|
+
http_port: z.number().optional(),
|
|
67
|
+
});
|
|
68
|
+
export const spriteApplyServicesContract = activityContract("spriteApplyServices", z.strictObject({
|
|
69
|
+
id: z.string().optional(),
|
|
70
|
+
services: z.array(serviceSpec).optional(),
|
|
71
|
+
box: z.boolean().optional(),
|
|
72
|
+
only: z.array(z.string()).optional(),
|
|
73
|
+
start: z.boolean().optional(),
|
|
74
|
+
restart: z.boolean().optional(),
|
|
75
|
+
spriteEnv: z.string().optional(),
|
|
76
|
+
endpoint: z.string().optional(),
|
|
77
|
+
token: z.string().optional(),
|
|
78
|
+
}), z.object({
|
|
79
|
+
applied: z.array(z.string()),
|
|
80
|
+
started: z.array(z.string()),
|
|
81
|
+
services: z.array(z.object({ name: z.string(), action: z.enum(["created", "replaced", "restarted", "started", "left"]) })).optional(),
|
|
82
|
+
}));
|