@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
@@ -15,6 +15,13 @@
15
15
  "when": 1793827495694,
16
16
  "tag": "0001_wakeup_row",
17
17
  "breakpoints": true
18
+ },
19
+ {
20
+ "idx": 2,
21
+ "version": "7",
22
+ "when": 1793857547422,
23
+ "tag": "0002_runs_tell_the_app",
24
+ "breakpoints": true
18
25
  }
19
26
  ]
20
27
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanoma-ai/workflows",
3
- "version": "0.11.14",
3
+ "version": "0.11.16",
4
4
  "description": "Business processes as TypeScript functions, run durably on DBOS",
5
5
  "keywords": [
6
6
  "approvals",
@@ -0,0 +1,67 @@
1
+ import { DBOS } from "@dbos-inc/dbos-sdk";
2
+ import type { AppAction, AppRunner, AppStepContext, AppSteps } from "./app-workflows.ts";
3
+ import type { ResolvedConfig, SanomaConfig } from "./config.ts";
4
+ import { type Run, runNow } from "./run.ts";
5
+ import { APPLY_WORKFLOW } from "./shared.ts";
6
+
7
+ // The app's built-ins' steps (`app-workflows.ts` is held to the workflow lint and imports no
8
+ // DBOS): each app step in a DBOS step of its own, given what the worker has (`WorkerState.appSteps`),
9
+ // and the apply after it started as a child run. Each reads its run from `currentRun`.
10
+
11
+ /** What a worker started with the app's steps keeps for them (`WorkerState.appSteps`). */
12
+ export interface WorkerApp {
13
+ steps: AppSteps;
14
+ config: SanomaConfig;
15
+ resolved: ResolvedConfig;
16
+ }
17
+
18
+ /** What a worker started without the app's steps answers a run of one of the app's built-ins. */
19
+ export const NO_APP_STEPS =
20
+ "This worker runs none of the app's actions: start it with the app's steps (`WorkerOptions.app`), as `sanoma serve` and `sanoma worker` do";
21
+
22
+ /** The app's steps the run's worker was given; thrown, saying `missing`, when it was given none. */
23
+ export function appStepsOf(run: Run, missing: string): WorkerApp {
24
+ const app = run.state.appSteps;
25
+ if (!app) throw new Error(missing);
26
+ return app;
27
+ }
28
+
29
+ /** What the run's app step is given (`AppStepContext`): the worker's own client, connected on first use, and what the worker has. */
30
+ export async function stepContext(run: Run, { config, resolved }: WorkerApp): Promise<AppStepContext> {
31
+ const { state } = run;
32
+ return {
33
+ client: await state.client(),
34
+ ...(state.source && { source: state.source }),
35
+ serially: state.serially,
36
+ config,
37
+ resolved,
38
+ by: run.actor,
39
+ runId: run.id,
40
+ };
41
+ }
42
+
43
+ /** The app's step `name` with `input`, in a DBOS step of the run's (`app:<name>`). */
44
+ function step(run: Run, name: AppAction, input: unknown): Promise<any> {
45
+ const app = appStepsOf(run, NO_APP_STEPS);
46
+ return DBOS.runStep(
47
+ async () =>
48
+ (app.steps[name] as (step: AppStepContext, input: unknown) => Promise<unknown>)(
49
+ await stepContext(run, app),
50
+ input,
51
+ ),
52
+ { name: `app:${name}` },
53
+ );
54
+ }
55
+
56
+ /** The runner the app's built-ins run their work through. */
57
+ export const appRunner: AppRunner = {
58
+ step: (name, input) => step(runNow("An app step"), name, input) as ReturnType<AppSteps[typeof name]>,
59
+ async stepThenApply(name, input) {
60
+ const run = runNow("An app step");
61
+ const { apply, ...rest } = await step(run, name, input);
62
+ // Its child, as its own actor, not waited for: an apply may wait for its plan's approval.
63
+ const runId = `${run.id}:apply`;
64
+ await run.state.startRun(APPLY_WORKFLOW, runId, { input: apply, startedBy: run.actor });
65
+ return { ...rest, runId };
66
+ },
67
+ };
@@ -0,0 +1,251 @@
1
+ import { z } from "zod";
2
+ import type { ApplyInput } from "./apply.ts";
3
+ import type { ChatTurnInput, ChatTurnOutput } from "./chat-workflow.ts";
4
+ import type { ResolvedConfig, SanomaConfig } from "./config.ts";
5
+ import type { SanomaClient } from "./client.ts";
6
+ import { defineWorkflow, type Principal, type WorkflowDefinition } from "./define.ts";
7
+ import { AtSchema } from "./description.ts";
8
+ import type {
9
+ ADOPT_WORKFLOW,
10
+ CONNECT_EXISTING_WORKFLOW,
11
+ GO_LIVE_HOST_WORKFLOW,
12
+ GO_LIVE_WORKFLOW,
13
+ PATCH_WORKFLOW,
14
+ WorkflowFn,
15
+ } from "./shared.ts";
16
+ import type { Source } from "./source.ts";
17
+
18
+ // The app's own actions on the company's repository, as built-in workflows: the app starts each
19
+ // (its pages through `api.<name>`, polling `.result`) and the worker runs it, since only the
20
+ // worker has the checkout. What each does is the app's, given to the worker as `AppSteps`
21
+ // (`WorkerOptions.app`, `@sanoma-ai/app/steps`), and each is called in a step of its own run
22
+ // (`app-steps.ts`), its writes to the checkout one at a time per worker. Workflows like any other, held to
23
+ // the same lint: no DBOS here, and no operation, so nothing goes through `ctx`.
24
+
25
+ /** Adopt's input: a drift run, a resource it found drifted, and the fields whose value at the vendor to write into the resource's data file. */
26
+ export const AdoptInput = z.object({
27
+ runId: z.string().min(1),
28
+ id: z.string().min(1),
29
+ paths: z.array(z.string().min(1)).min(1),
30
+ });
31
+ export type AdoptInput = z.infer<typeof AdoptInput>;
32
+
33
+ /**
34
+ * A form's Save: a declaration the description names (a resource, a collection, a site or an
35
+ * app, by its `at`), and the fields to set in it, by dotted path as `setFields` takes them, each
36
+ * to a JSON value.
37
+ */
38
+ export const PatchInput = z.object({
39
+ at: AtSchema,
40
+ fields: z.record(z.string().min(1), z.json()).refine((fields) => Object.keys(fields).length > 0, "Set a field"),
41
+ });
42
+ export type PatchInput = z.infer<typeof PatchInput>;
43
+
44
+ /**
45
+ * What going live chooses: who holds the repository, the database's region (and size, when it
46
+ * takes one), Supabase's organization for a Supabase project, the host's port.
47
+ */
48
+ export const GoLiveInput = z.object({
49
+ owner: z.string().regex(/^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/, "Choose a GitHub account or organization"),
50
+ /** The repository's name, and the database's and the host's. */
51
+ name: z
52
+ .string()
53
+ .regex(/^[a-z0-9][a-z0-9-]{0,62}$/, "Lower case letters, digits and hyphens, starting with a letter or digit"),
54
+ organization: z.string().min(1, "Choose a Supabase organization").optional(),
55
+ region: z.string().min(1, "Choose a region"),
56
+ size: z.string().min(1).optional(),
57
+ /** The database type the page showed (`GoLiveChoices.database`): refused when going live would now choose another. */
58
+ database: z.string().min(1).optional(),
59
+ /** The host type the page showed (`GoLiveChoices.host`): refused when going live would now choose another. */
60
+ host: z.string().min(1).optional(),
61
+ port: z.number().int().min(1024, "A port from 1024").max(65535, "A port up to 65535"),
62
+ });
63
+ export type GoLiveInput = z.infer<typeof GoLiveInput>;
64
+
65
+ /** Going live's second half takes nothing: what it pushes and applies is in `infra.ts`. */
66
+ export const GoLiveHostInput = z.strictObject({});
67
+ export type GoLiveHostInput = z.infer<typeof GoLiveHostInput>;
68
+
69
+ /** An existing GitHub repository (`owner/name`: the owner is always written) and a Supabase project's ref, either or both. */
70
+ export const ConnectExistingInput = z
71
+ .object({
72
+ repository: z
73
+ .string()
74
+ .trim()
75
+ .regex(/^[\w-]+\/(?!\.\.?$)[\w.-]+$/, "Enter a repository as owner/name")
76
+ .optional(),
77
+ project: z
78
+ .string()
79
+ .trim()
80
+ .regex(/^[a-z0-9]+$/, "Enter a project's ref: lowercase letters and digits")
81
+ .optional(),
82
+ })
83
+ .refine((r) => r.repository || r.project, { message: "Enter a repository, a project's ref, or both" });
84
+ export type ConnectExistingInput = z.infer<typeof ConnectExistingInput>;
85
+
86
+ /** A data file written, relative to the root, and its commit, when the worker has a repository. */
87
+ export interface Written {
88
+ file: string;
89
+ revision?: string;
90
+ }
91
+
92
+ /** What a Save lands as: the change opened for review (its run id), when the repository has a remote; otherwise the file written. */
93
+ export type PatchOutput = { change: string } | Written;
94
+
95
+ /** What going live's runs answer: the apply run each started (`<run>:apply`), which the page follows. */
96
+ export interface GoLiveOutput {
97
+ runId: string;
98
+ }
99
+
100
+ /**
101
+ * What connecting answers: the data file it wrote and its text; its commit, on `branch` (with its
102
+ * `diff`) when the repository has a remote and the change waits for a person to merge it; and the
103
+ * apply run that binds what it declares.
104
+ */
105
+ export interface ConnectExistingOutput extends Written {
106
+ text: string;
107
+ branch?: string;
108
+ diff?: string;
109
+ runId: string;
110
+ }
111
+
112
+ /**
113
+ * What an app step is given: the worker's own client (connected on first use, closed when the
114
+ * worker stops), its repository (`WorkerOptions.source`) and the lock its writes there take, the
115
+ * config and its resolution, who started the run (`by`, as `SanomaClient.start` admitted them)
116
+ * and the run's id.
117
+ */
118
+ export interface AppStepContext {
119
+ client: SanomaClient;
120
+ source?: Source;
121
+ /**
122
+ * Runs `write` once the worker's write before it has settled (`WorkerState.serially`): what a step
123
+ * holds around reading and writing the checkout and committing, so two never write one file.
124
+ */
125
+ serially<T>(write: () => Promise<T>): Promise<T>;
126
+ config: SanomaConfig;
127
+ resolved: ResolvedConfig;
128
+ by: Principal;
129
+ runId: string;
130
+ }
131
+
132
+ /**
133
+ * What a chat turn's step is given besides an app step's: when to stop (a new message came, or the
134
+ * worker stopped), a way to take the sign-in codes the person pastes for this turn, and whether
135
+ * the turn had begun before, on a worker that restarted under it.
136
+ */
137
+ export interface ChatTurnContext extends AppStepContext {
138
+ signal: AbortSignal;
139
+ /** Hands each code pasted for this turn (`SanomaClient.turnCode`) to `listener`, until the function it returns is called. */
140
+ onCode(listener: (code: string) => void): () => void;
141
+ /** The turn had begun (`Chats.begin`) before the worker restarted: it settles what it left, and runs nothing again. */
142
+ resumed: boolean;
143
+ }
144
+
145
+ /**
146
+ * The app's own actions, which a worker runs (`WorkerOptions.app`): one method per built-in
147
+ * workflow of the app's, each called in a DBOS step of its run, its writes to the checkout under
148
+ * `AppStepContext.serially`. A step refuses by throwing: a `SanomaError` with the code the page
149
+ * branches on (`invalid_input` with its `issues`, or a `field`; `not_approver`), else an error
150
+ * whose message the page shows. What a step returns is checkpointed, so it is JSON. One that
151
+ * applies after its write returns what apply plans (`apply`), which its run starts as a child.
152
+ */
153
+ export interface AppSteps {
154
+ /** Adopt: what the vendor holds for drifted fields, by the drift run's report, into their data file, committed. */
155
+ adopt(step: AppStepContext, input: AdoptInput): Promise<Written>;
156
+ /** A form's Save: the fields set in the declaration's file, as a change for review with a remote, else written and committed. */
157
+ patch(step: AppStepContext, input: PatchInput): Promise<PatchOutput>;
158
+ /** Going live's first half: `infra.ts` written for the choices and committed; what apply makes first. */
159
+ goLive(step: AppStepContext, input: GoLiveInput): Promise<{ apply: ApplyInput }>;
160
+ /** Going live's second half: `main` pushed to the repository apply made; the host (and its wakeup) to apply. */
161
+ goLiveHost(step: AppStepContext, input: GoLiveHostInput): Promise<{ apply: ApplyInput }>;
162
+ /** An existing repository and/or project declared in `infra.ts` and committed; the apply that only binds them. */
163
+ connectExisting(
164
+ step: AppStepContext,
165
+ input: ConnectExistingInput,
166
+ ): Promise<Omit<ConnectExistingOutput, "runId"> & { apply: ApplyInput }>;
167
+ /**
168
+ * A chat turn: the reply to the person's message, by the chat's engine, written to the chat's
169
+ * transcript and, as it streams, to its chunks (`Chats.append`, then `Chats.end`). It writes its
170
+ * own draft, not the checkout.
171
+ */
172
+ chat(step: ChatTurnContext, input: ChatTurnInput): Promise<ChatTurnOutput>;
173
+ }
174
+
175
+ /** The app's steps an action's run calls (`AppRunner`): all but the chat's turn, which its own workflow runs. */
176
+ export type AppAction = Exclude<keyof AppSteps, "chat">;
177
+
178
+ /** The actions whose step answers what to apply after its write. */
179
+ type Applying = {
180
+ [K in AppAction]: Awaited<ReturnType<AppSteps[K]>> extends { apply: ApplyInput } ? K : never;
181
+ }[AppAction];
182
+
183
+ /** What a built-in of the app's runs its work through (`appRunner`, in `app-steps.ts`). */
184
+ export interface AppRunner {
185
+ /** The app's step `name` with `input`, in a DBOS step of this run; refused when the worker runs no app steps. */
186
+ step<K extends AppAction>(name: K, input: Parameters<AppSteps[K]>[1]): ReturnType<AppSteps[K]>;
187
+ /**
188
+ * The step, then the apply it answers started as a child run of this one (`<run>:apply`), as its
189
+ * own actor, without waiting for it: what the step answered besides, with that run's id.
190
+ */
191
+ stepThenApply<K extends Applying>(
192
+ name: K,
193
+ input: Parameters<AppSteps[K]>[1],
194
+ ): Promise<Omit<Awaited<ReturnType<AppSteps[K]>>, "apply"> & { runId: string }>;
195
+ }
196
+
197
+ /** The app's built-ins as its pages call them, by api name (`apiNameOf`): started, then their `.result` polled. */
198
+ export interface AppApi {
199
+ adopt: WorkflowFn<AdoptInput, Written>;
200
+ patch: WorkflowFn<PatchInput, PatchOutput>;
201
+ goLive: WorkflowFn<GoLiveInput, GoLiveOutput>;
202
+ goLiveHost: WorkflowFn<GoLiveHostInput, GoLiveOutput>;
203
+ connectExisting: WorkflowFn<ConnectExistingInput, ConnectExistingOutput>;
204
+ }
205
+
206
+ /** The built-in workflows of the app's actions, over `runner`: `resolveConfig` adds them to every config. */
207
+ export function appWorkflows(runner: AppRunner): WorkflowDefinition<any, any>[] {
208
+ return [
209
+ defineWorkflow({
210
+ // The literal, held to the one name: the outline finds a workflow by a string, or a const of its own file.
211
+ name: "adopt" satisfies typeof ADOPT_WORKFLOW,
212
+ title: "Adopt a vendor's value",
213
+ trigger: "manual",
214
+ input: AdoptInput,
215
+ uses: [],
216
+ run: async (_ctx, input) => runner.step("adopt", input),
217
+ }),
218
+ defineWorkflow({
219
+ name: "patch" satisfies typeof PATCH_WORKFLOW,
220
+ title: "Save a declaration",
221
+ trigger: "manual",
222
+ input: PatchInput,
223
+ uses: [],
224
+ run: async (_ctx, input) => runner.step("patch", input),
225
+ }),
226
+ defineWorkflow({
227
+ name: "go-live" satisfies typeof GO_LIVE_WORKFLOW,
228
+ title: "Go live",
229
+ trigger: "manual",
230
+ input: GoLiveInput,
231
+ uses: [],
232
+ run: async (_ctx, input): Promise<GoLiveOutput> => runner.stepThenApply("goLive", input),
233
+ }),
234
+ defineWorkflow({
235
+ name: "go-live-host" satisfies typeof GO_LIVE_HOST_WORKFLOW,
236
+ title: "Push and run the host",
237
+ trigger: "manual",
238
+ input: GoLiveHostInput,
239
+ uses: [],
240
+ run: async (_ctx, input): Promise<GoLiveOutput> => runner.stepThenApply("goLiveHost", input),
241
+ }),
242
+ defineWorkflow({
243
+ name: "connect-existing" satisfies typeof CONNECT_EXISTING_WORKFLOW,
244
+ title: "Connect an existing repository or database",
245
+ trigger: "manual",
246
+ input: ConnectExistingInput,
247
+ uses: [],
248
+ run: async (_ctx, input): Promise<ConnectExistingOutput> => runner.stepThenApply("connectExisting", input),
249
+ }),
250
+ ];
251
+ }
@@ -4,7 +4,7 @@ import type { ApplyDeclared, ApplySteps, PlannedCall } from "./apply.ts";
4
4
  import { decide } from "./call.ts";
5
5
  import type { Approver } from "./define.ts";
6
6
  import { type Binding, type Bindings, diffLockfiles, LOCKFILE, readLockfile, writeLockfile } from "./lockfile.ts";
7
- import { currentRun, type Run } from "./run.ts";
7
+ import { runNow } from "./run.ts";
8
8
  import { APPLY_WORKFLOW } from "./shared.ts";
9
9
 
10
10
  // The apply workflow's steps that call no vendor: reading the data files, asking the policy
@@ -17,12 +17,6 @@ const WRITE_STEP = "apply:lockfile";
17
17
  /** How many of the latest apply runs are searched for the lockfile one last wrote. */
18
18
  const RUNS_SEARCHED = 100;
19
19
 
20
- const runNow = (): Run => {
21
- const run = currentRun.getStore();
22
- if (!run) throw new Error("An apply step ran outside its run");
23
- return run;
24
- };
25
-
26
20
  /**
27
21
  * The bindings the latest apply run that wrote the lockfile wrote last, from its steps; undefined
28
22
  * when none of the latest `RUNS_SEARCHED` did. A sandbox run writes none.
@@ -68,7 +62,7 @@ export function applySteps(
68
62
  holds: (calls: PlannedCall[]) =>
69
63
  DBOS.runStep(
70
64
  async (): Promise<Approver | undefined> => {
71
- const run = runNow();
65
+ const run = runNow("An apply step");
72
66
  for (const { op: id, input, connection } of calls) {
73
67
  const op = run.state.ops.get(id)!;
74
68
  // Parsed as the call will be, so the policy sees the same input with its defaults.
@@ -87,7 +81,7 @@ export function applySteps(
87
81
  // step whose output is what it wrote. A sandbox run keeps its lockfile in memory: the run's
88
82
  // report has it.
89
83
  write: async (key: string, binding: Binding | undefined, message: string, moved?: string) => {
90
- const run = runNow();
84
+ const run = runNow("An apply step");
91
85
  if (run.sandbox !== undefined || root === undefined) return { written: false };
92
86
  const { source } = run.state;
93
87
  const { revision } = await DBOS.runStep(
package/src/approvals.ts CHANGED
@@ -5,7 +5,7 @@ import { RejectedError, WithdrawnError } from "./errors.ts";
5
5
  import { entry, write } from "./ledger.ts";
6
6
  import { warn } from "./log.ts";
7
7
  import type { Placed } from "./call.ts";
8
- import type { Run } from "./run.ts";
8
+ import { recordSandboxChecks, type Run } from "./run.ts";
9
9
  import { readPeople } from "./resources.ts";
10
10
  import { approverLabel, isVisitor, OWN_CHANGE, refusalOf } from "./shared.ts";
11
11
 
@@ -140,6 +140,8 @@ export async function awaitApproval(
140
140
  { key: state.id, at: state.requestedAt },
141
141
  ),
142
142
  );
143
+ // A sandbox run's checks as it starts to wait, for the app: awaited, since Cucumber runs one at a time in a process.
144
+ await recordSandboxChecks(run);
143
145
  for (;;) {
144
146
  // A decision comes from a request (`SanomaClient.decide`), which wakes a stopped worker: no deadline.
145
147
  const raw = await run.state.deadlines.waiting(run.id, { kind: "approval" }, () =>
package/src/call.ts CHANGED
@@ -59,7 +59,8 @@ const takenByDbos = (err: unknown) =>
59
59
  * cancelled the run or another process took it over, or the run's worker is stopping and the
60
60
  * error has none of our codes. `stop()` marks the worker stopped before DBOS shuts down, so from
61
61
  * then on whatever fails without a code (DBOS's "system database has been shut down", pg's
62
- * closed pool) is the shutdown, and the run recovered on the next worker records how it ends.
62
+ * closed pool) is the shutdown. `runWorkflow` then keeps DBOS from recording it, so the run
63
+ * recovered on the next worker records how it ends.
63
64
  * A coded failure then (a driver's final answer, a denial, a rejection) is still the outcome:
64
65
  * DBOS records the run's error and never runs it again, so the ledger records it too. Narrower
65
66
  * than `isRunControlError`: DBOS's other errors (a run that is not deterministic) end the run
@@ -12,7 +12,6 @@ import {
12
12
  type ChangeFacts,
13
13
  type ChangeState,
14
14
  type ChangeVerified,
15
- followBase,
16
15
  repositoryOf,
17
16
  isRules,
18
17
  mayDeclarePeople,
@@ -25,7 +24,7 @@ import { isRunControlError } from "./errors.ts";
25
24
  import { warn } from "./log.ts";
26
25
  import { inside } from "./paths.ts";
27
26
  import { peopleSources } from "./resources.ts";
28
- import { currentRun, type Run } from "./run.ts";
27
+ import { runNow, type Run } from "./run.ts";
29
28
  import {
30
29
  AGENTS,
31
30
  APPLY_WORKFLOW,
@@ -43,12 +42,6 @@ import type { CheckResult, RemoteHalf } from "./source.ts";
43
42
  // change-workflow.ts, which is held to the workflow lint and imports no DBOS; each reads its run
44
43
  // from `currentRun`.
45
44
 
46
- const runNow = (): Run => {
47
- const run = currentRun.getStore();
48
- if (!run) throw new Error("A change step ran outside its run");
49
- return run;
50
- };
51
-
52
45
  /** True when the file at `path` is a people file (`mayDeclarePeople`), one `readPeople` would load; false when there is none. */
53
46
  const fileDeclaresPeople = async (path: string) => mayDeclarePeople(await readFile(path, "utf8").catch(() => ""));
54
47
 
@@ -135,7 +128,7 @@ export function changeSteps({ remotes, hosts }: ChangeStepsOptions): ChangeSteps
135
128
  const settled = (number: number) =>
136
129
  DBOS.runStep(
137
130
  async (): Promise<ChangeEvent> => {
138
- const { host } = await repositoryOf(remotes, runNow().state.secrets);
131
+ const { host } = await repositoryOf(remotes, runNow("A change step").state.secrets);
139
132
  const pr = await host.pullRequest(number);
140
133
  if (pr.state === "merged") return { kind: "merged", sha: pr.mergeSha ?? pr.head };
141
134
  return pr.state === "closed" ? { kind: "closed" } : { kind: "commits" };
@@ -157,7 +150,7 @@ export function changeSteps({ remotes, hosts }: ChangeStepsOptions): ChangeSteps
157
150
  plan: (id, check) =>
158
151
  DBOS.runStep(
159
152
  async (): Promise<ChangeFacts> => {
160
- const run = runNow();
153
+ const run = runNow("A change step");
161
154
  const source = sourceOfRun();
162
155
  if (!source.diff || !source.draft) throw new Error("This repository keeps no drafts (`Source.diff`)");
163
156
  const [{ files }, draft] = await Promise.all([source.diff(id), source.draft(id)]);
@@ -205,7 +198,7 @@ export function changeSteps({ remotes, hosts }: ChangeStepsOptions): ChangeSteps
205
198
  ahead: (merge) =>
206
199
  DBOS.runStep(
207
200
  async (): Promise<ChangeDecision> => {
208
- const run = runNow();
201
+ const run = runNow("A change step");
209
202
  const op = run.state.ops.get(CHANGE_MERGE)!;
210
203
  const decision = await decide(run, op, op.input.parse(merge));
211
204
  if (decision.kind === "deny") return { answer: "deny", reason: decision.reason };
@@ -226,7 +219,7 @@ export function changeSteps({ remotes, hosts }: ChangeStepsOptions): ChangeSteps
226
219
  ),
227
220
 
228
221
  async next(number) {
229
- const run = runNow();
222
+ const run = runNow("A change step");
230
223
  for (;;) {
231
224
  // An event comes from a request (`SanomaClient.changeEvent`), which wakes a stopped worker: no deadline.
232
225
  const event = await received(run, RECV_SECONDS);
@@ -234,15 +227,15 @@ export function changeSteps({ remotes, hosts }: ChangeStepsOptions): ChangeSteps
234
227
  }
235
228
  },
236
229
 
237
- queued: (number) => drain(runNow(), () => settled(number)),
230
+ queued: (number) => drain(runNow("A change step"), () => settled(number)),
238
231
 
239
- retire: (reason) => withdrawApprovals(runNow(), CHANGE_MERGE, reason),
232
+ retire: (reason) => withdrawApprovals(runNow("A change step"), CHANGE_MERGE, reason),
240
233
 
241
234
  async deploy() {
242
- const run = runNow();
235
+ const run = runNow("A change step");
243
236
  // The checkout moves to the merge first, so the hosts and what apply reads are the merged ones.
244
237
  // Merged already: a deploy that fails is the change's to report, not a failed run.
245
- const follow: ChangeDeploy["follow"] = await DBOS.runStep(() => followBase(remotes, run.state.secrets), {
238
+ const follow: ChangeDeploy["follow"] = await DBOS.runStep(() => run.state.followMain(), {
246
239
  name: "change:follow",
247
240
  });
248
241
  const declared = run.state.workflows.has(APPLY_WORKFLOW)
@@ -254,7 +247,7 @@ export function changeSteps({ remotes, hosts }: ChangeStepsOptions): ChangeSteps
254
247
  },
255
248
 
256
249
  async verify({ apply }) {
257
- const run = runNow();
250
+ const run = runNow("A change step");
258
251
  if (apply === undefined || !run.state.workflows.has(DRIFT_WORKFLOW)) return undefined;
259
252
  const { runId, output, error } = await child(run, DRIFT_WORKFLOW, "verify", {});
260
253
  const applied = new Set(apply.hosts);
@@ -0,0 +1,117 @@
1
+ import { DBOS, Error as DBOSErrors } from "@dbos-inc/dbos-sdk";
2
+ import { appStepsOf, stepContext } from "./app-steps.ts";
3
+ import type { ChatSteps, ChatTurnOutput } from "./chat-workflow.ts";
4
+ import { ChatTell } from "./chat-workflow.ts";
5
+ import { isRunControlError } from "./errors.ts";
6
+ import { warn } from "./log.ts";
7
+ import { runNow } from "./run.ts";
8
+ import { openChatCode } from "./seal.ts";
9
+ import { CHAT_TOPIC, errorMessage } from "./shared.ts";
10
+
11
+ // The chat workflow's steps (`chat-workflow.ts` is held to the workflow lint and imports no DBOS):
12
+ // the wait for the turn before, the turn itself in a step of its own (the app's `AppSteps.chat`,
13
+ // given what an app step is), and what the run is told meanwhile, handed to the turn under way
14
+ // through the turns these steps keep. The turn's step and the run's `recv`s run side by side:
15
+ // DBOS numbers each call as it is made, so a replay meets them in the same order.
16
+
17
+ /** What a worker without the app's steps answers a chat turn: its run fails at once, saying where the agent runs. */
18
+ export const NO_CHAT_STEPS =
19
+ "No agent runs on this instance's worker: chat from the Mac app, or where `sanoma dev` or `sanoma serve` runs the company's checkout";
20
+
21
+ /**
22
+ * How long one `recv` waits before it asks again: the turn's own end ends the loop once its step
23
+ * has settled, should its `ended` not have reached the run. A turn waits for its person's sign-in
24
+ * as long as it lets them.
25
+ */
26
+ const RECV_SECONDS = 10 * 60;
27
+
28
+ /** A chat turn under way: how to stop it, and who takes a code pasted for its sign-in. */
29
+ interface LiveTurn {
30
+ stop: AbortController;
31
+ code?: (code: string) => void;
32
+ }
33
+
34
+ /** The chat workflow's steps. */
35
+ export function chatSteps(): ChatSteps {
36
+ /** Each turn under way in this process, by its run's id: what `tell` hands a message to the run to. */
37
+ const turns = new Map<string, LiveTurn>();
38
+ return {
39
+ async after(runId) {
40
+ const run = runNow("A chat step");
41
+ try {
42
+ await run.state.deadlines.waiting(run.id, { kind: "run" }, () => DBOS.retrieveWorkflow(runId).getResult());
43
+ } catch (err) {
44
+ // However the turn before ended, it has: a failed or cancelled one too.
45
+ if (isRunControlError(err) && !(err instanceof DBOSErrors.DBOSAwaitedWorkflowCancelledError)) throw err;
46
+ }
47
+ },
48
+
49
+ turn(input) {
50
+ const run = runNow("A chat step");
51
+ // Thrown, not a rejection: the run fails at once, before it waits for an end no turn would tell.
52
+ const app = appStepsOf(run, NO_CHAT_STEPS);
53
+ const live: LiveTurn = { stop: new AbortController() };
54
+ // Kept for `tell` before the step starts, so a stop it reads at once finds the turn.
55
+ turns.set(run.id, live);
56
+ const done = DBOS.runStep(
57
+ async (): Promise<ChatTurnOutput> => {
58
+ try {
59
+ const step = await stepContext(run, app);
60
+ const resumed = !(await step.client.chats.begin(input.chat, run.id));
61
+ return await app.steps.chat(
62
+ {
63
+ ...step,
64
+ // Stopped by a new message, or once the worker has stopped, so no agent goes on without it.
65
+ signal: AbortSignal.any([live.stop.signal, run.state.shutdown]),
66
+ onCode(listener) {
67
+ live.code = listener;
68
+ return () => {
69
+ if (live.code === listener) live.code = undefined;
70
+ };
71
+ },
72
+ resumed,
73
+ },
74
+ input,
75
+ );
76
+ } finally {
77
+ // From the step: DBOS keeps no record of it, and a step done again tells it again. A
78
+ // send that fails leaves the step's own answer or error standing: `tell` sees it settled.
79
+ await DBOS.send(run.id, { kind: "ended" } satisfies ChatTell, CHAT_TOPIC).catch((err: unknown) =>
80
+ warn(`run ${run.id}: could not tell the run its turn ended: ${errorMessage(err)}`),
81
+ );
82
+ }
83
+ },
84
+ { name: "chat:turn" },
85
+ );
86
+ const forget = () => {
87
+ if (turns.get(run.id) === live) turns.delete(run.id);
88
+ };
89
+ // The run awaits it once `tell` says it ended: handled meanwhile, so its failure is not unhandled.
90
+ done.then(forget, forget);
91
+ return done;
92
+ },
93
+
94
+ async tell() {
95
+ const run = runNow("A chat step");
96
+ // Its turn's step has settled (it is no longer under way here): nothing more to hand it.
97
+ if (!turns.has(run.id)) return false;
98
+ // No deadline: the turn's step is executing all the while, so the run is busy, not waiting.
99
+ const raw = await DBOS.recv<unknown>(CHAT_TOPIC, { timeoutSeconds: RECV_SECONDS });
100
+ if (raw === null || raw === undefined) return turns.has(run.id);
101
+ const told = ChatTell.safeParse(raw);
102
+ if (!told.success) {
103
+ warn(`run ${run.id}: a message on ${CHAT_TOPIC} is none a chat turn takes, so it is ignored`);
104
+ return true;
105
+ }
106
+ const live = turns.get(run.id);
107
+ if (told.data.kind === "ended") return false;
108
+ if (told.data.kind === "stop") live?.stop.abort();
109
+ else {
110
+ const code = openChatCode(told.data.sealed, run.id);
111
+ if (code === undefined) warn(`run ${run.id}: a sign-in code not sealed for it is ignored`);
112
+ else live?.code?.(code);
113
+ }
114
+ return true;
115
+ },
116
+ };
117
+ }