@celestea/studio 2.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 (240) hide show
  1. package/LICENSE +21 -0
  2. package/dist/app.d.ts +111 -0
  3. package/dist/app.js +219 -0
  4. package/dist/auth/api-token.d.ts +76 -0
  5. package/dist/auth/api-token.js +172 -0
  6. package/dist/auth/htpasswd.d.ts +22 -0
  7. package/dist/auth/htpasswd.js +61 -0
  8. package/dist/auth/index.d.ts +14 -0
  9. package/dist/auth/index.js +14 -0
  10. package/dist/auth/page.d.ts +19 -0
  11. package/dist/auth/page.js +73 -0
  12. package/dist/auth/rate-limit.d.ts +27 -0
  13. package/dist/auth/rate-limit.js +47 -0
  14. package/dist/auth/token.d.ts +49 -0
  15. package/dist/auth/token.js +112 -0
  16. package/dist/config.d.ts +78 -0
  17. package/dist/config.js +72 -0
  18. package/dist/deployment.d.ts +92 -0
  19. package/dist/deployment.js +154 -0
  20. package/dist/fake-runtime-adapter.d.ts +37 -0
  21. package/dist/fake-runtime-adapter.js +276 -0
  22. package/dist/handlers/auth.d.ts +24 -0
  23. package/dist/handlers/auth.js +155 -0
  24. package/dist/handlers/common.d.ts +76 -0
  25. package/dist/handlers/common.js +127 -0
  26. package/dist/handlers/config-shape.d.ts +76 -0
  27. package/dist/handlers/config-shape.js +227 -0
  28. package/dist/handlers/config.d.ts +26 -0
  29. package/dist/handlers/config.js +161 -0
  30. package/dist/handlers/context-shape.d.ts +33 -0
  31. package/dist/handlers/context-shape.js +59 -0
  32. package/dist/handlers/dialog.d.ts +22 -0
  33. package/dist/handlers/dialog.js +292 -0
  34. package/dist/handlers/exec.d.ts +22 -0
  35. package/dist/handlers/exec.js +131 -0
  36. package/dist/handlers/fs-read.d.ts +63 -0
  37. package/dist/handlers/fs-read.js +159 -0
  38. package/dist/handlers/fs.d.ts +57 -0
  39. package/dist/handlers/fs.js +153 -0
  40. package/dist/handlers/grants-shape.d.ts +45 -0
  41. package/dist/handlers/grants-shape.js +130 -0
  42. package/dist/handlers/grants.d.ts +21 -0
  43. package/dist/handlers/grants.js +267 -0
  44. package/dist/handlers/health.d.ts +37 -0
  45. package/dist/handlers/health.js +139 -0
  46. package/dist/handlers/index.d.ts +40 -0
  47. package/dist/handlers/index.js +85 -0
  48. package/dist/handlers/permissions.d.ts +9 -0
  49. package/dist/handlers/permissions.js +183 -0
  50. package/dist/handlers/plugins.d.ts +18 -0
  51. package/dist/handlers/plugins.js +23 -0
  52. package/dist/handlers/prompts.d.ts +12 -0
  53. package/dist/handlers/prompts.js +120 -0
  54. package/dist/handlers/providers.d.ts +12 -0
  55. package/dist/handlers/providers.js +178 -0
  56. package/dist/handlers/questions.d.ts +23 -0
  57. package/dist/handlers/questions.js +115 -0
  58. package/dist/handlers/session-model.d.ts +38 -0
  59. package/dist/handlers/session-model.js +105 -0
  60. package/dist/handlers/session-move.d.ts +22 -0
  61. package/dist/handlers/session-move.js +188 -0
  62. package/dist/handlers/session-tools.d.ts +19 -0
  63. package/dist/handlers/session-tools.js +75 -0
  64. package/dist/handlers/sessions.d.ts +32 -0
  65. package/dist/handlers/sessions.js +242 -0
  66. package/dist/handlers/usage.d.ts +26 -0
  67. package/dist/handlers/usage.js +77 -0
  68. package/dist/handlers/worker.d.ts +12 -0
  69. package/dist/handlers/worker.js +82 -0
  70. package/dist/handlers/workspaces.d.ts +12 -0
  71. package/dist/handlers/workspaces.js +88 -0
  72. package/dist/harness.test-util.d.ts +101 -0
  73. package/dist/harness.test-util.js +176 -0
  74. package/dist/index.d.ts +33 -0
  75. package/dist/index.js +33 -0
  76. package/dist/main.d.ts +13 -0
  77. package/dist/main.js +35 -0
  78. package/dist/plugins.d.ts +105 -0
  79. package/dist/plugins.js +97 -0
  80. package/dist/question-registry.d.ts +124 -0
  81. package/dist/question-registry.js +212 -0
  82. package/dist/question-rows.d.ts +32 -0
  83. package/dist/question-rows.js +43 -0
  84. package/dist/replay/compare.d.ts +47 -0
  85. package/dist/replay/compare.js +89 -0
  86. package/dist/replay/e2e-replay.d.ts +46 -0
  87. package/dist/replay/e2e-replay.js +91 -0
  88. package/dist/replay/expect-compact.d.ts +31 -0
  89. package/dist/replay/expect-compact.js +92 -0
  90. package/dist/replay/fixtures.d.ts +52 -0
  91. package/dist/replay/fixtures.js +53 -0
  92. package/dist/replay/host.d.ts +32 -0
  93. package/dist/replay/host.js +62 -0
  94. package/dist/replay/index.d.ts +16 -0
  95. package/dist/replay/index.js +16 -0
  96. package/dist/replay/probes.d.ts +55 -0
  97. package/dist/replay/probes.js +225 -0
  98. package/dist/replay/report.d.ts +13 -0
  99. package/dist/replay/report.js +103 -0
  100. package/dist/replay/session-e2e.d.ts +44 -0
  101. package/dist/replay/session-e2e.js +91 -0
  102. package/dist/routes.d.ts +47 -0
  103. package/dist/routes.js +59 -0
  104. package/dist/runtime/attachments-llm.d.ts +13 -0
  105. package/dist/runtime/attachments-llm.js +37 -0
  106. package/dist/runtime/boot-recovery.d.ts +32 -0
  107. package/dist/runtime/boot-recovery.js +71 -0
  108. package/dist/runtime/context-snapshot.d.ts +59 -0
  109. package/dist/runtime/context-snapshot.js +113 -0
  110. package/dist/runtime/engine-grants.d.ts +121 -0
  111. package/dist/runtime/engine-grants.js +338 -0
  112. package/dist/runtime/engine-permissions.d.ts +29 -0
  113. package/dist/runtime/engine-permissions.js +92 -0
  114. package/dist/runtime/engine-plugins.d.ts +189 -0
  115. package/dist/runtime/engine-plugins.js +292 -0
  116. package/dist/runtime/engine-profile.d.ts +28 -0
  117. package/dist/runtime/engine-profile.js +88 -0
  118. package/dist/runtime/engine-session.d.ts +9 -0
  119. package/dist/runtime/engine-session.js +9 -0
  120. package/dist/runtime/fallback-contract.d.ts +32 -0
  121. package/dist/runtime/fallback-contract.js +10 -0
  122. package/dist/runtime/fallback-host.d.ts +134 -0
  123. package/dist/runtime/fallback-host.js +299 -0
  124. package/dist/runtime/host-autowake.d.ts +79 -0
  125. package/dist/runtime/host-autowake.js +86 -0
  126. package/dist/runtime/image-downgrade.d.ts +49 -0
  127. package/dist/runtime/image-downgrade.js +90 -0
  128. package/dist/runtime/inbox-message.d.ts +11 -0
  129. package/dist/runtime/inbox-message.js +20 -0
  130. package/dist/runtime/index.d.ts +17 -0
  131. package/dist/runtime/index.js +17 -0
  132. package/dist/runtime/ledger-view.d.ts +40 -0
  133. package/dist/runtime/ledger-view.js +56 -0
  134. package/dist/runtime/llm-assembly.d.ts +66 -0
  135. package/dist/runtime/llm-assembly.js +114 -0
  136. package/dist/runtime/offline-llm.d.ts +71 -0
  137. package/dist/runtime/offline-llm.js +132 -0
  138. package/dist/runtime/provider-target.d.ts +10 -0
  139. package/dist/runtime/provider-target.js +10 -0
  140. package/dist/runtime/question-host.d.ts +33 -0
  141. package/dist/runtime/question-host.js +41 -0
  142. package/dist/runtime/question-view.d.ts +48 -0
  143. package/dist/runtime/question-view.js +76 -0
  144. package/dist/runtime/real-runtime-adapter.d.ts +95 -0
  145. package/dist/runtime/real-runtime-adapter.js +553 -0
  146. package/dist/runtime/recovery-audit.d.ts +81 -0
  147. package/dist/runtime/recovery-audit.js +95 -0
  148. package/dist/runtime/recovery-view.d.ts +36 -0
  149. package/dist/runtime/recovery-view.js +63 -0
  150. package/dist/runtime/session-compose.d.ts +235 -0
  151. package/dist/runtime/session-compose.js +350 -0
  152. package/dist/runtime/session-grants.d.ts +36 -0
  153. package/dist/runtime/session-grants.js +69 -0
  154. package/dist/runtime/session-lifecycle.d.ts +40 -0
  155. package/dist/runtime/session-lifecycle.js +86 -0
  156. package/dist/runtime/session-publisher.d.ts +40 -0
  157. package/dist/runtime/session-publisher.js +48 -0
  158. package/dist/runtime/session-release.d.ts +53 -0
  159. package/dist/runtime/session-release.js +80 -0
  160. package/dist/runtime/test-util.d.ts +78 -0
  161. package/dist/runtime/test-util.js +162 -0
  162. package/dist/runtime/watchdog-view.d.ts +33 -0
  163. package/dist/runtime/watchdog-view.js +45 -0
  164. package/dist/runtime/worker-bridge.d.ts +76 -0
  165. package/dist/runtime/worker-bridge.js +156 -0
  166. package/dist/runtime/worker-live.d.ts +22 -0
  167. package/dist/runtime/worker-live.js +25 -0
  168. package/dist/runtime/worker-recovery.d.ts +35 -0
  169. package/dist/runtime/worker-recovery.js +64 -0
  170. package/dist/runtime/worker-table.d.ts +58 -0
  171. package/dist/runtime/worker-table.js +70 -0
  172. package/dist/runtime-adapter.d.ts +421 -0
  173. package/dist/runtime-adapter.js +76 -0
  174. package/dist/serial-queue.d.ts +16 -0
  175. package/dist/serial-queue.js +22 -0
  176. package/dist/server.d.ts +44 -0
  177. package/dist/server.js +119 -0
  178. package/dist/settings.d.ts +23 -0
  179. package/dist/settings.js +31 -0
  180. package/dist/sse.d.ts +67 -0
  181. package/dist/sse.js +186 -0
  182. package/dist/static.d.ts +20 -0
  183. package/dist/static.js +120 -0
  184. package/dist/store/builtin-sections.d.ts +52 -0
  185. package/dist/store/builtin-sections.js +118 -0
  186. package/dist/store/celestea-home.d.ts +12 -0
  187. package/dist/store/celestea-home.js +11 -0
  188. package/dist/store/fs-json.d.ts +51 -0
  189. package/dist/store/fs-json.js +119 -0
  190. package/dist/store/grants-audit.d.ts +84 -0
  191. package/dist/store/grants-audit.js +109 -0
  192. package/dist/store/grants-service.d.ts +44 -0
  193. package/dist/store/grants-service.js +41 -0
  194. package/dist/store/grants-tokens.d.ts +62 -0
  195. package/dist/store/grants-tokens.js +105 -0
  196. package/dist/store/grants.d.ts +113 -0
  197. package/dist/store/grants.js +238 -0
  198. package/dist/store/index.d.ts +36 -0
  199. package/dist/store/index.js +36 -0
  200. package/dist/store/mode.d.ts +35 -0
  201. package/dist/store/mode.js +41 -0
  202. package/dist/store/permissions.d.ts +61 -0
  203. package/dist/store/permissions.js +139 -0
  204. package/dist/store/prompts-compose.d.ts +56 -0
  205. package/dist/store/prompts-compose.js +62 -0
  206. package/dist/store/prompts-template.d.ts +26 -0
  207. package/dist/store/prompts-template.js +99 -0
  208. package/dist/store/prompts.d.ts +142 -0
  209. package/dist/store/prompts.js +273 -0
  210. package/dist/store/provider-probe.d.ts +81 -0
  211. package/dist/store/provider-probe.js +154 -0
  212. package/dist/store/providers.d.ts +97 -0
  213. package/dist/store/providers.js +219 -0
  214. package/dist/store/result.d.ts +29 -0
  215. package/dist/store/result.js +30 -0
  216. package/dist/store/session-id.d.ts +119 -0
  217. package/dist/store/session-id.js +220 -0
  218. package/dist/store/session-meta.d.ts +41 -0
  219. package/dist/store/session-meta.js +66 -0
  220. package/dist/store/session-ops.d.ts +109 -0
  221. package/dist/store/session-ops.js +264 -0
  222. package/dist/store/session-tools.d.ts +42 -0
  223. package/dist/store/session-tools.js +69 -0
  224. package/dist/store/sessions.d.ts +162 -0
  225. package/dist/store/sessions.js +307 -0
  226. package/dist/store/validate.d.ts +13 -0
  227. package/dist/store/validate.js +38 -0
  228. package/dist/store/workspaces.d.ts +67 -0
  229. package/dist/store/workspaces.js +228 -0
  230. package/dist/user-questions.d.ts +58 -0
  231. package/dist/user-questions.js +157 -0
  232. package/dist/version.d.ts +14 -0
  233. package/dist/version.js +52 -0
  234. package/package.json +38 -0
  235. package/webdist/assets/index-Bngs7gUm.js +98 -0
  236. package/webdist/assets/index-Bu6ci_rN.css +1 -0
  237. package/webdist/assets/katex-CCbS2qAY.js +257 -0
  238. package/webdist/assets/mhchem-CnUN8HwY.js +1 -0
  239. package/webdist/build-meta.json +7 -0
  240. package/webdist/index.html +261 -0
@@ -0,0 +1,553 @@
1
+ /**
2
+ * The REAL `RuntimeAdapter` — `packages/runtime` mounted behind the engine seam.
3
+ *
4
+ * W513 shape: this class is a HOST over a [SessionRuntimeRegistry], not a single
5
+ * engine. Every session gets its own composition (own agent loop, own tool
6
+ * registry, own status/usage trackers, own worker registry, own session log,
7
+ * own inbox) created lazily on first use, reused while the profile epoch is
8
+ * current, and reclaimed when idle. There is no "global main session":
9
+ * `workspaces.json.active_session` is only the view the UI should restore.
10
+ *
11
+ * W742 lifecycle (both were documented before but not wired):
12
+ * - an epoch bump (POST /api/config, POST /api/providers/default, a grant
13
+ * write) NEVER tears down an instance that is still driving workers: the
14
+ * registry only marks it and rebuilds it once those workers ended, so a
15
+ * model switch can no longer abort a background worker and erase its rows
16
+ * (the HTTP 409 guards of both endpoints close the same hole up front);
17
+ * - `CELESTEA_SESSION_IDLE_TTL_MS` is real: the registry's unref'ed reclaimer
18
+ * sweeps deferrable rebuilds + the idle TTL in the background, and
19
+ * `shutdown()` disarms it (no timer outlives the engine).
20
+ *
21
+ * Mapping (host HTTP surface -> composition / engine):
22
+ * POST /api/turn -> `startTurn` (idle) or `inject` (busy);
23
+ * GET /api/events -> `attach(bus)`, frames carry the session;
24
+ * POST /api/sessions/{id}/activate-> `ensureSession` (never 409);
25
+ * POST /api/cancel -> the target session's AbortController;
26
+ * POST /api/sessions/batch-delete -> `releaseSession` (W794: abort + drop the
27
+ * instance before the directory moves);
28
+ * POST /api/clear -> the target session log's `clear()`;
29
+ * POST /api/sessions/{id}/compact -> `runCompaction` + that instance rebuilt;
30
+ * GET /api/status -> the requested session's statusline;
31
+ * GET /api/tools -> the composed `ToolRegistry.schemas()`;
32
+ * GET+POST /api/config -> bump the profile epoch (lazy rebuild);
33
+ * worker endpoints -> per-session registries, merged for reads.
34
+ *
35
+ * The per-session turn/state machinery lives in the registry and the composition
36
+ * in `session-compose.ts`; this file is the seam implementation the handlers see.
37
+ *
38
+ * LLM: an injected seam wins (tests / replay inject the OFFLINE deterministic
39
+ * engine, so no test, no contract check and no replay reaches the network); with
40
+ * nothing injected each instance is assembled against the profile's provider
41
+ * (`llm-assembly.ts`), i.e. production is a real model.
42
+ */
43
+ import { createSessionInbox } from "@celestea/runtime";
44
+ import { autowakeEnabled, coldStatusline, keyOfSession, outcomePhaseOf, HOST_SESSION_ID, SessionRuntimeRegistry, statuslineOf, TurnBusyError, } from "@celestea/runtime";
45
+ import { costBlockView, usageLedgerView } from "./ledger-view.js";
46
+ import { join } from "node:path";
47
+ import { CapacityError, EngineError, toolSpecView } from "../runtime-adapter.js";
48
+ import { HostAutowake, autowakeLog, autowakeStateOf } from "./host-autowake.js";
49
+ import { injectionHooksOf } from "./session-publisher.js";
50
+ import { sessionContextOf } from "./context-snapshot.js";
51
+ import { mergedWorkerRows, sendWorkerThrough, spawnWorkerThrough, workerMessagesAcross } from "./worker-bridge.js";
52
+ import { QuestionHost } from "./question-host.js";
53
+ import { AdapterFallback } from "./fallback-host.js";
54
+ import { createImageDowngradeReporter } from "./image-downgrade.js";
55
+ import { contextViewOf } from "./context-snapshot.js";
56
+ import { applyProfilePatch, defaultEngineProfile, engineProfileOf, profileFromEngine } from "./engine-profile.js";
57
+ import { SESSION_LOG_NAME } from "./engine-session.js";
58
+ import { capacityErrorOf, disposeRuntime, limitFromEnv, MAX_CONCURRENT_TURNS, MAX_LIVE_SESSIONS, SESSION_IDLE_TTL_MS, SessionComposer, } from "./session-compose.js";
59
+ import { dispatchWorkerTool, sendBodyOf, spawnOutcomeOf, workerMessagesOf, workerSessionsOf, } from "./worker-bridge.js";
60
+ import { inboxMessageOf } from "./inbox-message.js";
61
+ import { watchdogCount, watchdogOf, watchdogRunningOf, workerStatusOf } from "./watchdog-view.js";
62
+ import { hasLiveWorkersOf } from "./worker-live.js";
63
+ import { recoveryViewOf } from "./recovery-view.js";
64
+ import { workerRecoveryBlock, workerTablePath } from "./worker-table.js";
65
+ import { clearSession, compactSession } from "./session-lifecycle.js";
66
+ import { releaseSessionOf, releaseSettleMs } from "./session-release.js";
67
+ import { faceForMode } from "@celestea/tools";
68
+ import { DEFAULT_SESSION_MODE, effectiveMode } from "../store/mode.js";
69
+ export { SESSION_LOG_ID, SESSION_LOG_NAME } from "./engine-session.js";
70
+ export { MAX_CONCURRENT_TURNS, MAX_LIVE_SESSIONS, SESSION_IDLE_TTL_MS } from "./session-compose.js";
71
+ export { RELEASE_SETTLE_MS } from "./session-release.js";
72
+ class RealEngine {
73
+ name = "real-runtime-adapter";
74
+ opts;
75
+ env;
76
+ composer;
77
+ registry;
78
+ profileValue;
79
+ bus = null;
80
+ baseEpoch = 0;
81
+ toolCalls = 0;
82
+ shutdownPromise = null;
83
+ /**
84
+ * W769: the wake-up loops (one per host conversation; see `host-autowake.ts`).
85
+ * They carry no turn logic: the adapter supplies the wake callback below.
86
+ */
87
+ autowake;
88
+ /** E §4 P1 (W785): the process-wide fallback glue (see `fallback-host.ts`). */
89
+ fallback;
90
+ /** E §2.3 P0 ①: the resolved worker table path (null = in-memory only). */
91
+ workerTable;
92
+ /**
93
+ * W863: the ONE default downgrade reporter of this adapter — it owns the
94
+ * per-session (model, cause) memo, so a multi-step image turn reports ONCE
95
+ * instead of once per step. A host-supplied `onModelDowngrade` still wins.
96
+ */
97
+ downgrades = createImageDowngradeReporter({ bus: () => this.bus });
98
+ /** W783: process-wide user-question capability (table + host view). */
99
+ questions = new QuestionHost({
100
+ emit: (sessionId, turn, f) => void this.bus?.emit(f.event, turn, f.payload, sessionId),
101
+ turnOf: (sessionId) => this.registry.peek(sessionId)?.turnNo ?? 0,
102
+ });
103
+ constructor(opts = {}) {
104
+ // W863: these two assignments share one line on purpose — this file sits
105
+ // exactly on the eslint 400-code-line budget and the downgrade reporter
106
+ // field above needs the line. Pure formatting, no behaviour change.
107
+ this.opts = opts;
108
+ this.env = opts.env ?? process.env;
109
+ this.autowake = new HostAutowake({
110
+ enabled: autowakeEnabled(this.env),
111
+ lookup: (session) => autowakeStateOf(this.registry.peek(session)),
112
+ wake: (session, input) => this.startAutowakeTurn(session, input),
113
+ });
114
+ this.profileValue = profileFromEngine(opts.profile ?? defaultEngineProfile(this.env, "CELESTEA_API_KEY"));
115
+ this.workerTable = workerTablePath({
116
+ env: this.env,
117
+ dataDir: opts.dataDir ?? null,
118
+ resultsDir: opts.resultsDir ?? null,
119
+ ...(opts.workerRegistryPath === undefined ? {} : { override: opts.workerRegistryPath }),
120
+ });
121
+ // E §4 P1 (W785): OFF unless `CELESTEA_LLM_FALLBACK` says on — `wrap()`
122
+ // then returns null and the composer keeps the pre-P1 path (D9).
123
+ this.fallback = new AdapterFallback({ dataDir: opts.dataDir ?? null, ledgerFile: opts.ledgerFile ?? null, env: this.env, bus: () => this.bus, peek: (s) => this.registry.peek(s), ...(opts.now === undefined ? {} : { now: opts.now }) });
124
+ this.composer = new SessionComposer({
125
+ ...opts,
126
+ env: this.env,
127
+ baseProfile: () => this.profileValue,
128
+ fallback: this.fallback.wiring,
129
+ sessionHooks: (sessionId) => this.injectionHooks(sessionId),
130
+ // W783: every composed session offers `ask_user_question` and publishes a
131
+ // parked request on the bus as a `question` frame.
132
+ questionRegistry: this.questions.table(),
133
+ publishQuestion: (sessionId, question) => this.questions.publish(sessionId, question),
134
+ // W804 section 7.6: the downgrade visibility is the HOST's job. The default
135
+ // emits a status frame (statusline + info block) and an audit line; a host
136
+ // may override it. W863: that default is now deduplicated per session by
137
+ // (model, cause) — see `downgrades` above and image-downgrade.ts.
138
+ onModelDowngrade: opts.onModelDowngrade ?? ((sessionId, info) => void this.downgrades.report(sessionId, info)),
139
+ });
140
+ this.registry = new SessionRuntimeRegistry({
141
+ build: (sessionId, dir) => {
142
+ const runtime = this.composer.compose(sessionId, dir);
143
+ // W769: every host conversation gets a wake-up loop over its OWN mailbox.
144
+ this.autowake.ensure(sessionId);
145
+ return runtime;
146
+ },
147
+ dispose: (runtime) => disposeRuntime(runtime),
148
+ currentEpoch: () => this.baseEpoch,
149
+ maxLive: opts.maxLiveSessions ?? limitFromEnv(this.env, "CELESTEA_MAX_LIVE_SESSIONS", MAX_LIVE_SESSIONS),
150
+ maxConcurrentTurns: opts.maxConcurrentTurns ?? limitFromEnv(this.env, "CELESTEA_MAX_CONCURRENT_TURNS", MAX_CONCURRENT_TURNS),
151
+ idleTtlMs: opts.idleTtlMs ?? limitFromEnv(this.env, "CELESTEA_SESSION_IDLE_TTL_MS", SESSION_IDLE_TTL_MS),
152
+ // The detached instance is never reclaimed (it backs `/api/tools`), nor is a
153
+ // session with LIVE worker work (W513 pin) — the worker, its driver and its
154
+ // row must outlive an idle sweep. W787: the pin follows LIVE work, not "the
155
+ // registry holds rows": since the table persists (§2.2.3), a session that
156
+ // ever spawned a worker would otherwise be pinned for the rest of the
157
+ // process (and, across restarts, exempt from the session cap forever). A
158
+ // settled row survives on disk and comes back with the next generation.
159
+ pinned: (entry) => entry.key === keyOfSession(null) || hasLiveWorkersOf(entry),
160
+ // W742 §1: only LIVE worker work defers a rebuild; a settled, parked worker
161
+ // must not block the generation swap of its session forever.
162
+ rebuildDeferred: (entry) => hasLiveWorkersOf(entry),
163
+ ...(opts.now === undefined ? {} : { now: opts.now }),
164
+ });
165
+ // W742 §2: arm the low-frequency reclaimer (unref'ed; `shutdown` disarms it).
166
+ this.registry.startReclaimer();
167
+ this.registry.ensure(null, null);
168
+ }
169
+ // --- host lifecycle ----------------------------------------------------
170
+ attach(bus) {
171
+ this.bus = bus;
172
+ }
173
+ // --- W783: user questions (the capability itself lives in QuestionHost) ---
174
+ answerQuestion(requestId, answers, sessionId) {
175
+ return this.questions.answer(requestId, answers, sessionId);
176
+ }
177
+ pendingQuestions(sessionId) {
178
+ return this.questions.list(sessionId);
179
+ }
180
+ generationEpoch() { return this.baseEpoch; }
181
+ sessionLogPath() {
182
+ const path = this.registry.peek(null)?.runtime.session?.path;
183
+ return typeof path === "string" ? path : null;
184
+ }
185
+ primeSystemPrompt(prompt) {
186
+ if (prompt === "" || prompt === this.profileValue.system_prompt)
187
+ return;
188
+ this.profileValue = { ...this.profileValue, system_prompt: prompt };
189
+ this.bumpEpoch();
190
+ }
191
+ async shutdown() {
192
+ if (this.shutdownPromise === null) {
193
+ // W769: unpark the wake-up loops FIRST: a loop that grabbed a queue during
194
+ // the teardown would otherwise start a turn on a disposing runtime.
195
+ // W833 (R3 B8 / W816 F4): flush the in-flight fallback audit POSTs before
196
+ // the process can drop them (main.ts flushes grants, this flushes llm).
197
+ this.shutdownPromise = this.autowake.stop().then(() => this.fallback.flush()).then(() => this.registry.shutdown());
198
+ }
199
+ await this.shutdownPromise;
200
+ }
201
+ /** Is auto-wake on? (`CELESTEA_AUTOWAKE`, read once at construction.) */
202
+ get autowakeRunning() { return this.autowake.running; }
203
+ /** W794: mounted auto-wake loops (one per live host conversation). */
204
+ autowakeLoops() { return this.autowake.count; }
205
+ /**
206
+ * Run ONE ordinary turn with the drained receipts as its input — the same
207
+ * `beginTurn` + status + `drive` path a `POST /api/turn` takes, with
208
+ * `source: "autowake"` on the start frame (contracts/sse-events.json allows
209
+ * it). Returns false when the slot is gone (session deleted) or already taken
210
+ * (busy): the loop then re-queues the messages into the CURRENT generation and
211
+ * retries, so nothing is lost and nothing is consumed twice.
212
+ */
213
+ startAutowakeTurn(session, input) {
214
+ const entry = this.registry.peek(session);
215
+ if (entry === null || entry.inFlight)
216
+ return false;
217
+ // W855 C8: a null wake needs the lane; a throw before/at the claim keeps it.
218
+ if (input === null && entry.runtime.pendingInjections("next-turn") === 0)
219
+ return false;
220
+ try {
221
+ const turn = this.launch(entry, input, undefined, "autowake");
222
+ autowakeLog(session, `woke the host: turn ${turn}`);
223
+ return true;
224
+ }
225
+ catch (error) {
226
+ if (error instanceof CapacityError)
227
+ return false;
228
+ if (entry.inFlight)
229
+ this.registry.endTurn(entry, null);
230
+ autowakeLog(session, `wake failed: ${error instanceof Error ? error.message : String(error)}; messages kept queued`);
231
+ return false;
232
+ }
233
+ }
234
+ /** The composed tool registry of the default instance (`GET /api/tools`). */
235
+ tools() {
236
+ return (this.registry.peek(null)?.runtime.tools?.schemas() ?? []).map(toolSpecView);
237
+ }
238
+ /**
239
+ * W729 (S2): the tool face of ONE session — never a second assembly, always
240
+ * the default generation's registered set passed through that SESSION's mode.
241
+ *
242
+ * W791 (P1, §5.2 #5 / §10.5 #2): the face is a function of the MODE, not of
243
+ * "whichever instance happens to be live". This is called while the session's
244
+ * own prompt is assembled — i.e. DURING its compose / rebuild — and at that
245
+ * moment `peek(session)` still answers the previous generation, so a
246
+ * peek-derived face would render the old tool list into the new prompt (and,
247
+ * on a first compose, the detached default's 11 names into an execution
248
+ * session's prompt). `faceForMode` applies exactly the rule the composed
249
+ * instance's `exposedRegistry` will apply, so prompt and tool array agree.
250
+ * W857: the rule also includes the session's PERMISSION baseline (W9
251
+ * `toolDeny`), read through the composer's OWN reader so the reported face
252
+ * cannot disagree with the instance the session dispatches through; an
253
+ * absent reader / empty deny = the pre-W857 bytes.
254
+ */
255
+ sessionTools(session) {
256
+ const specs = this.registry.peek(null)?.runtime.tools?.schemas() ?? [];
257
+ const mode = session === null ? DEFAULT_SESSION_MODE : effectiveMode(this.opts.sessionMode?.(session) ?? null);
258
+ // W857: the deny is read through the composer's own reader, same session dir.
259
+ return faceForMode(specs, mode, this.opts.grants?.read(session, session === null ? null : (this.opts.resolveSession?.(session)?.dir ?? null)).grants.toolDeny ?? []).map(toolSpecView);
260
+ }
261
+ /**
262
+ * W515 §2/§4: the session's inbox publishes every placement change on the bus
263
+ * (see `session-publisher.ts` for the shapes).
264
+ */
265
+ injectionHooks(sessionId) {
266
+ return injectionHooksOf(sessionId, {
267
+ emitStatus: (id, payload) => void this.bus?.emit("status", 0, payload, id),
268
+ now: this.now,
269
+ });
270
+ }
271
+ // --- sessions ----------------------------------------------------------
272
+ /** The session's instance (creating it, and making room, when needed). */
273
+ entryFor(session) {
274
+ try {
275
+ return this.registry.ensure(session, session === null ? null : (this.opts.resolveSession?.(session)?.dir ?? null));
276
+ }
277
+ catch (e) {
278
+ throw capacityErrorOf(e);
279
+ }
280
+ }
281
+ ensureSession(session) {
282
+ const before = this.registry.peek(session);
283
+ // The registry rebuilds an instance IN PLACE (the entry object survives), so
284
+ // the comparison has to be on the runtime, not on the entry (W516: a grant
285
+ // invalidates exactly one session, and `rebuilt` is how the host sees it).
286
+ const previous = before?.runtime;
287
+ const entry = this.entryFor(session);
288
+ return {
289
+ runtime: before === null ? "created" : "reused",
290
+ busy: entry.inFlight,
291
+ rebuilt: previous !== undefined && previous !== entry.runtime,
292
+ };
293
+ }
294
+ /**
295
+ * W516 §4.2: the session's grants were written, so ITS instance is stale. An
296
+ * idle instance is recomposed now, a busy one at its next turn boundary — and
297
+ * no other session is touched (that is why this is not `invalidateAll`).
298
+ */
299
+ invalidateSession(session) { return this.registry.invalidateSession(session); }
300
+ /** W794: the session is being removed — see `session-release.ts`. */
301
+ releaseSession(session) {
302
+ const release = { registry: this.registry, cancel: (id) => this.cancel(id), forget: (id) => this.autowake.forget(id), settleMs: releaseSettleMs(this.env) };
303
+ return releaseSessionOf(release, session);
304
+ }
305
+ liveSessions() { return this.registry.liveSessionIds(); }
306
+ busySessions() { return this.registry.busySessionIds(); }
307
+ // --- turns -------------------------------------------------------------
308
+ isBusy(session) {
309
+ if (session === undefined)
310
+ return this.registry.inFlightCount() > 0;
311
+ return this.registry.peek(session)?.inFlight ?? false;
312
+ }
313
+ async startTurn(req) {
314
+ const entry = this.entryFor(req.session);
315
+ if (entry.inFlight)
316
+ throw new TurnBusyError("turn");
317
+ // W515 §2: this input IS the turn, so it is already in the context.
318
+ return { turn: this.launch(entry, req.input, req.attachments), placement: "context" };
319
+ }
320
+ /**
321
+ * Claim the slot and start one turn — the ONE path both a manual turn and a
322
+ * W769 auto-wake take, so SSE frames, statusline phases, the turn number and
323
+ * the busy guard cannot differ between them.
324
+ */
325
+ launch(entry, input, attachments, source) {
326
+ const controller = new AbortController();
327
+ const turn = this.beginTurn(entry, controller);
328
+ // W833 (R3 B8 / W816 F5): the signature is source?: "autowake", so the
329
+ // sentinel is undefined — === null was always false and every manual turn
330
+ // carried a source: undefined KEY in the payload.
331
+ this.emitStatus(entry, turn, "start", source === undefined ? {} : { source });
332
+ void this.drive(entry, input, turn, controller, attachments);
333
+ return turn;
334
+ }
335
+ /**
336
+ * W513/W515 §1-§3: the delivery decision table in one place —
337
+ * owner session RUNNING (or closing) -> `next-step` lane, a STEERING message
338
+ * consumed at the running turn's next step boundary (`injected: true`);
339
+ * owner session IDLE -> `next-turn` lane, QUEUED for the next turn start.
340
+ * The lane is what makes "insert now" and "wake me later" the same mechanism.
341
+ *
342
+ * W847 adds the caller's explicit `mode: "queue"`: on a BUSY session it also
343
+ * picks the next-turn lane, so the running turn is left untouched (placement
344
+ * `queued`, `injected: false`) and the input is drained at the NEXT turn start.
345
+ */
346
+ inject(req) {
347
+ const entry = this.registry.peek(req.session);
348
+ const busy = entry?.inFlight === true;
349
+ // W847: only an explicit "queue" on a busy session diverts the input; the
350
+ // omitted request and "steer" keep the W513 table byte for byte.
351
+ const steering = busy && req.mode !== "queue";
352
+ const target = entry ?? this.entryFor(req.session);
353
+ const message = target.runtime.inject(req.input, steering ? "next-step" : "next-turn", { kind: "user", source: { kind: "user", form: "message" } });
354
+ target.lastActiveAt = this.now();
355
+ return {
356
+ turn: target.turnNo,
357
+ injected: steering,
358
+ pending: target.runtime.pendingInjections(steering ? "next-step" : "next-turn"),
359
+ placement: steering ? "steering" : "queued",
360
+ duplicate: message.duplicate,
361
+ };
362
+ }
363
+ beginTurn(entry, controller) {
364
+ try {
365
+ return this.registry.beginTurn(entry, controller);
366
+ }
367
+ catch (e) {
368
+ throw capacityErrorOf(e);
369
+ }
370
+ }
371
+ /**
372
+ * Drive one turn to its terminal state, then publish the closing status.
373
+ *
374
+ * W794: a turn whose session was DELETED while it ran publishes nothing more.
375
+ * `releaseSession` aborts it first, but the unwind is asynchronous, so the tail
376
+ * of this method can run after the directory has moved — the detached flag is
377
+ * what keeps a dangling frame for a session that no longer exists off every SSE
378
+ * subscriber's stream (and off the released runtime's statusline, which would
379
+ * throw). The turn's own log write is unaffected: it already happened.
380
+ */
381
+ async drive(entry, input, turn, controller, attachments) {
382
+ try {
383
+ const outcome = await entry.runtime.runTurn(input, {
384
+ signal: controller.signal,
385
+ sink: (frame) => this.emitFrame(entry, frame.event, turn, frame.payload),
386
+ ...(attachments === undefined ? {} : { attachments }),
387
+ });
388
+ this.registry.endTurn(entry, outcome);
389
+ this.emitStatus(entry, turn, outcomePhaseOf(outcome));
390
+ }
391
+ catch (e) {
392
+ this.registry.endTurn(entry, null);
393
+ this.emitStatus(entry, turn, "error", { error: e instanceof Error ? e.message : String(e) });
394
+ }
395
+ }
396
+ /**
397
+ * W794: the ONE gate every frame of a session's turn goes through. A turn whose
398
+ * session was DELETED while it ran publishes nothing more — `releaseSession`
399
+ * aborts it first, but the unwind is asynchronous, so the tail of `drive` can run
400
+ * after the directory moved. Without this a subscriber would receive a dangling
401
+ * frame for a session that no longer exists (and reading the released runtime's
402
+ * statusline would throw). The turn's own log write is unaffected: it happened.
403
+ */
404
+ emitFrame(entry, event, turn, payload) {
405
+ if (entry.detached === true)
406
+ return;
407
+ this.bus?.emit(event, turn, payload, entry.sessionId);
408
+ }
409
+ cancel(session) {
410
+ const entry = session === undefined ? this.newestBusy() : this.registry.peek(session);
411
+ if (entry === null || entry === undefined || !entry.inFlight || entry.controller === null)
412
+ return false;
413
+ entry.controller.abort();
414
+ return true;
415
+ }
416
+ newestBusy() {
417
+ // W833 (R3 B8 / W816 F7): registry.list() is CREATION order, so pick the
418
+ // in-flight entry with the greatest lastActiveAt (the most recently used).
419
+ let newest = null;
420
+ for (const entry of this.registry.list())
421
+ if (entry.inFlight && (newest === null || entry.lastActiveAt >= newest.lastActiveAt))
422
+ newest = entry;
423
+ return newest;
424
+ }
425
+ lastTurnOutcome() {
426
+ let best = null;
427
+ for (const entry of this.registry.list()) {
428
+ if (entry.lastOutcome === null)
429
+ continue;
430
+ if (best === null || entry.lastActiveAt >= best.lastActiveAt)
431
+ best = entry;
432
+ }
433
+ return best?.lastOutcome ?? null;
434
+ }
435
+ // --- workers: liveness (W740) ------------------------------------------
436
+ /** The session's watchdog (see `watchdog-view.ts`); unknown = null, never composed. */
437
+ watchdog(session) { return watchdogOf(this.registry, session); }
438
+ /** Is this session's sweep timer running? (no instance / watchdog off = false.) */
439
+ watchdogRunning(session) { return watchdogRunningOf(this.registry, session); }
440
+ /** The session's live worker registry, or null when it has no instance. */
441
+ workersOf(session) { return this.registry.peek(session ?? null)?.runtime.workers ?? null; }
442
+ emitStatus(entry, turn, phase, extra = {}) {
443
+ // W833 (R3 B8): a drive tail can land after the generation was released
444
+ // (adapter.shutdown -> registry.shutdown). Reading a released runtime's
445
+ // statusline throws RuntimeReleasedError; a status frame for it is
446
+ // meaningless anyway, so skip it exactly like the W794 detached case.
447
+ if (entry.detached === true || entry.runtime.isReleased)
448
+ return;
449
+ this.bus?.emit("status", turn, { phase, statusline: entry.runtime.statusline(), ...extra }, entry.sessionId);
450
+ }
451
+ // --- host views --------------------------------------------------------
452
+ profile() { return engineProfileOf(this.profileValue); }
453
+ /**
454
+ * W725: the session's model-visible context (`GET /api/sessions/{id}/context`).
455
+ * The instance is ensured first (same path as activate / a turn), then the
456
+ * agent loop assembles the request — this adapter only forwards it, so the
457
+ * snapshot is the engine's own, never a host-side re-derivation.
458
+ */
459
+ sessionContext(session) {
460
+ // W729: THAT session's profile (mode variant included), not the process's.
461
+ return sessionContextOf(this.entryFor(session).runtime, this.composer.profileFor(session));
462
+ }
463
+ /** E §4.2.3 #4 (W785): the fallback block of `/api/status` (null = off). */
464
+ fallbackView(session) { return this.fallback.view(session); }
465
+ /** The requested session's statusline (no instance yet = an empty one). */
466
+ statusline(session) {
467
+ const entry = this.registry.peek(session ?? null);
468
+ if (entry !== null)
469
+ return entry.runtime.statusline();
470
+ // W755: a cold session measures nothing — `coldStatusline` owns that shape.
471
+ const profile = this.profileValue;
472
+ return coldStatusline({ ...profile, context_window: profile.context_window_tokens, now: this.now });
473
+ }
474
+ // --- E-P1 (capability 3, W785): the usage ledger's aggregate views --------
475
+ // Both read the ONE process-shared ledger file through `ledger-view.ts` (no
476
+ // cache: a row booked a moment ago is visible to the next poll).
477
+ /** `GET /api/usage/ledger` (see `ledger-view.ts`). */
478
+ usageLedger(q) { return usageLedgerView(this.opts.ledgerFile ?? null, q); }
479
+ /** `/api/status.cost`: `null` (no ledger) makes the handler omit the key. */
480
+ costBlock(session) {
481
+ const dir = session === null ? null : (this.opts.resolveSession?.(session)?.dir ?? null);
482
+ return costBlockView(this.opts.ledgerFile ?? null, session, dir);
483
+ }
484
+ async configure(patch) {
485
+ if (patch.api_key !== undefined && patch.api_key !== "")
486
+ this.env[this.profileValue.api_key_env] = patch.api_key;
487
+ this.profileValue = applyProfilePatch(this.profileValue, patch);
488
+ this.bumpEpoch();
489
+ return this.profile();
490
+ }
491
+ /** Config change: instances are rebuilt lazily, at their next turn boundary. */
492
+ bumpEpoch() {
493
+ this.baseEpoch += 1;
494
+ this.registry.invalidateAll();
495
+ }
496
+ async clear(session) {
497
+ return clearSession(this.registry, session);
498
+ }
499
+ async compact(session) {
500
+ return compactSession(this.lifecycleDeps(), session);
501
+ }
502
+ /** Everything `clear`/`compact` need (see `session-lifecycle.ts`). */
503
+ lifecycleDeps() {
504
+ return {
505
+ registry: this.registry,
506
+ resolve: (id) => this.opts.resolveSession?.(id) ?? null,
507
+ summarizer: () => this.composer.summarizer(),
508
+ };
509
+ }
510
+ // --- workers -----------------------------------------------------------
511
+ /** Merged worker rows over every live instance (W513 aggregate view). */
512
+ workerSessions() { return mergedWorkerRows(this.registry.list()); }
513
+ workerMessages(sessionId) { return workerMessagesAcross(this.registry.list(), sessionId); }
514
+ async workerSpawn(req) {
515
+ return spawnWorkerThrough(this.entryFor(req.session ?? null), req, `host-spawn-${(this.toolCalls += 1)}`);
516
+ }
517
+ async workerSend(req) {
518
+ return sendWorkerThrough(this.registry.list(), req, () => `host-send-${(this.toolCalls += 1)}`);
519
+ }
520
+ /**
521
+ * W740 §2: the panel/tool face is where a watchdog verdict becomes visible —
522
+ * `by_status` counts the registry rows, so a settle changes it, and the count of
523
+ * live sweepers rides along.
524
+ */
525
+ workerStatus(wid) {
526
+ return workerStatusOf(this.workerSessions(), watchdogCount(this.registry.list()), wid, this.workerRecovery());
527
+ }
528
+ /** E §1.3 P1 ②: `/api/status.recovery` of one session (never composes one). */
529
+ recoveryView(session) {
530
+ return recoveryViewOf(this.registry.peek(session)?.runtime.session ?? null, session);
531
+ }
532
+ /**
533
+ * E §2.3 P0 ③: judge the PERSISTED table on every status poll (the boot
534
+ * observer writes the same judgement to the audit channel once). Observation
535
+ * only — nothing here settles a row or re-dispatches a worker (P2 territory).
536
+ */
537
+ workerRecovery() {
538
+ return workerRecoveryBlock({
539
+ path: this.workerTable,
540
+ knownHost: (sid) => this.opts.resolveSession?.(sid) != null,
541
+ resultsDir: this.opts.resultsDir ?? join(process.cwd(), "worker-results"),
542
+ now: this.now,
543
+ });
544
+ }
545
+ // --- internals ---------------------------------------------------------
546
+ get now() {
547
+ return this.opts.now ?? Date.now;
548
+ }
549
+ }
550
+ /** Build the real adapter (the host's default engine). */
551
+ export function createRealRuntimeAdapter(opts = {}) {
552
+ return new RealEngine(opts);
553
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The iteration-E recovery audit channel (E §5.2③, P1).
3
+ *
4
+ * §5.2③: every automatic or observed recovery fact must appear in three places —
5
+ * an append-only local log, a structured field and an audit line. W730's P0
6
+ * deliberately deferred the audit half of the checkpoint capability because
7
+ * inventing a second audit FILE then had no owner; P1 is that owner, and this is
8
+ * the ONE channel both P1 capabilities write through:
9
+ *
10
+ * - `log_degraded` — a session log refused a write (`writeErrorCount>0`):
11
+ * disk and memory forked, and nobody would ever know;
12
+ * - `worker_stale` — a persisted worker row whose owning process is dead;
13
+ * - `worker_orphan` — a RUNNING row whose `host=` session is gone;
14
+ * - `worker_observed` — the boot sweep's summary line (always exactly one);
15
+ * - `session_repaired` — a crashed turn was closed by appending ONE
16
+ * `turn_end: interrupted` row (§1.2.3, R1-1).
17
+ *
18
+ * Discipline (identical to `grants-audit.jsonl` / `fallbacks-audit.jsonl`):
19
+ * local append-only `<data dir>/recovery-audit.jsonl`, mode 0600, rotated at
20
+ * 16 MiB; the platform `POST /api/audit` is BEST-EFFORT and only when
21
+ * `CELESTEA_AUDIT_URL` is set. A failed write is reported, never thrown — an
22
+ * audit channel must not be able to break the thing it observes. Nothing here
23
+ * ever records a prompt, a command line or a credential: wid / turn_id / count
24
+ * only (§4.4).
25
+ */
26
+ export declare const RECOVERY_AUDIT_FILE = "recovery-audit.jsonl";
27
+ /** Rotate at 16 MiB, keeping the previous chain (LTS ops audit rules). */
28
+ export declare const RECOVERY_AUDIT_MAX_BYTES: number;
29
+ export declare const ENV_AUDIT_URL = "CELESTEA_AUDIT_URL";
30
+ export declare const ENV_CENTER_TOKEN = "CELESTEA_CENTER_TOKEN";
31
+ export type RecoveryAuditEventName = "log_degraded" | "session_repaired" | "worker_stale" | "worker_orphan" | "worker_observed";
32
+ /** One audit line. `session` is null for process-level facts. */
33
+ export interface RecoveryAuditEvent {
34
+ ts: number;
35
+ event: RecoveryAuditEventName;
36
+ session: string | null;
37
+ /** Worker id (worker events). */
38
+ wid?: string;
39
+ /** Which attempt of that worker the row was on. */
40
+ attempt?: number;
41
+ /** The host session the row was dispatched from (`host=`). */
42
+ host_session?: string | null;
43
+ /** Why the observer flagged the row (`stale_lease` / `orphan_host`). */
44
+ reason?: string;
45
+ /** What P2 WOULD do — recorded so the future action is auditable up front. */
46
+ action?: string;
47
+ /** Turn id (log events) / row counts (sweep events). */
48
+ turn_id?: string | null;
49
+ count?: number;
50
+ detail?: string;
51
+ }
52
+ export interface RecoveryAuditOptions {
53
+ /** `<data dir>` — the audit file lives next to workspaces.json. */
54
+ dataDir: string;
55
+ env?: NodeJS.ProcessEnv;
56
+ now?: () => number;
57
+ /** Injected transport (tests); default `fetch`. */
58
+ post?: (url: string, body: string, headers: Record<string, string>) => Promise<{
59
+ ok: boolean;
60
+ status: number;
61
+ }>;
62
+ }
63
+ export declare class RecoveryAuditWriter {
64
+ private readonly path;
65
+ private readonly env;
66
+ private readonly now;
67
+ private readonly post;
68
+ /** Diagnostics of failed writes (never thrown): the caller reports them. */
69
+ readonly errors: string[];
70
+ constructor(opts: RecoveryAuditOptions);
71
+ get filePath(): string;
72
+ /** Append one line (local first, platform best-effort — §4.4). */
73
+ write(event: Omit<RecoveryAuditEvent, "ts"> & {
74
+ ts?: number;
75
+ }): void;
76
+ private append;
77
+ /** 16 MiB ceiling: the previous chain becomes `<path>.1` (replaced). */
78
+ private rotate;
79
+ /** Best-effort platform channel; its own failure is recorded locally. */
80
+ private deliver;
81
+ }