@sanoma-ai/workflows 0.0.0-stage → 0.1.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 (148) hide show
  1. package/LICENSE +105 -0
  2. package/README.md +493 -2
  3. package/dist/approvals.d.ts +46 -0
  4. package/dist/approvals.js +111 -0
  5. package/dist/approvals.js.map +1 -0
  6. package/dist/ast.d.ts +57 -0
  7. package/dist/ast.js +81 -0
  8. package/dist/ast.js.map +1 -0
  9. package/dist/call.d.ts +33 -0
  10. package/dist/call.js +402 -0
  11. package/dist/call.js.map +1 -0
  12. package/dist/client.d.ts +167 -0
  13. package/dist/client.js +400 -0
  14. package/dist/client.js.map +1 -0
  15. package/dist/config.d.ts +126 -0
  16. package/dist/config.js +247 -0
  17. package/dist/config.js.map +1 -0
  18. package/dist/credentials.d.ts +30 -0
  19. package/dist/credentials.js +193 -0
  20. package/dist/credentials.js.map +1 -0
  21. package/dist/cucumber.d.ts +64 -0
  22. package/dist/cucumber.js +158 -0
  23. package/dist/cucumber.js.map +1 -0
  24. package/dist/datafile.d.ts +62 -0
  25. package/dist/datafile.js +326 -0
  26. package/dist/datafile.js.map +1 -0
  27. package/dist/db.d.ts +58 -0
  28. package/dist/db.js +179 -0
  29. package/dist/db.js.map +1 -0
  30. package/dist/define.d.ts +205 -0
  31. package/dist/define.js +103 -0
  32. package/dist/define.js.map +1 -0
  33. package/dist/describe.d.ts +135 -0
  34. package/dist/describe.js +127 -0
  35. package/dist/describe.js.map +1 -0
  36. package/dist/drift.d.ts +75 -0
  37. package/dist/drift.js +90 -0
  38. package/dist/drift.js.map +1 -0
  39. package/dist/env.d.ts +49 -0
  40. package/dist/env.js +83 -0
  41. package/dist/env.js.map +1 -0
  42. package/dist/errors.d.ts +88 -0
  43. package/dist/errors.js +148 -0
  44. package/dist/errors.js.map +1 -0
  45. package/dist/expectations.d.ts +26 -0
  46. package/dist/expectations.js +137 -0
  47. package/dist/expectations.js.map +1 -0
  48. package/dist/fake.d.ts +55 -0
  49. package/dist/fake.js +87 -0
  50. package/dist/fake.js.map +1 -0
  51. package/dist/fill.d.ts +9 -0
  52. package/dist/fill.js +17 -0
  53. package/dist/fill.js.map +1 -0
  54. package/dist/index.d.ts +19 -0
  55. package/dist/index.js +25 -0
  56. package/dist/index.js.map +1 -0
  57. package/dist/ledger.d.ts +217 -0
  58. package/dist/ledger.js +251 -0
  59. package/dist/ledger.js.map +1 -0
  60. package/dist/lint.d.ts +32 -0
  61. package/dist/lint.js +168 -0
  62. package/dist/lint.js.map +1 -0
  63. package/dist/log.d.ts +24 -0
  64. package/dist/log.js +83 -0
  65. package/dist/log.js.map +1 -0
  66. package/dist/op.d.ts +257 -0
  67. package/dist/op.js +188 -0
  68. package/dist/op.js.map +1 -0
  69. package/dist/outline.d.ts +179 -0
  70. package/dist/outline.js +506 -0
  71. package/dist/outline.js.map +1 -0
  72. package/dist/paths.d.ts +11 -0
  73. package/dist/paths.js +30 -0
  74. package/dist/paths.js.map +1 -0
  75. package/dist/plugin.d.ts +28 -0
  76. package/dist/plugin.js +25 -0
  77. package/dist/plugin.js.map +1 -0
  78. package/dist/policy.d.ts +114 -0
  79. package/dist/policy.js +60 -0
  80. package/dist/policy.js.map +1 -0
  81. package/dist/resource.d.ts +212 -0
  82. package/dist/resource.js +285 -0
  83. package/dist/resource.js.map +1 -0
  84. package/dist/resources.d.ts +62 -0
  85. package/dist/resources.js +197 -0
  86. package/dist/resources.js.map +1 -0
  87. package/dist/run.d.ts +87 -0
  88. package/dist/run.js +2 -0
  89. package/dist/run.js.map +1 -0
  90. package/dist/sandbox.d.ts +33 -0
  91. package/dist/sandbox.js +123 -0
  92. package/dist/sandbox.js.map +1 -0
  93. package/dist/scenario.d.ts +212 -0
  94. package/dist/scenario.js +1079 -0
  95. package/dist/scenario.js.map +1 -0
  96. package/dist/scenarios.d.ts +55 -0
  97. package/dist/scenarios.js +71 -0
  98. package/dist/scenarios.js.map +1 -0
  99. package/dist/shared.d.ts +61 -0
  100. package/dist/shared.js +77 -0
  101. package/dist/shared.js.map +1 -0
  102. package/dist/steps.d.ts +76 -0
  103. package/dist/steps.js +23 -0
  104. package/dist/steps.js.map +1 -0
  105. package/dist/version.d.ts +29 -0
  106. package/dist/version.js +76 -0
  107. package/dist/version.js.map +1 -0
  108. package/dist/worker.d.ts +34 -0
  109. package/dist/worker.js +219 -0
  110. package/dist/worker.js.map +1 -0
  111. package/oxlint.json +128 -0
  112. package/package.json +91 -3
  113. package/src/approvals.ts +157 -0
  114. package/src/ast.ts +115 -0
  115. package/src/call.ts +468 -0
  116. package/src/client.ts +534 -0
  117. package/src/config.ts +381 -0
  118. package/src/credentials.ts +211 -0
  119. package/src/cucumber.ts +230 -0
  120. package/src/datafile.ts +409 -0
  121. package/src/db.ts +197 -0
  122. package/src/define.ts +285 -0
  123. package/src/describe.ts +262 -0
  124. package/src/drift.ts +149 -0
  125. package/src/env.ts +122 -0
  126. package/src/errors.ts +202 -0
  127. package/src/expectations.ts +165 -0
  128. package/src/fake.ts +156 -0
  129. package/src/fill.ts +24 -0
  130. package/src/index.ts +120 -0
  131. package/src/ledger.ts +419 -0
  132. package/src/lint.ts +208 -0
  133. package/src/log.ts +82 -0
  134. package/src/op.ts +399 -0
  135. package/src/outline.ts +620 -0
  136. package/src/paths.ts +32 -0
  137. package/src/plugin.ts +37 -0
  138. package/src/policy.ts +139 -0
  139. package/src/resource.ts +447 -0
  140. package/src/resources.ts +256 -0
  141. package/src/run.ts +91 -0
  142. package/src/sandbox.ts +157 -0
  143. package/src/scenario.ts +1328 -0
  144. package/src/scenarios.ts +111 -0
  145. package/src/shared.ts +116 -0
  146. package/src/steps.ts +90 -0
  147. package/src/version.ts +86 -0
  148. package/src/worker.ts +253 -0
package/src/db.ts ADDED
@@ -0,0 +1,197 @@
1
+ import { DBOS, DBOSClient } from "@dbos-inc/dbos-sdk";
2
+ import { Client, Pool } from "pg";
3
+ import { warn } from "./log.ts";
4
+ import { errorMessage } from "./shared.ts";
5
+
6
+ // The runtime's own tables live in the config's database, beside DBOS's: the credentials and the
7
+ // ledger. What they share is here: the one preparation of a database, a pool that opens once it
8
+ // is prepared and survives a dropped connection, and a table creation two processes may race.
9
+
10
+ /** A connection or a pool: what the queries take. */
11
+ export type Db = Pick<Pool | Client, "query">;
12
+
13
+ /** Where the credentials set from the app live. */
14
+ export const CREDENTIALS_TABLE = "sanoma_credentials";
15
+ /** Where the Postgres ledger keeps its records. */
16
+ export const LEDGER_TABLE = "sanoma_ledger";
17
+
18
+ /** The runtime's tables, each with the word the messages name it by. */
19
+ const TABLES: readonly [what: string, ddl: string][] = [
20
+ [
21
+ "credentials",
22
+ `CREATE TABLE IF NOT EXISTS ${CREDENTIALS_TABLE} (name text PRIMARY KEY, value text NOT NULL, ` +
23
+ "set_by text NOT NULL, set_at timestamptz NOT NULL DEFAULT now())",
24
+ ],
25
+ [
26
+ "ledger",
27
+ // `n` is the write order, `id` the record's, which the first write of takes, and `seq` the
28
+ // record's place in its run: a run reads in `seq` order, then write order.
29
+ `CREATE TABLE IF NOT EXISTS ${LEDGER_TABLE} (n bigserial PRIMARY KEY, id text NOT NULL UNIQUE, ` +
30
+ `run_id text NOT NULL, seq integer NOT NULL, record jsonb NOT NULL); ` +
31
+ `CREATE INDEX IF NOT EXISTS ${LEDGER_TABLE}_run_seq ON ${LEDGER_TABLE} (run_id, seq, n)`,
32
+ ],
33
+ ];
34
+
35
+ /** The Postgres error code of `err`, or an empty string when it has none. */
36
+ export const pgCode = (err: unknown): string => (err as { code?: unknown } | null)?.code?.toString() ?? "";
37
+
38
+ /** Postgres's codes for a row type or a table another process made first. */
39
+ const MADE_ALREADY = new Set(["23505", "42P07"]);
40
+
41
+ /** True for the failure of a create that another process won: what was to be made is there. */
42
+ export const madeAlready = (err: unknown): boolean => MADE_ALREADY.has(pgCode(err));
43
+
44
+ /**
45
+ * A pool on the config's database for `what` (a word for the messages). An idle connection that
46
+ * drops (Postgres restarted) is replaced on the next query; unheard, it would end the process.
47
+ */
48
+ export function openPool(databaseUrl: string, what: string): Pool {
49
+ const pool = new Pool({ connectionString: databaseUrl });
50
+ pool.on("error", (err) => warn(`a ${what} connection failed: ${errorMessage(err)}`));
51
+ return pool;
52
+ }
53
+
54
+ /**
55
+ * What every DBOS handle on the config's database is given (`DBOS.setConfig`, `dbosClient`): DBOS
56
+ * is told not to set Postgres's `idle_in_transaction_session_timeout` on each connection of its
57
+ * pool. It sends that `SET` from the pool's `connect` event without awaiting it (dbos-sdk 5.2.11,
58
+ * dist/src/system_database.js:691), so the pool hands the connection out while it runs: two
59
+ * queries at once on one client, which pg deprecates. `prepareDatabase` sets the same 60 s guard
60
+ * on the database instead. Remove the switch when DBOS awaits its `SET`.
61
+ */
62
+ export const DBOS_DATABASE_OPTIONS = { systemDatabaseIdleTransactionTimeoutMs: 0 } as const;
63
+
64
+ /**
65
+ * A DBOS client of the app `applicationName` on the database at `databaseUrl`, as every client of
66
+ * the runtime, its tests and `@sanoma-ai/testing` make one (`DBOS_DATABASE_OPTIONS`).
67
+ */
68
+ export const dbosClient = (databaseUrl: string, applicationName?: string): Promise<DBOSClient> =>
69
+ DBOSClient.create({
70
+ systemDatabaseUrl: databaseUrl,
71
+ ...(applicationName !== undefined && { applicationName }),
72
+ ...DBOS_DATABASE_OPTIONS,
73
+ });
74
+
75
+ /**
76
+ * The `idle_in_transaction_session_timeout` `prepareDatabase` sets: DBOS's own default
77
+ * (`DEFAULT_IDLE_TRANSACTION_TIMEOUT_MS`, 60_000, in dbos-sdk 5.2.11 dist/src/system_database.js),
78
+ * how long a frozen or unreachable client may hold the system database's locks.
79
+ */
80
+ const IDLE_IN_TRANSACTION_TIMEOUT = "60s";
81
+
82
+ /** Postgres's code for a statement the role lacks the privilege for. */
83
+ const INSUFFICIENT_PRIVILEGE = "42501";
84
+
85
+ /** A pool opened on first use, once its database is prepared, and ended by `close`. */
86
+ export interface LazyPool {
87
+ db(): Promise<Pool>;
88
+ close(): Promise<void>;
89
+ }
90
+
91
+ /**
92
+ * A pool for `what` on the database at `databaseUrl`, opened on the first `db()` once the
93
+ * database is prepared (`prepareDatabase`). A preparation that fails lets the pool go, so the
94
+ * next `db()` tries again. `close` ends the pool, for good: a `db()` after it is refused.
95
+ */
96
+ export function lazyPool(databaseUrl: string, what: string): LazyPool {
97
+ let pool: Pool | undefined;
98
+ let closed = false;
99
+ return {
100
+ async db() {
101
+ if (closed) throw new Error(`the ${what} store is closed`);
102
+ const current = (pool ??= openPool(databaseUrl, what));
103
+ try {
104
+ await prepareDatabase(databaseUrl);
105
+ } catch (err) {
106
+ if (pool === current) pool = undefined;
107
+ await current.end().catch(() => undefined);
108
+ throw err;
109
+ }
110
+ return current;
111
+ },
112
+ async close() {
113
+ closed = true;
114
+ const current = pool;
115
+ pool = undefined;
116
+ await current?.end();
117
+ },
118
+ };
119
+ }
120
+
121
+ /** Each database prepared, or being prepared, by its URL. A failed preparation is forgotten. */
122
+ const prepared = new Map<string, Promise<void>>();
123
+
124
+ /**
125
+ * Makes the database at `databaseUrl` ready for the runtime, once per process and URL: the
126
+ * database itself and DBOS's tables when they are not there yet (as a worker's launch makes
127
+ * them; credentials may be set, and a ledger read, before any worker has run), then the
128
+ * credentials and ledger tables, and its idle-transaction timeout (`guardIdleTransactions`).
129
+ * Two processes preparing one database both succeed. A
130
+ * preparation that fails is forgotten, so the next call tries again.
131
+ */
132
+ export function prepareDatabase(databaseUrl: string): Promise<void> {
133
+ let preparing = prepared.get(databaseUrl);
134
+ if (!preparing) {
135
+ preparing = prepare(databaseUrl).catch((err: unknown) => {
136
+ if (prepared.get(databaseUrl) === preparing) prepared.delete(databaseUrl);
137
+ throw err;
138
+ });
139
+ prepared.set(databaseUrl, preparing);
140
+ }
141
+ return preparing;
142
+ }
143
+
144
+ async function prepare(databaseUrl: string): Promise<void> {
145
+ try {
146
+ await DBOS.migrate(databaseUrl);
147
+ } catch (err) {
148
+ // The database, or a DBOS table, made by another process meanwhile: it is there.
149
+ if (!madeAlready(err)) throw new Error(`could not prepare the database: ${errorMessage(err)}`, { cause: err });
150
+ }
151
+ const conn = new Client({ connectionString: databaseUrl });
152
+ await conn.connect();
153
+ try {
154
+ for (const [what, ddl] of TABLES) await createTable(conn, ddl, what);
155
+ await guardIdleTransactions(conn);
156
+ } finally {
157
+ await conn.end();
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Sets `idle_in_transaction_session_timeout` on the database, for each session that starts after,
163
+ * as DBOS would per connection (`DBOS_DATABASE_OPTIONS`), unless the server, the role or the
164
+ * database sets it already. One process at a time (an advisory lock), so two preparing at once
165
+ * both succeed. A role that may not alter the database leaves it, quietly: its administrator decides.
166
+ */
167
+ async function guardIdleTransactions(db: Db): Promise<void> {
168
+ try {
169
+ await db.query(`DO $$ BEGIN
170
+ PERFORM pg_advisory_xact_lock(hashtext('sanoma.idle_in_transaction_session_timeout'));
171
+ IF current_setting('idle_in_transaction_session_timeout') = '0' AND NOT EXISTS (
172
+ SELECT FROM pg_db_role_setting s JOIN pg_database d ON d.oid = s.setdatabase, unnest(s.setconfig) c
173
+ WHERE d.datname = current_database() AND s.setrole = 0 AND c LIKE 'idle_in_transaction_session_timeout=%'
174
+ ) THEN
175
+ EXECUTE format('ALTER DATABASE %I SET idle_in_transaction_session_timeout = %L', current_database(),
176
+ '${IDLE_IN_TRANSACTION_TIMEOUT}');
177
+ END IF;
178
+ END $$`);
179
+ } catch (err) {
180
+ if (pgCode(err) !== INSUFFICIENT_PRIVILEGE) {
181
+ throw new Error(`could not prepare the database: ${errorMessage(err)}`, { cause: err });
182
+ }
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Runs `ddl` (`CREATE TABLE IF NOT EXISTS`, and the like) on `db`. Two processes racing to create
188
+ * the same table both succeed; any other failure names the `what` table, with its cause.
189
+ */
190
+ export async function createTable(db: Db, ddl: string, what: string): Promise<void> {
191
+ try {
192
+ await db.query(ddl);
193
+ } catch (err) {
194
+ if (madeAlready(err)) return;
195
+ throw new Error(`could not prepare the ${what} table: ${errorMessage(err)}`, { cause: err });
196
+ }
197
+ }
package/src/define.ts ADDED
@@ -0,0 +1,285 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { getCallSites } from "node:util";
3
+ import { z } from "zod";
4
+ import { isOp, type Op } from "./op.ts";
5
+ import { approverLabel } from "./shared.ts";
6
+
7
+ /**
8
+ * Who is acting: the person or service that starts a run or decides an approval. `id` is
9
+ * whatever the deployment identifies people by; `groups` are the roles it vouches for.
10
+ */
11
+ export interface Principal {
12
+ id: string;
13
+ groups?: string[];
14
+ }
15
+
16
+ export const Principal: z.ZodType<Principal> = z.object({
17
+ id: z.string().regex(/\S/, "id must not be blank"),
18
+ groups: z.array(z.string()).optional(),
19
+ });
20
+
21
+ export type Builtin = "approval" | "sleep" | "all";
22
+ export type Use = Op | Builtin;
23
+
24
+ /** Who may decide an approval: one person, by `Principal.id`, or anyone in a group, by `Principal.groups`. */
25
+ export type Approver = string | { group: string };
26
+
27
+ export const Approver: z.ZodType<Approver> = z.union(
28
+ [z.string().regex(/\S/), z.object({ group: z.string().regex(/\S/) })],
29
+ { error: 'needs an approver: a name, or { group: "name" }' },
30
+ );
31
+
32
+ /** Why `by` may not decide the approval, naming who may. */
33
+ export const notApprover = ({ approver }: Pick<ApprovalState, "approver">, by: Principal): string =>
34
+ typeof approver === "string"
35
+ ? `${by.id} is not the approver; ${approverLabel(approver)} is`
36
+ : `${by.id} is not in ${approverLabel(approver)}`;
37
+
38
+ /** True when the two name the same approver: the same id, or the same group. */
39
+ export const sameApprover = (a: Approver, b: Approver): boolean =>
40
+ typeof a === "string" || typeof b === "string" ? a === b : a.group === b.group;
41
+
42
+ const notOps = "covers must be a list of operations";
43
+
44
+ /** `covers` as given (operations), parsed to their ids. */
45
+ export const Covers = z
46
+ .array(
47
+ z.object({ id: z.string() }, { error: notOps }).transform((op) => op.id),
48
+ { error: notOps },
49
+ )
50
+ .optional();
51
+
52
+ export interface ApprovalRequest {
53
+ /** Who must approve: a person's id, or `{ group }` for anyone in it. Phase 4 replaces this with a Cedar decision. */
54
+ approver: Approver;
55
+ /**
56
+ * The operations the approval stands for, so a policy can let their calls through once it
57
+ * is approved (`a.covers.includes(op.id)`). None unless given.
58
+ */
59
+ covers?: Op[];
60
+ links?: string[];
61
+ details?: string;
62
+ }
63
+
64
+ /**
65
+ * Checks a `ctx.approval` request as a workflow may give it, so the UI and the ledger get what
66
+ * the types say, and turns `covers` into op ids.
67
+ */
68
+ export const ApprovalRequestSchema = z.object(
69
+ {
70
+ approver: Approver,
71
+ covers: Covers,
72
+ links: z.array(z.string(), { error: "links must be a list of strings" }).optional(),
73
+ details: z.string({ error: "details must be a string" }).optional(),
74
+ },
75
+ { error: "needs a request: { approver, covers?, links?, details? }" },
76
+ );
77
+
78
+ /** One approval in a run, as the run sees it. Published as the run's "approvals" event. */
79
+ export interface ApprovalState extends Omit<ApprovalRequest, "covers"> {
80
+ id: string;
81
+ title: string;
82
+ /** Who asked: the workflow (`ctx.approval`), or the policy holding an operation call. */
83
+ requestedBy: "workflow" | "policy";
84
+ /**
85
+ * The operations the approval stands for, by id. A policy hold covers the call it held and
86
+ * any `covers` the policy added; a workflow's approval covers the `covers` it named, or none.
87
+ */
88
+ covers: string[];
89
+ /** For a policy request: the operation call held, by op id. */
90
+ op?: string;
91
+ /** For a policy request: the ledger `seq` of the call held, which its `op.called` record has. */
92
+ opSeq?: number;
93
+ /** For a policy request: the held call's input. */
94
+ input?: unknown;
95
+ status: "pending" | "approved" | "rejected";
96
+ requestedAt: number;
97
+ decidedBy?: string;
98
+ decidedAt?: number;
99
+ note?: string;
100
+ /** The `id` of the decision message the run decided with, when it had one. */
101
+ decidedWith?: string;
102
+ /**
103
+ * Messages that were ignored: decisions sent by someone who may not decide the approval,
104
+ * and messages that were not a decision. `by` is the sender, when the message named one, and
105
+ * `id` the decision message's own, when it had one.
106
+ */
107
+ refused: { by?: string; id?: string; at: number; reason: string }[];
108
+ }
109
+
110
+ export interface ApprovalResult {
111
+ approvedBy: string;
112
+ at: number;
113
+ note?: string;
114
+ }
115
+
116
+ /**
117
+ * How long `ctx.sleep` waits: until a time (an ISO 8601 date-time with an offset, such as
118
+ * `2026-10-07T09:00:00Z`, or epoch milliseconds), or for a duration whose parts add up. A time
119
+ * already past waits not at all.
120
+ */
121
+ export type SleepRequest =
122
+ | { until: string | number }
123
+ | { ms?: number; seconds?: number; minutes?: number; hours?: number; days?: number };
124
+
125
+ const time = "must be an ISO 8601 date-time with an offset, such as 2026-10-07T09:00:00Z, or epoch milliseconds";
126
+ const duration = z.number({ error: "must be a number" }).nonnegative("must not be negative").optional();
127
+
128
+ /** Checks a `ctx.sleep` request that names a time. */
129
+ export const SleepUntil = z.strictObject({
130
+ until: z.union([z.iso.datetime({ offset: true, error: time }), z.number({ error: time })], { error: time }),
131
+ }) satisfies z.ZodType<SleepRequest>;
132
+
133
+ /** Checks a `ctx.sleep` request that names a duration. */
134
+ export const SleepFor = z.strictObject({
135
+ ms: duration,
136
+ seconds: duration,
137
+ minutes: duration,
138
+ hours: duration,
139
+ days: duration,
140
+ }) satisfies z.ZodType<SleepRequest>;
141
+
142
+ /** Checks a `ctx.all` argument: a list of functions, each one member. */
143
+ export const AllMembers = z.array(
144
+ z.custom<() => unknown>((m) => typeof m === "function", "each member must be a function, such as () => ctx.…"),
145
+ { error: "needs a list of functions: ctx.all([() => …, () => …])" },
146
+ );
147
+
148
+ /**
149
+ * What a step is called, beside its operation: `ctx.ghost.post.create(input, { name: "Draft" })`,
150
+ * `ctx.sleep({ days: 1 }, { name: "Wait a day" })`. A string literal, or a top-level `const` of
151
+ * the workflow's file holding one, so the outline reads it. Without one, a call is named by its
152
+ * operation's id, a sleep `Sleep`; an approval by its title. Names are unique in a workflow, so a
153
+ * workflow that calls one operation twice names the calls.
154
+ */
155
+ export interface StepOptions {
156
+ name?: string;
157
+ }
158
+
159
+ type OpsOf<U extends readonly Use[]> = Extract<U[number], Op>;
160
+
161
+ type CtxOps<O extends Op> = {
162
+ readonly [V in O["vendor"]]: {
163
+ readonly [R in Extract<O, { vendor: V }>["resource"]]: {
164
+ readonly [N in Extract<O, { vendor: V; resource: R }>["name"]]: Extract<
165
+ O,
166
+ { vendor: V; resource: R; name: N }
167
+ > extends Op<any, any, any, infer I, infer Out>
168
+ ? (input: I, options?: StepOptions) => Promise<Out>
169
+ : never;
170
+ };
171
+ };
172
+ };
173
+
174
+ interface ApprovalCtx {
175
+ /** Waits, durably, until the approver (or someone in the approver group) approves. Throws `RejectedError` on a rejection. */
176
+ approval(title: string, request: ApprovalRequest): Promise<ApprovalResult>;
177
+ }
178
+
179
+ interface SleepCtx {
180
+ /** Waits, durably, until a time or for a duration. Survives worker restarts. */
181
+ sleep(request: SleepRequest, options?: StepOptions): Promise<void>;
182
+ }
183
+
184
+ interface AllCtx {
185
+ /**
186
+ * Runs the members one after another, in order, and returns their outputs in that order. It
187
+ * declares a fan-out for the ledger and the graph; it adds no concurrency. The first member
188
+ * that throws stops the group with its error, and later members never run. Not nestable.
189
+ */
190
+ all<const T extends readonly (() => Promise<unknown>)[]>(
191
+ members: T,
192
+ ): Promise<{ -readonly [K in keyof T]: Awaited<ReturnType<T[K]>> }>;
193
+ }
194
+
195
+ interface BaseCtx {
196
+ /** The current time, recorded so replays see the same value. Use instead of `Date.now()`. */
197
+ now(): Promise<number>;
198
+ readonly runId: string;
199
+ }
200
+
201
+ export type Ctx<U extends readonly Use[]> = BaseCtx &
202
+ CtxOps<OpsOf<U>> &
203
+ ("approval" extends U[number] ? ApprovalCtx : unknown) &
204
+ ("sleep" extends U[number] ? SleepCtx : unknown) &
205
+ ("all" extends U[number] ? AllCtx : unknown);
206
+
207
+ /**
208
+ * A schema as JSON Schema (draft 2020-12). By default it describes what a caller sends
209
+ * (`io: "input"`: defaults stay optional); `io: "output"` describes what parsing returns.
210
+ * What JSON Schema can't express is left open. Throws when zod can't convert the schema at all.
211
+ */
212
+ export function jsonSchemaOf(
213
+ schema: z.ZodType,
214
+ io: "input" | "output" = "input",
215
+ refs?: ReadonlyMap<unknown, string>,
216
+ ): Record<string, unknown> {
217
+ return z.toJSONSchema(schema, {
218
+ io,
219
+ target: "draft-2020-12",
220
+ unrepresentable: "any",
221
+ // A schema `refs` names, inside another, becomes a `$ref` to it (a resource type's state).
222
+ ...(refs?.size && {
223
+ override: ({ zodSchema, jsonSchema, path }) => {
224
+ const ref = path.length > 0 ? refs.get(zodSchema) : undefined;
225
+ if (ref === undefined) return;
226
+ for (const key of Object.keys(jsonSchema)) delete (jsonSchema as Record<string, unknown>)[key];
227
+ Object.assign(jsonSchema, { $ref: ref });
228
+ },
229
+ }),
230
+ });
231
+ }
232
+
233
+ export interface WorkflowDefinition<U extends readonly Use[] = readonly Use[], S extends z.ZodType = z.ZodType> {
234
+ readonly kind: "workflow";
235
+ readonly name: string;
236
+ readonly title?: string;
237
+ readonly trigger: "manual";
238
+ readonly input: S;
239
+ /** Every operation and built-in the workflow may call. `ctx` is built from this list and nothing else. */
240
+ readonly uses: U;
241
+ readonly run: (ctx: Ctx<U>, input: z.output<S>) => Promise<unknown>;
242
+ /**
243
+ * The file that defined it, absolute. Set by defineWorkflow from its call site: the outline is
244
+ * read from it, the worker holds each run to that outline, and the app shows it as the code view.
245
+ */
246
+ readonly file: string;
247
+ }
248
+
249
+ /** The ids of the operations a workflow `uses`, in the order declared. */
250
+ export const opIdsOf = (wf: Pick<WorkflowDefinition, "uses">): string[] => wf.uses.filter(isOp).map((op) => op.id);
251
+
252
+ export function defineWorkflow<const U extends readonly Use[], S extends z.ZodType>(
253
+ def: Omit<WorkflowDefinition<U, S>, "kind" | "file">,
254
+ ): WorkflowDefinition<U, S> {
255
+ if (!/^[a-z][a-z0-9-]*$/.test(def.name)) {
256
+ throw new Error(`Workflow name "${def.name}" must be lowercase letters, digits and dashes`);
257
+ }
258
+ const file = callerFile();
259
+ if (file === undefined) {
260
+ throw new Error(
261
+ `Workflow "${def.name}" was defined from code with no file (an eval, or a bundle without one): ` +
262
+ "its outline is read from the file, and a run is held to the outline",
263
+ );
264
+ }
265
+ return Object.freeze({ kind: "workflow", ...def, file });
266
+ }
267
+
268
+ /**
269
+ * The file of the code that called the function calling this one (`defineWorkflow`'s caller),
270
+ * from V8's call sites, as a path; undefined where there is none.
271
+ */
272
+ export function callerFile(): string | undefined {
273
+ // Frame 0 is this function, frame 1 its caller, frame 2 theirs: by index, since a bundle may hold all three.
274
+ return scriptPath(getCallSites(3)[2]?.scriptName);
275
+ }
276
+
277
+ /** A V8 script name (a `file:` URL for a module, a path for a script) as a path; undefined for none. */
278
+ export function scriptPath(script: string | undefined): string | undefined {
279
+ if (!script?.startsWith("file:")) return script || undefined;
280
+ try {
281
+ return fileURLToPath(script);
282
+ } catch {
283
+ return undefined;
284
+ }
285
+ }