diff --git a/AGENTS.md b/AGENTS.md index af9f5bf95..406313f1c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,6 +33,7 @@ Vite+ (`vp`) is the toolchain. Run from the repo root: - `vp run e2e`: Playwright end-to-end tests (`*.e2e.ts` in `e2e/`) - `vp run smoke:workerd`: boot core on plain workerd (on-prem readiness) - `vp run dev`: run the frontend (localhost:5173) with core and connect behind it (localhost:8787); `vp -C apps/console dev` for the console +- `vp run seed`: while `vp run dev` runs, fill it with a demo company (`scripts/seed/`): people in teams, engines with 30 days of runs, Knowledge and chats; sign in as admin@acme.test. It adds only what's missing; to start over, stop dev and delete `apps/core/.wrangler/state` - `vp run -r typegen`: regenerate `worker-configuration.d.ts` after changing a `wrangler.jsonc` - `vp run -r db:generate`: generate migrations after changing a schema diff --git a/package.json b/package.json index f0713767e..ef82b5f62 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "scripts": { "prepare": "vp config --no-agent", "dev": "node scripts/dev.ts", + "seed": "node scripts/seed.ts", "e2e": "playwright test", "smoke:workerd": "node scripts/workerd-smoke.ts", "smoke:code-mode": "node scripts/workerd-code-mode.ts", diff --git a/scripts/dev.ts b/scripts/dev.ts index 9a7751bb3..ee1347655 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -6,7 +6,8 @@ * compiler is built again when the kit or the compiler changes * (packages/compiler/watch.ts), into the assets core serves, so App screens * follow `@grasp-os/ui` as the frontend does. When one of them stops, or - * this script is stopped, all of them stop. + * this script is stopped, all of them stop. `vp run seed` (scripts/seed.ts) + * fills the stack with a demo company. */ import { spawn } from "node:child_process"; import type { ChildProcess } from "node:child_process"; diff --git a/scripts/seed.ts b/scripts/seed.ts new file mode 100644 index 000000000..61c9b47a4 --- /dev/null +++ b/scripts/seed.ts @@ -0,0 +1,443 @@ +/** + * `vp run seed`: fills the local stack `vp run dev` runs with a demo + * company (scripts/seed/company.ts), so every page has something to show: + * people in teams and roles, engines with released workflows and 30 days + * of their runs, Knowledge, and chats. Run it while `vp run dev` runs. + * + * Everything goes through core's API as the person who would do it, signed + * in through the local fake IdP; only what no API does is written into the + * local state directly: people's full names and when they joined, the + * runs' times (scripts/seed/runs.ts), and chat transcripts + * (scripts/seed/chats.ts). + * + * It only ever reaches localhost, and stops unless core there signs people + * in through the fake IdP, which only a local stack does + * (scripts/seed/local.ts). It can run again: it adds what is missing and + * leaves the rest, and gives an engine runs only while it has none. To + * start over, stop `vp run dev`, delete `apps/core/.wrangler/state`, and + * start it again. + */ +import { appErrors } from "@grasp-os/shared/apps"; +import { knowledgeErrors } from "@grasp-os/shared/knowledge"; +import type { PermissionRequest } from "@grasp-os/shared/permissions"; +import { z } from "zod"; + +import { chats, writeTranscript } from "./seed/chats.ts"; +import { people, teamNames } from "./seed/company.ts"; +import type { Person, PersonKey, TeamName } from "./seed/company.ts"; +import { engines } from "./seed/engines.ts"; +import type { EngineSeed } from "./seed/engines.ts"; +import { collections } from "./seed/knowledge.ts"; +import type { CollectionSeed } from "./seed/knowledge.ts"; +import { + authCall, + localD1, + localObject, + sessionOf, + signInAs, +} from "./seed/local.ts"; +import type { Session } from "./seed/local.ts"; +import { seedRuns } from "./seed/runs.ts"; + +const dayMs = 24 * 60 * 60 * 1000; + +/** Sign-ins at once: more keep the local core too busy (e2e/people.ts). */ +const signInsAtOnce = 4; + +const [admin] = people; + +const core = localD1("workflow_runs"); + +/** Runs `work` for each item, `limit` at a time, keeping their order. */ +const mapLimited = async ( + items: readonly T[], + limit: number, + work: (item: T) => Promise +): Promise => { + const results: R[] = []; + for (let index = 0; index < items.length; index += limit) { + results.push( + // oxlint-disable-next-line no-await-in-loop -- one batch at a time + ...(await Promise.all(items.slice(index, index + limit).map(work))) + ); + } + return results; +}; + +/** A team, as Better Auth's organization plugin returns it. */ +const teamSchema = z.object({ id: z.string(), name: z.string() }); + +/** + * The teams, each with its people (their user IDs by email), created + * through Better Auth as an admin does. + */ +const seedTeams = async ( + adminSession: Session, + userIds: ReadonlyMap +): Promise> => { + const existing = z + .array(teamSchema) + .parse(await authCall(adminSession, "/organization/list-teams")); + const ids = new Map(); + for (const name of teamNames) { + const found = existing.find((team) => team.name === name); + const team = + found ?? + teamSchema.parse( + // oxlint-disable-next-line no-await-in-loop -- one team at a time + await authCall(adminSession, "/organization/create-team", { name }) + ); + ids.set(name, team.id); + } + const inTeam = core.prepare( + "SELECT 1 FROM team_members WHERE team_id = ? AND user_id = ?" + ); + for (const person of people satisfies readonly Person[]) { + const userId = userIds.get(person.email) ?? ""; + for (const name of person.teams) { + const teamId = ids.get(name); + if (teamId !== undefined && inTeam.get(teamId, userId) === undefined) { + // oxlint-disable-next-line no-await-in-loop -- one change at a time + await authCall(adminSession, "/organization/add-team-member", { + teamId, + userId, + }); + } + } + } + return ids; +}; + +/** + * Signs everyone in, which makes them members, then gives them their + * names, roles, join dates and teams. Sessions open once all of that is + * set, so each acts as who they are now. + */ +const seedPeople = async (): Promise<{ + sessions: Map; + teams: Map; +}> => { + // The admin first, the stack's configured admin anyway: signing in + // refuses anything but a local stack, and nobody else signs in until + // the stack is known to be this checkout's. + const adminSession = await sessionOf(admin.email); + let teams: Map; + try { + // The state this checkout's `vp run dev` keeps is the one that answers. + if ( + core + .prepare("SELECT 1 FROM users WHERE id = ?") + .get(adminSession.userId) === undefined + ) { + throw new Error( + "The core answering at localhost doesn't keep its state in this checkout: run the seed from the checkout `vp run dev` runs from." + ); + } + await mapLimited(people, signInsAtOnce, async ({ email }) => { + await signInAs(email); + }); + const members = await adminSession.api.members.list(); + for (const person of people satisfies readonly Person[]) { + const member = members.find(({ email }) => email === person.email); + if (member === undefined) { + throw new Error(`${person.email} signed in but isn't a member`); + } + if (member.role !== person.role) { + // oxlint-disable-next-line no-await-in-loop -- one change at a time + await adminSession.api.members.setRole(member.userId, person.role); + } + // The fake IdP names people by their email's first word, and they + // all joined just now. + core + .prepare("UPDATE users SET name = ? WHERE id = ?") + .run(person.name, member.userId); + core + .prepare("UPDATE members SET created_at = ? WHERE user_id = ?") + .run(Date.now() - person.joinedDaysAgo * dayMs, member.userId); + } + teams = await seedTeams( + adminSession, + new Map(members.map(({ email, userId }) => [email, userId])) + ); + } finally { + adminSession.close(); + } + const sessions = await mapLimited( + people, + signInsAtOnce, + async (person) => [person.key, await sessionOf(person.email)] as const + ); + return { sessions: new Map(sessions), teams }; +}; + +const sessionFor = ( + sessions: ReadonlyMap, + key: PersonKey +): Session => { + const session = sessions.get(key); + if (session === undefined) { + throw new Error(`Nobody signed in as ${key}`); + } + return session; +}; + +/** A collection, created by an admin, and its documents, written by its writer. */ +const seedCollection = async ( + seed: CollectionSeed, + sessions: ReadonlyMap, + teams: ReadonlyMap +): Promise => { + const creator = sessionFor(sessions, seed.createdBy).api.knowledge; + const { knowledge } = sessionFor(sessions, seed.writer).api; + // Its writer reads it, whoever created it. + const listed = await knowledge.listCollections(); + const collection = + listed.find(({ name }) => name === seed.name) ?? + (await creator.createCollection({ + name: seed.name, + description: seed.description, + ...(seed.teams.length === 0 + ? { access: "everyone" } + : { + access: "teams", + teams: seed.teams.flatMap((team) => teams.get(team) ?? []), + }), + sensitive: seed.sensitive ?? false, + })); + for (const document of seed.documents) { + try { + // oxlint-disable-next-line no-await-in-loop -- one save at a time + await knowledge.saveDocument({ + collectionId: collection.id, + path: document.path, + text: document.text, + ifVersion: 0, + }); + } catch (error) { + // It's there already: seeded before. + if (knowledgeErrors.codeOf(error) === "knowledge.conflict") { + continue; + } + throw error; + } + if (document.update !== undefined) { + // oxlint-disable-next-line no-await-in-loop -- one save at a time + await knowledge.saveDocument({ + collectionId: collection.id, + path: document.path, + text: document.update.text, + ifVersion: 1, + message: document.update.message, + }); + } + } + return collection.id; +}; + +/** + * An engine: created by its owner, its version made current by an admin, + * shared with its teams, its deciders set, and, while it has none, its + * runs. Returns its ID, and how many runs it got. + */ +const seedEngine = async ( + seed: EngineSeed, + sessions: ReadonlyMap, + teams: ReadonlyMap +): Promise<{ id: string; runs: number }> => { + const adminSession = sessionFor(sessions, admin.key); + const owner = sessionFor(sessions, seed.owner); + const all = await adminSession.api.apps.list(); + const app = + all.find(({ name }) => name === seed.name) ?? + (await owner.api.apps.create({ + name: seed.name, + description: seed.description, + })); + if (app.currentVersion === null) { + const files: Record = { "AGENTS.md": seed.agents }; + for (const { id, code, tests } of seed.workflows) { + files[`workflows/${id}.ts`] = code; + files[`workflows/${id}.workflow-tests.ts`] = tests; + } + const { version } = await owner.api.apps.files.commit( + app.id, + files, + "Release the engine's workflows" + ); + // An admin makes it current, which approves its code. + await adminSession.api.apps.versions.setCurrent(app.id, version); + } + const shared = await owner.api.apps.members.list(app.id); + for (const { team, role } of seed.teams) { + const id = teams.get(team); + if ( + id !== undefined && + !shared.some((member) => member.type === "team" && member.id === id) + ) { + // oxlint-disable-next-line no-await-in-loop -- one change at a time + await owner.api.apps.members.add(app.id, { type: "team", id, role }); + } + } + for (const { id: workflow, deciders = {} } of seed.workflows) { + for (const [param, team] of Object.entries(deciders)) { + const teamId = teams.get(team); + if (teamId !== undefined) { + // oxlint-disable-next-line no-await-in-loop -- one change at a time + await owner.api.workflows.params.set( + app.id, + workflow, + param, + `team:${teamId}` + ); + } + } + } + const ran = await owner.api.workflows.list(app.id); + if (ran.length > 0) { + return { id: app.id, runs: 0 }; + } + const runs = await seedRuns({ + app: app.id, + engine: seed.name, + workflows: seed.workflows, + sessions, + db: core, + }); + return { id: app.id, runs }; +}; + +/** + * Engines' access to Knowledge: one an admin granted, and one still + * waiting for an admin, so the dashboard has a request to show. + */ +const seedPermissions = async ( + sessions: ReadonlyMap, + engineIds: ReadonlyMap, + collectionIds: ReadonlyMap +): Promise => { + const adminSession = sessionFor(sessions, admin.key); + const wanted = [ + { + engine: "Service desk", + by: "lisa", + collection: "Procedures", + binding: "PROCEDURES", + grant: true, + }, + { + engine: "Finance", + by: "eva", + collection: "Finance", + binding: "HANDBOOK", + grant: false, + }, + ] as const; + for (const { engine, by, collection, binding, grant } of wanted) { + const appId = engineIds.get(engine); + const collectionId = collectionIds.get(collection); + if (appId === undefined || collectionId === undefined) { + continue; + } + // oxlint-disable-next-line no-await-in-loop -- one engine at a time + const held = await adminSession.api.permissions.list({ + type: "app", + appId, + }); + if (held.some(({ binding: name }) => name === binding)) { + continue; + } + const request: PermissionRequest = { + subject: { type: "app", appId }, + object: { type: "collection", collectionId }, + actions: ["read"], + binding, + }; + // oxlint-disable-next-line no-await-in-loop -- one request at a time + const { id } = await sessionFor(sessions, by).api.permissions.request( + request + ); + if (grant) { + // oxlint-disable-next-line no-await-in-loop -- one grant at a time + const { currentVersion } = await adminSession.api.apps.get(appId); + // oxlint-disable-next-line no-await-in-loop -- one grant at a time + await adminSession.api.permissions.grant(id, { version: currentVersion }); + } + } +}; + +/** The chats, each in its person's Workspace object. */ +const seedChats = async ( + sessions: ReadonlyMap +): Promise => { + let created = 0; + const now = Date.now(); + for (const chat of chats) { + const { api } = sessionFor(sessions, chat.by); + // oxlint-disable-next-line no-await-in-loop -- one chat at a time + const listed = await api.chats.list(); + if (listed.some(({ title }) => title === chat.title)) { + continue; + } + // oxlint-disable-next-line no-await-in-loop -- one chat at a time + const { id } = await api.chats.create(chat.title); + const workspace = localObject("Workspace", "chats", id); + try { + writeTranscript(workspace, id, chat, now); + } finally { + workspace.close(); + } + created += 1; + } + return created; +}; + +const main = async (): Promise => { + console.info("Signing the demo company in…"); + const { sessions, teams } = await seedPeople(); + try { + console.info(`${people.length} people in ${teams.size} teams`); + + const collectionIds = new Map(); + for (const seed of collections) { + // oxlint-disable-next-line no-await-in-loop -- one collection at a time + const id = await seedCollection(seed, sessions, teams); + collectionIds.set(seed.name, id); + } + console.info(`${collections.length} Knowledge collections`); + + const engineIds = new Map(); + for (const seed of engines) { + // oxlint-disable-next-line no-await-in-loop -- one engine at a time + const { id, runs } = await seedEngine(seed, sessions, teams); + engineIds.set(seed.name, id); + console.info( + runs === 0 + ? `Engine ${seed.name}: has its runs already` + : `Engine ${seed.name}: ${runs} runs over the last 30 days` + ); + } + await seedPermissions(sessions, engineIds, collectionIds); + + const created = await seedChats(sessions); + console.info(`${created} new chats`); + console.info( + `Done. Sign in at http://localhost:5173 as ${admin.email} (${admin.name}), or as anyone in scripts/seed/company.ts.` + ); + } finally { + for (const session of sessions.values()) { + session.close(); + } + core.close(); + } +}; + +try { + await main(); +} catch (error) { + // An App or Knowledge error says what core refused, in its own words. + const coded = appErrors.codeOf(error) ?? knowledgeErrors.codeOf(error); + console.error( + `The seed stopped${coded === undefined ? "" : ` (${coded})`}: ${error instanceof Error ? error.message : String(error)}` + ); + process.exitCode = 1; +} diff --git a/scripts/seed/chats.ts b/scripts/seed/chats.ts new file mode 100644 index 000000000..7c2d57eaa --- /dev/null +++ b/scripts/seed/chats.ts @@ -0,0 +1,167 @@ +/** + * A few chats with the organization's agent. A question sent through the + * API is answered by a model, which the local stack has none of, so the + * seed creates each chat through the API and writes its transcript into + * the person's Workspace object, as core stores one: pi's messages, the + * answers marked as no model's (as core marks its own, + * `interruptedMessage` in apps/core/src/workspace.ts). + */ +import type { DatabaseSync } from "node:sqlite"; + +import type { PersonKey } from "./company.ts"; + +export interface ChatSeed { + by: PersonKey; + title: string; + /** How long ago it started, in hours. */ + hoursAgo: number; + /** Questions and answers, in turn. */ + turns: { question: string; answer: string }[]; +} + +export const chats: ChatSeed[] = [ + { + by: "eva", + title: "Invoices waiting longest", + hoursAgo: 26, + turns: [ + { + question: + "Which supplier invoices have waited longest for an approval?", + answer: + "Three invoices above €2,500 are waiting for finance to approve them in Supplier invoices. The oldest is from Kantoorhuis Utrecht (office rent), waiting since yesterday morning. You can answer each one from its run in Workflows.", + }, + { + question: "And how many failed this month?", + answer: + "Three runs of Supplier invoices failed in the last 30 days, each because the invoice had no VAT number. The coding rules in Finance say to send those back to the supplier.", + }, + ], + }, + { + by: "lisa", + title: "Complaint deadlines", + hoursAgo: 50, + turns: [ + { + question: "How long do we have to acknowledge a complaint?", + answer: + "Two working days, in writing, according to the complaints procedure in Procedures. Complaint acknowledgement sets that deadline when it registers a complaint, and asks compliance before offering compensation above €250.", + }, + ], + }, + { + by: "nina", + title: "Files without a payslip", + hoursAgo: 75, + turns: [ + { + question: + "How many applications came in without a payslip in the last two weeks?", + answer: + "Of the applications Application intake checked in the last two weeks, about two in five still had documents to chase, most often the payslip or the employer statement. Required documents in Procedures says to chase them by email the same day.", + }, + { + question: "Draft a reminder I can send for a missing payslip.", + answer: + "Dear client,\n\nThank you for your application. To open your file we still need your most recent payslip, no older than three months. You can reply to this email with a photo or a PDF.\n\nKind regards,\nClient onboarding", + }, + ], + }, + { + by: "sanne", + title: "This week for the board", + hoursAgo: 4, + turns: [ + { + question: "What should I tell the board about this week?", + answer: + "Runs held up well: most completed, a handful failed on bad input (empty emails, invoices without a VAT number, applications without an identity document), and a few wait on decisions, the oldest a file handover to mortgage advice. Compliance cleared every politically exposed client this month after enhanced due diligence.", + }, + ], + }, + { + by: "mark", + title: "Lender requirements", + hoursAgo: 98, + turns: [ + { + question: + "Which lender wants a valuation report no older than six months?", + answer: + "Lender A, according to Lender requirements in Client files. The page is past its review date, so it's worth checking with the lender before you rely on it.", + }, + ], + }, +]; + +/** Core's own mark on an answer no model wrote: it names none, and cost nothing. */ +const noModel = { + api: "grasp", + provider: "grasp", + model: "grasp", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", +} as const; + +/** How long the agent takes to answer, in the transcript. */ +const answerMs = 40_000; +/** How long the person takes to ask again. */ +const nextQuestionMs = 6 * 60_000; + +/** + * Writes the chat's transcript into `db`, its person's Workspace object, + * and moves the chat back to when it started. + */ +export const writeTranscript = ( + db: DatabaseSync, + chatId: string, + chat: ChatSeed, + now: number +): void => { + const start = now - chat.hoursAgo * 3_600_000; + const insert = db.prepare( + "INSERT INTO chat_messages (chat_id, message, created_at) VALUES (?, ?, ?)" + ); + db.exec("BEGIN IMMEDIATE"); + try { + db.prepare("UPDATE chats SET created_at = ? WHERE id = ?").run( + start, + chatId + ); + for (const [index, { question, answer }] of chat.turns.entries()) { + const asked = start + index * nextQuestionMs; + const answered = asked + answerMs; + insert.run( + chatId, + JSON.stringify({ + role: "user", + content: [{ type: "text", text: question }], + timestamp: asked, + }), + asked + ); + insert.run( + chatId, + JSON.stringify({ + role: "assistant", + content: [{ type: "text", text: answer }], + ...noModel, + timestamp: answered, + }), + answered + ); + } + db.exec("COMMIT"); + } catch (error) { + db.exec("ROLLBACK"); + throw error; + } +}; diff --git a/scripts/seed/company.ts b/scripts/seed/company.ts new file mode 100644 index 000000000..4d6fdc468 --- /dev/null +++ b/scripts/seed/company.ts @@ -0,0 +1,137 @@ +/** + * The demo company the seed fills the local stack with, after the + * prototype's Noordkade Finance (grasplabs/prototype, + * `packages/grasp/src/data/noordkade.ts`): a mortgage and lending adviser + * with five teams. Everyone is at acme.test, the domain the local stack + * signs people in from (apps/core/test/sign-in-config.ts), and every name + * is made up. + */ +import type { Role } from "@grasp-os/shared/roles"; + +import { localAdmin } from "../../apps/core/test/sign-in-config.ts"; + +export const teamNames = [ + "Client onboarding", + "Mortgage advice", + "Customer service", + "Finance", + "Compliance", +] as const; + +export type TeamName = (typeof teamNames)[number]; + +export interface Person { + /** Who they are in the seed's own data: what runs and chats name. */ + key: string; + email: string; + name: string; + role: Role; + teams: TeamName[]; + /** How long ago they joined, in days: their first sign-in, backdated. */ + joinedDaysAgo: number; +} + +/** + * Who works there. The configured admin of the local stack, whom a + * developer signs in as, is the COO. + */ +export const people = [ + { + key: "sanne", + email: localAdmin, + name: "Sanne de Wit", + role: "admin", + teams: [], + joinedDaysAgo: 58, + }, + { + key: "pieter", + email: "pieter.deboer@acme.test", + name: "Pieter de Boer", + role: "admin", + teams: ["Compliance"], + joinedDaysAgo: 57, + }, + { + key: "eva", + email: "eva.bakker@acme.test", + name: "Eva Bakker", + role: "builder", + teams: ["Finance"], + joinedDaysAgo: 52, + }, + { + key: "jeroen", + email: "jeroen.mulder@acme.test", + name: "Jeroen Mulder", + role: "user", + teams: ["Finance"], + joinedDaysAgo: 44, + }, + { + key: "nina", + email: "nina.visser@acme.test", + name: "Nina Visser", + role: "builder", + teams: ["Client onboarding"], + joinedDaysAgo: 51, + }, + { + key: "tom", + email: "tom.hendriks@acme.test", + name: "Tom Hendriks", + role: "user", + teams: ["Client onboarding"], + joinedDaysAgo: 40, + }, + { + key: "mark", + email: "mark.jansen@acme.test", + name: "Mark Jansen", + role: "builder", + teams: ["Mortgage advice"], + joinedDaysAgo: 49, + }, + { + key: "fatima", + email: "fatima.elamrani@acme.test", + name: "Fatima El Amrani", + role: "user", + teams: ["Mortgage advice"], + joinedDaysAgo: 38, + }, + { + key: "lisa", + email: "lisa.devries@acme.test", + name: "Lisa de Vries", + role: "builder", + teams: ["Customer service"], + joinedDaysAgo: 47, + }, + { + key: "bram", + email: "bram.peeters@acme.test", + name: "Bram Peeters", + role: "user", + teams: ["Customer service"], + joinedDaysAgo: 33, + }, + { + key: "joost", + email: "joost.smit@acme.test", + name: "Joost Smit", + role: "user", + teams: ["Compliance"], + joinedDaysAgo: 45, + }, + { + key: "anouk", + email: "anouk.vermeer@acme.test", + name: "Anouk Vermeer", + role: "user", + teams: ["Compliance", "Customer service"], + joinedDaysAgo: 21, + }, +] as const satisfies Person[]; + +export type PersonKey = (typeof people)[number]["key"]; diff --git a/scripts/seed/engines.ts b/scripts/seed/engines.ts new file mode 100644 index 000000000..079d3ad7e --- /dev/null +++ b/scripts/seed/engines.ts @@ -0,0 +1,900 @@ +/** + * The demo company's engines (core's Apps), each with workflows that run + * on the local stack as they would anywhere: real code against + * `@grasp-os/sdk`, with tests, that calls nothing outside Grasp, so a run + * needs no connection. Inputs stand in for what a connection would bring + * (an email, an invoice from the bookkeeping), so the seed starts each + * run with one, and a few are bad on purpose, so some runs fail as real + * ones do. + */ +import type { PersonKey, TeamName } from "./company.ts"; + +/** Every value an input holds: what a run is started with. */ +export type Input = Record; + +/** How a run that waits on a decision is left: answered, or still open. */ +export type Answer = + | { by: PersonKey; approved: boolean; comment?: string } + | "open"; + +/** One run the seed starts, before it moves it back in time. */ +export interface RunPlan { + by: PersonKey; + input: Input; + /** For a run that asks someone to decide. */ + answer?: Answer; +} + +/** Draws from the seed's numbers, the same ones each time it runs. */ +export interface Draw { + /** A number from 0 up to 1. */ + next: () => number; + /** One of `items`. */ + pick: (items: readonly T[]) => T; + /** A whole number from `min` to `max`. */ + between: (min: number, max: number) => number; +} + +export interface WorkflowSeed { + id: string; + /** The workflow's code and its tests. */ + code: string; + tests: string; + /** Person parameters, set to a team: who decides. */ + deciders?: Record; + /** + * Its schedule started its runs, each on a Monday morning, rather than + * the people who start the others. + */ + scheduled?: boolean; + /** + * The input field the day each run starts goes in (YYYY-MM-DD), for a + * workflow whose result depends on it: runs are started today and moved + * back, so it can't read the day from the clock. + */ + dateInput?: string; + /** The runs of the last 30 days, oldest first. */ + runs: (draw: Draw) => RunPlan[]; +} + +export interface EngineSeed { + name: string; + description: string; + owner: PersonKey; + /** The teams it is shared with, and their role in it. */ + teams: { team: TeamName; role: "user" | "builder" }[]; + agents: string; + workflows: WorkflowSeed[]; +} + +/** Euros in cents, as money parameters and amounts hold them. */ +const euros = (amount: number): number => Math.round(amount * 100); + +// Client onboarding + +const clients = [ + "Fam. Willems", + "D. Mulder", + "Fam. van Leeuwen", + "K. Jacobs", + "S. de Jong", + "Fam. Bakker", + "T. Kok", + "R. Bos", + "L. Smit", + "P. Meijer", + "A. Visser", + "H. Dekker", + "Fam. Peeters", + "J. de Graaf", + "M. Hendriks", +] as const; + +const towns = [ + "Utrecht", + "Zeist", + "Houten", + "Bunnik", + "Nieuwegein", + "De Bilt", +] as const; + +const requiredDocuments = [ + "identity", + "payslip", + "employer statement", + "bank statements", +] as const; + +const applicationIntake = `import { workflow, z } from "@grasp-os/sdk/workflow"; + +/** What every application needs before it becomes a client file. */ +const required = ${JSON.stringify(requiredDocuments)}; + +export default workflow( + "application-intake", + { + params: {}, + input: z.object({ + client: z.string(), + town: z.string(), + documents: z.array(z.string()), + }), + }, + async (step, { input }) => { + const missing = await step.do( + "check-documents", + { description: "Check which required documents the application lacks", locked: true }, + async () => { + if (input.client.trim() === "") { + throw new Error("The application has no client name, so no file can be opened for it."); + } + return required.filter((document) => !input.documents.includes(document)); + } + ); + return await step.do( + "open-file", + { description: "Open the client file with what is still to chase", locked: true }, + async () => ({ + client: input.client, + town: input.town, + complete: missing.length === 0, + chase: missing, + }) + ); + } +); +`; + +const applicationIntakeTests = `import { workflowTests } from "@grasp-os/sdk/testing"; + +import intake from "./application-intake.ts"; + +export default workflowTests(intake, [ + { + name: "opens a complete file when nothing is missing", + input: { client: "Fam. Willems", town: "Utrecht", documents: ${JSON.stringify(requiredDocuments)} }, + expect: { output: { client: "Fam. Willems", town: "Utrecht", complete: true, chase: [] } }, + }, + { + name: "lists the documents to chase", + input: { client: "D. Mulder", town: "Zeist", documents: ["identity"] }, + expect: { + output: { + client: "D. Mulder", + town: "Zeist", + complete: false, + chase: ["payslip", "employer statement", "bank statements"], + }, + }, + }, + { + name: "refuses an application without a client", + input: { client: " ", town: "Zeist", documents: [] }, + expect: { error: "no client name" }, + }, +]); +`; + +const fileHandover = `import { person, workflow, z } from "@grasp-os/sdk/workflow"; + +export default workflow( + "file-handover", + { + params: { + advisers: person({ label: "Who takes files over", default: "role:admin" }), + }, + input: z.object({ client: z.string(), loan: z.number(), chase: z.array(z.string()) }), + }, + async (step, { input, params }) => { + const summary = await step.do( + "summarise", + { description: "Summarise the file for the adviser", locked: true }, + async () => { + if (input.chase.length > 0) { + throw new Error(\`The file of \${input.client} still lacks: \${input.chase.join(", ")}.\`); + } + return { client: input.client, loan: input.loan }; + } + ); + const decision = await step.decision("accept", { + description: \`Take over the file of \${input.client}\`, + from: params.advisers, + // Advisers see what waits for them on their dashboard. + ask: async () => {}, + timeout: "5 days", + }); + if (decision.timedOut || !decision.approved) { + return { ...summary, handedOver: false }; + } + return { ...summary, handedOver: true, adviser: decision.by }; + } +); +`; + +const fileHandoverTests = `import { workflowTests } from "@grasp-os/sdk/testing"; + +import handover from "./file-handover.ts"; + +export default workflowTests(handover, [ + { + name: "hands a complete file over once an adviser takes it", + input: { client: "Fam. Bakker", loan: 41500000, chase: [] }, + decisions: { accept: { approved: true, by: "mark" } }, + expect: { output: { client: "Fam. Bakker", loan: 41500000, handedOver: true, adviser: "mark" } }, + }, + { + name: "keeps the file when no adviser takes it", + input: { client: "T. Kok", loan: 28000000, chase: [] }, + decisions: { accept: { approved: false, by: "mark" } }, + expect: { output: { client: "T. Kok", loan: 28000000, handedOver: false } }, + }, + { + name: "refuses a file that still lacks documents", + input: { client: "R. Bos", loan: 30000000, chase: ["payslip"] }, + expect: { error: "still lacks: payslip" }, + }, +]); +`; + +const onboarding: EngineSeed = { + name: "Client onboarding", + description: + "Turns an application into a complete client file and hands it to mortgage advice.", + owner: "nina", + teams: [ + { team: "Client onboarding", role: "builder" }, + { team: "Mortgage advice", role: "user" }, + ], + agents: `# Client onboarding + +Turns a mortgage application into a complete client file, then hands the file to an adviser. + +- \`application-intake\` checks the application for the four documents every file needs (identity, payslip, employer statement, bank statements) and opens the file with what is still to chase. +- \`file-handover\` hands a complete file to mortgage advice: an adviser takes it over, or it stays with onboarding. +`, + workflows: [ + { + id: "application-intake", + code: applicationIntake, + tests: applicationIntakeTests, + runs: (draw) => + Array.from({ length: 34 }, (_, index) => { + const missing = draw.next() < 0.45; + const documents = missing + ? requiredDocuments.filter(() => draw.next() < 0.6) + : [...requiredDocuments]; + // Now and then an application comes in from the web form empty. + const client = index % 13 === 7 ? "" : draw.pick(clients); + return { + by: draw.pick(["nina", "tom", "tom"] as const), + input: { client, town: draw.pick(towns), documents }, + }; + }), + }, + { + id: "file-handover", + code: fileHandover, + tests: fileHandoverTests, + deciders: { advisers: "Mortgage advice" }, + runs: (draw) => + Array.from({ length: 16 }, (_, index) => { + const lacking = index % 9 === 4; + return { + by: draw.pick(["nina", "tom"] as const), + input: { + client: draw.pick(clients), + loan: euros(draw.between(180, 520) * 1000), + chase: lacking ? ["employer statement"] : [], + }, + ...(lacking + ? {} + : { + answer: + index >= 13 + ? "open" + : { + by: draw.pick(["mark", "fatima"] as const), + approved: index % 6 !== 2, + ...(index % 6 === 2 + ? { + comment: + "The valuation report is older than six months.", + } + : {}), + }, + }), + }; + }), + }, + ], +}; + +// Service desk + +const subjects = [ + { + subject: "Where is my file?", + body: "I sent my payslip last week. Has my file gone to the lender yet?", + }, + { + subject: "Change of the monthly payment", + body: "We want to pay off more each month from January. What do we need to send?", + }, + { + subject: "Copy of the offer", + body: "Could you send me the offer again? I can't find the attachment.", + }, + { + subject: "Complaint about the valuation", + body: "I'm not happy with how the valuation went and I want to file a complaint.", + }, + { + subject: "New bank account", + body: "My bank account number changed. Please use the new one for the payments.", + }, + { + subject: "Early repayment", + body: "We sold our old house. How much would an early repayment cost us?", + }, + { + subject: "Documents for the valuation", + body: "Which documents does the valuer need from us before Thursday?", + }, +] as const; + +const emailTriage = `import { workflow, z } from "@grasp-os/sdk/workflow"; + +/** Where each kind of question goes, by words that give it away. */ +const queues = [ + { queue: "complaints", words: ["complaint", "not happy", "unacceptable"] }, + { queue: "payments", words: ["payment", "bank account", "repayment", "pay off"] }, + { queue: "documents", words: ["document", "payslip", "offer", "attachment"] }, + { queue: "file status", words: ["file", "lender", "status"] }, +]; + +export default workflow( + "email-triage", + { + params: {}, + input: z.object({ from: z.string(), subject: z.string(), body: z.string() }), + }, + async (step, { input }) => + await step.do( + "sort", + { description: "Put the email in the queue that answers it", locked: true }, + async () => { + const text = \`\${input.subject} \${input.body}\`.toLowerCase(); + if (input.body.trim() === "") { + throw new Error(\`The email from \${input.from} has no text to sort.\`); + } + const found = queues.find(({ words }) => words.some((word) => text.includes(word))); + return { from: input.from, queue: found?.queue ?? "general", urgent: found?.queue === "complaints" }; + } + ) +); +`; + +const emailTriageTests = `import { workflowTests } from "@grasp-os/sdk/testing"; + +import triage from "./email-triage.ts"; + +export default workflowTests(triage, [ + { + name: "sends a complaint to the complaints queue, urgently", + input: { from: "M. Hendriks", subject: "Complaint", body: "I'm not happy with the valuation." }, + expect: { output: { from: "M. Hendriks", queue: "complaints", urgent: true } }, + }, + { + name: "sends a question about a file to file status", + input: { from: "S. Peeters", subject: "Where is my file?", body: "Has it gone to the lender?" }, + expect: { output: { from: "S. Peeters", queue: "file status", urgent: false } }, + }, + { + name: "refuses an empty email", + input: { from: "R. Bos", subject: "Question", body: "" }, + expect: { error: "no text to sort" }, + }, +]); +`; + +const complaintAcknowledgement = `import { money, person, workflow, z } from "@grasp-os/sdk/workflow"; + +export default workflow( + "complaint-acknowledgement", + { + params: { + limit: money({ label: "Compliance approves compensation above", currency: "EUR", default: 25000 }), + compliance: person({ label: "Who approves compensation", default: "role:admin" }), + }, + input: z.object({ + client: z.string(), + about: z.string(), + compensation: z.number(), + /** The day it came in, as YYYY-MM-DD. */ + received: z.string(), + }), + }, + async (step, { input, params }) => { + const registered = await step.do( + "register", + { description: "Register the complaint and set the acknowledgement deadline", locked: true }, + async () => { + // Two working days after the day it came in. + const due = new Date(\`\${input.received}T00:00:00Z\`); + let workingDays = 0; + while (workingDays < 2) { + due.setUTCDate(due.getUTCDate() + 1); + const day = due.getUTCDay(); + if (day !== 0 && day !== 6) { + workingDays += 1; + } + } + return { client: input.client, about: input.about, acknowledgeBy: due.toISOString().slice(0, 10) }; + } + ); + if (input.compensation <= params.limit) { + return { ...registered, compensation: input.compensation, approvedBy: null }; + } + const decision = await step.decision("approve-compensation", { + description: \`Approve compensation for \${input.client}\`, + from: params.compliance, + ask: async () => {}, + timeout: "3 days", + }); + if (decision.timedOut || !decision.approved) { + return { ...registered, compensation: 0, approvedBy: null }; + } + return { ...registered, compensation: input.compensation, approvedBy: decision.by }; + } +); +`; + +const complaintAcknowledgementTests = `import { workflowTests } from "@grasp-os/sdk/testing"; + +import complaint from "./complaint-acknowledgement.ts"; + +export default workflowTests(complaint, [ + { + name: "offers small compensation without asking, and acknowledges within two working days", + input: { client: "K. Mulder", about: "Waiting on the phone", compensation: 2500, received: "2026-10-05" }, + expect: { + output: { client: "K. Mulder", about: "Waiting on the phone", acknowledgeBy: "2026-10-07", compensation: 2500, approvedBy: null }, + }, + }, + { + name: "acknowledges a complaint from a Friday by Tuesday", + input: { client: "R. Bos", about: "A late answer", compensation: 0, received: "2026-10-09" }, + expect: { + output: { client: "R. Bos", about: "A late answer", acknowledgeBy: "2026-10-13", compensation: 0, approvedBy: null }, + }, + }, + { + name: "asks compliance for larger compensation", + input: { client: "M. Hendriks", about: "The valuation", compensation: 75000, received: "2026-10-05" }, + decisions: { "approve-compensation": { approved: true, by: "joost" } }, + expect: { + output: { client: "M. Hendriks", about: "The valuation", acknowledgeBy: "2026-10-07", compensation: 75000, approvedBy: "joost" }, + }, + }, +]); +`; + +const serviceDesk: EngineSeed = { + name: "Service desk", + description: + "Sorts the shared inbox and handles complaints within the two working days the procedure promises.", + owner: "lisa", + teams: [ + { team: "Customer service", role: "builder" }, + { team: "Compliance", role: "user" }, + ], + agents: `# Service desk + +Every question and complaint that reaches customer service. + +- \`email-triage\` puts each email from the shared inbox in the queue that answers it: file status, documents, payments, complaints, or general. +- \`complaint-acknowledgement\` registers a complaint with its acknowledgement deadline, and asks compliance before offering compensation above the limit. +`, + workflows: [ + { + id: "email-triage", + code: emailTriage, + tests: emailTriageTests, + runs: (draw) => + Array.from({ length: 46 }, (_, index) => { + const mail = draw.pick(subjects); + return { + by: draw.pick(["lisa", "bram", "bram", "anouk"] as const), + input: { + from: draw.pick(clients), + subject: mail.subject, + // A forwarded calendar invite now and then: no text at all. + body: index % 17 === 5 ? "" : mail.body, + }, + }; + }), + }, + { + id: "complaint-acknowledgement", + code: complaintAcknowledgement, + tests: complaintAcknowledgementTests, + dateInput: "received", + deciders: { compliance: "Compliance" }, + runs: (draw) => + Array.from({ length: 11 }, (_, index) => { + const large = index % 3 !== 1; + return { + by: draw.pick(["lisa", "bram"] as const), + input: { + client: draw.pick(clients), + about: draw.pick([ + "The valuation", + "Waiting on the phone", + "A late answer", + "The advice fee", + ]), + compensation: large + ? euros(draw.between(6, 12) * 50) + : euros(draw.between(1, 4) * 25), + }, + ...(large + ? { + answer: + index === 9 + ? "open" + : { + by: draw.pick(["joost", "anouk"] as const), + approved: index !== 5, + }, + } + : {}), + }; + }), + }, + ], +}; + +// Finance + +const suppliers = [ + { supplier: "Kantoorhuis Utrecht", what: "Office rent", amount: 4180 }, + { supplier: "Hypotheekdata BV", what: "Data subscription", amount: 2950 }, + { supplier: "Brinkman ICT", what: "Laptops", amount: 1720 }, + { supplier: "Drukkerij Noord", what: "Brochures", amount: 640 }, + { supplier: "Eneco", what: "Energy", amount: 1315 }, + { supplier: "KPN", what: "Phones", amount: 486 }, + { supplier: "Schoonmaak Plus", what: "Cleaning", amount: 920 }, + { supplier: "Juridisch Advies Groep", what: "Legal advice", amount: 3400 }, +] as const; + +const supplierInvoices = `import { money, person, workflow, z } from "@grasp-os/sdk/workflow"; + +/** The ledger account each kind of cost is booked on. */ +const accounts = [ + { account: "4100 Housing", words: ["rent", "energy", "cleaning"] }, + { account: "4300 Software and data", words: ["subscription", "laptops", "phones"] }, + { account: "4500 Marketing", words: ["brochures"] }, + { account: "4700 Advice", words: ["legal"] }, +]; + +export default workflow( + "supplier-invoices", + { + params: { + limit: money({ label: "Approve invoices above", currency: "EUR", default: 250000 }), + approvers: person({ label: "Who approves invoices", default: "role:admin" }), + }, + input: z.object({ + supplier: z.string(), + invoice: z.string(), + description: z.string(), + amount: z.number(), + vatNumber: z.string().optional(), + }), + }, + async (step, { input, params }) => { + const coded = await step.do( + "code", + { description: "Check the invoice and code it to a ledger account", locked: true }, + async () => { + if (!input.vatNumber) { + throw new Error(\`Invoice \${input.invoice} from \${input.supplier} has no VAT number.\`); + } + const text = input.description.toLowerCase(); + const found = accounts.find(({ words }) => words.some((word) => text.includes(word))); + return { invoice: input.invoice, account: found?.account ?? "4900 Other costs" }; + } + ); + if (input.amount <= params.limit) { + return { ...coded, amount: input.amount, approved: true, approvedBy: null }; + } + const decision = await step.decision("approve", { + description: \`Approve invoice \${input.invoice} from \${input.supplier}\`, + from: params.approvers, + ask: async () => {}, + timeout: "7 days", + remindAfter: "2 days", + }); + const approved = !decision.timedOut && decision.approved; + return { ...coded, amount: input.amount, approved, approvedBy: approved ? decision.by : null }; + } +); +`; + +const supplierInvoicesTests = `import { workflowTests } from "@grasp-os/sdk/testing"; + +import invoices from "./supplier-invoices.ts"; + +export default workflowTests(invoices, [ + { + name: "codes a small invoice and books it without asking", + input: { supplier: "KPN", invoice: "KPN-2291", description: "Phones", amount: 48600, vatNumber: "NL009292056B01" }, + expect: { output: { invoice: "KPN-2291", account: "4300 Software and data", amount: 48600, approved: true, approvedBy: null } }, + }, + { + name: "asks finance to approve an invoice above the limit", + input: { supplier: "Kantoorhuis Utrecht", invoice: "KU-1043", description: "Office rent", amount: 418000, vatNumber: "NL812345678B01" }, + decisions: { approve: { approved: true, by: "eva" } }, + expect: { output: { invoice: "KU-1043", account: "4100 Housing", amount: 418000, approved: true, approvedBy: "eva" } }, + }, + { + name: "refuses an invoice without a VAT number", + input: { supplier: "Drukkerij Noord", invoice: "DN-77", description: "Brochures", amount: 64000 }, + expect: { error: "has no VAT number" }, + }, +]); +`; + +const weeklyReport = `import { schedule, workflow, z } from "@grasp-os/sdk/workflow"; + +/** What the board pack holds, and who brings it. */ +const sections = [ + { section: "Revenue and costs", from: "Exact" }, + { section: "New clients and files", from: "the CRM" }, + { section: "Inbox and complaints", from: "the service desk" }, + { section: "Commissions", from: "the lenders' statements" }, +]; + +export default workflow( + "weekly-report", + { + params: { every: schedule({ label: "Runs", default: "0 7 * * 1" }) }, + input: z.object({ week: z.string().optional() }).optional(), + triggers: [{ type: "schedule", param: "every", timeZone: "Europe/Amsterdam" }], + }, + async (step, { input }) => + await step.do( + "plan", + { description: "Plan the board pack for Monday's meeting", locked: true }, + async () => { + const today = new Date(); + const monday = new Date(today); + monday.setUTCDate(today.getUTCDate() - ((today.getUTCDay() + 6) % 7)); + return { + week: input?.week ?? monday.toISOString().slice(0, 10), + sections, + due: "10:00", + }; + } + ) +); +`; + +const weeklyReportTests = `import { workflowTests } from "@grasp-os/sdk/testing"; + +import report from "./weekly-report.ts"; + +export default workflowTests(report, [ + { + name: "plans the week's board pack", + input: { week: "2026-10-05" }, + expect: { + output: { + week: "2026-10-05", + sections: [ + { section: "Revenue and costs", from: "Exact" }, + { section: "New clients and files", from: "the CRM" }, + { section: "Inbox and complaints", from: "the service desk" }, + { section: "Commissions", from: "the lenders' statements" }, + ], + due: "10:00", + }, + }, + }, +]); +`; + +const finance: EngineSeed = { + name: "Finance", + description: + "Codes and approves supplier invoices, and plans the board pack before the Monday meeting.", + owner: "eva", + teams: [{ team: "Finance", role: "builder" }], + agents: `# Finance + +Finance's own engine. + +- \`supplier-invoices\` checks each supplier invoice for a VAT number, codes it to a ledger account, and asks finance to approve it when it is above the limit. +- \`weekly-report\` plans the board pack every Monday at 7:00, before the 10:00 meeting. +`, + workflows: [ + { + id: "supplier-invoices", + code: supplierInvoices, + tests: supplierInvoicesTests, + deciders: { approvers: "Finance" }, + runs: (draw) => + Array.from({ length: 30 }, (_, index) => { + const { supplier, what, amount } = draw.pick(suppliers); + const cents = euros(amount * (0.9 + draw.next() * 0.2)); + const asks = cents > euros(2500); + const noVat = index % 11 === 3; + const prefix = supplier + .split(" ") + .map((word) => word.charAt(0)) + .join(""); + return { + by: "jeroen" as const, + input: { + supplier, + invoice: `${prefix}-${1000 + index * 7}`, + description: what, + amount: cents, + ...(noVat + ? {} + : { + vatNumber: `NL8${String(index).padStart(2, "0")}315478B01`, + }), + }, + ...(asks && !noVat + ? { + answer: + index >= 27 + ? "open" + : { by: "eva" as const, approved: index % 8 !== 6 }, + } + : {}), + }; + }), + }, + { + id: "weekly-report", + code: weeklyReport, + tests: weeklyReportTests, + scheduled: true, + dateInput: "week", + runs: () => + Array.from({ length: 4 }, () => ({ by: "eva" as const, input: {} })), + }, + ], +}; + +// Compliance + +const countries = ["NL", "NL", "NL", "BE", "DE", "GB"] as const; + +const kycReview = `import { person, workflow, z } from "@grasp-os/sdk/workflow"; + +export default workflow( + "kyc-review", + { + params: { + officers: person({ label: "Who clears high-risk clients", default: "role:admin" }), + }, + input: z.object({ + client: z.string(), + country: z.string(), + politicallyExposed: z.boolean(), + identity: z.string().optional(), + }), + }, + async (step, { input, params }) => { + const screened = await step.do( + "screen", + { description: "Screen the client and score the risk", locked: true }, + async () => { + if (!input.identity) { + throw new Error(\`No identity document to check for \${input.client}.\`); + } + const risk = input.politicallyExposed ? "high" : input.country === "NL" ? "low" : "medium"; + return { client: input.client, risk }; + } + ); + if (screened.risk !== "high") { + return { ...screened, cleared: true, clearedBy: null }; + } + const decision = await step.decision("enhanced-review", { + description: \`Clear \${input.client} after enhanced due diligence\`, + from: params.officers, + ask: async () => {}, + timeout: "10 days", + }); + const cleared = !decision.timedOut && decision.approved; + return { ...screened, cleared, clearedBy: cleared ? decision.by : null }; + } +); +`; + +const kycReviewTests = `import { workflowTests } from "@grasp-os/sdk/testing"; + +import review from "./kyc-review.ts"; + +export default workflowTests(review, [ + { + name: "clears a low-risk client at once", + input: { client: "Fam. Willems", country: "NL", politicallyExposed: false, identity: "passport" }, + expect: { output: { client: "Fam. Willems", risk: "low", cleared: true, clearedBy: null } }, + }, + { + name: "asks an officer to clear a politically exposed client", + input: { client: "S. de Jong", country: "NL", politicallyExposed: true, identity: "passport" }, + decisions: { "enhanced-review": { approved: true, by: "joost" } }, + expect: { output: { client: "S. de Jong", risk: "high", cleared: true, clearedBy: "joost" } }, + }, + { + name: "refuses a client without an identity document", + input: { client: "T. Kok", country: "NL", politicallyExposed: false }, + expect: { error: "No identity document" }, + }, +]); +`; + +const compliance: EngineSeed = { + name: "Compliance", + description: + "Screens every new client before advice starts, and asks an officer to clear the high-risk ones.", + owner: "pieter", + teams: [ + { team: "Compliance", role: "builder" }, + { team: "Client onboarding", role: "user" }, + ], + agents: `# Compliance + +Know-your-customer checks for every new client. + +- \`kyc-review\` screens a new client and scores the risk: low for Dutch residents, medium for clients abroad, high for politically exposed people, whom a compliance officer clears after enhanced due diligence. +`, + workflows: [ + { + id: "kyc-review", + code: kycReview, + tests: kycReviewTests, + deciders: { officers: "Compliance" }, + runs: (draw) => + Array.from({ length: 22 }, (_, index) => { + const exposed = index % 5 === 2; + const noIdentity = index % 10 === 6; + return { + by: draw.pick(["nina", "tom"] as const), + input: { + client: draw.pick(clients), + country: draw.pick(countries), + politicallyExposed: exposed, + ...(noIdentity + ? {} + : { identity: draw.pick(["passport", "identity card"]) }), + }, + ...(exposed && !noIdentity + ? { + answer: + index === 17 + ? "open" + : { + by: draw.pick(["joost", "anouk"] as const), + approved: true, + }, + } + : {}), + }; + }), + }, + ], +}; + +export const engines: EngineSeed[] = [ + onboarding, + serviceDesk, + finance, + compliance, +]; diff --git a/scripts/seed/knowledge.ts b/scripts/seed/knowledge.ts new file mode 100644 index 000000000..964de6719 --- /dev/null +++ b/scripts/seed/knowledge.ts @@ -0,0 +1,336 @@ +/** + * The demo company's Knowledge: a collection everyone reads, and one per + * team, each written by the people who own it, with a second version + * where a document was brought up to date. Some documents are past their + * review date, so Knowledge has something to flag. + */ +import type { PersonKey, TeamName } from "./company.ts"; + +export interface DocumentSeed { + path: string; + text: string; + /** A later version, with what changed. */ + update?: { text: string; message: string }; +} + +export interface CollectionSeed { + name: string; + description: string; + /** The admin who creates it: only admins create shared collections. */ + createdBy: PersonKey; + /** Who writes its documents: someone it is shared with. */ + writer: PersonKey; + /** Everyone, or only these teams. */ + teams: TeamName[]; + sensitive?: boolean; + documents: DocumentSeed[]; +} + +/** A document: its frontmatter, then its Markdown. */ +const doc = ( + meta: { description: string; tags: string[]; review?: string }, + body: string +): string => + [ + "---", + `description: ${JSON.stringify(meta.description)}`, + `tags: [${meta.tags.join(", ")}]`, + ...(meta.review === undefined ? [] : [`review: ${meta.review}`]), + "---", + "", + body.trim(), + "", + ].join("\n"); + +const requiredDocuments = doc( + { + description: + "What every mortgage application needs before it becomes a client file.", + tags: ["onboarding", "documents"], + review: "2027-03-01", + }, + ` +# Required documents + +Every application needs these four before onboarding opens a client file: + +1. **Identity**: a valid passport or identity card of each applicant. +2. **Payslip**: the most recent one, no older than three months. +3. **Employer statement**: signed, no older than three months. +4. **Bank statements**: the last three months, showing salary payments. + +Self-employed applicants send the last three annual accounts instead of a payslip and an employer statement. + +Missing documents are chased by email the same day. After three reminders without an answer, the application is put on hold and the client is called. +` +); + +const responseTimes = doc( + { + description: "How quickly customer service answers, by channel.", + tags: ["service"], + review: "2026-09-15", + }, + ` +# Response times + +| Channel | First answer within | +| --- | --- | +| Email | 1 working day | +| Phone | 2 minutes on average | +| Web form | 1 working day | +| Complaint | Acknowledged within 2 working days | + +A question about the status of a file is answered from the CRM, never from memory. A question an adviser must answer goes to the adviser through the service desk, so the answer comes back the same way. +` +); + +const complaintsProcedure = doc( + { + description: + "How a complaint is registered, acknowledged and resolved, and when compliance approves compensation.", + tags: ["complaints", "service", "compliance"], + review: "2027-01-15", + }, + ` +# Complaints procedure + +1. Register every complaint the day it comes in, whatever the channel. +2. Acknowledge it within **two working days**, in writing. +3. Resolve it within **six weeks**, or tell the client why it takes longer. +4. Compensation up to **€250** is offered by customer service. Above that, compliance approves it first. + +Every complaint is reported to the board each quarter, by kind and by how long it took. +` +); + +const affordability = doc( + { + description: + "How advisers draft the affordability calculation before the first meeting.", + tags: ["advice", "mortgage"], + }, + ` +# Affordability calculation + +Advisers draft the calculation before the first meeting, from what the client file holds: + +- gross annual income, from the employer statement; +- existing debts, from the bank statements and the BKR check; +- the energy label of the home, which raises the maximum loan. + +The calculation follows the Nibud tables of the current year. A draft is never shared with the client before an adviser has checked it. +` +); + +const monthEndClose = doc( + { + description: "The month-end close, day by day, and who does what.", + tags: ["finance", "close"], + review: "2026-12-01", + }, + ` +# Month-end close + +| Day | What | Who | +| --- | --- | --- | +| 1 | Bank reconciled | Eva | +| 2 | Accruals booked | Eva | +| 3 | VAT return checked | Jeroen | +| 4 | Commissions reconciled with the lenders' statements | Eva | +| 5 | Board report sent | Sanne | + +The target is to close in five working days. +` +); + +const monthEndCloseUpdated = monthEndClose.replace( + "| 4 | Commissions reconciled with the lenders' statements | Eva |", + "| 4 | Commissions reconciled with the lenders' statements | Jeroen |" +); + +const codingRules = doc( + { + description: + "Which ledger account each kind of supplier invoice is booked on.", + tags: ["finance", "invoices"], + }, + ` +# Coding supplier invoices + +| Kind of cost | Account | +| --- | --- | +| Rent, energy, cleaning | 4100 Housing | +| Software, data, laptops, phones | 4300 Software and data | +| Brochures, events | 4500 Marketing | +| Legal and other advice | 4700 Advice | +| Anything else | 4900 Other costs | + +An invoice without a VAT number is sent back to the supplier, never booked. +` +); + +const approvalLimits = doc( + { + description: "Who approves what, and up to which amount.", + tags: ["finance", "approvals"], + }, + ` +# Approval limits + +- Supplier invoices up to **€2,500** are booked once coded. +- Above €2,500, someone in finance approves the invoice. +- Above €25,000, the CFO approves it too. +- New software is approved by the COO after a check by IT. +` +); + +const lenderRequirements = doc( + { + description: + "What the lenders we work with most ask for, beyond the standard documents.", + tags: ["advice", "lenders"], + review: "2026-08-31", + }, + ` +# Lender requirements + +Most files go to four lenders. What each asks for beyond the standard documents: + +- **Lender A**: a valuation report no older than six months. +- **Lender B**: the energy label, for the sustainability discount. +- **Lender C**: two years of annual accounts for self-employed clients. +- **Lender D**: a signed declaration of existing debts. +` +); + +const handoverChecklist = doc( + { + description: "What a file holds before onboarding hands it to an adviser.", + tags: ["onboarding", "advice"], + }, + ` +# Handing a file over + +A file goes to mortgage advice once: + +- all four required documents are in it and checked; +- the client's details are in the CRM, typed once; +- the know-your-customer check has cleared the client; +- the client knows which adviser will call them. + +The adviser takes the file over in Grasp. A file nobody takes over within five working days goes back to onboarding. +` +); + +const handoverChecklistUpdated = handoverChecklist.replace( + "- the client knows which adviser will call them.", + "- the client knows which adviser will call them, and when." +); + +const kycPolicy = doc( + { + description: + "How new clients are screened, and when enhanced due diligence applies.", + tags: ["compliance", "kyc"], + review: "2027-06-30", + }, + ` +# Know your customer + +Every new client is screened before advice starts. + +| Risk | When | What follows | +| --- | --- | --- | +| Low | Resident in the Netherlands | Cleared at once | +| Medium | Resident elsewhere | Cleared at once, reviewed yearly | +| High | Politically exposed person | Enhanced due diligence, cleared by a compliance officer | + +A client without a valid identity document is never screened: the file waits for one. +` +); + +const aiGuideline = doc( + { + description: + "Which data and tools may be used with AI, as compliance and IT agreed.", + tags: ["compliance", "ai", "data"], + }, + ` +# AI and data guideline + +- Client data stays in the EU, in tools the company has approved. +- No client data goes into public AI tools. +- Every automated step that changes a client file is logged, with who approved it. +- A person approves anything that leaves the company: an offer, a compensation, a letter to a lender. +` +); + +export const collections: CollectionSeed[] = [ + { + name: "Procedures", + description: + "How the company works: onboarding, advice, service and complaints. Start here for any question about a procedure.", + createdBy: "sanne", + writer: "sanne", + teams: [], + documents: [ + { path: "onboarding/required-documents.md", text: requiredDocuments }, + { path: "service/response-times.md", text: responseTimes }, + { path: "service/complaints.md", text: complaintsProcedure }, + { path: "advice/affordability.md", text: affordability }, + ], + }, + { + name: "Finance", + description: + "Finance's handbook: the month-end close, coding invoices and approval limits.", + createdBy: "sanne", + writer: "eva", + teams: ["Finance"], + documents: [ + { + path: "month-end-close.md", + text: monthEndClose, + update: { + text: monthEndCloseUpdated, + message: "Jeroen reconciles the commissions from now on", + }, + }, + { path: "invoices/coding.md", text: codingRules }, + { path: "invoices/approval-limits.md", text: approvalLimits }, + ], + }, + { + name: "Client files", + description: + "What onboarding and mortgage advice agreed about client files: lenders' requirements and the handover.", + createdBy: "sanne", + writer: "nina", + teams: ["Client onboarding", "Mortgage advice"], + documents: [ + { path: "lenders.md", text: lenderRequirements }, + { + path: "handover.md", + text: handoverChecklist, + update: { + text: handoverChecklistUpdated, + message: "Tell the client when the adviser calls", + }, + }, + ], + }, + { + name: "Compliance", + description: + "Compliance policies: know your customer, and the AI and data guideline.", + createdBy: "pieter", + writer: "joost", + teams: ["Compliance"], + sensitive: true, + documents: [ + { path: "kyc.md", text: kycPolicy }, + { path: "ai-and-data.md", text: aiGuideline }, + ], + }, +]; diff --git a/scripts/seed/local.ts b/scripts/seed/local.ts new file mode 100644 index 000000000..cc64aca07 --- /dev/null +++ b/scripts/seed/local.ts @@ -0,0 +1,235 @@ +/** + * The local stack `vp run dev` runs, as the seed reaches it: people sign in + * through the product, as e2e/people.ts does, and act over `/rpc` with + * their own session; what an API can't backdate is written straight into + * the stack's local state, as core's tests write to D1. + */ +import { existsSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; + +import type { CoreApi } from "@grasp-os/shared/rpc"; +import { newWebSocketRpcSession } from "capnweb"; +import type { RpcStub } from "capnweb"; + +import { localIdpOrigin } from "../../apps/core/test/sign-in-config.ts"; + +/** + * The frontend `vp run dev` serves, which sends `/api` and `/rpc` on to + * core: the origin sign-in is configured for (scripts/dev.ts). Never read + * from the environment, so the seed can't be pointed anywhere else. + */ +export const origin = "http://localhost:5173"; + +const root = path.join(import.meta.dirname, "../.."); + +/** Where `vp run dev` keeps core's and connect's state (apps/core's dev script). */ +export const stateDir = path.resolve( + root, + "apps/core", + process.env.DEV_PERSIST_TO ?? ".wrangler/state" +); + +const sessionCookie = "__Host-grasp.session_token"; + +/** The `name=value` pairs a response sets, as a `Cookie` header. */ +const cookiesOf = (response: Response): string => + response.headers + .getSetCookie() + .map((cookie) => cookie.split(";")[0] ?? "") + .join("; "); + +const locationOf = (response: Response, step: string): string => { + const location = response.headers.get("location"); + if (location === null) { + throw new Error(`${step} did not redirect (${response.status})`); + } + return location; +}; + +/** + * Signs in as `email` the way a browser does, and returns the session + * cookie's value. Refuses to go on unless core sends the browser to the + * fake IdP on this machine: core does that only for a local stack + * (`devIdpOrigin` in apps/core/src/auth/config.ts), so a deployed core, + * or anything else answering here, is never seeded. + */ +export const signInAs = async (email: string): Promise => { + let started: Response; + try { + started = await fetch(new URL("/api/auth/sign-in/sso", origin), { + method: "POST", + headers: { origin, "content-type": "application/json" }, + body: JSON.stringify({ + providerId: "microsoft", + callbackURL: "/", + errorCallbackURL: "/", + loginHint: email, + }), + }); + } catch { + throw new Error( + `Nothing answers at ${origin}. Start the local stack with \`vp run dev\` first.` + ); + } + const body: unknown = await started.json(); + if ( + !started.ok || + typeof body !== "object" || + body === null || + !("url" in body) || + typeof body.url !== "string" + ) { + throw new Error(`Sign-in as ${email} did not start (${started.status})`); + } + if (new URL(body.url).origin !== localIdpOrigin) { + throw new Error( + `Core at ${origin} signs people in at ${new URL(body.url).origin}, not the local fake IdP: the seed only fills the stack \`vp run dev\` runs.` + ); + } + const atIdp = await fetch(body.url, { redirect: "manual" }); + const finished = await fetch(locationOf(atIdp, "The IdP"), { + redirect: "manual", + headers: { cookie: cookiesOf(started) }, + }); + const session = cookiesOf(finished) + .split("; ") + .find((pair) => pair.startsWith(`${sessionCookie}=`)); + if (session === undefined) { + throw new Error( + `Sign-in as ${email} refused: ${locationOf(finished, "Core")}` + ); + } + return decodeURIComponent(session.slice(sessionCookie.length + 1)); +}; + +/** The `Cookie` header of a session. */ +export const cookieHeader = (cookie: string): string => + `${sessionCookie}=${encodeURIComponent(cookie)}`; + +/** A connection to `/rpc` from Node, with the session cookie. */ +const rpcSocket = (cookie: string): WebSocket => { + const url = new URL("/rpc", origin); + url.protocol = "ws:"; + const headers = { origin, cookie: cookieHeader(cookie) }; + // Node's WebSocket (undici) takes headers, which a browser's doesn't. + return new WebSocket(url, { headers }); +}; + +export type Api = ReturnType["authenticate"]>; + +/** Someone signed in, with their API over `/rpc`. */ +export interface Session { + email: string; + userId: string; + cookie: string; + api: Api; + close: () => void; +} + +/** Signs `email` in and opens their API. */ +export const sessionOf = async (email: string): Promise => { + const cookie = await signInAs(email); + const core = newWebSocketRpcSession(rpcSocket(cookie)); + const api = core.authenticate(); + const { userId } = await api.whoami(); + return { + email, + userId, + cookie, + api, + close: () => { + core[Symbol.dispose](); + }, + }; +}; + +/** + * Calls one of Better Auth's endpoints under `/api/auth` as the session's + * person, as the frontend's auth client does: how admins manage teams. + */ +export const authCall = async ( + session: Pick, + endpoint: string, + body?: unknown +): Promise => { + const response = await fetch(new URL(`/api/auth${endpoint}`, origin), { + method: body === undefined ? "GET" : "POST", + headers: { + origin, + cookie: cookieHeader(session.cookie), + ...(body === undefined ? {} : { "content-type": "application/json" }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + if (!response.ok) { + throw new Error( + `${endpoint} failed (${response.status}): ${await response.text()}` + ); + } + return await response.json(); +}; + +/** How long a write waits for workerd to let go of a database. */ +const busyTimeoutMs = 10_000; + +/** The SQLite files under a folder of the local state, if it exists. */ +const sqliteFiles = (dir: string): string[] => + existsSync(dir) + ? readdirSync(dir) + .filter((name) => name.endsWith(".sqlite")) + .map((name) => path.join(dir, name)) + : []; + +const open = (file: string): DatabaseSync => { + const db = new DatabaseSync(file); + db.exec(`PRAGMA busy_timeout = ${busyTimeoutMs}`); + return db; +}; + +const hasTable = (db: DatabaseSync, table: string): boolean => + db + .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?") + .get(table) !== undefined; + +/** + * The local D1 database that has `table`: core's (`workflow_runs`) or + * Knowledge's (`collections`), as Miniflare keeps them, one file each. + */ +export const localD1 = (table: string): DatabaseSync => { + const dir = path.join(stateDir, "v3/d1/miniflare-D1DatabaseObject"); + for (const file of sqliteFiles(dir)) { + const db = open(file); + if (hasTable(db, table)) { + return db; + } + db.close(); + } + throw new Error( + `No local database with \`${table}\` under ${dir}: is \`vp run dev\` running from this checkout?` + ); +}; + +/** + * The storage of the Durable Object of core's class `className` that has a + * row `id` in `table`: Miniflare names each object's file by its ID, + * which the seed can't derive from the object's name, so it looks. + */ +export const localObject = ( + className: string, + table: string, + id: string +): DatabaseSync => { + const dir = path.join(stateDir, `v3/do/grasp-os-core-${className}`); + for (const file of sqliteFiles(dir)) { + const db = open(file); + if ( + hasTable(db, table) && + db.prepare(`SELECT 1 FROM ${table} WHERE id = ?`).get(id) !== undefined + ) { + return db; + } + db.close(); + } + throw new Error(`No ${className} object under ${dir} holds ${table} ${id}`); +}; diff --git a/scripts/seed/runs.ts b/scripts/seed/runs.ts new file mode 100644 index 000000000..771e5d816 --- /dev/null +++ b/scripts/seed/runs.ts @@ -0,0 +1,377 @@ +/** + * Runs over the last 30 days. Each run is a real one: started through + * core's API by the person it names, run by the engine, its decision + * answered through the API by the person who decides. Only its times are + * then moved back, in core's local D1, as core's tests write it: no API + * starts a run in the past, and the dashboard counts runs by the day they + * started. + */ +import type { DatabaseSync } from "node:sqlite"; + +import { z } from "zod"; + +import type { PersonKey } from "./company.ts"; +import type { Answer, Draw, RunPlan, WorkflowSeed } from "./engines.ts"; +import type { Session } from "./local.ts"; + +const minuteMs = 60_000; +const hourMs = 60 * minuteMs; +const dayMs = 24 * hourMs; + +/** How far back the history goes. */ +const historyDays = 30; + +/** How many runs start at once: the local engine takes more, slowly. */ +const startsAtOnce = 6; + +/** How long a run may take to end, or to wait for its decision. */ +const settleTimeoutMs = 60_000; +const pollMs = 250; + +/** The modulus of the Park–Miller generator: the prime 2^31 - 1. */ +const modulus = 2_147_483_647; + +/** + * Numbers that look random and are the same on every seed: a Park–Miller + * generator, started from the workflow's name. + */ +export const drawFor = (name: string): Draw => { + let state = 1; + for (const char of name) { + state = (state * 31 + (char.codePointAt(0) ?? 0)) % modulus; + } + // Zero would stay zero. + state = state === 0 ? 1 : state; + const random = (): number => { + state = (state * 48_271) % modulus; + return (state - 1) / (modulus - 1); + }; + return { + next: random, + pick: (items: readonly T[]): T => { + const item = items[Math.floor(random() * items.length)]; + if (item === undefined) { + throw new Error("Nothing to pick from"); + } + return item; + }, + between: (min, max) => min + Math.floor(random() * (max - min + 1)), + }; +}; + +/** Saturday or Sunday, in UTC. */ +const isWeekend = (time: number): boolean => { + const day = new Date(time).getUTCDay(); + return day === 0 || day === 6; +}; + +/** Seven in the morning, Amsterdam time (UTC in winter, near enough). */ +const scheduleHourUtc = 5; + +/** + * When each of a workflow's runs started: spread over the last 30 days, + * oldest first, on working days in working hours; the ones still waiting + * for a decision within the last day and a half, so their deadlines are + * still ahead; a scheduled workflow's on the Mondays its schedule ran. + */ +const startTimes = ( + workflow: WorkflowSeed, + plans: RunPlan[], + draw: Draw, + now: number +): number[] => { + const today = now - (now % dayMs); + if (workflow.scheduled === true) { + const mondays = Array.from( + { length: historyDays }, + (_, back) => today - back * dayMs + ).filter( + // Mondays whose run has started: not this morning's, before 7:00. + (day) => + new Date(day).getUTCDay() === 1 && day + scheduleHourUtc * hourMs < now + ); + return plans.map( + (_, index) => + (mondays[plans.length - 1 - index] ?? today - index * 7 * dayMs) + + scheduleHourUtc * hourMs + + draw.between(0, 3) * 1000 + ); + } + // The working days of the window, oldest first. + const days = Array.from( + { length: historyDays }, + (_, index) => today - (historyDays - 1 - index) * dayMs + ).filter((day) => !isWeekend(day)); + return plans.map(({ answer }, index) => { + if (answer === "open") { + return now - draw.between(2, 36) * hourMs; + } + const day = days[Math.floor((index * days.length) / plans.length)] ?? today; + const time = day + draw.between(7 * 60, 16 * 60) * minuteMs; + // Today's runs started before now. + return Math.min(time, now - draw.between(5, 90) * minuteMs); + }); +}; + +/** One run the seed started, and where it should be in time. */ +interface Started { + id: string; + workflow: WorkflowSeed; + plan: RunPlan; + at: number; + /** How long after the decision opened it was answered. */ + answerAfter: number; +} + +/** A run as core's D1 keeps it, as far as the seed reads it. */ +const runRowSchema = z.object({ + status: z.string(), + created_at: z.number(), + ended_at: z.number().nullable(), +}); + +/** A decision as core's D1 keeps it, as far as the seed reads it. */ +const decisionRowSchema = z.object({ + id: z.string(), + opened_at: z.number(), + expires_at: z.number(), + decided_at: z.number().nullable(), +}); + +/** Runs `work` on each item, at most `limit` at a time. */ +const eachLimited = async ( + items: readonly T[], + limit: number, + work: (item: T) => Promise +): Promise => { + let next = 0; + const worker = async (): Promise => { + while (next < items.length) { + const item = items[next]; + next += 1; + if (item !== undefined) { + // oxlint-disable-next-line no-await-in-loop -- each worker takes one at a time + await work(item); + } + } + }; + await Promise.all(Array.from({ length: limit }, worker)); +}; + +const wait = async (ms: number): Promise => { + // oxlint-disable-next-line promise/avoid-new -- setTimeout has no promise form here + await new Promise((resolve) => { + setTimeout(resolve, ms); + }); +}; + +const ended = (status: string): boolean => + ["completed", "failed", "cancelled"].includes(status); + +/** + * Waits until every run has ended, or waits for a decision: core's own + * record of it, read from its D1. + */ +const settled = async (db: DatabaseSync, runs: readonly Started[]) => { + const run = db.prepare( + "SELECT status, created_at, ended_at FROM workflow_runs WHERE id = ?" + ); + const open = db.prepare( + "SELECT id FROM workflow_decisions WHERE run_id = ? AND status = 'open'" + ); + const deadline = Date.now() + settleTimeoutMs; + for (;;) { + const pending = runs.filter(({ id }) => { + const row = runRowSchema.optional().parse(run.get(id)); + return !( + row !== undefined && + (ended(row.status) || open.get(id) !== undefined) + ); + }); + if (pending.length === 0) { + return; + } + if (Date.now() > deadline) { + throw new Error( + `${pending.length} runs neither ended nor waited for a decision in time, ${pending[0]?.id} among them` + ); + } + // oxlint-disable-next-line no-await-in-loop -- polling core's record + await wait(pollMs); + } +}; + +const answered = ( + answer: Answer | undefined +): answer is Exclude => + answer !== undefined && answer !== "open"; + +/** + * Moves each run, its decision and what it told people back to when the + * plan has it start, keeping how long each part took: seconds for the + * engine's own steps, and for a decision the hours someone took to + * answer, never past now. + */ +const backdate = ( + db: DatabaseSync, + runs: readonly Started[], + now: number +): void => { + const runOf = db.prepare( + "SELECT status, created_at, ended_at FROM workflow_runs WHERE id = ?" + ); + const decisionsOf = db.prepare( + "SELECT id, opened_at, expires_at, decided_at FROM workflow_decisions WHERE run_id = ?" + ); + const moveRun = db.prepare( + `UPDATE workflow_runs SET created_at = ?, ended_at = ?, + started_by = CASE WHEN ? THEN NULL ELSE started_by END, + failure = CASE WHEN failure IS NULL THEN NULL ELSE json_set(failure, '$.failedAt', ?) END + WHERE id = ?` + ); + const moveDecision = db.prepare( + "UPDATE workflow_decisions SET opened_at = ?, expires_at = ?, decided_at = ? WHERE id = ?" + ); + const moveNotifications = db.prepare( + "UPDATE notifications SET created_at = ?, updated_at = ? WHERE run_id = ?" + ); + db.exec("BEGIN IMMEDIATE"); + try { + for (const { id, workflow, at, answerAfter } of runs) { + const run = runRowSchema.optional().parse(runOf.get(id)); + if (run === undefined) { + continue; + } + const shift = (time: number): number => at + (time - run.created_at); + let endedAt = run.ended_at === null ? null : shift(run.ended_at); + const decisions = z.array(decisionRowSchema).parse(decisionsOf.all(id)); + for (const decision of decisions) { + const openedAt = shift(decision.opened_at); + // An open decision keeps the deadline the engine waits until, so + // it can be answered for as long as its run waits; an answered + // one's moves with it. + const expiresAt = + decision.decided_at === null + ? decision.expires_at + : openedAt + (decision.expires_at - decision.opened_at); + let decidedAt: number | null = null; + if (decision.decided_at !== null) { + decidedAt = Math.min( + openedAt + answerAfter, + openedAt + (now - openedAt) * 0.8 + ); + // The run went on once it was answered. + if (run.ended_at !== null) { + endedAt = decidedAt + (run.ended_at - decision.decided_at); + } + } + moveDecision.run(openedAt, expiresAt, decidedAt, decision.id); + } + moveRun.run( + at, + endedAt, + workflow.scheduled === true ? 1 : 0, + new Date(endedAt ?? at).toISOString(), + id + ); + if (endedAt !== null) { + moveNotifications.run(endedAt, endedAt, id); + } + } + db.exec("COMMIT"); + } catch (error) { + db.exec("ROLLBACK"); + throw error; + } +}; + +/** + * Starts every workflow's runs on `app`, the engine `engine`, answers the + * decisions the plans answer, and moves them all back in time. Returns + * how many it started. + */ +export const seedRuns = async ({ + app, + engine, + workflows, + sessions, + db, +}: { + app: string; + engine: string; + workflows: readonly WorkflowSeed[]; + sessions: ReadonlyMap; + db: DatabaseSync; +}): Promise => { + const sessionOf = (key: PersonKey): Session => { + const session = sessions.get(key); + if (session === undefined) { + throw new Error(`Nobody signed in as ${key}`); + } + return session; + }; + const now = Date.now(); + const planned = workflows.flatMap((workflow) => { + // By name, not ID: the same runs on every new stack. + const draw = drawFor(`${engine}:${workflow.id}`); + const plans = workflow.runs(draw); + const times = startTimes(workflow, plans, draw, now); + return plans.map((plan, index) => { + const at = times[index] ?? now; + const { dateInput } = workflow; + return { + workflow, + plan: + dateInput === undefined + ? plan + : { + ...plan, + input: { + ...plan.input, + [dateInput]: new Date(at).toISOString().slice(0, 10), + }, + }, + at, + answerAfter: draw.between(20, 26 * 60) * minuteMs, + }; + }); + }); + + const started: Started[] = []; + await eachLimited(planned, startsAtOnce, async (run) => { + const { id } = await sessionOf(run.plan.by).api.workflows.start( + app, + run.workflow.id, + run.plan.input + ); + started.push({ ...run, id }); + }); + await settled(db, started); + + const decisionOf = db.prepare( + "SELECT id FROM workflow_decisions WHERE run_id = ? AND status = 'open'" + ); + await eachLimited(started, startsAtOnce, async ({ id, plan }) => { + const { answer } = plan; + const decision = decisionRowSchema + .pick({ id: true }) + .optional() + .parse(decisionOf.get(id)); + if (!answered(answer) || decision === undefined) { + return; + } + await sessionOf(answer.by).api.decisions.answer(decision.id, { + approved: answer.approved, + ...(answer.comment === undefined + ? {} + : { payload: { comment: answer.comment } }), + }); + }); + await settled( + db, + started.filter(({ plan }) => answered(plan.answer)) + ); + + backdate(db, started, now); + return started.length; +};