@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/shared.ts CHANGED
@@ -308,13 +308,58 @@ export const APPS_LIST = "sanoma.apps.list";
308
308
  */
309
309
  export const LOGIN_CODE_WORKFLOW = "login-code";
310
310
 
311
- /** The workflows the runtime adds itself, which a config's own may not be named: drift, apply, call, change and login-code. */
311
+ /**
312
+ * The app's own actions on the company's repository, each a built-in workflow the app starts and
313
+ * the worker runs with the app's steps (`WorkerOptions.app`, `app-workflows.ts`): Adopt (a drifted
314
+ * field's value at the vendor into its data file), a form's Save, going live (`infra.ts` written,
315
+ * then the push and the host's apply) and connecting an existing repository or database.
316
+ */
317
+ export const ADOPT_WORKFLOW = "adopt";
318
+ export const PATCH_WORKFLOW = "patch";
319
+ export const GO_LIVE_WORKFLOW = "go-live";
320
+ export const GO_LIVE_HOST_WORKFLOW = "go-live-host";
321
+ export const CONNECT_EXISTING_WORKFLOW = "connect-existing";
322
+
323
+ /**
324
+ * What a git host's webhook says happened to the company's repository, done by the worker
325
+ * (`follow-workflow.ts`): started by the app as `host-check`, one run per event (`SanomaClient.follow`).
326
+ */
327
+ export const FOLLOW_WORKFLOW = "follow";
328
+
329
+ /**
330
+ * A turn of a person's chat with the operator agent, run by the worker with the app's steps
331
+ * (`chat-workflow.ts`): started by the app as the chat's person (`SanomaClient.startTurn`), one run
332
+ * per message, `chat:<chat>:<message>`.
333
+ */
334
+ export const CHAT_WORKFLOW = "chat";
335
+
336
+ /** What the run id of each of the chat `chat`'s turns starts with: exact, as a chat's id has no `:` (`DRAFT_ID`). */
337
+ export const chatRunPrefix = (chat: string) => `chat:${chat}:`;
338
+
339
+ /** The run id of the chat `chat`'s turn for the message `message`. */
340
+ export const chatRunId = (chat: string, message: string) => `${chatRunPrefix(chat)}${message}`;
341
+
342
+ /** The feature files that did not load (`loadScenarios`' errors), as a scenario not found names them after its name. */
343
+ export const notLoaded = (errors: readonly { message: string }[]) =>
344
+ errors.length ? `; these files did not load: ${errors.map((e) => e.message).join("; ")}` : "";
345
+
346
+ /** The `recv` topic a turn's run is told on (`ChatTell`): a stop, a sign-in code, its own turn's end. */
347
+ export const CHAT_TOPIC = "chat:tell";
348
+
349
+ /** The workflows the runtime adds itself, which a config's own may not be named: drift, apply, call, change, login-code, follow, chat and the app's actions. */
312
350
  export const BUILT_IN_WORKFLOWS: ReadonlySet<string> = new Set([
313
351
  DRIFT_WORKFLOW,
314
352
  APPLY_WORKFLOW,
315
353
  CALL_WORKFLOW,
316
354
  CHANGE_WORKFLOW,
317
355
  LOGIN_CODE_WORKFLOW,
356
+ FOLLOW_WORKFLOW,
357
+ CHAT_WORKFLOW,
358
+ ADOPT_WORKFLOW,
359
+ PATCH_WORKFLOW,
360
+ GO_LIVE_WORKFLOW,
361
+ GO_LIVE_HOST_WORKFLOW,
362
+ CONNECT_EXISTING_WORKFLOW,
318
363
  ]);
319
364
 
320
365
  /**
@@ -422,7 +467,7 @@ export const ANONYMOUS_PRINCIPAL: Principal = Object.freeze({ id: ANONYMOUS, gro
422
467
  export const WAKEUP_AGENT = "wakeup";
423
468
  /** The runtime's agent a scheduled run is started by (`trigger: { schedule }`): the policy decides its starts. */
424
469
  export const SCHEDULE_AGENT = "schedule";
425
- /** The runtime's agent that checks and deploys the hosts: drift's, and apply's after a push to `main` (`SanomaClient.remoteEvent`). */
470
+ /** The runtime's agent that checks and deploys the hosts: drift's, and a host's webhook's (`SanomaClient.follow`), whose apply after a push to `main` is its too. */
426
471
  export const HOST_CHECK_AGENT = "host-check";
427
472
  /** The runtime's agent that sends a sign-in code (`LOGIN_CODE_WORKFLOW`): a company's policy exempts it from its holds on sending. */
428
473
  export const LOGIN_AGENT = "login";
package/src/worker.ts CHANGED
@@ -1,10 +1,22 @@
1
- import { DBOS, type ScheduledWorkflowFn } from "@dbos-inc/dbos-sdk";
1
+ import { DBOS, Error as DBOSErrors, type ScheduledWorkflowFn } from "@dbos-inc/dbos-sdk";
2
+ import type { WorkerApp } from "./app-steps.ts";
3
+ import { readApps } from "./apps.ts";
4
+ import type { AppSteps } from "./app-workflows.ts";
2
5
  import { awaitApproval } from "./approvals.ts";
3
6
  import { buildCtx, decideStart, isInfrastructureError, startHoldOf } from "./call.ts";
4
- import { dbosStatusesOf } from "./client.ts";
7
+ import { followBase } from "./change.ts";
8
+ import { dbosStatusesOf, SanomaClient } from "./client.ts";
5
9
  import { declaredWakeup, envWakeup, type ResolvedConfig, resolveConfig, type SanomaConfig } from "./config.ts";
6
10
  import { configuredSecrets } from "./credentials.ts";
7
- import { type Db, DBOS_DATABASE_OPTIONS, lazyDb, prepareDatabase, stampVersion } from "./db.ts";
11
+ import {
12
+ type AppBehind,
13
+ type Db,
14
+ DBOS_DATABASE_OPTIONS,
15
+ lazyDb,
16
+ prepareDatabase,
17
+ stampVersion,
18
+ writeAppsBehind,
19
+ } from "./db.ts";
8
20
  import {
9
21
  deadlineRegistry,
10
22
  type Deadlines,
@@ -22,7 +34,14 @@ import { entry, ownedLedger, skipped, write, writeFailure } from "./ledger.ts";
22
34
  import { dbosLogger, info, quietIfSandbox, setLogLevel, warn } from "./log.ts";
23
35
  import { lineStartsOf, readSource } from "./ast.ts";
24
36
  import { callsOf, outlineWithSource } from "./outline.ts";
25
- import { currentRun, type Run, type RunArgs, type WorkerState, type WorkflowOutline } from "./run.ts";
37
+ import {
38
+ currentRun,
39
+ recordSandboxChecks,
40
+ type Run,
41
+ type RunArgs,
42
+ type WorkerState,
43
+ type WorkflowOutline,
44
+ } from "./run.ts";
26
45
  import type { DatabasePreparation } from "./op.ts";
27
46
  import { applyScheduled, nextScheduledFire, type ScheduledStarter, scheduledStarter } from "./schedules.ts";
28
47
  import type { Source } from "./source.ts";
@@ -84,6 +103,13 @@ export interface WorkerOptions {
84
103
  promote?: boolean;
85
104
  /** The company's repository, which apply commits the lockfile to; the launcher passes its checkout. */
86
105
  source?: Source;
106
+ /**
107
+ * The app's own actions (`@sanoma-ai/app/steps`), which its built-in workflows run (Adopt, a
108
+ * form's Save, going live, connecting an existing repository, a chat's turn): the launcher passes
109
+ * them. Without them, a run of one fails, saying this worker runs none of them (`NO_APP_STEPS`,
110
+ * or for a chat `NO_CHAT_STEPS`).
111
+ */
112
+ app?: AppSteps;
87
113
  /**
88
114
  * How soon a deadline keeps the worker up (`idle`): 60 s by default, so apply's short sleeps
89
115
  * and other short waits never stop it.
@@ -141,6 +167,29 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
141
167
  let stopRule: { close(): void } | undefined;
142
168
  let stopping: Promise<void> | undefined;
143
169
  const horizonMs = options.idleHorizonMs ?? 60_000;
170
+ // The worker's own client, connected by the first step that needs it, for the worker's version: what it starts runs on.
171
+ let client: Promise<SanomaClient> | undefined;
172
+ const appSteps: WorkerApp | undefined = options.app && { steps: options.app, config, resolved };
173
+ const shutdown = new AbortController();
174
+ // One check of the apps' sources at a time; one asked for meanwhile runs once it ends, so the
175
+ // table says what the latest `main` is behind.
176
+ let behind: Promise<void> | undefined;
177
+ let again = false;
178
+ const refreshAppsBehind = () => {
179
+ if (behind) {
180
+ again = true;
181
+ return;
182
+ }
183
+ behind = (async () => {
184
+ do {
185
+ again = false;
186
+ await recordAppsBehind(resolved.root, options.source, state.db, shutdown.signal);
187
+ } while (again && !shutdown.signal.aborted);
188
+ // In the same turn as the last look at `again`, so no ask falls between (it never throws).
189
+ behind = undefined;
190
+ })();
191
+ };
192
+ const serially = serialWrites();
144
193
  const state: WorkerState = {
145
194
  app: resolved.appName,
146
195
  connections: resolved.connections,
@@ -149,6 +198,22 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
149
198
  fakes: resolved.fakes,
150
199
  ...(resolved.scenarios && { scenarios: resolved.scenarios }),
151
200
  ...(options.source && { source: options.source }),
201
+ ...(appSteps && { appSteps }),
202
+ client: () =>
203
+ (client ??= SanomaClient.connect(config, { appVersion: resolved.version }).catch((err: unknown) => {
204
+ // Tried again by the next step: a client that failed to connect is none.
205
+ client = undefined;
206
+ throw err;
207
+ })),
208
+ serially,
209
+ followMain: () =>
210
+ serially(async () => {
211
+ const moved = await followBase(resolved.remotes, storedSecrets, options.source);
212
+ if ("revision" in moved) refreshAppsBehind();
213
+ return moved;
214
+ }),
215
+ // The runtime's tables, on the secrets' pool: the same database.
216
+ db: () => secrets.db(),
152
217
  types: resolved.types,
153
218
  declaredObjects: resolved.declaredObjects,
154
219
  collections: resolved.collections,
@@ -167,6 +232,7 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
167
232
  },
168
233
  stopped: false,
169
234
  deadlines: deadlineRegistry(),
235
+ shutdown: shutdown.signal,
170
236
  };
171
237
  const worker: Worker = {
172
238
  unconfigured: [...credentialsOf(resolved.driversByVendor, (name) => process.env[name])]
@@ -185,6 +251,11 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
185
251
  let failed: { error: unknown } | undefined;
186
252
  for (const close of [
187
253
  () => DBOS.shutdown(),
254
+ // Once DBOS is down, so a turn's step cut short is recorded nowhere: the next worker settles it.
255
+ async () => shutdown.abort(),
256
+ async () => (await client?.catch(() => undefined))?.close(),
257
+ // Before the pool it writes to closes: the app it checks now, then none.
258
+ async () => behind,
188
259
  () => storedSecrets.close(),
189
260
  () => resolved.connections.close(),
190
261
  () => resolved.identityProvisioning.close(),
@@ -204,7 +275,7 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
204
275
  };
205
276
  // Runs recovered during launch start before it returns, so they must already find this
206
277
  // state. If starting fails, a worker still running in the process gets its own back, once
207
- // DBOS has stopped: until then a run DBOS dispatches finds this state stopped, and is refused.
278
+ // DBOS has stopped: until then a run DBOS dispatches finds this state stopped, and is handed back.
208
279
  const previous = current;
209
280
  let launched = false;
210
281
  try {
@@ -245,7 +316,11 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
245
316
  }
246
317
  // Built-in runs move to the latest version only: a rollback's worker, unless promoted, leaves them be.
247
318
  if (isLatest) await carryBuiltInRuns(resolved, () => secrets.db());
248
- await DBOS.registerQueue(resolved.queueName);
319
+ // How often it looks for queued runs: DBOS's default second, unless a test suite asks for less.
320
+ const pollMs = process.env.SANOMA_QUEUE_POLL_MS;
321
+ if (pollMs && !(Number(pollMs) > 0))
322
+ throw new Error(`SANOMA_QUEUE_POLL_MS is milliseconds above zero, not "${pollMs}"`);
323
+ await DBOS.registerQueue(resolved.queueName, pollMs ? { minPollingIntervalMs: Number(pollMs) } : undefined);
249
324
  // The schedules are the latest version's: an older worker leaves them as the newer set them.
250
325
  if (isLatest && options.schedules !== false) {
251
326
  await applyScheduled(resolved, registered, () => secrets.db());
@@ -253,6 +328,8 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
253
328
  state.deadlines.addSource(() => nextScheduledFire(resolved, new Date()));
254
329
  }
255
330
  await warnAboutStrandedRuns(resolved);
331
+ // How far each app taken from a source is behind it, fetched in the background: the app reads the table.
332
+ refreshAppsBehind();
256
333
  const summary = unconfiguredSummary(worker.unconfigured, resolved.driversByVendor.size);
257
334
  if (summary && !options.quiet) warn(summary);
258
335
  if (resolved.policyProblems.length && !options.quiet) {
@@ -322,6 +399,54 @@ function outlineOf(wf: WorkflowDefinition<any, any>): WorkflowOutline {
322
399
  return { file: outline.file, lineStarts: lineStartsOf(source!), calls: callsOf(outline.nodes) };
323
400
  }
324
401
 
402
+ /**
403
+ * `WorkerState.serially`, over a chain of its own: each write waits for the one before it to
404
+ * settle, failed or not.
405
+ */
406
+ function serialWrites(): WorkerState["serially"] {
407
+ let tail: Promise<unknown> = Promise.resolve();
408
+ return (next) => {
409
+ const done = tail.then(next);
410
+ tail = done.catch(() => undefined);
411
+ return done;
412
+ };
413
+ }
414
+
415
+ /**
416
+ * Tells how far each app under `root` taken from a source (its `app.ts` names one) is behind it,
417
+ * fetched now (`Source.behind`), or why that cannot be told: offline, no shared history, a
418
+ * repository that cannot fetch. Written to the runtime's table for the app (`writeAppsBehind`),
419
+ * which then lists no other app. Never throws: a failure is warned about. One at a time, since each
420
+ * fetches into the one checkout; nothing more once `stopped` is aborted, nor written.
421
+ */
422
+ async function recordAppsBehind(
423
+ root: string | undefined,
424
+ source: Source | undefined,
425
+ db: () => Promise<Db>,
426
+ stopped: AbortSignal,
427
+ ) {
428
+ try {
429
+ const told = new Map<string, AppBehind>();
430
+ for (const app of readApps(root).filter((a) => a.source !== undefined)) {
431
+ if (stopped.aborted) return;
432
+ if (!source?.behind) {
433
+ told.set(app.name, { error: "This instance's repository cannot fetch an app's source" });
434
+ continue;
435
+ }
436
+ told.set(
437
+ app.name,
438
+ await source.behind(app.name).then(
439
+ (behind): AppBehind => ({ behind }),
440
+ (err: unknown) => ({ error: errorMessage(err) }),
441
+ ),
442
+ );
443
+ }
444
+ if (!stopped.aborted) await writeAppsBehind(await db(), told);
445
+ } catch (err) {
446
+ warn(`could not tell how far the apps are behind their sources: ${errorMessage(err)}`);
447
+ }
448
+ }
449
+
325
450
  /**
326
451
  * Takes over the unfinished runs of the built-in workflows (apply, change, …) started on other
327
452
  * versions whose code is this worker's (`builtInCodes`), keeping each run's id, history and unread
@@ -413,9 +538,18 @@ function register(name: string): ScheduledWorkflowFn {
413
538
  */
414
539
  async function runWorkflow(name: string, start: RunArgs | Date): Promise<unknown> {
415
540
  const state = current;
416
- // A stopped worker's state stays current while DBOS shuts down.
417
- if (!state || state.stopped) throw new Error(`Run of "${name}" started with no worker running`);
418
- return state.deadlines.running(DBOS.workflowID!, () => runOn(state, name, start));
541
+ const runId = DBOS.workflowID!;
542
+ if (!state) throw new Error(`Run of "${name}" started with no worker running`);
543
+ // A stop's failure is not the run's outcome, but DBOS would record it as one, for good, while
544
+ // its pool is still open. A run's own cancellation it records nothing for: the next worker recovers it.
545
+ // So does a run DBOS dispatches during the stop, as a stopped worker's state stays current until DBOS is down.
546
+ if (state.stopped) throw new DBOSErrors.DBOSWorkflowCancelledError(runId);
547
+ try {
548
+ return await state.deadlines.running(runId, () => runOn(state, name, start));
549
+ } catch (err) {
550
+ if (state.stopped && isInfrastructureError(err, state)) throw new DBOSErrors.DBOSWorkflowCancelledError(runId);
551
+ throw err;
552
+ }
419
553
  }
420
554
 
421
555
  async function runOn(state: WorkerState, name: string, start: RunArgs | Date): Promise<unknown> {
@@ -494,9 +628,14 @@ async function runOn(state: WorkerState, name: string, start: RunArgs | Date): P
494
628
  } catch (err) {
495
629
  const record = entry(run, { type: "run.failed", error: errorInfo(err) });
496
630
  if (isInfrastructureError(err, state)) skipped(record, `the run was interrupted (${errorMessage(err)})`);
497
- else await writeFailure(run, record, err);
631
+ else {
632
+ await writeFailure(run, record, err);
633
+ await recordSandboxChecks(run);
634
+ }
498
635
  throw err;
499
636
  }
500
637
  await write(run, entry(run, { type: "run.finished", output }));
638
+ // Once its outcome is recorded, before DBOS records it, so a page that reads the run ended reads its last checks.
639
+ await recordSandboxChecks(run);
501
640
  return output;
502
641
  }