@dvmkit/dvmctl 0.1.0-rc.2 → 0.2.0-rc.2
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/NOTICE +1983 -0
- package/README.md +34 -1
- package/dist/dvmctl.js +106 -59
- package/package.json +9 -4
- package/skills/build-dvm/SKILL.md +93 -0
- package/skills/build-dvm/references/configure-context.md +273 -0
- package/skills/build-dvm/references/local-cashu-test.md +141 -0
- package/skills/build-dvm/references/operating-feedback.md +153 -0
- package/skills/build-dvm/references/patterns-auth.md +390 -0
- package/skills/build-dvm/references/payment-rails.md +11 -0
- package/skills/build-dvm/references/pricing-credit.md +155 -0
- package/skills/build-dvm/references/running-deployment.md +231 -0
- package/skills/build-dvm/references/sdk-reference.md +47 -0
- package/skills/build-dvm/references/testing.md +181 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dvmkit/dvmctl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0-rc.2",
|
|
4
4
|
"description": "Builder CLI for creating, testing, and deploying Digital Vending Machines",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -17,9 +17,14 @@
|
|
|
17
17
|
},
|
|
18
18
|
"files": [
|
|
19
19
|
"dist",
|
|
20
|
+
"skills",
|
|
20
21
|
"LICENSE",
|
|
21
|
-
"NOTICE"
|
|
22
|
+
"NOTICE",
|
|
23
|
+
"README.md"
|
|
22
24
|
],
|
|
25
|
+
"exports": {
|
|
26
|
+
"./skill": "./skills/build-dvm/SKILL.md"
|
|
27
|
+
},
|
|
23
28
|
"engines": {
|
|
24
29
|
"node": ">=22"
|
|
25
30
|
},
|
|
@@ -31,14 +36,14 @@
|
|
|
31
36
|
"verify": "npm run build && npm run lint && npm run typecheck && npm test"
|
|
32
37
|
},
|
|
33
38
|
"dependencies": {
|
|
34
|
-
"@dvmkit/sdk": "0.1.
|
|
39
|
+
"@dvmkit/sdk": "0.1.4-rc.7"
|
|
35
40
|
},
|
|
36
41
|
"optionalDependencies": {
|
|
37
42
|
"pg": "8.20.0",
|
|
38
43
|
"tsx": "^4"
|
|
39
44
|
},
|
|
40
45
|
"devDependencies": {
|
|
41
|
-
"@cashu/cashu-ts": "4.2
|
|
46
|
+
"@cashu/cashu-ts": "4.10.2",
|
|
42
47
|
"@scure/bip39": "2.0.1",
|
|
43
48
|
"commander": "^14",
|
|
44
49
|
"mppx": "0.8.15",
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: build-dvm
|
|
3
|
+
description: Build a first DVM with dvmkit. Use when asked to turn an idea into a paid HTTPS capability, scaffold or hand-write a DVM, implement a `configureDVM` handler, run it locally, test it with Cashu FakeWallet funds, or prepare it for deployment. Do not use for changing the dvmkit platform, SDK internals, or a caller wallet unrelated to testing a DVM.
|
|
4
|
+
compatibility: Node.js 22+, npm, the invited `@dvmkit/dvmctl` release, and Docker for the local paid Cashu test. The optional paid test also uses `@dvmkit/dvm-cli@0.2.0` with isolated builder and caller state.
|
|
5
|
+
allowed-tools: Bash Read Write Edit Glob Grep
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Build a DVM
|
|
9
|
+
|
|
10
|
+
Use this process for a first build. Stop after each approval point; do not deploy, connect a wallet, or spend real funds until the builder explicitly asks.
|
|
11
|
+
|
|
12
|
+
## 1. Install the CLI and this skill
|
|
13
|
+
|
|
14
|
+
Use the exact `@dvmkit/dvmctl` version named in the builder's invitation. Never use a floating tag. Install the CLI, then install the complete skill directory so its references remain available.
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install -g @dvmkit/dvmctl@<invited-version>
|
|
18
|
+
npm install --ignore-scripts --prefix "$HOME/.dvmkit" @dvmkit/dvmctl@<invited-version>
|
|
19
|
+
skill_dir="$(dirname "$(NODE_PATH="$HOME/.dvmkit/node_modules" node -p "require.resolve('@dvmkit/dvmctl/skill')")")"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
<!-- agent-mirror:verbatim start -->
|
|
23
|
+
For Claude Code, install the complete directory globally:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
mkdir -p "$HOME/.claude/skills/build-dvm"
|
|
27
|
+
cp -R "$skill_dir"/. "$HOME/.claude/skills/build-dvm/"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
For Codex in the current project, install the complete directory locally:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
mkdir -p .agents/skills/build-dvm
|
|
34
|
+
cp -R "$skill_dir"/. .agents/skills/build-dvm/
|
|
35
|
+
```
|
|
36
|
+
<!-- agent-mirror:verbatim end -->
|
|
37
|
+
|
|
38
|
+
For another harness, copy the same complete directory to that harness's project-skill location. Confirm `dvmctl --version` reports the invited version before continuing.
|
|
39
|
+
|
|
40
|
+
Give the builder this canonical prompt after installation:
|
|
41
|
+
|
|
42
|
+
> Help me build a DVM with dvmkit. Read the installed `build-dvm` skill. Interview me about one capability, its input, output, example, fiat price, and name. Write a short brief and wait for my approval. Then build and test it locally, including the optional Cashu FakeWallet paid-call path with isolated caller state. Do not deploy or use real funds without my approval.
|
|
43
|
+
|
|
44
|
+
## 2. Shape one capability
|
|
45
|
+
|
|
46
|
+
Ask only for the decisions that change the first version: input, result, one example, name, and a fiat price. Prefer one capability and one typed object input. Write this brief and get approval before editing:
|
|
47
|
+
|
|
48
|
+
```text
|
|
49
|
+
Name:
|
|
50
|
+
Capability:
|
|
51
|
+
Input: example JSON and validation rules
|
|
52
|
+
Result: text or named artifact
|
|
53
|
+
Price: $0.01 (or free)
|
|
54
|
+
Paid-test cap: separately approved before test money is spent
|
|
55
|
+
Example: input → result
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The approved DVM price and the approved paid-test cap are different decisions. A $0.01 DVM can use a $0.02 per-test cap to allow whole-satoshi rounding, but ask for that cap if it is not already approved. Never raise a cap automatically. Do not choose a payment rail or cloud configuration during this interview. A local Cashu mint is test infrastructure, not the builder's production payment choice.
|
|
59
|
+
|
|
60
|
+
## 3. Implement and test the free path
|
|
61
|
+
|
|
62
|
+
Scaffold when it helps; a hand-written Node 22 project with `@dvmkit/sdk` is equally supported.
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
dvmctl create my-dvm
|
|
66
|
+
cd my-dvm
|
|
67
|
+
npm install
|
|
68
|
+
npm test
|
|
69
|
+
dvmctl dev handler.ts
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Keep `handler.ts` small: use `configureDVM`, a Zod `input` schema, a USD `price` string when paid, and `ctx.complete()` with a useful summary. Put provider keys in `.env`, never source code. Add a unit test using `createTestContext` before calling external services. Use the `/_dev` URL printed by `dvmctl dev` to show the builder the free result.
|
|
73
|
+
|
|
74
|
+
Read [the SDK reference](references/sdk-reference.md) for descriptor shapes, job-context methods, streaming, dynamic quotes, auth, persistence, custom routes, and deployment. Read [the Cashu test reference](references/local-cashu-test.md) before a paid test.
|
|
75
|
+
|
|
76
|
+
## 4. Exercise a paid local call with test money
|
|
77
|
+
|
|
78
|
+
This is the only first-build paid path. It uses a local Cashu FakeWallet mint, which auto-settles test invoices and has no real value. Before starting, confirm a separate per-test dollar cap; approving the DVM's price alone is not payment approval. Then follow the complete [Local Cashu paid test](references/local-cashu-test.md). It isolates `DVMKIT_CONFIG_DIR`, `DVMCTL_CONFIG_DIR`, and `DVM_CONFIG_DIR`; uses self-hosted `dvmctl serve --dvm` with Postgres; and proves Cashu advertisement, an unpaid 402, the paid result, receipt, and balance decrease.
|
|
79
|
+
|
|
80
|
+
## 5. Decide before deployment
|
|
81
|
+
|
|
82
|
+
After the local result and paid test work, show the approved brief, test result, and the exact files changed. Ask whether to prepare deployment. Only then follow the invitation's account and deployment instructions. Production rails, payout configuration, wallet connections, and real funds are separate decisions.
|
|
83
|
+
|
|
84
|
+
## Reference routing
|
|
85
|
+
|
|
86
|
+
- [Configure and job context](references/configure-context.md): descriptor shapes, context methods, artifacts, cancellation, and costs.
|
|
87
|
+
- [Pricing and credit](references/pricing-credit.md): fiat pricing, quotes, and prepaid credit.
|
|
88
|
+
- [Patterns and auth](references/patterns-auth.md): multi-turn handlers, signed requests, refunds, receipts, and custom routes.
|
|
89
|
+
- [Testing](references/testing.md): schemas, unit contexts, and live-provider boundaries.
|
|
90
|
+
- [Running and deployment](references/running-deployment.md): dev server, host options, persistence, environment, and deployment.
|
|
91
|
+
- [Operating and feedback](references/operating-feedback.md): deployed-DVM operations, revenue, and caller feedback.
|
|
92
|
+
- [Local Cashu paid test](references/local-cashu-test.md): prerequisites, expected behavior, and failures.
|
|
93
|
+
- [Rail limits](references/payment-rails.md): what can and cannot be tested locally today.
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
## Quickstart — scaffold with `dvmctl create`
|
|
2
|
+
|
|
3
|
+
```bash
|
|
4
|
+
dvmctl create my-dvm # scaffolds ./my-dvm/
|
|
5
|
+
cd my-dvm
|
|
6
|
+
npm install
|
|
7
|
+
npm test # the scaffold ships a passing test
|
|
8
|
+
dvmctl dev handler.ts # http://localhost:3000, hot reload, /_dev test page
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
> Zod ships with the SDK — `import { z } from "@dvmkit/sdk"` for typed `input:` schemas, no extra
|
|
12
|
+
> install. Using the SDK's own zod guarantees a builder schema and the SDK validate against one
|
|
13
|
+
> instance. Skip the schema and `ctx.input` is a raw string you parse yourself.
|
|
14
|
+
|
|
15
|
+
`dvmctl create <name>` writes a standalone npm project:
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
my-dvm/
|
|
19
|
+
handler.ts # your DVM — a configureDVM(...) descriptor (the file you edit)
|
|
20
|
+
serve.ts # production entry point — calls serve(handler, {...})
|
|
21
|
+
handler.test.ts # vitest unit tests
|
|
22
|
+
package.json # depends on @dvmkit/sdk; scripts: test / dev / deploy
|
|
23
|
+
tsconfig.json
|
|
24
|
+
Dockerfile # two-stage Node 22 build (add system deps here)
|
|
25
|
+
.env.example # copy to .env for API keys / secrets
|
|
26
|
+
README.md
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The generated `handler.ts` is a one-capability echo stub. Replace its `onJob` body with your
|
|
30
|
+
logic; everything below is how to grow it.
|
|
31
|
+
|
|
32
|
+
> **Self-hosting instead of deploying to the platform?** Run `dvmctl init <handle> --self-hosted`
|
|
33
|
+
> once to generate a stable `canonical_dvm_id` and persist it to `~/.dvmkit/builder.json` (a
|
|
34
|
+
> platform deploy assigns this ID for you at deploy time). You then `serve(dvm)` on your own
|
|
35
|
+
> infrastructure.
|
|
36
|
+
|
|
37
|
+
**Imports** (the SDK ships three entry points):
|
|
38
|
+
|
|
39
|
+
```typescript
|
|
40
|
+
import { configureDVM } from "@dvmkit/sdk"; // descriptor + types
|
|
41
|
+
import { serve, createDVMHost } from "@dvmkit/sdk/server"; // hosting
|
|
42
|
+
import { secp256k1Auth } from "@dvmkit/sdk/server"; // descriptor-level signed-request auth
|
|
43
|
+
import { createTestContext } from "@dvmkit/sdk/testing"; // unit tests
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Quick example — single-capability DVM
|
|
49
|
+
|
|
50
|
+
`handler.ts`:
|
|
51
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
import { configureDVM, z } from "@dvmkit/sdk";
|
|
54
|
+
|
|
55
|
+
const inputSchema = z.object({ text: z.string() });
|
|
56
|
+
|
|
57
|
+
export function createUppercaseDVM() {
|
|
58
|
+
return configureDVM({
|
|
59
|
+
name: "uppercase",
|
|
60
|
+
description: "Convert text to uppercase.",
|
|
61
|
+
capability: "uppercase",
|
|
62
|
+
tags: ["text", "uppercase"],
|
|
63
|
+
input: inputSchema,
|
|
64
|
+
price: "$0.01",
|
|
65
|
+
|
|
66
|
+
async onJob(ctx) {
|
|
67
|
+
ctx.complete(ctx.input.text.toUpperCase());
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export default createUppercaseDVM();
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`serve.ts`:
|
|
76
|
+
|
|
77
|
+
```typescript
|
|
78
|
+
import { serve } from "@dvmkit/sdk/server";
|
|
79
|
+
import dvm from "./handler";
|
|
80
|
+
|
|
81
|
+
await serve(dvm);
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`serve(dvm)` reads all wiring (port, `DATABASE_URL`, `DVMKIT_CASHU_MINTS`, Tempo, x402, FX,
|
|
85
|
+
platform reporter) from env. Pass `opts` to override; reach for `createDVMHost` when you need
|
|
86
|
+
custom HTTP routes or to mount several DVMs on one host.
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## configureDVM() reference
|
|
91
|
+
|
|
92
|
+
```typescript
|
|
93
|
+
configureDVM({
|
|
94
|
+
// --- DVM-level (always at top level) ---
|
|
95
|
+
name: string,
|
|
96
|
+
description?: string,
|
|
97
|
+
tag?: string,
|
|
98
|
+
tags?: string[],
|
|
99
|
+
idleTimeout?: number, // seconds before auto-cancel (default: 3600)
|
|
100
|
+
|
|
101
|
+
auth?: DVMAuthScheme, // descriptor-level signed-request auth (secp256k1Auth({...}))
|
|
102
|
+
|
|
103
|
+
onBoot?(env): void | Promise<void>, // run once at server start
|
|
104
|
+
onShutdown?(): void | Promise<void>, // run once at server stop
|
|
105
|
+
routes?(app: Hono): void | Promise<void>, // DVM-scoped non-protocol routes
|
|
106
|
+
|
|
107
|
+
paymentMethods?: PaymentMethod[], // ["cashu" | "x402" | "tempo"]; auto-derived from configured rails
|
|
108
|
+
|
|
109
|
+
// --- Pick one of the two shapes below ---
|
|
110
|
+
|
|
111
|
+
// Flat single-capability shape (most DVMs):
|
|
112
|
+
capability?: string, // e.g. "synthesise" — names this one capability
|
|
113
|
+
// (auto-slugified from `name` if omitted)
|
|
114
|
+
input?: ZodSchema, // ctx.input is typed
|
|
115
|
+
state?: object, // ctx.state defaults, typed
|
|
116
|
+
price?: PriceValue, // "$0.05" — USD literal, the only form
|
|
117
|
+
onQuote?: QuoteConfig, // dynamic pricing
|
|
118
|
+
onJob(ctx): void | Promise<void>, // required
|
|
119
|
+
onResponse?(ctx, content): void | Promise<void>,
|
|
120
|
+
onPayment?(ctx, content): void | Promise<void>,
|
|
121
|
+
onApproval?(ctx, content): void | Promise<void>,
|
|
122
|
+
onCancel?(ctx, content): void | Promise<void>,
|
|
123
|
+
onMessage?(ctx, msg): void | Promise<void>,
|
|
124
|
+
|
|
125
|
+
// OR multi-capability block (mutually exclusive with flat shape):
|
|
126
|
+
capabilities?: Record<string, CapabilityConfig>,
|
|
127
|
+
});
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`configureDVM` returns a frozen `DVMDescriptor`. The flat shape desugars internally to a
|
|
131
|
+
one-entry `capabilities` map — `dvm.capabilities[<name>].onJob` works in either form (tests rely
|
|
132
|
+
on this).
|
|
133
|
+
|
|
134
|
+
### Per-capability fields (multi-capability shape)
|
|
135
|
+
|
|
136
|
+
Each entry in `capabilities` accepts: `description`, `input`, `state`, `price`, `onQuote`,
|
|
137
|
+
`onJob` (required), and the same message hooks (`onResponse`, `onPayment`, `onApproval`,
|
|
138
|
+
`onCancel`, `onMessage`). DVM-level concerns (`name`, `description`, `tags`, `auth`,
|
|
139
|
+
`paymentMethods`, `routes`, lifecycle hooks) stay on the parent and are shared across every
|
|
140
|
+
capability.
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## SDKJobContext reference
|
|
145
|
+
|
|
146
|
+
### Identity (readonly)
|
|
147
|
+
|
|
148
|
+
| Property | Type | Description |
|
|
149
|
+
| ------------- | ----------------------------------- | --------------------------------------------------------------------------------------------------------- |
|
|
150
|
+
| `jobId` | `string` | Server-assigned job ID |
|
|
151
|
+
| `tags` | `string[]` | Tags from the DVM descriptor |
|
|
152
|
+
| `input` | `Input` | Job input (raw string, or parsed object when `input` schema is set) |
|
|
153
|
+
| `params` | `Record<string, string>` | Key-value parameters |
|
|
154
|
+
| `requesterId` | `string` | Opaque requester ID (derived from auth or "anonymous") |
|
|
155
|
+
| `paidMsats` | `number` | Total msats paid so far |
|
|
156
|
+
| `auth` | `{ pubkey, envelope } \| undefined` | Verified caller identity — present only when the descriptor declares `auth: secp256k1Auth(...)` |
|
|
157
|
+
|
|
158
|
+
### Messaging
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
ctx.text(message: string): void
|
|
162
|
+
ctx.artifact(content: ArtifactContent): void
|
|
163
|
+
ctx.sendMessage(type: MessageType, content: object): void
|
|
164
|
+
ctx.progress(percentComplete: number, phase?: string, hint?: string): void
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
`ArtifactContent`: `{ data: string, mime_type: string, name?: string, encoding?: "base64" }`
|
|
168
|
+
|
|
169
|
+
### Flow control
|
|
170
|
+
|
|
171
|
+
```typescript
|
|
172
|
+
ctx.prompt(id: string, text: string, opts?): Promise<ResponseContent>
|
|
173
|
+
ctx.requestPayment(
|
|
174
|
+
amount: number | { amount: number; currency: "usd" },
|
|
175
|
+
reason: string,
|
|
176
|
+
opts?: SDKPaymentRequestOpts,
|
|
177
|
+
): Promise<PaymentContent>
|
|
178
|
+
ctx.working(estimateSeconds?: number, hint?: string): void
|
|
179
|
+
ctx.complete(summary: string): void // one-liner; artifacts carry the payload
|
|
180
|
+
ctx.fail(error: string, opts?: { refund?: boolean }): void
|
|
181
|
+
ctx.cost({ amount, currency }): void // your serving cost, in major units
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
`requestPayment` takes msats (`number`) or a fiat envelope (`{ amount, currency: "usd" }`) — the
|
|
185
|
+
SDK converts to sats at send-time. The legacy `"$0.05"` string form was removed. On counter-based
|
|
186
|
+
auto-credit, `requestPayment` resolves synchronously when the upfront pool already
|
|
187
|
+
covers the amount and only suspends when the pool is exhausted. The caller sees whatever currency
|
|
188
|
+
you name; the ledger records the charge in the DVM's own `currency`, converted at the moment the
|
|
189
|
+
charge is made, so the anti-double-charge ceiling on the job's credit stays exact.
|
|
190
|
+
|
|
191
|
+
Pass `{ refund: true }` to `ctx.fail` to return Cashu proofs to the caller — typical when the
|
|
192
|
+
failure isn't operator fault (a network glitch, an upstream provider error). Don't refund on
|
|
193
|
+
operator-fault failures (misconfiguration, your own upload failing). See [Refund-on-failure
|
|
194
|
+
policy](patterns-auth.md#refund-on-failure-policy).
|
|
195
|
+
|
|
196
|
+
### Cancellation — `ctx.signal`
|
|
197
|
+
|
|
198
|
+
```typescript
|
|
199
|
+
ctx.signal: AbortSignal // aborts on caller cancel, idle timeout, stale sweep, supersession
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Cancel is **terminal**: after it, `ctx.complete` / `ctx.fail` / `ctx.working` and the message
|
|
203
|
+
emitters are no-ops that log a warning (a cancelled job can never come back as `completed`, and no
|
|
204
|
+
revenue is booked on it), and `ctx.prompt` / `ctx.requestPayment` reject instead of hanging. This
|
|
205
|
+
holds across machines — a terminal status is sticky in the job store and the runtime re-reads it
|
|
206
|
+
before persisting, so a handler on a machine that never saw the cancel still can't overwrite it.
|
|
207
|
+
|
|
208
|
+
### Report serving costs
|
|
209
|
+
|
|
210
|
+
Use `ctx.cost({ amount, currency })` immediately after a paid provider call or other builder-side
|
|
211
|
+
expense. Amounts are major units, repeated calls add, and only the job total is rounded to
|
|
212
|
+
millionths. Every declaration in one job must use the same lowercase ISO currency; a mixed-currency
|
|
213
|
+
job reports no total rather than guessing an exchange rate.
|
|
214
|
+
|
|
215
|
+
No call means “unknown”; `{ amount: 0, currency: "usd" }` explicitly means free. Cost declarations
|
|
216
|
+
inside `ctx.step()` are cached with the result and replay exactly once when the body is skipped, so
|
|
217
|
+
declare the cost where it is incurred. Paid completions attach it to revenue, while free completions,
|
|
218
|
+
failures, and cancellations use the separate cost-only report and create no revenue row.
|
|
219
|
+
|
|
220
|
+
`ctx.fetch` already carries the signal — every request through it aborts on cancel, merged with any
|
|
221
|
+
per-request signal you pass. Thread `ctx.signal` into everything else that can be torn down
|
|
222
|
+
(provider SDK `signal` options, spawned processes, your own sleeps): that is where provider spend
|
|
223
|
+
actually stops.
|
|
224
|
+
|
|
225
|
+
```typescript
|
|
226
|
+
async onJob(ctx) {
|
|
227
|
+
const audio = await client.tts.generate(text, { signal: ctx.signal });
|
|
228
|
+
|
|
229
|
+
for (const chunk of chunks) {
|
|
230
|
+
if (ctx.signal.aborted) return; // already terminal — don't call ctx.fail()
|
|
231
|
+
await process(chunk);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
ctx.complete("Done");
|
|
235
|
+
}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Return early on abort; the runtime has written the terminal status already. `onCancel` still fires if
|
|
239
|
+
you declare one — use it for cleanup (release a lock, delete a staged upload); the signal is raised
|
|
240
|
+
before the hook runs, so a slow cleanup can't hold the abort back.
|
|
241
|
+
|
|
242
|
+
**`onCancel` is cleanup only — it can't emit.** The job is terminal before the hook runs, so a
|
|
243
|
+
`ctx.text()` / `ctx.artifact()` / `ctx.complete()` inside it is ignored with a
|
|
244
|
+
`ctx.<op>() ignored — job already cancelled` warning. Emit partial results as you go, not on the way
|
|
245
|
+
out.
|
|
246
|
+
|
|
247
|
+
**Multi-machine DVMs are covered.** On a DVM with `max_machines > 1`, a cancel that lands on a machine
|
|
248
|
+
other than the one running the handler is committed to the store there; the machine running the
|
|
249
|
+
handler subscribes to its own jobs' notifications, so it picks the cancel up within a notification
|
|
250
|
+
round-trip — `ctx.signal` fires, `onCancel` runs on the machine holding the resources, the cancel
|
|
251
|
+
lands in the job's message log, and the handler's late writes are rejected. Same behaviour whichever
|
|
252
|
+
machine the cancel hits.
|
|
253
|
+
|
|
254
|
+
**Isolate tier.** An isolate-hosted handler runs in its own Machine, so it learns of a cancel by
|
|
255
|
+
polling its job's durable status on the platform (default every 2 s, `DVMKIT_ISOLATE_CANCEL_POLL_MS`).
|
|
256
|
+
The contract above is identical — `ctx.signal` aborts, `ctx.fetch` tears down, emitters no-op, yields
|
|
257
|
+
reject — just one poll tick behind the cancel.
|
|
258
|
+
|
|
259
|
+
### Platform services
|
|
260
|
+
|
|
261
|
+
```typescript
|
|
262
|
+
ctx.state: State // typed from defaults; auto-persisted on every yield
|
|
263
|
+
ctx.store: KVStore // cross-job async KV storage
|
|
264
|
+
ctx.fetch: typeof fetch // instrumented fetch — dvm.fetch spans + auto-aborts on cancel
|
|
265
|
+
ctx.env: Record<string, string> // environment variables (read at job time, not boot)
|
|
266
|
+
ctx.log: Logger // structured logging (.debug, .info, .warn, .error)
|
|
267
|
+
ctx.step<T>(id: string, fn: () => Promise<T>): Promise<T> // cached on yield
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
Prefer `ctx.fetch` over the global `fetch` for outbound HTTP: calls show up in traces, and they
|
|
271
|
+
abort when the caller cancels the job.
|
|
272
|
+
|
|
273
|
+
---
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Local Cashu paid test
|
|
2
|
+
|
|
3
|
+
Use this only after the builder has approved both the DVM price and a separate dollar cap for this one test. A $0.01 DVM commonly needs a $0.02 cap because Cashu payments settle in whole satoshis. Do not infer approval or raise the cap after a refusal. This one shell session owns its temporary state, server process, Postgres container, and (unless `CASHU_MINT_URL` is supplied) FakeWallet container. It never stops an externally supplied mint.
|
|
4
|
+
|
|
5
|
+
The handler must have the approved static USD price and produce a useful completion summary or `ctx.artifact`. Replace the variables with the approved brief. For a named artifact, specify its name and MIME type; for JSON, also specify expected keys from the approved example. Leave the name and MIME values empty only when the completion summary is the whole result. Use `--data` for an object input; a deliberately primitive string input uses `-i` in the final request instead.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
set -euo pipefail
|
|
9
|
+
|
|
10
|
+
DVM_HANDLE="my-dvm"
|
|
11
|
+
DVM_CAPABILITY="uppercase"
|
|
12
|
+
DVM_INPUT='{"text":"your approved example"}'
|
|
13
|
+
DVM_PAYMENT_CAP='$0.02' # explicit approval for this test, separate from the $0.01 DVM price
|
|
14
|
+
DVM_ARTIFACT_NAME="" # e.g. result.json; required when the handler emits an artifact
|
|
15
|
+
DVM_ARTIFACT_MIME="" # e.g. application/json
|
|
16
|
+
DVM_ARTIFACT_JSON_KEYS="" # comma-separated required keys, e.g. answer,language
|
|
17
|
+
|
|
18
|
+
test_root="$(mktemp -d "${TMPDIR:-/tmp}/dvmkit-cashu-paid.XXXXXX")"
|
|
19
|
+
export DVMKIT_CONFIG_DIR="$test_root/builder"
|
|
20
|
+
export DVMCTL_CONFIG_DIR="$test_root/dvmctl"
|
|
21
|
+
export DVM_CONFIG_DIR="$test_root/caller"
|
|
22
|
+
mkdir -p "$DVMKIT_CONFIG_DIR" "$DVMCTL_CONFIG_DIR" "$DVM_CONFIG_DIR"
|
|
23
|
+
|
|
24
|
+
postgres_name="dvmkit-cashu-postgres-$$"
|
|
25
|
+
postgres_id=""
|
|
26
|
+
mint_id=""
|
|
27
|
+
dvm_pid=""
|
|
28
|
+
cleanup() {
|
|
29
|
+
status=$?
|
|
30
|
+
trap - EXIT INT TERM
|
|
31
|
+
[ -z "$dvm_pid" ] || kill "$dvm_pid" 2>/dev/null || true
|
|
32
|
+
[ -z "$mint_id" ] || docker rm -f "$mint_id" >/dev/null 2>&1 || true
|
|
33
|
+
[ -z "$postgres_id" ] || docker rm -f "$postgres_id" >/dev/null 2>&1 || true
|
|
34
|
+
rm -rf "$test_root"
|
|
35
|
+
exit "$status"
|
|
36
|
+
}
|
|
37
|
+
trap cleanup EXIT
|
|
38
|
+
trap 'exit 130' INT TERM
|
|
39
|
+
|
|
40
|
+
docker run --rm -d --name "$postgres_name" -e POSTGRES_PASSWORD=paid-test \
|
|
41
|
+
-p 127.0.0.1::5432 postgres:17-alpine >/dev/null
|
|
42
|
+
postgres_id="$(docker container inspect --format '{{.Id}}' "$postgres_name")"
|
|
43
|
+
for _ in $(seq 1 30); do docker exec "$postgres_id" pg_isready -U postgres -d postgres >/dev/null 2>&1 && break; sleep 1; done
|
|
44
|
+
docker exec "$postgres_id" pg_isready -U postgres -d postgres >/dev/null
|
|
45
|
+
postgres_port="$(docker port "$postgres_id" 5432/tcp | sed -E 's/.*:([0-9]+)$/\1/')"
|
|
46
|
+
export DATABASE_URL="postgresql://postgres:paid-test@127.0.0.1:${postgres_port}/postgres"
|
|
47
|
+
|
|
48
|
+
if [ -n "${CASHU_MINT_URL:-}" ]; then
|
|
49
|
+
cashu_mint_url="$CASHU_MINT_URL"
|
|
50
|
+
else
|
|
51
|
+
mint_name="dvmkit-cashu-mint-$$"
|
|
52
|
+
docker run --rm -d --name "$mint_name" -p 127.0.0.1::3338 \
|
|
53
|
+
-e MINT_LISTEN_HOST=0.0.0.0 -e MINT_LISTEN_PORT=3338 \
|
|
54
|
+
-e MINT_BACKEND_BOLT11_SAT=FakeWallet \
|
|
55
|
+
-e MINT_PRIVATE_KEY=test-key-do-not-use-in-production \
|
|
56
|
+
cashubtc/nutshell:0.19.2 poetry run mint >/dev/null
|
|
57
|
+
mint_id="$(docker container inspect --format '{{.Id}}' "$mint_name")"
|
|
58
|
+
cashu_mint_url="http://127.0.0.1:$(docker port "$mint_id" 3338/tcp | sed -E 's/.*:([0-9]+)$/\1/')"
|
|
59
|
+
fi
|
|
60
|
+
for _ in $(seq 1 30); do curl -fsS "$cashu_mint_url/v1/info" >"$test_root/mint-info.json" && break; sleep 1; done
|
|
61
|
+
test -s "$test_root/mint-info.json"
|
|
62
|
+
|
|
63
|
+
dvmctl lock create
|
|
64
|
+
dvmctl identity create
|
|
65
|
+
dvmctl init "$DVM_HANDLE" --self-hosted
|
|
66
|
+
dvm_port="$(node -e 'require("node:net").createServer().listen(0, "127.0.0.1", function () { console.log(this.address().port); this.close(); })')"
|
|
67
|
+
DVMKIT_CASHU_MINTS="$cashu_mint_url" dvmctl serve handler.ts --dvm "$DVM_HANDLE" --port "$dvm_port" >"$test_root/dvm.log" 2>&1 &
|
|
68
|
+
dvm_pid=$!
|
|
69
|
+
dvm_endpoint="http://127.0.0.1:$dvm_port"
|
|
70
|
+
for _ in $(seq 1 30); do curl -fsS "$dvm_endpoint/v1/info" >"$test_root/info.json" && break; sleep 1; done
|
|
71
|
+
test -s "$test_root/info.json"
|
|
72
|
+
node -e 'const i=require(process.argv[1]); if (!i.payment?.methods?.includes("cashu")) throw new Error("/v1/info does not advertise cashu")' "$test_root/info.json"
|
|
73
|
+
|
|
74
|
+
node -e 'process.stdout.write(JSON.stringify({capability:process.argv[1],data:JSON.parse(process.argv[2])}))' "$DVM_CAPABILITY" "$DVM_INPUT" >"$test_root/unpaid-request.json"
|
|
75
|
+
unpaid_status="$(curl -sS -o "$test_root/unpaid.json" -w '%{http_code}' -H 'content-type: application/json' --data-binary @"$test_root/unpaid-request.json" "$dvm_endpoint/v1/job")"
|
|
76
|
+
test "$unpaid_status" = 402
|
|
77
|
+
|
|
78
|
+
npm install -g @dvmkit/dvm-cli@0.2.0
|
|
79
|
+
dvm init
|
|
80
|
+
dvm wallet init
|
|
81
|
+
dvm wallet mint-add "$cashu_mint_url"
|
|
82
|
+
dvm wallet fund '$1' --mint "$cashu_mint_url" --test-skip-lightning
|
|
83
|
+
dvm wallet show >"$test_root/balance-before.json"
|
|
84
|
+
dvm request --endpoint "$dvm_endpoint" --data "$DVM_INPUT" --rail cashu --mint "$cashu_mint_url" \
|
|
85
|
+
--budget "$DVM_PAYMENT_CAP" --auto-pay-below "$DVM_PAYMENT_CAP" \
|
|
86
|
+
--max-increment "$DVM_PAYMENT_CAP" --max-payments 1 </dev/null >"$test_root/paid.json"
|
|
87
|
+
dvm wallet show >"$test_root/balance-after.json"
|
|
88
|
+
node -e '
|
|
89
|
+
const before=require(process.argv[1]).total_sats;
|
|
90
|
+
const after=require(process.argv[2]).total_sats;
|
|
91
|
+
const paid=require(process.argv[3]);
|
|
92
|
+
if (!(typeof before === "number" && typeof after === "number" && after < before)) throw new Error("Cashu balance did not decrease");
|
|
93
|
+
if (!paid.jobId || paid.next_action !== null || paid.receipt_verified !== "verified") throw new Error("paid receipt is not verified");
|
|
94
|
+
if (paid.receipt?.outcome !== "completed" || paid.receipt?.paid?.rail !== "cashu" || !(paid.receipt.paid.msats > 0)) throw new Error("receipt does not prove a positive Cashu payment for a completed job");
|
|
95
|
+
if (!(paid.summary || paid.has_artifacts)) throw new Error("paid result has no completion output");
|
|
96
|
+
' "$test_root/balance-before.json" "$test_root/balance-after.json" "$test_root/paid.json"
|
|
97
|
+
if [ -n "$DVM_ARTIFACT_NAME" ]; then
|
|
98
|
+
node -e 'if (!require(process.argv[1]).has_artifacts) throw new Error("expected artifact is missing")' "$test_root/paid.json"
|
|
99
|
+
test -n "$DVM_ARTIFACT_NAME" && test -n "$DVM_ARTIFACT_MIME"
|
|
100
|
+
case "$DVM_ARTIFACT_MIME" in application/json*) test -n "$DVM_ARTIFACT_JSON_KEYS" ;; esac
|
|
101
|
+
job_id="$(node -p 'require(process.argv[1]).jobId' "$test_root/paid.json")"
|
|
102
|
+
dvm messages "$job_id" --no-stream >"$test_root/artifacts.json"
|
|
103
|
+
cat "$test_root/artifacts.json"
|
|
104
|
+
node -e '
|
|
105
|
+
const m=require(process.argv[1]).messages.filter((m) => m.type === "artifact");
|
|
106
|
+
if (!m.length) throw new Error("artifact content is missing");
|
|
107
|
+
const [name, mime, keys]=process.argv.slice(2);
|
|
108
|
+
Promise.all(m.map(async ({content}) => {
|
|
109
|
+
let bytes;
|
|
110
|
+
if (typeof content?.data === "string" && content.data.length > 0) {
|
|
111
|
+
bytes=Buffer.from(content.data, content.encoding === "base64" ? "base64" : "utf8");
|
|
112
|
+
} else if (typeof content?.url === "string") {
|
|
113
|
+
const response=await fetch(content.url);
|
|
114
|
+
bytes=Buffer.from(await response.arrayBuffer());
|
|
115
|
+
if (!response.ok || bytes.byteLength === 0) throw new Error("artifact URL did not return content");
|
|
116
|
+
} else {
|
|
117
|
+
throw new Error("artifact content is missing");
|
|
118
|
+
}
|
|
119
|
+
const name=typeof content.name === "string" ? content.name :
|
|
120
|
+
content.name?._source === "provider" && typeof content.name.text === "string" ? content.name.text : undefined;
|
|
121
|
+
return {name, mime_type: content.mime_type, bytes_base64: bytes.toString("base64"), text: bytes.toString("utf8")};
|
|
122
|
+
})).then((artifacts) => {
|
|
123
|
+
const artifact=artifacts.find((a) => a.name === name && a.mime_type === mime);
|
|
124
|
+
if (!artifact) throw new Error("expected artifact name or MIME type is missing");
|
|
125
|
+
if (keys) { const value=JSON.parse(artifact.text); for (const key of keys.split(",")) if (!(key in value)) throw new Error(`artifact JSON is missing ${key}`); }
|
|
126
|
+
console.log(JSON.stringify({artifact}));
|
|
127
|
+
});' "$test_root/artifacts.json" "$DVM_ARTIFACT_NAME" "$DVM_ARTIFACT_MIME" "$DVM_ARTIFACT_JSON_KEYS" >"$test_root/artifact-content.json"
|
|
128
|
+
cat "$test_root/artifact-content.json"
|
|
129
|
+
fi
|
|
130
|
+
cat "$test_root/paid.json"
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
The final JSON is the evidence artifact: it must show a terminal paid job and verified Cashu receipt, while the assertions prove Cashu was advertised, the same unauthenticated request returned 402, and the isolated balance fell. When the approved result is a named artifact, the recipe fetches URL content or decodes inline data, then checks its name, MIME type, and expected JSON keys. Add a stricter assertion for any deeper approved schema before counting the test as passed; the generic recipe cannot infer it. The trap deletes only the state and processes this session created. The FakeWallet key and funds are test-only; do not point this recipe at a production mint.
|
|
134
|
+
|
|
135
|
+
## Failure routing
|
|
136
|
+
|
|
137
|
+
- `/v1/info` lacks `cashu`: do not retry with `dvmctl dev`. Confirm the three disposable configuration directories are exported, then rerun `lock create`, `identity create`, `init --self-hosted`, and `serve --dvm` in this session.
|
|
138
|
+
- The unpaid request is not 402: confirm the handler's approved price and capability/input values before spending test money.
|
|
139
|
+
- `fx_rate_unavailable` or `rate_unavailable`: the public BTC/USD rate provider could not answer. Wait for recovery before retrying, keeping the same approved dollar cap. A DVM-side test rate source does not configure the caller’s rate provider.
|
|
140
|
+
- The paid request exceeds the cap: stop and ask for a new explicit dollar cap. Do not increase any flag automatically.
|
|
141
|
+
- The mint cannot be reached: start the owned FakeWallet or correct `CASHU_MINT_URL`; an externally supplied mint remains outside this session's cleanup.
|