@sanoma-ai/workflows 0.11.15 → 0.11.17

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 (117) hide show
  1. package/README.md +14 -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.d.ts +20 -0
  31. package/dist/config.js +55 -4
  32. package/dist/config.js.map +1 -1
  33. package/dist/db/company.d.ts +259 -2
  34. package/dist/db/company.js +32 -1
  35. package/dist/db/company.js.map +1 -1
  36. package/dist/db.d.ts +15 -0
  37. package/dist/db.js +42 -2
  38. package/dist/db.js.map +1 -1
  39. package/dist/description.d.ts +6 -3
  40. package/dist/description.js +4 -3
  41. package/dist/description.js.map +1 -1
  42. package/dist/follow-steps.d.ts +8 -0
  43. package/dist/follow-steps.js +113 -0
  44. package/dist/follow-steps.js.map +1 -0
  45. package/dist/follow-workflow.d.ts +56 -0
  46. package/dist/follow-workflow.js +50 -0
  47. package/dist/follow-workflow.js.map +1 -0
  48. package/dist/index.d.ts +10 -5
  49. package/dist/index.js +9 -4
  50. package/dist/index.js.map +1 -1
  51. package/dist/local-sandbox.d.ts +10 -0
  52. package/dist/local-sandbox.js +130 -0
  53. package/dist/local-sandbox.js.map +1 -0
  54. package/dist/op.d.ts +9 -2
  55. package/dist/op.js.map +1 -1
  56. package/dist/paths.d.ts +9 -0
  57. package/dist/paths.js +20 -4
  58. package/dist/paths.js.map +1 -1
  59. package/dist/resource.d.ts +7 -1
  60. package/dist/resource.js +5 -1
  61. package/dist/resource.js.map +1 -1
  62. package/dist/run.d.ts +39 -0
  63. package/dist/run.js +15 -0
  64. package/dist/run.js.map +1 -1
  65. package/dist/sandbox-contract.d.ts +100 -0
  66. package/dist/sandbox-contract.js +26 -0
  67. package/dist/sandbox-contract.js.map +1 -0
  68. package/dist/sandbox.d.ts +10 -0
  69. package/dist/sandbox.js +42 -4
  70. package/dist/sandbox.js.map +1 -1
  71. package/dist/scenario.d.ts +19 -0
  72. package/dist/scenario.js.map +1 -1
  73. package/dist/schedules.d.ts +11 -3
  74. package/dist/schedules.js +15 -3
  75. package/dist/schedules.js.map +1 -1
  76. package/dist/seal.d.ts +7 -0
  77. package/dist/seal.js +14 -2
  78. package/dist/seal.js.map +1 -1
  79. package/dist/shared.d.ts +34 -2
  80. package/dist/shared.js +39 -2
  81. package/dist/shared.js.map +1 -1
  82. package/dist/worker.d.ts +8 -0
  83. package/dist/worker.js +116 -10
  84. package/dist/worker.js.map +1 -1
  85. package/drizzle/company/0002_runs_tell_the_app.sql +27 -0
  86. package/drizzle/company/meta/0002_snapshot.json +819 -0
  87. package/drizzle/company/meta/_journal.json +7 -0
  88. package/package.json +1 -1
  89. package/src/app-steps.ts +67 -0
  90. package/src/app-workflows.ts +251 -0
  91. package/src/apply-steps.ts +3 -9
  92. package/src/approvals.ts +3 -1
  93. package/src/call.ts +2 -1
  94. package/src/change-steps.ts +10 -17
  95. package/src/chat-steps.ts +117 -0
  96. package/src/chat-workflow.ts +69 -0
  97. package/src/chats.ts +80 -14
  98. package/src/client.ts +154 -109
  99. package/src/config.ts +66 -3
  100. package/src/db/company.ts +49 -2
  101. package/src/db.ts +51 -3
  102. package/src/description.ts +4 -3
  103. package/src/follow-steps.ts +129 -0
  104. package/src/follow-workflow.ts +88 -0
  105. package/src/index.ts +32 -3
  106. package/src/local-sandbox.ts +132 -0
  107. package/src/op.ts +9 -2
  108. package/src/paths.ts +22 -4
  109. package/src/resource.ts +16 -2
  110. package/src/run.ts +41 -0
  111. package/src/sandbox-contract.ts +114 -0
  112. package/src/sandbox.ts +41 -4
  113. package/src/scenario.ts +17 -0
  114. package/src/schedules.ts +19 -4
  115. package/src/seal.ts +16 -2
  116. package/src/shared.ts +47 -2
  117. package/src/worker.ts +149 -10
package/src/config.ts CHANGED
@@ -26,8 +26,14 @@ import { type ApplyDeclared, applyWorkflow } from "./apply.ts";
26
26
  import { applySteps } from "./apply-steps.ts";
27
27
  import { callWorkflow } from "./call-workflow.ts";
28
28
  import { changeDriver, changeOps, reservedFor } from "./change.ts";
29
+ import { appRunner } from "./app-steps.ts";
30
+ import { appWorkflows } from "./app-workflows.ts";
29
31
  import { changeSteps } from "./change-steps.ts";
30
32
  import { changeWorkflow } from "./change-workflow.ts";
33
+ import { chatSteps } from "./chat-steps.ts";
34
+ import { chatWorkflow } from "./chat-workflow.ts";
35
+ import { followSteps } from "./follow-steps.ts";
36
+ import { followWorkflow } from "./follow-workflow.ts";
31
37
  import {
32
38
  type Collection,
33
39
  collectionsConnector,
@@ -68,7 +74,9 @@ import {
68
74
  BUILT_IN_WORKFLOWS,
69
75
  CALL_WORKFLOW,
70
76
  CHANGE_WORKFLOW,
77
+ CHAT_WORKFLOW,
71
78
  DATA_VENDOR,
79
+ FOLLOW_WORKFLOW,
72
80
  LOGIN_CODE_WORKFLOW,
73
81
  } from "./shared.ts";
74
82
  import { fakeSourceDriver, type RemoteHalf, SOURCE_RESOLVE, sourceDriver, sourceOps } from "./source.ts";
@@ -223,6 +231,12 @@ export interface ResolvedConfig {
223
231
  * declares a host type.
224
232
  */
225
233
  hosts(): Promise<string[]>;
234
+ /**
235
+ * The resources the data files under `root` declare whose type is a sandbox (`<file>#<export>`),
236
+ * read at each call: where a coding agent's boxes are opened. None when no connector declares a
237
+ * sandbox type.
238
+ */
239
+ sandboxes(): Promise<string[]>;
226
240
  /**
227
241
  * The objects the data files under `root` declare, read at each call: what a workflow's change
228
242
  * operation may not aim at, since only apply changes a declared object.
@@ -281,6 +295,47 @@ export function defineConfig(config: SanomaConfig): SanomaConfig {
281
295
  return { ...config, file: config.file ?? callerFile() };
282
296
  }
283
297
 
298
+ /**
299
+ * Joins the declaration files' modules (`declarationFiles`, each by its path) to `config`, as
300
+ * `sanoma` loads it and the Worker's bundle composes it: a workflow file's default export is a
301
+ * workflow (`defineWorkflow`), a file without one a helper; each export of a data file is a
302
+ * collection. One the config lists already is kept, once. A `workflows` or `collections` that is
303
+ * no list is left for `resolveConfig` to explain. Throws naming the file for an export that is
304
+ * neither.
305
+ */
306
+ export function joinDeclarations(
307
+ config: SanomaConfig,
308
+ modules: { workflows: [path: string, module: { default?: unknown }][]; data: [path: string, module: object][] },
309
+ ): void {
310
+ if (config.workflows === undefined || Array.isArray(config.workflows)) {
311
+ const workflows = new Set(config.workflows);
312
+ for (const [path, { default: wf }] of modules.workflows) {
313
+ if (wf === undefined) continue;
314
+ if ((wf as { kind?: unknown } | null)?.kind !== "workflow") {
315
+ throw new Error(
316
+ `${path}'s default export is not a workflow: a file of workflows/ exports one, \`export default defineWorkflow({ … })\`, or none (a helper the workflows import)`,
317
+ );
318
+ }
319
+ workflows.add(wf as WorkflowDefinition<any, any>);
320
+ }
321
+ if (workflows.size > 0) config.workflows = [...workflows];
322
+ }
323
+ if (config.collections === undefined || Array.isArray(config.collections)) {
324
+ const collections = new Set(config.collections);
325
+ for (const [path, module] of modules.data) {
326
+ for (const [name, value] of Object.entries(module)) {
327
+ if (!isCollection(value)) {
328
+ throw new Error(
329
+ `${path} exports ${name}, which is not a collection: resources/data.ts holds collections only, each \`export const ${name} = collection({ name, key, fields })\` with collection from "sanoma/data"`,
330
+ );
331
+ }
332
+ collections.add(value);
333
+ }
334
+ }
335
+ if (collections.size > 0) config.collections = [...collections];
336
+ }
337
+ }
338
+
284
339
  export function resolveDatabaseUrl(config: { databaseUrl?: string }, env: NodeJS.ProcessEnv = process.env): string {
285
340
  return config.databaseUrl ?? env.SANOMA_DATABASE_URL ?? DEFAULT_DATABASE_URL;
286
341
  }
@@ -437,14 +492,21 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
437
492
  names.set(APPLY_WORKFLOW, applyWorkflow(types, ops, drivers, applySteps(appName, root, read)));
438
493
  }
439
494
  // Built-in, in every config: the life of a change, which deploys to the declared hosts after its merge.
440
- // The declared hosts' ids, as the files say them: nothing derived, nothing minted.
441
- const hosts = async () => {
495
+ // The declared hosts' (and sandboxes') ids, as the files say them: nothing derived, nothing minted.
496
+ const declaredOf = (contract: "host" | "sandbox") => async () => {
442
497
  const { loadDataFiles } = await import("./resources.ts");
443
498
  return loadDataFiles(root, connectors, declarable)
444
- .resources.filter((r) => types.get(`${r.vendor}.${r.type}`)?.host)
499
+ .resources.filter((r) => types.get(`${r.vendor}.${r.type}`)?.[contract])
445
500
  .map((r) => r.id);
446
501
  };
502
+ const hosts = declaredOf("host");
447
503
  names.set(CHANGE_WORKFLOW, changeWorkflow(changeOps, changeSteps({ remotes, hosts })));
504
+ // Built-in, in every config: what a git host's webhook says happened, which the worker with the checkout follows.
505
+ names.set(FOLLOW_WORKFLOW, followWorkflow(followSteps({ remotes, hosts })));
506
+ // Built-in, in every config: the app's own actions on the repository, which a worker given the app's steps runs.
507
+ for (const wf of appWorkflows(appRunner)) names.set(wf.name, wf);
508
+ // Built-in, in every config: a chat turn, which a worker given the app's steps runs.
509
+ names.set(CHAT_WORKFLOW, chatWorkflow(chatSteps()));
448
510
  const declaredObjects = () => readDeclaredObjects(root, connectors, declarable);
449
511
  // Built-in: a direct call the policy holds for an approval, made a run, when any operation may be called directly.
450
512
  const call = callWorkflow(ops, drivers, types);
@@ -480,6 +542,7 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
480
542
  models: makersOf(config.connectors, "model"),
481
543
  remotes,
482
544
  hosts,
545
+ sandboxes: declaredOf("sandbox"),
483
546
  declaredObjects,
484
547
  ...(scenarios && { scenarios }),
485
548
  types,
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
+ }
@@ -298,10 +298,11 @@ export const VendorEntrySchema = z.strictObject({
298
298
  export type VendorEntry = z.infer<typeof VendorEntrySchema>;
299
299
 
300
300
  /**
301
- * The contracts a resource type meets, from its spec: `host`, `database` and `wakeup` (`Host`,
302
- * `Database`, `Wakeup`), and `source` for a type a host's `source` names (the repository it clones).
301
+ * The contracts a resource type meets, from its spec: `host`, `database`, `wakeup` and `sandbox`
302
+ * (`Host`, `Database`, `Wakeup`, `Sandbox`), and `source` for a type a host's `source` names (the
303
+ * repository it clones).
303
304
  */
304
- export const CAPABILITIES = ["host", "database", "wakeup", "source"] as const;
305
+ export const CAPABILITIES = ["host", "database", "wakeup", "sandbox", "source"] as const;
305
306
 
306
307
  /** A resource type a connector declares with `defineResource`. */
307
308
  export const ResourceTypeEntrySchema = z.strictObject({
@@ -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";
@@ -68,6 +69,7 @@ export {
68
69
  declaredWakeup,
69
70
  defineConfig,
70
71
  envWakeup,
72
+ joinDeclarations,
71
73
  type Login,
72
74
  readRootSnapshot,
73
75
  resolveConfig,
@@ -78,7 +80,7 @@ export {
78
80
  // An app's `app.ts` (`defineApp`), and where the launcher finds an app's workflows and data file.
79
81
  export { type AppEntry, appSourceOf, readApps } from "./apps.ts";
80
82
  export { type AppDefinition, defineApp } from "./definitions.ts";
81
- export { appFolders, APPS_DIR, filesUnder, kindDirs } from "./paths.ts";
83
+ export { appFolders, APPS_DIR, declarationFiles, filesUnder, kindDirs } from "./paths.ts";
82
84
  // `sanoma` itself (the people types' constructors) is at `@sanoma-ai/workflows/resources`, for data files.
83
85
  // `collection()` itself is at `@sanoma-ai/workflows/data`, for `resources/data.ts`; this tells one apart, as the launcher does.
84
86
  export { isCollection } from "./collection.ts";
@@ -93,6 +95,23 @@ export {
93
95
  type PlanStep,
94
96
  } from "./apply.ts";
95
97
  export { CALL_WORKFLOW, CallInput } from "./call-workflow.ts";
98
+ export {
99
+ AdoptInput,
100
+ type AppApi,
101
+ type AppStepContext,
102
+ type AppSteps,
103
+ type ChatTurnContext,
104
+ ConnectExistingInput,
105
+ type ConnectExistingOutput,
106
+ GoLiveHostInput,
107
+ GoLiveInput,
108
+ type GoLiveOutput,
109
+ PatchInput,
110
+ type PatchOutput,
111
+ type Written,
112
+ } from "./app-workflows.ts";
113
+ export { type FollowOutput } from "./follow-workflow.ts";
114
+ export { ChatTurnInput, type ChatTurnOutput } from "./chat-workflow.ts";
96
115
  export {
97
116
  ADMIN,
98
117
  type ChangeDecision,
@@ -175,7 +194,7 @@ export type { RunArgs } from "./run.ts";
175
194
  export { startWorker, type Worker, type WorkerOptions } from "./worker.ts";
176
195
  export { superviseWorker, type Supervisor, type SupervisorOptions } from "./supervisor.ts";
177
196
  export type { DeadlineSource, Deadlines, SelfStop, Wait } from "./deadlines.ts";
178
- export { nextFire, nextScheduledFire } from "./schedules.ts";
197
+ export { nextFire, nextScheduledFire, timeMatcherFile, type TimeMatcherClass, useTimeMatcher } from "./schedules.ts";
179
198
  // `secret` itself is at `@sanoma-ai/workflows/resources`, for data files; this is the drivers' side.
180
199
  export {
181
200
  generateSecret,
@@ -234,6 +253,16 @@ export {
234
253
  databaseUrlSecret,
235
254
  storeDatabaseUrl,
236
255
  } from "./database.ts";
256
+ export {
257
+ type Box,
258
+ type Process,
259
+ type SandboxBackend,
260
+ type SandboxCapabilities,
261
+ type SandboxContext,
262
+ type SandboxOutputs,
263
+ type SandboxSpec,
264
+ } from "./sandbox-contract.ts";
265
+ export { LOCAL_SANDBOX, localBackend } from "./local-sandbox.ts";
237
266
  export type { Chat, Chats } from "./chats.ts";
238
267
  export type { Tokens } from "./tokens.ts";
239
268
  export { type InviteRequest, openInvite, sealInvite, sealLoginCode } from "./seal.ts";
@@ -263,7 +292,7 @@ export {
263
292
  type StoredSecret,
264
293
  } from "./client.ts";
265
294
  // A raw DBOS client on the runtime's database, for tests and tools that read DBOS directly.
266
- export { dbosClient } from "./db.ts";
295
+ export { type AppBehind, dbosClient } from "./db.ts";
267
296
  // The runtime bundled where a company's files are not (the Cloudflare host's Worker): the root's
268
297
  // people, policies and app name as data, Cedar's web build, and a database it only checks.
269
298
  export { type RootSnapshot, useRootSnapshot } from "./bundled.ts";