feat(web): Work OS surface with Work Units, Signals, dual-mode composer
Lane C implementation of the Zopu Web Work OS for dogfooding: - Work Unit card/detail projections built from per-issue events only (artifact counts from distinct artifact.updated paths, PR from gitea events, activity timeline, agent summaries). Signal linkage is explicitly 0/unavailable until Lane A relations are integrated. - Persistent composer with Project and Work Unit mode switching. Project mode sends to the global Zopu agent; Work Unit mode sends to the issue-scoped project-manager agent identity. - Collapsed Work Unit cards show title, summary, signal/step/artifact counts, current activity, PR indicator, needs-input indicator. - Expanded Work Unit detail shows objective, timeline, artifacts, PR with directly usable link, needs-input alert, start action. - Project-level Signals panel in the sidebar (not attributed to any issue until a signal-to-issue relation exists). - Dark theme with calm, Apple-like visual direction. - 21 projection tests including 5 cross-issue isolation tests and 2 signal isolation tests proving one issue cannot inherit another issue's artifacts, PR, summary, or project signals. - Mobile-responsive: detail overlay on mobile, sidebar on desktop. No backend or schema changes. Uses existing Convex contracts and Flue agent transport throughout.
This commit is contained in:
@@ -0,0 +1,422 @@
|
||||
import type { Doc, Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
buildActivityTimeline,
|
||||
buildWorkUnitCard,
|
||||
buildWorkUnitDetail,
|
||||
eventsForIssue,
|
||||
extractArtifactPaths,
|
||||
extractLatestSummary,
|
||||
extractPullRequestFromEvents,
|
||||
} from "./work-unit-projection";
|
||||
|
||||
type Issue = Doc<"projectIssues">;
|
||||
type Event = Doc<"projectEvents">;
|
||||
|
||||
const ISSUE_A = "issue-a" as Id<"projectIssues">;
|
||||
const ISSUE_B = "issue-b" as Id<"projectIssues">;
|
||||
|
||||
const makeIssue = (overrides: Partial<Issue> = {}): Issue =>
|
||||
({
|
||||
_creationTime: 1,
|
||||
_id: ISSUE_A,
|
||||
body: "Fix the Safari OAuth callback so users can complete sign-in.",
|
||||
createdAt: 1000,
|
||||
number: 1,
|
||||
projectId: "project-1" as Id<"projects">,
|
||||
status: "open",
|
||||
title: "Fix Safari OAuth callback",
|
||||
updatedAt: 2000,
|
||||
...overrides,
|
||||
}) as Issue;
|
||||
|
||||
const makeEvent = (
|
||||
kind: string,
|
||||
data: Record<string, unknown>,
|
||||
overrides: Partial<Event> = {}
|
||||
): Event =>
|
||||
({
|
||||
_creationTime: 1,
|
||||
_id: `evt-${kind}-${overrides.createdAt ?? 1000}` as Id<"projectEvents">,
|
||||
createdAt: overrides.createdAt ?? 1000,
|
||||
data,
|
||||
issueId: ISSUE_A,
|
||||
kind,
|
||||
projectId: "project-1" as Id<"projects">,
|
||||
...overrides,
|
||||
}) as Event;
|
||||
|
||||
describe("eventsForIssue", () => {
|
||||
test("filters to a specific issue and ignores undefined", () => {
|
||||
const events: Event[] = [
|
||||
makeEvent("issue.created", { number: 1 }, { createdAt: 1000 }),
|
||||
makeEvent(
|
||||
"issue.created",
|
||||
{ number: 2 },
|
||||
{ createdAt: 2000, issueId: ISSUE_B }
|
||||
),
|
||||
];
|
||||
expect(eventsForIssue(events, ISSUE_A)).toHaveLength(1);
|
||||
expect(eventsForIssue(undefined, ISSUE_A)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractArtifactPaths", () => {
|
||||
test("collects distinct paths from artifact.updated events", () => {
|
||||
const events: Event[] = [
|
||||
makeEvent(
|
||||
"artifact.updated",
|
||||
{ path: "work.md", revision: 1 },
|
||||
{ createdAt: 1000 }
|
||||
),
|
||||
makeEvent(
|
||||
"artifact.updated",
|
||||
{ path: "work.md", revision: 2 },
|
||||
{ createdAt: 2000 }
|
||||
),
|
||||
makeEvent(
|
||||
"artifact.updated",
|
||||
{ path: "artifacts.md", revision: 1 },
|
||||
{ createdAt: 3000 }
|
||||
),
|
||||
makeEvent("issue.created", { number: 1 }, { createdAt: 4000 }),
|
||||
];
|
||||
expect(extractArtifactPaths(events)).toEqual(["artifacts.md", "work.md"]);
|
||||
});
|
||||
|
||||
test("returns empty for no artifact events", () => {
|
||||
expect(extractArtifactPaths([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildWorkUnitCard", () => {
|
||||
test("projects a fresh issue with no events", () => {
|
||||
const card = buildWorkUnitCard({
|
||||
issue: makeIssue(),
|
||||
issueEvents: [],
|
||||
});
|
||||
expect(card.title).toBe("Fix Safari OAuth callback");
|
||||
expect(card.status).toBe("open");
|
||||
expect(card.stepCount).toBe(0);
|
||||
expect(card.currentActivity).toBeNull();
|
||||
expect(card.needsInput).toBe(false);
|
||||
expect(card.hasPullRequest).toBe(false);
|
||||
expect(card.artifactCount).toBe(0);
|
||||
expect(card.signalCount).toBe(0);
|
||||
expect(card.summary).toContain("Fix the Safari OAuth");
|
||||
});
|
||||
|
||||
test("counts per-issue artifacts from events, not project-wide", () => {
|
||||
const events: Event[] = [
|
||||
makeEvent(
|
||||
"artifact.updated",
|
||||
{ path: "work.md", revision: 1 },
|
||||
{ createdAt: 1000 }
|
||||
),
|
||||
makeEvent(
|
||||
"artifact.updated",
|
||||
{ path: "artifacts.md", revision: 1 },
|
||||
{ createdAt: 2000 }
|
||||
),
|
||||
];
|
||||
const card = buildWorkUnitCard({
|
||||
issue: makeIssue({ status: "working" }),
|
||||
issueEvents: events,
|
||||
});
|
||||
expect(card.artifactCount).toBe(2);
|
||||
expect(card.stepCount).toBe(2);
|
||||
});
|
||||
|
||||
test("reflects needs-input status and activity from events", () => {
|
||||
const events = [
|
||||
makeEvent("issue.created", { number: 1 }, { createdAt: 1000 }),
|
||||
makeEvent("issue.queued", { number: 1 }, { createdAt: 2000 }),
|
||||
makeEvent("agent.needs-input", {}, { createdAt: 3000 }),
|
||||
];
|
||||
const card = buildWorkUnitCard({
|
||||
issue: makeIssue({ status: "needs-input" }),
|
||||
issueEvents: events,
|
||||
});
|
||||
expect(card.needsInput).toBe(true);
|
||||
expect(card.stepCount).toBe(3);
|
||||
expect(card.currentActivity).toBe("Needs your input");
|
||||
});
|
||||
|
||||
test("detects PR from gitea event", () => {
|
||||
const events = [
|
||||
makeEvent("issue.created", { number: 1 }, { createdAt: 1000 }),
|
||||
makeEvent(
|
||||
"gitea.pull_request.created",
|
||||
{
|
||||
pullRequest: { number: 42, url: "https://git.example.com/pulls/42" },
|
||||
},
|
||||
{ createdAt: 5000 }
|
||||
),
|
||||
];
|
||||
const card = buildWorkUnitCard({
|
||||
issue: makeIssue({ status: "completed" }),
|
||||
issueEvents: events,
|
||||
});
|
||||
expect(card.hasPullRequest).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractLatestSummary", () => {
|
||||
test("returns the most recent agent summary", () => {
|
||||
const events = [
|
||||
makeEvent("agent.working", {}, { createdAt: 1000 }),
|
||||
makeEvent(
|
||||
"agent.completed",
|
||||
{ summary: "All tests passed." },
|
||||
{ createdAt: 5000 }
|
||||
),
|
||||
];
|
||||
expect(extractLatestSummary(events)).toBe("All tests passed.");
|
||||
});
|
||||
|
||||
test("returns null when no terminal agent event exists", () => {
|
||||
const events = [
|
||||
makeEvent("issue.created", { number: 1 }, { createdAt: 1000 }),
|
||||
];
|
||||
expect(extractLatestSummary(events)).toBeNull();
|
||||
});
|
||||
|
||||
test("prefers failure error over earlier completion", () => {
|
||||
const events = [
|
||||
makeEvent("agent.completed", { summary: "Done." }, { createdAt: 1000 }),
|
||||
makeEvent(
|
||||
"agent.failed",
|
||||
{ error: "Tests failed." },
|
||||
{ createdAt: 5000 }
|
||||
),
|
||||
];
|
||||
expect(extractLatestSummary(events)).toBe("Tests failed.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractPullRequestFromEvents", () => {
|
||||
test("extracts URL and number from gitea event", () => {
|
||||
const events = [
|
||||
makeEvent(
|
||||
"gitea.pull_request.created",
|
||||
{ pullRequest: { number: 7, url: "https://git.example.com/pulls/7" } },
|
||||
{ createdAt: 1000 }
|
||||
),
|
||||
];
|
||||
const info = extractPullRequestFromEvents(events);
|
||||
expect(info.hasPR).toBe(true);
|
||||
expect(info.url).toBe("https://git.example.com/pulls/7");
|
||||
expect(info.number).toBe(7);
|
||||
});
|
||||
|
||||
test("returns false when no PR event exists", () => {
|
||||
expect(extractPullRequestFromEvents([]).hasPR).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildActivityTimeline", () => {
|
||||
test("sorts newest first and maps labels", () => {
|
||||
const events = [
|
||||
makeEvent("issue.created", { number: 1 }, { createdAt: 1000 }),
|
||||
makeEvent("agent.completed", { summary: "Done." }, { createdAt: 5000 }),
|
||||
];
|
||||
const timeline = buildActivityTimeline(events);
|
||||
expect(timeline).toHaveLength(2);
|
||||
expect(timeline[0].kind).toBe("agent.completed");
|
||||
expect(timeline[0].label).toBe("Work completed");
|
||||
expect(timeline[1].kind).toBe("issue.created");
|
||||
expect(timeline[1].label).toBe("Work created");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildWorkUnitDetail", () => {
|
||||
test("aggregates all detail fields from per-issue events", () => {
|
||||
const events: Event[] = [
|
||||
makeEvent("issue.created", { number: 1 }, { createdAt: 1000 }),
|
||||
makeEvent(
|
||||
"artifact.updated",
|
||||
{ path: "work.md", revision: 2 },
|
||||
{ createdAt: 2000 }
|
||||
),
|
||||
makeEvent(
|
||||
"artifact.updated",
|
||||
{ path: "artifacts.md", revision: 1 },
|
||||
{ createdAt: 2500 }
|
||||
),
|
||||
makeEvent(
|
||||
"gitea.pull_request.created",
|
||||
{ pullRequest: { number: 3, url: "https://git.example.com/pulls/3" } },
|
||||
{ createdAt: 3000 }
|
||||
),
|
||||
];
|
||||
const detail = buildWorkUnitDetail({
|
||||
issue: makeIssue({ status: "completed" }),
|
||||
issueEvents: events,
|
||||
});
|
||||
expect(detail.stepCount).toBe(4);
|
||||
expect(detail.artifactCount).toBe(2);
|
||||
expect(detail.artifactPaths).toEqual(["artifacts.md", "work.md"]);
|
||||
expect(detail.signalCount).toBe(0);
|
||||
expect(detail.hasPullRequest).toBe(true);
|
||||
expect(detail.pullRequestUrl).toBe("https://git.example.com/pulls/3");
|
||||
expect(detail.pullRequestNumber).toBe(3);
|
||||
expect(detail.activity).toHaveLength(4);
|
||||
expect(detail.activity[0].kind).toBe("gitea.pull_request.created");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-issue isolation: one issue must never inherit another issue's
|
||||
// artifacts, PR, signals, or activity.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("cross-issue isolation", () => {
|
||||
const sharedEvents: Event[] = [
|
||||
makeEvent(
|
||||
"issue.created",
|
||||
{ number: 1 },
|
||||
{ createdAt: 1000, issueId: ISSUE_A }
|
||||
),
|
||||
makeEvent(
|
||||
"artifact.updated",
|
||||
{ path: "work.md", revision: 1 },
|
||||
{ createdAt: 2000, issueId: ISSUE_A }
|
||||
),
|
||||
makeEvent(
|
||||
"gitea.pull_request.created",
|
||||
{ pullRequest: { number: 5, url: "https://git.example.com/pulls/5" } },
|
||||
{ createdAt: 3000, issueId: ISSUE_A }
|
||||
),
|
||||
makeEvent(
|
||||
"agent.completed",
|
||||
{ summary: "Done for A." },
|
||||
{ createdAt: 4000, issueId: ISSUE_A }
|
||||
),
|
||||
// Issue B events — completely separate
|
||||
makeEvent(
|
||||
"issue.created",
|
||||
{ number: 2 },
|
||||
{ createdAt: 1500, issueId: ISSUE_B }
|
||||
),
|
||||
makeEvent(
|
||||
"artifact.updated",
|
||||
{ path: "artifacts.md", revision: 1 },
|
||||
{ createdAt: 2500, issueId: ISSUE_B }
|
||||
),
|
||||
makeEvent(
|
||||
"agent.failed",
|
||||
{ error: "Failed for B." },
|
||||
{ createdAt: 3500, issueId: ISSUE_B }
|
||||
),
|
||||
];
|
||||
|
||||
test("issue A does not inherit issue B's artifacts", () => {
|
||||
const eventsA = eventsForIssue(sharedEvents, ISSUE_A);
|
||||
const cardA = buildWorkUnitCard({
|
||||
issue: makeIssue({ _id: ISSUE_A, number: 1 }),
|
||||
issueEvents: eventsA,
|
||||
});
|
||||
expect(cardA.artifactCount).toBe(1);
|
||||
expect(cardA.stepCount).toBe(4);
|
||||
|
||||
const eventsB = eventsForIssue(sharedEvents, ISSUE_B);
|
||||
const cardB = buildWorkUnitCard({
|
||||
issue: makeIssue({ _id: ISSUE_B, number: 2, title: "Issue B" }),
|
||||
issueEvents: eventsB,
|
||||
});
|
||||
expect(cardB.artifactCount).toBe(1);
|
||||
expect(cardB.stepCount).toBe(3);
|
||||
});
|
||||
|
||||
test("issue A does not inherit issue B's PR", () => {
|
||||
const eventsB = eventsForIssue(sharedEvents, ISSUE_B);
|
||||
const cardB = buildWorkUnitCard({
|
||||
issue: makeIssue({ _id: ISSUE_B, number: 2, title: "Issue B" }),
|
||||
issueEvents: eventsB,
|
||||
});
|
||||
expect(cardB.hasPullRequest).toBe(false);
|
||||
});
|
||||
|
||||
test("issue A does not inherit issue B's summary", () => {
|
||||
const eventsA = eventsForIssue(sharedEvents, ISSUE_A);
|
||||
const summaryA = extractLatestSummary(eventsA);
|
||||
expect(summaryA).toBe("Done for A.");
|
||||
|
||||
const eventsB = eventsForIssue(sharedEvents, ISSUE_B);
|
||||
const summaryB = extractLatestSummary(eventsB);
|
||||
expect(summaryB).toBe("Failed for B.");
|
||||
});
|
||||
|
||||
test("detail for issue A lists only issue A's artifact paths", () => {
|
||||
const eventsA = eventsForIssue(sharedEvents, ISSUE_A);
|
||||
const detailA = buildWorkUnitDetail({
|
||||
issue: makeIssue({ _id: ISSUE_A, number: 1 }),
|
||||
issueEvents: eventsA,
|
||||
});
|
||||
expect(detailA.artifactPaths).toEqual(["work.md"]);
|
||||
expect(detailA.pullRequestNumber).toBe(5);
|
||||
});
|
||||
|
||||
test("detail for issue B lists only issue B's artifact paths and no PR", () => {
|
||||
const eventsB = eventsForIssue(sharedEvents, ISSUE_B);
|
||||
const detailB = buildWorkUnitDetail({
|
||||
issue: makeIssue({ _id: ISSUE_B, number: 2, title: "Issue B" }),
|
||||
issueEvents: eventsB,
|
||||
});
|
||||
expect(detailB.artifactPaths).toEqual(["artifacts.md"]);
|
||||
expect(detailB.hasPullRequest).toBe(false);
|
||||
expect(detailB.pullRequestUrl).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Signal isolation: a project may have signals, but no Work Unit should
|
||||
// claim them until a signal-to-issue relation is integrated.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("signal isolation", () => {
|
||||
test("card signalCount is 0 even when the project has signals", () => {
|
||||
// Simulate a project that has signals — these are NOT passed to the
|
||||
// card builder. The card must show 0 regardless.
|
||||
const projectHasSignals = [
|
||||
{
|
||||
sourceCount: 3,
|
||||
summary: "Three tickets about Safari",
|
||||
title: "Users report OAuth crash",
|
||||
},
|
||||
{
|
||||
sourceCount: 1,
|
||||
summary: "CI broke after merge",
|
||||
title: "Build failure on staging",
|
||||
},
|
||||
];
|
||||
|
||||
const card = buildWorkUnitCard({
|
||||
issue: makeIssue(),
|
||||
issueEvents: [],
|
||||
});
|
||||
// The card never sees projectSignals — signalCount is always 0.
|
||||
expect(card.signalCount).toBe(0);
|
||||
expect(projectHasSignals.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("detail signalCount is 0 even when the project has signals", () => {
|
||||
const projectHasSignals = [
|
||||
{
|
||||
sourceCount: 3,
|
||||
summary: "Three tickets",
|
||||
title: "Users report OAuth crash",
|
||||
},
|
||||
];
|
||||
|
||||
const detail = buildWorkUnitDetail({
|
||||
issue: makeIssue(),
|
||||
issueEvents: [
|
||||
makeEvent("issue.created", { number: 1 }, { createdAt: 1000 }),
|
||||
],
|
||||
});
|
||||
expect(detail.signalCount).toBe(0);
|
||||
expect(projectHasSignals.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,314 @@
|
||||
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.
|
||||
//
|
||||
// Every count is derived from issue-scoped events only. We never use
|
||||
// project-wide artifact lists or signal lists, because those would
|
||||
// incorrectly attribute evidence to every issue in the project. Signal
|
||||
// linkage is intentionally 0 until a Lane A signal-to-issue relation is
|
||||
// integrated.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ProjectEvent = Doc<"projectEvents">;
|
||||
export type ProjectIssue = Doc<"projectIssues">;
|
||||
|
||||
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 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",
|
||||
}),
|
||||
};
|
||||
|
||||
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 all counts from issue-scoped events only
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const buildWorkUnitCard = ({
|
||||
issue,
|
||||
issueEvents,
|
||||
}: {
|
||||
readonly issue: ProjectIssue;
|
||||
readonly issueEvents: readonly ProjectEvent[];
|
||||
}): 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,
|
||||
// No signal-to-issue relation exists in the event model yet; Lane A will
|
||||
// add one. Show 0 rather than falsely claiming project-wide signals.
|
||||
signalCount: 0,
|
||||
status: issue.status,
|
||||
stepCount: issueEvents.length,
|
||||
summary,
|
||||
title: issue.title,
|
||||
updatedAt: issue.updatedAt,
|
||||
};
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Detail builder — same per-issue derivation, plus artifact paths
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const buildWorkUnitDetail = ({
|
||||
issue,
|
||||
issueEvents,
|
||||
}: {
|
||||
readonly issue: ProjectIssue;
|
||||
readonly issueEvents: readonly ProjectEvent[];
|
||||
}): 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),
|
||||
needsInput: issue.status === "needs-input",
|
||||
pullRequestNumber: prInfo.number,
|
||||
pullRequestUrl: prInfo.url,
|
||||
signalCount: 0,
|
||||
stepCount: issueEvents.length,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user