@sanoma-ai/workflows 0.7.0 → 0.8.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 +43 -12
- 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 +56 -8
- package/dist/config.js +71 -29
- package/dist/config.js.map +1 -1
- package/dist/constructors.d.ts +37 -1
- package/dist/constructors.js +6 -2
- package/dist/constructors.js.map +1 -1
- package/dist/db.d.ts +21 -21
- package/dist/db.js +31 -54
- package/dist/db.js.map +1 -1
- package/dist/deadlines.d.ts +18 -12
- package/dist/deadlines.js +18 -24
- 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/fake.d.ts +26 -0
- package/dist/fake.js +123 -0
- package/dist/fake.js.map +1 -1
- package/dist/host.d.ts +2 -3
- package/dist/host.js +1 -1
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +8 -7
- package/dist/index.js +7 -6
- package/dist/index.js.map +1 -1
- package/dist/lint.js +20 -6
- package/dist/lint.js.map +1 -1
- package/dist/op.d.ts +8 -1
- package/dist/op.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/person.d.ts +5 -0
- package/dist/person.js +3 -1
- package/dist/person.js.map +1 -1
- package/dist/resources.d.ts +7 -2
- package/dist/resources.js +27 -10
- package/dist/resources.js.map +1 -1
- package/dist/run.d.ts +7 -0
- package/dist/run.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 +179 -5
- package/dist/source.js +13 -0
- package/dist/source.js.map +1 -1
- package/dist/supervisor.d.ts +5 -4
- package/dist/supervisor.js +2 -11
- package/dist/supervisor.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/wakeup-driver.d.ts +128 -0
- package/dist/wakeup-driver.js +216 -0
- package/dist/wakeup-driver.js.map +1 -0
- package/dist/wakeup-service.d.ts +4 -4
- package/dist/wakeup-service.js +19 -25
- package/dist/wakeup-service.js.map +1 -1
- package/dist/wakeup-type.d.ts +43 -0
- package/dist/wakeup-type.js +80 -0
- package/dist/wakeup-type.js.map +1 -0
- package/dist/wakeup.d.ts +1 -8
- package/dist/wakeup.js +2 -11
- package/dist/wakeup.js.map +1 -1
- package/dist/worker.d.ts +2 -2
- package/dist/worker.js +50 -7
- package/dist/worker.js.map +1 -1
- package/oxlint.json +3 -2
- package/package.json +3 -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 +104 -35
- package/src/constructors.ts +8 -2
- package/src/db.ts +39 -66
- package/src/deadlines.ts +27 -30
- 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/fake.ts +137 -0
- package/src/host.ts +3 -4
- package/src/index.ts +22 -3
- package/src/lint.ts +21 -6
- package/src/op.ts +8 -1
- package/src/outline.ts +15 -6
- package/src/paths.ts +57 -2
- package/src/person.ts +4 -1
- package/src/resources.ts +31 -11
- package/src/run.ts +5 -0
- 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 +168 -5
- package/src/supervisor.ts +7 -12
- package/src/version.ts +25 -16
- package/src/wakeup-driver.ts +270 -0
- package/src/wakeup-service.ts +21 -28
- package/src/wakeup-type.ts +90 -0
- package/src/wakeup.ts +6 -17
- package/src/worker.ts +48 -9
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. */
|
|
@@ -135,3 +180,121 @@ export async function resolveRevision(url: string, ref: string): Promise<string>
|
|
|
135
180
|
}
|
|
136
181
|
throw new DriverError(`${url} has no branch or tag ${ref}`, { retryable: false, vendorCode: "no_such_ref" });
|
|
137
182
|
}
|
|
183
|
+
|
|
184
|
+
// A git host's side of a change: the pull request a branch is reviewed in, merged through and
|
|
185
|
+
// closed by, the `sanoma` commit status the review card links from, whether the host refuses a
|
|
186
|
+
// change to `main` that is not a merge, and the host's webhooks. A connector carries it as its
|
|
187
|
+
// remote half (`ConnectorSetup.remote`), the way a login or a model half is carried.
|
|
188
|
+
|
|
189
|
+
/** A repository on a host: `owner/name`. */
|
|
190
|
+
export interface RepoRef {
|
|
191
|
+
owner: string;
|
|
192
|
+
name: string;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** A pull request (a merge request, on a host that calls it so), as a change follows it. */
|
|
196
|
+
export interface PullRequest {
|
|
197
|
+
number: number;
|
|
198
|
+
/** Its page on the host. */
|
|
199
|
+
url: string;
|
|
200
|
+
/** The branch it merges. */
|
|
201
|
+
branch: string;
|
|
202
|
+
/** The branch's commit it was read at. */
|
|
203
|
+
head: string;
|
|
204
|
+
state: "open" | "closed" | "merged";
|
|
205
|
+
/** The commit its merge made on the base branch, once merged. */
|
|
206
|
+
mergeSha?: string;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Whether the host refuses a change to a branch that does not follow the rules: `pullRequest`
|
|
211
|
+
* when a direct push is refused (every change is a merge), `check` when a merge needs the
|
|
212
|
+
* `sanoma` status to pass. `reason` says why neither can be read or held, such as a plan that
|
|
213
|
+
* has no rules.
|
|
214
|
+
*/
|
|
215
|
+
export interface HostEnforcement {
|
|
216
|
+
pullRequest: boolean;
|
|
217
|
+
check: boolean;
|
|
218
|
+
reason?: string;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* One repository on a host, opened by its remote half with the company's credentials, read when
|
|
223
|
+
* a call needs them; a half may keep a token for a few minutes (its README says how long), so a
|
|
224
|
+
* token stored later is used soon after. A failure is a `DriverError` (retryable as the host's
|
|
225
|
+
* status says).
|
|
226
|
+
*/
|
|
227
|
+
export interface SourceHost {
|
|
228
|
+
/**
|
|
229
|
+
* A token that writes to the repository: the company's app's on the host when it is installed
|
|
230
|
+
* on the owner, else a person's token (`prefer: "token"` asks for that one first). It is never
|
|
231
|
+
* logged; a push signs in with it. A `DriverError` whose `vendorCode` is `no_token` when there is
|
|
232
|
+
* neither.
|
|
233
|
+
*/
|
|
234
|
+
token(prefer?: "app" | "token"): Promise<string>;
|
|
235
|
+
/** The pull request of `branch`: the open one, else the latest; undefined when it has none. */
|
|
236
|
+
pullRequestFor(branch: string): Promise<PullRequest | undefined>;
|
|
237
|
+
pullRequest(number: number): Promise<PullRequest>;
|
|
238
|
+
/** Opens a pull request of `branch` into `base`; the open one when `branch` has one already. */
|
|
239
|
+
openPullRequest(request: { branch: string; base: string; title: string; body: string }): Promise<PullRequest>;
|
|
240
|
+
/**
|
|
241
|
+
* Squash-merges pull request `number` when its branch is still at `sha`, as one commit titled
|
|
242
|
+
* `title`. A `DriverError` whose `vendorCode` is `head_moved` when the branch moved on, and
|
|
243
|
+
* `not_mergeable` when the host refuses the merge (a conflict, a rule).
|
|
244
|
+
*/
|
|
245
|
+
mergePullRequest(number: number, merge: { sha: string; method: "squash"; title?: string }): Promise<{ sha: string }>;
|
|
246
|
+
closePullRequest(number: number): Promise<void>;
|
|
247
|
+
/** Deletes `branch`; nothing when it is gone already. */
|
|
248
|
+
deleteBranch?(branch: string): Promise<void>;
|
|
249
|
+
/** Sets the `sanoma` status on commit `sha`, linking to `targetUrl`. */
|
|
250
|
+
setStatus(status: {
|
|
251
|
+
sha: string;
|
|
252
|
+
state: "pending" | "success" | "failure";
|
|
253
|
+
description: string;
|
|
254
|
+
targetUrl?: string;
|
|
255
|
+
}): Promise<void>;
|
|
256
|
+
/** What the host enforces on `base` (see `HostEnforcement`). */
|
|
257
|
+
enforcement(base: string): Promise<HostEnforcement>;
|
|
258
|
+
/** Uploads a PNG the pull request's comment shows; its URL. */
|
|
259
|
+
uploadImage?(png: Uint8Array, name: string): Promise<string>;
|
|
260
|
+
/** Writes the pull request's comment that carries `marker`, once: a second call edits it. */
|
|
261
|
+
upsertComment?(number: number, marker: string, body: string): Promise<void>;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** What a host's webhook says happened: a branch pushed, or a pull request closed (merged or not). */
|
|
265
|
+
export type RemoteEvent =
|
|
266
|
+
| { kind: "pushed"; repo: RepoRef; branch: string; head: string }
|
|
267
|
+
| { kind: "closed"; repo: RepoRef; branch: string; number: number; merged: boolean; sha?: string };
|
|
268
|
+
|
|
269
|
+
/** What a remote half reads the company's credentials with, as a login or a model half reads them. */
|
|
270
|
+
export interface RemoteContext {
|
|
271
|
+
/** Secrets' values by name: the environment's first, then the stored credentials (`LoginContext.secrets`). */
|
|
272
|
+
secrets(names: readonly string[]): Promise<Map<string, string>>;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** A connector's remote half (`ConnectorSetup.remote`): a git host's repositories, made once per resolved config. */
|
|
276
|
+
export interface RemoteHalf {
|
|
277
|
+
/** The repository a clone URL of this host names, or undefined for another host's. */
|
|
278
|
+
matches(url: string): RepoRef | undefined;
|
|
279
|
+
/** The repository `repo`, with the company's credentials. */
|
|
280
|
+
open(repo: RepoRef, context: RemoteContext): SourceHost;
|
|
281
|
+
/**
|
|
282
|
+
* The events a webhook request carries, once its signature is verified: throws for a missing or
|
|
283
|
+
* bad signature, and answers `[]` for an event the change workflow does not follow.
|
|
284
|
+
*/
|
|
285
|
+
webhook?(request: Request, context: RemoteContext): Promise<RemoteEvent[]>;
|
|
286
|
+
/** The accounts the company's app on the host is installed on: where it acts with no person's token. */
|
|
287
|
+
owners?(context: RemoteContext): Promise<{ login: string; kind: "user" | "organization" }[]>;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** The remote half among `remotes` (`ResolvedConfig.remotes`) whose host `url` is on, with its vendor and the repository. */
|
|
291
|
+
export function remoteFor(
|
|
292
|
+
remotes: ReadonlyMap<string, RemoteHalf>,
|
|
293
|
+
url: string,
|
|
294
|
+
): { vendor: string; remote: RemoteHalf; repo: RepoRef } | undefined {
|
|
295
|
+
for (const [vendor, remote] of remotes) {
|
|
296
|
+
const repo = remote.matches(url);
|
|
297
|
+
if (repo) return { vendor, remote, repo };
|
|
298
|
+
}
|
|
299
|
+
return undefined;
|
|
300
|
+
}
|
package/src/supervisor.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { shouldWake } from "./db.ts";
|
|
1
|
+
import type { SanomaConfig } from "./config.ts";
|
|
3
2
|
import type { SelfStop } from "./deadlines.ts";
|
|
4
3
|
import { warn } from "./log.ts";
|
|
5
4
|
import { errorMessage } from "./shared.ts";
|
|
@@ -21,10 +20,11 @@ export interface Supervisor {
|
|
|
21
20
|
*/
|
|
22
21
|
resume(): Promise<void>;
|
|
23
22
|
/**
|
|
24
|
-
* A wakeup's `GET /wake
|
|
25
|
-
*
|
|
23
|
+
* A wakeup's `GET /wake`: `resume` when the worker stopped itself; a no-op while one starts or
|
|
24
|
+
* runs. A wake that arrives late, twice or for a replaced registration starts a worker that
|
|
25
|
+
* finds nothing to do and stops again on its own check.
|
|
26
26
|
*/
|
|
27
|
-
wake(
|
|
27
|
+
wake(): Promise<void>;
|
|
28
28
|
/** Stops the worker running or starting, and starts none again. */
|
|
29
29
|
stop(): Promise<void>;
|
|
30
30
|
}
|
|
@@ -53,7 +53,6 @@ export async function superviseWorker(config: SanomaConfig, options: SupervisorO
|
|
|
53
53
|
let closed = false;
|
|
54
54
|
// A write while the worker starts or runs, since it began starting.
|
|
55
55
|
let heard = false;
|
|
56
|
-
let databaseUrl: string | undefined;
|
|
57
56
|
|
|
58
57
|
const launch = async () => {
|
|
59
58
|
heard = false;
|
|
@@ -64,7 +63,7 @@ export async function superviseWorker(config: SanomaConfig, options: SupervisorO
|
|
|
64
63
|
const selfStopped = (stopped: SelfStop) => {
|
|
65
64
|
worker = undefined;
|
|
66
65
|
if (closed) return;
|
|
67
|
-
// Resumed first, so `stopped` is false when `onStopped`
|
|
66
|
+
// Resumed first, so `stopped` is false when `onStopped` looks.
|
|
68
67
|
if (heard) void supervisor.resume();
|
|
69
68
|
onStopped?.(stopped);
|
|
70
69
|
};
|
|
@@ -89,11 +88,7 @@ export async function superviseWorker(config: SanomaConfig, options: SupervisorO
|
|
|
89
88
|
starting = undefined;
|
|
90
89
|
}));
|
|
91
90
|
},
|
|
92
|
-
|
|
93
|
-
if (!supervisor.stopped) return;
|
|
94
|
-
databaseUrl ??= resolveConfig(config).databaseUrl;
|
|
95
|
-
if (await shouldWake(databaseUrl, generation)) await supervisor.resume();
|
|
96
|
-
},
|
|
91
|
+
wake: () => (supervisor.stopped ? supervisor.resume() : Promise.resolve()),
|
|
97
92
|
async stop() {
|
|
98
93
|
closed = true;
|
|
99
94
|
await starting;
|
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 {
|