@bitkyc08/opencodex 2.10.0 → 2.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS_INSTALL.md +77 -0
- package/README.md +4 -10
- package/bin/ocx.mjs +71 -18
- package/gui/dist/assets/index-Cd6_PBKn.css +1 -0
- package/gui/dist/assets/index-ChZQsmBY.js +70 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/alibaba-color.svg +1 -1
- package/gui/dist/provider-icons/antigravity-color.svg +1 -1
- package/gui/dist/provider-icons/claude-color.svg +1 -1
- package/gui/dist/provider-icons/cline-color.svg +16 -0
- package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -1
- package/gui/dist/provider-icons/copilot-color.svg +1 -1
- package/gui/dist/provider-icons/cursor-color.svg +1 -1
- package/gui/dist/provider-icons/deepseek-color.svg +1 -1
- package/gui/dist/provider-icons/firepass-color.svg +1 -1
- package/gui/dist/provider-icons/fireworks-color.svg +1 -1
- package/gui/dist/provider-icons/gemini-color.svg +1 -1
- package/gui/dist/provider-icons/github-copilot-color.svg +1 -1
- package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -1
- package/gui/dist/provider-icons/grok.svg +1 -1
- package/gui/dist/provider-icons/groq-color.svg +1 -1
- package/gui/dist/provider-icons/huggingface-color.svg +1 -1
- package/gui/dist/provider-icons/kimi-color.svg +1 -1
- package/gui/dist/provider-icons/kiro-color.svg +2 -2
- package/gui/dist/provider-icons/lm-studio-color.svg +1 -1
- package/gui/dist/provider-icons/mistral-color.svg +1 -1
- package/gui/dist/provider-icons/moonshot-color.svg +1 -1
- package/gui/dist/provider-icons/nvidia-color.svg +1 -1
- package/gui/dist/provider-icons/ollama-color.svg +1 -1
- package/gui/dist/provider-icons/openai.svg +1 -1
- package/gui/dist/provider-icons/opencode.svg +2 -1
- package/gui/dist/provider-icons/openrouter-color.svg +1 -1
- package/gui/dist/provider-icons/pi.svg +2 -2
- package/gui/dist/provider-icons/qianfan-color.svg +1 -1
- package/gui/dist/provider-icons/qwen-portal-color.svg +1 -1
- package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -1
- package/gui/dist/provider-icons/vllm-color.svg +1 -1
- package/gui/dist/provider-icons/xiaomi-color.svg +1 -1
- package/package.json +8 -4
- package/src/adapters/anthropic.ts +208 -14
- package/src/adapters/base.ts +16 -5
- package/src/adapters/cursor/effort-map.ts +3 -2
- package/src/adapters/cursor/framing.ts +39 -0
- package/src/adapters/cursor/live-transport.ts +105 -95
- package/src/adapters/cursor/native-exec.ts +32 -6
- package/src/adapters/cursor/protobuf-request.ts +20 -15
- package/src/adapters/cursor/request-builder.ts +21 -7
- package/src/adapters/cursor/types.ts +7 -0
- package/src/adapters/google-antigravity-replay.ts +237 -21
- package/src/adapters/google-truncation.ts +11 -0
- package/src/adapters/google.ts +50 -9
- package/src/adapters/identity.ts +39 -6
- package/src/adapters/kiro-errors.ts +11 -0
- package/src/adapters/kiro-events.ts +19 -1
- package/src/adapters/kiro-thinking.ts +10 -2
- package/src/adapters/kiro-tools.ts +10 -1
- package/src/adapters/kiro.ts +37 -11
- package/src/adapters/openai-chat.ts +284 -83
- package/src/adapters/openai-responses.ts +182 -24
- package/src/bridge.ts +177 -7
- package/src/chat/outbound.ts +78 -23
- package/src/claude/agents-inject.ts +27 -5
- package/src/claude/inbound.ts +11 -1
- package/src/claude/model-info.ts +13 -10
- package/src/claude/outbound.ts +17 -0
- package/src/cli/account-api.ts +24 -0
- package/src/cli/account-auth.ts +31 -6
- package/src/cli/account-main.ts +317 -0
- package/src/cli/account.ts +5 -0
- package/src/cli/claude.ts +2 -1
- package/src/cli/doctor.ts +93 -22
- package/src/cli/export-command.ts +26 -12
- package/src/cli/help.ts +8 -6
- package/src/cli/index.ts +56 -22
- package/src/cli/integrations.ts +84 -1
- package/src/cli/observe.ts +54 -1
- package/src/cli/opencode.ts +2 -1
- package/src/cli/provider-runtime.ts +18 -1
- package/src/cli/route-policy.ts +92 -0
- package/src/cli/runtime-api.ts +6 -3
- package/src/cli/star-prompt.ts +71 -15
- package/src/cli/status.ts +1 -1
- package/src/cli/v2.ts +36 -9
- package/src/clients/config-export.ts +687 -10
- package/src/codex/account-lifecycle.ts +30 -5
- package/src/codex/account-usability.ts +22 -2
- package/src/codex/admission.ts +255 -0
- package/src/codex/auth-api.ts +427 -140
- package/src/codex/auth-context.ts +155 -30
- package/src/codex/autostart-health.ts +8 -1
- package/src/codex/catalog/account-models.ts +62 -0
- package/src/codex/catalog/aggregation.ts +14 -1
- package/src/codex/catalog/bundled.ts +282 -32
- package/src/codex/catalog/filesystem-evidence.ts +302 -0
- package/src/codex/catalog/metadata.ts +51 -6
- package/src/codex/catalog/parsing.ts +6 -3
- package/src/codex/catalog/provider-fetch.ts +576 -41
- package/src/codex/catalog/sync.ts +505 -66
- package/src/codex/catalog-admission.ts +197 -0
- package/src/codex/catalog-write-serialization.ts +241 -0
- package/src/codex/catalog.ts +2 -1
- package/src/codex/codex-write-lock.ts +372 -0
- package/src/codex/convergence-types.ts +593 -0
- package/src/codex/convergence.ts +441 -0
- package/src/codex/desired-state.ts +177 -0
- package/src/codex/features.ts +52 -8
- package/src/codex/generation.ts +202 -0
- package/src/codex/history-job.ts +257 -0
- package/src/codex/history-lock.ts +241 -0
- package/src/codex/history-migration-guardian.ts +18 -5
- package/src/codex/history-provider.ts +9 -2
- package/src/codex/history-transition.ts +105 -0
- package/src/codex/history-worker.ts +176 -0
- package/src/codex/inject-coordination.ts +245 -0
- package/src/codex/inject.ts +605 -124
- package/src/codex/integration-record.ts +266 -0
- package/src/codex/internal/catalog-writer.ts +203 -0
- package/src/codex/internal/history-writer.ts +80 -0
- package/src/codex/journal.ts +10 -1
- package/src/codex/main-account-cache.ts +24 -0
- package/src/codex/management-convergence.ts +114 -0
- package/src/codex/native-main-admission.ts +47 -0
- package/src/codex/native-main-auth-temp.ts +187 -0
- package/src/codex/native-main-claim.ts +167 -0
- package/src/codex/native-main-lock-file.ts +158 -0
- package/src/codex/native-main-owner.ts +315 -0
- package/src/codex/native-profile-api.ts +247 -0
- package/src/codex/native-profile-manager.ts +1512 -0
- package/src/codex/native-profile-processes.ts +121 -0
- package/src/codex/native-profile-recovery.ts +99 -0
- package/src/codex/native-profile-stage-store.ts +387 -0
- package/src/codex/native-profile-startup.ts +340 -0
- package/src/codex/native-profile-store.ts +855 -0
- package/src/codex/native-profile-types.ts +120 -0
- package/src/codex/native-residue.ts +557 -0
- package/src/codex/project-config-warnings.ts +18 -4
- package/src/codex/prompt-journal.ts +311 -0
- package/src/codex/prompt-layers.ts +967 -0
- package/src/codex/prompt-lock.ts +143 -0
- package/src/codex/quota-rejection.ts +224 -0
- package/src/codex/quota.ts +86 -3
- package/src/codex/routing.ts +299 -62
- package/src/codex/runtime.ts +159 -38
- package/src/codex/shim.ts +39 -13
- package/src/codex/subagent-model-fallback.ts +73 -12
- package/src/codex/transition-state.ts +604 -0
- package/src/codex/upstream-host-health.ts +70 -0
- package/src/codex/user-identity.ts +266 -0
- package/src/codex/write-coordination.ts +114 -0
- package/src/config.ts +562 -26
- package/src/generated/jawcode-model-metadata.ts +2 -2
- package/src/grok/inject.ts +15 -4
- package/src/grok/inspect.ts +45 -0
- package/src/images/loop.ts +113 -20
- package/src/integrations/config-io.ts +151 -0
- package/src/integrations/journal.ts +315 -0
- package/src/integrations/merge.ts +135 -0
- package/src/integrations/native/ownership-preflight.ts +165 -0
- package/src/integrations/ownership.ts +111 -0
- package/src/integrations/registry.ts +101 -0
- package/src/integrations/serialize.ts +235 -0
- package/src/integrations/state.ts +290 -0
- package/src/integrations/store.ts +103 -0
- package/src/integrations/writer.ts +492 -0
- package/src/lib/bounded-body.ts +46 -8
- package/src/lib/bun-runtime.ts +110 -1
- package/src/lib/bun-stream-caps.ts +2 -1
- package/src/lib/redact.ts +407 -2
- package/src/lib/shadow-call.ts +24 -0
- package/src/lib/translator-budget.ts +10 -0
- package/src/lib/upstream-reachability.ts +91 -0
- package/src/lib/upstream-retry.ts +154 -2
- package/src/lib/windows-secret-acl.ts +212 -11
- package/src/lib/winsw.ts +9 -3
- package/src/oauth/index.ts +61 -3
- package/src/oauth/key-providers.ts +4 -0
- package/src/oauth/kiro.ts +50 -6
- package/src/oauth/store.ts +31 -6
- package/src/oauth/token-guardian.ts +9 -3
- package/src/providers/codex-capacity.ts +288 -0
- package/src/providers/derive.ts +33 -1
- package/src/providers/free-directory.ts +3 -1
- package/src/providers/key-failover.ts +71 -3
- package/src/providers/openai-sidecar.ts +64 -4
- package/src/providers/openai-virtual-models.ts +1 -0
- package/src/providers/quota.ts +334 -26
- package/src/providers/registry.ts +284 -16
- package/src/providers/xai-transport.ts +11 -4
- package/src/responses/compaction.ts +8 -1
- package/src/responses/hosted-tool-policy.ts +9 -0
- package/src/responses/parser.ts +44 -2
- package/src/responses/reasoning-envelope.ts +9 -1
- package/src/responses/reasoning-replay-cache.ts +105 -0
- package/src/responses/spill-store.ts +45 -8
- package/src/responses/state.ts +161 -17
- package/src/router.ts +302 -16
- package/src/routing/analytics.ts +377 -0
- package/src/routing/capability.ts +204 -0
- package/src/routing/cost.ts +77 -0
- package/src/routing/evaluator.ts +444 -0
- package/src/routing/health.ts +401 -0
- package/src/routing/history/cursor.ts +43 -0
- package/src/routing/history/indexer.ts +590 -0
- package/src/routing/history/schema.ts +72 -0
- package/src/routing/profile.ts +423 -0
- package/src/routing/quota.ts +124 -0
- package/src/routing/request-evidence.ts +45 -0
- package/src/routing/trace.ts +686 -0
- package/src/server/auth-cors.ts +46 -6
- package/src/server/chat-completions.ts +28 -13
- package/src/server/claude-messages.ts +23 -15
- package/src/server/gui-static.ts +39 -10
- package/src/server/images.ts +10 -1
- package/src/server/index.ts +238 -52
- package/src/server/lifecycle.ts +228 -9
- package/src/server/live.ts +14 -3
- package/src/server/management/agent-settings-routes.ts +64 -14
- package/src/server/management/combo-routes.ts +5 -5
- package/src/server/management/config-routes.ts +1 -1
- package/src/server/management/context.ts +42 -2
- package/src/server/management/integration-routes.ts +538 -0
- package/src/server/management/logs-usage-routes.ts +1 -1
- package/src/server/management/model-routes.ts +32 -113
- package/src/server/management/model-rows.ts +117 -0
- package/src/server/management/native-integration-routes.ts +587 -0
- package/src/server/management/oauth-account-routes.ts +1 -1
- package/src/server/management/provider-routes.ts +218 -117
- package/src/server/management/request-history-routes.ts +191 -0
- package/src/server/management/routing-analytics-routes.ts +74 -0
- package/src/server/management/routing-profile-routes.ts +177 -0
- package/src/server/management/shared.ts +2 -2
- package/src/server/management/sidebar-routes.ts +47 -31
- package/src/server/management/sync-response.ts +69 -0
- package/src/server/management/system-restart.ts +276 -24
- package/src/server/management/system-routes.ts +4 -0
- package/src/server/management-api.ts +84 -9
- package/src/server/management-auth.ts +43 -5
- package/src/server/relay-eager.ts +82 -42
- package/src/server/relay.ts +120 -6
- package/src/server/request-log.ts +26 -6
- package/src/server/responses/collaboration.ts +63 -8
- package/src/server/responses/compact.ts +272 -41
- package/src/server/responses/core.ts +730 -132
- package/src/server/responses/fetch-helpers.ts +15 -1
- package/src/server/responses-item-id-repair.ts +32 -3
- package/src/server/responses-json-events.ts +52 -0
- package/src/server/responses-snapshot-repair.ts +621 -0
- package/src/server/search.ts +51 -6
- package/src/server/sse-payload-rewrite.ts +89 -12
- package/src/server/startup-health-cache.ts +7 -1
- package/src/server/ws-bridge.ts +11 -17
- package/src/service-manager-probe.ts +297 -0
- package/src/service.ts +222 -32
- package/src/tray/windows-tray.ps1 +9 -0
- package/src/tray/windows.ts +15 -7
- package/src/types.ts +194 -14
- package/src/update/index.ts +13 -13
- package/src/update/job.ts +24 -21
- package/src/update/notify.ts +7 -3
- package/src/usage/cost.ts +0 -0
- package/src/usage/expected-prices.ts +129 -10
- package/src/usage/log.ts +50 -15
- package/src/usage/summary.ts +4 -4
- package/src/vision/index.ts +6 -1
- package/src/web-search/loop.ts +161 -34
- package/gui/dist/assets/index-OY43ubAq.css +0 -1
- package/gui/dist/assets/index-YwNnKZcL.js +0 -67
- package/gui/dist/provider-icons/antigravity.svg +0 -1
- package/gui/dist/provider-icons/claude.svg +0 -1
- package/gui/dist/provider-icons/copilot.svg +0 -1
- package/gui/dist/provider-icons/cursor.svg +0 -2
- package/gui/dist/provider-icons/gemini.svg +0 -1
- package/gui/dist/provider-icons/grok-color.svg +0 -1
- package/gui/dist/provider-icons/kiro.svg +0 -14
package/src/codex/features.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* features.ts — codex feature-flag view for $CODEX_HOME/config.toml.
|
|
3
3
|
*
|
|
4
|
-
* Scope boundary: this module mirrors
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Scope boundary: this module mirrors only the flags opencodex has to READ
|
|
5
|
+
* directly from config.toml:
|
|
6
|
+
* - `multi_agent_v2`, because opencodex migrates its concurrency value across
|
|
7
|
+
* the v1/v2 boundary and exposes the multi-agent config surface;
|
|
8
|
+
* - `default_mode_request_user_input` (Codex Auth page toggle), because the
|
|
9
|
+
* management API needs a live reader for the flag it manages.
|
|
10
|
+
* Every other upstream feature flag is delegated to the native `codex features`
|
|
11
|
+
* command (see src/cli/v2.ts) and must not be hardcoded here.
|
|
9
12
|
*
|
|
10
13
|
* Upstream reshapes flags freely: in the 1f0566d3f..5a1097ed2 range alone,
|
|
11
14
|
* `code_mode_host` changed from a boolean to a table (it is Stage::Stable and
|
|
@@ -30,9 +33,13 @@
|
|
|
30
33
|
import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
|
31
34
|
import { join, resolve } from "node:path";
|
|
32
35
|
import { realpathSync } from "node:fs";
|
|
33
|
-
import { atomicWriteFile, expandUserPath } from "../config";
|
|
36
|
+
import { AtomicWriteResidualTempError, AtomicWriteSecretResidualError, atomicWriteFile, expandUserPath } from "../config";
|
|
37
|
+
import { forgetEphemeralSecretPath } from "../lib/windows-secret-acl";
|
|
34
38
|
import { CODEX_CONFIG_PATH } from "./paths";
|
|
35
39
|
|
|
40
|
+
/** Upstream codex-rs feature key: allow `request_user_input` in Default mode. */
|
|
41
|
+
export const DEFAULT_MODE_REQUEST_USER_INPUT_FEATURE_KEY = "default_mode_request_user_input";
|
|
42
|
+
|
|
36
43
|
// EOL preservation, local copies of inject.ts dominantEol/applyEol: importing
|
|
37
44
|
// inject here would close a module cycle (features -> inject -> catalog -> features).
|
|
38
45
|
function dominantEol(content: string): "\r\n" | "\n" {
|
|
@@ -55,7 +62,7 @@ function mergeTrailingComments(existing?: string, migrated?: string): string {
|
|
|
55
62
|
return `${existing}; ${migratedText}`;
|
|
56
63
|
}
|
|
57
64
|
|
|
58
|
-
function activeCodexConfigPath(): string {
|
|
65
|
+
export function activeCodexConfigPath(): string {
|
|
59
66
|
const raw = process.env.CODEX_HOME?.trim();
|
|
60
67
|
if (!raw) return CODEX_CONFIG_PATH;
|
|
61
68
|
const path = resolve(expandUserPath(raw));
|
|
@@ -127,6 +134,22 @@ export function isMultiAgentV2Enabled(configPath?: string): boolean {
|
|
|
127
134
|
return false;
|
|
128
135
|
}
|
|
129
136
|
|
|
137
|
+
/**
|
|
138
|
+
* TRUE when the codex `default_mode_request_user_input` feature is enabled in
|
|
139
|
+
* config.toml — lets a Default-mode session pause and ask the user questions
|
|
140
|
+
* through `request_user_input` (upstream FeatureSpec: under development,
|
|
141
|
+
* default_enabled = false). Recognizes the shipped boolean form
|
|
142
|
+
* `[features] default_mode_request_user_input = true`.
|
|
143
|
+
* Missing file/key -> false.
|
|
144
|
+
*/
|
|
145
|
+
export function isDefaultModeRequestUserInputEnabled(configPath?: string): boolean {
|
|
146
|
+
const content = readConfigText(configPath);
|
|
147
|
+
if (content === null) return false;
|
|
148
|
+
const features = tomlTableBody(content, "features");
|
|
149
|
+
if (features === null) return false;
|
|
150
|
+
return tomlBoolInBody(features, DEFAULT_MODE_REQUEST_USER_INPUT_FEATURE_KEY) === true;
|
|
151
|
+
}
|
|
152
|
+
|
|
130
153
|
/**
|
|
131
154
|
* TRUE when config.toml still carries `[agents] max_threads` — codex-rs REFUSES to
|
|
132
155
|
* boot with that key while multi_agent_v2 is enabled ("agents.max_threads cannot be
|
|
@@ -833,10 +856,20 @@ function activeThreadComment(content: string, v2Enabled: boolean): string | unde
|
|
|
833
856
|
}
|
|
834
857
|
|
|
835
858
|
let migrationEditSeq = 0;
|
|
859
|
+
/** Both residual classes gate the memo release: a plain residual and a
|
|
860
|
+
* secret-bearing one alike keep their destination memo while the file
|
|
861
|
+
* remains on disk. Exported for the regression seam. */
|
|
862
|
+
export function isAtomicResidualError(error: unknown): boolean {
|
|
863
|
+
return error instanceof AtomicWriteResidualTempError || error instanceof AtomicWriteSecretResidualError;
|
|
864
|
+
}
|
|
865
|
+
|
|
836
866
|
function applyConfigEditsAtomically(path: string, edit: (tempPath: string) => ConfigEditResult): ConfigEditResult {
|
|
837
867
|
const content = readConfigText(path);
|
|
838
868
|
if (content === null) return { ok: false, error: `config.toml not readable at ${path}` };
|
|
839
869
|
const tempPath = `${path}.ocx-migration.${process.pid}.${++migrationEditSeq}`;
|
|
870
|
+
// An inner residual temp (AtomicWriteResidualTempError) keeps its
|
|
871
|
+
// destination-keyed memo: fail-closed while the residual exists.
|
|
872
|
+
let innerResidual = false;
|
|
840
873
|
try {
|
|
841
874
|
atomicWriteFile(tempPath, content);
|
|
842
875
|
const result = edit(tempPath);
|
|
@@ -846,8 +879,19 @@ function applyConfigEditsAtomically(path: string, edit: (tempPath: string) => Co
|
|
|
846
879
|
if (edited === content) return { ok: true, changed: false };
|
|
847
880
|
atomicWriteFile(path, edited);
|
|
848
881
|
return { ok: true, changed: true };
|
|
882
|
+
} catch (error) {
|
|
883
|
+
if (isAtomicResidualError(error)) innerResidual = true;
|
|
884
|
+
throw error;
|
|
849
885
|
} finally {
|
|
850
|
-
try {
|
|
886
|
+
try {
|
|
887
|
+
unlinkSync(tempPath);
|
|
888
|
+
if (!innerResidual) forgetEphemeralSecretPath(tempPath);
|
|
889
|
+
} catch (error) {
|
|
890
|
+
// Already absent is also proven-absent; other failures keep the memo.
|
|
891
|
+
if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") {
|
|
892
|
+
if (!innerResidual) forgetEphemeralSecretPath(tempPath);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
851
895
|
}
|
|
852
896
|
}
|
|
853
897
|
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable generation ownership for OpenCodex config bytes.
|
|
3
|
+
*
|
|
4
|
+
* The WP8b C-phase review found that config ABA and moved configuration could
|
|
5
|
+
* not be detected because cooperating saves had no durable counter. This module
|
|
6
|
+
* owns the singleton schema and conditional increment in the existing config
|
|
7
|
+
* mutation database; callers that already hold its transaction reuse that
|
|
8
|
+
* Database handle so bytes and generation remain one cooperating commit.
|
|
9
|
+
*
|
|
10
|
+
* A hand edit from a non-cooperating writer deliberately does not increment this
|
|
11
|
+
* counter. The convergence contract detects that case with its post-commit file
|
|
12
|
+
* observation instead of pretending SQLite can coordinate an external editor.
|
|
13
|
+
*/
|
|
14
|
+
import { chmodSync, statSync } from "node:fs";
|
|
15
|
+
|
|
16
|
+
import { Database } from "bun:sqlite";
|
|
17
|
+
|
|
18
|
+
import type {
|
|
19
|
+
ConfigGeneration,
|
|
20
|
+
ConfigGenerationBump,
|
|
21
|
+
ConfigGenerationRead,
|
|
22
|
+
} from "./convergence-types";
|
|
23
|
+
|
|
24
|
+
const CREATE_CONFIG_GENERATION = `
|
|
25
|
+
CREATE TABLE IF NOT EXISTS config_generation (
|
|
26
|
+
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
|
27
|
+
value INTEGER NOT NULL CHECK (value >= 0)
|
|
28
|
+
)`;
|
|
29
|
+
const INITIALIZE_CONFIG_GENERATION = `
|
|
30
|
+
INSERT OR IGNORE INTO config_generation (singleton, value) VALUES (1, 0)`;
|
|
31
|
+
const SELECT_CONFIG_GENERATION = `
|
|
32
|
+
SELECT value FROM config_generation WHERE singleton = 1`;
|
|
33
|
+
const BUMP_CONFIG_GENERATION = `
|
|
34
|
+
UPDATE config_generation
|
|
35
|
+
SET value = value + 1
|
|
36
|
+
WHERE singleton = 1 AND value = ?`;
|
|
37
|
+
|
|
38
|
+
interface ConfigGenerationRow {
|
|
39
|
+
value: unknown;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface SchemaVersionRow {
|
|
43
|
+
schema_version: unknown;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Observation adds exactly one variant to `ConfigGenerationRead`: `absent`.
|
|
48
|
+
*
|
|
49
|
+
* This used to be deliberately absent itself, on the reasoning that a caller
|
|
50
|
+
* who may only LOOK must not be handed something it could mistake for a
|
|
51
|
+
* known-good baseline of zero. That reasoning still holds, and `absent` does
|
|
52
|
+
* not violate it — because `absent` is not a baseline. It authorizes nothing on
|
|
53
|
+
* its own. A caller may only promote it after taking the config transaction and
|
|
54
|
+
* reading a real zero THERE (`readConfigGenerationInCurrentMutationTransaction`),
|
|
55
|
+
* at which point the zero is observed rather than assumed. A caller with no
|
|
56
|
+
* transaction to open, such as catalog gather, must keep refusing it.
|
|
57
|
+
*
|
|
58
|
+
* What forced the distinction: refusing on absence meant refusing every Codex
|
|
59
|
+
* write on any home whose config predates this database — a permanent refusal
|
|
60
|
+
* for existing users, not a fixture problem.
|
|
61
|
+
*
|
|
62
|
+
* `absent` is returned ONLY for ENOENT on the initial `statSync`. A file that
|
|
63
|
+
* exists but cannot be read, a directory in its place, a bad schema version, or
|
|
64
|
+
* corrupt SQLite all stay `unavailable`, because those are reasons to stop, and
|
|
65
|
+
* collapsing them into absence is how a corrupt coordinator would become a
|
|
66
|
+
* licence to write.
|
|
67
|
+
*/
|
|
68
|
+
export type ConfigGenerationObservation =
|
|
69
|
+
| ConfigGenerationRead
|
|
70
|
+
| { kind: "absent" };
|
|
71
|
+
|
|
72
|
+
function errorCode(error: unknown): string {
|
|
73
|
+
return error && typeof error === "object" && "code" in error
|
|
74
|
+
? String((error as { code?: unknown }).code)
|
|
75
|
+
: "";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isBusy(error: unknown): boolean {
|
|
79
|
+
const code = errorCode(error);
|
|
80
|
+
const message = error instanceof Error ? error.message : "";
|
|
81
|
+
return code === "SQLITE_BUSY"
|
|
82
|
+
|| code === "SQLITE_LOCKED"
|
|
83
|
+
|| /database (?:is|table is) locked/i.test(message);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function unavailable(error: unknown): Extract<ConfigGenerationRead, { kind: "unavailable" }> {
|
|
87
|
+
return { kind: "unavailable", reason: isBusy(error) ? "busy" : "database" };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function initializeConfigGeneration(database: Database): void {
|
|
91
|
+
database.exec(CREATE_CONFIG_GENERATION);
|
|
92
|
+
database.exec(INITIALIZE_CONFIG_GENERATION);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function readConfigGenerationInTransaction(database: Database): ConfigGeneration {
|
|
96
|
+
const row = database.query<ConfigGenerationRow, []>(SELECT_CONFIG_GENERATION).get();
|
|
97
|
+
if (!row || !Number.isSafeInteger(row.value) || Number(row.value) < 0) {
|
|
98
|
+
throw new Error("The config generation singleton is missing or invalid.");
|
|
99
|
+
}
|
|
100
|
+
return { value: Number(row.value) };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function bumpConfigGenerationInTransaction(
|
|
104
|
+
database: Database,
|
|
105
|
+
expected: ConfigGeneration,
|
|
106
|
+
): ConfigGenerationBump {
|
|
107
|
+
const result = database.query(BUMP_CONFIG_GENERATION).run(expected.value);
|
|
108
|
+
if (result.changes === 1) {
|
|
109
|
+
return { kind: "updated", generation: { value: expected.value + 1 } };
|
|
110
|
+
}
|
|
111
|
+
return { kind: "conflict", current: readConfigGenerationInTransaction(database) };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function bumpCurrentConfigGeneration(database: Database): ConfigGeneration {
|
|
115
|
+
const current = readConfigGenerationInTransaction(database);
|
|
116
|
+
const result = bumpConfigGenerationInTransaction(database, current);
|
|
117
|
+
if (result.kind !== "updated") {
|
|
118
|
+
throw new Error("The config generation changed inside its owning transaction.");
|
|
119
|
+
}
|
|
120
|
+
return result.generation;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function runGenerationTransaction<T>(databasePath: string, operation: (database: Database) => T): T {
|
|
124
|
+
let database: Database | undefined;
|
|
125
|
+
let transactionOpen = false;
|
|
126
|
+
try {
|
|
127
|
+
database = new Database(databasePath, { create: true });
|
|
128
|
+
try { chmodSync(databasePath, 0o600); } catch { /* platform may ignore chmod */ }
|
|
129
|
+
database.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE");
|
|
130
|
+
transactionOpen = true;
|
|
131
|
+
initializeConfigGeneration(database);
|
|
132
|
+
const result = operation(database);
|
|
133
|
+
database.exec("COMMIT");
|
|
134
|
+
transactionOpen = false;
|
|
135
|
+
return result;
|
|
136
|
+
} catch (error) {
|
|
137
|
+
if (transactionOpen) {
|
|
138
|
+
try { database?.exec("ROLLBACK"); } catch { /* close releases the transaction */ }
|
|
139
|
+
}
|
|
140
|
+
throw error;
|
|
141
|
+
} finally {
|
|
142
|
+
try { database?.close(); } catch { /* operation already completed */ }
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function readConfigGenerationAtPath(databasePath: string): ConfigGenerationRead {
|
|
147
|
+
try {
|
|
148
|
+
return {
|
|
149
|
+
kind: "ready",
|
|
150
|
+
generation: runGenerationTransaction(databasePath, readConfigGenerationInTransaction),
|
|
151
|
+
};
|
|
152
|
+
} catch (error) {
|
|
153
|
+
return unavailable(error);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Observe generation state without preparing the mutation database in any way.
|
|
159
|
+
* Missing storage is a first-class state: only cooperating config writes have
|
|
160
|
+
* authority to create and initialize the generation singleton.
|
|
161
|
+
*/
|
|
162
|
+
export function observeConfigGenerationAtPath(
|
|
163
|
+
databasePath: string,
|
|
164
|
+
): ConfigGenerationObservation {
|
|
165
|
+
try {
|
|
166
|
+
statSync(databasePath);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
// ENOENT alone means absent. Everything else — EACCES, ENOTDIR, EIO — is a
|
|
169
|
+
// reason the question could not be answered, which is not the same answer.
|
|
170
|
+
return errorCode(error) === "ENOENT" ? { kind: "absent" } : unavailable(error);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
let database: Database | undefined;
|
|
174
|
+
try {
|
|
175
|
+
database = new Database(databasePath, { readonly: true });
|
|
176
|
+
const schema = database.query<SchemaVersionRow, []>("PRAGMA schema_version").get();
|
|
177
|
+
if (!schema || !Number.isSafeInteger(schema.schema_version)) {
|
|
178
|
+
throw new Error("The config generation schema version is invalid.");
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
kind: "ready",
|
|
182
|
+
generation: readConfigGenerationInTransaction(database),
|
|
183
|
+
};
|
|
184
|
+
} catch (error) {
|
|
185
|
+
return unavailable(error);
|
|
186
|
+
} finally {
|
|
187
|
+
try { database?.close(); } catch { /* observation already completed */ }
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function bumpConfigGenerationAtPath(
|
|
192
|
+
databasePath: string,
|
|
193
|
+
expected: ConfigGeneration,
|
|
194
|
+
): ConfigGenerationBump {
|
|
195
|
+
try {
|
|
196
|
+
return runGenerationTransaction(databasePath, database => (
|
|
197
|
+
bumpConfigGenerationInTransaction(database, expected)
|
|
198
|
+
));
|
|
199
|
+
} catch (error) {
|
|
200
|
+
return unavailable(error);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The parent half of the history unit: derive the operation, dispatch the
|
|
3
|
+
* Worker, and never let its failure become the caller's stall.
|
|
4
|
+
*
|
|
5
|
+
* The operation is DERIVED here from what the caller already decided — its
|
|
6
|
+
* config and the direction its native mutation just took — and then handed down
|
|
7
|
+
* as a fixed value. It is not a request field the Worker trusts, because the
|
|
8
|
+
* distinctions are real: `syncResumeHistory: false` means leave history alone,
|
|
9
|
+
* apply targets opencodex only in legacy mode, and legacy recovery must not
|
|
10
|
+
* touch the manifest that generic restore consumes.
|
|
11
|
+
*
|
|
12
|
+
* Every exit is typed. A Worker that errors, dies, or overruns its watchdog
|
|
13
|
+
* produces an outcome the caller can record, because the alternative — an
|
|
14
|
+
* exception crossing back into a route that already persisted its mutation — is
|
|
15
|
+
* how a successful change gets reported as a 500.
|
|
16
|
+
*
|
|
17
|
+
* Design record: devlog/_plan/260804_codex_write_substrate/020_history_isolation.md.
|
|
18
|
+
*/
|
|
19
|
+
import { randomUUID } from "node:crypto";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
|
|
22
|
+
import type {
|
|
23
|
+
CodexHistoryWorkerOperation,
|
|
24
|
+
HistoryWorkerResult,
|
|
25
|
+
} from "./history-worker";
|
|
26
|
+
import { historyBackupPathFor } from "./history-provider";
|
|
27
|
+
import { getCodexHome } from "./paths";
|
|
28
|
+
|
|
29
|
+
/** Where Codex keeps its resume history, and the manifest that shadows it. */
|
|
30
|
+
const STATE_DB_FILE = "state_5.sqlite";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve the paths a history job needs, at CALL time.
|
|
34
|
+
*
|
|
35
|
+
* `history-provider.ts` resolves its equivalents at module load (`:16`, `:22`),
|
|
36
|
+
* which is fine in one process and wrong for a Worker: the Worker does not
|
|
37
|
+
* inherit them, so anything derived from those constants would address a
|
|
38
|
+
* different home than the caller intended. Resolving here also means a test that
|
|
39
|
+
* moves `CODEX_HOME` is honoured rather than ignored.
|
|
40
|
+
*/
|
|
41
|
+
export function resolveCodexHistoryJobTarget(): {
|
|
42
|
+
readonly canonicalCodexHome: string;
|
|
43
|
+
readonly canonicalStateDbPath: string;
|
|
44
|
+
readonly canonicalBackupPath: string;
|
|
45
|
+
} {
|
|
46
|
+
const home = getCodexHome();
|
|
47
|
+
const stateDb = join(home, STATE_DB_FILE);
|
|
48
|
+
return {
|
|
49
|
+
canonicalCodexHome: home,
|
|
50
|
+
canonicalStateDbPath: stateDb,
|
|
51
|
+
// Derived by the provider's own rule rather than guessed: the manifest lives
|
|
52
|
+
// in the config directory under a hash of the state database, so a
|
|
53
|
+
// hand-built path would address a different file entirely.
|
|
54
|
+
canonicalBackupPath: historyBackupPathFor(stateDb),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** How long a history unit may run before the parent stops waiting on it. */
|
|
59
|
+
const WORKER_TIMEOUT_MS = 30_000;
|
|
60
|
+
|
|
61
|
+
export interface CodexHistoryJobRequest {
|
|
62
|
+
readonly canonicalCodexHome: string;
|
|
63
|
+
readonly canonicalStateDbPath: string;
|
|
64
|
+
readonly canonicalBackupPath: string;
|
|
65
|
+
readonly operation: CodexHistoryWorkerOperation;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type CodexHistoryJobOutcome =
|
|
69
|
+
| { readonly kind: "converged"; readonly rows: number; readonly files: number }
|
|
70
|
+
| { readonly kind: "skipped" }
|
|
71
|
+
| { readonly kind: "blocked"; readonly reason: "busy" | "database" | "unsafe-path" }
|
|
72
|
+
| { readonly kind: "failed"; readonly reason: "worker-error" | "worker-died" | "timeout";
|
|
73
|
+
readonly message: string };
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Derive the durable history operation from admitted intent.
|
|
77
|
+
*
|
|
78
|
+
* `resumeHistory === false` is the user's explicit opt-out and outranks the
|
|
79
|
+
* direction entirely — an apply that quietly migrated history anyway would be
|
|
80
|
+
* the setting failing silently. `legacyMode` is the only case that routes
|
|
81
|
+
* history TO opencodex; the ordinary apply migrates to native so a later restore
|
|
82
|
+
* has nothing to undo.
|
|
83
|
+
*/
|
|
84
|
+
/**
|
|
85
|
+
* Test seam: the boundary suite exercises the validator without standing up a
|
|
86
|
+
* Worker whose malformed message it cannot easily emit from inside a test.
|
|
87
|
+
*/
|
|
88
|
+
export function isPlausibleWorkerResultForTests(
|
|
89
|
+
message: Record<string, unknown>,
|
|
90
|
+
requestId: string,
|
|
91
|
+
jobId: string,
|
|
92
|
+
): boolean {
|
|
93
|
+
return isPlausibleWorkerResult(message, requestId, jobId);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Reject a message that names a recognized type but lacks its payload.
|
|
98
|
+
*
|
|
99
|
+
* Without this, `{requestId, type:"done"}` reached an unchecked cast and read
|
|
100
|
+
* as `converged` with undefined fields — a success report for work that may not
|
|
101
|
+
* have happened. Each type is checked for the fields it actually carries, and
|
|
102
|
+
* the ids are matched against the request in flight, not merely present.
|
|
103
|
+
*/
|
|
104
|
+
function isPlausibleWorkerResult(
|
|
105
|
+
message: Record<string, unknown>,
|
|
106
|
+
requestId: string,
|
|
107
|
+
jobId: string,
|
|
108
|
+
): boolean {
|
|
109
|
+
if (message.requestId !== requestId || message.jobId !== jobId) return false;
|
|
110
|
+
switch (message.type) {
|
|
111
|
+
case "done":
|
|
112
|
+
return (message.outcome === "converged" || message.outcome === "skipped")
|
|
113
|
+
&& typeof message.rows === "number"
|
|
114
|
+
&& typeof message.files === "number";
|
|
115
|
+
case "blocked":
|
|
116
|
+
return message.reason === "busy" || message.reason === "database" || message.reason === "unsafe-path";
|
|
117
|
+
case "error":
|
|
118
|
+
return typeof message.message === "string";
|
|
119
|
+
default:
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function deriveCodexHistoryOperation(intent: {
|
|
125
|
+
readonly direction: "apply" | "restore";
|
|
126
|
+
readonly resumeHistory: boolean;
|
|
127
|
+
readonly legacyMode: boolean;
|
|
128
|
+
}): CodexHistoryWorkerOperation {
|
|
129
|
+
if (!intent.resumeHistory) return "skip";
|
|
130
|
+
if (intent.direction === "restore") return "restore-openai";
|
|
131
|
+
return intent.legacyMode ? "apply-opencodex" : "migrate-openai";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function classifyWorkerResult(result: HistoryWorkerResult): CodexHistoryJobOutcome {
|
|
135
|
+
if (result.type === "blocked") return { kind: "blocked", reason: result.reason };
|
|
136
|
+
if (result.type === "error") {
|
|
137
|
+
return { kind: "failed", reason: "worker-error", message: result.message };
|
|
138
|
+
}
|
|
139
|
+
return result.outcome === "skipped"
|
|
140
|
+
? { kind: "skipped" }
|
|
141
|
+
: { kind: "converged", rows: result.rows, files: result.files };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Run one history unit in a Worker and join it before returning.
|
|
146
|
+
*
|
|
147
|
+
* The join is not optional politeness: returning while the thread may still be
|
|
148
|
+
* mutating CODEX_HOME would let a caller's next step observe a half-applied
|
|
149
|
+
* transition, and would let a test suite reach its next file with a worker still
|
|
150
|
+
* exiting behind it.
|
|
151
|
+
*/
|
|
152
|
+
export async function runCodexHistoryJob(
|
|
153
|
+
request: CodexHistoryJobRequest,
|
|
154
|
+
options: { readonly timeoutMs?: number } = {},
|
|
155
|
+
): Promise<CodexHistoryJobOutcome> {
|
|
156
|
+
const requestId = randomUUID();
|
|
157
|
+
const jobId = randomUUID();
|
|
158
|
+
const timeoutMs = options.timeoutMs ?? WORKER_TIMEOUT_MS;
|
|
159
|
+
|
|
160
|
+
// `skip` writes nothing, so spawning a thread to decide that would be pure
|
|
161
|
+
// cost. It still returns a recorded outcome rather than silence.
|
|
162
|
+
if (request.operation === "skip") return { kind: "skipped" };
|
|
163
|
+
|
|
164
|
+
let worker: Worker;
|
|
165
|
+
try {
|
|
166
|
+
worker = new Worker(new URL("./history-worker.ts", import.meta.url).href);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
return {
|
|
169
|
+
kind: "failed",
|
|
170
|
+
reason: "worker-died",
|
|
171
|
+
message: error instanceof Error ? error.message : "history_worker_spawn_failed",
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return new Promise<CodexHistoryJobOutcome>(resolve => {
|
|
176
|
+
let settled = false;
|
|
177
|
+
const finish = (outcome: CodexHistoryJobOutcome) => {
|
|
178
|
+
if (settled) return;
|
|
179
|
+
settled = true;
|
|
180
|
+
clearTimeout(timer);
|
|
181
|
+
// Terminate and join before settling: see the note above.
|
|
182
|
+
const done = () => resolve(outcome);
|
|
183
|
+
try {
|
|
184
|
+
const terminated = worker.terminate() as unknown;
|
|
185
|
+
if (terminated && typeof (terminated as Promise<unknown>).then === "function") {
|
|
186
|
+
void (terminated as Promise<unknown>).then(done, done);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
} catch { /* already gone */ }
|
|
190
|
+
done();
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const timer = setTimeout(() => {
|
|
194
|
+
finish({ kind: "failed", reason: "timeout", message: "history_worker_timeout" });
|
|
195
|
+
}, timeoutMs);
|
|
196
|
+
|
|
197
|
+
const died = (detail: string): void => {
|
|
198
|
+
finish({ kind: "failed", reason: "worker-died", message: detail });
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
worker.onmessage = (event: MessageEvent<unknown>) => {
|
|
202
|
+
const data = event.data;
|
|
203
|
+
if (!data || typeof data !== "object") {
|
|
204
|
+
// Not the shape at all: this is a death signal, not silence. Ignoring it
|
|
205
|
+
// would let the watchdog call a dead Worker a timeout.
|
|
206
|
+
died("history_worker_malformed_message");
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const message = data as Record<string, unknown>;
|
|
210
|
+
// A reply for a different request is somebody else's; ignoring it is not
|
|
211
|
+
// the same as accepting it.
|
|
212
|
+
if (message.requestId !== requestId) return;
|
|
213
|
+
if (message.type !== "done" && message.type !== "blocked" && message.type !== "error") {
|
|
214
|
+
died("history_worker_unknown_message_type");
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (!isPlausibleWorkerResult(message, requestId, jobId)) {
|
|
218
|
+
// A recognized type with a missing payload read as `converged` with
|
|
219
|
+
// undefined fields once — success for work that may not have happened.
|
|
220
|
+
died("history_worker_malformed_payload");
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
finish(classifyWorkerResult(message as unknown as HistoryWorkerResult));
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
/*
|
|
227
|
+
* A Worker that exits early — without erroring — is not an error event, so
|
|
228
|
+
* without these it surfaced as `timeout` after the full wait. Both are death
|
|
229
|
+
* signals. Neither can overturn a settled success: `finish` is idempotent,
|
|
230
|
+
* and the Worker always closes after posting its result.
|
|
231
|
+
*/
|
|
232
|
+
worker.addEventListener("messageerror", () => died("history_worker_unserializable_message"));
|
|
233
|
+
worker.addEventListener("close", () => died("history_worker_closed_early"));
|
|
234
|
+
|
|
235
|
+
worker.onerror = (event: ErrorEvent) => {
|
|
236
|
+
finish({
|
|
237
|
+
kind: "failed",
|
|
238
|
+
reason: "worker-died",
|
|
239
|
+
message: event.message || "history_worker_failed",
|
|
240
|
+
});
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
worker.postMessage({
|
|
244
|
+
type: "run",
|
|
245
|
+
requestId,
|
|
246
|
+
jobId,
|
|
247
|
+
operation: request.operation,
|
|
248
|
+
canonicalCodexHome: request.canonicalCodexHome,
|
|
249
|
+
canonicalStateDbPath: request.canonicalStateDbPath,
|
|
250
|
+
canonicalBackupPath: request.canonicalBackupPath,
|
|
251
|
+
env: {
|
|
252
|
+
...(process.env.CODEX_HOME ? { CODEX_HOME: process.env.CODEX_HOME } : {}),
|
|
253
|
+
...(process.env.OPENCODEX_HOME ? { OPENCODEX_HOME: process.env.OPENCODEX_HOME } : {}),
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
}
|