@sanoma-ai/workflows 0.7.0 → 0.8.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 (168) hide show
  1. package/README.md +43 -12
  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 +56 -8
  34. package/dist/config.js +71 -29
  35. package/dist/config.js.map +1 -1
  36. package/dist/constructors.d.ts +37 -1
  37. package/dist/constructors.js +6 -2
  38. package/dist/constructors.js.map +1 -1
  39. package/dist/db.d.ts +21 -21
  40. package/dist/db.js +31 -54
  41. package/dist/db.js.map +1 -1
  42. package/dist/deadlines.d.ts +18 -12
  43. package/dist/deadlines.js +18 -24
  44. package/dist/deadlines.js.map +1 -1
  45. package/dist/define.d.ts +30 -14
  46. package/dist/define.js +9 -1
  47. package/dist/define.js.map +1 -1
  48. package/dist/demo.d.ts +15 -4
  49. package/dist/demo.js +27 -12
  50. package/dist/demo.js.map +1 -1
  51. package/dist/describe.d.ts +16 -2
  52. package/dist/describe.js +39 -4
  53. package/dist/describe.js.map +1 -1
  54. package/dist/direct.d.ts +4 -3
  55. package/dist/direct.js +7 -4
  56. package/dist/direct.js.map +1 -1
  57. package/dist/errors.d.ts +2 -0
  58. package/dist/errors.js +2 -0
  59. package/dist/errors.js.map +1 -1
  60. package/dist/fake.d.ts +26 -0
  61. package/dist/fake.js +123 -0
  62. package/dist/fake.js.map +1 -1
  63. package/dist/host.d.ts +2 -3
  64. package/dist/host.js +1 -1
  65. package/dist/host.js.map +1 -1
  66. package/dist/index.d.ts +8 -7
  67. package/dist/index.js +7 -6
  68. package/dist/index.js.map +1 -1
  69. package/dist/lint.js +20 -6
  70. package/dist/lint.js.map +1 -1
  71. package/dist/op.d.ts +8 -1
  72. package/dist/op.js.map +1 -1
  73. package/dist/outline.js +13 -6
  74. package/dist/outline.js.map +1 -1
  75. package/dist/paths.d.ts +16 -0
  76. package/dist/paths.js +54 -2
  77. package/dist/paths.js.map +1 -1
  78. package/dist/person.d.ts +5 -0
  79. package/dist/person.js +3 -1
  80. package/dist/person.js.map +1 -1
  81. package/dist/resources.d.ts +7 -2
  82. package/dist/resources.js +27 -10
  83. package/dist/resources.js.map +1 -1
  84. package/dist/run.d.ts +7 -0
  85. package/dist/run.js.map +1 -1
  86. package/dist/scenario.d.ts +2 -2
  87. package/dist/scenario.js +10 -4
  88. package/dist/scenario.js.map +1 -1
  89. package/dist/scenarios.d.ts +1 -1
  90. package/dist/scenarios.js.map +1 -1
  91. package/dist/sdk.d.ts +1 -1
  92. package/dist/sdk.js +19 -5
  93. package/dist/sdk.js.map +1 -1
  94. package/dist/shared.d.ts +43 -2
  95. package/dist/shared.js +46 -4
  96. package/dist/shared.js.map +1 -1
  97. package/dist/sites.d.ts +26 -5
  98. package/dist/sites.js +56 -20
  99. package/dist/sites.js.map +1 -1
  100. package/dist/source.d.ts +179 -5
  101. package/dist/source.js +13 -0
  102. package/dist/source.js.map +1 -1
  103. package/dist/supervisor.d.ts +5 -4
  104. package/dist/supervisor.js +2 -11
  105. package/dist/supervisor.js.map +1 -1
  106. package/dist/version.d.ts +8 -0
  107. package/dist/version.js +21 -13
  108. package/dist/version.js.map +1 -1
  109. package/dist/wakeup-driver.d.ts +128 -0
  110. package/dist/wakeup-driver.js +216 -0
  111. package/dist/wakeup-driver.js.map +1 -0
  112. package/dist/wakeup-service.d.ts +4 -4
  113. package/dist/wakeup-service.js +19 -25
  114. package/dist/wakeup-service.js.map +1 -1
  115. package/dist/wakeup-type.d.ts +43 -0
  116. package/dist/wakeup-type.js +80 -0
  117. package/dist/wakeup-type.js.map +1 -0
  118. package/dist/wakeup.d.ts +1 -8
  119. package/dist/wakeup.js +2 -11
  120. package/dist/wakeup.js.map +1 -1
  121. package/dist/worker.d.ts +2 -2
  122. package/dist/worker.js +50 -7
  123. package/dist/worker.js.map +1 -1
  124. package/oxlint.json +3 -2
  125. package/package.json +3 -1
  126. package/src/apps-list.ts +119 -0
  127. package/src/apps.ts +162 -0
  128. package/src/call-workflow.ts +10 -2
  129. package/src/call.ts +6 -3
  130. package/src/cedar.ts +9 -7
  131. package/src/change-steps.ts +274 -0
  132. package/src/change-workflow.ts +198 -0
  133. package/src/change.ts +257 -27
  134. package/src/claims.ts +79 -0
  135. package/src/client.ts +320 -4
  136. package/src/collection.ts +22 -3
  137. package/src/config.ts +104 -35
  138. package/src/constructors.ts +8 -2
  139. package/src/db.ts +39 -66
  140. package/src/deadlines.ts +27 -30
  141. package/src/define.ts +31 -12
  142. package/src/demo.ts +55 -24
  143. package/src/describe.ts +67 -4
  144. package/src/direct.ts +9 -5
  145. package/src/errors.ts +3 -0
  146. package/src/fake.ts +137 -0
  147. package/src/host.ts +3 -4
  148. package/src/index.ts +22 -3
  149. package/src/lint.ts +21 -6
  150. package/src/op.ts +8 -1
  151. package/src/outline.ts +15 -6
  152. package/src/paths.ts +57 -2
  153. package/src/person.ts +4 -1
  154. package/src/resources.ts +31 -11
  155. package/src/run.ts +5 -0
  156. package/src/scenario.ts +14 -4
  157. package/src/scenarios.ts +1 -1
  158. package/src/sdk.ts +21 -7
  159. package/src/shared.ts +60 -5
  160. package/src/sites.ts +70 -24
  161. package/src/source.ts +168 -5
  162. package/src/supervisor.ts +7 -12
  163. package/src/version.ts +25 -16
  164. package/src/wakeup-driver.ts +270 -0
  165. package/src/wakeup-service.ts +21 -28
  166. package/src/wakeup-type.ts +90 -0
  167. package/src/wakeup.ts +6 -17
  168. package/src/worker.ts +48 -9
@@ -0,0 +1,119 @@
1
+ import { z } from "zod";
2
+ import { appClaim, appTier, type Claims, claims, TIERS } from "./claims.ts";
3
+ import { defineConnector, defineDriver } from "./op.ts";
4
+ import { errorMessage } from "./shared.ts";
5
+
6
+ // The apps a company could take: GitHub's repositories with the topic `sanoma-app`, read when
7
+ // asked and never stored, joined with Sanoma's claims (`claims.ts`). One built-in read operation,
8
+ // `sanoma.apps.list`, which the app's Apps page and a public site's pages both call (`isDirect`
9
+ // admits it), under the policy as the person asking.
10
+
11
+ /** The topic a source repository carries so the listing finds it. */
12
+ export const APP_TOPIC = "sanoma-app";
13
+ /** GitHub's REST API, searched without a token (so rate-limited: ten searches a minute per address). */
14
+ export const GITHUB_API = "https://api.github.com";
15
+ /** How long GitHub gets to answer, so a slow or absent network only empties the list. */
16
+ const TIMEOUT_MS = 5_000;
17
+
18
+ /** One repository of GitHub's search answer: what the list shows of it. */
19
+ const Found = z.object({
20
+ full_name: z.string().regex(/^[\w.-]+\/[\w.-]+$/),
21
+ html_url: z.url({ protocol: /^https$/, hostname: /^github\.com$/ }),
22
+ description: z.string().nullish(),
23
+ license: z.object({ spdx_id: z.string().nullish() }).nullish(),
24
+ });
25
+ const Search = z.object({ items: z.array(z.unknown()) });
26
+
27
+ /** An app's source on GitHub, with what Sanoma claims about it. */
28
+ export const AvailableApp = z.object({
29
+ /** `<owner>/<repo>`, as GitHub names it. */
30
+ repo: z.string(),
31
+ /** Its page on GitHub, `https://github.com/<owner>/<repo>`: what `app.ts`'s `source` names once taken. */
32
+ url: z.string(),
33
+ description: z.string().optional(),
34
+ /** Its licence's SPDX id, when GitHub recognizes one. */
35
+ license: z.string().optional(),
36
+ /** Its claim's tier, else official for `Sanoma-AI`'s, else community (`appTier`). */
37
+ tier: z.enum(TIERS),
38
+ reviewed: z.boolean(),
39
+ /** Its commits Sanoma blocks. */
40
+ blocked: z.array(z.string()).optional(),
41
+ /** The solutions it is in, each with its demo's URL once that runs: where "Try it" goes. */
42
+ solutions: z.array(z.object({ id: z.string(), title: z.string(), demo: z.string().optional() })),
43
+ });
44
+ export type AvailableApp = z.infer<typeof AvailableApp>;
45
+
46
+ /** The apps found, Sanoma's first; `error` says why there are none when GitHub could not be searched. */
47
+ export const AvailableApps = z.object({ apps: z.array(AvailableApp), error: z.string().optional() });
48
+ export type AvailableApps = z.infer<typeof AvailableApps>;
49
+
50
+ /** The built-in `sanoma.apps.list` (`APPS_LIST`), which `resolveConfig` registers in every config with its driver. */
51
+ export const appsListOps = defineConnector("sanoma", {
52
+ apps: {
53
+ list: {
54
+ effect: "read",
55
+ idempotent: true,
56
+ description: `List the apps on GitHub (the topic ${APP_TOPIC}), with Sanoma's claims about each`,
57
+ input: z.object({}),
58
+ output: AvailableApps,
59
+ },
60
+ },
61
+ });
62
+
63
+ /** The driver of `sanoma.apps.list`: GitHub's search, now. */
64
+ export const appsListDriver = defineDriver(appsListOps, { apps: { list: () => availableApps() } });
65
+
66
+ /** An app found, with what `from` claims about it. */
67
+ function listed(found: z.infer<typeof Found>, from: Claims): AvailableApp {
68
+ const repo = found.full_name;
69
+ const claim = appClaim(repo, from);
70
+ const solutions = (claim?.solutions ?? []).flatMap((id) => {
71
+ const solution = from.solutions[id];
72
+ return solution ? [{ id, title: solution.title, ...(solution.demo.url && { demo: solution.demo.url }) }] : [];
73
+ });
74
+ return {
75
+ repo,
76
+ url: found.html_url,
77
+ ...(found.description && { description: found.description }),
78
+ ...(found.license?.spdx_id && found.license.spdx_id !== "NOASSERTION" && { license: found.license.spdx_id }),
79
+ tier: appTier(repo, from),
80
+ reviewed: claim?.reviewed ?? false,
81
+ ...(claim?.blocked?.length && { blocked: claim.blocked }),
82
+ solutions,
83
+ };
84
+ }
85
+
86
+ /** 1 for an official app, which the list shows first. */
87
+ const official = (app: AvailableApp) => Number(app.tier === "official");
88
+
89
+ /**
90
+ * The repositories GitHub finds with the topic `sanoma-app`, not archived, the first hundred, each with what
91
+ * `from` claims about it (the claims by default), official first, then by name. A repository
92
+ * GitHub describes otherwise than expected is left out. When GitHub cannot be searched the list
93
+ * is empty, and says why.
94
+ */
95
+ export async function availableApps(from: Claims = claims): Promise<AvailableApps> {
96
+ const url = `${GITHUB_API}/search/repositories?q=${encodeURIComponent(`topic:${APP_TOPIC} archived:false`)}&per_page=100`;
97
+ let items: unknown[];
98
+ try {
99
+ const res = await fetch(url, {
100
+ headers: { accept: "application/vnd.github+json", "user-agent": "sanoma", "x-github-api-version": "2022-11-28" },
101
+ signal: AbortSignal.timeout(TIMEOUT_MS),
102
+ });
103
+ if (!res.ok) {
104
+ await res.body?.cancel();
105
+ throw new Error(`${new URL(url).host} answered ${res.status} to ${new URL(url).pathname}`);
106
+ }
107
+ items = Search.parse(await res.json()).items;
108
+ } catch (err) {
109
+ return { apps: [], error: `Could not search GitHub: ${errorMessage(err)}` };
110
+ }
111
+ return {
112
+ apps: items
113
+ .flatMap((item) => {
114
+ const found = Found.safeParse(item);
115
+ return found.success ? [listed(found.data, from)] : [];
116
+ })
117
+ .toSorted((a, b) => official(b) - official(a) || a.repo.localeCompare(b.repo, "en")),
118
+ };
119
+ }
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
+ }