@sanoma-ai/workflows 0.11.17 → 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 (94) hide show
  1. package/README.md +13 -9
  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 +8 -5
  12. package/dist/config.js +10 -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.js +1 -1
  20. package/dist/description.js.map +1 -1
  21. package/dist/errors.d.ts +2 -1
  22. package/dist/errors.js +3 -2
  23. package/dist/errors.js.map +1 -1
  24. package/dist/grid.js +3 -2
  25. package/dist/grid.js.map +1 -1
  26. package/dist/host-derive.d.ts +13 -8
  27. package/dist/host-derive.js +26 -13
  28. package/dist/host-derive.js.map +1 -1
  29. package/dist/index.d.ts +2 -0
  30. package/dist/index.js +2 -0
  31. package/dist/index.js.map +1 -1
  32. package/dist/ledger.d.ts +7 -0
  33. package/dist/ledger.js +32 -2
  34. package/dist/ledger.js.map +1 -1
  35. package/dist/local-sandbox.d.ts +8 -1
  36. package/dist/local-sandbox.js +3 -2
  37. package/dist/local-sandbox.js.map +1 -1
  38. package/dist/op.d.ts +3 -2
  39. package/dist/op.js.map +1 -1
  40. package/dist/resources.d.ts +1 -1
  41. package/dist/resources.js +9 -6
  42. package/dist/resources.js.map +1 -1
  43. package/dist/runner-box.d.ts +23 -0
  44. package/dist/runner-box.js +181 -0
  45. package/dist/runner-box.js.map +1 -0
  46. package/dist/runner-protocol.d.ts +73 -0
  47. package/dist/runner-protocol.js +34 -0
  48. package/dist/runner-protocol.js.map +1 -0
  49. package/dist/sandbox-contract.d.ts +14 -5
  50. package/dist/sandbox-contract.js.map +1 -1
  51. package/dist/sandbox-runner.cjs +7 -0
  52. package/dist/scenario.js +16 -7
  53. package/dist/scenario.js.map +1 -1
  54. package/dist/schema.js +6 -3
  55. package/dist/schema.js.map +1 -1
  56. package/dist/secret.d.ts +2 -1
  57. package/dist/secret.js +3 -2
  58. package/dist/secret.js.map +1 -1
  59. package/dist/shared.d.ts +39 -5
  60. package/dist/shared.js +13 -1
  61. package/dist/shared.js.map +1 -1
  62. package/dist/sites.js +10 -8
  63. package/dist/sites.js.map +1 -1
  64. package/dist/source.d.ts +21 -0
  65. package/dist/source.js.map +1 -1
  66. package/drizzle/company/0003_ledger_target.sql +2 -0
  67. package/drizzle/company/meta/0003_snapshot.json +845 -0
  68. package/drizzle/company/meta/_journal.json +7 -0
  69. package/package.json +5 -2
  70. package/src/call.ts +181 -60
  71. package/src/client.ts +118 -67
  72. package/src/collection.ts +265 -97
  73. package/src/config.ts +20 -20
  74. package/src/db/company.ts +9 -1
  75. package/src/describe.ts +1 -1
  76. package/src/description.ts +1 -1
  77. package/src/errors.ts +3 -2
  78. package/src/grid.ts +3 -1
  79. package/src/host-derive.ts +38 -18
  80. package/src/index.ts +2 -0
  81. package/src/ledger.ts +42 -2
  82. package/src/local-sandbox.ts +3 -2
  83. package/src/op.ts +3 -2
  84. package/src/resources.ts +8 -6
  85. package/src/runner-box.ts +218 -0
  86. package/src/runner-protocol.ts +61 -0
  87. package/src/sandbox-contract.ts +11 -4
  88. package/src/sandbox-runner.ts +187 -0
  89. package/src/scenario.ts +16 -6
  90. package/src/schema.ts +14 -3
  91. package/src/secret.ts +6 -2
  92. package/src/shared.ts +40 -4
  93. package/src/sites.ts +11 -7
  94. package/src/source.ts +14 -0
package/src/client.ts CHANGED
@@ -26,7 +26,19 @@ import {
26
26
  topicOf,
27
27
  withdrawalRefusal,
28
28
  } from "./approvals.ts";
29
- import { beforeOf, decideCall, decideStart, recorded, startHoldOf } from "./call.ts";
29
+ import {
30
+ beforeOf,
31
+ type Call,
32
+ callsOf,
33
+ decideCall,
34
+ decideStart,
35
+ decisionOf,
36
+ deniedKeys,
37
+ outputsOf,
38
+ recorded,
39
+ startHoldOf,
40
+ targetOf,
41
+ } from "./call.ts";
30
42
  import { type Chats, chatStore } from "./chats.ts";
31
43
  import { type Tokens, tokenStore } from "./tokens.ts";
32
44
  import { collectionsDriver } from "./collection.ts";
@@ -97,9 +109,11 @@ import {
97
109
  errorMessage,
98
110
  FOLLOW_WORKFLOW,
99
111
  HOST_CHECK_AGENT,
112
+ isBatch,
100
113
  isEnded,
101
114
  isToken,
102
115
  isVisitor,
116
+ LIST_LIMIT,
103
117
  refusalOf,
104
118
  type RunStatus,
105
119
  SCHEDULE_AGENT,
@@ -452,8 +466,9 @@ export class SanomaClient {
452
466
  * it as the person, in a run `direct:<actor>:<id>` of the workflow `call` with no approvals. A
453
467
  * deny throws `policy_denied` and writes nothing. An allowed call runs the driver once, without
454
468
  * DBOS and without a retry (a driver's failure is `driver_failed`). A read returns its output
455
- * and records nothing. A write is recorded as one `op.called` (seq 0) in that run, and with the
456
- * default ledger in one transaction with the record it saves, so both are written or neither;
469
+ * and records nothing. A write is recorded as one `op.called` per call it makes (`callsOf`, seq
470
+ * from 0) in that run, and with the default ledger in one transaction with what it saves, so
471
+ * both are written or neither;
457
472
  * a write with the same id and input returns the output recorded. When the policy asks for an
458
473
  * approval, the call becomes run `call:<actor>:<id>` of the built-in `call` workflow, started
459
474
  * as the person, which makes it once approved, and this throws `approval_pending` with its id.
@@ -482,23 +497,42 @@ export class SanomaClient {
482
497
  const runId = `direct:${actor.id}:${id}`;
483
498
  const heldId = `call:${actor.id}:${id}`;
484
499
  const held: RunArgs = { input: { op: op.id, input, ...(selected && { connection: selected }) }, startedBy: actor };
485
- const logged = recorded(op, parsed);
500
+ // The calls this one makes (a batch's entries), each decided and recorded on its own.
501
+ const calls = callsOf(op, parsed, ops);
486
502
  const write = op.effect !== "read";
487
503
  // Only an id the caller gave can have been used: a write made, or held, with it settles it.
504
+ // One made and partly recorded is made again, as a retry (the store keeps the records it has).
505
+ let attempt = 1;
488
506
  if (write && options.id !== undefined) {
489
- const first =
490
- (await this.recordedCall(runId, op.id, logged, connection)) ?? (await this.heldCall(op.id, heldId, held));
491
- if (first) return first.output;
507
+ const made = await this.recordedCall(runId, op, calls, connection);
508
+ if (made && "partial" in made) attempt = 2;
509
+ else {
510
+ const first = made ?? (await this.heldCall(op.id, heldId, held));
511
+ if (first) return first.output;
512
+ }
492
513
  }
493
- const before = await beforeOf(policy, op, parsed, this.config.collections, (get, key) =>
494
- drivers.get(get)?.(
495
- { key },
496
- { idempotencyKey: `${runId}:before`, runId, opId: get, attempt: 1, secrets: this.storedSecrets, by: actor.id },
497
- ),
498
- );
499
514
  const run = { id: runId, workflow: CALL_WORKFLOW, approvals: [] };
500
- const decision = decideCall(policy, op, parsed, actor, run, { ...before, ...(connection && { connection }) });
501
- if (decision.kind === "deny") throw new PolicyDeniedError(op.id, decision.reason);
515
+ const decisions: RecordedDecision[] = [];
516
+ for (const [i, c] of calls.entries()) {
517
+ const before = await beforeOf(policy, c.op, c.input, this.config.collections, (get, key) =>
518
+ drivers.get(get)?.(
519
+ { key },
520
+ {
521
+ idempotencyKey: `${runId}:before${i ? `:${i}` : ""}`,
522
+ runId,
523
+ opId: get,
524
+ attempt: 1,
525
+ secrets: this.storedSecrets,
526
+ by: actor.id,
527
+ },
528
+ ),
529
+ );
530
+ decisions.push(decideCall(policy, c.op, c.input, actor, run, { ...before, ...(connection && { connection }) }));
531
+ }
532
+ const decision = decisionOf(op, calls, decisions, this.config.collections);
533
+ if (decision.kind === "deny") {
534
+ throw new PolicyDeniedError(op.id, decision.reason, deniedKeys(op, calls, decisions, this.config.collections));
535
+ }
502
536
  if (decision.kind === "approve") {
503
537
  await this.start(CALL_WORKFLOW, held.input, { startedBy: actor, runId: heldId });
504
538
  // A read held before, whose run has ended since, answers with that run's result.
@@ -508,7 +542,7 @@ export class SanomaClient {
508
542
  idempotencyKey: runId,
509
543
  runId,
510
544
  opId: op.id,
511
- attempt: 1,
545
+ attempt,
512
546
  secrets: this.storedSecrets,
513
547
  by: actor.id,
514
548
  principal: actor,
@@ -516,26 +550,27 @@ export class SanomaClient {
516
550
  };
517
551
  await this.config.connections.assertCurrent(connection, actor.id);
518
552
  if (!write) return outputOf(op, () => fn(parsed, context));
519
- return this.directWrite({ op, fn, parsed, logged, actor, decision, context });
553
+ return this.directWrite({ op, fn, parsed, calls, actor, decisions, context });
520
554
  }
521
555
 
522
556
  /**
523
557
  * A direct write, made once and recorded once: with the default ledger, a collection's write
524
- * and its record in one transaction on the database both are in, so both are kept or neither;
525
- * otherwise the driver's call, then the record in the config's ledger. The first record of the
526
- * call's id stands: a write that finds one already there (two calls with one id at once) is
527
- * undone, and answers as `recordedCall` does.
558
+ * and its records in one transaction on the database both are in, so both are kept or neither;
559
+ * otherwise the driver's call, then the records in the config's ledger. A write is recorded as
560
+ * one `op.called` per call it makes (`callsOf`: one, or a batch's entries), numbered from 0.
561
+ * The first records of the call's id stand: a write that finds one already there (two calls
562
+ * with one id at once) is undone, and answers as `recordedCall` does.
528
563
  */
529
564
  private async directWrite(call: {
530
565
  op: Op;
531
566
  fn: (input: unknown, context: CallContext) => Promise<unknown>;
532
567
  parsed: unknown;
533
- logged: unknown;
568
+ calls: readonly Call[];
534
569
  actor: Principal;
535
- decision: RecordedDecision;
570
+ decisions: readonly RecordedDecision[];
536
571
  context: CallContext;
537
572
  }): Promise<unknown> {
538
- const { op, parsed, logged, actor, decision, context } = call;
573
+ const { op, parsed, calls, actor, decisions, context } = call;
539
574
  const { runId } = context;
540
575
  const { dataStore, collections, ledger } = this.config;
541
576
  const connectionMutation = op.id.startsWith("sanoma.connection.");
@@ -545,39 +580,45 @@ export class SanomaClient {
545
580
  ? dataStore
546
581
  : undefined;
547
582
  const at = Date.now();
548
- const record = (outcome: { output: unknown } | { error: ErrorInfo }): LedgerRecord => ({
549
- v: 1,
550
- app: this.config.appName,
551
- id: `${runId}:op.called:0`,
552
- runId,
553
- seq: 0,
554
- at,
555
- actor,
556
- workflow: CALL_WORKFLOW,
557
- type: "op.called",
558
- op: op.id,
559
- effect: op.effect,
560
- input: logged,
561
- decision,
562
- ...(context.connection && { connection: context.connection }),
563
- ...outcome,
564
- durationMs: Date.now() - at,
565
- });
583
+ const records = (outcome: (i: number) => { output: unknown } | { error: ErrorInfo }): LedgerRecord[] =>
584
+ calls.map((c, i) => ({
585
+ v: 1,
586
+ app: this.config.appName,
587
+ id: `${runId}:op.called:${i}`,
588
+ runId,
589
+ seq: i,
590
+ at,
591
+ actor,
592
+ workflow: CALL_WORKFLOW,
593
+ type: "op.called",
594
+ op: c.op.id,
595
+ ...targetOf(c.op, c.input),
596
+ effect: c.op.effect,
597
+ input: recorded(c.op, c.input),
598
+ decision: decisions[i]!,
599
+ ...(context.connection && { connection: context.connection }),
600
+ ...outcome(i),
601
+ durationMs: Date.now() - at,
602
+ }));
603
+ const made = (result: unknown) => {
604
+ const outputs = outputsOf(op, result);
605
+ return records((i) => ({ output: recorded(calls[i]!.op, outputs[i]) }));
606
+ };
566
607
  /** Runs the call through `fn`; a failure is recorded on the store's own connection, outside any transaction. */
567
608
  const output = async (fn: typeof call.fn): Promise<unknown> => {
568
609
  try {
569
610
  return await outputOf(op, () => fn(parsed, context));
570
611
  } catch (err) {
571
- await this.audit.store.append(record({ error: errorInfo(err) }));
612
+ for (const record of records(() => ({ error: errorInfo(err) }))) await this.audit.store.append(record);
572
613
  throw err;
573
614
  }
574
615
  };
575
616
  if (ledger || !transactionStore) {
576
617
  const result = await output(call.fn);
577
- await this.audit.store.append(record({ output: recorded(op, result) }));
618
+ for (const record of made(result)) await this.audit.store.append(record);
578
619
  return result;
579
620
  }
580
- // The write and its record in one transaction: the metadata or collection driver runs on the
621
+ // The write and its records in one transaction: the metadata or collection driver runs on the
581
622
  // transaction's connection (neither has an env guard), and a record another write of the id
582
623
  // was first with rolls the write back, for the recorded output to stand.
583
624
  const store = await transactionStore.db();
@@ -589,33 +630,37 @@ export class SanomaClient {
589
630
  : collectionsDriver([collections.get(op.resource)!], { db: async () => tx })
590
631
  ).ops[`${op.resource}.${op.name}`]!;
591
632
  const result = await output(fn);
592
- if (await insertRecord(tx, record({ output: recorded(op, result) }))) return result;
593
- tx.rollback();
633
+ for (const record of made(result)) if (!(await insertRecord(tx, record))) tx.rollback();
634
+ return result;
594
635
  });
595
636
  } catch (err) {
596
637
  if (!is(err, TransactionRollbackError)) throw err;
597
- const first = await this.recordedCall(runId, op.id, logged, context.connection);
598
- if (!first) throw err;
638
+ const first = await this.recordedCall(runId, op, calls, context.connection);
639
+ if (!first || "partial" in first) throw err;
599
640
  return first.output;
600
641
  }
601
642
  }
602
643
 
603
644
  /**
604
- * What the direct write recorded as `runId` returned, when one did: refused (`invalid_input`)
605
- * when it was of another operation or input, and its failure again (`driver_failed`) when it failed.
645
+ * What the direct write recorded as `runId` returned, when one did (a batch's output from its
646
+ * entries' records): refused (`invalid_input`) when it was of another operation or input, its
647
+ * failure again (`driver_failed`) when it failed, and `partial` when fewer records than its
648
+ * calls are there (a batch's, appended one by one outside a transaction, cut short).
606
649
  */
607
650
  private async recordedCall(
608
651
  runId: string,
609
- opId: string,
610
- input: unknown,
652
+ op: Op,
653
+ calls: readonly Call[],
611
654
  connection?: ConnectionSnapshot,
612
- ): Promise<{ output: unknown } | undefined> {
613
- const first = (await this.audit.store.read(runId)).find((r) => r.type === "op.called");
614
- if (!first) return undefined;
655
+ ): Promise<{ output: unknown } | { partial: true } | undefined> {
656
+ const made = (await this.audit.store.read(runId)).filter((r) => r.type === "op.called");
657
+ if (!made.length) return undefined;
658
+ const same = (what: (record: (typeof made)[number], call: Call) => boolean) =>
659
+ made.length <= calls.length && made.every((r, i) => what(r, calls[i]!));
615
660
  const differs = [
616
- ...(first.op === opId ? [] : ["op"]),
617
- ...(sameJson(first.input, input) ? [] : ["input"]),
618
- ...(sameJson(first.connection ?? null, connection ?? null) ? [] : ["connection"]),
661
+ ...(same((r, c) => r.op === c.op.id) ? [] : ["op"]),
662
+ ...(same((r, c) => sameJson(r.input, recorded(c.op, c.input))) ? [] : ["input"]),
663
+ ...(sameJson(made[0]!.connection ?? null, connection ?? null) ? [] : ["connection"]),
619
664
  ];
620
665
  if (differs.length) {
621
666
  throw new SanomaError(
@@ -624,18 +669,16 @@ export class SanomaClient {
624
669
  { runId, differs },
625
670
  );
626
671
  }
627
- if (first.error) {
672
+ const failed = made.find((r) => r.error);
673
+ if (failed?.error) {
628
674
  throw new SanomaError(
629
675
  "driver_failed",
630
- `${opId} failed when first called as ${runId}: ${first.error.message}; call again with another id`,
631
- {
632
- runId,
633
- op: opId,
634
- error: first.error,
635
- },
676
+ `${op.id} failed when first called as ${runId}: ${failed.error.message}; call again with another id`,
677
+ { runId, op: op.id, error: failed.error },
636
678
  );
637
679
  }
638
- return { output: first.output };
680
+ if (made.length < calls.length) return { partial: true };
681
+ return { output: isBatch(op) ? made.map((r) => r.output) : made[0]!.output };
639
682
  }
640
683
 
641
684
  /**
@@ -713,6 +756,14 @@ export class SanomaClient {
713
756
  return this.audit.store.read(runId);
714
757
  }
715
758
 
759
+ /**
760
+ * The calls that acted on `target` (a collection's record, `data:outbound/Acme`; a resource
761
+ * instance) across every run, direct writes among them, newest first, at most `limit`.
762
+ */
763
+ history(target: string, limit: number = LIST_LIMIT.default): Promise<LedgerRecord[]> {
764
+ return this.audit.store.readByTarget(target, limit);
765
+ }
766
+
716
767
  /**
717
768
  * The app's runs, newest first: the latest `limit`, or the latest `limit` with a `status`.
718
769
  * Only the approvals tell `running` from `waiting`, so those read PENDING runs a page at a