@celestea/runtime 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 (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +106 -0
  3. package/dist/agent-config.d.ts +18 -0
  4. package/dist/agent-config.js +31 -0
  5. package/dist/autowake.d.ts +141 -0
  6. package/dist/autowake.js +262 -0
  7. package/dist/compact/index.d.ts +13 -0
  8. package/dist/compact/index.js +13 -0
  9. package/dist/compact/plan.d.ts +51 -0
  10. package/dist/compact/plan.js +98 -0
  11. package/dist/compact/rewrite.d.ts +23 -0
  12. package/dist/compact/rewrite.js +79 -0
  13. package/dist/compact/run.d.ts +44 -0
  14. package/dist/compact/run.js +59 -0
  15. package/dist/compact/summarize.d.ts +30 -0
  16. package/dist/compact/summarize.js +70 -0
  17. package/dist/compact/transcript.d.ts +35 -0
  18. package/dist/compact/transcript.js +88 -0
  19. package/dist/compose.d.ts +117 -0
  20. package/dist/compose.js +191 -0
  21. package/dist/errors.d.ts +25 -0
  22. package/dist/errors.js +34 -0
  23. package/dist/frames.d.ts +46 -0
  24. package/dist/frames.js +62 -0
  25. package/dist/gen.d.ts +86 -0
  26. package/dist/gen.js +129 -0
  27. package/dist/host/engine-session.d.ts +117 -0
  28. package/dist/host/engine-session.js +109 -0
  29. package/dist/host/index.d.ts +39 -0
  30. package/dist/host/index.js +39 -0
  31. package/dist/host/provider-target.d.ts +113 -0
  32. package/dist/host/provider-target.js +116 -0
  33. package/dist/inbox-checkpoint.d.ts +18 -0
  34. package/dist/inbox-checkpoint.js +37 -0
  35. package/dist/inbox.d.ts +94 -0
  36. package/dist/inbox.js +139 -0
  37. package/dist/index.d.ts +71 -0
  38. package/dist/index.js +71 -0
  39. package/dist/ledger-io.d.ts +27 -0
  40. package/dist/ledger-io.js +74 -0
  41. package/dist/ledger-llm.d.ts +48 -0
  42. package/dist/ledger-llm.js +115 -0
  43. package/dist/ledger-query.d.ts +91 -0
  44. package/dist/ledger-query.js +153 -0
  45. package/dist/ledger.d.ts +271 -0
  46. package/dist/ledger.js +444 -0
  47. package/dist/pricing.d.ts +100 -0
  48. package/dist/pricing.js +167 -0
  49. package/dist/profile.d.ts +26 -0
  50. package/dist/profile.js +39 -0
  51. package/dist/recovery.d.ts +56 -0
  52. package/dist/recovery.js +91 -0
  53. package/dist/retention.d.ts +49 -0
  54. package/dist/retention.js +119 -0
  55. package/dist/runtime.d.ts +197 -0
  56. package/dist/runtime.js +347 -0
  57. package/dist/sanitize.d.ts +35 -0
  58. package/dist/sanitize.js +36 -0
  59. package/dist/session-binding.d.ts +36 -0
  60. package/dist/session-binding.js +33 -0
  61. package/dist/session-registry.d.ts +238 -0
  62. package/dist/session-registry.js +388 -0
  63. package/dist/status.d.ts +279 -0
  64. package/dist/status.js +411 -0
  65. package/dist/tokens.d.ts +25 -0
  66. package/dist/tokens.js +25 -0
  67. package/dist/turn-runner.d.ts +169 -0
  68. package/dist/turn-runner.js +242 -0
  69. package/dist/usage.d.ts +64 -0
  70. package/dist/usage.js +88 -0
  71. package/dist/watchdog-mount.d.ts +79 -0
  72. package/dist/watchdog-mount.js +120 -0
  73. package/dist/worker-wiring.d.ts +74 -0
  74. package/dist/worker-wiring.js +107 -0
  75. package/package.json +31 -0
@@ -0,0 +1,191 @@
1
+ /**
2
+ * compose — the composition root of `packages/runtime`
3
+ * (`crates/runtime/src/compose.rs:74-234`).
4
+ *
5
+ * Assembly order is SEMANTICS, not taste (ARCHITECTURE.md §3.2), so it is
6
+ * explicit and tested:
7
+ *
8
+ * 1. runtime services event bus, usage accounting, status tracker;
9
+ * 2. session binding `sessionBinding` (if given) opens the host log;
10
+ * 3. host plugins `config.plugins` in order — a later `provide` of a
11
+ * token REPLACES an earlier one (patch semantics, so
12
+ * a test can mount a fake over a real implementation);
13
+ * 4. worker wiring mount the default workers plugin only when the host
14
+ * did not provide a registry (worker tools must land
15
+ * in the tool registry, hence last);
16
+ * 4b. watchdog W740: mount the liveness watchdog over the resolved
17
+ * worker registry and keep its stop handle, so the
18
+ * sweep timer dies with `shutdown`/`release`;
19
+ * 5. seam resolution session (required) + llm / tools / agentLoop
20
+ * (optional, and `null` when no plugin provides them);
21
+ * 6. driver attach hand Llm/ToolRegistry/AgentLoop to the worker
22
+ * registry so `spawn_worker` is driven, not merely
23
+ * registered, and register the host conversation so
24
+ * receipts have an address;
25
+ * 7. turn runner bind the per-turn loop factory, sink mapper, usage
26
+ * accounting and receipt drain into one driver.
27
+ *
28
+ * Everything the runtime needs beyond `core` is injected: the concrete agent
29
+ * loop arrives as a `loopFactory`, the frame mapper as `frameMapper`, the worker
30
+ * log factory as `workers.logFactory`. That is what keeps this layer free of
31
+ * L1 implementation imports (and lets P3 tests drive it with fakes).
32
+ */
33
+ import { AGENT_LOOP_SERVICE, LLM_REGISTRY_SERVICE, LLM_SERVICE, SESSION_LOG_SERVICE, TOOL_REGISTRY_SERVICE, Context, createEventBus, EVENT_BUS_SERVICE, mountPlugins, pluginNames, } from "@celestea/core";
34
+ import { RETENTION_SERVICE } from "@celestea/agent-loop";
35
+ import { agentConfigFromProfile } from "./agent-config.js";
36
+ import { createToolResultRetention, retentionSettingsFromEnv } from "./retention.js";
37
+ import { ComposeError } from "./errors.js";
38
+ import { loopEventToFrame } from "./frames.js";
39
+ import { Runtime } from "./runtime.js";
40
+ import { bindSession } from "./session-binding.js";
41
+ import { createStatusTracker } from "./status.js";
42
+ import { STATUS_TRACKER_SERVICE, USAGE_TRACKER_SERVICE } from "./tokens.js";
43
+ import { TurnRunner } from "./turn-runner.js";
44
+ import { createUsageTracker } from "./usage.js";
45
+ import { createSessionInbox } from "./inbox.js";
46
+ import { checkpointInboxSink } from "./inbox-checkpoint.js";
47
+ import { ensureWorkerWiring } from "./worker-wiring.js";
48
+ import { checkpointStoreOf } from "@celestea/session";
49
+ import { WATCHDOG_PLUGIN_NAME, celesteaWatchdogSettings, mountWatchdog, stopWatchdog, } from "./watchdog-mount.js";
50
+ /** Compose one engine generation. Throws [ComposeError] on a missing seam. */
51
+ export function compose(config) {
52
+ const ctx = Context.root();
53
+ const usage = config.usage ?? createUsageTracker();
54
+ const status = config.status ?? createStatusTracker(config.now ?? Date.now);
55
+ ctx.provide(EVENT_BUS_SERVICE, createEventBus());
56
+ ctx.provide(USAGE_TRACKER_SERVICE, usage);
57
+ ctx.provide(STATUS_TRACKER_SERVICE, status);
58
+ const binding = config.sessionBinding ?? null;
59
+ const bound = binding === null ? null : bindSession(ctx, binding);
60
+ const plugins = config.plugins ?? [];
61
+ mountPlugins(ctx, plugins);
62
+ const workerHost = ensureWorkerWiring(ctx, config.workers);
63
+ const mounted = mountWatchdogOf(ctx, config, workerHost);
64
+ const session = requireSession(ctx);
65
+ const sessionRef = { log: session };
66
+ // W855: session-scoped tool-result retention (the loop reads this per turn).
67
+ if (config.retention !== null) {
68
+ ctx.provide(RETENTION_SERVICE, config.retention ?? createToolResultRetention(retentionSettingsFromEnv(binding?.dir ?? null, config.env ?? process.env)));
69
+ }
70
+ const llm = ctx.get(LLM_REGISTRY_SERVICE) ?? null;
71
+ const tools = ctx.get(TOOL_REGISTRY_SERVICE) ?? null;
72
+ const agentLoop = ctx.get(AGENT_LOOP_SERVICE) ?? null;
73
+ attachDrivers(workerHost, { llm: resolveDriverLlm(ctx, llm), tools, agentLoop });
74
+ const agentConfig = agentConfigFromProfile(config.profile, config.agentConfig ?? {});
75
+ const inbox = config.inbox ?? createSessionInbox();
76
+ // E §1.3 P1 ①: the lanes + the accepted-id ledger live in this session's
77
+ // checkpoint sidecar when the log is a checkpointed persistent one; an
78
+ // in-memory (detached) session has no sidecar and therefore no persistence.
79
+ const store = bound === null ? null : checkpointStoreOf(bound);
80
+ if (store !== null)
81
+ inbox.bindPersistence(checkpointInboxSink(store));
82
+ const receipts = () => workerHost?.drain() ?? [];
83
+ const drained = (messages, boundary) => {
84
+ if (messages.length === 0)
85
+ return messages;
86
+ // A mailbox message never entered a lane: the BOUNDARY that consumed it is
87
+ // what tells the client where it landed (W515 §1/§2).
88
+ const lane = boundary === "step" ? "next-step" : "next-turn";
89
+ const annotated = messages.map((message) => (message.lane === undefined ? { ...message, lane } : message));
90
+ // P1-6 (W836): the lane was ALREADY drained (the message left the queue and
91
+ // the sidecar). A throwing observer must never take the message with it: the
92
+ // callback is a notification, so its failure is a warning, not a loss.
93
+ try {
94
+ config.onInjected?.(annotated, boundary);
95
+ }
96
+ catch (e) {
97
+ process.stderr.write(`[celestea-runtime] onInjected callback failed: ${String(e)}\n`);
98
+ }
99
+ return annotated;
100
+ };
101
+ const runner = new TurnRunner({
102
+ ctx,
103
+ session: () => sessionRef.log,
104
+ status,
105
+ usage,
106
+ agentConfig,
107
+ frameMapper: config.frameMapper ?? loopEventToFrame,
108
+ ...(config.ledger === undefined ? {} : { ledger: config.ledger }),
109
+ ...(config.loopFactory === undefined ? {} : { loopFactory: config.loopFactory }),
110
+ ...(config.turnContext === undefined ? {} : { turnContext: config.turnContext }),
111
+ drainPending: () => drained([...inbox.drain("next-turn"), ...receipts()], "turn-start"),
112
+ injections: {
113
+ drain: () => drained([...inbox.drain("next-step"), ...receipts()], "step"),
114
+ pending: () => inbox.pending("next-step") + (workerHost?.pending() ?? 0),
115
+ },
116
+ });
117
+ const parts = {
118
+ ctx,
119
+ profile: config.profile,
120
+ agentConfig,
121
+ sessionRef,
122
+ binding,
123
+ status,
124
+ usage,
125
+ inbox,
126
+ runner,
127
+ workerHost: watchdogHostOf(workerHost, mounted),
128
+ llm,
129
+ tools,
130
+ agentLoop,
131
+ plugins: pluginNamesOf(plugins, workerHost, mounted),
132
+ shutdownHooks: [...(config.shutdownHooks ?? []), stopWatchdog(mounted)],
133
+ };
134
+ return new Runtime(parts);
135
+ }
136
+ /**
137
+ * The plugin set that was mounted, in mount order (order is contract): the host
138
+ * plugins, then the workers plugin (when this root mounted it), then the W740
139
+ * watchdog — which is always LAST, because it may only adjudicate rows a fully
140
+ * mounted worker registry already owns.
141
+ */
142
+ export function pluginNamesOf(plugins, workerHost, mounted = null) {
143
+ const names = pluginNames(plugins);
144
+ if (workerHost !== null && workerHost.mountedPlugin !== null)
145
+ names.push(workerHost.mountedPlugin);
146
+ if (workerHost !== null && mounted !== null)
147
+ names.push(WATCHDOG_PLUGIN_NAME);
148
+ return names;
149
+ }
150
+ /**
151
+ * W740: mount the watchdog over the RESOLVED registry — the host-provided one or
152
+ * the default this root mounted. It runs even when a host plugin provided the
153
+ * registry: liveness judgement is exactly what is missing there. With no worker
154
+ * wiring (`workers: false`) there is no registry to sweep and nothing mounts.
155
+ */
156
+ function mountWatchdogOf(ctx, config, workerHost) {
157
+ if (workerHost === null || config.watchdog === false)
158
+ return null;
159
+ const fromEnv = celesteaWatchdogSettings(config.env ?? process.env);
160
+ return mountWatchdog(ctx, workerHost.registry, { ...fromEnv, ...config.watchdog });
161
+ }
162
+ /** Attach the watchdog handle to the host view (services stay in the Context). */
163
+ function watchdogHostOf(workerHost, mounted) {
164
+ return workerHost === null ? null : { ...workerHost, watchdog: mounted?.watchdog ?? null };
165
+ }
166
+ function requireSession(ctx) {
167
+ const session = ctx.get(SESSION_LOG_SERVICE);
168
+ if (session === undefined) {
169
+ throw new ComposeError("no SessionLog: pass a sessionBinding or mount a session log plugin");
170
+ }
171
+ return session;
172
+ }
173
+ /**
174
+ * The single adapter the worker driver uses: the composed `LlmService`, else the
175
+ * registry's first registered provider (compose registers exactly one).
176
+ */
177
+ function resolveDriverLlm(ctx, registry) {
178
+ const direct = ctx.get(LLM_SERVICE);
179
+ if (direct !== undefined)
180
+ return direct;
181
+ const first = registry?.list()[0];
182
+ return first === undefined ? undefined : registry?.resolve(first);
183
+ }
184
+ /** Attach every driver seam (all three, or none — a partial set cannot drive). */
185
+ function attachDrivers(host, seams) {
186
+ if (host === null)
187
+ return;
188
+ const { llm, tools, agentLoop } = seams;
189
+ const drivers = llm === undefined || tools === null || agentLoop === null ? null : { llm, tools, agentLoop };
190
+ host.attach(drivers);
191
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Runtime-owned errors. Machine-readable first: callers branch on `status`
3
+ * (HTTP-shaped, via core's `StudioError`) or on `kind`, never on prose
4
+ * (ARCHITECTURE.md §6.2).
5
+ */
6
+ import { StudioError } from "@celestea/core";
7
+ /**
8
+ * A second `runTurn` while one is in flight. The engine has a single
9
+ * concurrency slot per Runtime generation (`AppState::busy`): a turn is
10
+ * either running or not, and a conflicting start is a 409, never a queue.
11
+ */
12
+ export declare class TurnBusyError extends StudioError {
13
+ readonly kind = "turn_busy";
14
+ constructor(what?: string);
15
+ }
16
+ /** The generation was shut down / released: its handles must not be used again. */
17
+ export declare class RuntimeReleasedError extends StudioError {
18
+ readonly kind = "runtime_released";
19
+ constructor(what?: string);
20
+ }
21
+ /** Composition failed: a required seam was never provided by any plugin. */
22
+ export declare class ComposeError extends Error {
23
+ readonly kind = "compose";
24
+ constructor(message: string);
25
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Runtime-owned errors. Machine-readable first: callers branch on `status`
3
+ * (HTTP-shaped, via core's `StudioError`) or on `kind`, never on prose
4
+ * (ARCHITECTURE.md §6.2).
5
+ */
6
+ import { StudioError } from "@celestea/core";
7
+ /**
8
+ * A second `runTurn` while one is in flight. The engine has a single
9
+ * concurrency slot per Runtime generation (`AppState::busy`): a turn is
10
+ * either running or not, and a conflicting start is a 409, never a queue.
11
+ */
12
+ export class TurnBusyError extends StudioError {
13
+ kind = "turn_busy";
14
+ constructor(what = "cancel/rebind/config") {
15
+ super(409, `turn in progress; ${what} applies between turns`);
16
+ this.name = "TurnBusyError";
17
+ }
18
+ }
19
+ /** The generation was shut down / released: its handles must not be used again. */
20
+ export class RuntimeReleasedError extends StudioError {
21
+ kind = "runtime_released";
22
+ constructor(what = "the runtime generation was shut down or released") {
23
+ super(410, `runtime released: ${what}`);
24
+ this.name = "RuntimeReleasedError";
25
+ }
26
+ }
27
+ /** Composition failed: a required seam was never provided by any plugin. */
28
+ export class ComposeError extends Error {
29
+ kind = "compose";
30
+ constructor(message) {
31
+ super(message);
32
+ this.name = "ComposeError";
33
+ }
34
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * LoopEvent -> SSE frame mapping and the per-turn sink shape.
3
+ *
4
+ * The runtime owns the host-facing event stream: a turn drives the
5
+ * `AgentLoop` seam and every `LoopEvent` (core) it produces becomes exactly one
6
+ * `TurnFrame` — the frozen `contracts/sse-events.json` name plus its payload
7
+ * (`celestea_studio/src/main.rs:667-713`, `loop_event_to_json`).
8
+ *
9
+ * The mapping is injectable (`FrameMapper`): the default below is contract-faithful
10
+ * and complete, and a host that already ships its own mapper (for example
11
+ * `@celestea/agent-loop`'s `loopEventToSse`) passes it in the compose config
12
+ * instead. Either way the runtime never imports an L1 implementation.
13
+ */
14
+ import { type LoopEvent, type SseEventName } from "@celestea/core";
15
+ /** One SSE frame: the frozen event name plus its `data:` payload. */
16
+ export interface TurnFrame {
17
+ event: SseEventName;
18
+ payload: Record<string, unknown>;
19
+ }
20
+ /** Maps a core `LoopEvent` onto the host-facing frame. */
21
+ export type FrameMapper = (event: LoopEvent) => TurnFrame;
22
+ /** Sink handed to the agent loop; structurally identical to `EventSink`. */
23
+ export type LoopEventSink = (event: LoopEvent) => void;
24
+ /** `loop_event_to_json` — the default, contract-faithful mapping. */
25
+ export declare function loopEventToFrame(ev: LoopEvent): TurnFrame;
26
+ /**
27
+ * W783: one parked user question.
28
+ *
29
+ * This frame is NOT produced by the agent loop — a question parks the tool call,
30
+ * so no `LoopEvent` exists to map. It is host-emitted, which is why it is built
31
+ * here rather than in `loopEventToFrame`: the module that owns every frozen SSE
32
+ * payload owns this one too, so the two can never drift apart.
33
+ *
34
+ * `expires_at` is an absolute deadline and `timeout_ms` the resolved wait (§6.1),
35
+ * so a client can draw the countdown from the frame alone and judge expiry with
36
+ * the server's clock rather than its own.
37
+ */
38
+ export declare function questionFrame(input: QuestionFrameInput): TurnFrame;
39
+ /** What [questionFrame] needs: the request as the pending table holds it. */
40
+ export interface QuestionFrameInput {
41
+ session: string | null;
42
+ id: string;
43
+ questions: readonly unknown[];
44
+ expiresAt: number;
45
+ timeoutMs: number;
46
+ }
package/dist/frames.js ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * LoopEvent -> SSE frame mapping and the per-turn sink shape.
3
+ *
4
+ * The runtime owns the host-facing event stream: a turn drives the
5
+ * `AgentLoop` seam and every `LoopEvent` (core) it produces becomes exactly one
6
+ * `TurnFrame` — the frozen `contracts/sse-events.json` name plus its payload
7
+ * (`celestea_studio/src/main.rs:667-713`, `loop_event_to_json`).
8
+ *
9
+ * The mapping is injectable (`FrameMapper`): the default below is contract-faithful
10
+ * and complete, and a host that already ships its own mapper (for example
11
+ * `@celestea/agent-loop`'s `loopEventToSse`) passes it in the compose config
12
+ * instead. Either way the runtime never imports an L1 implementation.
13
+ */
14
+ import { outcomeError, outcomePhase } from "@celestea/core";
15
+ /** `loop_event_to_json` — the default, contract-faithful mapping. */
16
+ export function loopEventToFrame(ev) {
17
+ switch (ev.kind) {
18
+ case "text":
19
+ return frame("text", { delta: ev.delta });
20
+ case "thinking":
21
+ return frame("thinking", { delta: ev.delta });
22
+ case "tool_call":
23
+ return frame("tool", { id: ev.id, name: ev.name, args: ev.args });
24
+ case "tool_result":
25
+ return frame("tool_result", {
26
+ id: ev.callId,
27
+ ok: ev.ok,
28
+ value: ev.value,
29
+ render: ev.render,
30
+ error: ev.error,
31
+ decision: ev.decision,
32
+ });
33
+ case "turn_end":
34
+ return frame("turn_end", { outcome: outcomePhase(ev.outcome), error: outcomeError(ev.outcome) });
35
+ case "done":
36
+ return frame("done", { text: ev.text, tool_calls: ev.tool_calls });
37
+ }
38
+ }
39
+ function frame(event, payload) {
40
+ return { event, payload };
41
+ }
42
+ /**
43
+ * W783: one parked user question.
44
+ *
45
+ * This frame is NOT produced by the agent loop — a question parks the tool call,
46
+ * so no `LoopEvent` exists to map. It is host-emitted, which is why it is built
47
+ * here rather than in `loopEventToFrame`: the module that owns every frozen SSE
48
+ * payload owns this one too, so the two can never drift apart.
49
+ *
50
+ * `expires_at` is an absolute deadline and `timeout_ms` the resolved wait (§6.1),
51
+ * so a client can draw the countdown from the frame alone and judge expiry with
52
+ * the server's clock rather than its own.
53
+ */
54
+ export function questionFrame(input) {
55
+ return frame("question", {
56
+ session: input.session,
57
+ id: input.id,
58
+ questions: [...input.questions],
59
+ expires_at: input.expiresAt,
60
+ timeout_ms: input.timeoutMs,
61
+ });
62
+ }
package/dist/gen.d.ts ADDED
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Generation (Gen) — the hot-swappable unit behind `/api/config`.
3
+ *
4
+ * A generation is `{ runtime, profile, sanitized config, epoch }`, and every
5
+ * field derives from the SAME profile, so a reader can never observe a mixed
6
+ * state (model from one compose, session from another). The swap protocol is a
7
+ * single synchronous pointer flip followed by old-generation teardown:
8
+ *
9
+ * 1. flip `this.gen = next` — atomic in the reader's eyes: a reader holds
10
+ * either the previous object or the next one, never a blend;
11
+ * 2. migrate pending host receipts are moved from the old generation's
12
+ * mailbox onto the new one, so a receipt that arrived during the
13
+ * swap is not lost with the generation it was addressed to (W240);
14
+ * 3. teardown the previous generation is shut down and released AFTER the
15
+ * flip — its drivers stop, its mailbox is purged, its registry
16
+ * cleared, its handles dropped (W248).
17
+ *
18
+ * `buildAndSwap` composes the next generation through an injected factory, so
19
+ * this module never imports `compose` (no cycle) and a test can swap in a fake.
20
+ */
21
+ import type { Profile } from "./profile.js";
22
+ import type { Runtime } from "./runtime.js";
23
+ import { type SanitizedConfig } from "./sanitize.js";
24
+ /** One generation: the runtime, the profile it was built from, its safe config. */
25
+ export interface Gen {
26
+ readonly runtime: Runtime;
27
+ readonly profile: Profile;
28
+ readonly config: SanitizedConfig;
29
+ readonly epoch: number;
30
+ }
31
+ export interface GenSwapResult {
32
+ epoch: number;
33
+ prevEpoch: number | null;
34
+ /** Host receipts migrated from the previous generation's mailbox. */
35
+ migrated: number;
36
+ }
37
+ export interface GenerationHubOptions {
38
+ /** Queue key the host receipts are addressed to (default `cli-main`). */
39
+ hostSessionId?: string;
40
+ /** Builds the next generation; `compose(profile)` at the composition root. */
41
+ build?: (profile: Profile) => Runtime | Promise<Runtime>;
42
+ /** Observers notified after a swap, in registration order. */
43
+ onSwap?: (next: Gen, prev: Gen | null) => void;
44
+ }
45
+ /** A generation from a runtime + the profile it was composed from. */
46
+ export declare function createGen(runtime: Runtime, profile: Profile, epoch?: number): Gen;
47
+ export declare class GenerationHub {
48
+ private gen;
49
+ private epochCounter;
50
+ private swaps;
51
+ private readonly listeners;
52
+ private readonly opts;
53
+ constructor(opts?: GenerationHubOptions);
54
+ /** The current generation (throws before the first install). */
55
+ current(): Gen;
56
+ /** The current generation, or null when none is installed yet. */
57
+ peek(): Gen | null;
58
+ /** Current epoch (0 before the first install). */
59
+ get epoch(): number;
60
+ /** How many swaps happened (diagnostics / tests). */
61
+ get swapCount(): number;
62
+ /** Install the first generation (also a swap when one already exists). */
63
+ install(runtime: Runtime, profile: Profile): Gen;
64
+ /** Flip + migrate, synchronously; the previous generation is NOT torn down. */
65
+ swapSync(runtime: Runtime, profile: Profile): {
66
+ gen: Gen;
67
+ result: GenSwapResult;
68
+ };
69
+ /** Flip, migrate, then shut down and release the previous generation. */
70
+ swap(runtime: Runtime, profile: Profile): Promise<GenSwapResult>;
71
+ /** Compose the next generation through `build`, then swap it in. */
72
+ buildAndSwap(profile: Profile): Promise<GenSwapResult>;
73
+ /** Subscribe to swaps; the returned function unsubscribes. */
74
+ onSwap(listener: (next: Gen, prev: Gen | null) => void): () => void;
75
+ /** Shut down and release the current generation (idempotent). */
76
+ shutdown(): Promise<void>;
77
+ private hostSessionId;
78
+ }
79
+ /**
80
+ * Move pending host receipts from one generation's mailbox onto the next.
81
+ *
82
+ * `== null` on purpose (W769): a generation composed without worker wiring has no
83
+ * `workers` at all, and the W769 caller runs inside a session rebuild where a
84
+ * missing wiring must be a no-op, never a crash.
85
+ */
86
+ export declare function migrateReceipts(from: Runtime, to: Runtime, hostSessionId: string): number;
package/dist/gen.js ADDED
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Generation (Gen) — the hot-swappable unit behind `/api/config`.
3
+ *
4
+ * A generation is `{ runtime, profile, sanitized config, epoch }`, and every
5
+ * field derives from the SAME profile, so a reader can never observe a mixed
6
+ * state (model from one compose, session from another). The swap protocol is a
7
+ * single synchronous pointer flip followed by old-generation teardown:
8
+ *
9
+ * 1. flip `this.gen = next` — atomic in the reader's eyes: a reader holds
10
+ * either the previous object or the next one, never a blend;
11
+ * 2. migrate pending host receipts are moved from the old generation's
12
+ * mailbox onto the new one, so a receipt that arrived during the
13
+ * swap is not lost with the generation it was addressed to (W240);
14
+ * 3. teardown the previous generation is shut down and released AFTER the
15
+ * flip — its drivers stop, its mailbox is purged, its registry
16
+ * cleared, its handles dropped (W248).
17
+ *
18
+ * `buildAndSwap` composes the next generation through an injected factory, so
19
+ * this module never imports `compose` (no cycle) and a test can swap in a fake.
20
+ */
21
+ import { ComposeError } from "./errors.js";
22
+ import { sanitizeProfile } from "./sanitize.js";
23
+ import { HOST_SESSION_ID } from "./tokens.js";
24
+ /** A generation from a runtime + the profile it was composed from. */
25
+ export function createGen(runtime, profile, epoch = 0) {
26
+ return { runtime, profile, config: sanitizeProfile(profile), epoch };
27
+ }
28
+ export class GenerationHub {
29
+ gen = null;
30
+ epochCounter = 0;
31
+ swaps = 0;
32
+ listeners = [];
33
+ opts;
34
+ constructor(opts = {}) {
35
+ this.opts = opts;
36
+ if (opts.onSwap !== undefined)
37
+ this.listeners.push(opts.onSwap);
38
+ }
39
+ /** The current generation (throws before the first install). */
40
+ current() {
41
+ const gen = this.gen;
42
+ if (gen === null)
43
+ throw new ComposeError("no generation installed");
44
+ return gen;
45
+ }
46
+ /** The current generation, or null when none is installed yet. */
47
+ peek() {
48
+ return this.gen;
49
+ }
50
+ /** Current epoch (0 before the first install). */
51
+ get epoch() {
52
+ return this.gen?.epoch ?? 0;
53
+ }
54
+ /** How many swaps happened (diagnostics / tests). */
55
+ get swapCount() {
56
+ return this.swaps;
57
+ }
58
+ /** Install the first generation (also a swap when one already exists). */
59
+ install(runtime, profile) {
60
+ return this.swapSync(runtime, profile).gen;
61
+ }
62
+ /** Flip + migrate, synchronously; the previous generation is NOT torn down. */
63
+ swapSync(runtime, profile) {
64
+ const prev = this.gen;
65
+ const next = createGen(runtime, profile, ++this.epochCounter);
66
+ this.gen = next;
67
+ const migrated = prev === null ? 0 : migrateReceipts(prev.runtime, runtime, this.hostSessionId());
68
+ this.swaps += 1;
69
+ for (const listener of this.listeners)
70
+ listener(next, prev);
71
+ return { gen: next, result: { epoch: next.epoch, prevEpoch: prev?.epoch ?? null, migrated } };
72
+ }
73
+ /** Flip, migrate, then shut down and release the previous generation. */
74
+ async swap(runtime, profile) {
75
+ const prev = this.gen;
76
+ const { result } = this.swapSync(runtime, profile);
77
+ if (prev !== null)
78
+ await teardown(prev.runtime);
79
+ return result;
80
+ }
81
+ /** Compose the next generation through `build`, then swap it in. */
82
+ async buildAndSwap(profile) {
83
+ const build = this.opts.build;
84
+ if (build === undefined)
85
+ throw new ComposeError("GenerationHub has no build factory");
86
+ const runtime = await build(profile);
87
+ return this.swap(runtime, profile);
88
+ }
89
+ /** Subscribe to swaps; the returned function unsubscribes. */
90
+ onSwap(listener) {
91
+ this.listeners.push(listener);
92
+ return () => {
93
+ const i = this.listeners.indexOf(listener);
94
+ if (i >= 0)
95
+ this.listeners.splice(i, 1);
96
+ };
97
+ }
98
+ /** Shut down and release the current generation (idempotent). */
99
+ async shutdown() {
100
+ const gen = this.gen;
101
+ this.gen = null;
102
+ if (gen !== null)
103
+ await teardown(gen.runtime);
104
+ }
105
+ hostSessionId() {
106
+ return this.opts.hostSessionId ?? HOST_SESSION_ID;
107
+ }
108
+ }
109
+ /**
110
+ * Move pending host receipts from one generation's mailbox onto the next.
111
+ *
112
+ * `== null` on purpose (W769): a generation composed without worker wiring has no
113
+ * `workers` at all, and the W769 caller runs inside a session rebuild where a
114
+ * missing wiring must be a no-op, never a crash.
115
+ */
116
+ export function migrateReceipts(from, to, hostSessionId) {
117
+ const source = from.workers;
118
+ const target = to.workers;
119
+ if (source == null || target == null)
120
+ return 0;
121
+ const pending = source.mailbox.poll(hostSessionId);
122
+ for (const msg of pending)
123
+ target.mailbox.send(hostSessionId, msg.content, msg.from_label);
124
+ return pending.length;
125
+ }
126
+ async function teardown(runtime) {
127
+ await runtime.shutdown();
128
+ runtime.release();
129
+ }