import type { Doc, Id } from "@code/backend/convex/_generated/dataModel"; // --------------------------------------------------------------------------- // Work Unit card projection — pure functions that turn Convex documents into // the collapsed/expanded Work Unit views used by the Work OS surface. // // Counts are derived from issue-scoped evidence: events for artifacts/steps/ // PRs, and authenticated signalIssueAttachments for linked Signals. We never // use project-wide artifact lists or signal lists. // --------------------------------------------------------------------------- export type ProjectEvent = Doc<"projectEvents">; export type ProjectIssue = Doc<"projectIssues">; /** A Signal linked to a Work Unit via signalIssueAttachments. */ export interface LinkedSignal { readonly signalId: Id<"signals">; readonly title: string; readonly summary: string; readonly sourceCount: number; readonly createdAt: number; } export interface WorkUnitCard { readonly issueId: Id<"projectIssues">; readonly number: number; readonly title: string; readonly status: ProjectIssue["status"]; readonly summary: string; readonly signalCount: number; readonly currentActivity: string | null; readonly stepCount: number; readonly artifactCount: number; readonly hasPullRequest: boolean; readonly needsInput: boolean; readonly updatedAt: number; } export interface WorkUnitActivityItem { readonly label: string; readonly detail: string; readonly time: string; readonly kind: string; readonly createdAt: number; } export interface WorkUnitDetail { readonly issue: ProjectIssue; readonly activity: readonly WorkUnitActivityItem[]; readonly stepCount: number; readonly artifactCount: number; readonly artifactPaths: readonly string[]; readonly signalCount: number; readonly linkedSignals: readonly LinkedSignal[]; readonly hasPullRequest: boolean; readonly pullRequestUrl: string | null; readonly pullRequestNumber: number | null; readonly needsInput: boolean; readonly latestSummary: string | null; } // --------------------------------------------------------------------------- // Time formatting // --------------------------------------------------------------------------- export const formatRelativeTime = (timestamp: number): string => { const minutes = Math.max(0, Math.round((Date.now() - timestamp) / 60_000)); if (minutes < 1) { return "just now"; } if (minutes < 60) { return `${minutes}m ago`; } const hours = Math.round(minutes / 60); if (hours < 24) { return `${hours}h ago`; } return `${Math.round(hours / 24)}d ago`; }; export const formatClockTime = (timestamp: number): string => new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit", }).format(timestamp); // --------------------------------------------------------------------------- // Event filtering // --------------------------------------------------------------------------- export const eventsForIssue = ( events: readonly ProjectEvent[] | undefined, issueId: Id<"projectIssues"> ): readonly ProjectEvent[] => (events ?? []).filter((e) => e.issueId === issueId); // --------------------------------------------------------------------------- // Event-to-activity projection // --------------------------------------------------------------------------- interface ActivityProjection { readonly label: string; readonly detail: string; } const getString = ( data: Record, key: string, fallback: string ): string => (typeof data[key] === "string" ? (data[key] as string) : fallback); const ACTIVITY_HANDLERS: Record< string, (data: Record, event: ProjectEvent) => ActivityProjection > = { "agent.completed": (data) => ({ detail: getString(data, "summary", "Run completed successfully"), label: "Work completed", }), "agent.failed": (data) => ({ detail: getString(data, "error", "The run stopped unexpectedly"), label: "Run failed", }), "agent.needs-input": () => ({ detail: "Waiting for a decision or missing information", label: "Needs your input", }), "agent.working": () => ({ detail: "The project manager is progressing the issue", label: "Agent working", }), "agent.workspace.created": (data) => ({ detail: `Workspace ready on ${getString(data, "branchName", "work branch")}`, label: "Workspace prepared", }), "artifact.updated": (data) => ({ detail: `Evidence revised in ${getString(data, "path", "artifact")} (r${data.revision ?? "?"})`, label: "Evidence updated", }), "gitea.lifecycle.updated": (data) => ({ detail: `${getString(data, "status", "updated")} on ${getString(data, "branch", "branch")}`, label: "Git lifecycle", }), "gitea.pull_request.created": (data) => { const pr = data.pullRequest as { number?: number } | undefined; return { detail: `Pull request #${pr?.number ?? "?"} opened for review`, label: "Pull request opened", }; }, "issue.created": (data) => ({ detail: `Issue #${data.number ?? "?"} entered the loop`, label: "Work created", }), "issue.queued": (data) => ({ detail: `Queued #${data.number ?? "?"} for the project manager`, label: "Work queued", }), "signal.attached": (data) => ({ detail: `Signal "${getString(data, "signalProblemTitle", "Unknown")}" linked to this Work Unit`, label: "Signal linked", }), }; const projectEventToActivity = (event: ProjectEvent): ActivityProjection => { const data = (event.data ?? {}) as Record; const handler = ACTIVITY_HANDLERS[event.kind]; if (handler) { return handler(data, event); } return { detail: event.kind, label: "Activity" }; }; export const buildActivityTimeline = ( issueEvents: readonly ProjectEvent[] ): readonly WorkUnitActivityItem[] => [...issueEvents] .toSorted((a, b) => b.createdAt - a.createdAt) .map((event) => { const projection = projectEventToActivity(event); return { createdAt: event.createdAt, detail: projection.detail, kind: event.kind, label: projection.label, time: formatRelativeTime(event.createdAt), }; }); // --------------------------------------------------------------------------- // PR extraction from issue-scoped events // --------------------------------------------------------------------------- export interface PullRequestInfo { readonly hasPR: boolean; readonly url: string | null; readonly number: number | null; } export const extractPullRequestFromEvents = ( issueEvents: readonly ProjectEvent[] ): PullRequestInfo => { const prEvent = issueEvents.find( (e) => e.kind === "gitea.pull_request.created" ); if (!prEvent) { return { hasPR: false, number: null, url: null }; } const data = prEvent.data as { pullRequest?: { url?: string; number?: number }; }; const pr = data.pullRequest; return { hasPR: true, number: pr?.number ?? null, url: pr?.url ?? null, }; }; // --------------------------------------------------------------------------- // Latest agent summary // --------------------------------------------------------------------------- export const extractLatestSummary = ( issueEvents: readonly ProjectEvent[] ): string | null => { const summaryEvent = [...issueEvents] .toSorted((a, b) => b.createdAt - a.createdAt) .find((e) => e.kind === "agent.completed" || e.kind === "agent.failed"); if (!summaryEvent) { return null; } const data = summaryEvent.data as { summary?: string; error?: string }; if (summaryEvent.kind === "agent.completed") { return data.summary ?? null; } return data.error ?? null; }; // --------------------------------------------------------------------------- // Artifact path extraction from issue-scoped artifact.updated events // --------------------------------------------------------------------------- export const extractArtifactPaths = ( issueEvents: readonly ProjectEvent[] ): readonly string[] => { const paths = new Set(); for (const event of issueEvents) { if (event.kind !== "artifact.updated") { continue; } const data = event.data as { path?: string }; if (typeof data.path === "string") { paths.add(data.path); } } return [...paths].toSorted(); }; // --------------------------------------------------------------------------- // Card builder — derives counts from issue-scoped events + linked signals // --------------------------------------------------------------------------- export const buildWorkUnitCard = ({ issue, issueEvents, linkedSignals, }: { readonly issue: ProjectIssue; readonly issueEvents: readonly ProjectEvent[]; readonly linkedSignals: readonly LinkedSignal[]; }): WorkUnitCard => { const sorted = [...issueEvents].toSorted((a, b) => b.createdAt - a.createdAt); const [latestEvent] = sorted; const currentActivity = latestEvent ? projectEventToActivity(latestEvent).label : null; const prInfo = extractPullRequestFromEvents(issueEvents); const summary = extractLatestSummary(issueEvents) ?? issue.body.slice(0, 140); const artifactPaths = extractArtifactPaths(issueEvents); return { artifactCount: artifactPaths.length, currentActivity, hasPullRequest: prInfo.hasPR, issueId: issue._id, needsInput: issue.status === "needs-input", number: issue.number, signalCount: linkedSignals.length, status: issue.status, stepCount: issueEvents.length, summary, title: issue.title, updatedAt: issue.updatedAt, }; }; // --------------------------------------------------------------------------- // Detail builder — same per-issue derivation, plus linked signals // --------------------------------------------------------------------------- export const buildWorkUnitDetail = ({ issue, issueEvents, linkedSignals, }: { readonly issue: ProjectIssue; readonly issueEvents: readonly ProjectEvent[]; readonly linkedSignals: readonly LinkedSignal[]; }): WorkUnitDetail => { const activity = buildActivityTimeline(issueEvents); const prInfo = extractPullRequestFromEvents(issueEvents); const artifactPaths = extractArtifactPaths(issueEvents); return { activity, artifactCount: artifactPaths.length, artifactPaths, hasPullRequest: prInfo.hasPR, issue, latestSummary: extractLatestSummary(issueEvents), linkedSignals, needsInput: issue.status === "needs-input", pullRequestNumber: prInfo.number, pullRequestUrl: prInfo.url, signalCount: linkedSignals.length, stepCount: issueEvents.length, }; };