From e6c32a125194f7b16fbdbbe4e2bc44a943a53e44 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 3 Nov 2026 12:54:07 -0600 Subject: [PATCH] fix(evals): retain canonical child receipts and company user scope Co-Authored-By: Paperclip --- .../helpers/work-timeline-receipt-cases.ts | 204 ++++++++++++++++++ .../__tests__/work-timeline-service.test.ts | 18 +- server/src/routes/issues.ts | 3 + server/src/services/issue-creation-source.ts | 5 + server/src/services/issues.ts | 7 +- server/src/services/work-timeline.ts | 39 +++- tests/runner-e2e/README.md | 16 +- 7 files changed, 280 insertions(+), 12 deletions(-) create mode 100644 server/src/__tests__/helpers/work-timeline-receipt-cases.ts diff --git a/server/src/__tests__/helpers/work-timeline-receipt-cases.ts b/server/src/__tests__/helpers/work-timeline-receipt-cases.ts new file mode 100644 index 00000000000..53fc9b1322a --- /dev/null +++ b/server/src/__tests__/helpers/work-timeline-receipt-cases.ts @@ -0,0 +1,204 @@ +import { randomUUID } from "node:crypto"; +import type { Express } from "express"; +import { eq } from "drizzle-orm"; +import request from "supertest"; +import { expect, it } from "vitest"; +import { activityLog, agents, authUsers, companies, companyMemberships, createDb, documents, documentRevisions, heartbeatRuns, issueComments, issueDocuments, issueThreadInteractions, issues } from "@paperclipai/db"; + +import { childCreatedByRequest } from "../../services/issue-creation-source.js"; +import { issueService } from "../../services/issues.js"; +import { createAcceptedPlanDecompositionSchema, createChildIssueSchema } from "@paperclipai/shared"; + +type Seed = { companyId: string; userId: string; agentAId: string; agentBId: string }; +type Fixture = { + getDb: () => ReturnType; + seedBase: () => Promise; + createApp: (userId?: string, includeIssues?: boolean, actor?: { companyId: string; agentId: string }) => Express; +}; + +/** Shares the owning suite's real PostgreSQL lifecycle and route actors. */ +export function registerTimelineReceiptBoundaryCases(f: Fixture) { + async function acceptedPlanContext(watchdog = false) { + const db = f.getDb(); const base = await f.seedBase(); + const sourceId = randomUUID(), documentId = randomUUID(), revisionId = randomUUID(); + await db.update(agents).set({ status: "paused" }).where(eq(agents.companyId, base.companyId)); + await db.insert(issues).values({ id: sourceId, companyId: base.companyId, title: "Approved plan", workMode: "planning", + status: "in_progress", assigneeAgentId: base.agentAId, createdByUserId: base.userId, + ...(watchdog ? { originKind: "task_watchdog" } : {}) }); + await db.insert(documents).values({ id: documentId, companyId: base.companyId, title: "Plan", format: "markdown", latestBody: "Plan body", latestRevisionId: revisionId, latestRevisionNumber: 1 }); + await db.insert(documentRevisions).values({ id: revisionId, companyId: base.companyId, documentId, revisionNumber: 1, title: "Plan", format: "markdown", body: "Plan body" }); + await db.insert(issueDocuments).values({ companyId: base.companyId, issueId: sourceId, documentId, key: "plan" }); + await db.insert(issueThreadInteractions).values({ companyId: base.companyId, issueId: sourceId, kind: "request_confirmation", status: "accepted", continuationPolicy: "wake_assignee", + payload: { version: 1, prompt: "Approve plan?", target: { type: "issue_document", issueId: sourceId, documentId, key: "plan", revisionId, revisionNumber: 1 } }, + result: { version: 1, outcome: "accepted" }, resolvedAt: new Date(), createdByUserId: base.userId, resolvedByUserId: base.userId }); + return { ...base, sourceId, revisionId }; + } + + it("keeps accepted-plan child identities while excluding proven keyed reuse from fresh receipts", async () => { + const db = f.getDb(); const ctx = await acceptedPlanContext(); const svc = issueService(db); + const keyed = { title: "Reused plan task", status: "backlog" as const, assigneeAgentId: ctx.agentBId, idempotencyKey: randomUUID() }; + const existing = await svc.createChild(ctx.sourceId, { ...keyed, createdByUserId: ctx.userId }); + const result = await svc.decomposeAcceptedPlan(ctx.sourceId, { acceptedPlanRevisionId: ctx.revisionId, + children: [keyed, { title: "Fresh plan task", status: "backlog", assigneeAgentId: ctx.agentBId }], actorUserId: ctx.userId }); + expect(result.decomposition.status).toBe("completed"); + expect(result.childIssueIds).toHaveLength(2); expect(result.childIssueIds).toContain(existing.issue.id); + expect(result.childIssues.map(issue => issue.id)).toEqual(result.childIssueIds); + expect(result.newlyCreatedIssues).toHaveLength(1); + expect(result.newlyCreatedIssues[0]!.id).not.toBe(existing.issue.id); + const publicInput = createAcceptedPlanDecompositionSchema.parse({ acceptedPlanRevisionId: ctx.revisionId, + children: [{ ...keyed, conversationAgentId: ctx.agentAId, conversationUserId: ctx.userId }] }); + for (const unsupported of ["idempotencyKey", "conversationAgentId", "conversationUserId"]) expect(publicInput.children[0]).not.toHaveProperty(unsupported); + }); + + it("keeps the existing serialized watchdog blocker while supported public plan inputs create a fresh child", async () => { + const db = f.getDb(); const ctx = await acceptedPlanContext(true); const svc = issueService(db); + const keyed = { title: "Existing serialized child", status: "todo" as const, assigneeAgentId: ctx.agentBId, idempotencyKey: randomUUID() }; + const existing = await svc.createChild(ctx.sourceId, { ...keyed, createdByUserId: ctx.userId }); + const response = await request(f.createApp(ctx.userId, true)).post(`/api/issues/${ctx.sourceId}/accepted-plan-decompositions`) + .send({ acceptedPlanRevisionId: ctx.revisionId, children: [keyed] }); + expect(response.status, JSON.stringify(response.body)).toBe(200); + expect(response.body.childIssueIds).toHaveLength(1); + const freshId = response.body.childIssueIds[0]; + expect(freshId).not.toBe(existing.issue.id); + expect(response.body.newlyCreatedChildIssueIds).toEqual([freshId]); + expect((await svc.getRelationSummaries(freshId)).blockedBy).toContainEqual(expect.objectContaining({ id: existing.issue.id })); + const parent = await svc.getById(ctx.sourceId); + expect(parent!.status).toBe("blocked"); + expect((await svc.getRelationSummaries(ctx.sourceId)).blockedBy).toContainEqual(expect.objectContaining({ id: existing.issue.id })); + const logs = await db.select().from(activityLog); + expect(logs.filter(row => row.entityId === existing.issue.id && row.action === "issue.child_created")).toEqual([]); + expect(logs).toContainEqual(expect.objectContaining({ entityId: freshId, action: "issue.child_created", details: expect.objectContaining({ createdByRequest: true }) })); + expect(logs).toContainEqual(expect.objectContaining({ action: "issue.accepted_plan_decomposition_updated", details: expect.objectContaining({ + childIssueIds: [freshId], newlyCreatedChildIssueIds: [freshId], currentSerializedChildIssueId: existing.issue.id, + }) })); + expect(await db.select().from(heartbeatRuns)).toEqual([]); + }); + + it("projects actual public child creation receipts without admitting unsupported replay keys", async () => { + const db = f.getDb(); const { companyId, userId, agentAId, agentBId } = await f.seedBase(); + const parentId = randomUUID(), from = new Date(Date.now() - 1000); + await db.update(agents).set({ status: "idle" }).where(eq(agents.companyId, companyId)); + await db.insert(issues).values({ id: parentId, companyId, title: "Parent", status: "backlog", assigneeAgentId: agentAId, createdByUserId: userId, createdAt: from }); + const body = { title: "Canonical child", status: "backlog", assigneeAgentId: agentBId, idempotencyKey: randomUUID() }; + expect(createChildIssueSchema.parse(body)).not.toHaveProperty("idempotencyKey"); + const created = await request(f.createApp(userId, true, { companyId, agentId: agentAId })).post(`/api/issues/${parentId}/children`).send(body); + expect(created.status, JSON.stringify(created.body)).toBe(201); + const id = created.body.id; + const receipt = (await db.select().from(activityLog).where(eq(activityLog.entityId, id))).find(row => row.action === "issue.child_created")!; + expect(receipt).toMatchObject({ actorType: "agent", actorId: agentAId, details: { parentId, assigneeAgentId: agentBId, assigneeUserId: null, createdByRequest: true } }); + const response = await request(f.createApp(userId)).get(`/api/companies/${companyId}/timeline`).query({ from: from.toISOString(), to: new Date().toISOString() }); + expect(response.status, JSON.stringify(response.body)).toBe(200); + expect(response.body.edges.filter((edge: any) => edge.issueId === id)).toEqual([ + { fromActorId: `agent:${agentAId}`, toActorId: `agent:${agentBId}`, issueId: id, at: receipt.createdAt.toISOString(), kind: "assignment" }, + { fromActorId: `agent:${agentAId}`, toActorId: `agent:${agentBId}`, issueId: id, at: receipt.createdAt.toISOString(), kind: "delegation" }, + ]); + expect(await db.select().from(heartbeatRuns)).toEqual([]); + }); + + it("keeps private-service replay facts separate from original child ownership, including missing originals", async () => { + const db = f.getDb(); const { companyId, userId, agentAId, agentBId } = await f.seedBase(); const svc = issueService(db); + const parentId = randomUUID(), laterAgentId = randomUUID(), from = new Date(Date.now() - 1000); + await db.update(agents).set({ status: "paused" }).where(eq(agents.companyId, companyId)); + await db.insert(agents).values({ id: laterAgentId, companyId, name: "Later worker", adapterType: "codex_local", status: "paused" }); + await db.insert(issues).values({ id: parentId, companyId, title: "Private service parent", status: "backlog", createdByUserId: userId, createdAt: from }); + const body = { title: "Keyed private child", status: "backlog" as const, assigneeAgentId: agentBId, createdByAgentId: agentAId, idempotencyKey: randomUUID() }; + const proposed = randomUUID(); const created = await svc.createChild(parentId, { ...body, id: proposed }); + expect(created.issue.id).toBe(proposed); + const firstAt = new Date(); + const [first] = await db.insert(activityLog).values({ companyId, actorType: "agent", actorId: agentAId, action: "issue.child_created", entityType: "issue", entityId: created.issue.id, createdAt: firstAt, + details: { parentId, assigneeAgentId: created.issue.assigneeAgentId, assigneeUserId: created.issue.assigneeUserId, createdByRequest: childCreatedByRequest(proposed, created.issue.id) } }).returning(); + const boardApp = f.createApp(userId, true); + const changed = await request(boardApp).patch(`/api/issues/${created.issue.id}`).send({ assigneeAgentId: laterAgentId }); + expect(changed.status, JSON.stringify(changed.body)).toBe(200); + const replayProposal = randomUUID(); const replayed = await svc.createChild(parentId, { ...body, id: replayProposal }); + expect(replayed.issue.id).toBe(created.issue.id); expect(replayed.issue.id).not.toBe(replayProposal); + expect(replayed.issue.assigneeAgentId).toBe(laterAgentId); + const replayAt = new Date(); + await db.insert(activityLog).values({ companyId, actorType: "agent", actorId: agentAId, action: "issue.child_created", entityType: "issue", entityId: replayed.issue.id, createdAt: replayAt, + details: { parentId, assigneeAgentId: replayed.issue.assigneeAgentId, assigneeUserId: replayed.issue.assigneeUserId, createdByRequest: childCreatedByRequest(replayProposal, replayed.issue.id) } }); + const timeline = async (start: Date) => { + const response = await request(boardApp).get(`/api/companies/${companyId}/timeline`).query({ from: start.toISOString(), to: new Date().toISOString() }); + expect(response.status, JSON.stringify(response.body)).toBe(200); return response.body; + }; + const full = await timeline(from); + expect(full.edges.filter((edge: any) => edge.issueId === created.issue.id && edge.kind === "delegation")).toEqual([ + { fromActorId: `agent:${agentAId}`, toActorId: `agent:${agentBId}`, issueId: created.issue.id, at: firstAt.toISOString(), kind: "delegation" }, + ]); + expect(full.edges.filter((edge: any) => edge.issueId === created.issue.id && edge.kind === "assignment")).toHaveLength(2); + expect(first!.details).toMatchObject({ createdByRequest: true }); + expect(childCreatedByRequest(replayProposal, replayed.issue.id)).toBe(false); + await db.delete(activityLog).where(eq(activityLog.id, first!.id)); + for (const start of [from, replayAt]) { + const view = await timeline(start); + expect(view.edges.some((edge: any) => edge.issueId === created.issue.id && edge.fromActorId === `agent:${agentAId}`)).toBe(false); + expect(view.events.some((event: any) => event.issueId === created.issue.id && event.kind === "delegated")).toBe(false); + } + expect(await db.select().from(heartbeatRuns)).toEqual([]); + }); + + it.each(["missing", "malformed"])("does not rescue an unknown earliest bounded child receipt (%s) with a later receipt", async kind => { + const db = f.getDb(); const { companyId, userId, agentAId, agentBId } = await f.seedBase(); + const parentId = randomUUID(), childId = randomUUID(); + await db.insert(issues).values([ + { id: parentId, companyId, title: "Parent", status: "done", createdByUserId: userId, createdAt: new Date("2026-03-01T10:00:00Z") }, + { id: childId, companyId, parentId, title: "Child", status: "done", createdAt: new Date("2026-03-01T11:00:00Z") }, + ]); + const details = { parentId, createdByRequest: true, assigneeAgentId: agentBId, assigneeUserId: null }; + await db.insert(activityLog).values([ + { companyId, actorType: "agent", actorId: agentAId, action: "issue.child_created", entityType: "issue", entityId: childId, createdAt: new Date("2026-03-01T11:00:00Z"), details: kind === "missing" ? { parentId, createdByRequest: true } : { ...details, assigneeAgentId: 42 } }, + { companyId, actorType: "agent", actorId: agentAId, action: "issue.child_created", entityType: "issue", entityId: childId, createdAt: new Date("2026-03-01T12:00:00Z"), details }, + ]); + const response = await request(f.createApp()).get(`/api/companies/${companyId}/timeline`).query({ from: "2026-03-01T00:00:00Z", to: "2026-03-02T00:00:00Z" }); + expect(response.status).toBe(200); expect(response.body.edges).toEqual([]); + }); + + it.each([undefined, false, "true"])("does not treat an unproven child receipt as creation: %j", async flag => { + const db = f.getDb(); const { companyId, userId, agentAId, agentBId } = await f.seedBase(); + const parentId = randomUUID(), childId = randomUUID(), at = new Date("2026-03-01T11:00:00Z"); + await db.insert(issues).values([ + { id: parentId, companyId, title: "Parent", status: "done", createdByUserId: userId, createdAt: new Date("2026-03-01T10:00:00Z") }, + { id: childId, parentId, companyId, title: "Child", status: "done", createdByAgentId: agentAId, createdAt: at }, + ]); + await db.insert(activityLog).values({ companyId, actorType: "agent", actorId: agentAId, action: "issue.child_created", entityType: "issue", entityId: childId, createdAt: at, + details: { parentId, assigneeAgentId: agentBId, assigneeUserId: null, ...(flag === undefined ? {} : { createdByRequest: flag }) } }); + const response = await request(f.createApp()).get(`/api/companies/${companyId}/timeline`).query({ from: "2026-03-01T00:00:00Z", to: "2026-03-02T00:00:00Z" }); + expect(response.status).toBe(200); expect(response.body.edges).toEqual([]); + }); + + it.each(["actor", "target"] as const)("keeps valid active same-company user %s facts", async role => { + const db = f.getDb(); const { companyId, userId, agentAId, agentBId } = await f.seedBase(); + const id = randomUUID(), at = new Date("2026-03-01T11:00:00Z"); + await db.insert(issues).values({ id, companyId, title: "Valid user facts", status: "done", createdByUserId: userId, createdAt: at }); + await db.insert(activityLog).values({ companyId, actorType: role === "actor" ? "user" : "agent", actorId: role === "actor" ? userId : agentAId, + action: "issue.created", entityType: "issue", entityId: id, createdAt: at, details: { parentId: null, + assigneeAgentId: role === "target" ? null : agentBId, assigneeUserId: role === "target" ? userId : null } }); + const response = await request(f.createApp()).get(`/api/companies/${companyId}/timeline`).query({ from: "2026-03-01T00:00:00Z", to: "2026-03-02T00:00:00Z" }); + expect(response.status).toBe(200); expect(response.body.edges).toHaveLength(1); + expect(response.body.actors).toContainEqual(expect.objectContaining({ id: `user:${userId}`, name: "User One" })); + }); + + it.each(["foreign-actor", "foreign-target", "inactive-actor", "inactive-target", "resolver"] as const)("does not expose out-of-bound user identity through %s", async kind => { + const db = f.getDb(); const { companyId, userId, agentAId, agentBId } = await f.seedBase(); + const foreignCompanyId = randomUUID(), privateUserId = randomUUID(), id = randomUUID(), at = new Date("2026-03-01T11:00:00Z"); + await db.insert(companies).values({ id: foreignCompanyId, name: "Foreign user company", issuePrefix: "FU" }); + await db.insert(authUsers).values({ id: privateUserId, name: "Foreign private name", image: "https://private.example/avatar", email: `${privateUserId}@example.test`, emailVerified: true, createdAt: at, updatedAt: at }); + await db.insert(companyMemberships).values({ companyId: kind.startsWith("inactive") ? companyId : foreignCompanyId, + principalType: "user", principalId: privateUserId, status: kind.startsWith("inactive") ? "inactive" : "active", membershipRole: "operator" }); + await db.insert(issues).values({ id, companyId, title: "Scoped user facts", status: "done", createdByUserId: kind === "resolver" ? privateUserId : userId, createdAt: at }); + const actor = kind.endsWith("actor"); const target = kind.endsWith("target"); + if (kind === "resolver") { + await db.insert(issueComments).values({ companyId, issueId: id, authorUserId: privateUserId, body: "Recorded comment", createdAt: at }); + } else { + await db.insert(activityLog).values({ companyId, actorType: actor ? "user" : "agent", actorId: actor ? privateUserId : agentAId, + action: "issue.created", entityType: "issue", entityId: id, createdAt: at, + details: { parentId: null, assigneeAgentId: target ? null : agentBId, assigneeUserId: target ? privateUserId : null } }); + } + const response = await request(f.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); + const json = JSON.stringify(response.body); + for (const secret of [privateUserId, "Foreign private name", "https://private.example/avatar"]) expect(json).not.toContain(secret); + const actorIds = new Set(response.body.actors.map((row: any) => row.id)); + expect(response.body.events.every((row: any) => actorIds.has(row.actorId))).toBe(true); + expect(response.body.edges.every((row: any) => actorIds.has(row.fromActorId) && actorIds.has(row.toActorId))).toBe(true); + }); +} diff --git a/server/src/__tests__/work-timeline-service.test.ts b/server/src/__tests__/work-timeline-service.test.ts index 9bd493e87d9..f112873f6d8 100644 --- a/server/src/__tests__/work-timeline-service.test.ts +++ b/server/src/__tests__/work-timeline-service.test.ts @@ -10,6 +10,11 @@ import { approvals, authUsers, companies, + companyMemberships, + documents, + documentRevisions, + issuePlanDecompositions, + issueThreadInteractions, createDb, heartbeatRuns, issueApprovals, @@ -24,6 +29,7 @@ import { errorHandler } from "../middleware/index.js"; import { issueRoutes } from "../routes/issues.js"; import { companyRoutes } from "../routes/companies.js"; import { buildOpenApiSpec } from "../routes/openapi.js"; +import { registerTimelineReceiptBoundaryCases } from "./helpers/work-timeline-receipt-cases.js"; import { normalizeTimelineWindow, workTimelineService } from "../services/work-timeline.js"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); @@ -50,9 +56,14 @@ describeEmbeddedPostgres("work timeline aggregation", () => { await db.delete(issueApprovals); await db.delete(approvals); await db.delete(heartbeatRuns); + await db.delete(issuePlanDecompositions); + await db.delete(issueThreadInteractions); await db.delete(issues); + await db.delete(documentRevisions); + await db.delete(documents); await db.delete(agentWakeupRequests); await db.delete(agents); + await db.delete(companyMemberships); await db.delete(authUsers); await db.delete(companies); }); @@ -61,11 +72,11 @@ describeEmbeddedPostgres("work timeline aggregation", () => { await tempDb?.cleanup(); }); - function createApp(actorUserId = "local-board", includeIssues = false) { + function createApp(actorUserId = "local-board", includeIssues = false, agentActor?: { companyId: string; agentId: string }) { const app = express(); app.use(express.json()); app.use((req, _res, next) => { - (req as any).actor = { + (req as any).actor = agentActor ? { type: "agent", source: "agent_key", ...agentActor } : { type: "board", userId: actorUserId, companyIds: [], @@ -100,6 +111,7 @@ describeEmbeddedPostgres("work timeline aggregation", () => { createdAt: new Date("2026-01-01T00:00:00Z"), updatedAt: new Date("2026-01-01T00:00:00Z"), }); + await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "operator" }); await db.insert(agents).values([ { id: agentAId, @@ -970,4 +982,6 @@ describeEmbeddedPostgres("work timeline aggregation", () => { expect(await db.select().from(heartbeatRuns)).toEqual([]); }); + registerTimelineReceiptBoundaryCases({ getDb: () => db, seedBase, createApp }); + }); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 030d64a2b1a..4aa9bdfeea6 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -20,6 +20,7 @@ import { monitorPoliciesEqual, applyActorMonitorScheduledBy, assertCanManageIssu import type { IssuePrivacyConstraints } from "@paperclipai/shared"; import { canActorReadHeartbeatRun } from "../services/heartbeat-run-privacy.js"; import { + childCreatedByRequest, loadCreationSourceAgent, loadRunBoundIssue, resolveIssueCreationSource, @@ -13119,6 +13120,7 @@ export function issueRoutes( entityId: issue.id, details: { parentId: parent.id, + createdByRequest: childCreatedByRequest(issueId, issue.id), identifier: issue.identifier, title: issue.title, ...buildCreateIssueActivityStatusDetails(issue, res), @@ -13399,6 +13401,7 @@ export function issueRoutes( entityId: issue.id, details: { parentId: sourceIssue.id, + createdByRequest: true, identifier: issue.identifier, title: issue.title, inheritedExecutionWorkspaceFromIssueId: sourceIssue.id, diff --git a/server/src/services/issue-creation-source.ts b/server/src/services/issue-creation-source.ts index c40c898fd39..0eb92724acf 100644 --- a/server/src/services/issue-creation-source.ts +++ b/server/src/services/issue-creation-source.ts @@ -17,6 +17,11 @@ export interface IssueCreationSourceRecord { const CREATION_ACTIVITY_ACTIONS = ["issue.created", "issue.child_created"]; +/** The route supplies a fresh UUID; a different returned issue is reused. */ +export function childCreatedByRequest(proposedIssueId: string, returnedIssueId: string) { + return proposedIssueId === returnedIssueId; +} + function readNonEmptyString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value : null; } diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 34829069c0a..f28cabe7399 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -9916,11 +9916,16 @@ export function issueService(db: Db) { ); } + let childReused = false; const createdChild = await issueService( tx as unknown as Db, ).createChild(sourceIssue.id, { ...nextChildInput, executionWorkspaceInheritanceMode: "strategy_only", + onDeduplicated: reason => { + childReused = true; + nextChildInput.onDeduplicated?.(reason); + }, }); const nextIds = [...existingChildIssueIds, createdChild.issue.id]; const now = new Date(); @@ -9950,7 +9955,7 @@ export function issueService(db: Db) { ); return { claim: updatedClaim, - createdIssue: createdChild.issue, + createdIssue: childReused ? null : createdChild.issue, }; }); diff --git a/server/src/services/work-timeline.ts b/server/src/services/work-timeline.ts index 152a5280b0d..0a570fc9a76 100644 --- a/server/src/services/work-timeline.ts +++ b/server/src/services/work-timeline.ts @@ -6,6 +6,7 @@ import { agents, approvals, authUsers, + companyMemberships, heartbeatRuns, issueApprovals, issueComments, @@ -119,6 +120,14 @@ function dateIso(value: Date | null | undefined) { return value ? value.toISOString() : null; } +function activeTimelineUser(companyId: string | SQL, userId: SQL) { + return sql`exists (select 1 from ${companyMemberships} + where ${companyMemberships.companyId} = ${companyId} + and ${companyMemberships.principalType} = 'user' + and ${companyMemberships.principalId} = ${userId} + and ${companyMemberships.status} = 'active')`; +} + /** 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`; @@ -142,7 +151,8 @@ function timelineOwnershipProjection() { 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 knownUser = (id: SQL) => sql`(exists (select 1 from ${authUsers} where ${authUsers.id} = ${id}) + and ${activeTimelineUser(sql`${activityLog.companyId}`, 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`( @@ -152,7 +162,8 @@ function timelineOwnershipProjection() { 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' + creationRecorded: sql`coalesce((${activityLog.action} = 'issue.created' + or (${activityLog.action} = 'issue.child_created' and ${details} -> 'createdByRequest' = 'true'::jsonb)) and (${sql.join(creationOwnerTypes, sql` and `)}) and ((${creationAgentId} is not null and ${creationUserId} is null and ${sameCompanyAgent(creationAgentId)}) or (${creationUserId} is not null and ${creationAgentId} is null and ${knownUser(creationUserId)})) @@ -482,7 +493,7 @@ export function workTimelineService(db: Db) { ? db .select({ id: authUsers.id, name: authUsers.name, image: authUsers.image }) .from(authUsers) - .where(inArray(authUsers.id, maybeUuidList(userIds))) + .where(and(inArray(authUsers.id, maybeUuidList(userIds)), activeTimelineUser(companyId, sql`${authUsers.id}`))) : [], ]); @@ -742,12 +753,23 @@ export function workTimelineService(db: Db) { }); } + // The first visible receipt is selected before validating its facts. A + // later replay must not rescue an unknown original or invent its owner. + const creationReceipts = new Map(); + for (const row of logRows) { + if (row.action !== "issue.created" && row.action !== "issue.child_created") continue; + const first = creationReceipts.get(row.issueId); + if (!first || row.createdAt < first.createdAt) creationReceipts.set(row.issueId, row); + } + for (const row of logRows) { 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; + if (row.action === "issue.created" || row.action === "issue.child_created") { + const issue = issueById.get(row.issueId); + if (creationReceipts.get(row.issueId) !== row || !row.creationRecorded || !issue + || issue.createdAt < from || issue.createdAt > to) continue; const toActorId = row.creationAgentId ? actorId("agent", row.creationAgentId) : actorId("user", row.creationUserId!); actorIds.add(fromActorId); @@ -774,7 +796,8 @@ export function workTimelineService(db: Db) { } const actorMaps = await loadActorMaps(input.companyId, actorIds); - const actors: WorkTimelineActor[] = Array.from(actorIds).map((id) => { + const publicActorIds = new Set(Array.from(actorIds).filter(id => !id.startsWith("user:") || actorMaps.users.has(id.slice("user:".length)))); + const actors: WorkTimelineActor[] = Array.from(publicActorIds).map((id) => { const [type, rawId] = id.split(":", 2) as [TimelineActorType, string]; if (type === "agent") { const agent = actorMaps.agents.get(rawId); @@ -791,8 +814,8 @@ export function workTimelineService(db: Db) { return { actors, spans: Array.from(spanByRunId.values()).sort((left, right) => left.start.localeCompare(right.start)), - events: events.sort((left, right) => left.at.localeCompare(right.at)), - edges: edges.sort((left, right) => left.at.localeCompare(right.at)), + events: events.filter(event => publicActorIds.has(event.actorId)).sort((left, right) => left.at.localeCompare(right.at)), + edges: edges.filter(edge => publicActorIds.has(edge.fromActorId) && publicActorIds.has(edge.toActorId)).sort((left, right) => left.at.localeCompare(right.at)), pagination: { limit, offset, diff --git a/tests/runner-e2e/README.md b/tests/runner-e2e/README.md index 0086ad4819d..b6847207e18 100644 --- a/tests/runner-e2e/README.md +++ b/tests/runner-e2e/README.md @@ -3305,10 +3305,24 @@ 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, +`issue.created` audit facts or explicitly fresh `issue.child_created` receipts +inside the requested window. Child receipts require `createdByRequest: true`; +false, absent or malformed flags do not establish initial ownership. The first +bounded receipt is selected before validation, and the issue creation time must +also be in the requested window. This is not an all-history completeness proof. +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. + +User identities in the timeline require active membership in the requested +company. This gate covers receipt validation, identity lookup, and public actor, +event and edge output, including raw IDs. Canonical audit rows remain unchanged. +The public child and accepted-plan schemas strip idempotency and conversation +keys; private service replay controls do not imply HTTP replay support. The +accepted-plan fresh-child list excludes keyed/title reuse reported by its +existing callback for supported task inputs. This is not a universal internal +conversation-creation provenance guarantee.