@bnbagent/studio-cli 0.0.10 → 0.0.11-alpha.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.
Files changed (36) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +2 -2
  3. package/dist/_agentcoreName-DZDWEYD3.js +0 -0
  4. package/dist/_twak-4XF4H5PL.js +0 -0
  5. package/dist/bag.js +317 -124
  6. package/dist/chunk-RO726HJG.js +0 -0
  7. package/dist/{chunk-YFEM4564.js → chunk-TTPOH453.js} +79 -37
  8. package/dist/chunk-U7IDQ3K5.js +0 -0
  9. package/dist/{deployCli-NJFCWBSF.js → deployCli-K55GXDVO.js} +1 -1
  10. package/package.json +11 -12
  11. package/recipes/runtimes/agentcore/code/{{PKG}}/Dockerfile.tmpl +20 -21
  12. package/recipes/runtimes/agentcore/code/{{PKG}}/executor.ts.tmpl +36 -0
  13. package/recipes/runtimes/agentcore/code/{{PKG}}/unifiedMain.ts.tmpl +620 -0
  14. package/recipes/runtimes/agentcore/recipe.toml +3 -3
  15. package/recipes/runtimes/azure-foundry/code/{{PKG}}/Dockerfile.tmpl +25 -23
  16. package/recipes/runtimes/azure-foundry/code/{{PKG}}/agentCard.ts.tmpl +16 -12
  17. package/recipes/runtimes/azure-foundry/code/{{PKG}}/executor.ts.tmpl +72 -393
  18. package/recipes/runtimes/azure-foundry/code/{{PKG}}/mcpMain.ts.tmpl +160 -43
  19. package/recipes/runtimes/azure-foundry/code/{{PKG}}/sellerCore.ts.tmpl +504 -0
  20. package/recipes/runtimes/azure-foundry/code/{{PKG}}/unifiedMain.ts.tmpl +620 -0
  21. package/recipes/runtimes/azure-foundry/recipe.toml +19 -11
  22. package/recipes/x402-buyer/code/{{PKG}}/x402Buyer.ts.tmpl +6 -4
  23. package/skills/bnbagent-studio.md +2 -2
  24. package/skills/references/bnbagent-studio-adding-to-project.md +1 -1
  25. package/skills/references/bnbagent-studio-buying-from-bazaar.md +1 -1
  26. package/skills/references/bnbagent-studio-operating.md +4 -4
  27. package/skills/references/bnbagent-studio-scaffolding-agent.md +4 -4
  28. package/skills/references/bnbagent-studio-selling-via-8183.md +3 -3
  29. package/skills/references/bnbagent-studio-selling-via-b402.md +2 -2
  30. package/skills/references/bnbagent-studio-use-aws-agentcore.md +1 -1
  31. package/skills/references/bnbagent-studio-use-azure-foundry.md +3 -3
  32. package/skills/references/bnbagent-studio-use-bnb-trial.md +1 -1
  33. package/skills/references/bnbagent-studio-wiring-llm-tools.md +3 -3
  34. package/recipes/runtimes/agentcore/code/{{PKG}}/main.ts.tmpl +0 -347
  35. package/recipes/runtimes/azure-foundry/code/{{PKG}}/foundryMain.ts.tmpl +0 -422
  36. package/recipes/runtimes/azure-foundry/code/{{PKG}}/main.ts.tmpl +0 -196
@@ -0,0 +1,504 @@
1
+ /**
2
+ * Seller core — the a2a-free seller logic + background delivery machinery.
3
+ *
4
+ * This is the protocol-neutral heart of the ERC-8183 seller: the two fixed-code
5
+ * operations (`negotiate` → signed quote; `notifyFunded` → verify → ACK →
6
+ * deliver in the background) plus the background-delivery bookkeeping
7
+ * (`isBusy`, the spawn/run/sweep helpers). It imports NOTHING from
8
+ * `@a2a-js/sdk` so it can back any transport — the A2A executor
9
+ * (`executor.ts`) inherits it and wraps it with the a2a wire, and a non-A2A
10
+ * HTTP entrypoint can call it directly without dragging in the a2a sdk.
11
+ *
12
+ * negotiate → `signing.signQuote` (rule-based price clamp + EIP-191 sign)
13
+ * notifyFunded → `signing.verifySignedJob` (fast on-chain gate) → ACK at
14
+ * once, then in the BACKGROUND: LLM work → `signing.submitResult`
15
+ *
16
+ * `notifyFunded` is the buyer's "I funded job X — please deliver" notification.
17
+ * Because the work takes time, it does NOT block the caller: it verifies the
18
+ * funded job synchronously (a couple of eth_calls) to ACK accepted/rejected,
19
+ * then runs the slow LLM work + on-chain `submit` in a background task and
20
+ * returns immediately. The buyer reads the deliverable back from the CHAIN
21
+ * (SUBMITTED / `getDeliverableUrl`) — the chain is the source of truth. While
22
+ * any background delivery is in flight {@link SellerCore.isBusy} reports busy,
23
+ * which the transport feeds to AgentCore's `/ping` as `HEALTHY_BUSY` so the
24
+ * scale-to-zero runtime stays warm until the work lands (within the session
25
+ * max-lifetime).
26
+ *
27
+ * ALL signing is FIXED code in `signing.ts` — NEVER an LLM-callable tool
28
+ * (money is never in the LLM; the LLM only produces the work text, via the
29
+ * `runWork` hook). On each notification the core also opportunistically sweeps
30
+ * OTHER funded jobs assigned to this provider — the buyer-push fallback for
31
+ * jobs whose buyer funded on-chain but never sent `notify_funded` (deduped
32
+ * against in-flight jobs). Negotiate stays sweep-free so quotes are fast. A
33
+ * periodic Lambda poller — which also covers the scale-to-zero cold window
34
+ * when no one is invoking — is the v2 robust path.
35
+ *
36
+ * You own this file — specialise the work hook / dispatch, but keep signing
37
+ * OUT of the LLM tool list.
38
+ */
39
+
40
+ import { ERC8183JobOps } from "@bnbagent/sdk/erc8183";
41
+ import { SubmitPermanentlyUnsupportedError } from "@bnbagent/studio-runtime/erc8183";
42
+ import { getWallet } from "@bnbagent/studio-runtime/wallet";
43
+ import * as defaultSigning from "./signing.js";
44
+
45
+ const log = {
46
+ info: (msg: string) => console.log(`[seller-agent.core] ${msg}`),
47
+ warn: (msg: string) => console.warn(`[seller-agent.core] WARNING ${msg}`),
48
+ error: (msg: string, e?: unknown) =>
49
+ console.error(`[seller-agent.core] ERROR ${msg}`, e ?? ""),
50
+ };
51
+
52
+ /** Read a positive timeout (seconds) from the env, falling back to `dflt`. */
53
+ function envSeconds(name: string, dflt: number): number {
54
+ const v = Number(process.env[name] || dflt);
55
+ return Number.isFinite(v) && v > 0 ? v : dflt;
56
+ }
57
+
58
+ // Background-task ceilings. notifyFunded ACKs immediately and delivers in a
59
+ // BACKGROUND task; AgentCore keeps the scale-to-zero microVM warm
60
+ // (HEALTHY_BUSY) while isBusy() is true. A delivery (LLM text + on-chain
61
+ // submit + IPFS pin) normally finishes in ~1-2 min, so these caps sit far
62
+ // above real work and only fire on a HANG (e.g. an unresponsive RPC) —
63
+ // without them a hung task keeps the VM pinned to its 8h max-lifetime,
64
+ // billing memory the whole time. A timed-out job is treated as TRANSIENT
65
+ // (not dropped): the funded job stays on-chain and a later sweep re-delivers
66
+ // it idempotently. (Read lazily so tests can tune them via the env.)
67
+ const jobDeliveryTimeoutSeconds = () =>
68
+ envSeconds("NOTIFY_DELIVERY_TIMEOUT_SECONDS", 600);
69
+ const sweepTimeoutSeconds = () => envSeconds("NOTIFY_SWEEP_TIMEOUT_SECONDS", 60);
70
+ const preverifyTimeoutSeconds = () =>
71
+ envSeconds("NOTIFY_PREVERIFY_TIMEOUT_SECONDS", 30);
72
+
73
+ /** Rejection raised by {@link withTimeout} when the deadline fires. */
74
+ export class DeliveryTimeoutError extends Error {}
75
+
76
+ /**
77
+ * Race `work` against a deadline, aborting `controller` when it fires.
78
+ *
79
+ * JS cannot hard-cancel an arbitrary promise the way asyncio.wait_for
80
+ * cancels a coroutine: the abort signal stops the LLM call (the AI SDK
81
+ * honours it), and the on-chain layers are idempotent — `verifySignedJob`
82
+ * returns non-OK for an already-SUBMITTED job and `submitResult` re-verifies
83
+ * FUNDED — so an orphaned straggler can never double-deliver.
84
+ */
85
+ async function withTimeout<T>(
86
+ work: Promise<T>,
87
+ seconds: number,
88
+ controller?: AbortController,
89
+ ): Promise<T> {
90
+ let timer: ReturnType<typeof setTimeout> | undefined;
91
+ const deadline = new Promise<never>((_, reject) => {
92
+ timer = setTimeout(() => {
93
+ controller?.abort();
94
+ reject(new DeliveryTimeoutError(`timed out after ${seconds}s`));
95
+ }, seconds * 1000);
96
+ });
97
+ try {
98
+ return await Promise.race([work, deadline]);
99
+ } finally {
100
+ clearTimeout(timer);
101
+ }
102
+ }
103
+
104
+ /**
105
+ * The LLM work hook: produce the deliverable text for a prompt.
106
+ *
107
+ * Built in `main.ts` from the AI SDK (`generateText` + the read-only chain
108
+ * tools); called by verified ERC-8183 delivery and, through the runtime
109
+ * adapter, by x402 only after its commerce gate. `abortSignal` is wired to
110
+ * the delivery timeout so a hung LLM call is actually cancelled.
111
+ */
112
+ export type RunWork = (
113
+ prompt: string,
114
+ opts: { sessionId: string; abortSignal?: AbortSignal },
115
+ ) => Promise<string>;
116
+
117
+ /** The `signing.ts` surface the core drives (injectable for tests). */
118
+ export interface SigningApi {
119
+ listPrice(): bigint;
120
+ clampPrice(proposedWei: bigint): bigint;
121
+ signQuote(
122
+ request: Record<string, unknown>,
123
+ clampedPriceWei: bigint,
124
+ ): Promise<Record<string, unknown>>;
125
+ verifySignedJob(
126
+ jobId: number,
127
+ ): Promise<{ ok: boolean; reason: string; permanent: boolean }>;
128
+ jobSpec(
129
+ jobId: number,
130
+ ): Promise<{ task: string; terms: Record<string, unknown> } | null>;
131
+ submitResult(
132
+ jobId: number,
133
+ responseContent: string,
134
+ metadata?: Record<string, unknown> | null,
135
+ ): Promise<{ submitTx: string; deliverableUrl: string | null }>;
136
+ }
137
+
138
+ /** Pending-job scanner used by the sweep (injectable for tests). */
139
+ export type PendingJobsFetcher = (
140
+ network: string,
141
+ ) => Promise<Record<string, unknown>>;
142
+
143
+ const defaultPendingJobs: PendingJobsFetcher = async (network) => {
144
+ const ops = await ERC8183JobOps.create({
145
+ walletProvider: getWallet(),
146
+ network,
147
+ });
148
+ return (await ops.getPendingJobs()) as Record<string, unknown>;
149
+ };
150
+
151
+ export interface SellerCoreOpts {
152
+ runWork: RunWork;
153
+ generator: string;
154
+ network?: string | null;
155
+ /** Whether the project configured the ERC-8183 commerce rail. */
156
+ commerceSkills?: boolean;
157
+ /** Test seam: replace the signing module (default: `./signing.js`). */
158
+ signing?: SigningApi;
159
+ /** Test seam: replace the sweep's pending-job scan. */
160
+ pendingJobs?: PendingJobsFetcher;
161
+ }
162
+
163
+ /**
164
+ * ERC-8183 seller core: negotiate + notifyFunded, backed by signing.ts.
165
+ *
166
+ * `runWork(prompt, { sessionId })` is the LLM work hook (built in `main.ts`
167
+ * from the AI SDK); it is called inside the background delivery
168
+ * (`notifyFunded` → `doWorkAndSubmit`) to produce the deliverable text.
169
+ *
170
+ * The core exposes ONLY the two paid, structured operations — there is no
171
+ * free-form chat operation. The transport is responsible for routing a
172
+ * request to {@link negotiate} / {@link notifyFunded}; a request that names
173
+ * no structured operation must never trigger an LLM call or a paid action.
174
+ */
175
+ export class SellerCore {
176
+ protected readonly runWork: RunWork;
177
+ protected readonly generator: string;
178
+ protected readonly network: string;
179
+ protected readonly signing: SigningApi;
180
+ private readonly commerceSkills: boolean;
181
+ private readonly pendingJobs: PendingJobsFetcher;
182
+ // Background delivery bookkeeping (see notifyFunded / isBusy):
183
+ // tasks — live background promises (busy-status source).
184
+ // inflight — job ids in flight OR already terminally handled this
185
+ // process (notify/sweep dedup; retained on success so a
186
+ // slower sweep never re-delivers a just-submitted job).
187
+ private readonly tasks = new Set<Promise<void>>();
188
+ private readonly inflight = new Set<number>();
189
+
190
+ constructor(opts: SellerCoreOpts) {
191
+ this.runWork = opts.runWork;
192
+ this.generator = opts.generator;
193
+ this.network = opts.network ?? "bsc-testnet";
194
+ this.signing = opts.signing ?? defaultSigning;
195
+ this.commerceSkills = opts.commerceSkills ?? true;
196
+ this.pendingJobs = opts.pendingJobs ?? defaultPendingJobs;
197
+ }
198
+
199
+ /**
200
+ * True while any background delivery is in flight.
201
+ *
202
+ * The transport feeds this to AgentCore's `/ping` (`HEALTHY_BUSY` when
203
+ * busy) so the scale-to-zero runtime is not reaped on idle while work runs.
204
+ */
205
+ isBusy(): boolean {
206
+ return this.tasks.size > 0;
207
+ }
208
+
209
+ /** Await every in-flight background task (test helper — not on the wire). */
210
+ async drain(): Promise<void> {
211
+ while (this.tasks.size > 0) {
212
+ await Promise.allSettled([...this.tasks]);
213
+ }
214
+ }
215
+
216
+ // ── skills ──────────────────────────────────────────────────────────────
217
+
218
+ /**
219
+ * Rule-based quote → SDK `NegotiationResult` envelope (no LLM).
220
+ *
221
+ * The price is the FIXED list price from studio.toml, clamped to
222
+ * `[min,max]` BEFORE signing — a misconfigured or hostile request can
223
+ * never sign out of bounds. The buyer parses this envelope verbatim and
224
+ * anchors it on-chain via `createJob` + `fund`.
225
+ */
226
+ async negotiate(
227
+ data: Record<string, unknown>,
228
+ ): Promise<Record<string, unknown>> {
229
+ this.requireCommerceRail();
230
+ let request = data.request;
231
+ if (request === null || typeof request !== "object" || Array.isArray(request)) {
232
+ const picked: Record<string, unknown> = {};
233
+ for (const k of ["task_description", "terms"]) {
234
+ if (k in data) picked[k] = data[k];
235
+ }
236
+ request = picked;
237
+ }
238
+ const clamped = this.signing.clampPrice(this.signing.listPrice());
239
+ return this.signing.signQuote(request as Record<string, unknown>, clamped);
240
+ }
241
+
242
+ /** The seller's two advertised skills. */
243
+ skills(): string[] {
244
+ return this.commerceSkills ? ["negotiate", "notify_funded"] : [];
245
+ }
246
+
247
+ /**
248
+ * Buyer notification: "I funded job X — please deliver."
249
+ *
250
+ * Verify the funded job synchronously (a couple of eth_calls) to ACK
251
+ * accepted/rejected at once, then run the slow LLM work + on-chain
252
+ * `submit` in a BACKGROUND task and return IMMEDIATELY. The buyer reads
253
+ * the deliverable back from the CHAIN (SUBMITTED / `getDeliverableUrl`) —
254
+ * the chain is the source of truth (see erc8183-buyer-push.md).
255
+ *
256
+ * An accepted notification also kicks a background sweep (deduped against
257
+ * in-flight jobs), so a buyer that funded but forgot to notify is still
258
+ * served while we're warm. A rejected / malformed notification spawns
259
+ * nothing.
260
+ */
261
+ async notifyFunded(
262
+ data: Record<string, unknown>,
263
+ ): Promise<Record<string, unknown>> {
264
+ this.requireCommerceRail();
265
+ const raw = data.job_id;
266
+ if (raw === undefined || raw === null || String(raw) === "") {
267
+ this.spawn(() => this.sweep()); // bare notify → just scan stragglers
268
+ return {
269
+ status: "accepted",
270
+ note: "no job_id — scanning funded jobs in the background; poll the chain for results",
271
+ };
272
+ }
273
+ let jobId: number;
274
+ try {
275
+ jobId = parseJobId(raw);
276
+ } catch {
277
+ return { status: "rejected", error: `invalid job_id: ${JSON.stringify(raw)}` };
278
+ }
279
+ let verified = false;
280
+ try {
281
+ // Time-bounded: a hung RPC must not stall the ack path. On timeout we
282
+ // fall through to accept-and-re-verify below.
283
+ const v = await withTimeout(
284
+ this.signing.verifySignedJob(jobId),
285
+ preverifyTimeoutSeconds(),
286
+ );
287
+ if (!v.ok && v.permanent) {
288
+ return { status: "rejected", job_id: jobId, reason: v.reason };
289
+ }
290
+ verified = v.ok;
291
+ } catch (e) {
292
+ // pre-verify is best-effort; the background delivery re-verifies
293
+ log.warn(
294
+ `pre-verify of job ${jobId} failed (${e instanceof Error ? e.message : e}); accepting, will re-verify in background`,
295
+ );
296
+ }
297
+ this.spawnJob(jobId, { verified });
298
+ this.spawn(() => this.sweep()); // straggler fallback alongside the named job
299
+ return {
300
+ status: "accepted",
301
+ job_id: jobId,
302
+ note: "delivery started; poll the chain (SUBMITTED / get_deliverable_url) for the result",
303
+ };
304
+ }
305
+
306
+ // ── background delivery ──────────────────────────────────────────────────
307
+
308
+ /** Run `work` as a tracked background task (keeps {@link isBusy} true). */
309
+ protected spawn(work: () => Promise<void>): void {
310
+ const task = work().catch((e) => {
311
+ // a background task must never crash the process
312
+ log.error("background task failed", e);
313
+ });
314
+ this.tasks.add(task);
315
+ task.finally(() => this.tasks.delete(task));
316
+ }
317
+
318
+ /**
319
+ * Background-deliver `jobId` once, deduped against in-flight jobs.
320
+ *
321
+ * `inflight` is updated SYNCHRONOUSLY here (before scheduling) so a
322
+ * concurrent notify + sweep can never double-deliver the same job.
323
+ */
324
+ private spawnJob(jobId: number, opts: { verified: boolean }): void {
325
+ if (this.inflight.has(jobId)) return;
326
+ this.inflight.add(jobId);
327
+ this.spawn(() => this.runJob(jobId, opts));
328
+ }
329
+
330
+ /**
331
+ * Background runner: deliver one job, log the outcome, free the slot.
332
+ *
333
+ * `verified` jobs (pre-verified in `notifyFunded`) skip straight to the
334
+ * work; unverified ones (the sweep) run the full verify gate first.
335
+ */
336
+ private async runJob(
337
+ jobId: number,
338
+ { verified }: { verified: boolean },
339
+ ): Promise<void> {
340
+ let terminal = false;
341
+ const controller = new AbortController();
342
+ try {
343
+ // Hard ceiling so a hung delivery (e.g. unresponsive RPC) cannot keep
344
+ // isBusy() true — which would pin the microVM to its 8h max-lifetime.
345
+ // A timeout is TRANSIENT: terminal stays false, the slot is freed, and
346
+ // the funded job is re-delivered idempotently by a later sweep.
347
+ const result = await withTimeout(
348
+ verified
349
+ ? this.doWorkAndSubmit(jobId, controller.signal)
350
+ : this.fulfillJob(jobId, controller.signal),
351
+ jobDeliveryTimeoutSeconds(),
352
+ controller,
353
+ );
354
+ log.info(`notify_funded job ${jobId} → ${JSON.stringify(result)}`);
355
+ // A terminal outcome (delivered, or a permanent skip) must STAY in
356
+ // `inflight`: keeping it lets the dedup gate in spawnJob reject a
357
+ // slower concurrent sweep that still sees this job as FUNDED, so the
358
+ // just-submitted job is never re-delivered. Clearing on success
359
+ // reopened that race — the sweep re-ran the work and then failed the
360
+ // on-chain FUNDED gate (Job status is SUBMITTED). Only transient
361
+ // failures fall through to delete so a later sweep can retry them.
362
+ terminal = Boolean(result.ok || result.skip);
363
+ } catch (e) {
364
+ if (e instanceof DeliveryTimeoutError) {
365
+ // Transient by design — leave terminal false so a later sweep retries.
366
+ log.warn(
367
+ `background delivery of job ${jobId} timed out after ${jobDeliveryTimeoutSeconds()}s; will retry`,
368
+ );
369
+ } else {
370
+ log.error(`background delivery of job ${jobId} failed`, e);
371
+ }
372
+ } finally {
373
+ if (!terminal) {
374
+ this.inflight.delete(jobId);
375
+ }
376
+ }
377
+ }
378
+
379
+ private requireCommerceRail(): void {
380
+ if (!this.commerceSkills) {
381
+ throw new Error("8183 rail disabled");
382
+ }
383
+ }
384
+
385
+ // ── internals ────────────────────────────────────────────────────────────
386
+
387
+ /**
388
+ * Verify the signed deal on-chain, then deliver (the sweep's per-job worker).
389
+ *
390
+ * VERIFY before working: confirm the funded job carries the exact quote
391
+ * THIS agent signed (ecrecover + budget ≥ price). A permanent failure
392
+ * (not our signature, tampered terms, underfunded, expired) returns
393
+ * `skip: true`; a transient one returns `ok: false` to retry.
394
+ */
395
+ private async fulfillJob(
396
+ jobId: number,
397
+ abortSignal: AbortSignal,
398
+ ): Promise<Record<string, unknown>> {
399
+ const v = await this.signing.verifySignedJob(jobId);
400
+ if (!v.ok) {
401
+ return { ok: false, job_id: jobId, skip: v.permanent, reason: v.reason };
402
+ }
403
+ return this.doWorkAndSubmit(jobId, abortSignal);
404
+ }
405
+
406
+ /**
407
+ * LLM work → sign + submit. Assumes `jobId` is already verified.
408
+ *
409
+ * DEVELOPER HOOK: the LLM block produces the deliverable text — specialise
410
+ * it for your seller. `signing.submitResult` re-runs the SDK `verifyJob`
411
+ * (defense in depth) and THROWS on a failed submit, so an `ok: true`
412
+ * result always carries a landed tx hash.
413
+ */
414
+ protected async doWorkAndSubmit(
415
+ jobId: number,
416
+ abortSignal?: AbortSignal,
417
+ ): Promise<Record<string, unknown>> {
418
+ const spec = await this.signing.jobSpec(jobId);
419
+ const task =
420
+ spec !== null
421
+ ? JSON.stringify({ task: spec.task, terms: spec.terms })
422
+ : `job ${jobId}`;
423
+ const prompt =
424
+ "You accepted and were paid for the following job. Produce the " +
425
+ "deliverable now. Be complete and self-contained.\n\n" +
426
+ `JOB CONTEXT:\n${task}`;
427
+ const work = await this.runWork(prompt, {
428
+ sessionId: String(jobId),
429
+ abortSignal,
430
+ });
431
+
432
+ let res: { submitTx: string; deliverableUrl: string | null };
433
+ try {
434
+ res = await this.signing.submitResult(jobId, work, {
435
+ job_id: jobId,
436
+ generator: this.generator,
437
+ built_with: "https://github.com/bnb-chain/bnbagent-studio",
438
+ });
439
+ } catch (e) {
440
+ if (
441
+ e instanceof SubmitPermanentlyUnsupportedError ||
442
+ (e instanceof Error && e.name === "SubmitPermanentlyUnsupportedError")
443
+ ) {
444
+ // Deterministic for this wallet kind: submit can NEVER succeed →
445
+ // permanent skip (a transient error would burn one LLM call / retry).
446
+ return { ok: false, job_id: jobId, skip: true, reason: e.message };
447
+ }
448
+ throw e;
449
+ }
450
+ return {
451
+ ok: true,
452
+ job_id: jobId,
453
+ tx_hash: res.submitTx,
454
+ deliverable_url: res.deliverableUrl,
455
+ };
456
+ }
457
+
458
+ /**
459
+ * Best-effort background fallback: deliver any FUNDED jobs for this
460
+ * provider.
461
+ *
462
+ * Catches jobs whose buyer funded on-chain but never sent `notify_funded`.
463
+ * Each job is handed to `spawnJob` (deduped against in-flight jobs, so a
464
+ * concurrent notify never double-delivers); `verifySignedJob` returns
465
+ * non-OK for an already-SUBMITTED job (idempotent, no state file). Errors
466
+ * here are logged and never surface to the caller.
467
+ */
468
+ private async sweep(): Promise<void> {
469
+ let pending: Record<string, unknown>;
470
+ try {
471
+ // Time-bounded: a hung scan would otherwise keep isBusy() true (it
472
+ // runs on every notify) and pin the microVM to its 8h max-lifetime.
473
+ pending = await withTimeout(
474
+ this.pendingJobs(this.network),
475
+ sweepTimeoutSeconds(),
476
+ );
477
+ } catch (e) {
478
+ // the sweep is best-effort (incl. timeouts)
479
+ log.warn(`funded-job sweep failed: ${e instanceof Error ? e.message : e}`);
480
+ return;
481
+ }
482
+ const jobs = Array.isArray(pending?.jobs) ? pending.jobs : [];
483
+ for (const job of jobs) {
484
+ const jid =
485
+ job !== null && typeof job === "object" && !Array.isArray(job)
486
+ ? (job as Record<string, unknown>).jobId
487
+ : undefined;
488
+ if (jid === undefined || jid === null) continue;
489
+ try {
490
+ this.spawnJob(parseJobId(jid), { verified: false });
491
+ } catch {
492
+ // unparseable id — skip
493
+ }
494
+ }
495
+ }
496
+ }
497
+
498
+ /** Normalise an envelope `job_id` (`0x..` / decimal string / number) to int. */
499
+ export function parseJobId(raw: unknown): number {
500
+ if (typeof raw === "number" && Number.isInteger(raw)) return raw;
501
+ if (typeof raw === "bigint") return Number(raw);
502
+ // BigInt() parses both `0x..` hex and decimal strings, and throws on junk.
503
+ return Number(BigInt(String(raw).trim()));
504
+ }