From 07f71dbd75a77b6d2ff96e3d4d289d1947538091 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 3 Nov 2026 10:34:58 -0600 Subject: [PATCH] fix(activity): compose qualified recorded ownership timeline source Co-Authored-By: Paperclip --- maps/feature-map/activity.md | 8 - packages/shared/src/types/work-timeline.ts | 3 +- .../__tests__/work-timeline-service.test.ts | 217 ++++++++++++++++-- server/src/services/work-timeline.ts | 112 +++++---- tests/runner-e2e/README.md | 9 + .../runner-e2e/source-sdk-public-inputs.json | 2 +- 6 files changed, 257 insertions(+), 94 deletions(-) diff --git a/maps/feature-map/activity.md b/maps/feature-map/activity.md index e74689d2082..0cb2ada22bd 100644 --- a/maps/feature-map/activity.md +++ b/maps/feature-map/activity.md @@ -84,14 +84,6 @@ entries without pagination. The tested RoutineAuditActivity component has no registered activity-route host; inspect execution links actually offered by the routine rather than treating that component test as a reachable journey. -The company timeline keeps the actor, target worker and timestamp of recorded -assignment changes, including ordinary `issue.updated` events. A recorded change -replaces the inferred creation-time assignment to the current worker. The saved PATCH change receipt excludes repeated owner fields. Status-only -updates and unchanged assignment fields do not count as handoffs. Null assignment -fields do not create a target worker. [Timeline route regressions](../../server/src/__tests__/work-timeline-service.test.ts) -cover these distinctions with a disposable database; they do not prove a live -agent produced a complete timeline narrative. - ### `agent-commentary` Open Audit → Commentary (`/activity/commentary`). Search message text, filter by agent and suggestion/complaint, diff --git a/packages/shared/src/types/work-timeline.ts b/packages/shared/src/types/work-timeline.ts index 8ebfc5cd6cb..638253d043e 100644 --- a/packages/shared/src/types/work-timeline.ts +++ b/packages/shared/src/types/work-timeline.ts @@ -7,7 +7,8 @@ import type { AgentAppearance } from "../agent-appearance.js"; */ export type TimelineActorType = "agent" | "user" | "system" | "plugin"; -export type TimelineEventKind = "created" | "commented" | "approved" | "delegated" | "assigned"; +/** `unassigned` records an explicit ownership clear and has no target edge. */ +export type TimelineEventKind = "created" | "commented" | "approved" | "delegated" | "assigned" | "unassigned"; export type TimelineEdgeKind = "delegation" | "assignment" | "mention"; export interface WorkTimelineActor { diff --git a/server/src/__tests__/work-timeline-service.test.ts b/server/src/__tests__/work-timeline-service.test.ts index 050e7b64ab6..e2b3983520f 100644 --- a/server/src/__tests__/work-timeline-service.test.ts +++ b/server/src/__tests__/work-timeline-service.test.ts @@ -314,6 +314,11 @@ describeEmbeddedPostgres("work timeline aggregation", () => { }, ]); + await db.insert(activityLog).values({ companyId, actorType: "agent", actorId: agentAId, + action: "issue.created", entityType: "issue", entityId: childIssueId, + createdAt: new Date("2026-03-01T11:00:00Z"), details: { parentId: parentIssueId, + assigneeAgentId: agentBId, assigneeUserId: null, source: "paperclip_runner_protocol" } }); + const result = await workTimelineService(db).getTimeline({ companyId, from: new Date("2026-03-01T00:00:00Z"), @@ -581,7 +586,7 @@ describeEmbeddedPostgres("work timeline aggregation", () => { } }); - it("checks ownership history once for the readable page and its readable parents only", async () => { + it("reads creation and assignment receipts only inside the bounded readable page window", async () => { const { companyId, userId, agentAId, agentBId } = await seedBase(); const parentId = randomUUID(), childId = randomUUID(), hiddenId = randomUUID(), unpagedId = randomUUID(); const foreignCompanyId = randomUUID(); @@ -605,34 +610,30 @@ describeEmbeddedPostgres("work timeline aggregation", () => { from: new Date("2026-03-05T00:00:00Z"), to: new Date("2026-03-06T00:00:00Z"), canReadIssue: async issue => issue.id !== hiddenId }); } finally { db.$client.options.debug = previousDebug; } - const history = queries.filter(row => /^select distinct /i.test(row.query) && row.query.includes('"activity_log"')); - expect(history).toHaveLength(1); - expect(history[0]!.query).not.toContain("created_at"); - const scopedIds = history[0]!.parameters.filter(value => typeof value === "string" && /^[a-f0-9-]{36}$/.test(value)); - expect(new Set(scopedIds)).toEqual(new Set([companyId, parentId, childId])); - const rows = await db.$client.unsafe(history[0]!.query, history[0]!.parameters as any[]); - expect(rows.map(row => row.entity_id)).toEqual([parentId]); - const explain = await db.$client.unsafe(`explain (analyze, format json) ${history[0]!.query}`, history[0]!.parameters as any[]); - const plan = explain[0]!["QUERY PLAN"][0].Plan; - expect(plan["Actual Rows"]).toBe(1); - expect(result.edges).toContainEqual(expect.objectContaining({ issueId: childId, kind: "assignment", toActorId: `agent:${agentBId}` })); - expect(result.edges.some(edge => edge.kind === "delegation")).toBe(false); + expect(queries.some(row => /^select distinct /i.test(row.query) && row.query.includes('"activity_log"'))).toBe(false); + const receipts = queries.filter(row => row.query.includes('from "activity_log"') && row.query.includes('"details"') && !row.query.includes("join")); + expect(receipts).toHaveLength(1); + expect(receipts[0]!.query).toMatch(/"created_at" >=/); + expect(receipts[0]!.query).toMatch(/"created_at" <=/); + const scopedIds = receipts[0]!.parameters.filter(value => typeof value === "string" && /^[a-f0-9-]{36}$/.test(value)); + expect(new Set(scopedIds)).toEqual(new Set([companyId, childId])); + expect(result.edges).toEqual([]); expect(result.events.every(event => event.issueId === childId)).toBe(true); - console.info("Timeline ownership lookup", JSON.stringify({ queryCount: history.length, scopedIssueCount: 2, - returnedIssueCount: rows.length, actualPlanRows: plan["Actual Rows"], planNode: plan["Node Type"] })); + }); - it("fails the route when ownership history cannot be read instead of inventing creation ownership", async () => { + it("fails the route when bounded activity receipts cannot be read instead of inventing ownership", async () => { const { companyId, agentAId } = await seedBase(); const issueId = randomUUID(); await db.insert(issues).values({ id: issueId, companyId, title: "History failure", status: "done", assigneeAgentId: agentAId, createdAt: new Date("2026-03-05T10:00:00Z"), updatedAt: new Date("2026-03-05T10:00:00Z") }); - const selectDistinct = db.selectDistinct.bind(db); - const fault = vi.spyOn(db, "selectDistinct").mockImplementationOnce(() => selectDistinct({ issueId: sql`1 / 0` }) as any); + const select = db.select.bind(db); + const fault = vi.spyOn(db, "select").mockImplementation(((fields: any) => + fields?.creationRecorded ? select({ ...fields, creationRecorded: sql`1 / 0` }) : select(fields)) as any); try { const response = await request(createApp()).get(`/api/companies/${companyId}/timeline`) .query({ issueId, from: "2026-03-05T00:00:00Z", to: "2026-03-06T00:00:00Z" }); - expect(fault).toHaveBeenCalledOnce(); + expect(fault).toHaveBeenCalled(); expect(response.status).toBe(500); expect(response.body).not.toHaveProperty("edges"); } finally { fault.mockRestore(); } @@ -680,6 +681,9 @@ describeEmbeddedPostgres("work timeline aggregation", () => { const assigneeAgentId = kind === "null" ? null : kind === "self-assignment" ? agentBId : agentAId; await db.insert(issues).values({ id: issueId, companyId, title: kind, status: "done", priority: "medium", assigneeAgentId, createdByUserId: userId, createdAt, updatedAt: changedAt }); + await db.insert(activityLog).values({ companyId, actorType: "user", actorId: userId, + action: "issue.created", entityType: "issue", entityId: issueId, createdAt, + details: { parentId: null, assigneeAgentId: agentAId, assigneeUserId: null } }); const details = kind === "absent" ? { status: "done" } : kind === "canonical-empty" ? { assigneeAgentId, changes: {} } : kind === "canonical-unchanged" ? { assigneeAgentId, changes: { assigneeAgentId: { from: agentAId, to: agentAId } } } @@ -693,12 +697,16 @@ describeEmbeddedPostgres("work timeline aggregation", () => { const assignmentEdges = res.body.edges.filter((edge: { kind: string }) => edge.kind === "assignment"); if (kind === "self-assignment") { expect(assigned).toEqual([{ actorId: `agent:${agentAId}`, kind: "assigned", issueId, at: changedAt.toISOString() }]); - expect(assignmentEdges).toEqual([{ fromActorId: `agent:${agentAId}`, toActorId: `agent:${agentBId}`, - issueId, at: changedAt.toISOString(), kind: "assignment" }]); + expect(assignmentEdges).toEqual(expect.arrayContaining([ + { fromActorId: `user:${userId}`, toActorId: `agent:${agentAId}`, issueId, at: createdAt.toISOString(), kind: "assignment" }, + { fromActorId: `agent:${agentAId}`, toActorId: `agent:${agentBId}`, issueId, at: changedAt.toISOString(), kind: "assignment" }, + ])); } else { expect(assigned).toEqual([]); - expect(assignmentEdges).toHaveLength(kind === "null" ? 0 : 1); - if (kind !== "null") expect(assignmentEdges[0]).toMatchObject({ at: createdAt.toISOString(), toActorId: `agent:${agentAId}` }); + expect(assignmentEdges).toHaveLength(1); + expect(assignmentEdges[0]).toMatchObject({ at: createdAt.toISOString(), toActorId: `agent:${agentAId}` }); + expect(res.body.events.filter((event: { kind: string }) => event.kind === "unassigned")).toEqual(kind === "null" + ? [{ actorId: `agent:${agentAId}`, kind: "unassigned", issueId, at: changedAt.toISOString() }] : []); } } }); @@ -710,6 +718,9 @@ describeEmbeddedPostgres("work timeline aggregation", () => { const createdAt = new Date(Date.now() - 60_000); await db.insert(issues).values({ id: issueId, companyId, title: "Producer receipt launch", status: "todo", priority: "medium", assigneeAgentId: agentAId, createdByUserId: userId, createdAt, updatedAt: createdAt }); + await db.insert(activityLog).values({ companyId, actorType: "user", actorId: userId, + action: "issue.created", entityType: "issue", entityId: issueId, createdAt, + details: { parentId: null, assigneeAgentId: agentAId, assigneeUserId: null } }); const app = createApp(userId, true); const repeated = await request(app).patch(`/api/issues/${issueId}`).send({ status: "backlog", assigneeAgentId: agentAId }); expect(repeated.status, JSON.stringify(repeated.body)).toBe(200); @@ -737,12 +748,16 @@ describeEmbeddedPostgres("work timeline aggregation", () => { expect(changed.events.filter((event: { kind: string }) => event.kind === "assigned")).toEqual([ expect.objectContaining({ actorId: `user:${userId}`, issueId, kind: "assigned" }), ]); - expect(changed.edges.filter((edge: { kind: string }) => edge.kind === "assignment")).toEqual([ + expect(changed.edges.filter((edge: { kind: string }) => edge.kind === "assignment")).toEqual(expect.arrayContaining([ + { fromActorId: `user:${userId}`, toActorId: `agent:${agentAId}`, issueId, at: createdAt.toISOString(), kind: "assignment" }, expect.objectContaining({ fromActorId: `user:${userId}`, toActorId: `agent:${agentBId}`, issueId }), - ]); + ])); const unassigned = await request(app).patch(`/api/issues/${issueId}`).send({ assigneeAgentId: null }); expect(unassigned.status, JSON.stringify(unassigned.body)).toBe(200); const cleared = await timeline(); + const removal = (await db.select().from(activityLog)).find(row => row.entityId === issueId + && (row.details as any)?.changes?.assigneeAgentId?.to === null)!; + expect(cleared.events).toContainEqual({ actorId: `user:${userId}`, kind: "unassigned", issueId, at: removal.createdAt.toISOString() }); expect(cleared.edges.filter((edge: { kind: string }) => edge.kind === "assignment")).toEqual(changed.edges.filter((edge: { kind: string }) => edge.kind === "assignment")); expect(await db.select().from(heartbeatRuns)).toEqual([]); }); @@ -777,4 +792,156 @@ describeEmbeddedPostgres("work timeline aggregation", () => { pagination: expect.objectContaining({ totalIssues: 1 }), })); }); + it("uses immutable creation assignment and delegation receipts despite later ownership", async () => { + const { companyId, userId, agentAId, agentBId } = await seedBase(); + const parentId = randomUUID(), issueId = randomUUID(); + const createdAt = new Date("2026-03-01T11:00:00Z"); + await db.insert(issues).values([ + { id: parentId, companyId, title: "Parent", status: "done", createdByUserId: userId, assigneeAgentId: agentBId, + createdAt: new Date("2026-03-01T10:00:00Z"), updatedAt: new Date("2026-03-03T10:00:00Z") }, + { id: issueId, companyId, title: "Child", parentId, status: "done", createdByAgentId: agentAId, assigneeAgentId: agentAId, + createdAt, updatedAt: new Date("2026-03-03T10:00:00Z") }, + ]); + // This is the immutable receipt shape written by the native create_task authority. + await db.insert(activityLog).values([ + { companyId, actorType: "agent", actorId: agentAId, action: "issue.created", entityType: "issue", entityId: issueId, + createdAt, details: { identifier: "TL-child", title: "Child", parentId, assigneeAgentId: agentBId, + assigneeUserId: null, status: "todo", source: "paperclip_runner_protocol" } }, + { companyId, actorType: "user", actorId: userId, action: "issue.updated", entityType: "issue", entityId: issueId, + createdAt: new Date("2026-03-03T10:00:00Z"), details: { changes: { assigneeAgentId: { from: agentBId, to: agentAId } } } }, + ]); + const response = await request(createApp()).get(`/api/companies/${companyId}/timeline`) + .query({ limit: 1, from: "2026-03-01T00:00:00Z", to: "2026-03-02T00:00:00Z" }); + expect(response.status, JSON.stringify(response.body)).toBe(200); + expect(response.body.edges).toEqual(expect.arrayContaining([ + { fromActorId: `agent:${agentAId}`, toActorId: `agent:${agentBId}`, issueId, at: createdAt.toISOString(), kind: "assignment" }, + { fromActorId: `agent:${agentAId}`, toActorId: `agent:${agentBId}`, issueId, at: createdAt.toISOString(), kind: "delegation" }, + ])); + expect(response.body.events).toContainEqual({ actorId: `agent:${agentAId}`, issueId, at: createdAt.toISOString(), kind: "delegated" }); + }); + + it("does not infer creation ownership when immutable assignment metadata is absent", async () => { + const { companyId, userId, agentAId } = await seedBase(); + const issueId = randomUUID(); + await db.insert(issues).values({ id: issueId, companyId, title: "Unknown creation owner", status: "done", + createdByUserId: userId, assigneeAgentId: agentAId, createdAt: new Date("2026-03-01T10:00:00Z") }); + const response = await request(createApp()).get(`/api/companies/${companyId}/timeline`) + .query({ from: "2026-03-01T00:00:00Z", to: "2026-03-02T00:00:00Z" }); + expect(response.status, JSON.stringify(response.body)).toBe(200); + expect(response.body.edges).toEqual([]); + }); + + it.each(["missing", "malformed", "malformed-parent", "ambiguous", "foreign-target", "foreign-actor"] as const)("rejects %s creation assignment metadata", async kind => { + const { companyId, agentAId, agentBId } = await seedBase(); + const foreignCompanyId = randomUUID(), foreignAgentId = randomUUID(), issueId = randomUUID(); + await db.insert(companies).values({ id: foreignCompanyId, name: "Foreign", issuePrefix: "FC" }); + await db.insert(agents).values({ id: foreignAgentId, companyId: foreignCompanyId, name: "Foreign", role: "engineer", adapterType: "codex_local" }); + await db.insert(issues).values({ id: issueId, companyId, title: "Invalid creation receipt", status: "done", assigneeAgentId: agentBId, + createdByAgentId: agentAId, createdAt: new Date("2026-03-01T10:00:00Z") }); + const details = kind === "missing" ? { parentId: null } + : kind === "malformed" ? { parentId: null, assigneeAgentId: 42, assigneeUserId: null } + : { parentId: kind === "malformed-parent" ? 42 : null, assigneeAgentId: kind === "foreign-target" ? foreignAgentId : agentBId, + assigneeUserId: kind === "ambiguous" ? "user-1" : null }; + await db.insert(activityLog).values({ companyId, actorType: "agent", actorId: kind === "foreign-actor" ? foreignAgentId : agentAId, + action: "issue.created", entityType: "issue", entityId: issueId, details, createdAt: new Date("2026-03-01T10:00:00Z") }); + const response = await request(createApp()).get(`/api/companies/${companyId}/timeline`) + .query({ from: "2026-03-01T00:00:00Z", to: "2026-03-02T00:00:00Z" }); + expect(response.status, JSON.stringify(response.body)).toBe(200); + expect(response.body.edges).toEqual([]); + }); + + it("keeps explicit user creation targets without allowing fallback target fields to override them", async () => { + const { companyId, userId, agentAId, agentBId } = await seedBase(); + const issueId = randomUUID(), createdAt = new Date("2026-03-01T10:00:00Z"); + await db.insert(issues).values({ id: issueId, companyId, title: "User creation owner", status: "done", createdByAgentId: agentAId, createdAt }); + await db.insert(activityLog).values({ companyId, actorType: "agent", actorId: agentAId, action: "issue.created", + entityType: "issue", entityId: issueId, createdAt, + details: { parentId: null, assigneeAgentId: null, assigneeUserId: userId, toAgentId: agentBId } }); + const response = await request(createApp()).get(`/api/companies/${companyId}/timeline`) + .query({ from: "2026-03-01T00:00:00Z", to: "2026-03-02T00:00:00Z" }); + expect(response.status, JSON.stringify(response.body)).toBe(200); + expect(response.body.edges).toEqual([{ fromActorId: `agent:${agentAId}`, toActorId: `user:${userId}`, + issueId, at: createdAt.toISOString(), kind: "assignment" }]); + }); + + it.each([{}, { assigneeAgentId: 42 }, { changes: { assigneeAgentId: { from: "old", to: 42 } } }])("does not label missing or malformed assignment targets as unassignment: %j", async details => { + const { companyId, userId } = await seedBase(); + const issueId = randomUUID(), createdAt = new Date("2026-03-01T10:00:00Z"); + await db.insert(issues).values({ id: issueId, companyId, title: "Unknown assignment target", status: "done", createdAt }); + await db.insert(activityLog).values({ companyId, actorType: "user", actorId: userId, action: "issue.assignment", + entityType: "issue", entityId: issueId, createdAt, details }); + const response = await request(createApp()).get(`/api/companies/${companyId}/timeline`) + .query({ from: "2026-03-01T00:00:00Z", to: "2026-03-02T00:00:00Z" }); + expect(response.status, JSON.stringify(response.body)).toBe(200); + expect(response.body.events.filter((event: { kind: string }) => event.kind === "unassigned")).toEqual([]); + expect(response.body.edges).toEqual([]); + }); + + it.each(["unreadable-parent", "foreign-parent", "outside-window"] as const)("does not fabricate delegation from %s creation evidence", async kind => { + const { companyId, userId, agentAId, agentBId } = await seedBase(); + const issueId = randomUUID(), parentId = randomUUID(), foreignCompanyId = randomUUID(); + const createdAt = new Date("2026-03-01T11:00:00Z"); + await db.insert(companies).values({ id: foreignCompanyId, name: "Other", issuePrefix: "OP" }); + await db.insert(issues).values([ + { id: parentId, companyId: kind === "foreign-parent" ? foreignCompanyId : companyId, + title: "Parent", status: "done", createdByUserId: userId, createdAt: new Date("2026-03-01T10:00:00Z") }, + { id: issueId, companyId, title: "Child", parentId, status: "done", createdByAgentId: agentAId, assigneeAgentId: agentBId, + createdAt, updatedAt: new Date("2026-03-05T10:00:00Z") }, + ]); + await db.insert(activityLog).values({ companyId, actorType: "agent", actorId: agentAId, + action: "issue.created", entityType: "issue", entityId: issueId, createdAt, + details: { parentId, assigneeAgentId: agentBId, assigneeUserId: null, source: "paperclip_runner_protocol" } }); + const result = await workTimelineService(db).getTimeline({ companyId, + from: new Date(kind === "outside-window" ? "2026-03-05T00:00:00Z" : "2026-03-01T00:00:00Z"), + to: new Date(kind === "outside-window" ? "2026-03-06T00:00:00Z" : "2026-03-02T00:00:00Z"), + canReadIssue: async issue => kind !== "unreadable-parent" || issue.id !== parentId }); + expect(result.edges.filter(edge => edge.kind === "delegation")).toEqual([]); + expect(result.events.filter(event => event.kind === "delegated")).toEqual([]); + expect(result.edges.filter(edge => edge.kind === "assignment")).toHaveLength(kind === "outside-window" ? 0 : 1); + }); + + it.each([["agent", "alias"], ["agent", "primary"], ["user", "alias"], ["user", "primary"]] as const)("retains explicit legacy %s %s clears", async (owner, field) => { + const { companyId, userId, agentAId } = await seedBase(); + const issueId = randomUUID(), at = new Date("2026-03-01T11:00:00Z"); + await db.insert(issues).values({ id: issueId, companyId, title: "Legacy clear", status: "done", createdAt: at }); + const primary = owner === "agent" ? "assigneeAgentId" : "assigneeUserId"; + const alias = owner === "agent" ? "toAgentId" : "toUserId"; + const target = owner === "agent" ? agentAId : userId; + const details = field === "alias" ? { [alias]: null } : { [primary]: null, [alias]: target }; + await db.insert(activityLog).values({ companyId, actorType: "user", actorId: userId, action: "issue.assigned", + entityType: "issue", entityId: issueId, createdAt: at, details }); + const response = await request(createApp()).get(`/api/companies/${companyId}/timeline`) + .query({ from: "2026-03-01T00:00:00Z", to: "2026-03-02T00:00:00Z" }); + expect(response.status, JSON.stringify(response.body)).toBe(200); + expect(response.body.events.filter((event: { kind: string }) => event.kind !== "created")).toEqual([ + { actorId: `user:${userId}`, kind: "unassigned", issueId, at: at.toISOString() }, + ]); + expect(response.body.edges).toEqual([]); + }); + + it("uses indexed UUID lookups for creation agent identities without unsafe casts", async () => { + const { companyId, agentAId, agentBId } = await seedBase(); + await db.insert(agents).values(Array.from({ length: 1000 }, () => ({ companyId, name: "Other agent", role: "engineer", adapterType: "codex_local" }))); + await db.execute(sql`analyze agents`); + const issueId = randomUUID(), at = new Date("2026-03-01T11:00:00Z"); + await db.insert(issues).values({ id: issueId, companyId, title: "Indexed creation", status: "done", createdAt: at }); + await db.insert(activityLog).values({ companyId, actorType: "agent", actorId: agentAId, action: "issue.created", + entityType: "issue", entityId: issueId, createdAt: at, + details: { parentId: null, assigneeAgentId: agentBId, assigneeUserId: null } }); + const queries: { query: string; parameters: unknown[] }[] = []; + const previousDebug = db.$client.options.debug; + db.$client.options.debug = (_connection, query, parameters) => queries.push({ query, parameters }); + try { + await workTimelineService(db).getTimeline({ companyId, from: new Date("2026-03-01T00:00:00Z"), to: new Date("2026-03-02T00:00:00Z") }); + } finally { db.$client.options.debug = previousDebug; } + const receipt = queries.find(row => row.query.includes('from "activity_log"') && row.query.includes('"details"') && !row.query.includes("join"))!; + const explain = await db.$client.unsafe(`explain (analyze, format json) ${receipt.query}`, receipt.parameters as any[]); + const nodes = (plan: any): any[] => [plan, ...(plan.Plans ?? []).flatMap(nodes)]; + const scans = nodes(explain[0]!["QUERY PLAN"][0].Plan).filter(node => node["Relation Name"] === "agents"); + expect(scans.length).toBeGreaterThan(0); + expect(scans.every(node => /Index/.test(node["Node Type"]) && /\bid\b\s*=/.test(node["Index Cond"] ?? ""))).toBe(true); + expect(scans.some(node => node["Actual Loops"] > 0)).toBe(true); + console.info("Creation agent identity plan", JSON.stringify(scans.map(node => ({ type: node["Node Type"], index: node["Index Name"], condition: node["Index Cond"], loops: node["Actual Loops"], rows: node["Actual Rows"] })))); + }); + }); diff --git a/server/src/services/work-timeline.ts b/server/src/services/work-timeline.ts index 90fe95b3674..b666e4af9f0 100644 --- a/server/src/services/work-timeline.ts +++ b/server/src/services/work-timeline.ts @@ -119,7 +119,7 @@ function dateIso(value: Date | null | undefined) { return value ? value.toISOString() : null; } -/** One ownership contract for history detection and recorded event projection. */ +/** Scalar ownership facts for bounded recorded creation and change events. */ function timelineOwnershipProjection() { const record = (value: SQL) => sql`case when jsonb_typeof(${value}) = 'object' then ${value} else '{}'::jsonb end`; const details = record(sql`${activityLog.details}`); @@ -134,12 +134,34 @@ function timelineOwnershipProjection() { }); const stringValue = (value: SQL) => sql`case when jsonb_typeof(${value}) = 'string' then nullif(btrim(${value} #>> '{}'), '') else null end`; - const target = (key: string, legacyKey: string) => sql`case when ${canonical} - then ${stringValue(sql`${changes} -> ${key} -> 'to'`)} - else coalesce(${stringValue(sql`${details} -> ${key}`)}, ${stringValue(sql`${details} -> ${legacyKey}`)}) end`; + const targetValue = (key: string, legacyKey: string) => sql`case when ${canonical} then ${changes} -> ${key} -> 'to' + when ${details} ? ${key} then ${details} -> ${key} else ${details} -> ${legacyKey} end`; + const target = (key: string, legacyKey: string) => stringValue(targetValue(key, legacyKey)); + const creationAgentId = stringValue(sql`${details} -> 'assigneeAgentId'`); + const creationUserId = stringValue(sql`${details} -> 'assigneeUserId'`); + const sameCompanyAgent = (id: SQL) => sql`exists (select 1 from ${agents} + where ${agents.id} = case when ${id} ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + then ${id}::uuid else null end and ${agents.companyId} = ${activityLog.companyId})`; + const knownUser = (id: SQL) => sql`exists (select 1 from ${authUsers} where ${authUsers.id} = ${id})`; + const creationOwnerTypes = ["assigneeAgentId", "assigneeUserId"].map(key => sql`${details} ? ${key} + and jsonb_typeof(${details} -> ${key}) in ('string', 'null')`); + const cleared = ([["assigneeAgentId", "toAgentId"], ["assigneeUserId", "toUserId"]] as const).map(([key, alias], index) => sql`( + (case when ${canonical} or ${details} ? ${key} then ${changed[index]} else ${details} ? ${alias} end) + and jsonb_typeof(${targetValue(key, alias)}) = 'null')`); return { recorded: sql`coalesce(position('assign' in ${activityLog.action}) > 0 or (${activityLog.action} = 'issue.updated' and (${sql.join(changed, sql` or `)})), false)`, + cleared: sql`coalesce(${sql.join(cleared, sql` or `)}, false)`, + creationRecorded: sql`coalesce(${activityLog.action} = 'issue.created' + and (${sql.join(creationOwnerTypes, sql` and `)}) + and ${details} ? 'parentId' and jsonb_typeof(${details} -> 'parentId') in ('string', 'null') + and ((${creationAgentId} is not null and ${creationUserId} is null and ${sameCompanyAgent(creationAgentId)}) + or (${creationUserId} is not null and ${creationAgentId} is null and ${knownUser(creationUserId)})) + and ((${activityLog.actorType} = 'agent' and ${sameCompanyAgent(sql`${activityLog.actorId}`)}) + or (${activityLog.actorType} = 'user' and ${knownUser(sql`${activityLog.actorId}`)})), false)`, + creationAgentId, + creationUserId, + creationParentId: stringValue(sql`${details} -> 'parentId'`), agentId: target("assigneeAgentId", "toAgentId"), userId: target("assigneeUserId", "toUserId"), }; @@ -477,12 +499,6 @@ export function workTimelineService(db: Db) { return actorId("system", "system"); } - function actorForIssueAssignee(issue: IssueRow) { - if (issue.assigneeAgentId) return actorId("agent", issue.assigneeAgentId); - if (issue.assigneeUserId) return actorId("user", issue.assigneeUserId); - return null; - } - async function getTimeline(input: WorkTimelineQuery): Promise { const { from, to, capped } = normalizeTimelineWindow(input); const limit = normalizeLimit(input.limit); @@ -509,18 +525,6 @@ export function workTimelineService(db: Db) { } const ownership = timelineOwnershipProjection(); - // Read only presence, for the page and its already-readable parents. This - // returns at most 2 * MAX_LIMIT IDs, regardless of history length or window. - const ownershipIssueIds = maybeUuidList(pagedIssues.flatMap(issue => [ - issue.id, ...(issue.parentId && issueById.has(issue.parentId) ? [issue.parentId] : []), - ])); - const ownershipRows = await db.selectDistinct({ issueId: activityLog.entityId }) - .from(activityLog) - .where(and(eq(activityLog.companyId, input.companyId), eq(activityLog.entityType, "issue"), - inArray(activityLog.entityId, ownershipIssueIds), ownership.recorded)) - .limit(ownershipIssueIds.length); - const ownershipHistory = new Set(ownershipRows.map(row => row.issueId)); - const actorIds = new Set(); const events: WorkTimelineEvent[] = []; const edges: WorkTimelineEdge[] = []; @@ -534,38 +538,6 @@ export function workTimelineService(db: Db) { issueId: issue.id, at: issue.createdAt.toISOString(), }); - - const assigneeActorId = actorForIssueAssignee(issue); - if (assigneeActorId && !ownershipHistory.has(issue.id)) { - actorIds.add(assigneeActorId); - edges.push({ - fromActorId: creatorActorId, - toActorId: assigneeActorId, - issueId: issue.id, - at: issue.createdAt.toISOString(), - kind: "assignment", - }); - } - - const parent = issue.parentId ? issueById.get(issue.parentId) : null; - const parentActorId = parent ? actorForIssueAssignee(parent) ?? actorForIssueCreator(parent) : null; - if (parentActorId && assigneeActorId && parentActorId !== assigneeActorId - && !ownershipHistory.has(issue.id) && parent && !ownershipHistory.has(parent.id)) { - actorIds.add(parentActorId); - edges.push({ - fromActorId: parentActorId, - toActorId: assigneeActorId, - issueId: issue.id, - at: issue.createdAt.toISOString(), - kind: "delegation", - }); - events.push({ - actorId: parentActorId, - kind: "delegated", - issueId: issue.id, - at: issue.createdAt.toISOString(), - }); - } } const [contextRunRows, activityRunRows, commentRows, approvalRows, interactionRows, logRows] = await Promise.all([ @@ -681,6 +653,11 @@ export function workTimelineService(db: Db) { actorType: activityLog.actorType, actorId: activityLog.actorId, action: activityLog.action, + creationRecorded: ownership.creationRecorded, + creationParentId: ownership.creationParentId, + creationAgentId: ownership.creationAgentId, + creationUserId: ownership.creationUserId, + assignmentCleared: ownership.cleared, assignmentRecorded: ownership.recorded, assignmentAgentId: ownership.agentId, assignmentUserId: ownership.userId, @@ -767,16 +744,33 @@ export function workTimelineService(db: Db) { } for (const row of logRows) { - if (!row.assignmentRecorded) continue; - const toActorId = row.assignmentAgentId ? actorId("agent", row.assignmentAgentId) - : row.assignmentUserId ? actorId("user", row.assignmentUserId) : null; - if (!toActorId) continue; const logActorType = row.actorType === "agent" || row.actorType === "user" || row.actorType === "plugin" ? row.actorType : "system"; const fromActorId = actorId(logActorType, row.actorId); + if (row.action === "issue.created") { + if (!row.creationRecorded) continue; + const toActorId = row.creationAgentId ? actorId("agent", row.creationAgentId) + : actorId("user", row.creationUserId!); + actorIds.add(fromActorId); + actorIds.add(toActorId); + edges.push({ fromActorId, toActorId, issueId: row.issueId, at: row.createdAt.toISOString(), kind: "assignment" }); + // Delegation is the recorded creating agent's action under a readable + // parent, not an inference from that parent's mutable current owner. + if (logActorType === "agent" && row.creationParentId + && row.creationParentId !== row.issueId && issueById.has(row.creationParentId) && fromActorId !== toActorId) { + edges.push({ fromActorId, toActorId, issueId: row.issueId, at: row.createdAt.toISOString(), kind: "delegation" }); + events.push({ actorId: fromActorId, kind: "delegated", issueId: row.issueId, at: row.createdAt.toISOString() }); + } + continue; + } + if (!row.assignmentRecorded) continue; + const toActorId = row.assignmentAgentId ? actorId("agent", row.assignmentAgentId) + : row.assignmentUserId ? actorId("user", row.assignmentUserId) : null; + if (!toActorId && !row.assignmentCleared) continue; actorIds.add(fromActorId); + events.push({ actorId: fromActorId, kind: toActorId ? "assigned" : "unassigned", issueId: row.issueId, at: row.createdAt.toISOString() }); + if (!toActorId) continue; actorIds.add(toActorId); - events.push({ actorId: fromActorId, kind: "assigned", issueId: row.issueId, at: row.createdAt.toISOString() }); edges.push({ fromActorId, toActorId, issueId: row.issueId, at: row.createdAt.toISOString(), kind: "assignment" }); } diff --git a/tests/runner-e2e/README.md b/tests/runner-e2e/README.md index e9d1be6c5c3..0086ad4819d 100644 --- a/tests/runner-e2e/README.md +++ b/tests/runner-e2e/README.md @@ -3303,3 +3303,12 @@ Command, filename, digest, task/run, audit, link, privacy and read-only checks remain independent. The deterministic owner/verifier and production timeline route regressions do not qualify the actual model journey. Historical failures retain their original grades; a missing human handoff narrative still fails. + +The timeline API projects initial assignment and delegation only from complete +`issue.created` audit facts inside the requested window. It uses the saved actor, +target and timestamp, and requires a readable recorded parent for delegation. +It does not use current ownership as creation evidence. Missing or malformed +legacy creation details leave initial ownership unknown. The API performs no +all-history ownership-presence query. Recorded canonical and legacy ownership +clears emit `unassigned` events with their actual actor and timestamp, without a +target edge. Event markers are not currently rendered by the timeline chart. diff --git a/tests/runner-e2e/source-sdk-public-inputs.json b/tests/runner-e2e/source-sdk-public-inputs.json index d9f6066250d..3df19a97f40 100644 --- a/tests/runner-e2e/source-sdk-public-inputs.json +++ b/tests/runner-e2e/source-sdk-public-inputs.json @@ -398,7 +398,7 @@ "packages/shared/src/types/user-profile.ts": "d961a4074852e170c4e61599bb4e293d5708ec16", "packages/shared/src/types/voice-sessions.ts": "95e3b62cdd51f62290e68536728ddd7297bc0459", "packages/shared/src/types/work-product.ts": "26f9129f606a207ce7a218ccef40f96747283086", - "packages/shared/src/types/work-timeline.ts": "8ebfc5cd6cb664b2944c20b7b1b947d71e4f3c46", + "packages/shared/src/types/work-timeline.ts": "638253d043efb735d3da1a97a67eb92dc4aed9df", "packages/shared/src/types/workspace-file-resource.ts": "02f1bd181300b21330933450e72092023c5fae88", "packages/shared/src/types/workspace-operation.ts": "8fcb52c7088ae9da4417cf01e55632897f17645e", "packages/shared/src/types/workspace-readiness.ts": "c979f99c3dccb684028f8a6ab07660a018a4a3ea",