@bitkyc08/opencodex 2.15.1 → 2.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/gui/dist/assets/{index-CMCDkQ7U.js → index-CZwbOse7.js} +1 -1
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +1 -1
  4. package/src/adapters/base.ts +2 -0
  5. package/src/adapters/google-antigravity-replay.ts +9 -1
  6. package/src/adapters/kiro-thinking.ts +8 -0
  7. package/src/adapters/kiro.ts +45 -42
  8. package/src/adapters/openai-chat.ts +5 -2
  9. package/src/adapters/openai-responses.ts +5 -1
  10. package/src/cli/dispatch.ts +6 -3
  11. package/src/cli/index.ts +1 -0
  12. package/src/generated/compatibility-version.json +48 -24
  13. package/src/integrations/config-io.ts +119 -1
  14. package/src/integrations/omp-yaml-source.ts +6 -1
  15. package/src/integrations/serialize.ts +80 -1
  16. package/src/integrations/state.ts +37 -6
  17. package/src/integrations/writer.ts +11 -3
  18. package/src/lab/automation/orchestrator.ts +19 -0
  19. package/src/lib/lab-activation.ts +161 -0
  20. package/src/lib/lab-passive-linker-registration.ts +26 -0
  21. package/src/lib/optional-shutdown-hooks.ts +57 -0
  22. package/src/lib/translator-budget.ts +34 -0
  23. package/src/providers/antigravity-models.ts +65 -10
  24. package/src/routing/compatibility/assemble.ts +21 -107
  25. package/src/routing/compatibility/lab-evidence-provider.ts +130 -0
  26. package/src/routing/compatibility/provider-slot.ts +56 -0
  27. package/src/server/index.ts +8 -17
  28. package/src/server/lifecycle.ts +5 -3
  29. package/src/server/management/routing-profile-routes.ts +9 -1
  30. package/src/server/management-api.ts +37 -6
  31. package/src/server/passive-route-linker.ts +66 -0
  32. package/src/server/responses/core.ts +20 -20
  33. package/src/types.ts +10 -0
@@ -200,7 +200,12 @@ export function patchOmpYamlSource(
200
200
 
201
201
  let patched = `${text.slice(0, startOffset)}${text.slice(endOffset)}`;
202
202
  if (mutation.removeEmptyProviders) {
203
- const remaining = Bun.YAML.parse(patched) as { providers?: unknown } | null;
203
+ let remaining: { providers?: unknown } | null;
204
+ try {
205
+ remaining = Bun.YAML.parse(patched) as { providers?: unknown } | null;
206
+ } catch {
207
+ return null;
208
+ }
204
209
  if (remaining && Object.hasOwn(remaining, "providers")) {
205
210
  const provider = remaining.providers;
206
211
  const empty = provider === null || (
@@ -221,10 +221,89 @@ export function renderToml(document: Record<string, unknown>, prefix = ""): stri
221
221
  return `${[scalars.join("\n"), tables.join("\n\n")].filter(Boolean).join("\n\n")}\n`;
222
222
  }
223
223
 
224
+ /**
225
+ * Ceiling on container nesting for json documents, shared by the parse-time
226
+ * scanner (config-io.ts) and the serializer walk below. One constant on
227
+ * purpose: the walk must accept every document the scanner admits, or a file
228
+ * the classifier reported as recoverable would refuse at rewrite time. Real
229
+ * configs nest a handful of levels.
230
+ */
231
+ export const MAX_JSON_NESTING = 1000;
232
+
233
+ /** Error messages carry the path to the offending value; keep them readable. */
234
+ function clampPath(path: string): string {
235
+ return path.length > 200 ? `${path.slice(0, 100)}…${path.slice(-100)}` : path;
236
+ }
237
+
238
+ /**
239
+ * JSON.stringify writes a non-finite number as `null` and -0 as `0`; any
240
+ * other finite double round-trips value-exactly (literal-level rounding is
241
+ * the parse-time scanner's concern), so those two are exactly what this walk
242
+ * refuses — refusing more turned a state the classifier had promised as
243
+ * recoverable into a permanent refusal. Documents read from disk are already
244
+ * guarded at parse time, and the writer's merge layer JSON-clones documents —
245
+ * normalizing these values — before serializing, so on the apply/disable path
246
+ * this walk is unreachable for them: it guards the direct serializers
247
+ * (preview/export builders), same posture as the YAML and TOML renderers
248
+ * above, and enforces the nesting ceiling for every json caller before the
249
+ * recursive JSON.stringify can turn depth into a RangeError.
250
+ *
251
+ * Iterative frames instead of recursion or a node stack: depth AND size of
252
+ * the document are inputs under the writer of the config file. Recursion made
253
+ * a deep file a RangeError-500; materializing every node with its path made a
254
+ * wide file allocate a large multiple of its size. Frames keep memory
255
+ * proportional to nesting depth, and path strings exist only for the
256
+ * containers on the current path plus the failing value itself.
257
+ */
258
+ function assertJsonNumbersRoundTrip(document: unknown, rootPath: string): void {
259
+ const refuse = (value: number, path: string): never => {
260
+ throw new UnserializableValueError(Object.is(value, -0)
261
+ ? `JSON cannot rewrite -0 at ${clampPath(path)} without changing it to 0`
262
+ : `JSON cannot rewrite the number at ${clampPath(path)} without changing it to null`);
263
+ };
264
+ if (typeof document === "number" && (!Number.isFinite(document) || Object.is(document, -0))) {
265
+ refuse(document, rootPath);
266
+ }
267
+ type Frame = { container: unknown; keys: string[] | null; index: number; prefix: string };
268
+ const frames: Frame[] = [];
269
+ const pushContainer = (value: unknown, prefix: string) => {
270
+ if (Array.isArray(value)) frames.push({ container: value, keys: null, index: 0, prefix });
271
+ else if (isPlainRecord(value)) frames.push({ container: value, keys: Object.keys(value), index: 0, prefix });
272
+ };
273
+ pushContainer(document, rootPath);
274
+ while (frames.length > 0) {
275
+ const frame = frames[frames.length - 1]!;
276
+ const length = frame.keys ? frame.keys.length : (frame.container as unknown[]).length;
277
+ if (frame.index >= length) { frames.pop(); continue; }
278
+ const i = frame.index;
279
+ frame.index += 1;
280
+ const child = frame.keys
281
+ ? (frame.container as Record<string, unknown>)[frame.keys[i]!]
282
+ : (frame.container as unknown[])[i];
283
+ const childPath = () => frame.keys
284
+ ? (frame.prefix === "$" ? frame.keys[i]! : `${frame.prefix}.${frame.keys[i]!}`)
285
+ : `${frame.prefix}[${i}]`;
286
+ if (typeof child === "number") {
287
+ if (!Number.isFinite(child) || Object.is(child, -0)) refuse(child, childPath());
288
+ continue;
289
+ }
290
+ if (typeof child === "object" && child !== null) {
291
+ if (frames.length >= MAX_JSON_NESTING) {
292
+ throw new UnserializableValueError(
293
+ `the document nests deeper than ${MAX_JSON_NESTING} levels at ${clampPath(childPath())}, which JSON serialization cannot rewrite safely`);
294
+ }
295
+ pushContainer(child, childPath());
296
+ }
297
+ }
298
+ }
299
+
224
300
  /** Every serializer returns text ending in exactly one newline. */
225
301
  export function serializeDocument(document: unknown, format: ConfigFormat): string {
226
302
  switch (format) {
227
- case "json": return `${JSON.stringify(document, null, 2)}\n`;
303
+ case "json": {
304
+ assertJsonNumbersRoundTrip(document, "$");
305
+ return `${JSON.stringify(document, null, 2)}\n`;
306
+ }
228
307
  case "json5": return `${Bun.JSON5.stringify(document, null, 2)}\n`;
229
308
  case "yaml": return renderYaml(document);
230
309
  case "toml": {
@@ -134,9 +134,15 @@ function recordedFragmentFingerprint(
134
134
  /**
135
135
  * The two-axis rule: the recorded bytes or fragments prove nobody changed
136
136
  * what we may rewrite, and the contribution hash proves our catalog has not
137
- * moved on. OMP is the sole fragment-scoped client because its writer patches
138
- * only `providers.opencodex`; every whole-document serializer retains the
139
- * whole-file fingerprint guard.
137
+ * moved on. Three classes of client (revising the unconditional whole-file
138
+ * rule of devlog 260802_client_toggle_api/021 §3 for json — #1631):
139
+ * OMP is fragment-scoped because its writer patches only
140
+ * `providers.opencodex`, so the whole-file check is skipped entirely;
141
+ * strict-json clients keep the whole-file check but downgrade a drift with
142
+ * intact owned fragments to `stale`, because a rewrite there can lose only
143
+ * formatting (comments cannot parse, non-round-tripping numbers are refused
144
+ * by the serializer); every comment-capable whole-document serializer (yaml,
145
+ * json5, toml) retains the whole-file fingerprint guard as a hard conflict.
140
146
  */
141
147
  export function classifyIntegration(input: {
142
148
  fileText: string | null;
@@ -180,12 +186,37 @@ export function classifyIntegration(input: {
180
186
  return { state: "conflict", reason: "unowned-key" };
181
187
  }
182
188
  const clientId = input.clientId ?? input.record.clientId;
183
- if (clientId !== "omp" && fingerprint(input.fileText ?? "") !== input.record.fileFingerprint) {
184
- return { state: "conflict", reason: "foreign-edit" };
185
- }
189
+ /*
190
+ * Checked BEFORE file-level drift: an edit INSIDE an owned fragment is a
191
+ * conflict no matter what the rest of the file looks like, so the sibling-
192
+ * edit exemption below can never mask it.
193
+ */
186
194
  if (recordedFragmentFingerprint(input.parsed, input.record) !== input.record.blockFingerprint) {
187
195
  return { state: "conflict", reason: "foreign-edit" };
188
196
  }
197
+ if (clientId !== "omp" && fingerprint(input.fileText ?? "") !== input.record.fileFingerprint) {
198
+ /*
199
+ * The file changed since we wrote it, but every fragment we own is still
200
+ * byte-for-byte what we put there — a sibling edit, not tampering. Apply
201
+ * rewrites the WHOLE document, so for comment-capable formats (yaml,
202
+ * json5, toml) it would drop comments the user wrote next to us: fail
203
+ * closed there. Strict JSON cannot carry comments — a commented file
204
+ * never reaches this branch because parsing already failed — so the only
205
+ * possible loss is formatting normalization: everything a rewrite would
206
+ * actually change (numbers that would not round-trip, duplicate members
207
+ * a rewrite would delete) is PARSE_FAILED in parseConfig and classifies
208
+ * as unsafe long before this branch, exactly like comments. Refusing
209
+ * forever over formatting
210
+ * dead-ends the integration on the user's first own config edit (#1631).
211
+ * Report drift instead; a re-apply merges into the parsed document as it
212
+ * stands and re-owns the file. This also lets disable proceed on a
213
+ * drifted file — removal still touches only the recorded fragment paths.
214
+ */
215
+ if (EXPORT_CLIENTS[clientId].format !== "json") {
216
+ return { state: "conflict", reason: "foreign-edit" };
217
+ }
218
+ return { state: "stale" };
219
+ }
189
220
  return input.record.blockFingerprint === fingerprint(canonicalContribution(input.contribution))
190
221
  ? { state: "current" }
191
222
  : { state: "stale" };
@@ -213,7 +213,8 @@ function preflight(input: IntegrationWriteInput) {
213
213
  const before = target.before;
214
214
  const parsed = parseConfig(before, exportSpec.format);
215
215
  if (parsed === PARSE_FAILED) {
216
- return { failed: refuse(clientId, "unsafe", "unsafe", `${configPath} could not be parsed`) } as const;
216
+ return { failed: refuse(clientId, "unsafe", "unsafe",
217
+ `${configPath} could not be parsed, or holds something opencodex cannot rewrite without changing it (a non-finite number, a large integer or a tiny one a rewrite would round, -0, a duplicate member, or nesting deeper than 1000 levels)`) } as const;
217
218
  }
218
219
  const contribution = exportSpec.buildContribution(exportContextOf(input));
219
220
  // A record proves ownership of the file it was written FOR. Matching only by
@@ -368,8 +369,15 @@ export function disableIntegration(input: IntegrationWriteInput): WriteOutcome {
368
369
  : `${configPath} cannot be changed safely`);
369
370
  }
370
371
 
371
- // current | stale only: the file fingerprint still matches our record, so the
372
- // recorded paths are exactly what we put there.
372
+ /*
373
+ * current | stale only. What makes the removal safe is the BLOCK
374
+ * fingerprint, not the file fingerprint: the classifier verified the values
375
+ * at the recorded paths are byte-for-byte what we wrote, so removing them
376
+ * cannot take a user edit with them. The file itself may have drifted — a
377
+ * json client classifies a sibling edit as stale (#1631) — which is why the
378
+ * removal runs against the document as parsed NOW, and the re-serialize is
379
+ * value-safe because non-round-tripping numbers were refused at parse time.
380
+ */
373
381
  const { doc, removed } = removeFragments(
374
382
  parsed,
375
383
  record!.fragmentPaths,
@@ -1,5 +1,6 @@
1
1
  import { readConfigDiagnostics } from "../../config";
2
2
  import { registerCurrentServerResourceCleanup } from "../../lib/server-resource-ownership";
3
+ import { registerOptionalShutdownHook } from "../../lib/optional-shutdown-hooks";
3
4
  import { queryLabStatus } from "../query";
4
5
  import { rebuildLabProjection } from "../projection/rebuild";
5
6
  import { planLabAutomationRuns } from "./planner";
@@ -90,10 +91,12 @@ export function setLabAutomationDispatchDeps(deps: AutomationDispatchDeps): () =
90
91
 
91
92
  let released = false;
92
93
  let detachServerCleanup = () => {};
94
+ let detachShutdownHook = () => {};
93
95
  const release = () => {
94
96
  if (released) return;
95
97
  released = true;
96
98
  detachServerCleanup();
99
+ detachShutdownHook();
97
100
  const current = dispatchDepsByConfigDir.get(key);
98
101
  if (current?.token !== token) return;
99
102
  dispatchDepsByConfigDir.delete(key);
@@ -104,6 +107,13 @@ export function setLabAutomationDispatchDeps(deps: AutomationDispatchDeps): () =
104
107
  }
105
108
  };
106
109
  detachServerCleanup = registerCurrentServerResourceCleanup(release);
110
+ // Shutdown teardown is registered here, at activation, so `server/lifecycle.ts` never has
111
+ // to import Lab in order to stop it. Scoped to this configDir, unlike the previous
112
+ // unscoped call from the shutdown path.
113
+ detachShutdownHook = registerOptionalShutdownHook(`lab-automation:${key}`, () => {
114
+ requestLabAutomationShutdown();
115
+ stopLabAutomationScheduler(deps.configDir);
116
+ });
107
117
  return release;
108
118
  }
109
119
 
@@ -399,6 +409,15 @@ export function startLabAutomationScheduler(configDir?: string): void {
399
409
  if (currentOwner) existing.ownerToken = currentOwner;
400
410
  return;
401
411
  }
412
+ // The scheduler owns a live interval, so its teardown must be registered here rather
413
+ // than only in setLabAutomationDispatchDeps: the management API and the CLI can start a
414
+ // scheduler without ever installing dispatch deps (lab-automation-routes.ts
415
+ // applySchedulerPolicy, cli/lab.ts), and core no longer imports this module to stop it.
416
+ // Without this registration such a scheduler survives drainAndShutdown.
417
+ registerOptionalShutdownHook(`lab-automation-scheduler:${key}`, () => {
418
+ requestLabAutomationShutdown();
419
+ stopLabAutomationScheduler(configDir);
420
+ });
402
421
  shutdownRequested = false;
403
422
  const { policy, routes } = loadLabAutomationConfig(configDir);
404
423
  const now = Date.now();
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Compatibility Lab activation.
3
+ *
4
+ * The proxy core does not import Lab. This module is the seam that does, and the startup
5
+ * composition root calls it only when the install actually uses Lab, so a user with no
6
+ * routing profile and no automation executes no Lab code and starts no Lab timer.
7
+ *
8
+ * Activation is SYNCHRONOUS by design. Three audit rounds established that a deferred
9
+ * activation window is unpatchable: `routeModelInternal` is sync, and so is the
10
+ * subagent-fallback chain that calls `routeModel`, so those callers have nowhere to await.
11
+ * During such a window a policy alias would be silently dropped from a fallback chain and
12
+ * the subagent would run on a different model than the operator configured. Registration
13
+ * therefore completes before `startServer` returns, inside the same synchronous turn as
14
+ * `Bun.serve`, so no request can observe an unregistered slot.
15
+ *
16
+ * See devlog/_plan/260814_lab_core_decoupling/080_activation_is_synchronous.md
17
+ *
18
+ * Startup degrades, explicit operator action reports. This asymmetry is deliberate: an
19
+ * invalid automation config disables automation with a warning here, but the management
20
+ * API and CLI paths that start a scheduler leave `LabAutomationError` to surface (the
21
+ * management route maps it to a 400). Someone who just toggled automation should see the
22
+ * validation error; someone merely starting the proxy should not lose unrelated traffic.
23
+ *
24
+ * @internal host integration only
25
+ */
26
+ import { existsSync, readFileSync } from "node:fs";
27
+ import { dirname, join } from "node:path";
28
+ import { labAutomationPolicyPath } from "../lab/paths";
29
+ import type { OcxConfig } from "../types";
30
+ import { LabAutomationError } from "../lab/automation/types";
31
+ import { registerLabPassiveRouteLinker } from "./lab-passive-linker-registration";
32
+ import { setCompatibilityEvidenceProvider } from "../routing/compatibility/provider-slot";
33
+ import { labCompatibilityEvidenceProvider } from "../routing/compatibility/lab-evidence-provider";
34
+ import {
35
+ setLabAutomationDispatchDeps,
36
+ startLabAutomationScheduler,
37
+ } from "../lab/automation/orchestrator";
38
+ import { createProductionLabRouteExecutor } from "./lab-live-route-production";
39
+
40
+ /** Activation records keyed by configDir, so one process can own several configs. */
41
+ const activated = new Map<string, Array<() => void>>();
42
+
43
+ const activationKey = (configDir?: string): string => configDir ?? "";
44
+
45
+ function readJsonIfPresent(path: string): unknown {
46
+ try {
47
+ if (!existsSync(path)) return null;
48
+ return JSON.parse(readFileSync(path, "utf8")) as unknown;
49
+ } catch {
50
+ // A malformed or unreadable file means "not enabled": this detector must never throw
51
+ // during startup, and must never import Lab persistence to answer the question.
52
+ return null;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * True when Lab automation is enabled on disk.
58
+ *
59
+ * Mirrors `loadLabAutomationConfig` precedence deliberately: the current authority is the
60
+ * combined `automation-config.json`, with `automation-policy.json` as the legacy fallback.
61
+ * Reading only the legacy file would miss every install that enabled automation through
62
+ * the current dashboard.
63
+ */
64
+ export function labAutomationEnabledOnDisk(configDir?: string): boolean {
65
+ const legacyPath = labAutomationPolicyPath(configDir);
66
+ const combined = readJsonIfPresent(join(dirname(legacyPath), "automation-config.json"));
67
+ if (combined && typeof combined === "object") {
68
+ const policy = (combined as { policy?: unknown }).policy;
69
+ if (policy && typeof policy === "object") {
70
+ return (policy as { enabled?: unknown }).enabled === true;
71
+ }
72
+ }
73
+ const legacy = readJsonIfPresent(legacyPath);
74
+ if (legacy && typeof legacy === "object") {
75
+ return (legacy as { enabled?: unknown }).enabled === true;
76
+ }
77
+ return false;
78
+ }
79
+
80
+ /** True when this install actually uses Lab: any routing profile, or automation enabled. */
81
+ export function labActivationRequired(config: OcxConfig, configDir?: string): boolean {
82
+ if (Object.keys(config.routingProfiles ?? {}).length > 0) return true;
83
+ return labAutomationEnabledOnDisk(configDir);
84
+ }
85
+
86
+ /**
87
+ * Register Lab into the core slots. Idempotent per configDir and safe to call again after
88
+ * a routing profile is created at runtime.
89
+ */
90
+ export function activateLab(config: OcxConfig, configDir?: string): void {
91
+ const key = activationKey(configDir);
92
+ // INVARIANT: activation is all-or-nothing and reason-independent. Every slot is
93
+ // registered here regardless of WHY activation was required, which is what makes this
94
+ // key safe as configDir alone -- an automation-only activation still installs the
95
+ // compatibility provider a later profile needs. If any registration ever becomes
96
+ // conditional on the activation reason, this key must include that reason, or the early
97
+ // return will silently skip it forever.
98
+ if (activated.has(key)) return;
99
+
100
+ const detach: Array<() => void> = [];
101
+ detach.push(registerLabPassiveRouteLinker(configDir));
102
+ detach.push(setCompatibilityEvidenceProvider(labCompatibilityEvidenceProvider));
103
+
104
+ const routeExecutor = createProductionLabRouteExecutor({ configDir, loadConfig: () => config });
105
+ detach.push(setLabAutomationDispatchDeps({ configDir, loadConfig: () => config, routeExecutor }));
106
+
107
+ // Record the activation BEFORE the scheduler start. startLabAutomationScheduler runs the
108
+ // full automation normalizer, which throws on any field violation, and this call sits on
109
+ // the startup path of every install that has a routing profile. Storing the record first
110
+ // means a throw cannot orphan the detach receipts and leave slots registered with no
111
+ // activation record -- which would let a later activateLab register them a second time.
112
+ activated.set(key, detach);
113
+
114
+ if (labAutomationEnabledOnDisk(configDir)) {
115
+ try {
116
+ startLabAutomationScheduler(configDir);
117
+ } catch (err) {
118
+ // Neither a malformed automation file nor a busy state lock may take the proxy down
119
+ // at startup. Lab automation stays off for this run; routing, evidence, and every
120
+ // other subsystem keep working.
121
+ //
122
+ // The two causes get different messages because they need different actions, and a
123
+ // lock-contention failure reported as "invalid config" sends the operator to fix a
124
+ // file that is fine. Contention can also stall startup by up to the 5s lock wait.
125
+ const code = err instanceof LabAutomationError ? err.code : null;
126
+ if (code === "state_lock_busy" || code === "state_lock_failed") {
127
+ console.warn(
128
+ "[lab] Lab automation did not start: another process holds the automation state lock."
129
+ + " Automation stays off for this run and will be retried on the next start.",
130
+ );
131
+ } else {
132
+ console.warn(
133
+ "[lab] Lab automation is disabled for this run because its configuration could not be"
134
+ + " loaded:",
135
+ err instanceof Error ? err.message : err,
136
+ );
137
+ }
138
+ }
139
+ }
140
+ }
141
+
142
+ /** True when this configDir has been activated. */
143
+ export function isLabActivated(configDir?: string): boolean {
144
+ return activated.has(activationKey(configDir));
145
+ }
146
+
147
+ /**
148
+ * Test-only teardown. Deactivation is deliberately NOT a production path: tearing an
149
+ * activation down mid-run would finalize in-flight automation as ineligible rather than
150
+ * cancelled, and the orchestrator's shutdown signal is process-global. An install that
151
+ * creates then deletes a profile keeps Lab resident until restart, which does not affect
152
+ * users who never opted in.
153
+ */
154
+ export function resetLabActivationForTests(): void {
155
+ for (const [key, detach] of [...activated]) {
156
+ activated.delete(key);
157
+ for (const release of [...detach].reverse()) {
158
+ try { release(); } catch { /* teardown is best-effort */ }
159
+ }
160
+ }
161
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Registers Compatibility Lab's passive route-subject linker into the core slot.
3
+ *
4
+ * Imported only from the Lab activation path, never from the request path. This module
5
+ * lives outside `src/lab/` for the same reason the other `src/lib/lab-*.ts` host
6
+ * integrations do: it is the seam between core and the subsystem, not the subsystem.
7
+ *
8
+ * @internal host integration only
9
+ */
10
+ import { setPassiveRouteLinker } from "../server/passive-route-linker";
11
+ import { resolveProductionRouteSubject } from "../routing/compatibility/subject";
12
+
13
+ /** Install the Lab linker. Returns a detach function. */
14
+ export function registerLabPassiveRouteLinker(configDir?: string): () => void {
15
+ return setPassiveRouteLinker((config, providerName, modelId, routed, inboundWire) => {
16
+ const subject = resolveProductionRouteSubject(
17
+ config,
18
+ providerName,
19
+ modelId,
20
+ routed,
21
+ inboundWire,
22
+ configDir,
23
+ );
24
+ return subject ? subject.subjectId : null;
25
+ });
26
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Core-owned registry for optional-subsystem shutdown work.
3
+ *
4
+ * The proxy core must not import an optional subsystem merely to be able to stop it.
5
+ * Compatibility Lab is the first case: `server/lifecycle.ts` imported
6
+ * `lab/automation/orchestrator` for two teardown calls, and that single edge closed an
7
+ * import cycle (`routing/compatibility/assemble` → `routing/quota` → `providers/quota` →
8
+ * `codex/auth-api` → `codex/native-main-admission` → `server/lifecycle` → Lab) that pulled
9
+ * ~69 `src/lab/` modules into the graph of every install, including installs with no
10
+ * routing profile at all.
11
+ *
12
+ * A subsystem registers its teardown when it activates. A process that never activates it
13
+ * registers nothing, so shutdown does no work and loads no module.
14
+ *
15
+ * Hooks are synchronous and best-effort by contract: `drainAndShutdown` runs under an
16
+ * absolute deadline, so a hook that throws must not prevent its siblings — or
17
+ * `server.stop` — from running.
18
+ *
19
+ * See devlog/_plan/260814_lab_core_decoupling/010_lifecycle_shutdown_registry.md
20
+ */
21
+
22
+ type ShutdownHook = () => void;
23
+
24
+ const hooks = new Map<string, ShutdownHook>();
25
+
26
+ /**
27
+ * Register (or replace) the teardown for one optional subsystem.
28
+ *
29
+ * Keyed so repeated activation of the same subsystem cannot accumulate duplicate hooks.
30
+ * Returns a detach function so an owner-scoped lease can release its registration.
31
+ */
32
+ export function registerOptionalShutdownHook(key: string, hook: ShutdownHook): () => void {
33
+ hooks.set(key, hook);
34
+ return () => {
35
+ // Only detach our own registration: a later activation may have replaced it.
36
+ if (hooks.get(key) === hook) hooks.delete(key);
37
+ };
38
+ }
39
+
40
+ /** Run every registered teardown. Never throws. */
41
+ export function runOptionalShutdownHooks(): void {
42
+ for (const [key, hook] of [...hooks]) {
43
+ try {
44
+ hook();
45
+ } catch (err) {
46
+ console.warn(
47
+ `[shutdown] optional subsystem "${key}" teardown failed:`,
48
+ err instanceof Error ? err.message : err,
49
+ );
50
+ }
51
+ }
52
+ }
53
+
54
+ /** Test-only reset so an isolated lifecycle test does not inherit registrations. */
55
+ export function resetOptionalShutdownHooksForTests(): void {
56
+ hooks.clear();
57
+ }
@@ -71,6 +71,40 @@ export interface TranslatorBudget {
71
71
 
72
72
  const retainedEventOwnership = new WeakMap<object, { budget: TranslatorBudget; bytes: number }>();
73
73
 
74
+ /**
75
+ * Charge one event appended to an incrementally materialized adapter-event batch.
76
+ * The newest event owns the closing array bracket; moving that byte from the old
77
+ * tail keeps in-order release accounting equal to the still-retained JSON array.
78
+ */
79
+ export function retainTranslatedEvent<T extends object>(
80
+ event: T,
81
+ budget: TranslatorBudget,
82
+ previousTail?: object,
83
+ ): void {
84
+ if (retainedEventOwnership.has(event)) {
85
+ throw new Error("translated event is already retained");
86
+ }
87
+ if (previousTail === event) {
88
+ throw new Error("incremental translated event tail must be a distinct object");
89
+ }
90
+ const previousOwnership = previousTail === undefined
91
+ ? undefined
92
+ : retainedEventOwnership.get(previousTail);
93
+ if (
94
+ previousTail !== undefined
95
+ && (!previousOwnership || previousOwnership.budget !== budget || previousOwnership.bytes < 2)
96
+ ) {
97
+ throw new Error("incremental translated event tail is not retained by this budget");
98
+ }
99
+
100
+ const serializedBytes = Buffer.byteLength(JSON.stringify(event));
101
+ budget.chargeRetained(serializedBytes + (previousTail === undefined ? 2 : 1), {
102
+ kind: "retained_collectors",
103
+ });
104
+ if (previousOwnership) previousOwnership.bytes -= 1;
105
+ retainedEventOwnership.set(event, { budget, bytes: serializedBytes + 2 });
106
+ }
107
+
74
108
  /**
75
109
  * Charge a materialized adapter-event batch and attach its lease to the events themselves.
76
110
  * A copied event array (for example terminal-guard collection) preserves the event objects, so