@sanoma-ai/workflows 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -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 +47 -5
- package/dist/config.js +71 -7
- package/dist/config.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 +31 -1
- package/dist/db.js +89 -7
- package/dist/db.js.map +1 -1
- package/dist/deadlines.d.ts +88 -0
- package/dist/deadlines.js +171 -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 +8 -0
- package/dist/fake.js +11 -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 +13 -2
- package/dist/host.js +2 -2
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +16 -8
- package/dist/index.js +12 -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 +1 -1
- package/dist/lint.js.map +1 -1
- package/dist/op.d.ts +14 -2
- package/dist/op.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 +10 -1
- 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 +61 -2
- package/dist/source.js.map +1 -1
- package/dist/supervisor.d.ts +38 -0
- package/dist/supervisor.js +77 -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-service.d.ts +83 -0
- package/dist/wakeup-service.js +421 -0
- package/dist/wakeup-service.js.map +1 -0
- package/dist/wakeup.d.ts +84 -0
- package/dist/wakeup.js +87 -0
- package/dist/wakeup.js.map +1 -0
- package/dist/worker.d.ts +39 -1
- package/dist/worker.js +130 -39
- package/dist/worker.js.map +1 -1
- package/oxlint.json +2 -1
- package/package.json +8 -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 +110 -13
- package/src/database.ts +152 -0
- package/src/db.ts +108 -9
- package/src/deadlines.ts +234 -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 +12 -0
- package/src/grid.ts +100 -0
- package/src/host.ts +13 -4
- package/src/index.ts +61 -3
- package/src/ledger.ts +15 -3
- package/src/lint.ts +1 -1
- package/src/op.ts +15 -2
- package/src/policy.ts +7 -1
- package/src/resource.ts +22 -3
- package/src/resources.ts +12 -1
- 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 +58 -3
- package/src/supervisor.ts +107 -0
- package/src/version.ts +3 -1
- package/src/wakeup-service.ts +511 -0
- package/src/wakeup.ts +150 -0
- package/src/worker.ts +181 -44
package/src/scenario.ts
CHANGED
|
@@ -135,7 +135,19 @@ export interface Scenario {
|
|
|
135
135
|
file: string;
|
|
136
136
|
/** Its line in the file: its keyword's, or for an outline's row, the row's. */
|
|
137
137
|
line: number;
|
|
138
|
-
|
|
138
|
+
/**
|
|
139
|
+
* The workflow its `When` runs. Absent for a scenario with no `When`, a *world*: its `Given`
|
|
140
|
+
* steps alone, which a demo applies to the instance (`applyGivens`) and nothing runs or checks.
|
|
141
|
+
*/
|
|
142
|
+
workflow?: string;
|
|
143
|
+
/** Its tags, as written (`@slow`), the feature's and its own. */
|
|
144
|
+
tags: string[];
|
|
145
|
+
/**
|
|
146
|
+
* When it is tagged `@moment(<person>,<path>)` (no space: a Gherkin tag has none): a moment,
|
|
147
|
+
* which a demo starts as a sandbox run by `person`, a declared person, and leaves waiting, for
|
|
148
|
+
* a visitor signed in as them to land on at `path`.
|
|
149
|
+
*/
|
|
150
|
+
moment?: Moment;
|
|
139
151
|
/**
|
|
140
152
|
* The scenario's own lines of its feature file, for display: from its keyword line to the end
|
|
141
153
|
* of its last step (a step's doc string or table included, and an outline's examples), less
|
|
@@ -158,6 +170,12 @@ export interface Scenario {
|
|
|
158
170
|
expect: Expectation[];
|
|
159
171
|
}
|
|
160
172
|
|
|
173
|
+
/** A moment's visitor and where they land: `@moment(<person>,<path>)`. */
|
|
174
|
+
export interface Moment {
|
|
175
|
+
person: string;
|
|
176
|
+
path: string;
|
|
177
|
+
}
|
|
178
|
+
|
|
161
179
|
/** The result of one expectation against a run's ledger. */
|
|
162
180
|
export interface Check {
|
|
163
181
|
step: string;
|
|
@@ -380,6 +398,22 @@ function builtFor(scope: Scope): Built {
|
|
|
380
398
|
/** A `Then` step of the generic steps: its expectation, checked against the run's records. */
|
|
381
399
|
const expecting = (expect: CoreExpectation): Then => ({ expect, verify: (run) => detailOf(expect, run.records) });
|
|
382
400
|
|
|
401
|
+
/**
|
|
402
|
+
* `who`, as a run's start admits them (`admit`): a declared person or agent, or `anonymous`. The
|
|
403
|
+
* people are the root's data files' (`readPeople`, read again only once a file changed): while
|
|
404
|
+
* they declare no one, any id goes.
|
|
405
|
+
*/
|
|
406
|
+
function actorNamed(scope: Scope, who: string): string {
|
|
407
|
+
const declared = readPeople(scope.root);
|
|
408
|
+
try {
|
|
409
|
+
return admit({ id: who }, declared).id;
|
|
410
|
+
} catch (err) {
|
|
411
|
+
if (errorCode(err) !== "not_admitted" || declared.problems.length) throw err;
|
|
412
|
+
const ids = [...principalIdsOf(declared.people), ...RUNTIME_AGENTS].join(", ");
|
|
413
|
+
throw new Error(`${who} is not a declared person or agent; resources/ declares ${ids}`, { cause: err });
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
383
417
|
/** The rules a scope has, and the parameter types their expressions use. */
|
|
384
418
|
function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSpec[] } {
|
|
385
419
|
const { types, collections } = scope;
|
|
@@ -403,20 +437,10 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
|
|
|
403
437
|
];
|
|
404
438
|
const opOf = (id: unknown) => scope.ops.get(id as string)!;
|
|
405
439
|
const workflowOf = (name: unknown) => scope.workflows.get(name as string)!;
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
const declared = readPeople(scope.root);
|
|
411
|
-
try {
|
|
412
|
-
return admit({ id: who }, declared).id;
|
|
413
|
-
} catch (err) {
|
|
414
|
-
if (errorCode(err) !== "not_admitted" || declared.problems.length) throw err;
|
|
415
|
-
const ids = [...principalIdsOf(declared.people), ...RUNTIME_AGENTS].join(", ");
|
|
416
|
-
throw new Error(`${who} is not a declared person or agent; resources/ declares ${ids}`, { cause: err });
|
|
417
|
-
}
|
|
418
|
-
};
|
|
419
|
-
/** `group`, a declared group or `agents`, while the people's files have no problems. */
|
|
440
|
+
/**
|
|
441
|
+
* `group`, a declared group or `agents`, while the people's files have no problems (`readPeople`,
|
|
442
|
+
* read again only once a file changed): while they declare no one, any group goes.
|
|
443
|
+
*/
|
|
420
444
|
const groupNamed = (group: string) => {
|
|
421
445
|
const { people, problems } = readPeople(scope.root);
|
|
422
446
|
if (problems.length) {
|
|
@@ -434,7 +458,7 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
|
|
|
434
458
|
return { resource: id as string, vendor, type, name: name as string };
|
|
435
459
|
};
|
|
436
460
|
const rules: Rule[] = [
|
|
437
|
-
{ kind: "actor", source: "{who} is acting", read: ([who]) => actorNamed(who as string) },
|
|
461
|
+
{ kind: "actor", source: "{who} is acting", read: ([who]) => actorNamed(scope, who as string) },
|
|
438
462
|
// A visitor of a public site, as a login would vouch for one; `anonymous` is a `{who}`.
|
|
439
463
|
{ kind: "actor", source: "a visitor is acting", read: () => visitorPrincipal("scenario", "visitor").id },
|
|
440
464
|
{
|
|
@@ -881,7 +905,7 @@ function namesOf(report: Report): Map<string, string> {
|
|
|
881
905
|
}
|
|
882
906
|
|
|
883
907
|
/** What one scenario's steps said, as compiling it records them. */
|
|
884
|
-
type Spec = Omit<Scenario, "
|
|
908
|
+
type Spec = Omit<Scenario, "approvals" | "file" | "line" | "text" | "tags" | "moment">;
|
|
885
909
|
|
|
886
910
|
/** The run's records, as a `Then` step reads them. */
|
|
887
911
|
function viewOf(records: readonly LedgerRecord[]): RunView {
|
|
@@ -1177,6 +1201,28 @@ function stepError(built: Built, result: StepResult | undefined, step: PickleSte
|
|
|
1177
1201
|
}
|
|
1178
1202
|
}
|
|
1179
1203
|
|
|
1204
|
+
/**
|
|
1205
|
+
* The moment its tags make a scenario (`@moment(<person>,<path>)`), the person admitted as a run's
|
|
1206
|
+
* starter is, or undefined for none; throws, at `where`, for a malformed one or a second.
|
|
1207
|
+
*/
|
|
1208
|
+
function momentOf(scope: Scope, tags: readonly string[], where: string): Moment | undefined {
|
|
1209
|
+
const tagged = tags.filter((t) => t === "@moment" || t.startsWith("@moment("));
|
|
1210
|
+
if (tagged.length === 0) return undefined;
|
|
1211
|
+
if (tagged.length > 1) throw new Error(`${where}: ${tagged.join(" and ")}: a moment has one person and one page`);
|
|
1212
|
+
const [, person, path] = /^@moment\(([^,()]+),(\/[^()]*)\)$/.exec(tagged[0]!) ?? [];
|
|
1213
|
+
if (person === undefined || path === undefined) {
|
|
1214
|
+
throw new Error(
|
|
1215
|
+
`${where}: ${tagged[0]} needs a person and a path, as in @moment(alex,/sites/portal/requests), with no space: a tag has none`,
|
|
1216
|
+
);
|
|
1217
|
+
}
|
|
1218
|
+
try {
|
|
1219
|
+
return { person: actorNamed(scope, person), path };
|
|
1220
|
+
} catch (err) {
|
|
1221
|
+
if (isBug(err)) throw err;
|
|
1222
|
+
throw new Error(`${where}: ${tagged[0]}: ${errorMessage(err)}`, { cause: err });
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1180
1226
|
/** The `.feature` files under `dir`, relative to it, with `/` between directories, in order. */
|
|
1181
1227
|
function featureFiles(dir: string): string[] {
|
|
1182
1228
|
return readdirSync(dir, { recursive: true, encoding: "utf8" })
|
|
@@ -1197,8 +1243,9 @@ const parseErrorOf = ({ uri, line, message }: Report["parseErrors"][number]) =>
|
|
|
1197
1243
|
|
|
1198
1244
|
/**
|
|
1199
1245
|
* Compiles the feature files `files` (relative to `dir`) into scenarios: Cucumber runs each,
|
|
1200
|
-
* and its steps record what they say. A file
|
|
1201
|
-
*
|
|
1246
|
+
* and its steps record what they say. A file none of whose scenarios has a `When` is of worlds.
|
|
1247
|
+
* A file with an error has none of its scenarios: the
|
|
1248
|
+
* first error in it (a step that fails or matches no step or two, a scenario with no `When` beside one with,
|
|
1202
1249
|
* a name used twice, a file Gherkin cannot parse, no scenarios at all) is its error.
|
|
1203
1250
|
*/
|
|
1204
1251
|
async function compile(scope: Scope, dir: string, files: string[]): Promise<LoadedScenarios> {
|
|
@@ -1234,6 +1281,8 @@ async function compile(scope: Scope, dir: string, files: string[]): Promise<Load
|
|
|
1234
1281
|
try {
|
|
1235
1282
|
const list = pickles.get(file) ?? [];
|
|
1236
1283
|
if (list.length === 0) throw new Error(`${file}: no scenarios; add one with "Scenario: <name>"`);
|
|
1284
|
+
// A feature none of whose scenarios has a When is of worlds; elsewhere a missing When is a mistake.
|
|
1285
|
+
const ofWorlds = list.every((p) => specs.get(p.id)!.workflow === undefined);
|
|
1237
1286
|
for (const pickle of list) {
|
|
1238
1287
|
const line = lineOf(report, pickle);
|
|
1239
1288
|
const scenario = query.findLineageBy(pickle)!.scenario!;
|
|
@@ -1242,16 +1291,37 @@ async function compile(scope: Scope, dir: string, files: string[]): Promise<Load
|
|
|
1242
1291
|
const error = stepError(built, results.get(step.id), step, whereOf(report, file, step));
|
|
1243
1292
|
if (error) throw new Error(error);
|
|
1244
1293
|
}
|
|
1245
|
-
const
|
|
1294
|
+
const spec = specs.get(pickle.id)!;
|
|
1295
|
+
const { workflow } = spec;
|
|
1246
1296
|
const name = names.get(pickle.id)!;
|
|
1247
1297
|
if (inFile.some((s) => s.name === name)) {
|
|
1248
1298
|
throw new Error(`${where}: a second scenario named "${name}"; give each its own name`);
|
|
1249
1299
|
}
|
|
1250
|
-
|
|
1300
|
+
const tags = pickle.tags.map((t) => t.name);
|
|
1301
|
+
const moment = momentOf(scope, tags, where);
|
|
1302
|
+
const common = { name, file, line, tags, text: textOf(scenario, report.texts.get(file) ?? "") };
|
|
1303
|
+
if (workflow === undefined && !ofWorlds) {
|
|
1251
1304
|
throw new Error(
|
|
1252
|
-
`${where}: scenario "${name}" has no When: say which workflow runs, such as "When <workflow> runs with"`,
|
|
1305
|
+
`${where}: scenario "${name}" has no When: say which workflow runs, such as "When <workflow> runs with" (a feature with no When in any scenario is of worlds)`,
|
|
1253
1306
|
);
|
|
1254
1307
|
}
|
|
1308
|
+
if (workflow === undefined) {
|
|
1309
|
+
// A world: its Givens alone, which nothing runs or checks.
|
|
1310
|
+
const runs = moment
|
|
1311
|
+
? "is a moment, which starts a run"
|
|
1312
|
+
: spec.expect[0]
|
|
1313
|
+
? `checks a run ("${spec.expect[0].step}")`
|
|
1314
|
+
: spec.decisions[0]
|
|
1315
|
+
? `decides an approval ("${spec.decisions[0].step}")`
|
|
1316
|
+
: undefined;
|
|
1317
|
+
if (runs) {
|
|
1318
|
+
throw new Error(
|
|
1319
|
+
`${where}: scenario "${name}" has no When, so it is a world, Givens only, but it ${runs}: say which workflow runs, such as "When <workflow> runs with"`,
|
|
1320
|
+
);
|
|
1321
|
+
}
|
|
1322
|
+
inFile.push({ ...spec, ...common, approvals: [] });
|
|
1323
|
+
continue;
|
|
1324
|
+
}
|
|
1255
1325
|
// A step about a declared resource reads the report of the built-in it is about.
|
|
1256
1326
|
for (const e of spec.expect) {
|
|
1257
1327
|
const reads: string = "declared" in e ? ("status" in e ? DRIFT_WORKFLOW : APPLY_WORKFLOW) : workflow;
|
|
@@ -1263,11 +1333,9 @@ async function compile(scope: Scope, dir: string, files: string[]): Promise<Load
|
|
|
1263
1333
|
}
|
|
1264
1334
|
inFile.push({
|
|
1265
1335
|
...spec,
|
|
1266
|
-
|
|
1267
|
-
file,
|
|
1268
|
-
line,
|
|
1336
|
+
...common,
|
|
1269
1337
|
workflow,
|
|
1270
|
-
|
|
1338
|
+
...(moment && { moment }),
|
|
1271
1339
|
approvals: approvalsOf(scope.workflows.get(workflow)!),
|
|
1272
1340
|
});
|
|
1273
1341
|
}
|
|
@@ -1358,10 +1426,11 @@ export async function loadScenarios(scope: Scope): Promise<LoadedScenarios> {
|
|
|
1358
1426
|
*/
|
|
1359
1427
|
export async function check(scope: Scope, scenario: Scenario, records: readonly LedgerRecord[]): Promise<Check[]> {
|
|
1360
1428
|
if (!scope.scenarios) throw new Error(`No scenarios directory to check scenario "${scenario.name}" from`);
|
|
1429
|
+
const { file, line, name, workflow } = scenario;
|
|
1430
|
+
if (workflow === undefined) throw new Error(`Scenario "${name}" is a world: it runs nothing to check`);
|
|
1361
1431
|
const built = builtFor(scope);
|
|
1362
|
-
const { file, line, name } = scenario;
|
|
1363
1432
|
const verdicts = new Map<string, string | undefined>();
|
|
1364
|
-
const report = await runFeatures(built.library, checkWorld(built,
|
|
1433
|
+
const report = await runFeatures(built.library, checkWorld(built, workflow, viewOf(records), verdicts), {
|
|
1365
1434
|
cwd: fileURLToPath(scope.scenarios),
|
|
1366
1435
|
files: [file],
|
|
1367
1436
|
line,
|
package/src/scenarios.ts
CHANGED
|
@@ -6,11 +6,13 @@ import { type Check, check, drive, loadScenarios, type Scenario, type Scope } fr
|
|
|
6
6
|
import { errorMessage } from "./shared.ts";
|
|
7
7
|
import { startWorker } from "./worker.ts";
|
|
8
8
|
|
|
9
|
+
export { type DemoState, demoState, reseed } from "./demo.ts";
|
|
10
|
+
|
|
9
11
|
/*
|
|
10
12
|
* `@sanoma-ai/workflows/scenarios`: runs a config's scenarios as sandbox runs, with no test
|
|
11
13
|
* framework. Server-side only, like `./scenario`: it loads Cucumber and starts a worker.
|
|
12
14
|
* `sanoma check` runs every scenario through `runScenarios`; `@sanoma-ai/testing/scenarios` wraps
|
|
13
|
-
* `runScenario` in a vitest test per scenario.
|
|
15
|
+
* `runScenario` in a vitest test per scenario. A demo seeds its instance with `reseed`.
|
|
14
16
|
*/
|
|
15
17
|
|
|
16
18
|
export interface RunScenarioOptions {
|
|
@@ -30,12 +32,15 @@ export interface RunScenarioOptions {
|
|
|
30
32
|
/**
|
|
31
33
|
* Runs a scenario as a sandbox run: starts its workflow with its input, as whoever it says is
|
|
32
34
|
* acting, decides its approvals as it says, and checks the run's ledger against it (`check`:
|
|
33
|
-
* Cucumber runs its `Then` steps).
|
|
35
|
+
* Cucumber runs its `Then` steps). Refuses a world, which runs nothing.
|
|
34
36
|
*/
|
|
35
37
|
export async function runScenario(
|
|
36
38
|
scenario: Scenario,
|
|
37
39
|
{ client, scope, startedBy = "scenarios", timeoutMs }: RunScenarioOptions,
|
|
38
40
|
): Promise<{ runId: string; run: RunSummary; ledger: LedgerRecord[]; checks: Check[] }> {
|
|
41
|
+
if (scenario.workflow === undefined) {
|
|
42
|
+
throw new Error(`Scenario "${scenario.name}" is a world: it seeds a demo's instance, and runs nothing`);
|
|
43
|
+
}
|
|
39
44
|
const runId = await client.start(scenario.workflow, scenario.input, {
|
|
40
45
|
startedBy: scenario.startedBy ?? startedBy,
|
|
41
46
|
sandbox: scenario.name,
|
|
@@ -64,6 +69,13 @@ export interface RunScenariosOptions {
|
|
|
64
69
|
log?: (line: string) => void;
|
|
65
70
|
}
|
|
66
71
|
|
|
72
|
+
/**
|
|
73
|
+
* True for a scenario that is a test: one that runs a workflow (not a world), and not a moment
|
|
74
|
+
* that decides nothing, which a demo leaves waiting at its first approval.
|
|
75
|
+
*/
|
|
76
|
+
export const isTest = (scenario: Scenario): scenario is Scenario & { workflow: string } =>
|
|
77
|
+
scenario.workflow !== undefined && !(scenario.moment && scenario.decisions.length === 0);
|
|
78
|
+
|
|
67
79
|
/**
|
|
68
80
|
* Runs every scenario in `scenarios/` in the config's root, one after another, as sandbox runs
|
|
69
81
|
* on a worker it starts on `databaseUrl` with an in-memory ledger (the config's `ledger` is not
|
|
@@ -72,6 +84,7 @@ export interface RunScenariosOptions {
|
|
|
72
84
|
* could not be read, with why. A scenario whose run cannot be driven to its end (an approval
|
|
73
85
|
* with no decision, a run still going after `timeoutMs`) fails with that error at its `When`
|
|
74
86
|
* step. A config whose root has no `scenarios/`, or none in it, passes nothing and fails nothing.
|
|
87
|
+
* Worlds and moments that decide nothing are no tests (`isTest`): they are left out, each logged as skipped.
|
|
75
88
|
*/
|
|
76
89
|
export async function runScenarios(
|
|
77
90
|
config: SanomaConfig,
|
|
@@ -79,7 +92,9 @@ export async function runScenarios(
|
|
|
79
92
|
): Promise<{ passed: string[]; failed: ScenarioFailure[] }> {
|
|
80
93
|
const shared: SanomaConfig = { ...config, databaseUrl, ledger: memoryLedger() };
|
|
81
94
|
const resolved = resolveConfig(shared);
|
|
82
|
-
const { scenarios, errors } = await loadScenarios(resolved);
|
|
95
|
+
const { scenarios: loaded, errors } = await loadScenarios(resolved);
|
|
96
|
+
const scenarios = loaded.filter(isTest);
|
|
97
|
+
for (const s of loaded) if (!isTest(s)) log?.(`skipped ${s.file} › ${s.name}: ${s.moment ? "a moment" : "a world"}`);
|
|
83
98
|
const failed: ScenarioFailure[] = errors.map(({ file, message }) => ({
|
|
84
99
|
scenario: file,
|
|
85
100
|
step: "did not load",
|
|
@@ -88,8 +103,9 @@ export async function runScenarios(
|
|
|
88
103
|
const passed: string[] = [];
|
|
89
104
|
if (scenarios.length === 0) return { passed, failed };
|
|
90
105
|
|
|
91
|
-
// Quiet: the scenarios call the fakes alone, so no vendor need be configured
|
|
92
|
-
|
|
106
|
+
// Quiet: the scenarios call the fakes alone, so no vendor need be configured; and no schedule
|
|
107
|
+
// is applied, since a tick's run would be live.
|
|
108
|
+
const worker = await startWorker(shared, { promote: true, quiet: true, schedules: false });
|
|
93
109
|
try {
|
|
94
110
|
const client = await SanomaClient.connect(shared);
|
|
95
111
|
try {
|
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") =>
|