feat(web): real linked Signal projection from signalIssueAttachments
Replace the deliberate signalCount of 0 with authenticated linked-Signal data from the Lane A signalIssueAttachments relation. - Add listLinkedSignalsForProject query (user-authenticated via requireProjectMember) returning issue-grouped LinkedSignalView data using the existing by_issue index. One query serves both card counts and expanded detail. - Update work-unit-projection builders to accept linkedSignals param; LinkedSignal type with signalId, title, summary, sourceCount, createdAt. - Feed real attachment data into collapsed card signalCount and expanded detail linked-signal list with provenance (source count, relative time). - Add signal.attached event handler to activity timeline projection. - 32 tests including 4 linked-signal projection tests, 1 signal.attached activity mapping test, and cross-issue signal isolation test proving issue A cannot inherit issue B's linked signals.
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
extractLatestSummary,
|
||||
extractPullRequestFromEvents,
|
||||
} from "./work-unit-projection";
|
||||
import type { LinkedSignal } from "./work-unit-projection";
|
||||
|
||||
type Issue = Doc<"projectIssues">;
|
||||
type Event = Doc<"projectEvents">;
|
||||
@@ -17,6 +18,17 @@ type Event = Doc<"projectEvents">;
|
||||
const ISSUE_A = "issue-a" as Id<"projectIssues">;
|
||||
const ISSUE_B = "issue-b" as Id<"projectIssues">;
|
||||
|
||||
const makeLinkedSignal = (
|
||||
overrides: Partial<LinkedSignal> = {}
|
||||
): LinkedSignal => ({
|
||||
createdAt: 5000,
|
||||
signalId: "sig-1" as Id<"signals">,
|
||||
sourceCount: 2,
|
||||
summary: "Three users hit the same error.",
|
||||
title: "OAuth crash on Safari",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeIssue = (overrides: Partial<Issue> = {}): Issue =>
|
||||
({
|
||||
_creationTime: 1,
|
||||
@@ -91,10 +103,11 @@ describe("extractArtifactPaths", () => {
|
||||
});
|
||||
|
||||
describe("buildWorkUnitCard", () => {
|
||||
test("projects a fresh issue with no events", () => {
|
||||
test("projects a fresh issue with no events and no linked signals", () => {
|
||||
const card = buildWorkUnitCard({
|
||||
issue: makeIssue(),
|
||||
issueEvents: [],
|
||||
linkedSignals: [],
|
||||
});
|
||||
expect(card.title).toBe("Fix Safari OAuth callback");
|
||||
expect(card.status).toBe("open");
|
||||
@@ -123,6 +136,7 @@ describe("buildWorkUnitCard", () => {
|
||||
const card = buildWorkUnitCard({
|
||||
issue: makeIssue({ status: "working" }),
|
||||
issueEvents: events,
|
||||
linkedSignals: [],
|
||||
});
|
||||
expect(card.artifactCount).toBe(2);
|
||||
expect(card.stepCount).toBe(2);
|
||||
@@ -137,6 +151,7 @@ describe("buildWorkUnitCard", () => {
|
||||
const card = buildWorkUnitCard({
|
||||
issue: makeIssue({ status: "needs-input" }),
|
||||
issueEvents: events,
|
||||
linkedSignals: [],
|
||||
});
|
||||
expect(card.needsInput).toBe(true);
|
||||
expect(card.stepCount).toBe(3);
|
||||
@@ -157,11 +172,68 @@ describe("buildWorkUnitCard", () => {
|
||||
const card = buildWorkUnitCard({
|
||||
issue: makeIssue({ status: "completed" }),
|
||||
issueEvents: events,
|
||||
linkedSignals: [],
|
||||
});
|
||||
expect(card.hasPullRequest).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("linked signal projection", () => {
|
||||
test("card signalCount reflects real linked signals", () => {
|
||||
const card = buildWorkUnitCard({
|
||||
issue: makeIssue(),
|
||||
issueEvents: [],
|
||||
linkedSignals: [
|
||||
makeLinkedSignal(),
|
||||
makeLinkedSignal({
|
||||
signalId: "sig-2" as Id<"signals">,
|
||||
title: "Build error",
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(card.signalCount).toBe(2);
|
||||
});
|
||||
|
||||
test("card signalCount is 0 when no signals are linked", () => {
|
||||
const card = buildWorkUnitCard({
|
||||
issue: makeIssue(),
|
||||
issueEvents: [],
|
||||
linkedSignals: [],
|
||||
});
|
||||
expect(card.signalCount).toBe(0);
|
||||
});
|
||||
|
||||
test("detail exposes linked signals with title, summary, and source count", () => {
|
||||
const detail = buildWorkUnitDetail({
|
||||
issue: makeIssue(),
|
||||
issueEvents: [],
|
||||
linkedSignals: [
|
||||
makeLinkedSignal(),
|
||||
makeLinkedSignal({
|
||||
signalId: "sig-2" as Id<"signals">,
|
||||
sourceCount: 5,
|
||||
summary: "CI broke",
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(detail.signalCount).toBe(2);
|
||||
expect(detail.linkedSignals).toHaveLength(2);
|
||||
expect(detail.linkedSignals[0].title).toBe("OAuth crash on Safari");
|
||||
expect(detail.linkedSignals[0].sourceCount).toBe(2);
|
||||
expect(detail.linkedSignals[1].sourceCount).toBe(5);
|
||||
});
|
||||
|
||||
test("detail linked signals empty when no attachment exists", () => {
|
||||
const detail = buildWorkUnitDetail({
|
||||
issue: makeIssue(),
|
||||
issueEvents: [],
|
||||
linkedSignals: [],
|
||||
});
|
||||
expect(detail.signalCount).toBe(0);
|
||||
expect(detail.linkedSignals).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractLatestSummary", () => {
|
||||
test("returns the most recent agent summary", () => {
|
||||
const events = [
|
||||
@@ -228,6 +300,19 @@ describe("buildActivityTimeline", () => {
|
||||
expect(timeline[1].kind).toBe("issue.created");
|
||||
expect(timeline[1].label).toBe("Work created");
|
||||
});
|
||||
|
||||
test("maps signal.attached event to activity", () => {
|
||||
const events = [
|
||||
makeEvent(
|
||||
"signal.attached",
|
||||
{ signalId: "sig-1", signalProblemTitle: "OAuth crash" },
|
||||
{ createdAt: 3000 }
|
||||
),
|
||||
];
|
||||
const timeline = buildActivityTimeline(events);
|
||||
expect(timeline[0].label).toBe("Signal linked");
|
||||
expect(timeline[0].detail).toContain("OAuth crash");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildWorkUnitDetail", () => {
|
||||
@@ -253,11 +338,12 @@ describe("buildWorkUnitDetail", () => {
|
||||
const detail = buildWorkUnitDetail({
|
||||
issue: makeIssue({ status: "completed" }),
|
||||
issueEvents: events,
|
||||
linkedSignals: [makeLinkedSignal()],
|
||||
});
|
||||
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.signalCount).toBe(1);
|
||||
expect(detail.hasPullRequest).toBe(true);
|
||||
expect(detail.pullRequestUrl).toBe("https://git.example.com/pulls/3");
|
||||
expect(detail.pullRequestNumber).toBe(3);
|
||||
@@ -272,6 +358,12 @@ describe("buildWorkUnitDetail", () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("cross-issue isolation", () => {
|
||||
const SIGNAL_A = makeLinkedSignal({ signalId: "sig-a" as Id<"signals"> });
|
||||
const SIGNAL_B = makeLinkedSignal({
|
||||
signalId: "sig-b" as Id<"signals">,
|
||||
title: "Different signal",
|
||||
});
|
||||
|
||||
const sharedEvents: Event[] = [
|
||||
makeEvent(
|
||||
"issue.created",
|
||||
@@ -316,6 +408,7 @@ describe("cross-issue isolation", () => {
|
||||
const cardA = buildWorkUnitCard({
|
||||
issue: makeIssue({ _id: ISSUE_A, number: 1 }),
|
||||
issueEvents: eventsA,
|
||||
linkedSignals: [SIGNAL_A],
|
||||
});
|
||||
expect(cardA.artifactCount).toBe(1);
|
||||
expect(cardA.stepCount).toBe(4);
|
||||
@@ -324,6 +417,7 @@ describe("cross-issue isolation", () => {
|
||||
const cardB = buildWorkUnitCard({
|
||||
issue: makeIssue({ _id: ISSUE_B, number: 2, title: "Issue B" }),
|
||||
issueEvents: eventsB,
|
||||
linkedSignals: [SIGNAL_B],
|
||||
});
|
||||
expect(cardB.artifactCount).toBe(1);
|
||||
expect(cardB.stepCount).toBe(3);
|
||||
@@ -334,18 +428,17 @@ describe("cross-issue isolation", () => {
|
||||
const cardB = buildWorkUnitCard({
|
||||
issue: makeIssue({ _id: ISSUE_B, number: 2, title: "Issue B" }),
|
||||
issueEvents: eventsB,
|
||||
linkedSignals: [],
|
||||
});
|
||||
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.");
|
||||
expect(extractLatestSummary(eventsA)).toBe("Done for A.");
|
||||
|
||||
const eventsB = eventsForIssue(sharedEvents, ISSUE_B);
|
||||
const summaryB = extractLatestSummary(eventsB);
|
||||
expect(summaryB).toBe("Failed for B.");
|
||||
expect(extractLatestSummary(eventsB)).toBe("Failed for B.");
|
||||
});
|
||||
|
||||
test("detail for issue A lists only issue A's artifact paths", () => {
|
||||
@@ -353,6 +446,7 @@ describe("cross-issue isolation", () => {
|
||||
const detailA = buildWorkUnitDetail({
|
||||
issue: makeIssue({ _id: ISSUE_A, number: 1 }),
|
||||
issueEvents: eventsA,
|
||||
linkedSignals: [SIGNAL_A],
|
||||
});
|
||||
expect(detailA.artifactPaths).toEqual(["work.md"]);
|
||||
expect(detailA.pullRequestNumber).toBe(5);
|
||||
@@ -363,60 +457,28 @@ describe("cross-issue isolation", () => {
|
||||
const detailB = buildWorkUnitDetail({
|
||||
issue: makeIssue({ _id: ISSUE_B, number: 2, title: "Issue B" }),
|
||||
issueEvents: eventsB,
|
||||
linkedSignals: [],
|
||||
});
|
||||
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: [],
|
||||
test("issue A does not inherit issue B's linked signals", () => {
|
||||
const detailA = buildWorkUnitDetail({
|
||||
issue: makeIssue({ _id: ISSUE_A, number: 1 }),
|
||||
issueEvents: eventsForIssue(sharedEvents, ISSUE_A),
|
||||
linkedSignals: [SIGNAL_A],
|
||||
});
|
||||
// The card never sees projectSignals — signalCount is always 0.
|
||||
expect(card.signalCount).toBe(0);
|
||||
expect(projectHasSignals.length).toBeGreaterThan(0);
|
||||
});
|
||||
expect(detailA.signalCount).toBe(1);
|
||||
expect(detailA.linkedSignals[0].title).toBe("OAuth crash on Safari");
|
||||
|
||||
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 }),
|
||||
],
|
||||
const detailB = buildWorkUnitDetail({
|
||||
issue: makeIssue({ _id: ISSUE_B, number: 2, title: "Issue B" }),
|
||||
issueEvents: eventsForIssue(sharedEvents, ISSUE_B),
|
||||
linkedSignals: [SIGNAL_B],
|
||||
});
|
||||
expect(detail.signalCount).toBe(0);
|
||||
expect(projectHasSignals.length).toBeGreaterThan(0);
|
||||
expect(detailB.signalCount).toBe(1);
|
||||
expect(detailB.linkedSignals[0].title).toBe("Different signal");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,16 +4,23 @@ 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.
|
||||
// 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;
|
||||
@@ -44,6 +51,7 @@ export interface WorkUnitDetail {
|
||||
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;
|
||||
@@ -148,6 +156,10 @@ const ACTIVITY_HANDLERS: Record<
|
||||
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 => {
|
||||
@@ -246,15 +258,17 @@ export const extractArtifactPaths = (
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Card builder — derives all counts from issue-scoped events only
|
||||
// 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;
|
||||
@@ -272,9 +286,7 @@ export const buildWorkUnitCard = ({
|
||||
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,
|
||||
signalCount: linkedSignals.length,
|
||||
status: issue.status,
|
||||
stepCount: issueEvents.length,
|
||||
summary,
|
||||
@@ -284,15 +296,17 @@ export const buildWorkUnitCard = ({
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Detail builder — same per-issue derivation, plus artifact paths
|
||||
// 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);
|
||||
@@ -305,10 +319,11 @@ export const buildWorkUnitDetail = ({
|
||||
hasPullRequest: prInfo.hasPR,
|
||||
issue,
|
||||
latestSummary: extractLatestSummary(issueEvents),
|
||||
linkedSignals,
|
||||
needsInput: issue.status === "needs-input",
|
||||
pullRequestNumber: prInfo.number,
|
||||
pullRequestUrl: prInfo.url,
|
||||
signalCount: 0,
|
||||
signalCount: linkedSignals.length,
|
||||
stepCount: issueEvents.length,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user