@sanoma-ai/workflows 0.1.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 (184) hide show
  1. package/README.md +186 -76
  2. package/dist/apply-steps.d.ts +6 -0
  3. package/dist/apply-steps.js +93 -0
  4. package/dist/apply-steps.js.map +1 -0
  5. package/dist/apply.d.ts +197 -0
  6. package/dist/apply.js +470 -0
  7. package/dist/apply.js.map +1 -0
  8. package/dist/approvals.d.ts +11 -6
  9. package/dist/approvals.js +38 -10
  10. package/dist/approvals.js.map +1 -1
  11. package/dist/call-workflow.d.ts +16 -0
  12. package/dist/call-workflow.js +35 -0
  13. package/dist/call-workflow.js.map +1 -0
  14. package/dist/call.d.ts +35 -2
  15. package/dist/call.js +123 -43
  16. package/dist/call.js.map +1 -1
  17. package/dist/cedar.d.ts +87 -0
  18. package/dist/cedar.js +365 -0
  19. package/dist/cedar.js.map +1 -0
  20. package/dist/client.d.ts +123 -9
  21. package/dist/client.js +355 -34
  22. package/dist/client.js.map +1 -1
  23. package/dist/collection.d.ts +107 -0
  24. package/dist/collection.js +306 -0
  25. package/dist/collection.js.map +1 -0
  26. package/dist/config.d.ts +42 -14
  27. package/dist/config.js +109 -28
  28. package/dist/config.js.map +1 -1
  29. package/dist/constructors.d.ts +2 -0
  30. package/dist/constructors.js +5 -0
  31. package/dist/constructors.js.map +1 -0
  32. package/dist/credentials.d.ts +25 -0
  33. package/dist/credentials.js +73 -5
  34. package/dist/credentials.js.map +1 -1
  35. package/dist/data.d.ts +2 -0
  36. package/dist/data.js +3 -0
  37. package/dist/data.js.map +1 -0
  38. package/dist/datafile.d.ts +38 -3
  39. package/dist/datafile.js +166 -13
  40. package/dist/datafile.js.map +1 -1
  41. package/dist/db.d.ts +5 -3
  42. package/dist/db.js +88 -21
  43. package/dist/db.js.map +1 -1
  44. package/dist/define.d.ts +10 -4
  45. package/dist/define.js +1 -5
  46. package/dist/define.js.map +1 -1
  47. package/dist/describe.d.ts +54 -8
  48. package/dist/describe.js +84 -11
  49. package/dist/describe.js.map +1 -1
  50. package/dist/direct.d.ts +9 -0
  51. package/dist/direct.js +11 -0
  52. package/dist/direct.js.map +1 -0
  53. package/dist/drift.d.ts +64 -12
  54. package/dist/drift.js +118 -41
  55. package/dist/drift.js.map +1 -1
  56. package/dist/errors.d.ts +16 -1
  57. package/dist/errors.js +30 -1
  58. package/dist/errors.js.map +1 -1
  59. package/dist/expectations.d.ts +4 -4
  60. package/dist/expectations.js +43 -9
  61. package/dist/expectations.js.map +1 -1
  62. package/dist/host.d.ts +56 -0
  63. package/dist/host.js +45 -0
  64. package/dist/host.js.map +1 -0
  65. package/dist/index.d.ts +13 -5
  66. package/dist/index.js +15 -5
  67. package/dist/index.js.map +1 -1
  68. package/dist/ledger.d.ts +12 -1
  69. package/dist/ledger.js +18 -11
  70. package/dist/ledger.js.map +1 -1
  71. package/dist/lint.d.ts +5 -4
  72. package/dist/lint.js +24 -7
  73. package/dist/lint.js.map +1 -1
  74. package/dist/lockfile.d.ts +36 -0
  75. package/dist/lockfile.js +69 -0
  76. package/dist/lockfile.js.map +1 -0
  77. package/dist/op.d.ts +9 -0
  78. package/dist/op.js.map +1 -1
  79. package/dist/outline.d.ts +2 -0
  80. package/dist/outline.js +1 -1
  81. package/dist/outline.js.map +1 -1
  82. package/dist/paths.d.ts +4 -0
  83. package/dist/paths.js +17 -3
  84. package/dist/paths.js.map +1 -1
  85. package/dist/person.d.ts +61 -0
  86. package/dist/person.js +69 -0
  87. package/dist/person.js.map +1 -0
  88. package/dist/plugin.js +1 -1
  89. package/dist/plugin.js.map +1 -1
  90. package/dist/policy.d.ts +51 -55
  91. package/dist/policy.js +14 -36
  92. package/dist/policy.js.map +1 -1
  93. package/dist/resource.d.ts +207 -25
  94. package/dist/resource.js +184 -30
  95. package/dist/resource.js.map +1 -1
  96. package/dist/resources.d.ts +76 -9
  97. package/dist/resources.js +170 -30
  98. package/dist/resources.js.map +1 -1
  99. package/dist/run.d.ts +31 -1
  100. package/dist/run.js +6 -1
  101. package/dist/run.js.map +1 -1
  102. package/dist/sandbox.js +5 -1
  103. package/dist/sandbox.js.map +1 -1
  104. package/dist/scenario.d.ts +25 -9
  105. package/dist/scenario.js +125 -27
  106. package/dist/scenario.js.map +1 -1
  107. package/dist/scenarios.d.ts +10 -6
  108. package/dist/scenarios.js +8 -4
  109. package/dist/scenarios.js.map +1 -1
  110. package/dist/schema.d.ts +32 -0
  111. package/dist/schema.js +225 -0
  112. package/dist/schema.js.map +1 -0
  113. package/dist/sdk.d.ts +34 -0
  114. package/dist/sdk.js +168 -0
  115. package/dist/sdk.js.map +1 -0
  116. package/dist/secret.d.ts +42 -0
  117. package/dist/secret.js +58 -0
  118. package/dist/secret.js.map +1 -0
  119. package/dist/shared.d.ts +188 -4
  120. package/dist/shared.js +165 -5
  121. package/dist/shared.js.map +1 -1
  122. package/dist/sites.d.ts +52 -0
  123. package/dist/sites.js +174 -0
  124. package/dist/sites.js.map +1 -0
  125. package/dist/source.d.ts +58 -0
  126. package/dist/source.js +54 -0
  127. package/dist/source.js.map +1 -0
  128. package/dist/version.d.ts +3 -2
  129. package/dist/version.js +3 -2
  130. package/dist/version.js.map +1 -1
  131. package/dist/worker.d.ts +6 -2
  132. package/dist/worker.js +37 -8
  133. package/dist/worker.js.map +1 -1
  134. package/dist/writer.d.ts +35 -0
  135. package/dist/writer.js +201 -0
  136. package/dist/writer.js.map +1 -0
  137. package/oxlint.json +5 -16
  138. package/package.json +11 -1
  139. package/src/apply-steps.ts +110 -0
  140. package/src/apply.ts +626 -0
  141. package/src/approvals.ts +49 -13
  142. package/src/call-workflow.ts +44 -0
  143. package/src/call.ts +173 -46
  144. package/src/cedar.ts +493 -0
  145. package/src/client.ts +450 -49
  146. package/src/collection.ts +431 -0
  147. package/src/config.ts +168 -48
  148. package/src/constructors.ts +4 -0
  149. package/src/credentials.ts +90 -5
  150. package/src/data.ts +10 -0
  151. package/src/datafile.ts +207 -15
  152. package/src/db.ts +90 -22
  153. package/src/define.ts +11 -9
  154. package/src/describe.ts +146 -23
  155. package/src/direct.ts +15 -0
  156. package/src/drift.ts +155 -45
  157. package/src/errors.ts +36 -1
  158. package/src/expectations.ts +46 -11
  159. package/src/host.ts +96 -0
  160. package/src/index.ts +56 -2
  161. package/src/ledger.ts +35 -17
  162. package/src/lint.ts +29 -12
  163. package/src/lockfile.ts +95 -0
  164. package/src/op.ts +9 -0
  165. package/src/outline.ts +1 -1
  166. package/src/paths.ts +18 -3
  167. package/src/person.ts +75 -0
  168. package/src/plugin.ts +1 -1
  169. package/src/policy.ts +59 -63
  170. package/src/resource.ts +302 -52
  171. package/src/resources.ts +226 -30
  172. package/src/run.ts +33 -1
  173. package/src/sandbox.ts +5 -1
  174. package/src/scenario.ts +175 -43
  175. package/src/scenarios.ts +15 -8
  176. package/src/schema.ts +261 -0
  177. package/src/sdk.ts +211 -0
  178. package/src/secret.ts +92 -0
  179. package/src/shared.ts +279 -6
  180. package/src/sites.ts +220 -0
  181. package/src/source.ts +82 -0
  182. package/src/version.ts +3 -2
  183. package/src/worker.ts +45 -11
  184. package/src/writer.ts +225 -0
package/src/shared.ts CHANGED
@@ -1,6 +1,9 @@
1
+ import type { RunSummary } from "./client.ts";
1
2
  import type { CredentialStatus } from "./env.ts";
2
3
  import type { ApprovalState, Approver, Principal } from "./define.ts";
3
- import type { ResourceProblem } from "./resources.ts";
4
+ import type { LedgerRecord } from "./ledger.ts";
5
+ import type { Effect } from "./op.ts";
6
+ import type { DeclaredResource, ResourceProblem } from "./resources.ts";
4
7
  import type { OutlineNode } from "./outline.ts";
5
8
 
6
9
  // Types only: resource types as a UI reads them. Erased from the bundle.
@@ -15,6 +18,7 @@ export type {
15
18
  } from "./resource.ts";
16
19
  export type { DeclaredResource, ResourceProblem } from "./resources.ts";
17
20
  export type { DriftField, DriftReport, DriftResult, DriftStatus } from "./drift.ts";
21
+ export type { ApplyPlan, ApplyReport, ApplyResult, PlanAnswer, PlanStep } from "./apply.ts";
18
22
  export type { CredentialStatus } from "./env.ts";
19
23
 
20
24
  // What a UI in the browser may use from the runtime, besides types: `@sanoma-ai/workflows/shared`.
@@ -30,6 +34,124 @@ export const DEFAULT_SLEEP_NAME = "Sleep";
30
34
  /** The built-in drift workflow's name, which a config's own workflows may not take; a UI and the scenarios name its runs by it. */
31
35
  export const DRIFT_WORKFLOW = "drift";
32
36
 
37
+ /**
38
+ * The built-in apply workflow's name: the only workflow that may call a writable resource type's
39
+ * change operations (`create`, `update`, `delete`).
40
+ */
41
+ export const APPLY_WORKFLOW = "apply";
42
+
43
+ /**
44
+ * The vendor of the collections' operations, `data.<collection>.<op>`: a collection's `get`,
45
+ * `list`, `save` and `delete`. No connector may take it.
46
+ */
47
+ export const DATA_VENDOR = "data";
48
+
49
+ /** Every effect an operation may have, each an action group (`effect:<effect>`) of the Cedar schema. */
50
+ export const EFFECTS = ["read", "write", "publish", "send", "money", "access"] as const satisfies readonly Effect[];
51
+
52
+ /** What a workflow start's action id starts with, as a policy names it: `workflow:<name>`. */
53
+ export const WORKFLOW_ACTION = "workflow:";
54
+
55
+ /** A site's folder name under `sites/`, its URL segment: what the app mounts and `readSites` reads. */
56
+ export const SITE_NAME = /^[a-z][a-z0-9-]*$/;
57
+
58
+ /**
59
+ * A record of a collection as `get`, `list` and `save` answer it: its key, the record, and when
60
+ * and by whom it was last saved (`updatedBy`: a person's id, or `run:<id>` for a run). A record
61
+ * saved under an older declaration comes back as it is stored, with `issues` saying where the
62
+ * fields no longer accept it.
63
+ */
64
+ export type Saved<R = Record<string, unknown>> = {
65
+ key: string;
66
+ record: R;
67
+ /** ISO 8601. */
68
+ updatedAt: string;
69
+ updatedBy: string;
70
+ issues?: { path: (string | number)[]; message: string }[];
71
+ };
72
+
73
+ /**
74
+ * What a collection's `list` takes: fields equal to a value or within a range (`where`), the
75
+ * field to sort by (`-field` for descending; by key otherwise), and how many at most (1 to
76
+ * 1000, default 100).
77
+ */
78
+ export type ListQuery<R> = {
79
+ where?: { [F in keyof R]?: Exclude<R[F], undefined> | Range<Exclude<R[F], undefined>> };
80
+ order?: (keyof R & string) | `-${keyof R & string}`;
81
+ limit?: number;
82
+ };
83
+
84
+ type Range<V> = { gt?: V; gte?: V; lt?: V; lte?: V };
85
+
86
+ /** How many records a collection's `list` answers: `default` when the query names no `limit`, `max` at most. */
87
+ export const LIST_LIMIT = { default: 100, max: 1000 } as const;
88
+
89
+ /** The kinds of value a field holds: text, a number, true or false, a date (ISO 8601) or a person's id. */
90
+ export const FIELD_TYPES = ["text", "number", "boolean", "date", "person"] as const;
91
+
92
+ const COLLECTION_NAME = /^[a-z][a-zA-Z0-9]*$/;
93
+ const FIELD_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
94
+ const KINDS = `${FIELD_TYPES.map((t) => `"${t}"`).join(", ")}, { type, optional: true } or { enum: [...] }`;
95
+ const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
96
+
97
+ /**
98
+ * Throws unless `collection()` takes this declaration: the name a lowercase letter then letters
99
+ * and digits, at least one field, each a type, the type marked optional or an enum of distinct
100
+ * strings, the key a required text field, the title a string. `collection()` and the reader of
101
+ * `resources/data.ts` both ask it, so `sanoma check` refuses what the runtime would.
102
+ */
103
+ export function checkCollection(declaration: { name?: unknown; key?: unknown; fields?: unknown; title?: unknown }) {
104
+ const { name, key, fields, title } = declaration;
105
+ const where = `collection("${String(name)}")`;
106
+ if (typeof name !== "string" || !COLLECTION_NAME.test(name)) {
107
+ throw new Error(`${where}: the name must be a lowercase letter, then letters and digits, such as "outbound"`);
108
+ }
109
+ if (!isRecord(fields) || !Object.keys(fields).length) {
110
+ throw new Error(`${where}: it needs fields, by name: each one of ${KINDS}`);
111
+ }
112
+ for (const [field, kind] of Object.entries(fields)) checkField(where, field, kind);
113
+ const keyKind: unknown = typeof key === "string" && Object.hasOwn(fields, key) ? fields[key] : undefined;
114
+ if (!(keyKind === "text" || (isRecord(keyKind) && keyKind.type === "text" && keyKind.optional === undefined))) {
115
+ throw new Error(`${where}: the key "${String(key)}" must be one of its fields, a required "text" one`);
116
+ }
117
+ if (title !== undefined && typeof title !== "string") throw new Error(`${where}: the title must be a string`);
118
+ }
119
+
120
+ /** Throws unless `kind` is a field `collection()` takes: a type, the type marked optional, or an enum of distinct strings. */
121
+ function checkField(where: string, field: string, kind: unknown): void {
122
+ const refused = (why: string) => new Error(`${where}: the field "${field}" ${why}`);
123
+ if (!FIELD_NAME.test(field)) throw refused("must be named with letters, digits and _, not a digit first");
124
+ const types: readonly unknown[] = FIELD_TYPES;
125
+ const kinds = `is ${JSON.stringify(kind)}; a field is one of ${KINDS}`;
126
+ if (typeof kind === "string") {
127
+ if (!types.includes(kind)) throw refused(kinds);
128
+ return;
129
+ }
130
+ if (!isRecord(kind)) throw refused(kinds);
131
+ const { type, enum: values, optional, ...rest } = kind;
132
+ if (Object.keys(rest).length || (type === undefined) === (values === undefined)) throw refused(kinds);
133
+ if (optional !== undefined && optional !== true) throw refused("may only be marked `optional: true`");
134
+ if (type !== undefined && !types.includes(type)) throw refused(kinds);
135
+ if (
136
+ values !== undefined &&
137
+ (!Array.isArray(values) ||
138
+ !values.length ||
139
+ values.some((v) => typeof v !== "string") ||
140
+ new Set(values).size < values.length)
141
+ ) {
142
+ throw refused("needs an enum of distinct strings, at least one");
143
+ }
144
+ }
145
+
146
+ /**
147
+ * The built-in call workflow's name: one operation that may be called directly (`isDirect`),
148
+ * made a run when the policy holds a direct call for an approval.
149
+ */
150
+ export const CALL_WORKFLOW = "call";
151
+
152
+ /** The workflows the runtime adds itself, which a config's own may not be named: drift, apply and call. */
153
+ export const BUILT_IN_WORKFLOWS: ReadonlySet<string> = new Set([DRIFT_WORKFLOW, APPLY_WORKFLOW, CALL_WORKFLOW]);
154
+
33
155
  /**
34
156
  * Every run status, in the order a run may pass through them: waiting on the queue, running,
35
157
  * waiting for an approval, or ended. `waiting` is `running` with an approval pending.
@@ -46,12 +168,124 @@ export const ENDED_STATUSES = ["finished", "failed", "cancelled"] as const satis
46
168
  export const isEnded = (status: RunStatus): boolean => (ENDED_STATUSES as readonly RunStatus[]).includes(status);
47
169
 
48
170
  /**
49
- * True when `by` may decide the approval: `by.id` is the approver, or `by.groups` holds the
50
- * approver group. Pure: everything it reads is already recorded.
171
+ * Why the run's own actor may not decide an approval on it: separation of duties (`mayDecide`),
172
+ * while someone else could decide it.
173
+ */
174
+ export const OWN_CHANGE = "own change";
175
+
176
+ /** A run as `mayDecide` reads it: who started it, its own actor. */
177
+ type DecidedRun = { actor?: Pick<Principal, "id"> | undefined };
178
+
179
+ /**
180
+ * True when `by` may decide the approval on `run`: `by.id` is the approver, or `by.groups` holds
181
+ * the approver group. The run's own actor is refused only while someone else could decide (separation
182
+ * of duties): the approver is a group with another declared person in `people` (an agent does not
183
+ * count). Pure: everything it reads is passed in.
51
184
  */
52
- export function mayDecide(approval: Pick<ApprovalState, "approver">, by: Principal): boolean {
53
- const { approver } = approval;
54
- return typeof approver === "string" ? approver === by.id : (by.groups?.includes(approver.group) ?? false);
185
+ export const mayDecide = (
186
+ approval: Pick<ApprovalState, "approver">,
187
+ by: Principal,
188
+ run: DecidedRun,
189
+ people: readonly PeopleResource[],
190
+ ): boolean => refusalOf(approval, by, run, people) === undefined;
191
+
192
+ /** Why `by` may not decide the approval on `run` (`mayDecide`), naming who may, or `OWN_CHANGE`; undefined when they may. */
193
+ export function refusalOf(
194
+ { approver }: Pick<ApprovalState, "approver">,
195
+ by: Principal,
196
+ run: DecidedRun,
197
+ people: readonly PeopleResource[],
198
+ ): string | undefined {
199
+ if (typeof approver === "string") {
200
+ return approver === by.id ? undefined : `${by.id} is not the approver; ${approverLabel(approver)} is`;
201
+ }
202
+ if (!by.groups?.includes(approver.group)) return `${by.id} is not in ${approverLabel(approver)}`;
203
+ if (by.id !== run.actor?.id) return undefined;
204
+ const someoneElse = personsOf(people).some(
205
+ ({ id }) => id !== by.id && principalOf(id, people)?.groups?.includes(approver.group),
206
+ );
207
+ return someoneElse ? OWN_CHANGE : undefined;
208
+ }
209
+
210
+ /** A declared resource as the people functions read it: only the runtime's people types count. */
211
+ export type PeopleResource = Pick<DeclaredResource, "vendor" | "type" | "desired">;
212
+
213
+ /** The group every agent is in, the runtime's own among them: no data file declares it. */
214
+ export const AGENTS = "agents";
215
+
216
+ /** The runtime's own agents (scenarios, the host check, scheduled runs), declared whether a data file names them or not. */
217
+ export const RUNTIME_AGENTS: readonly string[] = ["scenarios", "host-check", "schedule"];
218
+
219
+ /** The declared resources of one of the runtime's people types, by `desired`. */
220
+ const peopleOfType = <T>(resources: readonly PeopleResource[], type: "person" | "agent" | "group") =>
221
+ resources.flatMap((r) => (r.vendor === "sanoma" && r.type === type ? [r.desired as T] : []));
222
+
223
+ type PersonFields = { id: string; name?: string; email?: string; github?: string };
224
+ type AgentFields = { id: string; onBehalfOf?: string };
225
+ type GroupFields = { name: string; members: string[] };
226
+
227
+ /** The groups a policy may name and a principal may be in: the declared ones and `agents`, sorted. */
228
+ export const groupNamesOf = (resources: readonly PeopleResource[]): string[] =>
229
+ [...new Set([...peopleOfType<GroupFields>(resources, "group").map((g) => g.name), AGENTS])].toSorted();
230
+
231
+ /** The declared people (`sanoma.person`), in the order declared: not agents, who never sign in. */
232
+ export const personsOf = (resources: readonly PeopleResource[]): PersonFields[] =>
233
+ peopleOfType<PersonFields>(resources, "person");
234
+
235
+ /** The ids of the declared people and agents (not the runtime's own). */
236
+ export const principalIdsOf = (resources: readonly PeopleResource[]): string[] =>
237
+ [...personsOf(resources), ...peopleOfType<AgentFields>(resources, "agent")].map((p) => p.id);
238
+
239
+ /** True when the data files declare a person, agent or group: then only a declared principal is admitted. */
240
+ export const declaresPeople = (resources: readonly PeopleResource[]): boolean =>
241
+ resources.some((r) => r.vendor === "sanoma");
242
+
243
+ /**
244
+ * The principal a declared person or agent (or one of the runtime's own agents) acts as, read
245
+ * from the data files' resources: its id, every group that lists it (and `agents` for an agent),
246
+ * sorted, and an agent's `onBehalfOf`. Undefined for an id nobody declares.
247
+ */
248
+ export function principalOf(id: string, resources: readonly PeopleResource[]): Principal | undefined {
249
+ const agent = peopleOfType<AgentFields>(resources, "agent").find((a) => a.id === id);
250
+ const isAgent = agent !== undefined || RUNTIME_AGENTS.includes(id);
251
+ if (!isAgent && !peopleOfType<PersonFields>(resources, "person").some((p) => p.id === id)) return undefined;
252
+ const groups = peopleOfType<GroupFields>(resources, "group")
253
+ .filter((g) => g.members.includes(id))
254
+ .map((g) => g.name);
255
+ return {
256
+ id,
257
+ groups: [...new Set(isAgent ? [...groups, AGENTS] : groups)].toSorted(),
258
+ ...(agent?.onBehalfOf !== undefined && { onBehalfOf: agent.onBehalfOf }),
259
+ };
260
+ }
261
+
262
+ /**
263
+ * Who `name` is as an approver: a declared group's name (or `agents`) as `{ group }`, else a
264
+ * declared person's or agent's id; undefined when it is neither.
265
+ */
266
+ export function approverOf(name: string, resources: readonly PeopleResource[]): Approver | undefined {
267
+ if (groupNamesOf(resources).includes(name)) return { group: name };
268
+ return principalOf(name, resources) && name;
269
+ }
270
+
271
+ /** True for two strings the same but for case: an email or a GitHub login. */
272
+ const same = (a: string | undefined, b: string | undefined) =>
273
+ a !== undefined && b !== undefined && a.toLowerCase() === b.toLowerCase();
274
+
275
+ /**
276
+ * The id of the declared person a sign-in vouches for, or undefined: by email first, then by
277
+ * GitHub login (each ignoring case), then by id. Never an agent's: agents do not sign in.
278
+ */
279
+ export function personOf(
280
+ login: { email?: string; github?: string; id?: string },
281
+ resources: readonly PeopleResource[],
282
+ ): string | undefined {
283
+ const people = personsOf(resources);
284
+ return (
285
+ people.find((p) => same(p.email, login.email)) ??
286
+ people.find((p) => same(p.github, login.github)) ??
287
+ people.find((p) => login.id !== undefined && p.id === login.id)
288
+ )?.id;
55
289
  }
56
290
 
57
291
  /** Who may decide, as text: the person's id, or "group <name>". */
@@ -114,3 +348,42 @@ export const isLoopback = (host: string) =>
114
348
  /** Whether a driver's environment variable lets it run: set, or unset and optional. */
115
349
  export const credentialReady = ({ status, optional }: CredentialStatus): boolean =>
116
350
  status === "set" || (status === "missing" && optional);
351
+
352
+ /**
353
+ * A workflow's name in a site's `api`: kebab-case made camelCase, `outbound-next` as
354
+ * `api.outboundNext`. `describeConfig` reports two workflows that come out the same, or one that
355
+ * comes out as a vendor's id or `data`, as a problem on the workflow.
356
+ */
357
+ export const apiNameOf = (name: string): string => name.replace(/-([a-z0-9])/g, (_, c: string) => c.toUpperCase());
358
+
359
+ /**
360
+ * A workflow as a site's `api` has it (`api.<apiNameOf(name)>`), started as the person on the
361
+ * page. Called, it starts a run and resolves with its output once the run ends, or rejects with
362
+ * its error; `start` gives the run's id at once, and the rest read runs of this workflow.
363
+ */
364
+ export interface WorkflowFn<I, O> {
365
+ (input: I): Promise<O>;
366
+ /** Starts a run with this input, and answers with its id. */
367
+ start(input: I): Promise<{ runId: string }>;
368
+ /** The run's output once it ends, or its error; `run_running` when it has not ended within `waitMs` (at most 20 s). */
369
+ result(runId: string, waitMs?: number): Promise<O>;
370
+ /** The newest finished run's output, and when it finished (ms since the epoch); null when none has. */
371
+ latest(): Promise<{ runId: string; output: O; at: number } | null>;
372
+ /** Its latest runs, newest first. */
373
+ runs(query?: { limit?: number }): Promise<RunSummary[]>;
374
+ /** The run's approvals, decided and waiting. */
375
+ approvals(runId: string): Promise<ApprovalState[]>;
376
+ /** The run's ledger records, in order. */
377
+ ledger(runId: string): Promise<LedgerRecord[]>;
378
+ }
379
+
380
+ /** An operation a site may call directly (a read, or a collection's write), as the person on the page. */
381
+ export type OpFn<I, O> = (input: I) => Promise<O>;
382
+
383
+ /** A collection as a site's `api.data.<name>` has it: its four operations (`Saved`, `ListQuery`). */
384
+ export interface CollectionApi<R> {
385
+ get: OpFn<{ key: string }, Saved<R> | null>;
386
+ list(query?: ListQuery<R>): Promise<Saved<R>[]>;
387
+ save: OpFn<{ record: R }, Saved<R>>;
388
+ delete: OpFn<{ key: string }, { deleted: boolean }>;
389
+ }
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/source.ts ADDED
@@ -0,0 +1,82 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { z } from "zod";
4
+ import { defineConnector, defineDriver, DriverError } from "./op.ts";
5
+
6
+ // Where a company's files live, and how a change to them is recorded: a git repository on this
7
+ // machine, or one a host cloned. The runtime's writers (the app's Adopt, apply's lockfile) change
8
+ // files under `root`, then commit them through it. The launcher makes one; a config without one
9
+ // writes the files and commits nothing.
10
+
11
+ /** A company's repository, as the runtime writes to it. */
12
+ export interface Source {
13
+ /** The repository's root directory. */
14
+ root: string;
15
+ /**
16
+ * Records the files at `paths`, relative to `root`, as one change by `author` (who made it, as
17
+ * the app names them: a login or a typed name). Its revision; undefined when nothing changed.
18
+ */
19
+ commit(change: {
20
+ paths: string[];
21
+ message: string;
22
+ author: string;
23
+ }): { revision: string } | undefined | Promise<{ revision: string } | undefined>;
24
+ /** The revision `ref` names, the checked-out one by default. */
25
+ head(ref?: string): string | Promise<string>;
26
+ /** Pushes `ref` to the repository at `url`, signed in with `token`. */
27
+ push?(target: { url: string; token: string; ref: string }): void | Promise<void>;
28
+ }
29
+
30
+ /**
31
+ * The built-in operation `sanoma.source.resolve`: the commit `ref` names in the repository at
32
+ * `url` now, by `git ls-remote` (a branch first, then a tag, peeled; a full commit id is itself).
33
+ * Apply calls it to fill a host's revision before it plans, and drift to see a merge. `resolveConfig`
34
+ * registers it when a connector declares a host type.
35
+ */
36
+ export const sourceOps = defineConnector("sanoma", {
37
+ source: {
38
+ resolve: {
39
+ effect: "read",
40
+ idempotent: true,
41
+ description: "Resolve a branch or tag of a git repository to the commit it names now",
42
+ input: z.object({ url: z.string().min(1), ref: z.string().min(1) }),
43
+ output: z.object({ revision: z.string() }),
44
+ target: ({ url, ref }: { url: string; ref: string }) => `${url}#${ref}`,
45
+ },
46
+ },
47
+ });
48
+
49
+ export const SOURCE_RESOLVE = "sanoma.source.resolve" satisfies typeof sourceOps.source.resolve.id;
50
+
51
+ /** The driver of `sanoma.source.resolve`: git on this machine, with its own credential helpers. */
52
+ export const sourceDriver = defineDriver(sourceOps, {
53
+ source: { resolve: async ({ url, ref }) => ({ revision: await resolveRevision(url, ref) }) },
54
+ });
55
+
56
+ const COMMIT = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
57
+
58
+ /** The commit `ref` (a branch, a tag, `HEAD`, a full ref or a commit id) names in the repository at `url`. */
59
+ export async function resolveRevision(url: string, ref: string): Promise<string> {
60
+ if (COMMIT.test(ref)) return ref;
61
+ const { stdout } = await promisify(execFile)("git", ["ls-remote", "--", url, ref, `${ref}^{}`], {
62
+ // Never a prompt for credentials: a repository that needs them and has none fails.
63
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
64
+ timeout: 30_000,
65
+ }).catch((err: { stderr?: string; message: string }) => {
66
+ throw new DriverError(`git ls-remote ${url} failed: ${err.stderr?.trim() || err.message}`, {
67
+ retryable: true,
68
+ cause: err,
69
+ });
70
+ });
71
+ const refs = new Map(
72
+ stdout.split("\n").flatMap((line) => {
73
+ const [sha, name] = line.split("\t");
74
+ return sha && name ? [[name, sha] as const] : [];
75
+ }),
76
+ );
77
+ for (const name of [`refs/heads/${ref}`, `refs/tags/${ref}^{}`, `refs/tags/${ref}`, ref]) {
78
+ const sha = refs.get(name);
79
+ if (sha) return sha;
80
+ }
81
+ throw new DriverError(`${url} has no branch or tag ${ref}`, { retryable: false, vendorCode: "no_such_ref" });
82
+ }
package/src/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.1.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