@sanoma-ai/workflows 0.6.0 → 0.7.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 (166) hide show
  1. package/README.md +52 -14
  2. package/dist/apply.d.ts +2 -1
  3. package/dist/apply.js +29 -7
  4. package/dist/apply.js.map +1 -1
  5. package/dist/approvals.d.ts +27 -2
  6. package/dist/approvals.js +73 -9
  7. package/dist/approvals.js.map +1 -1
  8. package/dist/call.d.ts +3 -2
  9. package/dist/call.js +38 -30
  10. package/dist/call.js.map +1 -1
  11. package/dist/cedar.js +24 -9
  12. package/dist/cedar.js.map +1 -1
  13. package/dist/change.d.ts +219 -0
  14. package/dist/change.js +150 -0
  15. package/dist/change.js.map +1 -0
  16. package/dist/chats.d.ts +43 -0
  17. package/dist/chats.js +48 -0
  18. package/dist/chats.js.map +1 -0
  19. package/dist/client.d.ts +52 -6
  20. package/dist/client.js +88 -47
  21. package/dist/client.js.map +1 -1
  22. package/dist/config.d.ts +75 -8
  23. package/dist/config.js +103 -13
  24. package/dist/config.js.map +1 -1
  25. package/dist/constructors.d.ts +37 -1
  26. package/dist/constructors.js +6 -2
  27. package/dist/constructors.js.map +1 -1
  28. package/dist/database.d.ts +83 -0
  29. package/dist/database.js +92 -0
  30. package/dist/database.js.map +1 -0
  31. package/dist/db.d.ts +19 -1
  32. package/dist/db.js +51 -7
  33. package/dist/db.js.map +1 -1
  34. package/dist/deadlines.d.ts +91 -0
  35. package/dist/deadlines.js +165 -0
  36. package/dist/deadlines.js.map +1 -0
  37. package/dist/define.d.ts +25 -4
  38. package/dist/define.js +40 -0
  39. package/dist/define.js.map +1 -1
  40. package/dist/demo.d.ts +28 -0
  41. package/dist/demo.js +72 -0
  42. package/dist/demo.js.map +1 -0
  43. package/dist/describe.d.ts +5 -2
  44. package/dist/describe.js +10 -6
  45. package/dist/describe.js.map +1 -1
  46. package/dist/errors.d.ts +10 -1
  47. package/dist/errors.js +13 -0
  48. package/dist/errors.js.map +1 -1
  49. package/dist/fake.d.ts +34 -0
  50. package/dist/fake.js +134 -1
  51. package/dist/fake.js.map +1 -1
  52. package/dist/grid.d.ts +34 -0
  53. package/dist/grid.js +62 -0
  54. package/dist/grid.js.map +1 -0
  55. package/dist/host.d.ts +12 -2
  56. package/dist/host.js +2 -2
  57. package/dist/host.js.map +1 -1
  58. package/dist/index.d.ts +15 -8
  59. package/dist/index.js +10 -5
  60. package/dist/index.js.map +1 -1
  61. package/dist/ledger.d.ts +11 -2
  62. package/dist/ledger.js.map +1 -1
  63. package/dist/lint.js +2 -2
  64. package/dist/lint.js.map +1 -1
  65. package/dist/op.d.ts +21 -2
  66. package/dist/op.js.map +1 -1
  67. package/dist/person.d.ts +5 -0
  68. package/dist/person.js +3 -1
  69. package/dist/person.js.map +1 -1
  70. package/dist/policy.d.ts +7 -1
  71. package/dist/policy.js +2 -1
  72. package/dist/policy.js.map +1 -1
  73. package/dist/resource.d.ts +13 -1
  74. package/dist/resource.js +9 -1
  75. package/dist/resource.js.map +1 -1
  76. package/dist/resources.d.ts +2 -0
  77. package/dist/resources.js +15 -4
  78. package/dist/resources.js.map +1 -1
  79. package/dist/run.d.ts +5 -0
  80. package/dist/run.js.map +1 -1
  81. package/dist/sandbox.d.ts +38 -10
  82. package/dist/sandbox.js +66 -35
  83. package/dist/sandbox.js.map +1 -1
  84. package/dist/scenario.d.ts +18 -1
  85. package/dist/scenario.js +77 -27
  86. package/dist/scenario.js.map +1 -1
  87. package/dist/scenarios.d.ts +10 -1
  88. package/dist/scenarios.js +19 -4
  89. package/dist/scenarios.js.map +1 -1
  90. package/dist/schedules.d.ts +66 -0
  91. package/dist/schedules.js +158 -0
  92. package/dist/schedules.js.map +1 -0
  93. package/dist/schema.d.ts +6 -0
  94. package/dist/schema.js +13 -3
  95. package/dist/schema.js.map +1 -1
  96. package/dist/shared.d.ts +30 -2
  97. package/dist/shared.js +42 -4
  98. package/dist/shared.js.map +1 -1
  99. package/dist/source.d.ts +185 -2
  100. package/dist/source.js +9 -0
  101. package/dist/source.js.map +1 -1
  102. package/dist/supervisor.d.ts +39 -0
  103. package/dist/supervisor.js +68 -0
  104. package/dist/supervisor.js.map +1 -0
  105. package/dist/version.d.ts +3 -1
  106. package/dist/version.js +3 -1
  107. package/dist/version.js.map +1 -1
  108. package/dist/wakeup-driver.d.ts +128 -0
  109. package/dist/wakeup-driver.js +216 -0
  110. package/dist/wakeup-driver.js.map +1 -0
  111. package/dist/wakeup-service.d.ts +83 -0
  112. package/dist/wakeup-service.js +415 -0
  113. package/dist/wakeup-service.js.map +1 -0
  114. package/dist/wakeup-type.d.ts +43 -0
  115. package/dist/wakeup-type.js +80 -0
  116. package/dist/wakeup-type.js.map +1 -0
  117. package/dist/wakeup.d.ts +77 -0
  118. package/dist/wakeup.js +78 -0
  119. package/dist/wakeup.js.map +1 -0
  120. package/dist/worker.d.ts +39 -1
  121. package/dist/worker.js +125 -38
  122. package/dist/worker.js.map +1 -1
  123. package/oxlint.json +3 -1
  124. package/package.json +10 -2
  125. package/src/apply.ts +25 -6
  126. package/src/approvals.ts +89 -16
  127. package/src/call.ts +40 -29
  128. package/src/cedar.ts +24 -6
  129. package/src/change.ts +222 -0
  130. package/src/chats.ts +114 -0
  131. package/src/client.ts +145 -55
  132. package/src/config.ts +149 -21
  133. package/src/constructors.ts +8 -2
  134. package/src/database.ts +152 -0
  135. package/src/db.ts +58 -9
  136. package/src/deadlines.ts +228 -0
  137. package/src/define.ts +71 -4
  138. package/src/demo.ts +87 -0
  139. package/src/describe.ts +18 -6
  140. package/src/errors.ts +14 -0
  141. package/src/fake.ts +149 -0
  142. package/src/grid.ts +100 -0
  143. package/src/host.ts +12 -4
  144. package/src/index.ts +66 -3
  145. package/src/ledger.ts +15 -3
  146. package/src/lint.ts +2 -2
  147. package/src/op.ts +22 -2
  148. package/src/person.ts +4 -1
  149. package/src/policy.ts +7 -1
  150. package/src/resource.ts +22 -3
  151. package/src/resources.ts +17 -4
  152. package/src/run.ts +5 -0
  153. package/src/sandbox.ts +97 -37
  154. package/src/scenario.ts +97 -28
  155. package/src/scenarios.ts +21 -5
  156. package/src/schedules.ts +206 -0
  157. package/src/schema.ts +16 -3
  158. package/src/shared.ts +51 -4
  159. package/src/source.ts +176 -3
  160. package/src/supervisor.ts +102 -0
  161. package/src/version.ts +3 -1
  162. package/src/wakeup-driver.ts +270 -0
  163. package/src/wakeup-service.ts +504 -0
  164. package/src/wakeup-type.ts +90 -0
  165. package/src/wakeup.ts +139 -0
  166. package/src/worker.ts +176 -43
@@ -0,0 +1,206 @@
1
+ import { DBOS, type ScheduledWorkflowFn, type WorkflowSchedule } from "@dbos-inc/dbos-sdk";
2
+ import { createRequire } from "node:module";
3
+ import { dirname, join } from "node:path";
4
+ import type { ResolvedConfig } from "./config.ts";
5
+ import { type Db, WAKEUP_TABLE } from "./db.ts";
6
+ import type { Principal, WorkflowDefinition } from "./define.ts";
7
+ import { readPeople, admit } from "./resources.ts";
8
+ import type { WorkerState } from "./run.ts";
9
+ import { errorMessage, SCHEDULE_AGENT } from "./shared.ts";
10
+
11
+ // Server-only: the worker's side of `trigger: { schedule, timezone? }`. DBOS fires the schedules
12
+ // (its scheduler, `DBOS.applySchedules`); this names them, keeps them to the config's, fires a
13
+ // tick missed while the instance was stopped, and decides each scheduled start.
14
+
15
+ /** A workflow on a schedule: its cron expression of five fields, and the IANA time zone it is read in. */
16
+ export interface ScheduledWorkflow {
17
+ name: string;
18
+ schedule: string;
19
+ /** UTC when the trigger names none: DBOS would read the cron in the process's own zone. */
20
+ timezone: string;
21
+ }
22
+
23
+ /** The config's workflows on a schedule, in the config's order. */
24
+ export const scheduledWorkflows = ({ workflows }: Pick<ResolvedConfig, "workflows">): ScheduledWorkflow[] =>
25
+ [...workflows.values()].flatMap(({ name, trigger }) =>
26
+ trigger === "manual" ? [] : [{ name, schedule: trigger.schedule, timezone: trigger.timezone ?? "UTC" }],
27
+ );
28
+
29
+ /**
30
+ * A workflow's schedule's name in DBOS: `<appName>/<workflow>`. Names are unique across every app
31
+ * on the system database, so the app's name comes first, and the app's own are those under it.
32
+ */
33
+ const scheduleName = (appName: string, workflow: string): string => `${appName}/${workflow}`;
34
+
35
+ /**
36
+ * Makes DBOS's schedules of the app the config's: one per scheduled workflow (`scheduleName`),
37
+ * firing it by its registered function in `registry` on the app's queue, with DBOS's automatic
38
+ * backfill (from the schedule's last fire); every other schedule under the app's name is deleted.
39
+ * Each keeps, as its context, from when its ticks are due (`since`). Then, when the instance last
40
+ * stopped (`sanoma_wakeup.stopped_at`), it fires once each tick missed since, of a schedule that
41
+ * has never fired, which DBOS's backfill leaves out, never one from before `since`, and moves
42
+ * `since` to now. A repeat is harmless anyway: a tick's run id is its time, so DBOS starts it once.
43
+ * Only the app's latest version calls this (`startWorker`): an older one must not undo a newer's.
44
+ */
45
+ export async function applyScheduled(
46
+ resolved: Pick<ResolvedConfig, "appName" | "queueName" | "workflows">,
47
+ registry: ReadonlyMap<string, ScheduledWorkflowFn>,
48
+ db: () => Promise<Db>,
49
+ ): Promise<void> {
50
+ const { appName, queueName } = resolved;
51
+ const before = new Map(
52
+ (await DBOS.listSchedules({ scheduleNamePrefix: scheduleName(appName, "") })).map((s) => [s.scheduleName, s]),
53
+ );
54
+ const now = new Date();
55
+ const declared = scheduledWorkflows(resolved).map(({ name, schedule, timezone }) => {
56
+ const named = scheduleName(appName, name);
57
+ // A schedule whose cron or zone changed is a new one: its ticks before now were never due.
58
+ const prior = before.get(named);
59
+ const same = prior?.schedule === schedule && (prior.cronTimezone ?? "UTC") === timezone;
60
+ const since = (same && sinceOf(prior)) || now;
61
+ const context: ScheduleContext = { since: since.toISOString() };
62
+ return {
63
+ since,
64
+ schedule: {
65
+ scheduleName: named,
66
+ workflowFn: registry.get(name)!,
67
+ schedule,
68
+ cronTimezone: timezone,
69
+ automaticBackfill: true,
70
+ queueName,
71
+ context,
72
+ },
73
+ };
74
+ });
75
+ if (declared.length) await DBOS.applySchedules(declared.map((d) => d.schedule));
76
+ const names = new Set(declared.map((d) => d.schedule.scheduleName));
77
+ await Promise.all([...before.keys()].filter((name) => !names.has(name)).map((name) => DBOS.deleteSchedule(name)));
78
+ const unfired = declared.filter((d) => before.get(d.schedule.scheduleName)?.lastFiredAt === null);
79
+ if (!unfired.length) return;
80
+ const stoppedAt = await lastStopped(await db());
81
+ if (!stoppedAt) return;
82
+ for (const { schedule, since } of unfired) {
83
+ const from = stoppedAt > since ? stoppedAt : since;
84
+ if (from >= now) continue;
85
+ await DBOS.backfillSchedule(schedule.scheduleName, from, now);
86
+ // Fired up to now: the next start backfills from here, not over the same ticks again.
87
+ const covered: ScheduleContext = { since: now.toISOString() };
88
+ await DBOS.updateSchedule(schedule.scheduleName, { context: covered });
89
+ }
90
+ }
91
+
92
+ /**
93
+ * What a schedule keeps in DBOS's `context`: from when its ticks are the runtime's to fire, ISO
94
+ * 8601: when it was applied with its cron and zone, or the end of its last backfill.
95
+ */
96
+ interface ScheduleContext {
97
+ since: string;
98
+ }
99
+
100
+ /** When the schedule was first applied, from its context; undefined for none, or one without it. */
101
+ function sinceOf(schedule: WorkflowSchedule | undefined): Date | undefined {
102
+ const since = (schedule?.context as Partial<ScheduleContext> | null | undefined)?.since;
103
+ const at = typeof since === "string" ? new Date(since) : undefined;
104
+ return at && !Number.isNaN(at.getTime()) ? at : undefined;
105
+ }
106
+
107
+ /** When the instance's worker last stopped, from the wakeup's row; undefined while it never has. */
108
+ async function lastStopped(db: Db): Promise<Date | undefined> {
109
+ const { rows } = await db.query<{ stopped_at: Date | null }>(`SELECT stopped_at FROM ${WAKEUP_TABLE} WHERE id = 1`);
110
+ return rows[0]?.stopped_at ?? undefined;
111
+ }
112
+
113
+ /** Who starts a scheduled run, as `scheduledStarter` admitted them, or why they were not. */
114
+ export interface ScheduledStarter {
115
+ startedBy: Principal;
116
+ refused?: string;
117
+ }
118
+
119
+ /**
120
+ * Who starts a scheduled run: the runtime's `schedule` agent as the data files declare it
121
+ * (`admit`), in a step, since the files may change before a replay. A refusal is the step's
122
+ * answer, for the run to throw once it has started, so the ledger records it; the run starts as
123
+ * the agent alone then. A failure of the step itself (the worker stopping) is thrown as it is.
124
+ */
125
+ export async function scheduledStarter(state: Pick<WorkerState, "root">): Promise<ScheduledStarter> {
126
+ const admitted = await DBOS.runStep(
127
+ async (): Promise<{ startedBy: Principal } | { refused: string }> => {
128
+ try {
129
+ return { startedBy: admit({ id: SCHEDULE_AGENT }, readPeople(state.root)) };
130
+ } catch (err) {
131
+ return { refused: errorMessage(err) };
132
+ }
133
+ },
134
+ { name: "schedule:starter" },
135
+ );
136
+ return "refused" in admitted ? { startedBy: { id: SCHEDULE_AGENT }, refused: admitted.refused } : admitted;
137
+ }
138
+
139
+ /** DBOS's cron parser (`TimeMatcher`), as its scheduler and backfill use it. */
140
+ interface TimeMatcher {
141
+ match(date: Date | number): boolean;
142
+ /** The first instant strictly after `date` that matches; one that does not at the end of its search horizon. */
143
+ nextWakeupTime(date: Date | number): Date;
144
+ }
145
+ type TimeMatcherClass = new (pattern: string, timezone?: string) => TimeMatcher;
146
+
147
+ let matcherClass: TimeMatcherClass | undefined;
148
+
149
+ /**
150
+ * DBOS's `TimeMatcher`, from `dist/src/scheduler/crontab.js` beside its main module: the package's
151
+ * exports leave it out (dbos-sdk 5.2.11), so it is loaded by path, and a test holds it there.
152
+ */
153
+ export function timeMatcher(): TimeMatcherClass {
154
+ if (matcherClass) return matcherClass;
155
+ const require = createRequire(import.meta.url);
156
+ const file = join(dirname(require.resolve("@dbos-inc/dbos-sdk")), "scheduler", "crontab.js");
157
+ const loaded = (require(file) as { TimeMatcher?: unknown }).TimeMatcher;
158
+ if (typeof loaded !== "function") throw new Error(`DBOS's cron parser is not in ${file} any more`);
159
+ return (matcherClass = loaded as TimeMatcherClass);
160
+ }
161
+
162
+ /**
163
+ * When `schedule` (a cron expression read in `timezone`, UTC without one) fires next, strictly
164
+ * after `from`: as DBOS's scheduler finds it. Throws for a schedule that never fires.
165
+ */
166
+ export function nextFire(schedule: string, timezone: string | undefined, from: Date): Date {
167
+ const matcher = new (timeMatcher())(schedule, timezone ?? "UTC");
168
+ // Past a horizon of a century DBOS answers an instant that does not match, as its scheduler
169
+ // would wait for it and look again: a few looks find any schedule that ever fires.
170
+ let at = from.getTime();
171
+ for (let look = 0; look < 4; look++) {
172
+ const next = matcher.nextWakeupTime(at);
173
+ if (matcher.match(next)) return next;
174
+ at = next.getTime();
175
+ }
176
+ throw new Error("it never fires");
177
+ }
178
+
179
+ /**
180
+ * Throws when DBOS could not run the workflow's schedule: a cron its parser refuses (a minute of
181
+ * 61), or one that never fires (30 February). `resolveConfig` runs it, so the worker, the app and
182
+ * `sanoma check` refuse the config before any schedule is applied.
183
+ */
184
+ export function checkSchedule({ name, trigger }: Pick<WorkflowDefinition, "name" | "trigger">): void {
185
+ if (trigger === "manual") return;
186
+ try {
187
+ nextFire(trigger.schedule, trigger.timezone, new Date());
188
+ } catch (err) {
189
+ throw new Error(`Workflow "${name}": DBOS cannot run its schedule "${trigger.schedule}": ${errorMessage(err)}`, {
190
+ cause: err,
191
+ });
192
+ }
193
+ }
194
+
195
+ /**
196
+ * The earliest next fire, strictly after `from`, of the config's scheduled workflows; undefined
197
+ * when none is on a schedule: the deadline a schedule sets for a worker that stops when quiet.
198
+ */
199
+ export function nextScheduledFire(resolved: Pick<ResolvedConfig, "workflows">, from: Date): Date | undefined {
200
+ let earliest: Date | undefined;
201
+ for (const { schedule, timezone } of scheduledWorkflows(resolved)) {
202
+ const next = nextFire(schedule, timezone, from);
203
+ if (!earliest || next < earliest) earliest = next;
204
+ }
205
+ return earliest;
206
+ }
package/src/schema.ts CHANGED
@@ -8,11 +8,12 @@ import type {
8
8
  Type,
9
9
  TypeOfAttribute,
10
10
  } from "@cedar-policy/cedar-wasm/nodejs";
11
+ import { ChangeFacts } from "./change.ts";
11
12
  import { recordSchemaOf } from "./collection.ts";
12
13
  import type { ResolvedConfig } from "./config.ts";
13
14
  import { jsonSchemaOf } from "./define.ts";
14
15
  import { isObject } from "./resource.ts";
15
- import { DATA_VENDOR, EFFECTS, groupNamesOf, type PeopleResource, WORKFLOW_ACTION } from "./shared.ts";
16
+ import { CHANGE_MERGE, DATA_VENDOR, EFFECTS, groupNamesOf, type PeopleResource, WORKFLOW_ACTION } from "./shared.ts";
16
17
 
17
18
  // The Cedar schema a company's policies are validated and decided against: made from the
18
19
  // resolved config and the people, in memory for the evaluator, and written as text to
@@ -36,6 +37,9 @@ export const NAMESPACE = "Sanoma";
36
37
  /** The action group every workflow start (`workflow:<name>`) is in. */
37
38
  export const START = "start";
38
39
 
40
+ /** The entity type a change's merge acts on (`Sanoma::Change`), its attributes the change's facts. */
41
+ export const CHANGE = "Change";
42
+
39
43
  const STRING = { type: "EntityOrCommon", name: "String" } as const;
40
44
  const LONG = { type: "EntityOrCommon", name: "Long" } as const;
41
45
  const BOOL = { type: "EntityOrCommon", name: "Bool" } as const;
@@ -72,7 +76,8 @@ const WRITES = new Set(["save", "delete"]);
72
76
  * `Target in [Collection]`; `Workflow`;
73
77
  * - an action per operation id, in its effect's group (`effect:money`), on a `Target`, with
74
78
  * `context` `{ input, before? }`: `input` from the operation's input schema, `before` the record
75
- * as it was for a collection's `save` and `delete`;
79
+ * as it was for a collection's `save` and `delete`; `sanoma.change.merge` on a `Change`, whose
80
+ * attributes are a change's facts (`ChangeFacts`), so a rule reads `resource.touches`;
76
81
  * - `workflow:<name>` per workflow, in the group `start`, on a `Workflow`, with its `input`.
77
82
  *
78
83
  * Zod to Cedar, through the JSON Schema of the parsed value: string and enum `String`, integer `Long`, boolean `Bool`,
@@ -97,7 +102,10 @@ function buildSchema(
97
102
  const before = collection && typeOf(recordSchemaOf(collection));
98
103
  actions[op.id] = {
99
104
  memberOf: [{ id: `effect:${op.effect}` }],
100
- appliesTo: applies("Target", { ...input(op.input), ...(before && { before: optional(before) }) }),
105
+ appliesTo: applies(op.id === CHANGE_MERGE ? CHANGE : "Target", {
106
+ ...input(op.input),
107
+ ...(before && { before: optional(before) }),
108
+ }),
101
109
  };
102
110
  }
103
111
  for (const name of [...resolved.workflows.keys()].toSorted()) {
@@ -112,6 +120,7 @@ function buildSchema(
112
120
  Collection: {},
113
121
  Target: { memberOfTypes: ["Collection"] },
114
122
  Workflow: {},
123
+ [CHANGE]: { shape: typeOf(ChangeFacts) as RecordType<string> & { type: "Record" } },
115
124
  };
116
125
  return { [NAMESPACE]: { entityTypes, actions } };
117
126
  }
@@ -221,6 +230,10 @@ const allowsNull = (schema: unknown): boolean =>
221
230
  /** Cedar's message, with its help when it has one. */
222
231
  export const messageOf = (e: DetailedError) => (e.help ? `${e.message} (${e.help})` : e.message);
223
232
 
233
+ /** The attributes of an entity type in the schema, when it has any (`Change`'s). */
234
+ export const shapeOf = (schema: CedarSchemaJson, type: string) =>
235
+ (schema[NAMESPACE]?.entityTypes[type] as { shape?: RecordType<string> & { type: "Record" } } | undefined)?.shape;
236
+
224
237
  /** An action's context type in the schema: a record, when the action is there. */
225
238
  export const contextTypeOf = (schema: CedarSchemaJson, action: string) =>
226
239
  schema[NAMESPACE]?.actions[action]?.appliesTo?.context as (RecordType<string> & { type: "Record" }) | undefined;
package/src/shared.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import type { RunSummary } from "./client.ts";
2
2
  import type { CredentialStatus } from "./env.ts";
3
3
  import type { ApprovalState, Approver, Principal } from "./define.ts";
4
+ import type { OpEntry, WorkflowEntry } from "./describe.ts";
4
5
  import type { LedgerRecord } from "./ledger.ts";
5
6
  import type { Effect } from "./op.ts";
7
+ import type { PreviewRequest } from "./policy.ts";
6
8
  import type { DeclaredResource, ResourceProblem } from "./resources.ts";
7
9
  import type { OutlineNode } from "./outline.ts";
8
10
 
@@ -20,6 +22,7 @@ export type { DeclaredResource, ResourceProblem } from "./resources.ts";
20
22
  export type { DriftField, DriftReport, DriftResult, DriftStatus } from "./drift.ts";
21
23
  export type { ApplyPlan, ApplyReport, ApplyResult, PlanAnswer, PlanStep } from "./apply.ts";
22
24
  export type { CredentialStatus } from "./env.ts";
25
+ export type { ChangeEvent, ChangeFacts, ChangeReport, ChangeState } from "./change.ts";
23
26
 
24
27
  // What a UI in the browser may use from the runtime, besides types: `@sanoma-ai/workflows/shared`.
25
28
  // Nothing here imports DBOS, Node or zod, so a browser bundle can carry it; the runtime uses the
@@ -52,6 +55,24 @@ export const EFFECTS = ["read", "write", "publish", "send", "money", "access"] a
52
55
  /** What a workflow start's action id starts with, as a policy names it: `workflow:<name>`. */
53
56
  export const WORKFLOW_ACTION = "workflow:";
54
57
 
58
+ /**
59
+ * What to ask the policy ahead of a run of `workflow` (`Policy.preview`, the principal aside): its
60
+ * start, then each operation it may call, with its effect, in the run (`inRun`). The run form's
61
+ * will-ask line asks it for the person; the Permissions grid (`permissionsGrid`) for each group.
62
+ */
63
+ export function previewRequests(
64
+ workflow: Pick<WorkflowEntry, "name" | "ops">,
65
+ ops: ReadonlyMap<string, Pick<OpEntry, "effect">>,
66
+ ): Omit<PreviewRequest, "principal">[] {
67
+ return [
68
+ { action: { id: `${WORKFLOW_ACTION}${workflow.name}` } },
69
+ ...workflow.ops.map((id) => {
70
+ const effect = ops.get(id)?.effect;
71
+ return { action: effect === undefined ? { id } : { id, effect }, inRun: true };
72
+ }),
73
+ ];
74
+ }
75
+
55
76
  /** A site's folder name under `sites/`, its URL segment: what the app mounts and `readSites` reads. */
56
77
  export const SITE_NAME = /^[a-z][a-z0-9-]*$/;
57
78
 
@@ -184,8 +205,30 @@ function checkField(where: string, field: string, kind: unknown): void {
184
205
  */
185
206
  export const CALL_WORKFLOW = "call";
186
207
 
187
- /** The workflows the runtime adds itself, which a config's own may not be named: drift, apply and call. */
188
- export const BUILT_IN_WORKFLOWS: ReadonlySet<string> = new Set([DRIFT_WORKFLOW, APPLY_WORKFLOW, CALL_WORKFLOW]);
208
+ /**
209
+ * The built-in change workflow's name: the life of a change to the company's repository, from its
210
+ * pull request to its merge. The only workflow that may call `sanoma.change.*`.
211
+ */
212
+ export const CHANGE_WORKFLOW = "change";
213
+
214
+ /** Opens a change: its draft's branch pushed and a pull request opened for it. */
215
+ export const CHANGE_OPEN = "sanoma.change.open";
216
+ /** Reports Sanoma's review of a change to its host (the `sanoma` commit status). */
217
+ export const CHANGE_REVIEW = "sanoma.change.review";
218
+ /** Merges a change; its resource is the `Sanoma::Change`, whose attributes are its facts (`ChangeFacts`). */
219
+ export const CHANGE_MERGE = "sanoma.change.merge";
220
+ /** Closes a change without merging it. */
221
+ export const CHANGE_CLOSE = "sanoma.change.close";
222
+ /** The DBOS event a change run publishes its `ChangeState` on. */
223
+ export const CHANGE_EVENT = "change";
224
+
225
+ /** The workflows the runtime adds itself, which a config's own may not be named: drift, apply, call and change. */
226
+ export const BUILT_IN_WORKFLOWS: ReadonlySet<string> = new Set([
227
+ DRIFT_WORKFLOW,
228
+ APPLY_WORKFLOW,
229
+ CALL_WORKFLOW,
230
+ CHANGE_WORKFLOW,
231
+ ]);
189
232
 
190
233
  /**
191
234
  * Every run status, in the order a run may pass through them: waiting on the queue, running,
@@ -277,8 +320,12 @@ export function visitorPrincipal(login: string, subject: string): Principal {
277
320
  /** Who a public page runs as when nobody is signed in. */
278
321
  export const ANONYMOUS_PRINCIPAL: Principal = Object.freeze({ id: ANONYMOUS, groups: [] });
279
322
 
280
- /** The runtime's own agents (scenarios, the host check, scheduled runs), declared whether a data file names them or not. */
281
- export const RUNTIME_AGENTS: readonly string[] = ["scenarios", "host-check", "schedule"];
323
+ /** The runtime's agent that registers the instance's wakeup (`register`'s `CallContext.by`). */
324
+ export const WAKEUP_AGENT = "wakeup";
325
+ /** The runtime's agent a scheduled run is started by (`trigger: { schedule }`): the policy decides its starts. */
326
+ export const SCHEDULE_AGENT = "schedule";
327
+ /** The runtime's own agents (scenarios, the host check, scheduled runs, the wakeup's registration), declared whether a data file names them or not. */
328
+ export const RUNTIME_AGENTS: readonly string[] = ["scenarios", "host-check", SCHEDULE_AGENT, WAKEUP_AGENT];
282
329
 
283
330
  /** The declared resources of one of the runtime's people types, by `desired`. */
284
331
  const peopleOfType = <T>(resources: readonly PeopleResource[], type: "person" | "agent" | "group") =>
package/src/source.ts CHANGED
@@ -1,12 +1,14 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
  import { z } from "zod";
4
+ import type { PermissionsGrid } from "./grid.ts";
4
5
  import { defineConnector, defineDriver, DriverError } from "./op.ts";
5
6
 
6
7
  // Where a company's files live, and how a change to them is recorded: a git repository on this
7
8
  // machine, or one a host cloned. The runtime's writers (the app's Adopt, apply's lockfile) change
8
9
  // files under `root`, then commit them through it. The launcher makes one; a config without one
9
- // writes the files and commits nothing.
10
+ // writes the files and commits nothing. A writer that must leave the live checkout alone writes
11
+ // in a draft instead: a checkout of its own, on a branch of its own.
10
12
 
11
13
  /** A company's repository, as the runtime writes to it. */
12
14
  export interface Source {
@@ -21,10 +23,63 @@ export interface Source {
21
23
  message: string;
22
24
  author: string;
23
25
  }): { revision: string } | undefined | Promise<{ revision: string } | undefined>;
24
- /** The revision `ref` names, the checked-out one by default. */
26
+ /** The revision `ref` names, the checked-out one by default (a draft's by its `branch`). */
25
27
  head(ref?: string): string | Promise<string>;
26
- /** Pushes `ref` to the repository at `url`, signed in with `token`. */
28
+ /** Pushes `ref` to the repository at `url`, signed in with `token` (a draft by its `branch`). */
27
29
  push?(target: { url: string; token: string; ref: string }): void | Promise<void>;
30
+ /**
31
+ * The draft `id` (letters, digits, `_` and `-`), opened: made from the checked-out revision the
32
+ * first time, then the same one, edits and all, until its branch is deleted.
33
+ */
34
+ draft?(id: string): Promise<Draft>;
35
+ /** The open drafts' ids, sorted. */
36
+ drafts?(): Promise<string[]>;
37
+ /** What the draft `id` has committed since its branch left the checked-out one (read from the branch alone). */
38
+ diff?(id: string): Promise<DraftDiff>;
39
+ /** Closes the draft `id`: its checkout removed and its branch deleted, whatever it held; nothing when it is gone. */
40
+ closeDraft?(id: string): Promise<void>;
41
+ /**
42
+ * `sanoma check` on the draft `id`, as a child in its checkout, one at a time in the process. A
43
+ * check that gives no result (a config that does not load, no install, a timeout) is a result
44
+ * of one problem saying why.
45
+ */
46
+ check?(id: string): Promise<CheckResult>;
47
+ /** The URL of the repository this one was cloned from (`origin`); undefined when it has none. */
48
+ remoteUrl?(): Promise<string | undefined>;
49
+ /**
50
+ * Fast-forwards the checked-out branch to `ref` in the repository at `url`, signed in with
51
+ * `token`, as `push` is: only when `ref` is checked out, nothing is uncommitted and the remote's
52
+ * `ref` contains it; otherwise `skipped`, saying why. The checked-out revision after.
53
+ */
54
+ follow?(target: { url: string; token: string; ref: string }): Promise<{ revision: string } | { skipped: string }>;
55
+ }
56
+
57
+ /**
58
+ * A draft: a checkout of its own, at `root`, on `branch`, which `commit` records in as
59
+ * `Source.commit` does in its root. Its `node_modules` may be the root's own install, linked in:
60
+ * a writer never writes under it.
61
+ */
62
+ export interface Draft {
63
+ branch: string;
64
+ root: string;
65
+ commit: Source["commit"];
66
+ }
67
+
68
+ /** A draft's change: the files it touches, relative to the root, and git's `--stat` of it. */
69
+ export interface DraftDiff {
70
+ files: string[];
71
+ summary: string;
72
+ }
73
+
74
+ /** What `sanoma check` finds in a repo (a draft's too). */
75
+ export interface CheckResult {
76
+ ok: boolean;
77
+ /** One printable line each: `<file>:<line>:<col>: <message>`, or `<scenario> › <step>: <message>`. */
78
+ problems: string[];
79
+ /** What is worth fixing but not wrong, one line each, `<file>:<line>:<col>: warning: <message>`: the sites' `warnings`. */
80
+ warnings: string[];
81
+ /** The repo's Permissions grid (`permissionsGrid`), when its config loaded and its policies hold: what a change is reviewed by. */
82
+ grid?: PermissionsGrid;
28
83
  }
29
84
 
30
85
  /**
@@ -80,3 +135,121 @@ export async function resolveRevision(url: string, ref: string): Promise<string>
80
135
  }
81
136
  throw new DriverError(`${url} has no branch or tag ${ref}`, { retryable: false, vendorCode: "no_such_ref" });
82
137
  }
138
+
139
+ // A git host's side of a change: the pull request a branch is reviewed in, merged through and
140
+ // closed by, the `sanoma` commit status the review card links from, whether the host refuses a
141
+ // change to `main` that is not a merge, and the host's webhooks. A connector carries it as its
142
+ // remote half (`ConnectorSetup.remote`), the way a login or a model half is carried.
143
+
144
+ /** A repository on a host: `owner/name`. */
145
+ export interface RepoRef {
146
+ owner: string;
147
+ name: string;
148
+ }
149
+
150
+ /** A pull request (a merge request, on a host that calls it so), as a change follows it. */
151
+ export interface PullRequest {
152
+ number: number;
153
+ /** Its page on the host. */
154
+ url: string;
155
+ /** The branch it merges. */
156
+ branch: string;
157
+ /** The branch's commit it was read at. */
158
+ head: string;
159
+ state: "open" | "closed" | "merged";
160
+ /** The commit its merge made on the base branch, once merged. */
161
+ mergeSha?: string;
162
+ }
163
+
164
+ /**
165
+ * Whether the host refuses a change to a branch that does not follow the rules: `pullRequest`
166
+ * when a direct push is refused (every change is a merge), `check` when a merge needs the
167
+ * `sanoma` status to pass. `reason` says why neither can be read or held, such as a plan that
168
+ * has no rules.
169
+ */
170
+ export interface HostEnforcement {
171
+ pullRequest: boolean;
172
+ check: boolean;
173
+ reason?: string;
174
+ }
175
+
176
+ /**
177
+ * One repository on a host, opened by its remote half with the company's credentials, read when
178
+ * a call needs them; a half may keep a token for a few minutes (its README says how long), so a
179
+ * token stored later is used soon after. A failure is a `DriverError` (retryable as the host's
180
+ * status says).
181
+ */
182
+ export interface SourceHost {
183
+ /**
184
+ * A token that writes to the repository: the company's app's on the host when it is installed
185
+ * on the owner, else a person's token (`prefer: "token"` asks for that one first). It is never
186
+ * logged; a push signs in with it. A `DriverError` whose `vendorCode` is `no_token` when there is
187
+ * neither.
188
+ */
189
+ token(prefer?: "app" | "token"): Promise<string>;
190
+ /** The pull request of `branch`: the open one, else the latest; undefined when it has none. */
191
+ pullRequestFor(branch: string): Promise<PullRequest | undefined>;
192
+ pullRequest(number: number): Promise<PullRequest>;
193
+ /** Opens a pull request of `branch` into `base`; the open one when `branch` has one already. */
194
+ openPullRequest(request: { branch: string; base: string; title: string; body: string }): Promise<PullRequest>;
195
+ /**
196
+ * Squash-merges pull request `number` when its branch is still at `sha`, as one commit titled
197
+ * `title`. A `DriverError` whose `vendorCode` is `head_moved` when the branch moved on, and
198
+ * `not_mergeable` when the host refuses the merge (a conflict, a rule).
199
+ */
200
+ mergePullRequest(number: number, merge: { sha: string; method: "squash"; title?: string }): Promise<{ sha: string }>;
201
+ closePullRequest(number: number): Promise<void>;
202
+ /** Deletes `branch`; nothing when it is gone already. */
203
+ deleteBranch?(branch: string): Promise<void>;
204
+ /** Sets the `sanoma` status on commit `sha`, linking to `targetUrl`. */
205
+ setStatus(status: {
206
+ sha: string;
207
+ state: "pending" | "success" | "failure";
208
+ description: string;
209
+ targetUrl?: string;
210
+ }): Promise<void>;
211
+ /** What the host enforces on `base` (see `HostEnforcement`). */
212
+ enforcement(base: string): Promise<HostEnforcement>;
213
+ /** Uploads a PNG the pull request's comment shows; its URL. */
214
+ uploadImage?(png: Uint8Array, name: string): Promise<string>;
215
+ /** Writes the pull request's comment that carries `marker`, once: a second call edits it. */
216
+ upsertComment?(number: number, marker: string, body: string): Promise<void>;
217
+ }
218
+
219
+ /** What a host's webhook says happened: a branch pushed, or a pull request closed (merged or not). */
220
+ export type RemoteEvent =
221
+ | { kind: "pushed"; repo: RepoRef; branch: string; head: string }
222
+ | { kind: "closed"; repo: RepoRef; branch: string; number: number; merged: boolean; sha?: string };
223
+
224
+ /** What a remote half reads the company's credentials with, as a login or a model half reads them. */
225
+ export interface RemoteContext {
226
+ /** Secrets' values by name: the environment's first, then the stored credentials (`LoginContext.secrets`). */
227
+ secrets(names: readonly string[]): Promise<Map<string, string>>;
228
+ }
229
+
230
+ /** A connector's remote half (`ConnectorSetup.remote`): a git host's repositories, made once per resolved config. */
231
+ export interface RemoteHalf {
232
+ /** The repository a clone URL of this host names, or undefined for another host's. */
233
+ matches(url: string): RepoRef | undefined;
234
+ /** The repository `repo`, with the company's credentials. */
235
+ open(repo: RepoRef, context: RemoteContext): SourceHost;
236
+ /**
237
+ * The events a webhook request carries, once its signature is verified: throws for a missing or
238
+ * bad signature, and answers `[]` for an event the change workflow does not follow.
239
+ */
240
+ webhook?(request: Request, context: RemoteContext): Promise<RemoteEvent[]>;
241
+ /** The accounts the company's app on the host is installed on: where it acts with no person's token. */
242
+ owners?(context: RemoteContext): Promise<{ login: string; kind: "user" | "organization" }[]>;
243
+ }
244
+
245
+ /** The remote half among `remotes` (`ResolvedConfig.remotes`) whose host `url` is on, with its vendor and the repository. */
246
+ export function remoteFor(
247
+ remotes: ReadonlyMap<string, RemoteHalf>,
248
+ url: string,
249
+ ): { vendor: string; remote: RemoteHalf; repo: RepoRef } | undefined {
250
+ for (const [vendor, remote] of remotes) {
251
+ const repo = remote.matches(url);
252
+ if (repo) return { vendor, remote, repo };
253
+ }
254
+ return undefined;
255
+ }
@@ -0,0 +1,102 @@
1
+ import type { SanomaConfig } from "./config.ts";
2
+ import type { SelfStop } from "./deadlines.ts";
3
+ import { warn } from "./log.ts";
4
+ import { errorMessage } from "./shared.ts";
5
+ import { startWorker, type Worker, type WorkerOptions } from "./worker.ts";
6
+
7
+ // One worker at a time in a process that outlives it. A worker that stops itself when idle (a
8
+ // wakeup declared) cannot launch again, so the next write that needs one, or the wakeup's wake,
9
+ // starts a fresh worker. `sanoma serve` runs its worker through it, and so does the testing
10
+ // kit's host in this process.
11
+
12
+ /** A worker under a supervisor (`superviseWorker`). */
13
+ export interface Supervisor {
14
+ /** True from the moment the worker stopped itself until a fresh one starts. */
15
+ readonly stopped: boolean;
16
+ /**
17
+ * Starts a fresh worker when the last one stopped itself, for a write that needs one (a run's
18
+ * start, an approval's decision); while one starts or runs, it is heard (below). Resolves once it runs, or
19
+ * once its failure to start is logged: the next call tries again. Never rejects.
20
+ */
21
+ resume(): Promise<void>;
22
+ /**
23
+ * A wakeup's `GET /wake`: `resume` when the worker stopped itself; a no-op while one starts or
24
+ * runs. A wake that arrives late, twice or for a replaced registration starts a worker that
25
+ * finds nothing to do and stops again on its own check.
26
+ */
27
+ wake(): Promise<void>;
28
+ /** Stops the worker running or starting, and starts none again. */
29
+ stop(): Promise<void>;
30
+ }
31
+
32
+ export interface SupervisorOptions extends Omit<WorkerOptions, "onStopped"> {
33
+ /**
34
+ * False: the worker never stops itself, as on an app on this machine alone, whose wakeup
35
+ * would wake the instance the host runs. Default true.
36
+ */
37
+ selfStop?: boolean;
38
+ /** Told each time the worker stops itself, once a fresh one is starting for a write it heard, or `stopped` is true. */
39
+ onStopped?: (stopped: SelfStop) => void;
40
+ }
41
+
42
+ /**
43
+ * Starts a worker on `config` that stops itself when idle with a wakeup declared (`onStopped`
44
+ * in `WorkerOptions`), and a fresh one each time `resume` or `wake` asks after it did. Throws
45
+ * when the first does not start. A write heard while a worker starts or runs may land after its last check
46
+ * for being idle, so a worker that stops itself after one is started again at once: it stops
47
+ * again on its own when nothing came of it.
48
+ */
49
+ export async function superviseWorker(config: SanomaConfig, options: SupervisorOptions = {}): Promise<Supervisor> {
50
+ const { onStopped, selfStop = true, ...workerOptions } = options;
51
+ let worker: Worker | undefined;
52
+ let starting: Promise<void> | undefined;
53
+ let closed = false;
54
+ // A write while the worker starts or runs, since it began starting.
55
+ let heard = false;
56
+
57
+ const launch = async () => {
58
+ heard = false;
59
+ const started = await startWorker(config, { ...workerOptions, ...(selfStop && { onStopped: selfStopped }) });
60
+ if (closed) await started.stop();
61
+ else worker = started;
62
+ };
63
+ const selfStopped = (stopped: SelfStop) => {
64
+ worker = undefined;
65
+ if (closed) return;
66
+ // Resumed first, so `stopped` is false when `onStopped` looks.
67
+ if (heard) void supervisor.resume();
68
+ onStopped?.(stopped);
69
+ };
70
+
71
+ const supervisor: Supervisor = {
72
+ get stopped() {
73
+ return !closed && !worker && !starting;
74
+ },
75
+ resume() {
76
+ if (closed) return Promise.resolve();
77
+ if (worker) {
78
+ heard = true;
79
+ return Promise.resolve();
80
+ }
81
+ if (starting) {
82
+ heard = true;
83
+ return starting;
84
+ }
85
+ return (starting = launch()
86
+ .catch((err: unknown) => warn(`the worker could not start again: ${errorMessage(err)}`))
87
+ .finally(() => {
88
+ starting = undefined;
89
+ }));
90
+ },
91
+ wake: () => (supervisor.stopped ? supervisor.resume() : Promise.resolve()),
92
+ async stop() {
93
+ closed = true;
94
+ await starting;
95
+ const running = worker;
96
+ worker = undefined;
97
+ await running?.stop();
98
+ },
99
+ };
100
+ await launch();
101
+ return supervisor;
102
+ }