@bitkyc08/opencodex 2.81.0 → 2.82.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/bin/ocx.mjs +166 -73
- package/gui/dist/assets/App-Ds_qL7o8.js +54 -0
- package/gui/dist/assets/{Tray-EwAOsP0S.js → Tray-BqHF5Trd.js} +1 -1
- package/gui/dist/assets/{index-Brk6F3fn.css → index-BAbcj51J.css} +1 -1
- package/gui/dist/assets/{index-RsoaAxXY.js → index-D3FBCNiP.js} +13 -13
- package/gui/dist/assets/{quota-summary-D2lN8tDm.js → quota-summary-DTBC8n7-.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +1 -1
- package/src/adapters/openai-responses/compaction-search-history.ts +109 -0
- package/src/adapters/openai-responses/passthrough.ts +9 -4
- package/src/adapters/openai-responses/tool-output-recovery.ts +20 -0
- package/src/claude/agents-inject.ts +14 -3
- package/src/claude/context-windows.ts +19 -17
- package/src/claude/desktop-3p.ts +88 -12
- package/src/claude/desktop-profile.ts +2 -1
- package/src/claude/gateway-cache.ts +18 -1
- package/src/claude/inbound.ts +77 -3
- package/src/claude/intercept/cli-catalog.ts +3 -0
- package/src/claude/intercept/picker-ca-persistence.ts +7 -6
- package/src/claude/intercept/picker-models.ts +14 -6
- package/src/claude/intercept/settings.ts +3 -0
- package/src/claude/long-context.ts +66 -0
- package/src/claude/model-info.ts +19 -16
- package/src/claude/outbound.ts +94 -9
- package/src/claude/subagent-model.ts +40 -11
- package/src/cli/agent.ts +9 -5
- package/src/cli/aside-profile-recovery.ts +33 -0
- package/src/cli/capabilities-base.ts +5 -5
- package/src/cli/capabilities-integrations.ts +2 -2
- package/src/cli/cli-path-diagnostics.ts +132 -0
- package/src/cli/desktop-runtime-guidance.ts +65 -0
- package/src/cli/dispatch.ts +5 -0
- package/src/cli/doctor.ts +16 -3
- package/src/cli/index.ts +14 -9
- package/src/cli/integration-aside-sync.ts +2 -1
- package/src/cli/integrations.ts +16 -3
- package/src/cli/resolve.ts +25 -2
- package/src/cli/root.ts +4 -2
- package/src/cli/status.ts +64 -12
- package/src/cli/update-restart-child.ts +4 -2
- package/src/cli/update-restart-home.ts +28 -10
- package/src/cli/update-restart-service-record.ts +116 -0
- package/src/cli/update-restart-supervision.ts +96 -0
- package/src/cli/update-restart.ts +59 -13
- package/src/cli/v2.ts +27 -20
- package/src/cli/version-skew-notice.ts +77 -0
- package/src/clients/lazycodex.ts +1 -1
- package/src/clients/omo-role-models.ts +32 -7
- package/src/codex/auth-api/account-selection.ts +107 -0
- package/src/codex/auth-api/routes.ts +10 -49
- package/src/codex/auth-context.ts +20 -1
- package/src/codex/autostart-health.ts +12 -3
- package/src/codex/catalog/build-entries.ts +18 -0
- package/src/codex/catalog/bundled.ts +155 -26
- package/src/codex/catalog/combo-member.ts +3 -2
- package/src/codex/catalog/effort.ts +26 -5
- package/src/codex/catalog/gather-capture.ts +5 -0
- package/src/codex/catalog/model-hints.ts +15 -8
- package/src/codex/catalog/model-visibility.ts +3 -1
- package/src/codex/catalog/operator-tiers.ts +38 -0
- package/src/codex/catalog/provider-models.ts +43 -35
- package/src/codex/catalog/retained-sync.ts +7 -5
- package/src/codex/catalog/routed-gather.ts +15 -1
- package/src/codex/catalog-auto-refresh-sources.ts +3 -4
- package/src/codex/catalog-auto-refresh.ts +19 -0
- package/src/codex/catalog.ts +1 -1
- package/src/codex/codex-home-owner.ts +3 -0
- package/src/codex/config-write-lock.ts +333 -0
- package/src/codex/convergence.ts +2 -1
- package/src/codex/features.ts +257 -200
- package/src/codex/inject/config-write-section.ts +34 -0
- package/src/codex/inject/multi-agent-v2.ts +14 -6
- package/src/codex/inject/remove.ts +36 -11
- package/src/codex/inject/restore.ts +85 -20
- package/src/codex/inject-coordination.ts +15 -6
- package/src/codex/inject.ts +62 -20
- package/src/codex/journal.ts +62 -17
- package/src/codex/main-account-cache.ts +43 -0
- package/src/codex/paths.ts +2 -0
- package/src/codex/prepared-home.ts +18 -0
- package/src/codex/prompt-journal.ts +25 -12
- package/src/codex/prompt-layers.ts +49 -26
- package/src/codex/prompt-lock-claim.ts +134 -0
- package/src/codex/prompt-lock-evidence.ts +26 -0
- package/src/codex/prompt-lock-io.ts +16 -0
- package/src/codex/prompt-lock-owner.ts +130 -0
- package/src/codex/prompt-lock.ts +84 -53
- package/src/codex/routing/failure-window.ts +146 -0
- package/src/codex/routing/selection.ts +4 -1
- package/src/codex/routing.ts +13 -17
- package/src/codex/runtime.ts +106 -18
- package/src/codex/shim.ts +45 -19
- package/src/combos/jev-service-exchange.ts +281 -0
- package/src/combos/jev.ts +34 -220
- package/src/config/atomic-write.ts +1 -1
- package/src/config/codex-ws-reuse-setting.ts +10 -0
- package/src/config/live-reconcile.ts +17 -0
- package/src/config/load-degrade.ts +8 -0
- package/src/config/rebase-provenance.ts +11 -0
- package/src/config/schema/config-schema.ts +4 -0
- package/src/generated/compatibility-version.json +303 -171
- package/src/generated/model-metadata.ts +2 -2
- package/src/grok/inject.ts +50 -4
- package/src/grok/sync.ts +2 -1
- package/src/images/loop.ts +29 -11
- package/src/integrations/aside-profiles.ts +10 -3
- package/src/integrations/catalog-refresh.ts +18 -2
- package/src/integrations/writer.ts +9 -3
- package/src/lib/bun-path-runtime.d.mts +32 -0
- package/src/lib/bun-path-runtime.mjs +95 -0
- package/src/lib/bun-runtime-preflight.ts +140 -0
- package/src/lib/desktop-cli-handoff.d.mts +20 -0
- package/src/lib/desktop-cli-handoff.mjs +92 -0
- package/src/lib/desktop-cli-record.d.mts +29 -0
- package/src/lib/desktop-cli-record.mjs +102 -0
- package/src/lib/request-execution-budget.ts +8 -0
- package/src/lib/synced-state-location.ts +46 -6
- package/src/lib/test-home-guard.ts +87 -1
- package/src/lib/translator-budget.ts +17 -4
- package/src/lib/windows-atomic-replace.ts +38 -16
- package/src/lib/windows-elevation.ts +6 -0
- package/src/lib/windows-secret-acl.ts +92 -25
- package/src/lib/winsw.ts +2 -3
- package/src/oauth/anthropic-account-refusal.ts +39 -1
- package/src/oauth/anthropic-routing.ts +1 -1
- package/src/oauth/anthropic-send-ownership.ts +3 -3
- package/src/oauth/chatgpt.ts +113 -41
- package/src/oauth/index.ts +6 -2
- package/src/oauth/store.ts +1 -1
- package/src/providers/azure-model-metadata.ts +115 -0
- package/src/providers/derive.ts +21 -0
- package/src/providers/quota/vendor-probes-key.ts +127 -17
- package/src/providers/quota.ts +1 -1
- package/src/responses/reasoning-envelope.ts +29 -1
- package/src/responses/reasoning-replay-cache.ts +17 -1
- package/src/responses/spill-store.ts +15 -2
- package/src/responses/state/metrics.ts +8 -0
- package/src/responses/state/spill-failure.ts +12 -0
- package/src/responses/state/spill-queue.ts +12 -5
- package/src/responses/state.ts +18 -10
- package/src/responses/task-input.ts +17 -0
- package/src/server/background-lifecycle.ts +27 -0
- package/src/server/chat-completions.ts +1 -0
- package/src/server/chat-native.ts +28 -12
- package/src/server/claude-messages.ts +43 -6
- package/src/server/inbound-body-admission.ts +1 -0
- package/src/server/index/claude-intercept-lifecycle.ts +3 -1
- package/src/server/index/serve-options.ts +10 -2
- package/src/server/index/spend-ledger-lifecycle.ts +2 -1
- package/src/server/index/startup-warnings.ts +19 -0
- package/src/server/index.ts +1 -0
- package/src/server/management/agent-settings-routes.ts +93 -32
- package/src/server/management/codex-agent-role-routes.ts +4 -2
- package/src/server/management/codex-prompt-routes.ts +1 -0
- package/src/server/management/codex-role-auto-assign.ts +13 -2
- package/src/server/management/config-routes.ts +59 -8
- package/src/server/management/context.ts +2 -2
- package/src/server/management/shared.ts +3 -3
- package/src/server/messages-native-oauth.ts +9 -8
- package/src/server/messages-native.ts +49 -13
- package/src/server/messages-response-headers.ts +14 -0
- package/src/server/request-log-conversation.ts +48 -5
- package/src/server/request-log.ts +31 -2
- package/src/server/responses/adapter-continuation.ts +2 -2
- package/src/server/responses/adapter-dispatch.ts +34 -13
- package/src/server/responses/codex-ws-exchange.ts +17 -2
- package/src/server/responses/codex-ws-pool.ts +100 -13
- package/src/server/responses/codex-ws-wire.ts +9 -0
- package/src/server/responses/combo-requested-effort.ts +3 -1
- package/src/server/responses/core-codex-account.ts +22 -1
- package/src/server/responses/core-combo-native.ts +20 -3
- package/src/server/responses/core-combo.ts +22 -14
- package/src/server/responses/core-lifetime.ts +3 -36
- package/src/server/responses/core-options.ts +5 -0
- package/src/server/responses/core-replay.ts +73 -0
- package/src/server/responses/core.ts +3 -3
- package/src/server/responses/fetch-helpers.ts +26 -3
- package/src/server/responses/passthrough-delivery.ts +24 -2
- package/src/server/responses/passthrough-dispatch.ts +19 -2
- package/src/server/responses/request-prepare.ts +4 -0
- package/src/server/responses/request-send-budget.ts +112 -49
- package/src/server/responses/response-effects.ts +1 -0
- package/src/server/responses/run-turn-execution.ts +2 -0
- package/src/server/responses/sidecar-execution.ts +25 -16
- package/src/server/responses/sidecar-send-budget.ts +58 -0
- package/src/server/responses/ws-upstream.ts +19 -6
- package/src/server/startup-health-cache.ts +2 -1
- package/src/server/workflow-refusal.ts +3 -0
- package/src/service/cli.ts +20 -4
- package/src/service/desktop-command-guard.ts +32 -0
- package/src/service/desktop-startup.ts +66 -40
- package/src/service/desktop-supervision.d.mts +26 -0
- package/src/service/desktop-supervision.mjs +140 -0
- package/src/service/managing-cli.ts +1 -0
- package/src/service/orchestration.ts +46 -20
- package/src/service/repair.ts +25 -9
- package/src/service/state.ts +4 -3
- package/src/service/systemd.ts +17 -8
- package/src/service/windows-ops.ts +61 -22
- package/src/storage/policy-job.ts +5 -1
- package/src/storage/policy-worker.ts +3 -1
- package/src/storage/policy.ts +9 -0
- package/src/types/config.ts +21 -0
- package/src/types/request.ts +5 -0
- package/src/types/wire.ts +1 -0
- package/src/update/index.ts +57 -9
- package/src/update/restart-ownership.ts +10 -3
- package/src/update/runtime-ownership.d.mts +6 -0
- package/src/update/runtime-ownership.mjs +23 -7
- package/src/web-search/loop.ts +5 -2
- package/gui/dist/assets/App-7yhoys8a.js +0 -54
package/src/codex/journal.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import { beginCodexWriteSection, publishCodexArtifact } from "./inject/config-write-section";
|
|
1
2
|
import { createHash } from "node:crypto";
|
|
3
|
+
import type { AtomicWriteHooks } from "../config/atomic-write";
|
|
2
4
|
import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
|
3
5
|
import { join } from "node:path";
|
|
4
6
|
import { atomicWriteFile } from "../config";
|
|
5
|
-
import { assertCodexHomeOwner, CODEX_HOME_JOURNAL_FILE, CodexHomeOwnerRefusal, opencodexHomeForInjection, readCodexHomeJournal, type CodexHomeOwnerRefusalReason } from "./codex-home-owner";
|
|
7
|
+
import { assertCodexHomeOwner, codexHomeIsAbsent, CODEX_HOME_JOURNAL_FILE, CodexHomeOwnerRefusal, opencodexHomeForInjection, readCodexHomeJournal, type CodexHomeOwnerRefusalReason } from "./codex-home-owner";
|
|
6
8
|
import { hasInjectedCodexRouting } from "./injected-marker";
|
|
9
|
+
import { configWriteLockFailureMessage, withConfigWriteLockHeld } from "./config-write-lock";
|
|
10
|
+
import type { LockHandle } from "./config-write-lock";
|
|
7
11
|
import { CODEX_HOME, CODEX_CONFIG_PATH, CODEX_PROFILE_PATH } from "./paths";
|
|
8
12
|
|
|
9
13
|
/**
|
|
@@ -80,6 +84,9 @@ interface Journal {
|
|
|
80
84
|
}
|
|
81
85
|
|
|
82
86
|
export interface RestoreJournalResult {
|
|
87
|
+
/** The shared config write lock was held by another opencodex writer; restore did not run. */
|
|
88
|
+
lockBusy?: true;
|
|
89
|
+
lockUnsafe?: string;
|
|
83
90
|
/** An unchanged generated profile could not be restored; distinct from a preserved user edit. */
|
|
84
91
|
profileRestoreFailed?: true;
|
|
85
92
|
ownershipRefusal?: CodexHomeOwnerRefusalReason;
|
|
@@ -154,7 +161,7 @@ export interface WriteJournalOptions {
|
|
|
154
161
|
* so an unclean shutdown days later replays a day-one config over the user's
|
|
155
162
|
* plugins, model choice, and trusted projects.
|
|
156
163
|
*/
|
|
157
|
-
export function writeJournal(options: WriteJournalOptions = {}): void {
|
|
164
|
+
export function writeJournal(options: WriteJournalOptions = {}, hooks: AtomicWriteHooks = {}): void {
|
|
158
165
|
assertCodexHomeOwner(CODEX_HOME);
|
|
159
166
|
if (!existsSync(CODEX_CONFIG_PATH)) return;
|
|
160
167
|
const config = options.configContent ?? readFileSync(CODEX_CONFIG_PATH, "utf-8");
|
|
@@ -185,7 +192,7 @@ export function writeJournal(options: WriteJournalOptions = {}): void {
|
|
|
185
192
|
: { kind: "process", pid: process.pid },
|
|
186
193
|
timestamp: new Date().toISOString(),
|
|
187
194
|
};
|
|
188
|
-
atomicWriteFile(JOURNAL_PATH, JSON.stringify(journal));
|
|
195
|
+
atomicWriteFile(JOURNAL_PATH, JSON.stringify(journal), undefined, hooks);
|
|
189
196
|
}
|
|
190
197
|
|
|
191
198
|
export interface InjectedJournalOwnership {
|
|
@@ -202,6 +209,7 @@ export function markJournalInjectedState(
|
|
|
202
209
|
config: string,
|
|
203
210
|
profile: string | null,
|
|
204
211
|
ownership: InjectedJournalOwnership,
|
|
212
|
+
hooks: AtomicWriteHooks = {},
|
|
205
213
|
): void {
|
|
206
214
|
assertCodexHomeOwner(CODEX_HOME);
|
|
207
215
|
const inspection = readCodexHomeJournal(JOURNAL_PATH);
|
|
@@ -224,7 +232,7 @@ export function markJournalInjectedState(
|
|
|
224
232
|
journal.replacedRootWebSearch = ownership.replacedRootWebSearch ?? null;
|
|
225
233
|
journal.injectedCatalogPath = ownership.injectedCatalogPath;
|
|
226
234
|
journal.opencodexHome = opencodexHomeForInjection(journal.opencodexHome);
|
|
227
|
-
atomicWriteFile(JOURNAL_PATH, JSON.stringify(journal));
|
|
235
|
+
atomicWriteFile(JOURNAL_PATH, JSON.stringify(journal), undefined, hooks);
|
|
228
236
|
}
|
|
229
237
|
|
|
230
238
|
/**
|
|
@@ -259,19 +267,21 @@ export function journaledInjectedCatalogPath(): string | null {
|
|
|
259
267
|
return readJournal()?.injectedCatalogPath ?? null;
|
|
260
268
|
}
|
|
261
269
|
|
|
262
|
-
export function removeJournal(): void {
|
|
270
|
+
export function removeJournal(hooks: AtomicWriteHooks = {}): void {
|
|
263
271
|
assertCodexHomeOwner(CODEX_HOME);
|
|
264
|
-
|
|
272
|
+
hooks.validateBeforeRename?.(JOURNAL_PATH);
|
|
273
|
+
try { unlinkSync(JOURNAL_PATH); } catch { return; }
|
|
274
|
+
hooks.afterRename?.(JOURNAL_PATH);
|
|
265
275
|
}
|
|
266
276
|
|
|
267
277
|
/** Successful field-level native restore releases ownership while retaining recovery evidence. */
|
|
268
|
-
export function releaseJournalHomeBinding(): void {
|
|
278
|
+
export function releaseJournalHomeBinding(hooks: AtomicWriteHooks = {}): void {
|
|
269
279
|
assertCodexHomeOwner(CODEX_HOME);
|
|
270
280
|
const inspection = readCodexHomeJournal(JOURNAL_PATH);
|
|
271
281
|
if (inspection.kind === "unknown") throw new CodexHomeOwnerRefusal("owner-unknown");
|
|
272
282
|
if (inspection.kind === "missing" || inspection.journal.opencodexHome === undefined) return;
|
|
273
283
|
delete inspection.journal.opencodexHome;
|
|
274
|
-
atomicWriteFile(JOURNAL_PATH, JSON.stringify(inspection.journal));
|
|
284
|
+
atomicWriteFile(JOURNAL_PATH, JSON.stringify(inspection.journal), undefined, hooks);
|
|
275
285
|
}
|
|
276
286
|
|
|
277
287
|
// Kept compatible with existing read-only callers; invalid evidence is never cleanup authority.
|
|
@@ -298,13 +308,21 @@ export function journalOwner(options: { readOnly?: boolean } = {}): JournalOwner
|
|
|
298
308
|
: null;
|
|
299
309
|
}
|
|
300
310
|
|
|
301
|
-
export function restoreJournalState(
|
|
311
|
+
export function restoreJournalState(
|
|
312
|
+
options: { heldConfigWriteLock?: LockHandle } = {},
|
|
313
|
+
): RestoreJournalResult {
|
|
314
|
+
if (codexHomeIsAbsent(CODEX_HOME)) return {
|
|
315
|
+
configRestored: false, profileRestored: false, configRewritten: false, profileRewritten: false,
|
|
316
|
+
configChanged: false, profileChanged: false, complete: false, unverified: false,
|
|
317
|
+
};
|
|
318
|
+
const locked = withConfigWriteLockHeld(CODEX_CONFIG_PATH, options.heldConfigWriteLock, held => {
|
|
302
319
|
try { assertCodexHomeOwner(CODEX_HOME); }
|
|
303
320
|
catch (error) {
|
|
304
321
|
if (!(error instanceof CodexHomeOwnerRefusal)) throw error;
|
|
305
322
|
return { configRestored: false, profileRestored: false, configRewritten: false, profileRewritten: false,
|
|
306
323
|
configChanged: false, profileChanged: false, complete: false, unverified: true, ownershipRefusal: error.reason };
|
|
307
324
|
}
|
|
325
|
+
beginCodexWriteSection(held);
|
|
308
326
|
const journal = readJournal();
|
|
309
327
|
if (!journal) {
|
|
310
328
|
return { configRestored: false, profileRestored: false, configRewritten: false, profileRewritten: false,
|
|
@@ -335,13 +353,13 @@ export function restoreJournalState(): RestoreJournalResult {
|
|
|
335
353
|
let configRewritten = false;
|
|
336
354
|
let profileRewritten = false;
|
|
337
355
|
if (configUnchanged && !configRestored) {
|
|
338
|
-
|
|
356
|
+
publishCodexArtifact(CODEX_CONFIG_PATH, held, (path, hooks) => atomicWriteFile(path, comparison.originalConfig, undefined, hooks));
|
|
339
357
|
configRestored = true;
|
|
340
358
|
configRewritten = true;
|
|
341
359
|
}
|
|
342
360
|
if (profileUnchanged && !profileRestored) {
|
|
343
361
|
if (comparison.originalProfile !== null) {
|
|
344
|
-
|
|
362
|
+
publishCodexArtifact(CODEX_PROFILE_PATH, held, (path, hooks) => atomicWriteFile(path, comparison.originalProfile!, undefined, hooks));
|
|
345
363
|
profileRestored = true;
|
|
346
364
|
profileRewritten = true;
|
|
347
365
|
} else if (existsSync(CODEX_PROFILE_PATH)) {
|
|
@@ -351,7 +369,7 @@ export function restoreJournalState(): RestoreJournalResult {
|
|
|
351
369
|
// not be there. ENOENT is the one benign outcome: the file is already gone, which is
|
|
352
370
|
// the state we wanted.
|
|
353
371
|
try {
|
|
354
|
-
|
|
372
|
+
publishCodexArtifact(CODEX_PROFILE_PATH, held, (path, hooks) => { hooks.validateBeforeRename?.(path); unlinkSync(path); hooks.afterRename?.(path); });
|
|
355
373
|
profileRestored = true;
|
|
356
374
|
profileRewritten = true;
|
|
357
375
|
} catch (error) {
|
|
@@ -361,8 +379,11 @@ export function restoreJournalState(): RestoreJournalResult {
|
|
|
361
379
|
profileRestored = true;
|
|
362
380
|
}
|
|
363
381
|
}
|
|
364
|
-
|
|
365
|
-
if (complete)
|
|
382
|
+
let complete = configRestored && profileRestored;
|
|
383
|
+
if (complete) {
|
|
384
|
+
try { publishCodexArtifact(JOURNAL_PATH, held, (_path, hooks) => removeJournal(hooks)); }
|
|
385
|
+
catch { complete = false; }
|
|
386
|
+
}
|
|
366
387
|
return {
|
|
367
388
|
configRestored,
|
|
368
389
|
profileRestored,
|
|
@@ -374,6 +395,24 @@ export function restoreJournalState(): RestoreJournalResult {
|
|
|
374
395
|
unverified: false,
|
|
375
396
|
...(profileUnchanged && !profileRestored ? { profileRestoreFailed: true as const } : {}),
|
|
376
397
|
};
|
|
398
|
+
});
|
|
399
|
+
if (!locked.ok) {
|
|
400
|
+
// Busy is not a verdict on the journal — report it as its own fact so a
|
|
401
|
+
// caller cannot mistake it for a failed or unneeded restore. Nothing was
|
|
402
|
+
// read yet, so the changed-file fields cannot be computed.
|
|
403
|
+
return {
|
|
404
|
+
configRestored: false,
|
|
405
|
+
profileRestored: false,
|
|
406
|
+
configRewritten: false,
|
|
407
|
+
profileRewritten: false,
|
|
408
|
+
configChanged: false,
|
|
409
|
+
profileChanged: false,
|
|
410
|
+
complete: false,
|
|
411
|
+
unverified: false,
|
|
412
|
+
...(locked.error === "unsafe" ? { lockUnsafe: configWriteLockFailureMessage(locked) } : { lockBusy: true as const }),
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
return locked.value;
|
|
377
416
|
}
|
|
378
417
|
|
|
379
418
|
export function restoreJournal(): boolean {
|
|
@@ -390,18 +429,21 @@ export interface ReconcileJournalOptions {
|
|
|
390
429
|
}
|
|
391
430
|
|
|
392
431
|
export function reconcileJournal(options: ReconcileJournalOptions = {}): boolean {
|
|
432
|
+
if (codexHomeIsAbsent(CODEX_HOME)) return false;
|
|
433
|
+
const locked = withConfigWriteLockHeld(CODEX_CONFIG_PATH, undefined, handle => {
|
|
393
434
|
try { assertCodexHomeOwner(CODEX_HOME); }
|
|
394
435
|
catch (error) {
|
|
395
436
|
if (!(error instanceof CodexHomeOwnerRefusal)) throw error;
|
|
396
437
|
console.error(error.message);
|
|
397
438
|
return false;
|
|
398
439
|
}
|
|
440
|
+
beginCodexWriteSection(handle);
|
|
399
441
|
const journal = readJournal();
|
|
400
442
|
if (!journal) return false;
|
|
401
|
-
const owner =
|
|
443
|
+
const owner = journal.owner ?? (Number.isSafeInteger(journal.pid) && journal.pid > 0 ? { kind: "process" as const, pid: journal.pid } : null);
|
|
402
444
|
if (owner?.kind === "client") {
|
|
403
445
|
if (options.activeClientApiKeyId === owner.apiKeyId) return false;
|
|
404
|
-
const restored = restoreJournalState();
|
|
446
|
+
const restored = restoreJournalState({ heldConfigWriteLock: handle });
|
|
405
447
|
if (restored.unverified) {
|
|
406
448
|
console.error("⚠️ Codex journal recovery was not verified; current configuration files and the journal were preserved.");
|
|
407
449
|
return false;
|
|
@@ -425,7 +467,7 @@ export function reconcileJournal(options: ReconcileJournalOptions = {}): boolean
|
|
|
425
467
|
return false;
|
|
426
468
|
}
|
|
427
469
|
}
|
|
428
|
-
const restored = restoreJournalState();
|
|
470
|
+
const restored = restoreJournalState({ heldConfigWriteLock: handle });
|
|
429
471
|
if (restored.unverified) {
|
|
430
472
|
console.error("⚠️ Codex journal recovery was not verified; current configuration files and the journal were preserved.");
|
|
431
473
|
return false;
|
|
@@ -440,4 +482,7 @@ export function reconcileJournal(options: ReconcileJournalOptions = {}): boolean
|
|
|
440
482
|
if (!restored.configRewritten && !restored.profileRewritten) { warnIfJournalRetained(); return false; }
|
|
441
483
|
console.error(`⚠️ Previous session (PID ${pid}) did not shut down cleanly. Codex state restored from journal.`);
|
|
442
484
|
return true;
|
|
485
|
+
});
|
|
486
|
+
if (!locked.ok && locked.error === "unsafe") console.error(configWriteLockFailureMessage(locked));
|
|
487
|
+
return locked.ok ? locked.value : false;
|
|
443
488
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { codexCredentialMutationEpoch } from "./credential-mutation-epoch";
|
|
2
3
|
import type { StoredAccountQuota } from "./quota-types";
|
|
3
4
|
import { truncateRetainedUtf8 } from "../lib/admission";
|
|
4
5
|
|
|
@@ -73,6 +74,48 @@ export function isMainQuotaWriterLive(writer: MainQuotaWriter): boolean {
|
|
|
73
74
|
&& writer.identityGeneration === mainAccountIdentityGeneration;
|
|
74
75
|
}
|
|
75
76
|
|
|
77
|
+
/** Proof that a dispatch used the observed main credential; process-local, never persisted. */
|
|
78
|
+
export type MainQuotaDispatch = Readonly<{
|
|
79
|
+
writer: MainQuotaWriter;
|
|
80
|
+
credentialGeneration: number;
|
|
81
|
+
credentialMutationEpoch: number;
|
|
82
|
+
configGeneration: number;
|
|
83
|
+
}>;
|
|
84
|
+
|
|
85
|
+
// WS quota frames publish through their observer; prelude quota can only come from those frames.
|
|
86
|
+
// A real HTTP fallback after a failed upgrade never invokes the observer and stays unclaimed.
|
|
87
|
+
const wsObservedMainDispatches = new WeakSet<MainQuotaDispatch>();
|
|
88
|
+
|
|
89
|
+
export function claimMainQuotaDispatchForWs(dispatch: MainQuotaDispatch): void {
|
|
90
|
+
wsObservedMainDispatches.add(dispatch);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function isMainQuotaDispatchWsClaimed(dispatch: MainQuotaDispatch): boolean {
|
|
94
|
+
return wsObservedMainDispatches.has(dispatch);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Give a replacement physical attempt its own quota ownership without recapturing credential fences. */
|
|
98
|
+
export function renewMainQuotaDispatchForAttempt(dispatch: MainQuotaDispatch): MainQuotaDispatch {
|
|
99
|
+
return { ...dispatch };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function captureMainQuotaDispatch(
|
|
103
|
+
accessToken: string, accountId: string | undefined, configGeneration: number,
|
|
104
|
+
): MainQuotaDispatch | undefined {
|
|
105
|
+
if (!accountId || !matchesMainQuotaCredential(accessToken, accountId)) return undefined;
|
|
106
|
+
const writer = captureMainQuotaWriter(accountId);
|
|
107
|
+
return writer ? { writer, credentialGeneration: mainQuotaCredentialGeneration,
|
|
108
|
+
credentialMutationEpoch: codexCredentialMutationEpoch(), configGeneration } : undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function isMainQuotaDispatchLive(dispatch: MainQuotaDispatch): boolean {
|
|
112
|
+
// Other OpenCodex-owned credential publications also advance this epoch;
|
|
113
|
+
// dropping a main quota update after any such publication is the intended safe direction.
|
|
114
|
+
return isMainQuotaWriterLive(dispatch.writer)
|
|
115
|
+
&& dispatch.credentialGeneration === mainQuotaCredentialGeneration
|
|
116
|
+
&& dispatch.credentialMutationEpoch === codexCredentialMutationEpoch();
|
|
117
|
+
}
|
|
118
|
+
|
|
76
119
|
export function getObservedMainQuotaIdentityKey(): string | undefined {
|
|
77
120
|
return observedMainQuotaIdentityKey;
|
|
78
121
|
}
|
package/src/codex/paths.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { readFileSync, realpathSync, statSync } from "node:fs";
|
|
|
2
2
|
import { isAbsolute, join, resolve } from "node:path";
|
|
3
3
|
import { expandUserPath } from "../config";
|
|
4
4
|
import { defaultCodexHome } from "./home";
|
|
5
|
+
import { codexHomeIsAbsent } from "./codex-home-owner";
|
|
5
6
|
|
|
6
7
|
function resolveCodexHome(): string {
|
|
7
8
|
const raw = process.env.CODEX_HOME?.trim();
|
|
@@ -11,6 +12,7 @@ function resolveCodexHome(): string {
|
|
|
11
12
|
try {
|
|
12
13
|
stat = statSync(path);
|
|
13
14
|
} catch (err) {
|
|
15
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT" && codexHomeIsAbsent(path)) return path;
|
|
14
16
|
const message = err instanceof Error ? err.message : String(err);
|
|
15
17
|
throw new Error(`CODEX_HOME points to ${raw}, but that path could not be read: ${message}`);
|
|
16
18
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { mkdirSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { codexHomeIsAbsent } from "./codex-home-owner";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Create a proven-missing home before a writer that has always created it.
|
|
7
|
+
* A failed request keeps the directory: a pathname-based removal cannot prove it
|
|
8
|
+
* still names the directory this request created, so deleting it could remove a
|
|
9
|
+
* concurrent replacement. An empty home is what Codex itself would create.
|
|
10
|
+
*/
|
|
11
|
+
export function prepareCodexHome(home: string, mode?: number): void {
|
|
12
|
+
if (!codexHomeIsAbsent(home)) return;
|
|
13
|
+
mkdirSync(dirname(home), { recursive: true, mode });
|
|
14
|
+
try { mkdirSync(home, { mode }); }
|
|
15
|
+
catch (error) {
|
|
16
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { configWriteLockFailureMessage, withConfigWriteLockHeld, publishConfigWrite, watchConfigWriteTargets, publishConfigWriteTarget, type LockHandle } from "./config-write-lock";
|
|
2
|
+
import { codexHomeIsAbsent } from "./codex-home-owner";
|
|
1
3
|
/**
|
|
2
4
|
* prompt-journal.ts — the write transaction behind the prompt-layer surface.
|
|
3
5
|
*
|
|
@@ -19,6 +21,7 @@
|
|
|
19
21
|
* concrete: crash after writing config.toml, user or Codex then edits it,
|
|
20
22
|
* recovery sees a mismatch and overwrites their work with a stale image.
|
|
21
23
|
*/
|
|
24
|
+
import type { AtomicWriteHooks } from "../config/atomic-write";
|
|
22
25
|
import { existsSync, mkdirSync, openSync, closeSync, fsyncSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
23
26
|
import { dirname, resolve } from "node:path";
|
|
24
27
|
import { createHash, randomBytes } from "node:crypto";
|
|
@@ -68,7 +71,7 @@ function fsyncDir(path: string): void {
|
|
|
68
71
|
}
|
|
69
72
|
|
|
70
73
|
/** Write at mode 0600 from creation — a later chmod leaves a readable window. */
|
|
71
|
-
export function durableWrite(path: string, content: string): void {
|
|
74
|
+
export function durableWrite(path: string, content: string, hooks: AtomicWriteHooks = {}): void {
|
|
72
75
|
const tmp = `${path}.ocx.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
73
76
|
let fd: number | undefined;
|
|
74
77
|
try {
|
|
@@ -83,7 +86,9 @@ export function durableWrite(path: string, content: string): void {
|
|
|
83
86
|
// Windows can refuse the replace with EBUSY/EPERM/EACCES while a scanner
|
|
84
87
|
// still holds the target; the shared helper retries that briefly. Losing
|
|
85
88
|
// this publish breaks journal restore, so it should not fail on a blink.
|
|
86
|
-
|
|
89
|
+
hooks.beforeRename?.(tmp, path);
|
|
90
|
+
renameAtomicFile(tmp, path, undefined, "prompt-journal", hooks);
|
|
91
|
+
hooks.afterRename?.(path);
|
|
87
92
|
// The temp is renamed away: proven absent — release its ACL memos.
|
|
88
93
|
forgetEphemeralSecretPath(tmp);
|
|
89
94
|
fsyncDir(path);
|
|
@@ -108,9 +113,11 @@ export function durableWrite(path: string, content: string): void {
|
|
|
108
113
|
}
|
|
109
114
|
}
|
|
110
115
|
|
|
111
|
-
export function durableDelete(path: string): void {
|
|
116
|
+
export function durableDelete(path: string, hooks: AtomicWriteHooks = {}): void {
|
|
112
117
|
try {
|
|
118
|
+
hooks.validateBeforeRename?.(path);
|
|
113
119
|
if (existsSync(path)) unlinkSync(path);
|
|
120
|
+
hooks.afterRename?.(path);
|
|
114
121
|
fsyncDir(path);
|
|
115
122
|
} catch (error) {
|
|
116
123
|
// Only absence is the state we wanted; every other deletion failure must
|
|
@@ -212,7 +219,7 @@ export function classify(actual: string | null, pre: string, post: string): Targ
|
|
|
212
219
|
|
|
213
220
|
export type RecoveryOutcome =
|
|
214
221
|
| { ok: true; action: "none" | "committed" | "rolled-back" }
|
|
215
|
-
| { ok: false; error: "recovery_required"; detail: string };
|
|
222
|
+
| { ok: false; error: "recovery_required" | "unsafe"; detail: string };
|
|
216
223
|
|
|
217
224
|
export interface RecoveryTargets {
|
|
218
225
|
configPath: string;
|
|
@@ -247,7 +254,11 @@ export function sameRecoveryPath(
|
|
|
247
254
|
export function recoverIfNeeded(
|
|
248
255
|
journalPath: string,
|
|
249
256
|
expectedTargets: RecoveryTargets,
|
|
257
|
+
heldConfigWriteLock?: LockHandle,
|
|
250
258
|
): RecoveryOutcome {
|
|
259
|
+
if (codexHomeIsAbsent(dirname(expectedTargets.configPath)) && !existsSync(journalPath)) return { ok: true, action: "none" };
|
|
260
|
+
const locked = withConfigWriteLockHeld(expectedTargets.configPath, heldConfigWriteLock, held => {
|
|
261
|
+
watchConfigWriteTargets(held, [journalPath, expectedTargets.storePath]);
|
|
251
262
|
const raw = readOrNull(journalPath);
|
|
252
263
|
if (raw === null) return { ok: true, action: "none" };
|
|
253
264
|
|
|
@@ -285,7 +296,7 @@ export function recoverIfNeeded(
|
|
|
285
296
|
|
|
286
297
|
if (config === "post" && store === "post") {
|
|
287
298
|
try {
|
|
288
|
-
|
|
299
|
+
publishConfigWriteTarget(expectedTargets.configPath, held, journalPath, (destination, hooks) => durableDelete(destination, hooks));
|
|
289
300
|
} catch (error) {
|
|
290
301
|
return {
|
|
291
302
|
ok: false,
|
|
@@ -307,7 +318,7 @@ export function recoverIfNeeded(
|
|
|
307
318
|
};
|
|
308
319
|
}
|
|
309
320
|
try {
|
|
310
|
-
|
|
321
|
+
publishConfigWrite(expectedTargets.configPath, held, (destination, hooks) => restore(destination, record.preConfigBytes, hooks));
|
|
311
322
|
} catch (error) {
|
|
312
323
|
return {
|
|
313
324
|
ok: false,
|
|
@@ -325,7 +336,7 @@ export function recoverIfNeeded(
|
|
|
325
336
|
};
|
|
326
337
|
}
|
|
327
338
|
try {
|
|
328
|
-
|
|
339
|
+
publishConfigWriteTarget(expectedTargets.configPath, held, expectedTargets.storePath, (destination, hooks) => restore(destination, record.preStoreBytes, hooks));
|
|
329
340
|
} catch (error) {
|
|
330
341
|
return {
|
|
331
342
|
ok: false,
|
|
@@ -335,7 +346,7 @@ export function recoverIfNeeded(
|
|
|
335
346
|
}
|
|
336
347
|
}
|
|
337
348
|
try {
|
|
338
|
-
|
|
349
|
+
publishConfigWriteTarget(expectedTargets.configPath, held, journalPath, (destination, hooks) => durableDelete(destination, hooks));
|
|
339
350
|
} catch (error) {
|
|
340
351
|
return {
|
|
341
352
|
ok: false,
|
|
@@ -343,10 +354,12 @@ export function recoverIfNeeded(
|
|
|
343
354
|
detail: `rollback restored but the journal could not be removed: ${error instanceof Error ? error.message : String(error)}`,
|
|
344
355
|
};
|
|
345
356
|
}
|
|
346
|
-
return { ok: true, action: "rolled-back" };
|
|
357
|
+
return { ok: true, action: "rolled-back" } as RecoveryOutcome;
|
|
358
|
+
});
|
|
359
|
+
return locked.ok ? locked.value as RecoveryOutcome : { ok: false, error: locked.error === "unsafe" ? "unsafe" : "recovery_required", detail: configWriteLockFailureMessage(locked) };
|
|
347
360
|
}
|
|
348
361
|
|
|
349
|
-
function restore(path: string, bytes: string | null): void {
|
|
350
|
-
if (bytes === null) durableDelete(path);
|
|
351
|
-
else durableWrite(path, bytes);
|
|
362
|
+
function restore(path: string, bytes: string | null, hooks: AtomicWriteHooks): void {
|
|
363
|
+
if (bytes === null) durableDelete(path, hooks);
|
|
364
|
+
else durableWrite(path, bytes, hooks);
|
|
352
365
|
}
|
|
@@ -31,6 +31,7 @@ import { dirname, join, resolve } from "node:path";
|
|
|
31
31
|
import { createHash, randomBytes } from "node:crypto";
|
|
32
32
|
import { expandUserPath } from "../config";
|
|
33
33
|
import { resolveCodexHomeDir } from "./home";
|
|
34
|
+
import { prepareCodexHome } from "./prepared-home";
|
|
34
35
|
import {
|
|
35
36
|
durableWrite,
|
|
36
37
|
durableWriteExclusive,
|
|
@@ -42,6 +43,7 @@ import {
|
|
|
42
43
|
type JournalRecord,
|
|
43
44
|
} from "./prompt-journal";
|
|
44
45
|
import { release, stillHeld, tryAcquire } from "./prompt-lock";
|
|
46
|
+
import { configWriteLockFailureMessage, withConfigWriteLockHeld, publishConfigWrite, publishConfigWriteTarget, watchConfigWriteTargets, type LockHandle } from "./config-write-lock";
|
|
45
47
|
|
|
46
48
|
// ---------------------------------------------------------------------------
|
|
47
49
|
// Inventory — ONE definition, consumed by the route and the GUI alike.
|
|
@@ -644,7 +646,8 @@ export type WriteError =
|
|
|
644
646
|
// which means another writer won a race — here nobody won and nothing landed.
|
|
645
647
|
| "write_failed"
|
|
646
648
|
| "recovery_required"
|
|
647
|
-
| "locked"
|
|
649
|
+
| "locked"
|
|
650
|
+
| "unsafe";
|
|
648
651
|
|
|
649
652
|
export type WriteResult =
|
|
650
653
|
| { ok: true; changed: boolean; snapshot: PromptLayerSnapshot }
|
|
@@ -669,24 +672,34 @@ function commit(
|
|
|
669
672
|
revision: string,
|
|
670
673
|
build: (snapshot: PromptLayerSnapshot, configBytes: string | null, storeBytes: string | null)
|
|
671
674
|
=> Mutation | { error: WriteError; detail?: string },
|
|
675
|
+
): WriteResult {
|
|
676
|
+
for (const path of [activeConfigPath(opts), activeStorePath(opts)]) prepareCodexHome(dirname(path), 0o700);
|
|
677
|
+
return commitPrepared(opts, revision, build);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function commitPrepared(
|
|
681
|
+
opts: Paths | undefined,
|
|
682
|
+
revision: string,
|
|
683
|
+
build: (snapshot: PromptLayerSnapshot, configBytes: string | null, storeBytes: string | null)
|
|
684
|
+
=> Mutation | { error: WriteError; detail?: string },
|
|
672
685
|
): WriteResult {
|
|
673
686
|
const configPath = activeConfigPath(opts);
|
|
674
687
|
const storePath = activeStorePath(opts);
|
|
675
688
|
const journalPath = journalPathFor(storePath);
|
|
676
689
|
const lockPath = lockPathFor(storePath);
|
|
677
690
|
|
|
678
|
-
ensureDir(configPath);
|
|
679
|
-
ensureDir(storePath);
|
|
680
|
-
|
|
681
691
|
const acquired = tryAcquire(lockPath);
|
|
682
|
-
if (!acquired.ok) return { ok: false, error: "
|
|
692
|
+
if (!acquired.ok) return { ok: false, error: acquired.error, ...(acquired.error === "unsafe" ? { detail: configWriteLockFailureMessage(acquired) } : {}) };
|
|
683
693
|
const handle = acquired.handle;
|
|
684
694
|
|
|
685
695
|
try {
|
|
696
|
+
// Store -> config; cleanup protection starts before config acquisition.
|
|
697
|
+
const configLocked = withConfigWriteLockHeld(configPath, undefined, configHandle => {
|
|
698
|
+
watchConfigWriteTargets(configHandle, [storePath, journalPath]);
|
|
686
699
|
// 1. recovery first: a journal on disk means an earlier attempt never
|
|
687
700
|
// committed, and we must not stack a second transaction on top of it.
|
|
688
|
-
const recovered = recoverJournal(journalPath, { configPath, storePath });
|
|
689
|
-
if (!recovered.ok) return { ok: false, error:
|
|
701
|
+
const recovered = recoverJournal(journalPath, { configPath, storePath }, configHandle);
|
|
702
|
+
if (!recovered.ok) return { ok: false, error: recovered.error, detail: recovered.detail };
|
|
690
703
|
|
|
691
704
|
// 2. re-read and compare against the caller's edit base.
|
|
692
705
|
const configBytes = readFileOrNull(configPath);
|
|
@@ -722,7 +735,7 @@ function commit(
|
|
|
722
735
|
preStoreBytes: storeBytes,
|
|
723
736
|
postStoreBytes: nextStore,
|
|
724
737
|
};
|
|
725
|
-
|
|
738
|
+
publishConfigWriteTarget(configPath, configHandle, journalPath, (destination, hooks) => durableWrite(destination, encodeJournal(record), hooks));
|
|
726
739
|
|
|
727
740
|
// 4/5. each target re-verifies ITS OWN bytes immediately before its rename,
|
|
728
741
|
// so a third party writing between step 2 and here is not overwritten.
|
|
@@ -740,24 +753,28 @@ function commit(
|
|
|
740
753
|
try {
|
|
741
754
|
if (configChanged) {
|
|
742
755
|
if (hashBytes(readFileOrNull(configPath)) !== record.preConfig) {
|
|
743
|
-
return rollback(record, journalPath, "stale_revision");
|
|
756
|
+
return rollback(record, journalPath, "stale_revision", configHandle);
|
|
744
757
|
}
|
|
745
|
-
|
|
746
|
-
|
|
758
|
+
publishConfigWrite(configPath, configHandle, (destination, hooks) => {
|
|
759
|
+
if (nextConfig === null) durableDelete(destination, hooks);
|
|
760
|
+
else durableWrite(destination, nextConfig, hooks);
|
|
761
|
+
});
|
|
747
762
|
}
|
|
748
763
|
if (storeChanged) {
|
|
749
764
|
if (hashBytes(readFileOrNull(storePath)) !== record.preStore) {
|
|
750
|
-
return rollback(record, journalPath, "stale_revision");
|
|
765
|
+
return rollback(record, journalPath, "stale_revision", configHandle);
|
|
751
766
|
}
|
|
752
|
-
|
|
753
|
-
|
|
767
|
+
publishConfigWriteTarget(configPath, configHandle, storePath, (destination, hooks) => {
|
|
768
|
+
if (nextStore === null) durableDelete(destination, hooks);
|
|
769
|
+
else durableWrite(destination, nextStore, hooks);
|
|
770
|
+
});
|
|
754
771
|
}
|
|
755
772
|
} catch (error) {
|
|
756
773
|
// `rollback` is byte-hash driven and refuses to touch a file it does not
|
|
757
774
|
// recognise, so it is safe to run against a partially applied pair. If it
|
|
758
775
|
// cannot account for what it finds it returns recovery_required, which is the
|
|
759
776
|
// honest answer — better than a silent half-write either way.
|
|
760
|
-
const undone = rollback(record, journalPath, "write_failed");
|
|
777
|
+
const undone = rollback(record, journalPath, "write_failed", configHandle);
|
|
761
778
|
return { ...undone, detail: error instanceof Error ? error.message : String(error) } as WriteResult;
|
|
762
779
|
}
|
|
763
780
|
|
|
@@ -768,20 +785,21 @@ function commit(
|
|
|
768
785
|
if (finalConfig !== record.postConfig || finalStore !== record.postStore) {
|
|
769
786
|
return { ok: false, error: "write_superseded" };
|
|
770
787
|
}
|
|
771
|
-
if (!stillHeld(handle)) return { ok: false, error: "write_superseded" };
|
|
788
|
+
if (!stillHeld(handle) || !stillHeld(configHandle)) return { ok: false, error: "write_superseded" };
|
|
772
789
|
|
|
773
|
-
|
|
790
|
+
publishConfigWriteTarget(configPath, configHandle, journalPath, (destination, hooks) => durableDelete(destination, hooks)); // this deletion is the commit
|
|
774
791
|
// The FULL opts, not just the two paths this transaction owns: rebuilding the
|
|
775
792
|
// snapshot from a narrowed object dropped the injected variant directory, so every
|
|
776
793
|
// successful write reported an empty variant list back to its caller.
|
|
777
794
|
return { ok: true, changed: true, snapshot: readPromptLayers({ ...opts, configPath, storePath }) };
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
}
|
|
795
|
+
});
|
|
796
|
+
return configLocked.ok ? configLocked.value as WriteResult : { ok: false, error: configLocked.error === "locked" ? "locked" : "unsafe", ...(configLocked.error !== "locked" ? { detail: configWriteLockFailureMessage(configLocked) } : {}) };
|
|
797
|
+
} finally { release(handle); }
|
|
781
798
|
}
|
|
782
799
|
|
|
783
800
|
/** Undo whatever landed, then drop the journal. Never touches an unknown file. */
|
|
784
|
-
function rollback(record: JournalRecord, journalPath: string, error: WriteError): WriteResult {
|
|
801
|
+
function rollback(record: JournalRecord, journalPath: string, error: WriteError, held: LockHandle): WriteResult {
|
|
802
|
+
try {
|
|
785
803
|
const configNow = hashBytes(readFileOrNull(record.configPath));
|
|
786
804
|
const storeNow = hashBytes(readFileOrNull(record.storePath));
|
|
787
805
|
if (configNow !== record.preConfig && configNow !== record.postConfig) {
|
|
@@ -791,15 +809,20 @@ function rollback(record: JournalRecord, journalPath: string, error: WriteError)
|
|
|
791
809
|
return { ok: false, error: "recovery_required", detail: record.storePath };
|
|
792
810
|
}
|
|
793
811
|
if (configNow === record.postConfig) {
|
|
794
|
-
|
|
795
|
-
|
|
812
|
+
publishConfigWrite(record.configPath, held, (destination, hooks) => {
|
|
813
|
+
if (record.preConfigBytes === null) durableDelete(destination, hooks);
|
|
814
|
+
else durableWrite(destination, record.preConfigBytes, hooks);
|
|
815
|
+
});
|
|
796
816
|
}
|
|
797
817
|
if (storeNow === record.postStore) {
|
|
798
|
-
|
|
799
|
-
|
|
818
|
+
publishConfigWriteTarget(record.configPath, held, record.storePath, (destination, hooks) => {
|
|
819
|
+
if (record.preStoreBytes === null) durableDelete(destination, hooks);
|
|
820
|
+
else durableWrite(destination, record.preStoreBytes, hooks);
|
|
821
|
+
});
|
|
800
822
|
}
|
|
801
|
-
|
|
823
|
+
publishConfigWriteTarget(record.configPath, held, journalPath, (destination, hooks) => durableDelete(destination, hooks));
|
|
802
824
|
return { ok: false, error };
|
|
825
|
+
} catch { return { ok: false, error: "recovery_required" }; }
|
|
803
826
|
}
|
|
804
827
|
|
|
805
828
|
/** Flip one of the five prompt toggles. */
|