@pellux/goodvibes-daemon 1.28.20 → 1.28.22
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 +64 -0
- package/README.md +34 -13
- package/package.json +4 -4
- package/src/cli/command-catalog.ts +6 -5
- package/src/daemon/cli.ts +3 -3
- 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/approval-store.ts +275 -0
- package/src/daemon/handlers/payments/budget-store.ts +357 -0
- package/src/daemon/handlers/payments/checkout-handlers.ts +678 -0
- package/src/daemon/handlers/payments/checkout-journal-store.ts +162 -0
- package/src/daemon/handlers/payments/index.ts +14 -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 +376 -136
- package/src/runtime/browser-checkout-seam-holder.ts +55 -0
- package/src/runtime/daemon-handler-composition.ts +39 -13
- package/src/runtime/legacy-daemon-migration.ts +1 -1
- package/src/runtime/payments-composition.ts +95 -28
- package/src/runtime/services.ts +13 -6
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* checkout-journal-store.ts, the in-flight checkout journal, durable across a
|
|
3
|
+
* restart.
|
|
4
|
+
*
|
|
5
|
+
* ── The defect this closes ────────────────────────────────────────────────
|
|
6
|
+
*
|
|
7
|
+
* The composition used to hand `PaymentsGatewayServiceImpl` the SDK's own
|
|
8
|
+
* `MemoryCheckoutJournal`, documented "durable across nothing". The phase
|
|
9
|
+
* ladder in checkout-registry.ts exists for exactly one ambiguous moment: a
|
|
10
|
+
* crash between the `submit-pending` flush and the merchant's response. With
|
|
11
|
+
* an in-memory journal that flush kept nothing, so a restart could never say
|
|
12
|
+
* "this purchase may already have been submitted, do not resubmit it". This
|
|
13
|
+
* file is the durable journal that record was waiting for.
|
|
14
|
+
*
|
|
15
|
+
* ── The contract this implements ──────────────────────────────────────────
|
|
16
|
+
*
|
|
17
|
+
* The SDK's `CheckoutJournal` (checkout-registry.ts): `put` must not return
|
|
18
|
+
* until the record would survive a power cut, `remove` drops one by
|
|
19
|
+
* purchaseId, `list` returns what is held. `put` here writes through
|
|
20
|
+
* `atomicWriteFileSync` (a synchronous rename-into-place) before resolving,
|
|
21
|
+
* which is the flush the `submit-pending` guarantee rides on; a `put` whose
|
|
22
|
+
* write fails THROWS, because a journal that reports durable-and-was-not
|
|
23
|
+
* turns that guarantee into a comment.
|
|
24
|
+
*
|
|
25
|
+
* `remove` is the one deliberate asymmetry: its write failure is logged and
|
|
26
|
+
* swallowed rather than thrown. By the time `remove` runs the checkout is
|
|
27
|
+
* finished or abandoned; failing the caller would turn a completed purchase's
|
|
28
|
+
* report into an error over a cleanup write, and the stale record it leaves
|
|
29
|
+
* on disk fails in the safe direction, a restart discloses a checkout that
|
|
30
|
+
* needs checking rather than forgetting one that does. The in-memory removal
|
|
31
|
+
* stands either way, and the next successful `put`/`remove` rewrites the file
|
|
32
|
+
* without the stale record.
|
|
33
|
+
*
|
|
34
|
+
* ── Unknown fields ride along untouched ───────────────────────────────────
|
|
35
|
+
*
|
|
36
|
+
* Records are persisted and reloaded as the objects they arrive as, not
|
|
37
|
+
* projected through this module's idea of the record shape. Only
|
|
38
|
+
* `purchaseId` (the removal key) is checked at load; every other field,
|
|
39
|
+
* including fields added by an SDK this build has never seen, round-trips
|
|
40
|
+
* byte-for-byte. The repin that teaches
|
|
41
|
+
* the SDK to recover these records must find everything its writer put here,
|
|
42
|
+
* not everything this file knew to keep.
|
|
43
|
+
*
|
|
44
|
+
* ── Corruption is a warning, not a crash ──────────────────────────────────
|
|
45
|
+
*
|
|
46
|
+
* Same conventions as `DurableBudgetLedger` (budget-store.ts): a missing file
|
|
47
|
+
* starts empty silently; a file that exists but cannot be parsed or does not
|
|
48
|
+
* hold this shape is logged as a warning naming the file, and the journal
|
|
49
|
+
* starts empty rather than taking the daemon down. Entries without a string
|
|
50
|
+
* `purchaseId` are dropped with a warning, since nothing could ever remove
|
|
51
|
+
* them.
|
|
52
|
+
*/
|
|
53
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
54
|
+
import { atomicWriteFileSync } from '@pellux/goodvibes-sdk/platform/config';
|
|
55
|
+
import { logger } from '@pellux/goodvibes-sdk/platform/utils';
|
|
56
|
+
import type { CheckoutJournal, InFlightCheckout } from '@pellux/goodvibes-sdk/platform/payments';
|
|
57
|
+
|
|
58
|
+
const JOURNAL_FILE_VERSION = 1;
|
|
59
|
+
|
|
60
|
+
interface JournalFile {
|
|
61
|
+
readonly version: number;
|
|
62
|
+
readonly records: readonly Record<string, unknown>[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The two fields this module actually reads; everything else is opaque cargo. */
|
|
66
|
+
function hasJournalKeys(value: unknown): value is Record<string, unknown> & { purchaseId: string } {
|
|
67
|
+
return typeof value === 'object'
|
|
68
|
+
&& value !== null
|
|
69
|
+
&& !Array.isArray(value)
|
|
70
|
+
&& typeof (value as Record<string, unknown>)['purchaseId'] === 'string';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Read the persisted records, keyed by purchaseId, or empty for "start empty". */
|
|
74
|
+
function loadInitialRecords(filePath: string): Map<string, Record<string, unknown>> {
|
|
75
|
+
const records = new Map<string, Record<string, unknown>>();
|
|
76
|
+
if (!existsSync(filePath)) return records;
|
|
77
|
+
let parsed: unknown;
|
|
78
|
+
try {
|
|
79
|
+
parsed = JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
80
|
+
} catch (error) {
|
|
81
|
+
logger.warn(
|
|
82
|
+
'Checkout journal file could not be read; starting with no in-flight checkouts rather than guessing. '
|
|
83
|
+
+ 'If a purchase was mid-submit when this daemon last stopped, check that merchant\'s order history by hand.',
|
|
84
|
+
{ filePath, error: error instanceof Error ? error.message : String(error) },
|
|
85
|
+
);
|
|
86
|
+
return records;
|
|
87
|
+
}
|
|
88
|
+
if (
|
|
89
|
+
typeof parsed !== 'object'
|
|
90
|
+
|| parsed === null
|
|
91
|
+
|| !Array.isArray((parsed as Partial<JournalFile>).records)
|
|
92
|
+
) {
|
|
93
|
+
logger.warn(
|
|
94
|
+
'Checkout journal file does not hold the expected shape; starting with no in-flight checkouts rather than guessing.',
|
|
95
|
+
{ filePath },
|
|
96
|
+
);
|
|
97
|
+
return records;
|
|
98
|
+
}
|
|
99
|
+
const rows = (parsed as JournalFile).records;
|
|
100
|
+
for (const row of rows) {
|
|
101
|
+
if (hasJournalKeys(row)) {
|
|
102
|
+
records.set(row.purchaseId, row);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (records.size !== rows.length) {
|
|
106
|
+
logger.warn(
|
|
107
|
+
'Checkout journal file held entries with no purchaseId; those entries were dropped rather than trusted, '
|
|
108
|
+
+ 'since nothing could ever remove them.',
|
|
109
|
+
{ filePath },
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return records;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The durable `CheckoutJournal` this daemon composes. See the module header
|
|
117
|
+
* for the contract and the conventions.
|
|
118
|
+
*/
|
|
119
|
+
export class DurableCheckoutJournal implements CheckoutJournal {
|
|
120
|
+
private readonly records: Map<string, Record<string, unknown>>;
|
|
121
|
+
|
|
122
|
+
constructor(private readonly filePath: string) {
|
|
123
|
+
this.records = loadInitialRecords(filePath);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Durable before it resolves; throws when the write does not land. */
|
|
127
|
+
async put(record: InFlightCheckout): Promise<void> {
|
|
128
|
+
const previous = this.records.get(record.purchaseId);
|
|
129
|
+
this.records.set(record.purchaseId, record as unknown as Record<string, unknown>);
|
|
130
|
+
try {
|
|
131
|
+
this.persist();
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (previous === undefined) this.records.delete(record.purchaseId);
|
|
134
|
+
else this.records.set(record.purchaseId, previous);
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Removes in memory always; a failed cleanup write is logged, never thrown. See the header. */
|
|
140
|
+
async remove(purchaseId: string): Promise<void> {
|
|
141
|
+
if (!this.records.delete(purchaseId)) return;
|
|
142
|
+
try {
|
|
143
|
+
this.persist();
|
|
144
|
+
} catch (error) {
|
|
145
|
+
logger.warn(
|
|
146
|
+
'Checkout journal could not be rewritten after removing a finished checkout. The stale record stays on '
|
|
147
|
+
+ 'disk until the next journal write lands; at worst a restart reports a checkout that needs checking '
|
|
148
|
+
+ 'when it was already complete, never the reverse.',
|
|
149
|
+
{ filePath: this.filePath, purchaseId, error: error instanceof Error ? error.message : String(error) },
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async list(): Promise<readonly InFlightCheckout[]> {
|
|
155
|
+
return [...this.records.values()] as unknown as readonly InFlightCheckout[];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private persist(): void {
|
|
159
|
+
const contents: JournalFile = { version: JOURNAL_FILE_VERSION, records: [...this.records.values()] };
|
|
160
|
+
atomicWriteFileSync(this.filePath, `${JSON.stringify(contents, null, 2)}\n`, { mode: 0o600, mkdirp: true });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -24,9 +24,22 @@ export type {
|
|
|
24
24
|
export { DaemonPurchaseLedger, MAX_PURCHASE_LIST_LIMIT } from './purchase-ledger.ts';
|
|
25
25
|
export type { DaemonPurchaseLedgerOptions, PurchaseListQuery, StoredPurchase } from './purchase-ledger.ts';
|
|
26
26
|
|
|
27
|
+
export { DurableBudgetLedger } from './budget-store.ts';
|
|
28
|
+
|
|
29
|
+
export { DaemonApprovalStore } from './approval-store.ts';
|
|
30
|
+
export type { ApprovalTakeHit, ApprovalTakeMiss } from './approval-store.ts';
|
|
31
|
+
|
|
32
|
+
export { DurableCheckoutJournal } from './checkout-journal-store.ts';
|
|
33
|
+
|
|
34
|
+
export { CHECKOUT_APPROVAL_ACTION, checkoutApprovalContent } from './checkout-handlers.ts';
|
|
35
|
+
|
|
36
|
+
export { configBackedAddressStore } from './address-store.ts';
|
|
37
|
+
export { channelBackedPaymentNotifier } from './notifier.ts';
|
|
38
|
+
export { createProviderBackedMerchantJudgeModel } from './merchant-judge.ts';
|
|
39
|
+
|
|
27
40
|
export {
|
|
28
41
|
ATTACHED_PAYMENTS_METHOD_IDS,
|
|
29
42
|
UNATTACHED_PAYMENTS_METHOD_IDS,
|
|
30
43
|
registerPaymentsMethods,
|
|
31
44
|
} from './register.ts';
|
|
32
|
-
export type { PaymentsHandlerDeps } from './register.ts';
|
|
45
|
+
export type { CheckoutComposition, PaymentsHandlerDeps } from './register.ts';
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* merchant-judge.ts, the merchant-recourse judgement, answered by this
|
|
3
|
+
* daemon's own configured model.
|
|
4
|
+
*
|
|
5
|
+
* `createModelMerchantJudge` (platform/payments) wants a `MerchantJudgeModel`,
|
|
6
|
+
* one method, `chat(task, prompt, options)`. This adapts the daemon's
|
|
7
|
+
* `ProviderRegistry` to that shape, the same pattern
|
|
8
|
+
* `createProviderBackedCheckinJudge` (platform/checkin) already uses for the
|
|
9
|
+
* proactive check-in judge: resolve the currently configured model, ask its
|
|
10
|
+
* provider, and treat any failure as "no judgement available" rather than as a
|
|
11
|
+
* thrown error, because `createModelMerchantJudge` already reads a null/failed
|
|
12
|
+
* chat as an honest "I could not judge this merchant" verdict (unqualified,
|
|
13
|
+
* unconfident), never as a reason to fail the purchase in some OTHER way.
|
|
14
|
+
*/
|
|
15
|
+
import type { ProviderRegistry } from '@pellux/goodvibes-sdk/platform/providers';
|
|
16
|
+
import type { MerchantJudgeModel } from '@pellux/goodvibes-sdk/platform/payments';
|
|
17
|
+
import { logger } from '@pellux/goodvibes-sdk/platform/utils';
|
|
18
|
+
|
|
19
|
+
/** How long the merchant judge waits for the model before giving up. */
|
|
20
|
+
const MERCHANT_JUDGE_TIMEOUT_MS = 20_000;
|
|
21
|
+
|
|
22
|
+
export function createProviderBackedMerchantJudgeModel(
|
|
23
|
+
providerRegistry: Pick<ProviderRegistry, 'getCurrentModel' | 'getForModel'>,
|
|
24
|
+
): MerchantJudgeModel {
|
|
25
|
+
return {
|
|
26
|
+
async chat(task, prompt, options) {
|
|
27
|
+
const controller = new AbortController();
|
|
28
|
+
const timer = setTimeout(() => controller.abort(), MERCHANT_JUDGE_TIMEOUT_MS);
|
|
29
|
+
timer.unref?.();
|
|
30
|
+
try {
|
|
31
|
+
const current = providerRegistry.getCurrentModel();
|
|
32
|
+
const provider = providerRegistry.getForModel(current.registryKey, current.provider);
|
|
33
|
+
const response = await provider.chat({
|
|
34
|
+
model: current.id,
|
|
35
|
+
messages: [{ role: 'user', content: prompt }],
|
|
36
|
+
...(options.systemPrompt !== undefined ? { systemPrompt: options.systemPrompt } : {}),
|
|
37
|
+
...(options.maxTokens !== undefined ? { maxTokens: options.maxTokens } : {}),
|
|
38
|
+
reasoningEffort: 'low',
|
|
39
|
+
signal: controller.signal,
|
|
40
|
+
});
|
|
41
|
+
return response.content ?? null;
|
|
42
|
+
} catch (error) {
|
|
43
|
+
// `createModelMerchantJudge` treats a null answer as "I could not judge
|
|
44
|
+
// this merchant", the safe direction (see merchant-judge-model.ts's
|
|
45
|
+
// header: an unjudgeable domain must never make spending MORE
|
|
46
|
+
// automatic). The task name rides along only for the operator log.
|
|
47
|
+
logger.warn('Merchant judge model call failed; the purchase proceeds as unjudged', {
|
|
48
|
+
task,
|
|
49
|
+
error: error instanceof Error ? error.message : String(error),
|
|
50
|
+
});
|
|
51
|
+
return null;
|
|
52
|
+
} finally {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* notifier.ts, sending a purchase notice over this daemon's channels.
|
|
3
|
+
*
|
|
4
|
+
* `createChannelPaymentNotifier` (platform/payments/notice-delivery.ts) wants
|
|
5
|
+
* a router, a target per configured channel, and a `PaymentReplySource`. The
|
|
6
|
+
* first two are real: `payments.notifyChannels` names which
|
|
7
|
+
* `CommandAuthorityChannel`s to notify, and delivery goes out over this
|
|
8
|
+
* daemon's own `ChannelDeliveryRouter`, the SAME router every other channel
|
|
9
|
+
* send in this daemon uses (see services.ts's comment on why there is exactly
|
|
10
|
+
* one).
|
|
11
|
+
*
|
|
12
|
+
* ── What is deliberately NOT wired in this pass ───────────────────────────
|
|
13
|
+
*
|
|
14
|
+
* `PaymentReplySource.waitForAnswer` always resolves `null`. That is not a
|
|
15
|
+
* stub standing in for something broken, it is the documented meaning of
|
|
16
|
+
* SILENCE (platform/payments/windows.ts): an approval window's silence DENIES
|
|
17
|
+
* and a veto window's silence PROCEEDS, both already correct, tested behaviors
|
|
18
|
+
* the decision layer exercises with no reply source at all. What is missing is
|
|
19
|
+
* the OTHER path, an inbound reply on a channel resolving the window before its
|
|
20
|
+
* deadline, "approve"/"yes"/"stop" arriving back from wherever the notice went.
|
|
21
|
+
* Building that needs an inbound-message correlation path this daemon does not
|
|
22
|
+
* have yet (there is no `payments.*` counterpart to
|
|
23
|
+
* `tryResolveApprovalReplyFromChannel`/`tryResolveWorkProposalReplyFromChannel`
|
|
24
|
+
* in `platform/daemon/surface-actions.ts`, which resolve DIFFERENT kinds of
|
|
25
|
+
* reply against a DIFFERENT store). Wiring it is a distinct, sizeable piece of
|
|
26
|
+
* work and is left for a later pass, exactly like `describeSubmission` above
|
|
27
|
+
* it; every purchase in the meantime is decided by budget and by the windows'
|
|
28
|
+
* own silence rules, with the notice actually reaching the owner's configured
|
|
29
|
+
* channels.
|
|
30
|
+
*/
|
|
31
|
+
import type { ChannelDeliveryRouter, ChannelDeliveryTarget } from '@pellux/goodvibes-sdk/platform/channels';
|
|
32
|
+
import {
|
|
33
|
+
createChannelPaymentNotifier,
|
|
34
|
+
parseCommandAuthorityChannel,
|
|
35
|
+
readNotifyChannels,
|
|
36
|
+
} from '@pellux/goodvibes-sdk/platform/payments';
|
|
37
|
+
import type {
|
|
38
|
+
PaymentNotifier,
|
|
39
|
+
PaymentNoticeRouter,
|
|
40
|
+
PaymentNoticeTarget,
|
|
41
|
+
PaymentReplySource,
|
|
42
|
+
PaymentsConfigReader,
|
|
43
|
+
} from '@pellux/goodvibes-sdk/platform/payments';
|
|
44
|
+
import { logger } from '@pellux/goodvibes-sdk/platform/utils';
|
|
45
|
+
|
|
46
|
+
const PAYMENTS_NOTICE_JOB_ID = 'payments-notice';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The channel name (`payments.notifyChannels` entry) turned into the router's
|
|
50
|
+
* own addressing shape. `parseChannelDeliveryTarget` (platform/channels'
|
|
51
|
+
* internal delivery/types.ts) is not on the published subpath, so this mirrors
|
|
52
|
+
* its `surface` construction for the plain channel names `readNotifyChannels`
|
|
53
|
+
* produces (no `kind:address` suffix, `CommandAuthorityChannel` carries none).
|
|
54
|
+
*/
|
|
55
|
+
function surfaceTarget(surfaceKind: string): ChannelDeliveryTarget {
|
|
56
|
+
return { kind: 'surface', surfaceKind: surfaceKind as ChannelDeliveryTarget['surfaceKind'] };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Adapts this daemon's router to the notifier's narrow, opaque-`request` shape. */
|
|
60
|
+
function daemonNoticeRouter(router: Pick<ChannelDeliveryRouter, 'deliver'>): PaymentNoticeRouter {
|
|
61
|
+
return {
|
|
62
|
+
deliver: async (request) => {
|
|
63
|
+
const merged = request as unknown as Record<string, unknown> & { readonly content: string };
|
|
64
|
+
return router.deliver({
|
|
65
|
+
target: merged['target'] as ChannelDeliveryTarget,
|
|
66
|
+
body: merged.content,
|
|
67
|
+
title: 'Purchase',
|
|
68
|
+
jobId: PAYMENTS_NOTICE_JOB_ID,
|
|
69
|
+
runId: `${PAYMENTS_NOTICE_JOB_ID}-${String(Date.now())}`,
|
|
70
|
+
includeLinks: false,
|
|
71
|
+
});
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** No live reply integration yet; see this module's header for why silence is still correct. */
|
|
77
|
+
const NO_REPLIES: PaymentReplySource = {
|
|
78
|
+
async waitForAnswer() {
|
|
79
|
+
return null;
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export function channelBackedPaymentNotifier(
|
|
84
|
+
config: PaymentsConfigReader,
|
|
85
|
+
router: Pick<ChannelDeliveryRouter, 'deliver'>,
|
|
86
|
+
): PaymentNotifier {
|
|
87
|
+
const targets: PaymentNoticeTarget[] = [];
|
|
88
|
+
for (const name of readNotifyChannels(config)) {
|
|
89
|
+
const channel = parseCommandAuthorityChannel(name);
|
|
90
|
+
if (channel === null) {
|
|
91
|
+
logger.warn('payments.notifyChannels names a channel this daemon does not recognise; it will not be notified', { channel: name });
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
targets.push({
|
|
95
|
+
channel,
|
|
96
|
+
request: { target: surfaceTarget(name) },
|
|
97
|
+
// No backfill path is wired (see this module's header): a notice missed
|
|
98
|
+
// while the daemon was down cannot be recovered by re-reading history it
|
|
99
|
+
// never asked this router to keep.
|
|
100
|
+
backfillable: false,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return createChannelPaymentNotifier({
|
|
105
|
+
router: daemonNoticeRouter(router),
|
|
106
|
+
targets,
|
|
107
|
+
replies: NO_REPLIES,
|
|
108
|
+
onDeliveryFailure: ({ channel, reason }) => {
|
|
109
|
+
logger.warn('A payments notice could not be delivered on a configured channel', { channel, reason });
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
}
|