@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/describe.ts CHANGED
@@ -2,13 +2,13 @@ import type { z } from "zod";
2
2
  import { type LintProblem, readSource, type SourceFile } from "./ast.ts";
3
3
  import { resolveConfig, type SanomaConfig } from "./config.ts";
4
4
  import { type Builtin, jsonSchemaOf, opIdsOf, type Use } from "./define.ts";
5
- import { DRIFT_WORKFLOW } from "./drift.ts";
6
5
  import { lintWorkflow } from "./lint.ts";
7
6
  import { type Effect, VENDOR, type VendorInfo } from "./op.ts";
8
7
  import { type Outline, outlineWithSource } from "./outline.ts";
9
8
  import { allowAll, policyOpOf } from "./policy.ts";
10
9
  import { type ResourceFields, resourceTypesOf } from "./resource.ts";
11
- import { type DeclaredResource, readDataFiles, type ResourceProblem } from "./resources.ts";
10
+ import { type DataFiles, type DeclaredResource, readDataFiles, type ResourceProblem } from "./resources.ts";
11
+ import { BUILT_IN_WORKFLOWS } from "./shared.ts";
12
12
 
13
13
  // `@sanoma-ai/workflows/describe`: what a UI renders from, apart from the main entry.
14
14
  export { outlineWorkflow, type Outline, type OutlineNode, type Span } from "./outline.ts";
@@ -21,6 +21,21 @@ export {
21
21
  type DeclaredResource,
22
22
  type ResourceProblem,
23
23
  } from "./resources.ts";
24
+ // The writer: setting a resource's fields in its data file, and rendering a new one.
25
+ export { formatOptions, renderDataFile, setFields, type DataFileContent } from "./writer.ts";
26
+ export { Reference, type DataValue } from "./datafile.ts";
27
+ export {
28
+ LOCKFILE,
29
+ readLockfile,
30
+ serializeLockfile,
31
+ writeLockfile,
32
+ diffLockfiles,
33
+ type Binding,
34
+ type Bindings,
35
+ type Rebind,
36
+ } from "./lockfile.ts";
37
+ // The apply workflow's plan and report, which the app renders.
38
+ export type { ApplyInput, ApplyPlan, ApplyReport, ApplyResult, PlanAnswer, PlanStep } from "./apply.ts";
24
39
 
25
40
  /** What a workflow is, read from its definition: enough to draw a start form and show what it may call. */
26
41
  export interface WorkflowEntry {
@@ -28,7 +43,7 @@ export interface WorkflowEntry {
28
43
  title?: string;
29
44
  trigger: "manual";
30
45
  /**
31
- * Set for a workflow the runtime adds itself, `drift`, rather than the config: a label. It
46
+ * Set for a workflow the runtime adds itself, `drift` or `apply`, rather than the config: a label. It
32
47
  * starts like any other.
33
48
  */
34
49
  builtin?: true;
@@ -96,8 +111,8 @@ export interface ResourceTypeEntry {
96
111
  vendor: string;
97
112
  type: string;
98
113
  title: string;
99
- /** How a declared resource's import id is made, such as `name` or `repository_id:pattern`. */
100
- identity: string;
114
+ /** How a declared resource's identity is made, such as `name` or `repository_id:pattern`; absent when it is found only by its binding. */
115
+ identity?: string;
101
116
  /** Its flagged fields, by dotted path. */
102
117
  fields: ResourceFields;
103
118
  /**
@@ -105,7 +120,7 @@ export interface ResourceTypeEntry {
105
120
  * `sanoma:resource-type/<id>`: the operations' contracts `$ref` it instead of repeating it.
106
121
  */
107
122
  schema: Record<string, unknown>;
108
- /** Its operations' ids: `<id>.import` and `<id>.read`. */
123
+ /** Its operations' ids, sorted: `<id>.import` and `<id>.read`, and for a writable type its change operations. */
109
124
  ops: string[];
110
125
  }
111
126
 
@@ -129,6 +144,8 @@ export interface ConfigDescription {
129
144
  * less any with a problem and any that names one.
130
145
  */
131
146
  resources: DeclaredResource[];
147
+ /** Each binding in the lockfile (`LOCKFILE`) whose key no data file declares, as `readDataFiles` reports it. */
148
+ orphans: DataFiles["orphans"];
132
149
  /** What is wrong in the data files (`readDataFiles`), each at its file, relative to the root, line and column. A workflow's are on its entry. */
133
150
  problems: ResourceProblem[];
134
151
  /** `defined` is false for `allowAll`. */
@@ -155,7 +172,7 @@ export function describeConfig(config: SanomaConfig): ConfigDescription {
155
172
  // Each resource type's state is described once, on its entry, and referenced from its operations.
156
173
  const refs = new Map<z.ZodType, string>([...resources].map(([id, r]) => [r.schema, resourceTypeUri(id)]));
157
174
  const resourceTypes: ResourceTypeEntry[] = [...resources]
158
- .map(([id, { vendor, type, title, identity, fields, schema }]) => ({
175
+ .map(([id, { vendor, type, title, identity, fields, schema, ops }]) => ({
159
176
  id,
160
177
  vendor,
161
178
  type,
@@ -163,7 +180,9 @@ export function describeConfig(config: SanomaConfig): ConfigDescription {
163
180
  identity,
164
181
  fields,
165
182
  schema: { $id: resourceTypeUri(id), ...toJsonSchema(schema, `${id} state`, "output") },
166
- ops: [`${id}.import`, `${id}.read`],
183
+ ops: Object.keys(ops)
184
+ .map((name) => `${id}.${name}`)
185
+ .toSorted(),
167
186
  }))
168
187
  .toSorted((a, b) => a.id.localeCompare(b.id));
169
188
 
@@ -184,18 +203,18 @@ export function describeConfig(config: SanomaConfig): ConfigDescription {
184
203
  const { outline, source, found } = outlineWithSource(wf, file);
185
204
  // What the lint does not report: the file unread, or no workflow of this name in it.
186
205
  const own = "error" in outline && !found && (typeof file === "string" || !file.problems.length);
187
- const problems =
188
- wf.name === DRIFT_WORKFLOW
189
- ? []
190
- : [
191
- ...(typeof file === "string" ? [] : lint(file)),
192
- ...(own ? [{ line: 1, column: 1, message: outline.error }] : []),
193
- ];
206
+ const builtin = BUILT_IN_WORKFLOWS.has(wf.name);
207
+ const problems = builtin
208
+ ? []
209
+ : [
210
+ ...(typeof file === "string" ? [] : lint(file)),
211
+ ...(own ? [{ line: 1, column: 1, message: outline.error }] : []),
212
+ ];
194
213
  return {
195
214
  name: wf.name,
196
215
  title: wf.title,
197
216
  trigger: wf.trigger,
198
- ...(wf.name === DRIFT_WORKFLOW && { builtin: true as const }),
217
+ ...(builtin && { builtin: true as const }),
199
218
  input: toJsonSchema(wf.input, `${wf.name} input`, "input"),
200
219
  ops: opIdsOf(wf),
201
220
  builtins: (wf.uses as Use[]).filter((u): u is Builtin => typeof u === "string"),
@@ -216,6 +235,7 @@ export function describeConfig(config: SanomaConfig): ConfigDescription {
216
235
  vendors,
217
236
  resourceTypes,
218
237
  resources: dataFiles.resources,
238
+ orphans: dataFiles.orphans,
219
239
  problems: dataFiles.problems,
220
240
  policy: {
221
241
  defined: resolved.policy !== allowAll,
package/src/drift.ts CHANGED
@@ -2,9 +2,10 @@ import { z } from "zod";
2
2
  import { defineWorkflow, type WorkflowDefinition } from "./define.ts";
3
3
  import { isRunControlError } from "./errors.ts";
4
4
  import type { Op } from "./op.ts";
5
- import { type DriftField, diffDeclared, type Resource } from "./resource.ts";
5
+ import { type DriftField, diffDeclared, fieldPathOf, type Resource, valueAt } from "./resource.ts";
6
6
  import type { DeclaredResource, ResourceProblem } from "./resources.ts";
7
7
  import { type DRIFT_WORKFLOW, errorMessage } from "./shared.ts";
8
+ import { SOURCE_RESOLVE, type sourceOps } from "./source.ts";
8
9
 
9
10
  export type { DriftField } from "./resource.ts";
10
11
  export { DRIFT_WORKFLOW } from "./shared.ts";
@@ -14,11 +15,12 @@ export { DRIFT_WORKFLOW } from "./shared.ts";
14
15
  // vendor call goes through `ctx`: the policy sees each (effect `read`) and the ledger records it.
15
16
 
16
17
  /**
17
- * How a declared resource compares: `clean` when every declared field holds its declared value,
18
- * `drifted` when one does not, `gone` when the vendor has no such object, and `error` when it
19
- * could not be read (the vendor's error, a policy denial, or no driver for its type).
18
+ * How a declared resource compares: `clean` when every declared field of its bound object holds
19
+ * its declared value, `drifted` when one does not, `gone` when the vendor no longer has its bound
20
+ * object, `absent` when it is bound to none (whether or not its identity finds one: `found`), and
21
+ * `error` when it could not be read (the vendor's error, a policy denial, or no driver for its type).
20
22
  */
21
- export type DriftStatus = "clean" | "drifted" | "gone" | "error";
23
+ export type DriftStatus = "clean" | "drifted" | "gone" | "absent" | "error";
22
24
 
23
25
  /** One declared resource in a drift report. */
24
26
  export interface DriftResult {
@@ -27,9 +29,13 @@ export interface DriftResult {
27
29
  /** Its type within the vendor, such as `repository`. */
28
30
  type: string;
29
31
  vendor: string;
30
- /** Its import id. */
32
+ /** What people call it: its identity, else its export's name. */
31
33
  name: string;
32
34
  status: DriftStatus;
35
+ /** The vendor's id of the object the lockfile binds it to; absent when unbound. */
36
+ binding?: string;
37
+ /** For `absent`: the binding id of the object its identity found, which apply may bind it to. */
38
+ found?: string;
33
39
  /** The declared fields that differ; empty unless `drifted`. */
34
40
  fields: DriftField[];
35
41
  /** Why it could not be read, for `error`. */
@@ -59,7 +65,7 @@ export const DriftInput = z.object({}).strict();
59
65
  * write-only values, and the problems.
60
66
  */
61
67
  export interface DriftDeclared {
62
- resources: Pick<DeclaredResource, "id" | "vendor" | "type" | "name" | "desired" | "refs">[];
68
+ resources: Pick<DeclaredResource, "id" | "vendor" | "type" | "name" | "identity" | "binding" | "desired" | "refs">[];
63
69
  problems: ResourceProblem[];
64
70
  }
65
71
 
@@ -67,9 +73,99 @@ type State = Record<string, unknown>;
67
73
 
68
74
  // A resource type's import, as `ctx` gives it: by vendor and type, both only known per run.
69
75
  type ImportOp = Op<string, string, "import", { id: string }, { id: string; gone: boolean; state?: State }>;
76
+ // The built-in `sanoma.source.resolve`, which a host's check calls for the commit its ref names now.
77
+ export type ResolveOp = typeof sourceOps.source.resolve;
78
+
79
+ /** A clone URL (a scheme's, or scp-like `user@host:path`): what a `by` field of a host's source holds, told from an id. */
80
+ const CLONE_URL = /^((https?|ssh|git|file):\/\/|[\w.-]+@[\w.-]+:)/;
70
81
 
71
82
  /** What one import found: the object's state, that it is gone, or why it could not be read. */
72
- type Found = { state: State } | { gone: true } | { error: string };
83
+ export type Found = { state: State } | { gone: true } | { error: string };
84
+ /** What a host's ref names now, or why it could not be resolved. */
85
+ export type Revision = { revision: string } | { error: string };
86
+
87
+ // What drift and apply share. Observing a resource is one `ctx` call, which a workflow makes in
88
+ // its own `run` (the outline draws only those): `observeBy` says what each import is called with,
89
+ // `observed` reads its answer, and `compare` turns what every import found into drift's results.
90
+
91
+ /** The `import` ops of the types among `ops` with a driver, by type, sorted: what observing them uses. */
92
+ export function importsOf(
93
+ types: ReadonlyMap<string, Resource>,
94
+ ops: ReadonlyMap<string, Op>,
95
+ drivers: ReadonlyMap<string, unknown>,
96
+ ): ImportOp[] {
97
+ return [...types.keys()].toSorted().flatMap((type) => {
98
+ const op = ops.get(`${type}.import`);
99
+ return op && drivers.has(op.id) ? [op as ImportOp] : [];
100
+ });
101
+ }
102
+
103
+ /**
104
+ * What a declared resource is observed by: the import id (`by`, default its binding, else its
105
+ * identity), or without one what it is found to be: `gone` for none, `error` when its type is
106
+ * not the connectors' or has no import among `imports`.
107
+ */
108
+ export function observeBy(
109
+ { vendor, type, binding, identity }: Pick<DeclaredResource, "vendor" | "type" | "binding" | "identity">,
110
+ types: ReadonlyMap<string, Resource>,
111
+ imports: readonly Pick<Op, "id">[],
112
+ by = binding ?? identity,
113
+ ): { id: string } | Found {
114
+ const key = `${vendor}.${type}`;
115
+ if (!types.has(key)) return { error: `the config's connectors declare no resource type ${key}` };
116
+ if (!imports.some((op) => op.id === `${key}.import`)) return { error: `no driver for ${key}.import` };
117
+ return by === undefined ? { gone: true } : { id: by };
118
+ }
119
+
120
+ /** What an import's answer found. */
121
+ export const observed = (out: { gone: boolean; state?: State }): Found =>
122
+ out.gone || !out.state ? { gone: true } : { state: out.state };
123
+
124
+ /**
125
+ * Each declared resource against what its import `found`: `error`, `absent` (unbound, with the
126
+ * binding id of what its identity found as `found`), `gone`, else the state and the declared
127
+ * fields compared by `diffDeclared`, a reference against the state of the resource it names
128
+ * (so GitHub's node id for a repository counts as its name). A host with a `revisions` entry
129
+ * also compares its revision against the commit its ref names now (or is that entry's `error`).
130
+ */
131
+ export function compare(
132
+ types: ReadonlyMap<string, Resource>,
133
+ resources: DriftDeclared["resources"],
134
+ found: ReadonlyMap<string, Found>,
135
+ revisions: ReadonlyMap<string, Revision> = new Map(),
136
+ ): DriftResult[] {
137
+ return resources.map(({ id, vendor, type, name, binding, desired, refs }): DriftResult => {
138
+ const row = { id, type, vendor, name, ...(binding !== undefined && { binding }) };
139
+ const now = found.get(id)!;
140
+ if ("error" in now) return { ...row, status: "error", fields: [], error: now.error };
141
+ const resource = types.get(`${vendor}.${type}`)!;
142
+ if (binding === undefined) {
143
+ return {
144
+ ...row,
145
+ status: "absent",
146
+ fields: [],
147
+ ...("state" in now && { found: resource.bindingId(now.state) }),
148
+ };
149
+ }
150
+ if ("gone" in now) return { ...row, status: "gone", fields: [] };
151
+ // Each reference, by where it is: the values the vendor may hold there for its resource.
152
+ const accept: Record<string, unknown[]> = {};
153
+ for (const [at, target] of Object.entries(refs)) {
154
+ const by = resource.fields.references?.[fieldPathOf(at)]?.by ?? [];
155
+ const named = found.get(target);
156
+ if (named && "state" in named) accept[at] = by.map((field) => named.state[field]).filter((v) => v != null);
157
+ }
158
+ const fields = diffDeclared(resource, now.state, desired, accept);
159
+ const resolved = revisions.get(id);
160
+ if (resolved && "error" in resolved) return { ...row, status: "error", fields: [], error: resolved.error };
161
+ // A host whose state does not say which commit it runs is not compared.
162
+ const actual = resource.host?.outputs(now.state).revision;
163
+ if (resolved && actual !== undefined && actual !== resolved.revision) {
164
+ fields.push({ path: resource.host!.revision, desired: resolved.revision, actual });
165
+ }
166
+ return { ...row, status: fields.length ? "drifted" : "clean", fields };
167
+ });
168
+ }
73
169
 
74
170
  /**
75
171
  * The drift workflow for a config: it `uses` the `import` of every resource type the connectors
@@ -77,11 +173,13 @@ type Found = { state: State } | { gone: true } | { error: string };
77
173
  * files. `resolveConfig` adds it to a config whose connectors declare resource types, with
78
174
  * `declared`, the step that reads the data files.
79
175
  *
80
- * A run reads the data files (`declared`, recorded as a step, so a replay checks the same list),
81
- * then imports each resource by its import id (its `name`), in the files' order: one vendor call
82
- * each. An import that says gone is `gone`. Else the state and the declared fields are compared
83
- * by `diffDeclared`, a reference against the state of the resource it names, read in the same run
84
- * (so GitHub's node id for a repository counts as its name). A failed call is that resource's
176
+ * A run reads the data files and the lockfile (`declared`, recorded as a step, so a replay checks
177
+ * the same list), then imports each resource by its binding, else by its identity, in the files'
178
+ * order: one vendor call each, none for a resource with neither. A bound resource whose import
179
+ * says gone is `gone`; an unbound one is `absent`, with the binding id of what its identity found
180
+ * as `found`. Else the state and the declared fields are compared by `diffDeclared`, a reference
181
+ * against the state of the resource it names, read in the same run (so GitHub's node id for a
182
+ * repository counts as its name). A failed call is that resource's
85
183
  * `error`, and the run goes on to the next; a failure that ends the run (`isRunControlError`)
86
184
  * ends it.
87
185
  */
@@ -90,16 +188,10 @@ export function driftWorkflow(
90
188
  ops: ReadonlyMap<string, Op>,
91
189
  drivers: ReadonlyMap<string, unknown>,
92
190
  declared: () => Promise<DriftDeclared>,
93
- ): WorkflowDefinition<readonly ImportOp[], typeof DriftInput> {
94
- const uses = [...types.keys()].toSorted().flatMap((type) => {
95
- const op = ops.get(`${type}.import`);
96
- return op && drivers.has(op.id) ? [op as ImportOp] : [];
97
- });
98
- // Why a resource of each type cannot be read, if it cannot.
99
- const unreadable = new Map<string, string>();
100
- for (const type of types.keys()) {
101
- if (!uses.some((op) => op.id === `${type}.import`)) unreadable.set(type, `no driver for ${type}.import`);
102
- }
191
+ ): WorkflowDefinition<readonly (ImportOp | ResolveOp)[], typeof DriftInput> {
192
+ const imports = importsOf(types, ops, drivers);
193
+ const resolve = ops.get(SOURCE_RESOLVE) as ResolveOp | undefined;
194
+ const uses = [...imports, ...(resolve ? [resolve] : [])];
103
195
  return defineWorkflow({
104
196
  // The literal, held to the one name: the outline finds a workflow by a string, or a const of its own file.
105
197
  name: "drift" satisfies typeof DRIFT_WORKFLOW,
@@ -112,38 +204,56 @@ export function driftWorkflow(
112
204
  const { resources, problems } = await declared();
113
205
  // Every import first, so a reference compares against what its resource holds now.
114
206
  const found = new Map<string, Found>();
115
- for (const { id, vendor, type, name } of resources) {
116
- const key = `${vendor}.${type}`;
117
- const why = types.has(key) ? unreadable.get(key) : `the config's connectors declare no resource type ${key}`;
207
+ for (const resource of resources) {
208
+ const by = observeBy(resource, types, imports);
118
209
  // An if/else, not a `continue`: the outline draws the import as the way past the error.
119
- if (why !== undefined) found.set(id, { error: why });
210
+ if (!("id" in by)) found.set(resource.id, by);
120
211
  else {
121
212
  try {
122
- const out = await ctx[vendor]![type]!.import({ id: name });
123
- found.set(id, out.gone || !out.state ? { gone: true } : { state: out.state });
213
+ found.set(resource.id, observed(await ctx[resource.vendor]![resource.type]!.import({ id: by.id })));
124
214
  } catch (err) {
125
215
  if (isRunControlError(err)) throw err;
126
- found.set(id, { error: errorMessage(err) });
216
+ found.set(resource.id, { error: errorMessage(err) });
127
217
  }
128
218
  }
129
219
  }
130
- const results = resources.map(({ id, vendor, type, name, desired, refs }): DriftResult => {
131
- const row = { id, type, vendor, name };
132
- const now = found.get(id)!;
133
- if ("error" in now) return { ...row, status: "error", fields: [], error: now.error };
134
- if ("gone" in now) return { ...row, status: "gone", fields: [] };
135
- const resource = types.get(`${vendor}.${type}`)!;
136
- // Each reference, by where it is: the values the vendor may hold there for its resource.
137
- const accept: Record<string, unknown[]> = {};
138
- for (const [at, target] of Object.entries(refs)) {
139
- const by = resource.fields.references?.[at.replaceAll(/\.\d+(?=\.|$)/g, "")]?.by ?? [];
140
- const named = found.get(target);
141
- if (named && "state" in named) accept[at] = by.map((field) => named.state[field]).filter((v) => v != null);
220
+ // A host's revision against the commit its ref names now: a merge is drift on the host.
221
+ const revisions = new Map<string, Revision>();
222
+ for (const { id, vendor, type, desired, refs } of resources) {
223
+ const resource = types.get(`${vendor}.${type}`);
224
+ const host = resource?.host;
225
+ const source = host && sourceUrl(desired, refs[host.inputs.source], resource, found);
226
+ const ref = host && valueAt(desired, host.inputs.ref);
227
+ if (host && "state" in found.get(id)! && source !== undefined && typeof ref === "string" && ref) {
228
+ try {
229
+ revisions.set(id, await ctx.sanoma!.source!.resolve({ url: source, ref }));
230
+ } catch (err) {
231
+ if (isRunControlError(err)) throw err;
232
+ revisions.set(id, { error: errorMessage(err) });
233
+ }
142
234
  }
143
- const fields = diffDeclared(resource, now.state, desired, accept);
144
- return { ...row, status: fields.length ? "drifted" : "clean", fields };
145
- });
235
+ }
236
+ const results = compare(types, resources, found, revisions);
146
237
  return { startedAt, finishedAt: await ctx.now(), problems, resources: results } satisfies DriftReport;
147
238
  },
148
239
  });
149
240
  }
241
+
242
+ /**
243
+ * A host's source: the clone URL its data file gives, or, when it names a declared repository
244
+ * (`target`), the first of the reference's `by` fields that holds one in that repository's state,
245
+ * read in this run.
246
+ */
247
+ export function sourceUrl(
248
+ desired: State,
249
+ target: string | undefined,
250
+ { host, fields }: Resource,
251
+ found: ReadonlyMap<string, Found>,
252
+ ): string | undefined {
253
+ const path = host!.inputs.source;
254
+ const named = target === undefined ? undefined : found.get(target);
255
+ const by = fields.references?.[path]?.by ?? [];
256
+ const values =
257
+ target === undefined ? [valueAt(desired, path)] : named && "state" in named ? by.map((f) => named.state[f]) : [];
258
+ return values.find((v): v is string => typeof v === "string" && CLONE_URL.test(v));
259
+ }
@@ -1,5 +1,6 @@
1
1
  import { isDeepStrictEqual } from "node:util";
2
2
  import type { Approver } from "./define.ts";
3
+ import type { ApplyReport } from "./apply.ts";
3
4
  import type { DriftReport, DriftResult } from "./drift.ts";
4
5
  import type { LedgerRecord } from "./ledger.ts";
5
6
  import type { RecordedDecision } from "./policy.ts";
@@ -99,7 +100,7 @@ type Unstepped<T> = T extends unknown ? Omit<T, "step"> : never;
99
100
  * output, or undefined when the report found the resource as expected.
100
101
  */
101
102
  function driftDetail(
102
- e: Extract<CoreExpectation, { declared: string }>,
103
+ e: Extract<CoreExpectation, { status: string }>,
103
104
  end: Extract<LedgerRecord, { type: "run.finished" }>,
104
105
  ): string | undefined {
105
106
  const report = end.output as Partial<DriftReport> | null | undefined;
@@ -115,14 +116,38 @@ function driftDetail(
115
116
  return `${e.declared} is ${found(resource)}, not ${e.status === "drifted" ? `drifted in ${e.field}` : e.status}`;
116
117
  }
117
118
 
119
+ /**
120
+ * Why an apply expectation fails against the report a finished apply run returned as its output:
121
+ * the resource `created` (a step that created it, done), or `bound` to an object in the lockfile it ended with.
122
+ */
123
+ function applyDetail(
124
+ e: Extract<CoreExpectation, { created: true } | { bound: string }>,
125
+ end: Extract<LedgerRecord, { type: "run.finished" }>,
126
+ ): string | undefined {
127
+ const report = end.output as Partial<ApplyReport> | null | undefined;
128
+ if (!Array.isArray(report?.results) || !report.lockfile) return "the run's output is not an apply report";
129
+ if ("bound" in e) {
130
+ const binding = report.lockfile.bindings[e.declared];
131
+ if (binding?.id === e.bound) return undefined;
132
+ return binding ? `${e.declared} is bound to ${binding.id}, not ${e.bound}` : `${e.declared} is not bound`;
133
+ }
134
+ const result = report.results.find((r) => r.id === e.declared);
135
+ // A gone object recreated is bound again; one forgotten is not.
136
+ const created =
137
+ result?.status === "done" && (result.action === "create" || (result.action === "gone" && !!result.binding));
138
+ if (created) return undefined;
139
+ if (!result) return `apply did nothing with ${e.declared}`;
140
+ return `apply's ${result.action} of ${e.declared} ${result.status === "failed" ? `failed: ${result.error}` : `was ${result.status}`}`;
141
+ }
142
+
118
143
  /**
119
144
  * Why an expectation of the generic steps does not hold against a run's ledger records, or
120
145
  * undefined when it does. A call is an `op.called` record without an `error`; one with an error
121
146
  * (a policy denial, a rejected approval, a vendor's final failure) was attempted, not made. An
122
147
  * operation `was denied` when the policy's decision on some call to it is `deny`, and `was held
123
148
  * for` an approver when an approval for that approver holds it: one the policy asked for on its
124
- * call, or one that `covers` it. A drift expectation reads the report a finished drift run
125
- * returned. A step named in the workflow's outline is read by the `name` its records carry
149
+ * call, or one that `covers` it. A drift or apply expectation reads the report a finished run
150
+ * of it returned. A step named in the workflow's outline is read by the `name` its records carry
126
151
  * (its calls made, and `waitsOf`); a record written before steps had names has none.
127
152
  */
128
153
  export function detailOf(e: CoreExpectation, records: readonly LedgerRecord[]): string | undefined {
@@ -161,5 +186,6 @@ export function detailOf(e: CoreExpectation, records: readonly LedgerRecord[]):
161
186
  }
162
187
  // A finished run: what `the run succeeds` expects, and what a drift expectation reads its report from.
163
188
  if (end.type === "run.failed") return `the run failed: ${end.error.message}`;
164
- return "declared" in e ? driftDetail(e, end) : undefined;
189
+ if (!("declared" in e)) return undefined;
190
+ return "status" in e ? driftDetail(e, end) : applyDetail(e, end);
165
191
  }
package/src/host.ts ADDED
@@ -0,0 +1,96 @@
1
+ import type { z } from "zod";
2
+
3
+ // The `Host` contract: what a resource type that runs the company (a container, a Render service,
4
+ // a Fly app) declares about itself, so hosts are swappable and one conformance kit decides
5
+ // whether one works. A host is a resource type like any other; this is what sets it apart.
6
+
7
+ /** How a host keeps the runtime up: `long-lived`, or (later) woken on a schedule. */
8
+ export type HostLifetime = "long-lived" | "wake-on-schedule";
9
+
10
+ /** What a host can do, for choosing one. */
11
+ export interface HostCapabilities {
12
+ lifetime: HostLifetime;
13
+ /** Its `url` is reachable from the internet, not only from this machine. */
14
+ publicUrl: boolean;
15
+ /** It builds the repository from git itself, at the revision apply gives it. */
16
+ buildsFromGit: boolean;
17
+ /** Its outbound connections come from fixed addresses, for a vendor's allow-list. */
18
+ staticEgress: boolean;
19
+ }
20
+
21
+ /** What a host's state says of it: where the app answers, which commit it runs, and whether it is up. */
22
+ export interface HostOutputs {
23
+ url: string;
24
+ /** The commit it runs, when its state says. */
25
+ revision?: string;
26
+ healthy: boolean;
27
+ }
28
+
29
+ /**
30
+ * A resource type's `host`: the fields it takes the runtime's inputs in, by dotted path into its
31
+ * declared fields, and how its state reads as `HostOutputs`. A host is drifted when
32
+ * `outputs(state).revision` differs from what `sanoma.source.resolve` says `ref` is now.
33
+ */
34
+ export interface HostSpec {
35
+ capabilities: HostCapabilities;
36
+ inputs: {
37
+ /** The repository's clone URL, or a reference to a declared repository whose state holds one (a `by` field). */
38
+ source: string;
39
+ /** The branch (or tag) it runs: `main`, never a commit. */
40
+ ref: string;
41
+ /** The secrets it passes the runtime (`SANOMA_DATABASE_URL`, `SANOMA_SECRETS_KEY`): a write-only field. */
42
+ secrets: string;
43
+ /** The port the app listens on. */
44
+ port: string;
45
+ };
46
+ /**
47
+ * The field apply fills with the commit `ref` resolves to, before it plans: hidden, as a data
48
+ * file may not declare it, so a merge to `ref` is a change to the host.
49
+ */
50
+ revision: string;
51
+ outputs: (state: Record<string, unknown>) => HostOutputs;
52
+ }
53
+
54
+ const LIFETIMES: readonly HostLifetime[] = ["long-lived", "wake-on-schedule"];
55
+
56
+ /** True when `schema` has a field at the dotted `path`, looking through optional, nullable and default wrappers. */
57
+ function hasPath(schema: z.ZodType, path: string): boolean {
58
+ let at: z.ZodType | undefined = schema;
59
+ for (const key of path.split(".")) {
60
+ at = unwrap(at);
61
+ const shape = (at as Partial<z.ZodObject>).shape;
62
+ at = shape?.[key];
63
+ if (!at) return false;
64
+ }
65
+ return true;
66
+ }
67
+
68
+ function unwrap(schema: z.ZodType): z.ZodType {
69
+ let at = schema;
70
+ while ("unwrap" in at && typeof at.unwrap === "function") at = at.unwrap() as z.ZodType;
71
+ return at;
72
+ }
73
+
74
+ /**
75
+ * Throws what is wrong with a resource type's `host`: an input or `revision` that names no field,
76
+ * `secrets` that is not write-only, a `revision` that is, an unknown lifetime, a type apply cannot
77
+ * change (not `writable`).
78
+ */
79
+ export function checkHost(
80
+ what: string,
81
+ host: HostSpec,
82
+ type: { schema: z.ZodObject; fields: { writeOnly: readonly string[] }; writable?: boolean },
83
+ ): void {
84
+ const problems: string[] = [];
85
+ if (!type.writable) problems.push("it must be writable, so apply can create and update it");
86
+ if (!LIFETIMES.includes(host.capabilities?.lifetime)) {
87
+ problems.push(`capabilities.lifetime is one of ${LIFETIMES.join(", ")}`);
88
+ }
89
+ for (const [input, path] of [...Object.entries(host.inputs ?? {}), ["revision", host.revision] as const]) {
90
+ if (typeof path !== "string" || !hasPath(type.schema, path)) problems.push(`${input} names no field (${path})`);
91
+ }
92
+ if (!type.fields.writeOnly.includes(host.inputs?.secrets)) problems.push("inputs.secrets must be a write-only field");
93
+ if (type.fields.writeOnly.includes(host.revision)) problems.push("revision must not be write-only: drift reads it");
94
+ if (typeof host.outputs !== "function") problems.push("outputs must be a function of the state");
95
+ if (problems.length) throw new Error(`${what}: its host: ${problems.join("; ")}`);
96
+ }
package/src/index.ts CHANGED
@@ -38,14 +38,25 @@ export {
38
38
  defineResource,
39
39
  diffDeclared,
40
40
  type FieldReference,
41
+ type PlanAction,
41
42
  type References,
42
43
  type Resource,
43
44
  type ResourceFields,
44
45
  type ResourceOps,
45
46
  type ResourceSpec,
47
+ withoutWriteOnly,
46
48
  } from "./resource.ts";
47
49
  export { defineConfig, resolveConfig, resolveDatabaseUrl, type ResolvedConfig, type SanomaConfig } from "./config.ts";
48
50
  export { type CredentialStatus, credentialsOf } from "./env.ts";
51
+ export {
52
+ APPLY_WORKFLOW,
53
+ ApplyInput,
54
+ type ApplyPlan,
55
+ type ApplyReport,
56
+ type ApplyResult,
57
+ type PlanAnswer,
58
+ type PlanStep,
59
+ } from "./apply.ts";
49
60
  export {
50
61
  DRIFT_WORKFLOW,
51
62
  type DriftDeclared,
@@ -93,7 +104,19 @@ export { ApprovalMessage, APPROVALS_EVENT, decisionEventOf } from "./approvals.t
93
104
  export { RUNTIME_VERSION } from "./version.ts";
94
105
  export type { RunArgs } from "./run.ts";
95
106
  export { startWorker, type Worker, type WorkerOptions } from "./worker.ts";
96
- export { SanomaClient, type RunsFilter, type RunSummary, type StartOptions } from "./client.ts";
107
+ // `secret` itself is at `@sanoma-ai/workflows/resources`, for data files; this is the drivers' side.
108
+ export {
109
+ generateSecret,
110
+ isSecret,
111
+ memorySecrets,
112
+ type Resolved,
113
+ resolveSecrets,
114
+ type Secret,
115
+ type Secrets,
116
+ } from "./secret.ts";
117
+ export { resolveRevision, SOURCE_RESOLVE, type Source } from "./source.ts";
118
+ export type { HostCapabilities, HostLifetime, HostOutputs, HostSpec } from "./host.ts";
119
+ export { SanomaClient, type RunsFilter, type RunSummary, type StartOptions, type StoredSecret } from "./client.ts";
97
120
  // A raw DBOS client on the runtime's database, for tests and tools that read DBOS directly.
98
121
  export { dbosClient } from "./db.ts";
99
122
  // Also at `@sanoma-ai/workflows/shared`, which a browser bundle can import.