@bitkyc08/opencodex 2.84.0 → 2.86.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 (246) hide show
  1. package/README.md +0 -4
  2. package/bin/ocx.mjs +61 -3
  3. package/gui/dist/assets/App-BjlttVLy.js +58 -0
  4. package/gui/dist/assets/{Tray-C9TGtwad.js → Tray-DyR9yBee.js} +1 -1
  5. package/gui/dist/assets/index-BMssP4k3.js +94 -0
  6. package/gui/dist/assets/quota-summary-D8M1bc_R.js +1 -0
  7. package/gui/dist/index.html +1 -1
  8. package/package.json +1 -2
  9. package/src/adapters/anthropic/beta-allowlist.ts +87 -8
  10. package/src/adapters/anthropic/client-identity.ts +1 -1
  11. package/src/adapters/anthropic/passthrough.ts +33 -4
  12. package/src/adapters/azure.ts +19 -1
  13. package/src/adapters/client-fingerprint.ts +24 -0
  14. package/src/adapters/coding-agent/turn.ts +22 -9
  15. package/src/adapters/cursor/arg-codec.ts +17 -5
  16. package/src/adapters/cursor/catalog.ts +8 -0
  17. package/src/adapters/cursor/effort-map.ts +3 -0
  18. package/src/adapters/cursor/freeform-args.ts +75 -0
  19. package/src/adapters/cursor/protobuf-events.ts +36 -23
  20. package/src/adapters/google-antigravity-wire.ts +22 -1
  21. package/src/adapters/google.ts +2 -2
  22. package/src/adapters/openai-chat/response-events.ts +59 -1
  23. package/src/adapters/openai-chat.ts +8 -5
  24. package/src/adapters/qoder/adapter.ts +40 -1
  25. package/src/advisor/consult.ts +220 -0
  26. package/src/advisor/context.ts +226 -0
  27. package/src/advisor/disclosure.ts +20 -0
  28. package/src/advisor/runtime.ts +305 -0
  29. package/src/advisor/settings.ts +135 -0
  30. package/src/advisor/state.ts +369 -0
  31. package/src/advisor/synthetic-tool.ts +33 -0
  32. package/src/bridge/errors.ts +5 -0
  33. package/src/chat/inbound.ts +30 -6
  34. package/src/chat/outbound.ts +5 -0
  35. package/src/claude/desktop-3p-library.ts +43 -3
  36. package/src/claude/desktop-3p.ts +22 -8
  37. package/src/claude/desktop-picker-profile.ts +11 -4
  38. package/src/claude/desktop-profile.ts +10 -1
  39. package/src/claude/desktop-remote-store-io.ts +5 -3
  40. package/src/claude/inbound.ts +121 -50
  41. package/src/claude/intercept/local-ca.ts +8 -1
  42. package/src/claude/model-default-settings.ts +154 -0
  43. package/src/claude/model-info.ts +7 -7
  44. package/src/claude/outbound.ts +13 -6
  45. package/src/cli/advisor.ts +151 -0
  46. package/src/cli/auxiliary-listener-preflight.ts +37 -0
  47. package/src/cli/capabilities-agents-routing.ts +18 -0
  48. package/src/cli/capabilities-base.ts +1 -1
  49. package/src/cli/chatgpt-command.ts +35 -14
  50. package/src/cli/claude.ts +46 -6
  51. package/src/cli/dispatch.ts +5 -1
  52. package/src/cli/doctor.ts +9 -0
  53. package/src/cli/ensure-desktop-cold-start.ts +329 -0
  54. package/src/cli/export-command.ts +1 -1
  55. package/src/cli/help.ts +2 -1
  56. package/src/cli/index.ts +13 -4
  57. package/src/cli/integration-plan-dto.ts +2 -0
  58. package/src/cli/registry.ts +15 -3
  59. package/src/cli/restart-scope.ts +3 -1
  60. package/src/clients/config-export/contracts.ts +1 -0
  61. package/src/clients/config-export/model-metadata.ts +3 -2
  62. package/src/clients/config-export/qoder.ts +93 -0
  63. package/src/clients/config-export.ts +17 -4
  64. package/src/codex/auth-api/pool-mode-gate.ts +5 -2
  65. package/src/codex/auth-context.ts +79 -5
  66. package/src/codex/autostart-health.ts +4 -0
  67. package/src/codex/catalog/build-entries.ts +11 -2
  68. package/src/codex/catalog/configured-tiers.ts +50 -0
  69. package/src/codex/catalog/effort.ts +4 -1
  70. package/src/codex/catalog/model-hints.ts +21 -3
  71. package/src/codex/catalog/native-ultrafast.ts +106 -0
  72. package/src/codex/catalog/parsing.ts +5 -0
  73. package/src/codex/catalog/provider-models.ts +4 -0
  74. package/src/codex/catalog/retained-sync.ts +2 -0
  75. package/src/codex/catalog/routed-gather.ts +22 -4
  76. package/src/codex/catalog/vendor-context.ts +82 -0
  77. package/src/codex/config-write-lock.ts +40 -2
  78. package/src/codex/convergence.ts +2 -0
  79. package/src/codex/desktop-app/types.ts +2 -0
  80. package/src/codex/desktop-app/windows.ts +29 -16
  81. package/src/codex/desktop-app-restart.ts +4 -2
  82. package/src/codex/desktop-compatibility/certificate-service.ts +99 -0
  83. package/src/codex/desktop-compatibility/certificate-store.ts +193 -0
  84. package/src/codex/desktop-compatibility/connection-store.ts +87 -0
  85. package/src/codex/desktop-compatibility/installed-build.ts +55 -0
  86. package/src/codex/desktop-compatibility/json-body.ts +29 -0
  87. package/src/codex/desktop-compatibility/native-identity.ts +62 -0
  88. package/src/codex/desktop-compatibility/relay-listener.ts +93 -0
  89. package/src/codex/desktop-compatibility/routing-binding.ts +10 -0
  90. package/src/codex/desktop-compatibility/routing-preflight.ts +69 -0
  91. package/src/codex/desktop-compatibility/runtime-ownership.ts +28 -0
  92. package/src/codex/desktop-compatibility/runtime.ts +173 -0
  93. package/src/codex/desktop-compatibility/service.ts +30 -0
  94. package/src/codex/desktop-compatibility/startup-settings.ts +50 -0
  95. package/src/codex/desktop-compatibility/usage-activation.ts +86 -0
  96. package/src/codex/desktop-compatibility/usage-controlled-fetch.ts +59 -0
  97. package/src/codex/desktop-compatibility/usage-controller.ts +107 -0
  98. package/src/codex/desktop-compatibility/usage-policy.ts +59 -0
  99. package/src/codex/desktop-compatibility/usage-refresh.ts +33 -0
  100. package/src/codex/desktop-compatibility/usage-sse-controller.ts +86 -0
  101. package/src/codex/desktop-compatibility/windows-activation-source.ts +44 -0
  102. package/src/codex/desktop-compatibility/windows-certificate-trust.ts +81 -0
  103. package/src/codex/desktop-compatibility/windows-key-protection.ts +59 -0
  104. package/src/codex/desktop-compatibility/windows-package-command.ts +86 -0
  105. package/src/codex/desktop-compatibility/windows-package-launch.ts +28 -0
  106. package/src/codex/history-provider.ts +1 -1
  107. package/src/codex/inject/config-write-section.ts +4 -1
  108. package/src/codex/inject/remove.ts +18 -6
  109. package/src/codex/journal.ts +16 -1
  110. package/src/codex/main-account.ts +99 -15
  111. package/src/codex/native-main-refresh-mode.ts +217 -0
  112. package/src/codex/routing/health-store.ts +1 -1
  113. package/src/codex/routing/pool-recovery.ts +79 -0
  114. package/src/codex/shim-diagnostics.ts +6 -0
  115. package/src/codex/shim-overlay.ts +7 -2
  116. package/src/config/diagnostics.ts +2 -1
  117. package/src/config/live-reconcile.ts +13 -0
  118. package/src/config/load-degrade.ts +6 -1
  119. package/src/config/paths.ts +10 -3
  120. package/src/config/schema/config-schema.ts +17 -0
  121. package/src/config/schema/desktop-compatibility.ts +9 -0
  122. package/src/config/schema/leaf-validators.ts +1 -0
  123. package/src/generated/compatibility-version.json +451 -163
  124. package/src/generated/model-metadata.ts +1 -1
  125. package/src/integrations/catalog-refresh.ts +1 -1
  126. package/src/integrations/config-io.ts +20 -2
  127. package/src/integrations/journal.ts +15 -1
  128. package/src/integrations/mutation-plan.ts +1 -0
  129. package/src/integrations/registry.ts +7 -0
  130. package/src/integrations/store.ts +3 -0
  131. package/src/integrations/writer.ts +10 -1
  132. package/src/lib/advisor-activation.ts +16 -0
  133. package/src/lib/background-work.ts +65 -0
  134. package/src/lib/bounded-file-read.ts +58 -0
  135. package/src/lib/bun-path-runtime.d.mts +10 -1
  136. package/src/lib/bun-path-runtime.mjs +62 -3
  137. package/src/lib/desktop-proxy-route.ts +30 -0
  138. package/src/lib/desktop-upstream-tunnel.ts +107 -0
  139. package/src/lib/errors.ts +7 -0
  140. package/src/lib/local-internal-call-capability.ts +55 -0
  141. package/src/lib/package-tree-integrity.ts +2 -2
  142. package/src/lib/socks5-fetch.ts +48 -71
  143. package/src/lib/socks5-handshake.ts +112 -0
  144. package/src/lib/translator-budget.ts +7 -1
  145. package/src/oauth/generic-account-failover.ts +29 -5
  146. package/src/oauth/index.ts +4 -2
  147. package/src/oauth/kiro-device-login.ts +2 -1
  148. package/src/oauth/kiro.ts +7 -1
  149. package/src/protocols/codecs/chat-ir.ts +467 -0
  150. package/src/protocols/codecs/chat.ts +10 -1
  151. package/src/protocols/codecs/messages-ir.ts +500 -0
  152. package/src/protocols/features.ts +10 -2
  153. package/src/protocols/path.ts +11 -1
  154. package/src/protocols/plan-snapshot.ts +39 -16
  155. package/src/protocols/plan.ts +9 -1
  156. package/src/protocols/settings.ts +8 -0
  157. package/src/providers/antigravity-account-roster.ts +102 -0
  158. package/src/providers/antigravity-models.ts +85 -7
  159. package/src/providers/model-rename-fields.ts +2 -0
  160. package/src/providers/model-rename-migration.ts +5 -3
  161. package/src/providers/registry/entries-core.ts +7 -6
  162. package/src/providers/registry/entries-extended.ts +19 -0
  163. package/src/responses/parser.ts +30 -19
  164. package/src/responses/state.ts +32 -29
  165. package/src/routing/history/build-file.ts +92 -0
  166. package/src/routing/history/indexer.ts +248 -216
  167. package/src/routing/history/ingest.ts +238 -0
  168. package/src/routing/history/rebuild-job.ts +111 -0
  169. package/src/routing/history/rebuild-protocol.ts +15 -0
  170. package/src/routing/history/rebuild-worker.ts +34 -0
  171. package/src/server/admission-model-scope.ts +4 -1
  172. package/src/server/antigravity-direct-eligibility.ts +208 -0
  173. package/src/server/auth-cors.ts +4 -0
  174. package/src/server/chat-completions.ts +75 -9
  175. package/src/server/claude-messages.ts +110 -20
  176. package/src/server/index/desktop-compatibility-startup.ts +56 -0
  177. package/src/server/index/serve-options.ts +2 -2
  178. package/src/server/index/startup-warnings.ts +9 -0
  179. package/src/server/index/websocket-handler.ts +9 -0
  180. package/src/server/index.ts +13 -10
  181. package/src/server/inference/client-encoder-delivery.ts +6 -5
  182. package/src/server/login-shell-path.ts +146 -0
  183. package/src/server/management/advisor-routes.ts +191 -0
  184. package/src/server/management/agent-settings-routes.ts +22 -4
  185. package/src/server/management/companion-routes.ts +4 -0
  186. package/src/server/management/config-routes.ts +2 -2
  187. package/src/server/management/context.ts +4 -0
  188. package/src/server/management/desktop-compatibility-routes.ts +46 -0
  189. package/src/server/management/desktop-compatibility-runtime-routes.ts +46 -0
  190. package/src/server/management/desktop-compatibility-settings-routes.ts +27 -0
  191. package/src/server/management/model-routes.ts +2 -2
  192. package/src/server/management/protocol-settings-patch.ts +1 -0
  193. package/src/server/management/route-registry.ts +11 -0
  194. package/src/server/management/sibling-guard.ts +1 -0
  195. package/src/server/management-api.ts +6 -0
  196. package/src/server/messages-antigravity-direct.ts +195 -0
  197. package/src/server/messages-native.ts +5 -1
  198. package/src/server/request-log.ts +7 -0
  199. package/src/server/responses/adapter-continuation.ts +35 -18
  200. package/src/server/responses/adapter-delivery.ts +24 -2
  201. package/src/server/responses/adapter-dispatch.ts +29 -2
  202. package/src/server/responses/advisor-plan-slot.ts +27 -0
  203. package/src/server/responses/advisor-slot.ts +336 -0
  204. package/src/server/responses/code-mode-collab-surface.ts +116 -8
  205. package/src/server/responses/core-combo-failure.ts +10 -1
  206. package/src/server/responses/core-combo.ts +21 -0
  207. package/src/server/responses/core-lifetime.ts +2 -0
  208. package/src/server/responses/core-normalize.ts +11 -0
  209. package/src/server/responses/core-options.ts +46 -0
  210. package/src/server/responses/core-replay.ts +7 -4
  211. package/src/server/responses/core.ts +1 -1
  212. package/src/server/responses/passthrough-delivery.ts +16 -10
  213. package/src/server/responses/passthrough-dispatch.ts +14 -3
  214. package/src/server/responses/request-prepare.ts +68 -5
  215. package/src/server/responses/request-transport.ts +16 -0
  216. package/src/server/responses/run-turn-execution.ts +70 -8
  217. package/src/server/responses/sidecar-execution.ts +78 -3
  218. package/src/server/responses/terminal-guard.ts +2 -1
  219. package/src/server/responses-undeclared-tool-guard.ts +30 -9
  220. package/src/server/startup-action-control.ts +16 -2
  221. package/src/server/startup-health-cache.ts +1 -0
  222. package/src/service/desktop-startup.ts +14 -0
  223. package/src/service/health-sentinel-main.ts +23 -4
  224. package/src/service/health-sentinel-probe.ts +24 -7
  225. package/src/service/logging.ts +72 -0
  226. package/src/tray/windows-tray.ps1 +43 -24
  227. package/src/types/config.ts +63 -6
  228. package/src/types/provider.ts +7 -0
  229. package/src/types/request.ts +15 -0
  230. package/src/types/tools.ts +2 -0
  231. package/src/update/async-check.ts +1 -1
  232. package/src/update/badge.ts +2 -2
  233. package/src/update/index.ts +88 -15
  234. package/src/update/job-supervised-restart.ts +107 -0
  235. package/src/update/job.ts +32 -32
  236. package/src/update/notify.ts +1 -1
  237. package/src/update/refresh-scheduler.ts +2 -2
  238. package/src/update/supervised-recovery.d.mts +27 -0
  239. package/src/update/supervised-recovery.mjs +157 -0
  240. package/src/usage/cache-diagnostic.ts +2 -0
  241. package/src/usage/expected-prices.ts +12 -0
  242. package/src/web-search/gemini-executor.ts +2 -2
  243. package/assets/sponsors/packycode.png +0 -0
  244. package/gui/dist/assets/App-CZtqiGcw.js +0 -56
  245. package/gui/dist/assets/index-CTm4l-HQ.js +0 -94
  246. package/gui/dist/assets/quota-summary-BbEeRfPY.js +0 -1
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The private build file of a worker rebuild (#6990).
3
+ *
4
+ * The worker writes the whole index into `<index>.rebuild-<pid>-<n>` next to
5
+ * the published file; the serving thread swaps it in only once it is complete,
6
+ * so no reader ever sees a partial index.
7
+ */
8
+
9
+ import { Database } from "bun:sqlite";
10
+ import { readdirSync, rmSync } from "node:fs";
11
+ import { basename, dirname, join } from "node:path";
12
+ import type { UsageLogRevision } from "../../usage/log";
13
+ import { ingestSourceTail, recordSourceMeta, resetAndCreateSchema, setMeta } from "./ingest";
14
+ import { HISTORY_META_KEYS } from "./schema";
15
+
16
+ const BUILD_SIDE_FILES = ["", "-journal", "-wal", "-shm"] as const;
17
+
18
+ export function buildFilePath(indexPath: string, pid: number, sequence: number): string {
19
+ return `${indexPath}.rebuild-${pid}-${sequence}`;
20
+ }
21
+
22
+ export function removeBuildFiles(buildPath: string): void {
23
+ for (const suffix of BUILD_SIDE_FILES) {
24
+ try { rmSync(`${buildPath}${suffix}`, { force: true }); } catch { /* best effort; swept on a later build */ }
25
+ }
26
+ }
27
+
28
+ function processIsAlive(pid: number): boolean {
29
+ try {
30
+ process.kill(pid, 0);
31
+ return true;
32
+ } catch (error) {
33
+ // EPERM: the process exists under another user. Anything but ESRCH is treated as alive.
34
+ return (error as { code?: unknown } | null)?.code !== "ESRCH";
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Remove build files a crashed process left beside the index. A build of this
40
+ * process, or of another live process, is left alone; this process removes
41
+ * its own builds once their worker has exited.
42
+ */
43
+ export function removeAbandonedBuilds(indexPath: string): number {
44
+ const prefix = `${basename(indexPath)}.rebuild-`;
45
+ let names: string[];
46
+ try {
47
+ names = readdirSync(dirname(indexPath));
48
+ } catch {
49
+ return 0;
50
+ }
51
+ const abandoned = new Set<string>();
52
+ for (const name of names) {
53
+ if (!name.startsWith(prefix)) continue;
54
+ const match = /^(\d+)-(\d+)/.exec(name.slice(prefix.length));
55
+ if (!match) continue;
56
+ const pid = Number(match[1]);
57
+ if (pid === process.pid || processIsAlive(pid)) continue;
58
+ abandoned.add(`${prefix}${match[1]}-${match[2]}`);
59
+ }
60
+ for (const name of abandoned) removeBuildFiles(join(dirname(indexPath), name));
61
+ return abandoned.size;
62
+ }
63
+
64
+ /**
65
+ * Build a complete index of the ledger snapshot `revision` describes into
66
+ * `buildPath`, leave it in WAL mode and close it. Returns the rows ingested.
67
+ * A private build needs no crash durability: an interrupted one is discarded.
68
+ */
69
+ export function buildIndexFile(buildPath: string, reason: string, revision: UsageLogRevision): number {
70
+ removeBuildFiles(buildPath);
71
+ const build = new Database(buildPath, { create: true, readwrite: true });
72
+ try {
73
+ build.exec("PRAGMA journal_mode = MEMORY");
74
+ build.exec("PRAGMA synchronous = OFF");
75
+ resetAndCreateSchema(build);
76
+ setMeta(build, HISTORY_META_KEYS.lastError, reason);
77
+ recordSourceMeta(build, revision);
78
+ const inserted = ingestSourceTail(build, revision.path, 0, {
79
+ expected: revision,
80
+ endOffset: Number(revision.size),
81
+ });
82
+ // The serving thread opens the published file in WAL mode; switching here
83
+ // keeps that first open from rewriting the database header.
84
+ build.exec("PRAGMA journal_mode = WAL");
85
+ build.close();
86
+ return inserted;
87
+ } catch (error) {
88
+ try { build.close(); } catch { /* already unusable */ }
89
+ removeBuildFiles(buildPath);
90
+ throw error;
91
+ }
92
+ }
@@ -7,17 +7,15 @@
7
7
  * JSONL rows arrived since the last index. Missing/corrupt/stale index or a
8
8
  * replaced/truncated source triggers an automatic full rebuild; canonical
9
9
  * history is never touched.
10
+ *
11
+ * A full rebuild of a ledger of at least `REQUEST_HISTORY_WORKER_REBUILD_MIN_BYTES`
12
+ * runs on a worker thread (#6990): the worker writes a private build file and
13
+ * this thread swaps it in once complete. Async readers wait for it; the
14
+ * routing-time sync entry never waits and throws `RequestHistoryIndexBusyError`.
10
15
  */
11
16
 
12
17
  import { Database } from "bun:sqlite";
13
- import {
14
- closeSync,
15
- existsSync,
16
- fstatSync,
17
- openSync,
18
- readSync,
19
- unlinkSync,
20
- } from "node:fs";
18
+ import { existsSync, renameSync, unlinkSync } from "node:fs";
21
19
  import { getConfigDir } from "../../config";
22
20
  import { recordOwnedConfigPath } from "../../lib/config-ownership";
23
21
  import {
@@ -40,6 +38,22 @@ import {
40
38
  InvalidCursorError,
41
39
  type HistoryCursor,
42
40
  } from "./cursor";
41
+ import {
42
+ ingestSourceTail,
43
+ metaValue,
44
+ recordSourceMeta,
45
+ RequestHistoryIndexStaleError,
46
+ resetAndCreateSchema,
47
+ setMeta,
48
+ } from "./ingest";
49
+ import { buildFilePath, removeBuildFiles } from "./build-file";
50
+ import { RebuildWorkerJob } from "./rebuild-job";
51
+
52
+ export {
53
+ REQUEST_HISTORY_INSERT_BATCH,
54
+ REQUEST_HISTORY_MAX_RECORD_BYTES,
55
+ REQUEST_HISTORY_READ_CHUNK_BYTES,
56
+ } from "./ingest";
43
57
 
44
58
  export interface RequestHistoryIndexMeta {
45
59
  schemaVersion: number;
@@ -76,16 +90,74 @@ export interface RequestHistoryPage {
76
90
 
77
91
  export const REQUEST_HISTORY_MAX_PAGE_SIZE = 100;
78
92
  export const REQUEST_HISTORY_DEFAULT_PAGE_SIZE = 50;
79
- export const REQUEST_HISTORY_INSERT_BATCH = 500;
80
- export const REQUEST_HISTORY_READ_CHUNK_BYTES = 64 * 1024;
81
- // The SQLite index is a disposable projection. Complete JSONL records above
82
- // this bound are omitted from the projection; the canonical usage.jsonl is
83
- // never truncated or rewritten by the indexer.
84
- export const REQUEST_HISTORY_MAX_RECORD_BYTES = 1024 * 1024;
93
+ // Ledgers at least this large rebuild on the worker; smaller ones rebuild
94
+ // inline, where a worker spawn would cost more than the ingest.
95
+ export const REQUEST_HISTORY_WORKER_REBUILD_MIN_BYTES = 1024 * 1024;
96
+ // A worker build that failed for any reason but a replaced ledger is not
97
+ // retried by routing-time reads for this long, so a persistent failure (disk
98
+ // full, unreadable ledger) cannot spawn a worker on every routing decision.
99
+ const WORKER_FAILURE_COOLDOWN_MS = 30_000;
100
+ // Async readers wait for at most this many worker rebuilds before reporting the
101
+ // index busy (a ledger replaced again during every build).
102
+ const MAX_ASYNC_REFRESH_BUILDS = 4;
103
+
104
+ /** The index is being rebuilt in the background; routing treats history as unknown meanwhile. */
105
+ export class RequestHistoryIndexBusyError extends Error {
106
+ readonly code = "request_history_index_busy";
107
+ constructor(message = "request-history index is rebuilding") {
108
+ super(message);
109
+ this.name = "RequestHistoryIndexBusyError";
110
+ }
111
+ }
112
+
113
+ /** The index was closed while a reader waited for its rebuild. */
114
+ export class RequestHistoryIndexClosedError extends Error {
115
+ readonly code = "request_history_index_closed";
116
+ constructor(message = "request-history index was closed during a rebuild") {
117
+ super(message);
118
+ this.name = "RequestHistoryIndexClosedError";
119
+ }
120
+ }
121
+
122
+ interface WorkerRebuild {
123
+ readonly job: RebuildJob;
124
+ readonly promise: Promise<void>;
125
+ }
126
+
127
+ /** The part of a rebuild worker job the indexer drives; tests substitute a failing one. */
128
+ type RebuildJob = Pick<RebuildWorkerJob, "build" | "dispose" | "afterExit">;
129
+ let createRebuildJob: () => RebuildJob = () => new RebuildWorkerJob();
130
+
131
+ /** Test seam: replace the worker job factory; `undefined` restores the real worker. */
132
+ export function setRequestHistoryRebuildJobFactoryForTests(factory: (() => RebuildJob) | undefined): void {
133
+ createRebuildJob = factory ?? (() => new RebuildWorkerJob());
134
+ }
135
+
136
+ /** Internal signal: the refresh needs a rebuild that is running (or now started) on the worker. */
137
+ class RebuildHandedOff {
138
+ constructor(readonly rebuild: WorkerRebuild) {}
139
+ }
85
140
 
86
141
  let db: Database | null = null;
87
142
  let dbPath = "";
88
143
  let openPromise: Promise<RequestHistoryIndexMeta> | null = null;
144
+ let workerRebuild: WorkerRebuild | null = null;
145
+ let workerFailure: { at: number; error: unknown } | null = null;
146
+ let workerRebuildMinBytes = REQUEST_HISTORY_WORKER_REBUILD_MIN_BYTES;
147
+ let maxAsyncRefreshBuilds = MAX_ASYNC_REFRESH_BUILDS;
148
+ let buildSequence = 0;
149
+ // Bumped by closeRequestHistoryIndex, so a rebuild finishing after a close is discarded.
150
+ let indexLifetime = 0;
151
+
152
+ /** Test seam: move the worker threshold; `undefined` restores the default. */
153
+ export function setRequestHistoryWorkerRebuildMinBytesForTests(bytes: number | undefined): void {
154
+ workerRebuildMinBytes = bytes ?? REQUEST_HISTORY_WORKER_REBUILD_MIN_BYTES;
155
+ }
156
+
157
+ /** Test seam: how many worker rebuilds one async refresh waits for; `undefined` restores the default. */
158
+ export function setRequestHistoryAsyncRefreshBuildsForTests(builds: number | undefined): void {
159
+ maxAsyncRefreshBuilds = builds ?? MAX_ASYNC_REFRESH_BUILDS;
160
+ }
89
161
 
90
162
  function indexDbPath(): string {
91
163
  const dir = getConfigDir();
@@ -94,19 +166,6 @@ function indexDbPath(): string {
94
166
  return path;
95
167
  }
96
168
 
97
- function metaValue(dbHandle: Database, key: string): string | null {
98
- const row = dbHandle.query("SELECT value FROM schema_meta WHERE key = ?").get(key) as
99
- | { value: string }
100
- | undefined;
101
- return row?.value ?? null;
102
- }
103
-
104
- function setMeta(dbHandle: Database, key: string, value: string | number): void {
105
- dbHandle.query(
106
- "INSERT INTO schema_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
107
- ).run(key, String(value));
108
- }
109
-
110
169
  function readIndexedMeta(dbHandle: Database): Omit<RequestHistoryIndexMeta, "dbPath"> {
111
170
  const asNumber = (key: string): number => {
112
171
  const value = metaValue(dbHandle, key);
@@ -150,172 +209,6 @@ function sourceIdentityMatches(dbHandle: Database, revision: UsageLogRevision |
150
209
  && storedBirthtimeMs === Number(revision.birthtimeMs);
151
210
  }
152
211
 
153
- /** Extract the `requests` row columns from a canonical persisted entry. */
154
- function extractRow(entry: PersistedUsageEntry): Array<string | number | null> {
155
- const attempts = entry.attempts;
156
- return [
157
- entry.requestId,
158
- entry.timestamp,
159
- entry.provider,
160
- entry.model,
161
- // Old canonical JSONL rows can predate bounded selector persistence. Encode
162
- // the disposable projection on rebuild so exact filters match across versions.
163
- typeof entry.requestedModel === "string" ? encodePersistedRequestedModel(entry.requestedModel) : null,
164
- entry.status,
165
- entry.surface ?? null,
166
- entry.inboundProtocol ?? null,
167
- entry.apiKeyId ?? null,
168
- entry.conversationId ?? null,
169
- entry.routeDecision?.routeKind ?? null,
170
- entry.routeDecision?.profile?.id ?? null,
171
- entry.routeDecision?.profile?.revision ?? null,
172
- (attempts?.length ?? 0) > 1 ? 1 : 0,
173
- entry.durationMs,
174
- entry.firstOutputMs ?? null,
175
- entry.usageStatus,
176
- entry.usage ? JSON.stringify(entry.usage) : null,
177
- entry.totalTokens ?? null,
178
- entry.errorCode ?? null,
179
- entry.terminalStatus ?? null,
180
- entry.closeReason ?? null,
181
- attempts?.length ?? 1,
182
- entry.routeDecision ? JSON.stringify(entry.routeDecision) : null,
183
- JSON.stringify(entry),
184
- ];
185
- }
186
-
187
- const ROW_INSERT = `
188
- INSERT OR IGNORE INTO requests (
189
- request_id, timestamp, provider, model, requested_model, status, surface,
190
- inbound_protocol, api_key_id, conversation_id, route_kind, profile_id,
191
- profile_revision, fallback, duration_ms, first_output_ms, usage_status,
192
- usage_json, total_tokens, error_code, terminal_status, close_reason,
193
- attempt_count, decision_json, row_json
194
- ) VALUES (
195
- ?, ?, ?, ?, ?, ?, ?,
196
- ?, ?, ?, ?, ?, ?,
197
- ?, ?, ?, ?, ?, ?, ?,
198
- ?, ?, ?, ?, ?
199
- )`;
200
-
201
- function parsedEntryFromLine(line: string): PersistedUsageEntry | null {
202
- if (!line.trim()) return null;
203
- try {
204
- const parsed = JSON.parse(line) as PersistedUsageEntry;
205
- if (parsed && typeof parsed === "object"
206
- && typeof parsed.requestId === "string"
207
- && typeof parsed.timestamp === "number"
208
- && typeof parsed.provider === "string"
209
- && typeof parsed.model === "string"
210
- && typeof parsed.status === "number"
211
- && typeof parsed.durationMs === "number") {
212
- return parsed;
213
- }
214
- } catch {
215
- /* skip partial / hand-edited lines, same as every other reader */
216
- }
217
- return null;
218
- }
219
-
220
- function ingestSourceTail(dbHandle: Database, path: string, fromOffset: number): number {
221
- let inserted = 0;
222
- let pending: Array<Array<string | number | null>> = [];
223
- const insert = dbHandle.prepare(ROW_INSERT);
224
- let fd: number | undefined;
225
- try {
226
- const commitBatch = () => {
227
- dbHandle.transaction((rows: Array<Array<string | number | null>>) => {
228
- for (const row of rows) {
229
- // `changes` counts real inserts only; INSERT OR IGNORE replays add 0.
230
- inserted += insert.run(...row).changes;
231
- }
232
- })(pending);
233
- pending = [];
234
- };
235
- const ingestLine = (line: Buffer) => {
236
- const entry = parsedEntryFromLine(line.toString("utf-8"));
237
- if (!entry) return;
238
- pending.push(extractRow(entry));
239
- if (pending.length >= REQUEST_HISTORY_INSERT_BATCH) commitBatch();
240
- };
241
-
242
- fd = openSync(path, "r");
243
- const stat = fstatSync(fd);
244
- const size = Number(stat.size);
245
- const readBuffer = Buffer.allocUnsafe(REQUEST_HISTORY_READ_CHUNK_BYTES);
246
- let position = fromOffset;
247
- let nextOffset = fromOffset;
248
- let fragments: Buffer[] = [];
249
- let fragmentBytes = 0;
250
- let oversized = false;
251
- while (position < size) {
252
- const requested = Math.min(readBuffer.length, size - position);
253
- const bytesRead = readSync(fd, readBuffer, 0, requested, position);
254
- if (bytesRead === 0) throw new Error("usage log changed while indexing");
255
- let lineStart = 0;
256
- for (let index = 0; index < bytesRead; index++) {
257
- if (readBuffer[index] !== 0x0a) continue;
258
- const segment = readBuffer.subarray(lineStart, index);
259
- if (!oversized && fragmentBytes + segment.length <= REQUEST_HISTORY_MAX_RECORD_BYTES) {
260
- const line = fragments.length === 0
261
- ? segment
262
- : Buffer.concat([...fragments, segment], fragmentBytes + segment.length);
263
- ingestLine(line);
264
- }
265
- fragments = [];
266
- fragmentBytes = 0;
267
- oversized = false;
268
- lineStart = index + 1;
269
- nextOffset = position + index + 1;
270
- }
271
- const remainder = readBuffer.subarray(lineStart, bytesRead);
272
- if (!oversized && fragmentBytes + remainder.length <= REQUEST_HISTORY_MAX_RECORD_BYTES) {
273
- // Copy because readBuffer is reused on the next iteration.
274
- fragments.push(Buffer.from(remainder));
275
- fragmentBytes += remainder.length;
276
- } else if (remainder.length > 0) {
277
- fragments = [];
278
- fragmentBytes = 0;
279
- oversized = true;
280
- }
281
- position += bytesRead;
282
- }
283
- if (pending.length > 0) commitBatch();
284
- const current = readIndexedMeta(dbHandle);
285
- setMeta(dbHandle, HISTORY_META_KEYS.indexedOffset, nextOffset);
286
- setMeta(dbHandle, HISTORY_META_KEYS.indexedRows, current.indexedRows + inserted);
287
- setMeta(dbHandle, HISTORY_META_KEYS.sourceSize, Number(stat.size));
288
- setMeta(dbHandle, HISTORY_META_KEYS.sourceMtimeMs, Number(stat.mtimeMs));
289
- setMeta(dbHandle, HISTORY_META_KEYS.builtAtMs, Date.now());
290
- return inserted;
291
- } finally {
292
- if (fd !== undefined) closeSync(fd);
293
- // Windows file locks: an unterminated prepared statement keeps the DB
294
- // file busy after close (verified on Bun 1.3.14). Finalize always.
295
- insert.finalize();
296
- }
297
- }
298
-
299
- function resetAndCreateSchema(dbHandle: Database): void {
300
- dbHandle.exec("DROP TABLE IF EXISTS requests");
301
- dbHandle.exec("DROP TABLE IF EXISTS schema_meta");
302
- dbHandle.exec(HISTORY_DDL);
303
- setMeta(dbHandle, HISTORY_META_KEYS.schemaVersion, HISTORY_SCHEMA_VERSION);
304
- setMeta(dbHandle, HISTORY_META_KEYS.indexedOffset, 0);
305
- setMeta(dbHandle, HISTORY_META_KEYS.indexedRows, 0);
306
- setMeta(dbHandle, HISTORY_META_KEYS.builtAtMs, Date.now());
307
- setMeta(dbHandle, HISTORY_META_KEYS.lastError, "rebuilt");
308
- }
309
-
310
- function recordSourceMeta(dbHandle: Database, revision: UsageLogRevision | null): void {
311
- setMeta(dbHandle, HISTORY_META_KEYS.sourcePath, revision?.path ?? "");
312
- setMeta(dbHandle, HISTORY_META_KEYS.sourceDev, revision?.dev ?? 0);
313
- setMeta(dbHandle, HISTORY_META_KEYS.sourceIno, revision?.ino ?? 0);
314
- setMeta(dbHandle, HISTORY_META_KEYS.sourceBirthtimeMs, revision?.birthtimeMs ?? 0);
315
- setMeta(dbHandle, HISTORY_META_KEYS.sourceSize, revision?.size ?? 0);
316
- setMeta(dbHandle, HISTORY_META_KEYS.sourceMtimeMs, revision?.mtimeMs ?? 0);
317
- }
318
-
319
212
  function isHealthy(dbHandle: Database): boolean {
320
213
  try {
321
214
  const row = dbHandle.query("PRAGMA quick_check").get() as { quick_check?: string } | undefined;
@@ -325,11 +218,8 @@ function isHealthy(dbHandle: Database): boolean {
325
218
  }
326
219
  }
327
220
 
328
- function destroyAndRecreate(path: string, reason: string): Database {
329
- if (db) {
330
- try { db.close(); } catch { /* already closed */ }
331
- db = null;
332
- }
221
+ /** Remove the index file and its WAL side files. */
222
+ function removeIndexFiles(path: string): void {
333
223
  // Windows: a partially-opened handle from a failed `new Database` can hold
334
224
  // the file briefly after the throw. Retry the unlink before recreating.
335
225
  for (let attempt = 0; attempt < 5; attempt++) {
@@ -337,13 +227,21 @@ function destroyAndRecreate(path: string, reason: string): Database {
337
227
  for (const suffix of ["-wal", "-shm"] as const) {
338
228
  try { unlinkSync(`${path}${suffix}`); } catch { /* sidecar may not exist */ }
339
229
  }
340
- unlinkSync(path);
341
- break;
230
+ if (existsSync(path)) unlinkSync(path);
231
+ return;
342
232
  } catch {
343
- if (attempt === 4) break;
233
+ if (attempt === 4) return;
344
234
  Bun.sleepSync(50);
345
235
  }
346
236
  }
237
+ }
238
+
239
+ function destroyAndRecreate(path: string, reason: string): Database {
240
+ if (db) {
241
+ try { db.close(); } catch { /* already closed */ }
242
+ db = null;
243
+ }
244
+ removeIndexFiles(path);
347
245
  const fresh = new Database(path, { create: true });
348
246
  fresh.exec("PRAGMA journal_mode = WAL");
349
247
  fresh.exec("PRAGMA busy_timeout = 5000");
@@ -429,14 +327,92 @@ function fullRebuild(dbHandle: Database, reason: string): void {
429
327
  setMeta(dbHandle, HISTORY_META_KEYS.builtAtMs, Date.now());
430
328
  }
431
329
 
432
- function refreshLockedSync(): RequestHistoryIndexMeta {
330
+ /**
331
+ * Start a worker rebuild of `revision`, or return null when no worker can be
332
+ * spawned (the caller then rebuilds inline, as before #6990).
333
+ */
334
+ function workerCoolingDown(): boolean {
335
+ return workerFailure !== null && Date.now() - workerFailure.at < WORKER_FAILURE_COOLDOWN_MS;
336
+ }
337
+
338
+ function startWorkerRebuild(reason: string, revision: UsageLogRevision): WorkerRebuild | null {
339
+ let job: RebuildJob;
340
+ try {
341
+ job = createRebuildJob();
342
+ } catch {
343
+ return null;
344
+ }
345
+ const lifetime = indexLifetime;
346
+ const indexPath = dbPath || indexDbPath();
347
+ const buildPath = buildFilePath(indexPath, process.pid, ++buildSequence);
348
+ const rebuild: { job: RebuildJob; promise: Promise<void> } = { job, promise: Promise.resolve() };
349
+ rebuild.promise = job.build(indexPath, buildPath, reason, revision)
350
+ .then(() => {
351
+ if (lifetime !== indexLifetime) throw new RequestHistoryIndexClosedError();
352
+ installBuild(buildPath, indexPath);
353
+ workerFailure = null;
354
+ })
355
+ .catch((error: unknown) => {
356
+ // A replaced ledger is retried at once; a close is nobody's failure.
357
+ if (lifetime === indexLifetime && !(error instanceof RequestHistoryIndexStaleError)) {
358
+ workerFailure = { at: Date.now(), error };
359
+ }
360
+ throw error;
361
+ })
362
+ .finally(() => {
363
+ if (workerRebuild === rebuild) workerRebuild = null;
364
+ void job.afterExit(() => removeBuildFiles(buildPath));
365
+ });
366
+ // A rebuild started by the routing-time entry has no awaiting reader.
367
+ rebuild.promise.catch(() => undefined);
368
+ workerRebuild = rebuild;
369
+ return rebuild;
370
+ }
371
+
372
+ /** Swap a complete worker build in for the current index file. */
373
+ function installBuild(buildPath: string, indexPath: string): void {
374
+ if (db) {
375
+ try { db.close(); } catch { /* already closed */ }
376
+ db = null;
377
+ }
378
+ removeIndexFiles(indexPath);
379
+ renameSync(buildPath, indexPath);
380
+ }
381
+
382
+ /**
383
+ * Who is refreshing. Routing-time reads never rebuild a large ledger inline;
384
+ * async readers do once the worker has just failed, which is the pre-#6990
385
+ * behaviour and better than leaving history unavailable.
386
+ */
387
+ type RefreshCaller = "routing" | "reader";
388
+
389
+ /** Full rebuild, on the worker when the ledger is large enough to stall the event loop. */
390
+ function rebuildFromLedger(handle: Database, reason: string, caller: RefreshCaller): void {
391
+ const revision = sourceIdentity();
392
+ if (revision !== null && Number(revision.size) >= workerRebuildMinBytes) {
393
+ if (workerCoolingDown()) {
394
+ if (caller === "routing") {
395
+ const error = workerFailure!.error;
396
+ const cause = error instanceof Error ? error.message : String(error);
397
+ throw new RequestHistoryIndexBusyError(`request-history index rebuild failed recently: ${cause}`);
398
+ }
399
+ } else {
400
+ const rebuild = startWorkerRebuild(reason, revision);
401
+ if (rebuild) throw new RebuildHandedOff(rebuild);
402
+ }
403
+ }
404
+ fullRebuild(handle, reason);
405
+ }
406
+
407
+ function refreshLockedSync(caller: RefreshCaller): RequestHistoryIndexMeta {
408
+ if (workerRebuild) throw new RebuildHandedOff(workerRebuild);
433
409
  openIndexDb();
434
410
  const state = ensureSchemaAndIdentity(db!);
435
411
  const handle = db!;
436
412
  const revision = sourceIdentity();
437
413
  if (state === "rebuilt") {
438
- fullRebuild(db!, "rebuilt after identity/schema mismatch");
439
- return metaFor(db!);
414
+ rebuildFromLedger(handle, "rebuilt after identity/schema mismatch", caller);
415
+ return metaFor(handle);
440
416
  }
441
417
  const current = readIndexedMeta(handle);
442
418
  if (revision === null) {
@@ -447,31 +423,63 @@ function refreshLockedSync(): RequestHistoryIndexMeta {
447
423
  const tailNextOffset = current.indexedOffset;
448
424
  if (Number(revision.size) < tailNextOffset) {
449
425
  // Truncated source: offsets no longer make sense.
450
- fullRebuild(handle, "source truncated; index rebuilt");
426
+ rebuildFromLedger(handle, "source truncated; index rebuilt", caller);
451
427
  return metaFor(handle);
452
428
  }
453
- if (tailNextOffset < Number(revision.size)) {
454
- const inserted = ingestSourceTail(handle, revision.path, tailNextOffset);
455
- // A clean tail ingest proves the index is healthy: clear any earlier
456
- // rebuild marker so status readers can distinguish rebuilds from tails.
457
- if (inserted > 0) setMeta(handle, HISTORY_META_KEYS.lastError, "");
458
- }
429
+ if (tailNextOffset < Number(revision.size)) {
430
+ const inserted = ingestSourceTail(handle, revision.path, tailNextOffset);
431
+ // A clean tail ingest proves the index is healthy: clear any earlier
432
+ // rebuild marker so status readers can distinguish rebuilds from tails.
433
+ if (inserted > 0) setMeta(handle, HISTORY_META_KEYS.lastError, "");
434
+ }
459
435
  return metaFor(handle);
460
436
  }
461
437
 
462
- /** Synchronous refresh for routing-time evidence reads (RI-06+). */
438
+ /**
439
+ * Synchronous refresh for routing-time evidence reads (RI-06+). Never waits
440
+ * for a worker rebuild: while one runs (this call may start it), throws
441
+ * `RequestHistoryIndexBusyError`, which health scoring reads as unknown evidence.
442
+ */
463
443
  export function openRequestHistoryIndexSync(): RequestHistoryIndexMeta {
464
- return refreshLockedSync();
444
+ try {
445
+ return refreshLockedSync("routing");
446
+ } catch (error) {
447
+ if (error instanceof RebuildHandedOff) throw new RequestHistoryIndexBusyError();
448
+ throw error;
449
+ }
450
+ }
451
+
452
+ async function refreshWaitingForWorker(): Promise<RequestHistoryIndexMeta> {
453
+ const lifetime = indexLifetime;
454
+ for (let builds = 0; ; ) {
455
+ let handedOff: RebuildHandedOff;
456
+ try {
457
+ return refreshLockedSync("reader");
458
+ } catch (error) {
459
+ if (!(error instanceof RebuildHandedOff)) throw error;
460
+ handedOff = error;
461
+ }
462
+ // Only a refresh that needs yet another build hits the limit; one that
463
+ // follows a successful build returns that index above.
464
+ if (builds >= maxAsyncRefreshBuilds) throw new RequestHistoryIndexBusyError();
465
+ let failure: unknown = null;
466
+ await handedOff.rebuild.promise.catch((error: unknown) => { failure = error ?? new Error("rebuild failed"); });
467
+ builds += 1;
468
+ if (lifetime !== indexLifetime) throw new RequestHistoryIndexClosedError();
469
+ if (failure !== null && builds >= maxAsyncRefreshBuilds) throw failure;
470
+ }
465
471
  }
466
472
 
467
473
  /**
468
474
  * Open (and refresh) the index. Single-flight: concurrent callers share one
469
- * refresh. Never throws for missing/corrupt index or ledger state; those are
470
- * repaired or reflected in the returned meta.
475
+ * refresh, and wait for a worker rebuild without blocking the event loop.
476
+ * Missing/corrupt index or ledger state is repaired or reflected in the
477
+ * returned meta; a close during a rebuild rejects with
478
+ * `RequestHistoryIndexClosedError`.
471
479
  */
472
480
  export function openRequestHistoryIndex(): Promise<RequestHistoryIndexMeta> {
473
481
  if (!openPromise) {
474
- openPromise = Promise.resolve(refreshLockedSync()).finally(() => {
482
+ openPromise = refreshWaitingForWorker().finally(() => {
475
483
  openPromise = null;
476
484
  });
477
485
  }
@@ -479,6 +487,12 @@ export function openRequestHistoryIndex(): Promise<RequestHistoryIndexMeta> {
479
487
  }
480
488
 
481
489
  export function closeRequestHistoryIndex(): void {
490
+ indexLifetime += 1;
491
+ const rebuild = workerRebuild;
492
+ workerRebuild = null;
493
+ workerFailure = null;
494
+ // The rebuild's own settlement removes its build files once the thread exits.
495
+ if (rebuild) void rebuild.job.dispose();
482
496
  if (db) {
483
497
  try { db.close(); } catch { /* ignore */ }
484
498
  db = null;
@@ -488,7 +502,25 @@ export function closeRequestHistoryIndex(): void {
488
502
 
489
503
  /** Force a full rebuild from the canonical ledger (CLI / tests). */
490
504
  export async function rebuildRequestHistoryIndex(): Promise<RequestHistoryIndexMeta> {
505
+ while (workerRebuild) await workerRebuild.promise.catch(() => undefined);
491
506
  const handle = openIndexDb();
507
+ const revision = sourceIdentity();
508
+ if (revision !== null && Number(revision.size) >= workerRebuildMinBytes) {
509
+ workerFailure = null;
510
+ const rebuild = startWorkerRebuild("manual rebuild requested", revision);
511
+ if (rebuild) {
512
+ const lifetime = indexLifetime;
513
+ try {
514
+ await rebuild.promise;
515
+ return openRequestHistoryIndex();
516
+ } catch (error) {
517
+ // A close is final; any other worker failure falls back to the inline rebuild below.
518
+ if (lifetime !== indexLifetime || error instanceof RequestHistoryIndexClosedError) throw error;
519
+ }
520
+ fullRebuild(openIndexDb(), "manual rebuild requested");
521
+ return metaFor(db!);
522
+ }
523
+ }
492
524
  fullRebuild(handle, "manual rebuild requested");
493
525
  return metaFor(handle);
494
526
  }