@sanoma-ai/workflows 0.7.1 → 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 +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 +25 -2
- package/dist/config.js +36 -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/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 +60 -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/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/client.ts
CHANGED
|
@@ -21,6 +21,18 @@ import { dbosClient, lazyPool, type LazyPool } from "./db.ts";
|
|
|
21
21
|
import { type CredentialStatus, credentialsOf } from "./env.ts";
|
|
22
22
|
import { type ApprovalState, Principal, type WorkflowDefinition } from "./define.ts";
|
|
23
23
|
import { APPLY_WORKFLOW, type ApplyInput, type ApplyReport } from "./apply.ts";
|
|
24
|
+
import {
|
|
25
|
+
ADMIN,
|
|
26
|
+
BASE,
|
|
27
|
+
type ChangeEvent,
|
|
28
|
+
ChangeEventSchema,
|
|
29
|
+
ChangeInput,
|
|
30
|
+
type ChangeReport,
|
|
31
|
+
type ChangeState,
|
|
32
|
+
followBase,
|
|
33
|
+
initialChangeState,
|
|
34
|
+
repositoryOf,
|
|
35
|
+
} from "./change.ts";
|
|
24
36
|
import { DRIFT_WORKFLOW, type DriftReport } from "./drift.ts";
|
|
25
37
|
import { isDirect } from "./direct.ts";
|
|
26
38
|
import {
|
|
@@ -32,8 +44,10 @@ import {
|
|
|
32
44
|
parseOrThrow,
|
|
33
45
|
PolicyDeniedError,
|
|
34
46
|
SanomaError,
|
|
47
|
+
vendorCodeOf,
|
|
35
48
|
} from "./errors.ts";
|
|
36
49
|
import { insertRecord, type LedgerRecord, type OwnedLedger, ownedLedger } from "./ledger.ts";
|
|
50
|
+
import { warn } from "./log.ts";
|
|
37
51
|
import type { CallContext, Driver, Op } from "./op.ts";
|
|
38
52
|
import { allowAll, type RecordedDecision } from "./policy.ts";
|
|
39
53
|
import { admit, readPeople } from "./resources.ts";
|
|
@@ -42,8 +56,15 @@ import type { Secrets } from "./secret.ts";
|
|
|
42
56
|
import {
|
|
43
57
|
BUILT_IN_WORKFLOWS,
|
|
44
58
|
CALL_WORKFLOW,
|
|
59
|
+
CHANGE_EVENT,
|
|
60
|
+
CHANGE_MERGE,
|
|
61
|
+
CHANGE_TOPIC,
|
|
62
|
+
CHANGE_WORKFLOW,
|
|
63
|
+
changeIdOf,
|
|
45
64
|
DATA_VENDOR,
|
|
65
|
+
ENDED_STATUSES,
|
|
46
66
|
errorMessage,
|
|
67
|
+
HOST_CHECK_AGENT,
|
|
47
68
|
isEnded,
|
|
48
69
|
isVisitor,
|
|
49
70
|
refusalOf,
|
|
@@ -51,6 +72,14 @@ import {
|
|
|
51
72
|
SCHEDULE_AGENT,
|
|
52
73
|
WORKFLOW_ACTION,
|
|
53
74
|
} from "./shared.ts";
|
|
75
|
+
import { draftOf, type RemoteEvent, type RepoRef, type Source } from "./source.ts";
|
|
76
|
+
|
|
77
|
+
/** The company's repository on its host, for `remoteEvent`. */
|
|
78
|
+
type Repository = Awaited<ReturnType<typeof repositoryOf>>;
|
|
79
|
+
|
|
80
|
+
/** Whether two references name one repository: a host's names are compared without case. */
|
|
81
|
+
const sameRepo = (a: RepoRef, b: RepoRef) =>
|
|
82
|
+
a.owner.toLowerCase() === b.owner.toLowerCase() && a.name.toLowerCase() === b.name.toLowerCase();
|
|
54
83
|
|
|
55
84
|
/** A principal, or its id alone. */
|
|
56
85
|
const PrincipalOrId = z.union([
|
|
@@ -154,6 +183,27 @@ export interface StartOptions {
|
|
|
154
183
|
sandbox?: string;
|
|
155
184
|
}
|
|
156
185
|
|
|
186
|
+
/** A change as the app shows it: its run, where the change is (`ChangeState`), and how it ended. */
|
|
187
|
+
export interface ChangeView {
|
|
188
|
+
runId: string;
|
|
189
|
+
status: RunStatus;
|
|
190
|
+
startedBy?: Principal;
|
|
191
|
+
/** When the run started, and when its row last changed (epoch ms), as `RunSummary` has them. */
|
|
192
|
+
createdAt: number;
|
|
193
|
+
updatedAt?: number;
|
|
194
|
+
approvals: ApprovalState[];
|
|
195
|
+
/** What the run last published (`CHANGE_EVENT`); `opening`, with no branch yet, before it has. */
|
|
196
|
+
state: ChangeState;
|
|
197
|
+
/** The run's output, once it has finished. */
|
|
198
|
+
report?: ChangeReport;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Which changes `SanomaClient.changes` lists: `open` (not ended) or not, and at most `limit` (20), newest first. */
|
|
202
|
+
export interface ChangesFilter {
|
|
203
|
+
open?: boolean;
|
|
204
|
+
limit?: number;
|
|
205
|
+
}
|
|
206
|
+
|
|
157
207
|
/** What `SanomaClient.call` takes besides the operation's id and its input. */
|
|
158
208
|
export interface CallOptions {
|
|
159
209
|
/**
|
|
@@ -182,6 +232,11 @@ export interface ClientOptions {
|
|
|
182
232
|
* `dataStore`.
|
|
183
233
|
*/
|
|
184
234
|
resolved?: ResolvedConfig;
|
|
235
|
+
/**
|
|
236
|
+
* The company's repository, as the worker is given it: what `remoteEvent` acts for (its
|
|
237
|
+
* `origin`) and fetches a draft's new commits into. Without one, `remoteEvent` does nothing.
|
|
238
|
+
*/
|
|
239
|
+
source?: Source;
|
|
185
240
|
}
|
|
186
241
|
|
|
187
242
|
/** Talks to the runtime from another process (the app, a script), through the shared Postgres. */
|
|
@@ -263,9 +318,19 @@ export class SanomaClient {
|
|
|
263
318
|
options.sandbox,
|
|
264
319
|
"`sandbox` must be the name of a scenario in the config's `scenarios/`",
|
|
265
320
|
);
|
|
321
|
+
if (workflow.name === CHANGE_WORKFLOW && !isChangeRunOf((parsed as ChangeInput).id, runId)) {
|
|
322
|
+
throw new SanomaError("invalid_input", "A change is started with openChange, which names its run", { runId });
|
|
323
|
+
}
|
|
266
324
|
const hold = await this.startHold(workflow.name, parsed, startedBy, runId);
|
|
267
325
|
const args: RunArgs = { input, startedBy, ...(sandbox === undefined ? {} : { sandbox }), ...(hold && { hold }) };
|
|
268
|
-
|
|
326
|
+
// Kept on the run's status row too, so a listing tells sandbox runs apart without its input,
|
|
327
|
+
// and says whom an agent's run is for (DBOS keeps the principal's id and groups itself); a
|
|
328
|
+
// change run's code, so a worker on another version with the same takes it over.
|
|
329
|
+
const attributes = {
|
|
330
|
+
sandbox,
|
|
331
|
+
onBehalfOf: startedBy.onBehalfOf,
|
|
332
|
+
changeCode: workflow.name === CHANGE_WORKFLOW ? this.config.changeCode : undefined,
|
|
333
|
+
};
|
|
269
334
|
this.options.onWrite?.();
|
|
270
335
|
// An id DBOS has already returns that run, unless it is another workflow's, which DBOS refuses.
|
|
271
336
|
const handle = await this.dbos
|
|
@@ -277,9 +342,7 @@ export class SanomaClient {
|
|
|
277
342
|
applicationName: this.config.appName,
|
|
278
343
|
authenticatedUser: startedBy.id,
|
|
279
344
|
authenticatedRoles: startedBy.groups ?? [],
|
|
280
|
-
|
|
281
|
-
// and says whom an agent's run is for (DBOS keeps the principal's id and groups itself).
|
|
282
|
-
...((sandbox !== undefined || onBehalfOf !== undefined) && { attributes: { sandbox, onBehalfOf } }),
|
|
345
|
+
...(Object.values(attributes).some((v) => v !== undefined) && { attributes }),
|
|
283
346
|
},
|
|
284
347
|
args,
|
|
285
348
|
)
|
|
@@ -524,6 +587,21 @@ export class SanomaClient {
|
|
|
524
587
|
return row ? { runId: row.workflowID, output: row.output, at: row.updatedAt ?? row.createdAt } : null;
|
|
525
588
|
}
|
|
526
589
|
|
|
590
|
+
/**
|
|
591
|
+
* Cancels every run of the app that has not ended (queued, running or waiting), as a demo's
|
|
592
|
+
* `reseed` starts afresh: its moments' runs from the last seed would otherwise wait beside the
|
|
593
|
+
* new ones.
|
|
594
|
+
*/
|
|
595
|
+
async cancelUnfinished(): Promise<void> {
|
|
596
|
+
const rows = await this.dbos.listWorkflows({
|
|
597
|
+
status: dbosStatusesOf("queued", "running"),
|
|
598
|
+
applicationName: this.config.appName,
|
|
599
|
+
loadInput: false,
|
|
600
|
+
loadOutput: false,
|
|
601
|
+
});
|
|
602
|
+
if (rows.length) await this.dbos.cancelWorkflows(rows.map((r) => r.workflowID));
|
|
603
|
+
}
|
|
604
|
+
|
|
527
605
|
/** The run's audit record, in order, from the ledger store. */
|
|
528
606
|
async ledger(runId: string): Promise<LedgerRecord[]> {
|
|
529
607
|
await this.mustExist(runId);
|
|
@@ -760,6 +838,230 @@ export class SanomaClient {
|
|
|
760
838
|
return row.status === "SUCCESS" ? row.output : await this.awaitResult(runId, 30_000);
|
|
761
839
|
}
|
|
762
840
|
|
|
841
|
+
/**
|
|
842
|
+
* Starts the built-in change workflow on the draft `input.id` (`ChangeInput`): its run id is
|
|
843
|
+
* `change:<id>`, or `change:<id>~<n>` once that run has ended (the second `~2`). While a run of
|
|
844
|
+
* the change has not ended, returns its id and starts nothing.
|
|
845
|
+
*/
|
|
846
|
+
async openChange(input: ChangeInput, options: StartOptions): Promise<string> {
|
|
847
|
+
const { id } = parseOrThrow(ChangeInput, input, "Not a change to open");
|
|
848
|
+
const runs = await this.changeRuns(id);
|
|
849
|
+
const open = runs.find((r) => !isEnded(runStatus(r.status, [])));
|
|
850
|
+
if (open) return open.workflowID;
|
|
851
|
+
const runId = runs.length ? `${changeRunId(id)}~${runs.length + 1}` : changeRunId(id);
|
|
852
|
+
return this.start(this.config.workflows.get(CHANGE_WORKFLOW)!, input, { ...options, runId });
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* The changes, newest first, each draft's by its latest run (`change`): `open` ones (not ended)
|
|
857
|
+
* or the others, else all; at most `limit` (20).
|
|
858
|
+
*/
|
|
859
|
+
async changes({ open, limit = 20 }: ChangesFilter = {}): Promise<ChangeView[]> {
|
|
860
|
+
const runs = await this.dbos.listWorkflows({
|
|
861
|
+
workflowName: CHANGE_WORKFLOW,
|
|
862
|
+
applicationName: this.config.appName,
|
|
863
|
+
sortDesc: true,
|
|
864
|
+
loadInput: false,
|
|
865
|
+
loadOutput: false,
|
|
866
|
+
});
|
|
867
|
+
// A draft's later run (`~2`) is its change now: an earlier one is neither open nor done.
|
|
868
|
+
const ended = new Set<string>(dbosStatusesOf(...ENDED_STATUSES));
|
|
869
|
+
const ids = [...Map.groupBy(runs, (run) => changeIdOf(run.workflowID)).values()]
|
|
870
|
+
.map(([run]) => run!)
|
|
871
|
+
.filter((run) => open === undefined || ended.has(run.status) !== open)
|
|
872
|
+
.slice(0, limit)
|
|
873
|
+
.map((run) => run.workflowID);
|
|
874
|
+
if (ids.length === 0) return [];
|
|
875
|
+
const rows = await this.dbos.listWorkflows({ workflowIDs: ids, sortDesc: true, loadInput: true, loadOutput: true });
|
|
876
|
+
return Promise.all(rows.map((row) => this.changeView(row)));
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/** The change of the draft `id`: its latest run's (`openChange`), or undefined when it has none. */
|
|
880
|
+
async change(id: string): Promise<ChangeView | undefined> {
|
|
881
|
+
const [row] = await this.changeRuns(id, { load: true });
|
|
882
|
+
return row && this.changeView(row);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Tells the change of the draft `id` what happened (`ChangeEvent`), as `options.by`: new commits
|
|
887
|
+
* on its branch, a close asked for, or its pull request closed or merged on the host. The event is
|
|
888
|
+
* queued for its latest run, which reads it when it next waits; a pending approval of the merge
|
|
889
|
+
* (Publish, or a hold on it) is withdrawn as `by` (C4's `withdraw`), so a run waiting on it reads
|
|
890
|
+
* the event now. A `close` is refused (`not_approver`) unless `by` may withdraw that approval; the
|
|
891
|
+
* other kinds are facts, so a withdrawal `by` may not make is skipped and the run's merge refuses a
|
|
892
|
+
* head that moved. `run_not_found` when the draft has no change, `run_ended` when its run has ended.
|
|
893
|
+
* With `key`, an event told again under the same key is queued once while the run has not read it.
|
|
894
|
+
*/
|
|
895
|
+
async changeEvent(id: string, event: ChangeEvent, options: { by: Principal | string; key?: string }): Promise<void> {
|
|
896
|
+
const by = await this.admitted(
|
|
897
|
+
parseOrThrow(PrincipalOrId, options?.by, '`by` must be a principal, such as "alice" or { id: "alice" }'),
|
|
898
|
+
);
|
|
899
|
+
const given = parseOrThrow(ChangeEventSchema, event, "Not a change event");
|
|
900
|
+
// A close is asked for by whoever asks it, whatever the event names.
|
|
901
|
+
const told: ChangeEvent = given.kind === "close" ? { kind: "close", by: by.id } : given;
|
|
902
|
+
const [row] = await this.changeRuns(id);
|
|
903
|
+
if (!row) throw new SanomaError("run_not_found", `The draft ${id} has no change`, { id });
|
|
904
|
+
const runId = row.workflowID;
|
|
905
|
+
const approvals = await this.approvals(runId);
|
|
906
|
+
const status = runStatus(row.status, approvals);
|
|
907
|
+
if (isEnded(status)) {
|
|
908
|
+
throw new SanomaError("run_ended", `Run ${runId} has ${status}; it takes no more events`, { runId, status });
|
|
909
|
+
}
|
|
910
|
+
const held = approvals.find((a) => a.status === "pending" && a.covers.includes(CHANGE_MERGE));
|
|
911
|
+
// A close is whoever may withdraw Publish's, or with none pending, the starter's or admin's.
|
|
912
|
+
const refusal = withdrawalRefusal(held ?? { approver: { group: ADMIN } }, by, row.authenticatedUser);
|
|
913
|
+
if (refusal !== undefined && told.kind === "close") {
|
|
914
|
+
if (held) throw notApprover(runId, held, refusal);
|
|
915
|
+
throw new SanomaError("not_approver", refusal, { runId, reason: refusal });
|
|
916
|
+
}
|
|
917
|
+
this.options.onWrite?.();
|
|
918
|
+
await this.dbos.send(runId, told, CHANGE_TOPIC, options.key);
|
|
919
|
+
if (held && refusal === undefined) {
|
|
920
|
+
await this.withdraw(runId, held.id, WITHDRAWAL[told.kind], { by, timeoutSeconds: 5 }).catch((err: unknown) => {
|
|
921
|
+
// Decided or ended meanwhile: the run reads the event when it next waits, if it does.
|
|
922
|
+
if (!["already_decided", "run_ended", "no_pending_approval"].includes(errorCode(err) ?? "")) throw err;
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* Does what a host's webhook says happened (`RemoteEvent`, from a remote half's `webhook`) to the
|
|
929
|
+
* company's repository, the `origin` of `ClientOptions.source` (`repositoryOf`); an event of
|
|
930
|
+
* another repository, or with no source, is ignored. A delivery told again does nothing more.
|
|
931
|
+
*
|
|
932
|
+
* - `pushed` to `main`: the checkout follows `main`, then apply on the declared hosts
|
|
933
|
+
* (`ResolvedConfig.hosts`) as `host-check`, as the run `deploy:<head>`, once per head; nothing
|
|
934
|
+
* with no host, nor while a change is merging or deploying, which does both itself.
|
|
935
|
+
* - `pushed` to a draft's branch (`draftOf`) with its change open: the host's commits fetched into
|
|
936
|
+
* the draft (`Source.follow` on its branch), then the change told `commits`, as `by`; nothing
|
|
937
|
+
* when the change or the draft is at that head already (the run's own push), or when the draft
|
|
938
|
+
* cannot fast-forward to it (warned).
|
|
939
|
+
* - `closed` for the open change's pull request: the change told `merged` (with its merge commit)
|
|
940
|
+
* or `closed`, as `by`. The run checks either on the host before it believes it.
|
|
941
|
+
*/
|
|
942
|
+
async remoteEvent(event: RemoteEvent, options: { by: Principal | string }): Promise<void> {
|
|
943
|
+
const main = event.kind === "pushed" && event.branch === BASE;
|
|
944
|
+
const id = draftOf(event.branch);
|
|
945
|
+
if (!main && id === undefined) return;
|
|
946
|
+
const repository = await this.repository();
|
|
947
|
+
if (!repository || !sameRepo(repository.repo, event.repo)) return;
|
|
948
|
+
if (main) return this.deployPush(repository, event.head);
|
|
949
|
+
const change = await this.change(id!);
|
|
950
|
+
if (!change || isEnded(change.status)) return;
|
|
951
|
+
let told: ChangeEvent;
|
|
952
|
+
let key: string;
|
|
953
|
+
if (event.kind === "closed") {
|
|
954
|
+
// A pull request of an earlier change of the draft is not this one's.
|
|
955
|
+
if (event.number !== change.state.pullRequest?.number) return;
|
|
956
|
+
told = event.merged && event.sha !== undefined ? { kind: "merged", sha: event.sha } : { kind: "closed" };
|
|
957
|
+
key = `${event.number}:${event.sha ?? "closed"}`;
|
|
958
|
+
} else {
|
|
959
|
+
if (change.state.head === event.head || !(await this.fetched(repository, event.branch, event.head))) return;
|
|
960
|
+
told = { kind: "commits" };
|
|
961
|
+
key = event.head;
|
|
962
|
+
}
|
|
963
|
+
await this.changeEvent(id!, told, { by: options.by, key: `${event.kind}:${event.branch}:${key}` }).catch(
|
|
964
|
+
(err: unknown) => {
|
|
965
|
+
// Ended, or gone, since it was read: nothing to tell.
|
|
966
|
+
if (!["run_ended", "run_not_found"].includes(errorCode(err) ?? "")) throw err;
|
|
967
|
+
},
|
|
968
|
+
);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/** After a push of `head` to `main` (`remoteEvent`): the checkout follows it, then apply on the declared hosts. */
|
|
972
|
+
private async deployPush({ source }: Repository, head: string): Promise<void> {
|
|
973
|
+
const runId = `deploy:${head}`;
|
|
974
|
+
const [deployed, phases] = await Promise.all([this.run(runId), this.openChangePhases()]);
|
|
975
|
+
if (deployed || phases.some((phase) => phase === "merging" || phase === "deploying")) return;
|
|
976
|
+
// First, as a change's deploy does: the hosts, and what apply reads, are then main's.
|
|
977
|
+
const moved = await followBase(this.config.remotes, this.storedSecrets, source);
|
|
978
|
+
if (!("revision" in moved)) {
|
|
979
|
+
// Not on stale files, and nothing recorded as `deploy:<head>`: a redelivery may deploy it.
|
|
980
|
+
warn(
|
|
981
|
+
`${BASE} was not deployed: the checkout did not follow it: ${"skipped" in moved ? moved.skipped : moved.error}`,
|
|
982
|
+
);
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
const hosts = await this.config.hosts();
|
|
986
|
+
if (hosts.length) await this.apply({ only: hosts }, { startedBy: HOST_CHECK_AGENT, runId });
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/** Where each open change is (`ChangeState.phase`), read from what its run last published. */
|
|
990
|
+
private async openChangePhases(): Promise<(ChangeState["phase"] | undefined)[]> {
|
|
991
|
+
const rows = await this.dbos.listWorkflows({
|
|
992
|
+
workflowName: CHANGE_WORKFLOW,
|
|
993
|
+
applicationName: this.config.appName,
|
|
994
|
+
status: dbosStatusesOf("queued", "running"),
|
|
995
|
+
loadInput: false,
|
|
996
|
+
loadOutput: false,
|
|
997
|
+
});
|
|
998
|
+
return Promise.all(
|
|
999
|
+
rows.map(async (r) => (await this.dbos.getEvent<ChangeState>(r.workflowID, CHANGE_EVENT, 0))?.phase),
|
|
1000
|
+
);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* Brings `head`, pushed to the draft's `branch` on the host, into the draft: false when it holds
|
|
1005
|
+
* it already, or cannot take it (no `follow`, a commit of its own, or one uncommitted: warned).
|
|
1006
|
+
*/
|
|
1007
|
+
private async fetched({ source, url, host }: Repository, branch: string, head: string): Promise<boolean> {
|
|
1008
|
+
const local = await Promise.resolve(source.head(branch)).catch(() => undefined);
|
|
1009
|
+
if (local === head) return false;
|
|
1010
|
+
const moved = source.follow
|
|
1011
|
+
? await source.follow({ url, token: await host.token(), ref: branch })
|
|
1012
|
+
: { skipped: "this repository cannot follow its remote" };
|
|
1013
|
+
if ("skipped" in moved) {
|
|
1014
|
+
warn(`${branch} did not take the host's commits: ${moved.skipped}`);
|
|
1015
|
+
return false;
|
|
1016
|
+
}
|
|
1017
|
+
return true;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/** The repository of `ClientOptions.source` on its host (`repositoryOf`); undefined with no source, remote or half for it. */
|
|
1021
|
+
private async repository(): Promise<Repository | undefined> {
|
|
1022
|
+
const { source } = this.options;
|
|
1023
|
+
if (!source) return undefined;
|
|
1024
|
+
return repositoryOf(this.config.remotes, this.storedSecrets, source).catch((err: unknown) => {
|
|
1025
|
+
if (["no_remote", "no_remote_half"].includes(vendorCodeOf(err) ?? "")) return undefined;
|
|
1026
|
+
throw err;
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/** The runs of the draft `id`'s change, newest first: `change:<id>` and `change:<id>~<n>`. */
|
|
1031
|
+
private async changeRuns(id: string, { load = false } = {}) {
|
|
1032
|
+
const base = changeRunId(id);
|
|
1033
|
+
const rows = await this.dbos.listWorkflows({
|
|
1034
|
+
workflow_id_prefix: base,
|
|
1035
|
+
workflowName: CHANGE_WORKFLOW,
|
|
1036
|
+
applicationName: this.config.appName,
|
|
1037
|
+
sortDesc: true,
|
|
1038
|
+
loadInput: load,
|
|
1039
|
+
loadOutput: load,
|
|
1040
|
+
});
|
|
1041
|
+
return rows.filter((r) => isChangeRunOf(id, r.workflowID));
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
/** A change run's row as the app shows it: its summary, its last `ChangeState` and, once finished, its report. */
|
|
1045
|
+
private async changeView(row: Parameters<SanomaClient["summarize"]>[0] & { input?: unknown; output?: unknown }) {
|
|
1046
|
+
const [summary, published] = await Promise.all([
|
|
1047
|
+
this.summarize(row),
|
|
1048
|
+
this.dbos.getEvent<ChangeState>(row.workflowID, CHANGE_EVENT, 0),
|
|
1049
|
+
]);
|
|
1050
|
+
const input = (row.input as [RunArgs])[0].input as ChangeInput;
|
|
1051
|
+
const state = published ?? initialChangeState(input);
|
|
1052
|
+
const { runId, status, startedBy, createdAt, updatedAt, approvals } = summary;
|
|
1053
|
+
return {
|
|
1054
|
+
runId,
|
|
1055
|
+
status,
|
|
1056
|
+
...(startedBy && { startedBy }),
|
|
1057
|
+
createdAt,
|
|
1058
|
+
...(updatedAt !== undefined && { updatedAt }),
|
|
1059
|
+
approvals,
|
|
1060
|
+
state,
|
|
1061
|
+
...(row.status === "SUCCESS" && { report: row.output as ChangeReport }),
|
|
1062
|
+
} satisfies ChangeView;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
763
1065
|
/**
|
|
764
1066
|
* Stores a value for a variable a driver's `env` declares, as `by`, and tells the workers: each
|
|
765
1067
|
* loads it into its environment, unless its environment sets the variable itself. Refuses
|
|
@@ -1034,5 +1336,19 @@ const alreadyDecided = (runId: string, a: ApprovalState) =>
|
|
|
1034
1336
|
},
|
|
1035
1337
|
);
|
|
1036
1338
|
|
|
1339
|
+
/** A change's first run's id: `change:<draft id>`. */
|
|
1340
|
+
const changeRunId = (id: string) => `change:${id}`;
|
|
1341
|
+
|
|
1342
|
+
/** True when `runId` is a run of the draft `id`'s change: `change:<id>` or `change:<id>~<n>`. */
|
|
1343
|
+
const isChangeRunOf = (id: string, runId: string | undefined) => runId !== undefined && changeIdOf(runId) === id;
|
|
1344
|
+
|
|
1345
|
+
/** Why a change event withdraws the merge's pending approval. */
|
|
1346
|
+
const WITHDRAWAL: Record<ChangeEvent["kind"], string> = {
|
|
1347
|
+
commits: "The change has new commits",
|
|
1348
|
+
close: "The change is being closed",
|
|
1349
|
+
closed: "The pull request was closed on the host",
|
|
1350
|
+
merged: "The pull request was merged on the host",
|
|
1351
|
+
};
|
|
1352
|
+
|
|
1037
1353
|
const notApprover = (runId: string, a: ApprovalState, reason: string) =>
|
|
1038
1354
|
new SanomaError("not_approver", `${reason} (${a.id})`, { runId, approvalId: a.id, approver: a.approver, reason });
|
package/src/collection.ts
CHANGED
|
@@ -12,7 +12,17 @@ import {
|
|
|
12
12
|
type Op,
|
|
13
13
|
type OpSpec,
|
|
14
14
|
} from "./op.ts";
|
|
15
|
-
import {
|
|
15
|
+
import { callerFile } from "./define.ts";
|
|
16
|
+
import { appOfFile } from "./paths.ts";
|
|
17
|
+
import {
|
|
18
|
+
checkCollection,
|
|
19
|
+
DATA_VENDOR,
|
|
20
|
+
type FIELD_TYPES,
|
|
21
|
+
LIST_LIMIT,
|
|
22
|
+
type ListQuery,
|
|
23
|
+
qualified,
|
|
24
|
+
type Saved,
|
|
25
|
+
} from "./shared.ts";
|
|
16
26
|
|
|
17
27
|
// A company's own records: a collection is declared with plain values (a name, a key and typed
|
|
18
28
|
// fields), and its `get`, `list`, `save` and `delete` are operations of the vendor `data`, under
|
|
@@ -62,7 +72,10 @@ export interface Collection<
|
|
|
62
72
|
> {
|
|
63
73
|
readonly [COLLECTION]: true;
|
|
64
74
|
readonly kind: "collection";
|
|
75
|
+
/** Its name: as written at the root; in an app's folder, `<app>.<name>` (`app`), though its type says the written one. */
|
|
65
76
|
readonly name: N;
|
|
77
|
+
/** The app whose folder declared it (`apps/<app>/resources/data.ts`); absent for one at the root. */
|
|
78
|
+
readonly app?: string;
|
|
66
79
|
readonly key: K;
|
|
67
80
|
readonly fields: F;
|
|
68
81
|
readonly title?: string;
|
|
@@ -77,7 +90,9 @@ export interface Collection<
|
|
|
77
90
|
* `collection({ name: "outbound", key: "company", fields: { company: "text", stage: { enum: ["new", "won"] } } })`.
|
|
78
91
|
* Its operations are `data.<name>.get`, `.list`, `.save` and `.delete`, which a workflow names in
|
|
79
92
|
* `uses` and calls on `ctx.data.<name>`. The name is a lowercase letter then letters and digits;
|
|
80
|
-
* the key is a required text field, each record's own and unique.
|
|
93
|
+
* the key is a required text field, each record's own and unique. In an app's folder
|
|
94
|
+
* (`apps/<app>/resources/data.ts`) the runtime names it `<app>.<name>`: `data.<app>.<name>.get`,
|
|
95
|
+
* called on `ctx.data.<app>.<name>`.
|
|
81
96
|
*/
|
|
82
97
|
export function collection<
|
|
83
98
|
const N extends string,
|
|
@@ -85,7 +100,10 @@ export function collection<
|
|
|
85
100
|
const K extends RequiredTextKeys<F>,
|
|
86
101
|
>(declaration: { name: N; key: K; fields: F; title?: string }): Collection<N, F, K> {
|
|
87
102
|
checkCollection(declaration);
|
|
88
|
-
const {
|
|
103
|
+
const { key, fields, title } = declaration;
|
|
104
|
+
// Frame 2 of `callerFile` is this function's caller: the data file.
|
|
105
|
+
const app = appOfFile(callerFile());
|
|
106
|
+
const name = qualified(app, declaration.name);
|
|
89
107
|
const record = z.strictObject(
|
|
90
108
|
Object.fromEntries(
|
|
91
109
|
Object.entries(fields).map(([field, kind]) => {
|
|
@@ -99,6 +117,7 @@ export function collection<
|
|
|
99
117
|
[COLLECTION]: true,
|
|
100
118
|
kind: "collection",
|
|
101
119
|
name,
|
|
120
|
+
...(app && { app }),
|
|
102
121
|
key,
|
|
103
122
|
fields,
|
|
104
123
|
...(title !== undefined && { title }),
|
package/src/config.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join, resolve } from "node:path";
|
|
3
3
|
import { pathToFileURL } from "node:url";
|
|
4
4
|
import { DBOS } from "@dbos-inc/dbos-sdk";
|
|
@@ -6,6 +6,8 @@ import { type ApplyDeclared, applyWorkflow } from "./apply.ts";
|
|
|
6
6
|
import { applySteps } from "./apply-steps.ts";
|
|
7
7
|
import { callWorkflow } from "./call-workflow.ts";
|
|
8
8
|
import { changeDriver, changeOps, reservedFor } from "./change.ts";
|
|
9
|
+
import { changeSteps } from "./change-steps.ts";
|
|
10
|
+
import { changeWorkflow } from "./change-workflow.ts";
|
|
9
11
|
import {
|
|
10
12
|
type Collection,
|
|
11
13
|
collectionsConnector,
|
|
@@ -14,6 +16,7 @@ import {
|
|
|
14
16
|
isCollection,
|
|
15
17
|
} from "./collection.ts";
|
|
16
18
|
import { lazyPool, type LazyPool } from "./db.ts";
|
|
19
|
+
import { isDirectory, kindDirs, SCENARIOS_DIR } from "./paths.ts";
|
|
17
20
|
import { callerFile, type Use, type WorkflowDefinition } from "./define.ts";
|
|
18
21
|
import { DRIFT_WORKFLOW, driftWorkflow } from "./drift.ts";
|
|
19
22
|
import { statusesOf, unconfigured } from "./env.ts";
|
|
@@ -37,11 +40,12 @@ import {
|
|
|
37
40
|
import { type CedarPolicySource, policyAt } from "./cedar.ts";
|
|
38
41
|
import type { Policy } from "./policy.ts";
|
|
39
42
|
import { type Resource, resourceTypesOf, withoutWriteOnly } from "./resource.ts";
|
|
40
|
-
import { APPLY_WORKFLOW, BUILT_IN_WORKFLOWS, CALL_WORKFLOW, DATA_VENDOR } from "./shared.ts";
|
|
43
|
+
import { APPLY_WORKFLOW, BUILT_IN_WORKFLOWS, CALL_WORKFLOW, CHANGE_WORKFLOW, DATA_VENDOR } from "./shared.ts";
|
|
41
44
|
import { type RemoteHalf, sourceDriver, sourceOps } from "./source.ts";
|
|
45
|
+
import { appsListDriver, appsListOps } from "./apps-list.ts";
|
|
42
46
|
import type { Steps } from "./steps.ts";
|
|
43
47
|
import { checkSchedule } from "./schedules.ts";
|
|
44
|
-
import { computeVersion } from "./version.ts";
|
|
48
|
+
import { changeCodeOf, computeVersion } from "./version.ts";
|
|
45
49
|
import type { DeclaredResource } from "./resources.ts";
|
|
46
50
|
import { checkWakeupOps, type DeclaredWakeup } from "./wakeup.ts";
|
|
47
51
|
import { sanomaWakeupDriver, wakeupSteps } from "./wakeup-driver.ts";
|
|
@@ -56,7 +60,11 @@ import { checkDatabaseOps } from "./database.ts";
|
|
|
56
60
|
* policies are `scenarios/`, `resources/` and `policies/` in `root`.
|
|
57
61
|
*/
|
|
58
62
|
export interface SanomaConfig {
|
|
59
|
-
|
|
63
|
+
/**
|
|
64
|
+
* Workflows besides those the launcher finds (`sanoma` imports every file of `workflows/` and
|
|
65
|
+
* each app's `apps/<app>/workflows/`): listing one it finds as well is no second one.
|
|
66
|
+
*/
|
|
67
|
+
workflows?: WorkflowDefinition<any, any>[];
|
|
60
68
|
/**
|
|
61
69
|
* The connectors (from `defineConnector`, as their packages export them). The worker takes
|
|
62
70
|
* each operation's effect, schemas and retry setting from here, not from the workflow, and
|
|
@@ -106,6 +114,12 @@ export interface SanomaConfig {
|
|
|
106
114
|
* that sets it and declares a wakeup of its own.
|
|
107
115
|
*/
|
|
108
116
|
wakeup?: true;
|
|
117
|
+
/**
|
|
118
|
+
* What the app says when it runs this repo as a demo (`sanoma demo`): `start`, the URL its
|
|
119
|
+
* banner's "Start with these apps" opens, such as the hosted wizard with the demo's apps
|
|
120
|
+
* chosen; without one, the banner has no such link.
|
|
121
|
+
*/
|
|
122
|
+
demo?: { start?: string };
|
|
109
123
|
}
|
|
110
124
|
|
|
111
125
|
/** A config, checked, with everything a worker, client or app derives from it. */
|
|
@@ -118,6 +132,8 @@ export interface ResolvedConfig {
|
|
|
118
132
|
databaseUrl: string;
|
|
119
133
|
/** The DBOS application version: `<appName>@<DBOS__APPVERSION, or a hash of the workflows>`. */
|
|
120
134
|
version: string;
|
|
135
|
+
/** The hash of the built-in change workflow's own code (`changeCodeOf`), which each change run carries. */
|
|
136
|
+
changeCode: string;
|
|
121
137
|
/** The DBOS queue runs are started on: `sanoma:<appName>`. */
|
|
122
138
|
queueName: string;
|
|
123
139
|
/**
|
|
@@ -151,7 +167,16 @@ export interface ResolvedConfig {
|
|
|
151
167
|
models: ReadonlyMap<string, NonNullable<ConnectorSetup["model"]>>;
|
|
152
168
|
/** The remote halves the connectors attach, made once for this resolution, by vendor id in `connectors` order: `remoteFor` finds a URL's. */
|
|
153
169
|
remotes: ReadonlyMap<string, RemoteHalf>;
|
|
154
|
-
/**
|
|
170
|
+
/**
|
|
171
|
+
* The resources the data files under `root` declare whose type is a host (`<file>#<export>`),
|
|
172
|
+
* read at each call: what a change's deploy and a push to `main` apply. None when no connector
|
|
173
|
+
* declares a host type.
|
|
174
|
+
*/
|
|
175
|
+
hosts(): Promise<string[]>;
|
|
176
|
+
/**
|
|
177
|
+
* `root`, as a `file:` URL, when `scenarios/` or an app's `apps/<app>/scenarios/` in it is a
|
|
178
|
+
* directory: the feature files under those, each named by its path from the root.
|
|
179
|
+
*/
|
|
155
180
|
scenarios?: URL;
|
|
156
181
|
/** The resource types the connectors declare (and `sanoma.wakeup`, beside a host type), by `<vendor>.<type>`: what the drift workflow checks and scenarios seed. */
|
|
157
182
|
types: ReadonlyMap<string, Resource>;
|
|
@@ -258,8 +283,8 @@ export function resolveConfig(config: SanomaConfig, options: ResolveOptions = {}
|
|
|
258
283
|
throw new Error("The config needs `connectors`: the connectors its workflows call, as their packages export them");
|
|
259
284
|
}
|
|
260
285
|
for (const connector of config.connectors as unknown[]) checkConnector(connector);
|
|
261
|
-
if (!Array.isArray(config.workflows)) {
|
|
262
|
-
throw new Error("The config
|
|
286
|
+
if (config.workflows !== undefined && !Array.isArray(config.workflows)) {
|
|
287
|
+
throw new Error("The config's `workflows`, when given, is an array of defineWorkflow definitions");
|
|
263
288
|
}
|
|
264
289
|
// An untyped config naming fake drivers would otherwise run live. Delete once sanoma-ops has migrated.
|
|
265
290
|
if (config.drivers !== undefined || config.fakes !== undefined)
|
|
@@ -279,16 +304,18 @@ export function resolveConfig(config: SanomaConfig, options: ResolveOptions = {}
|
|
|
279
304
|
const running = withBuiltIns(config.connectors);
|
|
280
305
|
const types = resourceTypesOf(running);
|
|
281
306
|
// Built-in: `sanoma.source.resolve`, which apply and drift call for a host's revision.
|
|
282
|
-
const
|
|
307
|
+
const hostTyped = [...types.values()].some((type) => type.host);
|
|
283
308
|
// Built-in: the collections' operations, `data.<name>.<op>`, on the config's database.
|
|
284
309
|
const databaseUrl = resolveDatabaseUrl(config);
|
|
285
310
|
const declared = [...collections.values()];
|
|
286
311
|
const dataStore = declared.length ? lazyPool(databaseUrl, "data") : undefined;
|
|
287
|
-
// Built-in, in every config: a change's operations, which the template's policy names
|
|
312
|
+
// Built-in, in every config: a change's operations, which the template's policy names
|
|
313
|
+
// (`sanoma.change.merge`), and the listing of apps a page may call (`sanoma.apps.list`).
|
|
288
314
|
const ops = indexConnectors([
|
|
289
315
|
...running,
|
|
290
316
|
changeOps,
|
|
291
|
-
|
|
317
|
+
appsListOps,
|
|
318
|
+
...(hostTyped ? [sourceOps] : []),
|
|
292
319
|
...(dataStore ? [collectionsConnector(declared)] : []),
|
|
293
320
|
]);
|
|
294
321
|
checkWakeupOps(types, ops);
|
|
@@ -298,18 +325,20 @@ export function resolveConfig(config: SanomaConfig, options: ResolveOptions = {}
|
|
|
298
325
|
const driversByVendor = instanceFakes
|
|
299
326
|
? new Map([...instanceFakes].map(([vendor, fake]) => [vendor, fake.driver]))
|
|
300
327
|
: new Map([...makersOf(running, "driver")].map(([vendor, make]) => [vendor, make()]));
|
|
328
|
+
const remotes = new Map([...makersOf(config.connectors, "remote")].map(([vendor, make]) => [vendor, make()]));
|
|
301
329
|
const drivers = indexDrivers(
|
|
302
330
|
[
|
|
303
331
|
...driversByVendor.values(),
|
|
304
|
-
changeDriver(),
|
|
305
|
-
|
|
332
|
+
changeDriver(remotes),
|
|
333
|
+
appsListDriver,
|
|
334
|
+
...(hostTyped ? [sourceDriver] : []),
|
|
306
335
|
...(dataStore ? [collectionsDriver(declared, dataStore)] : []),
|
|
307
336
|
],
|
|
308
337
|
ops,
|
|
309
338
|
);
|
|
310
339
|
if (dataStore) fakes.set(DATA_VENDOR, () => collectionsFake(declared));
|
|
311
340
|
const names = new Map<string, WorkflowDefinition<any, any>>();
|
|
312
|
-
for (const wf of config.workflows) {
|
|
341
|
+
for (const wf of config.workflows ?? []) {
|
|
313
342
|
if (BUILT_IN_WORKFLOWS.has(wf.name)) {
|
|
314
343
|
throw new Error(`A workflow is named "${wf.name}", the name of a built-in workflow; name it otherwise`);
|
|
315
344
|
}
|
|
@@ -324,17 +353,21 @@ export function resolveConfig(config: SanomaConfig, options: ResolveOptions = {}
|
|
|
324
353
|
names.set(wf.name, wf);
|
|
325
354
|
}
|
|
326
355
|
// Built-in: checking the resources the data files declare against their vendors, and applying them.
|
|
356
|
+
const connectors = connectorsOf(config);
|
|
357
|
+
// The types the data files may declare: the vendors' and the people's, whose resources are left out.
|
|
358
|
+
const declarable = resourceTypesOf(connectors);
|
|
359
|
+
const read = () => readDeclared(root, connectors, declarable, types);
|
|
327
360
|
if (types.size) {
|
|
328
|
-
const connectors = connectorsOf(config);
|
|
329
|
-
// The types the data files may declare: the vendors' and the people's, whose resources are left out.
|
|
330
|
-
const declarable = resourceTypesOf(connectors);
|
|
331
|
-
const read = () => readDeclared(root, connectors, declarable, types);
|
|
332
361
|
names.set(
|
|
333
362
|
DRIFT_WORKFLOW,
|
|
334
363
|
driftWorkflow(types, ops, drivers, () => DBOS.runStep(read, { name: "drift:data-files" })),
|
|
335
364
|
);
|
|
336
365
|
names.set(APPLY_WORKFLOW, applyWorkflow(types, ops, drivers, applySteps(appName, root, read)));
|
|
337
366
|
}
|
|
367
|
+
// Built-in, in every config: the life of a change, which deploys to the declared hosts after its merge.
|
|
368
|
+
const hosts = async () =>
|
|
369
|
+
(await read()).resources.filter((r) => types.get(`${r.vendor}.${r.type}`)?.host).map((r) => r.id);
|
|
370
|
+
names.set(CHANGE_WORKFLOW, changeWorkflow(changeOps, changeSteps({ remotes, hosts })));
|
|
338
371
|
// Built-in: a direct call the policy holds for an approval, made a run, when any operation may be called directly.
|
|
339
372
|
const call = callWorkflow(ops, drivers, types);
|
|
340
373
|
if (call.uses.length) names.set(CALL_WORKFLOW, call);
|
|
@@ -344,6 +377,7 @@ export function resolveConfig(config: SanomaConfig, options: ResolveOptions = {}
|
|
|
344
377
|
databaseUrl,
|
|
345
378
|
// The built-in's code and operations are part of the app's version, like the config's own.
|
|
346
379
|
version: computeVersion({ appName, workflows: [...names.values()] }),
|
|
380
|
+
changeCode: changeCodeOf(names.get(CHANGE_WORKFLOW)!),
|
|
347
381
|
queueName: `sanoma:${appName}`,
|
|
348
382
|
ops,
|
|
349
383
|
drivers,
|
|
@@ -352,7 +386,8 @@ export function resolveConfig(config: SanomaConfig, options: ResolveOptions = {}
|
|
|
352
386
|
...(instanceFakes && { instanceFakes }),
|
|
353
387
|
logins: makersOf(config.connectors, "login"),
|
|
354
388
|
models: makersOf(config.connectors, "model"),
|
|
355
|
-
remotes
|
|
389
|
+
remotes,
|
|
390
|
+
hosts,
|
|
356
391
|
...(scenarios && { scenarios }),
|
|
357
392
|
types,
|
|
358
393
|
steps: [...new Set([...running.flatMap((c) => setupOf(c).steps ?? []), ...(config.steps ?? [])])],
|
|
@@ -479,11 +514,14 @@ function appNameAt(root: string | undefined): string {
|
|
|
479
514
|
return typeof named === "string" && named.trim() ? named.trim() : "sanoma";
|
|
480
515
|
}
|
|
481
516
|
|
|
482
|
-
/**
|
|
517
|
+
/**
|
|
518
|
+
* The root, as a `file:` URL to the directory (with its trailing slash), when `<root>/scenarios/`
|
|
519
|
+
* or an app's `apps/<app>/scenarios/` is a directory: the scenarios are the feature files there,
|
|
520
|
+
* each named by its path from the root (`scenarioFiles`).
|
|
521
|
+
*/
|
|
483
522
|
function scenariosAt(root: string | undefined): URL | undefined {
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
return pathToFileURL(`${dir}/`);
|
|
523
|
+
if (root === undefined || !kindDirs(root, SCENARIOS_DIR).some(isDirectory)) return undefined;
|
|
524
|
+
return pathToFileURL(`${root}/`);
|
|
487
525
|
}
|
|
488
526
|
|
|
489
527
|
/**
|