@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/index.ts CHANGED
@@ -47,6 +47,9 @@ export {
47
47
  withoutWriteOnly,
48
48
  } from "./resource.ts";
49
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";
50
53
  export { type CredentialStatus, credentialsOf } from "./env.ts";
51
54
  export {
52
55
  APPLY_WORKFLOW,
@@ -57,6 +60,8 @@ export {
57
60
  type PlanAnswer,
58
61
  type PlanStep,
59
62
  } from "./apply.ts";
63
+ export { CALL_WORKFLOW, CallInput } from "./call-workflow.ts";
64
+ export { isDirect } from "./direct.ts";
60
65
  export {
61
66
  DRIFT_WORKFLOW,
62
67
  type DriftDeclared,
@@ -73,20 +78,32 @@ export {
73
78
  approve,
74
79
  approvedFor,
75
80
  type Decision,
76
- definePolicy,
77
81
  deny,
78
82
  type Policy,
79
83
  type PolicyCall,
80
84
  type PolicyOp,
81
85
  policyOpOf,
86
+ type PreviewAnswer,
87
+ type PreviewRequest,
82
88
  type RecordedDecision,
83
89
  } from "./policy.ts";
84
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,
85
101
  errorCode,
86
102
  type ErrorCode,
87
103
  type ErrorInfo,
88
104
  type InputIssue,
89
105
  invalidInput,
106
+ NotAdmittedError,
90
107
  PolicyDeniedError,
91
108
  RejectedError,
92
109
  SanomaError,
@@ -116,12 +133,26 @@ export {
116
133
  } from "./secret.ts";
117
134
  export { resolveRevision, SOURCE_RESOLVE, type Source } from "./source.ts";
118
135
  export type { HostCapabilities, HostLifetime, HostOutputs, HostSpec } from "./host.ts";
119
- export { SanomaClient, type RunsFilter, type RunSummary, type StartOptions, type StoredSecret } from "./client.ts";
136
+ export {
137
+ type CallOptions,
138
+ SanomaClient,
139
+ type RunsFilter,
140
+ type RunSummary,
141
+ type StartOptions,
142
+ type StoredSecret,
143
+ } from "./client.ts";
120
144
  // A raw DBOS client on the runtime's database, for tests and tools that read DBOS directly.
121
145
  export { dbosClient } from "./db.ts";
122
146
  // Also at `@sanoma-ai/workflows/shared`, which a browser bundle can import.
123
147
  export {
148
+ AGENTS,
124
149
  approverLabel,
150
+ approverOf,
151
+ declaresPeople,
152
+ type PeopleResource,
153
+ personOf,
154
+ principalOf,
155
+ RUNTIME_AGENTS,
125
156
  credentialReady,
126
157
  DEFAULT_SLEEP_NAME,
127
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
+ }
package/src/op.ts CHANGED
@@ -238,6 +238,12 @@ export interface CallContext {
238
238
  attempt: number;
239
239
  /** The secrets store, for the values `secret()` markers name: `resolveSecrets(input, call.secrets)`. In memory for a fake. */
240
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;
241
247
  }
242
248
 
243
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
+ }
package/src/person.ts ADDED
@@ -0,0 +1,75 @@
1
+ import { z } from "zod";
2
+ import { defineConnector } from "./op.ts";
3
+ import { defineResource } from "./resource.ts";
4
+
5
+ // Who exists in a company: people, agents and groups, declared in data files like any resource,
6
+ // as the runtime's own vendor's types (`sanoma.person`, `sanoma.agent`, `sanoma.group`, imported
7
+ // from "sanoma/resources"). They are `vendorless`: the repo is all there is of them, so they have
8
+ // no driver and no drift. A membership is written on a group only, its members references to the
9
+ // declared people and agents; every agent is in the group `agents` besides. Who a principal is,
10
+ // read from them, is `principalOf` (`shared.ts`).
11
+
12
+ /** An id or a group's name: one word, as a scenario names an approver. */
13
+ const WORD = z.string().regex(/^\S+$/, 'is one word, such as "ada"');
14
+ const NONE = { immutable: [], vendorOwned: [], writeOnly: [] };
15
+
16
+ const person = defineResource({
17
+ vendor: "sanoma",
18
+ type: "person",
19
+ title: "Person",
20
+ identity: "id",
21
+ schema: z.object({
22
+ id: WORD,
23
+ name: z.string().optional(),
24
+ /** Matched first by a sign-in that vouches for an email (GitHub's, its verified public one). */
25
+ email: z.email().optional(),
26
+ /** Their GitHub login, which GitHub sign-in matches, ignoring case, when no email does. */
27
+ github: z
28
+ .string()
29
+ .regex(/^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/, "is a GitHub login, such as ada-ortiz")
30
+ .optional(),
31
+ }),
32
+ fields: NONE,
33
+ find: ({ id }) => id,
34
+ vendorless: true,
35
+ });
36
+
37
+ const agent = defineResource({
38
+ vendor: "sanoma",
39
+ type: "agent",
40
+ title: "Agent",
41
+ identity: "id",
42
+ schema: z.object({ id: WORD, name: z.string().optional(), onBehalfOf: WORD.optional() }),
43
+ fields: { ...NONE, references: { onBehalfOf: { type: "sanoma.person" } } },
44
+ find: ({ id }) => id,
45
+ vendorless: true,
46
+ });
47
+
48
+ const group = defineResource({
49
+ vendor: "sanoma",
50
+ type: "group",
51
+ title: "Group",
52
+ identity: "name",
53
+ schema: z.object({ name: WORD, members: z.array(WORD) }),
54
+ fields: {
55
+ ...NONE,
56
+ unordered: ["members"],
57
+ references: { members: { type: ["sanoma.person", "sanoma.agent"] } },
58
+ },
59
+ find: ({ name }) => name,
60
+ vendorless: true,
61
+ });
62
+
63
+ /**
64
+ * What a data file declares the company's people with, imported from "sanoma/resources":
65
+ * `export const ada = sanoma.person({ id: "ada", name: "Ada Ortiz", github: "ada-ortiz" })`,
66
+ * `sanoma.agent({ id: "refund-bot", onBehalfOf: bo })` and
67
+ * `sanoma.group({ name: "finance", members: [ada, refundBot] })`.
68
+ */
69
+ export const sanoma = { person, agent, group };
70
+
71
+ /**
72
+ * The runtime's own connector of the people types: every config's, so its data files may declare
73
+ * them. Its package is `sanoma`, whose `/resources` entry exports `sanoma`.
74
+ */
75
+ export const peopleConnector = defineConnector("sanoma", sanoma, { title: "Sanoma", package: "sanoma" });
package/src/plugin.ts CHANGED
@@ -1,15 +1,67 @@
1
1
  import { readDataFile } from "./datafile.ts";
2
2
 
3
- // An oxlint JS plugin with one rule, `sanoma/data-file`: the data-file subset `readResources`
4
- // reads, the same check as `lintResources`, on the tree oxlint has already parsed. The
5
- // package's `oxlint.json` turns it on for `resources/**`. Typed here by the little it uses, so
6
- // the package needs no oxlint types.
3
+ // An oxlint JS plugin with two rules, on the trees oxlint has already parsed, which the package's
4
+ // `oxlint.json` turns on: `sanoma/data-file` for `resources/**`, the data-file subset
5
+ // `readResources` reads, the same check as `lintResources`; and `sanoma/date` for `workflows/**`,
6
+ // `Date` reading the clock. Typed here by the little they use, so the package needs no oxlint types.
7
7
 
8
8
  interface Context {
9
9
  filename: string;
10
+ sourceCode: { isGlobalReference(node: Node): boolean };
10
11
  report(diagnostic: { message: string; node: { range: [number, number] } }): void;
11
12
  }
12
13
 
14
+ /** A node of oxlint's ESTree, by the little the `date` rule reads. */
15
+ interface Node {
16
+ type: string;
17
+ range: [number, number];
18
+ parent?: Node;
19
+ name?: string;
20
+ callee?: Node;
21
+ object?: Node;
22
+ property?: Node;
23
+ computed?: boolean;
24
+ arguments?: Node[];
25
+ operator?: string;
26
+ right?: Node;
27
+ }
28
+
29
+ const DATE_MESSAGE =
30
+ "Workflows are replayed, so a workflow reads the clock with `await ctx.now()`, never `Date`: `new Date(await ctx.now())` for a date, `new Date(value)`, `Date.parse` or `Date.UTC` for one it has, and `ctx.sleep({ until })` to wait for a date.";
31
+
32
+ /** Where `Date` names the type, which the build erases: `d: Date`, `typeof Date`, `implements Date`. */
33
+ const TYPE_PARENTS = new Set([
34
+ "TSTypeReference",
35
+ "TSTypeQuery",
36
+ "TSQualifiedName",
37
+ "TSClassImplements",
38
+ "TSInterfaceHeritage",
39
+ ]);
40
+
41
+ /** `Date`'s functions that read no clock. */
42
+ const PURE = new Set(["parse", "UTC"]);
43
+
44
+ /**
45
+ * Whether `Date` (`node`) reads no clock where it is: `new Date(value)`, `Date.parse(…)`,
46
+ * `Date.UTC(…)`, `x instanceof Date`, or a type. A node without its parent is reported.
47
+ */
48
+ function readsNoClock(node: Node): boolean {
49
+ const { parent } = node;
50
+ if (!parent) return false;
51
+ if (TYPE_PARENTS.has(parent.type)) return true;
52
+ if (parent.type === "BinaryExpression") return parent.operator === "instanceof" && parent.right === node;
53
+ if (parent.type === "NewExpression" && parent.callee === node) {
54
+ const [first] = parent.arguments ?? [];
55
+ return first !== undefined && first.type !== "SpreadElement";
56
+ }
57
+ return (
58
+ parent.type === "MemberExpression" &&
59
+ parent.object === node &&
60
+ !parent.computed &&
61
+ PURE.has(parent.property?.name ?? "")
62
+ );
63
+ }
64
+
13
65
  const plugin = {
14
66
  meta: { name: "sanoma" },
15
67
  rules: {
@@ -18,7 +70,7 @@ const plugin = {
18
70
  type: "problem",
19
71
  docs: {
20
72
  description:
21
- "A data file under resources/ holds only imports, `export const <name> = <vendor>.<type>({ … })` with literal fields, and `export default [ … ]`",
73
+ "A data file under resources/ holds only imports, `export const <name> = <vendor>.<type>({ … })` with literal fields, and `export default [ … ]`; resources/data.ts holds only collections, `collection({ … })` from sanoma/data",
22
74
  },
23
75
  },
24
76
  create(context: Context) {
@@ -31,6 +83,23 @@ const plugin = {
31
83
  };
32
84
  },
33
85
  },
86
+ // `Date` reads the clock in `Date.now()`, `new Date()` and `Date()`, which a replay would read
87
+ // again; a workflow reads it with `ctx.now()`, recorded. Every other use of `Date` is pure:
88
+ // `new Date(value)` (of `await ctx.now()`, or of a date the workflow has), `Date.parse`, `Date.UTC`.
89
+ date: {
90
+ meta: {
91
+ type: "problem",
92
+ docs: { description: "A workflow reads the clock with `await ctx.now()`, never `Date`" },
93
+ },
94
+ create(context: Context) {
95
+ return {
96
+ Identifier(node: Node) {
97
+ if (node.name !== "Date" || readsNoClock(node)) return;
98
+ if (context.sourceCode.isGlobalReference(node)) context.report({ message: DATE_MESSAGE, node });
99
+ },
100
+ };
101
+ },
102
+ },
34
103
  },
35
104
  };
36
105