@intentius/chant 0.94.0 → 0.95.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 (110) hide show
  1. package/dist/cli/handlers/operator.d.ts.map +1 -1
  2. package/dist/cli/handlers/run.d.ts.map +1 -1
  3. package/dist/cli/main.d.ts.map +1 -1
  4. package/dist/cli/registry.d.ts +2 -0
  5. package/dist/cli/registry.d.ts.map +1 -1
  6. package/dist/op/builders.d.ts +14 -3
  7. package/dist/op/builders.d.ts.map +1 -1
  8. package/dist/op/index.d.ts +6 -3
  9. package/dist/op/index.d.ts.map +1 -1
  10. package/dist/op/operator.d.ts +90 -0
  11. package/dist/op/operator.d.ts.map +1 -1
  12. package/dist/op/steward-beside.d.ts +84 -0
  13. package/dist/op/steward-beside.d.ts.map +1 -0
  14. package/dist/op/steward.d.ts +87 -2
  15. package/dist/op/steward.d.ts.map +1 -1
  16. package/dist/workspace/box-services.d.ts +31 -0
  17. package/dist/workspace/box-services.d.ts.map +1 -0
  18. package/dist/workspace/compose-graph.d.ts +11 -0
  19. package/dist/workspace/compose-graph.d.ts.map +1 -1
  20. package/dist/workspace/composites.d.ts +5 -1
  21. package/dist/workspace/composites.d.ts.map +1 -1
  22. package/dist/workspace/declaration.d.ts +37 -0
  23. package/dist/workspace/declaration.d.ts.map +1 -1
  24. package/dist/workspace/declaration.schema.json +57 -1
  25. package/dist/workspace/graph-cache.d.ts +168 -0
  26. package/dist/workspace/graph-cache.d.ts.map +1 -0
  27. package/dist/workspace/graph-cli.d.ts +11 -5
  28. package/dist/workspace/graph-cli.d.ts.map +1 -1
  29. package/dist/workspace/kind-readers.d.ts +39 -0
  30. package/dist/workspace/kind-readers.d.ts.map +1 -0
  31. package/dist/workspace/kinds.d.ts +29 -0
  32. package/dist/workspace/kinds.d.ts.map +1 -1
  33. package/dist/workspace/member-commands.d.ts +15 -1
  34. package/dist/workspace/member-commands.d.ts.map +1 -1
  35. package/dist/workspace/member-run.d.ts +2 -0
  36. package/dist/workspace/member-run.d.ts.map +1 -1
  37. package/dist/workspace/reason-codes.d.ts +3 -1
  38. package/dist/workspace/reason-codes.d.ts.map +1 -1
  39. package/dist/workspace/records-cli.d.ts +10 -1
  40. package/dist/workspace/records-cli.d.ts.map +1 -1
  41. package/dist/workspace/records-write.d.ts +4 -2
  42. package/dist/workspace/records-write.d.ts.map +1 -1
  43. package/dist/workspace/records.d.ts +8 -3
  44. package/dist/workspace/records.d.ts.map +1 -1
  45. package/dist/workspace/status-stewards.d.ts +23 -7
  46. package/dist/workspace/status-stewards.d.ts.map +1 -1
  47. package/dist/workspace/status.d.ts +12 -0
  48. package/dist/workspace/status.d.ts.map +1 -1
  49. package/dist/workspace/work-evidence.d.ts +1 -1
  50. package/dist/workspace/work-evidence.d.ts.map +1 -1
  51. package/dist/workspace/workspace-kinds.schema.json +26 -0
  52. package/package.json +1 -1
  53. package/src/cli/commands/carve-bridge.test.ts +7 -3
  54. package/src/cli/handlers/operator-steward-signal.e2e.test.ts +97 -0
  55. package/src/cli/handlers/operator.ts +53 -11
  56. package/src/cli/handlers/run.test.ts +71 -0
  57. package/src/cli/handlers/run.ts +65 -7
  58. package/src/cli/main.ts +17 -6
  59. package/src/cli/mcp/workspace-tools.ts +1 -1
  60. package/src/cli/registry.ts +2 -0
  61. package/src/cli/static-config-read.test.ts +8 -2
  62. package/src/meta/source-is-text.test.ts +21 -3
  63. package/src/okf.test.ts +6 -1
  64. package/src/op/builders.ts +14 -3
  65. package/src/op/index.ts +8 -2
  66. package/src/op/operator.ts +264 -16
  67. package/src/op/steward-beside.test.ts +267 -0
  68. package/src/op/steward-beside.ts +219 -0
  69. package/src/op/steward-points.test.ts +61 -1
  70. package/src/op/steward.ts +135 -3
  71. package/src/workspace/box-services.test.ts +129 -0
  72. package/src/workspace/box-services.ts +51 -0
  73. package/src/workspace/checks/boxes.test.ts +1 -0
  74. package/src/workspace/compose-graph.test.ts +1 -0
  75. package/src/workspace/compose-graph.ts +11 -0
  76. package/src/workspace/composites.test.ts +1 -1
  77. package/src/workspace/composites.ts +12 -5
  78. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +4 -2
  79. package/src/workspace/declaration.schema.json +57 -1
  80. package/src/workspace/declaration.ts +104 -0
  81. package/src/workspace/graph-cache.test.ts +343 -0
  82. package/src/workspace/graph-cache.ts +409 -0
  83. package/src/workspace/graph-cli.ts +93 -26
  84. package/src/workspace/graph-contract.test.ts +129 -6
  85. package/src/workspace/graph.schema.json +21 -1
  86. package/src/workspace/kind-readers.e2e.test.ts +68 -0
  87. package/src/workspace/kind-readers.test.ts +134 -0
  88. package/src/workspace/kind-readers.ts +111 -0
  89. package/src/workspace/kinds.test.ts +49 -0
  90. package/src/workspace/kinds.ts +59 -2
  91. package/src/workspace/member-commands.test.ts +15 -0
  92. package/src/workspace/member-commands.ts +47 -7
  93. package/src/workspace/member-run.ts +10 -2
  94. package/src/workspace/reason-codes.ts +3 -1
  95. package/src/workspace/records-amend.schema.json +1 -0
  96. package/src/workspace/records-cli.ts +20 -12
  97. package/src/workspace/records-contract.test.ts +2 -1
  98. package/src/workspace/records-new.schema.json +1 -0
  99. package/src/workspace/records-quorum.test.ts +10 -3
  100. package/src/workspace/records-sessions-write.test.ts +2 -1
  101. package/src/workspace/records-write.test.ts +101 -1
  102. package/src/workspace/records-write.ts +50 -6
  103. package/src/workspace/records.ts +22 -5
  104. package/src/workspace/status-contract.test.ts +31 -1
  105. package/src/workspace/status-stewards.ts +39 -6
  106. package/src/workspace/status.schema.json +49 -4
  107. package/src/workspace/status.ts +22 -0
  108. package/src/workspace/trust/record-seal.test.ts +26 -2
  109. package/src/workspace/work-evidence.schema.json +1 -0
  110. package/src/workspace/workspace-kinds.schema.json +26 -0
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The services a box declares, read for the process that runs in the box
3
+ * (#2880).
4
+ *
5
+ * A box member's block in the declaration lists the services the box runs
6
+ * under its supervisor (`BoxService` in `declaration.ts`). The fly lexicon's
7
+ * `spriteServicesObserve`, `spriteServiceRestart` and `spriteApplyServices`
8
+ * take `box: true` to read that list instead of one passed to them: the list
9
+ * of the member whose directory holds the working directory. They import
10
+ * this module when they run, so it stays small: it finds the workspace root,
11
+ * reads the declaration from the working tree and picks the member.
12
+ */
13
+
14
+ import { realpathSync } from "node:fs";
15
+ import { relative } from "node:path";
16
+ import { findWorkspaceRoot } from "../project-root";
17
+ import { ownerOf, readDeclaration, WorkspaceReadError, type BoxService } from "./declaration";
18
+ import { workingTree } from "./tree";
19
+
20
+ export type { BoxService } from "./declaration";
21
+
22
+ /** The box block's services, and whose they are. */
23
+ export interface DeclaredBoxServices {
24
+ /** The workspace root, absolute. */
25
+ root: string;
26
+ /** The member whose directory holds the working directory. */
27
+ member: string;
28
+ /** In file order. */
29
+ services: BoxService[];
30
+ }
31
+
32
+ /**
33
+ * The services of the box block of the member whose directory holds `cwd`.
34
+ * Throws a {@link WorkspaceReadError} when there is no workspace or its
35
+ * declaration can't be read, and an Error when no member holds `cwd` or the
36
+ * member has no box block. A block with no `services` gives an empty list.
37
+ */
38
+ export function readBoxServices(cwd: string = process.cwd()): DeclaredBoxServices {
39
+ const found = findWorkspaceRoot(cwd);
40
+ if (!found) {
41
+ throw new WorkspaceReadError("declaration-missing", `no chant.workspace.json or .jsonc between ${cwd} and the git root, so there is no box block to read services from`);
42
+ }
43
+ const root = realpathSync(found.dir);
44
+ const declaration = readDeclaration(workingTree(root));
45
+ const path = relative(root, realpathSync(cwd)).split("\\").join("/") || ".";
46
+ const owner = ownerOf(declaration, [], path);
47
+ if (!owner || !("member" in owner)) throw new Error(`no member of the workspace at ${root} holds ${cwd}, so there is no box block to read services from`);
48
+ const m = owner.member;
49
+ if (!m.box) throw new Error(`member ${m.name} (${m.dir}) has no box block in ${declaration.file}, so it declares no services`);
50
+ return { root, member: m.name, services: m.box.services };
51
+ }
@@ -157,6 +157,7 @@ describe("the box block in the declaration", () => {
157
157
  pointer: "/members/0/box",
158
158
  isolation: null,
159
159
  intent: null,
160
+ services: [],
160
161
  capabilities: [
161
162
  { name: "fountain", broker: "lobby", scope: ["agent", "vault"], pointer: "/members/0/box/capabilities/0" },
162
163
  { name: "inference", broker: null, scope: [], pointer: "/members/0/box/capabilities/1" },
@@ -17,6 +17,7 @@ const member = (name: string, dir: string): ComposedMember => ({
17
17
  reason: null,
18
18
  chant: "0.80.0",
19
19
  irVersion: 1,
20
+ live: false,
20
21
  });
21
22
 
22
23
  const web: GraphIR = {
@@ -76,6 +76,17 @@ export interface ComposedMember {
76
76
  chant: string | null;
77
77
  /** The IR version the member printed; `null` when it printed none and was upgraded as version 1. */
78
78
  irVersion: number | null;
79
+ /** Whether the member was read with `--live` (#2875): its graph is the account as it stands, not its source. */
80
+ live: boolean;
81
+ /** For a live read, when the member's read finished, as an ISO time. */
82
+ readAt?: string;
83
+ /**
84
+ * Whether the per-member cache answered this read (#2876). Set on composed
85
+ * members by `chant workspace graph`.
86
+ */
87
+ cached?: boolean;
88
+ /** The member's stamp (#2876): what the cache keys the read on, or null when none could be taken. */
89
+ stamp?: string | null;
79
90
  /** Whole-read facts the member's IR carried (`meta`, `pipeline`), kept apart from the composed sections. */
80
91
  meta?: Record<string, unknown>;
81
92
  pipeline?: unknown;
@@ -113,7 +113,7 @@ describe("composites output schema", () => {
113
113
  test("lists exactly the reason and error codes the code can return", () => {
114
114
  expect(schema.$defs.reason.properties.code.enum).toEqual([...COMPOSITES_REASON_CODES]);
115
115
  expect(schema.$defs.failure.properties.error.properties.code.enum).toEqual([...COMPOSITES_ERROR_CODES]);
116
- expect(COMPOSITES_ERROR_CODES).toEqual(GRAPH_ERROR_CODES);
116
+ expect(COMPOSITES_ERROR_CODES).toEqual(GRAPH_ERROR_CODES.filter((c) => c !== "live-at-revision"));
117
117
  expect(schema.$defs.member.properties.reason.oneOf[1].properties!.code.enum).toEqual([...MEMBER_RUN_REASON_CODES]);
118
118
  expect(schema.$defs.member.properties.runtimeReasons.items.properties.code.enum).toEqual([...COMPOSITES_RUNTIME_REASON_CODES]);
119
119
  expect(schema.$defs.member.properties.environmentReasons.items.properties.code.enum).toEqual([...COMPOSITES_ENVIRONMENT_REASON_CODES]);
@@ -44,10 +44,10 @@ import type { CommandContext } from "../cli/registry";
44
44
  import { joinLabel, type JoinLabel } from "../join-key";
45
45
  import type { ComposedMember, MemberReason, WorkspaceGraph } from "./compose-graph";
46
46
  import { readMemberIr } from "./compose-graph";
47
- import { GRAPH_ERROR_CODES, workspaceGraph, type GraphQuery } from "./graph-cli";
47
+ import { workspaceGraph, type GraphQuery } from "./graph-cli";
48
48
  import type { LinkRow } from "./links";
49
49
  import { emitDocument, type UnitResult } from "./member-commands";
50
- import { readerVersion, type ErrorLocation, type WorkspaceErrorCode } from "./declaration";
50
+ import { readerVersion, WORKSPACE_ERROR_CODES, type ErrorLocation, type WorkspaceErrorCode } from "./declaration";
51
51
  import type { ReasonCode } from "./reason-codes";
52
52
  import { componentEnvironments, ENVIRONMENT_REASON_CODES, memberEnvironments, readLedgerEnvironments, type ComponentEnvironment, type EnvironmentReason, type LedgerEnvironmentReader, type MemberEnvironments } from "./environments";
53
53
  import { componentRuntimes, readRuntimesIn, RUNTIME_REASON_CODES, type ComponentRuntime, type MemberRuntimes, type PluginLoader, type RuntimeReason } from "./runtimes";
@@ -58,8 +58,12 @@ export const COMPOSITES_OUTPUT_SCHEMA_ID = "https://intentius.io/chant/schemas/w
58
58
  /** The read-contract version the document follows. */
59
59
  export const COMPOSITES_CONTRACT_VERSION = 1;
60
60
 
61
- /** Why the composites couldn't be read at all: the graph's codes. */
62
- export const COMPOSITES_ERROR_CODES = GRAPH_ERROR_CODES;
61
+ /**
62
+ * Why the composites couldn't be read at all: the declaration's codes, as for
63
+ * the graph. The composites never read live, so the graph's own
64
+ * `live-at-revision` (#2875) can't reach them.
65
+ */
66
+ export const COMPOSITES_ERROR_CODES = WORKSPACE_ERROR_CODES;
63
67
 
64
68
  /** Why the list is empty, or why no instance has a component. Closed: part of the read contract. */
65
69
  export const COMPOSITES_REASON_CODES = [
@@ -262,12 +266,15 @@ export async function workspaceComposites(
262
266
  const { loadPlugin, readLedgerEnvironments: readLedger = readLedgerEnvironments, ...graphQuery } = query;
263
267
  const { doc: graph, failed, components: runs } = await workspaceGraph({
264
268
  ...graphQuery,
269
+ // The composites read source only; a live flag never reaches the members.
270
+ ...(graphQuery.args ? { args: { ...graphQuery.args, live: false, overlay: false, traffic: undefined } } : {}),
265
271
  components: true,
266
272
  inTree: async (root, members) => {
267
273
  runtimes = await readRuntimesIn(root, members, loadPlugin);
268
274
  },
269
275
  });
270
- if ("error" in graph) return { doc: { ...head, error: graph.error }, failed: true };
276
+ // With no live read, the graph fails only with a declaration code.
277
+ if ("error" in graph) return { doc: { ...head, error: { ...graph.error, code: graph.error.code as WorkspaceErrorCode } }, failed: true };
271
278
 
272
279
  const byMember = new Map((runs ?? []).map((r) => [r.unit.member, r]));
273
280
  const members: CompositesMember[] = [];
@@ -33,8 +33,10 @@ export const recordKind = {
33
33
  // (#2773): chant workspace check --changes reports change-out-of-scope.
34
34
  outOfScope: { field: "out_of_scope" },
35
35
  // Verdicts, and the field naming the decider, for each record's digest and
36
- // quorum (#2671, #2672).
37
- reviews: { field: "reviews", decider: "decided_by" },
36
+ // quorum (#2671, #2672). A decision becomes ratified only once its quorum
37
+ // is met: records new and amend refuse it below, and the digest leaves the
38
+ // state out, so ratifying keeps the verdicts counting (#2873).
39
+ reviews: { field: "reviews", decider: "decided_by", ratified: "ratified" },
38
40
  // records new --by and the MCP records-new tool's by name a proposal's
39
41
  // proposer here, apart from decided_by, which stays null until the
40
42
  // decision is decided (#2756).
@@ -402,13 +402,20 @@
402
402
  },
403
403
  "box": {
404
404
  "type": "object",
405
- "description": "The member is a box, or holds a box's declarations (#2726). A box holds no credential: each capability it needs outside itself is reached through a broker, a runtime such as a lobby or a door, which holds the credential and enforces the scope. chant workspace check fails when a file in the member's directory carries a literal secret (WSP121) and when a capability names no broker (WSP122). chant workspace status --json lists the capabilities, so a broker can read the scopes. Its host, slot, ports, state and cookies state the box's isolation (#2727): chant derives each value from the box's identity, its host, the member's name and its slot, and prints them in chant workspace status --json, so a runtime reads them instead of choosing its own. chant workspace check fails when two boxes on a host resolve to the same value (WSP123) and when a box hard-codes a machine path (WSP124). A member that builds the fountain lexicon's Box declares the callback token fountain gives its persistent sandbox as the capability fountain-callback, brokered by fountain, with scope owner, or chant workspace check fails (WSP125, #2780). Its intent names the decision record that says what the box is for (#2850): chant workspace status --json reports the record's state and answer, and chant workspace check fails when no decision record has the id (WSP126) and warns when the record constrains no member or path of this workspace (WSP127, #2857). Added in schema 1 by chant 0.91.0, so a declaration that uses it sets minReader to 0.91.0 or newer.",
405
+ "description": "The member is a box, or holds a box's declarations (#2726). A box holds no credential: each capability it needs outside itself is reached through a broker, a runtime such as a lobby or a door, which holds the credential and enforces the scope. chant workspace check fails when a file in the member's directory carries a literal secret (WSP121) and when a capability names no broker (WSP122). chant workspace status --json lists the capabilities, so a broker can read the scopes. Its host, slot, ports, state and cookies state the box's isolation (#2727): chant derives each value from the box's identity, its host, the member's name and its slot, and prints them in chant workspace status --json, so a runtime reads them instead of choosing its own. chant workspace check fails when two boxes on a host resolve to the same value (WSP123) and when a box hard-codes a machine path (WSP124). A member that builds the fountain lexicon's Box declares the callback token fountain gives its persistent sandbox as the capability fountain-callback, brokered by fountain, with scope owner, or chant workspace check fails (WSP125, #2780). Its intent names the decision record that says what the box is for (#2850): chant workspace status --json reports the record's state and answer, and chant workspace check fails when no decision record has the id (WSP126) and warns when the record constrains no member or path of this workspace (WSP127, #2857). Its services list what the box runs under its supervisor (#2880), which the fly lexicon observes, restarts and applies through sprite-env, and which chant workspace status --json prints. Added in schema 1 by chant 0.91.0, so a declaration that uses it sets minReader to 0.91.0 or newer.",
406
406
  "properties": {
407
407
  "intent": {
408
408
  "type": "string",
409
409
  "minLength": 1,
410
410
  "description": "The id of the decision record that says what the box is for (#2850), a record of a declared kind named decision. A new box starts as a question: the record is proposed with a null choice until the person who answers it decides it. The record constrains a member or path of this workspace, with member:<a member's name> or a path: entry at, above or inside a member's directory. It need not be this member: the intent can constrain the app the box runs when that app is another member (#2857). When it constrains this member, chant workspace graph --intent shows it for the box's files. Added by chant 0.94.0, so a declaration that uses it sets minReader to 0.94.0 or newer."
411
411
  },
412
+ "services": {
413
+ "type": "array",
414
+ "description": "The services the box runs under its supervisor, sprite-env on a sprite (#2880). Each name is given once, every needs entry names a service of this list, the needs form no cycle, and at most one service sets httpPort; a declaration that breaks one of these can't be read (declaration-invalid). The fly lexicon's spriteServicesObserve and spriteServiceRestart read the list with box: true, and spriteApplyServices with box: true creates and replaces the services through sprite-env. None when omitted. Added by chant 0.95.0, so a declaration that uses it sets minReader to 0.95.0 or newer.",
415
+ "items": {
416
+ "$ref": "#/$defs/boxService"
417
+ }
418
+ },
412
419
  "capabilities": {
413
420
  "type": "array",
414
421
  "description": "Each capability the box reaches outside itself, named once. None when omitted.",
@@ -481,6 +488,55 @@
481
488
  },
482
489
  "additionalProperties": false
483
490
  },
491
+ "boxService": {
492
+ "type": "object",
493
+ "description": "A service a box runs under its supervisor (#2880).",
494
+ "required": [
495
+ "name",
496
+ "cmd"
497
+ ],
498
+ "properties": {
499
+ "name": {
500
+ "$ref": "#/$defs/kindName",
501
+ "description": "The service's name in the supervisor. Unique within the box."
502
+ },
503
+ "cmd": {
504
+ "type": "string",
505
+ "minLength": 1,
506
+ "description": "The command the supervisor runs (sprite-env services create --cmd). ${HOME} and other ${VAR} references are expanded from the environment of the process that applies the list, so the declaration holds no machine path."
507
+ },
508
+ "needs": {
509
+ "type": "array",
510
+ "uniqueItems": true,
511
+ "description": "Names of services of the same box that start before this one (sprite-env services create --needs).",
512
+ "items": {
513
+ "$ref": "#/$defs/kindName"
514
+ }
515
+ },
516
+ "httpPort": {
517
+ "$ref": "#/$defs/port",
518
+ "description": "The port the supervisor routes the sprite's URL to (sprite-env services create --http-port). At most one service of a box sets it."
519
+ },
520
+ "duration": {
521
+ "type": "string",
522
+ "pattern": "^[0-9]+(\\.[0-9]+)?(ms|s|m)$",
523
+ "description": "How long the service must stay up after it is created or started, such as 3s (sprite-env services create --duration)."
524
+ },
525
+ "health": {
526
+ "type": "string",
527
+ "pattern": "^https?://",
528
+ "description": "A URL that answers 200 while the service works. spriteServicesObserve probes it, and spriteServiceRestart waits for it after a restart. Without it, the supervisor's state decides."
529
+ },
530
+ "optional": {
531
+ "type": "boolean",
532
+ "description": "True for a service something else creates by name, such as a site a release Op makes: spriteApplyServices creates it only when its only names it, and spriteServicesObserve skips it while the supervisor has no such service."
533
+ }
534
+ },
535
+ "patternProperties": {
536
+ "^x-": true
537
+ },
538
+ "additionalProperties": false
539
+ },
484
540
  "capability": {
485
541
  "type": "object",
486
542
  "description": "A capability a box needs and does not hold the credential for, such as inference, fountain or a third-party API (#2726).",
@@ -216,10 +216,37 @@ export interface BoxDeclaration {
216
216
  * proposed with no choice until the person who answers it decides it.
217
217
  */
218
218
  intent: string | null;
219
+ /**
220
+ * The services the box runs under its supervisor (sprite-env on a sprite),
221
+ * in file order, or empty when the block declares none (#2880). The fly
222
+ * lexicon's `spriteServicesObserve`, `spriteServiceRestart` and
223
+ * `spriteApplyServices` read them with `box: true`.
224
+ */
225
+ services: BoxService[];
219
226
  /** The block's JSON Pointer in the file, for messages. */
220
227
  pointer: string;
221
228
  }
222
229
 
230
+ /** A service a box runs under its supervisor (#2880). */
231
+ export interface BoxService {
232
+ /** Unique in the box. */
233
+ name: string;
234
+ /** The command the supervisor runs, as written: `${VAR}` references are left for the applying process to expand. */
235
+ cmd: string;
236
+ /** Names of services in the same block that start first. Empty when none. */
237
+ needs: string[];
238
+ /** The port the supervisor routes the sprite's URL to, or null. At most one service of a box sets it. */
239
+ httpPort: number | null;
240
+ /** How long the service must stay up after a create or start, such as `3s` (`sprite-env services create --duration`), or null for the supervisor's default. */
241
+ duration: string | null;
242
+ /** A URL that answers 200 while the service works, or null when the supervisor's state decides. */
243
+ health: string | null;
244
+ /** True: an apply creates it only when named, and an observer skips it while the supervisor has no such service. */
245
+ optional: boolean;
246
+ /** The entry's JSON Pointer in the file, for messages. */
247
+ pointer: string;
248
+ }
249
+
223
250
  /** What a box declares about its isolation (#2727): its identity on a host, and the names of what it needs kept apart. */
224
251
  export interface BoxIsolationDeclaration {
225
252
  host: string;
@@ -629,6 +656,9 @@ export function parseDeclaration(text: string, file: string, reader: string = re
629
656
  if (first) throw new WorkspaceReadError("declaration-invalid", `member ${m.name}'s box lists the capability ${c.name} twice; the first is at ${first.pointer}`, at(`${c.pointer}/name`));
630
657
  seen.set(c.name, c);
631
658
  }
659
+ // Its services name each other only within the block, without a cycle (#2880).
660
+ const problem = m.box ? boxServicesProblem(m.name, m.box.services) : null;
661
+ if (problem) throw new WorkspaceReadError("declaration-invalid", problem.message, at(problem.pointer));
632
662
  }
633
663
 
634
664
  // A diagram name is given once across the declaration (#2764): a reader keys diagrams by name.
@@ -720,6 +750,7 @@ function boxOf(raw: unknown, pointer: string): BoxDeclaration | null {
720
750
  state?: Record<string, string>;
721
751
  cookies?: string[];
722
752
  intent?: string;
753
+ services?: { name: string; cmd: string; needs?: string[]; httpPort?: number; duration?: string; health?: string; optional?: boolean }[];
723
754
  };
724
755
  return {
725
756
  capabilities: (b.capabilities ?? []).map((c, i) => ({ name: c.name, broker: c.broker ?? null, scope: [...(c.scope ?? [])], pointer: `${pointer}/capabilities/${i}` })),
@@ -729,10 +760,83 @@ function boxOf(raw: unknown, pointer: string): BoxDeclaration | null {
729
760
  ? null
730
761
  : { host: b.host, slot: b.slot!, ports: { ...(b.ports ?? {}) }, state: { ...(b.state ?? {}) }, cookies: [...(b.cookies ?? [])] },
731
762
  intent: b.intent ?? null,
763
+ services: (b.services ?? []).map((s, i) => ({
764
+ name: s.name,
765
+ cmd: s.cmd,
766
+ needs: [...(s.needs ?? [])],
767
+ httpPort: s.httpPort ?? null,
768
+ duration: s.duration ?? null,
769
+ health: s.health ?? null,
770
+ optional: s.optional ?? false,
771
+ pointer: `${pointer}/services/${i}`,
772
+ })),
732
773
  pointer,
733
774
  };
734
775
  }
735
776
 
777
+ /**
778
+ * The rules the schema can't say about a box's services (#2880), as a
779
+ * message and the pointer to show, or null when they hold: each name is
780
+ * given once, every `needs` names a service of the same block, the `needs`
781
+ * form no cycle, and at most one service sets `httpPort`. The fly lexicon
782
+ * checks a list it is handed inline with the same rules.
783
+ */
784
+ export function boxServicesProblem(member: string, services: readonly BoxService[]): { message: string; pointer: string } | null {
785
+ const byName = new Map<string, BoxService>();
786
+ for (const s of services) {
787
+ const first = byName.get(s.name);
788
+ if (first) return { message: `member ${member}'s box declares the service ${s.name} twice; the first is at ${first.pointer}`, pointer: `${s.pointer}/name` };
789
+ byName.set(s.name, s);
790
+ }
791
+ let routed: BoxService | undefined;
792
+ for (const s of services) {
793
+ if (s.httpPort === null) continue;
794
+ if (routed) {
795
+ return {
796
+ message: `member ${member}'s box gives both ${routed.name} (port ${routed.httpPort}) and ${s.name} (port ${s.httpPort}) an httpPort, and the supervisor routes the sprite's URL to one service`,
797
+ pointer: `${s.pointer}/httpPort`,
798
+ };
799
+ }
800
+ routed = s;
801
+ }
802
+ for (const s of services) {
803
+ const j = s.needs.findIndex((n) => !byName.has(n));
804
+ if (j >= 0) {
805
+ const known = services.map((x) => x.name).join(", ");
806
+ return { message: `member ${member}'s box service ${s.name} needs ${JSON.stringify(s.needs[j])}, which the block does not declare; declared services: ${known}`, pointer: `${s.pointer}/needs/${j}` };
807
+ }
808
+ }
809
+ const cycle = serviceCycle(services);
810
+ if (cycle) {
811
+ const s = byName.get(cycle[0])!;
812
+ return { message: `member ${member}'s box services need each other in a cycle: ${cycle.join(" -> ")}`, pointer: `${s.pointer}/needs` };
813
+ }
814
+ return null;
815
+ }
816
+
817
+ /** The first cycle the services' `needs` form, as names ending where it starts, or null. */
818
+ function serviceCycle(services: readonly { name: string; needs: readonly string[] }[]): string[] | null {
819
+ const byName = new Map(services.map((s) => [s.name, s]));
820
+ const state = new Map<string, "visiting" | "done">();
821
+ const visit = (name: string, trail: string[]): string[] | null => {
822
+ const st = state.get(name);
823
+ if (st === "done") return null;
824
+ if (st === "visiting") return [...trail.slice(trail.indexOf(name)), name];
825
+ state.set(name, "visiting");
826
+ for (const dep of byName.get(name)?.needs ?? []) {
827
+ const found = visit(dep, [...trail, name]);
828
+ if (found) return found;
829
+ }
830
+ state.set(name, "done");
831
+ return null;
832
+ };
833
+ for (const s of services) {
834
+ const found = visit(s.name, []);
835
+ if (found) return found;
836
+ }
837
+ return null;
838
+ }
839
+
736
840
  /**
737
841
  * The hosts, already validated, with the rules the schema can't say (#2727):
738
842
  * host names are unique, a box's host is declared, its slot's block fits the