@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
@@ -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";
@@ -82,9 +83,13 @@ const words = (d: RecordedDecision) =>
82
83
  /** What the policy decided for the calls, each decision once: `allowed and held for finance`. */
83
84
  const decisions = (calls: CallRecord[]) => [...new Set(calls.map((r) => words(r.decision)))].join(" and ");
84
85
 
85
- /** True when `who` names the approver: a person by id, or a group by its name. */
86
- const isApprover = (approver: Approver, who: string) =>
87
- typeof approver === "string" ? approver === who : approver.group === who;
86
+ /** True when `who` names the approver: a person by id, or a group by its name; `{ group }` the group alone. */
87
+ const isApprover = (approver: Approver, who: Approver) =>
88
+ typeof who !== "string"
89
+ ? typeof approver !== "string" && approver.group === who.group
90
+ : typeof approver === "string"
91
+ ? approver === who
92
+ : approver.group === who;
88
93
 
89
94
  /** How a drift report found a resource, as words: `clean`, `gone`, `drifted in has_wiki, topics`. */
90
95
  const found = (r: DriftResult) =>
@@ -99,7 +104,7 @@ type Unstepped<T> = T extends unknown ? Omit<T, "step"> : never;
99
104
  * output, or undefined when the report found the resource as expected.
100
105
  */
101
106
  function driftDetail(
102
- e: Extract<CoreExpectation, { declared: string }>,
107
+ e: Extract<CoreExpectation, { status: string }>,
103
108
  end: Extract<LedgerRecord, { type: "run.finished" }>,
104
109
  ): string | undefined {
105
110
  const report = end.output as Partial<DriftReport> | null | undefined;
@@ -115,14 +120,38 @@ function driftDetail(
115
120
  return `${e.declared} is ${found(resource)}, not ${e.status === "drifted" ? `drifted in ${e.field}` : e.status}`;
116
121
  }
117
122
 
123
+ /**
124
+ * Why an apply expectation fails against the report a finished apply run returned as its output:
125
+ * the resource `created` (a step that created it, done), or `bound` to an object in the lockfile it ended with.
126
+ */
127
+ function applyDetail(
128
+ e: Extract<CoreExpectation, { created: true } | { bound: string }>,
129
+ end: Extract<LedgerRecord, { type: "run.finished" }>,
130
+ ): string | undefined {
131
+ const report = end.output as Partial<ApplyReport> | null | undefined;
132
+ if (!Array.isArray(report?.results) || !report.lockfile) return "the run's output is not an apply report";
133
+ if ("bound" in e) {
134
+ const binding = report.lockfile.bindings[e.declared];
135
+ if (binding?.id === e.bound) return undefined;
136
+ return binding ? `${e.declared} is bound to ${binding.id}, not ${e.bound}` : `${e.declared} is not bound`;
137
+ }
138
+ const result = report.results.find((r) => r.id === e.declared);
139
+ // A gone object recreated is bound again; one forgotten is not.
140
+ const created =
141
+ result?.status === "done" && (result.action === "create" || (result.action === "gone" && !!result.binding));
142
+ if (created) return undefined;
143
+ if (!result) return `apply did nothing with ${e.declared}`;
144
+ return `apply's ${result.action} of ${e.declared} ${result.status === "failed" ? `failed: ${result.error}` : `was ${result.status}`}`;
145
+ }
146
+
118
147
  /**
119
148
  * Why an expectation of the generic steps does not hold against a run's ledger records, or
120
149
  * undefined when it does. A call is an `op.called` record without an `error`; one with an error
121
150
  * (a policy denial, a rejected approval, a vendor's final failure) was attempted, not made. An
122
- * operation `was denied` when the policy's decision on some call to it is `deny`, and `was held
123
- * 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
151
+ * operation `was denied` when the policy's decision on some call to it is `deny` (for the `reason`
152
+ * expected, when there is one), and `was held for` an approver (a group alone, as `{ group }`) when
153
+ * an approval for that approver holds it: one the policy asked for on its call, or one that `covers` it. A drift or apply expectation reads the report a finished run
154
+ * of it returned. A step named in the workflow's outline is read by the `name` its records carry
126
155
  * (its calls made, and `waitsOf`); a record written before steps had names has none.
127
156
  */
128
157
  export function detailOf(e: CoreExpectation, records: readonly LedgerRecord[]): string | undefined {
@@ -133,13 +162,18 @@ export function detailOf(e: CoreExpectation, records: readonly LedgerRecord[]):
133
162
  if ("decision" in e) {
134
163
  const tries = callsTo(e.op);
135
164
  if (e.decision === "denied") {
136
- if (tries.some((r) => r.decision.kind === "deny")) return undefined;
165
+ const denials = tries.flatMap(({ decision: d }) => (d.kind === "deny" ? [d.reason] : []));
166
+ if (denials.some((reason) => e.reason === undefined || reason === e.reason)) return undefined;
167
+ if (denials.length) {
168
+ const said = [...new Set(denials)].map((reason) => JSON.stringify(reason)).join(" and ");
169
+ return `${e.op} was denied with ${said}, not ${JSON.stringify(e.reason)}`;
170
+ }
137
171
  return tries.length ? `${e.op} was ${decisions(tries)}, not denied` : `no call to ${e.op}`;
138
172
  }
139
173
  const holds = records.filter(isRequest).filter((r) => r.op === e.op || r.covers.includes(e.op));
140
174
  if (holds.some((r) => isApprover(r.approver, e.approver))) return undefined;
141
175
  return holds.length
142
- ? `${e.op} was held for ${[...new Set(holds.map((r) => approverLabel(r.approver)))].join(" and ")}, not ${e.approver}`
176
+ ? `${e.op} was held for ${[...new Set(holds.map((r) => approverLabel(r.approver)))].join(" and ")}, not ${approverLabel(e.approver)}`
143
177
  : tries.length
144
178
  ? `${e.op} was ${decisions(tries)}, not held`
145
179
  : `no call to ${e.op}`;
@@ -161,5 +195,6 @@ export function detailOf(e: CoreExpectation, records: readonly LedgerRecord[]):
161
195
  }
162
196
  // A finished run: what `the run succeeds` expects, and what a drift expectation reads its report from.
163
197
  if (end.type === "run.failed") return `the run failed: ${end.error.message}`;
164
- return "declared" in e ? driftDetail(e, end) : undefined;
198
+ if (!("declared" in e)) return undefined;
199
+ return "status" in e ? driftDetail(e, end) : applyDetail(e, end);
165
200
  }
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,30 @@ 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";
50
+ // `sanoma` itself (the people types' constructors) is at `@sanoma-ai/workflows/resources`, for data files.
51
+ // `collection()` itself is at `@sanoma-ai/workflows/data`, for `resources/data.ts`; this tells one apart, as the launcher does.
52
+ export { isCollection } from "./collection.ts";
48
53
  export { type CredentialStatus, credentialsOf } from "./env.ts";
54
+ export {
55
+ APPLY_WORKFLOW,
56
+ ApplyInput,
57
+ type ApplyPlan,
58
+ type ApplyReport,
59
+ type ApplyResult,
60
+ type PlanAnswer,
61
+ type PlanStep,
62
+ } from "./apply.ts";
63
+ export { CALL_WORKFLOW, CallInput } from "./call-workflow.ts";
64
+ export { isDirect } from "./direct.ts";
49
65
  export {
50
66
  DRIFT_WORKFLOW,
51
67
  type DriftDeclared,
@@ -62,20 +78,32 @@ export {
62
78
  approve,
63
79
  approvedFor,
64
80
  type Decision,
65
- definePolicy,
66
81
  deny,
67
82
  type Policy,
68
83
  type PolicyCall,
69
84
  type PolicyOp,
70
85
  policyOpOf,
86
+ type PreviewAnswer,
87
+ type PreviewRequest,
71
88
  type RecordedDecision,
72
89
  } from "./policy.ts";
73
90
  export {
91
+ cedarPolicy,
92
+ type CedarPolicySource,
93
+ loadPolicies,
94
+ POLICIES_DIR,
95
+ type PolicyProblem,
96
+ readPolicyFiles,
97
+ validatePolicies,
98
+ } from "./cedar.ts";
99
+ export {
100
+ ApprovalPendingError,
74
101
  errorCode,
75
102
  type ErrorCode,
76
103
  type ErrorInfo,
77
104
  type InputIssue,
78
105
  invalidInput,
106
+ NotAdmittedError,
79
107
  PolicyDeniedError,
80
108
  RejectedError,
81
109
  SanomaError,
@@ -93,12 +121,38 @@ export { ApprovalMessage, APPROVALS_EVENT, decisionEventOf } from "./approvals.t
93
121
  export { RUNTIME_VERSION } from "./version.ts";
94
122
  export type { RunArgs } from "./run.ts";
95
123
  export { startWorker, type Worker, type WorkerOptions } from "./worker.ts";
96
- export { SanomaClient, type RunsFilter, type RunSummary, type StartOptions } from "./client.ts";
124
+ // `secret` itself is at `@sanoma-ai/workflows/resources`, for data files; this is the drivers' side.
125
+ export {
126
+ generateSecret,
127
+ isSecret,
128
+ memorySecrets,
129
+ type Resolved,
130
+ resolveSecrets,
131
+ type Secret,
132
+ type Secrets,
133
+ } from "./secret.ts";
134
+ export { resolveRevision, SOURCE_RESOLVE, type Source } from "./source.ts";
135
+ export type { HostCapabilities, HostLifetime, HostOutputs, HostSpec } from "./host.ts";
136
+ export {
137
+ type CallOptions,
138
+ SanomaClient,
139
+ type RunsFilter,
140
+ type RunSummary,
141
+ type StartOptions,
142
+ type StoredSecret,
143
+ } from "./client.ts";
97
144
  // A raw DBOS client on the runtime's database, for tests and tools that read DBOS directly.
98
145
  export { dbosClient } from "./db.ts";
99
146
  // Also at `@sanoma-ai/workflows/shared`, which a browser bundle can import.
100
147
  export {
148
+ AGENTS,
101
149
  approverLabel,
150
+ approverOf,
151
+ declaresPeople,
152
+ type PeopleResource,
153
+ personOf,
154
+ principalOf,
155
+ RUNTIME_AGENTS,
102
156
  credentialReady,
103
157
  DEFAULT_SLEEP_NAME,
104
158
  ENDED_STATUSES,
package/src/ledger.ts CHANGED
@@ -3,7 +3,7 @@ import { appendFile, mkdir, readFile, truncate } from "node:fs/promises";
3
3
  import { join, resolve } from "node:path";
4
4
  import { setTimeout as delay } from "node:timers/promises";
5
5
  import { isDeepStrictEqual } from "node:util";
6
- import { LEDGER_TABLE as TABLE, lazyPool, pgCode } from "./db.ts";
6
+ import { type Db, LEDGER_TABLE as TABLE, lazyPool, pgCode } from "./db.ts";
7
7
  import type { Approver, Principal } from "./define.ts";
8
8
  import { type ErrorInfo, isFinal, keepCode } from "./errors.ts";
9
9
  import { shown, warn } from "./log.ts";
@@ -80,13 +80,23 @@ export type LedgerRecord = {
80
80
  op?: string;
81
81
  /** The held call's `seq`, which its `op.called` record has, for a policy request. */
82
82
  opSeq?: number;
83
+ /** The policies that held the call, by id, for a policy request whose policy names them. */
84
+ reasons?: string[];
83
85
  }
84
86
  /** A message the approval ignored: from someone other than the approver, or not a decision. */
85
87
  | { type: "approval.refused"; approval: string; by?: string; reason: string }
86
- | { type: "approval.decided"; approval: string; decision: "approve" | "reject"; by: string; note?: string }
88
+ | {
89
+ type: "approval.decided";
90
+ approval: string;
91
+ decision: "approve" | "reject";
92
+ by: string;
93
+ note?: string;
94
+ /** Set when `by` started the run: nobody else could decide (`mayDecide`). */
95
+ alone?: true;
96
+ }
87
97
  /**
88
98
  * A `ctx.sleep` began; `until` is when it ends, in ms since the epoch: the time asked for, or
89
- * for a duration the time the sleep started plus the duration, as the runtime saw it. Written
99
+ * for a duration the record's `at` plus the duration, as the runtime saw it. Written
90
100
  * for a time already past too, which waits not at all.
91
101
  */
92
102
  | {
@@ -292,25 +302,13 @@ export function postgresLedger(databaseUrl: string): LedgerStore & { close(): Pr
292
302
 
293
303
  return {
294
304
  async append(record) {
295
- const line = lineOf(record);
296
305
  const conn = await pool.db();
297
- const inserted = await conn
298
- .query(
299
- `INSERT INTO ${TABLE} (id, run_id, seq, record) VALUES ($1, $2, $3, $4::jsonb) ON CONFLICT (id) DO NOTHING RETURNING n`,
300
- [record.id, record.runId, record.seq, line],
301
- )
302
- .catch((err: unknown) => {
303
- if (DATA_EXCEPTION.test(pgCode(err))) {
304
- throw final(`Ledger record ${record.id} can't be stored: ${errorMessage(err)}`, err);
305
- }
306
- throw err;
307
- });
308
- if (inserted.rows.length) return;
306
+ if (await insertRecord(conn, record)) return;
309
307
  // Another write of the id was first: the record it stored stands.
310
308
  const { rows } = await conn.query<{ record: LedgerRecord }>(`SELECT record FROM ${TABLE} WHERE id = $1`, [
311
309
  record.id,
312
310
  ]);
313
- if (rows[0]) warnIfDifferent(rows[0].record, line);
311
+ if (rows[0]) warnIfDifferent(rows[0].record, lineOf(record));
314
312
  },
315
313
  async read(runId) {
316
314
  const { rows } = await (
@@ -323,6 +321,26 @@ export function postgresLedger(databaseUrl: string): LedgerStore & { close(): Pr
323
321
  };
324
322
  }
325
323
 
324
+ /**
325
+ * Stores the record in the Postgres ledger's table through `db`, unless one with its id is
326
+ * stored: true when this one was. What `postgresLedger` appends with, and a direct write
327
+ * (`SanomaClient.call`) inside the transaction that saves its record.
328
+ */
329
+ export async function insertRecord(db: Db, record: LedgerRecord): Promise<boolean> {
330
+ const inserted = await db
331
+ .query(
332
+ `INSERT INTO ${TABLE} (id, run_id, seq, record) VALUES ($1, $2, $3, $4::jsonb) ON CONFLICT (id) DO NOTHING RETURNING n`,
333
+ [record.id, record.runId, record.seq, lineOf(record)],
334
+ )
335
+ .catch((err: unknown) => {
336
+ if (DATA_EXCEPTION.test(pgCode(err))) {
337
+ throw final(`Ledger record ${record.id} can't be stored: ${errorMessage(err)}`, err);
338
+ }
339
+ throw err;
340
+ });
341
+ return inserted.rows.length > 0;
342
+ }
343
+
326
344
  /** Keeps records in memory, for tests. */
327
345
  export function memoryLedger(): LedgerStore {
328
346
  const runs = new Map<string, Map<string, LedgerRecord>>();
package/src/lint.ts CHANGED
@@ -1,6 +1,7 @@
1
+ import { realpathSync } from "node:fs";
1
2
  import { basename, dirname, join } from "node:path";
2
3
  import { byPosition, childrenOf, type LintProblem, type Node, parsed, type SourceFile } from "./ast.ts";
3
- import { readDataFile } from "./datafile.ts";
4
+ import { COLLECTIONS_FILE, readDataFile } from "./datafile.ts";
4
5
  import { constsOf, outlineBody, workflowLiterals } from "./outline.ts";
5
6
  import { inside, nearestDir, RELATIVE } from "./paths.ts";
6
7
  // From src/ and from dist/ alike, the package's own oxlint.json: the one list of allowed names.
@@ -33,7 +34,7 @@ const REFUSED_PACKAGES: [RegExp, string][] = [
33
34
  ];
34
35
 
35
36
  /**
36
- * What a workflow or policy may import from an author package, besides types: the one list,
37
+ * What a workflow may import from an author package, besides types: the one list,
37
38
  * `allowImportNames` of oxlint.json's `@sanoma-ai/workflows` entry (`sanoma`'s generated
38
39
  * `oxlint.json` derives its own entry from it). Everything else is
39
40
  * refused: the client, the worker and the app could start runs or approve the run's own
@@ -46,7 +47,7 @@ const WORKFLOW_IMPORTS: readonly string[] =
46
47
  const ALLOWED_NAMES = new Set(WORKFLOW_IMPORTS);
47
48
 
48
49
  const NOT_ALLOWED =
49
- `a workflow or policy imports only ${WORKFLOW_IMPORTS.join(", ")} and types from ` +
50
+ `a workflow imports only ${WORKFLOW_IMPORTS.join(", ")} and types from ` +
50
51
  `${AUTHOR_PACKAGES.join(" or ")}: the rest could start runs, approve its own approvals, forge the ledger or read credentials`;
51
52
 
52
53
  /**
@@ -57,11 +58,12 @@ const NOT_ALLOWED =
57
58
  const ERROR_CLASSES = new Set(["DriverError", "SanomaError", "PolicyDeniedError", "RejectedError"]);
58
59
 
59
60
  /**
60
- * Checks a workflow or policy file. Imports allowed: the names in `WORKFLOW_IMPORTS` and any
61
+ * Checks a workflow file. Imports allowed: the names in `WORKFLOW_IMPORTS` and any
61
62
  * type from `@sanoma-ai/workflows` or `sanoma`, `@sanoma-ai/connector-<vendor>`, `zod`, and relative files. With
62
- * `filename`, a relative import must stay inside the file's nearest `workflows/` or `policies/`
63
- * directory, or its own directory when it has neither. No `instanceof` against the runtime's
64
- * error classes: read `errorCode(err)`.
63
+ * `filename`, a relative import must stay inside the file's nearest `workflows/` directory, or its
64
+ * own directory when it has none; the one file outside it a workflow may import is the company's
65
+ * `resources/data.ts`, its collections (by real path). No
66
+ * `instanceof` against the runtime's error classes: read `errorCode(err)`.
65
67
  *
66
68
  * In each workflow's `run` (the literal `defineWorkflow` is called with), it reports what the
67
69
  * outline cannot draw, as the worker refuses to start with (`outlineBody`): `ctx` used other
@@ -82,7 +84,7 @@ export function lintWorkflow(input: string | SourceFile, path?: string): LintPro
82
84
  const read = typeof input === "string" ? parsed(input, filename ?? "workflow.ts") : input;
83
85
  const { program, at } = read;
84
86
  const problems = [...read.problems];
85
- const root = filename === undefined ? undefined : nearestDir(filename, ["workflows", "policies"]);
87
+ const root = filename === undefined ? undefined : nearestDir(filename, ["workflows"]);
86
88
 
87
89
  const checkSource = (node: Node) => {
88
90
  const spec: string = node.source.value;
@@ -197,12 +199,27 @@ type RestrictedPath = { name: string; allowImportNames?: string[] };
197
199
 
198
200
  function whyRefused(spec: string, filename: string | undefined, root: string | undefined): string | undefined {
199
201
  if (RELATIVE.test(spec)) {
200
- if (filename === undefined || root === undefined || inside(root, join(dirname(filename), spec))) return undefined;
201
- const where =
202
- basename(root) === "workflows" || basename(root) === "policies" ? `${basename(root)}/` : "this directory";
203
- return `it reaches outside ${where}, toward the config and its drivers; ${THROUGH_CTX}`;
202
+ if (filename === undefined || root === undefined) return undefined;
203
+ const target = join(dirname(filename), spec);
204
+ if (inside(root, target)) return undefined;
205
+ const tree = basename(root) === "workflows";
206
+ // The collections, which a workflow names in `uses`: the one data file the runtime imports too.
207
+ if (tree && realPath(target) === realPath(join(dirname(root), "resources", COLLECTIONS_FILE))) return undefined;
208
+ return (
209
+ `it reaches outside ${tree ? `${basename(root)}/` : "this directory"}, toward the config and its drivers; ${THROUGH_CTX}` +
210
+ (tree ? "; of the files outside it, only resources/data.ts (the collections) may be imported" : "")
211
+ );
204
212
  }
205
213
  for (const [pattern, reason] of REFUSED_PACKAGES) if (pattern.test(spec)) return reason;
206
214
  if (ALLOWED_PACKAGE.test(spec)) return undefined;
207
215
  return "import only from `sanoma` (or `@sanoma-ai/workflows`), a `@sanoma-ai/connector-*` package, zod, or a file beside this one";
208
216
  }
217
+
218
+ /** A path's real path, or the path itself when it does not exist. */
219
+ function realPath(path: string): string {
220
+ try {
221
+ return realpathSync(path);
222
+ } catch {
223
+ return path;
224
+ }
225
+ }
@@ -0,0 +1,95 @@
1
+ import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { z } from "zod";
4
+ import type { ResourceProblem } from "./resources.ts";
5
+
6
+ // The lockfile: which vendor object each declaration means, kept apart from the data files (which
7
+ // hold intent only), as `package-lock.json` is from `package.json`. Only apply writes it.
8
+
9
+ /** Where the lockfile is, relative to the config's root. */
10
+ export const LOCKFILE = "resources/sanoma.lock.json";
11
+
12
+ /** Which vendor object a declaration means: its `<vendor>.<type>` and the vendor's stable id for it. */
13
+ export interface Binding {
14
+ type: string;
15
+ id: string;
16
+ }
17
+
18
+ /** The lockfile's content: by declaration key, `<file>#<export>` (the file relative to the config's root, with `/`). */
19
+ export type Bindings = Record<string, Binding>;
20
+
21
+ const lockfile = z.record(
22
+ z.string().regex(/^resources\/[^#]+#[^#]+$/),
23
+ z.strictObject({ type: z.string().regex(/^[^.\s]+\.[^.\s]+$/), id: z.string().min(1) }),
24
+ );
25
+
26
+ /**
27
+ * The lockfile under `root`: no bindings when there is none, and no bindings with a problem when
28
+ * it is not JSON or not a map of `<file>#<export>` to `{ type, id }`.
29
+ */
30
+ export function readLockfile(root: string): { bindings: Bindings; problems: ResourceProblem[] } {
31
+ const path = join(root, LOCKFILE);
32
+ if (!existsSync(path)) return { bindings: {}, problems: [] };
33
+ let json: unknown;
34
+ try {
35
+ json = JSON.parse(readFileSync(path, "utf8"));
36
+ } catch (e) {
37
+ return { bindings: {}, problems: [{ file: LOCKFILE, message: `not JSON: ${(e as Error).message}` }] };
38
+ }
39
+ const parsed = lockfile.safeParse(json);
40
+ if (!parsed.success) {
41
+ // One line: zod's own spans lines.
42
+ return {
43
+ bindings: {},
44
+ problems: [{ file: LOCKFILE, message: z.prettifyError(parsed.error).replaceAll(/\s*\n\s*/g, " ") }],
45
+ };
46
+ }
47
+ return { bindings: parsed.data, problems: [] };
48
+ }
49
+
50
+ /** The lockfile's text for `bindings`: keys sorted, two-space JSON, a trailing newline, so a diff shows only what changed. */
51
+ export function serializeLockfile(bindings: Bindings): string {
52
+ const sorted = Object.entries(bindings)
53
+ .toSorted(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
54
+ .map(([key, { type, id }]) => [key, { type, id }]);
55
+ return `${JSON.stringify(Object.fromEntries(sorted), null, 2)}\n`;
56
+ }
57
+
58
+ /** Writes the lockfile under `root` whole or not at all: to a file beside it, then renamed over it. Apply only. */
59
+ export function writeLockfile(root: string, bindings: Bindings): void {
60
+ const path = join(root, LOCKFILE);
61
+ const temporary = `${path}.${process.pid}.tmp`;
62
+ try {
63
+ writeFileSync(temporary, serializeLockfile(bindings));
64
+ renameSync(temporary, path);
65
+ } catch (err) {
66
+ rmSync(temporary, { force: true });
67
+ throw err;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * One change to the lockfile: a declaration bound for the first time (`bind`, `to`), pointed at
73
+ * another object (`rebind`, `from` and `to`), or no longer bound (`unbind`, `from`). `type` is
74
+ * the binding's `<vendor>.<type>`, the new one's for a rebind.
75
+ */
76
+ export interface Rebind {
77
+ key: string;
78
+ type: string;
79
+ kind: "bind" | "rebind" | "unbind";
80
+ from?: string;
81
+ to?: string;
82
+ }
83
+
84
+ /** What changed from `before` to `after`, by key, sorted. */
85
+ export function diffLockfiles(before: Bindings, after: Bindings): Rebind[] {
86
+ const keys = [...new Set([...Object.keys(before), ...Object.keys(after)])].toSorted();
87
+ return keys.flatMap((key): Rebind[] => {
88
+ const was = before[key];
89
+ const now = after[key];
90
+ if (!now) return was ? [{ key, type: was.type, kind: "unbind", from: was.id }] : [];
91
+ if (!was) return [{ key, type: now.type, kind: "bind", to: now.id }];
92
+ if (was.id === now.id && was.type === now.type) return [];
93
+ return [{ key, type: now.type, kind: "rebind", from: was.id, to: now.id }];
94
+ });
95
+ }
package/src/op.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { z } from "zod";
2
2
  import type { Fake } from "./fake.ts";
3
+ import type { Secrets } from "./secret.ts";
3
4
  import type { Steps } from "./steps.ts";
4
5
 
5
6
  /**
@@ -235,6 +236,14 @@ export interface CallContext {
235
236
  opId: string;
236
237
  /** 1 on the first try; retries count up (idempotent operations only). */
237
238
  attempt: number;
239
+ /** The secrets store, for the values `secret()` markers name: `resolveSecrets(input, call.secrets)`. In memory for a fake. */
240
+ secrets: Secrets;
241
+ /**
242
+ * Who the call is made as: `run:<runId>` for a call in a run; the person's id for a direct call
243
+ * (`SanomaClient.call`) and in a run of the built-in `call` workflow. A collection's save
244
+ * records it as the record's `updatedBy`.
245
+ */
246
+ by?: string;
238
247
  }
239
248
 
240
249
  export type DriverFn<I = any, O = unknown> = (input: I, call: CallContext) => Promise<O>;
package/src/outline.ts CHANGED
@@ -576,7 +576,7 @@ function ctxPath(callee: Node, ctx: string): string[] | undefined {
576
576
  }
577
577
 
578
578
  /** A string literal's value, or a template literal's with nothing interpolated. */
579
- function stringValue(node: Node): string | undefined {
579
+ export function stringValue(node: Node): string | undefined {
580
580
  const n = unwrap(node);
581
581
  if (n?.type === "Literal" && typeof n.value === "string") return n.value;
582
582
  if (n?.type === "TemplateLiteral" && n.expressions.length === 0) return n.quasis[0]?.value.cooked ?? undefined;
package/src/paths.ts CHANGED
@@ -1,7 +1,9 @@
1
- import { basename, dirname, isAbsolute, relative, sep } from "node:path";
1
+ import { readdirSync, statSync } from "node:fs";
2
+ import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
2
3
 
3
- // Where an import may reach, as the lints and the data-file reader all decide it, and the one
4
- // walk up the directories. Node's `path` only, no parser: the oxlint plugin loads it.
4
+ // Where an import may reach, as the lints and the data-file reader all decide it, the one walk up
5
+ // the directories and the one walk down (`filesUnder`). Node's `path` and `fs` only, no parser:
6
+ // the oxlint plugin loads it.
5
7
 
6
8
  /** A relative import specifier: `./x.ts`, `../y.ts`. */
7
9
  export const RELATIVE = /^\.\.?\//;
@@ -30,3 +32,16 @@ export function inside(dir: string, path: string): boolean {
30
32
  const rel = relative(dir, path);
31
33
  return rel === "" || (rel.split(sep)[0] !== ".." && !isAbsolute(rel));
32
34
  }
35
+
36
+ /** True when `path` is a directory. */
37
+ export const isDirectory = (path: string): boolean => statSync(path, { throwIfNoEntry: false })?.isDirectory() ?? false;
38
+
39
+ /** The files under `dir` whose name `keep` takes, sorted, outside `node_modules`; none when `dir` is not a directory. */
40
+ export function filesUnder(dir: string, keep: (name: string) => boolean): string[] {
41
+ if (!isDirectory(dir)) return [];
42
+ return readdirSync(dir, { recursive: true, withFileTypes: true })
43
+ .filter((d) => d.isFile() && keep(d.name))
44
+ .map((d) => join(d.parentPath, d.name))
45
+ .filter((path) => !relative(dir, path).split(sep).includes("node_modules"))
46
+ .toSorted();
47
+ }