@bitkyc08/opencodex 2.55.0 → 2.57.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 +10 -0
- package/gui/dist/assets/{index-BBOZWGB6.css → index-C5-RdDmD.css} +1 -1
- package/gui/dist/assets/{index-VuoiWj9J.js → index-Cz7CLdif.js} +21 -21
- package/gui/dist/index.html +2 -2
- package/package.json +4 -3
- package/src/adapters/base.ts +21 -0
- package/src/adapters/codebuddy/adapter.ts +2 -1
- package/src/adapters/codebuddy/scaffold-guard.ts +248 -0
- package/src/adapters/command-code.ts +1 -1
- package/src/adapters/cursor/envelope-echo.ts +8 -2
- package/src/adapters/cursor/transport-retry.ts +46 -1
- package/src/adapters/cursor.ts +4 -0
- package/src/adapters/google.ts +7 -7
- package/src/adapters/kiro/adapter.ts +42 -1
- package/src/adapters/kiro/payload.ts +17 -3
- package/src/adapters/kiro/reasoning.ts +70 -7
- package/src/adapters/kiro/stream.ts +8 -2
- package/src/adapters/kiro/wire.ts +2 -1
- package/src/adapters/kiro-events.ts +21 -13
- package/src/adapters/kiro-retry.ts +23 -4
- package/src/adapters/openai-chat/errors.ts +116 -0
- package/src/adapters/openai-chat/messages.ts +346 -0
- package/src/adapters/openai-chat/passthrough.ts +146 -0
- package/src/adapters/openai-chat/response-events.ts +117 -0
- package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
- package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
- package/src/adapters/openai-chat/tool-schema.ts +495 -0
- package/src/adapters/openai-chat/wire.ts +50 -0
- package/src/adapters/openai-chat.ts +40 -1452
- package/src/adapters/openai-responses/canonical-forward.ts +202 -0
- package/src/adapters/openai-responses/image-gen.ts +406 -0
- package/src/adapters/openai-responses/internal.ts +3 -0
- package/src/adapters/openai-responses/passthrough.ts +642 -0
- package/src/adapters/openai-responses/prompt-cache.ts +83 -0
- package/src/adapters/openai-responses/reasoning.ts +220 -0
- package/src/adapters/openai-responses/request-strips.ts +185 -0
- package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
- package/src/adapters/openai-responses/tool-schema.ts +293 -0
- package/src/adapters/openai-responses/web-search.ts +156 -0
- package/src/adapters/openai-responses.ts +4 -2625
- package/src/bridge/errors.ts +58 -0
- package/src/bridge/internal.ts +174 -0
- package/src/bridge/response-json.ts +630 -0
- package/src/bridge/sse.ts +1462 -0
- package/src/bridge.ts +5 -2204
- package/src/chat/inbound.ts +12 -1
- package/src/claude/desktop-profile.ts +66 -9
- package/src/claude/outbound.ts +18 -0
- package/src/cli/account-main.ts +1 -1
- package/src/cli/capabilities.ts +2 -2
- package/src/cli/combo.ts +10 -1
- package/src/cli/index.ts +48 -5
- package/src/cli/registry.ts +2 -1
- package/src/cli/system-command.ts +4 -4
- package/src/clients/config-export.ts +7 -3
- package/src/codex/account-label.ts +14 -3
- package/src/codex/account-lifecycle.ts +3 -0
- package/src/codex/account-store.ts +184 -35
- package/src/codex/account-usability.ts +21 -0
- package/src/codex/auth-api/account-list.ts +507 -0
- package/src/codex/auth-api/http.ts +32 -0
- package/src/codex/auth-api/login-flow.ts +566 -0
- package/src/codex/auth-api/login-state.ts +64 -0
- package/src/codex/auth-api/main-account-probe.ts +331 -0
- package/src/codex/auth-api/pool-mode-gate.ts +274 -0
- package/src/codex/auth-api/pool-quota-probe.ts +512 -0
- package/src/codex/auth-api/reset-credit-service.ts +431 -0
- package/src/codex/auth-api/routes.ts +425 -0
- package/src/codex/auth-api/runtime-config.ts +48 -0
- package/src/codex/auth-api.ts +27 -3118
- package/src/codex/auth-context.ts +252 -35
- package/src/codex/catalog/aggregation.ts +80 -1
- package/src/codex/catalog/auto-review.ts +507 -0
- package/src/codex/catalog/build-entries.ts +981 -0
- package/src/codex/catalog/combo-member.ts +375 -0
- package/src/codex/catalog/derive-entry.ts +229 -0
- package/src/codex/catalog/effort.ts +0 -1
- package/src/codex/catalog/gated-native-warn.ts +63 -0
- package/src/codex/catalog/gather-capture.ts +533 -0
- package/src/codex/catalog/model-hints.ts +691 -0
- package/src/codex/catalog/model-visibility.ts +305 -0
- package/src/codex/catalog/provider-fetch.ts +52 -2942
- package/src/codex/catalog/provider-models.ts +685 -0
- package/src/codex/catalog/remote.ts +30 -0
- package/src/codex/catalog/restore.ts +132 -0
- package/src/codex/catalog/retained-sync.ts +714 -0
- package/src/codex/catalog/routed-gather.ts +895 -0
- package/src/codex/catalog/subagent-roster.ts +176 -0
- package/src/codex/catalog/sync.ts +52 -2698
- package/src/codex/cli-install-provenance.ts +7 -1
- package/src/codex/convergence.ts +7 -2
- package/src/codex/desktop-app/types.ts +11 -2
- package/src/codex/desktop-app/windows.ts +5 -5
- package/src/codex/inject/config-toml.ts +563 -0
- package/src/codex/inject/remove.ts +192 -0
- package/src/codex/inject/restore.ts +567 -0
- package/src/codex/inject/routing-classify.ts +109 -0
- package/src/codex/inject/routing-target.ts +125 -0
- package/src/codex/inject.ts +89 -1444
- package/src/codex/lineage.ts +458 -0
- package/src/codex/model-entitlements.ts +152 -15
- package/src/codex/pool-refresh-backoff.ts +161 -0
- package/src/codex/quota-rejection.ts +104 -15
- package/src/codex/routing/active-account.ts +194 -0
- package/src/codex/routing/cache-affinity.ts +70 -0
- package/src/codex/routing/cooldown-math.ts +285 -0
- package/src/codex/routing/health-store.ts +402 -0
- package/src/codex/routing/probe-lease.ts +358 -0
- package/src/codex/routing/selection.ts +780 -0
- package/src/codex/routing/thread-affinity.ts +586 -0
- package/src/codex/routing/transient-hold-dispatch.ts +141 -0
- package/src/codex/routing.ts +370 -2271
- package/src/codex/shim-fingerprint.ts +223 -0
- package/src/codex/shim-inspect.ts +175 -0
- package/src/codex/shim-probe.ts +367 -0
- package/src/codex/shim-restore-lock.ts +169 -0
- package/src/codex/shim-state-file.ts +151 -0
- package/src/codex/shim-templates.ts +265 -0
- package/src/codex/shim.ts +48 -1268
- package/src/codex/warmup.ts +1 -1
- package/src/combos/failover.ts +85 -0
- package/src/combos/request.ts +17 -10
- package/src/combos/types.ts +23 -2
- package/src/config/diagnostics.ts +705 -0
- package/src/config/feature-flags.ts +55 -0
- package/src/config/live-reconcile.ts +403 -0
- package/src/config/load-degrade.ts +880 -0
- package/src/config/mutation-lock.ts +244 -0
- package/src/config/openai-tier-backup.ts +268 -0
- package/src/config/pending-teardown.ts +31 -0
- package/src/config/persist-unlocked.ts +92 -0
- package/src/config/proxy-env.ts +188 -0
- package/src/config/salvage.ts +244 -0
- package/src/config/schema/config-schema.ts +640 -0
- package/src/config/schema/leaf-validators.ts +855 -0
- package/src/config/warn-memo.ts +28 -0
- package/src/config.ts +234 -4481
- package/src/generated/compatibility-version.json +649 -121
- package/src/images/loop.ts +1 -1
- package/src/lib/errors.ts +17 -0
- package/src/lib/request-execution-budget.ts +198 -23
- package/src/lib/spend-reservation-ledger.ts +958 -0
- package/src/lib/state-store-registrations.ts +6 -2
- package/src/lib/test-home-guard.ts +85 -1
- package/src/lib/upstream-retry.ts +132 -21
- package/src/lib/windows-elevation.ts +76 -14
- package/src/lib/workflow-budget.ts +553 -30
- package/src/oauth/index.ts +2 -2
- package/src/oauth/key-providers.ts +2 -2
- package/src/providers/kiro-models.ts +4 -3
- package/src/providers/label.ts +19 -1
- package/src/providers/model-discovery.ts +16 -0
- package/src/providers/quota/account-cache.ts +441 -0
- package/src/providers/quota/antigravity.ts +295 -0
- package/src/providers/quota/report-cache.ts +320 -0
- package/src/providers/quota/vendor-probes-key.ts +1243 -0
- package/src/providers/quota/vendor-probes-oauth.ts +590 -0
- package/src/providers/quota.ts +324 -3079
- package/src/providers/registry/entries-core.ts +1228 -0
- package/src/providers/registry/entries-extended.ts +1213 -0
- package/src/providers/registry/model-seeds.ts +912 -0
- package/src/providers/registry/types.ts +352 -0
- package/src/providers/registry.ts +24 -3536
- package/src/responses/continuation-ownership.ts +29 -0
- package/src/responses/reasoning-envelope.ts +6 -3
- package/src/responses/state/replay-fingerprint.ts +80 -0
- package/src/responses/state/snapshot-codec.ts +104 -0
- package/src/responses/state/spill-failure.ts +118 -0
- package/src/responses/state/spill-queue.ts +665 -0
- package/src/responses/state/temp-recovery.ts +257 -0
- package/src/responses/state.ts +82 -1143
- package/src/routing/identity-domains.ts +456 -0
- package/src/routing/probe-lease.ts +613 -0
- package/src/server/chat-completions.ts +3 -1
- package/src/server/chat-native.ts +37 -9
- package/src/server/index/bounded-request.ts +88 -0
- package/src/server/index/live-sideband.ts +601 -0
- package/src/server/index/serve-options.ts +1766 -0
- package/src/server/index/startup-warnings.ts +213 -0
- package/src/server/index/websocket-handler.ts +339 -0
- package/src/server/index.ts +45 -2552
- package/src/server/inspection-tee.ts +107 -0
- package/src/server/live.ts +46 -1
- package/src/server/management/combo-routes.ts +10 -1
- package/src/server/management/route-registry.ts +26 -23
- package/src/server/management/shared.ts +8 -5
- package/src/server/management/workflow-budget-routes.ts +133 -0
- package/src/server/management-api.ts +12 -0
- package/src/server/relay-eager.ts +2 -0
- package/src/server/relay.ts +14 -19
- package/src/server/request-log-conversation.ts +9 -7
- package/src/server/request-log.ts +372 -4
- package/src/server/response-log-body.ts +153 -0
- package/src/server/responses/account-change-state.ts +307 -0
- package/src/server/responses/adapter-continuation.ts +540 -0
- package/src/server/responses/adapter-delivery.ts +208 -0
- package/src/server/responses/adapter-dispatch.ts +1042 -0
- package/src/server/responses/codex-ws-wire.ts +5 -0
- package/src/server/responses/collaboration.ts +74 -4
- package/src/server/responses/combo-session-recall.ts +68 -8
- package/src/server/responses/compact.ts +113 -17
- package/src/server/responses/completion-policy.ts +33 -0
- package/src/server/responses/core-auth.ts +529 -0
- package/src/server/responses/core-codex-account.ts +907 -0
- package/src/server/responses/core-combo-failure.ts +210 -0
- package/src/server/responses/core-combo.ts +787 -0
- package/src/server/responses/core-errors.ts +170 -0
- package/src/server/responses/core-lifetime.ts +95 -0
- package/src/server/responses/core-normalize.ts +350 -0
- package/src/server/responses/core-opaque-recovery.ts +380 -0
- package/src/server/responses/core-options.ts +159 -0
- package/src/server/responses/core-replay.ts +298 -0
- package/src/server/responses/core.ts +192 -8893
- package/src/server/responses/encrypted-payload.ts +0 -1
- package/src/server/responses/input-admission.ts +126 -6
- package/src/server/responses/passthrough-delivery.ts +869 -0
- package/src/server/responses/passthrough-dispatch.ts +1494 -0
- package/src/server/responses/passthrough-error.ts +38 -2
- package/src/server/responses/passthrough-execution.ts +54 -0
- package/src/server/responses/request-prepare.ts +1080 -0
- package/src/server/responses/request-send-budget.ts +259 -0
- package/src/server/responses/request-sidecar-auth.ts +149 -0
- package/src/server/responses/request-spend.ts +147 -0
- package/src/server/responses/request-transport.ts +803 -0
- package/src/server/responses/response-effects.ts +157 -0
- package/src/server/responses/run-turn-execution.ts +476 -0
- package/src/server/responses/sidecar-execution.ts +463 -0
- package/src/server/responses/terminal-guard.ts +65 -4
- package/src/server/responses-image-gen-repair.ts +1 -1
- package/src/server/responses-undeclared-tool-guard.ts +9 -5
- package/src/server/workflow-refusal.ts +84 -0
- package/src/service/windows-ops.ts +210 -16
- package/src/service/windows-scheduler.ts +28 -21
- package/src/service.ts +1 -1
- package/src/types/config.ts +34 -1
- package/src/types/request.ts +8 -5
- package/src/types/tools.ts +24 -0
- package/src/types.ts +2 -0
- package/src/update/index.ts +10 -0
- package/src/update/stop-contract.d.mts +1 -0
- package/src/update/stop-contract.mjs +19 -0
- package/src/update/stop-decision.d.mts +1 -1
- package/src/update/stop-decision.mjs +12 -3
- package/src/usage/log.ts +147 -1
- package/src/usage/summary.ts +171 -21
- package/src/vision/anthropic-describe.ts +1 -1
- package/src/vision/describe.ts +5 -5
- package/src/web-search/anthropic-executor.ts +1 -1
- package/src/web-search/exa-executor.ts +1 -1
- package/src/web-search/executor.ts +1 -1
- package/src/web-search/gemini-executor.ts +1 -1
- package/src/web-search/loop.ts +1 -1
- package/src/web-search/ollama-executor.ts +1 -1
- package/src/web-search/parse.ts +67 -14
- package/src/web-search/passthrough-bridge.ts +64 -31
- package/src/web-search/xai-executor.ts +1 -1
|
@@ -0,0 +1,958 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable token spend reservation, above the send-count workflow guard (#4546).
|
|
3
|
+
*
|
|
4
|
+
* The count cap treats a 1k-token send and a 150k-token send as the same unit, and the
|
|
5
|
+
* in-memory ledger forgets everything on restart: an exhausted root came back with a fresh
|
|
6
|
+
* allowance after every relaunch, and a second process never saw the first one's spend at
|
|
7
|
+
* all. This ledger reserves TOKENS before dispatch and rebuilds its state from a journal
|
|
8
|
+
* under the opencodex home directory, so an exhausted scope is still exhausted after a
|
|
9
|
+
* restart.
|
|
10
|
+
*
|
|
11
|
+
* A reservation is always the request's whole input plus its ENFORCEABLE output ceiling --
|
|
12
|
+
* the caller's max_output_tokens, or the model's documented cap when the caller sent none.
|
|
13
|
+
* Never an optimistic estimate, and never shrunk by a cache-hit expectation: a prefix that
|
|
14
|
+
* misses is billed in full, so the safety figure reserves as if it misses. Cache
|
|
15
|
+
* expectations may inform efficiency reporting; they do not move this number.
|
|
16
|
+
*
|
|
17
|
+
* Admission requires, at every scope that applies at once -- root workflow, authenticated
|
|
18
|
+
* identity, and account pool:
|
|
19
|
+
*
|
|
20
|
+
* settled spend + in-flight reservations + unresolved spend + this reservation <= limit
|
|
21
|
+
*
|
|
22
|
+
* Unresolved spend is the conservative residue of a send whose usage frame was lost: the
|
|
23
|
+
* tokens may have been billed, so the reservation is moved to unresolved rather than
|
|
24
|
+
* released. Minting a new root id mints no new budget because the identity and pool scopes
|
|
25
|
+
* still hold the spend.
|
|
26
|
+
*
|
|
27
|
+
* SUPPORTED TOPOLOGY: this guarantees a single proxy process against its own journal. The
|
|
28
|
+
* file is append-friendly, but nothing here serializes two live processes writing it, so a
|
|
29
|
+
* second proxy sharing the same OPENCODEX_HOME is explicitly outside the guarantee -- that
|
|
30
|
+
* needs a shared store with cross-process atomicity and is declared out of scope rather
|
|
31
|
+
* than implied.
|
|
32
|
+
*
|
|
33
|
+
* Five properties this file owes its callers. Each one was absent in the first draft, and a
|
|
34
|
+
* budget that can be bypassed is worse than no budget because it looks like protection:
|
|
35
|
+
*
|
|
36
|
+
* 1. IDENTITY OF A SEND. A send id is either KNOWN -- and then reserving it again is refused
|
|
37
|
+
* rather than waved through booking nothing -- or FULLY forgotten, and then it books a
|
|
38
|
+
* fresh reservation. There is no third state where the ledger recognises an id and
|
|
39
|
+
* charges nothing for it, which is what let one id authorise unlimited physical sends.
|
|
40
|
+
* 2. DURABILITY BEFORE ADMISSION. Under a configured limit the reserve record must be on
|
|
41
|
+
* disk before the request is admitted. Failing open on a disk-full or permission error
|
|
42
|
+
* forgets the request across a restart, which is the exact case durability exists for.
|
|
43
|
+
* Observe-only mode still admits, and says so through `durable: false`.
|
|
44
|
+
* 3. REPLAY VALIDATES. Every journal record is checked field by field before it moves a
|
|
45
|
+
* counter. A corrupt record in the MIDDLE of the file would silently undercount, so it
|
|
46
|
+
* fails accounting closed instead; only an unparseable FINAL line -- a torn tail write --
|
|
47
|
+
* is dropped quietly.
|
|
48
|
+
* 4. BOUNDED RETENTION. Cleanup runs automatically, writes durable tombstones so replay
|
|
49
|
+
* cannot resurrect what it removed, and compacts the journal to a checkpoint. When
|
|
50
|
+
* nothing can be evicted safely, admission is refused rather than made room for by
|
|
51
|
+
* forgetting an exhausted scope -- forgetting one is the laundering this layer prevents.
|
|
52
|
+
* 5. NOTHING IDENTIFYING ON DISK. Root ids come from a client header and identity ids are
|
|
53
|
+
* credential ids, so the journal stores salted aliases only, under owner-only permissions
|
|
54
|
+
* that are re-applied to an EXISTING file rather than trusted from its creation.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
58
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
59
|
+
import { dirname, join } from "node:path";
|
|
60
|
+
// Definition-site import, not the ../config barrel -- same reasoning as
|
|
61
|
+
// src/quota/reset-seen-store.ts: the barrel pulls ~154 modules into a hot path.
|
|
62
|
+
import { getConfigDir } from "../config/paths";
|
|
63
|
+
import { assertNotRealHomeUnderTest } from "./test-home-guard";
|
|
64
|
+
// Windows chmod does not remove inherited ACEs; this is the repository's icacls path.
|
|
65
|
+
import { hardenSecretPath } from "./windows-secret-acl";
|
|
66
|
+
|
|
67
|
+
export const SPEND_LEDGER_JOURNAL_FILENAME = "spend-ledger.jsonl";
|
|
68
|
+
/**
|
|
69
|
+
* Per-install alias salt, beside the journal. Losing it is exactly as bad as losing the
|
|
70
|
+
* journal -- both reset accounting, both live in the same 0700 directory -- so it is not a
|
|
71
|
+
* new weakness, and keeping it out of the journal stops a copied or attached journal from
|
|
72
|
+
* being reversible by dictionary attack on guessable pool and identity ids.
|
|
73
|
+
*/
|
|
74
|
+
export const SPEND_LEDGER_SALT_FILENAME = "spend-ledger.salt";
|
|
75
|
+
|
|
76
|
+
export type SpendScope = "root" | "identity" | "pool";
|
|
77
|
+
|
|
78
|
+
export interface SpendScopeLimit {
|
|
79
|
+
/**
|
|
80
|
+
* Approved token ceiling for the scope. Undefined means OBSERVE ONLY: spend is still
|
|
81
|
+
* accounted and reported, but nothing is refused. That is the unconfigured default --
|
|
82
|
+
* an install that never opted in keeps the count caps and is not newly refused.
|
|
83
|
+
*/
|
|
84
|
+
readonly maxTokens?: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface SpendReservationPolicy {
|
|
88
|
+
readonly root: SpendScopeLimit;
|
|
89
|
+
readonly identity: SpendScopeLimit;
|
|
90
|
+
readonly pool: SpendScopeLimit;
|
|
91
|
+
/**
|
|
92
|
+
* How long a dormant scope's accounting is retained. A scope may be dropped only when it
|
|
93
|
+
* is BOTH inactive (no open reservation) AND not exhausted inside this window; dropping
|
|
94
|
+
* an exhausted scope would hand it a fresh allowance on next use.
|
|
95
|
+
*/
|
|
96
|
+
readonly retentionMs: number;
|
|
97
|
+
/**
|
|
98
|
+
* Hard ceiling on tracked scopes. Retention alone bounds nothing: a caller minting a fresh
|
|
99
|
+
* root id per request fills the map long before the window elapses. At the ceiling the
|
|
100
|
+
* ledger evicts the oldest scope that is safe to forget -- idle, under its limit, past
|
|
101
|
+
* retention -- and if there is none it REFUSES the new scope. Refusing is the only answer
|
|
102
|
+
* left: the alternative is evicting an exhausted scope, which hands it a fresh allowance.
|
|
103
|
+
*/
|
|
104
|
+
readonly maxTrackedScopes?: number;
|
|
105
|
+
/** Hard ceiling on remembered send ids, with the same evict-or-refuse rule. */
|
|
106
|
+
readonly maxTrackedSends?: number;
|
|
107
|
+
/**
|
|
108
|
+
* Journal records after which the file is compacted into a single checkpoint. Without
|
|
109
|
+
* this the file grows forever even while the in-memory maps stay bounded, and replay
|
|
110
|
+
* resurrects every entry cleanup removed.
|
|
111
|
+
*/
|
|
112
|
+
readonly compactAfterRecords?: number;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const DEFAULT_MAX_TRACKED_SCOPES = 4_096;
|
|
116
|
+
const DEFAULT_MAX_TRACKED_SENDS = 16_384;
|
|
117
|
+
const DEFAULT_COMPACT_AFTER_RECORDS = 8_192;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Unconfigured default: every limit undefined, so token accounting runs in observe-only
|
|
121
|
+
* mode and the count caps remain the only enforcement. Real numbers belong behind
|
|
122
|
+
* explicit operator configuration.
|
|
123
|
+
*/
|
|
124
|
+
export const DEFAULT_SPEND_RESERVATION_POLICY: SpendReservationPolicy = {
|
|
125
|
+
root: {},
|
|
126
|
+
identity: {},
|
|
127
|
+
pool: {},
|
|
128
|
+
retentionMs: 7 * 24 * 60 * 60_000,
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
export interface SpendScopes {
|
|
132
|
+
readonly rootId?: string;
|
|
133
|
+
readonly identityId?: string;
|
|
134
|
+
readonly poolId?: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface SpendUsage {
|
|
138
|
+
readonly inputTokens: number;
|
|
139
|
+
readonly outputTokens: number;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface SpendReservationRequest {
|
|
143
|
+
/** Stable id of the physical send. Settlement is idempotent on this key. */
|
|
144
|
+
readonly sendId: string;
|
|
145
|
+
readonly scopes: SpendScopes;
|
|
146
|
+
readonly inputTokens: number;
|
|
147
|
+
/** Enforceable output ceiling -- max_output_tokens or the model's documented cap. */
|
|
148
|
+
readonly outputCeilingTokens: number;
|
|
149
|
+
readonly at?: number;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Why a reservation was refused. Every member refuses a DISPATCH: none of them is an
|
|
154
|
+
* "already fine, carry on" answer, because that is precisely how a duplicate send id used
|
|
155
|
+
* to buy an unlimited number of physical sends while the scope totals never moved.
|
|
156
|
+
*/
|
|
157
|
+
export type SpendDenial =
|
|
158
|
+
| {
|
|
159
|
+
readonly reason: "spend-limit-exceeded";
|
|
160
|
+
readonly scope: SpendScope;
|
|
161
|
+
readonly scopeId: string;
|
|
162
|
+
readonly limit: number;
|
|
163
|
+
readonly projected: number;
|
|
164
|
+
}
|
|
165
|
+
/** This send id is already known -- open, settled, lost or abandoned. */
|
|
166
|
+
| { readonly reason: "duplicate-send-id"; readonly sendId: string }
|
|
167
|
+
/** The reserve record could not be written, and a configured limit needs it to survive. */
|
|
168
|
+
| { readonly reason: "reserve-not-durable"; readonly sendId: string }
|
|
169
|
+
/** Replay rejected records mid-file, so no scope total can be proven complete. */
|
|
170
|
+
| { readonly reason: "journal-corrupt"; readonly corruptRecords: number }
|
|
171
|
+
/** Tracking is full and nothing may be forgotten safely. */
|
|
172
|
+
| { readonly reason: "tracking-capacity-exhausted"; readonly scope?: SpendScope };
|
|
173
|
+
|
|
174
|
+
export type SpendReservationDecision =
|
|
175
|
+
| {
|
|
176
|
+
readonly reserved: true;
|
|
177
|
+
readonly sendId: string;
|
|
178
|
+
readonly tokens: number;
|
|
179
|
+
/**
|
|
180
|
+
* False only in observe-only mode, where the reservation was admitted although its
|
|
181
|
+
* journal record did not reach disk. A restart will not remember this spend; the flag
|
|
182
|
+
* is how a caller learns that instead of discovering it after the fact.
|
|
183
|
+
*/
|
|
184
|
+
readonly durable: boolean;
|
|
185
|
+
}
|
|
186
|
+
| { readonly reserved: false; readonly denial: SpendDenial };
|
|
187
|
+
|
|
188
|
+
interface ScopeState {
|
|
189
|
+
settled: number;
|
|
190
|
+
reserved: number;
|
|
191
|
+
unresolved: number;
|
|
192
|
+
lastSeenAt: number;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* `open` means admitted but not yet handed to a transport: it may still be abandoned for
|
|
197
|
+
* free. `dispatched` means bytes left for upstream, so from there a missing usage frame is
|
|
198
|
+
* unresolved SPEND rather than a release -- it may have been billed. Only a dispatched send
|
|
199
|
+
* can become `lost`; only an undispatched one can become `abandoned`.
|
|
200
|
+
*/
|
|
201
|
+
type ReservationStatus = "open" | "dispatched" | "settled" | "lost" | "abandoned";
|
|
202
|
+
|
|
203
|
+
interface ScopeRef {
|
|
204
|
+
readonly scope: SpendScope;
|
|
205
|
+
readonly alias: string;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
interface Reservation {
|
|
209
|
+
readonly targets: readonly ScopeRef[];
|
|
210
|
+
readonly tokens: number;
|
|
211
|
+
status: ReservationStatus;
|
|
212
|
+
readonly at: number;
|
|
213
|
+
/** When the status last changed; drives eviction of resolved entries. */
|
|
214
|
+
resolvedAt: number;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Journal shape. Every id on disk is a salted alias, never a root header value, credential
|
|
219
|
+
* id or pool name. `forget` and `drop` are the tombstones that make bounded cleanup
|
|
220
|
+
* durable -- without them replay rebuilds exactly what cleanup removed -- and `checkpoint`
|
|
221
|
+
* is a whole-state snapshot that lets the file be compacted instead of growing forever.
|
|
222
|
+
*/
|
|
223
|
+
type JournalRecord =
|
|
224
|
+
| { v: 1; kind: "reserve"; send: string; targets: ScopeRef[]; tokens: number; at: number }
|
|
225
|
+
| { v: 1; kind: "dispatch"; send: string; at: number }
|
|
226
|
+
| { v: 1; kind: "settle"; send: string; tokens: number; at: number }
|
|
227
|
+
| { v: 1; kind: "lost"; send: string; at: number }
|
|
228
|
+
| { v: 1; kind: "abandon"; send: string; at: number }
|
|
229
|
+
| { v: 1; kind: "forget"; send: string; at: number }
|
|
230
|
+
| { v: 1; kind: "drop"; scope: SpendScope; alias: string; at: number }
|
|
231
|
+
| {
|
|
232
|
+
v: 1;
|
|
233
|
+
kind: "checkpoint";
|
|
234
|
+
at: number;
|
|
235
|
+
scopes: { scope: SpendScope; alias: string; settled: number; unresolved: number; seenAt: number }[];
|
|
236
|
+
sends: { send: string; status: ReservationStatus; targets: ScopeRef[]; tokens: number; at: number; resolvedAt: number }[];
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const isCountable = (value: unknown): value is number =>
|
|
240
|
+
typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
241
|
+
|
|
242
|
+
const isAlias = (value: unknown): value is string =>
|
|
243
|
+
typeof value === "string" && value.length > 0 && value.length <= 256;
|
|
244
|
+
|
|
245
|
+
const isScopeName = (value: unknown): value is SpendScope =>
|
|
246
|
+
value === "root" || value === "identity" || value === "pool";
|
|
247
|
+
|
|
248
|
+
const isStatus = (value: unknown): value is ReservationStatus =>
|
|
249
|
+
value === "open" || value === "dispatched" || value === "settled"
|
|
250
|
+
|| value === "lost" || value === "abandoned";
|
|
251
|
+
|
|
252
|
+
const parseTargets = (value: unknown): ScopeRef[] | undefined => {
|
|
253
|
+
if (!Array.isArray(value) || value.length > 3) return undefined;
|
|
254
|
+
const targets: ScopeRef[] = [];
|
|
255
|
+
for (const entry of value) {
|
|
256
|
+
if (typeof entry !== "object" || entry === null) return undefined;
|
|
257
|
+
const { scope, alias } = entry as { scope?: unknown; alias?: unknown };
|
|
258
|
+
if (!isScopeName(scope) || !isAlias(alias)) return undefined;
|
|
259
|
+
targets.push({ scope, alias });
|
|
260
|
+
}
|
|
261
|
+
return targets;
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Validate one journal line into a record, or reject it.
|
|
266
|
+
*
|
|
267
|
+
* Exported because this is the boundary where a hostile or damaged file meets the accounting:
|
|
268
|
+
* `JSON.parse(line) as JournalRecord` type-asserts a lie, and a bare `null` line or a
|
|
269
|
+
* `{"v":1,"kind":"reserve"}` with no fields crashed the rebuild rather than being rejected.
|
|
270
|
+
* Every field is checked, including that numbers are finite and non-negative.
|
|
271
|
+
*/
|
|
272
|
+
export function parseSpendJournalRecord(line: string): JournalRecord | undefined {
|
|
273
|
+
let raw: unknown;
|
|
274
|
+
try {
|
|
275
|
+
raw = JSON.parse(line);
|
|
276
|
+
} catch {
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined;
|
|
280
|
+
const record = raw as Record<string, unknown>;
|
|
281
|
+
if (record.v !== 1) return undefined;
|
|
282
|
+
if (!isCountable(record.at)) return undefined;
|
|
283
|
+
const at = record.at;
|
|
284
|
+
switch (record.kind) {
|
|
285
|
+
case "reserve": {
|
|
286
|
+
const targets = parseTargets(record.targets);
|
|
287
|
+
if (!isAlias(record.send) || targets === undefined || !isCountable(record.tokens)) return undefined;
|
|
288
|
+
return { v: 1, kind: "reserve", send: record.send, targets, tokens: record.tokens, at };
|
|
289
|
+
}
|
|
290
|
+
case "settle":
|
|
291
|
+
if (!isAlias(record.send) || !isCountable(record.tokens)) return undefined;
|
|
292
|
+
return { v: 1, kind: "settle", send: record.send, tokens: record.tokens, at };
|
|
293
|
+
case "dispatch":
|
|
294
|
+
if (!isAlias(record.send)) return undefined;
|
|
295
|
+
return { v: 1, kind: "dispatch", send: record.send, at };
|
|
296
|
+
case "lost":
|
|
297
|
+
if (!isAlias(record.send)) return undefined;
|
|
298
|
+
return { v: 1, kind: "lost", send: record.send, at };
|
|
299
|
+
case "abandon":
|
|
300
|
+
if (!isAlias(record.send)) return undefined;
|
|
301
|
+
return { v: 1, kind: "abandon", send: record.send, at };
|
|
302
|
+
case "forget":
|
|
303
|
+
if (!isAlias(record.send)) return undefined;
|
|
304
|
+
return { v: 1, kind: "forget", send: record.send, at };
|
|
305
|
+
case "drop":
|
|
306
|
+
if (!isScopeName(record.scope) || !isAlias(record.alias)) return undefined;
|
|
307
|
+
return { v: 1, kind: "drop", scope: record.scope, alias: record.alias, at };
|
|
308
|
+
case "checkpoint": {
|
|
309
|
+
if (!Array.isArray(record.scopes) || !Array.isArray(record.sends)) return undefined;
|
|
310
|
+
const scopes: { scope: SpendScope; alias: string; settled: number; unresolved: number; seenAt: number }[] = [];
|
|
311
|
+
for (const entry of record.scopes) {
|
|
312
|
+
if (typeof entry !== "object" || entry === null) return undefined;
|
|
313
|
+
const e = entry as Record<string, unknown>;
|
|
314
|
+
if (!isScopeName(e.scope) || !isAlias(e.alias)) return undefined;
|
|
315
|
+
if (!isCountable(e.settled) || !isCountable(e.unresolved) || !isCountable(e.seenAt)) return undefined;
|
|
316
|
+
scopes.push({ scope: e.scope, alias: e.alias, settled: e.settled, unresolved: e.unresolved, seenAt: e.seenAt });
|
|
317
|
+
}
|
|
318
|
+
const sends: { send: string; status: ReservationStatus; targets: ScopeRef[]; tokens: number; at: number; resolvedAt: number }[] = [];
|
|
319
|
+
for (const entry of record.sends) {
|
|
320
|
+
if (typeof entry !== "object" || entry === null) return undefined;
|
|
321
|
+
const e = entry as Record<string, unknown>;
|
|
322
|
+
const targets = parseTargets(e.targets);
|
|
323
|
+
if (!isAlias(e.send) || !isStatus(e.status) || targets === undefined) return undefined;
|
|
324
|
+
if (!isCountable(e.tokens) || !isCountable(e.at) || !isCountable(e.resolvedAt)) return undefined;
|
|
325
|
+
sends.push({ send: e.send, status: e.status, targets, tokens: e.tokens, at: e.at, resolvedAt: e.resolvedAt });
|
|
326
|
+
}
|
|
327
|
+
return { v: 1, kind: "checkpoint", at, scopes, sends };
|
|
328
|
+
}
|
|
329
|
+
default:
|
|
330
|
+
return undefined;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Append-mostly persistence. `read` returns raw lines so replay can tell a torn TAIL write
|
|
336
|
+
* from corruption earlier in the file; only the former is safe to drop quietly. `append`
|
|
337
|
+
* THROWS when the record did not reach storage -- that signal is what lets admission refuse
|
|
338
|
+
* rather than admit a request a restart would forget. `rewrite` is optional: a store that
|
|
339
|
+
* cannot replace its contents atomically simply never compacts.
|
|
340
|
+
*/
|
|
341
|
+
export interface SpendJournal {
|
|
342
|
+
read(): string[];
|
|
343
|
+
append(line: string): void;
|
|
344
|
+
rewrite?(lines: string[]): void;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Re-apply owner-only permissions to a file that already exists.
|
|
349
|
+
*
|
|
350
|
+
* `mode` in a write option is honoured only when the file is CREATED, so a journal that was
|
|
351
|
+
* created loose -- by an older build, a restored backup, or a lax umask -- would keep its
|
|
352
|
+
* mode forever. Best-effort by design: a non-owner cannot chmod, and failing every append
|
|
353
|
+
* over it would be worse than the loose mode it is fixing.
|
|
354
|
+
*
|
|
355
|
+
* `force` marks the points where the WINDOWS ACL can actually be wrong: creation, compaction,
|
|
356
|
+
* and each process's replay. Windows chmod cannot drop inherited ACEs, so icacls is the real
|
|
357
|
+
* boundary there, and its memo keys on the file's ctime -- which every append changes. Running
|
|
358
|
+
* it per reservation would therefore spawn a process per send while protecting nothing an
|
|
359
|
+
* append can alter. On POSIX the mode is checked on every write and repaired the moment it
|
|
360
|
+
* drifts, which costs one stat.
|
|
361
|
+
*/
|
|
362
|
+
function hardenLedgerFile(path: string, options: { readonly force?: boolean } = {}): void {
|
|
363
|
+
if (process.platform === "win32") {
|
|
364
|
+
if (options.force) hardenSecretPath(path, { required: false });
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
try {
|
|
368
|
+
if ((statSync(path).mode & 0o777) === 0o600) return;
|
|
369
|
+
chmodSync(path, 0o600);
|
|
370
|
+
} catch { /* best-effort: a non-owner cannot chmod */ }
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export function createFileSpendJournal(path: string): SpendJournal {
|
|
374
|
+
const ensureDir = (): string => {
|
|
375
|
+
const dir = dirname(path);
|
|
376
|
+
// The guard runs before any mutation so a rejected write leaves nothing behind.
|
|
377
|
+
assertNotRealHomeUnderTest(dir);
|
|
378
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
379
|
+
return dir;
|
|
380
|
+
};
|
|
381
|
+
return {
|
|
382
|
+
read(): string[] {
|
|
383
|
+
if (!existsSync(path)) return [];
|
|
384
|
+
// Replay is once per process and is the moment a journal inherited from an older build
|
|
385
|
+
// or a restored backup first passes through here.
|
|
386
|
+
hardenLedgerFile(path, { force: true });
|
|
387
|
+
return readFileSync(path, "utf8").split("\n").filter((line) => line.length > 0);
|
|
388
|
+
},
|
|
389
|
+
append(line: string): void {
|
|
390
|
+
ensureDir();
|
|
391
|
+
const created = !existsSync(path);
|
|
392
|
+
appendFileSync(path, line + "\n", { encoding: "utf8", mode: 0o600 });
|
|
393
|
+
hardenLedgerFile(path, { force: created });
|
|
394
|
+
},
|
|
395
|
+
rewrite(lines: string[]): void {
|
|
396
|
+
ensureDir();
|
|
397
|
+
// Same directory, so the rename is atomic on the same filesystem: a crash mid-compaction
|
|
398
|
+
// leaves either the old journal or the new one, never a half-written ledger.
|
|
399
|
+
const temp = `${path}.compact-${process.pid}`;
|
|
400
|
+
writeFileSync(temp, lines.map((line) => line + "\n").join(""), { encoding: "utf8", mode: 0o600 });
|
|
401
|
+
hardenLedgerFile(temp, { force: true });
|
|
402
|
+
renameSync(temp, path);
|
|
403
|
+
hardenLedgerFile(path, { force: true });
|
|
404
|
+
},
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Load the per-install alias salt, minting it on first use.
|
|
410
|
+
*
|
|
411
|
+
* The salt must be STABLE across restarts or replay cannot match a live request to its own
|
|
412
|
+
* recorded spend, which would hand every scope a fresh allowance -- so it is a file, not a
|
|
413
|
+
* per-process value.
|
|
414
|
+
*/
|
|
415
|
+
export function loadOrCreateSpendLedgerSalt(path: string): string {
|
|
416
|
+
if (existsSync(path)) {
|
|
417
|
+
hardenLedgerFile(path, { force: true });
|
|
418
|
+
const existing = readFileSync(path, "utf8").trim();
|
|
419
|
+
if (/^[0-9a-f]{32,}$/.test(existing)) return existing;
|
|
420
|
+
}
|
|
421
|
+
const dir = dirname(path);
|
|
422
|
+
assertNotRealHomeUnderTest(dir);
|
|
423
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
424
|
+
const salt = randomBytes(32).toString("hex");
|
|
425
|
+
writeFileSync(path, salt + "\n", { encoding: "utf8", mode: 0o600 });
|
|
426
|
+
hardenLedgerFile(path, { force: true });
|
|
427
|
+
return salt;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export interface ScopeSpendSnapshot {
|
|
431
|
+
readonly settled: number;
|
|
432
|
+
readonly reserved: number;
|
|
433
|
+
readonly unresolved: number;
|
|
434
|
+
readonly exhausted: boolean;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export interface SpendReservationLedger {
|
|
438
|
+
reserve(request: SpendReservationRequest): SpendReservationDecision;
|
|
439
|
+
/**
|
|
440
|
+
* The send left for upstream. Until this is called the reservation may be abandoned for
|
|
441
|
+
* free; after it, a missing usage frame becomes unresolved spend. Returns false when the
|
|
442
|
+
* send is unknown or no longer open.
|
|
443
|
+
*/
|
|
444
|
+
markDispatched(sendId: string): boolean;
|
|
445
|
+
/**
|
|
446
|
+
* The send never happened -- local validation, routing, or a refusal before any byte left
|
|
447
|
+
* this process. The reservation is RELEASED and books nothing, because inventing debt the
|
|
448
|
+
* account never incurred is its own way of breaking the budget. Refused once the send is
|
|
449
|
+
* dispatched: from there only settle or markLost is honest.
|
|
450
|
+
*/
|
|
451
|
+
abandon(sendId: string): boolean;
|
|
452
|
+
/**
|
|
453
|
+
* Settle with real usage. Returns false when the send is unknown or already resolved --
|
|
454
|
+
* double settlement is as wrong as none, so a repeat call changes nothing.
|
|
455
|
+
*/
|
|
456
|
+
settle(sendId: string, usage: SpendUsage): boolean;
|
|
457
|
+
/**
|
|
458
|
+
* Usage never arrived. The reservation moves to unresolved spend -- it may have been
|
|
459
|
+
* billed -- rather than being released. Idempotent on the same key as settle.
|
|
460
|
+
*/
|
|
461
|
+
markLost(sendId: string): boolean;
|
|
462
|
+
snapshot(scope: SpendScope, scopeId: string): ScopeSpendSnapshot | undefined;
|
|
463
|
+
exhausted(scope: SpendScope, scopeId: string): boolean;
|
|
464
|
+
/**
|
|
465
|
+
* Drop dormant scopes per the retention rule in SpendReservationPolicy. Cleanup also runs
|
|
466
|
+
* automatically on every reservation, so nothing depends on a caller remembering this.
|
|
467
|
+
*/
|
|
468
|
+
prune(now?: number): void;
|
|
469
|
+
/** Whether this send id is already known, and therefore refused. */
|
|
470
|
+
knows(sendId: string): boolean;
|
|
471
|
+
/** Journal writes that failed; a nonzero count means durability is degraded. */
|
|
472
|
+
readonly persistFailures: number;
|
|
473
|
+
/**
|
|
474
|
+
* Records replay rejected in the MIDDLE of the journal. Nonzero means no scope total can
|
|
475
|
+
* be proven complete, so configured limits refuse rather than undercount.
|
|
476
|
+
*/
|
|
477
|
+
readonly corruptRecords: number;
|
|
478
|
+
/** True when durability is degraded in either direction: failed writes or a corrupt file. */
|
|
479
|
+
readonly degraded: boolean;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const scopeKey = (scope: SpendScope, alias: string): string => scope + "\0" + alias;
|
|
483
|
+
|
|
484
|
+
const sanitizeTokens = (value: number): number =>
|
|
485
|
+
Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
|
|
486
|
+
|
|
487
|
+
export function createSpendReservationLedger(options: {
|
|
488
|
+
readonly journal?: SpendJournal;
|
|
489
|
+
readonly policy?: SpendReservationPolicy;
|
|
490
|
+
readonly now?: () => number;
|
|
491
|
+
/**
|
|
492
|
+
* Per-install alias salt. Production passes the file-backed value from
|
|
493
|
+
* `loadOrCreateSpendLedgerSalt`; an empty default is for in-memory journals, which have
|
|
494
|
+
* no file anyone could correlate.
|
|
495
|
+
*/
|
|
496
|
+
readonly salt?: string;
|
|
497
|
+
} = {}): SpendReservationLedger {
|
|
498
|
+
const policy = options.policy ?? DEFAULT_SPEND_RESERVATION_POLICY;
|
|
499
|
+
const journal = options.journal;
|
|
500
|
+
const now = options.now ?? (() => Date.now());
|
|
501
|
+
const salt = options.salt ?? "";
|
|
502
|
+
const maxTrackedScopes = policy.maxTrackedScopes ?? DEFAULT_MAX_TRACKED_SCOPES;
|
|
503
|
+
const maxTrackedSends = policy.maxTrackedSends ?? DEFAULT_MAX_TRACKED_SENDS;
|
|
504
|
+
const compactAfterRecords = policy.compactAfterRecords ?? DEFAULT_COMPACT_AFTER_RECORDS;
|
|
505
|
+
const scopes = new Map<string, ScopeState>();
|
|
506
|
+
const reservations = new Map<string, Reservation>();
|
|
507
|
+
let persistFailures = 0;
|
|
508
|
+
let corruptRecords = 0;
|
|
509
|
+
let recordsOnDisk = 0;
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Salted alias for one identifier. The raw value -- a client-supplied root header, a
|
|
513
|
+
* credential id, a pool name -- never leaves this function, so nothing identifying is
|
|
514
|
+
* written to disk or held in a map key.
|
|
515
|
+
*/
|
|
516
|
+
const aliasFor = (kind: SpendScope | "send", id: string): string =>
|
|
517
|
+
createHash("sha256").update(salt).update("\u0000").update(kind).update("\u0000").update(id)
|
|
518
|
+
.digest("hex").slice(0, 32);
|
|
519
|
+
|
|
520
|
+
const scopeState = (scope: SpendScope, alias: string): ScopeState => {
|
|
521
|
+
const key = scopeKey(scope, alias);
|
|
522
|
+
let state = scopes.get(key);
|
|
523
|
+
if (!state) {
|
|
524
|
+
state = { settled: 0, reserved: 0, unresolved: 0, lastSeenAt: 0 };
|
|
525
|
+
scopes.set(key, state);
|
|
526
|
+
}
|
|
527
|
+
return state;
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
const limitFor = (scope: SpendScope): number | undefined => policy[scope].maxTokens;
|
|
531
|
+
|
|
532
|
+
const isExhausted = (scope: SpendScope, state: ScopeState): boolean => {
|
|
533
|
+
const limit = limitFor(scope);
|
|
534
|
+
return limit !== undefined && state.settled + state.reserved + state.unresolved >= limit;
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
/** The scopes a request touches, as aliases. Creates no state: a refusal must leave none. */
|
|
538
|
+
const refsFor = (targets: SpendScopes): ScopeRef[] => {
|
|
539
|
+
const refs: ScopeRef[] = [];
|
|
540
|
+
if (targets.rootId !== undefined) refs.push({ scope: "root", alias: aliasFor("root", targets.rootId) });
|
|
541
|
+
if (targets.identityId !== undefined) refs.push({ scope: "identity", alias: aliasFor("identity", targets.identityId) });
|
|
542
|
+
if (targets.poolId !== undefined) refs.push({ scope: "pool", alias: aliasFor("pool", targets.poolId) });
|
|
543
|
+
return refs;
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Returns whether the record reached storage. With no journal there is nothing to fail,
|
|
548
|
+
* and the caller's durability question is vacuously satisfied.
|
|
549
|
+
*/
|
|
550
|
+
const append = (record: JournalRecord): boolean => {
|
|
551
|
+
if (!journal) return true;
|
|
552
|
+
try {
|
|
553
|
+
journal.append(JSON.stringify(record));
|
|
554
|
+
recordsOnDisk += 1;
|
|
555
|
+
return true;
|
|
556
|
+
} catch {
|
|
557
|
+
// In-memory state still bounds this process; the counter is how a caller learns the
|
|
558
|
+
// restart guarantee degraded instead of discovering it after the fact.
|
|
559
|
+
persistFailures += 1;
|
|
560
|
+
return false;
|
|
561
|
+
}
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
const applyReserve = (send: string, targets: readonly ScopeRef[], tokens: number, at: number): void => {
|
|
565
|
+
if (reservations.has(send)) return;
|
|
566
|
+
reservations.set(send, { targets, tokens, status: "open", at, resolvedAt: at });
|
|
567
|
+
for (const ref of targets) {
|
|
568
|
+
const state = scopeState(ref.scope, ref.alias);
|
|
569
|
+
state.reserved += tokens;
|
|
570
|
+
state.lastSeenAt = Math.max(state.lastSeenAt, at);
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
const isLive = (status: ReservationStatus): boolean => status === "open" || status === "dispatched";
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Resolve a live reservation. `settled` books the real figure, `lost` keeps the whole
|
|
578
|
+
* reservation as unresolved spend because it may have been billed, and `abandoned`
|
|
579
|
+
* releases it because no byte ever left this process.
|
|
580
|
+
*/
|
|
581
|
+
const applyResolve = (send: string, outcome: "settled" | "lost" | "abandoned", tokens: number, at: number): void => {
|
|
582
|
+
const reservation = reservations.get(send);
|
|
583
|
+
if (!reservation || !isLive(reservation.status)) return;
|
|
584
|
+
reservation.status = outcome;
|
|
585
|
+
reservation.resolvedAt = at;
|
|
586
|
+
for (const ref of reservation.targets) {
|
|
587
|
+
const state = scopeState(ref.scope, ref.alias);
|
|
588
|
+
state.reserved = Math.max(0, state.reserved - reservation.tokens);
|
|
589
|
+
if (outcome === "lost") state.unresolved += reservation.tokens;
|
|
590
|
+
else if (outcome === "settled") state.settled += tokens;
|
|
591
|
+
state.lastSeenAt = Math.max(state.lastSeenAt, at);
|
|
592
|
+
}
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
const applyDispatch = (send: string, at: number): void => {
|
|
596
|
+
const reservation = reservations.get(send);
|
|
597
|
+
if (!reservation || reservation.status !== "open") return;
|
|
598
|
+
reservation.status = "dispatched";
|
|
599
|
+
reservation.resolvedAt = at;
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
/** Tombstone replay: the entry is gone, so a later reuse of the id books a fresh charge. */
|
|
603
|
+
const applyForget = (send: string): void => {
|
|
604
|
+
const reservation = reservations.get(send);
|
|
605
|
+
if (!reservation || isLive(reservation.status)) return;
|
|
606
|
+
reservations.delete(send);
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
const applyDrop = (scope: SpendScope, alias: string): void => {
|
|
610
|
+
const state = scopes.get(scopeKey(scope, alias));
|
|
611
|
+
if (!state || state.reserved > 0) return;
|
|
612
|
+
scopes.delete(scopeKey(scope, alias));
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
const applyCheckpoint = (record: Extract<JournalRecord, { kind: "checkpoint" }>): void => {
|
|
616
|
+
scopes.clear();
|
|
617
|
+
reservations.clear();
|
|
618
|
+
for (const entry of record.scopes) {
|
|
619
|
+
scopes.set(scopeKey(entry.scope, entry.alias), {
|
|
620
|
+
settled: entry.settled,
|
|
621
|
+
reserved: 0,
|
|
622
|
+
unresolved: entry.unresolved,
|
|
623
|
+
lastSeenAt: entry.seenAt,
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
for (const entry of record.sends) {
|
|
627
|
+
// `reserved` is rebuilt from the live entries rather than trusted from the snapshot,
|
|
628
|
+
// so the two can never disagree about the same tokens.
|
|
629
|
+
if (isLive(entry.status)) {
|
|
630
|
+
applyReserve(entry.send, entry.targets, entry.tokens, entry.at);
|
|
631
|
+
if (entry.status === "dispatched") applyDispatch(entry.send, entry.resolvedAt);
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
reservations.set(entry.send, {
|
|
635
|
+
targets: entry.targets,
|
|
636
|
+
tokens: entry.tokens,
|
|
637
|
+
status: entry.status,
|
|
638
|
+
at: entry.at,
|
|
639
|
+
resolvedAt: entry.resolvedAt,
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
// Rebuild from the journal before serving: an exhausted scope must still be exhausted
|
|
645
|
+
// after a restart, which is the whole reason this store exists.
|
|
646
|
+
if (journal) {
|
|
647
|
+
const lines = journal.read();
|
|
648
|
+
recordsOnDisk = lines.length;
|
|
649
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
650
|
+
const line = lines[index] as string;
|
|
651
|
+
const record = parseSpendJournalRecord(line);
|
|
652
|
+
if (!record) {
|
|
653
|
+
// A rejected FINAL line is a torn tail write -- the process died between the write
|
|
654
|
+
// and its newline -- and is dropped quietly, because that record never completed and
|
|
655
|
+
// therefore never authorised anything. A rejected line ANYWHERE ELSE is different:
|
|
656
|
+
// the records after it did complete, so skipping it silently undercounts a scope and
|
|
657
|
+
// hands back budget. It is counted, and a configured limit refuses on it below.
|
|
658
|
+
if (index < lines.length - 1) corruptRecords += 1;
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
switch (record.kind) {
|
|
662
|
+
case "reserve": applyReserve(record.send, record.targets, sanitizeTokens(record.tokens), record.at); break;
|
|
663
|
+
case "dispatch": applyDispatch(record.send, record.at); break;
|
|
664
|
+
case "settle": applyResolve(record.send, "settled", sanitizeTokens(record.tokens), record.at); break;
|
|
665
|
+
case "lost": applyResolve(record.send, "lost", 0, record.at); break;
|
|
666
|
+
case "abandon": applyResolve(record.send, "abandoned", 0, record.at); break;
|
|
667
|
+
case "forget": applyForget(record.send); break;
|
|
668
|
+
case "drop": applyDrop(record.scope, record.alias); break;
|
|
669
|
+
case "checkpoint": applyCheckpoint(record); break;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
// A reservation that survived replay has no owner left. The process that made it is gone,
|
|
673
|
+
// so nothing in this one can ever settle it, and leaving it live means the send stays
|
|
674
|
+
// pending forever against a scope that can never resolve it. Deleting the entry is not the
|
|
675
|
+
// alternative either: that would hand the same send id a second reservation.
|
|
676
|
+
//
|
|
677
|
+
// Both live states resolve to UNRESOLVED, including an undispatched one. The tempting
|
|
678
|
+
// distinction -- open never reached the wire, so give its tokens back -- assumes the
|
|
679
|
+
// journal is complete up to the crash, and the torn-tail handling above says it is not: a
|
|
680
|
+
// send can dispatch and die before its dispatch record lands. Abandoning that reservation
|
|
681
|
+
// returns tokens for a send that may have been billed, and worse, it RESETS a ceiling that
|
|
682
|
+
// had already fired. An exhausted scope staying exhausted across a restart is the whole
|
|
683
|
+
// reason this store is on disk.
|
|
684
|
+
const reconciledAt = now();
|
|
685
|
+
for (const [send, reservation] of reservations) {
|
|
686
|
+
if (!isLive(reservation.status)) continue;
|
|
687
|
+
applyResolve(send, "lost", 0, reconciledAt);
|
|
688
|
+
append({ v: 1, kind: "lost", send, at: reconciledAt });
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Bounded cleanup. It runs before every admission, so nothing depends on a caller
|
|
694
|
+
* remembering `prune()` -- the first draft exported one and no production path called it.
|
|
695
|
+
* Every removal writes a tombstone: without one, replay rebuilds precisely what cleanup
|
|
696
|
+
* removed and the file keeps growing while the maps look bounded.
|
|
697
|
+
*
|
|
698
|
+
* `force` is the at-capacity pass. It ignores the retention window but never the safety
|
|
699
|
+
* rule: an ACTIVE or EXHAUSTED scope is not a candidate at any pressure, because dropping
|
|
700
|
+
* one hands it a fresh allowance under the same id. When that leaves nothing to remove,
|
|
701
|
+
* the caller refuses admission rather than making room by forgetting a spent scope.
|
|
702
|
+
*/
|
|
703
|
+
const evictScopes = (at: number, force: boolean): number => {
|
|
704
|
+
const cutoff = at - policy.retentionMs;
|
|
705
|
+
const candidates: { key: string; scope: SpendScope; alias: string; seenAt: number }[] = [];
|
|
706
|
+
for (const [key, state] of scopes) {
|
|
707
|
+
const separator = key.indexOf("\0");
|
|
708
|
+
const scope = key.slice(0, separator) as SpendScope;
|
|
709
|
+
if (state.reserved > 0) continue;
|
|
710
|
+
if (isExhausted(scope, state)) continue;
|
|
711
|
+
if (!force && state.lastSeenAt >= cutoff) continue;
|
|
712
|
+
candidates.push({ key, scope, alias: key.slice(separator + 1), seenAt: state.lastSeenAt });
|
|
713
|
+
}
|
|
714
|
+
if (force) {
|
|
715
|
+
candidates.sort((a, b) => a.seenAt - b.seenAt);
|
|
716
|
+
candidates.length = Math.min(candidates.length, 1);
|
|
717
|
+
}
|
|
718
|
+
for (const candidate of candidates) {
|
|
719
|
+
scopes.delete(candidate.key);
|
|
720
|
+
append({ v: 1, kind: "drop", scope: candidate.scope, alias: candidate.alias, at });
|
|
721
|
+
}
|
|
722
|
+
return candidates.length;
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Forget resolved send ids. A forgotten id is forgotten COMPLETELY: reusing it later books
|
|
727
|
+
* a fresh reservation against every scope, which is conservative. The state this must never
|
|
728
|
+
* produce is the middle one -- an id the ledger recognises but charges nothing for.
|
|
729
|
+
*/
|
|
730
|
+
const evictSends = (at: number, force: boolean): number => {
|
|
731
|
+
const cutoff = at - policy.retentionMs;
|
|
732
|
+
const candidates: { send: string; resolvedAt: number }[] = [];
|
|
733
|
+
for (const [send, reservation] of reservations) {
|
|
734
|
+
if (isLive(reservation.status)) continue;
|
|
735
|
+
if (!force && reservation.resolvedAt >= cutoff) continue;
|
|
736
|
+
candidates.push({ send, resolvedAt: reservation.resolvedAt });
|
|
737
|
+
}
|
|
738
|
+
if (force) {
|
|
739
|
+
candidates.sort((a, b) => a.resolvedAt - b.resolvedAt);
|
|
740
|
+
candidates.length = Math.min(candidates.length, 1);
|
|
741
|
+
}
|
|
742
|
+
for (const candidate of candidates) {
|
|
743
|
+
reservations.delete(candidate.send);
|
|
744
|
+
append({ v: 1, kind: "forget", send: candidate.send, at });
|
|
745
|
+
}
|
|
746
|
+
return candidates.length;
|
|
747
|
+
};
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Replace the journal with a single checkpoint once it has grown past its record budget.
|
|
751
|
+
* Bounded maps are not enough on their own: the file behind them is what replay reads, and
|
|
752
|
+
* an uncompacted file grows forever on unique root and send ids.
|
|
753
|
+
*/
|
|
754
|
+
const compact = (at: number): void => {
|
|
755
|
+
const rewrite = journal?.rewrite;
|
|
756
|
+
if (!journal || !rewrite || recordsOnDisk < compactAfterRecords) return;
|
|
757
|
+
const checkpoint: JournalRecord = {
|
|
758
|
+
v: 1,
|
|
759
|
+
kind: "checkpoint",
|
|
760
|
+
at,
|
|
761
|
+
scopes: [...scopes].map(([key, state]) => {
|
|
762
|
+
const separator = key.indexOf("\0");
|
|
763
|
+
return {
|
|
764
|
+
scope: key.slice(0, separator) as SpendScope,
|
|
765
|
+
alias: key.slice(separator + 1),
|
|
766
|
+
settled: state.settled,
|
|
767
|
+
unresolved: state.unresolved,
|
|
768
|
+
seenAt: state.lastSeenAt,
|
|
769
|
+
};
|
|
770
|
+
}),
|
|
771
|
+
sends: [...reservations].map(([send, reservation]) => ({
|
|
772
|
+
send,
|
|
773
|
+
status: reservation.status,
|
|
774
|
+
targets: [...reservation.targets],
|
|
775
|
+
tokens: reservation.tokens,
|
|
776
|
+
at: reservation.at,
|
|
777
|
+
resolvedAt: reservation.resolvedAt,
|
|
778
|
+
})),
|
|
779
|
+
};
|
|
780
|
+
try {
|
|
781
|
+
rewrite.call(journal, [JSON.stringify(checkpoint)]);
|
|
782
|
+
recordsOnDisk = 1;
|
|
783
|
+
} catch {
|
|
784
|
+
// Compaction is maintenance, not accounting: a failed rewrite leaves the previous
|
|
785
|
+
// journal intact and every figure in it still replayable.
|
|
786
|
+
persistFailures += 1;
|
|
787
|
+
}
|
|
788
|
+
};
|
|
789
|
+
|
|
790
|
+
/** The denial when tracking cannot fit this request, or undefined when it can. */
|
|
791
|
+
const makeRoom = (refs: readonly ScopeRef[], at: number): SpendDenial | undefined => {
|
|
792
|
+
evictSends(at, false);
|
|
793
|
+
evictScopes(at, false);
|
|
794
|
+
while (reservations.size >= maxTrackedSends) {
|
|
795
|
+
if (evictSends(at, true) === 0) return { reason: "tracking-capacity-exhausted" };
|
|
796
|
+
}
|
|
797
|
+
let fresh = 0;
|
|
798
|
+
for (const ref of refs) if (!scopes.has(scopeKey(ref.scope, ref.alias))) fresh += 1;
|
|
799
|
+
while (scopes.size + fresh > maxTrackedScopes) {
|
|
800
|
+
if (evictScopes(at, true) === 0) {
|
|
801
|
+
return { reason: "tracking-capacity-exhausted", scope: refs[0]?.scope };
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
return undefined;
|
|
805
|
+
};
|
|
806
|
+
|
|
807
|
+
return {
|
|
808
|
+
get persistFailures() { return persistFailures; },
|
|
809
|
+
get corruptRecords() { return corruptRecords; },
|
|
810
|
+
get degraded() { return persistFailures > 0 || corruptRecords > 0; },
|
|
811
|
+
|
|
812
|
+
reserve(request: SpendReservationRequest): SpendReservationDecision {
|
|
813
|
+
const tokens = sanitizeTokens(request.inputTokens) + sanitizeTokens(request.outputCeilingTokens);
|
|
814
|
+
const at = request.at ?? now();
|
|
815
|
+
const send = aliasFor("send", request.sendId);
|
|
816
|
+
const refs = refsFor(request.scopes);
|
|
817
|
+
const enforced = refs.some((ref) => limitFor(ref.scope) !== undefined);
|
|
818
|
+
|
|
819
|
+
// A send id this ledger already knows is REFUSED. Returning success while booking
|
|
820
|
+
// nothing -- the old behaviour -- let one id authorise an unlimited number of physical
|
|
821
|
+
// sends with the scope totals never moving.
|
|
822
|
+
if (reservations.has(send)) {
|
|
823
|
+
return { reserved: false, denial: { reason: "duplicate-send-id", sendId: request.sendId } };
|
|
824
|
+
}
|
|
825
|
+
// Replay could not prove these totals are complete, so a configured ceiling cannot be
|
|
826
|
+
// enforced on them. Observe-only accounting continues and reports the degradation.
|
|
827
|
+
if (enforced && corruptRecords > 0) {
|
|
828
|
+
return { reserved: false, denial: { reason: "journal-corrupt", corruptRecords } };
|
|
829
|
+
}
|
|
830
|
+
const capacity = makeRoom(refs, at);
|
|
831
|
+
if (capacity) return { reserved: false, denial: capacity };
|
|
832
|
+
|
|
833
|
+
// Check every scope before mutating any: a refusal must not leave a partial
|
|
834
|
+
// reservation booked on the scopes that would have passed. Reading state without
|
|
835
|
+
// creating it matters here -- a denied request must not leave a tracked scope behind.
|
|
836
|
+
for (const ref of refs) {
|
|
837
|
+
const limit = limitFor(ref.scope);
|
|
838
|
+
if (limit === undefined) continue;
|
|
839
|
+
const state = scopes.get(scopeKey(ref.scope, ref.alias));
|
|
840
|
+
const projected = (state ? state.settled + state.reserved + state.unresolved : 0) + tokens;
|
|
841
|
+
if (projected > limit) {
|
|
842
|
+
const scopeId = ref.scope === "root"
|
|
843
|
+
? request.scopes.rootId
|
|
844
|
+
: ref.scope === "identity" ? request.scopes.identityId : request.scopes.poolId;
|
|
845
|
+
return {
|
|
846
|
+
reserved: false,
|
|
847
|
+
denial: { reason: "spend-limit-exceeded", scope: ref.scope, scopeId: scopeId ?? "", limit, projected },
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// Durability BEFORE admission. The record goes to disk first, and under a configured
|
|
853
|
+
// limit a failed write refuses the request rather than admitting one that a restart
|
|
854
|
+
// would forget -- which is exactly the disk-full and permission case durability is for.
|
|
855
|
+
const durable = append({ v: 1, kind: "reserve", send, targets: refs, tokens, at });
|
|
856
|
+
if (!durable && enforced) {
|
|
857
|
+
return { reserved: false, denial: { reason: "reserve-not-durable", sendId: request.sendId } };
|
|
858
|
+
}
|
|
859
|
+
applyReserve(send, refs, tokens, at);
|
|
860
|
+
compact(at);
|
|
861
|
+
return { reserved: true, sendId: request.sendId, tokens, durable };
|
|
862
|
+
},
|
|
863
|
+
|
|
864
|
+
markDispatched(sendId: string): boolean {
|
|
865
|
+
const send = aliasFor("send", sendId);
|
|
866
|
+
const reservation = reservations.get(send);
|
|
867
|
+
if (!reservation || reservation.status !== "open") return false;
|
|
868
|
+
const at = now();
|
|
869
|
+
applyDispatch(send, at);
|
|
870
|
+
append({ v: 1, kind: "dispatch", send, at });
|
|
871
|
+
return true;
|
|
872
|
+
},
|
|
873
|
+
|
|
874
|
+
abandon(sendId: string): boolean {
|
|
875
|
+
const send = aliasFor("send", sendId);
|
|
876
|
+
const reservation = reservations.get(send);
|
|
877
|
+
// Only an UNDISPATCHED reservation may be released for free. Once bytes have left for
|
|
878
|
+
// upstream the tokens may already be billed, so the caller owes settle or markLost.
|
|
879
|
+
if (!reservation || reservation.status !== "open") return false;
|
|
880
|
+
const at = now();
|
|
881
|
+
applyResolve(send, "abandoned", 0, at);
|
|
882
|
+
append({ v: 1, kind: "abandon", send, at });
|
|
883
|
+
return true;
|
|
884
|
+
},
|
|
885
|
+
|
|
886
|
+
settle(sendId: string, usage: SpendUsage): boolean {
|
|
887
|
+
const send = aliasFor("send", sendId);
|
|
888
|
+
const reservation = reservations.get(send);
|
|
889
|
+
if (!reservation || !isLive(reservation.status)) return false;
|
|
890
|
+
const tokens = sanitizeTokens(usage.inputTokens) + sanitizeTokens(usage.outputTokens);
|
|
891
|
+
const at = now();
|
|
892
|
+
applyResolve(send, "settled", tokens, at);
|
|
893
|
+
append({ v: 1, kind: "settle", send, tokens, at });
|
|
894
|
+
return true;
|
|
895
|
+
},
|
|
896
|
+
|
|
897
|
+
markLost(sendId: string): boolean {
|
|
898
|
+
const send = aliasFor("send", sendId);
|
|
899
|
+
const reservation = reservations.get(send);
|
|
900
|
+
if (!reservation || !isLive(reservation.status)) return false;
|
|
901
|
+
const at = now();
|
|
902
|
+
applyResolve(send, "lost", 0, at);
|
|
903
|
+
append({ v: 1, kind: "lost", send, at });
|
|
904
|
+
return true;
|
|
905
|
+
},
|
|
906
|
+
|
|
907
|
+
knows(sendId: string): boolean {
|
|
908
|
+
return reservations.has(aliasFor("send", sendId));
|
|
909
|
+
},
|
|
910
|
+
|
|
911
|
+
snapshot(scope: SpendScope, scopeId: string): ScopeSpendSnapshot | undefined {
|
|
912
|
+
const state = scopes.get(scopeKey(scope, aliasFor(scope, scopeId)));
|
|
913
|
+
if (!state) return undefined;
|
|
914
|
+
return {
|
|
915
|
+
settled: state.settled,
|
|
916
|
+
reserved: state.reserved,
|
|
917
|
+
unresolved: state.unresolved,
|
|
918
|
+
exhausted: isExhausted(scope, state),
|
|
919
|
+
};
|
|
920
|
+
},
|
|
921
|
+
|
|
922
|
+
exhausted(scope: SpendScope, scopeId: string): boolean {
|
|
923
|
+
const state = scopes.get(scopeKey(scope, aliasFor(scope, scopeId)));
|
|
924
|
+
return state !== undefined && isExhausted(scope, state);
|
|
925
|
+
},
|
|
926
|
+
|
|
927
|
+
prune(at: number = now()): void {
|
|
928
|
+
// Removal requires BOTH inactive and not exhausted inside the window. An
|
|
929
|
+
// exhausted-but-idle scope that was dropped would be recreated fresh under the
|
|
930
|
+
// same id -- the exact laundering the ceiling exists to stop.
|
|
931
|
+
evictSends(at, false);
|
|
932
|
+
evictScopes(at, false);
|
|
933
|
+
},
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
let sharedLedger: SpendReservationLedger | undefined;
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* Process-wide ledger backed by the journal under OPENCODEX_HOME. Created lazily so
|
|
941
|
+
* importing the module -- or running a request path that never reserves -- touches no
|
|
942
|
+
* disk.
|
|
943
|
+
*/
|
|
944
|
+
export function sharedSpendLedger(): SpendReservationLedger {
|
|
945
|
+
if (!sharedLedger) {
|
|
946
|
+
const home = getConfigDir();
|
|
947
|
+
sharedLedger = createSpendReservationLedger({
|
|
948
|
+
journal: createFileSpendJournal(join(home, SPEND_LEDGER_JOURNAL_FILENAME)),
|
|
949
|
+
salt: loadOrCreateSpendLedgerSalt(join(home, SPEND_LEDGER_SALT_FILENAME)),
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
return sharedLedger;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/** Test seam. Production never discards the ledger: that would reset a spent budget. */
|
|
956
|
+
export function resetSharedSpendLedgerForTest(): void {
|
|
957
|
+
sharedLedger = undefined;
|
|
958
|
+
}
|