@bitkyc08/opencodex 2.30.0-preview.20260821 → 2.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/gui/dist/assets/index-DkcRs1fL.js +102 -0
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/cursor/cursor-errors.ts +65 -6
- package/src/adapters/cursor/discovery.ts +7 -2
- package/src/adapters/cursor/effort-map.ts +6 -0
- package/src/adapters/cursor/h2-pool.ts +123 -0
- package/src/adapters/cursor/live-models.ts +21 -26
- package/src/adapters/cursor/live-transport.ts +213 -3
- package/src/adapters/cursor/native-exec-common.ts +17 -0
- package/src/adapters/cursor/native-exec.ts +9 -4
- package/src/adapters/cursor/protobuf-events.ts +5 -1
- package/src/adapters/cursor/protobuf-request.ts +11 -4
- package/src/adapters/cursor/tool-definitions.ts +20 -0
- package/src/adapters/cursor/transport.ts +10 -0
- package/src/adapters/cursor.ts +23 -5
- package/src/adapters/google.ts +16 -3
- package/src/adapters/openai-responses.ts +66 -20
- package/src/adapters/xai-web-search.ts +185 -0
- package/src/cli/agent.ts +2 -1
- package/src/cli/dispatch.ts +2 -2
- package/src/cli/doctor.ts +89 -0
- package/src/cli/help.ts +2 -0
- package/src/cli/registry.ts +7 -2
- package/src/codex/auth-context.ts +41 -2
- package/src/codex/catalog/effort.ts +1 -1
- package/src/codex/catalog/parsing.ts +2 -0
- package/src/codex/catalog/provider-fetch.ts +20 -5
- package/src/codex/coordinator-doctor.ts +332 -0
- package/src/codex/inject-coordination.ts +39 -6
- package/src/codex/transition-state.ts +12 -12
- package/src/generated/compatibility-version.json +74 -50
- package/src/lib/errors.ts +8 -2
- package/src/oauth/cursor.ts +21 -0
- package/src/providers/cursor-pool.ts +72 -0
- package/src/providers/derive.ts +3 -0
- package/src/providers/fastwire.ts +12 -1
- package/src/providers/openai-sidecar.ts +1 -0
- package/src/providers/registry.ts +25 -0
- package/src/providers/service-tier.ts +22 -7
- package/src/responses/custom-tool-compat.ts +24 -8
- package/src/responses/namespace-tool-compat.ts +2 -3
- package/src/router.ts +3 -0
- package/src/server/chat-completions.ts +4 -0
- package/src/server/chat-native.ts +20 -0
- package/src/server/management/agent-settings-routes.ts +16 -5
- package/src/server/management/config-routes.ts +25 -5
- package/src/server/management/vision-sidecar-options.ts +54 -19
- package/src/server/responses/compact.ts +1 -2
- package/src/server/responses/core.ts +54 -13
- package/src/service.ts +122 -14
- package/src/types/config.ts +9 -3
- package/src/types/provider.ts +6 -0
- package/src/usage/cost.ts +52 -38
- package/src/usage/expected-prices.ts +79 -9
- package/src/vision/backends.ts +97 -0
- package/src/vision/eligibility.ts +43 -22
- package/src/vision/index.ts +73 -5
- package/src/vision/routed-describe.ts +175 -0
- package/gui/dist/assets/index-eBA05kYB.js +0 -102
package/src/cli/doctor.ts
CHANGED
|
@@ -25,6 +25,11 @@ import { collectOrcaCodexHomeDiagnostic, resolveCodexHomeDir as resolveCodexHome
|
|
|
25
25
|
import { scanCodexAgentRolesWithTomlModelFallback } from "../codex/subagent-model-fallback";
|
|
26
26
|
import { findCodexOnPath, isWindowsInteropDir } from "../codex/shim";
|
|
27
27
|
import { countPendingOpencodexHistory } from "../codex/history-provider";
|
|
28
|
+
import {
|
|
29
|
+
inspectCodexCoordinator,
|
|
30
|
+
recoverZeroByteCodexCoordinator,
|
|
31
|
+
type CodexCoordinatorDiagnostic,
|
|
32
|
+
} from "../codex/coordinator-doctor";
|
|
28
33
|
import {
|
|
29
34
|
inspectAbandonedResponseStateTemps,
|
|
30
35
|
reclaimAbandonedResponseStateTemps,
|
|
@@ -684,6 +689,7 @@ export async function fetchServiceMemory(
|
|
|
684
689
|
const mb = (bytes: number): string => `${Math.round(bytes / (1024 * 1024))}MB`;
|
|
685
690
|
|
|
686
691
|
export const RECLAIM_RESPONSE_TEMPS_FLAG = "--reclaim-response-temps";
|
|
692
|
+
export const RECOVER_ZERO_BYTE_COORDINATOR_FLAG = "--recover-zero-byte-coordinator";
|
|
687
693
|
/** Matches the dry run's entry bound so report and reclaim agree on a large backlog. */
|
|
688
694
|
const RESPONSE_TEMP_RECLAIM_MAX_CLEANUPS = 4_096;
|
|
689
695
|
/** Names the subsystem: other components mint temps with the same shape and are not covered. */
|
|
@@ -734,6 +740,60 @@ export function formatResponseTempLines(
|
|
|
734
740
|
return lines;
|
|
735
741
|
}
|
|
736
742
|
|
|
743
|
+
export function formatCoordinatorDoctorLines(diagnostic: CodexCoordinatorDiagnostic): string[] {
|
|
744
|
+
const pathLine = diagnostic.path ? [` path: ${diagnostic.path}`] : [];
|
|
745
|
+
const evidenceLines = "evidence" in diagnostic && diagnostic.evidence
|
|
746
|
+
? [
|
|
747
|
+
` size: ${diagnostic.evidence.sizeBytes} bytes; user_version: ${diagnostic.evidence.schemaVersion}`,
|
|
748
|
+
` tables: ${diagnostic.evidence.tables.length === 0 ? "none" : diagnostic.evidence.tables.join(", ")}`,
|
|
749
|
+
` transition rows: ${diagnostic.evidence.transitionRows ?? "not inspected"}; singleton=1 rows: ${diagnostic.evidence.singletonRows ?? "not inspected"}`,
|
|
750
|
+
]
|
|
751
|
+
: [];
|
|
752
|
+
switch (diagnostic.kind) {
|
|
753
|
+
case "absent":
|
|
754
|
+
return [" ok native-write coordinator not created yet", ...pathLine];
|
|
755
|
+
case "ready":
|
|
756
|
+
return [" ok native-write coordinator has an authoritative transition row", ...pathLine, ...evidenceLines];
|
|
757
|
+
case "zero-byte":
|
|
758
|
+
return [
|
|
759
|
+
" !! native-write coordinator is a zero-byte remnant and has no authority",
|
|
760
|
+
...pathLine,
|
|
761
|
+
...evidenceLines,
|
|
762
|
+
` Action: stop the OpenCodex proxy/service, then run ocx doctor ${RECOVER_ZERO_BYTE_COORDINATOR_FLAG} --yes`,
|
|
763
|
+
];
|
|
764
|
+
case "unversioned-empty":
|
|
765
|
+
return [
|
|
766
|
+
" !! native-write coordinator is a non-empty unversioned database; automatic recovery is refused",
|
|
767
|
+
...pathLine,
|
|
768
|
+
...evidenceLines,
|
|
769
|
+
];
|
|
770
|
+
case "rowless":
|
|
771
|
+
return [
|
|
772
|
+
" !! native-write coordinator has schema version 1 but no authoritative row; automatic recovery is refused",
|
|
773
|
+
...pathLine,
|
|
774
|
+
...evidenceLines,
|
|
775
|
+
];
|
|
776
|
+
case "unversioned-nonempty":
|
|
777
|
+
return [
|
|
778
|
+
" !! native-write coordinator is unversioned and contains unknown tables; automatic recovery is refused",
|
|
779
|
+
...pathLine,
|
|
780
|
+
...evidenceLines,
|
|
781
|
+
];
|
|
782
|
+
case "unsupported":
|
|
783
|
+
return [
|
|
784
|
+
` !! native-write coordinator schema version ${diagnostic.version} is unsupported; automatic recovery is refused`,
|
|
785
|
+
...pathLine,
|
|
786
|
+
...evidenceLines,
|
|
787
|
+
];
|
|
788
|
+
case "changed":
|
|
789
|
+
return [" -- native-write coordinator changed during diagnosis; re-run ocx doctor", ...pathLine];
|
|
790
|
+
case "unsafe":
|
|
791
|
+
return [` !! native-write coordinator path is unsafe: ${diagnostic.reason}`, ...pathLine];
|
|
792
|
+
case "unreadable":
|
|
793
|
+
return [` !! native-write coordinator is unreadable: ${diagnostic.reason}`, ...pathLine, ...evidenceLines];
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
737
797
|
/** Render the doctor "Memory / runtime" section lines (testable without console capture). */
|
|
738
798
|
export function formatServiceMemoryLines(report: ServiceMemoryReport): string[] {
|
|
739
799
|
const lines: string[] = [];
|
|
@@ -846,6 +906,33 @@ export async function runDoctor(args: string[] = []): Promise<void> {
|
|
|
846
906
|
return;
|
|
847
907
|
}
|
|
848
908
|
|
|
909
|
+
if (args.includes(RECOVER_ZERO_BYTE_COORDINATOR_FLAG)) {
|
|
910
|
+
if (!args.includes("--yes")) {
|
|
911
|
+
console.log(`Recovery is explicit and creates a same-directory backup. Re-run: ocx doctor ${RECOVER_ZERO_BYTE_COORDINATOR_FLAG} --yes`);
|
|
912
|
+
process.exitCode = 1;
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
const diagnostics = readConfigDiagnostics().config;
|
|
916
|
+
const live = await findLiveProxy({
|
|
917
|
+
configFn: () => ({ port: diagnostics.port, hostname: diagnostics.hostname }),
|
|
918
|
+
});
|
|
919
|
+
if (live) {
|
|
920
|
+
console.log(`Recovery refused: OpenCodex proxy pid ${live.pid} is still running. Stop the proxy/service and retry.`);
|
|
921
|
+
process.exitCode = 1;
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
const recovered = recoverZeroByteCodexCoordinator();
|
|
925
|
+
if (!recovered.ok) {
|
|
926
|
+
console.log(`Recovery refused: ${recovered.reason}.`);
|
|
927
|
+
process.exitCode = 1;
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
console.log(`Moved the non-authoritative coordinator to ${recovered.backupPath}`);
|
|
931
|
+
console.log("Run `ocx sync` to retry Codex config injection. The backup was preserved and no Codex config/catalog file was changed by recovery.");
|
|
932
|
+
process.exitCode = 0;
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
|
|
849
936
|
console.log("opencodex doctor\n");
|
|
850
937
|
|
|
851
938
|
// Ordering note: the memory/runtime section renders after "Running proxy
|
|
@@ -1005,6 +1092,8 @@ export async function runDoctor(args: string[] = []): Promise<void> {
|
|
|
1005
1092
|
const reason = cause instanceof CodexUserIdentityRefusal ? cause.message : String(cause);
|
|
1006
1093
|
console.log(` -- history coordinator namespace refused: ${reason}`);
|
|
1007
1094
|
}
|
|
1095
|
+
console.log("\nCodex native-write coordinator");
|
|
1096
|
+
for (const line of formatCoordinatorDoctorLines(inspectCodexCoordinator())) console.log(line);
|
|
1008
1097
|
const pending = countPendingOpencodexHistory();
|
|
1009
1098
|
if (pending.failed) {
|
|
1010
1099
|
console.log(" -- state DB locked or unreadable (Codex app open?) — migration state unknown");
|
package/src/cli/help.ts
CHANGED
|
@@ -38,6 +38,8 @@ Usage:
|
|
|
38
38
|
ocx doctor Diagnose environment/network issues (WSL, proxy, ChatGPT reachability)
|
|
39
39
|
ocx doctor --reclaim-response-temps
|
|
40
40
|
Reclaim abandoned response-state temp files (works without a running proxy)
|
|
41
|
+
ocx doctor --recover-zero-byte-coordinator --yes
|
|
42
|
+
Back up a proven zero-byte Codex coordinator after stopping the proxy
|
|
41
43
|
ocx debug <scope> provider/usage/injection/claude on|off|status|reset
|
|
42
44
|
ocx login <provider> OAuth or API-key provider login
|
|
43
45
|
ocx logout <provider> Remove a stored OAuth login
|
package/src/cli/registry.ts
CHANGED
|
@@ -61,10 +61,11 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
|
|
|
61
61
|
},
|
|
62
62
|
{
|
|
63
63
|
name: "service",
|
|
64
|
-
usage: "ocx service [install|start|stop|status|uninstall|remove]",
|
|
64
|
+
usage: "ocx service [install|repair|restart|start|stop|status|uninstall|remove]",
|
|
65
65
|
summary: "Run as a background service.",
|
|
66
66
|
details: [
|
|
67
|
-
"With no subcommand, installs
|
|
67
|
+
"With no subcommand, installs when absent or repairs/restarts an existing service.",
|
|
68
|
+
"`restart` is an alias of `repair` and does not re-register an installed service.",
|
|
68
69
|
"Use `ocx service status` to see diagnostics and log paths.",
|
|
69
70
|
],
|
|
70
71
|
},
|
|
@@ -108,6 +109,10 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
|
|
|
108
109
|
name: "doctor",
|
|
109
110
|
usage: "ocx doctor",
|
|
110
111
|
summary: "Diagnose environment/network issues (paths, WSL /mnt, proxy env, ChatGPT reachability).",
|
|
112
|
+
details: [
|
|
113
|
+
"Default mode is observe-only and reports the native-write coordinator state and exact path.",
|
|
114
|
+
"After stopping the proxy/service, `--recover-zero-byte-coordinator --yes` moves only a proven zero-byte coordinator to a same-directory backup.",
|
|
115
|
+
],
|
|
111
116
|
},
|
|
112
117
|
{
|
|
113
118
|
name: "debug",
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHmac, randomBytes } from "node:crypto";
|
|
1
2
|
import {
|
|
2
3
|
CodexCredentialGenerationConflictError,
|
|
3
4
|
CodexCredentialRefreshLockTimeoutError,
|
|
@@ -38,6 +39,38 @@ import { getAccountQuota } from "./quota";
|
|
|
38
39
|
import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "../types";
|
|
39
40
|
import { FORWARD_HEADERS } from "../adapters/openai-responses";
|
|
40
41
|
import { captureConfigGeneration } from "../lib/state-store-sweeper";
|
|
42
|
+
import { retainedUtf8Bytes } from "../lib/admission";
|
|
43
|
+
|
|
44
|
+
const CODEX_AFFINITY_COMPONENT_MAX_BYTES = 512;
|
|
45
|
+
const CODEX_APP_AFFINITY_KEY = randomBytes(32);
|
|
46
|
+
|
|
47
|
+
function boundedCodexAffinityComponent(value: string | null): string | undefined {
|
|
48
|
+
const normalized = value?.trim();
|
|
49
|
+
if (!normalized) return undefined;
|
|
50
|
+
if (retainedUtf8Bytes(normalized) > CODEX_AFFINITY_COMPONENT_MAX_BYTES) return undefined;
|
|
51
|
+
return normalized;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Preserve Codex's parent-thread affinity when present. Desktop App requests can omit that
|
|
56
|
+
* header while retaining a stable session/thread pair, so derive an opaque process-local key
|
|
57
|
+
* only from the complete bounded pair. Raw identifiers and durable hashes never enter Pool state.
|
|
58
|
+
*/
|
|
59
|
+
export function codexPoolAffinityKey(headers: Headers): string | undefined {
|
|
60
|
+
const parentThreadId = boundedCodexAffinityComponent(headers.get("x-codex-parent-thread-id"));
|
|
61
|
+
if (parentThreadId) return parentThreadId;
|
|
62
|
+
|
|
63
|
+
const sessionId = boundedCodexAffinityComponent(headers.get("session-id"));
|
|
64
|
+
const threadId = boundedCodexAffinityComponent(headers.get("thread-id"));
|
|
65
|
+
if (!sessionId || !threadId) return undefined;
|
|
66
|
+
|
|
67
|
+
return `app:${createHmac("sha256", CODEX_APP_AFFINITY_KEY)
|
|
68
|
+
.update("opencodex-app-pool-affinity-v1\0")
|
|
69
|
+
.update(sessionId)
|
|
70
|
+
.update("\0")
|
|
71
|
+
.update(threadId)
|
|
72
|
+
.digest("base64url")}`;
|
|
73
|
+
}
|
|
41
74
|
|
|
42
75
|
export type CodexAuthContext =
|
|
43
76
|
| { kind: "main"; accountId: null }
|
|
@@ -50,6 +83,8 @@ export type CodexAuthContext =
|
|
|
50
83
|
chatgptAccountId: string;
|
|
51
84
|
/** Bypass Pool selection and suppress quota/transient failover for an exact selector. */
|
|
52
85
|
fixedAccount?: boolean;
|
|
86
|
+
/** Pool binding key; the Desktop fallback is an opaque process-local HMAC. */
|
|
87
|
+
affinityKey?: string;
|
|
53
88
|
/**
|
|
54
89
|
* Set when this request was admitted through an active quota cooldown as
|
|
55
90
|
* the account's single probe. Must be echoed into the upstream outcome so
|
|
@@ -71,6 +106,8 @@ export type CodexAuthContext =
|
|
|
71
106
|
chatgptAccountId: string;
|
|
72
107
|
/** Bypass Pool selection and suppress quota/transient failover for an exact selector. */
|
|
73
108
|
fixedAccount?: boolean;
|
|
109
|
+
/** See `pool.affinityKey`. */
|
|
110
|
+
affinityKey?: string;
|
|
74
111
|
/** See `pool.probeLeaseId`. */
|
|
75
112
|
probeLeaseId?: string;
|
|
76
113
|
quotaScope?: CodexQuotaScope;
|
|
@@ -343,6 +380,7 @@ export async function resolveCodexAuthContext(
|
|
|
343
380
|
}
|
|
344
381
|
return { kind: "main", accountId: null };
|
|
345
382
|
}
|
|
383
|
+
const affinityKey = fixedAccountId === undefined ? codexPoolAffinityKey(headers) : undefined;
|
|
346
384
|
const entitlementSnapshot = options.modelId && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(options.modelId)
|
|
347
385
|
? await (options.resolveCodexModelEntitlements ?? resolveCodexModelEntitlements)(config)
|
|
348
386
|
: undefined;
|
|
@@ -369,7 +407,6 @@ export async function resolveCodexAuthContext(
|
|
|
369
407
|
// routing inspect it. Selectors arriving after the fence skip reconciliation
|
|
370
408
|
// and may still route to non-main pool accounts without touching switch state.
|
|
371
409
|
if (!nativeMainReadsForbidden) reconcileMainCodexAccountRuntimeState();
|
|
372
|
-
const threadId = headers.get("x-codex-parent-thread-id");
|
|
373
410
|
const resolution = fixedAccountId !== undefined
|
|
374
411
|
? { status: "selected" as const, accountId: fixedAccountId }
|
|
375
412
|
: options.excludeAccountId
|
|
@@ -385,7 +422,7 @@ export async function resolveCodexAuthContext(
|
|
|
385
422
|
? { status: "selected" as const, accountId: selected }
|
|
386
423
|
: { status: "none" as const };
|
|
387
424
|
})()
|
|
388
|
-
: resolveCodexAccountForThreadDetailed(
|
|
425
|
+
: resolveCodexAccountForThreadDetailed(affinityKey ?? null, config, Date.now(), quotaScope, selectionOptions);
|
|
389
426
|
if (resolution.status === "expired") throw new CodexThreadAffinityExpiredError(resolution.accountId);
|
|
390
427
|
const selected = resolution.status === "selected" ? resolution.accountId : null;
|
|
391
428
|
if (!selected) {
|
|
@@ -500,6 +537,7 @@ export async function resolveCodexAuthContext(
|
|
|
500
537
|
accessToken: token.accessToken,
|
|
501
538
|
chatgptAccountId: token.chatgptAccountId,
|
|
502
539
|
...(fixedAccountId !== undefined ? { fixedAccount: true } : {}),
|
|
540
|
+
...(affinityKey ? { affinityKey } : {}),
|
|
503
541
|
...(quotaScope ? { quotaScope } : {}),
|
|
504
542
|
...(probeLeaseId ? { probeLeaseId } : {}),
|
|
505
543
|
...(probeQuotaScope ? { probeQuotaScope } : {}),
|
|
@@ -516,6 +554,7 @@ export async function resolveCodexAuthContext(
|
|
|
516
554
|
accessToken: token.accessToken,
|
|
517
555
|
chatgptAccountId: token.chatgptAccountId,
|
|
518
556
|
...(fixedAccountId !== undefined ? { fixedAccount: true } : {}),
|
|
557
|
+
...(affinityKey ? { affinityKey } : {}),
|
|
519
558
|
...(quotaScope ? { quotaScope } : {}),
|
|
520
559
|
...(probeLeaseId ? { probeLeaseId } : {}),
|
|
521
560
|
...(probeQuotaScope ? { probeQuotaScope } : {}),
|
|
@@ -147,7 +147,7 @@ export function applyCatalogModelMetadata(entry: RawEntry, model?: CatalogModel)
|
|
|
147
147
|
entry.service_tiers = [{
|
|
148
148
|
id: "priority",
|
|
149
149
|
name: "Fast",
|
|
150
|
-
description: "1.5x speed, increased usage",
|
|
150
|
+
description: model.fastTierDescription ?? "1.5x speed, increased usage",
|
|
151
151
|
}];
|
|
152
152
|
entry.additional_speed_tiers = ["fast"];
|
|
153
153
|
}
|
|
@@ -125,6 +125,8 @@ export interface CatalogModel {
|
|
|
125
125
|
supportsVerbosity?: boolean;
|
|
126
126
|
/** Whether this exact routed model has a verified OpenAI-compatible service tier. */
|
|
127
127
|
supportsServiceTier?: boolean;
|
|
128
|
+
/** Optional provider-specific copy for the advertised Fast tier. */
|
|
129
|
+
fastTierDescription?: string;
|
|
128
130
|
supportsReasoningSummaries?: boolean;
|
|
129
131
|
/**
|
|
130
132
|
* Codex tool calling mode for this routed model.
|
|
@@ -34,7 +34,8 @@ import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, r
|
|
|
34
34
|
import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive";
|
|
35
35
|
import {
|
|
36
36
|
captureFastPolicyAuthority,
|
|
37
|
-
|
|
37
|
+
fastPolicyForModel,
|
|
38
|
+
serviceTierSupportFromPolicy,
|
|
38
39
|
} from "../../providers/service-tier";
|
|
39
40
|
import type { FastPolicyAuthority } from "../../providers/fastwire";
|
|
40
41
|
import { effectiveGoogleMode, getProviderRegistryEntry, providerMatchesRegistryTransport } from "../../providers/registry";
|
|
@@ -647,8 +648,13 @@ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig,
|
|
|
647
648
|
const reasoningEfforts = configuredReasoningEfforts(prov, model.id);
|
|
648
649
|
const defaultReasoningEffort = modelRecordValue(prov.modelDefaultReasoningEfforts, model.id) ?? model.defaultReasoningEffort;
|
|
649
650
|
const supportsReasoningSummaries = configuredReasoningSummarySupport(prov, model.id);
|
|
650
|
-
const
|
|
651
|
-
const
|
|
651
|
+
const fastPolicy = fastPolicyForModel(prov, model.id, name);
|
|
652
|
+
const supportsServiceTier = serviceTierSupportFromPolicy(fastPolicy);
|
|
653
|
+
const {
|
|
654
|
+
supportsServiceTier: _staleServiceTier,
|
|
655
|
+
fastTierDescription: _staleFastTierDescription,
|
|
656
|
+
...modelWithoutServiceTier
|
|
657
|
+
} = model;
|
|
652
658
|
// 已发现窗口只允许被配置值压低;缺窗口时,已开的 Context cap 就是实际窗口。
|
|
653
659
|
const discoveredWindow = typeof model.contextWindow === "number" && model.contextWindow > 0
|
|
654
660
|
? model.contextWindow
|
|
@@ -671,6 +677,9 @@ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig,
|
|
|
671
677
|
...(defaultReasoningEffort ? { defaultReasoningEffort } : {}),
|
|
672
678
|
...(typeof supportsReasoningSummaries === "boolean" ? { supportsReasoningSummaries } : {}),
|
|
673
679
|
...(typeof supportsServiceTier === "boolean" ? { supportsServiceTier } : {}),
|
|
680
|
+
...(supportsServiceTier === true && fastPolicy.fastTierDescription !== undefined
|
|
681
|
+
? { fastTierDescription: fastPolicy.fastTierDescription }
|
|
682
|
+
: {}),
|
|
674
683
|
...(prov.adapter === "kiro" ? { supportsVerbosity: false } : {}),
|
|
675
684
|
// Default-on for openai-chat providers (explicit false opts out); other adapters
|
|
676
685
|
// advertise only on explicit opt-in.
|
|
@@ -1845,8 +1854,11 @@ async function gatherRoutedModelsUncached(
|
|
|
1845
1854
|
? nativeDefaultReasoningEffort(cm.modelId)
|
|
1846
1855
|
: undefined;
|
|
1847
1856
|
const supportsReasoningSummaries = configuredReasoningSummarySupport(rawProvider, cm.modelId);
|
|
1848
|
-
const
|
|
1849
|
-
?
|
|
1857
|
+
const fastPolicy = effectiveProvider
|
|
1858
|
+
? fastPolicyForModel(effectiveProvider, cm.modelId, cm.provider)
|
|
1859
|
+
: undefined;
|
|
1860
|
+
const supportsServiceTier = fastPolicy
|
|
1861
|
+
? serviceTierSupportFromPolicy(fastPolicy)
|
|
1850
1862
|
: undefined;
|
|
1851
1863
|
const base: CatalogModel = {
|
|
1852
1864
|
id: cm.modelId,
|
|
@@ -1883,6 +1895,9 @@ async function gatherRoutedModelsUncached(
|
|
|
1883
1895
|
...(Array.isArray(cm.reasoningEfforts) ? { reasoningEfforts: [...cm.reasoningEfforts] } : {}),
|
|
1884
1896
|
...(cm.defaultReasoningEffort ? { defaultReasoningEffort: cm.defaultReasoningEffort } : {}),
|
|
1885
1897
|
...(typeof supportsServiceTier === "boolean" ? { supportsServiceTier } : {}),
|
|
1898
|
+
...(supportsServiceTier === true && fastPolicy?.fastTierDescription !== undefined
|
|
1899
|
+
? { fastTierDescription: fastPolicy.fastTierDescription }
|
|
1900
|
+
: {}),
|
|
1886
1901
|
...(cm.codexToolMode !== undefined
|
|
1887
1902
|
? { codexToolMode: cm.codexToolMode }
|
|
1888
1903
|
: effectiveProvider?.codexToolMode !== undefined
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Observe and explicitly quarantine non-authoritative native-write coordinators.
|
|
3
|
+
*
|
|
4
|
+
* Default doctor runs use immutable SQLite reads so diagnostics cannot create
|
|
5
|
+
* WAL/SHM sidecars. Recovery is deliberately opt-in and moves, never deletes,
|
|
6
|
+
* only a file that is still the same private regular file observed beforehand.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import {
|
|
10
|
+
existsSync,
|
|
11
|
+
lstatSync,
|
|
12
|
+
realpathSync,
|
|
13
|
+
renameSync,
|
|
14
|
+
type Stats,
|
|
15
|
+
} from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { pathToFileURL } from "node:url";
|
|
18
|
+
|
|
19
|
+
import { Database, constants as sqliteConstants } from "bun:sqlite";
|
|
20
|
+
|
|
21
|
+
import { resolveCodexHomeDir } from "./home";
|
|
22
|
+
import {
|
|
23
|
+
CodexUserIdentityRefusal,
|
|
24
|
+
probeCodexCoordinatorNamespace,
|
|
25
|
+
resolveEffectiveUserIdentity,
|
|
26
|
+
samePathIdentity,
|
|
27
|
+
} from "./user-identity";
|
|
28
|
+
import {
|
|
29
|
+
CODEX_COORDINATOR_SCHEMA_VERSION,
|
|
30
|
+
readCodexCoordinatorState,
|
|
31
|
+
} from "./transition-state";
|
|
32
|
+
|
|
33
|
+
const IMMUTABLE_READONLY_FLAGS =
|
|
34
|
+
sqliteConstants.SQLITE_OPEN_READONLY | sqliteConstants.SQLITE_OPEN_URI;
|
|
35
|
+
|
|
36
|
+
export type FileIdentity = Pick<Stats, "dev" | "ino" | "size" | "mtimeMs" | "ctimeMs">;
|
|
37
|
+
|
|
38
|
+
export interface CodexCoordinatorDiagnosticEvidence {
|
|
39
|
+
sizeBytes: number;
|
|
40
|
+
schemaVersion: number;
|
|
41
|
+
tables: readonly string[];
|
|
42
|
+
transitionRows: number | null;
|
|
43
|
+
singletonRows: number | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type CodexCoordinatorDiagnostic =
|
|
47
|
+
| { kind: "absent"; path: string | null }
|
|
48
|
+
| { kind: "zero-byte"; path: string; identity: FileIdentity; evidence: CodexCoordinatorDiagnosticEvidence }
|
|
49
|
+
| { kind: "unversioned-empty"; path: string; identity: FileIdentity; evidence: CodexCoordinatorDiagnosticEvidence }
|
|
50
|
+
| { kind: "unversioned-nonempty"; path: string; identity: FileIdentity; evidence: CodexCoordinatorDiagnosticEvidence }
|
|
51
|
+
| { kind: "rowless"; path: string; identity: FileIdentity; evidence: CodexCoordinatorDiagnosticEvidence }
|
|
52
|
+
| { kind: "ready"; path: string; identity: FileIdentity; evidence: CodexCoordinatorDiagnosticEvidence }
|
|
53
|
+
| { kind: "unsupported"; path: string; identity: FileIdentity; version: number; evidence: CodexCoordinatorDiagnosticEvidence }
|
|
54
|
+
| { kind: "changed"; path: string }
|
|
55
|
+
| { kind: "unsafe"; path: string | null; reason: string }
|
|
56
|
+
| { kind: "unreadable"; path: string; reason: string; evidence?: CodexCoordinatorDiagnosticEvidence };
|
|
57
|
+
|
|
58
|
+
export type CodexCoordinatorRecoveryResult =
|
|
59
|
+
| { ok: true; backupPath: string }
|
|
60
|
+
| { ok: false; reason: string };
|
|
61
|
+
|
|
62
|
+
function errorCode(error: unknown): string {
|
|
63
|
+
return error && typeof error === "object" && "code" in error
|
|
64
|
+
? String((error as { code?: unknown }).code)
|
|
65
|
+
: "";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function sameIdentity(left: FileIdentity, right: FileIdentity): boolean {
|
|
69
|
+
return left.dev === right.dev
|
|
70
|
+
&& left.ino === right.ino
|
|
71
|
+
&& left.size === right.size
|
|
72
|
+
&& left.mtimeMs === right.mtimeMs
|
|
73
|
+
&& left.ctimeMs === right.ctimeMs;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function sameNodeAndSize(left: FileIdentity, right: FileIdentity): boolean {
|
|
77
|
+
return left.dev === right.dev && left.ino === right.ino && left.size === right.size;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function coordinatorPathWithoutCreation(): { kind: "absent"; path: string | null } | { kind: "path"; path: string } {
|
|
81
|
+
const identity = resolveEffectiveUserIdentity();
|
|
82
|
+
const canonicalCodexHome = realpathSync.native(resolveCodexHomeDir());
|
|
83
|
+
const namespace = probeCodexCoordinatorNamespace(identity);
|
|
84
|
+
if (namespace.status === "missing") return { kind: "absent", path: null };
|
|
85
|
+
|
|
86
|
+
const locks = join(namespace.root, "native-write-locks");
|
|
87
|
+
let locksEntry: Stats;
|
|
88
|
+
try {
|
|
89
|
+
locksEntry = lstatSync(locks);
|
|
90
|
+
} catch (cause) {
|
|
91
|
+
if (errorCode(cause) === "ENOENT") {
|
|
92
|
+
const digest = createHash("sha256").update(canonicalCodexHome).digest("hex");
|
|
93
|
+
return { kind: "absent", path: join(locks, `${digest}.sqlite`) };
|
|
94
|
+
}
|
|
95
|
+
throw new CodexUserIdentityRefusal("The coordinator lock directory cannot be inspected.", { cause });
|
|
96
|
+
}
|
|
97
|
+
if (locksEntry.isSymbolicLink() || !locksEntry.isDirectory()) {
|
|
98
|
+
throw new CodexUserIdentityRefusal("The coordinator lock namespace is not a real directory.");
|
|
99
|
+
}
|
|
100
|
+
if (identity.platform === "posix") {
|
|
101
|
+
if (locksEntry.uid !== identity.uid || (locksEntry.mode & 0o777) !== 0o700) {
|
|
102
|
+
throw new CodexUserIdentityRefusal("The coordinator lock namespace has unsafe ownership or permissions.");
|
|
103
|
+
}
|
|
104
|
+
} else if (!samePathIdentity(realpathSync.native(locks), locks, "win32")) {
|
|
105
|
+
throw new CodexUserIdentityRefusal("The coordinator lock namespace is redirected by a junction or reparse point.");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const digest = createHash("sha256").update(canonicalCodexHome).digest("hex");
|
|
109
|
+
return { kind: "path", path: join(locks, `${digest}.sqlite`) };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function inspectTarget(
|
|
113
|
+
path: string,
|
|
114
|
+
options: { allowSqliteSidecars?: boolean } = {},
|
|
115
|
+
): { kind: "absent" } | { kind: "file"; identity: FileIdentity } | { kind: "unsafe"; reason: string } {
|
|
116
|
+
let entry: Stats;
|
|
117
|
+
try {
|
|
118
|
+
entry = lstatSync(path);
|
|
119
|
+
} catch (cause) {
|
|
120
|
+
if (errorCode(cause) === "ENOENT") return { kind: "absent" };
|
|
121
|
+
return { kind: "unsafe", reason: "the coordinator file cannot be inspected" };
|
|
122
|
+
}
|
|
123
|
+
if (entry.isSymbolicLink() || !entry.isFile()) {
|
|
124
|
+
return { kind: "unsafe", reason: "the coordinator path is not a real file" };
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
if (!samePathIdentity(realpathSync.native(path), path)) {
|
|
128
|
+
return { kind: "unsafe", reason: "the coordinator path is redirected" };
|
|
129
|
+
}
|
|
130
|
+
} catch {
|
|
131
|
+
return { kind: "unsafe", reason: "the coordinator path cannot be resolved" };
|
|
132
|
+
}
|
|
133
|
+
if (process.platform !== "win32") {
|
|
134
|
+
const uid = process.getuid?.();
|
|
135
|
+
if (uid === undefined || entry.uid !== uid || (entry.mode & 0o777) !== 0o600) {
|
|
136
|
+
return { kind: "unsafe", reason: "the coordinator file has unsafe ownership or permissions" };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (!options.allowSqliteSidecars) {
|
|
140
|
+
for (const suffix of ["-journal", "-wal", "-shm"]) {
|
|
141
|
+
if (existsSync(`${path}${suffix}`)) {
|
|
142
|
+
return { kind: "unsafe", reason: `the coordinator has an active SQLite ${suffix.slice(1)} sidecar` };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { kind: "file", identity: entry };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function classifyOpenedDatabase(
|
|
150
|
+
database: Database,
|
|
151
|
+
path: string,
|
|
152
|
+
identity: FileIdentity,
|
|
153
|
+
): CodexCoordinatorDiagnostic {
|
|
154
|
+
const version = database.query<{ user_version: number }, []>("PRAGMA user_version").get()?.user_version ?? 0;
|
|
155
|
+
const tables = database.query<{ name: string }, []>(
|
|
156
|
+
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
|
|
157
|
+
).all().map(row => row.name);
|
|
158
|
+
const baseEvidence = {
|
|
159
|
+
sizeBytes: identity.size,
|
|
160
|
+
schemaVersion: version,
|
|
161
|
+
tables,
|
|
162
|
+
transitionRows: null,
|
|
163
|
+
singletonRows: null,
|
|
164
|
+
} satisfies CodexCoordinatorDiagnosticEvidence;
|
|
165
|
+
if (version === 0) {
|
|
166
|
+
const evidence = tables.length === 0
|
|
167
|
+
? { ...baseEvidence, transitionRows: 0, singletonRows: 0 }
|
|
168
|
+
: baseEvidence;
|
|
169
|
+
return tables.length === 0
|
|
170
|
+
? { kind: "unversioned-empty", path, identity, evidence }
|
|
171
|
+
: { kind: "unversioned-nonempty", path, identity, evidence };
|
|
172
|
+
}
|
|
173
|
+
if (version !== CODEX_COORDINATOR_SCHEMA_VERSION) {
|
|
174
|
+
return { kind: "unsupported", path, identity, version, evidence: baseEvidence };
|
|
175
|
+
}
|
|
176
|
+
if (tables.length !== 1 || tables[0] !== "codex_transition_state") {
|
|
177
|
+
return tables.length === 0
|
|
178
|
+
? { kind: "rowless", path, identity, evidence: baseEvidence }
|
|
179
|
+
: { kind: "unreadable", path, reason: "the coordinator contains unexpected tables", evidence: baseEvidence };
|
|
180
|
+
}
|
|
181
|
+
let rowCounts: { total: number; singleton: number } | null;
|
|
182
|
+
try {
|
|
183
|
+
rowCounts = database.query<{ total: number; singleton: number }, []>(
|
|
184
|
+
"SELECT count(*) AS total, sum(CASE WHEN singleton = 1 THEN 1 ELSE 0 END) AS singleton FROM codex_transition_state",
|
|
185
|
+
).get() ?? null;
|
|
186
|
+
} catch {
|
|
187
|
+
return {
|
|
188
|
+
kind: "unreadable",
|
|
189
|
+
path,
|
|
190
|
+
reason: "the transition table schema is not recognized",
|
|
191
|
+
evidence: baseEvidence,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
const evidence = {
|
|
195
|
+
...baseEvidence,
|
|
196
|
+
transitionRows: rowCounts?.total ?? null,
|
|
197
|
+
singletonRows: rowCounts?.singleton ?? null,
|
|
198
|
+
};
|
|
199
|
+
if (!rowCounts || rowCounts.total === 0) return { kind: "rowless", path, identity, evidence };
|
|
200
|
+
if (rowCounts.total !== 1 || rowCounts.singleton !== 1) {
|
|
201
|
+
return {
|
|
202
|
+
kind: "unreadable",
|
|
203
|
+
path,
|
|
204
|
+
reason: "the coordinator does not contain exactly one singleton row",
|
|
205
|
+
evidence,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
readCodexCoordinatorState(database);
|
|
210
|
+
} catch {
|
|
211
|
+
return {
|
|
212
|
+
kind: "unreadable",
|
|
213
|
+
path,
|
|
214
|
+
reason: "the authoritative transition row is malformed",
|
|
215
|
+
evidence,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
return { kind: "ready", path, identity, evidence };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function inspectCodexCoordinator(): CodexCoordinatorDiagnostic {
|
|
222
|
+
let resolved: ReturnType<typeof coordinatorPathWithoutCreation>;
|
|
223
|
+
try {
|
|
224
|
+
resolved = coordinatorPathWithoutCreation();
|
|
225
|
+
} catch (cause) {
|
|
226
|
+
return {
|
|
227
|
+
kind: "unsafe",
|
|
228
|
+
path: null,
|
|
229
|
+
reason: cause instanceof Error ? cause.message : String(cause),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
if (resolved.kind === "absent") return resolved;
|
|
233
|
+
return inspectCodexCoordinatorPath(resolved.path);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Inspect one already-resolved coordinator path without creating SQLite state. */
|
|
237
|
+
export function inspectCodexCoordinatorPath(path: string): CodexCoordinatorDiagnostic {
|
|
238
|
+
const target = inspectTarget(path);
|
|
239
|
+
if (target.kind === "absent") return { kind: "absent", path };
|
|
240
|
+
if (target.kind === "unsafe") return { kind: "unsafe", path, reason: target.reason };
|
|
241
|
+
|
|
242
|
+
let database: Database | undefined;
|
|
243
|
+
try {
|
|
244
|
+
const uri = `${pathToFileURL(path).href}?immutable=1`;
|
|
245
|
+
database = new Database(uri, IMMUTABLE_READONLY_FLAGS);
|
|
246
|
+
const result = classifyOpenedDatabase(database, path, target.identity);
|
|
247
|
+
const after = inspectTarget(path);
|
|
248
|
+
if (after.kind !== "file" || !sameIdentity(target.identity, after.identity)) {
|
|
249
|
+
return { kind: "changed", path };
|
|
250
|
+
}
|
|
251
|
+
// Size alone is not evidence that this is a non-authoritative remnant.
|
|
252
|
+
// Query the immutable snapshot too, so the recovery label means all three
|
|
253
|
+
// facts were observed together: zero bytes, schema version zero, no tables.
|
|
254
|
+
if (target.identity.size === 0 && result.kind === "unversioned-empty") {
|
|
255
|
+
return { kind: "zero-byte", path, identity: target.identity, evidence: result.evidence };
|
|
256
|
+
}
|
|
257
|
+
return result;
|
|
258
|
+
} catch (cause) {
|
|
259
|
+
return { kind: "unreadable", path, reason: cause instanceof Error ? cause.message : String(cause) };
|
|
260
|
+
} finally {
|
|
261
|
+
try { database?.close(); } catch { /* diagnostics already completed */ }
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function recoverable(diagnostic: CodexCoordinatorDiagnostic): diagnostic is Extract<
|
|
266
|
+
CodexCoordinatorDiagnostic,
|
|
267
|
+
{ kind: "zero-byte" }
|
|
268
|
+
> {
|
|
269
|
+
return diagnostic.kind === "zero-byte";
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function backupTimestamp(now: Date): string {
|
|
273
|
+
return now.toISOString().replace(/[-:.]/g, "");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function recoverZeroByteCodexCoordinator(now = new Date()): CodexCoordinatorRecoveryResult {
|
|
277
|
+
const observed = inspectCodexCoordinator();
|
|
278
|
+
if (!recoverable(observed)) {
|
|
279
|
+
if (observed.kind === "unsafe" || observed.kind === "unreadable") {
|
|
280
|
+
return { ok: false, reason: `coordinator state is ${observed.kind}: ${observed.reason}` };
|
|
281
|
+
}
|
|
282
|
+
return { ok: false, reason: `coordinator state is ${observed.kind}, not a recoverable zero-byte remnant` };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
let database: Database | undefined;
|
|
286
|
+
let transactionOpen = false;
|
|
287
|
+
try {
|
|
288
|
+
database = new Database(observed.path, { readwrite: true, create: false });
|
|
289
|
+
database.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE");
|
|
290
|
+
transactionOpen = true;
|
|
291
|
+
const lockedEntry = inspectTarget(observed.path, { allowSqliteSidecars: true });
|
|
292
|
+
// SQLite may update file timestamps merely by opening a zero-byte database
|
|
293
|
+
// for BEGIN IMMEDIATE. Device/inode/size are the stable identity here; the
|
|
294
|
+
// transaction excludes content writers while we reclassify the database.
|
|
295
|
+
if (lockedEntry.kind !== "file" || !sameNodeAndSize(observed.identity, lockedEntry.identity)) {
|
|
296
|
+
return { ok: false, reason: "the coordinator changed before recovery acquired its SQLite lock" };
|
|
297
|
+
}
|
|
298
|
+
if (lockedEntry.identity.size !== 0) {
|
|
299
|
+
return { ok: false, reason: "the coordinator stopped being zero-byte before recovery" };
|
|
300
|
+
}
|
|
301
|
+
database.exec("ROLLBACK");
|
|
302
|
+
transactionOpen = false;
|
|
303
|
+
database.close();
|
|
304
|
+
database = undefined;
|
|
305
|
+
|
|
306
|
+
const finalEntry = inspectTarget(observed.path);
|
|
307
|
+
if (finalEntry.kind !== "file" || !sameIdentity(lockedEntry.identity, finalEntry.identity)) {
|
|
308
|
+
return { ok: false, reason: "the coordinator changed before the backup move" };
|
|
309
|
+
}
|
|
310
|
+
const backupPath = `${observed.path}.zero-byte-backup-${backupTimestamp(now)}`;
|
|
311
|
+
if (existsSync(backupPath)) return { ok: false, reason: "the same-directory backup path already exists" };
|
|
312
|
+
renameSync(observed.path, backupPath);
|
|
313
|
+
const backupEntry = inspectTarget(backupPath);
|
|
314
|
+
// The rename itself can advance ctime, so post-move verification uses the
|
|
315
|
+
// stable filesystem object and byte size. The full timestamp identity was
|
|
316
|
+
// already revalidated immediately before rename while the source existed.
|
|
317
|
+
if (backupEntry.kind !== "file" || !sameNodeAndSize(finalEntry.identity, backupEntry.identity) || existsSync(observed.path)) {
|
|
318
|
+
return { ok: false, reason: "the coordinator backup move could not be verified" };
|
|
319
|
+
}
|
|
320
|
+
return { ok: true, backupPath };
|
|
321
|
+
} catch (cause) {
|
|
322
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
323
|
+
const busy = errorCode(cause) === "SQLITE_BUSY" || errorCode(cause) === "SQLITE_LOCKED"
|
|
324
|
+
|| /database (?:is|table is) locked/i.test(message);
|
|
325
|
+
return { ok: false, reason: busy ? "the coordinator is busy; stop active sync/service writers and retry" : message };
|
|
326
|
+
} finally {
|
|
327
|
+
if (transactionOpen) {
|
|
328
|
+
try { database?.exec("ROLLBACK"); } catch { /* close releases the lock */ }
|
|
329
|
+
}
|
|
330
|
+
try { database?.close(); } catch { /* recovery already completed */ }
|
|
331
|
+
}
|
|
332
|
+
}
|