@bitkyc08/opencodex 2.15.1 → 2.17.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 (41) hide show
  1. package/gui/dist/assets/{index-CMCDkQ7U.js → index-DOKr6RBR.js} +10 -10
  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/export-command.ts +19 -7
  12. package/src/cli/help.ts +1 -1
  13. package/src/cli/index.ts +1 -0
  14. package/src/cli/registry.ts +2 -2
  15. package/src/clients/config-export.ts +165 -3
  16. package/src/generated/compatibility-version.json +59 -31
  17. package/src/integrations/config-io.ts +119 -1
  18. package/src/integrations/omp-yaml-source.ts +232 -99
  19. package/src/integrations/registry.ts +14 -0
  20. package/src/integrations/serialize.ts +80 -1
  21. package/src/integrations/state.ts +38 -6
  22. package/src/integrations/writer-lock.ts +98 -0
  23. package/src/integrations/writer.ts +152 -19
  24. package/src/lab/automation/orchestrator.ts +19 -0
  25. package/src/lib/lab-activation.ts +161 -0
  26. package/src/lib/lab-passive-linker-registration.ts +26 -0
  27. package/src/lib/optional-shutdown-hooks.ts +57 -0
  28. package/src/lib/shadow-call.ts +6 -14
  29. package/src/lib/translator-budget.ts +34 -0
  30. package/src/providers/antigravity-models.ts +65 -10
  31. package/src/routing/compatibility/assemble.ts +21 -107
  32. package/src/routing/compatibility/lab-evidence-provider.ts +130 -0
  33. package/src/routing/compatibility/provider-slot.ts +56 -0
  34. package/src/server/index.ts +8 -17
  35. package/src/server/lifecycle.ts +5 -3
  36. package/src/server/management/integration-routes.ts +21 -14
  37. package/src/server/management/routing-profile-routes.ts +9 -1
  38. package/src/server/management-api.ts +37 -6
  39. package/src/server/passive-route-linker.ts +66 -0
  40. package/src/server/responses/core.ts +20 -21
  41. package/src/types.ts +15 -5
@@ -0,0 +1,98 @@
1
+ import { rm, writeFile } from "node:fs/promises";
2
+
3
+ const LOCK_DEADLINE_MS = 2_000;
4
+ const LOCK_DELAYS_MS = [20, 40, 80, 160, 200] as const;
5
+
6
+ export interface IntegrationWriterLockSeams {
7
+ writeFile: (
8
+ path: string,
9
+ payload: string,
10
+ options: { flag: "wx"; mode: 0o600 },
11
+ ) => Promise<void>;
12
+ removeFile: (path: string) => Promise<void>;
13
+ now: () => number;
14
+ delay: (milliseconds: number) => Promise<void>;
15
+ pid: number;
16
+ }
17
+
18
+ export class IntegrationWriterLockBusyError extends Error {
19
+ constructor(readonly lockPath: string) {
20
+ super("integration_mutation_busy");
21
+ this.name = "IntegrationWriterLockBusyError";
22
+ }
23
+ }
24
+
25
+ export class IntegrationWriterLockIOError extends Error {
26
+ constructor(readonly lockPath: string, readonly operation: "acquire" | "release", cause: unknown) {
27
+ // The management route returns Error.message to its caller. Keep the
28
+ // private config path and OS diagnostic on typed fields/cause, not the wire.
29
+ super(`integration writer lock ${operation} failed`, { cause });
30
+ this.name = "IntegrationWriterLockIOError";
31
+ }
32
+ }
33
+
34
+ const defaultSeams: IntegrationWriterLockSeams = {
35
+ writeFile: async (path, payload, options) => { await writeFile(path, payload, options); },
36
+ // Match DSH rc.6: an already-absent lock is a successful release.
37
+ removeFile: async path => { await rm(path, { force: true }); },
38
+ now: () => Date.now(),
39
+ delay: milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)),
40
+ pid: process.pid,
41
+ };
42
+
43
+ function errorCode(error: unknown): string | undefined {
44
+ return typeof error === "object" && error !== null && "code" in error
45
+ ? String((error as { code?: unknown }).code)
46
+ : undefined;
47
+ }
48
+
49
+ /**
50
+ * Hold the exact sibling `<settings>.lock` around one complete transaction.
51
+ * A contender is never deleted: release runs only after our exclusive create
52
+ * succeeded.
53
+ */
54
+ export async function withIntegrationWriterLock<T>(
55
+ configPath: string,
56
+ operation: () => Promise<T>,
57
+ seams: IntegrationWriterLockSeams = defaultSeams,
58
+ suffix: ".lock" = ".lock",
59
+ ): Promise<T> {
60
+ const lockPath = `${configPath}${suffix}`;
61
+ const startedAt = seams.now();
62
+ let delayIndex = 0;
63
+ for (;;) {
64
+ try {
65
+ await seams.writeFile(lockPath, `${seams.pid}\n`, { flag: "wx", mode: 0o600 });
66
+ break;
67
+ } catch (error) {
68
+ if (errorCode(error) !== "EEXIST") {
69
+ throw new IntegrationWriterLockIOError(lockPath, "acquire", error);
70
+ }
71
+ const elapsedMs = seams.now() - startedAt;
72
+ if (elapsedMs >= LOCK_DEADLINE_MS) {
73
+ throw new IntegrationWriterLockBusyError(lockPath);
74
+ }
75
+ const backoffMs = LOCK_DELAYS_MS[Math.min(delayIndex, LOCK_DELAYS_MS.length - 1)]!;
76
+ // Keep the final retry inside the advertised two-second deadline.
77
+ const delayMs = Math.min(backoffMs, LOCK_DEADLINE_MS - elapsedMs);
78
+ delayIndex += 1;
79
+ await seams.delay(delayMs);
80
+ }
81
+ }
82
+
83
+ let outcome: { ok: true; value: T } | { ok: false; error: unknown };
84
+ try {
85
+ outcome = { ok: true, value: await operation() };
86
+ } catch (error) {
87
+ outcome = { ok: false, error };
88
+ }
89
+ try {
90
+ await seams.removeFile(lockPath);
91
+ } catch (error) {
92
+ // Cleanup cannot replace the protected operation's actual failure.
93
+ if (!outcome.ok) throw outcome.error;
94
+ throw new IntegrationWriterLockIOError(lockPath, "release", error);
95
+ }
96
+ if (!outcome.ok) throw outcome.error;
97
+ return outcome.value;
98
+ }
@@ -9,6 +9,7 @@
9
9
  *
10
10
  * Design of record: devlog/_fin/260802_client_toggle_api/030 and 031.
11
11
  */
12
+ import { homedir } from "node:os";
12
13
  import { dirname } from "node:path";
13
14
  import { EXPORT_CLIENTS, type ExportModel, type ManagedContribution } from "../clients/config-export";
14
15
  import { isLoopbackHostname } from "../codex/inject";
@@ -23,7 +24,8 @@ import { serializeDocument, UnserializableValueError } from "./serialize";
23
24
  import { ClientPathError } from "../clients/config-export";
24
25
  import { matchesOperationResult, newOpId, type JournalEntry } from "./journal";
25
26
  import { createIntegrationStateStore, type IntegrationStateStore } from "./store";
26
- import { patchOmpYamlSource } from "./omp-yaml-source";
27
+ import { patchYamlFragmentSource, sourcePrunableYamlContainers } from "./omp-yaml-source";
28
+ import { withIntegrationWriterLock, type IntegrationWriterLockSeams } from "./writer-lock";
27
29
 
28
30
  export type RefusalReason =
29
31
  | "not_installed"
@@ -66,6 +68,8 @@ export interface IntegrationWriteInput {
66
68
  home?: string;
67
69
  store?: IntegrationStateStore;
68
70
  io?: IntegrationIO;
71
+ /** Frozen once by the async coordinator; synchronous callers may omit it. */
72
+ resolvedPaths?: { configPath: string; detectDir: string };
69
73
  }
70
74
 
71
75
  export interface IntegrationRestoreInput extends IntegrationWriteInput {
@@ -169,11 +173,13 @@ function snapshotAbsPath(store: IntegrationStateStore, entry: JournalEntry): str
169
173
  return snapshot.kind === "stored" ? snapshot.path : undefined;
170
174
  }
171
175
 
172
- function ompFragmentValue(contribution: ManagedContribution): unknown | undefined {
176
+ function sourcePreservingFragmentValue(
177
+ contribution: ManagedContribution,
178
+ path: readonly string[],
179
+ ): unknown | undefined {
173
180
  const fragment = contribution.fragments.find(item => (
174
- item.path.length === 2
175
- && item.path[0] === "providers"
176
- && item.path[1] === "opencodex"
181
+ item.path.length === path.length
182
+ && item.path.every((key, index) => key === path[index])
177
183
  ));
178
184
  return fragment?.value;
179
185
  }
@@ -194,7 +200,7 @@ function preflight(input: IntegrationWriteInput) {
194
200
  */
195
201
  let configPath: string;
196
202
  try {
197
- configPath = spec.configPath(input.env, input.home);
203
+ configPath = input.resolvedPaths?.configPath ?? spec.configPath(input.env, input.home);
198
204
  } catch (error) {
199
205
  if (!(error instanceof ClientPathError)) throw error;
200
206
  return { failed: refuse(clientId, "unsafe", "unsafe", error.message) } as const;
@@ -213,7 +219,8 @@ function preflight(input: IntegrationWriteInput) {
213
219
  const before = target.before;
214
220
  const parsed = parseConfig(before, exportSpec.format);
215
221
  if (parsed === PARSE_FAILED) {
216
- return { failed: refuse(clientId, "unsafe", "unsafe", `${configPath} could not be parsed`) } as const;
222
+ return { failed: refuse(clientId, "unsafe", "unsafe",
223
+ `${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
224
  }
218
225
  const contribution = exportSpec.buildContribution(exportContextOf(input));
219
226
  // A record proves ownership of the file it was written FOR. Matching only by
@@ -238,7 +245,7 @@ export function applyIntegration(input: IntegrationWriteInput): WriteOutcome {
238
245
  if (pre.failed) return pre.failed;
239
246
  const { store, io, clientId, spec, exportSpec, configPath, before, parsed, contribution, record, classified } = pre;
240
247
 
241
- if (io.statKind(spec.detectDir(input.env, input.home)) !== "dir") {
248
+ if (io.statKind(input.resolvedPaths?.detectDir ?? spec.detectDir(input.env, input.home)) !== "dir") {
242
249
  return refuse(clientId, "not_installed", "absent", `${clientId} is not installed`);
243
250
  }
244
251
  if (isLoopbackOnly(clientId) && !isLoopbackHostname(input.config.hostname)) {
@@ -293,11 +300,16 @@ export function applyIntegration(input: IntegrationWriteInput): WriteOutcome {
293
300
  const nextDocument = mergeContribution(base, contribution);
294
301
  let text: string;
295
302
  try {
296
- if (clientId === "omp" && before !== null) {
297
- const value = ompFragmentValue(contribution);
303
+ if (spec.sourcePreservingYaml && before !== null) {
304
+ const value = sourcePreservingFragmentValue(contribution, spec.sourcePreservingYaml.path);
298
305
  const patched = value === undefined
299
306
  ? null
300
- : patchOmpYamlSource(before, { kind: "upsert", value }, nextDocument);
307
+ : patchYamlFragmentSource(
308
+ before,
309
+ spec.sourcePreservingYaml.path,
310
+ { kind: "upsert", value },
311
+ nextDocument,
312
+ );
301
313
  if (patched === null) {
302
314
  return refuse(clientId, "unsafe", "unsafe",
303
315
  `${configPath} uses YAML source opencodex cannot patch without risking unrelated comments or formatting, so it was left alone`);
@@ -343,7 +355,7 @@ export function applyIntegration(input: IntegrationWriteInput): WriteOutcome {
343
355
  export function disableIntegration(input: IntegrationWriteInput): WriteOutcome {
344
356
  const pre = preflight(input);
345
357
  if (pre.failed) return pre.failed;
346
- const { store, io, clientId, exportSpec, configPath, before, parsed, record, classified } = pre;
358
+ const { store, io, clientId, spec, exportSpec, configPath, before, parsed, record, classified } = pre;
347
359
 
348
360
  if (classified.state === "absent") {
349
361
  return { ok: true, changed: false, state: "absent", clientId, message: "not applied" };
@@ -368,22 +380,40 @@ export function disableIntegration(input: IntegrationWriteInput): WriteOutcome {
368
380
  : `${configPath} cannot be changed safely`);
369
381
  }
370
382
 
371
- // current | stale only: the file fingerprint still matches our record, so the
372
- // recorded paths are exactly what we put there.
383
+ /*
384
+ * current | stale only. What makes the removal safe is the BLOCK
385
+ * fingerprint, not the file fingerprint: the classifier verified the values
386
+ * at the recorded paths are byte-for-byte what we wrote, so removing them
387
+ * cannot take a user edit with them. The file itself may have drifted — a
388
+ * json client classifies a sibling edit as stale (#1631) — which is why the
389
+ * removal runs against the document as parsed NOW, and the re-serialize is
390
+ * value-safe because non-round-tripping numbers were refused at parse time.
391
+ * Source-preserving YAML clients additionally compute which recorded
392
+ * containers are still source-empty before pruning, so a later sibling or
393
+ * comment makes its ancestor user-owned without protecting our leaf.
394
+ */
395
+ const recordedCreated = record!.createdContainers ?? [];
396
+ const prunableCreated = spec.sourcePreservingYaml && before !== null
397
+ ? sourcePrunableYamlContainers(before, spec.sourcePreservingYaml.path, recordedCreated)
398
+ : recordedCreated;
399
+ if (prunableCreated === null) {
400
+ return refuse(clientId, "unsafe", "unsafe",
401
+ `${configPath} uses YAML source opencodex cannot patch without risking unrelated comments or formatting, so nothing was removed`);
402
+ }
373
403
  const { doc, removed } = removeFragments(
374
404
  parsed,
375
405
  record!.fragmentPaths,
376
- new Set(record!.createdContainers ?? []),
406
+ new Set(prunableCreated),
377
407
  );
378
408
  if (!removed) {
379
409
  return { ok: true, changed: false, state: "absent", clientId, message: "nothing to remove" };
380
410
  }
381
411
  let text: string;
382
412
  try {
383
- if (clientId === "omp" && before !== null) {
384
- const patched = patchOmpYamlSource(before, {
413
+ if (spec.sourcePreservingYaml && before !== null) {
414
+ const patched = patchYamlFragmentSource(before, spec.sourcePreservingYaml.path, {
385
415
  kind: "remove",
386
- removeEmptyProviders: record!.createdContainers?.includes("providers") === true,
416
+ createdContainers: prunableCreated,
387
417
  }, doc);
388
418
  if (patched === null) {
389
419
  return refuse(clientId, "unsafe", "unsafe",
@@ -427,7 +457,8 @@ export function restoreIntegration(input: IntegrationRestoreInput): WriteOutcome
427
457
  if (entry.clientId !== input.clientId) throw new Error("restore input names a different client than the operation");
428
458
 
429
459
  const clientId = entry.clientId;
430
- const resolvedPath = INTEGRATION_CLIENTS[clientId].configPath(input.env, input.home);
460
+ const resolvedPath = input.resolvedPaths?.configPath
461
+ ?? INTEGRATION_CLIENTS[clientId].configPath(input.env, input.home);
431
462
  // Restore acts on the path the operation was journaled against. Resolving a
432
463
  // different path here would let an operation recorded for one home delete a
433
464
  // file in another.
@@ -525,3 +556,105 @@ export function restoreIntegration(input: IntegrationRestoreInput): WriteOutcome
525
556
  snapshotPath: snapshotAbsPath(store, restoreEntry),
526
557
  });
527
558
  }
559
+
560
+ export interface CoordinatedIntegrationOptions {
561
+ lockSeams?: IntegrationWriterLockSeams;
562
+ }
563
+
564
+ /** Freeze all mutable resolution seams before the first lock await. */
565
+ type FrozenIntegrationInput = IntegrationWriteInput & {
566
+ store: IntegrationStateStore;
567
+ io: IntegrationIO;
568
+ env: NodeJS.ProcessEnv;
569
+ home: string;
570
+ resolvedPaths: { configPath: string; detectDir: string };
571
+ };
572
+
573
+ function freezeIntegrationInput(input: IntegrationWriteInput): FrozenIntegrationInput {
574
+ const env = { ...(input.env ?? process.env) };
575
+ const home = input.home ?? homedir();
576
+ const store = input.store ?? createIntegrationStateStore();
577
+ const io = input.io ?? defaultIntegrationIO(store);
578
+ const spec = INTEGRATION_CLIENTS[input.clientId];
579
+ const resolvedPaths = {
580
+ configPath: spec.configPath(env, home),
581
+ detectDir: spec.detectDir(env, home),
582
+ };
583
+ return { ...input, env, home, store, io, resolvedPaths };
584
+ }
585
+
586
+ function tryFreezeIntegrationInput(input: IntegrationWriteInput):
587
+ | { ok: true; value: FrozenIntegrationInput }
588
+ | { ok: false; refusal: WriteRefused } {
589
+ try {
590
+ return { ok: true, value: freezeIntegrationInput(input) };
591
+ } catch (error) {
592
+ if (!(error instanceof ClientPathError)) throw error;
593
+ return {
594
+ ok: false,
595
+ refusal: refuse(input.clientId, "unsafe", "unsafe", error.message),
596
+ };
597
+ }
598
+ }
599
+
600
+ async function coordinatedWrite(
601
+ input: IntegrationWriteInput,
602
+ operation: (frozen: IntegrationWriteInput) => WriteOutcome,
603
+ options?: CoordinatedIntegrationOptions,
604
+ ): Promise<WriteOutcome> {
605
+ const prepared = tryFreezeIntegrationInput(input);
606
+ if (!prepared.ok) return prepared.refusal;
607
+ const frozen = prepared.value;
608
+ const spec = INTEGRATION_CLIENTS[frozen.clientId];
609
+ if (!spec.writerLock) return operation(frozen);
610
+
611
+ // An absent client home is not created merely to acquire a sibling lock.
612
+ if (frozen.io.statKind(frozen.resolvedPaths.detectDir) !== "dir") {
613
+ return operation(frozen);
614
+ }
615
+ return withIntegrationWriterLock(
616
+ frozen.resolvedPaths.configPath,
617
+ async () => operation(frozen),
618
+ options?.lockSeams,
619
+ spec.writerLock.suffix,
620
+ );
621
+ }
622
+
623
+ export function applyIntegrationCoordinated(
624
+ input: IntegrationWriteInput,
625
+ options?: CoordinatedIntegrationOptions,
626
+ ): Promise<WriteOutcome> {
627
+ return coordinatedWrite(input, applyIntegration, options);
628
+ }
629
+
630
+ export function disableIntegrationCoordinated(
631
+ input: IntegrationWriteInput,
632
+ options?: CoordinatedIntegrationOptions,
633
+ ): Promise<WriteOutcome> {
634
+ return coordinatedWrite(input, disableIntegration, options);
635
+ }
636
+
637
+ export async function restoreIntegrationCoordinated(
638
+ input: IntegrationRestoreInput,
639
+ options?: CoordinatedIntegrationOptions,
640
+ ): Promise<WriteOutcome> {
641
+ const prepared = tryFreezeIntegrationInput(input);
642
+ if (!prepared.ok) return prepared.refusal;
643
+ const frozen = prepared.value;
644
+ const spec = INTEGRATION_CLIENTS[frozen.clientId];
645
+ if (!spec.writerLock) return restoreIntegration({ ...frozen, opId: input.opId, confirmDrift: input.confirmDrift });
646
+ if (frozen.io.statKind(frozen.resolvedPaths.detectDir) !== "dir") {
647
+ return refuse(
648
+ frozen.clientId,
649
+ "unsafe",
650
+ "unsafe",
651
+ `${frozen.resolvedPaths.detectDir} is missing; restore will not create the client home`,
652
+ );
653
+ }
654
+ return withIntegrationWriterLock(
655
+ frozen.resolvedPaths.configPath,
656
+ async () => restoreIntegration({ ...frozen, opId: input.opId, confirmDrift: input.confirmDrift }),
657
+ options?.lockSeams,
658
+ spec.writerLock.suffix,
659
+ );
660
+ }
@@ -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
+ }