@sanoma-ai/workflows 0.5.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 +49 -15
- 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 +6 -3
- package/dist/describe.js +11 -7
- 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 +38 -2
- package/dist/shared.js +50 -4
- package/dist/shared.js.map +1 -1
- package/dist/sites.d.ts +38 -1
- package/dist/sites.js +153 -26
- package/dist/sites.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 +19 -7
- 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 +62 -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 +62 -4
- package/src/sites.ts +198 -33
- 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/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,9 +55,34 @@ 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
|
|
|
79
|
+
/**
|
|
80
|
+
* A public site's language, as `defineSite({ locales })` names it: a BCP 47 language tag of a
|
|
81
|
+
* language and subtags (`en`, `fr`, `pt-BR`), which is also its URL segment (`/pt-BR/…`) and a
|
|
82
|
+
* content file's suffix (`<slug>.pt-BR.md`).
|
|
83
|
+
*/
|
|
84
|
+
export const LOCALE = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
|
|
85
|
+
|
|
58
86
|
/** A public site's domain: a host name of two labels or more, lowercase (`sanoma.example`). */
|
|
59
87
|
export const DOMAIN_NAME = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
|
|
60
88
|
|
|
@@ -177,8 +205,30 @@ function checkField(where: string, field: string, kind: unknown): void {
|
|
|
177
205
|
*/
|
|
178
206
|
export const CALL_WORKFLOW = "call";
|
|
179
207
|
|
|
180
|
-
/**
|
|
181
|
-
|
|
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
|
+
]);
|
|
182
232
|
|
|
183
233
|
/**
|
|
184
234
|
* Every run status, in the order a run may pass through them: waiting on the queue, running,
|
|
@@ -270,8 +320,12 @@ export function visitorPrincipal(login: string, subject: string): Principal {
|
|
|
270
320
|
/** Who a public page runs as when nobody is signed in. */
|
|
271
321
|
export const ANONYMOUS_PRINCIPAL: Principal = Object.freeze({ id: ANONYMOUS, groups: [] });
|
|
272
322
|
|
|
273
|
-
/** The runtime's
|
|
274
|
-
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];
|
|
275
329
|
|
|
276
330
|
/** The declared resources of one of the runtime's people types, by `desired`. */
|
|
277
331
|
const peopleOfType = <T>(resources: readonly PeopleResource[], type: "person" | "agent" | "group") =>
|
|
@@ -395,6 +449,10 @@ export function problemLine(p: { file?: string; line?: number; column?: number;
|
|
|
395
449
|
return p.line === undefined ? `${p.file}: ${p.message}` : `${p.file}:${p.line}:${p.column ?? 1}: ${p.message}`;
|
|
396
450
|
}
|
|
397
451
|
|
|
452
|
+
/** A warning as one line, as `problemLine` with `warning: ` before its message: what is worth fixing but not wrong. */
|
|
453
|
+
export const warningLine = (p: Parameters<typeof problemLine>[0]): string =>
|
|
454
|
+
problemLine({ ...p, message: `warning: ${p.message}` });
|
|
455
|
+
|
|
398
456
|
/**
|
|
399
457
|
* Whether a host is this machine: `localhost`, `127.x.x.x` or `::1` (bare, or bracketed as a URL's
|
|
400
458
|
* `hostname` has it). The one rule for what the app listens on and answers to, and for where the
|
package/src/sites.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
apiNameOf,
|
|
13
13
|
BUILT_IN_WORKFLOWS,
|
|
14
14
|
DATA_VENDOR,
|
|
15
|
+
LOCALE,
|
|
15
16
|
SITE_NAME,
|
|
16
17
|
VISITORS,
|
|
17
18
|
WORKFLOW_ACTION,
|
|
@@ -51,6 +52,18 @@ export interface SiteCall {
|
|
|
51
52
|
page?: string;
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
/** A page of a site: a route file's path, and for a page per content entry the kind it reads. */
|
|
56
|
+
export interface SitePage {
|
|
57
|
+
/** As `SiteCall.page` has it: `/sites/www`, `/sites/www/blog/$slug`. */
|
|
58
|
+
path: string;
|
|
59
|
+
/**
|
|
60
|
+
* For a path with one parameter (`$slug`), the content kind whose entries are its pages: the one
|
|
61
|
+
* kind its file reads by that parameter, `content.<kind>.get(params.slug)` (or `slug`,
|
|
62
|
+
* destructured) with `content` from `sanoma/site`.
|
|
63
|
+
*/
|
|
64
|
+
content?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
54
67
|
/** A site, `sites/<name>/`, and what it calls. */
|
|
55
68
|
export interface SiteEntry {
|
|
56
69
|
/** Its folder's name, where it is mounted: `/sites/<name>`. */
|
|
@@ -61,8 +74,15 @@ export interface SiteEntry {
|
|
|
61
74
|
public?: true;
|
|
62
75
|
/** `defineSite({ login })`, the connector whose login half signs visitors in, as written: one that names none is among `problems`. */
|
|
63
76
|
login?: string;
|
|
77
|
+
/**
|
|
78
|
+
* `defineSite({ locales })`, a public site's languages, the first its default: kept only when
|
|
79
|
+
* every one is a `LOCALE`, written once, on a public site with no login; else a problem.
|
|
80
|
+
*/
|
|
81
|
+
locales?: string[];
|
|
64
82
|
/** The host a public site answers on: set by `startApp` from its `domains`, never by `readSites`. */
|
|
65
83
|
domain?: string;
|
|
84
|
+
/** Its pages, by path: one for each path its `routes/` files give (a layout and its index are one). */
|
|
85
|
+
pages: SitePage[];
|
|
66
86
|
/** Its calls, by file and then in the file's order. */
|
|
67
87
|
calls: SiteCall[];
|
|
68
88
|
/**
|
|
@@ -71,6 +91,11 @@ export interface SiteEntry {
|
|
|
71
91
|
* make, at its file; then what is wrong in its `site.ts`.
|
|
72
92
|
*/
|
|
73
93
|
problems: (LintProblem & { file: string })[];
|
|
94
|
+
/**
|
|
95
|
+
* On a public site, each page with one parameter whose file reads content the sitemap cannot
|
|
96
|
+
* tie to it (`SitePage.content`), so the sitemap lists none of its pages: at the first read.
|
|
97
|
+
*/
|
|
98
|
+
warnings: (LintProblem & { file: string })[];
|
|
74
99
|
}
|
|
75
100
|
|
|
76
101
|
/** Where a site's code is: its pages and its components. */
|
|
@@ -78,6 +103,8 @@ const SITE_DIRS = ["routes", "components"];
|
|
|
78
103
|
/** Where `api`, `RunForm` and `defineSite` come from: `sanoma/site`, which re-exports the app's. */
|
|
79
104
|
const SITE_SOURCES: ReadonlySet<string> = new Set(["sanoma/site", "@sanoma-ai/app/site"]);
|
|
80
105
|
const KIND_NAMES = { workflow: "workflow", collection: "collection", op: "operation" } as const;
|
|
106
|
+
/** What a route's loader and head name its path's parameters by. */
|
|
107
|
+
const PARAMS: ReadonlySet<string> = new Set(["params"]);
|
|
81
108
|
/** What makes a route from its id, whose first argument is the id. */
|
|
82
109
|
const ROUTE_MAKERS: ReadonlySet<string> = new Set(["createFileRoute", "createLazyFileRoute"]);
|
|
83
110
|
|
|
@@ -85,7 +112,7 @@ const ROUTE_MAKERS: ReadonlySet<string> = new Set(["createFileRoute", "createLaz
|
|
|
85
112
|
* The sites under `root`: each folder of `sites/` with a `routes/` whose name is a URL segment,
|
|
86
113
|
* by name (as `@sanoma-ai/app/build`'s `sitesOf` mounts them). Reads `routes/**` and
|
|
87
114
|
* `components/**` (`.ts` and `.tsx`, less tests and declarations) of each with oxc, never running
|
|
88
|
-
* them: each `<RunForm workflow="…" />` and each use of `api`, both imported from `sanoma/site`,
|
|
115
|
+
* them: each page (`SitePage`, from a route's id), each `<RunForm workflow="…" />` and each use of `api`, both imported from `sanoma/site`,
|
|
89
116
|
* called or passed on (`queryFn: api.data.leads.list`):
|
|
90
117
|
* `api.<name>` and `api.<name>.<method>` are a workflow, `api.data.<collection>.<op>` a collection,
|
|
91
118
|
* and `api.<vendor>.<resource>.<op>` an operation. A name that is not written out (a variable, a
|
|
@@ -107,7 +134,6 @@ export function readSites(
|
|
|
107
134
|
): SiteEntry[] {
|
|
108
135
|
if (root === undefined) return [];
|
|
109
136
|
const dir = join(root, "sites");
|
|
110
|
-
if (!isDirectory(dir)) return [];
|
|
111
137
|
const workflows = new Set(description.workflows.map((wf) => wf.name));
|
|
112
138
|
const collections = new Set(description.collections.map((c) => c.name));
|
|
113
139
|
const ops = new Map(description.ops.map((op) => [op.id, op]));
|
|
@@ -167,39 +193,105 @@ export function readSites(
|
|
|
167
193
|
return { call: { kind, target, via, ...at }, ...(problem && { problem }) };
|
|
168
194
|
};
|
|
169
195
|
|
|
170
|
-
return
|
|
171
|
-
|
|
172
|
-
.
|
|
196
|
+
return siteNames(dir).map((name) => {
|
|
197
|
+
const read = readSiteFiles(root, name);
|
|
198
|
+
const resolved = read.flatMap(({ uses }) => uses.map(resolve));
|
|
199
|
+
const pages = pagesOf(read);
|
|
200
|
+
const siteFile = `sites/${name}/site.ts`;
|
|
201
|
+
const { problems: optionProblems, ...options } = siteOptionsOf(join(root, siteFile), connectors);
|
|
202
|
+
const calls = resolved.map((r) => r.call);
|
|
203
|
+
const problems = [
|
|
204
|
+
...resolved.flatMap(({ call, problem }) => {
|
|
205
|
+
const message = problem ?? (options.public ? refused(call, options.login) : undefined);
|
|
206
|
+
return message === undefined ? [] : [{ file: call.file, line: call.line, column: call.column, message }];
|
|
207
|
+
}),
|
|
208
|
+
...optionProblems.map((p) => ({ file: siteFile, ...p })),
|
|
209
|
+
];
|
|
210
|
+
const sorted = [...pages.keys()].toSorted().map((path) => pages.get(path)!);
|
|
211
|
+
// A page's file it warns of, unless another of its files (a lazy one, its layout) gives its kind.
|
|
212
|
+
const warnings = options.public
|
|
213
|
+
? read.flatMap(({ route, warning }) => (warning && !pages.get(route!.path)?.content ? [warning] : []))
|
|
214
|
+
: [];
|
|
215
|
+
return { name, ...options, pages: sorted, calls, problems, warnings };
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** A site's pages, by path, from its files: a layout's and its index's (one path) merged. */
|
|
220
|
+
function pagesOf(read: readonly { route?: SitePage }[]): Map<string, SitePage> {
|
|
221
|
+
const pages = new Map<string, SitePage>();
|
|
222
|
+
for (const { route } of read) {
|
|
223
|
+
if (route) pages.set(route.path, { ...pages.get(route.path), ...route });
|
|
224
|
+
}
|
|
225
|
+
return pages;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Each of site `name`'s files under `root`, as `readSiteFile` reads it, by path. */
|
|
229
|
+
const readSiteFiles = (root: string, name: string) =>
|
|
230
|
+
SITE_DIRS.flatMap((sub) => filesUnder(join(root, "sites", name, sub), isSource))
|
|
173
231
|
.toSorted()
|
|
174
|
-
.
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
const file = readSource(path);
|
|
178
|
-
return typeof file === "string" ? [] : usesIn(file, relative(root, path).split(sep).join("/")).map(resolve);
|
|
179
|
-
});
|
|
180
|
-
const siteFile = `sites/${name}/site.ts`;
|
|
181
|
-
const { problems: optionProblems, ...options } = siteOptionsOf(join(root, siteFile), connectors);
|
|
182
|
-
const calls = resolved.map((r) => r.call);
|
|
183
|
-
const problems = [
|
|
184
|
-
...resolved.flatMap(({ call, problem }) => {
|
|
185
|
-
const message = problem ?? (options.public ? refused(call, options.login) : undefined);
|
|
186
|
-
return message === undefined ? [] : [{ file: call.file, line: call.line, column: call.column, message }];
|
|
187
|
-
}),
|
|
188
|
-
...optionProblems.map((p) => ({ file: siteFile, ...p })),
|
|
189
|
-
];
|
|
190
|
-
return { name, ...options, calls, problems };
|
|
232
|
+
.flatMap((path) => {
|
|
233
|
+
const file = readSource(path);
|
|
234
|
+
return typeof file === "string" ? [] : [readSiteFile(file, relative(root, path).split(sep).join("/"))];
|
|
191
235
|
});
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Each site's pages under `root` whose entries are a content kind's (`SitePage.content`), path to
|
|
239
|
+
* kind: what the app's build gives its router (`virtual:sanoma/locales`), so a page lists only the
|
|
240
|
+
* languages its entry is in. Read as `readSites` reads them, never run.
|
|
241
|
+
*/
|
|
242
|
+
export function siteContentPages(root: string): Record<string, string> {
|
|
243
|
+
return Object.fromEntries(
|
|
244
|
+
siteNames(join(root, "sites")).flatMap((name) =>
|
|
245
|
+
[...pagesOf(readSiteFiles(root, name)).values()].flatMap(({ path, content }) =>
|
|
246
|
+
content ? [[path, content]] : [],
|
|
247
|
+
),
|
|
248
|
+
),
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Each site's languages (`defineSite({ locales })`, the first its default) under `root`, by name,
|
|
254
|
+
* for the sites whose `site.ts` gives them without a problem there: what the app's build gives
|
|
255
|
+
* its router (`virtual:sanoma/locales`). Read as `readSites` reads them, never run.
|
|
256
|
+
*/
|
|
257
|
+
export function siteLocales(root: string): Record<string, string[]> {
|
|
258
|
+
const none = new Set<string>();
|
|
259
|
+
return Object.fromEntries(
|
|
260
|
+
siteNames(join(root, "sites")).flatMap((name) => {
|
|
261
|
+
const { locales } = siteOptionsOf(join(root, "sites", name, "site.ts"), { vendors: none, logins: none });
|
|
262
|
+
return locales ? [[name, locales]] : [];
|
|
263
|
+
}),
|
|
264
|
+
);
|
|
192
265
|
}
|
|
193
266
|
|
|
267
|
+
/** The sites in `dir`: each folder with a `routes/` whose name is a URL segment, sorted. */
|
|
268
|
+
const siteNames = (dir: string): string[] =>
|
|
269
|
+
isDirectory(dir)
|
|
270
|
+
? readdirSync(dir, { withFileTypes: true })
|
|
271
|
+
.filter((d) => d.isDirectory() && SITE_NAME.test(d.name) && existsSync(join(dir, d.name, "routes")))
|
|
272
|
+
.map((d) => d.name)
|
|
273
|
+
.toSorted()
|
|
274
|
+
: [];
|
|
275
|
+
|
|
194
276
|
/** A use of `api` or a `RunForm`, as written: the name it gives, before it is resolved. */
|
|
195
277
|
type Written = Omit<SiteCall, "target"> & { name: string };
|
|
196
278
|
|
|
197
|
-
/**
|
|
198
|
-
|
|
279
|
+
/**
|
|
280
|
+
* What one file holds: its uses, in its order, and the page it is, from its route's id, with the
|
|
281
|
+
* content kind whose entry its one parameter names (`SitePage.content`). A file under `routes/` is
|
|
282
|
+
* read whole; any other only when it imports `api` or `RunForm`.
|
|
283
|
+
*/
|
|
284
|
+
function readSiteFile(
|
|
285
|
+
{ program, at }: SourceFile,
|
|
286
|
+
file: string,
|
|
287
|
+
): { uses: Written[]; route?: SitePage; warning?: LintProblem & { file: string } } {
|
|
199
288
|
const api = importedAs(program, SITE_SOURCES, "api");
|
|
200
289
|
const runForm = importedAs(program, SITE_SOURCES, "RunForm");
|
|
201
|
-
|
|
290
|
+
const content = importedAs(program, SITE_SOURCES, "content");
|
|
291
|
+
if (!api.size && !runForm.size && !file.includes("/routes/")) return { uses: [] };
|
|
202
292
|
const found: Omit<Written, "page">[] = [];
|
|
293
|
+
/** Each `content.<kind>.get(arg)`: its kind, its argument and where it is. */
|
|
294
|
+
const gets: { kind: string; arg: Node; start: number }[] = [];
|
|
203
295
|
let page: string | undefined;
|
|
204
296
|
const visit = (node: Node) => {
|
|
205
297
|
if (!node) return;
|
|
@@ -207,12 +299,14 @@ function usesIn({ program, at }: SourceFile, file: string): Written[] {
|
|
|
207
299
|
const named = apiPath(node, api);
|
|
208
300
|
// The outermost member of a path on `api`: what it names, called or not; its inner members are not visited.
|
|
209
301
|
if (named) return void found.push({ ...named, via: "api", file, ...at(node.start) });
|
|
210
|
-
} else if (node.type === "CallExpression"
|
|
302
|
+
} else if (node.type === "CallExpression") {
|
|
211
303
|
const callee = unwrap(node.callee);
|
|
212
|
-
if (callee?.type === "Identifier" && ROUTE_MAKERS.has(callee.name)) {
|
|
304
|
+
if (callee?.type === "Identifier" && ROUTE_MAKERS.has(callee.name) && page === undefined) {
|
|
213
305
|
const id = stringValue(node.arguments[0]);
|
|
214
306
|
if (id !== undefined) page = pageOf(id);
|
|
215
307
|
}
|
|
308
|
+
const kind = contentKind(callee, content);
|
|
309
|
+
if (kind !== undefined) gets.push({ kind, arg: unwrap(node.arguments[0]), start: node.start });
|
|
216
310
|
} else if (node.type === "JSXOpeningElement" && node.name.type === "JSXIdentifier" && runForm.has(node.name.name)) {
|
|
217
311
|
const attribute = node.attributes.find(
|
|
218
312
|
(a: Node) => a.type === "JSXAttribute" && a.name.type === "JSXIdentifier" && a.name.name === "workflow",
|
|
@@ -224,11 +318,46 @@ function usesIn({ program, at }: SourceFile, file: string): Written[] {
|
|
|
224
318
|
for (const child of childrenOf(node)) visit(child);
|
|
225
319
|
};
|
|
226
320
|
visit(program);
|
|
227
|
-
|
|
321
|
+
const uses = found.map((use) => (page === undefined ? use : { ...use, page }));
|
|
322
|
+
if (page === undefined) return { uses };
|
|
323
|
+
// The one parameter's name (not a splat's), and the kinds whose `get` is given it (`params.slug`, or `slug` destructured).
|
|
324
|
+
const params = page.split("/").filter((segment) => segment.startsWith("$"));
|
|
325
|
+
if (params.length !== 1 || params[0] === "$") return { uses, route: { path: page } };
|
|
326
|
+
const param = params[0]!.slice(1);
|
|
327
|
+
const kinds = new Set(
|
|
328
|
+
gets
|
|
329
|
+
.filter(({ arg }) =>
|
|
330
|
+
arg?.type === "Identifier"
|
|
331
|
+
? arg.name === param
|
|
332
|
+
: arg?.type === "MemberExpression" && memberPath(arg, PARAMS)?.join(".") === param,
|
|
333
|
+
)
|
|
334
|
+
.map(({ kind }) => kind),
|
|
335
|
+
);
|
|
336
|
+
if (kinds.size === 1) return { uses, route: { path: page, content: [...kinds][0]! } };
|
|
337
|
+
// A read by the parameter in a form the sitemap does not follow (`params.id.toLowerCase()`, two kinds).
|
|
338
|
+
const unfollowed = gets.find(({ arg }) => mentions(arg, param));
|
|
339
|
+
if (!unfollowed) return { uses, route: { path: page } };
|
|
340
|
+
const message =
|
|
341
|
+
`the sitemap lists none of this page's entries: it reads content by the page's parameter in a way it cannot follow. ` +
|
|
342
|
+
`Read one kind by it as written, \`content.<kind>.get(params.${param})\` (or \`${param}\` destructured)`;
|
|
343
|
+
return { uses, route: { path: page }, warning: { file, ...at(unfollowed.start), message } };
|
|
228
344
|
}
|
|
229
345
|
|
|
230
|
-
/**
|
|
231
|
-
|
|
346
|
+
/** Whether `node` reads the parameter `param` anywhere in it: `params.<param>`, or `param` destructured (not another parameter, nor a property's name, `post.slug` or `{ slug: x }`). */
|
|
347
|
+
const mentions = (node: Node, param: string): boolean => {
|
|
348
|
+
if (!node) return false;
|
|
349
|
+
if (node.type === "Identifier") return node.name === param;
|
|
350
|
+
if (node.type === "Property" && !node.computed) return mentions(node.value, param);
|
|
351
|
+
if (node.type === "MemberExpression") {
|
|
352
|
+
const path = memberPath(node, PARAMS);
|
|
353
|
+
if (path) return path[0] === param;
|
|
354
|
+
if (!node.computed) return mentions(node.object, param);
|
|
355
|
+
}
|
|
356
|
+
return childrenOf(node).some((child) => mentions(child, param));
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
/** The names of a member path on one of `roots` (`api.data.leads.list` is `data`, `leads`, `list`); undefined for a path on anything else or a name not written out. */
|
|
360
|
+
function memberPath(member: Node, roots: ReadonlySet<string>): string[] | undefined {
|
|
232
361
|
const path: string[] = [];
|
|
233
362
|
let node = member;
|
|
234
363
|
while (node?.type === "MemberExpression") {
|
|
@@ -237,7 +366,19 @@ function apiPath(member: Node, api: ReadonlySet<string>): Pick<Written, "kind" |
|
|
|
237
366
|
path.unshift(key);
|
|
238
367
|
node = unwrap(node.object);
|
|
239
368
|
}
|
|
240
|
-
|
|
369
|
+
return node?.type === "Identifier" && roots.has(node.name) ? path : undefined;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** The kind of `content.<kind>.get`, a callee on `content` imported from `sanoma/site`; undefined for any other. */
|
|
373
|
+
function contentKind(callee: Node, content: ReadonlySet<string>): string | undefined {
|
|
374
|
+
const path = callee?.type === "MemberExpression" ? memberPath(callee, content) : undefined;
|
|
375
|
+
return path?.length === 2 && path[1] === "get" ? path[0] : undefined;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** What a member path on `api` names; undefined for a path on anything else, a name not written out, or no name. */
|
|
379
|
+
function apiPath(member: Node, api: ReadonlySet<string>): Pick<Written, "kind" | "name" | "method"> | undefined {
|
|
380
|
+
const path = memberPath(member, api);
|
|
381
|
+
if (!path?.length) return undefined;
|
|
241
382
|
const [first, second, third] = path as [string, ...string[]];
|
|
242
383
|
if (first === DATA_VENDOR)
|
|
243
384
|
return path.length === 3 ? { kind: "collection", name: second!, method: third } : undefined;
|
|
@@ -273,13 +414,14 @@ const unread = (what: string, as: string) => `${what} is read without running si
|
|
|
273
414
|
/**
|
|
274
415
|
* What `site.ts` says of its site, read without running it, and what is wrong there: its default
|
|
275
416
|
* export must be `defineSite({ … })` written out, so it can be read at all; then `title` when it is
|
|
276
|
-
* a string, `public` and `
|
|
417
|
+
* a string, `public`, `login` and `locales`, which must be literals. `login` is kept as written; one on a
|
|
277
418
|
* site that is not public, or naming no connector of the config with a login half, is a problem.
|
|
419
|
+
* `locales` is kept only when it has no problem.
|
|
278
420
|
*/
|
|
279
421
|
function siteOptionsOf(
|
|
280
422
|
path: string,
|
|
281
423
|
connectors: SiteConnectors,
|
|
282
|
-
): Pick<SiteEntry, "title" | "public" | "login"> & { problems: LintProblem[] } {
|
|
424
|
+
): Pick<SiteEntry, "title" | "public" | "login" | "locales"> & { problems: LintProblem[] } {
|
|
283
425
|
const file = readSource(path);
|
|
284
426
|
if (typeof file === "string") return { problems: [] };
|
|
285
427
|
const problems: LintProblem[] = [...file.problems];
|
|
@@ -329,10 +471,33 @@ function siteOptionsOf(
|
|
|
329
471
|
};
|
|
330
472
|
const message = loginAt && loginProblem();
|
|
331
473
|
if (message) problem(loginAt, message);
|
|
474
|
+
|
|
475
|
+
const localesAt = property("locales");
|
|
476
|
+
const list = unwrap(localesAt?.value);
|
|
477
|
+
// A hole or a spread is no string.
|
|
478
|
+
const tags: (string | undefined)[] | undefined =
|
|
479
|
+
list?.type === "ArrayExpression" ? list.elements.map((e: Node) => stringValue(e)) : undefined;
|
|
480
|
+
const locales = tags?.every((tag): tag is string => tag !== undefined) ? tags : undefined;
|
|
481
|
+
const localesProblem = (): string | undefined => {
|
|
482
|
+
if (locales === undefined) return unread("`locales`", "an array of strings");
|
|
483
|
+
if (!locales.length) return "`locales` names a site's languages, its default first: give one or more, or remove it";
|
|
484
|
+
const bad = locales.find((locale) => !LOCALE.test(locale));
|
|
485
|
+
if (bad !== undefined) return `"${bad}" is not a language tag such as "en", "fr" or "pt-BR"`;
|
|
486
|
+
const twice = locales.find((locale, i) => locales.indexOf(locale) !== i);
|
|
487
|
+
if (twice !== undefined) return `"${twice}" is in \`locales\` twice`;
|
|
488
|
+
// When `public` is what is wrong, it is said there.
|
|
489
|
+
if ((!isPublic && !publicUnread) || loginAt) {
|
|
490
|
+
return "`locales` are for a public site with no login: add `public: true`, or remove `locales`";
|
|
491
|
+
}
|
|
492
|
+
return undefined;
|
|
493
|
+
};
|
|
494
|
+
const localesMessage = localesAt && localesProblem();
|
|
495
|
+
if (localesMessage) problem(localesAt, localesMessage);
|
|
332
496
|
return {
|
|
333
497
|
...(title !== undefined && { title }),
|
|
334
498
|
...(isPublic && { public: true as const }),
|
|
335
499
|
...(login !== undefined && { login }),
|
|
500
|
+
...(localesAt && !localesMessage && isPublic && !loginAt && { locales }),
|
|
336
501
|
problems,
|
|
337
502
|
};
|
|
338
503
|
}
|
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
|
/**
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { resolveConfig, type SanomaConfig } from "./config.ts";
|
|
2
|
+
import { shouldWake } from "./db.ts";
|
|
3
|
+
import type { SelfStop } from "./deadlines.ts";
|
|
4
|
+
import { warn } from "./log.ts";
|
|
5
|
+
import { errorMessage } from "./shared.ts";
|
|
6
|
+
import { startWorker, type Worker, type WorkerOptions } from "./worker.ts";
|
|
7
|
+
|
|
8
|
+
// One worker at a time in a process that outlives it. A worker that stops itself when idle (a
|
|
9
|
+
// wakeup declared) cannot launch again, so the next write that needs one, or the wakeup's wake,
|
|
10
|
+
// starts a fresh worker. `sanoma serve` runs its worker through it, and so does the testing
|
|
11
|
+
// kit's host in this process.
|
|
12
|
+
|
|
13
|
+
/** A worker under a supervisor (`superviseWorker`). */
|
|
14
|
+
export interface Supervisor {
|
|
15
|
+
/** True from the moment the worker stopped itself until a fresh one starts. */
|
|
16
|
+
readonly stopped: boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Starts a fresh worker when the last one stopped itself, for a write that needs one (a run's
|
|
19
|
+
* start, an approval's decision); while one starts or runs, it is heard (below). Resolves once it runs, or
|
|
20
|
+
* once its failure to start is logged: the next call tries again. Never rejects.
|
|
21
|
+
*/
|
|
22
|
+
resume(): Promise<void>;
|
|
23
|
+
/**
|
|
24
|
+
* A wakeup's `GET /wake?generation=N`: `resume` when the worker stopped with `generation`
|
|
25
|
+
* registered (`shouldWake`); a no-op for any other, and while one starts or runs.
|
|
26
|
+
*/
|
|
27
|
+
wake(generation: number): 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
|
+
let databaseUrl: string | undefined;
|
|
57
|
+
|
|
58
|
+
const launch = async () => {
|
|
59
|
+
heard = false;
|
|
60
|
+
const started = await startWorker(config, { ...workerOptions, ...(selfStop && { onStopped: selfStopped }) });
|
|
61
|
+
if (closed) await started.stop();
|
|
62
|
+
else worker = started;
|
|
63
|
+
};
|
|
64
|
+
const selfStopped = (stopped: SelfStop) => {
|
|
65
|
+
worker = undefined;
|
|
66
|
+
if (closed) return;
|
|
67
|
+
// Resumed first, so `stopped` is false when `onStopped` (the exit rule) looks.
|
|
68
|
+
if (heard) void supervisor.resume();
|
|
69
|
+
onStopped?.(stopped);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const supervisor: Supervisor = {
|
|
73
|
+
get stopped() {
|
|
74
|
+
return !closed && !worker && !starting;
|
|
75
|
+
},
|
|
76
|
+
resume() {
|
|
77
|
+
if (closed) return Promise.resolve();
|
|
78
|
+
if (worker) {
|
|
79
|
+
heard = true;
|
|
80
|
+
return Promise.resolve();
|
|
81
|
+
}
|
|
82
|
+
if (starting) {
|
|
83
|
+
heard = true;
|
|
84
|
+
return starting;
|
|
85
|
+
}
|
|
86
|
+
return (starting = launch()
|
|
87
|
+
.catch((err: unknown) => warn(`the worker could not start again: ${errorMessage(err)}`))
|
|
88
|
+
.finally(() => {
|
|
89
|
+
starting = undefined;
|
|
90
|
+
}));
|
|
91
|
+
},
|
|
92
|
+
async wake(generation) {
|
|
93
|
+
if (!supervisor.stopped) return;
|
|
94
|
+
databaseUrl ??= resolveConfig(config).databaseUrl;
|
|
95
|
+
if (await shouldWake(databaseUrl, generation)) await supervisor.resume();
|
|
96
|
+
},
|
|
97
|
+
async stop() {
|
|
98
|
+
closed = true;
|
|
99
|
+
await starting;
|
|
100
|
+
const running = worker;
|
|
101
|
+
worker = undefined;
|
|
102
|
+
await running?.stop();
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
await launch();
|
|
106
|
+
return supervisor;
|
|
107
|
+
}
|
package/src/version.ts
CHANGED
|
@@ -16,8 +16,10 @@ export const RUNTIME_VERSION = (
|
|
|
16
16
|
* package release, so a run in flight on the old layout is not replayed against the new one.
|
|
17
17
|
* 2: calls run in program order; a decision event per approval (2026-10-07).
|
|
18
18
|
* 3: a start the policy holds waits on its approval before the body (2026-10-21).
|
|
19
|
+
* 4: a sleep for a duration reads DBOS's clock for its start (2026-10-25).
|
|
20
|
+
* 5: a scheduled run admits its starter and asks the policy in two steps before the body (2026-10-25).
|
|
19
21
|
*/
|
|
20
|
-
export const STEP_LAYOUT =
|
|
22
|
+
export const STEP_LAYOUT = 5;
|
|
21
23
|
|
|
22
24
|
let dbosVersion: string | undefined;
|
|
23
25
|
|