@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.
- package/gui/dist/assets/{index-CMCDkQ7U.js → index-CZwbOse7.js} +1 -1
- 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/index.ts +1 -0
- package/src/generated/compatibility-version.json +48 -24
- package/src/integrations/config-io.ts +119 -1
- package/src/integrations/omp-yaml-source.ts +6 -1
- package/src/integrations/serialize.ts +80 -1
- package/src/integrations/state.ts +37 -6
- package/src/integrations/writer.ts +11 -3
- 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/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/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 -20
- 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
|
-
|
|
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":
|
|
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.
|
|
138
|
-
*
|
|
139
|
-
*
|
|
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
|
-
|
|
184
|
-
|
|
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",
|
|
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
|
-
|
|
372
|
-
|
|
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
|