@intentius/chant-lexicon-fly 0.91.0 → 0.93.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 (68) hide show
  1. package/README.md +27 -0
  2. package/dist/components/fly-release.d.ts +51 -4
  3. package/dist/components/fly-release.d.ts.map +1 -1
  4. package/dist/composites/catalog.d.ts.map +1 -1
  5. package/dist/composites/fly-site.d.ts +86 -0
  6. package/dist/composites/fly-site.d.ts.map +1 -0
  7. package/dist/index.d.ts +3 -1
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/integrity.json +4 -4
  10. package/dist/manifest.json +1 -1
  11. package/dist/op/activities/fly-release-step.d.ts +24 -0
  12. package/dist/op/activities/fly-release-step.d.ts.map +1 -0
  13. package/dist/op/activities/fly-rollback-step.d.ts +25 -0
  14. package/dist/op/activities/fly-rollback-step.d.ts.map +1 -0
  15. package/dist/op/activities/index.d.ts +7 -1
  16. package/dist/op/activities/index.d.ts.map +1 -1
  17. package/dist/op/activities/machine-release.d.ts +16 -0
  18. package/dist/op/activities/machine-release.d.ts.map +1 -1
  19. package/dist/op/activities/machines-fake.d.ts +4 -2
  20. package/dist/op/activities/machines-fake.d.ts.map +1 -1
  21. package/dist/op/activities/machines-local-cli.d.ts +20 -0
  22. package/dist/op/activities/machines-local-cli.d.ts.map +1 -0
  23. package/dist/op/activities/machines-local.d.ts +107 -0
  24. package/dist/op/activities/machines-local.d.ts.map +1 -0
  25. package/dist/op/activities/sprite-fs.d.ts +12 -3
  26. package/dist/op/activities/sprite-fs.d.ts.map +1 -1
  27. package/dist/op/activities/sprite-service-converge.d.ts +107 -0
  28. package/dist/op/activities/sprite-service-converge.d.ts.map +1 -0
  29. package/dist/op/activities/sprites-fake.d.ts +18 -1
  30. package/dist/op/activities/sprites-fake.d.ts.map +1 -1
  31. package/dist/op/activities/sprites.d.ts +29 -9
  32. package/dist/op/activities/sprites.d.ts.map +1 -1
  33. package/dist/op/activity-contracts.d.ts +62 -0
  34. package/dist/op/activity-contracts.d.ts.map +1 -0
  35. package/dist/op/builders.d.ts +32 -0
  36. package/dist/op/builders.d.ts.map +1 -1
  37. package/dist/skills/chant-fly-ops.md +67 -0
  38. package/dist/skills/chant-fly-sprites.md +2 -1
  39. package/package.json +11 -4
  40. package/src/components/fly-release.test.ts +135 -1
  41. package/src/components/fly-release.ts +125 -8
  42. package/src/composites/catalog.test.ts +2 -1
  43. package/src/composites/catalog.ts +98 -0
  44. package/src/composites/fly-site.test.ts +66 -0
  45. package/src/composites/fly-site.ts +140 -0
  46. package/src/index.ts +8 -0
  47. package/src/op/activities/fly-release-step.ts +32 -0
  48. package/src/op/activities/fly-rollback-step.ts +33 -0
  49. package/src/op/activities/index.ts +25 -0
  50. package/src/op/activities/machine-release.ts +22 -0
  51. package/src/op/activities/machines-fake.ts +5 -3
  52. package/src/op/activities/machines-local-cli.ts +73 -0
  53. package/src/op/activities/machines-local.test.ts +225 -0
  54. package/src/op/activities/machines-local.ts +437 -0
  55. package/src/op/activities/sprite-exec-options.docker.integration.test.ts +70 -0
  56. package/src/op/activities/sprite-fs.test.ts +55 -0
  57. package/src/op/activities/sprite-fs.ts +14 -5
  58. package/src/op/activities/sprite-service-converge.test.ts +268 -0
  59. package/src/op/activities/sprite-service-converge.ts +268 -0
  60. package/src/op/activities/sprites-fake.ts +51 -8
  61. package/src/op/activities/sprites.integration.test.ts +30 -0
  62. package/src/op/activities/sprites.test.ts +20 -0
  63. package/src/op/activities/sprites.ts +50 -10
  64. package/src/op/activity-contracts.test.ts +71 -0
  65. package/src/op/activity-contracts.ts +67 -0
  66. package/src/op/builders.ts +32 -0
  67. package/src/skills/chant-fly-ops.md +67 -0
  68. package/src/skills/chant-fly-sprites.md +2 -1
@@ -214,6 +214,26 @@ describe("spriteExecWsUrl", () => {
214
214
  test("https → wss", () => {
215
215
  expect(spriteExecWsUrl("https://api.sprites.dev", "s", "ls").startsWith("wss://api.sprites.dev/")).toBe(true);
216
216
  });
217
+
218
+ test("dir → a single dir param (#2765)", () => {
219
+ const url = new URL(spriteExecWsUrl("http://x", "task-1", "pwd", { dir: "/work" }));
220
+ expect(url.searchParams.get("dir")).toBe("/work");
221
+ });
222
+
223
+ test("no dir → no dir param (#2765)", () => {
224
+ const url = new URL(spriteExecWsUrl("http://x", "task-1", "pwd"));
225
+ expect(url.searchParams.has("dir")).toBe(false);
226
+ });
227
+
228
+ test("env → repeated KEY=VALUE params, matching wisp's optsFromValues (#2765)", () => {
229
+ const url = new URL(spriteExecWsUrl("http://x", "task-1", "env", { env: { FOO: "1", BAR: "two" } }));
230
+ expect(url.searchParams.getAll("env")).toEqual(["FOO=1", "BAR=two"]);
231
+ });
232
+
233
+ test("no env → no env params (#2765)", () => {
234
+ const url = new URL(spriteExecWsUrl("http://x", "task-1", "echo hi"));
235
+ expect(url.searchParams.has("env")).toBe(false);
236
+ });
217
237
  });
218
238
 
219
239
  // ── Activity request shapes (injected SpritesHttp; no real sockets) ───────────
@@ -179,10 +179,19 @@ export function splitCommand(cmd: string): string[] {
179
179
  }
180
180
 
181
181
  /**
182
- * Build the `wss://.../exec?cmd=...&path=...&stdin=false&cc=true` URL for a
183
- * command (http→ws, https→wss). Pure.
182
+ * Build the `wss://.../exec?cmd=...&path=...&stdin=false&cc=true[&dir=...][&env=K=V]*`
183
+ * URL for a command (http→ws, https→wss). `dir` and `env` (#2765) are sent the
184
+ * way wisp's and spritzer's real exec API take them: `dir` is a single query
185
+ * param, and each `env` entry is its own repeated `env=KEY=VALUE` param — see
186
+ * wisp's `optsFromValues` (`Dir: q.Get("dir")`, `Env: q["env"]`) and spritzer's
187
+ * container-mode wrapping (`--dir`/`--env` per `real.go`). Pure.
184
188
  */
185
- export function spriteExecWsUrl(base: string, id: string, cmd: string): string {
189
+ export function spriteExecWsUrl(
190
+ base: string,
191
+ id: string,
192
+ cmd: string,
193
+ opts: { env?: Record<string, string>; dir?: string } = {},
194
+ ): string {
186
195
  const wsBase = base.replace(/^http(s?):\/\//i, (_m, s: string) => `ws${s}://`);
187
196
  const argv = splitCommand(cmd);
188
197
  const params = new URLSearchParams();
@@ -190,6 +199,10 @@ export function spriteExecWsUrl(base: string, id: string, cmd: string): string {
190
199
  params.set("path", argv[0] ?? "");
191
200
  params.set("stdin", "false");
192
201
  params.set("cc", "true");
202
+ if (opts.dir) params.set("dir", opts.dir);
203
+ if (opts.env) {
204
+ for (const [k, v] of Object.entries(opts.env)) params.append("env", `${k}=${v}`);
205
+ }
193
206
  return `${wsBase}/v1/sprites/${encodeURIComponent(id)}/exec?${params.toString()}`;
194
207
  }
195
208
 
@@ -281,8 +294,16 @@ export interface SpriteExecArgs {
281
294
  id: string;
282
295
  /** Command to run inside the sprite (tokenized into argv, quotes respected). */
283
296
  cmd: string;
284
- /** Per-exec timeout in ms. */
297
+ /**
298
+ * Per-exec timeout in ms (#2765). When it elapses before the command exits,
299
+ * the exec WebSocket is aborted and the activity throws a timeout error —
300
+ * previously declared and never enforced.
301
+ */
285
302
  timeoutMs?: number;
303
+ /** Extra environment variables for the command, on top of the sprite's own (#2765). */
304
+ env?: Record<string, string>;
305
+ /** Working directory to run the command in (#2765). */
306
+ dir?: string;
286
307
  endpoint?: string;
287
308
  token?: string;
288
309
  }
@@ -405,15 +426,20 @@ export async function spriteCreate(
405
426
 
406
427
  /**
407
428
  * Run a command in the sprite over the control WebSocket (non-PTY stream
408
- * framing, per superfly/sprites-go). Connects to `wss://.../exec`, sends a
409
- * single `[4]` (stdin EOF), accumulates stdout/stderr frames, and reads the
410
- * exit code from the `[3]` frame. A non-zero exit is a failed activity (it
411
- * throws) so a risky step fails its phase and triggers `onFailure`
412
- * compensation (S5).
429
+ * framing, per superfly/sprites-go). Connects to `wss://.../exec` (`dir`/`env`
430
+ * riding on the URL — #2765), sends a single `[4]` (stdin EOF), accumulates
431
+ * stdout/stderr frames, and reads the exit code from the `[3]` frame. A
432
+ * non-zero exit is a failed activity (it throws) so a risky step fails its
433
+ * phase and triggers `onFailure` compensation (S5).
434
+ *
435
+ * `timeoutMs` (#2765) bounds the whole exec: a timer set at connect time
436
+ * terminates the WebSocket and rejects with a timeout error once it elapses
437
+ * without the command finishing. Previously declared on `SpriteExecArgs` and
438
+ * never read.
413
439
  */
414
440
  export async function spriteExec(args: SpriteExecArgs, signal?: AbortSignal): Promise<SpriteExecResult> {
415
441
  const base = resolveSpritesEndpoint(args);
416
- const url = spriteExecWsUrl(base, args.id, args.cmd);
442
+ const url = spriteExecWsUrl(base, args.id, args.cmd, { env: args.env, dir: args.dir });
417
443
  const token = resolveSpritesToken(args.token);
418
444
  const headers: Record<string, string> = {};
419
445
  if (token) headers.Authorization = `Bearer ${token}`;
@@ -423,10 +449,12 @@ export async function spriteExec(args: SpriteExecArgs, signal?: AbortSignal): Pr
423
449
  const frames: Uint8Array[] = [];
424
450
  const ws = new WebSocket(url, { headers });
425
451
  let settled = false;
452
+ let timer: ReturnType<typeof setTimeout> | undefined;
426
453
  const finish = (fn: () => void): void => {
427
454
  if (settled) return;
428
455
  settled = true;
429
456
  if (signal) signal.removeEventListener("abort", onAbort);
457
+ if (timer !== undefined) clearTimeout(timer);
430
458
  fn();
431
459
  };
432
460
  const onAbort = (): void => {
@@ -441,6 +469,18 @@ export async function spriteExec(args: SpriteExecArgs, signal?: AbortSignal): Pr
441
469
  if (signal.aborted) return onAbort();
442
470
  signal.addEventListener("abort", onAbort);
443
471
  }
472
+ if (args.timeoutMs !== undefined) {
473
+ timer = setTimeout(() => {
474
+ try {
475
+ ws.terminate();
476
+ } catch {
477
+ /* already closed */
478
+ }
479
+ finish(() =>
480
+ reject(new Error(`sprite ${args.id} exec "${args.cmd}" timed out after ${args.timeoutMs}ms`)),
481
+ );
482
+ }, args.timeoutMs);
483
+ }
444
484
  ws.on("open", () => {
445
485
  // No stdin: signal EOF immediately (belt and braces with stdin=false).
446
486
  ws.send(Uint8Array.of(STREAM_STDIN_EOF));
@@ -0,0 +1,71 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import {
3
+ ConvergeOp,
4
+ eq,
5
+ isActivityContract,
6
+ loadActivityContracts,
7
+ run,
8
+ validateActivitySteps,
9
+ validateStepOutputRefs,
10
+ when,
11
+ type ActivityContract,
12
+ type ResourceSymptom,
13
+ } from "@intentius/chant/op";
14
+ import * as contracts from "./activity-contracts";
15
+ import * as activities from "./activities";
16
+ import { spriteServicesObserve, spriteServiceRestart } from "./builders";
17
+
18
+ const CONTRACTS: Map<string, ActivityContract> = new Map(
19
+ Object.values(contracts).filter(isActivityContract).map((c) => [c.name, c]),
20
+ );
21
+
22
+ const op = (steps: unknown[]) => ({ name: "restart-service", phases: [{ name: "Phase", steps: steps as never[] }] });
23
+
24
+ describe("fly activity contracts (#2843)", () => {
25
+ test("the module exports nothing but contracts, each for an exported activity, each with a return schema", () => {
26
+ for (const [key, value] of Object.entries(contracts)) {
27
+ expect(isActivityContract(value), `${key} is not an ActivityContract`).toBe(true);
28
+ const c = value as ActivityContract;
29
+ expect(typeof (activities as unknown as Record<string, unknown>)[c.name], `${c.name} is not an exported activity`).toBe("function");
30
+ expect(c.returns, `${c.name} declares no return schema`).toBeDefined();
31
+ }
32
+ expect([...CONTRACTS.keys()].sort()).toEqual(["spriteServiceRestart", "spriteServicesObserve"]);
33
+ });
34
+
35
+ test("a ConvergeOp observing spriteServicesObserve passes OPS012 and OPS013 (the guide's example)", () => {
36
+ const { op: converge } = ConvergeOp({
37
+ name: "converge",
38
+ env: "box",
39
+ dial: "apply",
40
+ schedule: "* * * * *",
41
+ observe: spriteServicesObserve({ servicesFile: "services.json" }),
42
+ rules: [when<ResourceSymptom>(eq("status", "drifted"), run("restart-service"), { id: "restart-drifted", why: "restart it" })],
43
+ });
44
+ const config = converge.props as never;
45
+ expect(validateActivitySteps(config, CONTRACTS)).toEqual([]);
46
+ expect(validateStepOutputRefs(config, CONTRACTS)).toEqual([]);
47
+ });
48
+
49
+ test("without the contracts, the same Op fails OPS013 (the bug)", () => {
50
+ const { op: converge } = ConvergeOp({
51
+ name: "converge",
52
+ env: "box",
53
+ observe: spriteServicesObserve({ servicesFile: "services.json" }),
54
+ rules: [when<ResourceSymptom>(eq("status", "drifted"), run("restart-service"), { id: "restart-drifted", why: "restart it" })],
55
+ });
56
+ const issues = validateStepOutputRefs(converge.props as never, new Map());
57
+ expect(issues.some((i) => i.message.includes("spriteServicesObserve"))).toBe(true);
58
+ });
59
+
60
+ test("the restart step's args validate, and a misspelled key is an error", () => {
61
+ expect(validateActivitySteps(op([spriteServiceRestart({ servicesFile: "services.json", waitMs: 30_000 })]), CONTRACTS)).toEqual([]);
62
+ const issues = validateActivitySteps(op([{ kind: "activity", fn: "spriteServiceRestart", args: { servicefile: "services.json" } }]), CONTRACTS);
63
+ expect(issues.some((i) => i.message.includes("servicefile"))).toBe(true);
64
+ });
65
+
66
+ test("loadActivityContracts finds them at @intentius/chant-lexicon-fly/op/activity-contracts", async () => {
67
+ const loaded = await loadActivityContracts(["fly"]);
68
+ expect(loaded.get("spriteServicesObserve")?.returns).toBeDefined();
69
+ expect(loaded.get("spriteServiceRestart")?.returns).toBeDefined();
70
+ });
71
+ });
@@ -0,0 +1,67 @@
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. The other Sprites activities have no contract yet,
16
+ * which OPS012 skips rather than flags.
17
+ *
18
+ * Each `args` schema mirrors the `*Args` interface in
19
+ * `./activities/sprite-service-converge.ts` and each `returns` schema the
20
+ * `*Result`. Authored with `z.strictObject(...)`, so a misspelled key fails
21
+ * the build instead of vanishing.
22
+ */
23
+
24
+ import { z } from "zod";
25
+ import { activityContract } from "@intentius/chant/op";
26
+
27
+ const declaredService = z.strictObject({
28
+ name: z.string(),
29
+ health: z.string().optional(),
30
+ optional: z.boolean().optional(),
31
+ });
32
+
33
+ /** `Via`: the sprite for the Sprites API, or `sprite-env` inside the sprite. */
34
+ const via = {
35
+ id: z.string().optional(),
36
+ spriteEnv: z.string().optional(),
37
+ endpoint: z.string().optional(),
38
+ token: z.string().optional(),
39
+ };
40
+
41
+ export const spriteServicesObserveContract = activityContract(
42
+ "spriteServicesObserve",
43
+ z.strictObject({
44
+ ...via,
45
+ services: z.array(declaredService).optional(),
46
+ servicesFile: z.string().optional(),
47
+ probes: z.number().optional(),
48
+ probeIntervalMs: z.number().optional(),
49
+ }),
50
+ z.object({
51
+ resources: z.array(z.object({ name: z.string(), status: z.enum(["in-sync", "drifted", "unknown"]), detail: z.string() })),
52
+ skipped: z.array(z.string()),
53
+ }),
54
+ );
55
+
56
+ export const spriteServiceRestartContract = activityContract(
57
+ "spriteServiceRestart",
58
+ z.strictObject({
59
+ ...via,
60
+ name: z.string().optional(),
61
+ health: z.string().optional(),
62
+ services: z.array(declaredService).optional(),
63
+ servicesFile: z.string().optional(),
64
+ waitMs: z.number().optional(),
65
+ }),
66
+ z.object({ name: z.string(), healthy: z.boolean().nullable() }),
67
+ );
@@ -40,6 +40,7 @@ import type {
40
40
  SpriteServiceDeleteArgs,
41
41
  SpriteServiceLogsArgs,
42
42
  } from "./activities/sprite-services";
43
+ import type { SpriteServicesObserveArgs, SpriteServiceRestartArgs } from "./activities/sprite-service-converge";
43
44
  import type { SpriteTaskCreateArgs, SpriteTaskRefreshArgs, SpriteTaskReleaseArgs } from "./activities/sprite-tasks";
44
45
  import type { SpritesUpArgs, SpritesDownArgs } from "./activities/sprites-emulator";
45
46
  import type {
@@ -49,6 +50,8 @@ import type {
49
50
  FlyMachineVerifyArgs,
50
51
  FlyMachineRestoreArgs,
51
52
  } from "./activities/machine-release";
53
+ import type { FlyReleaseArgs } from "./activities/fly-release-step";
54
+ import type { FlyRollbackArgs } from "./activities/fly-rollback-step";
52
55
 
53
56
  type StepOpts = { profile?: ActivityStep["profile"] };
54
57
 
@@ -121,6 +124,19 @@ export const spriteServiceStop = spriteStep<SpriteServiceStopArgs>("spriteServic
121
124
  export const spriteServiceDelete = spriteStep<SpriteServiceDeleteArgs>("spriteServiceDelete", "fastIdempotent");
122
125
  /** Read a background service's log tail (#2711). Defaults to the `fastIdempotent` profile. */
123
126
  export const spriteServiceLogs = spriteStep<SpriteServiceLogsArgs>("spriteServiceLogs", "fastIdempotent");
127
+ /**
128
+ * Observe a box's declared services for a ConvergeOp (#2778): one verdict per
129
+ * service, `in-sync`, `drifted` or `unknown`, from the supervisor's list and
130
+ * each service's health URL. Pass it as `ConvergeOp({ observe })`. Defaults
131
+ * to the `fastIdempotent` profile.
132
+ */
133
+ export const spriteServicesObserve = spriteStep<SpriteServicesObserveArgs>("spriteServicesObserve", "fastIdempotent");
134
+ /**
135
+ * Restart one service through its supervisor and wait for its health URL
136
+ * (#2778). Without `name`, the service is the one a ConvergeOp rule
137
+ * dispatched the run for. Defaults to the `longInfra` profile.
138
+ */
139
+ export const spriteServiceRestart = spriteStep<SpriteServiceRestartArgs>("spriteServiceRestart", "longInfra");
124
140
  /** Create a keep-alive task — the fully typed twin of core's `spriteTaskCreate`. Defaults to the `fastIdempotent` profile. */
125
141
  export const spriteTaskCreate = spriteStep<SpriteTaskCreateArgs>("spriteTaskCreate", "fastIdempotent");
126
142
  /** Refresh a keep-alive task's expiry — the fully typed twin of core's `spriteTaskRefresh`. Defaults to the `fastIdempotent` profile. */
@@ -152,3 +168,19 @@ export const flyMachineStop = spriteStep<FlyMachineStateArgs>("flyMachineStop",
152
168
  export const flyMachineVerify = spriteStep<FlyMachineVerifyArgs>("flyMachineVerify", "longInfra");
153
169
  /** Put a recorded Machine config back (restore, rollback). Defaults to the `longInfra` profile. */
154
170
  export const flyMachineRestore = spriteStep<FlyMachineRestoreArgs>("flyMachineRestore", "longInfra");
171
+ /**
172
+ * Ship a release to a Fly Machine from an Op (#2782): the `fly-release`
173
+ * capability's steps (upload and start, each migration once per environment,
174
+ * verify, restore on failure), for the environment `environment` names. With `source`
175
+ * it puts an approved source tree on the Machine. Defaults to the `longInfra`
176
+ * profile.
177
+ */
178
+ export const flyRelease = spriteStep<FlyReleaseArgs>("flyRelease", "longInfra");
179
+ /**
180
+ * Put an earlier release back on a Fly Machine from an Op (#2800): the
181
+ * `fly-rollback` capability's restore of the Machine config recorded for `to`
182
+ * in `environment`, then its verify. With `source` the archive is checked
183
+ * against its digest, and the recorded config against the archive's tree,
184
+ * before the Machine changes. Defaults to the `longInfra` profile.
185
+ */
186
+ export const flyRollback = spriteStep<FlyRollbackArgs>("flyRollback", "longInfra");
@@ -86,3 +86,70 @@ The step's output carries `uri` and `digest`, so `chant run --components web --e
86
86
  Each release's Machine config is kept on `chant/lifecycle` at `<env>/fly/<app>/<machine>/<digest>.json`. `fly-rollback` puts back the config of the release the serving one replaced (or the digest given as `to`), checks it, and outputs that digest so the ledger records it again. `fly-release`'s own saga compensation does the same.
87
87
 
88
88
  The steps are also Op activities, for an Op that composes them itself: `flyMachineRelease`, `flyMachineExec`, `flyMachineRestart`, `flyMachineStop`, `flyMachineVerify` and `flyMachineRestore`. Wrap a migration's `flyMachineExec` in `effect()` so it fires once.
89
+
90
+ ### A source tree instead of an image
91
+
92
+ An app with no image of its own ships as its files on the declared runtime image. Give `fly-release` a `source` in place of `image`: an archive made by core's `sourceArchive` step (`git archive` of one directory of a commit, the same bytes for the same commit), the sha256 it must have, the directory it holds, where the files go (default `/srv/app`) and the command that starts the app there. The archive is read only once its bytes hash to that digest, before the Machine changes, so the Machine never gets a tree nobody approved. The files go into the Machine's config, up to 1 MiB base64. A larger app needs an image.
93
+
94
+ An Op runs the same steps with the `flyRelease` activity, which takes the environment it ships to as `environment` (`env` stays the Machine's env vars). A release Op gates on the plan core's `releasePlan` writes and records the release with `releaseRecord`:
95
+
96
+ ```ts
97
+ import { Op, phase, gate, build, sourceArchive, releasePlan, releaseRecord } from "@intentius/chant/op";
98
+ import { flyRelease } from "@intentius/chant-lexicon-fly";
99
+
100
+ const archive = sourceArchive("../app", { id: "archive" });
101
+ const plan = releasePlan({ id: "plan", component: "app", env: "fly", gitSha: archive.out.commit, content: { artifact: { digest: archive.out.digest } } });
102
+
103
+ export default Op({
104
+ name: "release",
105
+ overview: "Ship the app member to Fly once its plan is approved",
106
+ phases: [
107
+ phase("Build", [archive, build(".", { script: "build:fly" })]),
108
+ phase("Plan", [plan]),
109
+ phase("Gate", [gate("ship", { plan: plan.out.digest })]),
110
+ phase("Ship", [
111
+ flyRelease({
112
+ environment: "fly",
113
+ plan: "dist/fly.json",
114
+ digest: plan.out.digest,
115
+ gitSha: archive.out.commit,
116
+ source: { archive: archive.out.archive, digest: archive.out.digest, dir: archive.out.dir, start: "node server.js" },
117
+ }),
118
+ ]),
119
+ phase("Record", [releaseRecord({ plan: plan.out.file, digest: plan.out.digest, approval: { op: "release", gate: "ship" } })]),
120
+ ],
121
+ });
122
+ ```
123
+
124
+ The Machine's metadata names the plan's digest, and so does the ledger record, so `chant components status fly --live` reconciles them. Running the Op again for the same commit plans the same digest, leaves the Machine as it is, and records nothing twice.
125
+
126
+ ### Rolling a source release back
127
+
128
+ A rollback Op takes the site back to the release it served before the latest one. Core's `releaseRollbackPlan` reads the release ledger, reads the earlier release's plan back, archives the same directory of the same commit again, and refuses unless the archive hashes to the digest that plan recorded. The gate binds to the rollback plan's digest. `flyRollback` puts back the Machine config `flyRelease` recorded for that release. It checks the archive against its digest before any flaps call, and checks that the recorded config carries exactly that tree. `releaseRollbackRecord` appends the restored release with `restores`, the actor and the approver:
129
+
130
+ ```ts
131
+ import { Op, phase, gate, build, releaseRollbackPlan, releaseRollbackRecord } from "@intentius/chant/op";
132
+ import { flyRollback } from "@intentius/chant-lexicon-fly";
133
+
134
+ const plan = releaseRollbackPlan({ id: "plan", component: "app", env: "fly" });
135
+
136
+ export default Op({
137
+ name: "rollback",
138
+ overview: "Put the previous release back on Fly once the rollback plan is approved",
139
+ phases: [
140
+ phase("Plan", [plan, build(".", { script: "build:fly" })]),
141
+ phase("Gate", [gate("rollback", { plan: plan.out.digest })]),
142
+ phase("Roll back", [
143
+ flyRollback({
144
+ environment: "fly",
145
+ plan: "dist/fly.json",
146
+ to: plan.out.to,
147
+ source: { archive: plan.out.archive, digest: plan.out.archiveDigest, dir: plan.out.dir },
148
+ }),
149
+ ]),
150
+ phase("Record", [releaseRollbackRecord({ plan: plan.out.file, digest: plan.out.digest, approval: { op: "rollback", gate: "rollback" } })]),
151
+ ],
152
+ });
153
+ ```
154
+
155
+ A rollback's own ledger record is not counted as a release, so running the Op again plans the same rollback, leaves the Machine as it is and records nothing. `to: "sha256:..."` on `releaseRollbackPlan` picks another release. Migrations are not undone.
@@ -17,7 +17,7 @@ Each activity is a direct REST call over an injectable HTTP client, imported fro
17
17
  | Activity | What it does |
18
18
  |----------|--------------|
19
19
  | `spriteCreate` | Create a sandbox. The caller-chosen `name` becomes the sprite `id` that every later activity keys on |
20
- | `spriteExec` | Run a command inside the sprite. A non-zero exit throws, so the phase fails and any `onFailure` compensation runs |
20
+ | `spriteExec` | Run a command inside the sprite, with optional `env`, `dir` and `timeoutMs` (#2765). A non-zero exit or a `timeoutMs` overrun throws, so the phase fails and any `onFailure` compensation runs |
21
21
  | `spriteCheckpoint` | Snapshot the sprite under a caller-chosen `label` |
22
22
  | `spriteRestore` | Rewind the sprite to a labeled checkpoint |
23
23
  | `spriteDestroy` | Destroy the sprite (idempotent; an already-gone sprite is a no-op) |
@@ -106,6 +106,7 @@ The same lexicon ships more Sprite primitives, all imported from `@intentius/cha
106
106
  | Filesystem (#848) | `spriteWriteFile` / `spriteReadFile` / `spriteListDir` / `spriteRemove` | stage an input file and read a result out without shelling `spriteExec` + `cat` |
107
107
  | Config reconcile (#849) | `spriteApplyNetworkPolicy` / `spriteApplyServices` | reconcile a Sprite's egress allowlist and background services against typed config (validated before any HTTP; a whole-object replace for policy, create-or-update by name for services) |
108
108
  | Services (#2711) | `spriteServiceCreate` / `spriteServiceGet` / `spriteServiceList` / `spriteServiceStart` / `spriteServiceStop` / `spriteServiceDelete` / `spriteServiceLogs` | the single-service primitives underneath `spriteApplyServices` — create-and-start one long-lived service (a box's door, hud or chud), inspect or list what's running, stop/start or delete one by name, read its log tail |
109
+ | Converge (#2778) | `spriteServicesObserve` / `spriteServiceRestart` | keep a box's declared services running with a `ConvergeOp`: the observer step gives one verdict per service (`in-sync`, `drifted`, `unknown`) from the supervisor and each health URL, and the restart is the step of the Op a rule dispatches for the drifted service. Inside the sprite they call `sprite-env services`; with a sprite `id` they use the API |
109
110
  | Sprite URL / delete (#2711) | `spriteUrl` / `spriteDelete` | resolve a Sprite's URL, optionally waiting until a path on it answers; delete the Sprite (`spriteDelete` is `spriteDestroy` under the name spritzer/wisp and the other lexicons' delete activities use — same call, either name) |
110
111
  | Keep-alive (#847) | `spriteTaskCreate` / `spriteTaskRefresh` / `spriteTaskRelease` | hold a Sprite active for a session so it will not pause; a session past the 1-hour task cap refreshes on an interval |
111
112