@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.
- package/gui/dist/assets/{index-CMCDkQ7U.js → index-DOKr6RBR.js} +10 -10
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/base.ts +2 -0
- package/src/adapters/google-antigravity-replay.ts +9 -1
- package/src/adapters/kiro-thinking.ts +8 -0
- package/src/adapters/kiro.ts +45 -42
- package/src/adapters/openai-chat.ts +5 -2
- package/src/adapters/openai-responses.ts +5 -1
- package/src/cli/dispatch.ts +6 -3
- package/src/cli/export-command.ts +19 -7
- package/src/cli/help.ts +1 -1
- package/src/cli/index.ts +1 -0
- package/src/cli/registry.ts +2 -2
- package/src/clients/config-export.ts +165 -3
- package/src/generated/compatibility-version.json +59 -31
- package/src/integrations/config-io.ts +119 -1
- package/src/integrations/omp-yaml-source.ts +232 -99
- package/src/integrations/registry.ts +14 -0
- package/src/integrations/serialize.ts +80 -1
- package/src/integrations/state.ts +38 -6
- package/src/integrations/writer-lock.ts +98 -0
- package/src/integrations/writer.ts +152 -19
- package/src/lab/automation/orchestrator.ts +19 -0
- package/src/lib/lab-activation.ts +161 -0
- package/src/lib/lab-passive-linker-registration.ts +26 -0
- package/src/lib/optional-shutdown-hooks.ts +57 -0
- package/src/lib/shadow-call.ts +6 -14
- package/src/lib/translator-budget.ts +34 -0
- package/src/providers/antigravity-models.ts +65 -10
- package/src/routing/compatibility/assemble.ts +21 -107
- package/src/routing/compatibility/lab-evidence-provider.ts +130 -0
- package/src/routing/compatibility/provider-slot.ts +56 -0
- package/src/server/index.ts +8 -17
- package/src/server/lifecycle.ts +5 -3
- package/src/server/management/integration-routes.ts +21 -14
- package/src/server/management/routing-profile-routes.ts +9 -1
- package/src/server/management-api.ts +37 -6
- package/src/server/passive-route-linker.ts +66 -0
- package/src/server/responses/core.ts +20 -21
- 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 {
|
|
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
|
|
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 ===
|
|
175
|
-
&& item.path
|
|
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",
|
|
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 (
|
|
297
|
-
const value =
|
|
303
|
+
if (spec.sourcePreservingYaml && before !== null) {
|
|
304
|
+
const value = sourcePreservingFragmentValue(contribution, spec.sourcePreservingYaml.path);
|
|
298
305
|
const patched = value === undefined
|
|
299
306
|
? null
|
|
300
|
-
:
|
|
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
|
-
|
|
372
|
-
|
|
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(
|
|
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 (
|
|
384
|
-
const patched =
|
|
413
|
+
if (spec.sourcePreservingYaml && before !== null) {
|
|
414
|
+
const patched = patchYamlFragmentSource(before, spec.sourcePreservingYaml.path, {
|
|
385
415
|
kind: "remove",
|
|
386
|
-
|
|
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 =
|
|
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
|
+
}
|