@mysten-incubation/devstack 0.7.0 → 0.7.1

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 (44) hide show
  1. package/dist/build-integrations/vite/index.mjs +1 -0
  2. package/dist/build-integrations/vite/index.mjs.map +1 -1
  3. package/dist/cli/wirings/apply.mjs +24 -6
  4. package/dist/cli/wirings/apply.mjs.map +1 -1
  5. package/dist/cli/wirings/dump-deployment.mjs +2 -1
  6. package/dist/cli/wirings/dump-deployment.mjs.map +1 -1
  7. package/dist/cli/wirings/identity.mjs +2 -1
  8. package/dist/cli/wirings/identity.mjs.map +1 -1
  9. package/dist/cli/wirings/snapshot.mjs +2 -1
  10. package/dist/cli/wirings/snapshot.mjs.map +1 -1
  11. package/dist/cli/wirings/up.mjs +25 -29
  12. package/dist/cli/wirings/up.mjs.map +1 -1
  13. package/dist/plugins/package/codegen.mjs +4 -4
  14. package/dist/plugins/package/codegen.mjs.map +1 -1
  15. package/dist/plugins/package/dep-resolution.mjs +19 -9
  16. package/dist/plugins/package/dep-resolution.mjs.map +1 -1
  17. package/dist/plugins/package/index.d.mts +3 -3
  18. package/dist/plugins/package/index.mjs +9 -5
  19. package/dist/plugins/package/index.mjs.map +1 -1
  20. package/dist/plugins/package/mode-known.mjs +2 -2
  21. package/dist/plugins/package/mode-known.mjs.map +1 -1
  22. package/dist/plugins/package/mode-local.mjs +4 -4
  23. package/dist/plugins/package/mode-local.mjs.map +1 -1
  24. package/dist/plugins/sui/chain-build-container.mjs.map +1 -1
  25. package/dist/plugins/sui/index.d.mts +10 -10
  26. package/dist/plugins/sui/index.mjs.map +1 -1
  27. package/dist/plugins/sui/move/index.mjs +10 -6
  28. package/dist/plugins/sui/move/index.mjs.map +1 -1
  29. package/dist/plugins/wallet/codegen.d.mts +14 -10
  30. package/dist/plugins/wallet/codegen.mjs.map +1 -1
  31. package/dist/plugins/wallet/origin-policy.mjs +43 -3
  32. package/dist/plugins/wallet/origin-policy.mjs.map +1 -1
  33. package/dist/plugins/wallet/service.mjs +9 -7
  34. package/dist/plugins/wallet/service.mjs.map +1 -1
  35. package/dist/substrate/cross-process.mjs +2 -1
  36. package/dist/substrate/cross-process.mjs.map +1 -1
  37. package/dist/substrate/runtime/cross-process/roster.mjs +5 -2
  38. package/dist/substrate/runtime/cross-process/roster.mjs.map +1 -1
  39. package/dist/surfaces/cli/commands/supervisor-presence.mjs +4 -2
  40. package/dist/surfaces/cli/commands/supervisor-presence.mjs.map +1 -1
  41. package/dist/surfaces/cli/errors.mjs +12 -2
  42. package/dist/surfaces/cli/errors.mjs.map +1 -1
  43. package/dist/surfaces/cli/index.mjs.map +1 -1
  44. package/package.json +1 -1
@@ -1,6 +1,38 @@
1
1
  import { LogAttr } from "../../substrate/runtime/observability/log-attrs.mjs";
2
+ import "../../substrate/runtime/routed-url.mjs";
2
3
  import { Effect } from "effect";
3
4
  //#region src/plugins/wallet/origin-policy.ts
5
+ const routedAppOriginScope = (inputs) => {
6
+ if (inputs.routedAppOrigin === null) return null;
7
+ try {
8
+ const url = new URL(inputs.routedAppOrigin);
9
+ const app = inputs.app.toLowerCase();
10
+ const stack = inputs.stack.toLowerCase();
11
+ const suffix = stack === "main" ? `${app}.localhost` : `${stack}.${app}.localhost`;
12
+ return {
13
+ protocol: url.protocol,
14
+ port: url.port,
15
+ app,
16
+ stack,
17
+ pattern: `${url.protocol}//*.${suffix}${url.port.length > 0 ? `:${url.port}` : ""}`
18
+ };
19
+ } catch {
20
+ return null;
21
+ }
22
+ };
23
+ const isSameStackRoutedOrigin = (scope, origin) => {
24
+ let url;
25
+ try {
26
+ url = new URL(origin);
27
+ } catch {
28
+ return false;
29
+ }
30
+ if (url.origin !== origin) return false;
31
+ if (url.protocol !== scope.protocol || url.port !== scope.port) return false;
32
+ const labels = url.hostname.toLowerCase().split(".");
33
+ if (scope.stack === "main") return labels.length === 3 && labels[0].length > 0 && labels[1] === scope.app && labels[2] === "localhost";
34
+ return labels.length === 4 && labels[0].length > 0 && labels[1] === scope.stack && labels[2] === scope.app && labels[3] === "localhost";
35
+ };
4
36
  /**
5
37
  * Resolve the per-stack origin allowlist.
6
38
  *
@@ -20,18 +52,26 @@ import { Effect } from "effect";
20
52
  */
21
53
  const resolveOriginPolicy = (inputs) => Effect.gen(function* () {
22
54
  const allowed = /* @__PURE__ */ new Set();
55
+ const routedScope = routedAppOriginScope(inputs);
23
56
  if (inputs.routedAppOrigin !== null) allowed.add(inputs.routedAppOrigin);
24
57
  for (const o of inputs.extraOrigins) allowed.add(o);
25
58
  if (allowed.size === 0) yield* Effect.logWarning("wallet origin allowlist is empty").pipe(Effect.annotateLogs({
26
59
  [LogAttr.app]: inputs.app,
27
60
  [LogAttr.stack]: inputs.stack
28
61
  }));
29
- return { allowed };
62
+ return {
63
+ allowed,
64
+ routedAppOriginPattern: routedScope?.pattern ?? null,
65
+ routedAppOriginScope: routedScope
66
+ };
30
67
  });
31
68
  const checkOrigin = (policy, headerValue) => {
32
69
  if (headerValue === void 0 || headerValue.length === 0) return "missing";
33
- return policy.allowed.has(headerValue) ? "ok" : "forbidden";
70
+ if (policy.allowed.has(headerValue)) return "ok";
71
+ if (policy.routedAppOriginScope !== null && isSameStackRoutedOrigin(policy.routedAppOriginScope, headerValue)) return "ok";
72
+ return "forbidden";
34
73
  };
74
+ const describeAllowedOrigins = (policy) => [...policy.allowed, ...policy.routedAppOriginPattern === null ? [] : [policy.routedAppOriginPattern]];
35
75
  /** Compose CORS headers for a successful request. Single-allowed-origin
36
76
  * echo (browsers don't honor wildcard with credentials, and we want
37
77
  * to keep `Access-Control-Allow-Credentials` open for fetch with
@@ -44,6 +84,6 @@ const corsHeadersFor = (origin) => ({
44
84
  vary: "origin"
45
85
  });
46
86
  //#endregion
47
- export { checkOrigin, corsHeadersFor, resolveOriginPolicy };
87
+ export { checkOrigin, corsHeadersFor, describeAllowedOrigins, resolveOriginPolicy };
48
88
 
49
89
  //# sourceMappingURL=origin-policy.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"origin-policy.mjs","names":[],"sources":["../../../src/plugins/wallet/origin-policy.ts"],"sourcesContent":["// Wallet plugin — CORS / origin allowlist policy.\n//\n// The allowlist is built from two real, substrate-wired sources: the\n// router-fronted dev-server origin for this stack (`routedAppOrigin`)\n// and any caller-supplied `extraOrigins`. Both are stack-scoped or\n// explicit, so neither opens the cross-stack pairing risk described in\n// 15-wallet.md.\n//\n// No raw-loopback origin: with the `hostService(...)` plugin devstack\n// owns the dev server's bind port (the broker allocates it; Vite binds\n// it via `--port {port}`), so the raw-loopback origin\n// (`http://127.0.0.1:<port>` / `http://localhost:<port>`) the dev sees\n// in Vite's own console banner is a real, knowable origin. The canonical\n// fix lives on the host-service side: it publishes the ROUTED origin as\n// its `HostServiceValue.url`, so `devstack up` output (and any consumer\n// holding the resolved host-service value) point devs at the URL whose\n// Origin THIS allowlist already accepts (the routed `routedAppOrigin`).\n// A raw-loopback allowlist branch would have to thread the host-service's\n// dynamic bind port into the wallet, which runs in the OPPOSITE direction\n// of the dependency edge (host-service `dependsOn` wallet), so the wallet\n// has no view of that port at its own boot. Devs who must load the raw\n// Vite URL pass it via `allowedOrigins` (→ `extraOrigins`).\n//\n// Why \"origin + bearer together\": bearer alone leaves a non-browser-\n// tooling bypass — curl / fetch from a service worker can forge\n// `Authorization` from a leaked token. Browsers always send `Origin`;\n// non-browsers omit it. Demanding BOTH closes the bypass.\n\nimport { Effect } from 'effect';\n\nimport { LogAttr } from '../../substrate/runtime/observability/log-attrs.ts';\n\n// ----------------------------------------------------------------------\n// Policy shape\n// ----------------------------------------------------------------------\n\n/** Result of resolving the origin allowlist at boot. Captured into\n * the HTTP handler closure so per-request checks are pure-string\n * comparison. */\nexport interface OriginPolicy {\n\treadonly allowed: ReadonlySet<string>;\n}\n\n/** Per-stack inputs the policy resolver needs. Supplied by the\n * substrate at acquire time (identity + routed-url derivation); this\n * module doesn't reach into the broker itself. */\nexport interface OriginPolicyInputs {\n\treadonly app: string;\n\treadonly stack: string;\n\treadonly routedAppOrigin: string | null;\n\treadonly extraOrigins: ReadonlyArray<string>;\n}\n\n// ----------------------------------------------------------------------\n// Resolution\n// ----------------------------------------------------------------------\n\n/**\n * Resolve the per-stack origin allowlist.\n *\n * - Always allowlisted: the router-fronted dev-server origin for this\n * stack (`routedAppOrigin`), when the router derivation produced one.\n * - Always allowlisted: any explicit caller-supplied origins from\n * `extraOrigins`.\n *\n * Empty-allowlist policy (no `routedAppOrigin` AND no `extraOrigins`):\n * allowed. The wallet boots normally; with an empty allowlist the\n * per-request gate refuses every request (every Origin lands in\n * `forbidden`). This is the correct behavior for a stack composed\n * without any client UI (e.g. node-only smoke / e2e configs) — the\n * wallet's keypair + token are still useful for the host process, but\n * the HTTP surface is effectively closed. A `Effect.logWarning`\n * surfaces the configuration for operator visibility.\n */\nexport const resolveOriginPolicy = (inputs: OriginPolicyInputs): Effect.Effect<OriginPolicy> =>\n\tEffect.gen(function* () {\n\t\tconst allowed = new Set<string>();\n\n\t\tif (inputs.routedAppOrigin !== null) {\n\t\t\tallowed.add(inputs.routedAppOrigin);\n\t\t}\n\n\t\tfor (const o of inputs.extraOrigins) {\n\t\t\tallowed.add(o);\n\t\t}\n\n\t\tif (allowed.size === 0) {\n\t\t\tyield* Effect.logWarning('wallet origin allowlist is empty').pipe(\n\t\t\t\tEffect.annotateLogs({\n\t\t\t\t\t[LogAttr.app]: inputs.app,\n\t\t\t\t\t[LogAttr.stack]: inputs.stack,\n\t\t\t\t}),\n\t\t\t);\n\t\t}\n\n\t\treturn { allowed } satisfies OriginPolicy;\n\t});\n\n// ----------------------------------------------------------------------\n// Per-request check\n// ----------------------------------------------------------------------\n\n/** Per-request origin gate. Returns `'missing'` for absent Origin,\n * `'forbidden'` for Origin not in the allowlist, `'ok'` for accepted.\n *\n * Distilled-doc invariant (C12): missing Origin is its OWN refusal\n * shape (closes the curl / non-browser bypass — bearer alone is not\n * enough). */\nexport type OriginCheckResult = 'missing' | 'forbidden' | 'ok';\n\nexport const checkOrigin = (\n\tpolicy: OriginPolicy,\n\theaderValue: string | undefined,\n): OriginCheckResult => {\n\tif (headerValue === undefined || headerValue.length === 0) return 'missing';\n\treturn policy.allowed.has(headerValue) ? 'ok' : 'forbidden';\n};\n\n/** Compose CORS headers for a successful request. Single-allowed-origin\n * echo (browsers don't honor wildcard with credentials, and we want\n * to keep `Access-Control-Allow-Credentials` open for fetch with\n * `credentials: 'include'` if a future codegen seam needs it). */\nexport const corsHeadersFor = (origin: string): Readonly<Record<string, string>> => ({\n\t'access-control-allow-origin': origin,\n\t'access-control-allow-methods': 'GET, POST, OPTIONS',\n\t'access-control-allow-headers': 'authorization, content-type',\n\t'access-control-allow-credentials': 'true',\n\tvary: 'origin',\n});\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AA0EA,MAAa,uBAAuB,WACnC,OAAO,IAAI,aAAa;CACvB,MAAM,0BAAU,IAAI,IAAY;CAEhC,IAAI,OAAO,oBAAoB,MAC9B,QAAQ,IAAI,OAAO,eAAe;CAGnC,KAAK,MAAM,KAAK,OAAO,cACtB,QAAQ,IAAI,CAAC;CAGd,IAAI,QAAQ,SAAS,GACpB,OAAO,OAAO,WAAW,kCAAkC,CAAC,CAAC,KAC5D,OAAO,aAAa;GAClB,QAAQ,MAAM,OAAO;GACrB,QAAQ,QAAQ,OAAO;CACzB,CAAC,CACF;CAGD,OAAO,EAAE,QAAQ;AAClB,CAAC;AAcF,MAAa,eACZ,QACA,gBACuB;CACvB,IAAI,gBAAgB,KAAA,KAAa,YAAY,WAAW,GAAG,OAAO;CAClE,OAAO,OAAO,QAAQ,IAAI,WAAW,IAAI,OAAO;AACjD;;;;;AAMA,MAAa,kBAAkB,YAAsD;CACpF,+BAA+B;CAC/B,gCAAgC;CAChC,gCAAgC;CAChC,oCAAoC;CACpC,MAAM;AACP"}
1
+ {"version":3,"file":"origin-policy.mjs","names":[],"sources":["../../../src/plugins/wallet/origin-policy.ts"],"sourcesContent":["// Wallet plugin — CORS / origin allowlist policy.\n//\n// The allowlist is built from two real, substrate-wired sources: the\n// router-fronted dev-server origin for this stack (`routedAppOrigin`,\n// generalized to same-stack routed endpoint origins) and any\n// caller-supplied `extraOrigins`. Both are stack-scoped or explicit, so\n// neither opens the cross-stack pairing risk described in 15-wallet.md.\n//\n// No raw-loopback origin: with the `hostService(...)` plugin devstack\n// owns the dev server's bind port (the broker allocates it; Vite binds\n// it via `--port {port}`), so the raw-loopback origin\n// (`http://127.0.0.1:<port>` / `http://localhost:<port>`) the dev sees\n// in Vite's own console banner is a real, knowable origin. The canonical\n// fix lives on the host-service side: it publishes the ROUTED origin as\n// its `HostServiceValue.url`, so `devstack up` output (and any consumer\n// holding the resolved host-service value) point devs at the URL whose\n// Origin THIS allowlist already accepts (the routed `routedAppOrigin`).\n// A raw-loopback allowlist branch would have to thread the host-service's\n// dynamic bind port into the wallet, which runs in the OPPOSITE direction\n// of the dependency edge (host-service `dependsOn` wallet), so the wallet\n// has no view of that port at its own boot. Devs who must load the raw\n// Vite URL pass it via `allowedOrigins` (→ `extraOrigins`).\n//\n// Why \"origin + bearer together\": bearer alone leaves a non-browser-\n// tooling bypass — curl / fetch from a service worker can forge\n// `Authorization` from a leaked token. Browsers always send `Origin`;\n// non-browsers omit it. Demanding BOTH closes the bypass.\n\nimport { Effect } from 'effect';\n\nimport { LogAttr } from '../../substrate/runtime/observability/log-attrs.ts';\nimport { DEFAULT_STACK } from '../../substrate/runtime/routed-url.ts';\n\n// ----------------------------------------------------------------------\n// Policy shape\n// ----------------------------------------------------------------------\n\n/** Result of resolving the origin allowlist at boot. Captured into\n * the HTTP handler closure so per-request checks are pure-string\n * comparison. */\nexport interface OriginPolicy {\n\treadonly allowed: ReadonlySet<string>;\n\treadonly routedAppOriginPattern: string | null;\n\treadonly routedAppOriginScope: RoutedAppOriginScope | null;\n}\n\n/** Per-stack inputs the policy resolver needs. Supplied by the\n * substrate at acquire time (identity + routed-url derivation); this\n * module doesn't reach into the broker itself. */\nexport interface OriginPolicyInputs {\n\treadonly app: string;\n\treadonly stack: string;\n\treadonly routedAppOrigin: string | null;\n\treadonly extraOrigins: ReadonlyArray<string>;\n}\n\nexport interface RoutedAppOriginScope {\n\treadonly protocol: string;\n\treadonly port: string;\n\treadonly app: string;\n\treadonly stack: string;\n\treadonly pattern: string;\n}\n\nconst routedAppOriginScope = (inputs: OriginPolicyInputs): RoutedAppOriginScope | null => {\n\tif (inputs.routedAppOrigin === null) return null;\n\ttry {\n\t\tconst url = new URL(inputs.routedAppOrigin);\n\t\tconst app = inputs.app.toLowerCase();\n\t\tconst stack = inputs.stack.toLowerCase();\n\t\tconst suffix = stack === DEFAULT_STACK ? `${app}.localhost` : `${stack}.${app}.localhost`;\n\t\treturn {\n\t\t\tprotocol: url.protocol,\n\t\t\tport: url.port,\n\t\t\tapp,\n\t\t\tstack,\n\t\t\tpattern: `${url.protocol}//*.${suffix}${url.port.length > 0 ? `:${url.port}` : ''}`,\n\t\t};\n\t} catch {\n\t\treturn null;\n\t}\n};\n\nconst isSameStackRoutedOrigin = (scope: RoutedAppOriginScope, origin: string): boolean => {\n\tlet url: URL;\n\ttry {\n\t\turl = new URL(origin);\n\t} catch {\n\t\treturn false;\n\t}\n\tif (url.origin !== origin) return false;\n\tif (url.protocol !== scope.protocol || url.port !== scope.port) return false;\n\n\tconst labels = url.hostname.toLowerCase().split('.');\n\tif (scope.stack === DEFAULT_STACK) {\n\t\treturn (\n\t\t\tlabels.length === 3 &&\n\t\t\tlabels[0]!.length > 0 &&\n\t\t\tlabels[1] === scope.app &&\n\t\t\tlabels[2] === 'localhost'\n\t\t);\n\t}\n\treturn (\n\t\tlabels.length === 4 &&\n\t\tlabels[0]!.length > 0 &&\n\t\tlabels[1] === scope.stack &&\n\t\tlabels[2] === scope.app &&\n\t\tlabels[3] === 'localhost'\n\t);\n};\n\n// ----------------------------------------------------------------------\n// Resolution\n// ----------------------------------------------------------------------\n\n/**\n * Resolve the per-stack origin allowlist.\n *\n * - Always allowlisted: the router-fronted dev-server origin for this\n * stack (`routedAppOrigin`), when the router derivation produced one.\n * - Always allowlisted: any explicit caller-supplied origins from\n * `extraOrigins`.\n *\n * Empty-allowlist policy (no `routedAppOrigin` AND no `extraOrigins`):\n * allowed. The wallet boots normally; with an empty allowlist the\n * per-request gate refuses every request (every Origin lands in\n * `forbidden`). This is the correct behavior for a stack composed\n * without any client UI (e.g. node-only smoke / e2e configs) — the\n * wallet's keypair + token are still useful for the host process, but\n * the HTTP surface is effectively closed. A `Effect.logWarning`\n * surfaces the configuration for operator visibility.\n */\nexport const resolveOriginPolicy = (inputs: OriginPolicyInputs): Effect.Effect<OriginPolicy> =>\n\tEffect.gen(function* () {\n\t\tconst allowed = new Set<string>();\n\t\tconst routedScope = routedAppOriginScope(inputs);\n\n\t\tif (inputs.routedAppOrigin !== null) {\n\t\t\tallowed.add(inputs.routedAppOrigin);\n\t\t}\n\n\t\tfor (const o of inputs.extraOrigins) {\n\t\t\tallowed.add(o);\n\t\t}\n\n\t\tif (allowed.size === 0) {\n\t\t\tyield* Effect.logWarning('wallet origin allowlist is empty').pipe(\n\t\t\t\tEffect.annotateLogs({\n\t\t\t\t\t[LogAttr.app]: inputs.app,\n\t\t\t\t\t[LogAttr.stack]: inputs.stack,\n\t\t\t\t}),\n\t\t\t);\n\t\t}\n\n\t\treturn {\n\t\t\tallowed,\n\t\t\troutedAppOriginPattern: routedScope?.pattern ?? null,\n\t\t\troutedAppOriginScope: routedScope,\n\t\t} satisfies OriginPolicy;\n\t});\n\n// ----------------------------------------------------------------------\n// Per-request check\n// ----------------------------------------------------------------------\n\n/** Per-request origin gate. Returns `'missing'` for absent Origin,\n * `'forbidden'` for Origin not in the allowlist, `'ok'` for accepted.\n *\n * Distilled-doc invariant (C12): missing Origin is its OWN refusal\n * shape (closes the curl / non-browser bypass — bearer alone is not\n * enough). */\nexport type OriginCheckResult = 'missing' | 'forbidden' | 'ok';\n\nexport const checkOrigin = (\n\tpolicy: OriginPolicy,\n\theaderValue: string | undefined,\n): OriginCheckResult => {\n\tif (headerValue === undefined || headerValue.length === 0) return 'missing';\n\tif (policy.allowed.has(headerValue)) return 'ok';\n\tif (\n\t\tpolicy.routedAppOriginScope !== null &&\n\t\tisSameStackRoutedOrigin(policy.routedAppOriginScope, headerValue)\n\t) {\n\t\treturn 'ok';\n\t}\n\treturn 'forbidden';\n};\n\nexport const describeAllowedOrigins = (policy: OriginPolicy): ReadonlyArray<string> => [\n\t...policy.allowed,\n\t...(policy.routedAppOriginPattern === null ? [] : [policy.routedAppOriginPattern]),\n];\n\n/** Compose CORS headers for a successful request. Single-allowed-origin\n * echo (browsers don't honor wildcard with credentials, and we want\n * to keep `Access-Control-Allow-Credentials` open for fetch with\n * `credentials: 'include'` if a future codegen seam needs it). */\nexport const corsHeadersFor = (origin: string): Readonly<Record<string, string>> => ({\n\t'access-control-allow-origin': origin,\n\t'access-control-allow-methods': 'GET, POST, OPTIONS',\n\t'access-control-allow-headers': 'authorization, content-type',\n\t'access-control-allow-credentials': 'true',\n\tvary: 'origin',\n});\n"],"mappings":";;;;AAgEA,MAAM,wBAAwB,WAA4D;CACzF,IAAI,OAAO,oBAAoB,MAAM,OAAO;CAC5C,IAAI;EACH,MAAM,MAAM,IAAI,IAAI,OAAO,eAAe;EAC1C,MAAM,MAAM,OAAO,IAAI,YAAY;EACnC,MAAM,QAAQ,OAAO,MAAM,YAAY;EACvC,MAAM,SAAS,UAAA,SAA0B,GAAG,IAAI,cAAc,GAAG,MAAM,GAAG,IAAI;EAC9E,OAAO;GACN,UAAU,IAAI;GACd,MAAM,IAAI;GACV;GACA;GACA,SAAS,GAAG,IAAI,SAAS,MAAM,SAAS,IAAI,KAAK,SAAS,IAAI,IAAI,IAAI,SAAS;EAChF;CACD,QAAQ;EACP,OAAO;CACR;AACD;AAEA,MAAM,2BAA2B,OAA6B,WAA4B;CACzF,IAAI;CACJ,IAAI;EACH,MAAM,IAAI,IAAI,MAAM;CACrB,QAAQ;EACP,OAAO;CACR;CACA,IAAI,IAAI,WAAW,QAAQ,OAAO;CAClC,IAAI,IAAI,aAAa,MAAM,YAAY,IAAI,SAAS,MAAM,MAAM,OAAO;CAEvE,MAAM,SAAS,IAAI,SAAS,YAAY,CAAC,CAAC,MAAM,GAAG;CACnD,IAAI,MAAM,UAAA,QACT,OACC,OAAO,WAAW,KAClB,OAAO,EAAE,CAAE,SAAS,KACpB,OAAO,OAAO,MAAM,OACpB,OAAO,OAAO;CAGhB,OACC,OAAO,WAAW,KAClB,OAAO,EAAE,CAAE,SAAS,KACpB,OAAO,OAAO,MAAM,SACpB,OAAO,OAAO,MAAM,OACpB,OAAO,OAAO;AAEhB;;;;;;;;;;;;;;;;;;AAuBA,MAAa,uBAAuB,WACnC,OAAO,IAAI,aAAa;CACvB,MAAM,0BAAU,IAAI,IAAY;CAChC,MAAM,cAAc,qBAAqB,MAAM;CAE/C,IAAI,OAAO,oBAAoB,MAC9B,QAAQ,IAAI,OAAO,eAAe;CAGnC,KAAK,MAAM,KAAK,OAAO,cACtB,QAAQ,IAAI,CAAC;CAGd,IAAI,QAAQ,SAAS,GACpB,OAAO,OAAO,WAAW,kCAAkC,CAAC,CAAC,KAC5D,OAAO,aAAa;GAClB,QAAQ,MAAM,OAAO;GACrB,QAAQ,QAAQ,OAAO;CACzB,CAAC,CACF;CAGD,OAAO;EACN;EACA,wBAAwB,aAAa,WAAW;EAChD,sBAAsB;CACvB;AACD,CAAC;AAcF,MAAa,eACZ,QACA,gBACuB;CACvB,IAAI,gBAAgB,KAAA,KAAa,YAAY,WAAW,GAAG,OAAO;CAClE,IAAI,OAAO,QAAQ,IAAI,WAAW,GAAG,OAAO;CAC5C,IACC,OAAO,yBAAyB,QAChC,wBAAwB,OAAO,sBAAsB,WAAW,GAEhE,OAAO;CAER,OAAO;AACR;AAEA,MAAa,0BAA0B,WAAgD,CACtF,GAAG,OAAO,SACV,GAAI,OAAO,2BAA2B,OAAO,CAAC,IAAI,CAAC,OAAO,sBAAsB,CACjF;;;;;AAMA,MAAa,kBAAkB,YAAsD;CACpF,+BAA+B;CAC/B,gCAAgC;CAChC,gCAAgC;CAChC,oCAAoC;CACpC,MAAM;AACP"}
@@ -1,6 +1,6 @@
1
1
  import { WalletHttpPath } from "../../contracts/wallet-protocol.mjs";
2
2
  import { walletBootError } from "./errors.mjs";
3
- import { resolveOriginPolicy } from "./origin-policy.mjs";
3
+ import { describeAllowedOrigins, resolveOriginPolicy } from "./origin-policy.mjs";
4
4
  import "./protocol.mjs";
5
5
  import { acquirePairingToken, composePairUrl, tokenPath } from "./pairing.mjs";
6
6
  import { startHttpServer } from "./server.mjs";
@@ -46,16 +46,17 @@ const acquireWallet = (opts, ctx) => Effect.gen(function* () {
46
46
  const bindAddress = opts.bindAddress ?? WALLET_DEFAULT_BIND_ADDRESS;
47
47
  const port = yield* ctx.allocatePort(opts.port, portProbeHostForBindAddress(bindAddress));
48
48
  const token = yield* acquirePairingToken(tokenPath(ctx.stateRoot));
49
+ const policy = yield* resolveOriginPolicy({
50
+ app: ctx.app,
51
+ stack: ctx.stack,
52
+ routedAppOrigin: ctx.routedAppOrigin,
53
+ extraOrigins: opts.allowedOrigins ?? []
54
+ });
49
55
  const server = yield* startHttpServer({
50
56
  bindAddress,
51
57
  port,
52
58
  token,
53
- policy: yield* resolveOriginPolicy({
54
- app: ctx.app,
55
- stack: ctx.stack,
56
- routedAppOrigin: ctx.routedAppOrigin,
57
- extraOrigins: opts.allowedOrigins ?? []
58
- }),
59
+ policy,
59
60
  accountsByAddress
60
61
  });
61
62
  const walletUrl = ctx.routerFrontedUrl ?? `http://${directUrlHostForBindAddress(bindAddress)}:${port}`;
@@ -63,6 +64,7 @@ const acquireWallet = (opts, ctx) => Effect.gen(function* () {
63
64
  const connection = {
64
65
  walletUrl,
65
66
  network: ctx.network,
67
+ allowedOrigins: describeAllowedOrigins(policy),
66
68
  protocolPaths: {
67
69
  health: WalletHttpPath.HEALTH,
68
70
  accounts: WalletHttpPath.ACCOUNTS,
@@ -1 +1 @@
1
- {"version":3,"file":"service.mjs","names":[],"sources":["../../../src/plugins/wallet/service.ts"],"sourcesContent":["// Wallet plugin — main acquire Effect.\n//\n// What this file does (15-wallet.md §Lifecycle, Startup ordered):\n//\n// 1. Resolve each consumed account tag and key the AccountValues by\n// address (load-bearing: sign endpoints look up by address).\n// 2. Allocate a port via the substrate's `PortBrokerService`.\n// 3. Mint or rehydrate the pairing token (read-existing-or-mint at\n// `<stateRoot>/wallet/token`, mode 0o600) via the substrate's\n// `atomicWriteFile`.\n// 4. Resolve the stack-scoped origin allowlist via\n// `origin-policy.ts`.\n// 5. Start the in-process HTTP server.\n// 6. Compose the wallet URL + pair URL + return the resolved\n// `WalletValue`.\n//\n// The substrate handles:\n//\n// - Scope finalizers (port release, server close) — declared by the\n// substrate primitives we call into.\n// - Endpoint registry publication (we surface the URL/pairUrl via\n// our resolved tag value; the substrate's endpoint-registry\n// primitive consumes the `RoutableDecl` from `routable.ts`).\n// - Manifest emit — the substrate projects from registry. We never\n// write `.devstack/manifest.json` directly.\n\nimport { Effect } from 'effect';\nimport type { FileSystem, Scope } from 'effect';\n\nimport type { AccountResourceId, AccountValue } from '../account/index.ts';\nimport type { DevWalletConnection } from './codegen.ts';\nimport { walletBootError, type WalletBootError } from './errors.ts';\nimport { resolveOriginPolicy } from './origin-policy.ts';\nimport { acquirePairingToken, composePairUrl, tokenPath, type PairingToken } from './pairing.ts';\nimport { startHttpServer, type WalletServerConfig, type WalletServerHandle } from './server.ts';\nimport { WalletHttpPath } from './protocol.ts';\n\n// ----------------------------------------------------------------------\n// User-facing options\n// ----------------------------------------------------------------------\n\nimport type { ResourceRef } from '../../api/define-plugin.ts';\n\n/** Literal sentinel for `WalletOptions.accounts: 'all'` — every account\n * member in the stack. Expanded by the composer at `defineDevstack`\n * call time (api-surface-design §4 D6). Kept as an exported constant\n * so the wallet factory + composer share one source of truth. */\nexport const WALLET_ACCOUNTS_ALL = 'all' as const;\nexport type WalletAccountsAll = typeof WALLET_ACCOUNTS_ALL;\n\n/** A user-supplied account ref. The user passes the result of\n * `account('alice')` — NOT a bare string. Generic over the literal\n * account name so the wallet's dependency tuple preserves each\n * per-account resource id (`account/alice`, `account/bob`, ...). */\nexport type WalletAccountMember<Name extends string = string> = ResourceRef<\n\tAccountResourceId<Name>,\n\tAccountValue\n>;\n\nexport interface WalletOptions<\n\tAccounts extends ReadonlyArray<WalletAccountMember> = ReadonlyArray<WalletAccountMember>,\n> {\n\t/** Accounts the wallet binds. Each is yielded for ordering AND its\n\t * resolved value is keyed by address into the sign-handler map.\n\t *\n\t * Two shapes:\n\t *\n\t * - Explicit tuple — each entry is the plugin/resource ref returned\n\t * by `account('name')`. Pins the bound set at the wallet's call\n\t * site; preserves each literal `account/${Name}` so stack\n\t * composition can validate and recursively expand the refs.\n\t *\n\t * - The literal `'all'` — shorthand for \"every account member in\n\t * the stack\". The composer expands this against the final\n\t * member tuple at `defineDevstack(...)` time (api-surface-design\n\t * §4 D6). The wallet member returned by the factory carries an\n\t * expander hook keyed off `WALLET_ACCOUNTS_ALL` that the\n\t * composer invokes once the account-providing members are\n\t * known. */\n\treadonly accounts: Accounts | typeof WALLET_ACCOUNTS_ALL;\n\t/** Extra origins merged on top of the router-fronted dev-server\n\t * origin. Useful for headless test runners and custom dev hosts. */\n\treadonly allowedOrigins?: ReadonlyArray<string>;\n\t/** Preferred host port. Substrate's port broker forward-scans if\n\t * this is taken. Default: substrate-picked (no preference). */\n\treadonly port?: number;\n\t/** NIC the HTTP server binds. Defaults to `'0.0.0.0'` because the\n\t * router runs in Docker and must reach the host process through the\n\t * host-gateway address on native Linux. The public wallet URL remains\n\t * router-fronted and stack-scoped. */\n\treadonly bindAddress?: string;\n}\n\n// ----------------------------------------------------------------------\n// Resolved value (what the wallet plugin publishes)\n// ----------------------------------------------------------------------\n\nexport interface WalletValue {\n\treadonly url: string; // router-fronted URL when available, loopback otherwise\n\treadonly pairUrl: string;\n\treadonly connection: DevWalletConnection;\n\treadonly localPort: number;\n\treadonly token: PairingToken;\n\t/** Server handle — substrate's scope finalizer chain invokes\n\t * `.close()`; callers don't reach in. Exposed here so tests can\n\t * drive teardown explicitly. */\n\treadonly server: WalletServerHandle;\n}\n\nconst WALLET_DEFAULT_BIND_ADDRESS = '0.0.0.0' as const;\nconst WALLET_DIRECT_URL_HOST = '127.0.0.1' as const;\ntype WalletPortProbeHost = '127.0.0.1' | '0.0.0.0';\n\nconst portProbeHostForBindAddress = (bindAddress: string): WalletPortProbeHost =>\n\tbindAddress === WALLET_DEFAULT_BIND_ADDRESS ? '0.0.0.0' : '127.0.0.1';\n\nconst directUrlHostForBindAddress = (bindAddress: string): string =>\n\tbindAddress === WALLET_DEFAULT_BIND_ADDRESS ? WALLET_DIRECT_URL_HOST : bindAddress;\n\n// ----------------------------------------------------------------------\n// Per-acquire context — supplied by the barrel\n// ----------------------------------------------------------------------\n\n/** Inputs from the substrate. The barrel fills these from the\n * BuildContext; tests can construct directly. */\nexport interface WalletAcquireContext {\n\treadonly app: string;\n\treadonly stack: string;\n\treadonly network: string;\n\t/** State root where `wallet/token` lives. Convention:\n\t * `<appDir>/.devstack/stacks/<stack>/runtime`. */\n\treadonly stateRoot: string;\n\t/** Port broker seam — returns the allocated port + a scope-\n\t * finalizer-installed release. The barrel adapts the substrate's\n\t * `PortBrokerService.allocate` to this signature so tests can pin\n\t * the port without yielding from a substrate Layer. */\n\treadonly allocatePort: (\n\t\tpreferred?: number,\n\t\tprobeHost?: WalletPortProbeHost,\n\t) => Effect.Effect<number, WalletBootError, Scope.Scope>;\n\t/** Account value resolver — the barrel hands this in keyed off the\n\t * BuildContext so the service body stays substrate-agnostic. */\n\treadonly resolveAccounts: () => Effect.Effect<ReadonlyArray<AccountValue>, WalletBootError>;\n\t/** Stable router-fronted base URL for this wallet on the stack-scoped\n\t * hostname (e.g. `http://wallet.<app>.localhost:<router-port>`). Null\n\t * only in tests that bypass the router derivation. */\n\treadonly routerFrontedUrl: string | null;\n\t/** Stable router-fronted dev-server origin for this stack. Added to\n\t * the wallet allowlist so app+wallet stacks work without repeating\n\t * router origins in user configs. */\n\treadonly routedAppOrigin: string | null;\n}\n\n// ----------------------------------------------------------------------\n// Acquire\n// ----------------------------------------------------------------------\n\n/**\n * Acquire the wallet service.\n *\n * Distilled-doc invariants honored:\n *\n * - C12 (mandatory Origin + bearer): both gates are wired in\n * `server.ts:dispatch`.\n * - Token comparison constant-time: `pairing.ts:safeBearerEquals`.\n * - Token file 0o600: `pairing.ts:acquirePairingToken`.\n * - Token in URL fragment only: `pairing.ts:composePairUrl`.\n * - Token NEVER in log lines: handlers log only `bearerValid:\n * boolean`.\n * - Default bindAddress `'0.0.0.0'`: required for the Docker router to\n * reach host-loopback services on native Linux.\n * - Stack-scoped origin allowlist (no cross-stack pairing risk):\n * `origin-policy.ts:resolveOriginPolicy`.\n */\nexport const acquireWallet = (\n\topts: WalletOptions,\n\tctx: WalletAcquireContext,\n): Effect.Effect<WalletValue, WalletBootError, Scope.Scope | FileSystem.FileSystem> =>\n\tEffect.gen(function* () {\n\t\t// 1. Resolve the dependency account values. The barrel sets up\n\t\t// `resolveAccounts` to walk the BuildContext via resolved dependencies in\n\t\t// `opts.accounts`; we just project to the address-keyed map.\n\t\tconst accounts = yield* ctx.resolveAccounts();\n\t\tif (accounts.length === 0) {\n\t\t\treturn yield* Effect.fail(\n\t\t\t\twalletBootError({\n\t\t\t\t\tphase: 'no-accounts',\n\t\t\t\t\tmessage:\n\t\t\t\t\t\t\"wallet resolved zero accounts; add account('name') to the stack or pass accounts explicitly.\",\n\t\t\t\t\thint: \"`wallet()` and `wallet({ accounts: 'all' })` require at least one account member in the final stack.\",\n\t\t\t\t}),\n\t\t\t);\n\t\t}\n\t\tconst accountsByAddress = new Map<string, AccountValue>();\n\t\tfor (const acct of accounts) {\n\t\t\t// Two accounts resolving to the same address would silently\n\t\t\t// last-write-wins the sign-route map, so a sign request for\n\t\t\t// that address binds to a non-deterministic account. Fail at\n\t\t\t// boot with the colliding address named.\n\t\t\tif (accountsByAddress.has(acct.address)) {\n\t\t\t\treturn yield* Effect.fail(\n\t\t\t\t\twalletBootError({\n\t\t\t\t\t\tphase: 'bind-account',\n\t\t\t\t\t\tmessage: `wallet resolved two accounts at the same address ${acct.address}; each account must own a distinct address.`,\n\t\t\t\t\t\thint: 'Remove the duplicate account member, or give the colliding accounts distinct keypairs.',\n\t\t\t\t\t}),\n\t\t\t\t);\n\t\t\t}\n\t\t\taccountsByAddress.set(acct.address, acct);\n\t\t}\n\n\t\t// 2. Port allocation. Probe the same host family the real\n\t\t// listener uses, otherwise a process bound on a non-loopback\n\t\t// interface could race the wallet's all-interface listen.\n\t\tconst bindAddress = opts.bindAddress ?? WALLET_DEFAULT_BIND_ADDRESS;\n\t\tconst port = yield* ctx.allocatePort(opts.port, portProbeHostForBindAddress(bindAddress));\n\n\t\t// 3. Token mint or rehydrate. Lives at the stack-scoped state root\n\t\t// so warm-start + snapshot-restore preserve the existing\n\t\t// pairing.\n\t\tconst token = yield* acquirePairingToken(tokenPath(ctx.stateRoot));\n\n\t\t// 4. Origin policy. Allowlist is the router-fronted dev-server\n\t\t// origin for this stack plus any explicit `allowedOrigins`.\n\t\tconst policy = yield* resolveOriginPolicy({\n\t\t\tapp: ctx.app,\n\t\t\tstack: ctx.stack,\n\t\t\troutedAppOrigin: ctx.routedAppOrigin,\n\t\t\textraOrigins: opts.allowedOrigins ?? [],\n\t\t});\n\n\t\t// 5. Start the HTTP server. The dispatcher in `server.ts` owns\n\t\t// route matching + the constant-time bearer compare + the\n\t\t// JSON envelope contract.\n\t\tconst serverConfig: WalletServerConfig = {\n\t\t\tbindAddress,\n\t\t\tport,\n\t\t\ttoken,\n\t\t\tpolicy,\n\t\t\taccountsByAddress,\n\t\t};\n\t\tconst server = yield* startHttpServer(serverConfig);\n\n\t\t// 6. Compose URLs. Router-fronted form when available, loopback\n\t\t// fallback otherwise. The token rides ONLY the fragment — never\n\t\t// a query param — per C13.\n\t\tconst walletUrl =\n\t\t\tctx.routerFrontedUrl ?? `http://${directUrlHostForBindAddress(bindAddress)}:${port}`;\n\t\tconst pairUrl = composePairUrl(walletUrl, token);\n\n\t\t// NON-secret connection metadata only — the token never rides this\n\t\t// (it stays in the `0o600` side-channel file the Vite plugin reads by\n\t\t// path; `pairUrl`/`token` remain on the resolved value for tests and the\n\t\t// snapshot subtree, but are NOT routed through `deployment.json`).\n\t\tconst connection: DevWalletConnection = {\n\t\t\twalletUrl,\n\t\t\tnetwork: ctx.network,\n\t\t\tprotocolPaths: {\n\t\t\t\thealth: WalletHttpPath.HEALTH,\n\t\t\t\taccounts: WalletHttpPath.ACCOUNTS,\n\t\t\t\tsignTransaction: WalletHttpPath.SIGN_TRANSACTION,\n\t\t\t\tsignPersonalMessage: WalletHttpPath.SIGN_PERSONAL_MESSAGE,\n\t\t\t},\n\t\t};\n\n\t\t// Defensive: NEVER log `pairUrl` directly. It carries the token.\n\t\tyield* Effect.logInfo('wallet ready').pipe(\n\t\t\tEffect.annotateLogs({\n\t\t\t\t'wallet.url': walletUrl,\n\t\t\t\t'wallet.token': 'redacted-fragment',\n\t\t\t}),\n\t\t);\n\n\t\treturn {\n\t\t\turl: walletUrl,\n\t\t\tpairUrl,\n\t\t\tconnection,\n\t\t\tlocalPort: port,\n\t\t\ttoken,\n\t\t\tserver,\n\t\t} satisfies WalletValue;\n\t});\n"],"mappings":";;;;;;;;AA6GA,MAAM,8BAA8B;AACpC,MAAM,yBAAyB;AAG/B,MAAM,+BAA+B,gBACpC,gBAAgB,8BAA8B,YAAY;AAE3D,MAAM,+BAA+B,gBACpC,gBAAgB,8BAA8B,yBAAyB;;;;;;;;;;;;;;;;;;AAyDxE,MAAa,iBACZ,MACA,QAEA,OAAO,IAAI,aAAa;CAIvB,MAAM,WAAW,OAAO,IAAI,gBAAgB;CAC5C,IAAI,SAAS,WAAW,GACvB,OAAO,OAAO,OAAO,KACpB,gBAAgB;EACf,OAAO;EACP,SACC;EACD,MAAM;CACP,CAAC,CACF;CAED,MAAM,oCAAoB,IAAI,IAA0B;CACxD,KAAK,MAAM,QAAQ,UAAU;EAK5B,IAAI,kBAAkB,IAAI,KAAK,OAAO,GACrC,OAAO,OAAO,OAAO,KACpB,gBAAgB;GACf,OAAO;GACP,SAAS,oDAAoD,KAAK,QAAQ;GAC1E,MAAM;EACP,CAAC,CACF;EAED,kBAAkB,IAAI,KAAK,SAAS,IAAI;CACzC;CAKA,MAAM,cAAc,KAAK,eAAe;CACxC,MAAM,OAAO,OAAO,IAAI,aAAa,KAAK,MAAM,4BAA4B,WAAW,CAAC;CAKxF,MAAM,QAAQ,OAAO,oBAAoB,UAAU,IAAI,SAAS,CAAC;CAqBjE,MAAM,SAAS,OAAO,gBAAgB;EANrC;EACA;EACA;EACA,QAAA,OAdqB,oBAAoB;GACzC,KAAK,IAAI;GACT,OAAO,IAAI;GACX,iBAAiB,IAAI;GACrB,cAAc,KAAK,kBAAkB,CAAC;EACvC,CAAC;EAUA;CAEgD,CAAC;CAKlD,MAAM,YACL,IAAI,oBAAoB,UAAU,4BAA4B,WAAW,EAAE,GAAG;CAC/E,MAAM,UAAU,eAAe,WAAW,KAAK;CAM/C,MAAM,aAAkC;EACvC;EACA,SAAS,IAAI;EACb,eAAe;GACd,QAAQ,eAAe;GACvB,UAAU,eAAe;GACzB,iBAAiB,eAAe;GAChC,qBAAqB,eAAe;EACrC;CACD;CAGA,OAAO,OAAO,QAAQ,cAAc,CAAC,CAAC,KACrC,OAAO,aAAa;EACnB,cAAc;EACd,gBAAgB;CACjB,CAAC,CACF;CAEA,OAAO;EACN,KAAK;EACL;EACA;EACA,WAAW;EACX;EACA;CACD;AACD,CAAC"}
1
+ {"version":3,"file":"service.mjs","names":[],"sources":["../../../src/plugins/wallet/service.ts"],"sourcesContent":["// Wallet plugin — main acquire Effect.\n//\n// What this file does (15-wallet.md §Lifecycle, Startup ordered):\n//\n// 1. Resolve each consumed account tag and key the AccountValues by\n// address (load-bearing: sign endpoints look up by address).\n// 2. Allocate a port via the substrate's `PortBrokerService`.\n// 3. Mint or rehydrate the pairing token (read-existing-or-mint at\n// `<stateRoot>/wallet/token`, mode 0o600) via the substrate's\n// `atomicWriteFile`.\n// 4. Resolve the stack-scoped origin allowlist via\n// `origin-policy.ts`.\n// 5. Start the in-process HTTP server.\n// 6. Compose the wallet URL + pair URL + return the resolved\n// `WalletValue`.\n//\n// The substrate handles:\n//\n// - Scope finalizers (port release, server close) — declared by the\n// substrate primitives we call into.\n// - Endpoint registry publication (we surface the URL/pairUrl via\n// our resolved tag value; the substrate's endpoint-registry\n// primitive consumes the `RoutableDecl` from `routable.ts`).\n// - Manifest emit — the substrate projects from registry. We never\n// write `.devstack/manifest.json` directly.\n\nimport { Effect } from 'effect';\nimport type { FileSystem, Scope } from 'effect';\n\nimport type { AccountResourceId, AccountValue } from '../account/index.ts';\nimport type { DevWalletConnection } from './codegen.ts';\nimport { walletBootError, type WalletBootError } from './errors.ts';\nimport { describeAllowedOrigins, resolveOriginPolicy } from './origin-policy.ts';\nimport { acquirePairingToken, composePairUrl, tokenPath, type PairingToken } from './pairing.ts';\nimport { startHttpServer, type WalletServerConfig, type WalletServerHandle } from './server.ts';\nimport { WalletHttpPath } from './protocol.ts';\n\n// ----------------------------------------------------------------------\n// User-facing options\n// ----------------------------------------------------------------------\n\nimport type { ResourceRef } from '../../api/define-plugin.ts';\n\n/** Literal sentinel for `WalletOptions.accounts: 'all'` — every account\n * member in the stack. Expanded by the composer at `defineDevstack`\n * call time (api-surface-design §4 D6). Kept as an exported constant\n * so the wallet factory + composer share one source of truth. */\nexport const WALLET_ACCOUNTS_ALL = 'all' as const;\nexport type WalletAccountsAll = typeof WALLET_ACCOUNTS_ALL;\n\n/** A user-supplied account ref. The user passes the result of\n * `account('alice')` — NOT a bare string. Generic over the literal\n * account name so the wallet's dependency tuple preserves each\n * per-account resource id (`account/alice`, `account/bob`, ...). */\nexport type WalletAccountMember<Name extends string = string> = ResourceRef<\n\tAccountResourceId<Name>,\n\tAccountValue\n>;\n\nexport interface WalletOptions<\n\tAccounts extends ReadonlyArray<WalletAccountMember> = ReadonlyArray<WalletAccountMember>,\n> {\n\t/** Accounts the wallet binds. Each is yielded for ordering AND its\n\t * resolved value is keyed by address into the sign-handler map.\n\t *\n\t * Two shapes:\n\t *\n\t * - Explicit tuple — each entry is the plugin/resource ref returned\n\t * by `account('name')`. Pins the bound set at the wallet's call\n\t * site; preserves each literal `account/${Name}` so stack\n\t * composition can validate and recursively expand the refs.\n\t *\n\t * - The literal `'all'` — shorthand for \"every account member in\n\t * the stack\". The composer expands this against the final\n\t * member tuple at `defineDevstack(...)` time (api-surface-design\n\t * §4 D6). The wallet member returned by the factory carries an\n\t * expander hook keyed off `WALLET_ACCOUNTS_ALL` that the\n\t * composer invokes once the account-providing members are\n\t * known. */\n\treadonly accounts: Accounts | typeof WALLET_ACCOUNTS_ALL;\n\t/** Extra origins merged on top of the router-fronted dev-server\n\t * origin. Useful for headless test runners and custom dev hosts. */\n\treadonly allowedOrigins?: ReadonlyArray<string>;\n\t/** Preferred host port. Substrate's port broker forward-scans if\n\t * this is taken. Default: substrate-picked (no preference). */\n\treadonly port?: number;\n\t/** NIC the HTTP server binds. Defaults to `'0.0.0.0'` because the\n\t * router runs in Docker and must reach the host process through the\n\t * host-gateway address on native Linux. The public wallet URL remains\n\t * router-fronted and stack-scoped. */\n\treadonly bindAddress?: string;\n}\n\n// ----------------------------------------------------------------------\n// Resolved value (what the wallet plugin publishes)\n// ----------------------------------------------------------------------\n\nexport interface WalletValue {\n\treadonly url: string; // router-fronted URL when available, loopback otherwise\n\treadonly pairUrl: string;\n\treadonly connection: DevWalletConnection;\n\treadonly localPort: number;\n\treadonly token: PairingToken;\n\t/** Server handle — substrate's scope finalizer chain invokes\n\t * `.close()`; callers don't reach in. Exposed here so tests can\n\t * drive teardown explicitly. */\n\treadonly server: WalletServerHandle;\n}\n\nconst WALLET_DEFAULT_BIND_ADDRESS = '0.0.0.0' as const;\nconst WALLET_DIRECT_URL_HOST = '127.0.0.1' as const;\ntype WalletPortProbeHost = '127.0.0.1' | '0.0.0.0';\n\nconst portProbeHostForBindAddress = (bindAddress: string): WalletPortProbeHost =>\n\tbindAddress === WALLET_DEFAULT_BIND_ADDRESS ? '0.0.0.0' : '127.0.0.1';\n\nconst directUrlHostForBindAddress = (bindAddress: string): string =>\n\tbindAddress === WALLET_DEFAULT_BIND_ADDRESS ? WALLET_DIRECT_URL_HOST : bindAddress;\n\n// ----------------------------------------------------------------------\n// Per-acquire context — supplied by the barrel\n// ----------------------------------------------------------------------\n\n/** Inputs from the substrate. The barrel fills these from the\n * BuildContext; tests can construct directly. */\nexport interface WalletAcquireContext {\n\treadonly app: string;\n\treadonly stack: string;\n\treadonly network: string;\n\t/** State root where `wallet/token` lives. Convention:\n\t * `<appDir>/.devstack/stacks/<stack>/runtime`. */\n\treadonly stateRoot: string;\n\t/** Port broker seam — returns the allocated port + a scope-\n\t * finalizer-installed release. The barrel adapts the substrate's\n\t * `PortBrokerService.allocate` to this signature so tests can pin\n\t * the port without yielding from a substrate Layer. */\n\treadonly allocatePort: (\n\t\tpreferred?: number,\n\t\tprobeHost?: WalletPortProbeHost,\n\t) => Effect.Effect<number, WalletBootError, Scope.Scope>;\n\t/** Account value resolver — the barrel hands this in keyed off the\n\t * BuildContext so the service body stays substrate-agnostic. */\n\treadonly resolveAccounts: () => Effect.Effect<ReadonlyArray<AccountValue>, WalletBootError>;\n\t/** Stable router-fronted base URL for this wallet on the stack-scoped\n\t * hostname (e.g. `http://wallet.<app>.localhost:<router-port>`). Null\n\t * only in tests that bypass the router derivation. */\n\treadonly routerFrontedUrl: string | null;\n\t/** Stable router-fronted dev-server origin for this stack. Added to\n\t * the wallet allowlist so app+wallet stacks work without repeating\n\t * router origins in user configs. */\n\treadonly routedAppOrigin: string | null;\n}\n\n// ----------------------------------------------------------------------\n// Acquire\n// ----------------------------------------------------------------------\n\n/**\n * Acquire the wallet service.\n *\n * Distilled-doc invariants honored:\n *\n * - C12 (mandatory Origin + bearer): both gates are wired in\n * `server.ts:dispatch`.\n * - Token comparison constant-time: `pairing.ts:safeBearerEquals`.\n * - Token file 0o600: `pairing.ts:acquirePairingToken`.\n * - Token in URL fragment only: `pairing.ts:composePairUrl`.\n * - Token NEVER in log lines: handlers log only `bearerValid:\n * boolean`.\n * - Default bindAddress `'0.0.0.0'`: required for the Docker router to\n * reach host-loopback services on native Linux.\n * - Stack-scoped origin allowlist (no cross-stack pairing risk):\n * `origin-policy.ts:resolveOriginPolicy`.\n */\nexport const acquireWallet = (\n\topts: WalletOptions,\n\tctx: WalletAcquireContext,\n): Effect.Effect<WalletValue, WalletBootError, Scope.Scope | FileSystem.FileSystem> =>\n\tEffect.gen(function* () {\n\t\t// 1. Resolve the dependency account values. The barrel sets up\n\t\t// `resolveAccounts` to walk the BuildContext via resolved dependencies in\n\t\t// `opts.accounts`; we just project to the address-keyed map.\n\t\tconst accounts = yield* ctx.resolveAccounts();\n\t\tif (accounts.length === 0) {\n\t\t\treturn yield* Effect.fail(\n\t\t\t\twalletBootError({\n\t\t\t\t\tphase: 'no-accounts',\n\t\t\t\t\tmessage:\n\t\t\t\t\t\t\"wallet resolved zero accounts; add account('name') to the stack or pass accounts explicitly.\",\n\t\t\t\t\thint: \"`wallet()` and `wallet({ accounts: 'all' })` require at least one account member in the final stack.\",\n\t\t\t\t}),\n\t\t\t);\n\t\t}\n\t\tconst accountsByAddress = new Map<string, AccountValue>();\n\t\tfor (const acct of accounts) {\n\t\t\t// Two accounts resolving to the same address would silently\n\t\t\t// last-write-wins the sign-route map, so a sign request for\n\t\t\t// that address binds to a non-deterministic account. Fail at\n\t\t\t// boot with the colliding address named.\n\t\t\tif (accountsByAddress.has(acct.address)) {\n\t\t\t\treturn yield* Effect.fail(\n\t\t\t\t\twalletBootError({\n\t\t\t\t\t\tphase: 'bind-account',\n\t\t\t\t\t\tmessage: `wallet resolved two accounts at the same address ${acct.address}; each account must own a distinct address.`,\n\t\t\t\t\t\thint: 'Remove the duplicate account member, or give the colliding accounts distinct keypairs.',\n\t\t\t\t\t}),\n\t\t\t\t);\n\t\t\t}\n\t\t\taccountsByAddress.set(acct.address, acct);\n\t\t}\n\n\t\t// 2. Port allocation. Probe the same host family the real\n\t\t// listener uses, otherwise a process bound on a non-loopback\n\t\t// interface could race the wallet's all-interface listen.\n\t\tconst bindAddress = opts.bindAddress ?? WALLET_DEFAULT_BIND_ADDRESS;\n\t\tconst port = yield* ctx.allocatePort(opts.port, portProbeHostForBindAddress(bindAddress));\n\n\t\t// 3. Token mint or rehydrate. Lives at the stack-scoped state root\n\t\t// so warm-start + snapshot-restore preserve the existing\n\t\t// pairing.\n\t\tconst token = yield* acquirePairingToken(tokenPath(ctx.stateRoot));\n\n\t\t// 4. Origin policy. Allowlist is the router-fronted dev-server\n\t\t// origin for this stack plus any explicit `allowedOrigins`.\n\t\tconst policy = yield* resolveOriginPolicy({\n\t\t\tapp: ctx.app,\n\t\t\tstack: ctx.stack,\n\t\t\troutedAppOrigin: ctx.routedAppOrigin,\n\t\t\textraOrigins: opts.allowedOrigins ?? [],\n\t\t});\n\n\t\t// 5. Start the HTTP server. The dispatcher in `server.ts` owns\n\t\t// route matching + the constant-time bearer compare + the\n\t\t// JSON envelope contract.\n\t\tconst serverConfig: WalletServerConfig = {\n\t\t\tbindAddress,\n\t\t\tport,\n\t\t\ttoken,\n\t\t\tpolicy,\n\t\t\taccountsByAddress,\n\t\t};\n\t\tconst server = yield* startHttpServer(serverConfig);\n\n\t\t// 6. Compose URLs. Router-fronted form when available, loopback\n\t\t// fallback otherwise. The token rides ONLY the fragment — never\n\t\t// a query param — per C13.\n\t\tconst walletUrl =\n\t\t\tctx.routerFrontedUrl ?? `http://${directUrlHostForBindAddress(bindAddress)}:${port}`;\n\t\tconst pairUrl = composePairUrl(walletUrl, token);\n\n\t\t// NON-secret connection metadata only — the token never rides this\n\t\t// (it stays in the `0o600` side-channel file the Vite plugin reads by\n\t\t// path; `pairUrl`/`token` remain on the resolved value for tests and the\n\t\t// snapshot subtree, but are NOT routed through `deployment.json`).\n\t\tconst connection: DevWalletConnection = {\n\t\t\twalletUrl,\n\t\t\tnetwork: ctx.network,\n\t\t\tallowedOrigins: describeAllowedOrigins(policy),\n\t\t\tprotocolPaths: {\n\t\t\t\thealth: WalletHttpPath.HEALTH,\n\t\t\t\taccounts: WalletHttpPath.ACCOUNTS,\n\t\t\t\tsignTransaction: WalletHttpPath.SIGN_TRANSACTION,\n\t\t\t\tsignPersonalMessage: WalletHttpPath.SIGN_PERSONAL_MESSAGE,\n\t\t\t},\n\t\t};\n\n\t\t// Defensive: NEVER log `pairUrl` directly. It carries the token.\n\t\tyield* Effect.logInfo('wallet ready').pipe(\n\t\t\tEffect.annotateLogs({\n\t\t\t\t'wallet.url': walletUrl,\n\t\t\t\t'wallet.token': 'redacted-fragment',\n\t\t\t}),\n\t\t);\n\n\t\treturn {\n\t\t\turl: walletUrl,\n\t\t\tpairUrl,\n\t\t\tconnection,\n\t\t\tlocalPort: port,\n\t\t\ttoken,\n\t\t\tserver,\n\t\t} satisfies WalletValue;\n\t});\n"],"mappings":";;;;;;;;AA6GA,MAAM,8BAA8B;AACpC,MAAM,yBAAyB;AAG/B,MAAM,+BAA+B,gBACpC,gBAAgB,8BAA8B,YAAY;AAE3D,MAAM,+BAA+B,gBACpC,gBAAgB,8BAA8B,yBAAyB;;;;;;;;;;;;;;;;;;AAyDxE,MAAa,iBACZ,MACA,QAEA,OAAO,IAAI,aAAa;CAIvB,MAAM,WAAW,OAAO,IAAI,gBAAgB;CAC5C,IAAI,SAAS,WAAW,GACvB,OAAO,OAAO,OAAO,KACpB,gBAAgB;EACf,OAAO;EACP,SACC;EACD,MAAM;CACP,CAAC,CACF;CAED,MAAM,oCAAoB,IAAI,IAA0B;CACxD,KAAK,MAAM,QAAQ,UAAU;EAK5B,IAAI,kBAAkB,IAAI,KAAK,OAAO,GACrC,OAAO,OAAO,OAAO,KACpB,gBAAgB;GACf,OAAO;GACP,SAAS,oDAAoD,KAAK,QAAQ;GAC1E,MAAM;EACP,CAAC,CACF;EAED,kBAAkB,IAAI,KAAK,SAAS,IAAI;CACzC;CAKA,MAAM,cAAc,KAAK,eAAe;CACxC,MAAM,OAAO,OAAO,IAAI,aAAa,KAAK,MAAM,4BAA4B,WAAW,CAAC;CAKxF,MAAM,QAAQ,OAAO,oBAAoB,UAAU,IAAI,SAAS,CAAC;CAIjE,MAAM,SAAS,OAAO,oBAAoB;EACzC,KAAK,IAAI;EACT,OAAO,IAAI;EACX,iBAAiB,IAAI;EACrB,cAAc,KAAK,kBAAkB,CAAC;CACvC,CAAC;CAYD,MAAM,SAAS,OAAO,gBAAgB;EANrC;EACA;EACA;EACA;EACA;CAEgD,CAAC;CAKlD,MAAM,YACL,IAAI,oBAAoB,UAAU,4BAA4B,WAAW,EAAE,GAAG;CAC/E,MAAM,UAAU,eAAe,WAAW,KAAK;CAM/C,MAAM,aAAkC;EACvC;EACA,SAAS,IAAI;EACb,gBAAgB,uBAAuB,MAAM;EAC7C,eAAe;GACd,QAAQ,eAAe;GACvB,UAAU,eAAe;GACzB,iBAAiB,eAAe;GAChC,qBAAqB,eAAe;EACrC;CACD;CAGA,OAAO,OAAO,QAAQ,cAAc,CAAC,CAAC,KACrC,OAAO,aAAa;EACnB,cAAc;EACd,gBAAgB;CACjB,CAAC,CACF;CAEA,OAAO;EACN,KAAK;EACL;EACA;EACA,WAAW;EACX;EACA;CACD;AACD,CAAC"}
@@ -7,7 +7,8 @@ const RosterHolderSchema = Schema.Struct({
7
7
  hostname: Schema.String,
8
8
  claimedAt: Schema.Number,
9
9
  heartbeatAt: Schema.Number,
10
- intent: Schema.Literals(["normal", "snapshot"])
10
+ intent: Schema.Literals(["normal", "snapshot"]),
11
+ graphInputId: Schema.optional(Schema.String)
11
12
  });
12
13
  const RosterDocumentSchema = versionedDocSchema(1, { holders: Schema.Array(RosterHolderSchema) });
13
14
  const DEFAULT_SWEEP_POLICY = {
@@ -1 +1 @@
1
- {"version":3,"file":"cross-process.mjs","names":[],"sources":["../../src/substrate/cross-process.ts"],"sourcesContent":["// Cross-process safety protocol.\n//\n// Architecture § Cross-process safety protocol. Core artifacts per\n// stack on disk under `<runtime-root>/stacks/<stack>/`:\n//\n// - `stack.lock` — OS-advisory exclusive lock; short critical\n// sections only.\n// - `roster.json` — authoritative cross-process record of holders;\n// mutated only under the lock.\n// - `commands.ndjson` / `events.ndjson` — filesystem command\n// channel between peer CLI commands and the live supervisor.\n//\n// The snapshot bounce holds `stack.lock` for the (bounded, whole-stack-\n// stopped) snapshot window — there is no separate snapshot-reservation file\n// (the lock subsumes the concurrency guard).\n\nimport { Schema } from 'effect';\n\nimport { versionedDocSchema } from './versioned-doc-schema.ts';\n\n/** Holder intent — `normal` for ordinary peers; `snapshot` while\n * the holder is mid-capture (peers' commands defer). */\nexport type HolderIntent = 'normal' | 'snapshot';\n\n/** One holder entry in `roster.json`. PID + startTime distinguish live\n * processes from PID reuse.\n *\n * `startTime` is `number | null` — `null` means \"the platform could\n * not probe a start-time stamp for this process at write time\" (an\n * exotic platform, or `ps`/`tasklist` failed). Readers MUST treat\n * `null` as the conservative branch (see `isOwnEntry` in `roster.ts`\n * and `checkHolderLiveness` in `liveness.ts`): on a null recorded\n * stamp the start-time comparison is skipped and the (pid, hostname)\n * pair carries the identity. */\nexport interface RosterHolder {\n\treadonly pid: number;\n\t/** Process start-time, used for PID-reuse-safe liveness. `null`\n\t * means \"unprobable\" — see the interface doc above. */\n\treadonly startTime: number | null;\n\treadonly hostname: string;\n\treadonly claimedAt: number;\n\treadonly heartbeatAt: number;\n\treadonly intent: HolderIntent;\n}\n\n/** Roster document schema — versioned for schema validation. */\nexport interface RosterDocument {\n\treadonly version: 1;\n\treadonly holders: ReadonlyArray<RosterHolder>;\n}\n\nexport const RosterHolderSchema = Schema.Struct({\n\tpid: Schema.Number,\n\tstartTime: Schema.NullOr(Schema.Number),\n\thostname: Schema.String,\n\tclaimedAt: Schema.Number,\n\theartbeatAt: Schema.Number,\n\tintent: Schema.Literals(['normal', 'snapshot']),\n});\n\nexport const RosterDocumentSchema = versionedDocSchema(1, {\n\tholders: Schema.Array(RosterHolderSchema),\n});\n\n/** Sweep policy — peers older than `staleAfterMillis` AND failing\n * the PID liveness check are evicted on the next claim under the\n * exclusive lock. */\nexport interface RosterSweepPolicy {\n\treadonly heartbeatIntervalMillis: number;\n\treadonly staleAfterMillis: number;\n}\n\nexport const DEFAULT_SWEEP_POLICY: RosterSweepPolicy = {\n\theartbeatIntervalMillis: 10_000,\n\tstaleAfterMillis: 30_000,\n};\n"],"mappings":";;;AAmDA,MAAa,qBAAqB,OAAO,OAAO;CAC/C,KAAK,OAAO;CACZ,WAAW,OAAO,OAAO,OAAO,MAAM;CACtC,UAAU,OAAO;CACjB,WAAW,OAAO;CAClB,aAAa,OAAO;CACpB,QAAQ,OAAO,SAAS,CAAC,UAAU,UAAU,CAAC;AAC/C,CAAC;AAED,MAAa,uBAAuB,mBAAmB,GAAG,EACzD,SAAS,OAAO,MAAM,kBAAkB,EACzC,CAAC;AAUD,MAAa,uBAA0C;CACtD,yBAAyB;CACzB,kBAAkB;AACnB"}
1
+ {"version":3,"file":"cross-process.mjs","names":[],"sources":["../../src/substrate/cross-process.ts"],"sourcesContent":["// Cross-process safety protocol.\n//\n// Architecture § Cross-process safety protocol. Core artifacts per\n// stack on disk under `<runtime-root>/stacks/<stack>/`:\n//\n// - `stack.lock` — OS-advisory exclusive lock; short critical\n// sections only.\n// - `roster.json` — authoritative cross-process record of holders;\n// mutated only under the lock.\n// - `commands.ndjson` / `events.ndjson` — filesystem command\n// channel between peer CLI commands and the live supervisor.\n//\n// The snapshot bounce holds `stack.lock` for the (bounded, whole-stack-\n// stopped) snapshot window — there is no separate snapshot-reservation file\n// (the lock subsumes the concurrency guard).\n\nimport { Schema } from 'effect';\n\nimport { versionedDocSchema } from './versioned-doc-schema.ts';\n\n/** Holder intent — `normal` for ordinary peers; `snapshot` while\n * the holder is mid-capture (peers' commands defer). */\nexport type HolderIntent = 'normal' | 'snapshot';\n\n/** One holder entry in `roster.json`. PID + startTime distinguish live\n * processes from PID reuse.\n *\n * `startTime` is `number | null` — `null` means \"the platform could\n * not probe a start-time stamp for this process at write time\" (an\n * exotic platform, or `ps`/`tasklist` failed). Readers MUST treat\n * `null` as the conservative branch (see `isOwnEntry` in `roster.ts`\n * and `checkHolderLiveness` in `liveness.ts`): on a null recorded\n * stamp the start-time comparison is skipped and the (pid, hostname)\n * pair carries the identity. */\nexport interface RosterHolder {\n\treadonly pid: number;\n\t/** Process start-time, used for PID-reuse-safe liveness. `null`\n\t * means \"unprobable\" — see the interface doc above. */\n\treadonly startTime: number | null;\n\treadonly hostname: string;\n\treadonly claimedAt: number;\n\treadonly heartbeatAt: number;\n\treadonly intent: HolderIntent;\n\t/** Optional desired-graph identity for a live supervisor. CLI peers use\n\t * this to refuse live apply/codegen refreshes against a supervisor booted\n\t * from a different stack graph. */\n\treadonly graphInputId?: string;\n}\n\n/** Roster document schema — versioned for schema validation. */\nexport interface RosterDocument {\n\treadonly version: 1;\n\treadonly holders: ReadonlyArray<RosterHolder>;\n}\n\nexport const RosterHolderSchema = Schema.Struct({\n\tpid: Schema.Number,\n\tstartTime: Schema.NullOr(Schema.Number),\n\thostname: Schema.String,\n\tclaimedAt: Schema.Number,\n\theartbeatAt: Schema.Number,\n\tintent: Schema.Literals(['normal', 'snapshot']),\n\tgraphInputId: Schema.optional(Schema.String),\n});\n\nexport const RosterDocumentSchema = versionedDocSchema(1, {\n\tholders: Schema.Array(RosterHolderSchema),\n});\n\n/** Sweep policy — peers older than `staleAfterMillis` AND failing\n * the PID liveness check are evicted on the next claim under the\n * exclusive lock. */\nexport interface RosterSweepPolicy {\n\treadonly heartbeatIntervalMillis: number;\n\treadonly staleAfterMillis: number;\n}\n\nexport const DEFAULT_SWEEP_POLICY: RosterSweepPolicy = {\n\theartbeatIntervalMillis: 10_000,\n\tstaleAfterMillis: 30_000,\n};\n"],"mappings":";;;AAuDA,MAAa,qBAAqB,OAAO,OAAO;CAC/C,KAAK,OAAO;CACZ,WAAW,OAAO,OAAO,OAAO,MAAM;CACtC,UAAU,OAAO;CACjB,WAAW,OAAO;CAClB,aAAa,OAAO;CACpB,QAAQ,OAAO,SAAS,CAAC,UAAU,UAAU,CAAC;CAC9C,cAAc,OAAO,SAAS,OAAO,MAAM;AAC5C,CAAC;AAED,MAAa,uBAAuB,mBAAmB,GAAG,EACzD,SAAS,OAAO,MAAM,kBAAkB,EACzC,CAAC;AAUD,MAAa,uBAA0C;CACtD,yBAAyB;CACzB,kBAAkB;AACnB"}
@@ -96,9 +96,12 @@ const withStackLock = (paths, body) => Effect.scoped(Effect.gen(function* () {
96
96
  *
97
97
  * Returns the swept document, our holder entry, and any evicted peers.
98
98
  */
99
- const claim = (paths, intent = "normal", policy = DEFAULT_SWEEP_POLICY) => withStackLock(paths, Effect.gen(function* () {
99
+ const claim = (paths, intent = "normal", policy = DEFAULT_SWEEP_POLICY, metadata = {}) => withStackLock(paths, Effect.gen(function* () {
100
100
  const { swept, evicted } = yield* sweepStaleHolders(yield* readRoster(paths.rosterFile).pipe(Effect.catchTag("RosterCorruptError", () => Effect.succeed(EMPTY_ROSTER))), policy);
101
- const self = { ...ownHolder(intent) };
101
+ const self = {
102
+ ...ownHolder(intent),
103
+ ...metadata
104
+ };
102
105
  const next = {
103
106
  version: 1,
104
107
  holders: [...swept.holders, self]
@@ -1 +1 @@
1
- {"version":3,"file":"roster.mjs","names":["nodeHostname"],"sources":["../../../../src/substrate/runtime/cross-process/roster.ts"],"sourcesContent":["// `roster.json` — authoritative cross-process holder record.\n//\n// Architecture § Cross-process safety protocol § Roster:\n// \"the authoritative cross-process record of which OS processes are\n// currently 'in' this stack.\"\n//\n// Mutated only under `stack.lock`. Schema-validated on every read so a\n// truncated/forward-version write never corrupts a peer's view. Stale\n// entries reaped during step-3 sweep on the next claim.\n\nimport { hostname as nodeHostname } from 'node:os';\n\nimport { Data, Effect } from 'effect';\n\nimport {\n\tDEFAULT_SWEEP_POLICY,\n\ttype RosterDocument,\n\tRosterDocumentSchema,\n\ttype RosterHolder,\n\ttype RosterSweepPolicy,\n} from '../../cross-process.ts';\nimport { LogAttr } from '../observability/log-attrs.ts';\nimport { readVersionedDocumentSync, writeVersionedDocumentSync } from '../../versioned-doc-sync.ts';\nimport { selfPid } from './self-pid.ts';\nimport { acquireStackLock } from './stack-lock.ts';\nimport {\n\tlayerLivenessProbeScope,\n\tLivenessProbeScope,\n\townHolder,\n\tprocessStartTime,\n} from './liveness.ts';\n\n// -----------------------------------------------------------------------------\n// Errors\n// -----------------------------------------------------------------------------\n\nexport class RosterCorruptError extends Data.TaggedError('RosterCorruptError')<{\n\treadonly path: string;\n\treadonly raw: string;\n\treadonly cause: unknown;\n}> {}\n\nexport class RosterIoError extends Data.TaggedError('RosterIoError')<{\n\treadonly path: string;\n\treadonly cause: unknown;\n}> {}\n\nexport type RosterError = RosterCorruptError | RosterIoError;\n\n// -----------------------------------------------------------------------------\n// Read / write\n// -----------------------------------------------------------------------------\n\nconst EMPTY_ROSTER: RosterDocument = { version: 1, holders: [] };\n\nconst ROSTER_DOC_ERRORS = {\n\tmkIo: ({ path, cause }: { path: string; cause: unknown }) => new RosterIoError({ path, cause }),\n\tmkCorrupt: ({ path, raw, cause }: { path: string; raw: string; cause: unknown }) =>\n\t\tnew RosterCorruptError({ path, raw, cause }),\n} as const;\n\n/** Read the roster from disk. Returns the empty document if absent.\n * Tolerates a missing file but NOT a malformed file (a malformed\n * roster surfaces a typed error so callers can decide whether to\n * abandon or rewrite). */\nexport const readRoster = (path: string): Effect.Effect<RosterDocument, RosterError> =>\n\treadVersionedDocumentSync(path, RosterDocumentSchema, ROSTER_DOC_ERRORS, EMPTY_ROSTER);\n\n/** Atomic write: route through the canonical sync primitive. The\n * roster's mutations are all under `stack.lock`, so the non-yielding\n * sync surface is correct here — and it shares ONE owner of the\n * tempfile dance with cache and manifest. */\nconst atomicWriteRoster = (path: string, doc: RosterDocument): Effect.Effect<void, RosterIoError> =>\n\twriteVersionedDocumentSync(path, doc, ROSTER_DOC_ERRORS);\n\n// -----------------------------------------------------------------------------\n// Sweep\n// -----------------------------------------------------------------------------\n\n/** Walk the roster's holders and drop those that fail the liveness\n * test AND whose heartbeats are older than `staleAfterMillis`.\n *\n * Architecture § Claim protocol step 3 — \"Holders whose\n * `heartbeatAt` is older than 3× the heartbeat interval AND who fail\n * the PID liveness check are evicted.\"\n *\n * A live holder with a stale heartbeat is NOT evicted — heartbeat\n * staleness alone is allowed (slow peer). The conjunction matters. */\nexport const sweepStaleHolders = Effect.fn('cross-process.roster.sweep')(function* (\n\tdoc: RosterDocument,\n\tpolicy: RosterSweepPolicy = DEFAULT_SWEEP_POLICY,\n\tnow: number = Date.now(),\n) {\n\t// Yield a fresh per-sweep liveness scope so the same pid is probed\n\t// AT MOST once across all holders in this pass — two holders sharing\n\t// a pid (corrupted-roster edge case) collapse to one fork. The\n\t// `Layer.provide` below scopes the cache to THIS sweep only.\n\tconst probe = yield* LivenessProbeScope;\n\tconst survivors: RosterHolder[] = [];\n\tconst evicted: RosterHolder[] = [];\n\tfor (const holder of doc.holders) {\n\t\tconst heartbeatStale = now - holder.heartbeatAt > policy.staleAfterMillis;\n\t\tif (!heartbeatStale) {\n\t\t\tsurvivors.push(holder);\n\t\t\tcontinue;\n\t\t}\n\t\tconst liveness = yield* probe\n\t\t\t.probeHolderLiveness(holder)\n\t\t\t.pipe(Effect.catch(() => Effect.succeed('alive' as const)));\n\t\tif (liveness === 'dead') {\n\t\t\tevicted.push(holder);\n\t\t} else {\n\t\t\tsurvivors.push(holder);\n\t\t}\n\t}\n\treturn {\n\t\tswept: { version: doc.version, holders: survivors } satisfies RosterDocument,\n\t\tevicted: evicted as ReadonlyArray<RosterHolder>,\n\t};\n}, Effect.provide(layerLivenessProbeScope));\n\n// -----------------------------------------------------------------------------\n// Claim / release / heartbeat\n// -----------------------------------------------------------------------------\n\n/** Outcome of a claim: the holder this process registered, the swept\n * document, and whether this process is now the sole holder. */\nexport interface ClaimResult {\n\treadonly self: RosterHolder;\n\treadonly roster: RosterDocument;\n\treadonly evicted: ReadonlyArray<RosterHolder>;\n\treadonly soleHolder: boolean;\n}\n\n/** Outcome of a release: the swept document and whether this process\n * was the last leaver (no peers remain after removal). Architecture §\n * Release protocol step 4. */\nexport interface ReleaseResult {\n\treadonly roster: RosterDocument;\n\treadonly lastLeaver: boolean;\n}\n\ninterface RosterPaths {\n\treadonly stackLockFile: string;\n\treadonly rosterFile: string;\n}\n\n/** Match a roster holder against THIS process's identity.\n *\n * Liveness elsewhere (`checkHolderLiveness`) uses\n * `(pid, hostname, startTime)` — PID alone is insufficient on\n * long-uptime hosts where the kernel can recycle PIDs. The roster\n * mutators (`heartbeat`, `release`, `setIntent`) must apply the same\n * triple match so a recycled-PID peer's entry is never silently\n * overwritten/removed by this process.\n *\n * `startTime` is the FNV-1a hash of `ps -o lstart` (see\n * `liveness.processStartTime`). A `null` probe (process gone, or\n * exotic platform) skips the start-time check — the same conservative\n * policy `checkHolderLiveness` applies. */\nconst isOwnEntry = (\n\th: RosterHolder,\n\townPid: number,\n\townHost: string,\n\townStartTime: number | null,\n): boolean => {\n\tif (h.pid !== ownPid || h.hostname !== ownHost) return false;\n\t// Either side null → fall back to (pid, hostname). The roster's\n\t// own-entry test must symmetrically accept a null recorded stamp:\n\t// the writer's probe failed (exotic platform / transient `ps`\n\t// error) but the entry IS ours. Mismatching a probed `ownStartTime`\n\t// against a recorded `null` would orphan our own entry — peers\n\t// would then harvest it as \"dead\" on the next sweep.\n\tif (ownStartTime === null || h.startTime === null) return true;\n\treturn h.startTime === ownStartTime;\n};\n\nconst withStackLock = <A, E, R>(\n\tpaths: RosterPaths,\n\tbody: Effect.Effect<A, E, R>,\n): Effect.Effect<A, E | import('./stack-lock.ts').StackLockError, R> =>\n\tEffect.scoped(\n\t\tEffect.gen(function* () {\n\t\t\tyield* acquireStackLock(paths.stackLockFile);\n\t\t\treturn yield* body;\n\t\t}),\n\t);\n\n/**\n * Claim protocol — architecture § Claim protocol.\n *\n * Under the exclusive lock:\n * 1. Read the roster (or initialize empty if missing).\n * 2. Sweep stale holders (PID liveness + heartbeat age).\n * 3. Append this process's entry.\n * 4. Atomic write the result.\n *\n * Returns the swept document, our holder entry, and any evicted peers.\n */\nexport const claim = (\n\tpaths: RosterPaths,\n\tintent: 'normal' | 'snapshot' = 'normal',\n\tpolicy: RosterSweepPolicy = DEFAULT_SWEEP_POLICY,\n): Effect.Effect<ClaimResult, RosterError | import('./stack-lock.ts').StackLockError> =>\n\twithStackLock(\n\t\tpaths,\n\t\tEffect.gen(function* () {\n\t\t\tconst initial = yield* readRoster(paths.rosterFile).pipe(\n\t\t\t\tEffect.catchTag('RosterCorruptError', () => Effect.succeed(EMPTY_ROSTER)),\n\t\t\t);\n\t\t\tconst { swept, evicted } = yield* sweepStaleHolders(initial, policy);\n\t\t\tconst self: RosterHolder = { ...ownHolder(intent) };\n\t\t\tconst next: RosterDocument = {\n\t\t\t\tversion: 1,\n\t\t\t\tholders: [...swept.holders, self],\n\t\t\t};\n\t\t\tyield* atomicWriteRoster(paths.rosterFile, next);\n\t\t\treturn {\n\t\t\t\tself,\n\t\t\t\troster: next,\n\t\t\t\tevicted,\n\t\t\t\tsoleHolder: swept.holders.length === 0,\n\t\t\t};\n\t\t}),\n\t);\n\n/**\n * Heartbeat protocol — refresh this process's `heartbeatAt`. Under the\n * stack lock to keep mutation atomic. Architecture § Heartbeat\n * protocol.\n */\nexport const heartbeat = (\n\tpaths: RosterPaths,\n\townPid: number = selfPid(),\n): Effect.Effect<void, RosterError | import('./stack-lock.ts').StackLockError> =>\n\twithStackLock(\n\t\tpaths,\n\t\tEffect.gen(function* () {\n\t\t\tconst current = yield* readRoster(paths.rosterFile).pipe(\n\t\t\t\tEffect.catchTag('RosterCorruptError', () => Effect.succeed(EMPTY_ROSTER)),\n\t\t\t);\n\t\t\tconst now = Date.now();\n\t\t\tconst ownHost = nodeHostname();\n\t\t\tconst ownStartTime = processStartTime(ownPid);\n\t\t\tlet touched = false;\n\t\t\tconst next: RosterDocument = {\n\t\t\t\tversion: 1,\n\t\t\t\tholders: current.holders.map((h) => {\n\t\t\t\t\tif (isOwnEntry(h, ownPid, ownHost, ownStartTime)) {\n\t\t\t\t\t\ttouched = true;\n\t\t\t\t\t\treturn { ...h, heartbeatAt: now };\n\t\t\t\t\t}\n\t\t\t\t\treturn h;\n\t\t\t\t}),\n\t\t\t};\n\t\t\tif (touched) yield* atomicWriteRoster(paths.rosterFile, next);\n\t\t}),\n\t);\n\n/**\n * Release protocol — architecture § Release protocol.\n *\n * Removes this process's holder. Returns whether this was the\n * last-leaver (caller runs the stop finalizer if so).\n */\nexport const release = (\n\tpaths: RosterPaths,\n\townPid: number = selfPid(),\n): Effect.Effect<ReleaseResult, RosterError | import('./stack-lock.ts').StackLockError> =>\n\twithStackLock(\n\t\tpaths,\n\t\tEffect.gen(function* () {\n\t\t\tconst current = yield* readRoster(paths.rosterFile).pipe(\n\t\t\t\tEffect.catchTag('RosterCorruptError', () => Effect.succeed(EMPTY_ROSTER)),\n\t\t\t);\n\t\t\tconst ownHost = nodeHostname();\n\t\t\tconst ownStartTime = processStartTime(ownPid);\n\t\t\tconst remaining = current.holders.filter(\n\t\t\t\t(h) => !isOwnEntry(h, ownPid, ownHost, ownStartTime),\n\t\t\t);\n\t\t\tconst next: RosterDocument = { version: 1, holders: remaining };\n\t\t\tyield* atomicWriteRoster(paths.rosterFile, next);\n\t\t\treturn {\n\t\t\t\troster: next,\n\t\t\t\tlastLeaver: remaining.length === 0,\n\t\t\t};\n\t\t}),\n\t);\n\n/**\n * Set this process's `intent` (`normal` ↔ `snapshot`) under the\n * exclusive lock. Architecture § Concurrent snapshot step 2 / 5.\n */\nexport const setIntent = (\n\tpaths: RosterPaths,\n\tintent: 'normal' | 'snapshot',\n\townPid: number = selfPid(),\n): Effect.Effect<void, RosterError | import('./stack-lock.ts').StackLockError> =>\n\twithStackLock(\n\t\tpaths,\n\t\tEffect.gen(function* () {\n\t\t\tconst current = yield* readRoster(paths.rosterFile).pipe(\n\t\t\t\tEffect.catchTag('RosterCorruptError', () => Effect.succeed(EMPTY_ROSTER)),\n\t\t\t);\n\t\t\tconst ownHost = nodeHostname();\n\t\t\tconst ownStartTime = processStartTime(ownPid);\n\t\t\tconst next: RosterDocument = {\n\t\t\t\tversion: 1,\n\t\t\t\tholders: current.holders.map((h) =>\n\t\t\t\t\tisOwnEntry(h, ownPid, ownHost, ownStartTime) ? { ...h, intent } : h,\n\t\t\t\t),\n\t\t\t};\n\t\t\tyield* atomicWriteRoster(paths.rosterFile, next);\n\t\t}),\n\t);\n\n/** Background heartbeat fiber. Wakes every `intervalMillis` (default\n * matches `DEFAULT_SWEEP_POLICY.heartbeatIntervalMillis`) and refreshes\n * this process's `heartbeatAt`.\n *\n * Returns an Effect that runs forever in its Scope — the supervisor\n * forks it via `Effect.forkScoped` so it tears down with the stack. */\nexport const heartbeatFiber = (\n\tpaths: RosterPaths,\n\tintervalMillis: number = DEFAULT_SWEEP_POLICY.heartbeatIntervalMillis,\n): Effect.Effect<never> =>\n\tEffect.gen(function* () {\n\t\twhile (true) {\n\t\t\tyield* Effect.sleep(`${intervalMillis} millis`);\n\t\t\tyield* heartbeat(paths).pipe(\n\t\t\t\t// A heartbeat failure is non-fatal — the next peer's sweep\n\t\t\t\t// would otherwise evict us, but the architecture treats this\n\t\t\t\t// as \"the dev didn't get to flush a heartbeat in time;\n\t\t\t\t// recover by retrying next interval.\" Log via Effect's\n\t\t\t\t// logger; do not propagate.\n\t\t\t\tEffect.catch((err) =>\n\t\t\t\t\tEffect.logWarning('roster heartbeat failed').pipe(\n\t\t\t\t\t\tEffect.annotateLogs({\n\t\t\t\t\t\t\t[LogAttr.rosterHeartbeatIntervalMs]: intervalMillis,\n\t\t\t\t\t\t\t[LogAttr.errorCause]: String(err),\n\t\t\t\t\t\t}),\n\t\t\t\t\t),\n\t\t\t\t),\n\t\t\t);\n\t\t}\n\t});\n"],"mappings":"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"}
1
+ {"version":3,"file":"roster.mjs","names":["nodeHostname"],"sources":["../../../../src/substrate/runtime/cross-process/roster.ts"],"sourcesContent":["// `roster.json` — authoritative cross-process holder record.\n//\n// Architecture § Cross-process safety protocol § Roster:\n// \"the authoritative cross-process record of which OS processes are\n// currently 'in' this stack.\"\n//\n// Mutated only under `stack.lock`. Schema-validated on every read so a\n// truncated/forward-version write never corrupts a peer's view. Stale\n// entries reaped during step-3 sweep on the next claim.\n\nimport { hostname as nodeHostname } from 'node:os';\n\nimport { Data, Effect } from 'effect';\n\nimport {\n\tDEFAULT_SWEEP_POLICY,\n\ttype RosterDocument,\n\tRosterDocumentSchema,\n\ttype RosterHolder,\n\ttype RosterSweepPolicy,\n} from '../../cross-process.ts';\nimport { LogAttr } from '../observability/log-attrs.ts';\nimport { readVersionedDocumentSync, writeVersionedDocumentSync } from '../../versioned-doc-sync.ts';\nimport { selfPid } from './self-pid.ts';\nimport { acquireStackLock } from './stack-lock.ts';\nimport {\n\tlayerLivenessProbeScope,\n\tLivenessProbeScope,\n\townHolder,\n\tprocessStartTime,\n} from './liveness.ts';\n\n// -----------------------------------------------------------------------------\n// Errors\n// -----------------------------------------------------------------------------\n\nexport class RosterCorruptError extends Data.TaggedError('RosterCorruptError')<{\n\treadonly path: string;\n\treadonly raw: string;\n\treadonly cause: unknown;\n}> {}\n\nexport class RosterIoError extends Data.TaggedError('RosterIoError')<{\n\treadonly path: string;\n\treadonly cause: unknown;\n}> {}\n\nexport type RosterError = RosterCorruptError | RosterIoError;\n\n// -----------------------------------------------------------------------------\n// Read / write\n// -----------------------------------------------------------------------------\n\nconst EMPTY_ROSTER: RosterDocument = { version: 1, holders: [] };\n\nconst ROSTER_DOC_ERRORS = {\n\tmkIo: ({ path, cause }: { path: string; cause: unknown }) => new RosterIoError({ path, cause }),\n\tmkCorrupt: ({ path, raw, cause }: { path: string; raw: string; cause: unknown }) =>\n\t\tnew RosterCorruptError({ path, raw, cause }),\n} as const;\n\n/** Read the roster from disk. Returns the empty document if absent.\n * Tolerates a missing file but NOT a malformed file (a malformed\n * roster surfaces a typed error so callers can decide whether to\n * abandon or rewrite). */\nexport const readRoster = (path: string): Effect.Effect<RosterDocument, RosterError> =>\n\treadVersionedDocumentSync(path, RosterDocumentSchema, ROSTER_DOC_ERRORS, EMPTY_ROSTER);\n\n/** Atomic write: route through the canonical sync primitive. The\n * roster's mutations are all under `stack.lock`, so the non-yielding\n * sync surface is correct here — and it shares ONE owner of the\n * tempfile dance with cache and manifest. */\nconst atomicWriteRoster = (path: string, doc: RosterDocument): Effect.Effect<void, RosterIoError> =>\n\twriteVersionedDocumentSync(path, doc, ROSTER_DOC_ERRORS);\n\n// -----------------------------------------------------------------------------\n// Sweep\n// -----------------------------------------------------------------------------\n\n/** Walk the roster's holders and drop those that fail the liveness\n * test AND whose heartbeats are older than `staleAfterMillis`.\n *\n * Architecture § Claim protocol step 3 — \"Holders whose\n * `heartbeatAt` is older than 3× the heartbeat interval AND who fail\n * the PID liveness check are evicted.\"\n *\n * A live holder with a stale heartbeat is NOT evicted — heartbeat\n * staleness alone is allowed (slow peer). The conjunction matters. */\nexport const sweepStaleHolders = Effect.fn('cross-process.roster.sweep')(function* (\n\tdoc: RosterDocument,\n\tpolicy: RosterSweepPolicy = DEFAULT_SWEEP_POLICY,\n\tnow: number = Date.now(),\n) {\n\t// Yield a fresh per-sweep liveness scope so the same pid is probed\n\t// AT MOST once across all holders in this pass — two holders sharing\n\t// a pid (corrupted-roster edge case) collapse to one fork. The\n\t// `Layer.provide` below scopes the cache to THIS sweep only.\n\tconst probe = yield* LivenessProbeScope;\n\tconst survivors: RosterHolder[] = [];\n\tconst evicted: RosterHolder[] = [];\n\tfor (const holder of doc.holders) {\n\t\tconst heartbeatStale = now - holder.heartbeatAt > policy.staleAfterMillis;\n\t\tif (!heartbeatStale) {\n\t\t\tsurvivors.push(holder);\n\t\t\tcontinue;\n\t\t}\n\t\tconst liveness = yield* probe\n\t\t\t.probeHolderLiveness(holder)\n\t\t\t.pipe(Effect.catch(() => Effect.succeed('alive' as const)));\n\t\tif (liveness === 'dead') {\n\t\t\tevicted.push(holder);\n\t\t} else {\n\t\t\tsurvivors.push(holder);\n\t\t}\n\t}\n\treturn {\n\t\tswept: { version: doc.version, holders: survivors } satisfies RosterDocument,\n\t\tevicted: evicted as ReadonlyArray<RosterHolder>,\n\t};\n}, Effect.provide(layerLivenessProbeScope));\n\n// -----------------------------------------------------------------------------\n// Claim / release / heartbeat\n// -----------------------------------------------------------------------------\n\n/** Outcome of a claim: the holder this process registered, the swept\n * document, and whether this process is now the sole holder. */\nexport interface ClaimResult {\n\treadonly self: RosterHolder;\n\treadonly roster: RosterDocument;\n\treadonly evicted: ReadonlyArray<RosterHolder>;\n\treadonly soleHolder: boolean;\n}\n\n/** Outcome of a release: the swept document and whether this process\n * was the last leaver (no peers remain after removal). Architecture §\n * Release protocol step 4. */\nexport interface ReleaseResult {\n\treadonly roster: RosterDocument;\n\treadonly lastLeaver: boolean;\n}\n\ninterface RosterPaths {\n\treadonly stackLockFile: string;\n\treadonly rosterFile: string;\n}\n\n/** Match a roster holder against THIS process's identity.\n *\n * Liveness elsewhere (`checkHolderLiveness`) uses\n * `(pid, hostname, startTime)` — PID alone is insufficient on\n * long-uptime hosts where the kernel can recycle PIDs. The roster\n * mutators (`heartbeat`, `release`, `setIntent`) must apply the same\n * triple match so a recycled-PID peer's entry is never silently\n * overwritten/removed by this process.\n *\n * `startTime` is the FNV-1a hash of `ps -o lstart` (see\n * `liveness.processStartTime`). A `null` probe (process gone, or\n * exotic platform) skips the start-time check — the same conservative\n * policy `checkHolderLiveness` applies. */\nconst isOwnEntry = (\n\th: RosterHolder,\n\townPid: number,\n\townHost: string,\n\townStartTime: number | null,\n): boolean => {\n\tif (h.pid !== ownPid || h.hostname !== ownHost) return false;\n\t// Either side null → fall back to (pid, hostname). The roster's\n\t// own-entry test must symmetrically accept a null recorded stamp:\n\t// the writer's probe failed (exotic platform / transient `ps`\n\t// error) but the entry IS ours. Mismatching a probed `ownStartTime`\n\t// against a recorded `null` would orphan our own entry — peers\n\t// would then harvest it as \"dead\" on the next sweep.\n\tif (ownStartTime === null || h.startTime === null) return true;\n\treturn h.startTime === ownStartTime;\n};\n\nconst withStackLock = <A, E, R>(\n\tpaths: RosterPaths,\n\tbody: Effect.Effect<A, E, R>,\n): Effect.Effect<A, E | import('./stack-lock.ts').StackLockError, R> =>\n\tEffect.scoped(\n\t\tEffect.gen(function* () {\n\t\t\tyield* acquireStackLock(paths.stackLockFile);\n\t\t\treturn yield* body;\n\t\t}),\n\t);\n\n/**\n * Claim protocol — architecture § Claim protocol.\n *\n * Under the exclusive lock:\n * 1. Read the roster (or initialize empty if missing).\n * 2. Sweep stale holders (PID liveness + heartbeat age).\n * 3. Append this process's entry.\n * 4. Atomic write the result.\n *\n * Returns the swept document, our holder entry, and any evicted peers.\n */\nexport const claim = (\n\tpaths: RosterPaths,\n\tintent: 'normal' | 'snapshot' = 'normal',\n\tpolicy: RosterSweepPolicy = DEFAULT_SWEEP_POLICY,\n\tmetadata: { readonly graphInputId?: string } = {},\n): Effect.Effect<ClaimResult, RosterError | import('./stack-lock.ts').StackLockError> =>\n\twithStackLock(\n\t\tpaths,\n\t\tEffect.gen(function* () {\n\t\t\tconst initial = yield* readRoster(paths.rosterFile).pipe(\n\t\t\t\tEffect.catchTag('RosterCorruptError', () => Effect.succeed(EMPTY_ROSTER)),\n\t\t\t);\n\t\t\tconst { swept, evicted } = yield* sweepStaleHolders(initial, policy);\n\t\t\tconst self: RosterHolder = { ...ownHolder(intent), ...metadata };\n\t\t\tconst next: RosterDocument = {\n\t\t\t\tversion: 1,\n\t\t\t\tholders: [...swept.holders, self],\n\t\t\t};\n\t\t\tyield* atomicWriteRoster(paths.rosterFile, next);\n\t\t\treturn {\n\t\t\t\tself,\n\t\t\t\troster: next,\n\t\t\t\tevicted,\n\t\t\t\tsoleHolder: swept.holders.length === 0,\n\t\t\t};\n\t\t}),\n\t);\n\n/**\n * Heartbeat protocol — refresh this process's `heartbeatAt`. Under the\n * stack lock to keep mutation atomic. Architecture § Heartbeat\n * protocol.\n */\nexport const heartbeat = (\n\tpaths: RosterPaths,\n\townPid: number = selfPid(),\n): Effect.Effect<void, RosterError | import('./stack-lock.ts').StackLockError> =>\n\twithStackLock(\n\t\tpaths,\n\t\tEffect.gen(function* () {\n\t\t\tconst current = yield* readRoster(paths.rosterFile).pipe(\n\t\t\t\tEffect.catchTag('RosterCorruptError', () => Effect.succeed(EMPTY_ROSTER)),\n\t\t\t);\n\t\t\tconst now = Date.now();\n\t\t\tconst ownHost = nodeHostname();\n\t\t\tconst ownStartTime = processStartTime(ownPid);\n\t\t\tlet touched = false;\n\t\t\tconst next: RosterDocument = {\n\t\t\t\tversion: 1,\n\t\t\t\tholders: current.holders.map((h) => {\n\t\t\t\t\tif (isOwnEntry(h, ownPid, ownHost, ownStartTime)) {\n\t\t\t\t\t\ttouched = true;\n\t\t\t\t\t\treturn { ...h, heartbeatAt: now };\n\t\t\t\t\t}\n\t\t\t\t\treturn h;\n\t\t\t\t}),\n\t\t\t};\n\t\t\tif (touched) yield* atomicWriteRoster(paths.rosterFile, next);\n\t\t}),\n\t);\n\n/**\n * Release protocol — architecture § Release protocol.\n *\n * Removes this process's holder. Returns whether this was the\n * last-leaver (caller runs the stop finalizer if so).\n */\nexport const release = (\n\tpaths: RosterPaths,\n\townPid: number = selfPid(),\n): Effect.Effect<ReleaseResult, RosterError | import('./stack-lock.ts').StackLockError> =>\n\twithStackLock(\n\t\tpaths,\n\t\tEffect.gen(function* () {\n\t\t\tconst current = yield* readRoster(paths.rosterFile).pipe(\n\t\t\t\tEffect.catchTag('RosterCorruptError', () => Effect.succeed(EMPTY_ROSTER)),\n\t\t\t);\n\t\t\tconst ownHost = nodeHostname();\n\t\t\tconst ownStartTime = processStartTime(ownPid);\n\t\t\tconst remaining = current.holders.filter(\n\t\t\t\t(h) => !isOwnEntry(h, ownPid, ownHost, ownStartTime),\n\t\t\t);\n\t\t\tconst next: RosterDocument = { version: 1, holders: remaining };\n\t\t\tyield* atomicWriteRoster(paths.rosterFile, next);\n\t\t\treturn {\n\t\t\t\troster: next,\n\t\t\t\tlastLeaver: remaining.length === 0,\n\t\t\t};\n\t\t}),\n\t);\n\n/**\n * Set this process's `intent` (`normal` ↔ `snapshot`) under the\n * exclusive lock. Architecture § Concurrent snapshot step 2 / 5.\n */\nexport const setIntent = (\n\tpaths: RosterPaths,\n\tintent: 'normal' | 'snapshot',\n\townPid: number = selfPid(),\n): Effect.Effect<void, RosterError | import('./stack-lock.ts').StackLockError> =>\n\twithStackLock(\n\t\tpaths,\n\t\tEffect.gen(function* () {\n\t\t\tconst current = yield* readRoster(paths.rosterFile).pipe(\n\t\t\t\tEffect.catchTag('RosterCorruptError', () => Effect.succeed(EMPTY_ROSTER)),\n\t\t\t);\n\t\t\tconst ownHost = nodeHostname();\n\t\t\tconst ownStartTime = processStartTime(ownPid);\n\t\t\tconst next: RosterDocument = {\n\t\t\t\tversion: 1,\n\t\t\t\tholders: current.holders.map((h) =>\n\t\t\t\t\tisOwnEntry(h, ownPid, ownHost, ownStartTime) ? { ...h, intent } : h,\n\t\t\t\t),\n\t\t\t};\n\t\t\tyield* atomicWriteRoster(paths.rosterFile, next);\n\t\t}),\n\t);\n\n/** Background heartbeat fiber. Wakes every `intervalMillis` (default\n * matches `DEFAULT_SWEEP_POLICY.heartbeatIntervalMillis`) and refreshes\n * this process's `heartbeatAt`.\n *\n * Returns an Effect that runs forever in its Scope — the supervisor\n * forks it via `Effect.forkScoped` so it tears down with the stack. */\nexport const heartbeatFiber = (\n\tpaths: RosterPaths,\n\tintervalMillis: number = DEFAULT_SWEEP_POLICY.heartbeatIntervalMillis,\n): Effect.Effect<never> =>\n\tEffect.gen(function* () {\n\t\twhile (true) {\n\t\t\tyield* Effect.sleep(`${intervalMillis} millis`);\n\t\t\tyield* heartbeat(paths).pipe(\n\t\t\t\t// A heartbeat failure is non-fatal — the next peer's sweep\n\t\t\t\t// would otherwise evict us, but the architecture treats this\n\t\t\t\t// as \"the dev didn't get to flush a heartbeat in time;\n\t\t\t\t// recover by retrying next interval.\" Log via Effect's\n\t\t\t\t// logger; do not propagate.\n\t\t\t\tEffect.catch((err) =>\n\t\t\t\t\tEffect.logWarning('roster heartbeat failed').pipe(\n\t\t\t\t\t\tEffect.annotateLogs({\n\t\t\t\t\t\t\t[LogAttr.rosterHeartbeatIntervalMs]: intervalMillis,\n\t\t\t\t\t\t\t[LogAttr.errorCause]: String(err),\n\t\t\t\t\t\t}),\n\t\t\t\t\t),\n\t\t\t\t),\n\t\t\t);\n\t\t}\n\t});\n"],"mappings":"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"}
@@ -21,12 +21,14 @@ const probeSupervisorPresence = (rosterFile) => Effect.gen(function* () {
21
21
  for (const holder of doc.holders) if ((yield* probe.probeHolderLiveness(holder).pipe(Effect.catch(() => Effect.succeed("alive")))) === "alive") return {
22
22
  live: true,
23
23
  pid: holder.pid,
24
- hostname: holder.hostname
24
+ hostname: holder.hostname,
25
+ graphInputId: holder.graphInputId ?? null
25
26
  };
26
27
  return {
27
28
  live: false,
28
29
  pid: null,
29
- hostname: null
30
+ hostname: null,
31
+ graphInputId: null
30
32
  };
31
33
  }).pipe(Effect.provide(layerLivenessProbeScope));
32
34
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"supervisor-presence.mjs","names":[],"sources":["../../../../src/surfaces/cli/commands/supervisor-presence.ts"],"sourcesContent":["// Detect whether a supervisor is currently live for a given stack.\n//\n// The CLI uses this to decide whether a verb should publish via the\n// cross-process command channel (live supervisor: publish + await ack)\n// or use its direct/offline fallback.\n//\n// Detection reads the existing `roster.json` (architecture § Cross-\n// process safety protocol § Roster) and walks holders. A holder with a\n// live PID + matching start-time → supervisor live (matches\n// `liveness.ts` discipline; devstack is single-host).\n\nimport { Effect } from 'effect';\n\nimport {\n\tlayerLivenessProbeScope,\n\tLivenessProbeScope,\n\treadRoster,\n\ttype RosterError,\n} from '../../../substrate/runtime/cross-process/index.ts';\n\nexport interface SupervisorPresence {\n\treadonly live: boolean;\n\treadonly pid: number | null;\n\treadonly hostname: string | null;\n}\n\n/**\n * Read `rosterFile` and report whether at least one live holder\n * remains. Returns `{ live: false, pid: null }` when the roster is\n * missing OR all holders are dead-by-liveness-check.\n *\n * Tolerates corrupt rosters: a malformed file is treated as \"no\n * live supervisor\" — the worst case is the CLI refusing a publish\n * the user could have buffered.\n */\nexport const probeSupervisorPresence = (\n\trosterFile: string,\n): Effect.Effect<SupervisorPresence, RosterError> =>\n\t// Yield a fresh `LivenessProbeScope` so a corrupted-roster edge case\n\t// with multiple holders sharing one pid forks `ps`/`tasklist` once.\n\tEffect.gen(function* () {\n\t\tconst doc = yield* readRoster(rosterFile).pipe(\n\t\t\tEffect.catchTag('RosterCorruptError', () =>\n\t\t\t\tEffect.succeed({ version: 1 as const, holders: [] }),\n\t\t\t),\n\t\t);\n\t\tconst probe = yield* LivenessProbeScope;\n\t\tfor (const holder of doc.holders) {\n\t\t\tconst liveness = yield* probe\n\t\t\t\t.probeHolderLiveness(holder)\n\t\t\t\t.pipe(Effect.catch(() => Effect.succeed('alive' as const)));\n\t\t\tif (liveness === 'alive') {\n\t\t\t\treturn { live: true, pid: holder.pid, hostname: holder.hostname };\n\t\t\t}\n\t\t}\n\t\treturn { live: false, pid: null, hostname: null };\n\t}).pipe(Effect.provide(layerLivenessProbeScope));\n"],"mappings":";;;;;;;;;;;;;;AAmCA,MAAa,2BACZ,eAIA,OAAO,IAAI,aAAa;CACvB,MAAM,MAAM,OAAO,WAAW,UAAU,CAAC,CAAC,KACzC,OAAO,SAAS,4BACf,OAAO,QAAQ;EAAE,SAAS;EAAY,SAAS,CAAC;CAAE,CAAC,CACpD,CACD;CACA,MAAM,QAAQ,OAAO;CACrB,KAAK,MAAM,UAAU,IAAI,SAIxB,KAAI,OAHoB,MACtB,oBAAoB,MAAM,CAAC,CAC3B,KAAK,OAAO,YAAY,OAAO,QAAQ,OAAgB,CAAC,CAAC,OAC1C,SAChB,OAAO;EAAE,MAAM;EAAM,KAAK,OAAO;EAAK,UAAU,OAAO;CAAS;CAGlE,OAAO;EAAE,MAAM;EAAO,KAAK;EAAM,UAAU;CAAK;AACjD,CAAC,CAAC,CAAC,KAAK,OAAO,QAAQ,uBAAuB,CAAC"}
1
+ {"version":3,"file":"supervisor-presence.mjs","names":[],"sources":["../../../../src/surfaces/cli/commands/supervisor-presence.ts"],"sourcesContent":["// Detect whether a supervisor is currently live for a given stack.\n//\n// The CLI uses this to decide whether a verb should publish via the\n// cross-process command channel (live supervisor: publish + await ack)\n// or use its direct/offline fallback.\n//\n// Detection reads the existing `roster.json` (architecture § Cross-\n// process safety protocol § Roster) and walks holders. A holder with a\n// live PID + matching start-time → supervisor live (matches\n// `liveness.ts` discipline; devstack is single-host).\n\nimport { Effect } from 'effect';\n\nimport {\n\tlayerLivenessProbeScope,\n\tLivenessProbeScope,\n\treadRoster,\n\ttype RosterError,\n} from '../../../substrate/runtime/cross-process/index.ts';\n\nexport interface SupervisorPresence {\n\treadonly live: boolean;\n\treadonly pid: number | null;\n\treadonly hostname: string | null;\n\treadonly graphInputId: string | null;\n}\n\n/**\n * Read `rosterFile` and report whether at least one live holder\n * remains. Returns `{ live: false, pid: null }` when the roster is\n * missing OR all holders are dead-by-liveness-check.\n *\n * Tolerates corrupt rosters: a malformed file is treated as \"no\n * live supervisor\" — the worst case is the CLI refusing a publish\n * the user could have buffered.\n */\nexport const probeSupervisorPresence = (\n\trosterFile: string,\n): Effect.Effect<SupervisorPresence, RosterError> =>\n\t// Yield a fresh `LivenessProbeScope` so a corrupted-roster edge case\n\t// with multiple holders sharing one pid forks `ps`/`tasklist` once.\n\tEffect.gen(function* () {\n\t\tconst doc = yield* readRoster(rosterFile).pipe(\n\t\t\tEffect.catchTag('RosterCorruptError', () =>\n\t\t\t\tEffect.succeed({ version: 1 as const, holders: [] }),\n\t\t\t),\n\t\t);\n\t\tconst probe = yield* LivenessProbeScope;\n\t\tfor (const holder of doc.holders) {\n\t\t\tconst liveness = yield* probe\n\t\t\t\t.probeHolderLiveness(holder)\n\t\t\t\t.pipe(Effect.catch(() => Effect.succeed('alive' as const)));\n\t\t\tif (liveness === 'alive') {\n\t\t\t\treturn {\n\t\t\t\t\tlive: true,\n\t\t\t\t\tpid: holder.pid,\n\t\t\t\t\thostname: holder.hostname,\n\t\t\t\t\tgraphInputId: holder.graphInputId ?? null,\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\t\treturn { live: false, pid: null, hostname: null, graphInputId: null };\n\t}).pipe(Effect.provide(layerLivenessProbeScope));\n"],"mappings":";;;;;;;;;;;;;;AAoCA,MAAa,2BACZ,eAIA,OAAO,IAAI,aAAa;CACvB,MAAM,MAAM,OAAO,WAAW,UAAU,CAAC,CAAC,KACzC,OAAO,SAAS,4BACf,OAAO,QAAQ;EAAE,SAAS;EAAY,SAAS,CAAC;CAAE,CAAC,CACpD,CACD;CACA,MAAM,QAAQ,OAAO;CACrB,KAAK,MAAM,UAAU,IAAI,SAIxB,KAAI,OAHoB,MACtB,oBAAoB,MAAM,CAAC,CAC3B,KAAK,OAAO,YAAY,OAAO,QAAQ,OAAgB,CAAC,CAAC,OAC1C,SAChB,OAAO;EACN,MAAM;EACN,KAAK,OAAO;EACZ,UAAU,OAAO;EACjB,cAAc,OAAO,gBAAgB;CACtC;CAGF,OAAO;EAAE,MAAM;EAAO,KAAK;EAAM,UAAU;EAAM,cAAc;CAAK;AACrE,CAAC,CAAC,CAAC,KAAK,OAAO,QAAQ,uBAAuB,CAAC"}
@@ -25,6 +25,12 @@ var CliConfirmDeclinedError = class extends Data.TaggedError("CliConfirmDeclined
25
25
  * refused to mutate shared state. */
26
26
  var CliSupervisorLiveError = class extends Data.TaggedError("CliSupervisorLiveError") {};
27
27
  Data.TaggedError("CliNoSupervisorError");
28
+ /** A live supervisor is running, but it was booted from a different
29
+ * stack graph than the config the current verb just loaded. Commands
30
+ * that only re-run post-acquire/codegen against the live supervisor
31
+ * must refuse this, otherwise generated static bindings can diverge
32
+ * from the live deployment values. */
33
+ var CliLiveGraphMismatchError = class extends Data.TaggedError("CliLiveGraphMismatchError") {};
28
34
  /** Internal/unexpected failure. Wraps an arbitrary cause; the cascade
29
35
  * formatter renders the inner. */
30
36
  var CliInternalError = class extends Data.TaggedError("CliInternalError") {};
@@ -42,6 +48,7 @@ const isCliError = (value) => {
42
48
  case "CliConfirmDeclinedError":
43
49
  case "CliSupervisorLiveError":
44
50
  case "CliNoSupervisorError":
51
+ case "CliLiveGraphMismatchError":
45
52
  case "CliInternalError":
46
53
  case "CliAlreadyReportedError": return true;
47
54
  default: return false;
@@ -62,6 +69,7 @@ const exitCodeFor = (error) => {
62
69
  case "CliConfirmDeclinedError": return ExitCode.CONFIRM_REQUIRED;
63
70
  case "CliSupervisorLiveError": return ExitCode.SUPERVISOR_LIVE;
64
71
  case "CliNoSupervisorError": return ExitCode.UNAVAILABLE;
72
+ case "CliLiveGraphMismatchError": return ExitCode.SUPERVISOR_LIVE;
65
73
  case "CliInternalError": return ExitCode.SOFTWARE;
66
74
  case "CliAlreadyReportedError": return error.exitCode;
67
75
  default: return ExitCode.SOFTWARE;
@@ -81,6 +89,7 @@ const summaryFor = (error) => {
81
89
  case "CliConfirmDeclinedError": return `${error.verb} confirmation declined`;
82
90
  case "CliSupervisorLiveError": return `supervisor live for ${error.app}/${error.stack}`;
83
91
  case "CliNoSupervisorError": return `no supervisor running for ${error.app}/${error.stack}`;
92
+ case "CliLiveGraphMismatchError": return `live supervisor graph is stale for ${error.app}/${error.stack}`;
84
93
  case "CliInternalError": return error.message;
85
94
  case "CliAlreadyReportedError": return "(already reported)";
86
95
  default: return "(unknown error)";
@@ -95,7 +104,8 @@ const hintFor = (error) => {
95
104
  case "CliConfirmRequiredError":
96
105
  case "CliConfirmDeclinedError":
97
106
  case "CliSupervisorLiveError":
98
- case "CliNoSupervisorError": return error.hint;
107
+ case "CliNoSupervisorError":
108
+ case "CliLiveGraphMismatchError": return error.hint;
99
109
  case "CliConfigNotFoundError": return "config file not found; run `devstack init` or pass `--config <path>`";
100
110
  case "CliConfigInvalidError":
101
111
  case "CliSnapshotNotFoundError":
@@ -105,6 +115,6 @@ const hintFor = (error) => {
105
115
  }
106
116
  };
107
117
  //#endregion
108
- export { CliConfigInvalidError, CliConfigNotFoundError, CliConfirmDeclinedError, CliConfirmRequiredError, CliInternalError, CliSnapshotAmbiguousError, CliSnapshotNotFoundError, CliSupervisorLiveError, CliUnavailableError, CliUsageError, exitCodeFor, hintFor, isCliError, summaryFor };
118
+ export { CliConfigInvalidError, CliConfigNotFoundError, CliConfirmDeclinedError, CliConfirmRequiredError, CliInternalError, CliLiveGraphMismatchError, CliSnapshotAmbiguousError, CliSnapshotNotFoundError, CliSupervisorLiveError, CliUnavailableError, CliUsageError, exitCodeFor, hintFor, isCliError, summaryFor };
109
119
 
110
120
  //# sourceMappingURL=errors.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"errors.mjs","names":["XC"],"sources":["../../../src/surfaces/cli/errors.ts"],"sourcesContent":["// CLI surface — typed errors carried up to the top-level renderer.\n//\n// Architecture (distilled/20-cli.md § Edge cases) enumerates the\n// surface's failure modes. Each one MUST resolve to a stable sysexit\n// code; the error tag here carries that mapping so the dispatcher\n// doesn't sprinkle exit codes through every command.\n//\n// Why tagged errors and not plain `Effect.fail(string)`?\n// - `catchTag` in commands can pick out the precise tag without\n// stringly matching.\n// - The cascade formatter (substrate/runtime/observability/\n// cascade-formatter.ts) renders these natively via their `_tag`.\n// - The `schema --json` command enumerates the tag table.\n\nimport { Data } from 'effect';\n\nimport { type ExitCode, ExitCode as XC } from './sysexits.ts';\n\n// -----------------------------------------------------------------------------\n// Tagged errors\n// -----------------------------------------------------------------------------\n\n/** Argv parsing produced an error — unknown subcommand, malformed\n * flag value, missing required positional. */\nexport class CliUsageError extends Data.TaggedError('CliUsageError')<{\n\treadonly message: string;\n\treadonly hint?: string;\n}> {}\n\n/** The user's `devstack.config.ts` could not be located (path probe\n * exhausted) or could not be imported. */\nexport class CliConfigNotFoundError extends Data.TaggedError('CliConfigNotFoundError')<{\n\treadonly message: string;\n\treadonly searchedPaths?: ReadonlyArray<string>;\n}> {}\n\n/** The config imported successfully but its default export is not a\n * `Stack` value (failed brand check, missing required field). */\nexport class CliConfigInvalidError extends Data.TaggedError('CliConfigInvalidError')<{\n\treadonly message: string;\n\treadonly cause?: unknown;\n}> {}\n\n/** A required service (Docker daemon, network) is unavailable. */\nexport class CliUnavailableError extends Data.TaggedError('CliUnavailableError')<{\n\treadonly message: string;\n\treadonly service: string;\n\treadonly hint?: string;\n}> {}\n\n/** Snapshot name/id not found. Carries the reference the caller\n * used so the failure renderer can echo it back. */\nexport class CliSnapshotNotFoundError extends Data.TaggedError('CliSnapshotNotFoundError')<{\n\treadonly snapshotRef: string;\n}> {}\n\nexport class CliSnapshotAmbiguousError extends Data.TaggedError('CliSnapshotAmbiguousError')<{\n\treadonly snapshotRef: string;\n\treadonly matches: ReadonlyArray<string>;\n}> {}\n\n/** Destructive verb refused because `--yes` was absent and prompting\n * was forbidden (non-TTY stdin or `--no-input`). */\nexport class CliConfirmRequiredError extends Data.TaggedError('CliConfirmRequiredError')<{\n\treadonly verb: string;\n\treadonly hint?: string;\n}> {}\n\n/** Destructive verb was prompted interactively and the user declined. */\nexport class CliConfirmDeclinedError extends Data.TaggedError('CliConfirmDeclinedError')<{\n\treadonly verb: string;\n\treadonly hint?: string;\n}> {}\n\n/** Engine reports a live supervisor for the target stack; the verb\n * refused to mutate shared state. */\nexport class CliSupervisorLiveError extends Data.TaggedError('CliSupervisorLiveError')<{\n\treadonly app: string;\n\treadonly stack: string;\n\treadonly hint?: string;\n}> {}\n\n/** The verb requires a live supervisor (a stack must be `up`) but\n * none is running for `(app, stack)`. Distinguished from\n * `CliSupervisorLiveError` (the inverse case where the verb refused\n * because one IS live). */\nexport class CliNoSupervisorError extends Data.TaggedError('CliNoSupervisorError')<{\n\treadonly app: string;\n\treadonly stack: string;\n\treadonly hint?: string;\n}> {}\n\n/** Internal/unexpected failure. Wraps an arbitrary cause; the cascade\n * formatter renders the inner. */\nexport class CliInternalError extends Data.TaggedError('CliInternalError')<{\n\treadonly message: string;\n\treadonly cause?: unknown;\n}> {}\n\n/** Sentinel: subcommand already pretty-rendered its failure. The\n * top-level renderer recognizes this tag and skips re-rendering, but\n * still propagates `exitCode` to the OS exit code.\n *\n * Architecture § Learnings: \"Already-reported sentinel pattern\" — the\n * marker traverses the cause structure used by the Effect runtime, so\n * the top-level `Effect.catch` sees it. */\nexport class CliAlreadyReportedError extends Data.TaggedError('CliAlreadyReportedError')<{\n\treadonly exitCode: ExitCode;\n}> {}\n\n// -----------------------------------------------------------------------------\n// Union + sysexit projection\n// -----------------------------------------------------------------------------\n\nexport type CliError =\n\t| CliUsageError\n\t| CliConfigNotFoundError\n\t| CliConfigInvalidError\n\t| CliUnavailableError\n\t| CliSnapshotNotFoundError\n\t| CliSnapshotAmbiguousError\n\t| CliConfirmRequiredError\n\t| CliConfirmDeclinedError\n\t| CliSupervisorLiveError\n\t| CliNoSupervisorError\n\t| CliInternalError\n\t| CliAlreadyReportedError;\n\nexport const isCliError = (value: unknown): value is CliError => {\n\tif (typeof value !== 'object' || value === null) return false;\n\tconst tag = (value as { readonly _tag?: unknown })._tag;\n\tswitch (tag) {\n\t\tcase 'CliUsageError':\n\t\tcase 'CliConfigNotFoundError':\n\t\tcase 'CliConfigInvalidError':\n\t\tcase 'CliUnavailableError':\n\t\tcase 'CliSnapshotNotFoundError':\n\t\tcase 'CliSnapshotAmbiguousError':\n\t\tcase 'CliConfirmRequiredError':\n\t\tcase 'CliConfirmDeclinedError':\n\t\tcase 'CliSupervisorLiveError':\n\t\tcase 'CliNoSupervisorError':\n\t\tcase 'CliInternalError':\n\t\tcase 'CliAlreadyReportedError':\n\t\t\treturn true;\n\t\tdefault:\n\t\t\treturn false;\n\t}\n};\n\n/** Project a tagged error to its sysexit code. The dispatcher reads\n * this to set `process.exitCode` — there is exactly one place in the\n * surface where tag → numeric mapping happens. */\nexport const exitCodeFor = (error: CliError): ExitCode => {\n\tswitch (error._tag) {\n\t\tcase 'CliUsageError':\n\t\t\treturn XC.USAGE;\n\t\tcase 'CliConfigNotFoundError':\n\t\t\treturn XC.NO_INPUT;\n\t\tcase 'CliConfigInvalidError':\n\t\t\treturn XC.CONFIG;\n\t\tcase 'CliUnavailableError':\n\t\t\treturn XC.UNAVAILABLE;\n\t\tcase 'CliSnapshotNotFoundError':\n\t\t\treturn XC.SNAPSHOT_NOT_FOUND;\n\t\tcase 'CliSnapshotAmbiguousError':\n\t\t\treturn XC.USAGE;\n\t\tcase 'CliConfirmRequiredError':\n\t\tcase 'CliConfirmDeclinedError':\n\t\t\treturn XC.CONFIRM_REQUIRED;\n\t\tcase 'CliSupervisorLiveError':\n\t\t\treturn XC.SUPERVISOR_LIVE;\n\t\tcase 'CliNoSupervisorError':\n\t\t\treturn XC.UNAVAILABLE;\n\t\tcase 'CliInternalError':\n\t\t\treturn XC.SOFTWARE;\n\t\tcase 'CliAlreadyReportedError':\n\t\t\treturn error.exitCode;\n\t\tdefault: {\n\t\t\tconst _exhaustive: never = error;\n\t\t\tvoid _exhaustive;\n\t\t\treturn XC.SOFTWARE;\n\t\t}\n\t}\n};\n\n/** Short single-line summary for the envelope's `error.summary`\n * field. Cascade detail lives in `error.chain[]`. */\nexport const summaryFor = (error: CliError): string => {\n\tswitch (error._tag) {\n\t\tcase 'CliUsageError':\n\t\t\treturn error.message;\n\t\tcase 'CliConfigNotFoundError':\n\t\t\treturn error.message;\n\t\tcase 'CliConfigInvalidError':\n\t\t\treturn error.message;\n\t\tcase 'CliUnavailableError':\n\t\t\treturn `${error.service} unavailable: ${error.message}`;\n\t\tcase 'CliSnapshotNotFoundError':\n\t\t\treturn `snapshot not found: ${error.snapshotRef}`;\n\t\tcase 'CliSnapshotAmbiguousError':\n\t\t\treturn `snapshot reference is ambiguous: ${error.snapshotRef}`;\n\t\tcase 'CliConfirmRequiredError':\n\t\t\treturn `${error.verb} requires confirmation`;\n\t\tcase 'CliConfirmDeclinedError':\n\t\t\treturn `${error.verb} confirmation declined`;\n\t\tcase 'CliSupervisorLiveError':\n\t\t\treturn `supervisor live for ${error.app}/${error.stack}`;\n\t\tcase 'CliNoSupervisorError':\n\t\t\treturn `no supervisor running for ${error.app}/${error.stack}`;\n\t\tcase 'CliInternalError':\n\t\t\treturn error.message;\n\t\tcase 'CliAlreadyReportedError':\n\t\t\treturn '(already reported)';\n\t\tdefault: {\n\t\t\tconst _exhaustive: never = error;\n\t\t\tvoid _exhaustive;\n\t\t\treturn '(unknown error)';\n\t\t}\n\t}\n};\n\n/** Optional hint for the envelope's `error.hint` field. */\nexport const hintFor = (error: CliError): string | undefined => {\n\tswitch (error._tag) {\n\t\tcase 'CliUsageError':\n\t\t\treturn error.hint;\n\t\tcase 'CliSnapshotAmbiguousError':\n\t\t\treturn `use one of these ids instead: ${error.matches.join(', ')}`;\n\t\tcase 'CliUnavailableError':\n\t\tcase 'CliConfirmRequiredError':\n\t\tcase 'CliConfirmDeclinedError':\n\t\tcase 'CliSupervisorLiveError':\n\t\tcase 'CliNoSupervisorError':\n\t\t\treturn error.hint;\n\t\tcase 'CliConfigNotFoundError':\n\t\t\t// Operator-facing hint: the most common cause is running a\n\t\t\t// devstack verb in a directory whose ancestors don't contain a\n\t\t\t// `devstack.config.ts`. Two actions resolve it — either\n\t\t\t// initialize one in the current tree, or point the loader at\n\t\t\t// an explicit path. We surface both so the envelope renderer\n\t\t\t// (JSON `error.hint`, human stderr line) can render one short\n\t\t\t// line instead of forcing the caller to remember the recipe.\n\t\t\treturn 'config file not found; run `devstack init` or pass `--config <path>`';\n\t\tcase 'CliConfigInvalidError':\n\t\tcase 'CliSnapshotNotFoundError':\n\t\tcase 'CliInternalError':\n\t\tcase 'CliAlreadyReportedError':\n\t\t\treturn undefined;\n\t\tdefault: {\n\t\t\tconst _exhaustive: never = error;\n\t\t\tvoid _exhaustive;\n\t\t\treturn undefined;\n\t\t}\n\t}\n};\n"],"mappings":";;;;;AAwBA,IAAa,gBAAb,cAAmC,KAAK,YAAY,eAAe,CAAC,CAGjE,CAAC;;;AAIJ,IAAa,yBAAb,cAA4C,KAAK,YAAY,wBAAwB,CAAC,CAGnF,CAAC;;;AAIJ,IAAa,wBAAb,cAA2C,KAAK,YAAY,uBAAuB,CAAC,CAGjF,CAAC;;AAGJ,IAAa,sBAAb,cAAyC,KAAK,YAAY,qBAAqB,CAAC,CAI7E,CAAC;;;AAIJ,IAAa,2BAAb,cAA8C,KAAK,YAAY,0BAA0B,CAAC,CAEvF,CAAC;AAEJ,IAAa,4BAAb,cAA+C,KAAK,YAAY,2BAA2B,CAAC,CAGzF,CAAC;;;AAIJ,IAAa,0BAAb,cAA6C,KAAK,YAAY,yBAAyB,CAAC,CAGrF,CAAC;;AAGJ,IAAa,0BAAb,cAA6C,KAAK,YAAY,yBAAyB,CAAC,CAGrF,CAAC;;;AAIJ,IAAa,yBAAb,cAA4C,KAAK,YAAY,wBAAwB,CAAC,CAInF,CAAC;AAMsC,KAAK,YAAY,sBAAsB;;;AAQjF,IAAa,mBAAb,cAAsC,KAAK,YAAY,kBAAkB,CAAC,CAGvE,CAAC;AASyC,KAAK,YAAY,yBAAyB;AAsBvF,MAAa,cAAc,UAAsC;CAChE,IAAI,OAAO,UAAU,YAAY,UAAU,MAAM,OAAO;CAExD,QADa,MAAsC,MACnD;EACC,KAAK;EACL,KAAK;EACL,KAAK;EACL,KAAK;EACL,KAAK;EACL,KAAK;EACL,KAAK;EACL,KAAK;EACL,KAAK;EACL,KAAK;EACL,KAAK;EACL,KAAK,2BACJ,OAAO;EACR,SACC,OAAO;CACT;AACD;;;;AAKA,MAAa,eAAe,UAA8B;CACzD,QAAQ,MAAM,MAAd;EACC,KAAK,iBACJ,OAAOA,SAAG;EACX,KAAK,0BACJ,OAAOA,SAAG;EACX,KAAK,yBACJ,OAAOA,SAAG;EACX,KAAK,uBACJ,OAAOA,SAAG;EACX,KAAK,4BACJ,OAAOA,SAAG;EACX,KAAK,6BACJ,OAAOA,SAAG;EACX,KAAK;EACL,KAAK,2BACJ,OAAOA,SAAG;EACX,KAAK,0BACJ,OAAOA,SAAG;EACX,KAAK,wBACJ,OAAOA,SAAG;EACX,KAAK,oBACJ,OAAOA,SAAG;EACX,KAAK,2BACJ,OAAO,MAAM;EACd,SAGC,OAAOA,SAAG;CAEZ;AACD;;;AAIA,MAAa,cAAc,UAA4B;CACtD,QAAQ,MAAM,MAAd;EACC,KAAK,iBACJ,OAAO,MAAM;EACd,KAAK,0BACJ,OAAO,MAAM;EACd,KAAK,yBACJ,OAAO,MAAM;EACd,KAAK,uBACJ,OAAO,GAAG,MAAM,QAAQ,gBAAgB,MAAM;EAC/C,KAAK,4BACJ,OAAO,uBAAuB,MAAM;EACrC,KAAK,6BACJ,OAAO,oCAAoC,MAAM;EAClD,KAAK,2BACJ,OAAO,GAAG,MAAM,KAAK;EACtB,KAAK,2BACJ,OAAO,GAAG,MAAM,KAAK;EACtB,KAAK,0BACJ,OAAO,uBAAuB,MAAM,IAAI,GAAG,MAAM;EAClD,KAAK,wBACJ,OAAO,6BAA6B,MAAM,IAAI,GAAG,MAAM;EACxD,KAAK,oBACJ,OAAO,MAAM;EACd,KAAK,2BACJ,OAAO;EACR,SAGC,OAAO;CAET;AACD;;AAGA,MAAa,WAAW,UAAwC;CAC/D,QAAQ,MAAM,MAAd;EACC,KAAK,iBACJ,OAAO,MAAM;EACd,KAAK,6BACJ,OAAO,iCAAiC,MAAM,QAAQ,KAAK,IAAI;EAChE,KAAK;EACL,KAAK;EACL,KAAK;EACL,KAAK;EACL,KAAK,wBACJ,OAAO,MAAM;EACd,KAAK,0BAQJ,OAAO;EACR,KAAK;EACL,KAAK;EACL,KAAK;EACL,KAAK,2BACJ;EACD,SAGC;CAEF;AACD"}
1
+ {"version":3,"file":"errors.mjs","names":["XC"],"sources":["../../../src/surfaces/cli/errors.ts"],"sourcesContent":["// CLI surface — typed errors carried up to the top-level renderer.\n//\n// Architecture (distilled/20-cli.md § Edge cases) enumerates the\n// surface's failure modes. Each one MUST resolve to a stable sysexit\n// code; the error tag here carries that mapping so the dispatcher\n// doesn't sprinkle exit codes through every command.\n//\n// Why tagged errors and not plain `Effect.fail(string)`?\n// - `catchTag` in commands can pick out the precise tag without\n// stringly matching.\n// - The cascade formatter (substrate/runtime/observability/\n// cascade-formatter.ts) renders these natively via their `_tag`.\n// - The `schema --json` command enumerates the tag table.\n\nimport { Data } from 'effect';\n\nimport { type ExitCode, ExitCode as XC } from './sysexits.ts';\n\n// -----------------------------------------------------------------------------\n// Tagged errors\n// -----------------------------------------------------------------------------\n\n/** Argv parsing produced an error — unknown subcommand, malformed\n * flag value, missing required positional. */\nexport class CliUsageError extends Data.TaggedError('CliUsageError')<{\n\treadonly message: string;\n\treadonly hint?: string;\n}> {}\n\n/** The user's `devstack.config.ts` could not be located (path probe\n * exhausted) or could not be imported. */\nexport class CliConfigNotFoundError extends Data.TaggedError('CliConfigNotFoundError')<{\n\treadonly message: string;\n\treadonly searchedPaths?: ReadonlyArray<string>;\n}> {}\n\n/** The config imported successfully but its default export is not a\n * `Stack` value (failed brand check, missing required field). */\nexport class CliConfigInvalidError extends Data.TaggedError('CliConfigInvalidError')<{\n\treadonly message: string;\n\treadonly cause?: unknown;\n}> {}\n\n/** A required service (Docker daemon, network) is unavailable. */\nexport class CliUnavailableError extends Data.TaggedError('CliUnavailableError')<{\n\treadonly message: string;\n\treadonly service: string;\n\treadonly hint?: string;\n}> {}\n\n/** Snapshot name/id not found. Carries the reference the caller\n * used so the failure renderer can echo it back. */\nexport class CliSnapshotNotFoundError extends Data.TaggedError('CliSnapshotNotFoundError')<{\n\treadonly snapshotRef: string;\n}> {}\n\nexport class CliSnapshotAmbiguousError extends Data.TaggedError('CliSnapshotAmbiguousError')<{\n\treadonly snapshotRef: string;\n\treadonly matches: ReadonlyArray<string>;\n}> {}\n\n/** Destructive verb refused because `--yes` was absent and prompting\n * was forbidden (non-TTY stdin or `--no-input`). */\nexport class CliConfirmRequiredError extends Data.TaggedError('CliConfirmRequiredError')<{\n\treadonly verb: string;\n\treadonly hint?: string;\n}> {}\n\n/** Destructive verb was prompted interactively and the user declined. */\nexport class CliConfirmDeclinedError extends Data.TaggedError('CliConfirmDeclinedError')<{\n\treadonly verb: string;\n\treadonly hint?: string;\n}> {}\n\n/** Engine reports a live supervisor for the target stack; the verb\n * refused to mutate shared state. */\nexport class CliSupervisorLiveError extends Data.TaggedError('CliSupervisorLiveError')<{\n\treadonly app: string;\n\treadonly stack: string;\n\treadonly hint?: string;\n}> {}\n\n/** The verb requires a live supervisor (a stack must be `up`) but\n * none is running for `(app, stack)`. Distinguished from\n * `CliSupervisorLiveError` (the inverse case where the verb refused\n * because one IS live). */\nexport class CliNoSupervisorError extends Data.TaggedError('CliNoSupervisorError')<{\n\treadonly app: string;\n\treadonly stack: string;\n\treadonly hint?: string;\n}> {}\n\n/** A live supervisor is running, but it was booted from a different\n * stack graph than the config the current verb just loaded. Commands\n * that only re-run post-acquire/codegen against the live supervisor\n * must refuse this, otherwise generated static bindings can diverge\n * from the live deployment values. */\nexport class CliLiveGraphMismatchError extends Data.TaggedError('CliLiveGraphMismatchError')<{\n\treadonly app: string;\n\treadonly stack: string;\n\treadonly liveGraphInputId: string | null;\n\treadonly currentGraphInputId: string;\n\treadonly hint?: string;\n}> {}\n\n/** Internal/unexpected failure. Wraps an arbitrary cause; the cascade\n * formatter renders the inner. */\nexport class CliInternalError extends Data.TaggedError('CliInternalError')<{\n\treadonly message: string;\n\treadonly cause?: unknown;\n}> {}\n\n/** Sentinel: subcommand already pretty-rendered its failure. The\n * top-level renderer recognizes this tag and skips re-rendering, but\n * still propagates `exitCode` to the OS exit code.\n *\n * Architecture § Learnings: \"Already-reported sentinel pattern\" — the\n * marker traverses the cause structure used by the Effect runtime, so\n * the top-level `Effect.catch` sees it. */\nexport class CliAlreadyReportedError extends Data.TaggedError('CliAlreadyReportedError')<{\n\treadonly exitCode: ExitCode;\n}> {}\n\n// -----------------------------------------------------------------------------\n// Union + sysexit projection\n// -----------------------------------------------------------------------------\n\nexport type CliError =\n\t| CliUsageError\n\t| CliConfigNotFoundError\n\t| CliConfigInvalidError\n\t| CliUnavailableError\n\t| CliSnapshotNotFoundError\n\t| CliSnapshotAmbiguousError\n\t| CliConfirmRequiredError\n\t| CliConfirmDeclinedError\n\t| CliSupervisorLiveError\n\t| CliNoSupervisorError\n\t| CliLiveGraphMismatchError\n\t| CliInternalError\n\t| CliAlreadyReportedError;\n\nexport const isCliError = (value: unknown): value is CliError => {\n\tif (typeof value !== 'object' || value === null) return false;\n\tconst tag = (value as { readonly _tag?: unknown })._tag;\n\tswitch (tag) {\n\t\tcase 'CliUsageError':\n\t\tcase 'CliConfigNotFoundError':\n\t\tcase 'CliConfigInvalidError':\n\t\tcase 'CliUnavailableError':\n\t\tcase 'CliSnapshotNotFoundError':\n\t\tcase 'CliSnapshotAmbiguousError':\n\t\tcase 'CliConfirmRequiredError':\n\t\tcase 'CliConfirmDeclinedError':\n\t\tcase 'CliSupervisorLiveError':\n\t\tcase 'CliNoSupervisorError':\n\t\tcase 'CliLiveGraphMismatchError':\n\t\tcase 'CliInternalError':\n\t\tcase 'CliAlreadyReportedError':\n\t\t\treturn true;\n\t\tdefault:\n\t\t\treturn false;\n\t}\n};\n\n/** Project a tagged error to its sysexit code. The dispatcher reads\n * this to set `process.exitCode` — there is exactly one place in the\n * surface where tag → numeric mapping happens. */\nexport const exitCodeFor = (error: CliError): ExitCode => {\n\tswitch (error._tag) {\n\t\tcase 'CliUsageError':\n\t\t\treturn XC.USAGE;\n\t\tcase 'CliConfigNotFoundError':\n\t\t\treturn XC.NO_INPUT;\n\t\tcase 'CliConfigInvalidError':\n\t\t\treturn XC.CONFIG;\n\t\tcase 'CliUnavailableError':\n\t\t\treturn XC.UNAVAILABLE;\n\t\tcase 'CliSnapshotNotFoundError':\n\t\t\treturn XC.SNAPSHOT_NOT_FOUND;\n\t\tcase 'CliSnapshotAmbiguousError':\n\t\t\treturn XC.USAGE;\n\t\tcase 'CliConfirmRequiredError':\n\t\tcase 'CliConfirmDeclinedError':\n\t\t\treturn XC.CONFIRM_REQUIRED;\n\t\tcase 'CliSupervisorLiveError':\n\t\t\treturn XC.SUPERVISOR_LIVE;\n\t\tcase 'CliNoSupervisorError':\n\t\t\treturn XC.UNAVAILABLE;\n\t\tcase 'CliLiveGraphMismatchError':\n\t\t\treturn XC.SUPERVISOR_LIVE;\n\t\tcase 'CliInternalError':\n\t\t\treturn XC.SOFTWARE;\n\t\tcase 'CliAlreadyReportedError':\n\t\t\treturn error.exitCode;\n\t\tdefault: {\n\t\t\tconst _exhaustive: never = error;\n\t\t\tvoid _exhaustive;\n\t\t\treturn XC.SOFTWARE;\n\t\t}\n\t}\n};\n\n/** Short single-line summary for the envelope's `error.summary`\n * field. Cascade detail lives in `error.chain[]`. */\nexport const summaryFor = (error: CliError): string => {\n\tswitch (error._tag) {\n\t\tcase 'CliUsageError':\n\t\t\treturn error.message;\n\t\tcase 'CliConfigNotFoundError':\n\t\t\treturn error.message;\n\t\tcase 'CliConfigInvalidError':\n\t\t\treturn error.message;\n\t\tcase 'CliUnavailableError':\n\t\t\treturn `${error.service} unavailable: ${error.message}`;\n\t\tcase 'CliSnapshotNotFoundError':\n\t\t\treturn `snapshot not found: ${error.snapshotRef}`;\n\t\tcase 'CliSnapshotAmbiguousError':\n\t\t\treturn `snapshot reference is ambiguous: ${error.snapshotRef}`;\n\t\tcase 'CliConfirmRequiredError':\n\t\t\treturn `${error.verb} requires confirmation`;\n\t\tcase 'CliConfirmDeclinedError':\n\t\t\treturn `${error.verb} confirmation declined`;\n\t\tcase 'CliSupervisorLiveError':\n\t\t\treturn `supervisor live for ${error.app}/${error.stack}`;\n\t\tcase 'CliNoSupervisorError':\n\t\t\treturn `no supervisor running for ${error.app}/${error.stack}`;\n\t\tcase 'CliLiveGraphMismatchError':\n\t\t\treturn `live supervisor graph is stale for ${error.app}/${error.stack}`;\n\t\tcase 'CliInternalError':\n\t\t\treturn error.message;\n\t\tcase 'CliAlreadyReportedError':\n\t\t\treturn '(already reported)';\n\t\tdefault: {\n\t\t\tconst _exhaustive: never = error;\n\t\t\tvoid _exhaustive;\n\t\t\treturn '(unknown error)';\n\t\t}\n\t}\n};\n\n/** Optional hint for the envelope's `error.hint` field. */\nexport const hintFor = (error: CliError): string | undefined => {\n\tswitch (error._tag) {\n\t\tcase 'CliUsageError':\n\t\t\treturn error.hint;\n\t\tcase 'CliSnapshotAmbiguousError':\n\t\t\treturn `use one of these ids instead: ${error.matches.join(', ')}`;\n\t\tcase 'CliUnavailableError':\n\t\tcase 'CliConfirmRequiredError':\n\t\tcase 'CliConfirmDeclinedError':\n\t\tcase 'CliSupervisorLiveError':\n\t\tcase 'CliNoSupervisorError':\n\t\tcase 'CliLiveGraphMismatchError':\n\t\t\treturn error.hint;\n\t\tcase 'CliConfigNotFoundError':\n\t\t\t// Operator-facing hint: the most common cause is running a\n\t\t\t// devstack verb in a directory whose ancestors don't contain a\n\t\t\t// `devstack.config.ts`. Two actions resolve it — either\n\t\t\t// initialize one in the current tree, or point the loader at\n\t\t\t// an explicit path. We surface both so the envelope renderer\n\t\t\t// (JSON `error.hint`, human stderr line) can render one short\n\t\t\t// line instead of forcing the caller to remember the recipe.\n\t\t\treturn 'config file not found; run `devstack init` or pass `--config <path>`';\n\t\tcase 'CliConfigInvalidError':\n\t\tcase 'CliSnapshotNotFoundError':\n\t\tcase 'CliInternalError':\n\t\tcase 'CliAlreadyReportedError':\n\t\t\treturn undefined;\n\t\tdefault: {\n\t\t\tconst _exhaustive: never = error;\n\t\t\tvoid _exhaustive;\n\t\t\treturn undefined;\n\t\t}\n\t}\n};\n"],"mappings":"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"}