@sanoma-ai/workflows 0.11.16 → 0.11.18

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 (108) hide show
  1. package/README.md +16 -8
  2. package/dist/call.d.ts +35 -4
  3. package/dist/call.js +128 -40
  4. package/dist/call.js.map +1 -1
  5. package/dist/client.d.ts +17 -8
  6. package/dist/client.js +90 -51
  7. package/dist/client.js.map +1 -1
  8. package/dist/collection.d.ts +25 -7
  9. package/dist/collection.js +236 -91
  10. package/dist/collection.js.map +1 -1
  11. package/dist/config.d.ts +24 -1
  12. package/dist/config.js +48 -10
  13. package/dist/config.js.map +1 -1
  14. package/dist/db/company.d.ts +20 -0
  15. package/dist/db/company.js +9 -1
  16. package/dist/db/company.js.map +1 -1
  17. package/dist/describe.js +1 -1
  18. package/dist/describe.js.map +1 -1
  19. package/dist/description.d.ts +6 -3
  20. package/dist/description.js +5 -4
  21. package/dist/description.js.map +1 -1
  22. package/dist/errors.d.ts +2 -1
  23. package/dist/errors.js +3 -2
  24. package/dist/errors.js.map +1 -1
  25. package/dist/grid.js +3 -2
  26. package/dist/grid.js.map +1 -1
  27. package/dist/host-derive.d.ts +13 -8
  28. package/dist/host-derive.js +26 -13
  29. package/dist/host-derive.js.map +1 -1
  30. package/dist/index.d.ts +7 -3
  31. package/dist/index.js +7 -3
  32. package/dist/index.js.map +1 -1
  33. package/dist/ledger.d.ts +7 -0
  34. package/dist/ledger.js +32 -2
  35. package/dist/ledger.js.map +1 -1
  36. package/dist/local-sandbox.d.ts +17 -0
  37. package/dist/local-sandbox.js +131 -0
  38. package/dist/local-sandbox.js.map +1 -0
  39. package/dist/op.d.ts +10 -2
  40. package/dist/op.js.map +1 -1
  41. package/dist/paths.d.ts +9 -0
  42. package/dist/paths.js +20 -4
  43. package/dist/paths.js.map +1 -1
  44. package/dist/resource.d.ts +7 -1
  45. package/dist/resource.js +5 -1
  46. package/dist/resource.js.map +1 -1
  47. package/dist/resources.d.ts +1 -1
  48. package/dist/resources.js +9 -6
  49. package/dist/resources.js.map +1 -1
  50. package/dist/runner-box.d.ts +23 -0
  51. package/dist/runner-box.js +181 -0
  52. package/dist/runner-box.js.map +1 -0
  53. package/dist/runner-protocol.d.ts +73 -0
  54. package/dist/runner-protocol.js +34 -0
  55. package/dist/runner-protocol.js.map +1 -0
  56. package/dist/sandbox-contract.d.ts +109 -0
  57. package/dist/sandbox-contract.js +26 -0
  58. package/dist/sandbox-contract.js.map +1 -0
  59. package/dist/sandbox-runner.cjs +7 -0
  60. package/dist/scenario.js +16 -7
  61. package/dist/scenario.js.map +1 -1
  62. package/dist/schedules.d.ts +11 -3
  63. package/dist/schedules.js +15 -3
  64. package/dist/schedules.js.map +1 -1
  65. package/dist/schema.js +6 -3
  66. package/dist/schema.js.map +1 -1
  67. package/dist/secret.d.ts +2 -1
  68. package/dist/secret.js +3 -2
  69. package/dist/secret.js.map +1 -1
  70. package/dist/shared.d.ts +39 -5
  71. package/dist/shared.js +13 -1
  72. package/dist/shared.js.map +1 -1
  73. package/dist/sites.js +10 -8
  74. package/dist/sites.js.map +1 -1
  75. package/dist/source.d.ts +21 -0
  76. package/dist/source.js.map +1 -1
  77. package/drizzle/company/0003_ledger_target.sql +2 -0
  78. package/drizzle/company/meta/0003_snapshot.json +845 -0
  79. package/drizzle/company/meta/_journal.json +7 -0
  80. package/package.json +5 -2
  81. package/src/call.ts +181 -60
  82. package/src/client.ts +118 -67
  83. package/src/collection.ts +265 -97
  84. package/src/config.ts +65 -16
  85. package/src/db/company.ts +9 -1
  86. package/src/describe.ts +1 -1
  87. package/src/description.ts +5 -4
  88. package/src/errors.ts +3 -2
  89. package/src/grid.ts +3 -1
  90. package/src/host-derive.ts +38 -18
  91. package/src/index.ts +15 -2
  92. package/src/ledger.ts +42 -2
  93. package/src/local-sandbox.ts +133 -0
  94. package/src/op.ts +10 -2
  95. package/src/paths.ts +22 -4
  96. package/src/resource.ts +16 -2
  97. package/src/resources.ts +8 -6
  98. package/src/runner-box.ts +218 -0
  99. package/src/runner-protocol.ts +61 -0
  100. package/src/sandbox-contract.ts +121 -0
  101. package/src/sandbox-runner.ts +187 -0
  102. package/src/scenario.ts +16 -6
  103. package/src/schedules.ts +19 -4
  104. package/src/schema.ts +14 -3
  105. package/src/secret.ts +6 -2
  106. package/src/shared.ts +40 -4
  107. package/src/sites.ts +11 -7
  108. package/src/source.ts +14 -0
@@ -22,6 +22,13 @@
22
22
  "when": 1793857547422,
23
23
  "tag": "0002_runs_tell_the_app",
24
24
  "breakpoints": true
25
+ },
26
+ {
27
+ "idx": 3,
28
+ "version": "7",
29
+ "when": 1793900809955,
30
+ "tag": "0003_ledger_target",
31
+ "breakpoints": true
25
32
  }
26
33
  ]
27
34
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanoma-ai/workflows",
3
- "version": "0.11.16",
3
+ "version": "0.11.18",
4
4
  "description": "Business processes as TypeScript functions, run durably on DBOS",
5
5
  "keywords": [
6
6
  "approvals",
@@ -122,9 +122,12 @@
122
122
  },
123
123
  "devDependencies": {
124
124
  "@types/pg": "^8.23.1",
125
+ "@types/ws": "8.18.2",
125
126
  "drizzle-kit": "0.31.11",
127
+ "esbuild": "0.28.2",
126
128
  "msw": "^3.0.3",
127
129
  "srvx": "^0.11.22",
130
+ "ws": "8.22.0",
128
131
  "zod": "^4.6.5"
129
132
  },
130
133
  "peerDependencies": {
@@ -135,7 +138,7 @@
135
138
  },
136
139
  "//": "randexp is a dependency only because zod-schema-faker, which makes up scenario inputs, needs it as a peer.",
137
140
  "scripts": {
138
- "build": "tsc -p tsconfig.build.json",
141
+ "build": "tsc -p tsconfig.build.json && esbuild src/sandbox-runner.ts --bundle --platform=node --target=node22 --format=cjs --minify --external:bufferutil --external:utf-8-validate --outfile=dist/sandbox-runner.cjs --log-level=warning",
139
142
  "clean": "rm -rf dist",
140
143
  "watch": "tsc -p tsconfig.build.json --watch --preserveWatchOutput",
141
144
  "generate": "drizzle-kit generate --config drizzle/company.config.ts && drizzle-kit generate --config drizzle/wakeup.config.ts"
package/src/call.ts CHANGED
@@ -27,7 +27,7 @@ import {
27
27
  SanomaError,
28
28
  } from "./errors.ts";
29
29
  import type { Collection } from "./collection.ts";
30
- import { currentGroup, entry, skipped, write, writeFailure } from "./ledger.ts";
30
+ import { currentGroup, entry, type LedgerBody, skipped, write, writeFailure } from "./ledger.ts";
31
31
  import { shown } from "./log.ts";
32
32
  import { type CallContext, isOp, type Op } from "./op.ts";
33
33
  import { callAt, callName, type CallNode } from "./outline.ts";
@@ -42,8 +42,10 @@ import {
42
42
  CALL_WORKFLOW,
43
43
  CHANGE_MERGE,
44
44
  DATA_VENDOR,
45
+ entryOpsOf,
45
46
  errorMessage,
46
47
  fitsOp,
48
+ isBatch,
47
49
  LOGIN_CODE_WORKFLOW,
48
50
  problemLine,
49
51
  type Saved,
@@ -293,7 +295,7 @@ function strict<T extends object>(obj: T, path: string, workflow: string): T {
293
295
  }
294
296
 
295
297
  /** The resource instance the call acts on, from the operation's `target`, when it declares one. */
296
- function targetOf(op: Op, input: unknown): { target?: string } {
298
+ export function targetOf(op: Op, input: unknown): { target?: string } {
297
299
  if (!op.target) return {};
298
300
  const target: unknown = op.target(input);
299
301
  if (typeof target !== "string") {
@@ -302,18 +304,104 @@ function targetOf(op: Op, input: unknown): { target?: string } {
302
304
  return { target };
303
305
  }
304
306
 
307
+ /** One call as the policy decides it and the ledger records it: an operation and its parsed input. */
308
+ export interface Call {
309
+ op: Op;
310
+ input: unknown;
311
+ }
312
+
313
+ /**
314
+ * The calls a call makes: a collection's `saveMany` (`isBatch`) makes one per entry, the
315
+ * collection's `save` or `delete` with the entry as its input, and these are what the policy
316
+ * decides and the ledger records, so a record's history reads the same whether it was saved
317
+ * alone or in a batch; any other call makes itself.
318
+ */
319
+ export function callsOf(op: Op, input: unknown, ops: ReadonlyMap<string, Op>): Call[] {
320
+ if (!isBatch(op)) return [{ op, input }];
321
+ const { records, deletes } = input as { records: unknown[]; deletes: unknown[] };
322
+ const [save, remove] = entryOpsOf(op).map((id) => ops.get(id)!);
323
+ return [...records.map((e) => ({ op: save!, input: e })), ...deletes.map((e) => ({ op: remove!, input: e }))];
324
+ }
325
+
326
+ /** The outputs of the calls a call made, from its output: a batch's, one per entry, in order. */
327
+ export const outputsOf = (op: Op, output: unknown): unknown[] => (isBatch(op) ? (output as unknown[]) : [output]);
328
+
329
+ /** The key of the record a collection's `save` or `delete` acts on. */
330
+ const keyOf = (op: Op, input: unknown, collection: Collection): string =>
331
+ op.name === "save"
332
+ ? ((input as { record: Record<string, unknown> }).record[collection.key] as string)
333
+ : (input as { key: string }).key;
334
+
335
+ /** A batch's denied entries: each one's key, with its decision. */
336
+ function denialsOf(
337
+ op: Op,
338
+ calls: readonly Call[],
339
+ decisions: readonly RecordedDecision[],
340
+ collections: ReadonlyMap<string, Collection>,
341
+ ): { key: string; decision: Extract<RecordedDecision, { kind: "deny" }> }[] {
342
+ const collection = collections.get(op.resource)!;
343
+ return calls.flatMap((c, i) => {
344
+ const decision = decisions[i]!;
345
+ return decision.kind === "deny" ? [{ key: keyOf(c.op, c.input, collection), decision }] : [];
346
+ });
347
+ }
348
+
305
349
  /**
306
- * Asks the policy about one call, with the record a collection's `save` or
307
- * `delete` replaces as `before` (`beforeOf`). The runtime's own rule first (`runtimeHold`, over
308
- * the running instance's people): while it holds or denies, the company's policy is not asked.
309
- * Runs inside a step, so it must have no side effects of its own.
350
+ * The decision for a call from those of the calls it makes: a batch is denied when any entry is
351
+ * (the reason naming each denied key), else held when any is (by the first hold), else allowed;
352
+ * any other call's is its own.
310
353
  */
354
+ export function decisionOf(
355
+ op: Op,
356
+ calls: readonly Call[],
357
+ decisions: readonly RecordedDecision[],
358
+ collections: ReadonlyMap<string, Collection>,
359
+ ): RecordedDecision {
360
+ if (!isBatch(op)) return decisions[0]!;
361
+ const version = decisions[0]?.policyVersion === undefined ? {} : { policyVersion: decisions[0].policyVersion };
362
+ const denied = denialsOf(op, calls, decisions, collections);
363
+ if (denied.length) {
364
+ const reasons = [...new Set(denied.flatMap(({ decision }) => decision.reasons ?? []))];
365
+ const reason = denied.map(({ key, decision }) => `${key}: ${decision.reason}`).join("; ");
366
+ return { kind: "deny", reason, ...(reasons.length && { reasons }), ...version };
367
+ }
368
+ return decisions.find((d) => d.kind === "approve") ?? { kind: "allow", ...version };
369
+ }
370
+
371
+ /** The keys of a batch's denied entries, for its `policy_denied` (`denied`); nothing for any other call. */
372
+ export function deniedKeys(
373
+ op: Op,
374
+ calls: readonly Call[],
375
+ decisions: readonly RecordedDecision[],
376
+ collections: ReadonlyMap<string, Collection>,
377
+ ): { denied?: string[] } {
378
+ if (!isBatch(op)) return {};
379
+ return { denied: denialsOf(op, calls, decisions, collections).map(({ key }) => key) };
380
+ }
381
+
382
+ /** `decideEach` for one call. */
311
383
  export async function decide(
312
384
  run: Run,
313
385
  op: Op,
314
386
  input: unknown,
315
387
  connection?: ConnectionSnapshot,
316
388
  ): Promise<RecordedDecision> {
389
+ const [decision] = await decideEach(run, [{ op, input }], connection);
390
+ return decision!;
391
+ }
392
+
393
+ /**
394
+ * Asks the policy about each of the calls a call makes (`callsOf`), in order, with the record a
395
+ * collection's `save` or `delete` replaces as `before` (`beforeOf`, each read keyed by its place).
396
+ * The runtime's own rule first (`runtimeHold`, over the running instance's people): while it
397
+ * holds or denies a call, the company's policy is not asked about it. Runs inside a step, so it
398
+ * must have no side effects of its own.
399
+ */
400
+ export async function decideEach(
401
+ run: Run,
402
+ calls: readonly Call[],
403
+ connection?: ConnectionSnapshot,
404
+ ): Promise<RecordedDecision[]> {
317
405
  const seen = {
318
406
  id: run.id,
319
407
  workflow: run.workflow,
@@ -321,20 +409,33 @@ export async function decide(
321
409
  (a) => a.requestedBy !== "policy" || a.connection === connection?.revision,
322
410
  ),
323
411
  };
324
- // Only a merge is the rule's: no other call reads the people's files.
325
- const held =
326
- op.id === CHANGE_MERGE ? runtimeHold(op.id, input, seen.approvals, readPeople(run.state.root).people) : undefined;
327
- if (held) return held;
328
412
  const { policy } = run.state;
329
- // Keyed by the call (`seq` was taken for it), so a fake answers each read afresh.
330
- const before = await beforeOf(policy, op, input, run.state.collections, (get, key) =>
331
- driverOf(run, get)?.(
332
- { key },
333
- { idempotencyKey: `${run.id}:${run.seq}:before`, runId: run.id, opId: get, attempt: 1, secrets: secretsOf(run) },
334
- ),
335
- );
336
- const call = { ...callOf(op, input, run.actor, seen), ...before, ...(connection && { connection }) };
337
- return ask(policy, op.id, () => policy(call), run.state);
413
+ const decisions: RecordedDecision[] = [];
414
+ for (const [i, { op, input }] of calls.entries()) {
415
+ // Only a merge is the rule's: no other call reads the people's files.
416
+ const held =
417
+ op.id === CHANGE_MERGE ? runtimeHold(op.id, input, seen.approvals, readPeople(run.state.root).people) : undefined;
418
+ if (held) {
419
+ decisions.push(held);
420
+ continue;
421
+ }
422
+ // Keyed by the call (`seq` was taken for it) and its place, so a fake answers each read afresh.
423
+ const before = await beforeOf(policy, op, input, run.state.collections, (get, key) =>
424
+ driverOf(run, get)?.(
425
+ { key },
426
+ {
427
+ idempotencyKey: `${run.id}:${run.seq}:before${i ? `:${i}` : ""}`,
428
+ runId: run.id,
429
+ opId: get,
430
+ attempt: 1,
431
+ secrets: secretsOf(run),
432
+ },
433
+ ),
434
+ );
435
+ const call = { ...callOf(op, input, run.actor, seen), ...before, ...(connection && { connection }) };
436
+ decisions.push(ask(policy, op.id, () => policy(call), run.state));
437
+ }
438
+ return decisions;
338
439
  }
339
440
 
340
441
  /** The run's function for an operation: its own fake's in a sandbox run, never a vendor's; else the driver's. */
@@ -368,10 +469,7 @@ export async function beforeOf(
368
469
  ): Promise<{ before?: unknown }> {
369
470
  const collection = op.vendor === DATA_VENDOR ? collections.get(op.resource) : undefined;
370
471
  if (policy === allowAll || !collection || (op.name !== "save" && op.name !== "delete")) return {};
371
- const key =
372
- op.name === "save"
373
- ? (input as { record: Record<string, unknown> }).record[collection.key]
374
- : (input as { key: unknown }).key;
472
+ const key = keyOf(op, input, collection);
375
473
  let saved: Saved | null | undefined;
376
474
  try {
377
475
  saved = (await read(`${DATA_VENDOR}.${op.resource}.get`, key)) as Saved | null | undefined;
@@ -511,11 +609,15 @@ async function callOp(run: Run, id: string, input: unknown, place: Placed, ref?:
511
609
  });
512
610
  }
513
611
  const parsed = parseOrThrow(op.input, input, `The input to ${op.id} does not match its schema`, { op: op.id });
612
+ // The calls this one makes (a batch's entries), each decided and recorded on its own.
613
+ const calls = callsOf(op, parsed, run.state.ops);
514
614
  // The call's identity (its ledger seq and id, and the driver's idempotency key) is taken
515
615
  // before the first await, so it depends only on the order the workflow made its calls. A
516
616
  // replay skips the step bodies but counts the same way. A call held for an approval is
517
- // numbered before the approval's records, so its op.called sorts ahead of them.
518
- const seq = run.seq++;
617
+ // numbered before the approval's records, so its op.called sorts ahead of them. A batch
618
+ // takes a seq per entry.
619
+ const seq = run.seq;
620
+ run.seq += calls.length;
519
621
  // The decision is recorded as a step, so a replay reuses it instead of asking a policy
520
622
  // that may have changed since. The policy must still be deterministic: a run that fails
521
623
  // before the step is recorded asks again.
@@ -536,34 +638,57 @@ async function callOp(run: Run, id: string, input: unknown, place: Placed, ref?:
536
638
  // Read in the step, so a replay reuses the first answer, and asked again after each
537
639
  // approval, so a call made after a long wait sees the files as they are then.
538
640
  if (run.workflow !== APPLY_WORKFLOW && isChangeOp(op, run.state.types)) await refuseDeclared(run, op, parsed);
539
- return decide(run, op, parsed, connection);
641
+ return decideEach(run, calls, connection);
540
642
  },
541
643
  { name: `policy:${op.id}` },
542
644
  );
543
- let decision = await decideStep();
645
+ let decisions = await decideStep();
646
+ let decision = decisionOf(op, calls, decisions, run.state.collections);
647
+ // The records keep the first decisions: a hold stays a hold once its approval let the call through.
648
+ const decided = decisions;
544
649
  const logged = recorded(op, parsed, run.workflow);
545
- const call = {
546
- type: "op.called",
547
- op: op.id,
548
- ...place,
549
- effect: op.effect,
550
- input: logged,
551
- decision,
552
- ...(connection && { connection }),
553
- } as const;
650
+ /** The call's records, one per call it made, numbered from `seq`, each with its decision and what `outcome` says of it. */
651
+ const records = (outcome: (i: number) => Partial<LedgerBody>, at?: number) =>
652
+ calls.map((c, i) =>
653
+ entry(
654
+ run,
655
+ {
656
+ type: "op.called",
657
+ op: c.op.id,
658
+ ...place,
659
+ ...targetOf(c.op, c.input),
660
+ effect: c.op.effect,
661
+ input: recorded(c.op, c.input, run.workflow),
662
+ decision: decided[i]!,
663
+ ...(connection && { connection }),
664
+ ...outcome(i),
665
+ } as LedgerBody,
666
+ { seq: seq + i, ...(at !== undefined && { at }) },
667
+ ),
668
+ );
669
+ const writeFailures = async (outcome: (i: number) => Partial<LedgerBody>, err: unknown, at?: number) => {
670
+ for (const record of records(outcome, at)) await writeFailure(run, record, err);
671
+ };
554
672
 
555
673
  // Each hold is lifted in turn: once one is approved, the policy is asked again, with it.
556
674
  for (;;) {
557
675
  if (decision.kind === "deny") {
558
- const err = new PolicyDeniedError(op.id, decision.reason);
559
- await writeFailure(run, entry(run, { ...call, decision, error: errorInfo(err), durationMs: 0 }, { seq }), err);
676
+ const err = new PolicyDeniedError(
677
+ op.id,
678
+ decision.reason,
679
+ deniedKeys(op, calls, decisions, run.state.collections),
680
+ );
681
+ // A deny after an approval is recorded as the deny, with its reason, not as the hold.
682
+ await writeFailures((i) => ({ decision: decisions[i]!, error: errorInfo(err), durationMs: 0 }), err);
560
683
  throw err;
561
684
  }
562
685
  if (decision.kind !== "approve") break;
563
686
  const title = decision.title ?? `${op.id} needs ${approverLabel(decision.approver)}`;
564
687
  try {
565
- // A hold covers the call it held.
566
- await awaitApproval(run, title, { approver: decision.approver, covers: [op.id] }, place, {
688
+ // A hold covers the call it held and the calls of a batch it held, each decided again once it is approved.
689
+ const held = calls.filter((_, i) => decisions[i]!.kind === "approve").map((c) => c.op.id);
690
+ const covers = [...new Set([op.id, ...held])];
691
+ await awaitApproval(run, title, { approver: decision.approver, covers }, place, {
567
692
  op: op.id,
568
693
  seq,
569
694
  input: logged,
@@ -575,12 +700,12 @@ async function callOp(run: Run, id: string, input: unknown, place: Placed, ref?:
575
700
  const code = errorCode(err);
576
701
  if (code === "approval_rejected" || code === "approval_withdrawn") {
577
702
  const approval = (err as SanomaError).data.approvalId as string;
578
- const record = entry(run, { ...call, approval, error: errorInfo(err), durationMs: 0 }, { seq });
579
- await writeFailure(run, record, err);
703
+ await writeFailures(() => ({ approval, error: errorInfo(err), durationMs: 0 }), err);
580
704
  }
581
705
  throw err;
582
706
  }
583
- decision = await decideStep();
707
+ decisions = await decideStep();
708
+ decision = decisionOf(op, calls, decisions, run.state.collections);
584
709
  }
585
710
 
586
711
  const started = Date.now();
@@ -619,27 +744,23 @@ async function callOp(run: Run, id: string, input: unknown, place: Placed, ref?:
619
744
  },
620
745
  );
621
746
  } catch (err) {
622
- // The step failed for good; on replay DBOS rethrows the recorded error, and this record
623
- // gets the same id. A success record would too, so a call has one op.called at most.
624
- const record = entry(
625
- run,
626
- { ...call, error: errorInfo(err), durationMs: Date.now() - started, ...(attempt ? { attempt } : {}) },
627
- { seq, at: started },
628
- );
629
- if (isInfrastructureError(err, run.state)) skipped(record, `the call was interrupted (${errorMessage(err)})`);
630
- else await writeFailure(run, record, err);
747
+ // The step failed for good; on replay DBOS rethrows the recorded error, and these records
748
+ // get the same ids. A success record would too, so a call has one op.called at most.
749
+ const failed = () => ({ error: errorInfo(err), durationMs: Date.now() - started, ...(attempt ? { attempt } : {}) });
750
+ if (isInfrastructureError(err, run.state)) {
751
+ for (const record of records(failed, started)) skipped(record, `the call was interrupted (${errorMessage(err)})`);
752
+ } else await writeFailures(failed, err, started);
631
753
  throw err;
632
754
  }
633
755
  const { output, at, durationMs } = result;
756
+ const outputs = outputsOf(op, output);
634
757
  try {
635
- await write(
636
- run,
637
- entry(
638
- run,
639
- { ...call, output: recorded(op, output, run.workflow), durationMs, attempt: result.attempt },
640
- { seq, at },
641
- ),
642
- );
758
+ for (const record of records(
759
+ (i) => ({ output: recorded(calls[i]!.op, outputs[i], run.workflow), durationMs, attempt: result.attempt }),
760
+ at,
761
+ )) {
762
+ await write(run, record);
763
+ }
643
764
  } catch (err) {
644
765
  // The vendor acted, and nothing records it. A workflow that caught this and called again
645
766
  // would repeat the side effect under a new key, so the run makes no further calls.