@dvmkit/sdk 0.5.0-rc.1 → 0.6.0-rc.1
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/README.md +100 -0
- package/dist/chunk-AFV77S4C.js +258 -0
- package/dist/chunk-BQJIFRAZ.js +36 -0
- package/dist/{chunk-PB4FYZET.js → chunk-EQLA4FFY.js} +54 -4
- package/dist/{chunk-TZ2WKCDD.js → chunk-HCLVYZUZ.js} +2 -2
- package/dist/{chunk-EYTHFFFU.js → chunk-HHBDBMEO.js} +159 -34
- package/dist/{chunk-O5AT6S5T.js → chunk-IV6Q7NRW.js} +25 -195
- package/dist/{chunk-Z4KLECAC.js → chunk-N5RZ5HVB.js} +1 -1
- package/dist/{chunk-TUA7PYH7.js → chunk-OQA7IHI4.js} +124 -124
- package/dist/{chunk-6DOGTAHA.js → chunk-QR2EF2NI.js} +9 -43
- package/dist/{chunk-A4YFWZ7R.js → chunk-RCM3P2HE.js} +1 -1
- package/dist/content-retention-BTGSQRfP.d.ts +78 -0
- package/dist/{credit-menu-CAfqe8IY.d.ts → credit-menu-BAsrRu3z.d.ts} +98 -5
- package/dist/{fx-D9nsN3n4.d.ts → fx-BgDmyTdd.d.ts} +1 -1
- package/dist/index.d.ts +5 -5
- package/dist/index.js +3 -3
- package/dist/internal/caller.d.ts +16 -15
- package/dist/internal/caller.js +7 -5
- package/dist/internal/server.d.ts +8 -99
- package/dist/internal/server.js +12 -9
- package/dist/{job-store-Bx-VFRjg.d.ts → job-store-BVeF5_Jq.d.ts} +1 -1
- package/dist/{lightning-backend-BAx2Y8pP.d.ts → lightning-backend-40fIzz1W.d.ts} +1 -1
- package/dist/{postgres-consumed-credential-store-CUQJKRSL.js → postgres-consumed-credential-store-BUVCAPLS.js} +13 -0
- package/dist/{postgres-job-store-VLXLWXUQ.js → postgres-job-store-VHLQBJZC.js} +3 -1
- package/dist/server/index.d.ts +28 -10
- package/dist/server/index.js +22 -9
- package/dist/{step-cache-QG_h5-3G.d.ts → step-cache-CPdH1hei.d.ts} +2 -2
- package/dist/testing/index.d.ts +9 -2
- package/dist/testing/index.js +6 -3
- package/dist/{usd-EwRqCPF-.d.ts → usd-AWlrwlM5.d.ts} +1 -1
- package/package.json +3 -2
- package/recipes/postgres/financial-export-v1.sql +137 -0
package/README.md
CHANGED
|
@@ -96,6 +96,12 @@ For Bitcoin credit monitoring, `GET /admin/credit/bitcoin-liability` reports exa
|
|
|
96
96
|
|
|
97
97
|
The public [SDK guide](https://dvmkit.com/docs/sdk) covers signed requests, custom routes, rail configuration, credit reclaim, persistence, and deployment in depth. The [protocol guide](https://dvmkit.com/docs/protocol) specifies the HTTP contract.
|
|
98
98
|
|
|
99
|
+
## Diagnostic retention
|
|
100
|
+
|
|
101
|
+
The SDK advertises 730 days of retention for sanitized diagnostics in every existing span dataset: `platform`, `cashu`, `lightning`, `stripe`, `x402`, `tempo`, `isolate`, `audit`, and `cron`. Dataset names, producer permissions, and query semantics remain unchanged; only operator services may write to `audit`.
|
|
102
|
+
|
|
103
|
+
This metadata describes the retention schedule. The SDK does not automatically delete diagnostics or override platform destruction or case controls. The platform must enforce the schedule and those controls.
|
|
104
|
+
|
|
99
105
|
## Repository development
|
|
100
106
|
|
|
101
107
|
Use the Node.js release in `.nvmrc` (Node 22). Install with `npm ci`, then run the same gate CI runs with `npm run verify`. The gate checks repository metadata in the checkout, copies only tracked and unignored source into an isolated directory, installs its locked dependencies, and runs lint, build, typecheck (including the release scripts under `checkJs`), unit and database tests, API, package, consumer, packed-artifact safety, and release checks there. The clean source tree owns the complete build and test pass, so verification never depends on the checkout's `node_modules` or pre-existing `dist` and does not repeat the full suite; after every check passes, its verified `dist` replaces the checkout's build output for release packaging.
|
|
@@ -182,3 +188,97 @@ None of this claims exclusive rights in the phrase "digital vending machine" or
|
|
|
182
188
|
## License
|
|
183
189
|
|
|
184
190
|
Apache-2.0. See [LICENSE](./LICENSE) and [NOTICE](./NOTICE).
|
|
191
|
+
|
|
192
|
+
## Job content retention and case holds
|
|
193
|
+
|
|
194
|
+
`configureDVM` defaults `jobRetentionDays` to 730. Set a shorter or longer whole-day
|
|
195
|
+
window explicitly, or `0` to disable automatic age expiry. Only completed, failed,
|
|
196
|
+
and cancelled jobs are eligible. The terminal `lastActivityAt` is frozen: reads,
|
|
197
|
+
receipt delivery, late messages, and re-saving an outcome do not renew it. The
|
|
198
|
+
sweep removes input, params, summary, messages, state, caches, and bearer proofs;
|
|
199
|
+
it preserves durable status, signed receipts, payment amounts, and recovery linkage.
|
|
200
|
+
|
|
201
|
+
Authenticated job submit, request-result, status and JSON message responses expose
|
|
202
|
+
`content_expires_at` (epoch milliseconds or `null`) and `content_available` (boolean).
|
|
203
|
+
A null deadline means the job is active, expiry is disabled, or an active hold
|
|
204
|
+
suppresses it. A past deadline can still have available content until the sweep
|
|
205
|
+
runs. Missing fields on an older service or unsupported custom store mean unknown.
|
|
206
|
+
SSE messages retain their existing event format; read job status for expiry.
|
|
207
|
+
|
|
208
|
+
With durable service-admin configuration, use signed `Authorization: P2PK`
|
|
209
|
+
requests on the service-local routes:
|
|
210
|
+
|
|
211
|
+
| Method | Path | JSON body |
|
|
212
|
+
| --- | --- | --- |
|
|
213
|
+
| GET | `/admin/jobs/:jobId/content-holds` | none |
|
|
214
|
+
| POST | `/admin/jobs/:jobId/content-holds` | `purpose`, `reason`, `review_due_at` |
|
|
215
|
+
| POST | `/admin/jobs/:jobId/content-holds/:holdId/renew` | `reason`, `review_due_at` |
|
|
216
|
+
| POST | `/admin/jobs/:jobId/content-holds/:holdId/clear` | `reason` |
|
|
217
|
+
|
|
218
|
+
Purpose is `support` or `dispute`; reason is 1–1000 characters and review dates
|
|
219
|
+
are future epoch millisecond integers. Unknown body fields are rejected. Hold IDs,
|
|
220
|
+
actor public keys, and creation/clear times are server-derived. Authentication uses
|
|
221
|
+
the existing method/path/service-bound admin challenge, current and grace-period
|
|
222
|
+
keys, and atomic durable nonce consumption. A hold request does not grant access
|
|
223
|
+
to job content. Authentication failure returns 401 with a reason; an unsupported
|
|
224
|
+
store returns `content_holds_unavailable` (503), missing jobs return 404, and a
|
|
225
|
+
redacted job or inactive hold returns 409. Holds cannot restore redacted content.
|
|
226
|
+
|
|
227
|
+
Responses include `job_id`, caller content metadata, `baseline_content_expires_at`,
|
|
228
|
+
`holds`, and append-only `events`. Each hold contains `id`, `job_id`, `purpose`,
|
|
229
|
+
`reason`, original `actor`, `created_at`, `review_due_at`, `cleared_at`, and computed
|
|
230
|
+
`review_overdue`. Each event contains `id`, `hold_id`, `job_id`, `action`, `actor`,
|
|
231
|
+
`reason`, `at`, `review_due_at`. Renew and clear preserve the original actor and
|
|
232
|
+
creation date; their authenticated actors are in history. Any active hold suppresses
|
|
233
|
+
the effective deadline. Overdue review flags work; it does not release the hold.
|
|
234
|
+
The baseline remains visible for review. Clearing the last hold restores the
|
|
235
|
+
baseline and can make content immediately eligible for the next sweep.
|
|
236
|
+
|
|
237
|
+
`PostgresJobStore.init()` adds `job_content_holds` and `job_content_hold_events`
|
|
238
|
+
with job-scoped indexes; existing jobs need no expiry column or backfill. Placement
|
|
239
|
+
and sweep lock the same job row and check redaction/terminal activity/active holds
|
|
240
|
+
inside the transaction. Admin operations refuse jobs bound to another service;
|
|
241
|
+
legacy unbound rows belong to the local service database. A committed hold wins any later competing sweep; if
|
|
242
|
+
redaction wins first, placement fails. `MemoryJobStore` supplies the same contract
|
|
243
|
+
for development. Custom stores must implement `ContentHoldStore` and atomic
|
|
244
|
+
redaction fencing to support holds and truthful metadata.
|
|
245
|
+
|
|
246
|
+
Before explicitly destroying a service database, use your existing database
|
|
247
|
+
connection/proxy and the packaged `recipes/postgres/financial-export-v1.sql`:
|
|
248
|
+
|
|
249
|
+
```sh
|
|
250
|
+
psql "$DATABASE_URL" -X -q -t -A -v ON_ERROR_STOP=1 \
|
|
251
|
+
-f node_modules/@dvmkit/sdk/recipes/postgres/financial-export-v1.sql \
|
|
252
|
+
> financial-evidence.json
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
The versioned JSON export takes a consistent read-only snapshot and includes signed
|
|
256
|
+
receipt tuples, ledger balances/amounts/currencies, job/payment links, and recovery
|
|
257
|
+
outcomes. Funding lots preserve every deposit's original sats and credit basis,
|
|
258
|
+
including depleted lots. Draws retain both the original allocation and settled
|
|
259
|
+
amount. Refunds retain fulfilment costs and funding-lot linkage. Optional channel
|
|
260
|
+
projections preserve amounts and settlement links while excluding authorization
|
|
261
|
+
signatures and vouchers; Tempo bigint amounts are decimal strings.
|
|
262
|
+
Unsigned JSON blocks use typed financial projections: unsupported fields and
|
|
263
|
+
objects or arrays in scalar positions are excluded. Optional ledger tables absent from a service become empty arrays. It
|
|
264
|
+
excludes raw input/output, bearer tokens/proofs, payout descriptors, and operational
|
|
265
|
+
credentials. Signed receipts may retain their signed terminal reason and result
|
|
266
|
+
hash; supported version-1 job, funding, and drain receipts are returned unchanged.
|
|
267
|
+
Unsupported receipt versions, kinds, fields, or nested extensions fail the entire
|
|
268
|
+
export before any JSON is emitted. Update the recipe for that schema before
|
|
269
|
+
destruction; a failed export is not retained financial evidence. Verify receipt
|
|
270
|
+
signatures and check the file before destruction, then keep it securely with this recipe
|
|
271
|
+
and the service's public receipt-key attestation. The builder owns this export;
|
|
272
|
+
the platform retains its separate financial history. An export does not settle
|
|
273
|
+
caller balances or refunds: resolve liabilities through their existing lifecycle.
|
|
274
|
+
Case holds affect age expiry, not explicit erasure or contract/lifecycle destruction.
|
|
275
|
+
The active-service window does not extend any contractual maximum for copies after
|
|
276
|
+
explicit destruction, including a 90-day post-destruction limit where applicable.
|
|
277
|
+
|
|
278
|
+
The wire fields and routes are additive within protocol version 1. Existing explicit
|
|
279
|
+
retention settings retain their value. The new default also applies to existing
|
|
280
|
+
unredacted terminal rows; previously removed content stays unavailable. Upgrade
|
|
281
|
+
all replicas before relying on holds: older SDKs do not recognize the hold fence.
|
|
282
|
+
Do not downgrade or mix old replicas with active holds unless automatic sweeping
|
|
283
|
+
is disabled on those replicas. The SQL export format is versioned independently
|
|
284
|
+
of the package and is not a raw database backup or a content archive.
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import {
|
|
2
|
+
StepCache
|
|
3
|
+
} from "./chunk-BQJIFRAZ.js";
|
|
4
|
+
|
|
5
|
+
// src/sdk/server/job.ts
|
|
6
|
+
function toJobRecord(job) {
|
|
7
|
+
return {
|
|
8
|
+
id: job.id,
|
|
9
|
+
tags: job.tags,
|
|
10
|
+
capability: job.capability,
|
|
11
|
+
input: job.input,
|
|
12
|
+
params: job.params,
|
|
13
|
+
requesterId: job.requesterId,
|
|
14
|
+
requesterTokenHash: job.requesterTokenHash,
|
|
15
|
+
requesterToken: job.requesterToken,
|
|
16
|
+
requestFingerprint: job.requestFingerprint,
|
|
17
|
+
requesterPubkey: job.requesterPubkey,
|
|
18
|
+
requestId: job.requestId,
|
|
19
|
+
authRequestPath: job.authRequestPath,
|
|
20
|
+
status: job.status,
|
|
21
|
+
summary: job.summary,
|
|
22
|
+
timingKnown: job.timingKnown,
|
|
23
|
+
endedBy: job.endedBy,
|
|
24
|
+
terminalAtMs: job.terminalAtMs,
|
|
25
|
+
callerWaitMs: job.callerWaitMs,
|
|
26
|
+
awaitingInputAtMs: job.awaitingInputAtMs,
|
|
27
|
+
messages: job.messages,
|
|
28
|
+
seq: job.seq,
|
|
29
|
+
paidMsats: job.paidMsats,
|
|
30
|
+
paymentMint: job.paymentMint,
|
|
31
|
+
paymentRail: job.paymentRail,
|
|
32
|
+
paymentTxHash: job.paymentTxHash,
|
|
33
|
+
paymentTransactionHash: job.paymentTransactionHash,
|
|
34
|
+
nativeAmount: job.nativeAmount,
|
|
35
|
+
nativeAsset: job.nativeAsset,
|
|
36
|
+
cashuFlow: job.cashuFlow,
|
|
37
|
+
costAmountMicro: job.costAmountMicro,
|
|
38
|
+
costCurrency: job.costCurrency,
|
|
39
|
+
costRevision: job.costRevision,
|
|
40
|
+
creditId: job.creditId,
|
|
41
|
+
drawId: job.drawId,
|
|
42
|
+
fundingReceipt: job.fundingReceipt,
|
|
43
|
+
fundingCredit: job.fundingCredit,
|
|
44
|
+
receivedProofs: job.receivedProofs,
|
|
45
|
+
pendingPaymentMsats: job.pendingPaymentMsats,
|
|
46
|
+
pendingMppChallengeIds: job.pendingMppChallengeIds,
|
|
47
|
+
pendingX402Nonce: job.pendingX402Nonce,
|
|
48
|
+
pendingX402AmountUsdcMicro: job.pendingX402AmountUsdcMicro,
|
|
49
|
+
pendingPaymentFiatMicro: job.pendingPaymentFiatMicro,
|
|
50
|
+
pendingPaymentFiatCurrency: job.pendingPaymentFiatCurrency,
|
|
51
|
+
askedTopUpMicro: job.askedTopUpMicro,
|
|
52
|
+
askedTopUpCurrency: job.askedTopUpCurrency,
|
|
53
|
+
topUpCapUnenforcedReason: job.topUpCapUnenforcedReason,
|
|
54
|
+
requiredMsats: job.requiredMsats,
|
|
55
|
+
receipt: job.receipt,
|
|
56
|
+
stepCache: job.stepCache.serialize(),
|
|
57
|
+
state: job.state,
|
|
58
|
+
createdAt: job.createdAt,
|
|
59
|
+
lastActivityAt: job.lastActivityAt
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function fromJobRecord(record) {
|
|
63
|
+
return {
|
|
64
|
+
id: record.id,
|
|
65
|
+
tags: record.tags,
|
|
66
|
+
capability: record.capability,
|
|
67
|
+
input: record.input,
|
|
68
|
+
params: record.params,
|
|
69
|
+
requesterId: record.requesterId,
|
|
70
|
+
requesterTokenHash: record.requesterTokenHash,
|
|
71
|
+
status: record.status,
|
|
72
|
+
summary: record.summary,
|
|
73
|
+
timingKnown: record.timingKnown,
|
|
74
|
+
endedBy: record.endedBy,
|
|
75
|
+
terminalAtMs: record.terminalAtMs,
|
|
76
|
+
callerWaitMs: record.callerWaitMs,
|
|
77
|
+
awaitingInputAtMs: record.awaitingInputAtMs,
|
|
78
|
+
messages: [...record.messages],
|
|
79
|
+
seq: record.seq,
|
|
80
|
+
paidMsats: record.paidMsats,
|
|
81
|
+
paymentMint: record.paymentMint,
|
|
82
|
+
paymentRail: record.paymentRail,
|
|
83
|
+
paymentTxHash: record.paymentTxHash,
|
|
84
|
+
paymentTransactionHash: record.paymentTransactionHash,
|
|
85
|
+
nativeAmount: record.nativeAmount,
|
|
86
|
+
nativeAsset: record.nativeAsset,
|
|
87
|
+
cashuFlow: record.cashuFlow,
|
|
88
|
+
costAmountMicro: record.costAmountMicro,
|
|
89
|
+
costCurrency: record.costCurrency,
|
|
90
|
+
costRevision: record.costRevision,
|
|
91
|
+
creditId: record.creditId,
|
|
92
|
+
drawId: record.drawId,
|
|
93
|
+
fundingReceipt: record.fundingReceipt,
|
|
94
|
+
fundingCredit: record.fundingCredit,
|
|
95
|
+
receivedProofs: [...record.receivedProofs],
|
|
96
|
+
pendingPaymentMsats: record.pendingPaymentMsats,
|
|
97
|
+
pendingMppChallengeIds: record.pendingMppChallengeIds ? [...record.pendingMppChallengeIds] : void 0,
|
|
98
|
+
requesterToken: record.requesterToken,
|
|
99
|
+
requestFingerprint: record.requestFingerprint,
|
|
100
|
+
requesterPubkey: record.requesterPubkey,
|
|
101
|
+
requestId: record.requestId,
|
|
102
|
+
authRequestPath: record.authRequestPath,
|
|
103
|
+
pendingX402Nonce: record.pendingX402Nonce,
|
|
104
|
+
pendingX402AmountUsdcMicro: record.pendingX402AmountUsdcMicro,
|
|
105
|
+
pendingPaymentFiatMicro: record.pendingPaymentFiatMicro,
|
|
106
|
+
pendingPaymentFiatCurrency: record.pendingPaymentFiatCurrency,
|
|
107
|
+
askedTopUpMicro: record.askedTopUpMicro,
|
|
108
|
+
askedTopUpCurrency: record.askedTopUpCurrency,
|
|
109
|
+
topUpCapUnenforcedReason: record.topUpCapUnenforcedReason,
|
|
110
|
+
requiredMsats: record.requiredMsats,
|
|
111
|
+
receipt: record.receipt,
|
|
112
|
+
listeners: /* @__PURE__ */ new Set(),
|
|
113
|
+
abort: new AbortController(),
|
|
114
|
+
messageAppender: void 0,
|
|
115
|
+
messageAppenderTail: void 0,
|
|
116
|
+
stepCache: StepCache.deserialize(record.stepCache),
|
|
117
|
+
state: record.state,
|
|
118
|
+
pendingPrompts: /* @__PURE__ */ new Map(),
|
|
119
|
+
pendingPayment: null,
|
|
120
|
+
replayHighSeq: null,
|
|
121
|
+
replayProviderSkip: 0,
|
|
122
|
+
replayPaymentIndex: 0,
|
|
123
|
+
createdAt: record.createdAt,
|
|
124
|
+
lastActivityAt: record.lastActivityAt
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
function abortJob(job, reason) {
|
|
128
|
+
job.abort.abort(new JobCancelledError(reason));
|
|
129
|
+
}
|
|
130
|
+
var JobCancelledError = class extends Error {
|
|
131
|
+
constructor(message = "Job cancelled") {
|
|
132
|
+
super(message);
|
|
133
|
+
this.name = "JobCancelledError";
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
function providerMessage(job, type, content) {
|
|
137
|
+
if (job.replayProviderSkip > 0) {
|
|
138
|
+
job.replayProviderSkip--;
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const msg = localMessage(job, "provider", type, content);
|
|
142
|
+
if (type === "payment-request") {
|
|
143
|
+
const paymentRequest = content;
|
|
144
|
+
const pendingPaymentDelta = typeof paymentRequest.amount_msats === "number" ? paymentRequest.amount_msats : void 0;
|
|
145
|
+
const pendingMppChallengeIds = paymentRequest.tempo?.challenges?.map((challenge) => challenge.id).filter((id) => typeof id === "string");
|
|
146
|
+
job.messageAppender?.(msg, {
|
|
147
|
+
awaitingInput: job.status === "awaiting-input",
|
|
148
|
+
pendingPaymentDelta,
|
|
149
|
+
pendingMppChallengeIds,
|
|
150
|
+
pendingX402Nonce: typeof paymentRequest.x402?.nonce === "string" ? paymentRequest.x402.nonce : void 0,
|
|
151
|
+
pendingX402AmountUsdcMicro: typeof paymentRequest.x402?.required_usdc_micro === "string" ? paymentRequest.x402.required_usdc_micro : void 0,
|
|
152
|
+
// read off the job rather than the content — the fiat envelope
|
|
153
|
+
// is ledger state, not part of the wire shape, and `requestPayment` has
|
|
154
|
+
// already pinned it on the job by the time this fires (same ordering the
|
|
155
|
+
// `pendingPaymentMsats` comment above describes).
|
|
156
|
+
pendingPaymentFiatMicro: job.pendingPaymentFiatMicro,
|
|
157
|
+
pendingPaymentFiatCurrency: job.pendingPaymentFiatCurrency
|
|
158
|
+
});
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
job.messageAppender?.(msg, type === "prompt" ? { awaitingInput: job.status === "awaiting-input" } : void 0);
|
|
162
|
+
}
|
|
163
|
+
function localMessage(job, from, type, content) {
|
|
164
|
+
job.seq++;
|
|
165
|
+
const msg = {
|
|
166
|
+
seq: job.seq,
|
|
167
|
+
from,
|
|
168
|
+
timestamp: Math.floor(Date.now() / 1e3),
|
|
169
|
+
type,
|
|
170
|
+
content
|
|
171
|
+
};
|
|
172
|
+
job.messages.push(msg);
|
|
173
|
+
for (const listener of job.listeners) {
|
|
174
|
+
listener(msg);
|
|
175
|
+
}
|
|
176
|
+
return msg;
|
|
177
|
+
}
|
|
178
|
+
function isTerminal(status) {
|
|
179
|
+
return status === "completed" || status === "failed" || status === "cancelled";
|
|
180
|
+
}
|
|
181
|
+
function isYieldMessage(msg) {
|
|
182
|
+
return msg.type === "prompt" || msg.type === "payment-request" || msg.type === "working" || msg.type === "complete" || msg.type === "cancel";
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// src/sdk/server/content-retention.ts
|
|
186
|
+
import { randomUUID } from "crypto";
|
|
187
|
+
var ContentHoldError = class extends Error {
|
|
188
|
+
constructor(code) {
|
|
189
|
+
super(code);
|
|
190
|
+
this.code = code;
|
|
191
|
+
}
|
|
192
|
+
code;
|
|
193
|
+
};
|
|
194
|
+
function isContentHoldStore(store) {
|
|
195
|
+
return "getContentSnapshot" in store && "mutateContentHold" in store;
|
|
196
|
+
}
|
|
197
|
+
function contentMetadata(job, retentionDays, holds = []) {
|
|
198
|
+
const baseline = baselineContentExpiry(job, retentionDays);
|
|
199
|
+
return {
|
|
200
|
+
content_expires_at: holds.some((hold) => hold.cleared_at === null) ? null : baseline,
|
|
201
|
+
content_available: job.redactedAt === void 0
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
function baselineContentExpiry(job, retentionDays) {
|
|
205
|
+
return retentionDays > 0 && isTerminal(job.status) ? job.lastActivityAt + retentionDays * 864e5 : null;
|
|
206
|
+
}
|
|
207
|
+
function applyContentHoldMutation(job, history, mutation) {
|
|
208
|
+
if (job.redactedAt !== void 0) throw new ContentHoldError("content_unavailable");
|
|
209
|
+
const result = structuredClone(history);
|
|
210
|
+
let hold;
|
|
211
|
+
if (mutation.action === "placed") {
|
|
212
|
+
hold = {
|
|
213
|
+
id: randomUUID(),
|
|
214
|
+
job_id: job.id,
|
|
215
|
+
purpose: mutation.purpose,
|
|
216
|
+
reason: mutation.reason,
|
|
217
|
+
actor: mutation.actor,
|
|
218
|
+
created_at: mutation.at,
|
|
219
|
+
review_due_at: mutation.review_due_at,
|
|
220
|
+
cleared_at: null
|
|
221
|
+
};
|
|
222
|
+
result.holds.push(hold);
|
|
223
|
+
} else {
|
|
224
|
+
const existing = result.holds.find((item) => item.id === mutation.hold_id && item.cleared_at === null);
|
|
225
|
+
if (!existing) throw new ContentHoldError("hold_not_active");
|
|
226
|
+
hold = existing;
|
|
227
|
+
if (mutation.action === "cleared") hold.cleared_at = mutation.at;
|
|
228
|
+
else hold.review_due_at = mutation.review_due_at;
|
|
229
|
+
hold.reason = mutation.reason;
|
|
230
|
+
}
|
|
231
|
+
result.events.push({
|
|
232
|
+
id: randomUUID(),
|
|
233
|
+
hold_id: hold.id,
|
|
234
|
+
job_id: job.id,
|
|
235
|
+
action: mutation.action,
|
|
236
|
+
actor: mutation.actor,
|
|
237
|
+
reason: mutation.reason,
|
|
238
|
+
at: mutation.at,
|
|
239
|
+
review_due_at: hold.review_due_at
|
|
240
|
+
});
|
|
241
|
+
return result;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export {
|
|
245
|
+
toJobRecord,
|
|
246
|
+
fromJobRecord,
|
|
247
|
+
abortJob,
|
|
248
|
+
JobCancelledError,
|
|
249
|
+
providerMessage,
|
|
250
|
+
localMessage,
|
|
251
|
+
isTerminal,
|
|
252
|
+
isYieldMessage,
|
|
253
|
+
ContentHoldError,
|
|
254
|
+
isContentHoldStore,
|
|
255
|
+
contentMetadata,
|
|
256
|
+
baselineContentExpiry,
|
|
257
|
+
applyContentHoldMutation
|
|
258
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// src/sdk/step-cache.ts
|
|
2
|
+
var StepCache = class _StepCache {
|
|
3
|
+
cache = /* @__PURE__ */ new Map();
|
|
4
|
+
has(id) {
|
|
5
|
+
return this.cache.has(id);
|
|
6
|
+
}
|
|
7
|
+
get(id) {
|
|
8
|
+
return this.getRecord(id).value;
|
|
9
|
+
}
|
|
10
|
+
getRecord(id) {
|
|
11
|
+
const record = this.cache.get(id);
|
|
12
|
+
if (record === void 0) throw new Error(`StepCache: no cached result for step "${id}"`);
|
|
13
|
+
return record;
|
|
14
|
+
}
|
|
15
|
+
set(id, value, costs) {
|
|
16
|
+
this.cache.set(id, {
|
|
17
|
+
id,
|
|
18
|
+
value,
|
|
19
|
+
...costs !== void 0 && costs.length > 0 ? { costs: costs.map(({ amount, currency }) => ({ amount, currency })) } : {}
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
serialize() {
|
|
23
|
+
return [...this.cache.values()];
|
|
24
|
+
}
|
|
25
|
+
static deserialize(records) {
|
|
26
|
+
const cache = new _StepCache();
|
|
27
|
+
for (const { id, value, costs } of records) {
|
|
28
|
+
cache.set(id, value, costs);
|
|
29
|
+
}
|
|
30
|
+
return cache;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export {
|
|
35
|
+
StepCache
|
|
36
|
+
};
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ContentHoldError,
|
|
3
|
+
applyContentHoldMutation
|
|
4
|
+
} from "./chunk-AFV77S4C.js";
|
|
1
5
|
import {
|
|
2
6
|
withSdkInitLock
|
|
3
7
|
} from "./chunk-COYKJRMM.js";
|
|
@@ -228,6 +232,18 @@ var PostgresJobStore = class {
|
|
|
228
232
|
ALTER TABLE jobs ADD COLUMN IF NOT EXISTS receipt JSONB;
|
|
229
233
|
ALTER TABLE jobs ADD COLUMN IF NOT EXISTS receipt_seq BIGINT;
|
|
230
234
|
ALTER TABLE jobs ADD COLUMN IF NOT EXISTS redacted_at BIGINT;
|
|
235
|
+
CREATE TABLE IF NOT EXISTS job_content_holds (
|
|
236
|
+
id TEXT PRIMARY KEY, job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
|
|
237
|
+
data JSONB NOT NULL
|
|
238
|
+
);
|
|
239
|
+
CREATE INDEX IF NOT EXISTS job_content_holds_job_idx ON job_content_holds(job_id);
|
|
240
|
+
CREATE TABLE IF NOT EXISTS job_content_hold_events (
|
|
241
|
+
event_position BIGSERIAL UNIQUE,
|
|
242
|
+
id TEXT PRIMARY KEY, job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
|
|
243
|
+
data JSONB NOT NULL
|
|
244
|
+
);
|
|
245
|
+
CREATE INDEX IF NOT EXISTS job_content_hold_events_job_idx ON job_content_hold_events(job_id);
|
|
246
|
+
|
|
231
247
|
-- credit-ledger linkage. Written at submission alongside the
|
|
232
248
|
-- payment fields; the terminal funnel settles/releases the draw and the
|
|
233
249
|
-- receipt countersigns the ReceiptCredit block from it.
|
|
@@ -536,7 +552,7 @@ var PostgresJobStore = class {
|
|
|
536
552
|
required_msats = EXCLUDED.required_msats,
|
|
537
553
|
step_cache = EXCLUDED.step_cache,
|
|
538
554
|
state = EXCLUDED.state,
|
|
539
|
-
last_activity_at = EXCLUDED.last_activity_at
|
|
555
|
+
last_activity_at = CASE WHEN jobs.status IN ('completed', 'failed', 'cancelled') THEN jobs.last_activity_at ELSE EXCLUDED.last_activity_at END
|
|
540
556
|
-- terminal is terminal. On a multi-machine DVM a DELETE can
|
|
541
557
|
-- land on a machine that isn't running the handler, which cancels the
|
|
542
558
|
-- job in the store only; the handler's machine never learns of it and
|
|
@@ -737,6 +753,7 @@ var PostgresJobStore = class {
|
|
|
737
753
|
const client = await this.pool.connect();
|
|
738
754
|
try {
|
|
739
755
|
await client.query("BEGIN");
|
|
756
|
+
await client.query("SELECT id FROM jobs WHERE id = $1 FOR UPDATE", [args.jobId]);
|
|
740
757
|
const { rowCount } = await client.query(
|
|
741
758
|
`UPDATE jobs SET
|
|
742
759
|
input = '',
|
|
@@ -760,6 +777,7 @@ var PostgresJobStore = class {
|
|
|
760
777
|
AND last_activity_at = $2
|
|
761
778
|
AND redacted_at IS NULL
|
|
762
779
|
AND status IN ('completed', 'failed', 'cancelled')
|
|
780
|
+
AND NOT EXISTS (SELECT 1 FROM job_content_holds h WHERE h.job_id=jobs.id AND h.data->'cleared_at' = 'null'::jsonb)
|
|
763
781
|
AND ($4::boolean = false OR NOT EXISTS (
|
|
764
782
|
SELECT 1 FROM job_messages m WHERE m.job_id=jobs.id
|
|
765
783
|
AND m.data->>'type'='payment'
|
|
@@ -781,6 +799,38 @@ var PostgresJobStore = class {
|
|
|
781
799
|
client.release();
|
|
782
800
|
}
|
|
783
801
|
}
|
|
802
|
+
async getContentSnapshot(jobId, dvmId) {
|
|
803
|
+
const { rows } = await this.pool.query(
|
|
804
|
+
`SELECT j.*, COALESCE((SELECT jsonb_agg(h.data ORDER BY h.data->>'created_at', h.id) FROM job_content_holds h WHERE h.job_id=j.id), '[]'::jsonb) AS content_holds,
|
|
805
|
+
COALESCE((SELECT jsonb_agg(e.data ORDER BY e.event_position) FROM job_content_hold_events e WHERE e.job_id=j.id), '[]'::jsonb) AS content_hold_events
|
|
806
|
+
FROM jobs j WHERE j.id=$1 AND ($2::text IS NULL OR j.dvm_id IS NULL OR j.dvm_id=$2)`,
|
|
807
|
+
[jobId, dvmId ?? null]
|
|
808
|
+
);
|
|
809
|
+
if (!rows[0]) return void 0;
|
|
810
|
+
return { job: rowToRecord(rows[0]), history: { holds: rows[0].content_holds, events: rows[0].content_hold_events } };
|
|
811
|
+
}
|
|
812
|
+
async mutateContentHold(jobId, mutation, dvmId) {
|
|
813
|
+
const client = await this.pool.connect();
|
|
814
|
+
try {
|
|
815
|
+
await client.query("BEGIN");
|
|
816
|
+
const { rows } = await client.query("SELECT * FROM jobs WHERE id=$1 AND ($2::text IS NULL OR dvm_id IS NULL OR dvm_id=$2) FOR UPDATE", [jobId, dvmId ?? null]);
|
|
817
|
+
if (!rows[0]) throw new ContentHoldError("job_not_found");
|
|
818
|
+
const holds = await client.query("SELECT data FROM job_content_holds WHERE job_id=$1 ORDER BY data->>'created_at', id", [jobId]);
|
|
819
|
+
const events = await client.query("SELECT data FROM job_content_hold_events WHERE job_id=$1 ORDER BY event_position", [jobId]);
|
|
820
|
+
const history = applyContentHoldMutation(rowToRecord(rows[0]), { holds: holds.rows.map((row) => row.data), events: events.rows.map((row) => row.data) }, mutation);
|
|
821
|
+
const event = history.events.at(-1);
|
|
822
|
+
const hold = history.holds.find((item) => item.id === event.hold_id);
|
|
823
|
+
await client.query(`INSERT INTO job_content_holds(id,job_id,data) VALUES($1,$2,$3) ON CONFLICT(id) DO UPDATE SET data=EXCLUDED.data`, [hold.id, jobId, JSON.stringify(hold)]);
|
|
824
|
+
await client.query("INSERT INTO job_content_hold_events(id,job_id,data) VALUES($1,$2,$3)", [event.id, jobId, JSON.stringify(event)]);
|
|
825
|
+
await client.query("COMMIT");
|
|
826
|
+
return history;
|
|
827
|
+
} catch (error) {
|
|
828
|
+
await client.query("ROLLBACK");
|
|
829
|
+
throw error;
|
|
830
|
+
} finally {
|
|
831
|
+
client.release();
|
|
832
|
+
}
|
|
833
|
+
}
|
|
784
834
|
async claimReceiptSeq(jobId) {
|
|
785
835
|
const client = await this.pool.connect();
|
|
786
836
|
try {
|
|
@@ -828,7 +878,7 @@ var PostgresJobStore = class {
|
|
|
828
878
|
try {
|
|
829
879
|
await client.query("BEGIN");
|
|
830
880
|
const { rows } = await client.query(
|
|
831
|
-
`UPDATE jobs SET next_seq = next_seq + 1, last_activity_at = $2,
|
|
881
|
+
`UPDATE jobs SET next_seq = next_seq + 1, last_activity_at = CASE WHEN status IN ('completed','failed','cancelled') THEN last_activity_at ELSE $2 END,
|
|
832
882
|
awaiting_input_at_ms = CASE WHEN $3::boolean AND status NOT IN ('completed', 'failed', 'cancelled')
|
|
833
883
|
THEN COALESCE(awaiting_input_at_ms, $2) ELSE awaiting_input_at_ms END,
|
|
834
884
|
status = CASE WHEN $3::boolean AND status NOT IN ('completed', 'failed', 'cancelled')
|
|
@@ -925,7 +975,7 @@ var PostgresJobStore = class {
|
|
|
925
975
|
await client.query("BEGIN");
|
|
926
976
|
if (message.type === "payment") await this.hostingCleanupFence?.assertNewIntent(client);
|
|
927
977
|
const { rows } = await client.query(
|
|
928
|
-
`UPDATE jobs SET next_seq = next_seq + 1, last_activity_at = $2
|
|
978
|
+
`UPDATE jobs SET next_seq = next_seq + 1, last_activity_at = CASE WHEN status IN ('completed','failed','cancelled') THEN last_activity_at ELSE $2 END
|
|
929
979
|
WHERE id = $1 AND redacted_at IS NULL
|
|
930
980
|
RETURNING (next_seq - 1) AS seq`,
|
|
931
981
|
[jobId, Date.now()]
|
|
@@ -1024,7 +1074,7 @@ var PostgresJobStore = class {
|
|
|
1024
1074
|
cashu_flow = COALESCE($9, cashu_flow),
|
|
1025
1075
|
pending_x402_nonce = CASE WHEN $11::boolean THEN NULL ELSE pending_x402_nonce END,
|
|
1026
1076
|
pending_x402_amount_usdc_micro = CASE WHEN $11::boolean THEN NULL ELSE pending_x402_amount_usdc_micro END,
|
|
1027
|
-
last_activity_at = $12
|
|
1077
|
+
last_activity_at = CASE WHEN status IN ('completed','failed','cancelled') THEN last_activity_at ELSE $12 END
|
|
1028
1078
|
WHERE id = $1
|
|
1029
1079
|
RETURNING status, paid_msats, pending_payment_msats, credit_id, draw_id`,
|
|
1030
1080
|
params
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
DEFAULT_CREDIT_TTL_SECONDS,
|
|
5
5
|
DEFAULT_JOB_RETENTION_DAYS,
|
|
6
6
|
isZodSchema
|
|
7
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-RCM3P2HE.js";
|
|
8
8
|
import {
|
|
9
9
|
InvalidCurrencyError,
|
|
10
10
|
validateCurrency
|
|
@@ -47,7 +47,7 @@ function configureDVM(config) {
|
|
|
47
47
|
}
|
|
48
48
|
function resolveJobRetentionDays(raw) {
|
|
49
49
|
const days = raw ?? DEFAULT_JOB_RETENTION_DAYS;
|
|
50
|
-
if (!Number.isSafeInteger(days) || days < 0) {
|
|
50
|
+
if (!Number.isSafeInteger(days) || days < 0 || days > Math.floor((Number.MAX_SAFE_INTEGER - Date.now()) / 864e5)) {
|
|
51
51
|
throw new Error(
|
|
52
52
|
`configureDVM: jobRetentionDays must be a non-negative whole number of days; received ${JSON.stringify(raw)}`
|
|
53
53
|
);
|