- Archive dormant apps (daemon, desktop, native, tui) and superseded packages/server into repos/ for reference - Archive 21 dead web components (chat page shell, work-os cluster, strays) into repos/web/; keep reused message-rendering primitives in components/chat - Merge @code/work-os into @code/primitives; work.ts absorbed via ./work subpath and barrel export; update all four importers - Add docs/futures.md tracking audio/video (blocked at Flue + provider), web layout cleanup, and message rendering quality - Repoint dev:zopu script to @code/agents (the live Flue server) - Note repos/ reference-code convention in AGENTS.md
330 lines
10 KiB
TypeScript
330 lines
10 KiB
TypeScript
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<string, unknown>,
|
|
key: string,
|
|
fallback: string
|
|
): string => (typeof data[key] === "string" ? (data[key] as string) : fallback);
|
|
|
|
const ACTIVITY_HANDLERS: Record<
|
|
string,
|
|
(data: Record<string, unknown>, 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<string, unknown>;
|
|
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<string>();
|
|
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,
|
|
};
|
|
};
|