@sanoma-ai/workflows 0.0.0-stage → 0.2.0

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 (176) hide show
  1. package/LICENSE +105 -0
  2. package/README.md +544 -2
  3. package/dist/apply-steps.d.ts +6 -0
  4. package/dist/apply-steps.js +93 -0
  5. package/dist/apply-steps.js.map +1 -0
  6. package/dist/apply.d.ts +197 -0
  7. package/dist/apply.js +470 -0
  8. package/dist/apply.js.map +1 -0
  9. package/dist/approvals.d.ts +46 -0
  10. package/dist/approvals.js +111 -0
  11. package/dist/approvals.js.map +1 -0
  12. package/dist/ast.d.ts +57 -0
  13. package/dist/ast.js +81 -0
  14. package/dist/ast.js.map +1 -0
  15. package/dist/call.d.ts +37 -0
  16. package/dist/call.js +417 -0
  17. package/dist/call.js.map +1 -0
  18. package/dist/client.d.ts +203 -0
  19. package/dist/client.js +457 -0
  20. package/dist/client.js.map +1 -0
  21. package/dist/config.d.ts +126 -0
  22. package/dist/config.js +264 -0
  23. package/dist/config.js.map +1 -0
  24. package/dist/credentials.d.ts +55 -0
  25. package/dist/credentials.js +261 -0
  26. package/dist/credentials.js.map +1 -0
  27. package/dist/cucumber.d.ts +64 -0
  28. package/dist/cucumber.js +158 -0
  29. package/dist/cucumber.js.map +1 -0
  30. package/dist/datafile.d.ts +71 -0
  31. package/dist/datafile.js +380 -0
  32. package/dist/datafile.js.map +1 -0
  33. package/dist/db.d.ts +58 -0
  34. package/dist/db.js +179 -0
  35. package/dist/db.js.map +1 -0
  36. package/dist/define.d.ts +205 -0
  37. package/dist/define.js +103 -0
  38. package/dist/define.js.map +1 -0
  39. package/dist/describe.d.ts +141 -0
  40. package/dist/describe.js +135 -0
  41. package/dist/describe.js.map +1 -0
  42. package/dist/drift.d.ts +127 -0
  43. package/dist/drift.js +167 -0
  44. package/dist/drift.js.map +1 -0
  45. package/dist/env.d.ts +49 -0
  46. package/dist/env.js +83 -0
  47. package/dist/env.js.map +1 -0
  48. package/dist/errors.d.ts +88 -0
  49. package/dist/errors.js +148 -0
  50. package/dist/errors.js.map +1 -0
  51. package/dist/expectations.d.ts +26 -0
  52. package/dist/expectations.js +162 -0
  53. package/dist/expectations.js.map +1 -0
  54. package/dist/fake.d.ts +55 -0
  55. package/dist/fake.js +87 -0
  56. package/dist/fake.js.map +1 -0
  57. package/dist/fill.d.ts +9 -0
  58. package/dist/fill.js +17 -0
  59. package/dist/fill.js.map +1 -0
  60. package/dist/host.d.ts +56 -0
  61. package/dist/host.js +45 -0
  62. package/dist/host.js.map +1 -0
  63. package/dist/index.d.ts +23 -0
  64. package/dist/index.js +29 -0
  65. package/dist/index.js.map +1 -0
  66. package/dist/ledger.d.ts +217 -0
  67. package/dist/ledger.js +251 -0
  68. package/dist/ledger.js.map +1 -0
  69. package/dist/lint.d.ts +32 -0
  70. package/dist/lint.js +168 -0
  71. package/dist/lint.js.map +1 -0
  72. package/dist/lockfile.d.ts +36 -0
  73. package/dist/lockfile.js +69 -0
  74. package/dist/lockfile.js.map +1 -0
  75. package/dist/log.d.ts +24 -0
  76. package/dist/log.js +83 -0
  77. package/dist/log.js.map +1 -0
  78. package/dist/op.d.ts +260 -0
  79. package/dist/op.js +188 -0
  80. package/dist/op.js.map +1 -0
  81. package/dist/outline.d.ts +179 -0
  82. package/dist/outline.js +506 -0
  83. package/dist/outline.js.map +1 -0
  84. package/dist/paths.d.ts +11 -0
  85. package/dist/paths.js +30 -0
  86. package/dist/paths.js.map +1 -0
  87. package/dist/plugin.d.ts +28 -0
  88. package/dist/plugin.js +25 -0
  89. package/dist/plugin.js.map +1 -0
  90. package/dist/policy.d.ts +114 -0
  91. package/dist/policy.js +60 -0
  92. package/dist/policy.js.map +1 -0
  93. package/dist/resource.d.ts +378 -0
  94. package/dist/resource.js +430 -0
  95. package/dist/resource.js.map +1 -0
  96. package/dist/resources.d.ts +77 -0
  97. package/dist/resources.js +244 -0
  98. package/dist/resources.js.map +1 -0
  99. package/dist/run.d.ts +101 -0
  100. package/dist/run.js +7 -0
  101. package/dist/run.js.map +1 -0
  102. package/dist/sandbox.d.ts +33 -0
  103. package/dist/sandbox.js +127 -0
  104. package/dist/sandbox.js.map +1 -0
  105. package/dist/scenario.d.ts +221 -0
  106. package/dist/scenario.js +1104 -0
  107. package/dist/scenario.js.map +1 -0
  108. package/dist/scenarios.d.ts +55 -0
  109. package/dist/scenarios.js +71 -0
  110. package/dist/scenarios.js.map +1 -0
  111. package/dist/secret.d.ts +42 -0
  112. package/dist/secret.js +58 -0
  113. package/dist/secret.js.map +1 -0
  114. package/dist/shared.d.ts +69 -0
  115. package/dist/shared.js +84 -0
  116. package/dist/shared.js.map +1 -0
  117. package/dist/source.d.ts +58 -0
  118. package/dist/source.js +54 -0
  119. package/dist/source.js.map +1 -0
  120. package/dist/steps.d.ts +76 -0
  121. package/dist/steps.js +23 -0
  122. package/dist/steps.js.map +1 -0
  123. package/dist/version.d.ts +29 -0
  124. package/dist/version.js +76 -0
  125. package/dist/version.js.map +1 -0
  126. package/dist/worker.d.ts +37 -0
  127. package/dist/worker.js +228 -0
  128. package/dist/worker.js.map +1 -0
  129. package/dist/writer.d.ts +35 -0
  130. package/dist/writer.js +201 -0
  131. package/dist/writer.js.map +1 -0
  132. package/oxlint.json +128 -0
  133. package/package.json +96 -3
  134. package/src/apply-steps.ts +110 -0
  135. package/src/apply.ts +626 -0
  136. package/src/approvals.ts +157 -0
  137. package/src/ast.ts +115 -0
  138. package/src/call.ts +483 -0
  139. package/src/client.ts +604 -0
  140. package/src/config.ts +404 -0
  141. package/src/credentials.ts +296 -0
  142. package/src/cucumber.ts +230 -0
  143. package/src/datafile.ts +481 -0
  144. package/src/db.ts +197 -0
  145. package/src/define.ts +285 -0
  146. package/src/describe.ts +282 -0
  147. package/src/drift.ts +259 -0
  148. package/src/env.ts +122 -0
  149. package/src/errors.ts +202 -0
  150. package/src/expectations.ts +191 -0
  151. package/src/fake.ts +156 -0
  152. package/src/fill.ts +24 -0
  153. package/src/host.ts +96 -0
  154. package/src/index.ts +143 -0
  155. package/src/ledger.ts +419 -0
  156. package/src/lint.ts +208 -0
  157. package/src/lockfile.ts +95 -0
  158. package/src/log.ts +82 -0
  159. package/src/op.ts +402 -0
  160. package/src/outline.ts +620 -0
  161. package/src/paths.ts +32 -0
  162. package/src/plugin.ts +37 -0
  163. package/src/policy.ts +139 -0
  164. package/src/resource.ts +670 -0
  165. package/src/resources.ts +311 -0
  166. package/src/run.ts +106 -0
  167. package/src/sandbox.ts +161 -0
  168. package/src/scenario.ts +1365 -0
  169. package/src/scenarios.ts +111 -0
  170. package/src/secret.ts +92 -0
  171. package/src/shared.ts +126 -0
  172. package/src/source.ts +82 -0
  173. package/src/steps.ts +90 -0
  174. package/src/version.ts +86 -0
  175. package/src/worker.ts +264 -0
  176. package/src/writer.ts +225 -0
@@ -0,0 +1,111 @@
1
+ import { type RunSummary, SanomaClient } from "./client.ts";
2
+ import { resolveConfig, type SanomaConfig } from "./config.ts";
3
+ import type { Principal } from "./define.ts";
4
+ import { type LedgerRecord, memoryLedger } from "./ledger.ts";
5
+ import { type Check, check, drive, loadScenarios, type Scenario, type Scope } from "./scenario.ts";
6
+ import { errorMessage } from "./shared.ts";
7
+ import { startWorker } from "./worker.ts";
8
+
9
+ /*
10
+ * `@sanoma-ai/workflows/scenarios`: runs a config's scenarios as sandbox runs, with no test
11
+ * framework. Server-side only, like `./scenario`: it loads Cucumber and starts a worker.
12
+ * `sanoma check` runs every scenario through `runScenarios`; `@sanoma-ai/testing/scenarios` wraps
13
+ * `runScenario` in a vitest test per scenario.
14
+ */
15
+
16
+ export interface RunScenarioOptions {
17
+ /** A client of the worker that runs the scenario, whose connectors carry the fakes and whose root has the scenarios. */
18
+ client: SanomaClient;
19
+ /** What the scenario was loaded from (`loadScenarios`), whose feature files its run is checked against: the resolved config. */
20
+ scope: Scope;
21
+ /** Who starts the run. Defaults to `{ id: "scenarios" }`. */
22
+ startedBy?: Principal;
23
+ /** How long the run may take, passed to `drive`. */
24
+ timeoutMs?: number;
25
+ }
26
+
27
+ /**
28
+ * Runs a scenario as a sandbox run: starts its workflow with its input, decides its approvals
29
+ * as it says, and checks the run's ledger against it (`check`: Cucumber runs its `Then` steps).
30
+ */
31
+ export async function runScenario(
32
+ scenario: Scenario,
33
+ { client, scope, startedBy = { id: "scenarios" }, timeoutMs }: RunScenarioOptions,
34
+ ): Promise<{ runId: string; run: RunSummary; ledger: LedgerRecord[]; checks: Check[] }> {
35
+ const runId = await client.start(scenario.workflow, scenario.input, { startedBy, sandbox: scenario.name });
36
+ const run = await drive(client, runId, scenario, { timeoutMs });
37
+ const ledger = await client.ledger(runId);
38
+ return { runId, run, ledger, checks: await check(scope, scenario, ledger) };
39
+ }
40
+
41
+ /** One step of a scenario that did not hold, or a file whose scenarios could not be read (`step` is then `did not load`). */
42
+ export interface ScenarioFailure {
43
+ /** The scenario's name, or the feature file's for one that did not load. */
44
+ scenario: string;
45
+ step: string;
46
+ message: string;
47
+ }
48
+
49
+ export interface RunScenariosOptions {
50
+ /** The Postgres the worker and the client run on, instead of the config's: a database of its own. */
51
+ databaseUrl: string;
52
+ /** Who starts the runs. Defaults to `{ id: "scenarios" }`. */
53
+ startedBy?: Principal;
54
+ /** How long each run may take (`drive`'s; 15 seconds by default). */
55
+ timeoutMs?: number;
56
+ /** Receives a line per scenario as it ends. */
57
+ log?: (line: string) => void;
58
+ }
59
+
60
+ /**
61
+ * Runs every scenario in `scenarios/` in the config's root, one after another, as sandbox runs
62
+ * on a worker it starts on `databaseUrl` with an in-memory ledger (the config's `ledger` is not
63
+ * written), and stops when done. `passed` names the scenarios whose every check held; `failed`
64
+ * has one entry per step that did not, with `check`'s detail, and one per feature file that
65
+ * could not be read, with why. A scenario whose run cannot be driven to its end (an approval
66
+ * with no decision, a run still going after `timeoutMs`) fails with that error at its `When`
67
+ * step. A config whose root has no `scenarios/`, or none in it, passes nothing and fails nothing.
68
+ */
69
+ export async function runScenarios(
70
+ config: SanomaConfig,
71
+ { databaseUrl, startedBy, timeoutMs, log }: RunScenariosOptions,
72
+ ): Promise<{ passed: string[]; failed: ScenarioFailure[] }> {
73
+ const shared: SanomaConfig = { ...config, databaseUrl, ledger: memoryLedger() };
74
+ const resolved = resolveConfig(shared);
75
+ const { scenarios, errors } = await loadScenarios(resolved);
76
+ const failed: ScenarioFailure[] = errors.map(({ file, message }) => ({
77
+ scenario: file,
78
+ step: "did not load",
79
+ message,
80
+ }));
81
+ const passed: string[] = [];
82
+ if (scenarios.length === 0) return { passed, failed };
83
+
84
+ // Quiet: the scenarios call the fakes alone, so no vendor need be configured.
85
+ const worker = await startWorker(shared, { promote: true, quiet: true });
86
+ try {
87
+ const client = await SanomaClient.connect(shared);
88
+ try {
89
+ for (const scenario of scenarios) {
90
+ const before = failed.length;
91
+ try {
92
+ const { checks } = await runScenario(scenario, { client, scope: resolved, startedBy, timeoutMs });
93
+ for (const c of checks) {
94
+ if (!c.ok) failed.push({ scenario: scenario.name, step: c.step, message: c.detail ?? "did not hold" });
95
+ }
96
+ } catch (err) {
97
+ const when = scenario.steps.find((s) => s.kind === "when")?.text ?? `${scenario.workflow} runs`;
98
+ failed.push({ scenario: scenario.name, step: when, message: errorMessage(err) });
99
+ }
100
+ const ok = failed.length === before;
101
+ if (ok) passed.push(scenario.name);
102
+ log?.(`${ok ? "passed" : "failed"} ${scenario.file} › ${scenario.name}`);
103
+ }
104
+ } finally {
105
+ await client.close();
106
+ }
107
+ } finally {
108
+ await worker.stop();
109
+ }
110
+ return { passed, failed };
111
+ }
package/src/secret.ts ADDED
@@ -0,0 +1,92 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { DriverError } from "./op.ts";
3
+
4
+ // Secrets are named, never written: `secret("NAME")` in a data file is a marker for a value in
5
+ // the company's secrets store (the credentials table), allowed only at a write-only field. The
6
+ // marker is what declarations, checkpoints and the ledger hold; a driver resolves it
7
+ // (`resolveSecrets`) just before the vendor call. The `./resources` entry, which data files import
8
+ // `secret` from (`sanoma/resources` re-exports it).
9
+
10
+ /** What `secret()` makes: a plain object, so it reads the same as JSON and across copies of this package. */
11
+ export interface Secret {
12
+ readonly $secret: string;
13
+ /** When none is stored, apply makes the value (`generateSecret`) and stores it before the vendor call. */
14
+ readonly generate?: true;
15
+ }
16
+
17
+ /** A secret's name: an environment variable's, upper case. */
18
+ export const SECRET_NAME = /^[A-Z][A-Z0-9_]*$/;
19
+
20
+ /**
21
+ * True for one of sanoma's own secrets (`SANOMA_…`, its key and database URLs among them): only a
22
+ * host, which runs the company, is given them, at its `secrets` input (`HostSpec`); never a vendor.
23
+ */
24
+ export const isSanomaSecret = (name: string) => name.startsWith("SANOMA_");
25
+
26
+ /**
27
+ * Names a stored secret for a write-only field of a data file: `secret("SUPABASE_DB_PASSWORD",
28
+ * { generate: true })`. With `generate`, apply makes the value when none is stored.
29
+ */
30
+ export function secret(name: string, options?: { generate: true }): Secret {
31
+ if (!SECRET_NAME.test(name)) {
32
+ throw new Error(`secret("${name}"): a secret's name is an environment variable's, upper case: A-Z, 0-9 and _`);
33
+ }
34
+ return Object.freeze(options?.generate ? { $secret: name, generate: true as const } : { $secret: name });
35
+ }
36
+
37
+ /** True for a secret marker, `{ $secret: "NAME" }`. */
38
+ export const isSecret = (value: unknown): value is Secret =>
39
+ typeof value === "object" && value !== null && typeof (value as { $secret?: unknown }).$secret === "string";
40
+
41
+ /** The secrets store, as a driver sees it (`CallContext.secrets`). */
42
+ export interface Secrets {
43
+ /** The stored value, or undefined when there is none. */
44
+ get(name: string): Promise<string | undefined>;
45
+ /** The stored value; when there is none, `make()`'s, stored first. */
46
+ ensure(name: string, make: () => string): Promise<string>;
47
+ }
48
+
49
+ /** A value for a secret with `generate`: 32 random bytes, base64url. */
50
+ export const generateSecret = (): string => randomBytes(32).toString("base64url");
51
+
52
+ /** `T` with each secret marker's place holding its value: what `resolveSecrets` returns. */
53
+ export type Resolved<T> = T extends Secret
54
+ ? string
55
+ : T extends readonly (infer I)[]
56
+ ? Resolved<I>[]
57
+ : T extends object
58
+ ? { [K in keyof T]: Resolved<T[K]> }
59
+ : T;
60
+
61
+ /**
62
+ * `value` with each secret marker in it replaced by the stored value: generated and stored first
63
+ * for one with `generate` and none stored. A marker naming nothing stored fails the call, for good.
64
+ */
65
+ export async function resolveSecrets<T>(value: T, secrets: Secrets): Promise<Resolved<T>> {
66
+ const resolve = async (v: unknown): Promise<unknown> => {
67
+ if (isSecret(v)) {
68
+ const stored = v.generate ? await secrets.ensure(v.$secret, generateSecret) : await secrets.get(v.$secret);
69
+ if (stored === undefined) {
70
+ throw new DriverError(`The secret ${v.$secret} is not set: store it before applying`, { retryable: false });
71
+ }
72
+ return stored;
73
+ }
74
+ if (Array.isArray(v)) return Promise.all(v.map(resolve));
75
+ if (typeof v !== "object" || v === null) return v;
76
+ const entries = await Promise.all(Object.entries(v).map(async ([k, item]) => [k, await resolve(item)] as const));
77
+ return Object.fromEntries(entries);
78
+ };
79
+ return (await resolve(value)) as Resolved<T>;
80
+ }
81
+
82
+ /** A secrets store in memory, for fakes, sandbox runs and tests. */
83
+ export function memorySecrets(initial: Record<string, string> = {}): Secrets {
84
+ const values = new Map(Object.entries(initial));
85
+ return {
86
+ get: async (name) => values.get(name),
87
+ async ensure(name, make) {
88
+ if (!values.has(name)) values.set(name, make());
89
+ return values.get(name)!;
90
+ },
91
+ };
92
+ }
package/src/shared.ts ADDED
@@ -0,0 +1,126 @@
1
+ import type { CredentialStatus } from "./env.ts";
2
+ import type { ApprovalState, Approver, Principal } from "./define.ts";
3
+ import type { ResourceProblem } from "./resources.ts";
4
+ import type { OutlineNode } from "./outline.ts";
5
+
6
+ // Types only: resource types as a UI reads them. Erased from the bundle.
7
+ export type {
8
+ Declared,
9
+ Declaring,
10
+ FieldReference,
11
+ References,
12
+ Resource,
13
+ ResourceFields,
14
+ ResourceSpec,
15
+ } from "./resource.ts";
16
+ export type { DeclaredResource, ResourceProblem } from "./resources.ts";
17
+ export type { DriftField, DriftReport, DriftResult, DriftStatus } from "./drift.ts";
18
+ export type { ApplyPlan, ApplyReport, ApplyResult, PlanAnswer, PlanStep } from "./apply.ts";
19
+ export type { CredentialStatus } from "./env.ts";
20
+
21
+ // What a UI in the browser may use from the runtime, besides types: `@sanoma-ai/workflows/shared`.
22
+ // Nothing here imports DBOS, Node or zod, so a browser bundle can carry it; the runtime uses the
23
+ // same functions, so a page answers these questions the way a run does.
24
+
25
+ /**
26
+ * What a `ctx.sleep` without a `{ name }` is called, in its outline node and its ledger record
27
+ * (`name`); a name given may not be it.
28
+ */
29
+ export const DEFAULT_SLEEP_NAME = "Sleep";
30
+
31
+ /** The built-in drift workflow's name, which a config's own workflows may not take; a UI and the scenarios name its runs by it. */
32
+ export const DRIFT_WORKFLOW = "drift";
33
+
34
+ /**
35
+ * The built-in apply workflow's name: the only workflow that may call a writable resource type's
36
+ * change operations (`create`, `update`, `delete`).
37
+ */
38
+ export const APPLY_WORKFLOW = "apply";
39
+
40
+ /** The workflows the runtime adds itself, which a config's own may not be named: drift and apply. */
41
+ export const BUILT_IN_WORKFLOWS: ReadonlySet<string> = new Set([DRIFT_WORKFLOW, APPLY_WORKFLOW]);
42
+
43
+ /**
44
+ * Every run status, in the order a run may pass through them: waiting on the queue, running,
45
+ * waiting for an approval, or ended. `waiting` is `running` with an approval pending.
46
+ */
47
+ export const RUN_STATUSES = ["queued", "running", "waiting", "finished", "failed", "cancelled"] as const;
48
+
49
+ /** Where a run is: one of `RUN_STATUSES`, so a new status reaches every list and map of them. */
50
+ export type RunStatus = (typeof RUN_STATUSES)[number];
51
+
52
+ /** The statuses of a run that has ended: it reads no more decisions. */
53
+ export const ENDED_STATUSES = ["finished", "failed", "cancelled"] as const satisfies readonly RunStatus[];
54
+
55
+ /** True when the run has finished, failed or been cancelled. */
56
+ export const isEnded = (status: RunStatus): boolean => (ENDED_STATUSES as readonly RunStatus[]).includes(status);
57
+
58
+ /**
59
+ * True when `by` may decide the approval: `by.id` is the approver, or `by.groups` holds the
60
+ * approver group. Pure: everything it reads is already recorded.
61
+ */
62
+ export function mayDecide(approval: Pick<ApprovalState, "approver">, by: Principal): boolean {
63
+ const { approver } = approval;
64
+ return typeof approver === "string" ? approver === by.id : (by.groups?.includes(approver.group) ?? false);
65
+ }
66
+
67
+ /** Who may decide, as text: the person's id, or "group <name>". */
68
+ export const approverLabel = (approver: Approver): string =>
69
+ typeof approver === "string" ? approver : `group ${approver.group}`;
70
+
71
+ /** An error's message, or the value as text when it is not an Error. */
72
+ export const errorMessage = (err: unknown) => (err instanceof Error ? err.message : String(err));
73
+
74
+ /** True when an outline's op id, a computed segment shown as `*`, could be this op: `a.*.c` fits `a.b.c`. */
75
+ export function fitsOp(pattern: string, op: string): boolean {
76
+ const want = pattern.split(".");
77
+ const got = op.split(".");
78
+ return want.length === got.length && want.every((segment, i) => segment === "*" || segment === got[i]);
79
+ }
80
+
81
+ /** A node's child lists, in order: the lists a `path`'s segments index into. */
82
+ export const childLists = (node: OutlineNode): OutlineNode[][] => {
83
+ switch (node.kind) {
84
+ case "all":
85
+ return node.branches;
86
+ case "branch":
87
+ return node.cases;
88
+ case "each":
89
+ case "repeat":
90
+ return [node.body];
91
+ case "try":
92
+ return [node.body, node.handler];
93
+ default:
94
+ return [];
95
+ }
96
+ };
97
+
98
+ /** Every node of an outline, nested ones included, in order. */
99
+ export function flatten(nodes: readonly OutlineNode[]): OutlineNode[] {
100
+ return nodes.flatMap((node) => [node, ...flatten(childLists(node).flat())]);
101
+ }
102
+
103
+ /** Where a data-file problem is, `file:line:column`; undefined for one that is the config's, with no file. */
104
+ export const problemAt = (p: ResourceProblem): string | undefined =>
105
+ p.file === undefined ? undefined : `${p.file}:${p.line}:${p.column}`;
106
+
107
+ /**
108
+ * A problem as one line: `file:line:column: message`, or `file: message` without a position, or
109
+ * the message alone without a file.
110
+ */
111
+ export function problemLine(p: { file?: string; line?: number; column?: number; message: string }): string {
112
+ if (p.file === undefined) return p.message;
113
+ return p.line === undefined ? `${p.file}: ${p.message}` : `${p.file}:${p.line}:${p.column ?? 1}: ${p.message}`;
114
+ }
115
+
116
+ /**
117
+ * Whether a host is this machine: `localhost`, `127.x.x.x` or `::1` (bare, or bracketed as a URL's
118
+ * `hostname` has it). The one rule for what the app listens on and answers to, and for where the
119
+ * bin may start Postgres.
120
+ */
121
+ export const isLoopback = (host: string) =>
122
+ ["localhost", "::1", "[::1]"].includes(host) || /^127\.\d+\.\d+\.\d+$/.test(host);
123
+
124
+ /** Whether a driver's environment variable lets it run: set, or unset and optional. */
125
+ export const credentialReady = ({ status, optional }: CredentialStatus): boolean =>
126
+ status === "set" || (status === "missing" && optional);
package/src/source.ts ADDED
@@ -0,0 +1,82 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { z } from "zod";
4
+ import { defineConnector, defineDriver, DriverError } from "./op.ts";
5
+
6
+ // Where a company's files live, and how a change to them is recorded: a git repository on this
7
+ // machine, or one a host cloned. The runtime's writers (the app's Adopt, apply's lockfile) change
8
+ // files under `root`, then commit them through it. The launcher makes one; a config without one
9
+ // writes the files and commits nothing.
10
+
11
+ /** A company's repository, as the runtime writes to it. */
12
+ export interface Source {
13
+ /** The repository's root directory. */
14
+ root: string;
15
+ /**
16
+ * Records the files at `paths`, relative to `root`, as one change by `author` (who made it, as
17
+ * the app names them: a login or a typed name). Its revision; undefined when nothing changed.
18
+ */
19
+ commit(change: {
20
+ paths: string[];
21
+ message: string;
22
+ author: string;
23
+ }): { revision: string } | undefined | Promise<{ revision: string } | undefined>;
24
+ /** The revision `ref` names, the checked-out one by default. */
25
+ head(ref?: string): string | Promise<string>;
26
+ /** Pushes `ref` to the repository at `url`, signed in with `token`. */
27
+ push?(target: { url: string; token: string; ref: string }): void | Promise<void>;
28
+ }
29
+
30
+ /**
31
+ * The built-in operation `sanoma.source.resolve`: the commit `ref` names in the repository at
32
+ * `url` now, by `git ls-remote` (a branch first, then a tag, peeled; a full commit id is itself).
33
+ * Apply calls it to fill a host's revision before it plans, and drift to see a merge. `resolveConfig`
34
+ * registers it when a connector declares a host type.
35
+ */
36
+ export const sourceOps = defineConnector("sanoma", {
37
+ source: {
38
+ resolve: {
39
+ effect: "read",
40
+ idempotent: true,
41
+ description: "Resolve a branch or tag of a git repository to the commit it names now",
42
+ input: z.object({ url: z.string().min(1), ref: z.string().min(1) }),
43
+ output: z.object({ revision: z.string() }),
44
+ target: ({ url, ref }: { url: string; ref: string }) => `${url}#${ref}`,
45
+ },
46
+ },
47
+ });
48
+
49
+ export const SOURCE_RESOLVE = "sanoma.source.resolve" satisfies typeof sourceOps.source.resolve.id;
50
+
51
+ /** The driver of `sanoma.source.resolve`: git on this machine, with its own credential helpers. */
52
+ export const sourceDriver = defineDriver(sourceOps, {
53
+ source: { resolve: async ({ url, ref }) => ({ revision: await resolveRevision(url, ref) }) },
54
+ });
55
+
56
+ const COMMIT = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
57
+
58
+ /** The commit `ref` (a branch, a tag, `HEAD`, a full ref or a commit id) names in the repository at `url`. */
59
+ export async function resolveRevision(url: string, ref: string): Promise<string> {
60
+ if (COMMIT.test(ref)) return ref;
61
+ const { stdout } = await promisify(execFile)("git", ["ls-remote", "--", url, ref, `${ref}^{}`], {
62
+ // Never a prompt for credentials: a repository that needs them and has none fails.
63
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
64
+ timeout: 30_000,
65
+ }).catch((err: { stderr?: string; message: string }) => {
66
+ throw new DriverError(`git ls-remote ${url} failed: ${err.stderr?.trim() || err.message}`, {
67
+ retryable: true,
68
+ cause: err,
69
+ });
70
+ });
71
+ const refs = new Map(
72
+ stdout.split("\n").flatMap((line) => {
73
+ const [sha, name] = line.split("\t");
74
+ return sha && name ? [[name, sha] as const] : [];
75
+ }),
76
+ );
77
+ for (const name of [`refs/heads/${ref}`, `refs/tags/${ref}^{}`, `refs/tags/${ref}`, ref]) {
78
+ const sha = refs.get(name);
79
+ if (sha) return sha;
80
+ }
81
+ throw new DriverError(`${url} has no branch or tag ${ref}`, { retryable: false, vendorCode: "no_such_ref" });
82
+ }
package/src/steps.ts ADDED
@@ -0,0 +1,90 @@
1
+ import type { LedgerRecord } from "./ledger.ts";
2
+ import type { Op } from "./op.ts";
3
+
4
+ /*
5
+ * Steps a connector (or a company) ships for its Gherkin tests, as plain data: expressions and
6
+ * functions, never Cucumber's own registration, which Sanoma does from the config. Kept free of
7
+ * Cucumber, Gherkin and faker, so a connector's package can import it on its main entry.
8
+ */
9
+
10
+ /** Any operation, whatever its input and output. */
11
+ type AnyOp<I = any, O = any> = Op<string, string, string, I, O>;
12
+
13
+ /**
14
+ * What a `Given` step sets up before the run: a call through an operation of the run's fake,
15
+ * with the fields of its input to set (the rest is made up), or a fault to inject into its next
16
+ * call. `as` names a call's output, so a later step's table of an operation's own test can use
17
+ * its fields: `$draft.id`.
18
+ */
19
+ export type Seed =
20
+ | { op: AnyOp; input: Record<string, unknown>; as?: string }
21
+ | { op: AnyOp; fault: "failNext" | "rateLimit" | "loseReply" };
22
+
23
+ /** A call through an operation: what it was given, and what it returned. */
24
+ export interface Call<I, O> {
25
+ input: I;
26
+ output: O;
27
+ }
28
+
29
+ /** A run as a `Then` step reads it. */
30
+ export interface RunView {
31
+ /** Every record of the run, in order. */
32
+ readonly records: readonly LedgerRecord[];
33
+ /** The calls the run made through the operation, in order: those that did not fail. */
34
+ calls<I, O>(op: AnyOp<I, O>): Call<I, O>[];
35
+ /** The calls the scenario's `Given` steps made through the operation, to seed the run's fake. */
36
+ seeded<I, O>(op: AnyOp<I, O>): Call<I, O>[];
37
+ /** The seeded calls, then the run's own: everything made through the operation. */
38
+ made<I, O>(op: AnyOp<I, O>): Call<I, O>[];
39
+ }
40
+
41
+ /**
42
+ * What a `Then` step expects. `check` reads the run and throws, or returns why the expectation
43
+ * does not hold; returning nothing passes. `op` and `called` say what the step is about, for the
44
+ * graph: the operation, and whether it expects a call to it (true) or rules one out (false).
45
+ */
46
+ export interface Expect {
47
+ op?: AnyOp;
48
+ called?: boolean;
49
+ check(run: RunView): string | undefined | void;
50
+ }
51
+
52
+ /** One step definition: a Cucumber expression, and what the step says, from its parameters' values. */
53
+ export type StepDefinition =
54
+ | { kind: "given"; expression: string; fn: (...values: any[]) => Seed }
55
+ | { kind: "then"; expression: string; fn: (...values: any[]) => Expect };
56
+
57
+ /** A connector's or a company's steps: `owner` (the vendor, say) heads them in a list of known steps. */
58
+ export interface Steps {
59
+ readonly owner: string;
60
+ readonly steps: readonly StepDefinition[];
61
+ }
62
+
63
+ /**
64
+ * Declares steps for Gherkin tests, as data the config's scenarios and a connector's own feature
65
+ * files can use: `given` steps say what to seed (`Seed`), `then` steps what to expect
66
+ * (`Expect`). A step's function gets its expression's values, in order, and must be pure:
67
+ * compiling a scenario calls it, and so does the app, whenever a feature file changes.
68
+ *
69
+ * ```ts
70
+ * export const ghostSteps = defineSteps("ghost", ({ given }) => {
71
+ * given("a Ghost draft titled {string}", (title: string) => ({ op: ghostOps.post.create, input: { title }, as: "draft" }));
72
+ * });
73
+ * ```
74
+ */
75
+ export function defineSteps(
76
+ owner: string,
77
+ define: (steps: {
78
+ given(expression: string, fn: (...values: any[]) => Seed): void;
79
+ then(expression: string, fn: (...values: any[]) => Expect): void;
80
+ }) => void,
81
+ ): Steps {
82
+ const steps: StepDefinition[] = [];
83
+ define({
84
+ given: (expression, fn) => void steps.push({ kind: "given", expression, fn }),
85
+ // Named for Gherkin's keyword; the object goes to `define` only, which never awaits it.
86
+ // oxlint-disable-next-line unicorn/no-thenable
87
+ then: (expression, fn) => void steps.push({ kind: "then", expression, fn }),
88
+ });
89
+ return Object.freeze({ owner, steps: Object.freeze(steps) });
90
+ }
package/src/version.ts ADDED
@@ -0,0 +1,86 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import { createRequire } from "node:module";
4
+ import { dirname, join } from "node:path";
5
+ import { jsonSchemaOf, opIdsOf, type WorkflowDefinition } from "./define.ts";
6
+ import { upFrom } from "./paths.ts";
7
+
8
+ /** This package's version. A unit test keeps it equal to package.json. */
9
+ export const RUNTIME_VERSION = "0.2.0";
10
+
11
+ /**
12
+ * The sequence of DBOS calls the runtime makes around each `ctx` call: policy step, approval
13
+ * events and messages, the driver step. Bump it whenever that sequence changes, even without a
14
+ * package release, so a run in flight on the old layout is not replayed against the new one.
15
+ * 2: calls run in program order; a decision event per approval (2026-10-07).
16
+ */
17
+ export const STEP_LAYOUT = 2;
18
+
19
+ let dbosVersion: string | undefined;
20
+
21
+ /** The installed DBOS SDK's version, read from its package.json (which its exports don't expose). */
22
+ function installedDbosVersion(): string {
23
+ dbosVersion ??= upFrom(dirname(createRequire(import.meta.url).resolve("@dbos-inc/dbos-sdk")), (dir) => {
24
+ let pkg: { name?: string; version?: string };
25
+ try {
26
+ pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as typeof pkg;
27
+ } catch (err) {
28
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return undefined;
29
+ throw err;
30
+ }
31
+ return pkg.name === "@dbos-inc/dbos-sdk" && pkg.version ? pkg.version : undefined;
32
+ });
33
+ if (!dbosVersion) throw new Error("Cannot find the package.json of @dbos-inc/dbos-sdk");
34
+ return dbosVersion;
35
+ }
36
+
37
+ /**
38
+ * The application version DBOS stamps on every run, always `<appName>@…`: DBOS version names
39
+ * are unique per system database across apps. A worker recovers and dequeues only runs of its
40
+ * own version, so the version must change whenever replaying an old run on the new code could
41
+ * go wrong.
42
+ *
43
+ * It is a hash of the app name, `RUNTIME_VERSION`, `STEP_LAYOUT`, the installed DBOS version,
44
+ * and for each workflow its name, the source text of its `run` function, the ids of the
45
+ * operations it uses and its input schema as JSON Schema: DBOS's own automatic versioning, over
46
+ * the workflow code instead of the runtime's one registration wrapper. The hash cannot see
47
+ * functions `run` calls that live elsewhere (their source is not reachable from the definition),
48
+ * op schemas, drivers or the policy. DBOS's `DBOS__APPVERSION` environment variable names a
49
+ * version instead (a git commit, say), still prefixed with the app name.
50
+ */
51
+ export function computeVersion({
52
+ appName,
53
+ workflows: definitions,
54
+ }: {
55
+ /** The resolved config's `appName`. */
56
+ appName: string;
57
+ workflows: readonly WorkflowDefinition<any, any>[];
58
+ }): string {
59
+ const named = process.env.DBOS__APPVERSION?.trim();
60
+ if (named) return `${appName}@${named}`;
61
+ const workflows = definitions
62
+ .map(
63
+ (wf) =>
64
+ [
65
+ wf.name,
66
+ wf.run.toString(),
67
+ opIdsOf(wf),
68
+ // The input schema is parsed outside steps on replay, so a change to it must change the version.
69
+ inputSchemaOf(wf),
70
+ ] as const,
71
+ )
72
+ .toSorted(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
73
+ const hash = createHash("sha256")
74
+ .update(JSON.stringify([appName, RUNTIME_VERSION, STEP_LAYOUT, installedDbosVersion(), ...workflows]))
75
+ .digest("hex");
76
+ return `${appName}@${hash}`;
77
+ }
78
+
79
+ /** The workflow's input schema as the hash sees it: its JSON Schema, or its text when zod can't convert it. */
80
+ function inputSchemaOf(wf: WorkflowDefinition<any, any>): unknown {
81
+ try {
82
+ return jsonSchemaOf(wf.input);
83
+ } catch {
84
+ return String(wf.input);
85
+ }
86
+ }