@pellux/goodvibes-daemon 1.28.20 → 1.28.21
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/CHANGELOG.md +34 -0
- package/README.md +11 -6
- package/package.json +4 -4
- package/src/daemon/handlers/contracts.ts +15 -0
- package/src/daemon/handlers/index.ts +1 -1
- package/src/daemon/handlers/payments/address-store.ts +54 -0
- package/src/daemon/handlers/payments/budget-store.ts +356 -0
- package/src/daemon/handlers/payments/checkout-handlers.ts +526 -0
- package/src/daemon/handlers/payments/index.ts +7 -1
- package/src/daemon/handlers/payments/merchant-judge.ts +57 -0
- package/src/daemon/handlers/payments/notifier.ts +112 -0
- package/src/daemon/handlers/payments/register.ts +261 -134
- package/src/runtime/browser-checkout-seam-holder.ts +55 -0
- package/src/runtime/daemon-handler-composition.ts +28 -11
- package/src/runtime/payments-composition.ts +70 -26
- package/src/runtime/services.ts +13 -6
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,40 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the GoodVibes daemon.
|
|
4
4
|
|
|
5
|
+
## [1.28.21] - 2026-08-21
|
|
6
|
+
|
|
7
|
+
### Changes
|
|
8
|
+
|
|
9
|
+
- **The checkout verbs answer.** `payments.checkout.begin` and
|
|
10
|
+
`payments.checkout.fillCard` attach through sdk 2.0.19's browser-checkout
|
|
11
|
+
seam: one payments service per registration holds the in-flight checkout
|
|
12
|
+
registry across both verbs, a second begin on a busy page refuses, and the
|
|
13
|
+
card-material guard is the engine's own instance end to end. Without a
|
|
14
|
+
merchant-response reader wired, purchase outcomes record honestly as
|
|
15
|
+
`submitted-unverified` and the owner report says so. `explicitUserRequest`
|
|
16
|
+
gates entry to `begin` and is documented as what it is: a caller assertion,
|
|
17
|
+
no stronger than a confirm flag; the money controls are the budget ledger,
|
|
18
|
+
the purchase notices and decision windows, and the card-material guard.
|
|
19
|
+
- **The daily budget survives restarts.** Reservations and commits persist
|
|
20
|
+
atomically to `payments-budget.json` and reload at boot; corrupt or
|
|
21
|
+
malformed entries (including non-finite or out-of-range amounts and
|
|
22
|
+
timestamps) are dropped with a warning instead of poisoning the pools; a
|
|
23
|
+
commit whose disk write fails keeps the in-memory spend, logs the exposure
|
|
24
|
+
plainly, and retries on the next mutation, so a full disk cannot erase a
|
|
25
|
+
spend that already happened at the merchant; a failed reserve rolls back
|
|
26
|
+
rather than holding budget; old spend records prune at boot; loaded
|
|
27
|
+
reservations are disclosed at startup for reconciliation.
|
|
28
|
+
- **Five payments verbs ride the sdk registrar.** `budget.status`,
|
|
29
|
+
`cards.list` and `cards.delete` attach through sdk 2.0.19's
|
|
30
|
+
`registerPaymentsGatewayMethods`; `cards.create` and `purchases.list` keep
|
|
31
|
+
thin local wrappers over the same service for stricter field-named
|
|
32
|
+
validation and query-string limits. Wire behavior for all five is pinned
|
|
33
|
+
byte-identical by the contract suites. Teardown restores all seven
|
|
34
|
+
descriptors to their pristine builtin state.
|
|
35
|
+
- Platform runtime 2.0.19: the pin rides the checkout-seam and wake-fix
|
|
36
|
+
cycle; this daemon serves the repaired `voice.wake.model.get`, which fixes
|
|
37
|
+
wake-word model delivery for every surface that fetches models from it.
|
|
38
|
+
|
|
5
39
|
---
|
|
6
40
|
|
|
7
41
|
## [1.28.20] - 2026-08-21
|
package/README.md
CHANGED
|
@@ -2,14 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/mgd34msu/goodvibes-daemon/actions/workflows/ci.yml)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
|
-
[](https://github.com/mgd34msu/goodvibes-daemon)
|
|
6
6
|
|
|
7
7
|
The GoodVibes daemon: one long-running process per machine that holds the control plane every
|
|
8
|
-
GoodVibes client talks to. It
|
|
9
|
-
|
|
10
|
-
answers
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
GoodVibes client talks to. It:
|
|
9
|
+
|
|
10
|
+
- answers the operator verb families over HTTP
|
|
11
|
+
- reads and replies on your channels
|
|
12
|
+
- elects a leader among the machines you have grouped together, so only one of them answers a
|
|
13
|
+
shared inbox
|
|
14
|
+
- runs scheduled and triggered work
|
|
15
|
+
- keeps the session, memory, knowledge and code-index stores
|
|
16
|
+
- provisions the local voice and wake-word models
|
|
17
|
+
- updates itself at an idle moment, with a rollback if the new binary will not start
|
|
13
18
|
|
|
14
19
|
The terminal app (`goodvibes`), the conversational agent (`goodvibes-agent`) and the web app are
|
|
15
20
|
clients of this process. They render, they capture input, and they call verbs; the work happens
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pellux/goodvibes-daemon",
|
|
3
|
-
"version": "1.28.
|
|
3
|
+
"version": "1.28.21",
|
|
4
4
|
"description": "The GoodVibes daemon \u2014 the one long-running host for the control plane, channels, cluster membership, scheduled work, knowledge and memory stores, and the verb families every GoodVibes client calls.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/daemon/cli.ts",
|
|
@@ -67,8 +67,8 @@
|
|
|
67
67
|
"@anthropic-ai/bedrock-sdk": "^0.28.1",
|
|
68
68
|
"@anthropic-ai/sdk": "^0.82.0",
|
|
69
69
|
"@ast-grep/napi": "^0.42.0",
|
|
70
|
-
"@pellux/goodvibes-sdk": "2.0.
|
|
71
|
-
"@pellux/goodvibes-terminal-shell": "2.0.
|
|
70
|
+
"@pellux/goodvibes-sdk": "2.0.19",
|
|
71
|
+
"@pellux/goodvibes-terminal-shell": "2.0.19",
|
|
72
72
|
"bash-language-server": "^5.6.0",
|
|
73
73
|
"fuse.js": "^7.1.0",
|
|
74
74
|
"graphql": "^16.13.2",
|
|
@@ -89,7 +89,7 @@
|
|
|
89
89
|
"web-tree-sitter": "^0.26.7"
|
|
90
90
|
},
|
|
91
91
|
"devDependencies": {
|
|
92
|
-
"@pellux/goodvibes-toolchain": "2.0.
|
|
92
|
+
"@pellux/goodvibes-toolchain": "2.0.19",
|
|
93
93
|
"@types/bun": "^1.3.10",
|
|
94
94
|
"typescript": "^5.9.3"
|
|
95
95
|
},
|
|
@@ -17,6 +17,21 @@ export type {
|
|
|
17
17
|
GatewayMethodHandler,
|
|
18
18
|
} from '@pellux/goodvibes-sdk/platform/control-plane';
|
|
19
19
|
|
|
20
|
+
// The SDK's own `payments.*` route module: the registrar and the service seam
|
|
21
|
+
// it dispatches to. Exported by the SDK barrel as of 2.0.18 (CHANGELOG). Three
|
|
22
|
+
// of the seven verbs attach through this unchanged; see
|
|
23
|
+
// daemon/handlers/payments/register.ts and checkout-handlers.ts for why the
|
|
24
|
+
// other four (`cards.create`, `purchases.list`, `checkout.begin`,
|
|
25
|
+
// `checkout.fillCard`) stay local wrappers instead.
|
|
26
|
+
export { registerPaymentsGatewayMethods } from '@pellux/goodvibes-sdk/platform/control-plane';
|
|
27
|
+
export type { PaymentsGatewayService, PaymentPurchaseView } from '@pellux/goodvibes-sdk/platform/control-plane';
|
|
28
|
+
|
|
29
|
+
// The browser-checkout seam a daemon composition receives through
|
|
30
|
+
// `onBrowserCheckout` (sdk 2.0.19, platform/control-plane's
|
|
31
|
+
// `composeDaemonBrowser`). See daemon/handlers/payments/register.ts for how
|
|
32
|
+
// the checkout pair reads it.
|
|
33
|
+
export type { BrowserCheckoutSeam } from '@pellux/goodvibes-sdk/platform/control-plane';
|
|
34
|
+
|
|
20
35
|
// Channel domain types reused in handler signatures (read-only SDK interfaces; never re-declared).
|
|
21
36
|
export type {
|
|
22
37
|
ChannelIdentity,
|
|
@@ -57,7 +57,7 @@ export interface DaemonHandlerSurfaceProviders {
|
|
|
57
57
|
/** channels.drafts.* */
|
|
58
58
|
readonly registerDrafts: SurfaceRegister;
|
|
59
59
|
/**
|
|
60
|
-
* payments.budget.status / cards.* / purchases.list.
|
|
60
|
+
* payments.budget.status / cards.* / purchases.list / checkout.begin / checkout.fillCard.
|
|
61
61
|
*
|
|
62
62
|
* Its stores need a shell-path resolver and a scoped secret writer, neither of
|
|
63
63
|
* which is on `HandlerContext`, so the composition root builds them and hands
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* address-store.ts, the stored shipping and billing addresses, read from
|
|
3
|
+
* config.
|
|
4
|
+
*
|
|
5
|
+
* The SDK's `platform/config/schema-domain-payments.ts` already declares flat
|
|
6
|
+
* `payments.shippingAddress.*` / `payments.billingAddress.*` keys (name,
|
|
7
|
+
* line1, line2, city, region, postalCode, country each), settable through the
|
|
8
|
+
* owner profile and the settings surfaces; address.ts's own header names the
|
|
9
|
+
* defect this closes: "nothing in the checkout path read either." This is
|
|
10
|
+
* that read path, the daemon-owned counterpart to `DaemonCardStore` for the
|
|
11
|
+
* OTHER thing a checkout needs supplied rather than typed by the model.
|
|
12
|
+
*
|
|
13
|
+
* A field that is present but blank reads as absent (`''` is not a value any
|
|
14
|
+
* of these fields can honestly hold), so a half-set address is reported the
|
|
15
|
+
* same way a wholly-unset one is: `AddressStore.read` returns the address with
|
|
16
|
+
* whatever is there, and `checkAddress` (address.ts) is what decides whether
|
|
17
|
+
* that is complete enough to ship to, exactly as it already does for the SDK's
|
|
18
|
+
* own test doubles. Nothing here validates completeness; this module only
|
|
19
|
+
* reads what config holds.
|
|
20
|
+
*/
|
|
21
|
+
import type { AddressKind, AddressStore, PostalAddress, PaymentsConfigReader } from '@pellux/goodvibes-sdk/platform/payments';
|
|
22
|
+
|
|
23
|
+
function readAddressField(config: PaymentsConfigReader, kind: AddressKind, field: string): string {
|
|
24
|
+
const value = config.get(`payments.${kind}Address.${field}`);
|
|
25
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Every field blank means "nothing stored", read back as `null` rather than an all-empty address. */
|
|
29
|
+
function isEntirelyBlank(address: PostalAddress): boolean {
|
|
30
|
+
return address.name === ''
|
|
31
|
+
&& address.line1 === ''
|
|
32
|
+
&& address.line2 === ''
|
|
33
|
+
&& address.city === ''
|
|
34
|
+
&& address.region === ''
|
|
35
|
+
&& address.postalCode === ''
|
|
36
|
+
&& address.country === '';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function configBackedAddressStore(config: PaymentsConfigReader): AddressStore {
|
|
40
|
+
return {
|
|
41
|
+
async read(kind: AddressKind): Promise<PostalAddress | null> {
|
|
42
|
+
const address: PostalAddress = {
|
|
43
|
+
name: readAddressField(config, kind, 'name'),
|
|
44
|
+
line1: readAddressField(config, kind, 'line1'),
|
|
45
|
+
line2: readAddressField(config, kind, 'line2'),
|
|
46
|
+
city: readAddressField(config, kind, 'city'),
|
|
47
|
+
region: readAddressField(config, kind, 'region'),
|
|
48
|
+
postalCode: readAddressField(config, kind, 'postalCode'),
|
|
49
|
+
country: readAddressField(config, kind, 'country'),
|
|
50
|
+
};
|
|
51
|
+
return isEntirelyBlank(address) ? null : address;
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* budget-store.ts, the daily budget pools, durable across a restart.
|
|
3
|
+
*
|
|
4
|
+
* ── The defect this closes ────────────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* `payments-composition.ts` used to construct a plain `BudgetLedger` with no
|
|
7
|
+
* initial state and never write anything back to disk, a choice its own header
|
|
8
|
+
* comment flagged as correct ONLY while checkout was unattached ("the sole
|
|
9
|
+
* writer of a spend record is the checkout flow, so there is nothing to
|
|
10
|
+
* persist"). Now that checkout is wired, that stopped being true: a daemon
|
|
11
|
+
* restarted at noon, after spending half the daily item budget, would come
|
|
12
|
+
* back with the full budget again, because nothing on disk remembered the
|
|
13
|
+
* spend. `register.ts`'s `PaymentsHandlerDeps.budget` doc comment names the
|
|
14
|
+
* same gap ("a ledger rebuilt at every boot would hand back a daily budget
|
|
15
|
+
* that was already spent").
|
|
16
|
+
*
|
|
17
|
+
* ── What this is, and is not ──────────────────────────────────────────────
|
|
18
|
+
*
|
|
19
|
+
* A thin subclass of the SDK's own `BudgetLedger`. Every pool computation,
|
|
20
|
+
* every reservation rule, every day-boundary decision stays exactly what the
|
|
21
|
+
* SDK wrote (`snapshot()` recomputes from `spend`/`reservations` against the
|
|
22
|
+
* CURRENT day every time it is asked, see budget.ts, which is what makes day
|
|
23
|
+
* rollover correct with zero code here). This class adds exactly one thing:
|
|
24
|
+
* a write-through to a JSON file after every mutating call, and a load of that
|
|
25
|
+
* file at construction, using the constructor seam `BudgetLedger` already
|
|
26
|
+
* exposes for it (`new BudgetLedger(initial?: BudgetStateSnapshot)`).
|
|
27
|
+
*
|
|
28
|
+
* ── Corruption is a warning, not a crash and not a silent empty pool ─────
|
|
29
|
+
*
|
|
30
|
+
* A missing file (the ordinary first-boot case, or any daemon that has never
|
|
31
|
+
* spent anything) starts empty with no log line, the same silent-empty
|
|
32
|
+
* treatment `DaemonPurchaseLedger`/`DaemonCardStore` give a missing file, and
|
|
33
|
+
* what keeps `payments.budget.status`'s fresh-daemon response identical to
|
|
34
|
+
* today's. A file that exists but fails to parse or does not hold the shape
|
|
35
|
+
* this class wrote is different: that is data loss for a store that tracks
|
|
36
|
+
* money, and it is logged as a warning naming the file, rather than swallowed
|
|
37
|
+
* the way a purchase ledger's read failure is. Either way the daemon starts
|
|
38
|
+
* empty and keeps running; refusing to boot over a damaged budget file would
|
|
39
|
+
* take every other capability on this daemon down with it, the same reasoning
|
|
40
|
+
* `readPurchasesFile` already gives for purchases.
|
|
41
|
+
*
|
|
42
|
+
* ── A write failure after commit must not erase a spend that already happened ──
|
|
43
|
+
*
|
|
44
|
+
* Every mutating method here throws on a write failure except one:
|
|
45
|
+
* `commit`. By the time the checkout flow calls it (checkout-flow.ts, step 10,
|
|
46
|
+
* after step 9 has already told the merchant to charge the card), the money is
|
|
47
|
+
* gone whether or not this class can get a byte to disk. A full disk turning
|
|
48
|
+
* that into a thrown error would mean the caller sees an exception for a
|
|
49
|
+
* purchase that, at the merchant, already succeeded, and nothing downstream
|
|
50
|
+
* of it, the response, the audit ledger row, would ever get written either.
|
|
51
|
+
* So `commit`'s own write failure is logged loudly instead, the in-memory
|
|
52
|
+
* commit stands (this class's whole `spend`/`reservations` state, and
|
|
53
|
+
* therefore every subsequent `snapshot()`, already reflects it), and the write
|
|
54
|
+
* is retried at the START of the next mutating call, whichever one that
|
|
55
|
+
* happens to be, rather than being retried right away or dropped.
|
|
56
|
+
*/
|
|
57
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
58
|
+
import { atomicWriteFileSync } from '@pellux/goodvibes-sdk/platform/config';
|
|
59
|
+
import { logger } from '@pellux/goodvibes-sdk/platform/utils';
|
|
60
|
+
import { BudgetLedger } from '@pellux/goodvibes-sdk/platform/payments';
|
|
61
|
+
import type {
|
|
62
|
+
BudgetLimits,
|
|
63
|
+
BudgetReservation,
|
|
64
|
+
BudgetStateSnapshot,
|
|
65
|
+
SpendRecord,
|
|
66
|
+
} from '@pellux/goodvibes-sdk/platform/payments';
|
|
67
|
+
|
|
68
|
+
const BUDGET_FILE_VERSION = 1;
|
|
69
|
+
|
|
70
|
+
interface BudgetFile {
|
|
71
|
+
readonly version: number;
|
|
72
|
+
readonly spend: readonly SpendRecord[];
|
|
73
|
+
readonly reservations: readonly BudgetReservation[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Whether `value` is a finite, non-negative amount.
|
|
78
|
+
*
|
|
79
|
+
* A pool total is a running sum of every entry that passes this check
|
|
80
|
+
* (`BudgetLedger.snapshot`, budget.ts), so one entry that is not a real,
|
|
81
|
+
* bounded number poisons the whole day's total: a negative amount inflates
|
|
82
|
+
* what looks spent into what looks remaining, and `Infinity` or `NaN` (which
|
|
83
|
+
* `1e999` parses to under `JSON.parse`) turns "remaining" into a value no
|
|
84
|
+
* comparison against a limit can ever refuse. `typeof value === 'number'`
|
|
85
|
+
* alone accepts both, which is why this checks `Number.isFinite` rather than
|
|
86
|
+
* only the type.
|
|
87
|
+
*/
|
|
88
|
+
function isFiniteNonNegativeAmount(value: unknown): value is number {
|
|
89
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Whether `value` is a real millisecond timestamp `Date` can hold.
|
|
94
|
+
*
|
|
95
|
+
* The amount guard above (`isFiniteNonNegativeAmount`) never applied to
|
|
96
|
+
* `atMs`/`createdAtMs`/`expiresAtMs`: those fields were only checked with a
|
|
97
|
+
* bare `typeof value === 'number'`, which accepts `Infinity` and `NaN` the
|
|
98
|
+
* same way an unchecked amount did. A timestamp that parses to `Infinity`
|
|
99
|
+
* (`1e999`, valid JSON number syntax) or that is simply out of the range
|
|
100
|
+
* `Date` can represent (`8.64e15` is `Date`'s own documented maximum) does not
|
|
101
|
+
* fail loudly where an amount would: it passes `loadInitialState` and then
|
|
102
|
+
* throws a `RangeError` out of `Intl.DateTimeFormat`/`Date` formatting the
|
|
103
|
+
* first time `snapshot()` or `reserve()` computes a day key from it (`day.ts`),
|
|
104
|
+
* on every call, forever, since nothing ever mutates the record to fix it or
|
|
105
|
+
* calls `persist()` to rewrite the file. Checked the same way an amount is:
|
|
106
|
+
* finite, and additionally a whole number inside the range `Date` accepts.
|
|
107
|
+
*/
|
|
108
|
+
function isValidTimestampMs(value: unknown): value is number {
|
|
109
|
+
return typeof value === 'number'
|
|
110
|
+
&& Number.isFinite(value)
|
|
111
|
+
&& Number.isInteger(value)
|
|
112
|
+
&& value >= 0
|
|
113
|
+
&& value <= 8_640_000_000_000_000;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isSpendRecord(value: unknown): value is SpendRecord {
|
|
117
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
118
|
+
const record = value as Record<string, unknown>;
|
|
119
|
+
return typeof record['purchaseId'] === 'string'
|
|
120
|
+
&& isValidTimestampMs(record['atMs'])
|
|
121
|
+
&& isFiniteNonNegativeAmount(record['itemMinorUnits'])
|
|
122
|
+
&& isFiniteNonNegativeAmount(record['overageMinorUnits'])
|
|
123
|
+
&& isFiniteNonNegativeAmount(record['toleranceMinorUnits']);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function isReservation(value: unknown): value is BudgetReservation {
|
|
127
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
128
|
+
const record = value as Record<string, unknown>;
|
|
129
|
+
return typeof record['id'] === 'string'
|
|
130
|
+
&& typeof record['dayKey'] === 'string'
|
|
131
|
+
&& isFiniteNonNegativeAmount(record['itemMinorUnits'])
|
|
132
|
+
&& isFiniteNonNegativeAmount(record['overageMinorUnits'])
|
|
133
|
+
&& isFiniteNonNegativeAmount(record['toleranceMinorUnits'])
|
|
134
|
+
&& isValidTimestampMs(record['createdAtMs'])
|
|
135
|
+
&& isValidTimestampMs(record['expiresAtMs']);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Read the persisted state, or `undefined` for "start empty".
|
|
140
|
+
*
|
|
141
|
+
* The only case that logs is a file that exists and fails to read as the
|
|
142
|
+
* shape this class wrote; a missing file is the ordinary case and stays
|
|
143
|
+
* quiet, matching the rest of this daemon's stores.
|
|
144
|
+
*/
|
|
145
|
+
function loadInitialState(filePath: string): BudgetStateSnapshot | undefined {
|
|
146
|
+
if (!existsSync(filePath)) return undefined;
|
|
147
|
+
let parsed: unknown;
|
|
148
|
+
try {
|
|
149
|
+
parsed = JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
150
|
+
} catch (error) {
|
|
151
|
+
logger.warn(
|
|
152
|
+
'Budget ledger file could not be read; starting today\'s pools empty rather than guessing. '
|
|
153
|
+
+ 'A spent amount recorded before this may be missing from today\'s totals until this file is repaired.',
|
|
154
|
+
{ filePath, error: error instanceof Error ? error.message : String(error) },
|
|
155
|
+
);
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
if (
|
|
159
|
+
typeof parsed !== 'object'
|
|
160
|
+
|| parsed === null
|
|
161
|
+
|| !Array.isArray((parsed as Partial<BudgetFile>).spend)
|
|
162
|
+
|| !Array.isArray((parsed as Partial<BudgetFile>).reservations)
|
|
163
|
+
) {
|
|
164
|
+
logger.warn(
|
|
165
|
+
'Budget ledger file does not hold the expected shape; starting today\'s pools empty rather than guessing.',
|
|
166
|
+
{ filePath },
|
|
167
|
+
);
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
const file = parsed as BudgetFile;
|
|
171
|
+
const spend = file.spend.filter(isSpendRecord);
|
|
172
|
+
const reservations = file.reservations.filter(isReservation);
|
|
173
|
+
if (spend.length !== file.spend.length || reservations.length !== file.reservations.length) {
|
|
174
|
+
logger.warn(
|
|
175
|
+
'Budget ledger file held entries that do not match the expected record shape; those entries were '
|
|
176
|
+
+ 'dropped rather than trusted.',
|
|
177
|
+
{ filePath },
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
// Named, not just counted: there is no durable journal for a reservation
|
|
181
|
+
// (unlike the checkout journal's own disclosed gap, see register.ts), so this
|
|
182
|
+
// log line is the only record an operator has, at restart, of money that is
|
|
183
|
+
// currently held against the daily limit for a purchase that may or may not
|
|
184
|
+
// still be in flight. Reconciling it against what the merchant actually
|
|
185
|
+
// charged is manual; naming the ids, amounts and dayKey here is what makes
|
|
186
|
+
// that possible at all.
|
|
187
|
+
if (reservations.length > 0) {
|
|
188
|
+
logger.info(
|
|
189
|
+
'Budget ledger loaded with reservations already held against today\'s pools. Each one holds budget until '
|
|
190
|
+
+ 'it is committed, released, or its TTL expires; if the purchase it belongs to did not actually complete, '
|
|
191
|
+
+ 'reconcile it against what the merchant charged.',
|
|
192
|
+
{
|
|
193
|
+
filePath,
|
|
194
|
+
reservations: reservations.map((entry) => ({
|
|
195
|
+
id: entry.id,
|
|
196
|
+
dayKey: entry.dayKey,
|
|
197
|
+
itemMinorUnits: entry.itemMinorUnits,
|
|
198
|
+
overageMinorUnits: entry.overageMinorUnits,
|
|
199
|
+
toleranceMinorUnits: entry.toleranceMinorUnits,
|
|
200
|
+
expiresAtMs: entry.expiresAtMs,
|
|
201
|
+
})),
|
|
202
|
+
},
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
return { spend, reservations };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The daemon's durable `BudgetLedger`.
|
|
210
|
+
*
|
|
211
|
+
* Every mutating method the base class exposes is overridden to persist the
|
|
212
|
+
* new state after the mutation succeeds. Reads (`snapshot`, `state`) are
|
|
213
|
+
* untouched: they are pure functions over in-memory state and stay that way.
|
|
214
|
+
*/
|
|
215
|
+
export class DurableBudgetLedger extends BudgetLedger {
|
|
216
|
+
private readonly filePath: string;
|
|
217
|
+
/**
|
|
218
|
+
* Set when a write to disk failed and was not retried at that moment.
|
|
219
|
+
* Checked at the START of every mutating method below, before its own
|
|
220
|
+
* write, so a disk that has come back gets the missed write on the very
|
|
221
|
+
* next mutation rather than waiting for one to fail again to notice.
|
|
222
|
+
*/
|
|
223
|
+
private persistPending = false;
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* `now` defaults to the real clock; a test passes its own so an old fixture
|
|
227
|
+
* timestamp stays "recent" relative to whatever moment the test considers
|
|
228
|
+
* "now", rather than being pruned out from under a scenario that has nothing
|
|
229
|
+
* to do with retention.
|
|
230
|
+
*/
|
|
231
|
+
constructor(filePath: string, now: () => number = Date.now) {
|
|
232
|
+
super(loadInitialState(filePath));
|
|
233
|
+
this.filePath = filePath;
|
|
234
|
+
// Never called anywhere before this: a spend record older than the base
|
|
235
|
+
// class's own two-day retention window (`BudgetLedger.prune`, budget.ts,
|
|
236
|
+
// "anything older than two days cannot be 'today' in any timezone on
|
|
237
|
+
// earth") could accumulate for the whole life of a long-running daemon,
|
|
238
|
+
// since nothing else in this class's own mutation set ever shrinks
|
|
239
|
+
// `spend`. Pruning once here, right after load, means a daemon that has
|
|
240
|
+
// been running for months does not carry months of spend records it will
|
|
241
|
+
// never again need: `snapshot()` only ever looks at "today", and the
|
|
242
|
+
// purchase audit ledger, not this file, is the durable long-term record.
|
|
243
|
+
// `this.prune` below persists on its own when it actually drops something,
|
|
244
|
+
// so a fresh or already-pruned file triggers no extra write.
|
|
245
|
+
this.prune(now());
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
override reserve(input: {
|
|
249
|
+
readonly id: string;
|
|
250
|
+
readonly itemMinorUnits: number;
|
|
251
|
+
readonly overageMinorUnits: number;
|
|
252
|
+
readonly toleranceMinorUnits: number;
|
|
253
|
+
readonly limits: BudgetLimits;
|
|
254
|
+
readonly nowMs: number;
|
|
255
|
+
readonly timezone: string;
|
|
256
|
+
readonly ttlMs?: number;
|
|
257
|
+
}): BudgetReservation | null {
|
|
258
|
+
this.retryPendingPersist();
|
|
259
|
+
const result = super.reserve(input);
|
|
260
|
+
if (result !== null) {
|
|
261
|
+
try {
|
|
262
|
+
this.persist();
|
|
263
|
+
} catch (error) {
|
|
264
|
+
// Unlike `commit` (below), a reservation that never reached disk
|
|
265
|
+
// represents nothing that has happened yet: no money left this
|
|
266
|
+
// process's control, nothing downstream saw it succeed. Holding it in
|
|
267
|
+
// memory anyway would mean a caller that saw this throw and treated
|
|
268
|
+
// the reservation as never made would nonetheless have it tie up
|
|
269
|
+
// budget for the full 4h TTL. Roll the in-memory mutation back before
|
|
270
|
+
// the caller sees the failure, so a failed reserve leaves `remaining`
|
|
271
|
+
// exactly where it was, the same as if `super.reserve` itself had
|
|
272
|
+
// refused.
|
|
273
|
+
super.release(result.id);
|
|
274
|
+
throw error;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return result;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Commit is different from every other mutation below: by the time this
|
|
282
|
+
* runs, the checkout flow has already told the merchant to charge the card
|
|
283
|
+
* (checkout-flow.ts, step 9, before step 10 calls this). A write failure
|
|
284
|
+
* here must never un-happen a charge that already happened at the
|
|
285
|
+
* merchant, so unlike every other method here it does not throw: the
|
|
286
|
+
* failure is logged loudly, the in-memory commit `super.commit` already
|
|
287
|
+
* made stands, and the write is retried on the next mutation
|
|
288
|
+
* (`retryPendingPersist`, above), whichever method that turns out to be.
|
|
289
|
+
*/
|
|
290
|
+
override commit(reservationId: string, atMs: number): SpendRecord | null {
|
|
291
|
+
this.retryPendingPersist();
|
|
292
|
+
const result = super.commit(reservationId, atMs);
|
|
293
|
+
if (result !== null) this.persistAfterCommit();
|
|
294
|
+
return result;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
override release(reservationId: string): boolean {
|
|
298
|
+
this.retryPendingPersist();
|
|
299
|
+
const result = super.release(reservationId);
|
|
300
|
+
if (result) this.persist();
|
|
301
|
+
return result;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
override sweep(nowMs: number): readonly BudgetReservation[] {
|
|
305
|
+
this.retryPendingPersist();
|
|
306
|
+
const result = super.sweep(nowMs);
|
|
307
|
+
if (result.length > 0) this.persist();
|
|
308
|
+
return result;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
override prune(nowMs: number): void {
|
|
312
|
+
this.retryPendingPersist();
|
|
313
|
+
const before = super.state().spend.length;
|
|
314
|
+
super.prune(nowMs);
|
|
315
|
+
if (super.state().spend.length !== before) this.persist();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Retries a write a prior mutation could not make land, before this one's own. Silent: a still-down disk just stays pending. */
|
|
319
|
+
private retryPendingPersist(): void {
|
|
320
|
+
if (!this.persistPending) return;
|
|
321
|
+
try {
|
|
322
|
+
this.writeToDisk();
|
|
323
|
+
} catch {
|
|
324
|
+
// Still down. Stays pending; tried again on the next mutation.
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** The commit-only path: never throws, logs instead, and leaves the write pending for a later retry. */
|
|
329
|
+
private persistAfterCommit(): void {
|
|
330
|
+
try {
|
|
331
|
+
this.writeToDisk();
|
|
332
|
+
} catch (error) {
|
|
333
|
+
this.persistPending = true;
|
|
334
|
+
logger.warn(
|
|
335
|
+
'Budget ledger could not be written to disk after a committed spend. The spend is kept in memory and '
|
|
336
|
+
+ 'reported correctly for the rest of this process, and the write will be retried on the next budget '
|
|
337
|
+
+ 'change. But if this process dies before that retry lands, the file on disk still holds the OLD '
|
|
338
|
+
+ 'reservation, not this commit: the amount stays counted against the daily limit only until that '
|
|
339
|
+
+ 'reservation\'s own TTL passes, at which point it is treated as expired rather than spent, and the '
|
|
340
|
+
+ 'daily limit becomes re-spendable by the lost amount with no record it was ever charged.',
|
|
341
|
+
{ filePath: this.filePath, error: error instanceof Error ? error.message : String(error) },
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Every mutation but commit: a write failure here throws, unchanged from before this class had a retry path. */
|
|
347
|
+
private persist(): void {
|
|
348
|
+
this.writeToDisk();
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
private writeToDisk(): void {
|
|
352
|
+
const contents: BudgetFile = { version: BUDGET_FILE_VERSION, ...super.state() };
|
|
353
|
+
atomicWriteFileSync(this.filePath, `${JSON.stringify(contents, null, 2)}\n`, { mode: 0o600, mkdirp: true });
|
|
354
|
+
this.persistPending = false;
|
|
355
|
+
}
|
|
356
|
+
}
|