@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.
- package/README.md +14 -3
- package/dist/app-steps.d.ts +17 -0
- package/dist/app-steps.js +43 -0
- package/dist/app-steps.js.map +1 -0
- package/dist/app-workflows.d.ts +170 -0
- package/dist/app-workflows.js +107 -0
- package/dist/app-workflows.js.map +1 -0
- package/dist/apply-steps.js +3 -9
- package/dist/apply-steps.js.map +1 -1
- package/dist/approvals.d.ts +1 -1
- package/dist/approvals.js +3 -0
- package/dist/approvals.js.map +1 -1
- package/dist/call.d.ts +2 -1
- package/dist/call.js +2 -1
- package/dist/call.js.map +1 -1
- package/dist/change-steps.js +11 -17
- package/dist/change-steps.js.map +1 -1
- package/dist/chat-steps.d.ts +5 -0
- package/dist/chat-steps.js +107 -0
- package/dist/chat-steps.js.map +1 -0
- package/dist/chat-workflow.d.ts +43 -0
- package/dist/chat-workflow.js +47 -0
- package/dist/chat-workflow.js.map +1 -0
- package/dist/chats.d.ts +23 -4
- package/dist/chats.js +54 -7
- package/dist/chats.js.map +1 -1
- package/dist/client.d.ts +42 -31
- package/dist/client.js +118 -105
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +20 -0
- package/dist/config.js +55 -4
- package/dist/config.js.map +1 -1
- package/dist/db/company.d.ts +259 -2
- package/dist/db/company.js +32 -1
- package/dist/db/company.js.map +1 -1
- package/dist/db.d.ts +15 -0
- package/dist/db.js +42 -2
- package/dist/db.js.map +1 -1
- package/dist/description.d.ts +6 -3
- package/dist/description.js +4 -3
- package/dist/description.js.map +1 -1
- package/dist/follow-steps.d.ts +8 -0
- package/dist/follow-steps.js +113 -0
- package/dist/follow-steps.js.map +1 -0
- package/dist/follow-workflow.d.ts +56 -0
- package/dist/follow-workflow.js +50 -0
- package/dist/follow-workflow.js.map +1 -0
- package/dist/index.d.ts +10 -5
- package/dist/index.js +9 -4
- package/dist/index.js.map +1 -1
- package/dist/local-sandbox.d.ts +10 -0
- package/dist/local-sandbox.js +130 -0
- package/dist/local-sandbox.js.map +1 -0
- package/dist/op.d.ts +9 -2
- package/dist/op.js.map +1 -1
- package/dist/paths.d.ts +9 -0
- package/dist/paths.js +20 -4
- package/dist/paths.js.map +1 -1
- package/dist/resource.d.ts +7 -1
- package/dist/resource.js +5 -1
- package/dist/resource.js.map +1 -1
- package/dist/run.d.ts +39 -0
- package/dist/run.js +15 -0
- package/dist/run.js.map +1 -1
- package/dist/sandbox-contract.d.ts +100 -0
- package/dist/sandbox-contract.js +26 -0
- package/dist/sandbox-contract.js.map +1 -0
- package/dist/sandbox.d.ts +10 -0
- package/dist/sandbox.js +42 -4
- package/dist/sandbox.js.map +1 -1
- package/dist/scenario.d.ts +19 -0
- package/dist/scenario.js.map +1 -1
- package/dist/schedules.d.ts +11 -3
- package/dist/schedules.js +15 -3
- package/dist/schedules.js.map +1 -1
- package/dist/seal.d.ts +7 -0
- package/dist/seal.js +14 -2
- package/dist/seal.js.map +1 -1
- package/dist/shared.d.ts +34 -2
- package/dist/shared.js +39 -2
- package/dist/shared.js.map +1 -1
- package/dist/worker.d.ts +8 -0
- package/dist/worker.js +116 -10
- package/dist/worker.js.map +1 -1
- package/drizzle/company/0002_runs_tell_the_app.sql +27 -0
- package/drizzle/company/meta/0002_snapshot.json +819 -0
- package/drizzle/company/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/src/app-steps.ts +67 -0
- package/src/app-workflows.ts +251 -0
- package/src/apply-steps.ts +3 -9
- package/src/approvals.ts +3 -1
- package/src/call.ts +2 -1
- package/src/change-steps.ts +10 -17
- package/src/chat-steps.ts +117 -0
- package/src/chat-workflow.ts +69 -0
- package/src/chats.ts +80 -14
- package/src/client.ts +154 -109
- package/src/config.ts +66 -3
- package/src/db/company.ts +49 -2
- package/src/db.ts +51 -3
- package/src/description.ts +4 -3
- package/src/follow-steps.ts +129 -0
- package/src/follow-workflow.ts +88 -0
- package/src/index.ts +32 -3
- package/src/local-sandbox.ts +132 -0
- package/src/op.ts +9 -2
- package/src/paths.ts +22 -4
- package/src/resource.ts +16 -2
- package/src/run.ts +41 -0
- package/src/sandbox-contract.ts +114 -0
- package/src/sandbox.ts +41 -4
- package/src/scenario.ts +17 -0
- package/src/schedules.ts +19 -4
- package/src/seal.ts +16 -2
- package/src/shared.ts +47 -2
- package/src/worker.ts +149 -10
package/package.json
CHANGED
package/src/app-steps.ts
ADDED
|
@@ -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
|
+
}
|
package/src/apply-steps.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
package/src/change-steps.ts
CHANGED
|
@@ -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 {
|
|
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(() =>
|
|
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
|
+
}
|