@sanoma-ai/workflows 0.2.0 → 0.4.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 (144) hide show
  1. package/README.md +121 -62
  2. package/dist/approvals.d.ts +11 -6
  3. package/dist/approvals.js +38 -10
  4. package/dist/approvals.js.map +1 -1
  5. package/dist/call-workflow.d.ts +16 -0
  6. package/dist/call-workflow.js +35 -0
  7. package/dist/call-workflow.js.map +1 -0
  8. package/dist/call.d.ts +33 -4
  9. package/dist/call.js +107 -42
  10. package/dist/call.js.map +1 -1
  11. package/dist/cedar.d.ts +87 -0
  12. package/dist/cedar.js +365 -0
  13. package/dist/cedar.js.map +1 -0
  14. package/dist/client.d.ts +85 -7
  15. package/dist/client.js +289 -25
  16. package/dist/client.js.map +1 -1
  17. package/dist/collection.d.ts +107 -0
  18. package/dist/collection.js +306 -0
  19. package/dist/collection.js.map +1 -0
  20. package/dist/config.d.ts +42 -14
  21. package/dist/config.js +82 -18
  22. package/dist/config.js.map +1 -1
  23. package/dist/constructors.d.ts +2 -0
  24. package/dist/constructors.js +5 -0
  25. package/dist/constructors.js.map +1 -0
  26. package/dist/data.d.ts +2 -0
  27. package/dist/data.js +3 -0
  28. package/dist/data.js.map +1 -0
  29. package/dist/datafile.d.ts +28 -2
  30. package/dist/datafile.js +118 -19
  31. package/dist/datafile.js.map +1 -1
  32. package/dist/db.d.ts +5 -3
  33. package/dist/db.js +88 -21
  34. package/dist/db.js.map +1 -1
  35. package/dist/define.d.ts +10 -4
  36. package/dist/define.js +1 -5
  37. package/dist/define.js.map +1 -1
  38. package/dist/describe.d.ts +45 -5
  39. package/dist/describe.js +72 -7
  40. package/dist/describe.js.map +1 -1
  41. package/dist/direct.d.ts +9 -0
  42. package/dist/direct.js +11 -0
  43. package/dist/direct.js.map +1 -0
  44. package/dist/errors.d.ts +16 -1
  45. package/dist/errors.js +30 -1
  46. package/dist/errors.js.map +1 -1
  47. package/dist/expectations.d.ts +3 -3
  48. package/dist/expectations.js +16 -7
  49. package/dist/expectations.js.map +1 -1
  50. package/dist/index.d.ts +8 -4
  51. package/dist/index.js +10 -4
  52. package/dist/index.js.map +1 -1
  53. package/dist/ledger.d.ts +12 -1
  54. package/dist/ledger.js +18 -11
  55. package/dist/ledger.js.map +1 -1
  56. package/dist/lint.d.ts +5 -4
  57. package/dist/lint.js +24 -7
  58. package/dist/lint.js.map +1 -1
  59. package/dist/op.d.ts +6 -0
  60. package/dist/op.js.map +1 -1
  61. package/dist/outline.d.ts +2 -0
  62. package/dist/outline.js +1 -1
  63. package/dist/outline.js.map +1 -1
  64. package/dist/paths.d.ts +4 -0
  65. package/dist/paths.js +17 -3
  66. package/dist/paths.js.map +1 -1
  67. package/dist/person.d.ts +61 -0
  68. package/dist/person.js +69 -0
  69. package/dist/person.js.map +1 -0
  70. package/dist/plugin.js +1 -1
  71. package/dist/plugin.js.map +1 -1
  72. package/dist/policy.d.ts +51 -55
  73. package/dist/policy.js +14 -36
  74. package/dist/policy.js.map +1 -1
  75. package/dist/resource.d.ts +21 -5
  76. package/dist/resource.js +12 -3
  77. package/dist/resource.js.map +1 -1
  78. package/dist/resources.d.ts +58 -6
  79. package/dist/resources.js +119 -26
  80. package/dist/resources.js.map +1 -1
  81. package/dist/run.d.ts +17 -1
  82. package/dist/run.js.map +1 -1
  83. package/dist/scenario.d.ts +16 -9
  84. package/dist/scenario.js +93 -20
  85. package/dist/scenario.js.map +1 -1
  86. package/dist/scenarios.d.ts +10 -6
  87. package/dist/scenarios.js +8 -4
  88. package/dist/scenarios.js.map +1 -1
  89. package/dist/schema.d.ts +32 -0
  90. package/dist/schema.js +225 -0
  91. package/dist/schema.js.map +1 -0
  92. package/dist/sdk.d.ts +34 -0
  93. package/dist/sdk.js +168 -0
  94. package/dist/sdk.js.map +1 -0
  95. package/dist/shared.d.ts +181 -5
  96. package/dist/shared.js +160 -7
  97. package/dist/shared.js.map +1 -1
  98. package/dist/sites.d.ts +52 -0
  99. package/dist/sites.js +174 -0
  100. package/dist/sites.js.map +1 -0
  101. package/dist/version.d.ts +3 -2
  102. package/dist/version.js +3 -2
  103. package/dist/version.js.map +1 -1
  104. package/dist/worker.d.ts +3 -2
  105. package/dist/worker.js +24 -4
  106. package/dist/worker.js.map +1 -1
  107. package/oxlint.json +5 -16
  108. package/package.json +8 -3
  109. package/src/approvals.ts +49 -13
  110. package/src/call-workflow.ts +44 -0
  111. package/src/call.ts +157 -45
  112. package/src/cedar.ts +493 -0
  113. package/src/client.ts +368 -37
  114. package/src/collection.ts +431 -0
  115. package/src/config.ts +131 -34
  116. package/src/constructors.ts +4 -0
  117. package/src/data.ts +10 -0
  118. package/src/datafile.ts +141 -21
  119. package/src/db.ts +90 -22
  120. package/src/define.ts +11 -9
  121. package/src/describe.ts +113 -10
  122. package/src/direct.ts +15 -0
  123. package/src/errors.ts +36 -1
  124. package/src/expectations.ts +17 -8
  125. package/src/index.ts +33 -2
  126. package/src/ledger.ts +35 -17
  127. package/src/lint.ts +29 -12
  128. package/src/op.ts +6 -0
  129. package/src/outline.ts +1 -1
  130. package/src/paths.ts +18 -3
  131. package/src/person.ts +75 -0
  132. package/src/plugin.ts +1 -1
  133. package/src/policy.ts +59 -63
  134. package/src/resource.ts +35 -8
  135. package/src/resources.ts +165 -24
  136. package/src/run.ts +18 -1
  137. package/src/scenario.ts +128 -33
  138. package/src/scenarios.ts +15 -8
  139. package/src/schema.ts +261 -0
  140. package/src/sdk.ts +211 -0
  141. package/src/shared.ts +271 -8
  142. package/src/sites.ts +220 -0
  143. package/src/version.ts +3 -2
  144. package/src/worker.ts +29 -6
package/src/sites.ts ADDED
@@ -0,0 +1,220 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import { join, relative, sep } from "node:path";
3
+ import { childrenOf, type LintProblem, type Node, readSource, type SourceFile } from "./ast.ts";
4
+ import { keyName } from "./datafile.ts";
5
+ import type { ConfigDescription } from "./describe.ts";
6
+ import { stringValue, unwrap } from "./outline.ts";
7
+ import { filesUnder, isDirectory } from "./paths.ts";
8
+ import { apiNameOf, DATA_VENDOR, SITE_NAME } from "./shared.ts";
9
+
10
+ // What a company's sites call, read from their source with oxc and never run: the reverse index
11
+ // a workflow's page shows as Used by, and the names `sanoma check` holds to the config.
12
+
13
+ /** A call a site makes, as written in its source. */
14
+ export interface SiteCall {
15
+ /** What it calls: a workflow, a collection (`api.data.<name>.<op>`) or an operation (`api.<vendor>.<resource>.<op>`). */
16
+ kind: "workflow" | "collection" | "op";
17
+ /**
18
+ * The workflow's name (`RunForm`'s `workflow`, or the workflow whose `apiNameOf` is the name
19
+ * `api` gives; that name itself when none is), the collection's name, or the operation's id.
20
+ */
21
+ target: string;
22
+ /** `<RunForm workflow="…" />` from `@sanoma-ai/app/ui`, or a call on `api` from `sanoma/site`. */
23
+ via: "RunForm" | "api";
24
+ /** The file, relative to the config's root, with `/`: `sites/sales/routes/index.tsx`. */
25
+ file: string;
26
+ /** From 1. */
27
+ line: number;
28
+ /** From 1, in UTF-16 code units. */
29
+ column: number;
30
+ /**
31
+ * The path of the page the file is, from its `createFileRoute` (or `createLazyFileRoute`) id: groups and pathless
32
+ * segments left out, no trailing slash (`/sites/sales`). Absent for a file that is no page,
33
+ * such as a component's.
34
+ */
35
+ page?: string;
36
+ }
37
+
38
+ /** A site, `sites/<name>/`, and what it calls. */
39
+ export interface SiteEntry {
40
+ /** Its folder's name, where it is mounted: `/sites/<name>`. */
41
+ name: string;
42
+ /** `defineSite({ title })` in its `site.ts`, when that is a string literal. */
43
+ title?: string;
44
+ /** Its calls, by file and then in the file's order. */
45
+ calls: SiteCall[];
46
+ /** Each call to a workflow, a collection or an operation the config does not have, or to an operation a page may not call directly, at its file. */
47
+ problems: (LintProblem & { file: string })[];
48
+ }
49
+
50
+ /** Where a site's code is: its pages and its components. */
51
+ const SITE_DIRS = ["routes", "components"];
52
+ /** Where `api` and `defineSite` come from: `sanoma/site`, which re-exports the app's. */
53
+ const SITE_SOURCES: ReadonlySet<string> = new Set(["sanoma/site", "@sanoma-ai/app/site"]);
54
+ const UI_SOURCES: ReadonlySet<string> = new Set(["@sanoma-ai/app/ui"]);
55
+ const KIND_NAMES = { workflow: "workflow", collection: "collection", op: "operation" } as const;
56
+ /** What makes a route from its id, whose first argument is the id. */
57
+ const ROUTE_MAKERS: ReadonlySet<string> = new Set(["createFileRoute", "createLazyFileRoute"]);
58
+
59
+ /**
60
+ * The sites under `root`: each folder of `sites/` with a `routes/` whose name is a URL segment,
61
+ * by name (as `@sanoma-ai/app/build`'s `sitesOf` mounts them). Reads `routes/**` and
62
+ * `components/**` (`.ts` and `.tsx`, less tests and declarations) of each with oxc, never running
63
+ * them: each `<RunForm workflow="…" />` whose `RunForm` is imported from `@sanoma-ai/app/ui`, and
64
+ * each use of `api` imported from `sanoma/site`, called or passed on (`queryFn: api.data.leads.list`):
65
+ * `api.<name>` and `api.<name>.<method>` are a workflow, `api.data.<collection>.<op>` a collection,
66
+ * and `api.<vendor>.<resource>.<op>` an operation. A name that is not written out (a variable, a
67
+ * template with holes, a computed member) is skipped, as is a use through a namespace import or a
68
+ * destructured `api`. A call to what `description` does not have, or to an operation a page may
69
+ * not call directly, is a problem.
70
+ */
71
+ export function readSites(
72
+ root: string | undefined,
73
+ description: Pick<ConfigDescription, "workflows" | "collections" | "ops">,
74
+ ): SiteEntry[] {
75
+ if (root === undefined) return [];
76
+ const dir = join(root, "sites");
77
+ if (!isDirectory(dir)) return [];
78
+ const workflows = new Set(description.workflows.map((wf) => wf.name));
79
+ const collections = new Set(description.collections.map((c) => c.name));
80
+ const ops = new Map(description.ops.map((op) => [op.id, op]));
81
+ // A workflow's api name to its name: two of one api name are a problem on the workflows.
82
+ const byApiName = new Map(description.workflows.map((wf) => [apiNameOf(wf.name), wf.name]));
83
+
84
+ /** A call as written, resolved as the app's `callApi` resolves it, with why it would fail. */
85
+ const resolve = ({ kind, name, via, ...at }: Written): { call: SiteCall; problem?: string } => {
86
+ const target = kind === "workflow" && via === "api" ? (byApiName.get(name) ?? name) : name;
87
+ const found =
88
+ kind === "workflow"
89
+ ? via === "api"
90
+ ? byApiName.has(name)
91
+ : workflows.has(name)
92
+ : kind === "collection"
93
+ ? collections.has(name)
94
+ : ops.has(name);
95
+ const problem = !found
96
+ ? `calls ${KIND_NAMES[kind]} "${target}", which does not exist`
97
+ : kind === "op" && !ops.get(name)!.direct
98
+ ? `calls operation "${target}", which a page may not call directly: start a workflow that calls it`
99
+ : undefined;
100
+ return { call: { kind, target, via, ...at }, ...(problem && { problem }) };
101
+ };
102
+
103
+ return readdirSync(dir, { withFileTypes: true })
104
+ .filter((d) => d.isDirectory() && SITE_NAME.test(d.name) && existsSync(join(dir, d.name, "routes")))
105
+ .map((d) => d.name)
106
+ .toSorted()
107
+ .map((name) => {
108
+ const files = SITE_DIRS.flatMap((sub) => filesUnder(join(dir, name, sub), isSource)).toSorted();
109
+ const resolved = files.flatMap((path) => {
110
+ const file = readSource(path);
111
+ return typeof file === "string" ? [] : usesIn(file, relative(root, path).split(sep).join("/")).map(resolve);
112
+ });
113
+ const title = titleOf(join(dir, name, "site.ts"));
114
+ const calls = resolved.map((r) => r.call);
115
+ const problems = resolved.flatMap(({ call: { file, line, column }, problem }) =>
116
+ problem === undefined ? [] : [{ file, line, column, message: problem }],
117
+ );
118
+ return { name, ...(title !== undefined && { title }), calls, problems };
119
+ });
120
+ }
121
+
122
+ /** A use of `api` or a `RunForm`, as written: the name it gives, before it is resolved. */
123
+ type Written = Omit<SiteCall, "target"> & { name: string };
124
+
125
+ /** The uses in one file, in its order. */
126
+ function usesIn({ program, at }: SourceFile, file: string): Written[] {
127
+ const api = importedAs(program, SITE_SOURCES, "api");
128
+ const runForm = importedAs(program, UI_SOURCES, "RunForm");
129
+ if (!api.size && !runForm.size) return [];
130
+ const found: Omit<Written, "page">[] = [];
131
+ let page: string | undefined;
132
+ const visit = (node: Node) => {
133
+ if (!node) return;
134
+ if (node.type === "MemberExpression") {
135
+ const named = apiPath(node, api);
136
+ // The outermost member of a path on `api`: what it names, called or not; its inner members are not visited.
137
+ if (named) return void found.push({ ...named, via: "api", file, ...at(node.start) });
138
+ } else if (node.type === "CallExpression" && page === undefined) {
139
+ const callee = unwrap(node.callee);
140
+ if (callee?.type === "Identifier" && ROUTE_MAKERS.has(callee.name)) {
141
+ const id = stringValue(node.arguments[0]);
142
+ if (id !== undefined) page = pageOf(id);
143
+ }
144
+ } else if (node.type === "JSXOpeningElement" && node.name.type === "JSXIdentifier" && runForm.has(node.name.name)) {
145
+ const attribute = node.attributes.find(
146
+ (a: Node) => a.type === "JSXAttribute" && a.name.type === "JSXIdentifier" && a.name.name === "workflow",
147
+ );
148
+ const value = attribute?.value?.type === "JSXExpressionContainer" ? attribute.value.expression : attribute?.value;
149
+ const name = stringValue(value);
150
+ if (name !== undefined) found.push({ kind: "workflow", name, via: "RunForm", file, ...at(node.start) });
151
+ }
152
+ for (const child of childrenOf(node)) visit(child);
153
+ };
154
+ visit(program);
155
+ return found.map((use) => (page === undefined ? use : { ...use, page }));
156
+ }
157
+
158
+ /** What a member path on `api` names; undefined for a path on anything else, a name not written out, or no name. */
159
+ function apiPath(member: Node, api: ReadonlySet<string>): Pick<Written, "kind" | "name"> | undefined {
160
+ const path: string[] = [];
161
+ let node = member;
162
+ while (node?.type === "MemberExpression") {
163
+ const key: string | undefined = node.computed ? stringValue(node.property) : node.property.name;
164
+ if (key === undefined) return undefined;
165
+ path.unshift(key);
166
+ node = unwrap(node.object);
167
+ }
168
+ if (node?.type !== "Identifier" || !api.has(node.name)) return undefined;
169
+ const [first, second] = path as [string, ...string[]];
170
+ if (first === DATA_VENDOR) return path.length === 3 ? { kind: "collection", name: second! } : undefined;
171
+ if (path.length === 3) return { kind: "op", name: path.join(".") };
172
+ return path.length <= 2 ? { kind: "workflow", name: first } : undefined;
173
+ }
174
+
175
+ /** The local names `imported` has in the file, imported as a value from one of `sources`. */
176
+ function importedAs(program: Node, sources: ReadonlySet<string>, imported: string): Set<string> {
177
+ const names = new Set<string>();
178
+ for (const statement of program.body) {
179
+ if (statement.type !== "ImportDeclaration" || statement.importKind === "type") continue;
180
+ if (!sources.has(statement.source.value)) continue;
181
+ for (const s of statement.specifiers) {
182
+ if (s.type === "ImportSpecifier" && s.importKind !== "type" && keyName(s.imported) === imported) {
183
+ names.add(s.local.name);
184
+ }
185
+ }
186
+ }
187
+ return names;
188
+ }
189
+
190
+ /** A site's title: the string `title` its `site.ts` exports by default through `defineSite`. */
191
+ function titleOf(path: string): string | undefined {
192
+ const file = readSource(path);
193
+ if (typeof file === "string") return undefined;
194
+ const defineSite = importedAs(file.program, SITE_SOURCES, "defineSite");
195
+ const exported = file.program.body.find((s: Node) => s.type === "ExportDefaultDeclaration")?.declaration;
196
+ const call = unwrap(exported);
197
+ const callee = unwrap(call?.callee);
198
+ const options = unwrap(call?.arguments?.[0]);
199
+ if (call?.type !== "CallExpression" || callee?.type !== "Identifier" || !defineSite.has(callee.name))
200
+ return undefined;
201
+ if (options?.type !== "ObjectExpression") return undefined;
202
+ const title = options.properties.find(
203
+ (p: Node) => p.type === "Property" && !p.computed && keyName(p.key) === "title",
204
+ );
205
+ return stringValue(title?.value);
206
+ }
207
+
208
+ /**
209
+ * A route's path from its id, as the router serves it: `(group)` and `_pathless` segments left
210
+ * out, a `_` that ends a segment dropped, and no trailing slash.
211
+ */
212
+ const pageOf = (id: string): string =>
213
+ `/${id
214
+ .split("/")
215
+ .filter((s) => s && !/^\(.*\)$/.test(s) && !s.startsWith("_"))
216
+ .map((s) => s.replace(/_$/, ""))
217
+ .join("/")}`;
218
+
219
+ /** A site's source file: `.ts` or `.tsx`, but a test or a declaration. */
220
+ const isSource = (name: string) => /\.tsx?$/.test(name) && !/\.(test|d)\.tsx?$/.test(name);
package/src/version.ts CHANGED
@@ -6,15 +6,16 @@ import { jsonSchemaOf, opIdsOf, type WorkflowDefinition } from "./define.ts";
6
6
  import { upFrom } from "./paths.ts";
7
7
 
8
8
  /** This package's version. A unit test keeps it equal to package.json. */
9
- export const RUNTIME_VERSION = "0.2.0";
9
+ export const RUNTIME_VERSION = "0.4.0";
10
10
 
11
11
  /**
12
12
  * The sequence of DBOS calls the runtime makes around each `ctx` call: policy step, approval
13
13
  * events and messages, the driver step. Bump it whenever that sequence changes, even without a
14
14
  * package release, so a run in flight on the old layout is not replayed against the new one.
15
15
  * 2: calls run in program order; a decision event per approval (2026-10-07).
16
+ * 3: a start the policy holds waits on its approval before the body (2026-10-21).
16
17
  */
17
- export const STEP_LAYOUT = 2;
18
+ export const STEP_LAYOUT = 3;
18
19
 
19
20
  let dbosVersion: string | undefined;
20
21
 
package/src/worker.ts CHANGED
@@ -1,19 +1,20 @@
1
1
  import { DBOS } from "@dbos-inc/dbos-sdk";
2
- import { buildCtx, isInfrastructureError } from "./call.ts";
2
+ import { awaitApproval } from "./approvals.ts";
3
+ import { buildCtx, decideStart, isInfrastructureError, startHoldOf } from "./call.ts";
3
4
  import { dbosStatusesOf } from "./client.ts";
4
5
  import { type ResolvedConfig, resolveConfig, type SanomaConfig } from "./config.ts";
5
6
  import { storedSecrets, watchCredentials } from "./credentials.ts";
6
7
  import { DBOS_DATABASE_OPTIONS, lazyPool, prepareDatabase } from "./db.ts";
7
8
  import type { WorkflowDefinition } from "./define.ts";
8
9
  import { credentialsOf, unconfigured, unconfiguredSummary } from "./env.ts";
9
- import { errorInfo, parseOrThrow } from "./errors.ts";
10
+ import { errorInfo, parseOrThrow, PolicyDeniedError } from "./errors.ts";
10
11
  import { entry, ownedLedger, skipped, write, writeFailure } from "./ledger.ts";
11
12
  import { dbosLogger, quietIfSandbox, setLogLevel, warn } from "./log.ts";
12
13
  import { lineStartsOf, readSource } from "./ast.ts";
13
14
  import { callsOf, outlineWithSource } from "./outline.ts";
14
15
  import { currentRun, type Run, type RunArgs, type WorkerState, type WorkflowOutline } from "./run.ts";
15
16
  import type { Source } from "./source.ts";
16
- import { errorMessage } from "./shared.ts";
17
+ import { errorMessage, WORKFLOW_ACTION } from "./shared.ts";
17
18
 
18
19
  export interface Worker {
19
20
  /**
@@ -35,8 +36,9 @@ export interface WorkerOptions {
35
36
  /** The least severe level of the runtime's and DBOS's messages to show, for the whole process: `warn` by default. */
36
37
  logLevel?: string;
37
38
  /**
38
- * Start without naming the vendors not configured yet (`unconfigured` still does): for a worker
39
- * whose runs use the fakes alone, as `runScenarios`'s.
39
+ * Start without naming the vendors not configured yet (`unconfigured` still does), or the
40
+ * policies' problems: for a worker whose runs use the fakes alone, as `runScenarios`'s, which
41
+ * `sanoma check` reports them beside.
40
42
  */
41
43
  quiet?: boolean;
42
44
  /**
@@ -72,8 +74,10 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
72
74
  ...(resolved.scenarios && { scenarios: resolved.scenarios }),
73
75
  ...(options.source && { source: options.source }),
74
76
  types: resolved.types,
77
+ collections: resolved.collections,
75
78
  steps: resolved.steps,
76
79
  policy: resolved.policy,
80
+ ...(resolved.root !== undefined && { root: resolved.root }),
77
81
  ledger: ledger.store,
78
82
  secrets: storedSecrets(() => secrets.db(), "sanoma"),
79
83
  workflows: resolved.workflows,
@@ -94,6 +98,7 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
94
98
  () => credentials.close(),
95
99
  () => secrets.close(),
96
100
  () => ledger.close(),
101
+ async () => resolved.dataStore?.close(),
97
102
  ]) {
98
103
  await close().catch((error: unknown) => (failed ??= { error }));
99
104
  }
@@ -143,6 +148,9 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
143
148
  await warnAboutStrandedRuns(resolved);
144
149
  const summary = unconfiguredSummary(worker.unconfigured, resolved.driversByVendor.size);
145
150
  if (summary && !options.quiet) warn(summary);
151
+ if (resolved.policyProblems.length && !options.quiet) {
152
+ warn(`the policies do not hold, so every call and start is refused:\n ${resolved.policyProblems.join("\n ")}`);
153
+ }
146
154
  } catch (err) {
147
155
  // Once DBOS is launched, the caller gets no worker to stop: stop it here, so no run goes on
148
156
  // in a worker nobody holds, and the next startWorker launches afresh.
@@ -216,7 +224,7 @@ function register(name: string) {
216
224
  }
217
225
 
218
226
  /** One run of the workflow registered as `name`, on the state of the worker running when it starts. */
219
- async function runWorkflow(name: string, { input, startedBy, sandbox }: RunArgs): Promise<unknown> {
227
+ async function runWorkflow(name: string, { input, startedBy, sandbox, hold }: RunArgs): Promise<unknown> {
220
228
  const state = current;
221
229
  // A stopped worker's state stays current while DBOS shuts down.
222
230
  if (!state || state.stopped) throw new Error(`Run of "${name}" started with no worker running`);
@@ -243,6 +251,21 @@ async function runWorkflow(name: string, { input, startedBy, sandbox }: RunArgs)
243
251
  try {
244
252
  // The one parse of the input: the client checked it, but sent it as given.
245
253
  const parsed = parseOrThrow(wf.input, input, `The input does not match ${wf.name}'s schema`);
254
+ // The policy held the start: the body waits for each hold's approval in turn, the policy
255
+ // asked again once one is approved, and a rejection or a deny fails the run.
256
+ const action = `${WORKFLOW_ACTION}${wf.name}`;
257
+ for (let next = hold; next;) {
258
+ const held = { op: action, input, reasons: next.reasons };
259
+ await awaitApproval(run, next.title, { approver: next.approver, covers: [action] }, undefined, held);
260
+ const decision = await DBOS.runStep(
261
+ async () => decideStart(state.policy, wf.name, parsed, startedBy, run.approvals),
262
+ {
263
+ name: `policy:${action}`,
264
+ },
265
+ );
266
+ if (decision.kind === "deny") throw new PolicyDeniedError(action, decision.reason);
267
+ next = decision.kind === "approve" ? startHoldOf(wf.name, decision) : undefined;
268
+ }
246
269
  if (sandbox !== undefined) {
247
270
  // Imported here, so a live worker never loads the Gherkin parser or faker.
248
271
  const { seedSandbox } = await import("./sandbox.ts");