Harden PRD page coverage and migration audit

This commit is contained in:
ZacharyZhang-NY committed 2026-05-21 09:04:36 -04:00
1 parent 83a51c6881
commit f107ecba31
12 files changed
+235 -25

No files matched your search

@@ -1,4 +1,5 @@
import { existsSync } from "node:fs";
import { createHash } from "node:crypto";
import { DatabaseSync, type StatementSync } from "node:sqlite";
import type { CreateMigrationReportInput } from "../../shared/commands";
import { createId } from "../../shared/ids";
@@ -37,13 +38,35 @@ export class MigrationService {
.filter((table) => availableTables.has(table))
.map((table) => [table, countRows(source.prepare(`select count(*) as count from ${table}`))]),
);
const tableChecksums = Object.fromEntries(
LEGACY_TABLES
.filter((table) => availableTables.has(table))
.map((table) => [table, tableChecksum(source, table)]),
);
const idMaps = Object.fromEntries(
LEGACY_TABLES
.filter((table) => availableTables.has(table))
.map((table) => [table, tableIds(source, table)]),
);
const missingTables = LEGACY_TABLES.filter((table) => !availableTables.has(table));
const failures = missingTables.map((table) => ({ table, reason: "missing_table" }));
const reportId = createId("mig");
const now = new Date().toISOString();
this.db.prepare(`
insert into migration_reports(id, org_id, source_ref, report_json, created_at)
values (?, ?, ?, ?, ?)
`).run(reportId, input.orgId, input.sourceDatabasePath, JSON.stringify({ tableCounts, missingTables }), now);
`).run(reportId, input.orgId, input.sourceDatabasePath, JSON.stringify({
tableCounts,
tableChecksums,
idMaps,
missingTables,
failures,
rollbackPack: {
sourceDatabasePath: input.sourceDatabasePath,
capturedAt: now,
restoreStrategy: "legacy_snapshot_restore",
},
}), now);
this.audit.record({
orgId: input.orgId,
entityType: "migration_report",
@@ -63,3 +86,16 @@ export class MigrationService {
function countRows(statement: StatementSync): number {
return (statement.get() as { count: number }).count;
}
function tableChecksum(source: DatabaseSync, table: string): string {
const hash = createHash("sha256");
const rows = source.prepare(`select * from ${table} order by rowid`).all();
for (const row of rows) hash.update(JSON.stringify(row));
return hash.digest("hex");
}
function tableIds(source: DatabaseSync, table: string): string[] {
const columns = source.prepare(`pragma table_info(${table})`).all() as Array<{ name: string }>;
if (!columns.some((column) => column.name === "id")) return [];
return source.prepare(`select id from ${table} order by id`).all().map((row) => String((row as { id: unknown }).id));
}
@@ -71,6 +71,8 @@ export class SnapshotService {
skillBindings,
skillRuntimeSnapshots: list(this.db, "skill_runtime_snapshots", "created_at desc"),
socialAccounts: list(this.db, "social_accounts", "created_at asc"),
socialAccountGrants: list(this.db, "social_account_grants", "created_at asc"),
browserActions: list(this.db, "browser_actions", "created_at desc"),
policyRules: list(this.db, "policy_rules", "created_at asc"),
migrationReports: list(this.db, "migration_reports", "created_at desc"),
auditEvents: list<AppSnapshot["auditEvents"][number]>(this.db, "audit_events", "created_at desc").slice(0, 200),
@@ -1,7 +1,7 @@
import { useState, type FormEvent, type ReactNode } from "react";
import { Avatar, Button, Field, Icon, IconButton } from "../shared/ui";
import { Dialog } from "../shared/Dialog";
import type { ChatSpaceRecord } from "../../../shared/domain";
import type { ChatAttachmentRecord, ChatSpaceRecord, FileObjectRecord } from "../../../shared/domain";
import type { PageProps } from "./types";
import { activeOrgId } from "./types";
@@ -18,10 +18,12 @@ export function ChatPage({ snapshot, actions }: PageProps) {
const spaces = snapshot.chatSpaces.filter((item) => item.orgId === orgId);
const employees = snapshot.employees.filter((item) => item.orgId === orgId);
const agents = snapshot.agents.filter((item) => item.orgId === orgId);
const files = snapshot.files.filter((item) => item.orgId === orgId);
const [spaceId, setSpaceId] = useState(spaces[0]?.id ?? "");
const [spaceOpen, setSpaceOpen] = useState(false);
const activeSpace = spaces.find((space) => space.id === spaceId) ?? spaces[0];
const messages = snapshot.chatMessages.filter((item) => item.orgId === orgId && item.spaceId === activeSpace?.id);
if (!activeSpace) throw new Error("At least one chat space is required.");
const messages = snapshot.chatMessages.filter((item) => item.orgId === orgId && item.spaceId === activeSpace.id);
const sender = employees[0];
const submit = (event: FormEvent<HTMLFormElement>) => {
@@ -31,6 +33,7 @@ export function ChatPage({ snapshot, actions }: PageProps) {
const bodyMd = messageBody(form);
if (!bodyMd) return;
event.currentTarget.reset();
const attachmentFileId = value(form, "attachmentFileId");
void actions.run(() => window.agentCompany.sendMessage({
orgId,
spaceId: activeSpace.id,
@@ -38,6 +41,7 @@ export function ChatPage({ snapshot, actions }: PageProps) {
senderType: "employee",
senderId: sender.id,
bodyMd,
attachmentFileIds: attachmentFileId ? [attachmentFileId] : [],
}));
};
@@ -82,10 +86,10 @@ export function ChatPage({ snapshot, actions }: PageProps) {
</aside>
<section style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", background: "var(--panel)" }}>
<header className="row" style={{ height: 52, padding: "0 24px", borderBottom: "1px solid var(--border-soft)" }}>
<Icon name={activeSpace?.visibility === "restricted" ? "lock" : "hash"} />
<div style={{ fontSize: 14, fontWeight: 600 }}>{activeSpace?.name ?? "Messages"}</div>
<Icon name={activeSpace.visibility === "restricted" ? "lock" : "hash"} />
<div style={{ fontSize: 14, fontWeight: 600 }}>{activeSpace.name}</div>
<div style={{ color: "var(--text-3)", borderLeft: "1px solid var(--border)", paddingLeft: 10 }}>
{activeSpace?.topic ?? ""}
{activeSpace.topic}
</div>
<div style={{ marginLeft: "auto" }}><Icon name="search" /></div>
</header>
@@ -99,6 +103,7 @@ export function ChatPage({ snapshot, actions }: PageProps) {
time={new Date(message.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
name={displayName(message.senderType, message.senderId, employees, agents)}
bot={message.senderType === "agent"}
files={messageFiles(message.id, snapshot.chatAttachments, files)}
/>
))}
</div>
@@ -107,7 +112,10 @@ export function ChatPage({ snapshot, actions }: PageProps) {
<textarea className="input" name="message" rows={2} aria-label="Message" />
<div className="row" style={{ paddingTop: 8, borderTop: "1px solid var(--border-soft)" }}>
<Icon name="attach" />
<Icon name="social" />
<select className="input" name="attachmentFileId" aria-label="File attachment" style={{ maxWidth: 220 }}>
<option value="">File</option>
{files.map((file) => <option key={file.id} value={file.id}>{file.name}</option>)}
</select>
<div style={{ marginLeft: "auto" }}>
<Button tone="primary" size="small" type="submit" disabled={!activeSpace || !sender}><Icon name="send" /> Send</Button>
</div>
@@ -158,7 +166,7 @@ function SpaceItem({
onClick: () => void;
}) {
return (
<button type="button" className={`nav-item ${active ? "active" : ""}`} onClick={onClick}>
<button type="button" aria-label={space.name} className={`nav-item ${active ? "active" : ""}`} onClick={onClick}>
<Icon name={space.visibility === "restricted" ? "lock" : "hash"} />
<span>{space.name}</span>
{count > 0 && <span className="nav-badge">{count}</span>}
@@ -166,7 +174,19 @@ function SpaceItem({
);
}
function MessageRow({ name, body, time, bot }: { name: string; body: string; time: string; bot: boolean }) {
function MessageRow({
name,
body,
time,
bot,
files,
}: {
name: string;
body: string;
time: string;
bot: boolean;
files: FileObjectRecord[];
}) {
return (
<div style={{ display: "flex", gap: 12, marginBottom: 18 }}>
<Avatar name={name} bot={bot} />
@@ -176,11 +196,34 @@ function MessageRow({ name, body, time, bot }: { name: string; body: string; tim
<span className="num" style={{ color: "var(--text-3)", fontSize: 11 }}>{time}</span>
</div>
<div style={{ lineHeight: 1.5, whiteSpace: "pre-wrap" }}>{body}</div>
{files.length > 0 && (
<div className="row" style={{ marginTop: 8, flexWrap: "wrap" }}>
{files.map((file) => (
<span key={file.id} className="attachment-pill">
<Icon name="file" size={13} /> {file.name}
</span>
))}
</div>
)}
</div>
</div>
);
}
function messageFiles(
messageId: string,
attachments: ChatAttachmentRecord[],
files: FileObjectRecord[],
): FileObjectRecord[] {
return attachments
.filter((attachment) => attachment.messageId === messageId)
.map((attachment) => {
const file = files.find((item) => item.id === attachment.fileId);
if (!file) throw new Error("Chat attachment file is missing.");
return file;
});
}
function displayName(
senderType: string,
senderId: string | null,
@@ -67,7 +67,7 @@ function TreeItem({
active?: boolean;
}) {
return (
<button type="button" className={`nav-item ${active ? "active" : ""}`} onClick={onClick}>
<button type="button" aria-label={label} className={`nav-item ${active ? "active" : ""}`} onClick={onClick}>
<Icon name={icon} />
<span>{label}</span>
{typeof count === "number" && <span className="nav-badge">{count}</span>}
@@ -4,7 +4,8 @@ import { activeOrgId, money } from "./types";
export function HomePage({ snapshot }: PageProps) {
const orgId = activeOrgId(snapshot);
const organization = snapshot.organizations.find((item) => item.id === orgId)!;
const organization = snapshot.organizations.find((item) => item.id === orgId);
if (!organization) throw new Error("Active organization is missing.");
const tasks = snapshot.tasks.filter((item) => item.orgId === orgId);
const approvals = snapshot.approvals.filter((item) => item.orgId === orgId);
const agents = snapshot.agents.filter((item) => item.orgId === orgId);
@@ -102,7 +102,7 @@ export function SettingsPage({ snapshot, actions }: PageProps) {
}
function SettingsNav({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) {
return <button type="button" className={`nav-item ${active ? "active" : ""}`} onClick={onClick}>{label}</button>;
return <button type="button" aria-label={label} className={`nav-item ${active ? "active" : ""}`} onClick={onClick}>{label}</button>;
}
function ProvidersPanel({ providers, onManage }: { providers: ProviderRecord[]; onManage: (provider: ProviderRecord) => void }) {
@@ -231,8 +231,9 @@ function MigrationPanel({ reports, onCreate }: { reports: MigrationReportRecord[
function MigrationReportRow({ report }: { report: MigrationReportRecord }) {
const body = parseMigrationReport(report.reportJson);
const idCount = Object.values(body.idMaps).reduce((sum, ids) => sum + ids.length, 0);
return (
<div className="table-row" style={{ gridTemplateColumns: "1.4fr 1fr 1fr" }}>
<div className="table-row" style={{ gridTemplateColumns: "1.4fr 1fr 1fr 1fr" }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 540, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={report.sourceRef}>{report.sourceRef}</div>
<div style={{ color: "var(--text-3)", fontSize: 11 }}>{new Date(report.createdAt).toLocaleString()}</div>
@@ -240,15 +241,36 @@ function MigrationReportRow({ report }: { report: MigrationReportRecord }) {
<div className="row" style={{ flexWrap: "wrap" }}>
{Object.entries(body.tableCounts).map(([table, count]) => <Pill key={table}>{table}: {count}</Pill>)}
</div>
<Pill>{body.missingTables.length} missing</Pill>
<Pill>{Object.keys(body.tableChecksums).length} checksums</Pill>
<div className="row" style={{ flexWrap: "wrap" }}>
<Pill>{idCount} ids</Pill>
<Pill>{body.failures.length} failures</Pill>
</div>
</div>
);
}
function parseMigrationReport(reportJson: string): { tableCounts: Record<string, number>; missingTables: string[] } {
const parsed = JSON.parse(reportJson) as { tableCounts?: Record<string, number>; missingTables?: string[] };
if (!parsed.tableCounts || !Array.isArray(parsed.missingTables)) throw new Error("Migration report is invalid.");
return { tableCounts: parsed.tableCounts, missingTables: parsed.missingTables };
function parseMigrationReport(reportJson: string): {
tableCounts: Record<string, number>;
tableChecksums: Record<string, string>;
idMaps: Record<string, string[]>;
failures: Array<{ table: string; reason: string }>;
} {
const parsed = JSON.parse(reportJson) as {
tableCounts?: Record<string, number>;
tableChecksums?: Record<string, string>;
idMaps?: Record<string, string[]>;
failures?: Array<{ table: string; reason: string }>;
};
if (!parsed.tableCounts || !parsed.tableChecksums || !parsed.idMaps || !Array.isArray(parsed.failures)) {
throw new Error("Migration report is invalid.");
}
return {
tableCounts: parsed.tableCounts,
tableChecksums: parsed.tableChecksums,
idMaps: parsed.idMaps,
failures: parsed.failures,
};
}
function requireRevision(
@@ -1,7 +1,12 @@
import { useState, type FormEvent } from "react";
import { Avatar, Button, Field, Icon } from "../shared/ui";
import { Dialog } from "../shared/Dialog";
import { SOCIAL_PLATFORM, type SocialAccountRecord, type SocialPlatform } from "../../../shared/domain";
import {
SOCIAL_PLATFORM,
type SocialAccountGrantRecord,
type SocialAccountRecord,
type SocialPlatform,
} from "../../../shared/domain";
import type { PageProps } from "./types";
import { activeOrgId } from "./types";
@@ -13,6 +18,8 @@ export function SocialPage({ snapshot, actions }: PageProps) {
const orgId = activeOrgId(snapshot);
const [open, setOpen] = useState(false);
const accounts = snapshot.socialAccounts.filter((item) => item.orgId === orgId);
const grants = snapshot.socialAccountGrants.filter((item) => item.orgId === orgId);
const actionsByAccount = snapshot.browserActions.filter((item) => item.orgId === orgId);
const employees = snapshot.employees.filter((item) => item.orgId === orgId);
const submit = (event: FormEvent<HTMLFormElement>) => {
@@ -47,6 +54,8 @@ export function SocialPage({ snapshot, actions }: PageProps) {
key={account.id}
account={account}
employeeName={authorizedEmployeeName(account.authorizedEmployeeId, employees)}
grants={grants.filter((grant) => grant.socialAccountId === account.id)}
actionCount={actionsByAccount.filter((action) => action.socialAccountId === account.id).length}
onLogin={() => void actions.run(() => window.agentCompany.launchSocialLogin({ socialAccountId: account.id }))}
/>
))}
@@ -76,10 +85,14 @@ export function SocialPage({ snapshot, actions }: PageProps) {
function AccountCard({
account,
employeeName,
grants,
actionCount,
onLogin,
}: {
account: SocialAccountRecord;
employeeName: string | null;
grants: SocialAccountGrantRecord[];
actionCount: number;
onLogin: () => void;
}) {
return (
@@ -97,6 +110,10 @@ function AccountCard({
<span>{employeeName}</span>
</div>
)}
<div className="row" style={{ color: "var(--text-3)", fontSize: 11 }}>
<span>{grants.length} grants</span>
<span>{actionCount} actions</span>
</div>
<Button size="small" onClick={onLogin}>Sign in</Button>
</div>
);
@@ -30,6 +30,10 @@ export function Shell({
}) {
const organization = snapshot.organizations.find((item) => item.id === snapshot.activeOrgId);
const employee = snapshot.employees.find((item) => item.orgId === snapshot.activeOrgId);
const activeRoute = navItems.find((item) => item.id === route);
if (!organization) throw new Error("Active organization is missing.");
if (!employee) throw new Error("Active employee is missing.");
if (!activeRoute) throw new Error("Active route is missing.");
const badges = {
work: snapshot.tasks.filter((item) => item.orgId === snapshot.activeOrgId && item.status !== "done").length,
chat: snapshot.chatMessages.filter((item) => item.orgId === snapshot.activeOrgId).length,
@@ -41,7 +45,7 @@ export function Shell({
<aside className="sidebar">
<div className="brand">
<div className="brand-mark">A</div>
<div className="brand-name">{organization?.name ?? "AgentCompany"}</div>
<div className="brand-name">{organization.name}</div>
<Icon name="chevronDown" size={12} />
</div>
<nav className="nav">
@@ -49,6 +53,7 @@ export function Shell({
<button
key={item.id}
type="button"
aria-label={item.label}
className={`nav-item ${route === item.id ? "active" : ""}`}
onClick={() => setRoute(item.id)}
>
@@ -59,16 +64,16 @@ export function Shell({
))}
</nav>
<div className="sidebar-foot">
<Avatar name={employee?.displayName ?? "User"} size="sm" />
<div style={{ flex: 1, minWidth: 0 }}>{employee?.displayName ?? "User"}</div>
<Avatar name={employee.displayName} size="sm" />
<div style={{ flex: 1, minWidth: 0 }}>{employee.displayName}</div>
<Icon name="bell" />
</div>
</aside>
<main className="main">
<header className="topbar">
<span className="crumb-sub">{organization?.name ?? "Workspace"}</span>
<span className="crumb-sub">{organization.name}</span>
<span style={{ color: "var(--text-4)" }}>/</span>
<span className="crumb">{navItems.find((item) => item.id === route)?.label}</span>
<span className="crumb">{activeRoute.label}</span>
<div className="topbar-actions">
{actions}
{window.agentCompany.platform !== "darwin" && (
@@ -177,6 +177,20 @@ button { cursor: pointer; }
.pill.amber { background: var(--amber-soft); color: var(--amber); border-color: transparent; }
.pill.green { background: var(--green-soft); color: var(--green); border-color: transparent; }
.pill.violet { background: var(--accent-soft); color: var(--accent); border-color: transparent; }
.attachment-pill {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 260px;
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: var(--r-md);
background: var(--panel-2);
color: var(--text-2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.avatar {
width: 28px;
+22
View File
@@ -324,6 +324,26 @@ export interface SocialAccountRecord {
createdAt: string;
}
export interface SocialAccountGrantRecord {
id: string;
orgId: string;
socialAccountId: string;
employeeId: string;
role: string;
scopeJson: string;
createdAt: string;
}
export interface BrowserActionRecord {
id: string;
orgId: string;
socialAccountId: string;
actionType: string;
policyDecision: string;
evidenceFileId: string | null;
createdAt: string;
}
export interface AuditEventRecord {
id: string;
orgId: string;
@@ -380,6 +400,8 @@ export interface AppSnapshot {
skillBindings: SkillBindingRecord[];
skillRuntimeSnapshots: SkillRuntimeSnapshotRecord[];
socialAccounts: SocialAccountRecord[];
socialAccountGrants: SocialAccountGrantRecord[];
browserActions: BrowserActionRecord[];
policyRules: PolicyRuleRecord[];
migrationReports: MigrationReportRecord[];
auditEvents: AuditEventRecord[];
+39 -1
View File
@@ -1,4 +1,4 @@
import { _electron as electron, expect, test } from "@playwright/test";
import { _electron as electron, expect, test, type Page } from "@playwright/test";
import { mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
@@ -50,7 +50,45 @@ test("onboards, hires, and creates work in the Electron shell", async () => {
await page.getByRole("button", { name: "Approvals" }).click();
await expect(page.getByText("Production deploy").first()).toBeVisible();
await page.getByRole("button", { name: "Messages" }).click();
await page.getByRole("button", { name: "New space" }).click();
await page.getByLabel("Name").fill("release");
await page.getByLabel("Topic").fill("Release coordination");
await page.locator(".modal").getByRole("button", { name: "Create" }).click();
await expect(page.getByText("release").first()).toBeVisible();
await page.getByRole("textbox", { name: "Message" }).fill("Release channel opened.");
await page.getByRole("button", { name: "Send" }).click();
await expect(page.getByText("Release channel opened.")).toBeVisible();
await verifyPagesDoNotOverflow(page);
} finally {
await app.close();
}
});
async function verifyPagesDoNotOverflow(page: Page): Promise<void> {
for (const size of [{ width: 1280, height: 860 }, { width: 980, height: 700 }]) {
await page.setViewportSize(size);
for (const nav of ["Home", "Team", "Work", "Messages", "Files", "Approvals", "Social", "Settings"]) {
await page.getByRole("button", { name: nav }).click();
await expectPageToFit(page);
}
for (const tab of ["Providers", "Skills", "Policies", "Migration"]) {
await page.getByRole("button", { name: "Settings" }).click();
await page.getByRole("button", { name: tab }).click();
await expectPageToFit(page);
}
}
}
async function expectPageToFit(page: Page): Promise<void> {
const dimensions = await page.evaluate(() => ({
documentScrollWidth: document.documentElement.scrollWidth,
documentClientWidth: document.documentElement.clientWidth,
bodyScrollWidth: document.body.scrollWidth,
bodyClientWidth: document.body.clientWidth,
}));
expect(dimensions.documentScrollWidth).toBeLessThanOrEqual(dimensions.documentClientWidth + 1);
expect(dimensions.bodyScrollWidth).toBeLessThanOrEqual(dimensions.bodyClientWidth + 1);
}
@@ -198,6 +198,16 @@ describe("domain services", () => {
const state = snapshot.load();
expect(state.migrationReports).toHaveLength(1);
expect(state.migrationReports[0]?.reportJson).toContain("\"agents\":1");
const report = JSON.parse(state.migrationReports[0]!.reportJson) as {
tableChecksums: Record<string, string>;
idMaps: Record<string, string[]>;
failures: Array<{ table: string; reason: string }>;
rollbackPack: { sourceDatabasePath: string };
};
expect(report.tableChecksums.agents).toHaveLength(64);
expect(report.idMaps.agents).toEqual(["agent-1"]);
expect(report.failures.some((failure) => failure.reason === "missing_table")).toBe(true);
expect(report.rollbackPack.sourceDatabasePath).toBe(legacyPath);
connection.close();
});