diff --git a/devlog/_plan/260902_nonbug_adoption_backlog/130_wp13_reset_credit_auto_redeem.md b/devlog/_plan/260902_nonbug_adoption_backlog/130_wp13_reset_credit_auto_redeem.md new file mode 100644 index 0000000000..f8f277f0b0 --- /dev/null +++ b/devlog/_plan/260902_nonbug_adoption_backlog/130_wp13_reset_credit_auto_redeem.md @@ -0,0 +1,25 @@ +# wp13 — #822 opt-in reset-credit auto-redemption (slice 1: policy + ledger + tests) + +Investigation (grok subagent Volta). Today: inspect (`GET .../wham/rate-limit-reset-credits`) and +manual consume (`POST .../consume` with a fresh `redeem_request_id` per call) in +`src/codex/auth-api.ts`, CLI `ocx account reset-credits`, dashboard button. An unused #657 ledger +(`reset-credit-operation-ledger.ts`, kinds `recovery|manual`) exists. No auto-redeem config. + +## Slice 1 (this cycle) +- Config: `resetCreditAutoRedeem: { enabled: boolean; leadTimeMinutes?: 1..60 }` (default off; malformed + → disabled with one warning). Types + zod `.catch(undefined)`. +- `src/codex/reset-credit-auto-redeem.ts`: pure policy `planAutoRedeem(now, credits, settings)` → nearest + unused credit with parseable `expires_at` and its due time `expires_at - lead`; identity + `{accountId, grantedAt, expiresAt}`; `shouldDispatch(refreshedCredits, plan)` re-validates the + identity after a fresh inspect. Ledger kind `"auto-redeem"` with one operationId reused as + `redeem_request_id` per identity (crash-safe idempotency). +- Scheduler: `startResetCreditAutoRedeem(config, deps)` registered from `src/server/index.ts` only when + enabled, teardown via `registerOptionalShutdownHook`; timer fire = refresh + re-check, never blind + redeem. Logs hashed account key only. +- Docs row in server.md. No GUI. + +## Acceptance +- Default off: no timer, no import cost on core files (core-lab boundary test green). +- Fake clock + fake WHAM: schedules at expiry-lead; identity change / disable / manual consume first → + skip; dispatch reuses the same redeem_request_id across a simulated restart; success re-reads balance. + diff --git a/devlog/_plan/260902_nonbug_adoption_backlog/131_wp13_audit_r1_synthesis.md b/devlog/_plan/260902_nonbug_adoption_backlog/131_wp13_audit_r1_synthesis.md new file mode 100644 index 0000000000..c156f4d12f --- /dev/null +++ b/devlog/_plan/260902_nonbug_adoption_backlog/131_wp13_audit_r1_synthesis.md @@ -0,0 +1,5 @@ +# wp13 audit r1 — synthesis + +Volta (grok-4.6): expiry-triggered, default-off, generation-keyed identity, fresh inspect before +dispatch, one redeem_request_id per identity in a new ledger kind, activation only from the +composition root. Adopted; scheduler included in slice 1 because policy without a trigger closes nothing. diff --git a/devlog/_plan/260902_nonbug_adoption_backlog/140_wp14_upstream_ws_carry.md b/devlog/_plan/260902_nonbug_adoption_backlog/140_wp14_upstream_ws_carry.md new file mode 100644 index 0000000000..409d2dd9c4 --- /dev/null +++ b/devlog/_plan/260902_nonbug_adoption_backlog/140_wp14_upstream_ws_carry.md @@ -0,0 +1,14 @@ +# wp14 — #2816 + PR #2817 opt-in upstream Responses WebSocket transport (carry) + +Investigation (grok subagent Turing): opt-in `providers..upstreamWebsocket` boolean; hook in +`providerFetch` via `shouldUseCodexWsUpstream`; HTTPS `/responses` only; SSE fallback on any +pre-open failure (426 included); fail-closed `response.done` mapping; no core-lab or startServer +changes; no body/token logging. macOS red on the PR head was the known `server-auth` websocket +passthrough flake; Linux shards green. 139 behind dev, one conflict in provider-routes.ts POST +overwrite block (retainModels/displayNames vs upstreamWebsocket omit-preserve). + +## Decision +Carry by merge in a side worktree (/tmp/ocx-wp14-c94721, branch `codex/carry-2817-upstream-ws`); conflict resolved by +subagent keeping both omit-preserves; tsc/privacy/focused green at `d4914f52d`. Land via new PR, +close #2817 as landed-via-carry, close #2816. + diff --git a/devlog/_plan/260902_nonbug_adoption_backlog/141_wp14_audit_r1_synthesis.md b/devlog/_plan/260902_nonbug_adoption_backlog/141_wp14_audit_r1_synthesis.md new file mode 100644 index 0000000000..3c2dee56c6 --- /dev/null +++ b/devlog/_plan/260902_nonbug_adoption_backlog/141_wp14_audit_r1_synthesis.md @@ -0,0 +1,6 @@ +# wp14 audit r1 — synthesis + +Turing (grok-4.6): carry + fix, not reimplement. Blockers: rebase + resolve provider-routes.ts keeping +both omit-preserves; exact-head CI (macOS server-auth ws flake to be treated as flake). Verdict near-pass. +Merge executed by Aristotle (grok-4.6) in the side worktree: resolved block keeps `existing` early, +samples `submittedUpstreamWebsocket` before enrich, preserves on omit; 153 pass / 1 skip. diff --git a/devlog/_plan/260902_nonbug_adoption_backlog/150_wp15_plaintext_v2_disposition.md b/devlog/_plan/260902_nonbug_adoption_backlog/150_wp15_plaintext_v2_disposition.md new file mode 100644 index 0000000000..fcf3e08930 --- /dev/null +++ b/devlog/_plan/260902_nonbug_adoption_backlog/150_wp15_plaintext_v2_disposition.md @@ -0,0 +1,14 @@ +# wp15 — #2495 + PR #2496 opt-in plaintext V2 collaboration rewrite + +Investigation (grok subagent James): head e4b88af4f, 14 ahead / 4 unique vs dev, +2729 / 18 files, draft, +CHANGES_REQUESTED on an older head, exact-head CI blocked on fork approval; last executed suite red on +two PR-specific assertions (plaintext alias rebuild after quota retry; WS relay rewrite). Depends on +undocumented ChatGPT/Codex behavior (reserved namespace/tool renames to defeat Fernet encryption; +`encrypted_function_args: []` receive path). Core-lab boundary clean; no body logging. + +## Disposition +Close PR #2496 with rationale; keep #2495 open with the reopen conditions. Not merged: protocol rewrite +keyed off undocumented upstream behavior, no exact-head green, reviewer blockers not re-reviewed, and a +smaller slice would not close the issue. Estimated honest merge path 8–12h with a maintainer-owned +rebase and security pass; not this batch. + diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 9d9803b7a1..1a82eb82cd 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -27,6 +27,7 @@ runs helper features around provider requests. | `codexAutoStart?` | `boolean` | `true` | Let the Codex shim run `ocx ensure` before launching Codex. False makes ensure a no-op. | | `codexShimAutoRestore?` | `boolean` | `true` | Restore an installed shim after a completed external Codex update replaces it. Environment opt-out: `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`. | | `codexDesktopAuthless?` | `boolean` | `false` | Opt-in authless Codex Desktop routing on a loopback bind: inject the dedicated `opencodex` provider with `requires_openai_auth = false` so Desktop opens without a ChatGPT login. Ignored on non-loopback binds. `ocx system settings --desktop-authless on`. See [Codex integration](/guides/codex-integration/#authless-codex-desktop-opt-in). | +| `resetCreditAutoRedeem?` | `{ enabled?: boolean; leadTimeMinutes?: number }` | off | Opt-in: redeem the main Codex account's soonest-expiring reset credit `leadTimeMinutes` (1–60, default 10) before it expires. Every attempt re-reads the upstream credit list first and skips when the credit is gone (for example, redeemed by hand); the `redeem_request_id` is journaled in `$OPENCODEX_HOME/reset-credit-auto-redeem.json` before the call so a crash replays the same idempotent request instead of spending a second credit. Logs carry a hashed account key only. | | `syncResumeHistory?` | `boolean` | `true` | Reversible Codex App history compatibility. Original metadata is backed up and restored by `ocx stop` / `ocx restore`. | | `shadowCallIntercept?` | `{ enabled?: boolean; model?: string; sourceModels?: string[] }` | off | Redirect recognized Codex helper/shadow calls to a chosen model while preserving the request's configured reasoning effort. The default source prefix is `gpt-5.6-luna`; older clients through 0.144.x used `gpt-5.4-mini`, which `sourceModels` can restore. | | `webSearchSidecar?` | `OcxWebSearchSidecarConfig` | on when usable | Web-search sidecar options. | diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 4f9c5efd28..52178b64f6 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -389,6 +389,49 @@ function safeResetCreditConsumeDto(input: unknown): { code: string } { return { code: typeof obj.code === "string" ? obj.code : "unknown" }; } +/** + * Background reset-credit access for the auto-redeemer (#822). Goes through the same + * account/lease wrapper as the management routes, but takes a caller-owned + * `redeem_request_id` so a journaled id can be replayed idempotently after a crash. + * Throws on any auth or upstream failure; the caller treats a throw on consume as ambiguous. + */ +export function createResetCreditWhamClient(config: OcxConfig, accountId: string): { + inspect: () => Promise<{ credits: { granted_at: string; expires_at: string }[] }>; + consume: (redeemRequestId: string) => Promise<{ code: string }>; +} { + const run = async (operation: (auth: ResetCreditAuth) => Promise): Promise => { + const result = await withResetCreditAuth(getRuntimeConfig(config), accountId, operation); + if (result.ok) return result.value; + throw new Error(`reset-credit auth unavailable (${result.response.status})`); + }; + return { + inspect: () => run(async auth => { + const resp = await fetch("https://chatgpt.com/backend-api/wham/rate-limit-reset-credits", { + headers: { Authorization: `Bearer ${auth.accessToken}`, "ChatGPT-Account-Id": auth.chatgptAccountId }, + signal: AbortSignal.timeout(8000), + }); + if (!resp.ok) { await resp.body?.cancel().catch(() => {}); throw new Error(`upstream ${resp.status}`); } + const parsed = await readResetCreditJson(resp, AbortSignal.timeout(8000)); + if (!parsed.ok) throw new Error("invalid upstream reset-credit response"); + return { credits: safeResetCreditsDto(parsed.value).credits }; + }), + consume: redeemRequestId => run(async auth => { + const resp = await fetch("https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume", { + method: "POST", + headers: { + Authorization: `Bearer ${auth.accessToken}`, + "ChatGPT-Account-Id": auth.chatgptAccountId, + "Content-Type": "application/json", + }, + body: JSON.stringify({ redeem_request_id: redeemRequestId }), + signal: AbortSignal.timeout(10_000), + }); + if (!resp.ok) { await resp.body?.cancel().catch(() => {}); throw new Error(`upstream ${resp.status}`); } + return safeResetCreditConsumeDto(await resp.json()); + }), + }; +} + type ResetCreditJsonRead = | { ok: true; value: unknown } | { ok: false }; diff --git a/src/codex/reset-credit-auto-redeem.ts b/src/codex/reset-credit-auto-redeem.ts new file mode 100644 index 0000000000..19b6d3ae0e --- /dev/null +++ b/src/codex/reset-credit-auto-redeem.ts @@ -0,0 +1,237 @@ +import { createHash, randomUUID } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { atomicWriteFile } from "../config/atomic-write"; +import { getConfigDir } from "../config/paths"; +import { registerOptionalShutdownHook } from "../lib/optional-shutdown-hooks"; +import type { OcxConfig } from "../types"; + +/** + * Opt-in auto-redemption of a Codex reset credit shortly before it expires (#822). + * + * Default off. When enabled, the nearest unexpired credit for the main Codex account is + * redeemed `leadTimeMinutes` before its `expires_at`. Every fire re-reads the upstream credit + * list first and dispatches only when the same credit (granted_at + expires_at) is still + * present, so a credit the operator already spent by hand is never redeemed twice. The + * `redeem_request_id` for a credit identity is minted once and journaled to disk before the + * consume call, so a crash between dispatch and settle replays the same idempotent request + * instead of spending a second credit. Logs carry a hashed account key only. + */ + +export interface ResetCreditAutoRedeemSettings { + enabled: boolean; + leadTimeMinutes: number; +} + +export const DEFAULT_LEAD_TIME_MINUTES = 10; +export const MIN_LEAD_TIME_MINUTES = 1; +export const MAX_LEAD_TIME_MINUTES = 60; + +export function resolveResetCreditAutoRedeemSettings(config: Pick): ResetCreditAutoRedeemSettings { + const raw = config.resetCreditAutoRedeem; + if (!raw || raw.enabled !== true) return { enabled: false, leadTimeMinutes: DEFAULT_LEAD_TIME_MINUTES }; + const lead = typeof raw.leadTimeMinutes === "number" && Number.isInteger(raw.leadTimeMinutes) + ? Math.min(Math.max(raw.leadTimeMinutes, MIN_LEAD_TIME_MINUTES), MAX_LEAD_TIME_MINUTES) + : DEFAULT_LEAD_TIME_MINUTES; + return { enabled: true, leadTimeMinutes: lead }; +} + +export interface ResetCredit { + granted_at: string; + expires_at: string; +} + +export interface AutoRedeemPlan { + /** Stable identity of the credit being protected. */ + grantedAt: string; + expiresAt: string; + /** Epoch ms at which the redeem should be attempted. */ + dueAt: number; +} + +/** Pick the credit that expires soonest and is still in the future; null when nothing qualifies. */ +export function planAutoRedeem(now: number, credits: readonly ResetCredit[], settings: ResetCreditAutoRedeemSettings): AutoRedeemPlan | null { + if (!settings.enabled) return null; + let best: AutoRedeemPlan | null = null; + for (const credit of credits) { + const expires = Date.parse(credit.expires_at); + if (!Number.isFinite(expires) || expires <= now) continue; + const dueAt = expires - settings.leadTimeMinutes * 60_000; + if (!best || expires < Date.parse(best.expiresAt)) best = { grantedAt: credit.granted_at, expiresAt: credit.expires_at, dueAt }; + } + return best; +} + +export function creditStillPresent(credits: readonly ResetCredit[], plan: Pick): boolean { + return credits.some(c => c.granted_at === plan.grantedAt && c.expires_at === plan.expiresAt); +} + +interface JournalEntry { + accountKey: string; + grantedAt: string; + expiresAt: string; + redeemRequestId: string; + state: "dispatched" | "settled"; + updatedAt: number; +} + +interface Journal { version: 1; entries: JournalEntry[] } + +export function journalPath(): string { + return join(getConfigDir(), "reset-credit-auto-redeem.json"); +} + +function readJournal(path: string): Journal { + if (!existsSync(path)) return { version: 1, entries: [] }; + try { + const parsed = JSON.parse(readFileSync(path, "utf8")) as Journal; + return parsed && parsed.version === 1 && Array.isArray(parsed.entries) ? parsed : { version: 1, entries: [] }; + } catch { + return { version: 1, entries: [] }; + } +} + +function writeJournal(path: string, journal: Journal): void { + // Keep only entries whose credit could still matter: settled ones older than a week are noise. + const cutoff = Date.now() - 7 * 24 * 60 * 60_000; + journal.entries = journal.entries.filter(e => e.state !== "settled" || e.updatedAt > cutoff); + atomicWriteFile(path, JSON.stringify(journal, null, 2)); +} + +export function hashAccountKey(accountId: string): string { + return createHash("sha256").update(accountId).digest("hex").slice(0, 12); +} + +export interface AutoRedeemDeps { + accountId: string; + settings: () => ResetCreditAutoRedeemSettings; + /** Fresh upstream read of the credit list; throws on auth/transport failure. */ + inspect: () => Promise<{ credits: ResetCredit[] }>; + /** Consume with a caller-owned idempotency key. Returns the upstream code. */ + consume: (redeemRequestId: string) => Promise<{ code: string }>; + now?: () => number; + setTimer?: (fn: () => void, ms: number) => unknown; + clearTimer?: (handle: unknown) => void; + journalFile?: string; + log?: (line: string) => void; + /** Upper bound on one sleep so a laptop sleep or clock jump re-checks rather than trusting a stale plan. */ + maxSleepMs?: number; + /** Interval to re-inspect when no credit is due yet (default 30 min). */ + idleRecheckMs?: number; +} + +export type AutoRedeemOutcome = + | { kind: "disabled" } + | { kind: "nothing-to-protect" } + | { kind: "scheduled"; dueAt: number } + | { kind: "skipped"; reason: "credit-gone" | "disabled-before-dispatch" } + | { kind: "dispatched"; code: string; redeemRequestId: string } + | { kind: "ambiguous"; redeemRequestId: string } + | { kind: "error"; message: string }; + +export interface ResetCreditAutoRedeemer { + /** Inspect, and either dispatch (if due) or schedule the next check. */ + tick(): Promise; + start(): void; + stop(): void; +} + +export function createResetCreditAutoRedeemer(deps: AutoRedeemDeps): ResetCreditAutoRedeemer { + const now = deps.now ?? (() => Date.now()); + const setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms)); + const clearTimer = deps.clearTimer ?? (handle => clearTimeout(handle as ReturnType)); + const log = deps.log ?? ((line: string) => console.log(line)); + const path = deps.journalFile ?? journalPath(); + const accountKey = hashAccountKey(deps.accountId); + const maxSleepMs = deps.maxSleepMs ?? 15 * 60_000; + const idleRecheckMs = deps.idleRecheckMs ?? 30 * 60_000; + let handle: unknown = null; + let stopped = false; + let inFlight: Promise | null = null; + + const schedule = (ms: number): void => { + if (stopped) return; + if (handle !== null) clearTimer(handle); + handle = setTimer(() => { handle = null; void tick(); }, Math.max(0, Math.min(ms, maxSleepMs))); + }; + + const dispatch = async (plan: AutoRedeemPlan): Promise => { + const journal = readJournal(path); + let entry = journal.entries.find(e => e.accountKey === accountKey && e.grantedAt === plan.grantedAt && e.expiresAt === plan.expiresAt); + if (entry?.state === "settled") return { kind: "skipped", reason: "credit-gone" }; + if (!entry) { + entry = { accountKey, grantedAt: plan.grantedAt, expiresAt: plan.expiresAt, redeemRequestId: randomUUID(), state: "dispatched", updatedAt: now() }; + journal.entries.push(entry); + // Journal BEFORE the network call: a crash after this line replays the same request id. + writeJournal(path, journal); + } + log(`[opencodex] reset-credit auto-redeem: dispatching for account ${accountKey} (credit expires ${plan.expiresAt})`); + let result: { code: string }; + try { + result = await deps.consume(entry.redeemRequestId); + } catch (error) { + log(`[opencodex] reset-credit auto-redeem: consume uncertain for account ${accountKey}; will retry with the same request id`); + schedule(60_000); + return { kind: "ambiguous", redeemRequestId: entry.redeemRequestId }; + } + entry.state = "settled"; + entry.updatedAt = now(); + writeJournal(path, journal); + log(`[opencodex] reset-credit auto-redeem: upstream answered ${result.code} for account ${accountKey}`); + schedule(idleRecheckMs); + return { kind: "dispatched", code: result.code, redeemRequestId: entry.redeemRequestId }; + }; + + const tick = async (): Promise => { + if (inFlight) return inFlight; + inFlight = (async () => { + const settings = deps.settings(); + if (!settings.enabled) return { kind: "disabled" } as AutoRedeemOutcome; + let credits: ResetCredit[]; + try { + ({ credits } = await deps.inspect()); + } catch (error) { + schedule(idleRecheckMs); + return { kind: "error", message: error instanceof Error ? error.message : "inspect failed" } as AutoRedeemOutcome; + } + const plan = planAutoRedeem(now(), credits, settings); + if (!plan) { schedule(idleRecheckMs); return { kind: "nothing-to-protect" } as AutoRedeemOutcome; } + if (plan.dueAt > now()) { schedule(plan.dueAt - now()); return { kind: "scheduled", dueAt: plan.dueAt } as AutoRedeemOutcome; } + // Due: re-read right before spending. The plan above came from this same inspect, but + // the settings may have flipped and a manual consume may have raced; check both again. + if (!deps.settings().enabled) return { kind: "skipped", reason: "disabled-before-dispatch" } as AutoRedeemOutcome; + let fresh: ResetCredit[]; + try { ({ credits: fresh } = await deps.inspect()); } catch (error) { + schedule(60_000); + return { kind: "error", message: error instanceof Error ? error.message : "inspect failed" } as AutoRedeemOutcome; + } + if (!creditStillPresent(fresh, plan)) { schedule(idleRecheckMs); return { kind: "skipped", reason: "credit-gone" } as AutoRedeemOutcome; } + return dispatch(plan); + })().finally(() => { inFlight = null; }); + return inFlight; + }; + + return { + tick, + start() { stopped = false; void tick(); }, + stop() { stopped = true; if (handle !== null) { clearTimer(handle); handle = null; } }, + }; +} + +/** + * Composition-root activation. Returns the redeemer only when the opt-in is on; the caller + * (src/server/index.ts) must not await this and must gate on `enabled` itself so a default + * install never constructs the timer. + */ +export function activateResetCreditAutoRedeem( + config: OcxConfig, + wham: Pick, +): ResetCreditAutoRedeemer { + const redeemer = createResetCreditAutoRedeemer({ + ...wham, + settings: () => resolveResetCreditAutoRedeemSettings(config), + }); + const unregister = registerOptionalShutdownHook("reset-credit-auto-redeem", () => { redeemer.stop(); unregister(); }); + redeemer.start(); + return redeemer; +} diff --git a/src/config.ts b/src/config.ts index a0965f9b6d..a0c35f3bb4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1088,6 +1088,10 @@ const configSchema = z.object({ // Same degrade-not-reject rule: a malformed hand edit hides Spark rather than discarding the // whole config. Hidden is also the default, so `catch(false)` and the default agree. showCodexSparkQuota: z.boolean().optional().catch(false), + resetCreditAutoRedeem: z.object({ + enabled: z.boolean().optional(), + leadTimeMinutes: z.number().int().min(1).max(60).optional(), + }).optional().catch(undefined), // Model ids excluded from the Grok Build managed block (dashboard switches). grokExcludedModels: z.array(z.string()).optional(), // Invalid values degrade to undefined ("auto") instead of failing the whole diff --git a/src/server/index.ts b/src/server/index.ts index 1c4f72bb09..ac59dc2990 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -32,7 +32,8 @@ import { type NativeCodexOwnership, type OwnershipInspection, } from "../integrations/native/ownership-preflight"; -import { registerCodexCooldownRecoveryProbeWorker } from "../codex/auth-api"; +import { createResetCreditWhamClient, registerCodexCooldownRecoveryProbeWorker } from "../codex/auth-api"; +import { activateResetCreditAutoRedeem } from "../codex/reset-credit-auto-redeem"; import { reconcileLiveStateStores, setLiveStateStoreConfig, @@ -2338,5 +2339,14 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server ({ + granted_at: grantedAt, + expires_at: new Date(T0 + expiresInMin * MIN).toISOString(), +}); + +/** Fake clock + manual timer: fire() runs the pending timer at its due time. */ +function harness(opts: { credits: () => ResetCredit[]; enabled?: () => boolean; lead?: number; journalFile: string; consumeCode?: string; consumeThrows?: boolean }) { + let now = T0; + let pending: { fn: () => void; at: number } | null = null; + const consumed: string[] = []; + const logs: string[] = []; + let inspects = 0; + const redeemer = createResetCreditAutoRedeemer({ + accountId: "acct-main", + settings: () => ({ enabled: opts.enabled ? opts.enabled() : true, leadTimeMinutes: opts.lead ?? 10 }), + inspect: async () => { inspects += 1; return { credits: opts.credits() }; }, + consume: async id => { + if (opts.consumeThrows) throw new Error("socket hangup"); + consumed.push(id); + return { code: opts.consumeCode ?? "reset" }; + }, + now: () => now, + setTimer: (fn, ms) => { pending = { fn, at: now + ms }; return 1; }, + clearTimer: () => { pending = null; }, + journalFile: opts.journalFile, + log: line => logs.push(line), + }); + return { + redeemer, consumed, logs, + inspects: () => inspects, + pendingAt: () => pending?.at ?? null, + advanceAndFire: async () => { if (!pending) throw new Error("no timer"); now = pending.at; const fn = pending.fn; pending = null; fn(); await new Promise(r => setTimeout(r, 5)); }, + setNow: (t: number) => { now = t; }, + }; +} + +let dir = ""; +beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "ocx-auto-redeem-")); }); +afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); + +describe("reset-credit auto-redeem settings + plan (#822)", () => { + test("default off; malformed reads as off; lead time clamped", () => { + expect(resolveResetCreditAutoRedeemSettings({}).enabled).toBe(false); + expect(resolveResetCreditAutoRedeemSettings({ resetCreditAutoRedeem: { enabled: false, leadTimeMinutes: 5 } }).enabled).toBe(false); + expect(resolveResetCreditAutoRedeemSettings({ resetCreditAutoRedeem: { enabled: true } })).toEqual({ enabled: true, leadTimeMinutes: 10 }); + expect(resolveResetCreditAutoRedeemSettings({ resetCreditAutoRedeem: { enabled: true, leadTimeMinutes: 500 } }).leadTimeMinutes).toBe(60); + }); + + test("plans the soonest future credit and ignores unparseable or expired ones", () => { + const settings = { enabled: true, leadTimeMinutes: 10 }; + expect(planAutoRedeem(T0, [], settings)).toBeNull(); + expect(planAutoRedeem(T0, [{ granted_at: "x", expires_at: "not a date" }, credit(-5)], settings)).toBeNull(); + const plan = planAutoRedeem(T0, [credit(120), credit(30, "2026-08-31T00:00:00Z"), credit(60)], settings)!; + expect(plan.grantedAt).toBe("2026-08-31T00:00:00Z"); + expect(plan.dueAt).toBe(T0 + 20 * MIN); + expect(planAutoRedeem(T0, [credit(30)], { enabled: false, leadTimeMinutes: 10 })).toBeNull(); + }); +}); + +describe("reset-credit auto-redeemer runtime (#822)", () => { + test("schedules at expiry minus lead, re-reads before dispatch, journals the request id first", async () => { + const journalFile = join(dir, "j.json"); + const h = harness({ credits: () => [credit(30)], journalFile }); + expect(await h.redeemer.tick()).toEqual({ kind: "scheduled", dueAt: T0 + 20 * MIN }); + // Sleeps are capped at 15 min so a laptop sleep re-checks instead of trusting a stale plan. + expect(h.pendingAt()).toBe(T0 + 15 * MIN); + expect(h.consumed).toHaveLength(0); + await h.advanceAndFire(); + expect(h.consumed).toHaveLength(0); + expect(h.pendingAt()).toBe(T0 + 20 * MIN); + await h.advanceAndFire(); + expect(h.consumed).toHaveLength(1); + // initial + intermediate re-check + (plan + pre-dispatch re-read) on the due tick + expect(h.inspects()).toBe(4); + const journal = JSON.parse(readFileSync(journalFile, "utf8")) as { entries: Array<{ redeemRequestId: string; state: string }> }; + expect(journal.entries[0]!.redeemRequestId).toBe(h.consumed[0]!); + expect(journal.entries[0]!.state).toBe("settled"); + expect(h.logs.join("\n")).not.toContain("acct-main"); + }); + + test("a credit redeemed by hand (gone on refresh) is skipped without a consume", async () => { + const journalFile = join(dir, "j.json"); + let list = [credit(30)]; + const h = harness({ credits: () => list, journalFile }); + await h.redeemer.tick(); + list = []; + h.setNow(T0 + 20 * MIN); + // With the credit gone the plan is empty: nothing to protect, and nothing consumed. + expect(await h.redeemer.tick()).toEqual({ kind: "nothing-to-protect" }); + expect(h.consumed).toHaveLength(0); + }); + + test("disabling before dispatch skips; a different credit identity is not redeemed with the old plan", async () => { + const journalFile = join(dir, "j.json"); + let enabled = true; + let list = [credit(30)]; + const h = harness({ credits: () => list, enabled: () => enabled, journalFile }); + await h.redeemer.tick(); + enabled = false; + h.setNow(T0 + 20 * MIN); + expect(await h.redeemer.tick()).toEqual({ kind: "disabled" }); + enabled = true; + // Replaced by a later credit: nothing is due yet, so no consume. + list = [credit(300, "2026-09-02T09:00:00Z")]; + expect((await h.redeemer.tick()).kind).toBe("scheduled"); + expect(h.consumed).toHaveLength(0); + }); + + test("an uncertain consume keeps the same request id across a simulated restart", async () => { + const journalFile = join(dir, "j.json"); + const crashy = harness({ credits: () => [credit(30)], journalFile, consumeThrows: true }); + crashy.setNow(T0 + 20 * MIN); + const first = await crashy.redeemer.tick(); + expect(first.kind).toBe("ambiguous"); + const id = (first as { redeemRequestId: string }).redeemRequestId; + expect(JSON.parse(readFileSync(journalFile, "utf8")).entries[0].state).toBe("dispatched"); + + // New process, same journal: the replay reuses the journaled id and settles it. + const resumed = harness({ credits: () => [credit(30)], journalFile, consumeCode: "already_redeemed" }); + resumed.setNow(T0 + 21 * MIN); + const second = await resumed.redeemer.tick(); + expect(second).toEqual({ kind: "dispatched", code: "already_redeemed", redeemRequestId: id }); + expect(resumed.consumed).toEqual([id]); + + // Settled: a third tick with the credit still listed does not spend again. + expect(await resumed.redeemer.tick()).toEqual({ kind: "skipped", reason: "credit-gone" }); + expect(resumed.consumed).toEqual([id]); + }); + + test("a manual redeem racing between the planning read and the pre-dispatch read is caught", async () => { + const journalFile = join(dir, "j.json"); + let reads = 0; + const h = harness({ credits: () => { reads += 1; return reads === 1 ? [credit(30)] : []; }, journalFile }); + h.setNow(T0 + 20 * MIN); + expect(await h.redeemer.tick()).toEqual({ kind: "skipped", reason: "credit-gone" }); + expect(h.consumed).toHaveLength(0); + }); + + test("stop clears the timer", async () => { + const h = harness({ credits: () => [credit(30)], journalFile: join(dir, "j.json") }); + await h.redeemer.tick(); + expect(h.pendingAt()).not.toBeNull(); + h.redeemer.stop(); + expect(h.pendingAt()).toBeNull(); + }); +});