@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
@@ -907,7 +907,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
907
907
  }, 2_000,
908
908
  {
909
909
  translatorBudget,
910
- replayCacheScope: parsed._clientThreadId ?? "global",
910
+ replayCacheScope: parsed._reasoningReplayScope,
911
911
  ...(deps.forceEmptyResponseId ? { responseId: "" } : {}),
912
912
  hideThinkingSummary: parsed.options.hideThinkingSummary,
913
913
  stallTimeoutSec: deps.stallTimeoutSec,
@@ -8,8 +8,8 @@ import { jcsStringify } from "../digest";
8
8
  import { redactSecretString } from "../../lib/redact";
9
9
 
10
10
  const FORBIDDEN_KEY = /^(?:authorization|proxy-authorization|cookie|set-cookie|api[-_]?key|x-api-key|token|secret|password|email|prompt|messages|content|body|url|hostname|baseUrl|path|account|alias)$/i;
11
- const SECRETISH = /sk-[a-z0-9]{10,}|Bearer\s+[A-Za-z0-9._\-]+|ghp_[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}/i;
12
- const SECRETISH_GLOBAL = /sk-[a-z0-9]{10,}|Bearer\s+[A-Za-z0-9._\-]+|ghp_[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}/gi;
11
+ const SECRETISH = /sk-[a-z0-9]{10,}|credential-canary-[a-z0-9]{10,}|Bearer\s+[A-Za-z0-9._\-]+|ghp_[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}/i;
12
+ const SECRETISH_GLOBAL = new RegExp(SECRETISH.source, "gi");
13
13
 
14
14
  export function redactForArtifact(artifactClass: ArtifactClass, payload: unknown): unknown {
15
15
  if (
@@ -583,4 +583,4 @@ export function sanitizeDiagnostic(value: unknown): string {
583
583
 
584
584
  export function sanitizedJsonBytes(value: unknown): Uint8Array {
585
585
  return new TextEncoder().encode(jcsStringify(scrubValue(value, 0)));
586
- }
586
+ }
@@ -0,0 +1,78 @@
1
+ import { LAB_AUTOMATION_HARD_MAX } from "./constants";
2
+ import type { LabAutomationPolicyV1, LabAutomationStateV1 } from "./types";
3
+
4
+ export function rollBudgetWindow(state: LabAutomationStateV1, now: number): LabAutomationStateV1 {
5
+ if (now - state.budgetWindowStartedAt < LAB_AUTOMATION_HARD_MAX.budgetWindowMs) return state;
6
+ return {
7
+ ...state,
8
+ budgetWindowStartedAt: now,
9
+ runsThisHour: 0,
10
+ liveRequestsThisHour: 0,
11
+ };
12
+ }
13
+
14
+ function rollingStartedRuns(state: LabAutomationStateV1, now: number): number {
15
+ const cutoff = now - LAB_AUTOMATION_HARD_MAX.budgetWindowMs;
16
+ return state.runs.filter((run) => typeof run.startedAt === "number" && run.startedAt > cutoff && run.startedAt <= now).length;
17
+ }
18
+
19
+ function rollingStartedLiveRuns(state: LabAutomationStateV1, now: number): number {
20
+ const cutoff = now - LAB_AUTOMATION_HARD_MAX.budgetWindowMs;
21
+ return state.runs.filter((run) =>
22
+ run.evidenceLayer === "live_route_compatibility"
23
+ && typeof run.startedAt === "number"
24
+ && run.startedAt > cutoff
25
+ && run.startedAt <= now
26
+ ).length;
27
+ }
28
+
29
+ function legacyCounterActive(state: LabAutomationStateV1, now: number): boolean {
30
+ return now - state.budgetWindowStartedAt < LAB_AUTOMATION_HARD_MAX.budgetWindowMs;
31
+ }
32
+
33
+ export function runBudgetRemaining(
34
+ policy: LabAutomationPolicyV1,
35
+ state: LabAutomationStateV1,
36
+ now = Date.now(),
37
+ ): number {
38
+ const rolling = rollingStartedRuns(state, now);
39
+ const legacy = legacyCounterActive(state, now) ? state.runsThisHour : 0;
40
+ return Math.max(0, policy.maxRunsPerHour - Math.max(rolling, legacy));
41
+ }
42
+
43
+ export function liveRequestBudgetRemaining(
44
+ policy: LabAutomationPolicyV1,
45
+ state: LabAutomationStateV1,
46
+ now = Date.now(),
47
+ ): number {
48
+ const rolling = rollingStartedLiveRuns(state, now);
49
+ const legacy = legacyCounterActive(state, now) ? state.liveRequestsThisHour : 0;
50
+ return Math.max(0, policy.maxLiveRequestsPerHour - Math.max(rolling, legacy));
51
+ }
52
+
53
+ export function isRunBudgetExhausted(
54
+ policy: LabAutomationPolicyV1,
55
+ state: LabAutomationStateV1,
56
+ now = Date.now(),
57
+ ): boolean {
58
+ return runBudgetRemaining(policy, state, now) <= 0;
59
+ }
60
+
61
+ export function isLiveRequestBudgetExhausted(
62
+ policy: LabAutomationPolicyV1,
63
+ state: LabAutomationStateV1,
64
+ now = Date.now(),
65
+ ): boolean {
66
+ return liveRequestBudgetRemaining(policy, state, now) <= 0;
67
+ }
68
+
69
+ /** Legacy persisted counters remain for schema compatibility; startedAt records are rolling authority. */
70
+ export function recordRunBudgetUse(state: LabAutomationStateV1, liveRequest: boolean): LabAutomationStateV1 {
71
+ return {
72
+ ...state,
73
+ runsThisHour: Math.min(LAB_AUTOMATION_HARD_MAX.maxRunsPerHour, state.runsThisHour + 1),
74
+ liveRequestsThisHour: liveRequest
75
+ ? Math.min(LAB_AUTOMATION_HARD_MAX.maxLiveRequestsPerHour, state.liveRequestsThisHour + 1)
76
+ : state.liveRequestsThisHour,
77
+ };
78
+ }
@@ -0,0 +1,256 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ closeSync,
4
+ existsSync,
5
+ fsyncSync,
6
+ linkSync,
7
+ openSync,
8
+ readFileSync,
9
+ renameSync,
10
+ unlinkSync,
11
+ writeFileSync,
12
+ } from "node:fs";
13
+ import { dirname, join } from "node:path";
14
+ import {
15
+ ensureLabDirs,
16
+ labAutomationPolicyPath,
17
+ } from "../paths";
18
+ import { normalizeLabAutomationPolicyV1 } from "./policy";
19
+ import {
20
+ loadLabAutomationPolicy,
21
+ loadLabAutomationRoutes,
22
+ normalizeLabAutomationRoutesV1,
23
+ } from "./persistence";
24
+ import type { LabAutomationPolicyV1, LabAutomationRoutesV1 } from "./types";
25
+ import { LabAutomationError } from "./types";
26
+
27
+ const CONFIG_SCHEMA_VERSION = 1 as const;
28
+ const CONFIG_KEYS = new Set(["schemaVersion", "policy", "routes"]);
29
+ const CONFIG_LOCK_WAIT_MS = 5_000;
30
+ const LOCK_SLEEP = new Int32Array(new SharedArrayBuffer(4));
31
+
32
+ interface ConfigLockMeta {
33
+ pid: number;
34
+ token: string;
35
+ }
36
+
37
+ export interface LabAutomationConfigV1 {
38
+ schemaVersion: typeof CONFIG_SCHEMA_VERSION;
39
+ policy: LabAutomationPolicyV1;
40
+ routes: LabAutomationRoutesV1;
41
+ }
42
+
43
+ type ConfigCommitFault = "before_publish" | null;
44
+ let configCommitFaultForTests: ConfigCommitFault = null;
45
+
46
+ function configPath(configDir?: string): string {
47
+ return join(dirname(labAutomationPolicyPath(configDir)), "automation-config.json");
48
+ }
49
+
50
+ function configLockPath(configDir?: string): string {
51
+ return `${configPath(configDir)}.lock`;
52
+ }
53
+
54
+ function sleepLockRetry(): void {
55
+ Atomics.wait(LOCK_SLEEP, 0, 0, 10);
56
+ }
57
+
58
+ function readLockMeta(lockPath: string): ConfigLockMeta | null {
59
+ try {
60
+ const parsed = JSON.parse(readFileSync(lockPath, "utf8")) as unknown;
61
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
62
+ const row = parsed as Record<string, unknown>;
63
+ if (!Number.isSafeInteger(row.pid) || (row.pid as number) <= 0) return null;
64
+ if (typeof row.token !== "string" || row.token.length < 16 || row.token.length > 128) return null;
65
+ return { pid: row.pid as number, token: row.token };
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ function pidDefinitelyDead(pid: number): boolean {
72
+ try {
73
+ process.kill(pid, 0);
74
+ return false;
75
+ } catch (error) {
76
+ const code = error && typeof error === "object" && "code" in error
77
+ ? String((error as { code?: unknown }).code)
78
+ : undefined;
79
+ return code === "ESRCH";
80
+ }
81
+ }
82
+
83
+ function cleanupFile(path: string): void {
84
+ try {
85
+ unlinkSync(path);
86
+ } catch {
87
+ // Absent or already cleaned.
88
+ }
89
+ }
90
+
91
+ function releaseLock(lockPath: string, token: string): void {
92
+ try {
93
+ if (readLockMeta(lockPath)?.token === token) unlinkSync(lockPath);
94
+ } catch {
95
+ // Already released or replaced.
96
+ }
97
+ }
98
+
99
+ function reclaimDeadLock(lockPath: string): boolean {
100
+ const observed = readLockMeta(lockPath);
101
+ if (!observed || !pidDefinitelyDead(observed.pid)) return false;
102
+ const current = readLockMeta(lockPath);
103
+ if (!current || current.pid !== observed.pid || current.token !== observed.token) return false;
104
+ try {
105
+ unlinkSync(lockPath);
106
+ return true;
107
+ } catch {
108
+ return false;
109
+ }
110
+ }
111
+
112
+ function acquireConfigLock(configDir?: string): () => void {
113
+ ensureLabDirs(configDir);
114
+ const lockPath = configLockPath(configDir);
115
+ const deadline = Date.now() + CONFIG_LOCK_WAIT_MS;
116
+ while (true) {
117
+ const token = randomUUID();
118
+ const privatePath = `${lockPath}.${process.pid}.${token}.tmp`;
119
+ let publicationAttempted = false;
120
+ try {
121
+ const fd = openSync(privatePath, "wx", 0o600);
122
+ try {
123
+ writeFileSync(fd, JSON.stringify({ pid: process.pid, token }), { encoding: "utf8" });
124
+ fsyncSync(fd);
125
+ } finally {
126
+ closeSync(fd);
127
+ }
128
+ publicationAttempted = true;
129
+ linkSync(privatePath, lockPath);
130
+ cleanupFile(privatePath);
131
+ return () => releaseLock(lockPath, token);
132
+ } catch (error) {
133
+ cleanupFile(privatePath);
134
+ const code = error && typeof error === "object" && "code" in error
135
+ ? String((error as { code?: unknown }).code)
136
+ : undefined;
137
+ if (code === "EEXIST" && !publicationAttempted) continue;
138
+ if (code !== "EEXIST") {
139
+ throw new LabAutomationError("automation config lock failed", "state_lock_failed");
140
+ }
141
+ if (reclaimDeadLock(lockPath)) continue;
142
+ if (Date.now() >= deadline) {
143
+ throw new LabAutomationError("automation config is busy", "state_lock_busy");
144
+ }
145
+ sleepLockRetry();
146
+ }
147
+ }
148
+ }
149
+
150
+ function withConfigLock<T>(configDir: string | undefined, action: () => T): T {
151
+ const release = acquireConfigLock(configDir);
152
+ try {
153
+ return action();
154
+ } finally {
155
+ release();
156
+ }
157
+ }
158
+
159
+ function normalizeConfig(raw: unknown): LabAutomationConfigV1 {
160
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
161
+ throw new LabAutomationError("automation config must be an object", "invalid_state");
162
+ }
163
+ const row = raw as Record<string, unknown>;
164
+ for (const key of Object.keys(row)) {
165
+ if (!CONFIG_KEYS.has(key)) {
166
+ throw new LabAutomationError(`unknown automation config field ${key}`, "invalid_state");
167
+ }
168
+ }
169
+ if (row.schemaVersion !== CONFIG_SCHEMA_VERSION) {
170
+ throw new LabAutomationError("unsupported automation config schemaVersion", "invalid_state");
171
+ }
172
+ return Object.freeze({
173
+ schemaVersion: CONFIG_SCHEMA_VERSION,
174
+ policy: normalizeLabAutomationPolicyV1(row.policy),
175
+ routes: normalizeLabAutomationRoutesV1(row.routes),
176
+ });
177
+ }
178
+
179
+ function loadConfigUnlocked(configDir?: string): LabAutomationConfigV1 {
180
+ ensureLabDirs(configDir);
181
+ const path = configPath(configDir);
182
+ if (!existsSync(path)) {
183
+ return Object.freeze({
184
+ schemaVersion: CONFIG_SCHEMA_VERSION,
185
+ policy: loadLabAutomationPolicy(configDir),
186
+ routes: loadLabAutomationRoutes(configDir),
187
+ });
188
+ }
189
+ try {
190
+ return normalizeConfig(JSON.parse(readFileSync(path, "utf8")) as unknown);
191
+ } catch (error) {
192
+ if (error instanceof LabAutomationError) throw error;
193
+ throw new LabAutomationError("invalid automation config JSON", "invalid_state");
194
+ }
195
+ }
196
+
197
+ function writeConfigUnlocked(
198
+ policy: LabAutomationPolicyV1,
199
+ routes: LabAutomationRoutesV1,
200
+ configDir?: string,
201
+ ): LabAutomationConfigV1 {
202
+ ensureLabDirs(configDir);
203
+ const normalized = Object.freeze({
204
+ schemaVersion: CONFIG_SCHEMA_VERSION,
205
+ policy: normalizeLabAutomationPolicyV1(policy),
206
+ routes: normalizeLabAutomationRoutesV1(routes),
207
+ });
208
+ const path = configPath(configDir);
209
+ const tmp = join(dirname(path), `.automation-config.${process.pid}.${randomUUID()}.tmp`);
210
+ let fd: number | null = null;
211
+ try {
212
+ fd = openSync(tmp, "wx", 0o600);
213
+ writeFileSync(fd, JSON.stringify(normalized), { encoding: "utf8" });
214
+ fsyncSync(fd);
215
+ closeSync(fd);
216
+ fd = null;
217
+ if (configCommitFaultForTests === "before_publish") {
218
+ throw new LabAutomationError("synthetic automation config commit failure", "invalid_state");
219
+ }
220
+ renameSync(tmp, path);
221
+ return normalized;
222
+ } finally {
223
+ if (fd !== null) closeSync(fd);
224
+ cleanupFile(tmp);
225
+ }
226
+ }
227
+
228
+ /** Load one coherent policy/routes generation. */
229
+ export function loadLabAutomationConfig(configDir?: string): LabAutomationConfigV1 {
230
+ return withConfigLock(configDir, () => loadConfigUnlocked(configDir));
231
+ }
232
+
233
+ /** Atomically publish one coherent policy/routes generation. */
234
+ export function saveLabAutomationConfig(
235
+ policy: LabAutomationPolicyV1,
236
+ routes: LabAutomationRoutesV1,
237
+ configDir?: string,
238
+ ): LabAutomationConfigV1 {
239
+ return withConfigLock(configDir, () => writeConfigUnlocked(policy, routes, configDir));
240
+ }
241
+
242
+ /** Update only policy while preserving routes from the same committed generation. */
243
+ export function saveLabAutomationPolicyConfig(
244
+ policy: LabAutomationPolicyV1,
245
+ configDir?: string,
246
+ ): LabAutomationConfigV1 {
247
+ return withConfigLock(configDir, () => {
248
+ const current = loadConfigUnlocked(configDir);
249
+ return writeConfigUnlocked(policy, current.routes, configDir);
250
+ });
251
+ }
252
+
253
+ /** Test-only fault injection before the atomic rename publication point. */
254
+ export function setLabAutomationConfigCommitFaultForTests(fault: ConfigCommitFault): void {
255
+ configCommitFaultForTests = fault;
256
+ }
@@ -0,0 +1,39 @@
1
+ /** CL-08 automation hard ceilings — callers cannot raise policy above these values. */
2
+
3
+ export const LAB_AUTOMATION_POLICY_SCHEMA_VERSION = 1 as const;
4
+
5
+ export const LAB_AUTOMATION_HARD_MAX = Object.freeze({
6
+ refreshBeforeStaleMs: 30 * 24 * 60 * 60 * 1000,
7
+ maxConcurrentRuns: 4,
8
+ maxConcurrentLiveRuns: 2,
9
+ maxConcurrentRunsPerRoute: 2,
10
+ maxRunsPerHour: 60,
11
+ maxLiveRequestsPerHour: 30,
12
+ failureCooldownMs: 24 * 60 * 60 * 1000,
13
+ blockedCooldownMs: 7 * 24 * 60 * 60 * 1000,
14
+ terminalRunRetentionMs: 7 * 24 * 60 * 60 * 1000,
15
+ maxQueuedRuns: 256,
16
+ maxPersistedRuns: 512,
17
+ maxAutomationRoutes: 64,
18
+ budgetWindowMs: 60 * 60 * 1000,
19
+ schedulerTickMs: 60 * 1000,
20
+ });
21
+
22
+ export const LAB_AUTOMATION_DEFAULT_POLICY = Object.freeze({
23
+ schemaVersion: LAB_AUTOMATION_POLICY_SCHEMA_VERSION,
24
+ enabled: false,
25
+ layers: Object.freeze({
26
+ protocolConformance: false,
27
+ liveRouteCompatibility: false,
28
+ taskEffectiveness: false,
29
+ }),
30
+ refreshBeforeStaleMs: 24 * 60 * 60 * 1000,
31
+ maxConcurrentRuns: 2,
32
+ maxConcurrentLiveRuns: 1,
33
+ maxConcurrentRunsPerRoute: 1,
34
+ maxRunsPerHour: 12,
35
+ maxLiveRequestsPerHour: 6,
36
+ failureCooldownMs: 5 * 60 * 1000,
37
+ blockedCooldownMs: 60 * 60 * 1000,
38
+ taskEffectivenessBackgroundEnabled: false,
39
+ });
@@ -0,0 +1,103 @@
1
+ import { LAB_AUTOMATION_HARD_MAX } from "./constants";
2
+ import type { LabAutomationPolicyV1, LabAutomationStateV1 } from "./types";
3
+
4
+ /**
5
+ * A persisted, expiring tombstone used only when the bounded per-key cooldown map
6
+ * cannot accept another key. While active, scheduled planning fails closed for all
7
+ * run keys. Its expiry covers every cooldown that was collapsed into it.
8
+ */
9
+ const COOLDOWN_SATURATION_KEY = "__ocx_cooldown_saturation__";
10
+
11
+ function saturationActive(cooldowns: Record<string, number>, now: number): boolean {
12
+ const until = cooldowns[COOLDOWN_SATURATION_KEY];
13
+ return typeof until === "number" && until > now;
14
+ }
15
+
16
+ export function cooldownActive(state: LabAutomationStateV1, key: string, now: number): boolean {
17
+ const cooldowns = activeCooldowns(state, now);
18
+ if (saturationActive(cooldowns, now)) return true;
19
+ const until = cooldowns[key];
20
+ return typeof until === "number" && until > now;
21
+ }
22
+
23
+ function activeCooldowns(state: LabAutomationStateV1, now: number): Record<string, number> {
24
+ const next: Record<string, number> = {};
25
+ for (const [key, until] of Object.entries(state.cooldownUntilByKey)) {
26
+ if (until > now) next[key] = until;
27
+ }
28
+ return next;
29
+ }
30
+
31
+ function reservedCooldownKeys(state: LabAutomationStateV1, now: number): Set<string> {
32
+ const reserved = new Set(Object.keys(activeCooldowns(state, now)));
33
+ for (const run of state.runs) {
34
+ if (run.trigger === "scheduled" && run.state === "running") reserved.add(run.runKey);
35
+ }
36
+ return reserved;
37
+ }
38
+
39
+ /** True when another scheduled run could no longer reserve a durable failure cooldown. */
40
+ export function cooldownCapacityExhausted(state: LabAutomationStateV1, now: number): boolean {
41
+ const cooldowns = activeCooldowns(state, now);
42
+ if (saturationActive(cooldowns, now)) return true;
43
+ return reservedCooldownKeys({ ...state, cooldownUntilByKey: cooldowns }, now).size
44
+ >= LAB_AUTOMATION_HARD_MAX.maxPersistedRuns;
45
+ }
46
+
47
+ /** Reserve capacity implicitly through the running scheduled record before dispatch. */
48
+ export function canReserveScheduledCooldown(
49
+ state: LabAutomationStateV1,
50
+ runKey: string,
51
+ now: number,
52
+ ): boolean {
53
+ const cooldowns = activeCooldowns(state, now);
54
+ if (saturationActive(cooldowns, now)) return false;
55
+ const reserved = reservedCooldownKeys({ ...state, cooldownUntilByKey: cooldowns }, now);
56
+ return reserved.has(runKey) || reserved.size < LAB_AUTOMATION_HARD_MAX.maxPersistedRuns;
57
+ }
58
+
59
+ export function setCooldown(
60
+ state: LabAutomationStateV1,
61
+ key: string,
62
+ untilMs: number,
63
+ now: number,
64
+ ): LabAutomationStateV1 {
65
+ const next = activeCooldowns(state, now);
66
+ const saturationUntil = next[COOLDOWN_SATURATION_KEY];
67
+ if (typeof saturationUntil === "number") {
68
+ next[COOLDOWN_SATURATION_KEY] = Math.max(saturationUntil, untilMs);
69
+ return { ...state, cooldownUntilByKey: next };
70
+ }
71
+ if (!(key in next) && Object.keys(next).length >= LAB_AUTOMATION_HARD_MAX.maxPersistedRuns) {
72
+ const longestExistingUntil = Math.max(now, ...Object.values(next));
73
+ return {
74
+ ...state,
75
+ cooldownUntilByKey: {
76
+ [COOLDOWN_SATURATION_KEY]: Math.max(longestExistingUntil, untilMs),
77
+ },
78
+ };
79
+ }
80
+ next[key] = untilMs;
81
+ return { ...state, cooldownUntilByKey: next };
82
+ }
83
+
84
+ export function clearCooldown(state: LabAutomationStateV1, key: string): LabAutomationStateV1 {
85
+ if (!state.cooldownUntilByKey[key]) return state;
86
+ const next = { ...state.cooldownUntilByKey };
87
+ delete next[key];
88
+ return { ...state, cooldownUntilByKey: next };
89
+ }
90
+
91
+ export function cooldownForFailure(
92
+ policy: LabAutomationPolicyV1,
93
+ classification: string,
94
+ now: number,
95
+ ): number {
96
+ if (classification === "authentication_blocked" || classification === "auth_blocked" || classification === "quota_blocked" || classification === "region_blocked") {
97
+ return now + policy.blockedCooldownMs;
98
+ }
99
+ if (classification === "harness_failure") {
100
+ return now + policy.blockedCooldownMs;
101
+ }
102
+ return now + policy.failureCooldownMs;
103
+ }