@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.
- package/README.md +52 -14
- package/dist/apply.d.ts +2 -1
- package/dist/apply.js +29 -7
- package/dist/apply.js.map +1 -1
- package/dist/approvals.d.ts +27 -2
- package/dist/approvals.js +73 -9
- package/dist/approvals.js.map +1 -1
- package/dist/call.d.ts +3 -2
- package/dist/call.js +38 -30
- package/dist/call.js.map +1 -1
- package/dist/cedar.js +24 -9
- package/dist/cedar.js.map +1 -1
- package/dist/change.d.ts +219 -0
- package/dist/change.js +150 -0
- package/dist/change.js.map +1 -0
- package/dist/chats.d.ts +43 -0
- package/dist/chats.js +48 -0
- package/dist/chats.js.map +1 -0
- package/dist/client.d.ts +52 -6
- package/dist/client.js +88 -47
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +75 -8
- package/dist/config.js +103 -13
- package/dist/config.js.map +1 -1
- package/dist/constructors.d.ts +37 -1
- package/dist/constructors.js +6 -2
- package/dist/constructors.js.map +1 -1
- package/dist/database.d.ts +83 -0
- package/dist/database.js +92 -0
- package/dist/database.js.map +1 -0
- package/dist/db.d.ts +19 -1
- package/dist/db.js +51 -7
- package/dist/db.js.map +1 -1
- package/dist/deadlines.d.ts +91 -0
- package/dist/deadlines.js +165 -0
- package/dist/deadlines.js.map +1 -0
- package/dist/define.d.ts +25 -4
- package/dist/define.js +40 -0
- package/dist/define.js.map +1 -1
- package/dist/demo.d.ts +28 -0
- package/dist/demo.js +72 -0
- package/dist/demo.js.map +1 -0
- package/dist/describe.d.ts +5 -2
- package/dist/describe.js +10 -6
- package/dist/describe.js.map +1 -1
- package/dist/errors.d.ts +10 -1
- package/dist/errors.js +13 -0
- package/dist/errors.js.map +1 -1
- package/dist/fake.d.ts +34 -0
- package/dist/fake.js +134 -1
- package/dist/fake.js.map +1 -1
- package/dist/grid.d.ts +34 -0
- package/dist/grid.js +62 -0
- package/dist/grid.js.map +1 -0
- package/dist/host.d.ts +12 -2
- package/dist/host.js +2 -2
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +15 -8
- package/dist/index.js +10 -5
- package/dist/index.js.map +1 -1
- package/dist/ledger.d.ts +11 -2
- package/dist/ledger.js.map +1 -1
- package/dist/lint.js +2 -2
- package/dist/lint.js.map +1 -1
- package/dist/op.d.ts +21 -2
- package/dist/op.js.map +1 -1
- package/dist/person.d.ts +5 -0
- package/dist/person.js +3 -1
- package/dist/person.js.map +1 -1
- package/dist/policy.d.ts +7 -1
- package/dist/policy.js +2 -1
- package/dist/policy.js.map +1 -1
- package/dist/resource.d.ts +13 -1
- package/dist/resource.js +9 -1
- package/dist/resource.js.map +1 -1
- package/dist/resources.d.ts +2 -0
- package/dist/resources.js +15 -4
- package/dist/resources.js.map +1 -1
- package/dist/run.d.ts +5 -0
- package/dist/run.js.map +1 -1
- package/dist/sandbox.d.ts +38 -10
- package/dist/sandbox.js +66 -35
- package/dist/sandbox.js.map +1 -1
- package/dist/scenario.d.ts +18 -1
- package/dist/scenario.js +77 -27
- package/dist/scenario.js.map +1 -1
- package/dist/scenarios.d.ts +10 -1
- package/dist/scenarios.js +19 -4
- package/dist/scenarios.js.map +1 -1
- package/dist/schedules.d.ts +66 -0
- package/dist/schedules.js +158 -0
- package/dist/schedules.js.map +1 -0
- package/dist/schema.d.ts +6 -0
- package/dist/schema.js +13 -3
- package/dist/schema.js.map +1 -1
- package/dist/shared.d.ts +30 -2
- package/dist/shared.js +42 -4
- package/dist/shared.js.map +1 -1
- package/dist/source.d.ts +185 -2
- package/dist/source.js +9 -0
- package/dist/source.js.map +1 -1
- package/dist/supervisor.d.ts +39 -0
- package/dist/supervisor.js +68 -0
- package/dist/supervisor.js.map +1 -0
- package/dist/version.d.ts +3 -1
- package/dist/version.js +3 -1
- package/dist/version.js.map +1 -1
- package/dist/wakeup-driver.d.ts +128 -0
- package/dist/wakeup-driver.js +216 -0
- package/dist/wakeup-driver.js.map +1 -0
- package/dist/wakeup-service.d.ts +83 -0
- package/dist/wakeup-service.js +415 -0
- package/dist/wakeup-service.js.map +1 -0
- package/dist/wakeup-type.d.ts +43 -0
- package/dist/wakeup-type.js +80 -0
- package/dist/wakeup-type.js.map +1 -0
- package/dist/wakeup.d.ts +77 -0
- package/dist/wakeup.js +78 -0
- package/dist/wakeup.js.map +1 -0
- package/dist/worker.d.ts +39 -1
- package/dist/worker.js +125 -38
- package/dist/worker.js.map +1 -1
- package/oxlint.json +3 -1
- package/package.json +10 -2
- package/src/apply.ts +25 -6
- package/src/approvals.ts +89 -16
- package/src/call.ts +40 -29
- package/src/cedar.ts +24 -6
- package/src/change.ts +222 -0
- package/src/chats.ts +114 -0
- package/src/client.ts +145 -55
- package/src/config.ts +149 -21
- package/src/constructors.ts +8 -2
- package/src/database.ts +152 -0
- package/src/db.ts +58 -9
- package/src/deadlines.ts +228 -0
- package/src/define.ts +71 -4
- package/src/demo.ts +87 -0
- package/src/describe.ts +18 -6
- package/src/errors.ts +14 -0
- package/src/fake.ts +149 -0
- package/src/grid.ts +100 -0
- package/src/host.ts +12 -4
- package/src/index.ts +66 -3
- package/src/ledger.ts +15 -3
- package/src/lint.ts +2 -2
- package/src/op.ts +22 -2
- package/src/person.ts +4 -1
- package/src/policy.ts +7 -1
- package/src/resource.ts +22 -3
- package/src/resources.ts +17 -4
- package/src/run.ts +5 -0
- package/src/sandbox.ts +97 -37
- package/src/scenario.ts +97 -28
- package/src/scenarios.ts +21 -5
- package/src/schedules.ts +206 -0
- package/src/schema.ts +16 -3
- package/src/shared.ts +51 -4
- package/src/source.ts +176 -3
- package/src/supervisor.ts +102 -0
- package/src/version.ts +3 -1
- package/src/wakeup-driver.ts +270 -0
- package/src/wakeup-service.ts +504 -0
- package/src/wakeup-type.ts +90 -0
- package/src/wakeup.ts +139 -0
- package/src/worker.ts +176 -43
package/src/schedules.ts
ADDED
|
@@ -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(
|
|
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
|
-
/**
|
|
188
|
-
|
|
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
|
|
281
|
-
export const
|
|
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
|
+
}
|