Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 93 additions & 5 deletions scripts/seed.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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";
Expand All @@ -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;

Expand Down Expand Up @@ -235,7 +238,78 @@ const seedCollection = async (
};

/**
* An engine: created by its owner, its version made current by an admin,
* 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 reviewScreens = async (
adminSession: Session,
app: string,
approve: boolean
): Promise<void> => {
const review = await adminSession.api.screenTrust.review(app);
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,
artifacts,
});
};

/**
* 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<Record<string, string>>
): Promise<void> => {
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,
* shared with its teams, its deciders set, and, while it has none, its
* runs. Returns its ID, and how many runs it got.
*/
Expand All @@ -253,19 +327,33 @@ 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<string, string> = { "AGENTS.md": seed.agents };
const files: Record<string, string> = {
"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;
}
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 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));
}
const shared = await owner.api.apps.members.list(app.id);
for (const { team, role } of seed.teams) {
Expand Down
200 changes: 200 additions & 0 deletions scripts/seed/screens.ts
Original file line number Diff line number Diff line change
@@ -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<ScreenRun["status"], string> = {
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<void> }) => {
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 (
<li className="flex flex-wrap items-center justify-between gap-2 border-b py-2 last:border-b-0">
<span className="flex flex-col">
<span>{run.waitingFor[0]?.description ?? "A decision"}</span>
<span className="text-muted-foreground text-sm">Since {day(run.createdAt)}</span>
{problem === "" ? null : <span className="text-destructive text-sm">{problem}</span>}
</span>
<span className="flex gap-2">
<Button onClick={() => void answer(true)} size="sm">Approve</Button>
<Button onClick={() => void answer(false)} size="sm" variant="outline">Reject</Button>
</span>
</li>
);
};

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 (
<main className="flex flex-col gap-4 p-4">
<header className="flex flex-col gap-1">
<h1 className="text-xl font-medium">${title}</h1>
<p className="text-muted-foreground">${intro}</p>
</header>
<div className="grid grid-cols-3 gap-4">
{numbers.map(({ label, value }) => (
<Card key={label}>
<CardHeader>
<CardDescription>{label}, last 30 days</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-medium">{value}</p>
</CardContent>
</Card>
))}
</div>
<Card>
<CardHeader>
<CardTitle>${waitingTitle}</CardTitle>
</CardHeader>
<CardContent>
{waiting.length === 0 ? (
<p className="text-muted-foreground">Nothing waits for a decision.</p>
) : (
<ul>
{waiting.map((run) => (
<Waiting
decide={async (approved) => {
const name = run.waitingFor[0]?.name;
if (name !== undefined) {
await decide(run.id, name, { approved });
}
}}
key={run.id}
run={run}
/>
))}
</ul>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Latest runs</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Started</TableHead>
<TableHead>Status</TableHead>
<TableHead>Ended</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{runs.slice(0, 15).map((run) => (
<TableRow key={run.id}>
<TableCell>{day(run.createdAt)}</TableCell>
<TableCell>
<Badge variant={run.status === "failed" ? "destructive" : "secondary"}>{statusLabels[run.status]}</Badge>
</TableCell>
<TableCell>{run.endedAt === null ? "" : day(run.endedAt)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</main>
);
}
`;

/** The screens of each engine, by engine name, then by screen name. */
export const engineScreens: Record<string, Record<string, string>> = {
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<string> = new Set([
"Finance",
"Client onboarding",
]);
Loading