@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.
- package/README.md +10 -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.js +14 -1
- 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/fill.d.ts +5 -1
- package/dist/fill.js +11 -2
- package/dist/fill.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 +5 -2
- package/dist/index.js +4 -1
- package/dist/index.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.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/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 +14 -0
- package/src/db/company.ts +49 -2
- package/src/db.ts +51 -3
- package/src/fill.ts +10 -2
- package/src/follow-steps.ts +129 -0
- package/src/follow-workflow.ts +88 -0
- package/src/index.ts +19 -1
- package/src/run.ts +41 -0
- package/src/sandbox.ts +41 -4
- package/src/scenario.ts +17 -0
- package/src/seal.ts +16 -2
- package/src/shared.ts +47 -2
- package/src/worker.ts +149 -10
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { defineWorkflow, type WorkflowDefinition } from "./define.ts";
|
|
3
|
+
import { type CHAT_WORKFLOW, DRAFT_ID } from "./shared.ts";
|
|
4
|
+
|
|
5
|
+
// The built-in `chat` workflow: a turn of a person's chat with the operator agent, run by the
|
|
6
|
+
// worker, where the agent's harness can run its children in the chat's draft. The app starts it
|
|
7
|
+
// (`SanomaClient.startTurn`, as the chat's person, one run per message) and streams the reply the
|
|
8
|
+
// turn writes to the database; a new message or a pasted sign-in code reaches the run as a message
|
|
9
|
+
// (`ChatTell`). What the turn does is the app's (`AppSteps.chat`), in a step of `chat-steps.ts`; a
|
|
10
|
+
// workflow like any other, held to the same lint.
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A turn's input: the chat, the id of the person's new message, which the app wrote to the chat's
|
|
14
|
+
* transcript before it started the turn, and the chat's turn before it, which this one waits for.
|
|
15
|
+
* Ids only: a run's input and output are in its ledger, which the company's staff read, and a
|
|
16
|
+
* chat's text is its person's (`Chats`).
|
|
17
|
+
*/
|
|
18
|
+
export const ChatTurnInput = z.object({
|
|
19
|
+
chat: z.string().regex(DRAFT_ID, "a chat id is letters, digits, _ and -"),
|
|
20
|
+
message: z.string().regex(DRAFT_ID, "a message id is letters, digits, _ and -"),
|
|
21
|
+
after: z.string().min(1).optional(),
|
|
22
|
+
});
|
|
23
|
+
export type ChatTurnInput = z.infer<typeof ChatTurnInput>;
|
|
24
|
+
|
|
25
|
+
/** What a turn answers: the reply's message id (never its text), once it wrote one; `interrupted` for a turn the worker's restart cut short. */
|
|
26
|
+
export interface ChatTurnOutput {
|
|
27
|
+
reply?: string;
|
|
28
|
+
interrupted?: true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** What a turn's run is told (`CHAT_TOPIC`): stop (a new message came), a sign-in code sealed for the run, its turn's end. */
|
|
32
|
+
export const ChatTell = z.discriminatedUnion("kind", [
|
|
33
|
+
z.object({ kind: z.literal("stop") }),
|
|
34
|
+
z.object({ kind: z.literal("code"), sealed: z.string().min(1) }),
|
|
35
|
+
z.object({ kind: z.literal("ended") }),
|
|
36
|
+
]);
|
|
37
|
+
export type ChatTell = z.infer<typeof ChatTell>;
|
|
38
|
+
|
|
39
|
+
/** What a chat turn does, each part a step or a wait of its own (`chat-steps.ts`). */
|
|
40
|
+
export interface ChatSteps {
|
|
41
|
+
/** Waits for the chat's turn before this one to end, however it ends: its reply is in the transcript then. */
|
|
42
|
+
after(runId: string): Promise<void>;
|
|
43
|
+
/**
|
|
44
|
+
* The turn, a step that runs while the run reads what it is told (`tell`), and tells the run when
|
|
45
|
+
* it ends. Throws at once, not rejecting, when the worker runs no agent.
|
|
46
|
+
*/
|
|
47
|
+
turn(input: ChatTurnInput): Promise<ChatTurnOutput>;
|
|
48
|
+
/** The next thing the run is told, handed to the turn under way: false once the turn has ended. */
|
|
49
|
+
tell(): Promise<boolean>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The built-in chat workflow over `steps`: `resolveConfig` adds it to every config. */
|
|
53
|
+
export function chatWorkflow(steps: ChatSteps): WorkflowDefinition<any, any> {
|
|
54
|
+
return defineWorkflow({
|
|
55
|
+
// The literal, held to the one name: the outline finds a workflow by a string, or a const of its own file.
|
|
56
|
+
name: "chat" satisfies typeof CHAT_WORKFLOW,
|
|
57
|
+
title: "A chat turn",
|
|
58
|
+
trigger: "manual",
|
|
59
|
+
input: ChatTurnInput,
|
|
60
|
+
uses: [],
|
|
61
|
+
run: async (_ctx, input): Promise<ChatTurnOutput> => {
|
|
62
|
+
if (input.after !== undefined) await steps.after(input.after);
|
|
63
|
+
const turn = steps.turn(input);
|
|
64
|
+
let open = true;
|
|
65
|
+
while (open) open = await steps.tell();
|
|
66
|
+
return turn;
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
}
|
package/src/chats.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
import type { UIMessage } from "ai";
|
|
2
|
-
import { asc, desc, eq, sql } from "drizzle-orm";
|
|
1
|
+
import type { UIMessage, UIMessageChunk } from "ai";
|
|
2
|
+
import { and, asc, desc, eq, gt, ne, sql } from "drizzle-orm";
|
|
3
3
|
import type { Db } from "./db.ts";
|
|
4
|
-
import { chatMessages, chats } from "./db/company.ts";
|
|
4
|
+
import { chatChunks, chatMessages, chats } from "./db/company.ts";
|
|
5
5
|
import { SanomaError } from "./errors.ts";
|
|
6
6
|
|
|
7
7
|
// The operator agent's chats, in the config's database beside the runtime's other tables: one row
|
|
8
|
-
// per chat (whose it is, the engine it runs on, the draft it edits
|
|
9
|
-
// SDK's UI messages
|
|
10
|
-
// the
|
|
8
|
+
// per chat (whose it is, the engine it runs on, the draft it edits, the turn last begun), its
|
|
9
|
+
// messages as the AI SDK's UI messages, and the reply its turn streams, as chunks. A turn is a run
|
|
10
|
+
// of `chat` the worker executes: it writes the transcript and the chunks, which the app reads. A
|
|
11
|
+
// worker's restart does not run a turn again: the transcript and the draft's branch are what it
|
|
12
|
+
// keeps, and the person sends "continue".
|
|
11
13
|
|
|
12
14
|
/** A chat as `SanomaClient.chats.list` reads it. */
|
|
13
15
|
export interface Chat {
|
|
@@ -33,20 +35,36 @@ export interface Chats {
|
|
|
33
35
|
/** Records the draft the chat edits. */
|
|
34
36
|
attach(id: string, draftId: string): Promise<void>;
|
|
35
37
|
/**
|
|
36
|
-
* Writes these messages of the chat, each by its id: a
|
|
37
|
-
*
|
|
38
|
+
* Writes these messages of the chat, each by its id: a new one goes after the rest, in the order
|
|
39
|
+
* given, and one already there is replaced in its place, or, with `last`, moved after the rest too.
|
|
38
40
|
*/
|
|
39
|
-
upsert(id: string, messages: readonly UIMessage[]): Promise<void>;
|
|
41
|
+
upsert(id: string, messages: readonly UIMessage[], options?: { last?: boolean }): Promise<void>;
|
|
40
42
|
/** The chat's messages in order; none for a chat that has none. */
|
|
41
43
|
load(id: string): Promise<UIMessage[]>;
|
|
44
|
+
/** Whether the chat `id` has a message `message`. */
|
|
45
|
+
has(id: string, message: string): Promise<boolean>;
|
|
42
46
|
/** The chat `id`, or undefined when there is none. */
|
|
43
47
|
get(id: string): Promise<Chat | undefined>;
|
|
44
48
|
/** The person's chats, the newest first: the latest 50. */
|
|
45
49
|
list(person: string): Promise<Chat[]>;
|
|
50
|
+
/**
|
|
51
|
+
* Begins the turn `turn` (its run's id) of the chat `id`: false when it had begun already (the
|
|
52
|
+
* worker restarted under it). The chunks of the chat's other turns go.
|
|
53
|
+
*/
|
|
54
|
+
begin(id: string, turn: string): Promise<boolean>;
|
|
55
|
+
/** Writes these chunks of the reply the turn `turn` of the chat `id` streams, after those written. */
|
|
56
|
+
append(id: string, turn: string, chunks: readonly UIMessageChunk[]): Promise<void>;
|
|
57
|
+
/** Ends the turn's stream: nothing is appended after it. */
|
|
58
|
+
end(id: string, turn: string): Promise<void>;
|
|
59
|
+
/** The turn's chunks after the one numbered `after` (0 for all), the last one's number, and whether its stream has ended. */
|
|
60
|
+
chunks(turn: string, after: number): Promise<{ chunks: UIMessageChunk[]; last: number; ended: boolean }>;
|
|
46
61
|
}
|
|
47
62
|
|
|
48
|
-
const chatOf = ({ draftId, createdAt
|
|
49
|
-
|
|
63
|
+
const chatOf = ({ id, person, engine, title, draftId, createdAt }: typeof chats.$inferSelect): Chat => ({
|
|
64
|
+
id,
|
|
65
|
+
person,
|
|
66
|
+
engine,
|
|
67
|
+
title,
|
|
50
68
|
...(draftId === null ? {} : { draftId }),
|
|
51
69
|
createdAt: createdAt.getTime(),
|
|
52
70
|
});
|
|
@@ -67,10 +85,10 @@ export function chatStore(db: () => Promise<Db>): Chats {
|
|
|
67
85
|
async attach(id, draftId) {
|
|
68
86
|
await (await db()).update(chats).set({ draftId }).where(eq(chats.id, id));
|
|
69
87
|
},
|
|
70
|
-
async upsert(id, messages) {
|
|
88
|
+
async upsert(id, messages, options) {
|
|
71
89
|
if (!messages.length) return;
|
|
72
90
|
if (messages.some((m) => !m.id)) throw new SanomaError("invalid_input", "A chat message needs an id");
|
|
73
|
-
// A new message's place is after the chat's last, in the order given.
|
|
91
|
+
// A new message's place, or a moved one's, is after the chat's last, in the order given.
|
|
74
92
|
const last = sql`(SELECT coalesce(max(${chatMessages.seq}), 0) FROM ${chatMessages} WHERE ${eq(chatMessages.chatId, id)})`;
|
|
75
93
|
await (
|
|
76
94
|
await db()
|
|
@@ -81,7 +99,7 @@ export function chatStore(db: () => Promise<Db>): Chats {
|
|
|
81
99
|
)
|
|
82
100
|
.onConflictDoUpdate({
|
|
83
101
|
target: [chatMessages.chatId, chatMessages.id],
|
|
84
|
-
set: { message: sql`excluded.message` },
|
|
102
|
+
set: { message: sql`excluded.message`, ...(options?.last && { seq: sql`excluded.seq` }) },
|
|
85
103
|
});
|
|
86
104
|
},
|
|
87
105
|
async load(id) {
|
|
@@ -94,10 +112,49 @@ export function chatStore(db: () => Promise<Db>): Chats {
|
|
|
94
112
|
.orderBy(asc(chatMessages.seq), asc(chatMessages.id));
|
|
95
113
|
return rows.map((row) => row.message);
|
|
96
114
|
},
|
|
115
|
+
async has(id, message) {
|
|
116
|
+
const [row] = await (
|
|
117
|
+
await db()
|
|
118
|
+
)
|
|
119
|
+
.select({ id: chatMessages.id })
|
|
120
|
+
.from(chatMessages)
|
|
121
|
+
.where(and(eq(chatMessages.chatId, id), eq(chatMessages.id, message)));
|
|
122
|
+
return row !== undefined;
|
|
123
|
+
},
|
|
97
124
|
async get(id) {
|
|
98
125
|
const [row] = await (await db()).select().from(chats).where(eq(chats.id, id));
|
|
99
126
|
return row && chatOf(row);
|
|
100
127
|
},
|
|
128
|
+
async begin(id, turn) {
|
|
129
|
+
const database = await db();
|
|
130
|
+
const begun = await database
|
|
131
|
+
.update(chats)
|
|
132
|
+
.set({ turn })
|
|
133
|
+
.where(and(eq(chats.id, id), sql`${chats.turn} IS DISTINCT FROM ${turn}`))
|
|
134
|
+
.returning({ id: chats.id });
|
|
135
|
+
await database.delete(chatChunks).where(and(eq(chatChunks.chatId, id), ne(chatChunks.runId, turn)));
|
|
136
|
+
return begun.length > 0;
|
|
137
|
+
},
|
|
138
|
+
async append(id, turn, chunks) {
|
|
139
|
+
if (chunks.length) await appendChunks(await db(), id, turn, chunks);
|
|
140
|
+
},
|
|
141
|
+
async end(id, turn) {
|
|
142
|
+
await appendChunks(await db(), id, turn, [null]);
|
|
143
|
+
},
|
|
144
|
+
async chunks(turn, after) {
|
|
145
|
+
const rows = await (
|
|
146
|
+
await db()
|
|
147
|
+
)
|
|
148
|
+
.select({ seq: chatChunks.seq, chunk: chatChunks.chunk })
|
|
149
|
+
.from(chatChunks)
|
|
150
|
+
.where(and(eq(chatChunks.runId, turn), gt(chatChunks.seq, after)))
|
|
151
|
+
.orderBy(asc(chatChunks.seq));
|
|
152
|
+
return {
|
|
153
|
+
chunks: rows.flatMap((row) => (row.chunk === null ? [] : [row.chunk])),
|
|
154
|
+
last: rows.at(-1)?.seq ?? after,
|
|
155
|
+
ended: rows.at(-1)?.chunk === null,
|
|
156
|
+
};
|
|
157
|
+
},
|
|
101
158
|
async list(person) {
|
|
102
159
|
const rows = await (
|
|
103
160
|
await db()
|
|
@@ -111,3 +168,12 @@ export function chatStore(db: () => Promise<Db>): Chats {
|
|
|
111
168
|
},
|
|
112
169
|
};
|
|
113
170
|
}
|
|
171
|
+
|
|
172
|
+
/** Writes `chunks` (null, the end) after the turn's last, in the order given. */
|
|
173
|
+
async function appendChunks(db: Db, id: string, turn: string, chunks: readonly (UIMessageChunk | null)[]) {
|
|
174
|
+
// A chunk's place is after the turn's last, in the order given.
|
|
175
|
+
const last = sql`(SELECT coalesce(max(${chatChunks.seq}), 0) FROM ${chatChunks} WHERE ${eq(chatChunks.runId, turn)})`;
|
|
176
|
+
await db
|
|
177
|
+
.insert(chatChunks)
|
|
178
|
+
.values(chunks.map((chunk, n) => ({ chatId: id, runId: turn, seq: sql<number>`${last} + ${n + 1}`, chunk })));
|
|
179
|
+
}
|
package/src/client.ts
CHANGED
|
@@ -32,21 +32,26 @@ import { type Tokens, tokenStore } from "./tokens.ts";
|
|
|
32
32
|
import { collectionsDriver } from "./collection.ts";
|
|
33
33
|
import { type ResolvedConfig, resolveConfig, type SanomaConfig } from "./config.ts";
|
|
34
34
|
import { configuredSecrets } from "./credentials.ts";
|
|
35
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
type AppBehind,
|
|
37
|
+
dbosClient,
|
|
38
|
+
lazyDb,
|
|
39
|
+
type LazyDb,
|
|
40
|
+
readAppBehind,
|
|
41
|
+
readRunChecks,
|
|
42
|
+
spendInviteRequest,
|
|
43
|
+
} from "./db.ts";
|
|
36
44
|
import { type CredentialStatus, credentialsOf } from "./env.ts";
|
|
37
45
|
import { type ApprovalState, Principal, type WorkflowDefinition } from "./define.ts";
|
|
38
46
|
import { APPLY_WORKFLOW, type ApplyInput, type ApplyReport } from "./apply.ts";
|
|
39
47
|
import {
|
|
40
48
|
ADMIN,
|
|
41
|
-
BASE,
|
|
42
49
|
type ChangeEvent,
|
|
43
50
|
ChangeEventSchema,
|
|
44
51
|
ChangeInput,
|
|
45
52
|
type ChangeReport,
|
|
46
53
|
type ChangeState,
|
|
47
|
-
followBase,
|
|
48
54
|
initialChangeState,
|
|
49
|
-
repositoryOf,
|
|
50
55
|
} from "./change.ts";
|
|
51
56
|
import { DRIFT_WORKFLOW, type DriftReport } from "./drift.ts";
|
|
52
57
|
import { isDirect } from "./direct.ts";
|
|
@@ -59,7 +64,6 @@ import {
|
|
|
59
64
|
parseOrThrow,
|
|
60
65
|
PolicyDeniedError,
|
|
61
66
|
SanomaError,
|
|
62
|
-
vendorCodeOf,
|
|
63
67
|
} from "./errors.ts";
|
|
64
68
|
import {
|
|
65
69
|
insertRecord,
|
|
@@ -70,7 +74,6 @@ import {
|
|
|
70
74
|
type OwnedLedger,
|
|
71
75
|
ownedLedger,
|
|
72
76
|
} from "./ledger.ts";
|
|
73
|
-
import { warn } from "./log.ts";
|
|
74
77
|
import type { CallContext, Driver, Op, SignInMethod } from "./op.ts";
|
|
75
78
|
import { allowAll, type RecordedDecision } from "./policy.ts";
|
|
76
79
|
import { admit, readPeople } from "./resources.ts";
|
|
@@ -83,10 +86,16 @@ import {
|
|
|
83
86
|
CHANGE_MERGE,
|
|
84
87
|
CHANGE_TOPIC,
|
|
85
88
|
CHANGE_WORKFLOW,
|
|
89
|
+
CHAT_TOPIC,
|
|
90
|
+
CHAT_WORKFLOW,
|
|
86
91
|
changeIdOf,
|
|
92
|
+
chatRunId,
|
|
93
|
+
chatRunPrefix,
|
|
87
94
|
DATA_VENDOR,
|
|
95
|
+
DRAFT_ID,
|
|
88
96
|
ENDED_STATUSES,
|
|
89
97
|
errorMessage,
|
|
98
|
+
FOLLOW_WORKFLOW,
|
|
90
99
|
HOST_CHECK_AGENT,
|
|
91
100
|
isEnded,
|
|
92
101
|
isToken,
|
|
@@ -96,14 +105,11 @@ import {
|
|
|
96
105
|
SCHEDULE_AGENT,
|
|
97
106
|
WORKFLOW_ACTION,
|
|
98
107
|
} from "./shared.ts";
|
|
99
|
-
import {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
type
|
|
103
|
-
|
|
104
|
-
/** Whether two references name one repository: a host's names are compared without case. */
|
|
105
|
-
const sameRepo = (a: RepoRef, b: RepoRef) =>
|
|
106
|
-
a.owner.toLowerCase() === b.owner.toLowerCase() && a.name.toLowerCase() === b.name.toLowerCase();
|
|
108
|
+
import { RemoteEventSchema } from "./follow-workflow.ts";
|
|
109
|
+
import type { ChatTell, ChatTurnInput } from "./chat-workflow.ts";
|
|
110
|
+
import { sealChatCode } from "./seal.ts";
|
|
111
|
+
import type { RunChecks } from "./scenario.ts";
|
|
112
|
+
import type { RemoteEvent } from "./source.ts";
|
|
107
113
|
|
|
108
114
|
/** A principal, or its id alone. */
|
|
109
115
|
const PrincipalOrId = z.union([
|
|
@@ -260,11 +266,6 @@ export interface ClientOptions {
|
|
|
260
266
|
* runs a worker that stops when idle starts it again with it (`Supervisor.resume`). Must not throw.
|
|
261
267
|
*/
|
|
262
268
|
onWrite?: () => void;
|
|
263
|
-
/**
|
|
264
|
-
* The company's repository, as the worker is given it: what `remoteEvent` acts for (its
|
|
265
|
-
* `origin`) and fetches a draft's new commits into. Without one, `remoteEvent` does nothing.
|
|
266
|
-
*/
|
|
267
|
-
source?: Source;
|
|
268
269
|
/**
|
|
269
270
|
* The version each run it starts is queued for, so only a worker of that code runs it: the app
|
|
270
271
|
* bundle's (its snapshot's), which is live beside a worker of another version while a deploy
|
|
@@ -365,10 +366,12 @@ export class SanomaClient {
|
|
|
365
366
|
options.sandbox,
|
|
366
367
|
"`sandbox` must be the name of a scenario in the config's `scenarios/`",
|
|
367
368
|
);
|
|
368
|
-
if (workflow.name === CHANGE_WORKFLOW && !isChangeRunOf((parsed as ChangeInput).id, runId)) {
|
|
369
|
-
throw new SanomaError("invalid_input", "A change is started with openChange, which names its run", { runId });
|
|
370
|
-
}
|
|
371
369
|
const hold = await this.startHold(workflow.name, parsed, startedBy, runId);
|
|
370
|
+
const own = OWN_STARTS[workflow.name];
|
|
371
|
+
if (own && !(await own.admits(parsed, startedBy, runId, this))) {
|
|
372
|
+
const refused = `A run of ${workflow.name} is started with ${own.by}, which names its run`;
|
|
373
|
+
throw new SanomaError("invalid_input", refused, { runId });
|
|
374
|
+
}
|
|
372
375
|
const args: RunArgs = { input, startedBy, ...(sandbox === undefined ? {} : { sandbox }), ...(hold && { hold }) };
|
|
373
376
|
// Kept on the run's status row too, so a listing tells sandbox runs apart without its input,
|
|
374
377
|
// and says whom an agent's run is for (DBOS keeps the principal's id and groups itself); a
|
|
@@ -414,7 +417,9 @@ export class SanomaClient {
|
|
|
414
417
|
* The policy's answer to starting a run of `workflow` as `startedBy` (`decideStart`), for a
|
|
415
418
|
* workflow of the config's own: a deny throws `policy_denied`; a hold is what the run waits on
|
|
416
419
|
* before its body. A run `runId` names already is returned as it is, whatever the policy says now, but to a visitor.
|
|
417
|
-
* A visitor (`isVisitor
|
|
420
|
+
* A visitor (`isVisitor`, an api token's principal too) starts no built-in but `call`, whose calls
|
|
421
|
+
* the policy decides as direct ones: the app's own actions on the repository (`app-workflows.ts`)
|
|
422
|
+
* are staff's, as their server functions were, which no site's host answered.
|
|
418
423
|
*/
|
|
419
424
|
private async startHold(
|
|
420
425
|
workflow: string,
|
|
@@ -976,6 +981,24 @@ export class SanomaClient {
|
|
|
976
981
|
return Promise.all(rows.map((row) => this.changeView(row)));
|
|
977
982
|
}
|
|
978
983
|
|
|
984
|
+
/**
|
|
985
|
+
* Whether a change is merging or deploying `main` now (`ChangeState.phase`, as its run last
|
|
986
|
+
* published it), which follows `main` and applies the hosts itself.
|
|
987
|
+
*/
|
|
988
|
+
async changeDeploying(): Promise<boolean> {
|
|
989
|
+
const rows = await this.dbos.listWorkflows({
|
|
990
|
+
workflowName: CHANGE_WORKFLOW,
|
|
991
|
+
applicationName: this.config.appName,
|
|
992
|
+
status: dbosStatusesOf("queued", "running"),
|
|
993
|
+
loadInput: false,
|
|
994
|
+
loadOutput: false,
|
|
995
|
+
});
|
|
996
|
+
const phases = await Promise.all(
|
|
997
|
+
rows.map(async (row) => (await this.dbos.getEvent<ChangeState>(row.workflowID, CHANGE_EVENT, 0))?.phase),
|
|
998
|
+
);
|
|
999
|
+
return phases.some((phase) => phase === "merging" || phase === "deploying");
|
|
1000
|
+
}
|
|
1001
|
+
|
|
979
1002
|
/** The change of the draft `id`: its latest run's (`openChange`), or undefined when it has none. */
|
|
980
1003
|
async change(id: string): Promise<ChangeView | undefined> {
|
|
981
1004
|
const [row] = await this.changeRuns(id, { load: true });
|
|
@@ -1025,105 +1048,86 @@ export class SanomaClient {
|
|
|
1025
1048
|
}
|
|
1026
1049
|
|
|
1027
1050
|
/**
|
|
1028
|
-
*
|
|
1029
|
-
*
|
|
1030
|
-
*
|
|
1031
|
-
*
|
|
1032
|
-
* - `pushed` to `main`: the checkout follows `main`, then apply on the declared hosts
|
|
1033
|
-
* (`ResolvedConfig.hosts`) as `host-check`, as the run `deploy:<head>`, once per head; nothing
|
|
1034
|
-
* with no host, nor while a change is merging or deploying, which does both itself.
|
|
1035
|
-
* - `pushed` to a draft's branch (`draftOf`) with its change open: the host's commits fetched into
|
|
1036
|
-
* the draft (`Source.follow` on its branch), then the change told `commits`, as `by`; nothing
|
|
1037
|
-
* when the change or the draft is at that head already (the run's own push), or when the draft
|
|
1038
|
-
* cannot fast-forward to it (warned).
|
|
1039
|
-
* - `closed` for the open change's pull request: the change told `merged` (with its merge commit)
|
|
1040
|
-
* or `closed`, as `by`. The run checks either on the host before it believes it.
|
|
1051
|
+
* Starts the built-in follow workflow on what a host's webhook says happened (`RemoteEvent`,
|
|
1052
|
+
* from a remote half's `webhook`, verified), as the runtime's agent `host-check`: the worker,
|
|
1053
|
+
* which has the checkout, follows it there (`follow-workflow.ts`). Its run id is
|
|
1054
|
+
* `follow:<sha256 of the event>`, so a delivery told again is the one run. Its id.
|
|
1041
1055
|
*/
|
|
1042
|
-
async
|
|
1043
|
-
const
|
|
1044
|
-
|
|
1045
|
-
if (!main && id === undefined) return;
|
|
1046
|
-
const repository = await this.repository();
|
|
1047
|
-
if (!repository || !sameRepo(repository.repo, event.repo)) return;
|
|
1048
|
-
if (main) return this.deployPush(repository, event.head);
|
|
1049
|
-
const change = await this.change(id!);
|
|
1050
|
-
if (!change || isEnded(change.status)) return;
|
|
1051
|
-
let told: ChangeEvent;
|
|
1052
|
-
let key: string;
|
|
1053
|
-
if (event.kind === "closed") {
|
|
1054
|
-
// A pull request of an earlier change of the draft is not this one's.
|
|
1055
|
-
if (event.number !== change.state.pullRequest?.number) return;
|
|
1056
|
-
told = event.merged && event.sha !== undefined ? { kind: "merged", sha: event.sha } : { kind: "closed" };
|
|
1057
|
-
key = `${event.number}:${event.sha ?? "closed"}`;
|
|
1058
|
-
} else {
|
|
1059
|
-
if (change.state.head === event.head || !(await this.fetched(repository, event.branch, event.head))) return;
|
|
1060
|
-
told = { kind: "commits" };
|
|
1061
|
-
key = event.head;
|
|
1062
|
-
}
|
|
1063
|
-
await this.changeEvent(id!, told, { by: options.by, key: `${event.kind}:${event.branch}:${key}` }).catch(
|
|
1064
|
-
(err: unknown) => {
|
|
1065
|
-
// Ended, or gone, since it was read: nothing to tell.
|
|
1066
|
-
if (!["run_ended", "run_not_found"].includes(errorCode(err) ?? "")) throw err;
|
|
1067
|
-
},
|
|
1068
|
-
);
|
|
1056
|
+
async follow(event: RemoteEvent): Promise<string> {
|
|
1057
|
+
const parsed = parseOrThrow(RemoteEventSchema, event, "Not an event of a host's webhook");
|
|
1058
|
+
return this.start(FOLLOW_WORKFLOW, parsed, { startedBy: HOST_CHECK_AGENT, runId: followRunId(parsed) });
|
|
1069
1059
|
}
|
|
1070
1060
|
|
|
1071
|
-
/**
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1061
|
+
/**
|
|
1062
|
+
* Starts a turn of the chat `input.chat` on the person's new message `input.message`, already in
|
|
1063
|
+
* its transcript (`chats.upsert`), as `startedBy`, the chat's person: the built-in chat workflow,
|
|
1064
|
+
* as the run `chat:<chat>:<message>` (`chatRunId`), after the chat's turn before it, which it
|
|
1065
|
+
* waits for; that turn, when it has not ended, is told to stop. The worker runs the turn and
|
|
1066
|
+
* writes its reply (`chats.chunks`, then the transcript). Its run id.
|
|
1067
|
+
*/
|
|
1068
|
+
async startTurn(input: Omit<ChatTurnInput, "after">, options: StartOptions): Promise<string> {
|
|
1069
|
+
const runId = chatRunId(input.chat, input.message);
|
|
1070
|
+
// The latest other than this one, which a start told again finds first.
|
|
1071
|
+
const before = (await this.turns(input.chat, 2)).find((row) => row.workflowID !== runId);
|
|
1072
|
+
await this.start(CHAT_WORKFLOW, { ...input, ...(before && { after: before.workflowID }) } satisfies ChatTurnInput, {
|
|
1073
|
+
...options,
|
|
1074
|
+
runId,
|
|
1075
|
+
});
|
|
1076
|
+
// Once the new turn stands, which refused anyone but the chat's person.
|
|
1077
|
+
if (before && !isEnded(runStatus(before.status, []))) {
|
|
1078
|
+
await this.dbos.send(before.workflowID, { kind: "stop" } satisfies ChatTell, CHAT_TOPIC);
|
|
1084
1079
|
}
|
|
1085
|
-
|
|
1086
|
-
if (hosts.length) await this.apply({ only: hosts }, { startedBy: HOST_CHECK_AGENT, runId });
|
|
1080
|
+
return runId;
|
|
1087
1081
|
}
|
|
1088
1082
|
|
|
1089
|
-
/**
|
|
1090
|
-
|
|
1091
|
-
const
|
|
1092
|
-
|
|
1093
|
-
applicationName: this.config.appName,
|
|
1094
|
-
status: dbosStatusesOf("queued", "running"),
|
|
1095
|
-
loadInput: false,
|
|
1096
|
-
loadOutput: false,
|
|
1097
|
-
});
|
|
1098
|
-
return Promise.all(
|
|
1099
|
-
rows.map(async (r) => (await this.dbos.getEvent<ChangeState>(r.workflowID, CHANGE_EVENT, 0))?.phase),
|
|
1100
|
-
);
|
|
1083
|
+
/** The chat's latest turn (`startTurn`), or undefined when it has had none. */
|
|
1084
|
+
async turn(chat: string): Promise<RunSummary | undefined> {
|
|
1085
|
+
const [row] = await this.turns(chat, 1);
|
|
1086
|
+
return row && this.summarize(row);
|
|
1101
1087
|
}
|
|
1102
1088
|
|
|
1103
1089
|
/**
|
|
1104
|
-
*
|
|
1105
|
-
*
|
|
1090
|
+
* Hands `code`, which the person pasted on their own agent's sign-in card, to the sign-in waiting
|
|
1091
|
+
* in the turn under way of their chat `chat`, sealed (`sealChatCode`), so the run's messages never
|
|
1092
|
+
* show it. `run_not_found` when the chat has no turn under way of `by`'s.
|
|
1106
1093
|
*/
|
|
1107
|
-
|
|
1108
|
-
const
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
warn(`${branch} did not take the host's commits: ${moved.skipped}`);
|
|
1115
|
-
return false;
|
|
1094
|
+
async turnCode(chat: string, code: string, options: { by: Principal | string }): Promise<void> {
|
|
1095
|
+
const by = await this.admitted(
|
|
1096
|
+
parseOrThrow(PrincipalOrId, options?.by, '`by` must be a principal, such as "alice" or { id: "alice" }'),
|
|
1097
|
+
);
|
|
1098
|
+
const [row] = await this.turns(chat, 1);
|
|
1099
|
+
if (!row || isEnded(runStatus(row.status, [])) || row.authenticatedUser !== by.id) {
|
|
1100
|
+
throw new SanomaError("run_not_found", `No turn of chat ${chat} is under way`, { chat });
|
|
1116
1101
|
}
|
|
1117
|
-
|
|
1102
|
+
const told: ChatTell = { kind: "code", sealed: sealChatCode(row.workflowID, code) };
|
|
1103
|
+
await this.dbos.send(row.workflowID, told, CHAT_TOPIC);
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/**
|
|
1107
|
+
* How far the app `folder` (taken from a source) is behind its source, or why that cannot be
|
|
1108
|
+
* told, as the worker last checked (at its start and after the checkout follows `main`);
|
|
1109
|
+
* undefined when it has not told it.
|
|
1110
|
+
*/
|
|
1111
|
+
async appBehind(folder: string): Promise<AppBehind | undefined> {
|
|
1112
|
+
return readAppBehind(await this.database.db(), folder);
|
|
1118
1113
|
}
|
|
1119
1114
|
|
|
1120
|
-
/**
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1115
|
+
/** A sandbox run's checks against its scenario as the worker last ran them (when the run last waited or ended); undefined for none yet. */
|
|
1116
|
+
async runChecks(runId: string): Promise<RunChecks | undefined> {
|
|
1117
|
+
return readRunChecks(await this.database.db(), runId);
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/** The latest `limit` turns of the chat `chat`, newest first: its runs `chat:<chat>:<message>` (`chatRunPrefix`). */
|
|
1121
|
+
private async turns(chat: string, limit: number) {
|
|
1122
|
+
return this.dbos.listWorkflows({
|
|
1123
|
+
// DBOS matches the prefix with LIKE as given, where a chat id's `_` is any character: escaped.
|
|
1124
|
+
workflow_id_prefix: chatRunPrefix(chat).replaceAll(/[\\%_]/g, "\\$&"),
|
|
1125
|
+
workflowName: CHAT_WORKFLOW,
|
|
1126
|
+
applicationName: this.config.appName,
|
|
1127
|
+
sortDesc: true,
|
|
1128
|
+
limit,
|
|
1129
|
+
loadInput: false,
|
|
1130
|
+
loadOutput: false,
|
|
1127
1131
|
});
|
|
1128
1132
|
}
|
|
1129
1133
|
|
|
@@ -1687,6 +1691,47 @@ const alreadyDecided = (runId: string, a: ApprovalState) =>
|
|
|
1687
1691
|
},
|
|
1688
1692
|
);
|
|
1689
1693
|
|
|
1694
|
+
/**
|
|
1695
|
+
* The built-ins only one of the client's methods starts, by name: that method (`by`), and whether
|
|
1696
|
+
* a start is its own (`admits`), which `start` asks once the policy has (a visitor is denied
|
|
1697
|
+
* first), refusing any other: a change under the id `openChange` names; a follow as `host-check`
|
|
1698
|
+
* under its event's id; a chat turn under its chat's and message's id, after a turn of the same
|
|
1699
|
+
* chat, as the chat's person.
|
|
1700
|
+
*/
|
|
1701
|
+
const OWN_STARTS: Record<
|
|
1702
|
+
string,
|
|
1703
|
+
{
|
|
1704
|
+
by: string;
|
|
1705
|
+
admits(
|
|
1706
|
+
input: any,
|
|
1707
|
+
startedBy: Principal,
|
|
1708
|
+
runId: string | undefined,
|
|
1709
|
+
client: SanomaClient,
|
|
1710
|
+
): boolean | Promise<boolean>;
|
|
1711
|
+
}
|
|
1712
|
+
> = {
|
|
1713
|
+
[CHANGE_WORKFLOW]: { by: "openChange", admits: (input: ChangeInput, _by, runId) => isChangeRunOf(input.id, runId) },
|
|
1714
|
+
[FOLLOW_WORKFLOW]: {
|
|
1715
|
+
by: "follow",
|
|
1716
|
+
admits: (event: RemoteEvent, by, runId) => runId === followRunId(event) && by.id === HOST_CHECK_AGENT,
|
|
1717
|
+
},
|
|
1718
|
+
[CHAT_WORKFLOW]: {
|
|
1719
|
+
by: "startTurn",
|
|
1720
|
+
admits: async ({ chat, message, after }: ChatTurnInput, by, runId, client) =>
|
|
1721
|
+
runId === chatRunId(chat, message) &&
|
|
1722
|
+
(after === undefined ||
|
|
1723
|
+
(after.startsWith(chatRunPrefix(chat)) && DRAFT_ID.test(after.slice(chatRunPrefix(chat).length)))) &&
|
|
1724
|
+
(await client.chats.get(chat))?.person === by.id,
|
|
1725
|
+
},
|
|
1726
|
+
};
|
|
1727
|
+
|
|
1728
|
+
/**
|
|
1729
|
+
* A follow's run id (`SanomaClient.follow`): `follow:` and the SHA-256 of the event as its schema
|
|
1730
|
+
* parsed it, its keys then in the schema's order, so one event is one id however its JSON was laid out.
|
|
1731
|
+
*/
|
|
1732
|
+
const followRunId = (parsed: RemoteEvent) =>
|
|
1733
|
+
`follow:${createHash("sha256").update(JSON.stringify(parsed)).digest("hex")}`;
|
|
1734
|
+
|
|
1690
1735
|
/** A change's first run's id: `change:<draft id>`. */
|
|
1691
1736
|
const changeRunId = (id: string) => `change:${id}`;
|
|
1692
1737
|
|
package/src/config.ts
CHANGED
|
@@ -26,8 +26,14 @@ import { type ApplyDeclared, applyWorkflow } from "./apply.ts";
|
|
|
26
26
|
import { applySteps } from "./apply-steps.ts";
|
|
27
27
|
import { callWorkflow } from "./call-workflow.ts";
|
|
28
28
|
import { changeDriver, changeOps, reservedFor } from "./change.ts";
|
|
29
|
+
import { appRunner } from "./app-steps.ts";
|
|
30
|
+
import { appWorkflows } from "./app-workflows.ts";
|
|
29
31
|
import { changeSteps } from "./change-steps.ts";
|
|
30
32
|
import { changeWorkflow } from "./change-workflow.ts";
|
|
33
|
+
import { chatSteps } from "./chat-steps.ts";
|
|
34
|
+
import { chatWorkflow } from "./chat-workflow.ts";
|
|
35
|
+
import { followSteps } from "./follow-steps.ts";
|
|
36
|
+
import { followWorkflow } from "./follow-workflow.ts";
|
|
31
37
|
import {
|
|
32
38
|
type Collection,
|
|
33
39
|
collectionsConnector,
|
|
@@ -68,7 +74,9 @@ import {
|
|
|
68
74
|
BUILT_IN_WORKFLOWS,
|
|
69
75
|
CALL_WORKFLOW,
|
|
70
76
|
CHANGE_WORKFLOW,
|
|
77
|
+
CHAT_WORKFLOW,
|
|
71
78
|
DATA_VENDOR,
|
|
79
|
+
FOLLOW_WORKFLOW,
|
|
72
80
|
LOGIN_CODE_WORKFLOW,
|
|
73
81
|
} from "./shared.ts";
|
|
74
82
|
import { fakeSourceDriver, type RemoteHalf, SOURCE_RESOLVE, sourceDriver, sourceOps } from "./source.ts";
|
|
@@ -445,6 +453,12 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
|
|
|
445
453
|
.map((r) => r.id);
|
|
446
454
|
};
|
|
447
455
|
names.set(CHANGE_WORKFLOW, changeWorkflow(changeOps, changeSteps({ remotes, hosts })));
|
|
456
|
+
// Built-in, in every config: what a git host's webhook says happened, which the worker with the checkout follows.
|
|
457
|
+
names.set(FOLLOW_WORKFLOW, followWorkflow(followSteps({ remotes, hosts })));
|
|
458
|
+
// Built-in, in every config: the app's own actions on the repository, which a worker given the app's steps runs.
|
|
459
|
+
for (const wf of appWorkflows(appRunner)) names.set(wf.name, wf);
|
|
460
|
+
// Built-in, in every config: a chat turn, which a worker given the app's steps runs.
|
|
461
|
+
names.set(CHAT_WORKFLOW, chatWorkflow(chatSteps()));
|
|
448
462
|
const declaredObjects = () => readDeclaredObjects(root, connectors, declarable);
|
|
449
463
|
// Built-in: a direct call the policy holds for an approval, made a run, when any operation may be called directly.
|
|
450
464
|
const call = callWorkflow(ops, drivers, types);
|