@sanoma-ai/workflows 0.11.14 → 0.11.16

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.
Files changed (93) hide show
  1. package/README.md +10 -3
  2. package/dist/app-steps.d.ts +17 -0
  3. package/dist/app-steps.js +43 -0
  4. package/dist/app-steps.js.map +1 -0
  5. package/dist/app-workflows.d.ts +170 -0
  6. package/dist/app-workflows.js +107 -0
  7. package/dist/app-workflows.js.map +1 -0
  8. package/dist/apply-steps.js +3 -9
  9. package/dist/apply-steps.js.map +1 -1
  10. package/dist/approvals.d.ts +1 -1
  11. package/dist/approvals.js +3 -0
  12. package/dist/approvals.js.map +1 -1
  13. package/dist/call.d.ts +2 -1
  14. package/dist/call.js +2 -1
  15. package/dist/call.js.map +1 -1
  16. package/dist/change-steps.js +11 -17
  17. package/dist/change-steps.js.map +1 -1
  18. package/dist/chat-steps.d.ts +5 -0
  19. package/dist/chat-steps.js +107 -0
  20. package/dist/chat-steps.js.map +1 -0
  21. package/dist/chat-workflow.d.ts +43 -0
  22. package/dist/chat-workflow.js +47 -0
  23. package/dist/chat-workflow.js.map +1 -0
  24. package/dist/chats.d.ts +23 -4
  25. package/dist/chats.js +54 -7
  26. package/dist/chats.js.map +1 -1
  27. package/dist/client.d.ts +42 -31
  28. package/dist/client.js +118 -105
  29. package/dist/client.js.map +1 -1
  30. package/dist/config.js +14 -1
  31. package/dist/config.js.map +1 -1
  32. package/dist/db/company.d.ts +259 -2
  33. package/dist/db/company.js +32 -1
  34. package/dist/db/company.js.map +1 -1
  35. package/dist/db.d.ts +15 -0
  36. package/dist/db.js +42 -2
  37. package/dist/db.js.map +1 -1
  38. package/dist/fill.d.ts +5 -1
  39. package/dist/fill.js +11 -2
  40. package/dist/fill.js.map +1 -1
  41. package/dist/follow-steps.d.ts +8 -0
  42. package/dist/follow-steps.js +113 -0
  43. package/dist/follow-steps.js.map +1 -0
  44. package/dist/follow-workflow.d.ts +56 -0
  45. package/dist/follow-workflow.js +50 -0
  46. package/dist/follow-workflow.js.map +1 -0
  47. package/dist/index.d.ts +5 -2
  48. package/dist/index.js +4 -1
  49. package/dist/index.js.map +1 -1
  50. package/dist/run.d.ts +39 -0
  51. package/dist/run.js +15 -0
  52. package/dist/run.js.map +1 -1
  53. package/dist/sandbox.d.ts +10 -0
  54. package/dist/sandbox.js +42 -4
  55. package/dist/sandbox.js.map +1 -1
  56. package/dist/scenario.d.ts +19 -0
  57. package/dist/scenario.js.map +1 -1
  58. package/dist/seal.d.ts +7 -0
  59. package/dist/seal.js +14 -2
  60. package/dist/seal.js.map +1 -1
  61. package/dist/shared.d.ts +34 -2
  62. package/dist/shared.js +39 -2
  63. package/dist/shared.js.map +1 -1
  64. package/dist/worker.d.ts +8 -0
  65. package/dist/worker.js +116 -10
  66. package/dist/worker.js.map +1 -1
  67. package/drizzle/company/0002_runs_tell_the_app.sql +27 -0
  68. package/drizzle/company/meta/0002_snapshot.json +819 -0
  69. package/drizzle/company/meta/_journal.json +7 -0
  70. package/package.json +1 -1
  71. package/src/app-steps.ts +67 -0
  72. package/src/app-workflows.ts +251 -0
  73. package/src/apply-steps.ts +3 -9
  74. package/src/approvals.ts +3 -1
  75. package/src/call.ts +2 -1
  76. package/src/change-steps.ts +10 -17
  77. package/src/chat-steps.ts +117 -0
  78. package/src/chat-workflow.ts +69 -0
  79. package/src/chats.ts +80 -14
  80. package/src/client.ts +154 -109
  81. package/src/config.ts +14 -0
  82. package/src/db/company.ts +49 -2
  83. package/src/db.ts +51 -3
  84. package/src/fill.ts +10 -2
  85. package/src/follow-steps.ts +129 -0
  86. package/src/follow-workflow.ts +88 -0
  87. package/src/index.ts +19 -1
  88. package/src/run.ts +41 -0
  89. package/src/sandbox.ts +41 -4
  90. package/src/scenario.ts +17 -0
  91. package/src/seal.ts +16 -2
  92. package/src/shared.ts +47 -2
  93. package/src/worker.ts +149 -10
package/src/db/company.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { UIMessage } from "ai";
1
+ import type { UIMessage, UIMessageChunk } from "ai";
2
2
  import { sql } from "drizzle-orm";
3
3
  import {
4
4
  bigserial,
@@ -14,6 +14,7 @@ import {
14
14
  } from "drizzle-orm/pg-core";
15
15
  import type { ConnectionHealth, ConnectionStatus } from "../connection.ts";
16
16
  import type { LedgerRecord } from "../ledger.ts";
17
+ import type { RunCheck } from "../scenario.ts";
17
18
  import type { SecretLocation } from "../secret-store.ts";
18
19
 
19
20
  // The runtime's tables in a company's database, beside DBOS's: what `prepareDatabase` makes and
@@ -86,7 +87,7 @@ export const wakeup = pgTable(
86
87
  (t) => [check("sanoma_wakeup_one_row", sql`${t.id} = 1`)],
87
88
  );
88
89
 
89
- /** The operator agent's chats (`chats.ts`): who each is for, its engine and its draft. */
90
+ /** The operator agent's chats (`chats.ts`): who each is for, its engine, its draft and the turn last begun (its run's id). */
90
91
  export const chats = pgTable(
91
92
  "sanoma_chats",
92
93
  {
@@ -96,6 +97,7 @@ export const chats = pgTable(
96
97
  draftId: text(),
97
98
  title: text().notNull(),
98
99
  createdAt: when().notNull().defaultNow(),
100
+ turn: text(),
99
101
  },
100
102
  (t) => [index("sanoma_chats_person").on(t.person, t.createdAt)],
101
103
  );
@@ -114,6 +116,24 @@ export const chatMessages = pgTable(
114
116
  (t) => [primaryKey({ columns: [t.chatId, t.id] })],
115
117
  );
116
118
 
119
+ /**
120
+ * The reply a chat turn streams, as the worker writes it (`Chats.append`): its UI message stream's
121
+ * chunks in `seq` order, then a row with none, its end. The app streams them to the browser; a
122
+ * chat's turn's rows go when its next turn begins.
123
+ */
124
+ export const chatChunks = pgTable(
125
+ "sanoma_chat_chunks",
126
+ {
127
+ chatId: text()
128
+ .notNull()
129
+ .references(() => chats.id, { onDelete: "cascade" }),
130
+ runId: text().notNull(),
131
+ seq: integer().notNull(),
132
+ chunk: jsonb().$type<UIMessageChunk>(),
133
+ },
134
+ (t) => [primaryKey({ columns: [t.runId, t.seq] }), index("sanoma_chat_chunks_chat").on(t.chatId)],
135
+ );
136
+
117
137
  /** The api tokens (`tokens.ts`): each one's SHA-256, never the token, whom it acts for, its groups, and when it was revoked. */
118
138
  export const apiTokens = pgTable("sanoma_api_tokens", {
119
139
  id: text().primaryKey(),
@@ -171,3 +191,30 @@ export const connectionSetups = pgTable(
171
191
  },
172
192
  (t) => [primaryKey({ columns: [t.connectionId, t.revision] })],
173
193
  );
194
+
195
+ /**
196
+ * How far each app taken from a source is behind it, as the worker last checked (`writeAppsBehind`):
197
+ * one row per app folder, its count of commits or why it could not be told.
198
+ */
199
+ export const appBehind = pgTable(
200
+ "sanoma_app_behind",
201
+ {
202
+ folder: text().primaryKey(),
203
+ behind: integer(),
204
+ error: text(),
205
+ checkedAt: when().notNull().defaultNow(),
206
+ },
207
+ (t) => [check("sanoma_app_behind_one_answer", sql`(${t.behind} IS NULL) <> (${t.error} IS NULL)`)],
208
+ );
209
+
210
+ /** Each sandbox run's checks against its scenario, as the worker last ran them (`recordChecks` in sandbox.ts): one row per run. */
211
+ export const runChecks = pgTable(
212
+ "sanoma_run_checks",
213
+ {
214
+ runId: text().primaryKey(),
215
+ checks: jsonb().$type<RunCheck[]>(),
216
+ error: text(),
217
+ checkedAt: when().notNull().defaultNow(),
218
+ },
219
+ (t) => [check("sanoma_run_checks_one_answer", sql`(${t.checks} IS NULL) <> (${t.error} IS NULL)`)],
220
+ );
package/src/db.ts CHANGED
@@ -1,17 +1,20 @@
1
1
  import { DBOS, DBOSClient } from "@dbos-inc/dbos-sdk";
2
2
  import { setTimeout as delay } from "node:timers/promises";
3
- import { and, eq, inArray, lte, ne, sql } from "drizzle-orm";
3
+ import { and, eq, inArray, lte, ne, notInArray, sql } from "drizzle-orm";
4
4
  import { DrizzleQueryError } from "drizzle-orm/errors";
5
5
  import { drizzle, type NodePgClient, type NodePgQueryResultHKT } from "drizzle-orm/node-postgres";
6
6
  import { type PgDatabase, PgPreparedQuery } from "drizzle-orm/pg-core";
7
7
  import { Client, Pool } from "pg";
8
- import { inviteRequests } from "./db/company.ts";
8
+ import { appBehind, inviteRequests, runChecks } from "./db/company.ts";
9
9
  import { workflowStatus } from "./db/dbos.ts";
10
10
  import { applyMigrations, COMPANY_MIGRATIONS, migrationsApplied } from "./db/migrate.ts";
11
11
  import { warn } from "./log.ts";
12
+ import type { RunChecks } from "./scenario.ts";
12
13
  import { errorMessage } from "./shared.ts";
13
14
 
14
- // The runtime's own tables live in the config's database, beside DBOS's (`db/company.ts`). What
15
+ // The runtime's own tables live in the config's database, beside DBOS's (`db/company.ts`), among
16
+ // them what the worker tells the app (how far each app is behind its source, a sandbox run's
17
+ // checks). What
15
18
  // the stores share is here: Drizzle over a `pg` pool or connection (`databaseOn`), the one
16
19
  // preparation of a database, which applies the generated migrations under a lock, and a pool that
17
20
  // opens once it is prepared and survives a dropped connection.
@@ -377,3 +380,48 @@ export async function spendInviteRequest(db: Db, nonce: string, expiresAt: Date)
377
380
  .returning({ nonce: inviteRequests.nonce });
378
381
  return spent.length === 1;
379
382
  }
383
+
384
+ /** How far an app is behind its source (`Source.behind`), or why that could not be told. */
385
+ export type AppBehind = { behind: number } | { error: string };
386
+
387
+ /** Sets each app's row to what was told of it now, and drops the rows of apps no longer listed. */
388
+ export async function writeAppsBehind(db: Db, told: ReadonlyMap<string, AppBehind>): Promise<void> {
389
+ const folders = [...told.keys()];
390
+ await db.delete(appBehind).where(folders.length ? notInArray(appBehind.folder, folders) : undefined);
391
+ for (const [folder, answer] of told) {
392
+ const row = "behind" in answer ? { behind: answer.behind, error: null } : { behind: null, error: answer.error };
393
+ await db
394
+ .insert(appBehind)
395
+ .values({ folder, ...row })
396
+ .onConflictDoUpdate({ target: appBehind.folder, set: { ...row, checkedAt: sql`now()` } });
397
+ }
398
+ }
399
+
400
+ /** The app `folder`'s row: how far it is behind its source, or why not; undefined when the worker has not told it. */
401
+ export async function readAppBehind(db: Db, folder: string): Promise<AppBehind | undefined> {
402
+ const [row] = await db
403
+ .select({ behind: appBehind.behind, error: appBehind.error })
404
+ .from(appBehind)
405
+ .where(eq(appBehind.folder, folder));
406
+ if (!row) return undefined;
407
+ return row.error === null ? { behind: row.behind! } : { error: row.error };
408
+ }
409
+
410
+ /** Sets the run's checks (`RunChecks`), replacing what was there. */
411
+ export async function writeRunChecks(db: Db, runId: string, checks: RunChecks): Promise<void> {
412
+ const row = "checks" in checks ? { checks: checks.checks, error: null } : { checks: null, error: checks.error };
413
+ await db
414
+ .insert(runChecks)
415
+ .values({ runId, ...row })
416
+ .onConflictDoUpdate({ target: runChecks.runId, set: { ...row, checkedAt: sql`now()` } });
417
+ }
418
+
419
+ /** The run's checks as the worker last ran them; undefined when it has run none. */
420
+ export async function readRunChecks(db: Db, runId: string): Promise<RunChecks | undefined> {
421
+ const [row] = await db
422
+ .select({ checks: runChecks.checks, error: runChecks.error })
423
+ .from(runChecks)
424
+ .where(eq(runChecks.runId, runId));
425
+ if (!row) return undefined;
426
+ return row.error === null ? { checks: row.checks! } : { error: row.error };
427
+ }
package/src/fill.ts CHANGED
@@ -10,9 +10,17 @@ setFaker(faker);
10
10
  /** Seeds the generator from a name, so one name always makes up the same values. */
11
11
  export const seedFrom = (name: string) => seed(createHash("sha256").update(name).digest().readUInt32BE(0));
12
12
 
13
- /** The schema's value with every field `given` does not set made up, parsed by the schema. */
13
+ /**
14
+ * The schema's value with every required field `given` does not set made up, parsed by the schema.
15
+ * Optional fields are left out, as a caller may leave them: made up, they could break a rule
16
+ * between fields (one of two ids, not both).
17
+ */
14
18
  export function fill(schema: z.ZodType, given: Record<string, unknown>): unknown {
15
- return schema.parse({ ...(fake(schema) as object), ...given });
19
+ const made = fake(schema) as Record<string, unknown>;
20
+ for (const [name, field] of Object.entries(shapeOf(schema))) {
21
+ if (field.safeParse(undefined).success) delete made[name];
22
+ }
23
+ return schema.parse({ ...made, ...given });
16
24
  }
17
25
 
18
26
  /** An object schema's fields by name; none for any other schema. */
@@ -0,0 +1,129 @@
1
+ import { DBOS } from "@dbos-inc/dbos-sdk";
2
+ import { BASE, type ChangeEvent, repositoryOf } from "./change.ts";
3
+ import { errorCode, vendorCodeOf } from "./errors.ts";
4
+ import type { FollowOutput, FollowSteps, FollowTarget } from "./follow-workflow.ts";
5
+ import { warn } from "./log.ts";
6
+ import { runNow, type Run } from "./run.ts";
7
+ import { APPLY_WORKFLOW, HOST_CHECK_AGENT, isEnded } from "./shared.ts";
8
+ import { draftOf, type RemoteHalf, type RepoRef } from "./source.ts";
9
+
10
+ // The follow workflow's steps (`follow-workflow.ts` is held to the workflow lint and imports no
11
+ // DBOS): where an event lands, `main` followed and its hosts applied, a draft's change told what
12
+ // its host says. Each reads its run from `currentRun`; the worker's own client tells and reads
13
+ // the changes, as `host-check`, and every write to the checkout is one of the worker's, one at a
14
+ // time (`WorkerState.serially`).
15
+
16
+ /** Whether two references name one repository: a host's names are compared without case. */
17
+ const sameRepo = (a: RepoRef, b: RepoRef) =>
18
+ a.owner.toLowerCase() === b.owner.toLowerCase() && a.name.toLowerCase() === b.name.toLowerCase();
19
+
20
+ /** What the follow steps need of the resolved config: the remote halves and the declared hosts. */
21
+ export interface FollowStepsOptions {
22
+ remotes: ReadonlyMap<string, RemoteHalf>;
23
+ hosts(): Promise<string[]>;
24
+ }
25
+
26
+ export function followSteps({ remotes, hosts }: FollowStepsOptions): FollowSteps {
27
+ /** The worker's repository on its host (`repositoryOf`); undefined with no repository, remote or half for it. */
28
+ const repository = async (run: Run) => {
29
+ const { source } = run.state;
30
+ if (!source) return undefined;
31
+ return repositoryOf(remotes, run.state.secrets, source).catch((err: unknown) => {
32
+ if (["no_remote", "no_remote_half"].includes(vendorCodeOf(err) ?? "")) return undefined;
33
+ throw err;
34
+ });
35
+ };
36
+
37
+ return {
38
+ where: (event) =>
39
+ DBOS.runStep(
40
+ async (): Promise<FollowTarget> => {
41
+ const main = event.kind === "pushed" && event.branch === BASE;
42
+ const id = draftOf(event.branch);
43
+ if (!main && id === undefined) return { ignored: `${event.branch} is neither ${BASE} nor a draft's branch` };
44
+ const run = runNow("A follow step");
45
+ if (!run.state.source) return { ignored: "This worker has no repository" };
46
+ const found = await repository(run);
47
+ if (!found) return { ignored: "This worker's repository has no remote on a host a connector reads" };
48
+ if (!sameRepo(found.repo, event.repo)) {
49
+ return { ignored: `The event is ${event.repo.owner}/${event.repo.name}'s, not this repository's` };
50
+ }
51
+ return event.kind === "pushed" && main ? { main: event.head } : { draft: id! };
52
+ },
53
+ { name: "follow:where" },
54
+ ),
55
+
56
+ async main(head) {
57
+ const run = runNow("A follow step");
58
+ const deploy = `deploy:${head}`;
59
+ const declared = await DBOS.runStep(
60
+ async () => {
61
+ const client = await run.state.client();
62
+ const [deployed, deploying] = await Promise.all([client.run(deploy), client.changeDeploying()]);
63
+ if (deployed || deploying) return undefined;
64
+ // First, as a change's deploy does: the hosts, and what apply reads, are then main's.
65
+ const moved = await run.state.followMain();
66
+ if (!("revision" in moved)) {
67
+ // Nothing recorded as `deploy:<head>`: a later push deploys it.
68
+ throw new Error(
69
+ `${BASE} was not deployed: the checkout did not follow it: ${"skipped" in moved ? moved.skipped : moved.error}`,
70
+ );
71
+ }
72
+ return run.state.workflows.has(APPLY_WORKFLOW) ? await hosts() : [];
73
+ },
74
+ { name: "follow:main" },
75
+ );
76
+ if (!declared) return { ignored: `${deploy} has run, or a change is deploying ${BASE}` };
77
+ if (!declared.length) return { followed: "main" } satisfies FollowOutput;
78
+ // As the run's own actor, `host-check` (`SanomaClient.follow`).
79
+ await run.state.startRun(APPLY_WORKFLOW, deploy, { input: { only: declared }, startedBy: run.actor });
80
+ return { followed: "main", deploy };
81
+ },
82
+
83
+ draft: (event, id) =>
84
+ DBOS.runStep(
85
+ async (): Promise<{ told: ChangeEvent; key: string } | { ignored: string }> => {
86
+ const run = runNow("A follow step");
87
+ const change = await (await run.state.client()).change(id);
88
+ if (!change || isEnded(change.status)) return { ignored: `The draft ${id} has no open change` };
89
+ const key = `${event.kind}:${event.branch}:`;
90
+ if (event.kind === "closed") {
91
+ // A pull request of an earlier change of the draft is not this one's.
92
+ if (event.number !== change.state.pullRequest?.number) {
93
+ return { ignored: `Pull request #${event.number} is not the open change's` };
94
+ }
95
+ return {
96
+ told: event.merged && event.sha !== undefined ? { kind: "merged", sha: event.sha } : { kind: "closed" },
97
+ key: `${key}${event.number}:${event.sha ?? "closed"}`,
98
+ };
99
+ }
100
+ if (change.state.head === event.head) return { ignored: `The change is at ${event.head} already` };
101
+ const found = await repository(run);
102
+ if (!found?.source.follow) return { ignored: "This repository cannot follow its remote" };
103
+ const { source, url, host } = found;
104
+ const fetched = await run.state.serially(async () => {
105
+ const local = await Promise.resolve(source.head(event.branch)).catch(() => undefined);
106
+ if (local === event.head) return false;
107
+ const moved = await source.follow!({ url, token: await host.token(), ref: event.branch });
108
+ if ("skipped" in moved) warn(`${event.branch} did not take the host's commits: ${moved.skipped}`);
109
+ return !("skipped" in moved);
110
+ });
111
+ if (!fetched) return { ignored: `${event.branch} holds ${event.head} already, or cannot take it` };
112
+ return { told: { kind: "commits" }, key: `${key}${event.head}` };
113
+ },
114
+ { name: "follow:draft" },
115
+ ),
116
+
117
+ tell: (id, told, key) =>
118
+ DBOS.runStep(
119
+ async () => {
120
+ const client = await runNow("A follow step").state.client();
121
+ await client.changeEvent(id, told, { by: HOST_CHECK_AGENT, key }).catch((err: unknown) => {
122
+ // Ended, or gone, since it was read: nothing to tell.
123
+ if (!["run_ended", "run_not_found"].includes(errorCode(err) ?? "")) throw err;
124
+ });
125
+ },
126
+ { name: "follow:tell" },
127
+ ),
128
+ };
129
+ }
@@ -0,0 +1,88 @@
1
+ import { z } from "zod";
2
+ import type { ChangeEvent } from "./change.ts";
3
+ import { defineWorkflow, type WorkflowDefinition } from "./define.ts";
4
+ import type { FOLLOW_WORKFLOW } from "./shared.ts";
5
+ import type { RemoteEvent } from "./source.ts";
6
+
7
+ // The built-in `follow` workflow: what a git host's webhook says happened to the company's
8
+ // repository, done by the worker, which has the checkout. The app verifies the webhook and starts
9
+ // the run (`SanomaClient.follow`, as the runtime's agent `host-check`, one run per event); what it
10
+ // does is no operation, so each part is a step of `follow-steps.ts`. A workflow like any other,
11
+ // held to the same lint.
12
+
13
+ const RepoRefSchema = z.object({ owner: z.string().min(1), name: z.string().min(1) });
14
+
15
+ /** What a host's webhook says happened (`RemoteEvent`): a branch pushed, or a pull request closed (merged or not). */
16
+ export const RemoteEventSchema: z.ZodType<RemoteEvent> = z.discriminatedUnion("kind", [
17
+ z.object({ kind: z.literal("pushed"), repo: RepoRefSchema, branch: z.string().min(1), head: z.string().min(1) }),
18
+ z.object({
19
+ kind: z.literal("closed"),
20
+ repo: RepoRefSchema,
21
+ branch: z.string().min(1),
22
+ number: z.number().int(),
23
+ merged: z.boolean(),
24
+ sha: z.string().min(1).optional(),
25
+ }),
26
+ ]);
27
+
28
+ /**
29
+ * What a follow did: nothing, saying why (`ignored`); `main` followed, with the run that applies
30
+ * the declared hosts (`deploy:<head>`) when there are any; or the change of a draft told an event.
31
+ */
32
+ export type FollowOutput =
33
+ | { ignored: string }
34
+ | { followed: "main"; deploy?: string }
35
+ | { change: string; told: ChangeEvent["kind"] };
36
+
37
+ /** Where an event lands (`FollowSteps.where`): nothing, saying why; a push to `main`, of its head; or a draft's branch. */
38
+ export type FollowTarget = { ignored: string } | { main: string } | { draft: string };
39
+
40
+ /** What a follow does that is no operation, each a step of its own (`follow-steps.ts`). */
41
+ export interface FollowSteps {
42
+ /**
43
+ * Where the event lands: ignored when the worker has no repository, the event is another
44
+ * repository's, or its branch is neither `main` (a push) nor a draft's.
45
+ */
46
+ where(event: RemoteEvent): Promise<FollowTarget>;
47
+ /**
48
+ * After a push of `head` to `main`: the checkout follows it, then apply on the declared hosts as
49
+ * `host-check`, as the run `deploy:<head>`, without waiting for it. Nothing while a change is
50
+ * merging or deploying, which does both itself; throws when the checkout cannot follow.
51
+ */
52
+ main(head: string): Promise<FollowOutput>;
53
+ /**
54
+ * The event for the draft `id`'s open change, when it is one to tell: a push's commits fetched
55
+ * into the draft first (none when the draft or the change is at that head already, or it cannot
56
+ * fast-forward: warned), a closed pull request of this change as `merged` or `closed`.
57
+ */
58
+ draft(event: RemoteEvent, id: string): Promise<{ told: ChangeEvent; key: string } | { ignored: string }>;
59
+ /** Tells the change of the draft `id` the event, once under `key`, as `host-check`: nothing once its run has ended. */
60
+ tell(id: string, told: ChangeEvent, key: string): Promise<void>;
61
+ }
62
+
63
+ /** The built-in follow workflow over `steps`: `resolveConfig` adds it to every config. */
64
+ export function followWorkflow(steps: FollowSteps): WorkflowDefinition<any, any> {
65
+ return defineWorkflow({
66
+ // The literal, held to the one name: the outline finds a workflow by a string, or a const of its own file.
67
+ name: "follow" satisfies typeof FOLLOW_WORKFLOW,
68
+ title: "Follow the repository's host",
69
+ trigger: "manual",
70
+ input: RemoteEventSchema,
71
+ uses: [],
72
+ run: async (_ctx, event): Promise<FollowOutput> => {
73
+ const at = await steps.where(event);
74
+ let output: FollowOutput;
75
+ if ("ignored" in at) output = at;
76
+ else if ("main" in at) output = await steps.main(at.main);
77
+ else {
78
+ const found = await steps.draft(event, at.draft);
79
+ if ("ignored" in found) output = found;
80
+ else {
81
+ await steps.tell(at.draft, found.told, found.key);
82
+ output = { change: at.draft, told: found.told.kind };
83
+ }
84
+ }
85
+ return output;
86
+ },
87
+ });
88
+ }
package/src/index.ts CHANGED
@@ -27,6 +27,7 @@ export {
27
27
  retryableStatus,
28
28
  type SignInMethod,
29
29
  type SocialClients,
30
+ VENDOR,
30
31
  type VendorInfo,
31
32
  withSetup,
32
33
  } from "./op.ts";
@@ -93,6 +94,23 @@ export {
93
94
  type PlanStep,
94
95
  } from "./apply.ts";
95
96
  export { CALL_WORKFLOW, CallInput } from "./call-workflow.ts";
97
+ export {
98
+ AdoptInput,
99
+ type AppApi,
100
+ type AppStepContext,
101
+ type AppSteps,
102
+ type ChatTurnContext,
103
+ ConnectExistingInput,
104
+ type ConnectExistingOutput,
105
+ GoLiveHostInput,
106
+ GoLiveInput,
107
+ type GoLiveOutput,
108
+ PatchInput,
109
+ type PatchOutput,
110
+ type Written,
111
+ } from "./app-workflows.ts";
112
+ export { type FollowOutput } from "./follow-workflow.ts";
113
+ export { ChatTurnInput, type ChatTurnOutput } from "./chat-workflow.ts";
96
114
  export {
97
115
  ADMIN,
98
116
  type ChangeDecision,
@@ -263,7 +281,7 @@ export {
263
281
  type StoredSecret,
264
282
  } from "./client.ts";
265
283
  // A raw DBOS client on the runtime's database, for tests and tools that read DBOS directly.
266
- export { dbosClient } from "./db.ts";
284
+ export { type AppBehind, dbosClient } from "./db.ts";
267
285
  // The runtime bundled where a company's files are not (the Cloudflare host's Worker): the root's
268
286
  // people, policies and app name as data, Cedar's web build, and a database it only checks.
269
287
  export { type RootSnapshot, useRootSnapshot } from "./bundled.ts";
package/src/run.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  import type { ConnectionRef, ConnectionRegistry, ConnectionSnapshot } from "./connection.ts";
2
2
  import { AsyncLocalStorage } from "node:async_hooks";
3
+ import type { WorkerApp } from "./app-steps.ts";
4
+ import type { SanomaClient } from "./client.ts";
3
5
  import type { Collection } from "./collection.ts";
4
6
  import type { DeclaredObjects } from "./config.ts";
7
+ import type { Db } from "./db.ts";
5
8
  import type { DeadlineRegistry } from "./deadlines.ts";
6
9
  import type { ApprovalState, Approver, Principal, WorkflowDefinition } from "./define.ts";
7
10
  import type { Fake } from "./fake.ts";
@@ -53,6 +56,24 @@ export interface WorkerState {
53
56
  scenarios?: URL;
54
57
  /** The worker's `source`, when it was given one. */
55
58
  source?: Source;
59
+ /** The app's steps (`WorkerOptions.app`): what the app's built-ins run; none without them. */
60
+ appSteps?: WorkerApp;
61
+ /**
62
+ * The worker's own client, connected on first use, for its version (what it starts runs on);
63
+ * closed when the worker stops. What the built-ins' steps tell and read through, never a run's body.
64
+ */
65
+ client(): Promise<SanomaClient>;
66
+ /** Runs `write` once the one before it has settled: the worker's writes to its repository, one at a time. */
67
+ serially<T>(write: () => Promise<T>): Promise<T>;
68
+ /**
69
+ * Fast-forwards the checkout to the host's `main` (`followBase`), one of the worker's writes
70
+ * (`serially`), as a change's deploy and a push to `main` do first; on a new revision, how far
71
+ * each app taken from a source is behind it is checked again, in the background, into the
72
+ * runtime's table (`recordAppsBehind` in worker.ts), as at the worker's start.
73
+ */
74
+ followMain(): Promise<{ revision: string } | { skipped: string } | { error: string }>;
75
+ /** The runtime's tables (`db.ts`): what the worker writes for the app to read, such as a sandbox run's checks. */
76
+ db(): Promise<Db>;
56
77
  types: ReadonlyMap<string, Resource>;
57
78
  /** The objects the data files declare, read at each call: a workflow's change operation may not aim at one (`refuseDeclared`). */
58
79
  declaredObjects(): Promise<DeclaredObjects>;
@@ -88,6 +109,11 @@ export interface WorkerState {
88
109
  stopped: boolean;
89
110
  /** Which of its runs are executing and which wait, until when: what the worker's `idle` reads. */
90
111
  deadlines: DeadlineRegistry;
112
+ /**
113
+ * Aborted once the worker has stopped, after DBOS is down: what work of its that outlives a DBOS
114
+ * call stops on (a chat turn's agent, the check of the apps behind), recorded nowhere.
115
+ */
116
+ shutdown: AbortSignal;
91
117
  }
92
118
 
93
119
  /** What a run keeps in memory. Rebuilt the same way when DBOS replays the run. */
@@ -136,3 +162,18 @@ export interface WorkflowOutline {
136
162
  * it, whether it is a sandbox run, the worker's source and policy), as no `ctx` member gives them.
137
163
  */
138
164
  export const currentRun = new AsyncLocalStorage<Run>();
165
+
166
+ /** The run whose built-in step is running (`currentRun`): `what` names the step for the error when there is none. */
167
+ export function runNow(what: string): Run {
168
+ const run = currentRun.getStore();
169
+ if (!run) throw new Error(`${what} ran outside its run`);
170
+ return run;
171
+ }
172
+
173
+ /**
174
+ * A sandbox run's checks recorded for the app (`recordChecks`, which never throws); nothing for a
175
+ * live run, which never loads `sandbox.ts`, and with it Cucumber.
176
+ */
177
+ export async function recordSandboxChecks(run: Run): Promise<void> {
178
+ if (run.sandbox !== undefined) await (await import("./sandbox.ts")).recordChecks(run, run.sandbox);
179
+ }
package/src/sandbox.ts CHANGED
@@ -9,8 +9,10 @@ import { entry, type LedgerRecord, write } from "./ledger.ts";
9
9
  import type { DriverFn } from "./op.ts";
10
10
  import type { Run } from "./run.ts";
11
11
  import { memorySecrets } from "./secret.ts";
12
- import { type Given, loadScenarios, type Scenario } from "./scenario.ts";
13
- import { errorMessage } from "./shared.ts";
12
+ import { writeRunChecks } from "./db.ts";
13
+ import { warn } from "./log.ts";
14
+ import { check, type Given, loadScenarios, type RunChecks, type Scenario } from "./scenario.ts";
15
+ import { errorMessage, notLoaded } from "./shared.ts";
14
16
 
15
17
  /*
16
18
  * What a worker does for a sandbox run, imported only when it starts one: kept apart from
@@ -125,8 +127,9 @@ export async function seedSandbox(run: Run, name: string, input: unknown): Promi
125
127
  const { scenarios, errors } = await loadScenarios(state);
126
128
  const scenario = scenarios.find((s) => s.name === name);
127
129
  if (!scenario) {
128
- const why = errors.length ? `; these files did not load: ${errors.map((e) => e.message).join("; ")}` : "";
129
- throw new SanomaError("invalid_input", `No scenario named "${name}"${why}`, { scenario: name });
130
+ throw new SanomaError("invalid_input", `No scenario named "${name}"${notLoaded(errors)}`, {
131
+ scenario: name,
132
+ });
130
133
  }
131
134
  if (scenario.workflow !== run.workflow) {
132
135
  throw new SanomaError("invalid_input", `Scenario "${name}" runs ${scenario.workflow}, not ${run.workflow}`, {
@@ -214,3 +217,37 @@ export async function seedSandbox(run: Run, name: string, input: unknown): Promi
214
217
  run.fakeSecrets = secrets;
215
218
  await write(run, entry(run, { type: "scenario.seeded", scenario: name, seeds }));
216
219
  }
220
+
221
+ /**
222
+ * A seeded sandbox run's checks against its ledger so far, written for the app (`writeRunChecks`):
223
+ * Cucumber runs its scenario's Then steps over the feature file as it reads now, which the agent
224
+ * may have changed since. Called when the run waits and when it ends (`recordSandboxChecks`); a
225
+ * check met is settled when what it expects stays so (a call made, a decision recorded), and every
226
+ * one once the run has ended, which the app reads from the run's status. No step and no ledger
227
+ * record, so a replay neither depends on it nor differs by it: a failure is warned about, and the
228
+ * run goes on.
229
+ */
230
+ export async function recordChecks(run: Run, name: string): Promise<void> {
231
+ if (!run.fakeDrivers) return;
232
+ const { state } = run;
233
+ try {
234
+ const checks = await checksOf(run, name).catch((err: unknown): RunChecks => ({ error: errorMessage(err) }));
235
+ await writeRunChecks(await state.db(), run.id, checks);
236
+ } catch (err) {
237
+ warn(`run ${run.id}: could not record its checks against scenario "${name}": ${errorMessage(err)}`);
238
+ }
239
+ }
240
+
241
+ async function checksOf({ id, state }: Run, name: string): Promise<RunChecks> {
242
+ const loaded = await loadScenarios(state);
243
+ const scenario = loaded.scenarios.find((s) => s.name === name);
244
+ if (!scenario) return { error: `The feature files no longer have scenario "${name}"${notLoaded(loaded.errors)}` };
245
+ const checks = await check(state, scenario, await state.ledger.read(id));
246
+ return {
247
+ checks: checks.map((c, i) => {
248
+ const e = scenario.expect[i]!;
249
+ // Met once: a call made, or a decision recorded, stays made. Every one is, once the run has ended (`runDetail`).
250
+ return { ...c, settled: c.ok && (("called" in e && e.called === true) || "decision" in e) };
251
+ }),
252
+ };
253
+ }
package/src/scenario.ts CHANGED
@@ -186,6 +186,23 @@ export interface Check {
186
186
  detail?: string;
187
187
  }
188
188
 
189
+ /** One of a sandbox run's checks, and whether its answer is final. */
190
+ export interface RunCheck extends Check {
191
+ /**
192
+ * True once the answer cannot change: the run has ended, or the check expects a call that
193
+ * has been made (or a decision recorded). Until then a check not met is not failed yet, and
194
+ * one met (a call not made so far) may still fail.
195
+ */
196
+ settled: boolean;
197
+ }
198
+
199
+ /**
200
+ * A sandbox run's checks, as the worker ran them when the run last waited or ended (`recordChecks`):
201
+ * each of its scenario's expectations against its ledger, or why there are none (its scenario is
202
+ * no longer in the feature files, with those that did not load, or they could not be read at all).
203
+ */
204
+ export type RunChecks = { checks: RunCheck[] } | { error: string };
205
+
189
206
  /**
190
207
  * What steps can name: the config's operations, workflows, resource types and collections by id,
191
208
  * the steps its connectors (and the company) ship, where the feature files are, and the root
package/src/seal.ts CHANGED
@@ -2,8 +2,9 @@ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
2
2
  import { deriveSigningKey, signingKeyFor } from "./deployment-key.ts";
3
3
 
4
4
  // Small values sealed under a key of their own, derived from the deployment's SANOMA_SIGNING_KEY
5
- // for one purpose (HKDF-SHA256): a sign-in code in a `login-code` run's input, and a request for
6
- // an invite that a holder of the key sends to the instance (`POST /auth/invite`). AES-256-GCM, the
5
+ // for one purpose (HKDF-SHA256): a sign-in code in a `login-code` run's input, a code a person
6
+ // pastes back to their own agent's sign-in, in the message to their chat turn's run, and a request
7
+ // for an invite that a holder of the key sends to the instance (`POST /auth/invite`). AES-256-GCM, the
7
8
  // purpose as its additional data, so a value sealed for one purpose never opens for another.
8
9
 
9
10
  const IV = 12;
@@ -40,6 +41,7 @@ function unseal(purpose: string, sealed: string): unknown {
40
41
  }
41
42
 
42
43
  const CODE = "login code";
44
+ const CHAT_CODE = "chat code";
43
45
  const INVITE = "invite request";
44
46
 
45
47
  /**
@@ -57,6 +59,18 @@ export function openLoginCode(sealed: string, to: string): string {
57
59
  return opened.code;
58
60
  }
59
61
 
62
+ /**
63
+ * A code a person pastes for their own agent's sign-in, sealed for the chat turn's run `runId`
64
+ * (`SanomaClient.turnCode`), so the run's messages, which DBOS keeps, never show it.
65
+ */
66
+ export const sealChatCode = (runId: string, code: string): string => seal(CHAT_CODE, { runId, code });
67
+
68
+ /** The code `sealChatCode` sealed for `runId`; undefined for anything else, a code sealed for another run among it. */
69
+ export function openChatCode(sealed: string, runId: string): string | undefined {
70
+ const opened = unseal(CHAT_CODE, sealed) as { runId?: unknown; code?: unknown } | undefined;
71
+ return typeof opened?.code === "string" && opened.runId === runId ? opened.code : undefined;
72
+ }
73
+
60
74
  /** A request for an invite (`POST /auth/invite { request }`): for whom, until when (ms since the epoch), and a nonce. */
61
75
  export interface InviteRequest {
62
76
  email: string;