@sanoma-ai/workflows 0.1.0 → 0.2.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 (99) hide show
  1. package/README.md +72 -21
  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/call.d.ts +4 -0
  9. package/dist/call.js +19 -4
  10. package/dist/call.js.map +1 -1
  11. package/dist/client.d.ts +38 -2
  12. package/dist/client.js +67 -10
  13. package/dist/client.js.map +1 -1
  14. package/dist/config.d.ts +3 -3
  15. package/dist/config.js +33 -16
  16. package/dist/config.js.map +1 -1
  17. package/dist/credentials.d.ts +25 -0
  18. package/dist/credentials.js +73 -5
  19. package/dist/credentials.js.map +1 -1
  20. package/dist/datafile.d.ts +11 -2
  21. package/dist/datafile.js +56 -2
  22. package/dist/datafile.js.map +1 -1
  23. package/dist/describe.d.ts +11 -5
  24. package/dist/describe.js +13 -5
  25. package/dist/describe.js.map +1 -1
  26. package/dist/drift.d.ts +64 -12
  27. package/dist/drift.js +118 -41
  28. package/dist/drift.js.map +1 -1
  29. package/dist/expectations.d.ts +2 -2
  30. package/dist/expectations.js +28 -3
  31. package/dist/expectations.js.map +1 -1
  32. package/dist/host.d.ts +56 -0
  33. package/dist/host.js +45 -0
  34. package/dist/host.js.map +1 -0
  35. package/dist/index.d.ts +6 -2
  36. package/dist/index.js +5 -1
  37. package/dist/index.js.map +1 -1
  38. package/dist/lockfile.d.ts +36 -0
  39. package/dist/lockfile.js +69 -0
  40. package/dist/lockfile.js.map +1 -0
  41. package/dist/op.d.ts +3 -0
  42. package/dist/op.js.map +1 -1
  43. package/dist/resource.d.ts +188 -22
  44. package/dist/resource.js +173 -28
  45. package/dist/resource.js.map +1 -1
  46. package/dist/resources.d.ts +18 -3
  47. package/dist/resources.js +53 -6
  48. package/dist/resources.js.map +1 -1
  49. package/dist/run.d.ts +14 -0
  50. package/dist/run.js +6 -1
  51. package/dist/run.js.map +1 -1
  52. package/dist/sandbox.js +5 -1
  53. package/dist/sandbox.js.map +1 -1
  54. package/dist/scenario.d.ts +12 -3
  55. package/dist/scenario.js +33 -8
  56. package/dist/scenario.js.map +1 -1
  57. package/dist/secret.d.ts +42 -0
  58. package/dist/secret.js +58 -0
  59. package/dist/secret.js.map +1 -0
  60. package/dist/shared.d.ts +8 -0
  61. package/dist/shared.js +7 -0
  62. package/dist/shared.js.map +1 -1
  63. package/dist/source.d.ts +58 -0
  64. package/dist/source.js +54 -0
  65. package/dist/source.js.map +1 -0
  66. package/dist/version.d.ts +1 -1
  67. package/dist/version.js +1 -1
  68. package/dist/worker.d.ts +3 -0
  69. package/dist/worker.js +13 -4
  70. package/dist/worker.js.map +1 -1
  71. package/dist/writer.d.ts +35 -0
  72. package/dist/writer.js +201 -0
  73. package/dist/writer.js.map +1 -0
  74. package/package.json +6 -1
  75. package/src/apply-steps.ts +110 -0
  76. package/src/apply.ts +626 -0
  77. package/src/call.ts +19 -4
  78. package/src/client.ts +83 -13
  79. package/src/config.ts +48 -25
  80. package/src/credentials.ts +90 -5
  81. package/src/datafile.ts +76 -4
  82. package/src/describe.ts +36 -16
  83. package/src/drift.ts +155 -45
  84. package/src/expectations.ts +30 -4
  85. package/src/host.ts +96 -0
  86. package/src/index.ts +24 -1
  87. package/src/lockfile.ts +95 -0
  88. package/src/op.ts +3 -0
  89. package/src/resource.ts +270 -47
  90. package/src/resources.ts +63 -8
  91. package/src/run.ts +15 -0
  92. package/src/sandbox.ts +5 -1
  93. package/src/scenario.ts +50 -13
  94. package/src/secret.ts +92 -0
  95. package/src/shared.ts +10 -0
  96. package/src/source.ts +82 -0
  97. package/src/version.ts +1 -1
  98. package/src/worker.ts +16 -5
  99. package/src/writer.ts +225 -0
package/src/apply.ts ADDED
@@ -0,0 +1,626 @@
1
+ import { z } from "zod";
2
+ import { type Approver, defineWorkflow, type WorkflowDefinition } from "./define.ts";
3
+ import {
4
+ compare,
5
+ type DriftDeclared,
6
+ type DriftField,
7
+ type DriftResult,
8
+ type Found,
9
+ importsOf,
10
+ observeBy,
11
+ observed,
12
+ type ResolveOp,
13
+ type Revision,
14
+ sourceUrl,
15
+ } from "./drift.ts";
16
+ import { isRunControlError } from "./errors.ts";
17
+ import type { HostOutputs } from "./host.ts";
18
+ import type { Binding, Bindings, Rebind } from "./lockfile.ts";
19
+ import type { Op } from "./op.ts";
20
+ import { fieldPathOf, holderOf, type PlanAction, type Resource, valueAt } from "./resource.ts";
21
+ import type { ResourceProblem } from "./resources.ts";
22
+ import { type APPLY_WORKFLOW, errorMessage } from "./shared.ts";
23
+ import { SOURCE_RESOLVE } from "./source.ts";
24
+
25
+ export { APPLY_WORKFLOW } from "./shared.ts";
26
+
27
+ // The built-in `apply` workflow: it observes the declared resources as drift does, plans what
28
+ // would make each match its declaration, asks one approval wherever the policy holds a change,
29
+ // then makes the changes in dependency order, keeping the lockfile's bindings as it goes. It is a
30
+ // workflow like any other, held to the same lint, so every vendor call goes through `ctx`. Only
31
+ // it may call a writable type's change operations (`isChangeOp`).
32
+
33
+ /** What a person answers for a bound object the vendor no longer has (`gone`), or a binding no declaration has (`orphan`). */
34
+ export const PLAN_ANSWERS = ["recreate", "forget", "delete"] as const;
35
+ export type PlanAnswer = (typeof PLAN_ANSWERS)[number];
36
+
37
+ /**
38
+ * The apply workflow's input: `only` the declared ids (`<file>#<export>`) or orphaned lockfile
39
+ * keys to plan, else all; `bind`, a declared id to the vendor's id of the object to bind it to;
40
+ * `answers`, by declared id or orphaned key: `recreate` or `forget` a gone object, `forget` or
41
+ * `delete` an orphaned binding's.
42
+ */
43
+ export const ApplyInput = z.strictObject({
44
+ only: z.array(z.string()).optional(),
45
+ bind: z.record(z.string(), z.string().min(1)).optional(),
46
+ answers: z.record(z.string(), z.enum(PLAN_ANSWERS)).optional(),
47
+ });
48
+ export type ApplyInput = z.infer<typeof ApplyInput>;
49
+
50
+ /**
51
+ * One declared resource, or orphaned binding, by what apply does with it: `create` it (as the
52
+ * vendor plans: `planned`, the paths `unknown` until it exists), `update` or `replace` its bound
53
+ * object (the declared `fields` that differ, the paths that force the replace), `bind` it to an
54
+ * object its identity `found` or the input `given`, ask about a `gone` object or an `orphan`ed
55
+ * binding (unless `answer`ed), or `none`.
56
+ */
57
+ export type PlanStep = { id: string; vendor: string; type: string } & (
58
+ | { action: "create"; planned: Record<string, unknown>; unknown: string[] }
59
+ | { action: "update" | "replace"; binding: string; fields: DriftField[]; replace: string[] }
60
+ | { action: "bind"; binding: string; source: "found" | "given" }
61
+ | { action: "gone"; binding: string; answer?: PlanAnswer }
62
+ | { action: "orphan"; key: string; binding: string; answer?: PlanAnswer }
63
+ | { action: "none"; binding: string }
64
+ );
65
+
66
+ /** What apply would change: the problems, the lockfile's changes it did not make, and each step in dependency order. */
67
+ export interface ApplyPlan {
68
+ problems: ResourceProblem[];
69
+ /** The committed lockfile against the one apply last wrote: changes someone else made. */
70
+ rebinds: Rebind[];
71
+ steps: PlanStep[];
72
+ }
73
+
74
+ /** What became of one step: `done` (a `none` too), `skipped` (a question unanswered), or `failed`, with why. */
75
+ export interface ApplyResult {
76
+ id: string;
77
+ action: PlanStep["action"];
78
+ status: "done" | "skipped" | "failed";
79
+ /** Its binding after the step, when it has one. */
80
+ binding?: string;
81
+ /** For a host it created or updated: where it answers, as its state says. */
82
+ host?: HostOutputs;
83
+ error?: string;
84
+ }
85
+
86
+ /** The apply workflow's output. */
87
+ export interface ApplyReport {
88
+ plan: ApplyPlan;
89
+ results: ApplyResult[];
90
+ /**
91
+ * Whether the run wrote the lockfile (a sandbox run keeps its copy in memory), the revision of
92
+ * its last commit, and the bindings it ended with.
93
+ */
94
+ lockfile: { written: boolean; revision?: string; bindings: Bindings };
95
+ }
96
+
97
+ /** What an apply run reads first: the data files and the lockfile, as drift does, and the lockfile's rebinds. */
98
+ export interface ApplyDeclared extends DriftDeclared {
99
+ bindings: Bindings;
100
+ orphans: (Binding & { key: string })[];
101
+ rebinds: Rebind[];
102
+ }
103
+
104
+ /** One change call apply plans to make, as the policy would see it. */
105
+ export interface PlannedCall {
106
+ op: string;
107
+ input: unknown;
108
+ }
109
+
110
+ /** What apply does that is no vendor call, each a step of its own (`apply-steps.ts`). */
111
+ export interface ApplySteps {
112
+ declared(): Promise<ApplyDeclared>;
113
+ /** Who the policy would hold any of these calls for, the first it holds; undefined when it holds none. */
114
+ holds(calls: PlannedCall[]): Promise<Approver | undefined>;
115
+ /**
116
+ * Binds `key` to `binding` (unbinds it when undefined) in the lockfile as it is now, so a change
117
+ * another run made since this one read it stays, and commits it with `message`; a sandbox run
118
+ * writes nothing.
119
+ */
120
+ write(key: string, binding: Binding | undefined, message: string): Promise<{ written: boolean; revision?: string }>;
121
+ }
122
+
123
+ type State = Record<string, unknown>;
124
+ type Declaration = ApplyDeclared["resources"][number];
125
+ type Expect = { action: PlanAction; replace: string[] };
126
+ // A resource type's operations, as `ctx` gives them: by vendor and type, both only known per run.
127
+ type ImportOp = Op<string, string, "import", { id: string }, { id: string; gone: boolean; state?: State }>;
128
+ type PlanOp = Op<
129
+ string,
130
+ string,
131
+ "plan",
132
+ { id?: string; desired?: State },
133
+ { action: PlanAction; planned?: State; unknown: string[]; replace: string[] }
134
+ >;
135
+ type CreateOp = Op<string, string, "create", { desired: State }, { id: string; state: State }>;
136
+ type UpdateOp = Op<
137
+ string,
138
+ string,
139
+ "update",
140
+ { id: string; desired: State; expect?: Expect },
141
+ { id: string; state: State }
142
+ >;
143
+ type DeleteOp = Op<string, string, "delete", { id: string }, { id: string; gone: true }>;
144
+ // An `afterCreate` operation is any of the config's, called by its id (`afterCreate`), so it is
145
+ // listed as an import is.
146
+ type ApplyUse = ImportOp | PlanOp | CreateOp | UpdateOp | DeleteOp | ResolveOp | "approval" | "sleep";
147
+
148
+ /** How long apply waits between calls of an `afterCreate` operation with `until`, and how many calls it makes. */
149
+ const AFTER_CREATE_SECONDS = 5;
150
+ const AFTER_CREATE_CALLS = 120;
151
+ /** An operation as `ctx` gives it, called by its id. */
152
+ type Call = (input: unknown) => Promise<unknown>;
153
+
154
+ /** What a step's resource is called in a problem: its file, and its export. */
155
+ const problemOf = (id: string, message: string): ResourceProblem => ({
156
+ file: id.slice(0, id.indexOf("#")),
157
+ message: `${id.slice(id.indexOf("#") + 1)}: ${message}`,
158
+ });
159
+
160
+ /** Each resource after the ones it names, otherwise in the files' order. */
161
+ function inDependencyOrder<T extends { id: string; refs: Record<string, string> }>(list: readonly T[]): T[] {
162
+ const byId = new Map(list.map((r) => [r.id, r]));
163
+ const out: T[] = [];
164
+ const seen = new Set<string>();
165
+ const visit = (r: T) => {
166
+ if (seen.has(r.id)) return;
167
+ seen.add(r.id);
168
+ for (const target of Object.values(r.refs)) {
169
+ const named = byId.get(target);
170
+ if (named) visit(named);
171
+ }
172
+ out.push(r);
173
+ };
174
+ list.forEach(visit);
175
+ return out;
176
+ }
177
+
178
+ /**
179
+ * What a change operation is given of a declaration: its fields, where it names a resource the
180
+ * vendor may hold by another field (`by`, as a repository's node id), that resource's binding id
181
+ * when it has one, made in this run or before.
182
+ */
183
+ function desiredOf(
184
+ r: Declaration,
185
+ types: ReadonlyMap<string, Resource>,
186
+ bindings: Bindings,
187
+ hosts: ReadonlyMap<string, { source: string; revision: string }>,
188
+ ): State {
189
+ const { fields, host } = types.get(`${r.vendor}.${r.type}`)!;
190
+ const { references = {} } = fields;
191
+ const out = structuredClone(r.desired);
192
+ for (const [at, target] of Object.entries(r.refs)) {
193
+ const bound = bindings[target]?.id;
194
+ const place = holderOf(out, at);
195
+ if (references[fieldPathOf(at)]?.by?.length && bound !== undefined && place) place.holder[place.key] = bound;
196
+ }
197
+ // A host is given its source's clone URL and the commit its ref names now.
198
+ const run = hosts.get(r.id);
199
+ if (host && run) {
200
+ for (const [path, value] of [
201
+ [host.inputs.source, run.source],
202
+ [host.revision, run.revision],
203
+ ] as const) {
204
+ const place = holderOf(out, path);
205
+ if (place) place.holder[place.key] = value;
206
+ }
207
+ }
208
+ return out;
209
+ }
210
+
211
+ /** Why apply cannot `what` (create, update, delete) an object of a type. */
212
+ const cannotChange = (type: Pick<Resource, "vendor" | "type" | "writable"> | undefined, what: string) =>
213
+ type?.writable
214
+ ? `apply cannot ${what} a ${type.vendor}.${type.type}: no driver does`
215
+ : `apply cannot ${what} it: ${type ? `a ${type.vendor}.${type.type}` : "its type"} is read-only`;
216
+
217
+ /** The ids in `list` that `known` refuses, as problems of the input's field `what`. */
218
+ const unknown = (what: string, list: readonly string[], known: (id: string) => boolean): ResourceProblem[] =>
219
+ list.filter((id) => !known(id)).map((id) => ({ message: `${what}: ${id} is no declared resource` }));
220
+
221
+ /** What the input names that is neither declared nor an orphaned binding. */
222
+ function inputProblems(
223
+ input: ApplyInput,
224
+ ids: ReadonlyMap<string, unknown>,
225
+ orphans: ReadonlySet<string>,
226
+ ): ResourceProblem[] {
227
+ return [
228
+ ...unknown("only", input.only ?? [], (id) => ids.has(id) || orphans.has(id)),
229
+ ...unknown("bind", Object.keys(input.bind ?? {}), (id) => ids.has(id)),
230
+ ...unknown("answers", Object.keys(input.answers ?? {}), (id) => ids.has(id) || orphans.has(id)),
231
+ ];
232
+ }
233
+
234
+ /**
235
+ * What to do with one declared resource, from what observing it found: a step, a problem, or
236
+ * both, or `ask`: the input to its type's `plan`, whose answer `fromPlan` reads.
237
+ */
238
+ function decide(
239
+ r: Declaration,
240
+ result: DriftResult,
241
+ now: Found,
242
+ input: ApplyInput,
243
+ desired: State,
244
+ types: ReadonlyMap<string, Resource>,
245
+ usable: ReadonlySet<string>,
246
+ ): { step?: PlanStep; problem?: ResourceProblem; ask?: { id?: string; desired: State } } {
247
+ const head = { id: r.id, vendor: r.vendor, type: r.type };
248
+ const key = `${r.vendor}.${r.type}`;
249
+ const may = (name: string) => usable.has(`${key}.${name}`);
250
+ const cannot = (what: string) => problemOf(r.id, cannotChange(types.get(key)!, what));
251
+ const given = input.bind?.[r.id];
252
+ if (given !== undefined) {
253
+ if (!("state" in now))
254
+ return { problem: problemOf(r.id, "error" in now ? now.error : `no ${key} ${given} to bind`) };
255
+ return { step: { ...head, action: "bind", binding: types.get(key)!.bindingId(now.state), source: "given" } };
256
+ }
257
+ if (result.status === "error") return { problem: problemOf(r.id, result.error ?? "could not be read") };
258
+ if (result.status === "absent") {
259
+ if (result.found !== undefined)
260
+ return { step: { ...head, action: "bind", binding: result.found, source: "found" } };
261
+ return may("create") ? { ask: { desired } } : { problem: cannot("create") };
262
+ }
263
+ const binding = result.binding!;
264
+ if (result.status === "gone") {
265
+ const answer = input.answers?.[r.id];
266
+ // An object that holds no data is made again, unless answered otherwise; anything else, a person decides.
267
+ if (answer === undefined && types.get(key)!.stateless && may("create")) return { ask: { desired } };
268
+ const step: PlanStep = { ...head, action: "gone", binding };
269
+ if (answer === "recreate" && !may("create")) return { step, problem: cannot("create") };
270
+ if (answer === "recreate" || answer === "forget") return { step: { ...step, answer } };
271
+ return answer === undefined
272
+ ? { step }
273
+ : { step, problem: problemOf(r.id, `a gone object is recreated or forgotten, not ${answer}d`) };
274
+ }
275
+ if (may("plan")) return { ask: { id: binding, desired } };
276
+ const step: PlanStep = { ...head, action: "none", binding };
277
+ return result.status === "drifted" ? { step, problem: cannot("update") } : { step };
278
+ }
279
+
280
+ /** The step a `plan` answer makes of a declared resource: a create for one unbound or gone, else an update, a replace or none. */
281
+ function fromPlan(
282
+ r: Declaration,
283
+ result: DriftResult,
284
+ p: { action: PlanAction; planned?: State; unknown: string[]; replace: string[] },
285
+ ): { step?: PlanStep; problem?: ResourceProblem } {
286
+ const head = { id: r.id, vendor: r.vendor, type: r.type };
287
+ const creating = result.status === "absent" || result.status === "gone";
288
+ if (creating && p.action === "create") {
289
+ return { step: { ...head, action: "create", planned: p.planned ?? {}, unknown: p.unknown } };
290
+ }
291
+ if (!creating && (p.action === "update" || p.action === "replace")) {
292
+ return { step: { ...head, action: p.action, binding: result.binding!, fields: result.fields, replace: p.replace } };
293
+ }
294
+ if (!creating && p.action === "none") return { step: { ...head, action: "none", binding: result.binding! } };
295
+ return { problem: problemOf(r.id, `the vendor plans to ${p.action} it, which apply does not do here`) };
296
+ }
297
+
298
+ /** Each orphaned binding the input wants, after the declarations, and the answers that cannot be done. */
299
+ function orphansOf(
300
+ orphans: ApplyDeclared["orphans"],
301
+ input: ApplyInput,
302
+ wanted: (id: string) => boolean,
303
+ usable: ReadonlySet<string>,
304
+ types: ReadonlyMap<string, Resource>,
305
+ ): { steps: PlanStep[]; problems: ResourceProblem[] } {
306
+ const steps: PlanStep[] = [];
307
+ const problems: ResourceProblem[] = [];
308
+ for (const { key, type, id } of orphans.filter((o) => wanted(o.key))) {
309
+ const answer = input.answers?.[key];
310
+ const [vendor = "", name = ""] = type.split(".");
311
+ const step = { id: key, vendor, type: name, action: "orphan" as const, key, binding: id };
312
+ const why =
313
+ answer === "recreate"
314
+ ? "an orphaned binding is forgotten or its object deleted, not recreated"
315
+ : answer === "delete" && !usable.has(`${type}.delete`)
316
+ ? cannotChange(types.get(type), "delete")
317
+ : undefined;
318
+ if (why) problems.push(problemOf(key, why));
319
+ steps.push(answer === undefined || why ? step : { ...step, answer });
320
+ }
321
+ return { steps, problems };
322
+ }
323
+
324
+ /** What running a step does: a change operation, a change to the lockfile only, nothing, or nothing as it waits for an answer. */
325
+ function runOf(step: PlanStep): "create" | "update" | "delete" | "bind" | "forget" | "none" | "skip" {
326
+ switch (step.action) {
327
+ case "create":
328
+ return "create";
329
+ case "update":
330
+ case "replace":
331
+ return "update";
332
+ case "bind":
333
+ case "none":
334
+ return step.action;
335
+ case "gone":
336
+ return step.answer === "recreate" ? "create" : step.answer === "forget" ? "forget" : "skip";
337
+ case "orphan":
338
+ return step.answer === "delete" ? "delete" : step.answer === "forget" ? "forget" : "skip";
339
+ }
340
+ }
341
+
342
+ /**
343
+ * The change calls the plan would make, as the policy would see them, with the declarations'
344
+ * fields as planned: a create's `afterCreate` writes too, for an object with no id yet.
345
+ */
346
+ function writesOf(
347
+ plan: ApplyPlan,
348
+ desired: ReadonlyMap<string, State>,
349
+ types: ReadonlyMap<string, Resource>,
350
+ ops: ReadonlyMap<string, Op>,
351
+ ): PlannedCall[] {
352
+ return plan.steps.flatMap((step): PlannedCall[] => {
353
+ const at = `${step.vendor}.${step.type}`;
354
+ const run = runOf(step);
355
+ if (run === "create") {
356
+ const made = { id: `new:${at}`, export: exportOf(keyOf(step)) };
357
+ const after = (types.get(at)?.afterCreate ?? [])
358
+ .filter((hook) => ops.get(hook.op)?.effect === "write")
359
+ .map((hook) => ({ op: hook.op, input: hook.input(made) }));
360
+ return [{ op: `${at}.create`, input: { desired: desired.get(step.id) } }, ...after];
361
+ }
362
+ if (run === "delete" && "binding" in step) return [{ op: `${at}.delete`, input: { id: step.binding } }];
363
+ if (run !== "update" || (step.action !== "update" && step.action !== "replace")) return [];
364
+ const expect = { action: step.action, replace: step.replace };
365
+ return [{ op: `${at}.update`, input: { id: step.binding, desired: desired.get(step.id), expect } }];
366
+ });
367
+ }
368
+
369
+ /** The lockfile's key a step binds: a declaration's id, or an orphan's key. */
370
+ const keyOf = (step: PlanStep) => (step.action === "orphan" ? step.key : step.id);
371
+
372
+ /** The export a lockfile key names: `store` of `resources/sanoma/infra.ts#store`. */
373
+ const exportOf = (key: string) => key.slice(key.indexOf("#") + 1);
374
+
375
+ /** The commit message for a step's change to the lockfile. */
376
+ function messageOf(step: PlanStep, before: Binding | undefined, after: Binding | undefined): string {
377
+ const key = keyOf(step);
378
+ if (!after) return `Apply: unbind ${key} (${before?.type} ${before?.id})`;
379
+ const what = runOf(step) === "create" ? "create" : before ? "rebind" : "bind";
380
+ return `Apply: ${what} ${key} (${after.type} ${after.id})`;
381
+ }
382
+
383
+ /**
384
+ * The apply workflow for a config: it `uses` the `import` of every resource type the connectors
385
+ * declare that has a driver, and the change operations (`plan`, `create`, `update`, `delete`) of
386
+ * each writable one, and `approval`. `resolveConfig` adds it beside drift, with `steps`, the
387
+ * steps that read the data files, ask the policy and write the lockfile.
388
+ *
389
+ * A run reads the data files, the lockfile and its rebinds (`steps.declared`), and observes each
390
+ * declared resource as drift does: one import each, by the binding the input gives, else the
391
+ * lockfile's, else its identity. Then, for each resource the input wants (`only`), each after the
392
+ * ones it names, it plans:
393
+ *
394
+ * - `bind` when the input gives an object (`bind`) or, unbound, its identity finds one;
395
+ * - `create` when unbound and nothing is found, as its type's `plan` says (a gone object of a
396
+ * `stateless` type too, unless answered);
397
+ * - `gone` when its bound object is no longer there: skipped unless `answers` says `recreate`
398
+ * (created again) or `forget` (unbound);
399
+ * - `update`, `replace` or `none`, as its type's `plan` says for its bound object;
400
+ * - `orphan` for a binding no declaration has: skipped unless `answers` says `forget` (unbound) or
401
+ * `delete` (deleted at the vendor, then unbound).
402
+ *
403
+ * What cannot be planned is a problem, and has no step. When the policy would hold any of the
404
+ * change calls the plan makes (`steps.holds`), one approval asks for them all, covering each
405
+ * change operation, with the plan as its details. Then each step runs in order: a change call,
406
+ * and right after one that changes a binding, the lockfile written and committed
407
+ * (`steps.write`), so an object made is never left unbound. An update is given the plan's action
408
+ * and replace (`expect`), and refuses (`plan_changed`) when the vendor now plans otherwise; a
409
+ * reference to a resource made in this run is given its new binding. A failed step is reported
410
+ * and the run goes on; a failure that ends the run (`isRunControlError`) ends it.
411
+ */
412
+ export function applyWorkflow(
413
+ types: ReadonlyMap<string, Resource>,
414
+ ops: ReadonlyMap<string, Op>,
415
+ drivers: ReadonlyMap<string, unknown>,
416
+ steps: ApplySteps,
417
+ ): WorkflowDefinition<readonly ApplyUse[], typeof ApplyInput> {
418
+ const imports = importsOf(types, ops, drivers);
419
+ const changes = [...types.values()]
420
+ .filter((r) => r.writable)
421
+ .flatMap((r) => ["plan", "create", "update", "delete"].map((name) => ops.get(`${r.vendor}.${r.type}.${name}`)))
422
+ .filter((op): op is Op => op !== undefined && drivers.has(op.id))
423
+ .toSorted((a, b) => a.id.localeCompare(b.id));
424
+ const usable = new Set(changes.map((op) => op.id));
425
+ const resolve = ops.get(SOURCE_RESOLVE) as ResolveOp | undefined;
426
+ const hooks = [...new Set([...types.values()].flatMap((r) => (r.afterCreate ?? []).map((hook) => hook.op)))]
427
+ .toSorted()
428
+ .flatMap((id) => {
429
+ const op = ops.get(id);
430
+ return op && drivers.has(id) ? [op] : [];
431
+ });
432
+ return defineWorkflow({
433
+ // The literal, held to the one name: the outline finds a workflow by a string, or a const of its own file.
434
+ name: "apply" satisfies typeof APPLY_WORKFLOW,
435
+ title: "Apply resources",
436
+ trigger: "manual",
437
+ input: ApplyInput,
438
+ uses: [
439
+ ...imports,
440
+ ...(changes as (PlanOp | CreateOp | UpdateOp | DeleteOp)[]),
441
+ ...(resolve ? [resolve] : []),
442
+ ...(hooks as ImportOp[]),
443
+ "approval",
444
+ ...(hooks.length ? (["sleep"] as const) : []),
445
+ ],
446
+ run: async (ctx, input) => {
447
+ const { resources, problems: read, bindings: locked, orphans, rebinds } = await steps.declared();
448
+ const wanted = (id: string) => input.only === undefined || input.only.includes(id);
449
+ const declarations = new Map(resources.map((r) => [r.id, r]));
450
+ const problems = [...read, ...inputProblems(input, declarations, new Set(orphans.map((o) => o.key)))];
451
+ // Observed as drift does, every import first, by the binding given before the lockfile's.
452
+ const found = new Map<string, Found>();
453
+ for (const resource of resources) {
454
+ const by = observeBy(
455
+ resource,
456
+ types,
457
+ imports,
458
+ input.bind?.[resource.id] ?? resource.binding ?? resource.identity,
459
+ );
460
+ if (!("id" in by)) found.set(resource.id, by);
461
+ else {
462
+ try {
463
+ found.set(resource.id, observed(await ctx[resource.vendor]![resource.type]!.import({ id: by.id })));
464
+ } catch (err) {
465
+ if (isRunControlError(err)) throw err;
466
+ found.set(resource.id, { error: errorMessage(err) });
467
+ }
468
+ }
469
+ }
470
+ // A host wanted runs the commit its ref names now, resolved before planning: a merge is a change
471
+ // to it. One bound by the input needs none.
472
+ const revisions = new Map<string, Revision>();
473
+ const hosts = new Map<string, { source: string; revision: string }>();
474
+ for (const { id, vendor, type, desired, refs } of resources.filter(
475
+ (r) => wanted(r.id) && input.bind?.[r.id] === undefined,
476
+ )) {
477
+ const resource = types.get(`${vendor}.${type}`);
478
+ const host = resource?.host;
479
+ const source = host && sourceUrl(desired, refs[host.inputs.source], resource, found);
480
+ const ref = host && valueAt(desired, host.inputs.ref);
481
+ if (host && source !== undefined && typeof ref === "string" && ref) {
482
+ try {
483
+ const { revision } = await ctx.sanoma!.source!.resolve({ url: source, ref });
484
+ revisions.set(id, { revision });
485
+ hosts.set(id, { source, revision });
486
+ } catch (err) {
487
+ if (isRunControlError(err)) throw err;
488
+ revisions.set(id, { error: errorMessage(err) });
489
+ }
490
+ } else if (host && source === undefined) {
491
+ revisions.set(id, { error: "its source has no clone URL yet: apply the repository it names first" });
492
+ }
493
+ }
494
+ const drift = new Map(compare(types, resources, found, revisions).map((result) => [result.id, result]));
495
+ const desired = new Map(resources.map((r) => [r.id, desiredOf(r, types, locked, hosts)]));
496
+
497
+ const planned: PlanStep[] = [];
498
+ for (const resource of inDependencyOrder(resources).filter((r) => wanted(r.id))) {
499
+ const result = drift.get(resource.id)!;
500
+ const unresolved = revisions.get(resource.id);
501
+ // A host whose ref could not be resolved has no commit to run (compare says so only for one it found).
502
+ const next =
503
+ unresolved && "error" in unresolved && result.status !== "error"
504
+ ? { problem: problemOf(resource.id, unresolved.error) }
505
+ : decide(resource, result, found.get(resource.id)!, input, desired.get(resource.id)!, types, usable);
506
+ if (next.ask !== undefined) {
507
+ try {
508
+ const answer = fromPlan(resource, result, await ctx[resource.vendor]![resource.type]!.plan(next.ask));
509
+ if (answer.step) planned.push(answer.step);
510
+ if (answer.problem) problems.push(answer.problem);
511
+ } catch (err) {
512
+ if (isRunControlError(err)) throw err;
513
+ problems.push(problemOf(resource.id, `could not be planned: ${errorMessage(err)}`));
514
+ }
515
+ }
516
+ if (next.step) planned.push(next.step);
517
+ if (next.problem) problems.push(next.problem);
518
+ }
519
+ const orphaned = orphansOf(orphans, input, wanted, usable, types);
520
+ const plan: ApplyPlan = {
521
+ problems: [...problems, ...orphaned.problems],
522
+ rebinds,
523
+ steps: [...planned, ...orphaned.steps],
524
+ };
525
+
526
+ const writes = writesOf(plan, desired, types, ops);
527
+ const approver = writes.length ? await steps.holds(writes) : undefined;
528
+ if (approver !== undefined) {
529
+ const covers = [...new Set(writes.map((w) => w.op))].map((id) => ops.get(id)!);
530
+ await ctx.approval("Apply resource changes", { approver, covers, details: JSON.stringify(plan, null, 2) });
531
+ }
532
+
533
+ const bindings: Bindings = { ...locked };
534
+ const results: ApplyResult[] = [];
535
+ let written = false;
536
+ let revision: string | undefined;
537
+ for (const step of plan.steps) {
538
+ const todo = runOf(step);
539
+ const key = keyOf(step);
540
+ const type = `${step.vendor}.${step.type}`;
541
+ const before = bindings[key];
542
+ const binding = "binding" in step ? step.binding : "";
543
+ const declared = declarations.get(step.id);
544
+ const spec = types.get(type);
545
+ let state: State | undefined;
546
+ try {
547
+ if (todo === "create") {
548
+ const made = await ctx[step.vendor]![step.type]!.create({
549
+ desired: desiredOf(declared!, types, bindings, hosts),
550
+ });
551
+ bindings[key] = { type, id: made.id };
552
+ state = made.state;
553
+ } else if (todo === "update" && (step.action === "update" || step.action === "replace")) {
554
+ const expect = { action: step.action, replace: step.replace };
555
+ const changed = await ctx[step.vendor]![step.type]!.update({
556
+ id: binding,
557
+ desired: desiredOf(declared!, types, bindings, hosts),
558
+ expect,
559
+ });
560
+ bindings[key] = { type, id: changed.id };
561
+ state = changed.state;
562
+ } else if (todo === "delete") {
563
+ await ctx[step.vendor]![step.type]!.delete({ id: binding });
564
+ delete bindings[key];
565
+ } else if (todo === "bind") bindings[key] = { type, id: binding };
566
+ else if (todo === "forget") delete bindings[key];
567
+ const after = bindings[key];
568
+ if (after?.id !== before?.id) {
569
+ const wrote = await steps.write(key, after, messageOf(step, before, after));
570
+ written ||= wrote.written;
571
+ revision = wrote.revision ?? revision;
572
+ }
573
+ // Bound first, so an object made is kept whatever its `afterCreate` calls do: each by its
574
+ // id, again every few seconds until its output passes `until`; one that never does fails
575
+ // the step, and the rest are not called.
576
+ let stuck: string | undefined;
577
+ for (const hook of todo === "create" ? (spec?.afterCreate ?? []) : []) {
578
+ if (stuck === undefined) {
579
+ const [vendor = "", group = "", name = ""] = hook.op.split(".");
580
+ const given = hook.input({ id: after!.id, export: exportOf(key) });
581
+ // With `until`, a failure is one more call that did not pass (a new object's vendor may
582
+ // not know it yet); without, it fails the step.
583
+ let output: unknown;
584
+ for (
585
+ let call = 0;
586
+ call < AFTER_CREATE_CALLS && (call === 0 || (hook.until && !hook.until(output)));
587
+ call++
588
+ ) {
589
+ if (call > 0) await ctx.sleep({ seconds: AFTER_CREATE_SECONDS });
590
+ try {
591
+ output = await (ctx[vendor]![group] as unknown as Record<string, Call>)[name]!(given);
592
+ } catch (err) {
593
+ if (isRunControlError(err) || !hook.until) throw err;
594
+ output = undefined;
595
+ }
596
+ }
597
+ if (hook.until && !hook.until(output)) {
598
+ stuck = `${hook.op} did not pass in ${(AFTER_CREATE_CALLS * AFTER_CREATE_SECONDS) / 60} minutes`;
599
+ }
600
+ }
601
+ }
602
+ results.push({
603
+ id: step.id,
604
+ action: step.action,
605
+ status: stuck !== undefined ? "failed" : todo === "skip" ? "skipped" : "done",
606
+ ...(after && { binding: after.id }),
607
+ ...(spec?.host && state && { host: spec.host.outputs(state) }),
608
+ ...(stuck !== undefined && { error: stuck }),
609
+ });
610
+ } catch (err) {
611
+ if (isRunControlError(err)) throw err;
612
+ const left = bindings[key];
613
+ results.push({
614
+ id: step.id,
615
+ action: step.action,
616
+ status: "failed",
617
+ ...(left && { binding: left.id }),
618
+ error: errorMessage(err),
619
+ });
620
+ }
621
+ }
622
+ const lockfile = { written, ...(revision !== undefined && { revision }), bindings };
623
+ return { plan, results, lockfile } satisfies ApplyReport;
624
+ },
625
+ });
626
+ }
package/src/call.ts CHANGED
@@ -28,7 +28,8 @@ import { type CallContext, isOp, type Op } from "./op.ts";
28
28
  import { callAt, callName, type CallNode } from "./outline.ts";
29
29
  import { DecisionSchema, type PolicyCall, policyOpOf, type RecordedDecision } from "./policy.ts";
30
30
  import type { Run, WorkerState } from "./run.ts";
31
- import { approverLabel, errorMessage, fitsOp } from "./shared.ts";
31
+ import { isChangeOp } from "./resource.ts";
32
+ import { APPLY_WORKFLOW, approverLabel, errorMessage, fitsOp } from "./shared.ts";
32
33
 
33
34
  /** DBOS took the run from this execution: it cancelled the run, or another process took it over. */
34
35
  const takenByDbos = (err: unknown) =>
@@ -271,8 +272,8 @@ function targetOf(op: Op, input: unknown): { target?: string } {
271
272
  return { target };
272
273
  }
273
274
 
274
- /** Asks the policy. Runs inside a step, so it must have no side effects of its own. */
275
- async function decide(run: Run, op: Op, input: unknown): Promise<RecordedDecision> {
275
+ /** Asks the policy about one call, and checks its answer. Runs inside a step, so it must have no side effects of its own. */
276
+ export async function decide(run: Run, op: Op, input: unknown): Promise<RecordedDecision> {
276
277
  const call: PolicyCall = {
277
278
  op: policyOpOf(op),
278
279
  effect: op.effect,
@@ -307,6 +308,13 @@ async function callOp(run: Run, id: string, input: unknown, place: Placed) {
307
308
  : `No connector or fake for ${id} in this worker: no fake its connectors attach implements it`,
308
309
  );
309
310
  }
311
+ // Only apply changes a declared resource; `resolveConfig` refuses any other workflow that uses such an operation.
312
+ if (isChangeOp(op, run.state.types) && run.workflow !== APPLY_WORKFLOW) {
313
+ throw new SanomaError("invalid_input", `${op.id} changes a declared resource, which only ${APPLY_WORKFLOW} does`, {
314
+ op: op.id,
315
+ workflow: run.workflow,
316
+ });
317
+ }
310
318
  const parsed = parseOrThrow(op.input, input, `The input to ${op.id} does not match its schema`, { op: op.id });
311
319
  // The call's identity (its ledger seq and id, and the driver's idempotency key) is taken
312
320
  // before the first await, so it depends only on the order the workflow made its calls. A
@@ -355,7 +363,14 @@ async function callOp(run: Run, id: string, input: unknown, place: Placed) {
355
363
  async () => {
356
364
  const at = Date.now();
357
365
  attempt = DBOS.stepStatus?.currentAttempt ?? 1;
358
- const context: CallContext = { idempotencyKey: `${run.id}:${seq}`, runId: run.id, opId: op.id, attempt };
366
+ const secrets = run.sandbox === undefined ? run.state.secrets : run.fakeSecrets!;
367
+ const context: CallContext = {
368
+ idempotencyKey: `${run.id}:${seq}`,
369
+ runId: run.id,
370
+ opId: op.id,
371
+ attempt,
372
+ secrets,
373
+ };
359
374
  // A live driver's `env` is checked here, inside the step, by the guard `indexDrivers` put
360
375
  // on its function: the refusal is the call's failure, recorded and never retried.
361
376
  const output = op.output.parse(await fn(parsed, context));