@sanoma-ai/workflows 0.7.1 → 0.8.1

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 (130) hide show
  1. package/README.md +34 -7
  2. package/dist/apps-list.d.ts +90 -0
  3. package/dist/apps-list.js +109 -0
  4. package/dist/apps-list.js.map +1 -0
  5. package/dist/apps.d.ts +42 -0
  6. package/dist/apps.js +137 -0
  7. package/dist/apps.js.map +1 -0
  8. package/dist/call-workflow.js +5 -6
  9. package/dist/call-workflow.js.map +1 -1
  10. package/dist/call.js +6 -3
  11. package/dist/call.js.map +1 -1
  12. package/dist/cedar.d.ts +1 -1
  13. package/dist/cedar.js +6 -4
  14. package/dist/cedar.js.map +1 -1
  15. package/dist/change-steps.d.ts +10 -0
  16. package/dist/change-steps.js +204 -0
  17. package/dist/change-steps.js.map +1 -0
  18. package/dist/change-workflow.d.ts +54 -0
  19. package/dist/change-workflow.js +161 -0
  20. package/dist/change-workflow.js.map +1 -0
  21. package/dist/change.d.ts +121 -24
  22. package/dist/change.js +196 -17
  23. package/dist/change.js.map +1 -1
  24. package/dist/claims.d.ts +58 -0
  25. package/dist/claims.js +27 -0
  26. package/dist/claims.js.map +1 -0
  27. package/dist/client.d.ts +92 -0
  28. package/dist/client.js +268 -6
  29. package/dist/client.js.map +1 -1
  30. package/dist/collection.d.ts +6 -1
  31. package/dist/collection.js +11 -3
  32. package/dist/collection.js.map +1 -1
  33. package/dist/config.d.ts +30 -2
  34. package/dist/config.js +41 -20
  35. package/dist/config.js.map +1 -1
  36. package/dist/db.d.ts +12 -0
  37. package/dist/db.js +15 -0
  38. package/dist/db.js.map +1 -1
  39. package/dist/deadlines.d.ts +5 -2
  40. package/dist/deadlines.js.map +1 -1
  41. package/dist/define.d.ts +30 -14
  42. package/dist/define.js +9 -1
  43. package/dist/define.js.map +1 -1
  44. package/dist/demo.d.ts +15 -4
  45. package/dist/demo.js +27 -12
  46. package/dist/demo.js.map +1 -1
  47. package/dist/describe.d.ts +16 -2
  48. package/dist/describe.js +39 -4
  49. package/dist/describe.js.map +1 -1
  50. package/dist/direct.d.ts +4 -3
  51. package/dist/direct.js +7 -4
  52. package/dist/direct.js.map +1 -1
  53. package/dist/errors.d.ts +2 -0
  54. package/dist/errors.js +2 -0
  55. package/dist/errors.js.map +1 -1
  56. package/dist/index.d.ts +7 -5
  57. package/dist/index.js +6 -3
  58. package/dist/index.js.map +1 -1
  59. package/dist/lint.js +19 -5
  60. package/dist/lint.js.map +1 -1
  61. package/dist/outline.js +13 -6
  62. package/dist/outline.js.map +1 -1
  63. package/dist/paths.d.ts +16 -0
  64. package/dist/paths.js +54 -2
  65. package/dist/paths.js.map +1 -1
  66. package/dist/resources.d.ts +7 -2
  67. package/dist/resources.js +23 -8
  68. package/dist/resources.js.map +1 -1
  69. package/dist/run.d.ts +7 -0
  70. package/dist/run.js.map +1 -1
  71. package/dist/sandbox.js +2 -2
  72. package/dist/sandbox.js.map +1 -1
  73. package/dist/scenario.d.ts +2 -2
  74. package/dist/scenario.js +10 -4
  75. package/dist/scenario.js.map +1 -1
  76. package/dist/scenarios.d.ts +1 -1
  77. package/dist/scenarios.js.map +1 -1
  78. package/dist/sdk.d.ts +1 -1
  79. package/dist/sdk.js +19 -5
  80. package/dist/sdk.js.map +1 -1
  81. package/dist/shared.d.ts +43 -2
  82. package/dist/shared.js +46 -4
  83. package/dist/shared.js.map +1 -1
  84. package/dist/sites.d.ts +26 -5
  85. package/dist/sites.js +56 -20
  86. package/dist/sites.js.map +1 -1
  87. package/dist/source.d.ts +55 -5
  88. package/dist/source.js +4 -0
  89. package/dist/source.js.map +1 -1
  90. package/dist/version.d.ts +8 -0
  91. package/dist/version.js +21 -13
  92. package/dist/version.js.map +1 -1
  93. package/dist/worker.js +50 -3
  94. package/dist/worker.js.map +1 -1
  95. package/oxlint.json +2 -2
  96. package/package.json +1 -1
  97. package/src/apps-list.ts +119 -0
  98. package/src/apps.ts +162 -0
  99. package/src/call-workflow.ts +10 -2
  100. package/src/call.ts +6 -3
  101. package/src/cedar.ts +9 -7
  102. package/src/change-steps.ts +274 -0
  103. package/src/change-workflow.ts +198 -0
  104. package/src/change.ts +257 -27
  105. package/src/claims.ts +79 -0
  106. package/src/client.ts +320 -4
  107. package/src/collection.ts +22 -3
  108. package/src/config.ts +66 -22
  109. package/src/db.ts +23 -0
  110. package/src/deadlines.ts +5 -2
  111. package/src/define.ts +31 -12
  112. package/src/demo.ts +55 -24
  113. package/src/describe.ts +67 -4
  114. package/src/direct.ts +9 -5
  115. package/src/errors.ts +3 -0
  116. package/src/index.ts +14 -0
  117. package/src/lint.ts +20 -5
  118. package/src/outline.ts +15 -6
  119. package/src/paths.ts +57 -2
  120. package/src/resources.ts +27 -9
  121. package/src/run.ts +5 -0
  122. package/src/sandbox.ts +2 -5
  123. package/src/scenario.ts +14 -4
  124. package/src/scenarios.ts +1 -1
  125. package/src/sdk.ts +21 -7
  126. package/src/shared.ts +60 -5
  127. package/src/sites.ts +70 -24
  128. package/src/source.ts +50 -5
  129. package/src/version.ts +25 -16
  130. package/src/worker.ts +46 -3
package/src/apps.ts ADDED
@@ -0,0 +1,162 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import { dirname, join, relative, resolve, sep } from "node:path";
3
+ import { childrenOf, type LintProblem, type Node, readSource } from "./ast.ts";
4
+ import { keyName } from "./datafile.ts";
5
+ import { stringValue, unwrap } from "./outline.ts";
6
+ import { APPS_DIR, filesUnder, inside, isDirectory, RELATIVE } from "./paths.ts";
7
+ import { APP_NAME } from "./shared.ts";
8
+
9
+ // A company's apps: each folder of `apps/` holds the kinds the root does (workflows, resources,
10
+ // policies, scenarios, sites), merged from a source repository whose URL its `app.ts` names, or
11
+ // made here. What a folder declares is named with the folder (`qualified`): never written, read
12
+ // from where the declaration's file is (`appOfFile`).
13
+
14
+ /** An app: a folder of `apps/`, by its name, and the source repository its `app.ts` names. */
15
+ export interface AppEntry {
16
+ /** The folder's name, which prefixes what it declares (`crm.contacts`) and is the app's title. */
17
+ name: string;
18
+ /** The folder, relative to the root, with `/`: `apps/crm`. */
19
+ dir: string;
20
+ /** `defineApp({ source })` in its `app.ts`: the repository it was taken from; absent for the company's own app. */
21
+ source?: string;
22
+ }
23
+
24
+ /** What `app.ts` declares: the source repository's URL, when the app was taken from one. */
25
+ export interface AppDefinition {
26
+ source?: string;
27
+ }
28
+
29
+ /**
30
+ * Declares an app in its folder's `app.ts`, read without running it: `defineApp({ source:
31
+ * "https://github.com/acme/crm" })`, the repository it was taken from; `defineApp({})` for an app
32
+ * the company made itself.
33
+ */
34
+ export function defineApp(definition: AppDefinition): AppDefinition {
35
+ return Object.freeze({ ...definition });
36
+ }
37
+
38
+ /** What is wrong with a company's apps, at a file relative to the root. */
39
+ export type AppProblem = LintProblem & { file: string };
40
+
41
+ /** The apps under `root` (`appsAt`), without what is wrong with them. */
42
+ export const readApps = (root: string | undefined): AppEntry[] => appsAt(root).apps;
43
+
44
+ /**
45
+ * The apps under `root`: each folder of `apps/` whose name is an `APP_NAME`, sorted, with what is
46
+ * wrong in its `app.ts` and each folder refused for its name as `problems`. `app.ts` is read
47
+ * with oxc, never run: its default export must be `defineApp({ … })`, from `sanoma`, written out,
48
+ * and `source` a string.
49
+ */
50
+ export function appsAt(root: string | undefined): { apps: AppEntry[]; problems: AppProblem[] } {
51
+ const dir = root === undefined ? undefined : join(root, APPS_DIR);
52
+ if (dir === undefined || !isDirectory(dir)) return { apps: [], problems: [] };
53
+ const apps: AppEntry[] = [];
54
+ const problems: AppProblem[] = [];
55
+ const folders = readdirSync(dir, { withFileTypes: true })
56
+ .filter((d) => d.isDirectory())
57
+ .map((d) => d.name)
58
+ .toSorted();
59
+ for (const name of folders) {
60
+ const at = `${APPS_DIR}/${name}`;
61
+ if (!APP_NAME.test(name)) {
62
+ problems.push({
63
+ file: at,
64
+ line: 1,
65
+ column: 1,
66
+ message: `${at} is not an app: an app's folder name is a lowercase letter, then lowercase letters and digits`,
67
+ });
68
+ continue;
69
+ }
70
+ const read = appOptionsOf(join(dir, name, "app.ts"));
71
+ problems.push(...read.problems.map((p) => ({ file: `${at}/app.ts`, ...p })));
72
+ apps.push({ name, dir: at, ...(read.source !== undefined && { source: read.source }) });
73
+ }
74
+ return { apps, problems };
75
+ }
76
+
77
+ /** Where `defineApp` comes from in `app.ts`. */
78
+ const APP_SOURCES: ReadonlySet<string> = new Set(["sanoma", "@sanoma-ai/workflows"]);
79
+
80
+ /** `app.ts`'s `source`, read without running it, and what is wrong there; nothing for no file. */
81
+ function appOptionsOf(path: string): { source?: string; problems: LintProblem[] } {
82
+ if (!existsSync(path)) return { problems: [] };
83
+ const file = readSource(path);
84
+ if (typeof file === "string") return { problems: [{ line: 1, column: 1, message: file }] };
85
+ const problems: LintProblem[] = [...file.problems];
86
+ const problem = (at: Node, message: string) => problems.push({ ...file.at(at.start), message });
87
+ const unread =
88
+ 'app.ts is read without running it: its default export is `defineApp({ source })` from "sanoma", written out';
89
+ const exported = file.program.body.find((s: Node) => s.type === "ExportDefaultDeclaration");
90
+ const call = unwrap(exported?.declaration);
91
+ const callee = unwrap(call?.callee);
92
+ const options = unwrap(call?.arguments?.[0]);
93
+ const defineApps = new Set<string>();
94
+ for (const statement of file.program.body) {
95
+ if (statement.type !== "ImportDeclaration" || !APP_SOURCES.has(statement.source.value)) continue;
96
+ for (const s of statement.specifiers) {
97
+ if (s.type === "ImportSpecifier" && keyName(s.imported) === "defineApp") defineApps.add(s.local.name);
98
+ }
99
+ }
100
+ const written =
101
+ call?.type === "CallExpression" &&
102
+ callee?.type === "Identifier" &&
103
+ defineApps.has(callee.name) &&
104
+ (options === undefined || options.type === "ObjectExpression");
105
+ if (!written) {
106
+ problem(exported ?? file.program, unread);
107
+ return { problems };
108
+ }
109
+ const property = options?.properties.findLast(
110
+ (p: Node) => p.type === "Property" && !p.computed && keyName(p.key) === "source",
111
+ );
112
+ const source = property && stringValue(property.value);
113
+ if (property && source === undefined)
114
+ problem(property, "`source` is read without running app.ts: write it as a string");
115
+ return { ...(source !== undefined && { source }), problems };
116
+ }
117
+
118
+ /** A source file under an app: `.ts` or `.tsx`, but a declaration. */
119
+ const isSource = (name: string) => /\.tsx?$/.test(name) && !name.endsWith(".d.ts");
120
+
121
+ /**
122
+ * Each import, from a file under one app's folder, of a file under another's, at its place: a
123
+ * warning, since a company may take one app without the other. Static imports, re-exports and
124
+ * `import("…")` with a literal path; relative specifiers only.
125
+ */
126
+ export function crossAppImports(root: string | undefined, apps: readonly AppEntry[]): AppProblem[] {
127
+ if (root === undefined) return [];
128
+ const warnings: AppProblem[] = [];
129
+ for (const app of apps) {
130
+ const own = join(root, app.dir);
131
+ for (const path of filesUnder(own, isSource)) {
132
+ const file = readSource(path);
133
+ if (typeof file === "string") continue;
134
+ const rel = relative(root, path).split(sep).join("/");
135
+ const visit = (node: Node) => {
136
+ if (!node) return;
137
+ const specifier =
138
+ node.type === "ImportDeclaration" ||
139
+ node.type === "ExportAllDeclaration" ||
140
+ node.type === "ExportNamedDeclaration"
141
+ ? node.source?.value
142
+ : node.type === "ImportExpression"
143
+ ? stringValue(node.source)
144
+ : undefined;
145
+ if (typeof specifier === "string" && RELATIVE.test(specifier)) {
146
+ const target = resolve(dirname(path), specifier);
147
+ const other = apps.find((a) => a !== app && inside(join(root, a.dir), target));
148
+ if (other) {
149
+ warnings.push({
150
+ file: rel,
151
+ ...file.at(node.start),
152
+ message: `imports "${specifier}" from the app ${other.name}: a company may take ${app.name} without it, so reach it through ctx or api instead`,
153
+ });
154
+ }
155
+ }
156
+ for (const child of childrenOf(node)) visit(child);
157
+ };
158
+ visit(file.program);
159
+ }
160
+ }
161
+ return warnings;
162
+ }
@@ -11,6 +11,9 @@ export { CALL_WORKFLOW } from "./shared.ts";
11
11
  // approval, so the approval reaches the Inbox like any run's. It is a workflow like any other,
12
12
  // held to the same lint: the call goes through `ctx`, under the policy, in the ledger.
13
13
 
14
+ /** An operation on `ctx`. */
15
+ type OpCall = (input: unknown) => Promise<unknown>;
16
+
14
17
  /** The call workflow's input: the operation's id, and its input. */
15
18
  export const CallInput = z.object({ op: z.string(), input: z.unknown() });
16
19
 
@@ -37,8 +40,13 @@ export function callWorkflow(
37
40
  uses,
38
41
  // `ctx` refuses an operation that is not in `uses`: one that may not be called directly, or has no driver.
39
42
  run: async (ctx, input) => {
40
- const [vendor = "", resource = "", name = ""] = input.op.split(".");
41
- return ctx[vendor]![resource]![name]!(input.input);
43
+ // Four segments for an app's collection: `data.<app>.<collection>.<op>`.
44
+ const [vendor = "", a = "", b = "", c] = input.op.split(".");
45
+ return c === undefined
46
+ ? ctx[vendor]![a]![b]!(input.input)
47
+ : (ctx as unknown as Record<string, Record<string, Record<string, Record<string, OpCall>>>>)[vendor]![a]![b]![
48
+ c
49
+ ]!(input.input);
42
50
  },
43
51
  });
44
52
  }
package/src/call.ts CHANGED
@@ -156,9 +156,12 @@ export function buildCtx(wf: WorkflowDefinition<any, any>, run: Run): any {
156
156
  };
157
157
  const members: Record<string, any> = {};
158
158
  for (const op of uses.filter(isOp)) {
159
- const vendor = (members[op.vendor] ??= {});
160
- const resource = (vendor[op.resource] ??= {});
161
- resource[op.name] = placed(op.id, (node, input) =>
159
+ // By the id's segments: an app's collection, `data.crm.contacts.get`, is `ctx.data.crm.contacts.get`.
160
+ const group = op.id
161
+ .split(".")
162
+ .slice(0, -1)
163
+ .reduce<Record<string, any>>((at, key) => (at[key] ??= {}), members);
164
+ group[op.name] = placed(op.id, (node, input) =>
162
165
  serial(run, () => callOp(run, op.id, input, { node: node.path, name: named(node) ?? op.id })),
163
166
  );
164
167
  }
package/src/cedar.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { readFileSync } from "node:fs";
3
- import { join, relative, sep } from "node:path";
3
+ import { relative, sep } from "node:path";
4
4
  import type { CedarValueJson, DetailedError, EntityJson, Expr, PolicyJson } from "@cedar-policy/cedar-wasm/nodejs";
5
5
  import { locator } from "./ast.ts";
6
6
  import type { ResolvedConfig } from "./config.ts";
7
7
  import type { ApprovalState, Approver, Principal } from "./define.ts";
8
- import { filesUnder } from "./paths.ts";
8
+ import { filesUnder, kindDirs } from "./paths.ts";
9
9
  import {
10
10
  allow,
11
11
  allowAll,
@@ -89,12 +89,14 @@ const ON_BEHALF_OF = "on-behalf-of";
89
89
 
90
90
  type Place = { line: number; column: number };
91
91
 
92
- /** `<root>/policies/**\/*.cedar`, sorted by path, each relative to the root with `/` between its parts. */
92
+ /** `<root>/policies/**\/*.cedar`, then each app's `apps/<app>/policies/**\/*.cedar`, sorted by path in each, each relative to the root with `/` between its parts. */
93
93
  export function readPolicyFiles(root: string): { file: string; text: string }[] {
94
- return filesUnder(join(root, POLICIES_DIR), (name) => name.endsWith(".cedar")).map((path) => ({
95
- file: relative(root, path).split(sep).join("/"),
96
- text: readFileSync(path, "utf8"),
97
- }));
94
+ return kindDirs(root, POLICIES_DIR)
95
+ .flatMap((dir) => filesUnder(dir, (name) => name.endsWith(".cedar")))
96
+ .map((path) => ({
97
+ file: relative(root, path).split(sep).join("/"),
98
+ text: readFileSync(path, "utf8"),
99
+ }));
98
100
  }
99
101
 
100
102
  /**
@@ -0,0 +1,274 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join, relative, sep } from "node:path";
3
+ import { DBOS } from "@dbos-inc/dbos-sdk";
4
+ import { withdrawApprovals } from "./approvals.ts";
5
+ import { decide } from "./call.ts";
6
+ import {
7
+ ADMIN,
8
+ type ChangeDecision,
9
+ type ChangeDeploy,
10
+ type ChangeEvent,
11
+ ChangeEventSchema,
12
+ type ChangeFacts,
13
+ type ChangeState,
14
+ type ChangeVerified,
15
+ followBase,
16
+ repositoryOf,
17
+ isRules,
18
+ sourceOfRun,
19
+ touchesOf,
20
+ } from "./change.ts";
21
+ import type { ChangeSteps } from "./change-workflow.ts";
22
+ import type { DriftReport } from "./drift.ts";
23
+ import { isRunControlError } from "./errors.ts";
24
+ import { warn } from "./log.ts";
25
+ import { inside } from "./paths.ts";
26
+ import { DECLARES_PEOPLE } from "./resources.ts";
27
+ import { currentRun, type Run } from "./run.ts";
28
+ import {
29
+ AGENTS,
30
+ APPLY_WORKFLOW,
31
+ CHANGE_EVENT,
32
+ CHANGE_MERGE,
33
+ CHANGE_TOPIC,
34
+ DRIFT_WORKFLOW,
35
+ errorMessage,
36
+ } from "./shared.ts";
37
+ import type { CheckResult, RemoteHalf } from "./source.ts";
38
+
39
+ // The change workflow's steps that are not one of its operations: checking and planning the
40
+ // draft, asking the policy ahead, waiting for what happens to the change, retiring approvals,
41
+ // deploying and verifying after the merge, and publishing where the change is. Here, not in
42
+ // change-workflow.ts, which is held to the workflow lint and imports no DBOS; each reads its run
43
+ // from `currentRun`.
44
+
45
+ const runNow = (): Run => {
46
+ const run = currentRun.getStore();
47
+ if (!run) throw new Error("A change step ran outside its run");
48
+ return run;
49
+ };
50
+
51
+ /** True when the file at `path` is a people file (`DECLARES_PEOPLE`), one `readPeople` would read; false when there is none. */
52
+ const fileDeclaresPeople = async (path: string) => DECLARES_PEOPLE.test(await readFile(path, "utf8").catch(() => ""));
53
+
54
+ /** Which events win when several wait: one that ends the change over one that plans it again. */
55
+ const WEIGHT: Record<ChangeEvent["kind"], number> = { commits: 0, close: 1, closed: 2, merged: 3 };
56
+
57
+ /** How long one `recv` waits before it asks again; a change waits as long as it takes. */
58
+ const RECV_SECONDS = 24 * 60 * 60;
59
+
60
+ /**
61
+ * The next event on the change's topic within `timeoutSeconds`, or undefined for none; a message
62
+ * that is no change event is skipped, with a warning.
63
+ */
64
+ async function received(run: Run, timeoutSeconds: number): Promise<ChangeEvent | undefined> {
65
+ for (;;) {
66
+ const raw = await run.state.deadlines.waiting(run.id, { kind: "message" }, () =>
67
+ DBOS.recv<unknown>(CHANGE_TOPIC, { timeoutSeconds }),
68
+ );
69
+ if (raw === null || raw === undefined) return undefined;
70
+ const event = ChangeEventSchema.safeParse(raw);
71
+ if (event.success) return event.data;
72
+ warn(`run ${run.id}: a message on ${CHANGE_TOPIC} is no change event, so it is ignored`);
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Of `first` and the events waiting on the change's topic, read without waiting, the one that
78
+ * weighs most; undefined for none. A `merged` or `closed` someone told is checked on the host
79
+ * (`settle`) as it is read, so a claim the pull request does not bear out weighs as `commits`.
80
+ */
81
+ async function drain(
82
+ run: Run,
83
+ settle: () => Promise<ChangeEvent>,
84
+ first?: ChangeEvent,
85
+ ): Promise<ChangeEvent | undefined> {
86
+ const checked = (event: ChangeEvent) => (event.kind === "merged" || event.kind === "closed" ? settle() : event);
87
+ let strongest = first && (await checked(first));
88
+ for (let event = await received(run, 0); event; event = await received(run, 0)) {
89
+ const told = await checked(event);
90
+ if (strongest === undefined || WEIGHT[told.kind] > WEIGHT[strongest.kind]) strongest = told;
91
+ }
92
+ return strongest;
93
+ }
94
+
95
+ /**
96
+ * Starts a run of the built-in `name` as a child of this one, with the id `<run>:<suffix>` (so a
97
+ * replay finds it), as the run's own actor, and waits for its output, counted as a wait for
98
+ * another run: undefined and the error's message when it failed.
99
+ */
100
+ async function child(
101
+ run: Run,
102
+ name: string,
103
+ suffix: string,
104
+ input: unknown,
105
+ ): Promise<{ runId: string; output?: unknown; error?: string }> {
106
+ const runId = `${run.id}:${suffix}`;
107
+ const handle = await run.state.startRun(name, runId, {
108
+ input,
109
+ startedBy: run.actor,
110
+ ...(run.sandbox !== undefined && { sandbox: run.sandbox }),
111
+ });
112
+ try {
113
+ return { runId, output: await run.state.deadlines.waiting(run.id, { kind: "run" }, () => handle.getResult()) };
114
+ } catch (err) {
115
+ if (isRunControlError(err)) throw err;
116
+ return { runId, error: errorMessage(err) };
117
+ }
118
+ }
119
+
120
+ /** A file's path relative to `root` with `/`, as git names it; undefined when it is outside. */
121
+ const pathIn = (root: string, file: string) =>
122
+ inside(root, file) ? relative(root, file).split(sep).join("/") : undefined;
123
+
124
+ /** What `changeSteps` is given: the connectors' remote halves, and the declared hosts' ids, read from the data files. */
125
+ export interface ChangeStepsOptions {
126
+ remotes: ReadonlyMap<string, RemoteHalf>;
127
+ /** The resources the data files declare whose type is a host (`<file>#<export>`): none when no connector declares one. */
128
+ hosts: () => Promise<string[]>;
129
+ }
130
+
131
+ /** The change workflow's steps for a config. */
132
+ export function changeSteps({ remotes, hosts }: ChangeStepsOptions): ChangeSteps {
133
+ /** What the host says became of the pull request: merged or closed, else (still open) `commits`, to plan again. */
134
+ const settled = (number: number) =>
135
+ DBOS.runStep(
136
+ async (): Promise<ChangeEvent> => {
137
+ const { host } = await repositoryOf(remotes, runNow().state.secrets);
138
+ const pr = await host.pullRequest(number);
139
+ if (pr.state === "merged") return { kind: "merged", sha: pr.mergeSha ?? pr.head };
140
+ return pr.state === "closed" ? { kind: "closed" } : { kind: "commits" };
141
+ },
142
+ { name: "change:settled" },
143
+ );
144
+
145
+ return {
146
+ check: (id) =>
147
+ DBOS.runStep(
148
+ async (): Promise<CheckResult> => {
149
+ const source = sourceOfRun();
150
+ if (!source.check) throw new Error("This repository cannot check a draft (`Source.check`)");
151
+ return source.check(id);
152
+ },
153
+ { name: "change:check" },
154
+ ),
155
+
156
+ plan: (id, check) =>
157
+ DBOS.runStep(
158
+ async (): Promise<ChangeFacts> => {
159
+ const run = runNow();
160
+ const source = sourceOfRun();
161
+ if (!source.diff || !source.draft) throw new Error("This repository keeps no drafts (`Source.diff`)");
162
+ const [{ files }, draft] = await Promise.all([source.diff(id), source.draft(id)]);
163
+ const touches = touchesOf(files);
164
+ const touched = new Set(touches);
165
+ // The waiting runs of a workflow whose file the change edits, which a publish cannot move.
166
+ const edited = [...run.state.workflows]
167
+ .filter(([, wf]) => {
168
+ const path = wf.file === undefined ? undefined : pathIn(source.root, wf.file);
169
+ return path !== undefined && touched.has(path);
170
+ })
171
+ .map(([name]) => name);
172
+ const pending = edited.length
173
+ ? await DBOS.listWorkflows({
174
+ status: "PENDING",
175
+ workflowName: edited,
176
+ applicationName: run.state.app,
177
+ loadInput: false,
178
+ loadOutput: false,
179
+ })
180
+ : [];
181
+ const stranded = pending
182
+ .map((r) => r.workflowID)
183
+ .filter((runId) => runId !== run.id)
184
+ .toSorted();
185
+ // A people file as main has it or as the draft makes it: one added, edited or emptied.
186
+ const people = await Promise.all(
187
+ files
188
+ .filter((file) => file.startsWith("resources/"))
189
+ .flatMap((file) => [join(source.root, file), join(draft.root, file)].map(fileDeclaresPeople)),
190
+ );
191
+ return {
192
+ touches,
193
+ strandedRuns: stranded.length,
194
+ stranded,
195
+ checksOk: check.ok,
196
+ rules: people.includes(true) || touches.some(isRules),
197
+ };
198
+ },
199
+ { name: "change:plan" },
200
+ ),
201
+
202
+ ahead: (merge) =>
203
+ DBOS.runStep(
204
+ async (): Promise<ChangeDecision> => {
205
+ const run = runNow();
206
+ const op = run.state.ops.get(CHANGE_MERGE)!;
207
+ const decision = await decide(run, op, op.input.parse(merge));
208
+ if (decision.kind === "deny") return { answer: "deny", reason: decision.reason };
209
+ if (decision.kind === "approve") {
210
+ return {
211
+ answer: "hold",
212
+ approver: decision.approver,
213
+ ...(decision.title !== undefined && { title: decision.title }),
214
+ ...(decision.reasons !== undefined && { reasons: decision.reasons }),
215
+ };
216
+ }
217
+ // Allowed: Publish is still someone's to press: whoever drafted it, else admin for an agent acting for no one.
218
+ const { actor } = run;
219
+ const drafter = actor.onBehalfOf ?? (actor.groups?.includes(AGENTS) ? undefined : actor.id);
220
+ return { answer: "allow", approver: drafter ?? { group: ADMIN } };
221
+ },
222
+ { name: "change:policy" },
223
+ ),
224
+
225
+ async next(number) {
226
+ const run = runNow();
227
+ for (;;) {
228
+ // An event comes from a request (`SanomaClient.changeEvent`), which wakes a stopped worker: no deadline.
229
+ const event = await received(run, RECV_SECONDS);
230
+ if (event) return (await drain(run, () => settled(number), event))!;
231
+ }
232
+ },
233
+
234
+ queued: (number) => drain(runNow(), () => settled(number)),
235
+
236
+ retire: (reason) => withdrawApprovals(runNow(), CHANGE_MERGE, reason),
237
+
238
+ async deploy() {
239
+ const run = runNow();
240
+ // The checkout moves to the merge first, so the hosts and what apply reads are the merged ones.
241
+ // Merged already: a deploy that fails is the change's to report, not a failed run.
242
+ const follow: ChangeDeploy["follow"] = await DBOS.runStep(() => followBase(remotes, run.state.secrets), {
243
+ name: "change:follow",
244
+ });
245
+ const declared = run.state.workflows.has(APPLY_WORKFLOW)
246
+ ? await DBOS.runStep(hosts, { name: "change:hosts" })
247
+ : [];
248
+ if (!declared.length) return { follow };
249
+ const { runId, error } = await child(run, APPLY_WORKFLOW, "deploy", { only: declared });
250
+ return { follow, apply: { runId, hosts: declared, ...(error !== undefined && { error }) } };
251
+ },
252
+
253
+ async verify({ apply }) {
254
+ const run = runNow();
255
+ if (apply === undefined || !run.state.workflows.has(DRIFT_WORKFLOW)) return undefined;
256
+ const { runId, output, error } = await child(run, DRIFT_WORKFLOW, "verify", {});
257
+ const applied = new Set(apply.hosts);
258
+ const read = ((output as DriftReport | undefined)?.resources ?? []).filter((r) => applied.has(r.id));
259
+ // A host drift could not read is no drift: it is said in `error`.
260
+ const drifted = read.filter((r) => r.status !== "clean" && r.status !== "error").map((r) => r.id);
261
+ const unread = read.filter((r) => r.status === "error").map((r) => r.id);
262
+ const errors = [error, ...(unread.length ? [`could not read ${unread.join(", ")}`] : [])].filter(
263
+ (e) => e !== undefined,
264
+ );
265
+ return { runId, drifted, ...(errors.length && { error: errors.join("; ") }) } satisfies ChangeVerified;
266
+ },
267
+
268
+ closeDraft: (id) => DBOS.runStep(async () => sourceOfRun().closeDraft?.(id), { name: "change:close-draft" }),
269
+
270
+ publish: async (state: ChangeState) => {
271
+ await DBOS.setEvent(CHANGE_EVENT, state);
272
+ },
273
+ };
274
+ }