From 5c2bcd86927e6d8d38849bbb3fb7d25486a826f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 22:30:36 +0000 Subject: [PATCH 1/3] feat: seed engine apps over their workflows' runs, one waiting for an admin Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DGjkfKxJZ9eCUeQ9YexmJF --- scripts/seed.ts | 65 ++++++++++++- scripts/seed/screens.ts | 200 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 260 insertions(+), 5 deletions(-) create mode 100644 scripts/seed/screens.ts diff --git a/scripts/seed.ts b/scripts/seed.ts index 61c9b47a4..d623effb5 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -1,8 +1,9 @@ /** * `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. + * people in teams and roles, engines with released workflows, 30 days of + * their runs and apps over them (scripts/seed/screens.ts), 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 @@ -20,6 +21,7 @@ import { appErrors } from "@grasp-os/shared/apps"; import { knowledgeErrors } from "@grasp-os/shared/knowledge"; import type { PermissionRequest } from "@grasp-os/shared/permissions"; +import { builtArtifacts } from "@grasp-os/shared/screen-trust"; import { z } from "zod"; import { chats, writeTranscript } from "./seed/chats.ts"; @@ -38,6 +40,7 @@ import { } from "./seed/local.ts"; import type { Session } from "./seed/local.ts"; import { seedRuns } from "./seed/runs.ts"; +import { approvedScreens, engineScreens } from "./seed/screens.ts"; const dayMs = 24 * 60 * 60 * 1000; @@ -235,7 +238,31 @@ const seedCollection = async ( }; /** - * An engine: created by its owner, its version made current by an admin, + * Approves, as an admin, every screen of the App's current version that + * builds and isn't approved yet, so its people see their data in it. + */ +const approveScreens = async ( + adminSession: Session, + app: string +): Promise => { + const review = await adminSession.api.screenTrust.review(app); + if (review.screens.every(({ trust }) => trust === "approved")) { + return; + } + const artifacts = builtArtifacts(review.screens); + if (artifacts.length < review.screens.length) { + throw new Error(`Not every screen of ${review.name} builds`); + } + await adminSession.api.screenTrust.approve(app, { + version: review.version, + generation: review.generation, + artifacts, + }); +}; + +/** + * An engine: created by its owner with its workflows and apps, its version + * made current by an admin, its apps approved where the seed says so, * shared with its teams, its deciders set, and, while it has none, its * runs. Returns its ID, and how many runs it got. */ @@ -253,8 +280,17 @@ const seedEngine = async ( name: seed.name, description: seed.description, })); + const screens = Object.fromEntries( + Object.entries(engineScreens[seed.name] ?? {}).map(([name, code]) => [ + `screens/${name}.tsx`, + code, + ]) + ); if (app.currentVersion === null) { - const files: Record = { "AGENTS.md": seed.agents }; + const files: Record = { + "AGENTS.md": seed.agents, + ...screens, + }; for (const { id, code, tests } of seed.workflows) { files[`workflows/${id}.ts`] = code; files[`workflows/${id}.workflow-tests.ts`] = tests; @@ -262,10 +298,29 @@ const seedEngine = async ( const { version } = await owner.api.apps.files.commit( app.id, files, - "Release the engine's workflows" + "Release the engine's workflows and apps" ); // An admin makes it current, which approves its code. await adminSession.api.apps.versions.setCurrent(app.id, version); + } else { + // An engine seeded before it had apps gets them as its next version. + const { screens: has } = await owner.api.apps.contents(app.id); + const missing = Object.fromEntries( + Object.entries(screens).filter( + ([path]) => !has.includes(path.slice("screens/".length, -".tsx".length)) + ) + ); + if (Object.keys(missing).length > 0) { + const { version } = await owner.api.apps.files.commit( + app.id, + missing, + "Add the engine's apps" + ); + await adminSession.api.apps.versions.setCurrent(app.id, version); + } + } + if (approvedScreens.has(seed.name) && Object.keys(screens).length > 0) { + await approveScreens(adminSession, app.id); } const shared = await owner.api.apps.members.list(app.id); for (const { team, role } of seed.teams) { diff --git a/scripts/seed/screens.ts b/scripts/seed/screens.ts new file mode 100644 index 000000000..b6cfa166b --- /dev/null +++ b/scripts/seed/screens.ts @@ -0,0 +1,200 @@ +/** + * The demo engines' apps (core's screens): views their teams work from, + * over the engine's own workflow runs, so the Engines page has apps to + * open and admins have a screen to approve. Each reads only what the + * screen SDK gives any screen of its App (`useWorkflow`): no permission, + * no connection. + */ + +/** + * A screen that follows one workflow's runs: how many ended how over + * the last 30 days, the decisions waiting with their answers, and the + * latest runs. `title` and `intro` name what it's for. + */ +const workflowScreen = ({ + name, + workflow, + title, + intro, + waitingTitle, +}: { + name: string; + workflow: string; + title: string; + intro: string; + waitingTitle: string; +}): string => `import { useWorkflow } from "@grasp-os/sdk/screen"; +import type { ScreenRun } from "@grasp-os/sdk/screen"; +import { Badge } from "@grasp-os/ui/components/badge"; +import { Button } from "@grasp-os/ui/components/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@grasp-os/ui/components/card"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@grasp-os/ui/components/table"; +import { useState } from "react"; + +const dayMs = 24 * 60 * 60 * 1000; + +const statusLabels: Record = { + running: "Running", + waiting: "Waiting for a decision", + paused: "Paused", + completed: "Completed", + failed: "Failed", + cancelled: "Cancelled", +}; + +const day = (iso: string): string => + new Date(iso).toLocaleDateString("en-GB", { day: "numeric", month: "short" }); + +/** How many of \`runs\` started in the last 30 days ended as \`status\`. */ +const countOf = (runs: ScreenRun[], status: ScreenRun["status"]): number => + runs.filter((run) => run.status === status && Date.now() - Date.parse(run.createdAt) < 30 * dayMs).length; + +const Waiting = ({ run, decide }: { run: ScreenRun; decide: (approved: boolean) => Promise }) => { + const [problem, setProblem] = useState(""); + const answer = async (approved: boolean) => { + setProblem(""); + try { + await decide(approved); + } catch (error) { + setProblem(error instanceof Error ? error.message : "That didn't work."); + } + }; + return ( +
  • + + {run.waitingFor[0]?.description ?? "A decision"} + Since {day(run.createdAt)} + {problem === "" ? null : {problem}} + + + + + +
  • + ); +}; + +export default function ${name}() { + const { runs, decide } = useWorkflow(${JSON.stringify(workflow)}); + const waiting = runs.filter((run) => run.waitingFor.length > 0); + const numbers = [ + { label: "Completed", value: countOf(runs, "completed") }, + { label: "Failed", value: countOf(runs, "failed") }, + { label: "Waiting", value: waiting.length }, + ]; + return ( +
    +
    +

    ${title}

    +

    ${intro}

    +
    +
    + {numbers.map(({ label, value }) => ( + + + {label}, last 30 days + + +

    {value}

    +
    +
    + ))} +
    + + + ${waitingTitle} + + + {waiting.length === 0 ? ( +

    Nothing waits for a decision.

    + ) : ( +
      + {waiting.map((run) => ( + { + const name = run.waitingFor[0]?.name; + if (name !== undefined) { + await decide(run.id, name, { approved }); + } + }} + key={run.id} + run={run} + /> + ))} +
    + )} +
    +
    + + + Latest runs + + + + + + Started + Status + Ended + + + + {runs.slice(0, 15).map((run) => ( + + {day(run.createdAt)} + + {statusLabels[run.status]} + + {run.endedAt === null ? "" : day(run.endedAt)} + + ))} + +
    +
    +
    +
    + ); +} +`; + +/** The screens of each engine, by engine name, then by screen name. */ +export const engineScreens: Record> = { + Finance: { + "invoice-approvals": workflowScreen({ + name: "InvoiceApprovals", + workflow: "supplier-invoices", + title: "Invoice approvals", + intro: + "Supplier invoices above the limit, waiting for finance, and how the last month went.", + waitingTitle: "Waiting for your approval", + }), + }, + "Service desk": { + complaints: workflowScreen({ + name: "Complaints", + workflow: "complaint-acknowledgement", + title: "Complaints", + intro: + "Every complaint registered, and the compensation waiting for compliance.", + waitingTitle: "Compensation to approve", + }), + }, + "Client onboarding": { + handovers: workflowScreen({ + name: "Handovers", + workflow: "file-handover", + title: "File handovers", + intro: "Complete client files waiting for an adviser to take them over.", + waitingTitle: "Files to take over", + }), + }, +}; + +/** + * The engines whose screens an admin approves as the seed runs; the + * others' wait for an admin, so the dashboard shows one to approve. + */ +export const approvedScreens: ReadonlySet = new Set([ + "Finance", + "Client onboarding", +]); From 4c390d51aa93d6e02acdaa6d2f2f9c77147e1063 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 22:36:22 +0000 Subject: [PATCH 2/3] fix: resume an interrupted engine upgrade, and check every seeded screen builds The upgrade compares against the engine's latest version, so one committed before a stop is made current instead of committed again; every engine's screens must build, approved or not. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DGjkfKxJZ9eCUeQ9YexmJF --- scripts/seed.ts | 48 ++++++++++++++++++++++++++++-------------------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/scripts/seed.ts b/scripts/seed.ts index d623effb5..935e7ac41 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -238,21 +238,24 @@ const seedCollection = async ( }; /** - * Approves, as an admin, every screen of the App's current version that - * builds and isn't approved yet, so its people see their data in it. + * Checks, as an admin, that every screen of the App's current version + * builds, and approves those not approved yet when `approve`, so its + * people see their data in them. A screen left unapproved still has to + * build: one that doesn't fails the seed, not the first person to open it. */ -const approveScreens = async ( +const reviewScreens = async ( adminSession: Session, - app: string + app: string, + approve: boolean ): Promise => { const review = await adminSession.api.screenTrust.review(app); - if (review.screens.every(({ trust }) => trust === "approved")) { - return; - } const artifacts = builtArtifacts(review.screens); if (artifacts.length < review.screens.length) { throw new Error(`Not every screen of ${review.name} builds`); } + if (!approve || review.screens.every(({ trust }) => trust === "approved")) { + return; + } await adminSession.api.screenTrust.approve(app, { version: review.version, generation: review.generation, @@ -303,24 +306,29 @@ const seedEngine = async ( // An admin makes it current, which approves its code. await adminSession.api.apps.versions.setCurrent(app.id, version); } else { - // An engine seeded before it had apps gets them as its next version. - const { screens: has } = await owner.api.apps.contents(app.id); - const missing = Object.fromEntries( - Object.entries(screens).filter( - ([path]) => !has.includes(path.slice("screens/".length, -".tsx".length)) - ) + // An engine seeded before it had its apps, or with older ones, gets + // them as its next version: over its latest version, which a seed + // stopped before making it current may have committed already. + const [latest] = await owner.api.apps.versions.list(app.id); + const version = latest?.version ?? app.currentVersion; + const files = await owner.api.apps.files.read(app.id, version); + const changed = Object.fromEntries( + Object.entries(screens).filter(([path, code]) => files[path] !== code) ); - if (Object.keys(missing).length > 0) { - const { version } = await owner.api.apps.files.commit( + let current = version; + if (Object.keys(changed).length > 0) { + ({ version: current } = await owner.api.apps.files.commit( app.id, - missing, + changed, "Add the engine's apps" - ); - await adminSession.api.apps.versions.setCurrent(app.id, version); + )); + } + if (current !== app.currentVersion) { + await adminSession.api.apps.versions.setCurrent(app.id, current); } } - if (approvedScreens.has(seed.name) && Object.keys(screens).length > 0) { - await approveScreens(adminSession, app.id); + if (Object.keys(screens).length > 0) { + await reviewScreens(adminSession, app.id, approvedScreens.has(seed.name)); } const shared = await owner.api.apps.members.list(app.id); for (const { team, role } of seed.teams) { From b9508cdfa9e1962a21d64318f94a453403952649 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 22:56:24 +0000 Subject: [PATCH 3/3] fix: make current only a version that holds exactly the seed's screen change An engine without seeded screens is never touched, and a later version with anyone else's changes is left unreleased, with the engine's apps as they are. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DGjkfKxJZ9eCUeQ9YexmJF --- scripts/seed.ts | 67 +++++++++++++++++++++++++++++++++---------------- 1 file changed, 46 insertions(+), 21 deletions(-) diff --git a/scripts/seed.ts b/scripts/seed.ts index 935e7ac41..90e63e6bf 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -263,6 +263,50 @@ const reviewScreens = async ( }); }; +/** + * Brings an engine seeded before it had its apps, or with older ones, to + * the seed's screens, as its next version made current. Only the seed's + * own change is made current: a later version that holds exactly the + * current one plus the seed's screens (a seed stopped before making it + * current) is reused; one with anyone else's changes is left alone, and + * so is the engine, as committing over it would release them. + */ +const upgradeScreens = async ( + owner: Session, + adminSession: Session, + app: { id: string; name: string; currentVersion: number | null }, + screens: Readonly> +): Promise => { + const current = app.currentVersion ?? undefined; + const files = await owner.api.apps.files.read(app.id, current); + const changed = Object.fromEntries( + Object.entries(screens).filter(([path, code]) => files[path] !== code) + ); + if (Object.keys(changed).length === 0) { + return; + } + const [latest] = await owner.api.apps.versions.list(app.id); + if (latest !== undefined && latest.version !== current) { + const later = await owner.api.apps.files.read(app.id, latest.version); + const upgraded = { ...files, ...screens }; + const paths = new Set([...Object.keys(later), ...Object.keys(upgraded)]); + if ([...paths].some((path) => later[path] !== upgraded[path])) { + console.warn( + `${app.name} has a version that isn't current, with changes of its own: its apps are left as they are.` + ); + return; + } + await adminSession.api.apps.versions.setCurrent(app.id, latest.version); + return; + } + const { version } = await owner.api.apps.files.commit( + app.id, + changed, + "Add the engine's apps" + ); + await adminSession.api.apps.versions.setCurrent(app.id, version); +}; + /** * An engine: created by its owner with its workflows and apps, its version * made current by an admin, its apps approved where the seed says so, @@ -305,27 +349,8 @@ const seedEngine = async ( ); // An admin makes it current, which approves its code. await adminSession.api.apps.versions.setCurrent(app.id, version); - } else { - // An engine seeded before it had its apps, or with older ones, gets - // them as its next version: over its latest version, which a seed - // stopped before making it current may have committed already. - const [latest] = await owner.api.apps.versions.list(app.id); - const version = latest?.version ?? app.currentVersion; - const files = await owner.api.apps.files.read(app.id, version); - const changed = Object.fromEntries( - Object.entries(screens).filter(([path, code]) => files[path] !== code) - ); - let current = version; - if (Object.keys(changed).length > 0) { - ({ version: current } = await owner.api.apps.files.commit( - app.id, - changed, - "Add the engine's apps" - )); - } - if (current !== app.currentVersion) { - await adminSession.api.apps.versions.setCurrent(app.id, current); - } + } else if (Object.keys(screens).length > 0) { + await upgradeScreens(owner, adminSession, app, screens); } if (Object.keys(screens).length > 0) { await reviewScreens(adminSession, app.id, approvedScreens.has(seed.name));