@yansigit/opencodex 2.36.1-dev.20260829.47 → 2.36.1-dev.20260829.50
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/gui/dist/assets/{ApiKeys-DAuYQsGJ.js → ApiKeys-BLPOvK4z.js} +1 -1
- package/gui/dist/assets/{Claude-bN_80RT8.js → Claude-BbHv3EU7.js} +1 -1
- package/gui/dist/assets/{CodexSet-D3dWX_ay.js → CodexSet-hgu5hES-.js} +1 -1
- package/gui/dist/assets/{FileIntegrationPage-BGyzkPs2.js → FileIntegrationPage-RCmDvvsx.js} +1 -1
- package/gui/dist/assets/{Grok-CuaKd1TT.js → Grok-DH-ggRqA.js} +1 -1
- package/gui/dist/assets/{Integrations-C7NGQoxR.js → Integrations-D6m5E2-T.js} +2 -2
- package/gui/dist/assets/{IntegrationsOverview-DjVorYO5.js → IntegrationsOverview-C1rbnXV7.js} +1 -1
- package/gui/dist/assets/{Logs-B21a-9xe.js → Logs-Bqb0vYRk.js} +1 -1
- package/gui/dist/assets/{Models-CN7Q3kuK.js → Models-gMnzHf0c.js} +1 -1
- package/gui/dist/assets/{NumberStepper-BWg90tEs.js → NumberStepper-awaoZNlj.js} +1 -1
- package/gui/dist/assets/{Providers-CP_i-7LS.js → Providers-Bo6wuQMa.js} +1 -1
- package/gui/dist/assets/{RestoreDialog-3M0NqCiD.js → RestoreDialog-DX7SGWIk.js} +1 -1
- package/gui/dist/assets/{Startup-BVPFoP2s.js → Startup-B1htiLU9.js} +1 -1
- package/gui/dist/assets/{Storage-BdsG9ypz.js → Storage-B3MfosmS.js} +1 -1
- package/gui/dist/assets/{Subagents-CguR_bGQ.js → Subagents-L6Q2V9PZ.js} +1 -1
- package/gui/dist/assets/{Usage-C0kKi9_H.js → Usage-xbZnkDoj.js} +1 -1
- package/gui/dist/assets/{codex-stale-banner-D4gvSWnO.js → codex-stale-banner-W4FhikN7.js} +1 -1
- package/gui/dist/assets/{data-surface-BtO3lwam.js → data-surface-B8PX8rjm.js} +1 -1
- package/gui/dist/assets/{data-surface-BCycbObp.js → data-surface-BbBMp0Wj.js} +1 -1
- package/gui/dist/assets/{index-CU1jE0st.js → index-JEC_6CUW.js} +3 -3
- package/gui/dist/assets/{model-display-D2elXkuE.js → model-display-BvLWy9D5.js} +1 -1
- package/gui/dist/assets/{provider-payload-DfkXWE7v.js → provider-payload-BRfSsonS.js} +1 -1
- package/gui/dist/assets/{section-tabs-BP7gEPK6.js → section-tabs-B8sLMiz6.js} +1 -1
- package/gui/dist/assets/shared-DI18-uF7.js +69 -0
- package/gui/dist/index.html +2 -2
- package/package.json +3 -1
- package/src/adapters/base.ts +26 -0
- package/src/adapters/cursor/catalog.ts +541 -0
- package/src/adapters/cursor/cursor-errors.ts +15 -0
- package/src/adapters/cursor/discovery.ts +34 -41
- package/src/adapters/cursor/envelope-echo.ts +128 -0
- package/src/adapters/cursor/request-builder.ts +19 -12
- package/src/adapters/cursor/tool-definitions.ts +2 -1
- package/src/adapters/cursor/tool-result-normalize.ts +23 -31
- package/src/adapters/cursor.ts +21 -2
- package/src/adapters/exec-tool-result-normalize.ts +99 -0
- package/src/adapters/google-antigravity-replay.ts +71 -2
- package/src/adapters/google.ts +19 -4
- package/src/adapters/kiro-constants.ts +12 -0
- package/src/adapters/kiro.ts +128 -11
- package/src/adapters/openai-chat.ts +16 -2
- package/src/adapters/openai-responses.ts +15 -2
- package/src/adapters/tool-catalog-nudge.ts +2 -1
- package/src/adapters/xai-web-search.ts +10 -14
- package/src/claude/outbound.ts +14 -3
- package/src/cli/access.ts +46 -3
- package/src/cli/account-api.ts +93 -16
- package/src/cli/account-extended.ts +262 -36
- package/src/cli/account-main.ts +12 -12
- package/src/cli/account.ts +40 -10
- package/src/cli/agent.ts +8 -1
- package/src/cli/capabilities-command.ts +94 -0
- package/src/cli/capabilities.ts +535 -0
- package/src/cli/claude-desktop.ts +31 -11
- package/src/cli/config-command.ts +7 -3
- package/src/cli/dispatch.ts +195 -27
- package/src/cli/doctor.ts +100 -1
- package/src/cli/help.ts +11 -2
- package/src/cli/index.ts +19 -3
- package/src/cli/init.ts +2 -2
- package/src/cli/inspect.ts +230 -0
- package/src/cli/observe.ts +11 -3
- package/src/cli/provider.ts +58 -74
- package/src/cli/registry.ts +34 -2
- package/src/cli/runtime-api.ts +51 -7
- package/src/cli/status.ts +16 -0
- package/src/cli/storage.ts +234 -0
- package/src/cli/system-command.ts +16 -0
- package/src/cli/usage-report.ts +52 -2
- package/src/cli/version-skew.ts +46 -0
- package/src/codex/account-label.ts +21 -0
- package/src/codex/catalog/provider-fetch.ts +4 -0
- package/src/codex/convergence.ts +110 -15
- package/src/codex/transition-state.ts +12 -3
- package/src/compatibility/openai-responses.ts +9 -1
- package/src/config.ts +209 -11
- package/src/generated/compatibility-version.json +115 -75
- package/src/integrations/ownership-policy.ts +24 -5
- package/src/integrations/ownership.ts +36 -2
- package/src/integrations/state.ts +40 -7
- package/src/integrations/writer.ts +21 -3
- package/src/lib/admin-secrets.ts +24 -0
- package/src/lib/errors.ts +25 -1
- package/src/lib/service-secrets.ts +15 -0
- package/src/oauth/index.ts +143 -82
- package/src/oauth/login-cli.ts +53 -11
- package/src/oauth/store.ts +16 -6
- package/src/providers/alibaba-region-startup.ts +31 -11
- package/src/providers/api-keys.ts +58 -34
- package/src/providers/key-failover.ts +59 -46
- package/src/providers/label.ts +34 -1
- package/src/providers/model-rename-startup.ts +25 -7
- package/src/providers/openai-tier-startup.ts +38 -14
- package/src/responses/turn-termination.ts +107 -0
- package/src/server/index.ts +10 -0
- package/src/server/management/context.ts +10 -3
- package/src/server/management/logs-usage-routes.ts +24 -43
- package/src/server/management/model-routes.ts +284 -165
- package/src/server/management/oauth-account-routes.ts +93 -15
- package/src/server/management/provider-routes.ts +200 -87
- package/src/server/management/route-registry.ts +317 -0
- package/src/server/proxy-liveness.ts +27 -4
- package/src/server/request-log.ts +37 -17
- package/src/server/responses/core.ts +80 -4
- package/src/server/responses/policy-fallback.ts +1 -1
- package/src/service.ts +34 -0
- package/src/storage/policy-input.ts +166 -0
- package/src/storage/policy-job.ts +14 -4
- package/src/storage/policy.ts +115 -221
- package/src/usage/log.ts +32 -6
- package/src/usage/summary.ts +22 -34
- package/gui/dist/assets/shared-BdYGV-yJ.js +0 -69
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ocx storage` — the archived-session cleanup, trash, and cleanup-policy surface (wp7).
|
|
3
|
+
*
|
|
4
|
+
* Every route here existed with no CLI caller, so reclaiming disk space was dashboard-only.
|
|
5
|
+
* Three of them delete or move operator data, and the rules for those are deliberate:
|
|
6
|
+
*
|
|
7
|
+
* 1. **Default to preview.** `ocx storage cleanup --percent N` runs the preview route and prints
|
|
8
|
+
* what WOULD be freed, then exits 0 having mutated nothing.
|
|
9
|
+
* 2. **`--yes` is required to mutate.** There is no interactive prompt: an agent cannot answer
|
|
10
|
+
* one, and a prompt an agent can answer is not a safety boundary.
|
|
11
|
+
* 3. **`--json` on the preview emits the candidate list**, so an agent can decide from data
|
|
12
|
+
* rather than from a sentence.
|
|
13
|
+
*
|
|
14
|
+
* This is the opposite of the GitHub star POST, which no flag can authorize: cleanup spends the
|
|
15
|
+
* operator's DATA, which they can delegate, while starring spends their IDENTITY, which they
|
|
16
|
+
* cannot delegate to an agent.
|
|
17
|
+
*/
|
|
18
|
+
import {
|
|
19
|
+
CliUsageError,
|
|
20
|
+
printData,
|
|
21
|
+
rejectArgs,
|
|
22
|
+
runCliAction,
|
|
23
|
+
runtimeRequest,
|
|
24
|
+
summaryLines,
|
|
25
|
+
takeFlag,
|
|
26
|
+
takeIntegerOption,
|
|
27
|
+
takeOption,
|
|
28
|
+
type RuntimeApiDeps,
|
|
29
|
+
} from "./runtime-api";
|
|
30
|
+
|
|
31
|
+
const USAGE = `Usage:
|
|
32
|
+
ocx storage report [--json]
|
|
33
|
+
ocx storage cleanup --percent <0-100> [--mode <quarantine|permanent>] [--yes] [--json]
|
|
34
|
+
ocx storage trash [list] [--json]
|
|
35
|
+
ocx storage trash restore <entry-id> [--yes] [--json]
|
|
36
|
+
ocx storage policy [show] [--json]
|
|
37
|
+
ocx storage policy set [--enabled <true|false>] [--percent <0-100>]
|
|
38
|
+
[--mode <quarantine|permanent>] [--schedule <startup|daily|weekly|manual>] [--json]
|
|
39
|
+
ocx storage policy run [--yes] [--json]
|
|
40
|
+
|
|
41
|
+
Cleanup and restore MUTATE operator data and require --yes.
|
|
42
|
+
Without --yes, cleanup prints the preview and changes nothing.`;
|
|
43
|
+
|
|
44
|
+
/** The digest binds a run to the preview it was authorized against. */
|
|
45
|
+
interface CleanupPreview {
|
|
46
|
+
percent?: number;
|
|
47
|
+
count?: number;
|
|
48
|
+
bytes?: number;
|
|
49
|
+
digest?: string;
|
|
50
|
+
candidates?: { relPath?: string; bytes?: number }[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function mib(bytes: number | undefined): string {
|
|
54
|
+
if (typeof bytes !== "number" || !Number.isFinite(bytes)) return "unknown size";
|
|
55
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MiB`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function previewLines(preview: CleanupPreview): string[] {
|
|
59
|
+
const lines = [
|
|
60
|
+
`Would remove ${preview.count ?? 0} archived session file(s), freeing ${mib(preview.bytes)}.`,
|
|
61
|
+
];
|
|
62
|
+
for (const candidate of (preview.candidates ?? []).slice(0, 10)) {
|
|
63
|
+
lines.push(` ${candidate.relPath ?? "(unnamed)"} ${mib(candidate.bytes)}`);
|
|
64
|
+
}
|
|
65
|
+
const shown = Math.min((preview.candidates ?? []).length, 10);
|
|
66
|
+
if ((preview.count ?? 0) > shown) lines.push(` … and ${(preview.count ?? 0) - shown} more`);
|
|
67
|
+
lines.push("Nothing was deleted. Re-run with --yes to apply.");
|
|
68
|
+
return lines;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function cleanup(argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
72
|
+
const args = [...argv];
|
|
73
|
+
const wantsJson = takeFlag(args, "--json");
|
|
74
|
+
const confirmed = takeFlag(args, "--yes");
|
|
75
|
+
const percent = takeIntegerOption(args, "--percent", { min: 0 });
|
|
76
|
+
const mode = takeOption(args, "--mode") ?? "quarantine";
|
|
77
|
+
rejectArgs(args, USAGE);
|
|
78
|
+
|
|
79
|
+
if (percent === undefined) throw new CliUsageError("--percent is required", USAGE);
|
|
80
|
+
if (percent > 100) throw new CliUsageError("--percent must be between 0 and 100", USAGE);
|
|
81
|
+
if (mode !== "quarantine" && mode !== "permanent") {
|
|
82
|
+
throw new CliUsageError("--mode must be quarantine or permanent", USAGE);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// The preview runs in BOTH paths, and not only to be friendly: the mutating route requires the
|
|
86
|
+
// digest this call returns and rejects a stale one with 409 `stale_preview`. So the confirmed
|
|
87
|
+
// path cannot skip it, which conveniently means --yes and no --yes agree on what they mean.
|
|
88
|
+
const preview = await runtimeRequest<CleanupPreview>("/api/storage/cleanup/preview", {
|
|
89
|
+
method: "POST",
|
|
90
|
+
headers: { "content-type": "application/json" },
|
|
91
|
+
body: JSON.stringify({ percent }),
|
|
92
|
+
}, deps);
|
|
93
|
+
|
|
94
|
+
if (!confirmed) {
|
|
95
|
+
printData(preview, wantsJson, previewLines(preview));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!preview.digest) {
|
|
100
|
+
// Refuse rather than send an empty digest: the server would reject it, but a clear local
|
|
101
|
+
// message beats a 400 that looks like a bug in the verb.
|
|
102
|
+
throw new CliUsageError("the preview returned no digest, so the cleanup cannot be authorized", USAGE);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const result = await runtimeRequest("/api/storage/cleanup", {
|
|
106
|
+
method: "POST",
|
|
107
|
+
headers: { "content-type": "application/json" },
|
|
108
|
+
body: JSON.stringify({ percent, mode, digest: preview.digest }),
|
|
109
|
+
}, deps);
|
|
110
|
+
printData(result, wantsJson, summaryLines(result));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function trash(argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
114
|
+
const action = argv[0] && !argv[0].startsWith("-") ? argv[0] : "list";
|
|
115
|
+
const rest = argv[0] && !argv[0].startsWith("-") ? argv.slice(1) : argv;
|
|
116
|
+
|
|
117
|
+
if (action === "list") {
|
|
118
|
+
const args = [...rest];
|
|
119
|
+
const wantsJson = takeFlag(args, "--json");
|
|
120
|
+
rejectArgs(args, USAGE);
|
|
121
|
+
const result = await runtimeRequest("/api/storage/trash", {}, deps);
|
|
122
|
+
printData(result, wantsJson, summaryLines(result));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (action !== "restore") throw new CliUsageError(`unknown trash action ${action}`, USAGE);
|
|
127
|
+
|
|
128
|
+
const args = [...rest];
|
|
129
|
+
const wantsJson = takeFlag(args, "--json");
|
|
130
|
+
const confirmed = takeFlag(args, "--yes");
|
|
131
|
+
const id = args.shift();
|
|
132
|
+
rejectArgs(args, USAGE);
|
|
133
|
+
if (!id) throw new CliUsageError("a trash entry id is required", USAGE);
|
|
134
|
+
|
|
135
|
+
// Restore moves files back and reconciles database rows, and can collide with an existing
|
|
136
|
+
// destination, so it is gated like cleanup rather than treated as a read.
|
|
137
|
+
if (!confirmed) {
|
|
138
|
+
throw new CliUsageError(`restoring ${id} modifies stored sessions; pass --yes to confirm`, USAGE);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const result = await runtimeRequest("/api/storage/trash/restore", {
|
|
142
|
+
method: "POST",
|
|
143
|
+
headers: { "content-type": "application/json" },
|
|
144
|
+
body: JSON.stringify({ id }),
|
|
145
|
+
}, deps);
|
|
146
|
+
printData(result, wantsJson, summaryLines(result));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function policy(argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
150
|
+
const action = argv[0] && !argv[0].startsWith("-") ? argv[0] : "show";
|
|
151
|
+
const rest = argv[0] && !argv[0].startsWith("-") ? argv.slice(1) : argv;
|
|
152
|
+
|
|
153
|
+
if (action === "show") {
|
|
154
|
+
const args = [...rest];
|
|
155
|
+
const wantsJson = takeFlag(args, "--json");
|
|
156
|
+
rejectArgs(args, USAGE);
|
|
157
|
+
const result = await runtimeRequest("/api/storage/cleanup-policy", {}, deps);
|
|
158
|
+
printData(result, wantsJson, summaryLines(result));
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (action === "set") {
|
|
163
|
+
const args = [...rest];
|
|
164
|
+
const wantsJson = takeFlag(args, "--json");
|
|
165
|
+
const enabled = takeOption(args, "--enabled");
|
|
166
|
+
const percent = takeIntegerOption(args, "--percent", { min: 0 });
|
|
167
|
+
const mode = takeOption(args, "--mode");
|
|
168
|
+
const schedule = takeOption(args, "--schedule");
|
|
169
|
+
rejectArgs(args, USAGE);
|
|
170
|
+
|
|
171
|
+
if (enabled !== undefined && enabled !== "true" && enabled !== "false") {
|
|
172
|
+
throw new CliUsageError("--enabled must be true or false", USAGE);
|
|
173
|
+
}
|
|
174
|
+
const body: Record<string, unknown> = {};
|
|
175
|
+
if (enabled !== undefined) body.enabled = enabled === "true";
|
|
176
|
+
if (percent !== undefined) body.percent = percent;
|
|
177
|
+
if (mode !== undefined) body.mode = mode;
|
|
178
|
+
if (schedule !== undefined) body.schedule = schedule;
|
|
179
|
+
if (Object.keys(body).length === 0) {
|
|
180
|
+
throw new CliUsageError("policy set needs at least one of --enabled, --percent, --mode, --schedule", USAGE);
|
|
181
|
+
}
|
|
182
|
+
// Values are NOT re-validated here beyond --enabled's shape. The server owns the mode and
|
|
183
|
+
// schedule vocabularies and returns a named 400; duplicating them is a second thing to
|
|
184
|
+
// keep in sync. `enabled` is checked because "--enabled maybe" would otherwise be sent as
|
|
185
|
+
// `false`, which is a wrong write rather than a rejected one.
|
|
186
|
+
const result = await runtimeRequest("/api/storage/cleanup-policy", {
|
|
187
|
+
method: "PUT",
|
|
188
|
+
headers: { "content-type": "application/json" },
|
|
189
|
+
body: JSON.stringify(body),
|
|
190
|
+
}, deps);
|
|
191
|
+
printData(result, wantsJson, summaryLines(result));
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (action !== "run") throw new CliUsageError(`unknown policy action ${action}`, USAGE);
|
|
196
|
+
|
|
197
|
+
const args = [...rest];
|
|
198
|
+
const wantsJson = takeFlag(args, "--json");
|
|
199
|
+
const confirmed = takeFlag(args, "--yes");
|
|
200
|
+
rejectArgs(args, USAGE);
|
|
201
|
+
// `force: true` server-side: this run ignores the schedule and deletes now.
|
|
202
|
+
if (!confirmed) {
|
|
203
|
+
throw new CliUsageError("policy run deletes archived sessions now; pass --yes to confirm", USAGE);
|
|
204
|
+
}
|
|
205
|
+
const result = await runtimeRequest("/api/storage/cleanup-policy/run", { method: "POST" }, deps);
|
|
206
|
+
printData(result, wantsJson, summaryLines(result));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function handleStorageCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise<number> {
|
|
210
|
+
const hasSub = argv[0] !== undefined && !argv[0].startsWith("-");
|
|
211
|
+
const sub = hasSub ? argv[0]! : "report";
|
|
212
|
+
const rest = hasSub ? argv.slice(1) : argv;
|
|
213
|
+
if (sub === "codex-logs") {
|
|
214
|
+
// Doctor and the Log Guard guides still document `ocx storage codex-logs …`.
|
|
215
|
+
// This module owns cleanup/trash/policy; log-guard stays on the observe handler.
|
|
216
|
+
const { handleObserveCommand } = await import("./observe");
|
|
217
|
+
return handleObserveCommand(["storage", "codex-logs", ...rest], deps);
|
|
218
|
+
}
|
|
219
|
+
return runCliAction(async () => {
|
|
220
|
+
if (sub === "report") {
|
|
221
|
+
const args = [...rest];
|
|
222
|
+
const wantsJson = takeFlag(args, "--json");
|
|
223
|
+
rejectArgs(args, USAGE);
|
|
224
|
+
const result = await runtimeRequest("/api/storage", {}, deps);
|
|
225
|
+
printData(result, wantsJson, summaryLines(result));
|
|
226
|
+
}
|
|
227
|
+
else if (sub === "cleanup") await cleanup(rest, deps);
|
|
228
|
+
else if (sub === "trash") await trash(rest, deps);
|
|
229
|
+
else if (sub === "policy") await policy(rest, deps);
|
|
230
|
+
else throw new CliUsageError(`unknown storage command ${sub}`, USAGE);
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export const STORAGE_USAGE = USAGE;
|
|
@@ -17,6 +17,8 @@ const USAGE = `Usage:
|
|
|
17
17
|
ocx system startup <health|install-service|install-shim> [--json]
|
|
18
18
|
ocx system diagnostics [--json]
|
|
19
19
|
ocx system sync [--json]
|
|
20
|
+
ocx system codex-app-server [--json]
|
|
21
|
+
ocx system codex-restart --yes [--json]
|
|
20
22
|
ocx system update check [--channel <latest|preview>] [--json]
|
|
21
23
|
ocx system update run [--channel <latest|preview>] [--restart <on|off>] --yes [--json]
|
|
22
24
|
ocx system update status <job-id> [--json]`;
|
|
@@ -104,6 +106,20 @@ export async function handleSystemCommand(argv: string[], deps: RuntimeApiDeps =
|
|
|
104
106
|
} else if (sub === "sync") {
|
|
105
107
|
const args = [...rest]; const wantsJson = takeFlag(args, "--json"); rejectArgs(args, USAGE);
|
|
106
108
|
printData(await runtimeRequest("/api/sync", { method: "POST" }, deps), wantsJson);
|
|
109
|
+
} else if (sub === "codex-app-server") {
|
|
110
|
+
// The GUI reads this state directly (gui/src/codex-app-server-state.ts). Without a verb
|
|
111
|
+
// an agent could not see whether the Codex app-server was reachable at all.
|
|
112
|
+
const args = [...rest]; const wantsJson = takeFlag(args, "--json"); rejectArgs(args, USAGE);
|
|
113
|
+
printData(await runtimeRequest("/api/system/codex-app-server", {}, deps), wantsJson);
|
|
114
|
+
} else if (sub === "codex-restart") {
|
|
115
|
+
// --yes required: this restarts the user's running Codex app-server, so it is exactly the
|
|
116
|
+
// class of action that must not happen because an agent guessed a subcommand.
|
|
117
|
+
const args = [...rest];
|
|
118
|
+
const wantsJson = takeFlag(args, "--json");
|
|
119
|
+
const yes = takeFlag(args, "--yes");
|
|
120
|
+
if (!yes) throw new CliUsageError("system codex-restart requires --yes", USAGE);
|
|
121
|
+
rejectArgs(args, USAGE);
|
|
122
|
+
printData(await runtimeRequest("/api/system/codex-restart", { method: "POST" }, deps), wantsJson, ["Codex app-server restart requested."]);
|
|
107
123
|
} else if (sub === "update") await update(rest, deps);
|
|
108
124
|
else throw new CliUsageError(`unknown system command ${sub}`, USAGE);
|
|
109
125
|
});
|
package/src/cli/usage-report.ts
CHANGED
|
@@ -38,10 +38,34 @@ interface UsageReportInput {
|
|
|
38
38
|
providers?: CostRow[];
|
|
39
39
|
days?: { date: string; requests: number; totalTokens: number; estimatedCostUsd?: number }[];
|
|
40
40
|
filter?: { provider: string | null; model: string | null; matched: boolean; comboOverlap: boolean };
|
|
41
|
+
/**
|
|
42
|
+
* Per-account totals the API already returns and the CLI discarded (#2700).
|
|
43
|
+
*
|
|
44
|
+
* Withheld by the server -- sent as `[]` -- whenever a provider or model filter is active,
|
|
45
|
+
* because account rows are not provider-partitioned in a way the projection could honestly
|
|
46
|
+
* re-derive (summary.ts, reasoned around :865-872). An empty array therefore means two very
|
|
47
|
+
* different things, and the renderer must say which.
|
|
48
|
+
*/
|
|
49
|
+
accounts?: {
|
|
50
|
+
accountLogLabel: string;
|
|
51
|
+
ambiguous?: boolean;
|
|
52
|
+
requests: number;
|
|
53
|
+
totalTokens: number;
|
|
54
|
+
estimatedCostUsd?: number;
|
|
55
|
+
}[];
|
|
41
56
|
}
|
|
42
57
|
|
|
43
58
|
const MAX_MODEL_ROWS = 10;
|
|
44
59
|
|
|
60
|
+
function terminalText(value: string): string {
|
|
61
|
+
return value.replace(/[\x00-\x1f\x7f-\x9f]/g, character => {
|
|
62
|
+
const code = character.charCodeAt(0);
|
|
63
|
+
return code <= 0x7f
|
|
64
|
+
? `\\x${code.toString(16).padStart(2, "0")}`
|
|
65
|
+
: `\\u${code.toString(16).padStart(4, "0")}`;
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
45
69
|
function count(value: number | undefined): string {
|
|
46
70
|
return (value ?? 0).toLocaleString("en-US");
|
|
47
71
|
}
|
|
@@ -58,6 +82,8 @@ function usd(value: number | undefined): string {
|
|
|
58
82
|
|
|
59
83
|
function table(header: string[], rows: string[][]): string[] {
|
|
60
84
|
if (rows.length === 0) return [];
|
|
85
|
+
header = header.map(terminalText);
|
|
86
|
+
rows = rows.map(row => row.map(terminalText));
|
|
61
87
|
const widths = header.map((h, i) => Math.max(h.length, ...rows.map(r => (r[i] ?? "").length)));
|
|
62
88
|
const line = (cols: string[]): string => cols.map((c, i) => (c ?? "").padEnd(widths[i]!)).join(" ").trimEnd();
|
|
63
89
|
return [line(header), ...rows.map(line)];
|
|
@@ -68,7 +94,7 @@ function describeScope(data: UsageReportInput): string {
|
|
|
68
94
|
if (data.surface && data.surface !== "all") parts.push(`surface=${data.surface}`);
|
|
69
95
|
if (data.filter?.provider) parts.push(`provider=${data.filter.provider}`);
|
|
70
96
|
if (data.filter?.model) parts.push(`model=${data.filter.model}`);
|
|
71
|
-
return parts.join(", ");
|
|
97
|
+
return terminalText(parts.join(", "));
|
|
72
98
|
}
|
|
73
99
|
|
|
74
100
|
export function formatUsageReport(data: UsageReportInput): string[] {
|
|
@@ -78,7 +104,7 @@ export function formatUsageReport(data: UsageReportInput): string[] {
|
|
|
78
104
|
if (data.filter && !data.filter.matched) {
|
|
79
105
|
const what = [data.filter.provider && `provider "${data.filter.provider}"`, data.filter.model && `model "${data.filter.model}"`]
|
|
80
106
|
.filter(Boolean).join(" and ");
|
|
81
|
-
lines.push(`No usage recorded for ${what} in this range.`);
|
|
107
|
+
lines.push(`No usage recorded for ${terminalText(what)} in this range.`);
|
|
82
108
|
lines.push("Check the spelling against `ocx usage --json`, or widen --range.");
|
|
83
109
|
return lines;
|
|
84
110
|
}
|
|
@@ -110,6 +136,30 @@ export function formatUsageReport(data: UsageReportInput): string[] {
|
|
|
110
136
|
));
|
|
111
137
|
}
|
|
112
138
|
|
|
139
|
+
// Per-account spend (#2700). The two empty cases are distinguished deliberately: printing an
|
|
140
|
+
// empty table under a filter would repeat the silently-wrong-output defect this unit removes,
|
|
141
|
+
// because "what did this provider cost me per account" is the most natural way to ask and the
|
|
142
|
+
// server cannot answer it honestly.
|
|
143
|
+
const accountFilterActive = Boolean(data.filter?.provider || data.filter?.model);
|
|
144
|
+
const accounts = (data.accounts ?? []).filter(row => row.requests > 0);
|
|
145
|
+
if (accountFilterActive) {
|
|
146
|
+
lines.push("");
|
|
147
|
+
lines.push("ACCOUNT: not reported under a provider or model filter; run without filters for per-account totals.");
|
|
148
|
+
} else if (accounts.length > 0) {
|
|
149
|
+
lines.push("");
|
|
150
|
+
lines.push(...table(
|
|
151
|
+
["ACCOUNT", "REQUESTS", "TOKENS", "EST. COST"],
|
|
152
|
+
accounts.map(row => [
|
|
153
|
+
// An ambiguous row aggregates several accounts, so reading it as one account draws the
|
|
154
|
+
// wrong conclusion. Mark it rather than presenting it as a single identity.
|
|
155
|
+
row.ambiguous ? `${terminalText(row.accountLogLabel)} (ambiguous)` : terminalText(row.accountLogLabel),
|
|
156
|
+
count(row.requests),
|
|
157
|
+
count(row.totalTokens),
|
|
158
|
+
usd(row.estimatedCostUsd),
|
|
159
|
+
]),
|
|
160
|
+
));
|
|
161
|
+
}
|
|
162
|
+
|
|
113
163
|
const models = (data.models ?? []).filter(row => row.requests > 0);
|
|
114
164
|
if (models.length > 0) {
|
|
115
165
|
lines.push("");
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI-versus-proxy version skew (#2701).
|
|
3
|
+
*
|
|
4
|
+
* The reported failure: `ocx` on PATH is an older install than the running proxy, so its
|
|
5
|
+
* help describes commands the proxy does not have and its output describes a different
|
|
6
|
+
* build. Nothing surfaced that, because the CLI never compared the two.
|
|
7
|
+
*
|
|
8
|
+
* Kept in its own module rather than inside `status.ts` so `doctor` can reuse the exact
|
|
9
|
+
* comparison instead of reimplementing it -- two diagnostics disagreeing about whether an
|
|
10
|
+
* install is stale would be worse than neither reporting it.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Placeholder versions that mean "unknown", not "different". */
|
|
14
|
+
const PLACEHOLDERS = new Set(["unknown", "0.0.0"]);
|
|
15
|
+
|
|
16
|
+
export interface VersionSkew {
|
|
17
|
+
readonly cliVersion: string;
|
|
18
|
+
/** Version the live proxy reported, or null when nothing is live or it reported none. */
|
|
19
|
+
readonly proxyVersion: string | null;
|
|
20
|
+
readonly skewed: boolean;
|
|
21
|
+
/** Operator-facing explanation; null when there is nothing to report. */
|
|
22
|
+
readonly warning: string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Compare the running CLI against the live proxy.
|
|
27
|
+
*
|
|
28
|
+
* Suppressed rather than reported when either side is a placeholder. `packageVersion()`
|
|
29
|
+
* answers `"unknown"` when `package.json` carries no string version, and the server's
|
|
30
|
+
* `VERSION` falls back to `"0.0.0"` when it cannot resolve its own package -- comparing
|
|
31
|
+
* against either would report skew that says nothing about the install. A false stale-CLI
|
|
32
|
+
* warning would send an operator to reinstall a healthy setup.
|
|
33
|
+
*/
|
|
34
|
+
export function computeVersionSkew(cliVersion: string, proxyVersion: string | undefined): VersionSkew {
|
|
35
|
+
const proxy = proxyVersion ?? null;
|
|
36
|
+
if (proxy === null || PLACEHOLDERS.has(proxy) || PLACEHOLDERS.has(cliVersion) || proxy === cliVersion) {
|
|
37
|
+
return { cliVersion, proxyVersion: proxy, skewed: false, warning: null };
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
cliVersion,
|
|
41
|
+
proxyVersion: proxy,
|
|
42
|
+
skewed: true,
|
|
43
|
+
warning: `CLI ${cliVersion} does not match the running proxy ${proxy} — this ocx on PATH is stale. `
|
|
44
|
+
+ "Its help and features describe a different build. Reinstall, or run the proxy's own binary.",
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -5,6 +5,27 @@ import { MAIN_CODEX_ACCOUNT_ID } from "./main-account";
|
|
|
5
5
|
|
|
6
6
|
export const CODEX_ACCOUNT_LOG_LABEL_RE = /^p[a-f0-9]{6}$/;
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Account log labels come in two families (#2699):
|
|
10
|
+
*
|
|
11
|
+
* - `p<hex6>` (plus the literal `main`) — a Codex pool account.
|
|
12
|
+
* - `o<hex6>` — a non-Codex OAuth provider account (xai, cursor, and siblings).
|
|
13
|
+
*
|
|
14
|
+
* Both are sha256-derived digests, never an email and never a raw provider account id. That is
|
|
15
|
+
* a privacy requirement, not a formatting preference: these labels are written to the usage log
|
|
16
|
+
* and served over the management API.
|
|
17
|
+
*
|
|
18
|
+
* hex6 is 16.7M values, so two accounts CAN collide and merge into one reported row. That is a
|
|
19
|
+
* reporting inaccuracy at operator scale, not a correctness or privacy failure, and it is the
|
|
20
|
+
* accepted cost of keeping the existing `p` format byte-compatible.
|
|
21
|
+
*/
|
|
22
|
+
export const OAUTH_ACCOUNT_LOG_LABEL_RE = /^o[a-f0-9]{6}$/;
|
|
23
|
+
export const ACCOUNT_LOG_LABEL_RE = /^(?:main|[po][a-f0-9]{6})$/;
|
|
24
|
+
|
|
25
|
+
export function oauthAccountLogLabel(accountId: string, provider = ""): string {
|
|
26
|
+
return `o${createHash("sha256").update(`${provider}\0${accountId}`).digest("hex").slice(0, 6)}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
8
29
|
export function createCodexAccountLogLabel(existingLabels: Iterable<string | undefined | null> = []): string {
|
|
9
30
|
const used = new Set([...existingLabels].filter((value): value is string => !!value));
|
|
10
31
|
for (let i = 0; i < 16; i++) {
|
|
@@ -46,6 +46,7 @@ import { routedSlug, slugEquals, slugEquivalenceKey, slugsEquivalent } from "../
|
|
|
46
46
|
import { CODEX_GPT5_IDENTITY_LINE } from "../../adapters/identity";
|
|
47
47
|
import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery";
|
|
48
48
|
import { fetchCursorUsableModels } from "../../adapters/cursor/live-models";
|
|
49
|
+
import { recordLiveCursorMaxModeModels } from "../../adapters/cursor/catalog";
|
|
49
50
|
import { isCanonicalOpenAiForwardProvider, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
|
|
50
51
|
import {
|
|
51
52
|
COMBO_NAMESPACE,
|
|
@@ -1330,6 +1331,9 @@ async function fetchProviderModelsWithAuth(
|
|
|
1330
1331
|
});
|
|
1331
1332
|
if (liveResult.ok) {
|
|
1332
1333
|
const available = filterCursorConfiguredModelsByLiveDiscovery(configured, liveResult.models);
|
|
1334
|
+
// Live Max-Mode evidence feeds the umbrella resolver's ultra gate
|
|
1335
|
+
// (devlog 260828_cursor_umbrella_catalog; union with static evidence).
|
|
1336
|
+
recordLiveCursorMaxModeModels(liveResult.maxModeModels ?? []);
|
|
1333
1337
|
const result = available.length > 0 ? available : configured;
|
|
1334
1338
|
// Cache the discovery-filtered roster without combo retention so a later
|
|
1335
1339
|
// gather can re-apply the current capture's retain set on read.
|
package/src/codex/convergence.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
|
|
3
|
-
import { getConfigDir,
|
|
4
|
-
import {
|
|
3
|
+
import { getConfigDir, mutatePersistedConfig, websocketsEnabled, withExpectedConfigGenerationSync } from "../config";
|
|
4
|
+
import {
|
|
5
|
+
reconcileSuccessfulModelDiscoveries,
|
|
6
|
+
type KnownModelBaseline,
|
|
7
|
+
} from "../providers/new-model-policy";
|
|
5
8
|
import { COMBO_NAMESPACE } from "../combos";
|
|
6
9
|
import { getAuthStorePath } from "../oauth/store";
|
|
7
|
-
import type { OcxConfig } from "../types";
|
|
10
|
+
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
8
11
|
import { captureCatalogAdmissionSnapshot } from "./catalog-admission";
|
|
9
12
|
import { legacyCustomModelCatalogSlugs } from "./custom-model-catalog-migration";
|
|
10
13
|
import {
|
|
@@ -137,7 +140,22 @@ interface CandidateState {
|
|
|
137
140
|
readonly changed: boolean;
|
|
138
141
|
readonly notices: readonly CatalogNotice[];
|
|
139
142
|
readonly modelEntitlements: CodexModelEntitlementSnapshot;
|
|
140
|
-
readonly
|
|
143
|
+
readonly discovery: DiscoveryEvidence;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
interface DiscoveryEvidence {
|
|
147
|
+
readonly models: readonly { provider: string; id: string; custom?: boolean }[];
|
|
148
|
+
readonly authoritativeProviders: readonly string[];
|
|
149
|
+
readonly providerIdentities: Readonly<Record<string, string>>;
|
|
150
|
+
readonly now: string;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
interface DiscoveryProjection {
|
|
154
|
+
readonly providers: Readonly<Record<string, Readonly<{
|
|
155
|
+
knownModel?: KnownModelBaseline;
|
|
156
|
+
recentArrivals?: readonly { id: string; at: string }[];
|
|
157
|
+
}>>>;
|
|
158
|
+
readonly disabledModelsToAdd: readonly string[];
|
|
141
159
|
}
|
|
142
160
|
|
|
143
161
|
const candidateStates = new WeakMap<object, CandidateState>();
|
|
@@ -145,6 +163,16 @@ function same(left: unknown, right: unknown): boolean {
|
|
|
145
163
|
return JSON.stringify(left) === JSON.stringify(right);
|
|
146
164
|
}
|
|
147
165
|
|
|
166
|
+
function discoveryProviderIdentity(provider: OcxProviderConfig | undefined): string {
|
|
167
|
+
if (!provider) return "missing";
|
|
168
|
+
const row: Record<string, unknown> = { ...provider };
|
|
169
|
+
delete row.note;
|
|
170
|
+
delete row.newModelPolicy;
|
|
171
|
+
return JSON.stringify(row, (_key, value) => value && typeof value === "object" && !Array.isArray(value)
|
|
172
|
+
? Object.fromEntries(Object.entries(value as Record<string, unknown>).sort(([left], [right]) => left.localeCompare(right)))
|
|
173
|
+
: value);
|
|
174
|
+
}
|
|
175
|
+
|
|
148
176
|
function targetPath(identity: string): string {
|
|
149
177
|
const parsed = JSON.parse(identity) as { path?: unknown };
|
|
150
178
|
if (typeof parsed.path !== "string") throw new TypeError("Catalog target identity has no path.");
|
|
@@ -441,13 +469,15 @@ export async function gatherCodexCatalogCandidate(
|
|
|
441
469
|
: !hasRoutedEntries(source.catalog) ? source.catalog : null)
|
|
442
470
|
: null);
|
|
443
471
|
const discoveryConfig = structuredClone(snapshot.config) as OcxConfig;
|
|
444
|
-
const
|
|
472
|
+
const authoritativeProviders = providerModelOutcomes
|
|
473
|
+
.filter(outcome => outcome.state === "authoritative")
|
|
474
|
+
.map(outcome => outcome.provider);
|
|
475
|
+
const discoveryNow = new Date().toISOString();
|
|
476
|
+
reconcileSuccessfulModelDiscoveries({
|
|
445
477
|
config: discoveryConfig,
|
|
446
478
|
models: routedModels,
|
|
447
|
-
authoritativeProviders
|
|
448
|
-
|
|
449
|
-
.map(outcome => outcome.provider),
|
|
450
|
-
now: new Date().toISOString(),
|
|
479
|
+
authoritativeProviders,
|
|
480
|
+
now: discoveryNow,
|
|
451
481
|
});
|
|
452
482
|
const preparedCatalog = prepareCatalog(
|
|
453
483
|
discoveryConfig,
|
|
@@ -515,7 +545,15 @@ export async function gatherCodexCatalogCandidate(
|
|
|
515
545
|
|| Buffer.from(cacheBytes ?? []).toString("utf8") !== preparedCacheBytes,
|
|
516
546
|
notices: Object.freeze([...notices]),
|
|
517
547
|
modelEntitlements,
|
|
518
|
-
|
|
548
|
+
discovery: {
|
|
549
|
+
models: routedModels,
|
|
550
|
+
authoritativeProviders,
|
|
551
|
+
providerIdentities: Object.fromEntries(authoritativeProviders.map(provider => [
|
|
552
|
+
provider,
|
|
553
|
+
discoveryProviderIdentity(snapshot.config.providers[provider]),
|
|
554
|
+
])),
|
|
555
|
+
now: discoveryNow,
|
|
556
|
+
},
|
|
519
557
|
});
|
|
520
558
|
return { kind: "candidate", candidate };
|
|
521
559
|
} catch (error) {
|
|
@@ -532,6 +570,63 @@ export async function gatherCodexCatalogCandidate(
|
|
|
532
570
|
}
|
|
533
571
|
}
|
|
534
572
|
|
|
573
|
+
function reconcilePersistedDiscovery(state: CandidateState) {
|
|
574
|
+
return mutatePersistedConfig(config => {
|
|
575
|
+
if (state.discovery.authoritativeProviders.some(provider => (
|
|
576
|
+
discoveryProviderIdentity(config.providers[provider]) !== state.discovery.providerIdentities[provider]
|
|
577
|
+
))) {
|
|
578
|
+
return { changed: false, value: null };
|
|
579
|
+
}
|
|
580
|
+
const disabledBefore = new Set(config.disabledModels ?? []);
|
|
581
|
+
const changed = reconcileSuccessfulModelDiscoveries({
|
|
582
|
+
config,
|
|
583
|
+
...state.discovery,
|
|
584
|
+
});
|
|
585
|
+
const providers = Object.fromEntries(state.discovery.authoritativeProviders.flatMap(provider => {
|
|
586
|
+
const configured = config.providers[provider];
|
|
587
|
+
if (!configured || configured.liveModels === false) return [];
|
|
588
|
+
const knownModel = config.modelDiscovery?.knownModels?.[provider];
|
|
589
|
+
const recentArrivals = config.modelDiscovery?.recentArrivals?.[provider];
|
|
590
|
+
return [[provider, {
|
|
591
|
+
...(knownModel ? { knownModel: structuredClone(knownModel) } : {}),
|
|
592
|
+
...(recentArrivals ? { recentArrivals: structuredClone(recentArrivals) } : {}),
|
|
593
|
+
}]];
|
|
594
|
+
}));
|
|
595
|
+
return {
|
|
596
|
+
changed,
|
|
597
|
+
value: {
|
|
598
|
+
providers,
|
|
599
|
+
disabledModelsToAdd: (config.disabledModels ?? []).filter(slug => !disabledBefore.has(slug)),
|
|
600
|
+
} satisfies DiscoveryProjection,
|
|
601
|
+
};
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function adoptDiscoveryProjection(config: OcxConfig, projection: DiscoveryProjection): void {
|
|
606
|
+
for (const [provider, discovered] of Object.entries(projection.providers)) {
|
|
607
|
+
if (discovered.knownModel) {
|
|
608
|
+
const discovery = config.modelDiscovery ??= {};
|
|
609
|
+
const knownModels = discovery.knownModels ??= {};
|
|
610
|
+
knownModels[provider] = structuredClone(discovered.knownModel);
|
|
611
|
+
} else if (config.modelDiscovery?.knownModels) {
|
|
612
|
+
delete config.modelDiscovery.knownModels[provider];
|
|
613
|
+
}
|
|
614
|
+
if (discovered.recentArrivals) {
|
|
615
|
+
const discovery = config.modelDiscovery ??= {};
|
|
616
|
+
const recentArrivals = discovery.recentArrivals ??= {};
|
|
617
|
+
recentArrivals[provider] = discovered.recentArrivals.map(arrival => ({ ...arrival }));
|
|
618
|
+
} else if (config.modelDiscovery?.recentArrivals) {
|
|
619
|
+
delete config.modelDiscovery.recentArrivals[provider];
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
if (projection.disabledModelsToAdd.length > 0) {
|
|
623
|
+
const disabledModels = config.disabledModels ??= [];
|
|
624
|
+
for (const slug of projection.disabledModelsToAdd) {
|
|
625
|
+
if (!disabledModels.includes(slug)) disabledModels.push(slug);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
535
630
|
function revalidateCandidate(state: CandidateState): CodexCatalogCommitResult | null {
|
|
536
631
|
if (!isCodexModelEntitlementSnapshotCurrent(state.modelEntitlements)) {
|
|
537
632
|
return { kind: "stale", reason: "account-entitlement" };
|
|
@@ -663,11 +758,11 @@ export async function convergeCodexCatalog(
|
|
|
663
758
|
const state = candidateStates.get(gathered.candidate as object)!;
|
|
664
759
|
lifecycle.onCommitBegin?.();
|
|
665
760
|
const committed = await commitCodexCatalogCandidate(gathered.candidate, request.deadlineMs);
|
|
666
|
-
if (committed.kind === "committed"
|
|
667
|
-
const
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
761
|
+
if (committed.kind === "committed") {
|
|
762
|
+
const persisted = reconcilePersistedDiscovery(state);
|
|
763
|
+
if ((persisted.status === "committed" || persisted.status === "unchanged") && persisted.value) {
|
|
764
|
+
adoptDiscoveryProjection(snapshot.config as OcxConfig, persisted.value);
|
|
765
|
+
}
|
|
671
766
|
}
|
|
672
767
|
return {
|
|
673
768
|
changed: committed.kind === "committed" ? committed.changed : false,
|
|
@@ -280,7 +280,16 @@ export interface CodexCoordinatorAdoptionOptions {
|
|
|
280
280
|
readonly onCheckpoint?: (checkpoint: CodexCoordinatorAdoptionCheckpoint) => void;
|
|
281
281
|
}
|
|
282
282
|
|
|
283
|
-
function
|
|
283
|
+
function fsyncFile(path: string): void {
|
|
284
|
+
// Writable fd on purpose. Windows FlushFileBuffers needs GENERIC_WRITE;
|
|
285
|
+
// openSync(path, "r") is GENERIC_READ only and fails with EPERM (measured on
|
|
286
|
+
// Windows 11 25H2 / bun 1.3.14). prompt-journal.ts uses the same "r+" open.
|
|
287
|
+
const fd = openSync(path, "r+");
|
|
288
|
+
try { fsyncSync(fd); } finally { closeSync(fd); }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function fsyncDir(path: string): void {
|
|
292
|
+
// POSIX directory fsync is O_RDONLY. O_RDWR ("r+") is EISDIR.
|
|
284
293
|
const fd = openSync(path, "r");
|
|
285
294
|
try { fsyncSync(fd); } finally { closeSync(fd); }
|
|
286
295
|
}
|
|
@@ -316,12 +325,12 @@ function publishAdoptionDatabase(
|
|
|
316
325
|
} finally {
|
|
317
326
|
temp.close();
|
|
318
327
|
}
|
|
319
|
-
|
|
328
|
+
fsyncFile(tempPath);
|
|
320
329
|
options.onCheckpoint?.("temp-committed");
|
|
321
330
|
|
|
322
331
|
linkSync(tempPath, finalDatabasePath);
|
|
323
332
|
options.onCheckpoint?.("published");
|
|
324
|
-
if (process.platform !== "win32")
|
|
333
|
+
if (process.platform !== "win32") fsyncDir(parent);
|
|
325
334
|
} catch (error) {
|
|
326
335
|
if (errorCode(error) !== "EEXIST") throw error;
|
|
327
336
|
} finally {
|