@sanoma-ai/workflows 0.7.1 → 0.8.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 +34 -7
- package/dist/apps-list.d.ts +90 -0
- package/dist/apps-list.js +109 -0
- package/dist/apps-list.js.map +1 -0
- package/dist/apps.d.ts +42 -0
- package/dist/apps.js +137 -0
- package/dist/apps.js.map +1 -0
- package/dist/call-workflow.js +5 -6
- package/dist/call-workflow.js.map +1 -1
- package/dist/call.js +6 -3
- package/dist/call.js.map +1 -1
- package/dist/cedar.d.ts +1 -1
- package/dist/cedar.js +6 -4
- package/dist/cedar.js.map +1 -1
- package/dist/change-steps.d.ts +10 -0
- package/dist/change-steps.js +204 -0
- package/dist/change-steps.js.map +1 -0
- package/dist/change-workflow.d.ts +54 -0
- package/dist/change-workflow.js +161 -0
- package/dist/change-workflow.js.map +1 -0
- package/dist/change.d.ts +121 -24
- package/dist/change.js +196 -17
- package/dist/change.js.map +1 -1
- package/dist/claims.d.ts +58 -0
- package/dist/claims.js +27 -0
- package/dist/claims.js.map +1 -0
- package/dist/client.d.ts +92 -0
- package/dist/client.js +268 -6
- package/dist/client.js.map +1 -1
- package/dist/collection.d.ts +6 -1
- package/dist/collection.js +11 -3
- package/dist/collection.js.map +1 -1
- package/dist/config.d.ts +30 -2
- package/dist/config.js +41 -20
- package/dist/config.js.map +1 -1
- package/dist/db.d.ts +12 -0
- package/dist/db.js +15 -0
- package/dist/db.js.map +1 -1
- package/dist/deadlines.d.ts +5 -2
- package/dist/deadlines.js.map +1 -1
- package/dist/define.d.ts +30 -14
- package/dist/define.js +9 -1
- package/dist/define.js.map +1 -1
- package/dist/demo.d.ts +15 -4
- package/dist/demo.js +27 -12
- package/dist/demo.js.map +1 -1
- package/dist/describe.d.ts +16 -2
- package/dist/describe.js +39 -4
- package/dist/describe.js.map +1 -1
- package/dist/direct.d.ts +4 -3
- package/dist/direct.js +7 -4
- package/dist/direct.js.map +1 -1
- package/dist/errors.d.ts +2 -0
- package/dist/errors.js +2 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +7 -5
- package/dist/index.js +6 -3
- package/dist/index.js.map +1 -1
- package/dist/lint.js +19 -5
- package/dist/lint.js.map +1 -1
- package/dist/outline.js +13 -6
- package/dist/outline.js.map +1 -1
- package/dist/paths.d.ts +16 -0
- package/dist/paths.js +54 -2
- package/dist/paths.js.map +1 -1
- package/dist/resources.d.ts +7 -2
- package/dist/resources.js +23 -8
- package/dist/resources.js.map +1 -1
- package/dist/run.d.ts +7 -0
- package/dist/run.js.map +1 -1
- package/dist/sandbox.js +2 -2
- package/dist/sandbox.js.map +1 -1
- package/dist/scenario.d.ts +2 -2
- package/dist/scenario.js +10 -4
- package/dist/scenario.js.map +1 -1
- package/dist/scenarios.d.ts +1 -1
- package/dist/scenarios.js.map +1 -1
- package/dist/sdk.d.ts +1 -1
- package/dist/sdk.js +19 -5
- package/dist/sdk.js.map +1 -1
- package/dist/shared.d.ts +43 -2
- package/dist/shared.js +46 -4
- package/dist/shared.js.map +1 -1
- package/dist/sites.d.ts +26 -5
- package/dist/sites.js +56 -20
- package/dist/sites.js.map +1 -1
- package/dist/source.d.ts +55 -5
- package/dist/source.js +4 -0
- package/dist/source.js.map +1 -1
- package/dist/version.d.ts +8 -0
- package/dist/version.js +21 -13
- package/dist/version.js.map +1 -1
- package/dist/worker.js +50 -3
- package/dist/worker.js.map +1 -1
- package/oxlint.json +2 -2
- package/package.json +1 -1
- package/src/apps-list.ts +119 -0
- package/src/apps.ts +162 -0
- package/src/call-workflow.ts +10 -2
- package/src/call.ts +6 -3
- package/src/cedar.ts +9 -7
- package/src/change-steps.ts +274 -0
- package/src/change-workflow.ts +198 -0
- package/src/change.ts +257 -27
- package/src/claims.ts +79 -0
- package/src/client.ts +320 -4
- package/src/collection.ts +22 -3
- package/src/config.ts +66 -22
- package/src/db.ts +23 -0
- package/src/deadlines.ts +5 -2
- package/src/define.ts +31 -12
- package/src/demo.ts +55 -24
- package/src/describe.ts +67 -4
- package/src/direct.ts +9 -5
- package/src/errors.ts +3 -0
- package/src/index.ts +14 -0
- package/src/lint.ts +20 -5
- package/src/outline.ts +15 -6
- package/src/paths.ts +57 -2
- package/src/resources.ts +27 -9
- package/src/run.ts +5 -0
- package/src/sandbox.ts +2 -5
- package/src/scenario.ts +14 -4
- package/src/scenarios.ts +1 -1
- package/src/sdk.ts +21 -7
- package/src/shared.ts +60 -5
- package/src/sites.ts +70 -24
- package/src/source.ts +50 -5
- package/src/version.ts +25 -16
- package/src/worker.ts +46 -3
package/src/sites.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
-
import { join, relative, sep } from "node:path";
|
|
2
|
+
import { basename, join, relative, sep } from "node:path";
|
|
3
3
|
import { childrenOf, type LintProblem, type Node, readSource, type SourceFile } from "./ast.ts";
|
|
4
4
|
import { type CedarPolicySource, visitorPermits } from "./cedar.ts";
|
|
5
5
|
import { keyName } from "./datafile.ts";
|
|
6
6
|
import type { ConfigDescription } from "./describe.ts";
|
|
7
7
|
import { stringValue, unwrap } from "./outline.ts";
|
|
8
|
-
import { filesUnder, isDirectory } from "./paths.ts";
|
|
8
|
+
import { APPS_DIR, appFolders, filesUnder, isDirectory } from "./paths.ts";
|
|
9
9
|
import { START } from "./schema.ts";
|
|
10
10
|
import {
|
|
11
11
|
ANONYMOUS,
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
DATA_VENDOR,
|
|
15
15
|
LOCALE,
|
|
16
16
|
SITE_NAME,
|
|
17
|
+
siteKey,
|
|
17
18
|
VISITORS,
|
|
18
19
|
WORKFLOW_ACTION,
|
|
19
20
|
} from "./shared.ts";
|
|
@@ -64,12 +65,16 @@ export interface SitePage {
|
|
|
64
65
|
content?: string;
|
|
65
66
|
}
|
|
66
67
|
|
|
67
|
-
/** A site, `sites/<name>/`, and what it calls. */
|
|
68
|
+
/** A site, `sites/<name>/` or an app's `apps/<app>/sites/<name>/`, and what it calls. */
|
|
68
69
|
export interface SiteEntry {
|
|
69
|
-
/** Its folder's name, where it is mounted
|
|
70
|
+
/** Its folder's name, `<app>/<name>` for an app's (`siteKey`): where it is mounted, `/sites/<name>`. */
|
|
70
71
|
name: string;
|
|
72
|
+
/** The app whose folder holds it (`apps/<app>/sites/`); absent for one at the root. */
|
|
73
|
+
app?: string;
|
|
71
74
|
/** `defineSite({ title })` in its `site.ts`, when that is a string literal. */
|
|
72
75
|
title?: string;
|
|
76
|
+
/** `defineSite({ icon })` in its `site.ts`: a Lucide icon's name, as written. */
|
|
77
|
+
icon?: string;
|
|
73
78
|
/** `defineSite({ public: true })`: served to visitors, not people. */
|
|
74
79
|
public?: true;
|
|
75
80
|
/** `defineSite({ login })`, the connector whose login half signs visitors in, as written: one that names none is among `problems`. */
|
|
@@ -109,12 +114,14 @@ const PARAMS: ReadonlySet<string> = new Set(["params"]);
|
|
|
109
114
|
const ROUTE_MAKERS: ReadonlySet<string> = new Set(["createFileRoute", "createLazyFileRoute"]);
|
|
110
115
|
|
|
111
116
|
/**
|
|
112
|
-
* The sites under `root
|
|
113
|
-
*
|
|
117
|
+
* The sites under `root` (`sitesUnder`): each folder of `sites/`, then of each app's
|
|
118
|
+
* `apps/<app>/sites/`, with a `routes/` whose name is a URL segment, by key (as
|
|
119
|
+
* `@sanoma-ai/app/build`'s `sitesOf` mounts them). Reads `routes/**` and
|
|
114
120
|
* `components/**` (`.ts` and `.tsx`, less tests and declarations) of each with oxc, never running
|
|
115
121
|
* them: each page (`SitePage`, from a route's id), each `<RunForm workflow="…" />` and each use of `api`, both imported from `sanoma/site`,
|
|
116
122
|
* called or passed on (`queryFn: api.data.leads.list`):
|
|
117
|
-
* `api.<name>` and `api.<name>.<method>` are a workflow, `api.data.<collection>.<op>`
|
|
123
|
+
* `api.<name>` and `api.<name>.<method>` are a workflow, `api.data.<collection>.<op>` (an app's
|
|
124
|
+
* `api.data.<app>.<collection>.<op>`) a collection,
|
|
118
125
|
* and `api.<vendor>.<resource>.<op>` an operation. A name that is not written out (a variable, a
|
|
119
126
|
* template with holes, a computed member) is skipped, as is a use through a namespace import or a
|
|
120
127
|
* destructured `api`. A call to what `description` does not have, or to an operation a page may
|
|
@@ -133,9 +140,11 @@ export function readSites(
|
|
|
133
140
|
policies?: readonly CedarPolicySource[],
|
|
134
141
|
): SiteEntry[] {
|
|
135
142
|
if (root === undefined) return [];
|
|
136
|
-
const dir = join(root, "sites");
|
|
137
143
|
const workflows = new Set(description.workflows.map((wf) => wf.name));
|
|
138
144
|
const collections = new Set(description.collections.map((c) => c.name));
|
|
145
|
+
// `api.data.<app>.<collection>` passed on as a value reads as a call of `<app>`'s method: it is none.
|
|
146
|
+
const isAppCollection = ({ kind, name, method }: Written) =>
|
|
147
|
+
kind === "collection" && collections.has(`${name}.${method}`);
|
|
139
148
|
const ops = new Map(description.ops.map((op) => [op.id, op]));
|
|
140
149
|
// A workflow's api name to its name: two of one api name are a problem on the workflows.
|
|
141
150
|
const byApiName = new Map(description.workflows.map((wf) => [apiNameOf(wf.name), wf.name]));
|
|
@@ -193,11 +202,11 @@ export function readSites(
|
|
|
193
202
|
return { call: { kind, target, via, ...at }, ...(problem && { problem }) };
|
|
194
203
|
};
|
|
195
204
|
|
|
196
|
-
return
|
|
197
|
-
const read = readSiteFiles(root,
|
|
198
|
-
const resolved = read.flatMap(({ uses }) => uses.map(resolve));
|
|
205
|
+
return sitesUnder(root).map(({ name, app, dir }) => {
|
|
206
|
+
const read = readSiteFiles(root, dir);
|
|
207
|
+
const resolved = read.flatMap(({ uses }) => uses.filter((use) => !isAppCollection(use)).map(resolve));
|
|
199
208
|
const pages = pagesOf(read);
|
|
200
|
-
const siteFile =
|
|
209
|
+
const siteFile = `${dir}/site.ts`;
|
|
201
210
|
const { problems: optionProblems, ...options } = siteOptionsOf(join(root, siteFile), connectors);
|
|
202
211
|
const calls = resolved.map((r) => r.call);
|
|
203
212
|
const problems = [
|
|
@@ -212,7 +221,7 @@ export function readSites(
|
|
|
212
221
|
const warnings = options.public
|
|
213
222
|
? read.flatMap(({ route, warning }) => (warning && !pages.get(route!.path)?.content ? [warning] : []))
|
|
214
223
|
: [];
|
|
215
|
-
return { name, ...options, pages: sorted, calls, problems, warnings };
|
|
224
|
+
return { name, ...(app && { app }), ...options, pages: sorted, calls, problems, warnings };
|
|
216
225
|
});
|
|
217
226
|
}
|
|
218
227
|
|
|
@@ -225,9 +234,9 @@ function pagesOf(read: readonly { route?: SitePage }[]): Map<string, SitePage> {
|
|
|
225
234
|
return pages;
|
|
226
235
|
}
|
|
227
236
|
|
|
228
|
-
/** Each of site `
|
|
229
|
-
const readSiteFiles = (root: string,
|
|
230
|
-
SITE_DIRS.flatMap((sub) => filesUnder(join(root,
|
|
237
|
+
/** Each file of the site in `dir` (relative to `root`), as `readSiteFile` reads it, by path. */
|
|
238
|
+
const readSiteFiles = (root: string, dir: string) =>
|
|
239
|
+
SITE_DIRS.flatMap((sub) => filesUnder(join(root, dir, sub), isSource))
|
|
231
240
|
.toSorted()
|
|
232
241
|
.flatMap((path) => {
|
|
233
242
|
const file = readSource(path);
|
|
@@ -241,8 +250,8 @@ const readSiteFiles = (root: string, name: string) =>
|
|
|
241
250
|
*/
|
|
242
251
|
export function siteContentPages(root: string): Record<string, string> {
|
|
243
252
|
return Object.fromEntries(
|
|
244
|
-
|
|
245
|
-
[...pagesOf(readSiteFiles(root,
|
|
253
|
+
sitesUnder(root).flatMap(({ dir }) =>
|
|
254
|
+
[...pagesOf(readSiteFiles(root, dir)).values()].flatMap(({ path, content }) =>
|
|
246
255
|
content ? [[path, content]] : [],
|
|
247
256
|
),
|
|
248
257
|
),
|
|
@@ -257,13 +266,44 @@ export function siteContentPages(root: string): Record<string, string> {
|
|
|
257
266
|
export function siteLocales(root: string): Record<string, string[]> {
|
|
258
267
|
const none = new Set<string>();
|
|
259
268
|
return Object.fromEntries(
|
|
260
|
-
|
|
261
|
-
const { locales } = siteOptionsOf(join(root,
|
|
269
|
+
sitesUnder(root).flatMap(({ name, dir }) => {
|
|
270
|
+
const { locales } = siteOptionsOf(join(root, dir, "site.ts"), { vendors: none, logins: none });
|
|
262
271
|
return locales ? [[name, locales]] : [];
|
|
263
272
|
}),
|
|
264
273
|
);
|
|
265
274
|
}
|
|
266
275
|
|
|
276
|
+
/**
|
|
277
|
+
* Each site's icon (`defineSite({ icon })`, a Lucide icon's name as written) under `root`, by name,
|
|
278
|
+
* for the sites whose `site.ts` gives one: the icons the app's build bundles (`virtual:sanoma/icons`).
|
|
279
|
+
* Read as `readSites` reads it, never run.
|
|
280
|
+
*/
|
|
281
|
+
export function siteIcons(root: string): Record<string, string> {
|
|
282
|
+
const none = new Set<string>();
|
|
283
|
+
return Object.fromEntries(
|
|
284
|
+
sitesUnder(root).flatMap(({ name, dir }) => {
|
|
285
|
+
const { icon } = siteOptionsOf(join(root, dir, "site.ts"), { vendors: none, logins: none });
|
|
286
|
+
return icon === undefined ? [] : [[name, icon]];
|
|
287
|
+
}),
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The sites under `root`, by key (`siteKey`): each folder of `sites/` with a `routes/` whose name
|
|
293
|
+
* is a URL segment, then each app's `apps/<app>/sites/`, with its folder relative to `root`.
|
|
294
|
+
*/
|
|
295
|
+
export const sitesUnder = (root: string): { name: string; app?: string; dir: string }[] => [
|
|
296
|
+
...siteNames(join(root, "sites")).map((name) => ({ name, dir: `sites/${name}` })),
|
|
297
|
+
...appFolders(root).flatMap((folder) => {
|
|
298
|
+
const app = basename(folder);
|
|
299
|
+
return siteNames(join(folder, "sites")).map((name) => ({
|
|
300
|
+
name: siteKey(app, name),
|
|
301
|
+
app,
|
|
302
|
+
dir: `${APPS_DIR}/${app}/sites/${name}`,
|
|
303
|
+
}));
|
|
304
|
+
}),
|
|
305
|
+
];
|
|
306
|
+
|
|
267
307
|
/** The sites in `dir`: each folder with a `routes/` whose name is a URL segment, sorted. */
|
|
268
308
|
const siteNames = (dir: string): string[] =>
|
|
269
309
|
isDirectory(dir)
|
|
@@ -379,9 +419,12 @@ function contentKind(callee: Node, content: ReadonlySet<string>): string | undef
|
|
|
379
419
|
function apiPath(member: Node, api: ReadonlySet<string>): Pick<Written, "kind" | "name" | "method"> | undefined {
|
|
380
420
|
const path = memberPath(member, api);
|
|
381
421
|
if (!path?.length) return undefined;
|
|
382
|
-
const [first, second
|
|
422
|
+
const [first, second] = path as [string, ...string[]];
|
|
423
|
+
// `api.data.<collection>.<op>`, or an app's, `api.data.<app>.<collection>.<op>`.
|
|
383
424
|
if (first === DATA_VENDOR)
|
|
384
|
-
return path.length === 3
|
|
425
|
+
return path.length === 3 || path.length === 4
|
|
426
|
+
? { kind: "collection", name: path.slice(1, -1).join("."), method: path.at(-1) }
|
|
427
|
+
: undefined;
|
|
385
428
|
if (path.length === 3) return { kind: "op", name: path.join(".") };
|
|
386
429
|
if (path.length > 2) return undefined;
|
|
387
430
|
return { kind: "workflow", name: first, ...(second !== undefined && { method: second }) };
|
|
@@ -414,14 +457,14 @@ const unread = (what: string, as: string) => `${what} is read without running si
|
|
|
414
457
|
/**
|
|
415
458
|
* What `site.ts` says of its site, read without running it, and what is wrong there: its default
|
|
416
459
|
* export must be `defineSite({ … })` written out, so it can be read at all; then `title` when it is
|
|
417
|
-
* a string, `public`, `login` and `locales`, which must be literals. `login` is kept as written; one on a
|
|
460
|
+
* a string, `icon`, `public`, `login` and `locales`, which must be literals. `login` is kept as written; one on a
|
|
418
461
|
* site that is not public, or naming no connector of the config with a login half, is a problem.
|
|
419
462
|
* `locales` is kept only when it has no problem.
|
|
420
463
|
*/
|
|
421
464
|
function siteOptionsOf(
|
|
422
465
|
path: string,
|
|
423
466
|
connectors: SiteConnectors,
|
|
424
|
-
): Pick<SiteEntry, "title" | "public" | "login" | "locales"> & { problems: LintProblem[] } {
|
|
467
|
+
): Pick<SiteEntry, "title" | "icon" | "public" | "login" | "locales"> & { problems: LintProblem[] } {
|
|
425
468
|
const file = readSource(path);
|
|
426
469
|
if (typeof file === "string") return { problems: [] };
|
|
427
470
|
const problems: LintProblem[] = [...file.problems];
|
|
@@ -453,6 +496,8 @@ function siteOptionsOf(
|
|
|
453
496
|
options?.properties.findLast((p: Node) => isWritten(p) && keyName(p.key) === name);
|
|
454
497
|
|
|
455
498
|
const title = stringValue(property("title")?.value);
|
|
499
|
+
// Cosmetic: one that is not a string (tsc refuses it) is shown as none, never a problem that stops a site.
|
|
500
|
+
const icon = stringValue(property("icon")?.value);
|
|
456
501
|
const publicAt = property("public");
|
|
457
502
|
const flag = unwrap(publicAt?.value);
|
|
458
503
|
const isPublic = flag?.type === "Literal" && flag.value === true;
|
|
@@ -495,6 +540,7 @@ function siteOptionsOf(
|
|
|
495
540
|
if (localesMessage) problem(localesAt, localesMessage);
|
|
496
541
|
return {
|
|
497
542
|
...(title !== undefined && { title }),
|
|
543
|
+
...(icon !== undefined && { icon }),
|
|
498
544
|
...(isPublic && { public: true as const }),
|
|
499
545
|
...(login !== undefined && { login }),
|
|
500
546
|
...(localesAt && !localesMessage && isPublic && !loginAt && { locales }),
|
package/src/source.ts
CHANGED
|
@@ -28,8 +28,9 @@ export interface Source {
|
|
|
28
28
|
/** Pushes `ref` to the repository at `url`, signed in with `token` (a draft by its `branch`). */
|
|
29
29
|
push?(target: { url: string; token: string; ref: string }): void | Promise<void>;
|
|
30
30
|
/**
|
|
31
|
-
* The draft `id` (letters, digits, `_` and `-`), opened
|
|
32
|
-
* first time, then the same one, edits and all, until its branch is
|
|
31
|
+
* The draft `id` (letters, digits, `_` and `-`), opened on its branch (`draftBranch`): made from
|
|
32
|
+
* the checked-out revision the first time, then the same one, edits and all, until its branch is
|
|
33
|
+
* deleted.
|
|
33
34
|
*/
|
|
34
35
|
draft?(id: string): Promise<Draft>;
|
|
35
36
|
/** The open drafts' ids, sorted. */
|
|
@@ -47,13 +48,27 @@ export interface Source {
|
|
|
47
48
|
/** The URL of the repository this one was cloned from (`origin`); undefined when it has none. */
|
|
48
49
|
remoteUrl?(): Promise<string | undefined>;
|
|
49
50
|
/**
|
|
50
|
-
* Fast-forwards the
|
|
51
|
-
* `
|
|
52
|
-
* `
|
|
51
|
+
* Fast-forwards the branch `ref` to `ref` in the repository at `url`, signed in with `token`, as
|
|
52
|
+
* `push` is, where it is checked out: the root's checked-out branch, or an open draft's (by its
|
|
53
|
+
* `branch`), whose new commits on the host it takes so. Only when nothing is uncommitted there
|
|
54
|
+
* and the remote's `ref` contains it; otherwise `skipped`, saying why. The revision after.
|
|
53
55
|
*/
|
|
54
56
|
follow?(target: { url: string; token: string; ref: string }): Promise<{ revision: string } | { skipped: string }>;
|
|
57
|
+
/**
|
|
58
|
+
* How many commits the source repository of the app `folder` (the URL its `apps/<folder>/app.ts`
|
|
59
|
+
* names) has on its default branch that the checked-out one lacks, of those changing what an
|
|
60
|
+
* update merges (`apps/<folder>/` and `package.json`): fetched now. An error when the app names
|
|
61
|
+
* no source.
|
|
62
|
+
*/
|
|
63
|
+
behind?(folder: string): Promise<number>;
|
|
55
64
|
}
|
|
56
65
|
|
|
66
|
+
/** The branch of the draft `id`, as a source names it: `draft/<id>`. */
|
|
67
|
+
export const draftBranch = (id: string) => `draft/${id}`;
|
|
68
|
+
|
|
69
|
+
/** The draft whose branch `branch` is (`draftBranch`), or undefined for a branch that is no draft's. */
|
|
70
|
+
export const draftOf = (branch: string) => (branch.startsWith("draft/") ? branch.slice("draft/".length) : undefined);
|
|
71
|
+
|
|
57
72
|
/**
|
|
58
73
|
* A draft: a checkout of its own, at `root`, on `branch`, which `commit` records in as
|
|
59
74
|
* `Source.commit` does in its root. Its `node_modules` may be the root's own install, linked in:
|
|
@@ -63,6 +78,36 @@ export interface Draft {
|
|
|
63
78
|
branch: string;
|
|
64
79
|
root: string;
|
|
65
80
|
commit: Source["commit"];
|
|
81
|
+
/**
|
|
82
|
+
* Takes an app into the draft: fetches `ref` (the default branch by default) of the repository
|
|
83
|
+
* at `url`, a source (a public company repo with one `apps/<folder>/`), and merges it in with its
|
|
84
|
+
* history (`--allow-unrelated-histories`), as one merge commit by `author`. What it merges is
|
|
85
|
+
* `apps/<folder>/` and `package.json`; every other file stays the draft's. `folder` may be left
|
|
86
|
+
* out when the source has one app. Refused when the draft has `apps/<folder>/` already.
|
|
87
|
+
*/
|
|
88
|
+
take?(app: { url: string; ref?: string; folder?: string; author: string }): Promise<AppMerge>;
|
|
89
|
+
/**
|
|
90
|
+
* Merges the new commits of the app `folder`'s source (the URL its `app.ts` names, its default
|
|
91
|
+
* branch) into the draft, by `author`, as `take` merges: `apps/<folder>/` and `package.json`.
|
|
92
|
+
*/
|
|
93
|
+
update?(app: { folder: string; author: string }): Promise<AppMerge>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* What taking or updating an app merged into a draft. A line both sides changed is committed with
|
|
98
|
+
* git's markers in its file (ours, the merge base and theirs), so the draft's checks fail until the
|
|
99
|
+
* agent resolves it; a file one side deleted stays as the draft had it. The merge commit keeps the
|
|
100
|
+
* source's history either way.
|
|
101
|
+
*/
|
|
102
|
+
export interface AppMerge {
|
|
103
|
+
/** The app's folder: `apps/<folder>/`. */
|
|
104
|
+
folder: string;
|
|
105
|
+
/** The draft's commit after the merge. */
|
|
106
|
+
revision: string;
|
|
107
|
+
/** The source's commits the merge brought in that change the app's folder or `package.json`; 0 when the draft had them all, and nothing was merged. */
|
|
108
|
+
merged: number;
|
|
109
|
+
/** The files git could not merge, relative to the root, committed with conflict markers; empty when it merged clean. */
|
|
110
|
+
conflicts: string[];
|
|
66
111
|
}
|
|
67
112
|
|
|
68
113
|
/** A draft's change: the files it touches, relative to the root, and git's `--stat` of it. */
|
package/src/version.ts
CHANGED
|
@@ -63,24 +63,33 @@ export function computeVersion({
|
|
|
63
63
|
}): string {
|
|
64
64
|
const named = process.env.DBOS__APPVERSION?.trim();
|
|
65
65
|
if (named) return `${appName}@${named}`;
|
|
66
|
-
const workflows = definitions
|
|
67
|
-
|
|
68
|
-
(wf) =>
|
|
69
|
-
[
|
|
70
|
-
wf.name,
|
|
71
|
-
wf.run.toString(),
|
|
72
|
-
opIdsOf(wf),
|
|
73
|
-
// The input schema is parsed outside steps on replay, so a change to it must change the version.
|
|
74
|
-
inputSchemaOf(wf),
|
|
75
|
-
] as const,
|
|
76
|
-
)
|
|
77
|
-
.toSorted(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
78
|
-
const hash = createHash("sha256")
|
|
79
|
-
.update(JSON.stringify([appName, RUNTIME_VERSION, STEP_LAYOUT, installedDbosVersion(), ...workflows]))
|
|
80
|
-
.digest("hex");
|
|
81
|
-
return `${appName}@${hash}`;
|
|
66
|
+
const workflows = definitions.map(codeOf).toSorted(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
67
|
+
return `${appName}@${sha256([appName, RUNTIME_VERSION, STEP_LAYOUT, installedDbosVersion(), ...workflows])}`;
|
|
82
68
|
}
|
|
83
69
|
|
|
70
|
+
/**
|
|
71
|
+
* A hash of the built-in `change` workflow's own code, which each change run carries (its
|
|
72
|
+
* `changeCode` attribute) so a worker on another version of the app may take it over
|
|
73
|
+
* (`carryChangeRuns` in worker.ts): what `computeVersion` hashes of that one workflow, with
|
|
74
|
+
* `RUNTIME_VERSION`, `STEP_LAYOUT` and the installed DBOS version. Never the company's workflows
|
|
75
|
+
* nor `DBOS__APPVERSION`, so a merge that changes only the company's code leaves it as it is.
|
|
76
|
+
*/
|
|
77
|
+
export function changeCodeOf(change: WorkflowDefinition<any, any>): string {
|
|
78
|
+
return sha256([RUNTIME_VERSION, STEP_LAYOUT, installedDbosVersion(), codeOf(change)]);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const sha256 = (parts: unknown[]) => createHash("sha256").update(JSON.stringify(parts)).digest("hex");
|
|
82
|
+
|
|
83
|
+
/** What the hashes read of a workflow: its name, the source of its `run`, its operations and its input schema. */
|
|
84
|
+
const codeOf = (wf: WorkflowDefinition<any, any>) =>
|
|
85
|
+
[
|
|
86
|
+
wf.name,
|
|
87
|
+
wf.run.toString(),
|
|
88
|
+
opIdsOf(wf),
|
|
89
|
+
// The input schema is parsed outside steps on replay, so a change to it must change the version.
|
|
90
|
+
inputSchemaOf(wf),
|
|
91
|
+
] as const;
|
|
92
|
+
|
|
84
93
|
/** The workflow's input schema as the hash sees it: its JSON Schema, or its text when zod can't convert it. */
|
|
85
94
|
function inputSchemaOf(wf: WorkflowDefinition<any, any>): unknown {
|
|
86
95
|
try {
|
package/src/worker.ts
CHANGED
|
@@ -4,19 +4,19 @@ import { buildCtx, decideStart, isInfrastructureError, startHoldOf } from "./cal
|
|
|
4
4
|
import { dbosStatusesOf } from "./client.ts";
|
|
5
5
|
import { declaredWakeup, type ResolvedConfig, resolveConfig, type SanomaConfig } from "./config.ts";
|
|
6
6
|
import { storedSecrets, watchCredentials } from "./credentials.ts";
|
|
7
|
-
import { DBOS_DATABASE_OPTIONS, lazyPool, prepareDatabase } from "./db.ts";
|
|
7
|
+
import { type Db, DBOS_DATABASE_OPTIONS, lazyPool, prepareDatabase, stampVersion } from "./db.ts";
|
|
8
8
|
import { deadlineRegistry, type Deadlines, idle, onOwnQueue, type SelfStop, stopWhenIdle } from "./deadlines.ts";
|
|
9
9
|
import type { WorkflowDefinition } from "./define.ts";
|
|
10
10
|
import { credentialsOf, unconfigured, unconfiguredSummary } from "./env.ts";
|
|
11
11
|
import { errorInfo, parseOrThrow, PolicyDeniedError } from "./errors.ts";
|
|
12
12
|
import { entry, ownedLedger, skipped, write, writeFailure } from "./ledger.ts";
|
|
13
|
-
import { dbosLogger, quietIfSandbox, setLogLevel, warn } from "./log.ts";
|
|
13
|
+
import { dbosLogger, info, quietIfSandbox, setLogLevel, warn } from "./log.ts";
|
|
14
14
|
import { lineStartsOf, readSource } from "./ast.ts";
|
|
15
15
|
import { callsOf, outlineWithSource } from "./outline.ts";
|
|
16
16
|
import { currentRun, type Run, type RunArgs, type WorkerState, type WorkflowOutline } from "./run.ts";
|
|
17
17
|
import { applyScheduled, nextScheduledFire, type ScheduledStarter, scheduledStarter } from "./schedules.ts";
|
|
18
18
|
import type { Source } from "./source.ts";
|
|
19
|
-
import { approverLabel, errorMessage, WORKFLOW_ACTION } from "./shared.ts";
|
|
19
|
+
import { approverLabel, CHANGE_WORKFLOW, errorMessage, WORKFLOW_ACTION } from "./shared.ts";
|
|
20
20
|
|
|
21
21
|
export interface Worker {
|
|
22
22
|
/**
|
|
@@ -144,6 +144,12 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
|
|
|
144
144
|
secrets: storedSecrets(() => secrets.db(), "sanoma"),
|
|
145
145
|
workflows: resolved.workflows,
|
|
146
146
|
outlines: new Map([...resolved.workflows].map(([name, wf]) => [name, outlineOf(wf)])),
|
|
147
|
+
// `registered` is filled below, before any run starts: every name of this config's is registered then.
|
|
148
|
+
startRun: async (name, runId, args) => {
|
|
149
|
+
const fn = registered.get(name) as unknown as ((args: RunArgs) => Promise<unknown>) | undefined;
|
|
150
|
+
if (!fn) throw new Error(`The worker has no workflow "${name}" to start`);
|
|
151
|
+
return DBOS.startWorkflow(fn, { workflowID: runId })(args);
|
|
152
|
+
},
|
|
147
153
|
stopped: false,
|
|
148
154
|
deadlines: deadlineRegistry(),
|
|
149
155
|
};
|
|
@@ -216,6 +222,8 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
|
|
|
216
222
|
);
|
|
217
223
|
}
|
|
218
224
|
}
|
|
225
|
+
// Change runs move to the latest version only: a rollback's worker, unless promoted, leaves them be.
|
|
226
|
+
if (isLatest) await carryChangeRuns(resolved, () => secrets.db());
|
|
219
227
|
await DBOS.registerQueue(resolved.queueName);
|
|
220
228
|
// The schedules are the latest version's: an older worker leaves them as the newer set them.
|
|
221
229
|
if (isLatest && options.schedules !== false) {
|
|
@@ -282,6 +290,41 @@ function outlineOf(wf: WorkflowDefinition<any, any>): WorkflowOutline {
|
|
|
282
290
|
return { file: outline.file, lineStarts: lineStartsOf(source!), calls: callsOf(outline.nodes) };
|
|
283
291
|
}
|
|
284
292
|
|
|
293
|
+
/**
|
|
294
|
+
* Takes over the unfinished change runs of other versions whose change code is this worker's,
|
|
295
|
+
* keeping each run's id, history and unread messages: a change's own deploy restarts the worker on
|
|
296
|
+
* the merged code, a new version, which DBOS would never give the run to. Each is stamped with this
|
|
297
|
+
* version (`stampVersion`), and a PENDING one queued again on the app's queue (`resumeWorkflows`);
|
|
298
|
+
* one stopped between the two is PENDING on this version, which DBOS's recovery takes. Not
|
|
299
|
+
* `rewindWorkflow`, which takes only a cancelled run and deletes its unread messages. It assumes one
|
|
300
|
+
* live worker per database, as a deploy does: an older one still running such a run runs it on
|
|
301
|
+
* until it stops. A failure is warned about, and the worker starts.
|
|
302
|
+
*/
|
|
303
|
+
async function carryChangeRuns({ appName, version, changeCode, queueName }: ResolvedConfig, db: () => Promise<Db>) {
|
|
304
|
+
try {
|
|
305
|
+
const runs = await DBOS.listWorkflows({
|
|
306
|
+
workflowName: CHANGE_WORKFLOW,
|
|
307
|
+
status: ["PENDING", "ENQUEUED"],
|
|
308
|
+
applicationName: appName,
|
|
309
|
+
attributes: { changeCode },
|
|
310
|
+
loadInput: false,
|
|
311
|
+
loadOutput: false,
|
|
312
|
+
});
|
|
313
|
+
const theirs = runs
|
|
314
|
+
.filter((r) => r.applicationVersion && r.applicationVersion !== version)
|
|
315
|
+
.map((r) => r.workflowID);
|
|
316
|
+
if (!theirs.length) return;
|
|
317
|
+
const carried = await stampVersion(await db(), theirs, version);
|
|
318
|
+
const pending = carried.filter((r) => r.pending).map((r) => r.id);
|
|
319
|
+
if (pending.length) await DBOS.resumeWorkflows(pending, { queueName });
|
|
320
|
+
if (carried.length) {
|
|
321
|
+
info(`carried ${carried.length} change run(s) onto version ${version}: ${carried.map((r) => r.id).join(", ")}`);
|
|
322
|
+
}
|
|
323
|
+
} catch (err) {
|
|
324
|
+
warn(`carrying the change runs of other versions onto ${version} failed, so they stay there: ${errorMessage(err)}`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
285
328
|
/**
|
|
286
329
|
* Warns about unfinished runs this worker will never pick up: started on another version of
|
|
287
330
|
* the app, or queued on another queue (such as the single "sanoma" queue before queues were
|