@bitkyc08/opencodex 2.12.0 → 2.13.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 (173) hide show
  1. package/README.md +1 -1
  2. package/gui/dist/assets/index-BHldBl6_.js +76 -0
  3. package/gui/dist/index.html +1 -1
  4. package/package.json +1 -1
  5. package/src/adapters/anthropic-output-schema.ts +137 -0
  6. package/src/adapters/anthropic.ts +15 -0
  7. package/src/adapters/google-antigravity-replay.ts +269 -5
  8. package/src/adapters/openai-chat.ts +25 -18
  9. package/src/adapters/openai-responses.ts +63 -17
  10. package/src/bridge.ts +11 -5
  11. package/src/claude/inbound.ts +24 -3
  12. package/src/cli/account-api.ts +2 -0
  13. package/src/cli/account-extended.ts +262 -1
  14. package/src/cli/account.ts +3 -1
  15. package/src/cli/dispatch.ts +532 -0
  16. package/src/cli/doctor.ts +26 -21
  17. package/src/cli/help.ts +9 -278
  18. package/src/cli/index.ts +45 -506
  19. package/src/cli/lab.ts +171 -65
  20. package/src/cli/registry.ts +384 -0
  21. package/src/cli/root.ts +86 -0
  22. package/src/cli/status.ts +9 -2
  23. package/src/cli/v2.ts +37 -2
  24. package/src/codex/account-label.ts +14 -1
  25. package/src/codex/account-store.ts +12 -1
  26. package/src/codex/admission.ts +2 -1
  27. package/src/codex/auth-api.ts +3 -2
  28. package/src/codex/catalog/metadata.ts +6 -4
  29. package/src/codex/catalog/parsing.ts +31 -7
  30. package/src/codex/catalog/provider-fetch.ts +2 -1
  31. package/src/codex/catalog/sync.ts +65 -11
  32. package/src/codex/features.ts +437 -31
  33. package/src/codex/history-job.ts +6 -11
  34. package/src/codex/history-provider.ts +10 -9
  35. package/src/codex/inject.ts +14 -1
  36. package/src/codex/native-profile-startup.ts +49 -3
  37. package/src/codex/native-residue.ts +14 -2
  38. package/src/codex/paths.ts +75 -1
  39. package/src/codex/reset-credit-recovery.ts +1044 -0
  40. package/src/codex/shim.ts +876 -16
  41. package/src/codex/sync.ts +27 -2
  42. package/src/config.ts +148 -10
  43. package/src/generated/compatibility-version.json +2572 -0
  44. package/src/images/loop.ts +1 -1
  45. package/src/lab/artifacts/sanitize.ts +3 -3
  46. package/src/lab/automation/budgets.ts +78 -0
  47. package/src/lab/automation/config-persistence.ts +256 -0
  48. package/src/lab/automation/constants.ts +39 -0
  49. package/src/lab/automation/cooldown.ts +103 -0
  50. package/src/lab/automation/dispatch.ts +211 -0
  51. package/src/lab/automation/index.ts +13 -0
  52. package/src/lab/automation/orchestrator.ts +480 -0
  53. package/src/lab/automation/persistence.ts +512 -0
  54. package/src/lab/automation/planner.ts +371 -0
  55. package/src/lab/automation/policy.ts +136 -0
  56. package/src/lab/automation/queue.ts +191 -0
  57. package/src/lab/automation/recovery.ts +24 -0
  58. package/src/lab/automation/route-context.ts +21 -0
  59. package/src/lab/automation/run-key.ts +44 -0
  60. package/src/lab/automation/runs-query.ts +34 -0
  61. package/src/lab/automation/types.ts +160 -0
  62. package/src/lab/conformance/types.ts +23 -0
  63. package/src/lab/events/types.ts +2 -1
  64. package/src/lab/events/validate.ts +2 -1
  65. package/src/lab/fabric/constants.ts +40 -0
  66. package/src/lab/fabric/executor.ts +492 -0
  67. package/src/lab/fabric/index.ts +80 -0
  68. package/src/lab/fabric/manifest.ts +222 -0
  69. package/src/lab/fabric/observe.ts +454 -0
  70. package/src/lab/fabric/patch.ts +79 -0
  71. package/src/lab/fabric/producer-child.ts +139 -0
  72. package/src/lab/fabric/producer-isolate.ts +276 -0
  73. package/src/lab/fabric/producer-protocol.ts +61 -0
  74. package/src/lab/fabric/scratch.ts +439 -0
  75. package/src/lab/fabric/subject.ts +106 -0
  76. package/src/lab/fabric/types.ts +134 -0
  77. package/src/lab/fabric/verifier.ts +98 -0
  78. package/src/lab/index.ts +2 -0
  79. package/src/lab/ledger/store.ts +173 -0
  80. package/src/lab/live/executor.ts +29 -4
  81. package/src/lab/live/transport.ts +3 -1
  82. package/src/lab/observe/from-conformance.ts +12 -57
  83. package/src/lab/observe/from-live.ts +9 -1
  84. package/src/lab/paths.ts +74 -9
  85. package/src/lab/projection/schema.ts +14 -1
  86. package/src/lab/projection/verdicts.ts +89 -53
  87. package/src/lab/projection/verification.ts +83 -3
  88. package/src/lab/query/catalog.ts +34 -5
  89. package/src/lab/query/freshness.ts +53 -0
  90. package/src/lab/query/index.ts +11 -0
  91. package/src/lab/query/latest-observation.ts +59 -0
  92. package/src/lab/query/passive-production.ts +159 -0
  93. package/src/lab/subject/installation-salt.ts +24 -4
  94. package/src/lab/subject/protocol-subject.ts +80 -0
  95. package/src/lab/subject/route-subject.ts +2 -1
  96. package/src/lib/fabric-task-execution-authority.ts +7 -0
  97. package/src/lib/fabric-task-host.ts +29 -0
  98. package/src/lib/lab-live-route-production.ts +130 -0
  99. package/src/lib/local-management-capability.ts +100 -0
  100. package/src/lib/server-resource-ownership.ts +71 -0
  101. package/src/lib/windows-elevation.ts +8 -1
  102. package/src/lib/windows-user-principal.ts +62 -2
  103. package/src/lib/winsw.ts +8 -2
  104. package/src/oauth/account-import/google-antigravity-adapter.ts +74 -0
  105. package/src/oauth/account-import/index.ts +15 -0
  106. package/src/oauth/account-import/parser.ts +83 -0
  107. package/src/oauth/account-import/registry.ts +18 -0
  108. package/src/oauth/account-import/service.ts +75 -0
  109. package/src/oauth/account-import/types.ts +91 -0
  110. package/src/oauth/anthropic.ts +12 -1
  111. package/src/oauth/callback-server.ts +8 -2
  112. package/src/oauth/chatgpt.ts +12 -1
  113. package/src/oauth/google-antigravity.ts +27 -0
  114. package/src/oauth/health.ts +12 -48
  115. package/src/oauth/index.ts +122 -0
  116. package/src/oauth/kimi.ts +16 -2
  117. package/src/oauth/nous.ts +749 -0
  118. package/src/oauth/store.ts +56 -3
  119. package/src/providers/codex-capacity.ts +1 -0
  120. package/src/providers/registry.ts +161 -16
  121. package/src/responses/custom-tool-compat.ts +182 -0
  122. package/src/responses/parser.ts +22 -0
  123. package/src/responses/reasoning-replay-cache.ts +168 -11
  124. package/src/responses/schema.ts +1 -1
  125. package/src/router.ts +5 -20
  126. package/src/routing/capability.ts +9 -3
  127. package/src/routing/compatibility/assemble.ts +159 -0
  128. package/src/routing/compatibility/behavior.ts +224 -0
  129. package/src/routing/compatibility/catalog.ts +99 -0
  130. package/src/routing/compatibility/endpoint.ts +52 -0
  131. package/src/routing/compatibility/policy.ts +181 -0
  132. package/src/routing/compatibility/reader.ts +110 -0
  133. package/src/routing/compatibility/subject.ts +191 -0
  134. package/src/routing/compatibility/types.ts +64 -0
  135. package/src/routing/compatibility/version.ts +104 -0
  136. package/src/routing/evaluator.ts +27 -32
  137. package/src/routing/profile.ts +118 -4
  138. package/src/routing/trace.ts +82 -28
  139. package/src/server/auth-cors.ts +7 -0
  140. package/src/server/background-lifecycle.ts +20 -7
  141. package/src/server/chat-completions.ts +2 -2
  142. package/src/server/claude-messages.ts +6 -3
  143. package/src/server/direct-local-http.ts +343 -0
  144. package/src/server/index.ts +80 -18
  145. package/src/server/lifecycle.ts +12 -4
  146. package/src/server/local-management-read-client.ts +90 -0
  147. package/src/server/management/agent-settings-routes.ts +27 -3
  148. package/src/server/management/lab-automation-routes.ts +206 -0
  149. package/src/server/management/lab-routes.ts +13 -0
  150. package/src/server/management/logs-usage-routes.ts +1 -0
  151. package/src/server/management/oauth-account-routes.ts +57 -1
  152. package/src/server/management/provider-routes.ts +16 -0
  153. package/src/server/management/routing-profile-routes.ts +12 -24
  154. package/src/server/management-api.ts +2 -0
  155. package/src/server/management-auth.ts +68 -3
  156. package/src/server/proxy-liveness.ts +3 -2
  157. package/src/server/request-decompress.ts +9 -3
  158. package/src/server/request-log.ts +53 -4
  159. package/src/server/responses/collaboration.ts +93 -17
  160. package/src/server/responses/compact.ts +3 -0
  161. package/src/server/responses/core.ts +202 -15
  162. package/src/server/responses/policy-fallback.ts +1 -0
  163. package/src/server/responses-custom-tool-repair.ts +280 -0
  164. package/src/server/responses-terminal-repair.ts +342 -0
  165. package/src/server/startup-action-control.ts +8 -1
  166. package/src/service.ts +278 -12
  167. package/src/types.ts +49 -3
  168. package/src/usage/expected-prices.ts +30 -0
  169. package/src/usage/log.ts +30 -4
  170. package/src/usage/summary.ts +153 -2
  171. package/src/web-search/loop.ts +1 -1
  172. package/gui/dist/assets/index-Cw1_mxO8.js +0 -70
  173. package/src/cli/internal-dispatch.ts +0 -20
@@ -0,0 +1,371 @@
1
+ import type { EvidenceLayer } from "../constants";
2
+ import { subjectIdForSubject, scenarioManifestDigest } from "../digest";
3
+ import { discoverScenarios, expandScenario, loadCaseAuthority } from "../conformance/manifest";
4
+ import { CL01_SUITES, CL03_LIVE_SUITES } from "../conformance/types";
5
+ import { resolveProtocolExecutionContext } from "../conformance/executor";
6
+ import { buildProtocolSubjectV1 } from "../subject/protocol-subject";
7
+ import { discoverLiveScenarios, expandLiveScenario, loadLiveCaseAuthority } from "../live/manifest";
8
+ import { isLiveCaseApplicableToRoute } from "../live/executor";
9
+ import { buildAutomationLiveRouteContext } from "./route-context";
10
+ import { liveSuiteManifestDigestForCase } from "../live/suite-manifest";
11
+ import { suiteManifestDigestForCase } from "../conformance/suite-manifest";
12
+ import { isScenarioApplicable } from "../projection/verification";
13
+ import { queryLatestLabObservation } from "../query/latest-observation";
14
+ import { LabProjectionUnavailableError } from "../query/errors";
15
+ import type { OcxConfig } from "../../types";
16
+ import { resolvePolicyCompatibilitySubjects } from "../../routing/compatibility/subject";
17
+ import type {
18
+ LabAutomationLayer,
19
+ LabAutomationPlanReason,
20
+ LabAutomationPolicyV1,
21
+ LabAutomationRoutesV1,
22
+ LabAutomationRunRecordV1,
23
+ LabAutomationStateV1,
24
+ PlannedLabRunV1,
25
+ } from "./types";
26
+ import { LabAutomationError } from "./types";
27
+ import {
28
+ buildLabAutomationRunKey,
29
+ liveExecutionContractDigest,
30
+ protocolExecutionContractDigest,
31
+ } from "./run-key";
32
+ import { cooldownActive, cooldownCapacityExhausted } from "./cooldown";
33
+ import { isLiveRequestBudgetExhausted, isRunBudgetExhausted } from "./budgets";
34
+ import { LAB_AUTOMATION_HARD_MAX } from "./constants";
35
+
36
+ export interface PlannerInput {
37
+ policy: LabAutomationPolicyV1;
38
+ routes: LabAutomationRoutesV1;
39
+ state: LabAutomationStateV1;
40
+ now: number;
41
+ config?: OcxConfig;
42
+ configDir?: string;
43
+ }
44
+
45
+ interface EvidenceIdentity {
46
+ layer: EvidenceLayer;
47
+ subjectId: string;
48
+ suiteId: string;
49
+ suiteVersion: string;
50
+ suiteManifestDigest: string;
51
+ scenarioId: string;
52
+ scenarioVersion: string;
53
+ scenarioManifestDigest: string;
54
+ }
55
+
56
+ function freshnessReason(
57
+ latestCompletedAt: number | undefined,
58
+ maxAgeMs: number | null,
59
+ refreshBeforeStaleMs: number,
60
+ now: number,
61
+ ): LabAutomationPlanReason {
62
+ if (latestCompletedAt === undefined) return "missing";
63
+ if (maxAgeMs === null) return "fresh";
64
+ const deadline = latestCompletedAt + maxAgeMs;
65
+ const refreshAt = deadline - refreshBeforeStaleMs;
66
+ if (now >= deadline) return "refresh_due";
67
+ if (now >= refreshAt) return "refresh_due";
68
+ return "fresh";
69
+ }
70
+
71
+ function activeRunForKey(state: LabAutomationStateV1, runKey: string): LabAutomationRunRecordV1 | undefined {
72
+ return state.runs.find((row) => row.runKey === runKey && (row.state === "queued" || row.state === "running"));
73
+ }
74
+
75
+ function cancellationBackoffActive(
76
+ state: LabAutomationStateV1,
77
+ runKey: string,
78
+ failureCooldownMs: number,
79
+ now: number,
80
+ ): boolean {
81
+ const backoffMs = Math.max(failureCooldownMs, LAB_AUTOMATION_HARD_MAX.schedulerTickMs);
82
+ return state.runs.some((row) =>
83
+ row.runKey === runKey
84
+ && row.trigger === "scheduled"
85
+ && row.state === "cancelled"
86
+ && row.terminalCode === "cancelled"
87
+ && typeof row.completedAt === "number"
88
+ && row.completedAt + backoffMs > now
89
+ );
90
+ }
91
+
92
+ function latestMatchingObservationCompletedAt(
93
+ identity: EvidenceIdentity,
94
+ configDir?: string,
95
+ ): number | undefined {
96
+ try {
97
+ return queryLatestLabObservation(identity, configDir);
98
+ } catch (error) {
99
+ if (error instanceof LabProjectionUnavailableError) return undefined;
100
+ throw error;
101
+ }
102
+ }
103
+
104
+ function planProtocolScenarios(input: PlannerInput): PlannedLabRunV1[] {
105
+ if (!input.policy.enabled || !input.policy.layers.protocolConformance) return [];
106
+ const authority = loadCaseAuthority();
107
+ const scenarios = discoverScenarios(authority, CL01_SUITES);
108
+ const planned: PlannedLabRunV1[] = [];
109
+ for (const caseRecord of scenarios) {
110
+ if (!isScenarioApplicable(caseRecord.id, "fixture", "protocol_conformance")) continue;
111
+ let subject;
112
+ try {
113
+ const ctx = resolveProtocolExecutionContext(caseRecord);
114
+ subject = buildProtocolSubjectV1(ctx);
115
+ } catch {
116
+ continue;
117
+ }
118
+ const subjectId = subjectIdForSubject(subject);
119
+ const expanded = expandScenario(caseRecord, authority);
120
+ const scenarioDigest = scenarioManifestDigest(expanded);
121
+ const suiteDigest = suiteManifestDigestForCase(caseRecord, authority);
122
+ const suiteVersion = authority.manifestDefaults.suiteVersion;
123
+ const scenarioVersion = authority.manifestDefaults.version;
124
+ const runKey = buildLabAutomationRunKey({
125
+ evidenceLayer: "protocol_conformance",
126
+ subjectId,
127
+ suiteId: caseRecord.suite,
128
+ suiteVersion,
129
+ suiteManifestDigest: suiteDigest,
130
+ scenarioId: caseRecord.id,
131
+ scenarioVersion,
132
+ scenarioManifestDigest: scenarioDigest,
133
+ executionContractDigest: protocolExecutionContractDigest(),
134
+ });
135
+ if (activeRunForKey(input.state, runKey)) continue;
136
+ if (cancellationBackoffActive(input.state, runKey, input.policy.failureCooldownMs, input.now)) continue;
137
+ if (cooldownActive(input.state, runKey, input.now)) continue;
138
+ const latestCompletedAt = latestMatchingObservationCompletedAt({
139
+ layer: "protocol_conformance",
140
+ subjectId,
141
+ suiteId: caseRecord.suite,
142
+ suiteVersion,
143
+ suiteManifestDigest: suiteDigest,
144
+ scenarioId: caseRecord.id,
145
+ scenarioVersion,
146
+ scenarioManifestDigest: scenarioDigest,
147
+ }, input.configDir);
148
+ // CL-00 freezes one freshness default into both suite and scenario manifests; there is no
149
+ // independent case-level override in the current authority schema.
150
+ const maxAge = authority.manifestDefaults.freshness.maxAgeMs;
151
+ const freshness = freshnessReason(latestCompletedAt, maxAge, input.policy.refreshBeforeStaleMs, input.now);
152
+ if (freshness === "fresh") continue;
153
+ planned.push({
154
+ runKey,
155
+ evidenceLayer: "protocol_conformance",
156
+ suiteId: caseRecord.suite,
157
+ suiteVersion,
158
+ suiteManifestDigest: suiteDigest,
159
+ scenarioId: caseRecord.id,
160
+ scenarioVersion,
161
+ scenarioManifestDigest: scenarioDigest,
162
+ subjectId,
163
+ reason: freshness,
164
+ priority: freshness === "missing" ? 0 : 1,
165
+ eligibleAt: input.now,
166
+ });
167
+ }
168
+ return planned;
169
+ }
170
+
171
+ function planLiveScenarios(input: PlannerInput): PlannedLabRunV1[] {
172
+ if (!input.policy.enabled || !input.policy.layers.liveRouteCompatibility) return [];
173
+ if (!input.config) return [];
174
+ if (isLiveRequestBudgetExhausted(input.policy, input.state, input.now)) return [];
175
+ const authority = loadLiveCaseAuthority();
176
+ const scenarios = discoverLiveScenarios(authority, CL03_LIVE_SUITES);
177
+ const planned: PlannedLabRunV1[] = [];
178
+ for (const routeRef of input.routes.routes) {
179
+ const routed = input.config.providers?.[routeRef.providerName];
180
+ if (!routed) continue;
181
+ const resolved = resolvePolicyCompatibilitySubjects(
182
+ input.config,
183
+ routeRef.providerName,
184
+ routeRef.modelId,
185
+ routed,
186
+ input.configDir,
187
+ );
188
+ if (!resolved.route) continue;
189
+ const routeContext = buildAutomationLiveRouteContext(
190
+ resolved.route,
191
+ routed.allowPrivateNetwork === true,
192
+ );
193
+ for (const caseRecord of scenarios) {
194
+ if (!isLiveCaseApplicableToRoute(caseRecord, routeContext)) continue;
195
+ const expanded = expandLiveScenario(caseRecord, authority);
196
+ const scenarioDigest = scenarioManifestDigest(expanded);
197
+ const suiteDigest = liveSuiteManifestDigestForCase(caseRecord, authority);
198
+ const suiteVersion = authority.manifestDefaults.suiteVersion;
199
+ const scenarioVersion = authority.manifestDefaults.version;
200
+ const subjectId = resolved.route.subjectId;
201
+ const runKey = buildLabAutomationRunKey({
202
+ evidenceLayer: "live_route_compatibility",
203
+ subjectId,
204
+ suiteId: caseRecord.suite,
205
+ suiteVersion,
206
+ suiteManifestDigest: suiteDigest,
207
+ scenarioId: caseRecord.id,
208
+ scenarioVersion,
209
+ scenarioManifestDigest: scenarioDigest,
210
+ executionContractDigest: liveExecutionContractDigest(),
211
+ });
212
+ if (activeRunForKey(input.state, runKey)) continue;
213
+ if (cancellationBackoffActive(input.state, runKey, input.policy.failureCooldownMs, input.now)) continue;
214
+ if (cooldownActive(input.state, runKey, input.now)) continue;
215
+ const latestCompletedAt = latestMatchingObservationCompletedAt({
216
+ layer: "live_route_compatibility",
217
+ subjectId,
218
+ suiteId: caseRecord.suite,
219
+ suiteVersion,
220
+ suiteManifestDigest: suiteDigest,
221
+ scenarioId: caseRecord.id,
222
+ scenarioVersion,
223
+ scenarioManifestDigest: scenarioDigest,
224
+ }, input.configDir);
225
+ const maxAge = authority.manifestDefaults.freshness.maxAgeMs;
226
+ const freshness = freshnessReason(latestCompletedAt, maxAge, input.policy.refreshBeforeStaleMs, input.now);
227
+ if (freshness === "fresh") continue;
228
+ planned.push({
229
+ runKey,
230
+ evidenceLayer: "live_route_compatibility",
231
+ suiteId: caseRecord.suite,
232
+ suiteVersion,
233
+ suiteManifestDigest: suiteDigest,
234
+ scenarioId: caseRecord.id,
235
+ scenarioVersion,
236
+ scenarioManifestDigest: scenarioDigest,
237
+ subjectId,
238
+ reason: freshness,
239
+ priority: freshness === "missing" ? 0 : 1,
240
+ eligibleAt: input.now,
241
+ providerName: routeRef.providerName,
242
+ modelId: routeRef.modelId,
243
+ });
244
+ }
245
+ }
246
+ return planned;
247
+ }
248
+
249
+ /** Deterministic planner — no execution side effects. */
250
+ export function planLabAutomationRuns(input: PlannerInput): PlannedLabRunV1[] {
251
+ if (!input.policy.enabled) return [];
252
+ if (isRunBudgetExhausted(input.policy, input.state, input.now)) return [];
253
+ // Cooldown persistence is bounded. If every slot contains an active backoff, fail closed rather
254
+ // than enqueueing work whose retry suppression could not be recorded.
255
+ if (cooldownCapacityExhausted(input.state, input.now)) return [];
256
+ const protocol = planProtocolScenarios(input);
257
+ const live = planLiveScenarios(input);
258
+ const merged = [...protocol, ...live];
259
+ merged.sort((a, b) => {
260
+ if (a.priority !== b.priority) return a.priority - b.priority;
261
+ if (a.eligibleAt !== b.eligibleAt) return a.eligibleAt - b.eligibleAt;
262
+ return a.runKey < b.runKey ? -1 : a.runKey > b.runKey ? 1 : 0;
263
+ });
264
+ const cap = LAB_AUTOMATION_HARD_MAX.maxQueuedRuns;
265
+ return merged.slice(0, cap);
266
+ }
267
+
268
+ export interface ManualLabRunInput {
269
+ evidenceLayer: LabAutomationLayer;
270
+ scenarioId: string;
271
+ providerName?: string;
272
+ modelId?: string;
273
+ config?: OcxConfig;
274
+ configDir?: string;
275
+ }
276
+
277
+ /** Build a single manual run plan — independent of automation enablement. */
278
+ export function planManualLabRun(input: ManualLabRunInput): PlannedLabRunV1 {
279
+ const now = Date.now();
280
+ if (input.evidenceLayer === "task_effectiveness") {
281
+ throw new LabAutomationError("manual task_effectiveness uses CL-07 tooling", "task_background_disabled");
282
+ }
283
+ if (input.evidenceLayer === "protocol_conformance") {
284
+ const authority = loadCaseAuthority();
285
+ const caseRecord = authority.cases.find((row) => row.id === input.scenarioId);
286
+ if (!caseRecord) throw new LabAutomationError("unknown protocol scenario", "scenario_inapplicable");
287
+ const ctx = resolveProtocolExecutionContext(caseRecord);
288
+ const subject = buildProtocolSubjectV1(ctx);
289
+ const subjectId = subjectIdForSubject(subject);
290
+ const expanded = expandScenario(caseRecord, authority);
291
+ const scenarioDigest = scenarioManifestDigest(expanded);
292
+ const suiteDigest = suiteManifestDigestForCase(caseRecord, authority);
293
+ return {
294
+ runKey: buildLabAutomationRunKey({
295
+ evidenceLayer: "protocol_conformance",
296
+ subjectId,
297
+ suiteId: caseRecord.suite,
298
+ suiteVersion: authority.manifestDefaults.suiteVersion,
299
+ suiteManifestDigest: suiteDigest,
300
+ scenarioId: caseRecord.id,
301
+ scenarioVersion: authority.manifestDefaults.version,
302
+ scenarioManifestDigest: scenarioDigest,
303
+ executionContractDigest: protocolExecutionContractDigest(),
304
+ }),
305
+ evidenceLayer: "protocol_conformance",
306
+ suiteId: caseRecord.suite,
307
+ suiteVersion: authority.manifestDefaults.suiteVersion,
308
+ suiteManifestDigest: suiteDigest,
309
+ scenarioId: caseRecord.id,
310
+ scenarioVersion: authority.manifestDefaults.version,
311
+ scenarioManifestDigest: scenarioDigest,
312
+ subjectId,
313
+ reason: "missing",
314
+ priority: 0,
315
+ eligibleAt: now,
316
+ };
317
+ }
318
+ if (!input.config || !input.providerName || !input.modelId) {
319
+ throw new LabAutomationError("live manual run requires providerName and modelId", "route_ineligible");
320
+ }
321
+ const routed = input.config.providers?.[input.providerName];
322
+ if (!routed) throw new LabAutomationError("unknown provider route", "route_ineligible");
323
+ const resolved = resolvePolicyCompatibilitySubjects(
324
+ input.config,
325
+ input.providerName,
326
+ input.modelId,
327
+ routed,
328
+ input.configDir,
329
+ );
330
+ if (!resolved.route) throw new LabAutomationError("route ineligible for compatibility subject", "route_ineligible");
331
+ const authority = loadLiveCaseAuthority();
332
+ const caseRecord = authority.cases.find((row) => row.id === input.scenarioId);
333
+ if (!caseRecord) throw new LabAutomationError("unknown live scenario", "scenario_inapplicable");
334
+ const routeContext = buildAutomationLiveRouteContext(
335
+ resolved.route,
336
+ routed.allowPrivateNetwork === true,
337
+ );
338
+ if (!isLiveCaseApplicableToRoute(caseRecord, routeContext)) {
339
+ throw new LabAutomationError("scenario inapplicable to route", "scenario_inapplicable");
340
+ }
341
+ const expanded = expandLiveScenario(caseRecord, authority);
342
+ const scenarioDigest = scenarioManifestDigest(expanded);
343
+ const suiteDigest = liveSuiteManifestDigestForCase(caseRecord, authority);
344
+ const subjectId = resolved.route.subjectId;
345
+ return {
346
+ runKey: buildLabAutomationRunKey({
347
+ evidenceLayer: "live_route_compatibility",
348
+ subjectId,
349
+ suiteId: caseRecord.suite,
350
+ suiteVersion: authority.manifestDefaults.suiteVersion,
351
+ suiteManifestDigest: suiteDigest,
352
+ scenarioId: caseRecord.id,
353
+ scenarioVersion: authority.manifestDefaults.version,
354
+ scenarioManifestDigest: scenarioDigest,
355
+ executionContractDigest: liveExecutionContractDigest(),
356
+ }),
357
+ evidenceLayer: "live_route_compatibility",
358
+ suiteId: caseRecord.suite,
359
+ suiteVersion: authority.manifestDefaults.suiteVersion,
360
+ suiteManifestDigest: suiteDigest,
361
+ scenarioId: caseRecord.id,
362
+ scenarioVersion: authority.manifestDefaults.version,
363
+ scenarioManifestDigest: scenarioDigest,
364
+ subjectId,
365
+ reason: "missing",
366
+ priority: 0,
367
+ eligibleAt: now,
368
+ providerName: input.providerName,
369
+ modelId: input.modelId,
370
+ };
371
+ }
@@ -0,0 +1,136 @@
1
+ import {
2
+ LAB_AUTOMATION_DEFAULT_POLICY,
3
+ LAB_AUTOMATION_HARD_MAX,
4
+ LAB_AUTOMATION_POLICY_SCHEMA_VERSION,
5
+ } from "./constants";
6
+ import type { LabAutomationPolicyV1 } from "./types";
7
+ import { LabAutomationError } from "./types";
8
+
9
+ const POLICY_KEYS = new Set([
10
+ "schemaVersion",
11
+ "enabled",
12
+ "layers",
13
+ "refreshBeforeStaleMs",
14
+ "maxConcurrentRuns",
15
+ "maxConcurrentLiveRuns",
16
+ "maxConcurrentRunsPerRoute",
17
+ "maxRunsPerHour",
18
+ "maxLiveRequestsPerHour",
19
+ "failureCooldownMs",
20
+ "blockedCooldownMs",
21
+ "taskEffectivenessBackgroundEnabled",
22
+ ]);
23
+ const LAYER_KEYS = new Set([
24
+ "protocolConformance",
25
+ "liveRouteCompatibility",
26
+ "taskEffectiveness",
27
+ ]);
28
+
29
+ function assertClosedKeys(value: Record<string, unknown>, allowed: ReadonlySet<string>, field: string): void {
30
+ for (const key of Object.keys(value)) {
31
+ if (!allowed.has(key)) {
32
+ throw new LabAutomationError(`unknown ${field} field ${key}`, "invalid_policy");
33
+ }
34
+ }
35
+ }
36
+
37
+ function assertBoolean(value: unknown, field: string): boolean {
38
+ if (typeof value !== "boolean") throw new LabAutomationError(`invalid policy field ${field}`, "invalid_policy");
39
+ return value;
40
+ }
41
+
42
+ function assertNonNegativeInt(value: unknown, field: string, max: number): number {
43
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
44
+ throw new LabAutomationError(`invalid policy field ${field}`, "invalid_policy");
45
+ }
46
+ if (value > max) {
47
+ throw new LabAutomationError(`policy field ${field} exceeds hard maximum`, "invalid_policy");
48
+ }
49
+ return value;
50
+ }
51
+
52
+ function assertPositiveInt(value: unknown, field: string, max: number): number {
53
+ const parsed = assertNonNegativeInt(value, field, max);
54
+ if (parsed === 0) {
55
+ throw new LabAutomationError(`policy field ${field} must be at least 1`, "invalid_policy");
56
+ }
57
+ return parsed;
58
+ }
59
+
60
+ /** Validate and normalize automation policy with a closed schema and hard upper bounds. */
61
+ export function normalizeLabAutomationPolicyV1(raw: unknown): LabAutomationPolicyV1 {
62
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
63
+ throw new LabAutomationError("policy must be an object", "invalid_policy");
64
+ }
65
+ const obj = raw as Record<string, unknown>;
66
+ assertClosedKeys(obj, POLICY_KEYS, "policy");
67
+ if (obj.schemaVersion !== LAB_AUTOMATION_POLICY_SCHEMA_VERSION) {
68
+ throw new LabAutomationError("unsupported policy schemaVersion", "invalid_policy");
69
+ }
70
+ const layersRaw = obj.layers;
71
+ if (!layersRaw || typeof layersRaw !== "object" || Array.isArray(layersRaw)) {
72
+ throw new LabAutomationError("invalid policy layers", "invalid_policy");
73
+ }
74
+ const layersObj = layersRaw as Record<string, unknown>;
75
+ assertClosedKeys(layersObj, LAYER_KEYS, "layers");
76
+ const taskBackground = assertBoolean(obj.taskEffectivenessBackgroundEnabled, "taskEffectivenessBackgroundEnabled");
77
+ const layers = {
78
+ protocolConformance: assertBoolean(layersObj.protocolConformance, "layers.protocolConformance"),
79
+ liveRouteCompatibility: assertBoolean(layersObj.liveRouteCompatibility, "layers.liveRouteCompatibility"),
80
+ taskEffectiveness: assertBoolean(layersObj.taskEffectiveness, "layers.taskEffectiveness"),
81
+ };
82
+ if (taskBackground && !layers.taskEffectiveness) {
83
+ throw new LabAutomationError("task background requires task_effectiveness layer", "invalid_policy");
84
+ }
85
+ return Object.freeze({
86
+ schemaVersion: LAB_AUTOMATION_POLICY_SCHEMA_VERSION,
87
+ enabled: assertBoolean(obj.enabled, "enabled"),
88
+ layers: Object.freeze(layers),
89
+ refreshBeforeStaleMs: assertNonNegativeInt(
90
+ obj.refreshBeforeStaleMs,
91
+ "refreshBeforeStaleMs",
92
+ LAB_AUTOMATION_HARD_MAX.refreshBeforeStaleMs,
93
+ ),
94
+ maxConcurrentRuns: assertPositiveInt(
95
+ obj.maxConcurrentRuns,
96
+ "maxConcurrentRuns",
97
+ LAB_AUTOMATION_HARD_MAX.maxConcurrentRuns,
98
+ ),
99
+ maxConcurrentLiveRuns: assertPositiveInt(
100
+ obj.maxConcurrentLiveRuns,
101
+ "maxConcurrentLiveRuns",
102
+ LAB_AUTOMATION_HARD_MAX.maxConcurrentLiveRuns,
103
+ ),
104
+ maxConcurrentRunsPerRoute: assertPositiveInt(
105
+ obj.maxConcurrentRunsPerRoute,
106
+ "maxConcurrentRunsPerRoute",
107
+ LAB_AUTOMATION_HARD_MAX.maxConcurrentRunsPerRoute,
108
+ ),
109
+ maxRunsPerHour: assertNonNegativeInt(
110
+ obj.maxRunsPerHour,
111
+ "maxRunsPerHour",
112
+ LAB_AUTOMATION_HARD_MAX.maxRunsPerHour,
113
+ ),
114
+ maxLiveRequestsPerHour: assertNonNegativeInt(
115
+ obj.maxLiveRequestsPerHour,
116
+ "maxLiveRequestsPerHour",
117
+ LAB_AUTOMATION_HARD_MAX.maxLiveRequestsPerHour,
118
+ ),
119
+ failureCooldownMs: assertNonNegativeInt(
120
+ obj.failureCooldownMs,
121
+ "failureCooldownMs",
122
+ LAB_AUTOMATION_HARD_MAX.failureCooldownMs,
123
+ ),
124
+ blockedCooldownMs: assertNonNegativeInt(
125
+ obj.blockedCooldownMs,
126
+ "blockedCooldownMs",
127
+ LAB_AUTOMATION_HARD_MAX.blockedCooldownMs,
128
+ ),
129
+ taskEffectivenessBackgroundEnabled: taskBackground,
130
+ });
131
+ }
132
+
133
+ /** Default-off automation policy for new installations. */
134
+ export function defaultLabAutomationPolicyV1(): LabAutomationPolicyV1 {
135
+ return normalizeLabAutomationPolicyV1({ ...LAB_AUTOMATION_DEFAULT_POLICY });
136
+ }
@@ -0,0 +1,191 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ LabAutomationError,
4
+ type LabAutomationPolicyV1,
5
+ type LabAutomationRunRecordV1,
6
+ type LabAutomationRunState,
7
+ type LabAutomationStateV1,
8
+ type PlannedLabRunV1,
9
+ } from "./types";
10
+ import { LAB_AUTOMATION_HARD_MAX } from "./constants";
11
+
12
+ const TERMINAL_STATES = new Set<LabAutomationRunState>([
13
+ "completed",
14
+ "blocked",
15
+ "failed",
16
+ "cancelled",
17
+ "abandoned",
18
+ ]);
19
+
20
+ function isTerminal(run: LabAutomationRunRecordV1): boolean {
21
+ return TERMINAL_STATES.has(run.state);
22
+ }
23
+
24
+ function isRollingBudgetEvidence(run: LabAutomationRunRecordV1, now: number): boolean {
25
+ if (typeof run.startedAt !== "number") return false;
26
+ const cutoff = now - LAB_AUTOMATION_HARD_MAX.budgetWindowMs;
27
+ return run.startedAt > cutoff && run.startedAt <= now;
28
+ }
29
+
30
+ /** Evict only disposable terminal history; rolling budget records remain authority. */
31
+ function evictOldestTerminal(runs: LabAutomationRunRecordV1[], now: number): boolean {
32
+ let oldestIndex = -1;
33
+ let oldestTime = Number.POSITIVE_INFINITY;
34
+ for (let index = 0; index < runs.length; index += 1) {
35
+ const run = runs[index]!;
36
+ if (!isTerminal(run) || isRollingBudgetEvidence(run, now)) continue;
37
+ const terminalAt = run.completedAt ?? run.updatedAt;
38
+ if (terminalAt < oldestTime) {
39
+ oldestTime = terminalAt;
40
+ oldestIndex = index;
41
+ }
42
+ }
43
+ if (oldestIndex < 0) return false;
44
+ runs.splice(oldestIndex, 1);
45
+ return true;
46
+ }
47
+
48
+ export function countRunsByState(state: LabAutomationStateV1, runState: LabAutomationRunState): number {
49
+ return state.runs.filter((row) => row.state === runState).length;
50
+ }
51
+
52
+ export function countRunningLive(state: LabAutomationStateV1): number {
53
+ return state.runs.filter((row) => row.state === "running" && row.evidenceLayer === "live_route_compatibility").length;
54
+ }
55
+
56
+ export function countRunningForRoute(state: LabAutomationStateV1, subjectId: string): number {
57
+ return state.runs.filter((row) => row.state === "running" && row.subjectId === subjectId).length;
58
+ }
59
+
60
+ export function findRunById(state: LabAutomationStateV1, runId: string): LabAutomationRunRecordV1 | undefined {
61
+ return state.runs.find((row) => row.runId === runId);
62
+ }
63
+
64
+ export function enqueuePlannedRuns(
65
+ state: LabAutomationStateV1,
66
+ planned: PlannedLabRunV1[],
67
+ trigger: LabAutomationRunRecordV1["trigger"],
68
+ now: number,
69
+ ): LabAutomationStateV1 {
70
+ const existingActiveKeys = new Set(
71
+ state.runs
72
+ .filter((row) => row.state === "queued" || row.state === "running")
73
+ .map((row) => row.runKey),
74
+ );
75
+ const runs = [...state.runs];
76
+ let queuedCount = runs.filter((row) => row.state === "queued").length;
77
+ for (const plan of planned) {
78
+ if (existingActiveKeys.has(plan.runKey)) continue;
79
+ if (queuedCount >= LAB_AUTOMATION_HARD_MAX.maxQueuedRuns) break;
80
+ while (runs.length >= LAB_AUTOMATION_HARD_MAX.maxPersistedRuns) {
81
+ if (!evictOldestTerminal(runs, now)) return { ...state, runs };
82
+ }
83
+ runs.push({
84
+ runId: randomUUID(),
85
+ runKey: plan.runKey,
86
+ state: "queued",
87
+ evidenceLayer: plan.evidenceLayer,
88
+ suiteId: plan.suiteId,
89
+ suiteVersion: plan.suiteVersion,
90
+ suiteManifestDigest: plan.suiteManifestDigest,
91
+ scenarioId: plan.scenarioId,
92
+ scenarioVersion: plan.scenarioVersion,
93
+ scenarioManifestDigest: plan.scenarioManifestDigest,
94
+ subjectId: plan.subjectId,
95
+ reason: plan.reason,
96
+ priority: plan.priority,
97
+ eligibleAt: plan.eligibleAt,
98
+ trigger,
99
+ createdAt: now,
100
+ updatedAt: now,
101
+ ...(plan.providerName ? { providerName: plan.providerName } : {}),
102
+ ...(plan.modelId ? { modelId: plan.modelId } : {}),
103
+ });
104
+ queuedCount += 1;
105
+ existingActiveKeys.add(plan.runKey);
106
+ }
107
+ return { ...state, runs };
108
+ }
109
+
110
+ export function transitionRun(
111
+ state: LabAutomationStateV1,
112
+ runId: string,
113
+ next: LabAutomationRunState,
114
+ now: number,
115
+ terminalCode?: string,
116
+ ): LabAutomationStateV1 {
117
+ const runs = state.runs.map((row) => {
118
+ if (row.runId !== runId) return row;
119
+ return {
120
+ ...row,
121
+ state: next,
122
+ updatedAt: now,
123
+ ...(next === "running" ? { startedAt: now } : {}),
124
+ ...(next === "completed" || next === "blocked" || next === "failed" || next === "cancelled" || next === "abandoned"
125
+ ? { completedAt: now, ...(terminalCode ? { terminalCode } : {}) }
126
+ : {}),
127
+ };
128
+ });
129
+ return { ...state, runs };
130
+ }
131
+
132
+ export function cancelQueuedRun(state: LabAutomationStateV1, runId: string, now: number): LabAutomationStateV1 {
133
+ const run = findRunById(state, runId);
134
+ if (!run || run.state !== "queued") return state;
135
+ return transitionRun(state, runId, "cancelled", now, "cancelled");
136
+ }
137
+
138
+ export function selectDispatchableRuns(
139
+ policy: LabAutomationPolicyV1,
140
+ state: LabAutomationStateV1,
141
+ now: number,
142
+ predicate: (run: LabAutomationRunRecordV1) => boolean = () => true,
143
+ ): LabAutomationRunRecordV1[] {
144
+ const running = countRunsByState(state, "running");
145
+ if (running >= policy.maxConcurrentRuns) return [];
146
+ const slots = policy.maxConcurrentRuns - running;
147
+ const queued = state.runs
148
+ .filter((row) => row.state === "queued" && row.eligibleAt <= now && predicate(row))
149
+ .sort((a, b) => {
150
+ if (a.priority !== b.priority) return a.priority - b.priority;
151
+ if (a.eligibleAt !== b.eligibleAt) return a.eligibleAt - b.eligibleAt;
152
+ return a.runId < b.runId ? -1 : 1;
153
+ });
154
+ const selected: LabAutomationRunRecordV1[] = [];
155
+ const selectedPerRoute = new Map<string, number>();
156
+ let liveRunning = countRunningLive(state);
157
+ for (const row of queued) {
158
+ if (selected.length >= slots) break;
159
+ const runningForRoute = countRunningForRoute(state, row.subjectId);
160
+ const selectedForRoute = selectedPerRoute.get(row.subjectId) ?? 0;
161
+ if (runningForRoute + selectedForRoute >= policy.maxConcurrentRunsPerRoute) continue;
162
+ if (row.evidenceLayer === "live_route_compatibility") {
163
+ if (liveRunning >= policy.maxConcurrentLiveRuns) continue;
164
+ }
165
+ selected.push(row);
166
+ selectedPerRoute.set(row.subjectId, selectedForRoute + 1);
167
+ if (row.evidenceLayer === "live_route_compatibility") liveRunning += 1;
168
+ }
169
+ return selected;
170
+ }
171
+
172
+ export function trimTerminalRuns(state: LabAutomationStateV1, now: number): LabAutomationStateV1 {
173
+ const keepMs = LAB_AUTOMATION_HARD_MAX.terminalRunRetentionMs;
174
+ const runs = state.runs.filter((row) => {
175
+ if (!isTerminal(row)) return true;
176
+ // The retention window exceeds the rolling budget window. Keep budget evidence explicit
177
+ // so a future retention reduction cannot over-grant the hourly run budgets.
178
+ if (isRollingBudgetEvidence(row, now)) return true;
179
+ const completedAt = row.completedAt ?? row.updatedAt;
180
+ return now - completedAt < keepMs;
181
+ });
182
+ while (runs.length > LAB_AUTOMATION_HARD_MAX.maxPersistedRuns) {
183
+ if (!evictOldestTerminal(runs, now)) {
184
+ throw new LabAutomationError(
185
+ "persisted run ceiling exceeded by non-disposable run records",
186
+ "invalid_state",
187
+ );
188
+ }
189
+ }
190
+ return { ...state, runs };
191
+ }