@sanoma-ai/workflows 0.2.0 → 0.4.1

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 (149) hide show
  1. package/README.md +146 -85
  2. package/dist/approvals.d.ts +11 -6
  3. package/dist/approvals.js +38 -10
  4. package/dist/approvals.js.map +1 -1
  5. package/dist/call-workflow.d.ts +16 -0
  6. package/dist/call-workflow.js +35 -0
  7. package/dist/call-workflow.js.map +1 -0
  8. package/dist/call.d.ts +33 -4
  9. package/dist/call.js +107 -42
  10. package/dist/call.js.map +1 -1
  11. package/dist/cedar.d.ts +87 -0
  12. package/dist/cedar.js +365 -0
  13. package/dist/cedar.js.map +1 -0
  14. package/dist/client.d.ts +85 -7
  15. package/dist/client.js +289 -25
  16. package/dist/client.js.map +1 -1
  17. package/dist/collection.d.ts +107 -0
  18. package/dist/collection.js +306 -0
  19. package/dist/collection.js.map +1 -0
  20. package/dist/config.d.ts +42 -14
  21. package/dist/config.js +82 -18
  22. package/dist/config.js.map +1 -1
  23. package/dist/constructors.d.ts +2 -0
  24. package/dist/constructors.js +5 -0
  25. package/dist/constructors.js.map +1 -0
  26. package/dist/data.d.ts +2 -0
  27. package/dist/data.js +3 -0
  28. package/dist/data.js.map +1 -0
  29. package/dist/datafile.d.ts +28 -2
  30. package/dist/datafile.js +118 -19
  31. package/dist/datafile.js.map +1 -1
  32. package/dist/db.d.ts +5 -3
  33. package/dist/db.js +88 -21
  34. package/dist/db.js.map +1 -1
  35. package/dist/define.d.ts +14 -5
  36. package/dist/define.js +1 -5
  37. package/dist/define.js.map +1 -1
  38. package/dist/describe.d.ts +45 -5
  39. package/dist/describe.js +72 -7
  40. package/dist/describe.js.map +1 -1
  41. package/dist/direct.d.ts +9 -0
  42. package/dist/direct.js +11 -0
  43. package/dist/direct.js.map +1 -0
  44. package/dist/errors.d.ts +16 -1
  45. package/dist/errors.js +30 -1
  46. package/dist/errors.js.map +1 -1
  47. package/dist/expectations.d.ts +3 -3
  48. package/dist/expectations.js +16 -7
  49. package/dist/expectations.js.map +1 -1
  50. package/dist/fill.d.ts +4 -0
  51. package/dist/fill.js +13 -0
  52. package/dist/fill.js.map +1 -1
  53. package/dist/index.d.ts +8 -4
  54. package/dist/index.js +10 -4
  55. package/dist/index.js.map +1 -1
  56. package/dist/ledger.d.ts +12 -1
  57. package/dist/ledger.js +18 -11
  58. package/dist/ledger.js.map +1 -1
  59. package/dist/lint.d.ts +5 -4
  60. package/dist/lint.js +24 -7
  61. package/dist/lint.js.map +1 -1
  62. package/dist/op.d.ts +6 -0
  63. package/dist/op.js.map +1 -1
  64. package/dist/outline.d.ts +2 -0
  65. package/dist/outline.js +1 -1
  66. package/dist/outline.js.map +1 -1
  67. package/dist/paths.d.ts +4 -0
  68. package/dist/paths.js +17 -3
  69. package/dist/paths.js.map +1 -1
  70. package/dist/person.d.ts +61 -0
  71. package/dist/person.js +69 -0
  72. package/dist/person.js.map +1 -0
  73. package/dist/plugin.d.ts +28 -0
  74. package/dist/plugin.js +52 -1
  75. package/dist/plugin.js.map +1 -1
  76. package/dist/policy.d.ts +51 -55
  77. package/dist/policy.js +14 -36
  78. package/dist/policy.js.map +1 -1
  79. package/dist/resource.d.ts +21 -5
  80. package/dist/resource.js +12 -3
  81. package/dist/resource.js.map +1 -1
  82. package/dist/resources.d.ts +58 -6
  83. package/dist/resources.js +119 -26
  84. package/dist/resources.js.map +1 -1
  85. package/dist/run.d.ts +17 -1
  86. package/dist/run.js.map +1 -1
  87. package/dist/scenario.d.ts +16 -9
  88. package/dist/scenario.js +149 -44
  89. package/dist/scenario.js.map +1 -1
  90. package/dist/scenarios.d.ts +10 -6
  91. package/dist/scenarios.js +8 -4
  92. package/dist/scenarios.js.map +1 -1
  93. package/dist/schema.d.ts +32 -0
  94. package/dist/schema.js +225 -0
  95. package/dist/schema.js.map +1 -0
  96. package/dist/sdk.d.ts +34 -0
  97. package/dist/sdk.js +168 -0
  98. package/dist/sdk.js.map +1 -0
  99. package/dist/shared.d.ts +181 -5
  100. package/dist/shared.js +160 -7
  101. package/dist/shared.js.map +1 -1
  102. package/dist/sites.d.ts +52 -0
  103. package/dist/sites.js +174 -0
  104. package/dist/sites.js.map +1 -0
  105. package/dist/version.d.ts +3 -2
  106. package/dist/version.js +3 -2
  107. package/dist/version.js.map +1 -1
  108. package/dist/worker.d.ts +3 -2
  109. package/dist/worker.js +24 -4
  110. package/dist/worker.js.map +1 -1
  111. package/oxlint.json +7 -20
  112. package/package.json +8 -3
  113. package/src/approvals.ts +49 -13
  114. package/src/call-workflow.ts +44 -0
  115. package/src/call.ts +157 -45
  116. package/src/cedar.ts +493 -0
  117. package/src/client.ts +368 -37
  118. package/src/collection.ts +431 -0
  119. package/src/config.ts +131 -34
  120. package/src/constructors.ts +4 -0
  121. package/src/data.ts +10 -0
  122. package/src/datafile.ts +141 -21
  123. package/src/db.ts +90 -22
  124. package/src/define.ts +15 -10
  125. package/src/describe.ts +113 -10
  126. package/src/direct.ts +15 -0
  127. package/src/errors.ts +36 -1
  128. package/src/expectations.ts +17 -8
  129. package/src/fill.ts +15 -0
  130. package/src/index.ts +33 -2
  131. package/src/ledger.ts +35 -17
  132. package/src/lint.ts +29 -12
  133. package/src/op.ts +6 -0
  134. package/src/outline.ts +1 -1
  135. package/src/paths.ts +18 -3
  136. package/src/person.ts +75 -0
  137. package/src/plugin.ts +74 -5
  138. package/src/policy.ts +59 -63
  139. package/src/resource.ts +35 -8
  140. package/src/resources.ts +165 -24
  141. package/src/run.ts +18 -1
  142. package/src/scenario.ts +186 -57
  143. package/src/scenarios.ts +15 -8
  144. package/src/schema.ts +261 -0
  145. package/src/sdk.ts +211 -0
  146. package/src/shared.ts +271 -8
  147. package/src/sites.ts +220 -0
  148. package/src/version.ts +3 -2
  149. package/src/worker.ts +29 -6
package/src/policy.ts CHANGED
@@ -1,14 +1,10 @@
1
- import { z } from "zod";
2
- import { type ApprovalState, Approver, Covers, type Principal, sameApprover } from "./define.ts";
1
+ import { type ApprovalState, type Approver, type Principal, sameApprover } from "./define.ts";
3
2
  import type { Effect, Op } from "./op.ts";
4
3
 
5
4
  /** An operation as a policy sees it: plain data, without its schemas. */
6
5
  export type PolicyOp = Pick<Op, "id" | "vendor" | "resource" | "name" | "effect">;
7
6
 
8
- /**
9
- * An operation as a policy sees it, for a `PolicyCall`: what the runtime passes, so a policy
10
- * test can build a call from the connector, `{ op: policyOpOf(shop.order.refund), ... }`.
11
- */
7
+ /** An operation as a policy sees it, for a `PolicyCall`: what the runtime passes. */
12
8
  export const policyOpOf = ({ id, vendor, resource, name, effect }: Op): PolicyOp => ({
13
9
  id,
14
10
  vendor,
@@ -17,7 +13,7 @@ export const policyOpOf = ({ id, vendor, resource, name, effect }: Op): PolicyOp
17
13
  effect,
18
14
  });
19
15
 
20
- /** What a policy sees for each operation a run calls. Plain data, so a test can build one by hand. */
16
+ /** What a policy sees for each operation a run calls: plain data. */
21
17
  export interface PolicyCall {
22
18
  op: PolicyOp;
23
19
  /** The same as `op.effect`. */
@@ -33,6 +29,8 @@ export interface PolicyCall {
33
29
  * A policy request has `requestedBy: "policy"` and the `op` it held.
34
30
  */
35
31
  run: { id: string; workflow: string; approvals: readonly ApprovalState[] };
32
+ /** For a collection's `save` or `delete`: the record as it was, when there was one. */
33
+ before?: unknown;
36
34
  }
37
35
 
38
36
  /**
@@ -44,88 +42,86 @@ export type Decision =
44
42
  | { kind: "deny"; reason: string; reasons?: string[] }
45
43
  | ApproveDecision;
46
44
 
47
- /**
48
- * Hold the call until the approver decides: a person's id, or `{ group }` for anyone in it.
49
- * The approval covers the held operation, and `covers` adds others, so a policy can let their
50
- * later calls through once it is approved.
51
- */
45
+ /** Hold the call until the approver decides: a person's id, or `{ group }` for anyone in it. */
52
46
  interface ApproveDecision {
53
47
  kind: "approve";
54
48
  approver: Approver;
55
49
  title?: string;
56
- covers?: Op[];
50
+ reasons?: string[];
57
51
  }
58
52
 
59
- const ReasonList = z.array(z.string(), { error: "reasons must be a list of strings" }).optional();
60
-
61
- /**
62
- * Checks a policy's answer. Parsing also copies it, so nothing else the policy returned is kept,
63
- * and turns `covers` into op ids.
64
- */
65
- export const DecisionSchema = z.discriminatedUnion(
66
- "kind",
67
- [
68
- z.object({ kind: z.literal("allow"), reasons: ReasonList }),
69
- z.object({ kind: z.literal("deny"), reason: z.string({ error: "deny needs a reason" }), reasons: ReasonList }),
70
- z.object({
71
- kind: z.literal("approve"),
72
- approver: Approver,
73
- title: z.string({ error: "the title must be a string" }).optional(),
74
- covers: Covers,
75
- }),
76
- ],
77
- { error: "not a decision" },
78
- );
79
-
80
- /**
81
- * A decision as checked and as the ledger records it: `covers` by op id, and the `version` of
82
- * the policy that made it, when it has one.
83
- */
84
- export type RecordedDecision = z.output<typeof DecisionSchema> & { policyVersion?: string };
53
+ /** A decision as the ledger records it, with the `version` of the policy that made it, when it has one. */
54
+ export type RecordedDecision = Decision & { policyVersion?: string };
85
55
 
86
56
  export const allow = (reasons?: string[]): Decision =>
87
57
  reasons === undefined ? { kind: "allow" } : { kind: "allow", reasons };
88
58
  export const deny = (reason: string, reasons?: string[]): Decision =>
89
59
  reasons === undefined ? { kind: "deny", reason } : { kind: "deny", reason, reasons };
90
60
 
91
- /** Holds the call for `approver`. `covers` lists other operations the approval also stands for. */
92
- export function approve(approver: Approver, { title, covers }: { title?: string; covers?: Op[] } = {}): Decision {
61
+ /** Holds the call for `approver`; `reasons`, the rules that held it, as `allow` and `deny` take them. */
62
+ export function approve(approver: Approver, { title, reasons }: { title?: string; reasons?: string[] } = {}): Decision {
93
63
  return {
94
64
  kind: "approve",
95
65
  approver,
96
66
  ...(title === undefined ? {} : { title }),
97
- ...(covers === undefined ? {} : { covers }),
67
+ ...(reasons === undefined ? {} : { reasons }),
98
68
  };
99
69
  }
100
70
 
101
71
  /**
102
- * Decides whether a run may make an operation call: allow it, deny it (the run fails),
103
- * or hold it until someone approves. Runs are replayed after a restart, so a policy must
104
- * decide the same way every time from the call alone: no clock, randomness or network.
105
- * `lintWorkflow` checks a policy file for these too. Each decision is recorded once made,
106
- * and a replay reuses it, but a call interrupted before that is decided again. A policy
107
- * must not have side effects.
72
+ * Decides whether a run may make an operation call: allow it, deny it (the run fails), or hold it
73
+ * until someone approves. A company's is made from its Cedar files (`cedarPolicy`); a repo with
74
+ * none has `allowAll`. Each decision is recorded once made, and a replay reuses it, but a call
75
+ * interrupted before that is decided again.
108
76
  */
109
- export type Policy = ((call: PolicyCall) => Decision | Promise<Decision>) & {
110
- /** Names this version of the policy, from `definePolicy(fn, { version })`. */
77
+ export type Policy = ((call: PolicyCall) => Decision) & {
78
+ /** Names this version of the policy: a hash of the Cedar policies' ids and text. */
111
79
  readonly version?: string;
80
+ /** What the policy would answer for each request ahead of the call, its input unknown. */
81
+ readonly preview: (requests: readonly PreviewRequest[]) => PreviewAnswer[];
82
+ /**
83
+ * May `actor` start a run of `workflow` with `input` (the action `workflow:<name>`), given the
84
+ * run's `approvals` so far (none before it starts)?
85
+ */
86
+ readonly start: (
87
+ actor: Principal,
88
+ workflow: string,
89
+ input: unknown,
90
+ approvals?: readonly ApprovalState[],
91
+ ) => Decision;
112
92
  };
113
93
 
114
- /** A policy, optionally named with a version so a reader can tell which rules decided a call. */
115
- export function definePolicy(
116
- policy: (call: PolicyCall) => Decision | Promise<Decision>,
117
- options: { version?: string } = {},
118
- ): Policy {
119
- if (typeof policy !== "function") throw new Error("definePolicy expects a function of the call");
120
- if (options.version !== undefined && (typeof options.version !== "string" || !options.version.trim())) {
121
- throw new Error("definePolicy: `version` must be a non-empty string");
122
- }
123
- const defined = (call: PolicyCall) => policy(call);
124
- return Object.freeze(options.version === undefined ? defined : Object.assign(defined, { version: options.version }));
94
+ /** A question asked ahead of a call: may `principal` take `action` (an operation, or `workflow:<name>` to start one) on `resource` (an operation's target)? */
95
+ export interface PreviewRequest {
96
+ principal: Principal;
97
+ action: { id: string; effect?: Effect };
98
+ resource?: string;
99
+ }
100
+
101
+ /**
102
+ * A preview's answer: `depends` when the unknown input decides it. `rule` is the Cedar policy
103
+ * behind a hold, a deny or a dependence; `approver`, a hold's.
104
+ */
105
+ export interface PreviewAnswer {
106
+ answer: "allow" | "hold" | "deny" | "depends";
107
+ rule?: { id: string; file: string; title?: string; reason?: string };
108
+ approver?: Approver;
125
109
  }
126
110
 
127
- /** Allows every operation call. A config says so explicitly: `policy: allowAll`. */
128
- export const allowAll: Policy = definePolicy(() => allow());
111
+ /** A policy that decides every call and start as `decide` does, and previews each as `answer`. */
112
+ const decidingAll = (decide: () => Decision, answer: "allow" | "deny"): Policy =>
113
+ Object.freeze(
114
+ Object.assign(decide, {
115
+ start: decide,
116
+ preview: (requests: readonly PreviewRequest[]): PreviewAnswer[] => requests.map(() => ({ answer })),
117
+ }),
118
+ );
119
+
120
+ /** Allows every operation call and run start: the policy of a repo with no `policies/**\/*.cedar`. */
121
+ export const allowAll: Policy = decidingAll(() => allow(), "allow");
122
+
123
+ /** Refuses every operation call and run start, and previews each as a deny, saying `reason`. */
124
+ export const denyAll = (reason: string): Policy => decidingAll(() => deny(reason), "deny");
129
125
 
130
126
  /**
131
127
  * True when an approval in the run was approved, covers the operation, and was addressed to
package/src/resource.ts CHANGED
@@ -24,8 +24,8 @@ export interface ResourceFields {
24
24
 
25
25
  /** Where a field names another declared resource: see `ResourceFields.references`. */
26
26
  export interface FieldReference {
27
- /** The `<vendor>.<type>` of the resource it names. */
28
- readonly type: string;
27
+ /** The `<vendor>.<type>` of the resource it names, or the types it may name one of. */
28
+ readonly type: string | readonly string[];
29
29
  /**
30
30
  * The fields of that resource's state the vendor may hold here instead of its `name`, such as
31
31
  * GitHub's `node_id`: a drift check takes any of their values, read in the same run, as the
@@ -85,6 +85,12 @@ export interface ResourceSpec<S extends z.ZodObject = z.ZodObject> {
85
85
  * recreated database looks fine and has lost its data. Default: true for a host, else false.
86
86
  */
87
87
  stateless?: boolean;
88
+ /**
89
+ * True when there is no vendor object behind a declaration: the repo is all there is of it (the
90
+ * runtime's people, `sanoma.person`). Such a type has no operations, so no driver, and drift and
91
+ * apply pass its resources by. Default false.
92
+ */
93
+ vendorless?: boolean;
88
94
  /**
89
95
  * Operations apply calls, in order, right after it creates one of its objects and binds it: a
90
96
  * Supabase project's health, until it is healthy, then its database URL stored.
@@ -156,12 +162,14 @@ type DeclaringAt<T, R extends References, P extends string> = T extends readonly
156
162
  : WithSecrets<T>;
157
163
 
158
164
  type DeclaringField<V, R extends References, P extends string> = P extends keyof R
159
- ? WithSecrets<V> | DeclaredOf<R[P]["type"]>
165
+ ? V extends readonly (infer I)[]
166
+ ? (WithSecrets<I> | DeclaredOf<R[P]["type"]>)[]
167
+ : WithSecrets<V> | DeclaredOf<R[P]["type"]>
160
168
  : [Extract<keyof R, `${P}.${string}`>] extends [never]
161
169
  ? WithSecrets<V>
162
170
  : DeclaringAt<V, R, P>;
163
171
 
164
- type DeclaredOf<Id> = Id extends string ? Declared<Id> : never;
172
+ type DeclaredOf<Id> = Id extends string ? Declared<Id> : Id extends readonly (infer E)[] ? DeclaredOf<E> : never;
165
173
 
166
174
  const importId = z.string().min(1);
167
175
  /** What a driver keeps beside a state (an OpenTofu provider's private data and state version), passed back unchanged. */
@@ -239,6 +247,15 @@ export function isChangeOp(
239
247
  );
240
248
  }
241
249
 
250
+ /**
251
+ * True for an operation of a declared resource type (`read`, `import`, and a writable type's
252
+ * `plan` and change operations), which only drift and apply call: by its vendor and resource.
253
+ */
254
+ export const isResourceOp = (
255
+ op: { readonly vendor: string; readonly resource: string },
256
+ types: ReadonlyMap<string, unknown>,
257
+ ): boolean => types.has(`${op.vendor}.${op.resource}`);
258
+
242
259
  /**
243
260
  * A resource type: call it to declare a resource in a data file, and give it to
244
261
  * `defineConnector` as a group, under its `type`: `defineConnector("github", { repository })`.
@@ -250,7 +267,11 @@ export interface Resource<
250
267
  W extends boolean = boolean,
251
268
  >
252
269
  extends
253
- Readonly<Required<Omit<ResourceSpec<S>, "identity" | "find" | "writable" | "host" | "stateless" | "afterCreate">>>,
270
+ Readonly<
271
+ Required<
272
+ Omit<ResourceSpec<S>, "identity" | "find" | "writable" | "host" | "stateless" | "afterCreate" | "vendorless">
273
+ >
274
+ >,
254
275
  Readonly<Pick<ResourceSpec<S>, "identity" | "find" | "host" | "afterCreate">> {
255
276
  (desired: Declaring<z.input<S>, R>): Declared<Id, z.input<S>>;
256
277
  /** `read` and `import`, each effect `read`, idempotent, with the id as `target`; for a writable type, the change operations too. */
@@ -259,6 +280,8 @@ export interface Resource<
259
280
  readonly writable: W;
260
281
  /** True when apply may recreate or replace its objects as routine: they keep no data. */
261
282
  readonly stateless: boolean;
283
+ /** True when there is no vendor object behind it (`ResourceSpec.vendorless`): it has no operations. */
284
+ readonly vendorless: boolean;
262
285
  readonly [RESOURCE]: true;
263
286
  }
264
287
 
@@ -517,6 +540,8 @@ export function defineResource<
517
540
  const writable = (spec.writable ?? false) as W;
518
541
  const what: `${V}.${T}` = `${vendor}.${type}`;
519
542
  if (host) checkHost(what, host, spec);
543
+ if (spec.vendorless && (writable || host))
544
+ throw new Error(`${what}: a vendorless type has no object to change or host`);
520
545
  const title = spec.title.toLowerCase();
521
546
  const io = opsOf(schema);
522
547
  // The policy's `target` for a create: the type, as no object has an id yet.
@@ -572,7 +597,7 @@ export function defineResource<
572
597
  opaque,
573
598
  },
574
599
  } satisfies Specs<S, "read" | "import">;
575
- const ops = (writable ? { ...base, ...changeOps } : base) as unknown as ResourceOps<S, W>;
600
+ const ops = (spec.vendorless ? {} : writable ? { ...base, ...changeOps } : base) as unknown as ResourceOps<S, W>;
576
601
 
577
602
  const named = new Set(identity?.match(/[A-Za-z_][\w.]*/g));
578
603
  const owned = new Set(fields.vendorOwned.filter((path) => !named.has(path)));
@@ -599,8 +624,9 @@ export function defineResource<
599
624
  const id = value[DECLARED];
600
625
  const wants = references[path];
601
626
  if (wants === undefined) throw new Error(`${what}: ${at} takes a value, not a resource (${id} ${value.name})`);
602
- if (wants.type !== id)
603
- throw new Error(`${what}: ${at} names a resource of type ${wants.type}, not ${id} (${value.name})`);
627
+ const types = [wants.type].flat();
628
+ if (!types.includes(id))
629
+ throw new Error(`${what}: ${at} names a resource of type ${types.join(" or ")}, not ${id} (${value.name})`);
604
630
  if (value.name === undefined)
605
631
  throw new Error(`${what}: ${at} names a ${id}, which has no identity to name it by`);
606
632
  refs[at] = `${id}:${value.name}`;
@@ -652,6 +678,7 @@ export function defineResource<
652
678
  writable,
653
679
  ...(host && { host }),
654
680
  stateless: spec.stateless ?? host !== undefined,
681
+ vendorless: spec.vendorless ?? false,
655
682
  ...(spec.afterCreate && { afterCreate: spec.afterCreate }),
656
683
  ops,
657
684
  // A symbol, so `defineConnector` tells a resource type from a group of operations.
package/src/resources.ts CHANGED
@@ -1,13 +1,31 @@
1
- import { readdirSync, readFileSync, statSync } from "node:fs";
1
+ import { readFileSync, statSync } from "node:fs";
2
2
  import { dirname, join, relative, resolve, sep } from "node:path";
3
3
  import { parsed } from "./ast.ts";
4
- import { rootOf, type SanomaConfig } from "./config.ts";
5
- import { type DataFile, type DataFileExport, type DataValue, readDataFile, Reference } from "./datafile.ts";
4
+ import {
5
+ type DataFile,
6
+ type DataFileExport,
7
+ type DataValue,
8
+ readDataFile,
9
+ Reference,
10
+ SECRET_ENTRIES,
11
+ } from "./datafile.ts";
6
12
  import { type Binding, type Bindings, LOCKFILE, readLockfile } from "./lockfile.ts";
7
13
  import { type Connector, VENDOR } from "./op.ts";
14
+ import { filesUnder } from "./paths.ts";
15
+ import { peopleConnector } from "./person.ts";
8
16
  import type { Span } from "./outline.ts";
9
17
  import { type Declared, type Resource, resourceTypesOf } from "./resource.ts";
10
- import { problemLine } from "./shared.ts";
18
+ import { Principal } from "./define.ts";
19
+ import { NotAdmittedError } from "./errors.ts";
20
+ import {
21
+ AGENTS,
22
+ declaresPeople,
23
+ type PeopleResource,
24
+ personOf,
25
+ principalOf,
26
+ problemLine,
27
+ RUNTIME_AGENTS,
28
+ } from "./shared.ts";
11
29
 
12
30
  /** A resource a data file declares, as read from the file without running it. */
13
31
  export interface DeclaredResource {
@@ -34,6 +52,22 @@ export interface DeclaredResource {
34
52
  refs: Record<string, string>;
35
53
  }
36
54
 
55
+ /**
56
+ * A collection `resources/data.ts` declares, as read without running it: where it is, for the
57
+ * Resources page. Its fields are the collection's own (`describeConfig`'s `collections`), from
58
+ * the same file, which the runtime imports.
59
+ */
60
+ export interface DeclaredCollection {
61
+ /** `<file>#<export>`, as a resource's: `resources/data.ts#outbound`. */
62
+ id: string;
63
+ /** `resources/data.ts`. */
64
+ file: string;
65
+ /** The collection's name, its operations' middle segment (`data.<name>.get`). */
66
+ name: string;
67
+ /** Its `export const` statement, as UTF-16 offsets into the file's text. */
68
+ span: Span;
69
+ }
70
+
37
71
  /** Something wrong in the data files, or in finding them. */
38
72
  export interface ResourceProblem {
39
73
  /**
@@ -56,22 +90,110 @@ export interface DataFiles {
56
90
  bindings: Bindings;
57
91
  /** Each binding whose key no data file declares, by key. */
58
92
  orphans: (Binding & { key: string })[];
93
+ /** The collections `resources/data.ts` declares, in file order. */
94
+ collections: DeclaredCollection[];
59
95
  /** By file, line and column. */
60
96
  problems: ResourceProblem[];
61
97
  }
62
98
 
99
+ /** What the data files declare of the people: the resources `principalOf` takes, and what is wrong in their files. */
100
+ export interface DeclaredPeople {
101
+ people: DeclaredResource[];
102
+ /**
103
+ * The problems in each file that declares (or tries to declare) a person, agent or group, and
104
+ * an id two of them share or one of the runtime's own agents (`RUNTIME_AGENTS`) takes. While
105
+ * there are any, nobody is admitted: a broken file never admits more than it says.
106
+ */
107
+ problems: ResourceProblem[];
108
+ }
109
+
110
+ /** A file that imports the runtime's constructors, `sanoma` (by any local name): the files `readPeople` reads. */
111
+ const DECLARES_PEOPLE = /\{[^}]*\bsanoma\b[^}]*\}\s*from\s*["'](?:sanoma|@sanoma-ai\/workflows)\/resources["']/;
112
+ /** The last read of the people under each root, by what its data files were then (`filesStamp`). */
113
+ const peopleRead = new Map<string, { stamp: string; read: DeclaredPeople }>();
114
+
115
+ /** Each data file under `root` with its size and modification time: unchanged while the files are. */
116
+ const filesStamp = (root: string) =>
117
+ dataFilesUnder(join(root, "resources"))
118
+ .map((path) => {
119
+ // Gone since it was listed (an editor's save by rename): stamped as such, and read next time.
120
+ const stat = statSync(path, { bigint: true, throwIfNoEntry: false });
121
+ return `${path} ${stat?.size} ${stat?.mtimeNs}`;
122
+ })
123
+ .join("\n");
124
+
63
125
  /**
64
- * The resources the config's data files declare, read without importing or running them; throws
65
- * one error listing every problem, each at `file:line:column`, for a test or CI to fail on.
66
- * `describeConfig` lists the same, with the problems beside them instead.
126
+ * The people, agents and groups the data files under `root` declare (`sanoma.person`, `.agent`,
127
+ * `.group`), read without running them, as `principalOf` takes them, with what is wrong in their
128
+ * files; none without a root. Read again only when a data file changed (by its size and time),
129
+ * so a new colleague is admitted once their file is saved, and a request costs a `stat` per file.
67
130
  */
68
- export function readResources(config: SanomaConfig): DeclaredResource[] {
69
- const { resources, problems } = readDataFiles(rootOf(config), config.connectors);
131
+ export function readPeople(root: string | undefined): DeclaredPeople {
132
+ if (root === undefined) return { people: [], problems: [] };
133
+ const stamp = filesStamp(root);
134
+ const cached = peopleRead.get(root);
135
+ if (cached?.stamp === stamp) return cached.read;
136
+ const read = readDataFiles(root, [peopleConnector], undefined, (source) => DECLARES_PEOPLE.test(source));
137
+ const people = read.resources;
138
+ // The people are never bound, so the lockfile's problems are not theirs.
139
+ const own = read.problems.filter((p) => p.file !== LOCKFILE);
140
+ const ids = new Map<string, DeclaredResource>();
141
+ for (const r of people) {
142
+ if (r.type === "group") {
143
+ if (r.name === AGENTS) {
144
+ own.push({
145
+ file: r.file,
146
+ message: `${r.id} is named "${AGENTS}", the group every agent is in: give it another name`,
147
+ });
148
+ }
149
+ continue;
150
+ }
151
+ const id = r.name;
152
+ const other = ids.get(id);
153
+ if (other || RUNTIME_AGENTS.includes(id)) {
154
+ own.push({
155
+ file: r.file,
156
+ message: other
157
+ ? `${r.id} has the id "${id}", which ${other.id} has: give each person and agent their own`
158
+ : `${r.id} has the id "${id}", one of the runtime's own agents': give it another`,
159
+ });
160
+ }
161
+ ids.set(id, r);
162
+ }
163
+ const declared = { people, problems: own };
164
+ peopleRead.set(root, { stamp, read: declared });
165
+ return declared;
166
+ }
167
+
168
+ /**
169
+ * Who `asking` is admitted as, by the people the data files declare (`readPeople`): the declared
170
+ * principal (`principalOf`) of the id it names, a person's or an agent's; for a sign-in
171
+ * (`signIn`), of the person it matches (`personOf`: email, GitHub login, then id), never an agent.
172
+ * While no person is declared, whoever names an id is taken as given. Throws `NotAdmittedError`
173
+ * for anyone else, and for everyone while the people's files have problems.
174
+ */
175
+ export function admit(
176
+ asking: { id?: string; email?: string; github?: string; groups?: string[]; onBehalfOf?: string },
177
+ { people, problems }: { people: readonly PeopleResource[]; problems: readonly ResourceProblem[] },
178
+ signIn = false,
179
+ ): Principal {
180
+ const who = asking.github ?? asking.id ?? asking.email ?? "This sign-in";
70
181
  if (problems.length) {
71
- const lines = problems.map((p) => ` ${problemLine(p)}`);
72
- throw new Error(`The resources' data files have problems:\n${lines.join("\n")}`);
182
+ // The runtime's own agents are declared by no file, so a broken one does not stop them (`check`'s scenarios).
183
+ if (!signIn && asking.id !== undefined && RUNTIME_AGENTS.includes(asking.id)) {
184
+ return principalOf(asking.id, people)!;
185
+ }
186
+ throw new NotAdmittedError(who, problems.map(problemLine));
187
+ }
188
+ if (!declaresPeople(people)) {
189
+ const given = Principal.safeParse(asking).data;
190
+ if (given) return given;
191
+ } else {
192
+ const id = signIn ? personOf(asking, people) : asking.id;
193
+ const admitted = id === undefined ? undefined : principalOf(id, people);
194
+ if (admitted) return admitted;
73
195
  }
74
- return resources;
196
+ throw new NotAdmittedError(who);
75
197
  }
76
198
 
77
199
  interface FileEntry {
@@ -93,14 +215,21 @@ interface FileEntry {
93
215
  * type the connector does not declare, a value the type refuses (a reference where its type
94
216
  * takes none among them), a reference cycle, and two resources of one type with one identity.
95
217
  *
218
+ * The collections `resources/data.ts` declares are listed as they are read (`collections`),
219
+ * never matched to a connector, a resource type or the lockfile: the runtime imports that file.
220
+ *
96
221
  * Each declaration is joined to its binding in the lockfile (`readLockfile`) by its key,
97
222
  * `<file>#<export>`; a binding no export declares is an orphan. Problems: the lockfile's, a
98
223
  * binding of another type than its declaration's, and two declarations bound to one object.
224
+ *
225
+ * `partial` reads part of the files: only those whose source it passes, and in them, an import of
226
+ * a connector not in `connectors`, or of a file left out, is left unread rather than reported.
99
227
  */
100
228
  export function readDataFiles(
101
229
  root: string | undefined,
102
230
  connectors: readonly Connector<any, any>[],
103
231
  types: ReadonlyMap<string, Resource> = resourceTypesOf(connectors),
232
+ partial?: (source: string) => boolean,
104
233
  ): DataFiles {
105
234
  const problems: ResourceProblem[] = [];
106
235
  if (root === undefined) {
@@ -108,15 +237,20 @@ export function readDataFiles(
108
237
  message:
109
238
  "The config has no root, so its data files cannot be found: make it with defineConfig, which records its file, or set its `root`",
110
239
  });
111
- return { resources: [], bindings: {}, orphans: [], problems };
240
+ return { resources: [], bindings: {}, orphans: [], collections: [], problems };
112
241
  }
113
242
  const lock = readLockfile(root);
114
243
  problems.push(...lock.problems);
115
244
 
116
245
  const files = new Map<string, FileEntry>();
246
+ const left = new Set<string>();
117
247
  for (const path of dataFilesUnder(join(root, "resources"))) {
118
248
  const rel = relative(root, path).split(sep).join("/");
119
249
  const source = readFileSync(path, "utf8");
250
+ if (partial && !partial(source)) {
251
+ left.add(path);
252
+ continue;
253
+ }
120
254
  const { program, problems: syntax, at } = parsed(source, path);
121
255
  const data = readDataFile(program, path, join(root, "resources"));
122
256
  const problem = (offset: number, message: string) => problems.push({ file: rel, ...at(offset), message });
@@ -131,6 +265,8 @@ export function readDataFiles(
131
265
  const { id, info } = connector[VENDOR];
132
266
  if (info?.package) entries.set(`${info.package}/resources`, id);
133
267
  }
268
+ // The runtime's own (`peopleConnector`, package `sanoma`) is imported from either name of its entry.
269
+ if (entries.get("sanoma/resources") === "sanoma") for (const entry of SECRET_ENTRIES) entries.set(entry, "sanoma");
134
270
 
135
271
  // What each file's names stand for, and every resource read by its id.
136
272
  const vendors = new Map<FileEntry, Map<string, string>>();
@@ -142,6 +278,7 @@ export function readDataFiles(
142
278
  for (const imp of file.data.imports) {
143
279
  if (imp.kind === "constructors") {
144
280
  const vendor = entries.get(imp.source);
281
+ if (vendor === undefined && partial) continue;
145
282
  if (vendor === undefined) {
146
283
  file.problem(
147
284
  imp.start,
@@ -157,7 +294,9 @@ export function readDataFiles(
157
294
  }
158
295
  continue;
159
296
  }
160
- const other = files.get(resolve(dirname(path), imp.source));
297
+ const target = resolve(dirname(path), imp.source);
298
+ const other = files.get(target);
299
+ if (!other && left.has(target)) continue;
161
300
  if (!other) {
162
301
  file.problem(imp.start, `"${imp.source}" is not a data file under resources/`);
163
302
  } else if (!other.data.declared.includes(imp.imported)) {
@@ -293,19 +432,21 @@ export function readDataFiles(
293
432
  refs: result.refs,
294
433
  });
295
434
  }
435
+ const collections = [...files.values()].flatMap(({ rel, data }) =>
436
+ data.collections.map((c) => ({
437
+ id: `${rel}#${c.export}`,
438
+ file: rel,
439
+ name: c.name,
440
+ span: [c.start, c.end] as Span,
441
+ })),
442
+ );
296
443
  problems.sort(
297
444
  (a, b) =>
298
445
  (a.file ?? "").localeCompare(b.file ?? "") || (a.line ?? 0) - (b.line ?? 0) || (a.column ?? 0) - (b.column ?? 0),
299
446
  );
300
- return { resources, bindings: lock.bindings, orphans, problems };
447
+ return { resources, bindings: lock.bindings, orphans, collections, problems };
301
448
  }
302
449
 
303
- /** The data files under `dir`, sorted: regular `.ts` files, less tests, declarations and `node_modules`; none when `dir` is not a directory. */
304
- function dataFilesUnder(dir: string): string[] {
305
- if (!statSync(dir, { throwIfNoEntry: false })?.isDirectory()) return [];
306
- return readdirSync(dir, { recursive: true, withFileTypes: true })
307
- .filter((d) => d.isFile() && d.name.endsWith(".ts") && !d.name.endsWith(".test.ts") && !d.name.endsWith(".d.ts"))
308
- .map((d) => join(d.parentPath, d.name))
309
- .filter((path) => !relative(dir, path).split(sep).includes("node_modules"))
310
- .toSorted();
311
- }
450
+ /** The data files under `dir`, sorted: regular `.ts` files, less tests, declarations and `node_modules`. */
451
+ const dataFilesUnder = (dir: string): string[] =>
452
+ filesUnder(dir, (name) => name.endsWith(".ts") && !name.endsWith(".test.ts") && !name.endsWith(".d.ts"));
package/src/run.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
- import type { ApprovalState, Principal, WorkflowDefinition } from "./define.ts";
2
+ import type { Collection } from "./collection.ts";
3
+ import type { ApprovalState, Approver, Principal, WorkflowDefinition } from "./define.ts";
3
4
  import type { Fake } from "./fake.ts";
4
5
  import type { LedgerStore } from "./ledger.ts";
5
6
  import type { DriverFn, Op } from "./op.ts";
@@ -16,6 +17,18 @@ export interface RunArgs {
16
17
  startedBy: Principal;
17
18
  /** The scenario a sandbox run is seeded from; the run calls the fakes instead of the drivers. */
18
19
  sandbox?: string;
20
+ /**
21
+ * The policy's hold on the start (`workflow:<name>`), as `SanomaClient.start` was answered: the
22
+ * run waits for this approval before its body runs.
23
+ */
24
+ hold?: StartHold;
25
+ }
26
+
27
+ /** A start the policy held: whom it asks, the approval's title, and the rules that held it. */
28
+ export interface StartHold {
29
+ approver: Approver;
30
+ title: string;
31
+ reasons?: string[];
19
32
  }
20
33
 
21
34
  /**
@@ -37,9 +50,13 @@ export interface WorkerState {
37
50
  /** The worker's `source`, when it was given one. */
38
51
  source?: Source;
39
52
  types: ReadonlyMap<string, Resource>;
53
+ /** The collections, by name, which scenarios' steps may name (`data.<name>`). */
54
+ collections: ReadonlyMap<string, Collection>;
40
55
  steps: readonly Steps[];
41
56
  /** Its `version`, when it has one, is recorded with each of its decisions. */
42
57
  policy: Policy;
58
+ /** The config's directory, whose data files declare the people an approval's decider is checked against (`readPeople`). */
59
+ root?: string;
43
60
  ledger: LedgerStore;
44
61
  /** The stored secrets, for a live run's drivers (`CallContext.secrets`). */
45
62
  secrets: Secrets;