diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index 5e806dff873..bf481923610 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -2458,6 +2458,15 @@ pending and continue to block a new run; a longer wait does not certify an outco The one-hour client deadline does not renew with the Runner lease. If the client deadline expires while Runner still owns a call, its effect remains unknown until authoritative reconciliation. Expiry permits neither replay nor attachment. +The OpenCode bridge emits a closed finish-input diagnostic through its existing +optional diagnostic sink. It records the effective offered schema's SHA-256 and, +for at most eight rejected inputs per bridge, the argument root and continuation +presence/type enums. A SHA-256 of the existing call identity permits correlation; +a closed root/continuation/other category distinguishes the failing boundary. +It records no raw call identity, argument values, field names, schema text or +validator messages. A failed sink does not change admission or cancellation. +These facts distinguish a schema/input-shape rejection from a pending MCP call; +they do not identify the submitted value in older attempts without that evidence. An adopted transport can receive authenticated queued semantic calls before the driver publishes its recovered session and tool handler. It holds those calls until the handler is installed, then rechecks the original authority and diff --git a/maps/agent-access-map/activity.md b/maps/agent-access-map/activity.md index 5e0bea6f4a2..8f030b9fe95 100644 --- a/maps/agent-access-map/activity.md +++ b/maps/agent-access-map/activity.md @@ -199,8 +199,7 @@ Agent access: `available` — REST / native call_api. Policy: Same company plus visibility/audit/cost/run permissions where the route requires them. -Evidence: `source-only` — [implementation](../../server/src/routes/activity.ts#L224) at -the reviewed commit above. +Evidence: `source-only` — [implementation](../../server/src/routes/companies.ts). Verification: `not-run` — exercise a listed permitted operation with actual agent credentials/run context; inspect its result and applicable persistence/attribution. diff --git a/packages/paperclip-runner/src/drivers/opencode/mcp-bridge-diagnostics.test.ts b/packages/paperclip-runner/src/drivers/opencode/mcp-bridge-diagnostics.test.ts new file mode 100644 index 00000000000..4c3b253a83c --- /dev/null +++ b/packages/paperclip-runner/src/drivers/opencode/mcp-bridge-diagnostics.test.ts @@ -0,0 +1,97 @@ +import { createHash } from "node:crypto"; +import { afterEach, expect, it, vi } from "vitest"; +import { startOpenCodeMcpBridge, type OpenCodeMcpBridge } from "./mcp-bridge.js"; + +const bridges: OpenCodeMcpBridge[] = []; +afterEach(async () => { await Promise.all(bridges.splice(0).map(bridge => bridge.close())); }); + +const report = { + reportedWorkDisposition: "done", summary: "Completed the synthetic request.", + completionClaim: { contractRevision: "1", objectiveSatisfied: true, + criteria: [{ criterionId: "objective", status: "satisfied", evidenceRefs: [] }], remainingWork: [] }, + evidence: [], verification: [], +}; + +async function rpc(bridge: OpenCodeMcpBridge, id: number, method: string, params?: unknown) { + return (await fetch(bridge.url, { method: "POST", headers: { + Authorization: `Bearer ${bridge.secret}`, "Content-Type": "application/json", + }, body: JSON.stringify({ jsonrpc: "2.0", id, method, params }) })).json(); +} + +it("binds closed rejection shapes to the actual offered finish schema without recording values", async () => { + const messages: string[] = [], handler = vi.fn(async () => ({ accepted: true })); + const bridge = await startOpenCodeMcpBridge({ handler, onDiagnostic: message => messages.push(message) }); + bridges.push(bridge); + const listed = await rpc(bridge, 1, "tools/list"); + const schema = listed.result.tools.find((tool: { name: string }) => tool.name === "paperclip_finish").inputSchema; + const digest = createHash("sha256").update(JSON.stringify(schema)).digest("hex"); + for (const [index, input] of [report, { ...report, continuation: null }].entries()) { + const result = await rpc(bridge, index + 2, "tools/call", { name: "paperclip_finish", arguments: input }); + expect(result.result.isError).not.toBe(true); + } + for (const [index, continuation] of ["PRIVATE_INPUT_VALUE", [], 42, false].entries()) { + const rejected = await rpc(bridge, index + 4, "tools/call", { + name: "paperclip_finish", arguments: { ...report, continuation }, + }); + expect(rejected.result.isError).toBe(true); + } + const unrelated = await rpc(bridge, 8, "tools/call", { + name: "paperclip_finish", arguments: { ...report, summary: 42, continuation: null, PRIVATE_FIELD: "PRIVATE_INPUT_VALUE" }, + }); + expect(unrelated.result.isError).toBe(true); + expect(handler).toHaveBeenCalledTimes(2); + expect(messages.map(message => JSON.parse(message))).toEqual([ + { schema: "paperclip.opencode.finish-input-diagnostic/v1", stage: "offered", schemaSha256: digest }, + ...["string", "array", "number", "boolean"].map((continuationShape, index) => ({ + schema: "paperclip.opencode.finish-input-diagnostic/v1", stage: "input_rejected", + schemaSha256: digest, callIdSha256: createHash("sha256").update(String(index + 4)).digest("hex"), + validationCategory: "continuation", rootShape: "object", continuationShape, + })), + { schema: "paperclip.opencode.finish-input-diagnostic/v1", stage: "input_rejected", + schemaSha256: digest, callIdSha256: createHash("sha256").update("8").digest("hex"), + validationCategory: "other", rootShape: "object", continuationShape: "null" }, + ]); + expect(JSON.stringify(messages)).not.toMatch(/PRIVATE_INPUT_VALUE|PRIVATE_FIELD|Completed|criteria|continuation.*must|instancePath/); +}); + +it("reports the effective supplied schema and bounds diagnostics without admitting rejected inputs", async () => { + const messages: string[] = [], handler = vi.fn(); + const inputSchema = { type: "object", required: ["continuation"], properties: { continuation: { type: "object" } } }; + const bridge = await startOpenCodeMcpBridge({ + tools: [{ name: "paperclip_finish", inputSchema }], handler, + onDiagnostic: message => messages.push(message), + }); + bridges.push(bridge); + for (let index = 0; index < 12; index++) { + const rejected = await rpc(bridge, index, "tools/call", { + name: "paperclip_finish", arguments: index === 0 ? [] : { continuation: null }, + }); + expect(rejected.result.isError).toBe(true); + } + expect(handler).not.toHaveBeenCalled(); + expect(messages).toHaveLength(9); + expect(JSON.parse(messages[0]!)).toMatchObject({ schemaSha256: createHash("sha256").update(JSON.stringify(inputSchema)).digest("hex") }); + expect(JSON.parse(messages[1]!)).toMatchObject({ rootShape: "array", continuationShape: "omitted", validationCategory: "root" }); + expect(JSON.parse(messages[2]!)).toMatchObject({ rootShape: "object", continuationShape: "null", validationCategory: "continuation" }); + expect(messages.every(message => Buffer.byteLength(message) < 448)).toBe(true); +}); + +it("keeps diagnostics optional and nonfatal while preserving cancellation", async () => { + const aborted = vi.fn(); + let started!: () => void; + const begun = new Promise(resolve => { started = resolve; }); + const bridge = await startOpenCodeMcpBridge({ timeoutMs: null, + onDiagnostic: () => { throw new Error("unavailable diagnostic sink"); }, + handler: ({ signal }) => new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => { aborted(); reject(new Error("cancelled")); }); + started(); + }), + }); + bridges.push(bridge); + const pending = rpc(bridge, 1, "tools/call", { name: "paperclip_finish", arguments: report }); + await begun; + await fetch(bridge.url, { method: "POST", headers: { Authorization: `Bearer ${bridge.secret}`, "Content-Type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId: 1 } }) }); + expect((await pending).result.isError).toBe(true); + expect(aborted).toHaveBeenCalledOnce(); +}); diff --git a/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.ts b/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.ts index 40377fe56ad..419943a8dfd 100644 --- a/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.ts +++ b/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.ts @@ -1,4 +1,4 @@ -import { randomBytes, timingSafeEqual } from "node:crypto"; +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import { Ajv2020, type ValidateFunction } from "ajv/dist/2020.js"; @@ -35,6 +35,7 @@ export interface OpenCodeMcpBridgeOptions { privateToolTimeoutMs?: number; maxBodyBytes?: number; secret?: string; + onDiagnostic?: (message: string) => void; } export interface OpenCodeMcpBridge { @@ -43,6 +44,12 @@ export interface OpenCodeMcpBridge { close(): Promise; } +type FinishInputRejection = { + input: unknown; + callId: string; + validationCategory: "root" | "continuation" | "other"; +}; + const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_MAX_BODY_BYTES = 1_048_576; // Native app results/pages are capped at 500 KB before reaching this bridge. @@ -66,6 +73,25 @@ export async function startOpenCodeMcpBridge( const privateTools = normalizeTools(options.privateTools ?? [], false); const admittedTools = [...tools, ...privateTools.filter((candidate) => !tools.some((tool) => tool.name === candidate.name))]; const validators = compileValidators(admittedTools); + const finishSchema = admittedTools.find(tool => tool.name === PRP_COMPLETION_TOOL_NAME)?.inputSchema; + const finishSchemaSha256 = finishSchema && options.onDiagnostic + ? createHash("sha256").update(JSON.stringify(finishSchema)).digest("hex") : undefined; + let rejectionDiagnostics = 0; + const diagnoseFinishInput = (rejection?: FinishInputRejection) => { + if (!finishSchemaSha256 || !options.onDiagnostic) return; + if (rejection && rejectionDiagnostics++ >= 8) return; + const diagnostic = { + schema: "paperclip.opencode.finish-input-diagnostic/v1", + stage: rejection ? "input_rejected" : "offered", schemaSha256: finishSchemaSha256, + ...(rejection ? { + callIdSha256: createHash("sha256").update(rejection.callId).digest("hex"), + validationCategory: rejection.validationCategory, + rootShape: inputShape(rejection.input), + continuationShape: inputShape(isRecord(rejection.input) ? rejection.input.continuation : undefined), + } : {}), + }; + try { options.onDiagnostic(JSON.stringify(diagnostic)); } catch { /* Optional diagnostics never change admission. */ } + }; const calls = new Map }>(); const controllers = new Map(); const server = createServer((request, response) => { @@ -77,6 +103,7 @@ export async function startOpenCodeMcpBridge( calls, controllers, handler: options.handler, + diagnoseFinishInput, timeoutMs: options.timeoutMs === null ? null : options.timeoutMs ?? DEFAULT_TIMEOUT_MS, privateToolTimeoutMs: options.privateToolTimeoutMs ?? options.timeoutMs ?? DEFAULT_TIMEOUT_MS, privateToolNames: new Set(privateTools.map((tool) => tool.name)), @@ -95,6 +122,7 @@ export async function startOpenCodeMcpBridge( await closeServer(server); throw new Error("OpenCode MCP bridge failed to bind a loopback port"); } + diagnoseFinishInput(); return { url: `http://127.0.0.1:${address.port}/mcp`, secret, @@ -105,6 +133,17 @@ export async function startOpenCodeMcpBridge( }; } +function inputShape(input: unknown): "omitted" | "null" | "array" | "object" | "string" | "number" | "boolean" | "other" { + if (input === undefined) return "omitted"; + if (input === null) return "null"; + if (Array.isArray(input)) return "array"; + if (typeof input === "object") return "object"; + if (typeof input === "string") return "string"; + if (typeof input === "number") return "number"; + if (typeof input === "boolean") return "boolean"; + return "other"; +} + function normalizeTools( input: readonly Readonly>[], includeTerminalTools = true, @@ -151,6 +190,7 @@ async function handleRequest( privateToolTimeoutMs: number; privateToolNames: ReadonlySet; maxBodyBytes: number; + diagnoseFinishInput: (rejection?: FinishInputRejection) => void; }, ): Promise { setSecurityHeaders(response); @@ -236,6 +276,12 @@ async function handleRequest( const args = params.arguments ?? {}; const validator = context.validators.get(tool); if (validator === undefined || !validator(args)) { + if (tool === PRP_COMPLETION_TOOL_NAME) { + const errors = validator?.errors ?? []; + const validationCategory = errors.some(error => error.instancePath === "/continuation" || error.instancePath.startsWith("/continuation/")) + ? "continuation" : errors.some(error => error.instancePath === "") ? "root" : "other"; + context.diagnoseFinishInput({ input: params.arguments, callId, validationCategory }); + } const detail = validator?.errors?.map((error) => `${error.instancePath || "/"} ${error.message ?? "is invalid"}`).join("; "); writeRpc(response, id, { isError: true, diff --git a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts index b8837776a18..6d03d051c3a 100644 --- a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts @@ -2340,6 +2340,7 @@ describe("OpenCodeServerDriver", () => { const continuation = { kind, summary: "Wait for the saved task path.", idempotencyKey: "task-wait:saved" }; const reads: string[] = []; const accepted: unknown[] = []; + const diagnostics: string[] = []; const driver = new OpenCodeServerDriver({ model: "openrouter/deepseek/deepseek-v4-flash-0731", runtimeDirectory: root, command: fixture, environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "fixture-key" }, @@ -2349,11 +2350,13 @@ describe("OpenCodeServerDriver", () => { completionContinuation: eligible ? continuation : null }; }, completionFeedback: async result => { accepted.push(result); return "Accepted monitor wait."; }, + onDiagnostic: message => diagnostics.push(message), }); const session = await driver.openSession({ runId: "monitor-default", normalizedSessionId: "monitor-default", workingDirectory: workspace }); try { await session.startTurn({ message: { role: "user", text: "monitor-default" } }); const events = await collectTurnEvents(session.events()); + expect(diagnostics).toContainEqual(expect.stringContaining('"schema":"paperclip.opencode.finish-input-diagnostic/v1","stage":"offered"')); expect(reads).toEqual(["get_task_context"]); if (eligible) { expect(accepted).toMatchObject([{ reportedWorkDisposition: "yielded", continuation }]); diff --git a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts index b3684dee58a..94808372eaf 100644 --- a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts @@ -2436,6 +2436,7 @@ async function startRuntime(input: { const bridge = await startOpenCodeMcpBridge({ tools: input.options.dynamicTools, handler: input.dispatch, + onDiagnostic: input.options.onDiagnostic, ...(input.options.durableToolRequests ? { timeoutMs: null } : {}), }).catch(async (error) => { await removeIsolatedHome(isolatedHome).catch(() => undefined); 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__/helpers/work-timeline-receipt-cases.ts b/server/src/__tests__/helpers/work-timeline-receipt-cases.ts new file mode 100644 index 00000000000..e5128f93532 --- /dev/null +++ b/server/src/__tests__/helpers/work-timeline-receipt-cases.ts @@ -0,0 +1,234 @@ +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); + }); + + it.each(["foreign-actor", "missing-actor", "foreign-target", "missing-target", "resolver"] as const)("does not expose out-of-bound agent identity through %s", async kind => { + const db = f.getDb(); const { companyId, userId, agentAId, agentBId } = await f.seedBase(); + const foreignCompanyId = randomUUID(), privateAgentId = randomUUID(), id = randomUUID(), at = new Date("2026-03-01T11:00:00Z"); + await db.insert(companies).values({ id: foreignCompanyId, name: "Foreign agent company", issuePrefix: "FA" }); + if (!kind.startsWith("missing")) await db.insert(agents).values({ id: privateAgentId, companyId: foreignCompanyId, name: "Foreign private agent", adapterType: "codex_local", status: "paused" }); + await db.insert(issues).values({ id, companyId, title: "Scoped agent facts", status: "done", createdByAgentId: kind === "resolver" ? privateAgentId : agentAId, createdAt: at }); + if (kind === "resolver") { + await db.insert(issueComments).values({ companyId, issueId: id, authorAgentId: privateAgentId, body: "Recorded comment", createdAt: at }); + await db.insert(heartbeatRuns).values({ companyId, agentId: privateAgentId, status: "succeeded", invocationSource: "assignment", contextSnapshot: { issueId: id }, createdAt: at, startedAt: at, finishedAt: at }); + } else { + await db.insert(activityLog).values({ companyId, actorType: "agent", actorId: kind.endsWith("actor") ? privateAgentId : agentAId, + action: "issue.updated", entityType: "issue", entityId: id, createdAt: at, + details: { assigneeAgentId: kind.endsWith("target") ? privateAgentId : agentBId } }); + } + // Valid company-local assignment/run remains visible beside rejected facts. + await db.insert(activityLog).values({ companyId, actorType: "agent", actorId: agentAId, action: "issue.updated", entityType: "issue", entityId: id, createdAt: at, details: { assigneeAgentId: agentBId } }); + const [validRun] = await db.insert(heartbeatRuns).values({ companyId, agentId: agentBId, status: "succeeded", invocationSource: "assignment", contextSnapshot: { issueId: id }, createdAt: at, startedAt: at, finishedAt: at }).returning(); + const response = await request(f.createApp(userId)).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 [privateAgentId, "Foreign private agent"]) expect(json).not.toContain(secret); + expect(response.body.edges).toContainEqual(expect.objectContaining({ fromActorId: `agent:${agentAId}`, toActorId: `agent:${agentBId}`, kind: "assignment" })); + expect(response.body.spans).toContainEqual(expect.objectContaining({ runId: validRun!.id, actorId: `agent:${agentBId}` })); + 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.spans.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__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index 0d61a57ed75..996bbf3b628 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -1428,3 +1428,12 @@ describe("heartbeat run ID OpenAPI contract", () => { expect(checked).toBe(12); }); }); + + +it("documents explicit owner removals in the timeline response event enum", () => { + const spec = buildOpenApiSpec() as any; + const schema = spec.paths["/api/companies/{companyId}/timeline"].get.responses["200"].content["application/json"].schema; + expect(schema.properties.events.items.properties.kind.enum).toEqual([ + "created", "commented", "approved", "delegated", "assigned", "unassigned", + ]); +}); diff --git a/server/src/__tests__/work-timeline-service.test.ts b/server/src/__tests__/work-timeline-service.test.ts index 3d75d735520..f112873f6d8 100644 --- a/server/src/__tests__/work-timeline-service.test.ts +++ b/server/src/__tests__/work-timeline-service.test.ts @@ -1,13 +1,20 @@ import { randomUUID } from "node:crypto"; import express from "express"; +import { eq, sql } from "drizzle-orm"; import request from "supertest"; -import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { activityLog, + agentWakeupRequests, agents, approvals, authUsers, companies, + companyMemberships, + documents, + documentRevisions, + issuePlanDecompositions, + issueThreadInteractions, createDb, heartbeatRuns, issueApprovals, @@ -19,7 +26,10 @@ import { startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; 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(); @@ -46,8 +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); }); @@ -56,13 +72,13 @@ describeEmbeddedPostgres("work timeline aggregation", () => { await tempDb?.cleanup(); }); - function createApp() { + 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: "local-board", + userId: actorUserId, companyIds: [], memberships: [], source: "local_implicit", @@ -71,6 +87,7 @@ describeEmbeddedPostgres("work timeline aggregation", () => { next(); }); app.use("/api/companies", companyRoutes(db, {} as any)); + if (includeIssues) app.use("/api", issueRoutes(db, {} as any)); app.use(errorHandler); return app; } @@ -94,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, @@ -131,7 +149,7 @@ describeEmbeddedPostgres("work timeline aggregation", () => { expect(result.from.toISOString()).toBe("2026-02-12T00:00:00.000Z"); }); - it("aggregates runs, human events, approvals, and delegation edges", async () => { + it("aggregates runs, human events, approvals, and recorded assignments", async () => { const { companyId, userId, agentAId, agentBId } = await seedBase(); const parentIssueId = randomUUID(); const childIssueId = randomUUID(); @@ -251,15 +269,33 @@ describeEmbeddedPostgres("work timeline aggregation", () => { "created", "commented", "approved", - "delegated", "assigned", ])); expect(result.edges).toEqual(expect.arrayContaining([ - expect.objectContaining({ kind: "delegation", issueId: childIssueId }), expect.objectContaining({ kind: "assignment", issueId: childIssueId }), ])); }); + it("does not retain an inferred delegation when assignment history was recorded", async () => { + const { companyId, agentAId, agentBId } = await seedBase(); + const parentId = randomUUID(), childId = randomUUID(); + await db.insert(issues).values([ + { id: parentId, companyId, title: "Parent", status: "done", assigneeAgentId: agentAId, + createdAt: new Date("2026-03-01T10:00:00Z"), updatedAt: new Date("2026-03-05T10:00:00Z") }, + { id: childId, companyId, parentId, title: "Child", status: "done", assigneeAgentId: agentBId, + createdAt: new Date("2026-03-03T10:00:00Z"), updatedAt: new Date("2026-03-05T10:00:00Z") }, + ]); + await db.insert(activityLog).values({ companyId, actorType: "agent", actorId: agentAId, + action: "issue.assigned", entityType: "issue", entityId: childId, + details: { assigneeAgentId: agentBId }, createdAt: new Date("2026-03-05T12:00:00Z") }); + const response = await request(createApp()).get(`/api/companies/${companyId}/timeline`) + .query({ from: "2026-03-05T00:00:00Z", to: "2026-03-06T00:00:00Z" }); + expect(response.status).toBe(200); + expect(response.body.events).toContainEqual(expect.objectContaining({ issueId: childId, kind: "assigned" })); + expect(response.body.events.filter((event: { kind: string }) => event.kind === "delegated")).toEqual([]); + expect(response.body.edges.filter((edge: { kind: string }) => edge.kind === "delegation")).toEqual([]); + }); + it("preserves delegation edges when parent and child cross a page boundary", async () => { const { companyId, userId, agentAId, agentBId } = await seedBase(); const parentIssueId = randomUUID(); @@ -291,6 +327,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"), @@ -508,6 +549,236 @@ describeEmbeddedPostgres("work timeline aggregation", () => { expect(maxActiveChecks).toBeLessThanOrEqual(16); }); + it.each(["before", "after"] as const)("does not invent creation ownership from a reassignment %s the query window", async when => { + const { companyId, userId, agentAId, agentBId } = await seedBase(); + const issueId = randomUUID(); + await db.insert(issues).values({ id: issueId, companyId, title: "Historical launch owner", + status: "done", priority: "medium", assigneeAgentId: agentBId, createdByUserId: userId, + createdAt: new Date("2026-03-01T10:00:00Z"), updatedAt: new Date("2026-03-05T10:00:00Z") }); + await db.insert(activityLog).values({ companyId, actorType: "user", actorId: userId, + entityType: "issue", entityId: issueId, action: "issue.updated", + createdAt: new Date(when === "before" ? "2026-03-02T10:00:00Z" : "2026-03-06T10:00:00Z"), + details: { assigneeAgentId: agentBId, changes: { assigneeAgentId: { from: agentAId, to: agentBId } } } }); + 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(response.status, JSON.stringify(response.body)).toBe(200); + expect(response.body.edges.filter((edge: { kind: string }) => edge.kind === "assignment")).toEqual([]); + expect(response.body.events.filter((event: { kind: string }) => event.kind === "assigned")).toEqual([]); + expect(response.body.events).toContainEqual(expect.objectContaining({ issueId, kind: "created" })); + }); + + it.each([ + ["child", "2026-03-02T10:00:00Z"], ["child", "2026-03-05T12:00:00Z"], + ["parent", "2026-03-02T10:00:00Z"], ["parent", "2026-03-06T10:00:00Z"], + ] as const)("does not infer a creation delegation after %s ownership changed at %s", async (changedIssue, at) => { + const { companyId, userId, agentAId, agentBId } = await seedBase(); + const parentId = randomUUID(), childId = randomUUID(); + await db.insert(issues).values([ + { id: parentId, companyId, title: "Launch parent", status: "done", priority: "medium", + createdByUserId: userId, assigneeAgentId: agentAId, + createdAt: new Date("2026-03-01T10:00:00Z"), updatedAt: new Date("2026-03-05T10:00:00Z") }, + { id: childId, companyId, parentId, title: "Launch child", status: "done", priority: "medium", + createdByAgentId: agentAId, assigneeAgentId: agentBId, + createdAt: new Date("2026-03-03T10:00:00Z"), updatedAt: new Date("2026-03-05T10:00:00Z") }, + ]); + const target = changedIssue === "parent" ? agentAId : agentBId; + const previous = changedIssue === "parent" ? agentBId : agentAId; + await db.insert(activityLog).values({ companyId, actorType: "user", actorId: userId, + entityType: "issue", entityId: changedIssue === "parent" ? parentId : childId, + action: "issue.updated", createdAt: new Date(at), + details: { assigneeAgentId: target, changes: { assigneeAgentId: { from: previous, to: target } } } }); + const response = await request(createApp()).get(`/api/companies/${companyId}/timeline`) + .query({ limit: 1, from: "2026-03-05T00:00:00Z", to: "2026-03-06T00:00:00Z" }); + expect(response.status, JSON.stringify(response.body)).toBe(200); + expect(response.body.pagination).toMatchObject({ totalIssues: 2, hasMore: true }); + expect(response.body.edges.filter((edge: { kind: string }) => edge.kind === "delegation")).toEqual([]); + expect(response.body.events.filter((event: { kind: string }) => event.kind === "delegated")).toEqual([]); + if (changedIssue === "child" && at.startsWith("2026-03-05")) { + expect(response.body.events).toContainEqual({ actorId: `user:${userId}`, issueId: childId, kind: "assigned", at: new Date(at).toISOString() }); + expect(response.body.edges).toContainEqual({ fromActorId: `user:${userId}`, toActorId: `agent:${agentBId}`, issueId: childId, kind: "assignment", at: new Date(at).toISOString() }); + } + }); + + 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(); + await db.insert(companies).values({ id: foreignCompanyId, name: "Foreign", issuePrefix: "TF" }); + await db.insert(issues).values([parentId, unpagedId, childId, hiddenId].map((id, index) => ({ + id, companyId, title: `Scoped ${index}`, status: "done", assigneeAgentId: id === parentId ? agentAId : agentBId, + parentId: id === childId ? parentId : null, createdByUserId: userId, + createdAt: new Date(Date.parse("2026-03-01T10:00:00Z") + index * 1000), updatedAt: new Date("2026-03-05T10:00:00Z"), + }))); + const ownershipRow = (entityId: string, scopedCompanyId = companyId) => ({ companyId: scopedCompanyId, + actorType: "user", actorId: userId, entityType: "issue", entityId, action: "issue.updated", + createdAt: new Date("2026-03-02T10:00:00Z"), details: { changes: { assigneeAgentId: { from: agentBId, to: agentAId } } } }); + await db.insert(activityLog).values([ownershipRow(parentId), ownershipRow(hiddenId), ownershipRow(childId, foreignCompanyId), + ...Array.from({ length: 1000 }, () => ownershipRow(unpagedId))]); + const queries: Array<{ query: string; parameters: unknown[] }> = []; + const previousDebug = db.$client.options.debug; + let result; + db.$client.options.debug = (_connection, query, parameters) => queries.push({ query, parameters }); + try { + result = await workTimelineService(db).getTimeline({ companyId, limit: 1, + 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; } + 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); + + }); + + 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 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).toHaveBeenCalled(); + expect(response.status).toBe(500); + expect(response.body).not.toHaveProperty("edges"); + } finally { fault.mockRestore(); } + }); + + it("serves the recorded human reassignment instead of inferring the current owner at creation", async () => { + const { companyId, userId, agentAId, agentBId } = await seedBase(); + const issueId = randomUUID(); + const createdAt = new Date("2026-03-05T10:00:00Z"); + const assignedAt = new Date("2026-03-05T11:00:00Z"); + await db.insert(issues).values({ + id: issueId, companyId, title: "Reassigned launch", identifier: "TL-10", + status: "done", priority: "medium", createdByUserId: userId, + assigneeAgentId: agentBId, createdAt, updatedAt: assignedAt, + }); + await db.insert(activityLog).values([ + { companyId, actorType: "user", actorId: userId, action: "issue.updated", + entityType: "issue", entityId: issueId, createdAt: assignedAt, + details: { assigneeAgentId: agentBId, _previous: { assigneeAgentId: agentAId } } }, + { companyId, actorType: "agent", actorId: agentBId, action: "issue.updated", + entityType: "issue", entityId: issueId, createdAt: new Date("2026-03-05T12:00:00Z"), + details: { status: "done", _previous: { status: "in_progress" } } }, + ]); + const res = await request(createApp()).get(`/api/companies/${companyId}/timeline`) + .query({ from: "2026-03-05T00:00:00Z", to: "2026-03-06T00:00:00Z" }); + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body.events.filter((event: { kind: string }) => event.kind === "assigned")).toEqual([ + { actorId: `user:${userId}`, kind: "assigned", issueId, at: assignedAt.toISOString() }, + ]); + expect(res.body.edges.filter((edge: { kind: string }) => edge.kind === "assignment")).toEqual([ + { fromActorId: `user:${userId}`, toActorId: `agent:${agentBId}`, issueId, at: assignedAt.toISOString(), kind: "assignment" }, + ]); + expect(res.body.actors).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: `user:${userId}`, type: "user", name: "User One" }), + expect.objectContaining({ id: `agent:${agentBId}`, type: "agent", name: "QA" }), + ])); + }); + + it("distinguishes absent, null and unchanged owners and keeps same-actor assignment history", async () => { + const { companyId, userId, agentAId, agentBId } = await seedBase(); + const createdAt = new Date("2026-03-05T10:00:00Z"); + const changedAt = new Date("2026-03-05T11:00:00Z"); + for (const kind of ["absent", "null", "unchanged", "canonical-empty", "canonical-unchanged", "self-assignment"] as const) { + const issueId = randomUUID(); + 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 } } } + : { assigneeAgentId, _previous: { assigneeAgentId: agentAId } }; + await db.insert(activityLog).values({ companyId, actorType: "agent", actorId: agentAId, + action: "issue.updated", entityType: "issue", entityId: issueId, details, createdAt: changedAt }); + const res = await request(createApp()).get(`/api/companies/${companyId}/timeline`) + .query({ issueId, from: "2026-03-05T00:00:00Z", to: "2026-03-06T00:00:00Z" }); + expect(res.status, JSON.stringify(res.body)).toBe(200); + const assigned = res.body.events.filter((event: { kind: string }) => event.kind === "assigned"); + 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(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(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() }] : []); + } + } + }); + + it("uses actual PATCH receipts to separate repeated owners from reassignment and unassignment", async () => { + const { companyId, userId, agentAId, agentBId } = await seedBase(); + await db.update(agents).set({ status: "paused" }).where(eq(agents.companyId, companyId)); + const issueId = randomUUID(); + 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); + const repeatedEvent = (await db.select().from(activityLog)).find(row => row.action === "issue.updated" && row.entityId === issueId)!; + expect(repeatedEvent).toMatchObject({ actorType: "user", actorId: userId }); + const details = repeatedEvent.details as Record; + expect(details.assigneeAgentId).toBe(agentAId); + expect(Object.keys(details.changes)).toContain("status"); + expect(details.changes).not.toHaveProperty("assigneeAgentId"); + expect(details._previous).not.toHaveProperty("assigneeAgentId"); + const timeline = async () => { + const response = await request(app).get(`/api/companies/${companyId}/timeline`) + .query({ issueId, from: new Date(createdAt.getTime() - 1000).toISOString(), to: new Date().toISOString() }); + expect(response.status, JSON.stringify(response.body)).toBe(200); + return response.body; + }; + const unchanged = await timeline(); + expect(unchanged.events.filter((event: { kind: string }) => event.kind === "assigned")).toEqual([]); + expect(unchanged.edges.filter((edge: { kind: string }) => edge.kind === "assignment")).toEqual([ + { fromActorId: `user:${userId}`, toActorId: `agent:${agentAId}`, issueId, at: createdAt.toISOString(), kind: "assignment" }, + ]); + const reassigned = await request(app).patch(`/api/issues/${issueId}`).send({ assigneeAgentId: agentBId }); + expect(reassigned.status, JSON.stringify(reassigned.body)).toBe(200); + const changed = await timeline(); + 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.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() }); + const responseSchema = (buildOpenApiSpec() as any).paths["/api/companies/{companyId}/timeline"] + .get.responses["200"].content["application/json"].schema; + const documentedKinds = responseSchema.properties.events.items.properties.kind.enum; + expect(cleared.events.every((event: { kind: string }) => documentedKinds.includes(event.kind))).toBe(true); + 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([]); + }); + it("serves GET /api/companies/:companyId/timeline", async () => { const { companyId, agentAId } = await seedBase(); const issueId = randomUUID(); @@ -538,4 +809,179 @@ 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", "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: 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", "missing-parent", "malformed-parent"] 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: { ...(kind === "missing-parent" ? {} : { parentId: kind === "malformed-parent" ? 42 : 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"] })))); + }); + + it("preserves the initial owner from the real ordinary REST create receipt without parent metadata", async () => { + const { companyId, userId, agentAId } = await seedBase(); + await db.update(agents).set({ status: "paused" }).where(eq(agents.companyId, companyId)); + const app = createApp(userId, true); + const created = await request(app).post(`/api/companies/${companyId}/issues`) + .send({ title: "Ordinary created owner", status: "backlog", assigneeAgentId: agentAId }); + expect(created.status, JSON.stringify(created.body)).toBe(201); + const issueId = created.body.id; + const receipt = (await db.select().from(activityLog)).find(row => row.action === "issue.created" && row.entityId === issueId)!; + expect(receipt.details).toMatchObject({ assigneeAgentId: agentAId, assigneeUserId: null }); + expect(receipt.details).not.toHaveProperty("parentId"); + const response = await request(app).get(`/api/companies/${companyId}/timeline`).query({ issueId, + from: new Date(receipt.createdAt.getTime() - 1000).toISOString(), to: new Date().toISOString() }); + expect(response.status, JSON.stringify(response.body)).toBe(200); + expect(response.body.edges).toEqual([{ fromActorId: `user:${userId}`, toActorId: `agent:${agentAId}`, + issueId, at: receipt.createdAt.toISOString(), kind: "assignment" }]); + expect(response.body.events.filter((event: { kind: string }) => event.kind === "delegated")).toEqual([]); + 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 e25a5fc647d..e7ae7f20531 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -25,6 +25,7 @@ import { monitorPoliciesEqual, applyActorMonitorScheduledBy, assertCanManageIssu import type { IssuePrivacyConstraints } from "@paperclipai/shared"; import { canActorReadHeartbeatRun } from "../services/heartbeat-run-privacy.js"; import { + childCreatedByRequest, loadCreationSourceAgent, loadRunBoundIssue, resolveIssueCreationSource, @@ -13210,6 +13211,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), @@ -13490,6 +13492,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/routes/openapi.ts b/server/src/routes/openapi.ts index 86d55ce1d63..0ede98c42f8 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -1248,6 +1248,7 @@ const workTimelineResponseSchema = z "approved", "delegated", "assigned", + "unassigned", ]), issueId: z.string(), at: z.string(), 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 3bd4b50fa24..c70d4d8047d 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -10003,11 +10003,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(); @@ -10037,7 +10042,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 59676cb33ad..1bce2ef8d36 100644 --- a/server/src/services/work-timeline.ts +++ b/server/src/services/work-timeline.ts @@ -1,11 +1,12 @@ import { withAgentAppearance } from "@paperclipai/shared"; -import { and, asc, desc, eq, gte, inArray, isNull, lte, or, sql } from "drizzle-orm"; +import { and, asc, desc, eq, gte, inArray, isNull, lte, or, sql, type SQL } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { activityLog, agents, approvals, authUsers, + companyMemberships, heartbeatRuns, issueApprovals, issueComments, @@ -119,8 +120,61 @@ function dateIso(value: Date | null | undefined) { return value ? value.toISOString() : null; } -function readString(value: unknown) { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : 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`; + const details = record(sql`${activityLog.details}`); + const previous = record(sql`${details} -> '_previous'`); + const changes = record(sql`${details} -> 'changes'`); + const canonical = sql`${activityLog.action} = 'issue.updated' and jsonb_typeof(${details} -> 'changes') = 'object'`; + const changed = ["assigneeAgentId", "assigneeUserId"].map(key => { + const change = record(sql`${changes} -> ${key}`); + return sql`case when ${canonical} then + (${change} ? 'from' and ${change} ? 'to' and (${change} -> 'from') is distinct from (${change} -> 'to')) + else (${details} ? ${key} and (not (${previous} ? ${key}) or (${details} -> ${key}) is distinct from (${previous} -> ${key}))) end`; + }); + const stringValue = (value: SQL) => sql`case when jsonb_typeof(${value}) = 'string' + then nullif(btrim(${value} #>> '{}'), '') else null 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}) + 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`( + (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' + 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)})) + 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"), + }; } function readNumber(value: unknown) { @@ -439,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}`))) : [], ]); @@ -455,12 +509,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); @@ -486,6 +534,7 @@ export function workTimelineService(db: Db) { }; } + const ownership = timelineOwnershipProjection(); const actorIds = new Set(); const events: WorkTimelineEvent[] = []; const edges: WorkTimelineEdge[] = []; @@ -499,37 +548,6 @@ export function workTimelineService(db: Db) { issueId: issue.id, at: issue.createdAt.toISOString(), }); - - const assigneeActorId = actorForIssueAssignee(issue); - if (assigneeActorId) { - 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) { - 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([ @@ -645,7 +663,14 @@ export function workTimelineService(db: Db) { actorType: activityLog.actorType, actorId: activityLog.actorId, action: activityLog.action, - details: activityLog.details, + creationRecorded: ownership.creationRecorded, + creationParentId: ownership.creationParentId, + creationAgentId: ownership.creationAgentId, + creationUserId: ownership.creationUserId, + assignmentCleared: ownership.cleared, + assignmentRecorded: ownership.recorded, + assignmentAgentId: ownership.agentId, + assignmentUserId: ownership.userId, createdAt: activityLog.createdAt, }) .from(activityLog) @@ -728,39 +753,55 @@ 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"; + ? row.actorType : "system"; const fromActorId = actorId(logActorType, row.actorId); - actorIds.add(fromActorId); - if (row.action.includes("assign")) { - events.push({ actorId: fromActorId, kind: "assigned", issueId: row.issueId, at: row.createdAt.toISOString() }); - const details = row.details && typeof row.details === "object" && !Array.isArray(row.details) - ? row.details as Record - : {}; - const targetAgentId = readString(details.assigneeAgentId) ?? readString(details.toAgentId); - const targetUserId = readString(details.assigneeUserId) ?? readString(details.toUserId); - const toActorId = targetAgentId - ? actorId("agent", targetAgentId) - : targetUserId - ? actorId("user", targetUserId) - : null; - if (toActorId) { - actorIds.add(toActorId); - edges.push({ - fromActorId, - toActorId, - issueId: row.issueId, - at: row.createdAt.toISOString(), - kind: "assignment", - }); + 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); + 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); + edges.push({ fromActorId, toActorId, issueId: row.issueId, at: row.createdAt.toISOString(), kind: "assignment" }); } const actorMaps = await loadActorMaps(input.companyId, actorIds); - const actors: WorkTimelineActor[] = Array.from(actorIds).map((id) => { + const publicActorIds = new Set(Array.from(actorIds).filter(id => { + if (id.startsWith("agent:")) return actorMaps.agents.has(id.slice("agent:".length)); + if (id.startsWith("user:")) return actorMaps.users.has(id.slice("user:".length)); + return true; + })); + 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); @@ -776,9 +817,9 @@ 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)), + spans: Array.from(spanByRunId.values()).filter(span => publicActorIds.has(span.actorId)).sort((left, right) => left.start.localeCompare(right.start)), + 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/chat-full-catalog-testops-manifest.json b/tests/runner-e2e/chat-full-catalog-testops-manifest.json index ad1ceed82ef..66cef83f915 100644 --- a/tests/runner-e2e/chat-full-catalog-testops-manifest.json +++ b/tests/runner-e2e/chat-full-catalog-testops-manifest.json @@ -1,6 +1,6 @@ { "schema": "paperclip.chat-full-catalog-testops-manifest/v1", - "targetSha": "ab2019e976076ec0eb60b026844f3d9211cb0208", + "targetSha": "fd5e3b1e5a379524c0d1651ed8273e75ffd86541", "metadataSourceSha": "de0941835cc8f0d01c982574ee258e110babdbeb", "profile": "runner-opencode", "model": "openrouter/deepseek/deepseek-v4.1-flash-1017", @@ -170,9 +170,9 @@ ], "repairMetadata": { "baseTargetSha": "3b66c3c144360ddf32714a0742fd774b8ceafb16", - "previousTargetSha": "de6d29e025683f5c28125bcaa4f38b41836d8516", + "previousTargetSha": "ab2019e976076ec0eb60b026844f3d9211cb0208", "definitionSourceSha": "de0941835cc8f0d01c982574ee258e110babdbeb", - "candidateDescriptorSha256": "d214f8bf1d6c8c737682999ca9eb9d4ea2a5945bebbccb96e9ac326d538b0629", + "candidateDescriptorSha256": "b70bd45c3e0bb2fcca04788259b7935483dd2eb5c8be82300730511383a2a150", "changedDefinitionFiles": [ { "path": "tests/runner-e2e/chat-scenarios/companies.yaml", @@ -191,7 +191,7 @@ "sha256": "18fbb9419bbdacb46eacdaa62dfa99feb9024faf2d1eef829aa70d43952575a9" } ], - "conditions": "New candidate measurements only; original definitions, failed attempts and previousSource5 de6 remain immutable. Source6 retains all Source5 repairs and adds remaining ORIGINAL deadline plus exact canonical source guards for personal-secret and team-install initial observations, real hire-review link readiness, and genuine skill-update provider checkpoints after cheap native readiness. Candidate-only Core18742/61996 overlay permits only a new Ask-first review for a strictly authenticated later same-person human intent after decline, including issue.comment.reopen; same-intent replay remains denied, with no approval or provider authority granted. All queries/models/human/native counts/clocks remain as de6; earlier Studio144 explicit2..3 accounting remains. CorePRs18717/18734/18742 remain UNMERGED; SDK115 completion remainsUNKNOWN and otherfiveSDK cases stay held.", + "conditions": "New candidate measurements only. Original definitions, prior failed attempts and source6 ab201 remain historical. This target retains that catalog and integrates merged runtime fixes #18711, #18713 and #18718, with the prior-run restoration condition adapted to its existing owner. It adds the reviewed previous-question locator, attributed human-only denial and conditional-outcome oracle, and actor-sensitive timeline narrative/recorded-reassignment fixes. Closed finish diagnostics retain an effective schema digest, hashed call correlation and bounded type/category enums only. Historical MCP timeout and continuation-shape causes remain unresolved; no new all-ten PASS is claimed. Queries, model, human/native counts and original clocks remain fixed; the earlier Studio144 explicit 2..3 accounting stays. Core PRs18717/18734/18742 are now merged. SDK115 completion remains UNKNOWN and the other five SDK cases remain held. Full244 runtime catalog is not merged to master and the196/244 gate is unchanged.", "changedFields": [ { "key": "activity.work-timeline", @@ -214,14 +214,22 @@ "chat-catalog-domains/internal-activity.ts", "chat-scenarios/activity.yaml", "chat-catalog-domains/internal-work-timeline.ts", + "chat-catalog-domains/internal-timeline-narrative.ts", "chat-catalog-domains/internal-timeline-verifier.ts", "chat-catalog-domains/internal-privacy.ts", "chat-catalog-domains/identity-auth.ts", "chat-catalog-domains/internal-routine-audit.ts", "../e2e/fetch-allowed-port.ts", - "../../server/src/services/native-runtime/status-decision-committer.ts" + "../../server/src/services/native-runtime/status-decision-committer.ts", + "../../server/src/services/work-timeline.ts" ] }, + { + "key": "activity.work-timeline", + "field": "oracleVersion", + "previous": "paperclip.chat-catalog.internal.v1", + "current": "paperclip.chat-catalog.work-timeline.v2" + }, { "key": "agents.choose-primary", "field": "sourceFiles", @@ -261,6 +269,9 @@ ], "current": [ "chat-catalog-domains/external-app-review.ts", + "chat-catalog-domains/external-app-review-outcomes.ts", + "chat-catalog-domains/external-app-review-probe.ts", + "../../packages/shared/src/validators/issue.ts", "chat-catalog-domains/external-provider.ts", "chat-catalog-domains/identity-support.ts", "chat-catalog-domains/external-secret-scan.ts", @@ -286,7 +297,7 @@ "key": "app-permissions.ask-before-action", "field": "oracleVersion", "previous": "paperclip.chat-catalog.external.v1", - "current": "paperclip.chat-catalog.app-review.v2" + "current": "paperclip.chat-catalog.app-review.v3" }, { "key": "app-permissions.test-as-agent", @@ -842,14 +853,22 @@ "chat-catalog-domains/internal-activity.ts", "chat-scenarios/activity.yaml", "chat-catalog-domains/internal-work-timeline.ts", + "chat-catalog-domains/internal-timeline-narrative.ts", "chat-catalog-domains/internal-timeline-verifier.ts", "chat-catalog-domains/internal-privacy.ts", "chat-catalog-domains/identity-auth.ts", "chat-catalog-domains/internal-routine-audit.ts", "../e2e/fetch-allowed-port.ts", - "../../server/src/services/native-runtime/status-decision-committer.ts" + "../../server/src/services/native-runtime/status-decision-committer.ts", + "../../server/src/services/work-timeline.ts" ] }, + { + "key": "activity.work-timeline", + "field": "oracleVersion", + "previous": "paperclip.chat-catalog.internal.v1", + "current": "paperclip.chat-catalog.work-timeline.v2" + }, { "key": "agents.choose-primary", "field": "sourceFiles", @@ -889,6 +908,9 @@ ], "current": [ "chat-catalog-domains/external-app-review.ts", + "chat-catalog-domains/external-app-review-outcomes.ts", + "chat-catalog-domains/external-app-review-probe.ts", + "../../packages/shared/src/validators/issue.ts", "chat-catalog-domains/external-provider.ts", "chat-catalog-domains/identity-support.ts", "chat-catalog-domains/external-secret-scan.ts", @@ -914,7 +936,7 @@ "key": "app-permissions.ask-before-action", "field": "oracleVersion", "previous": "paperclip.chat-catalog.external.v1", - "current": "paperclip.chat-catalog.app-review.v2" + "current": "paperclip.chat-catalog.app-review.v3" }, { "key": "app-permissions.test-as-agent", @@ -1481,11 +1503,11 @@ "runtimeConditions": { "toolGuidance106": { "corePR": 18717, - "corePRUnmerged": true, + "corePRUnmerged": false, "sourcePath": "packages/paperclip-runner/src/protocol-actions/create-skill.ts", "sourceSha256": "02738a3afec54d7d5f417c10efd96f428409da4748d58d9bd60c2c4a6457916e", "queryAndModelUnchanged": true, - "condition": "Candidate-only new-authoring versus existing-source-import guidance; own catalogs regenerated, no master inventory transplant." + "condition": "Reviewed new-authoring versus existing-source-import guidance; source PR18717 is merged. Own catalog source bytes remain pinned without a master catalog transplant." }, "studio144Accounting": { "key": "skills.studio-test", @@ -1505,8 +1527,8 @@ "condition": "Reviewed synthetic staging input only; per-worker singleton550 enables it; production feed and global index unchanged." }, "sdkPublicProof": { - "previousRegistrySha256": "018b5b15450e84f63920575de3b5903f7838678ef66a1e17aa050e0b21b40319", - "registrySha256": "32c7971e1d9c9d31cd6791045b0265f0475c8f10932ff6dd34681681b6a89342", + "previousRegistrySha256": "32c7971e1d9c9d31cd6791045b0265f0475c8f10932ff6dd34681681b6a89342", + "registrySha256": "9169583e54d165dc8d08b9a9010175d11ca87f3590477a7b23032508020ea039", "inputCount": 506, "scannerAndCanaryCertificatesUnchanged": true, "plugin558StillHeld": true, @@ -1514,17 +1536,37 @@ "previousExpectedLockSha256": "c7f23021335b60da0cff1dd4c75cf8b948333ef3165d7d1703eb79a589edb798", "expectedLockSha256": "54a81b2ed3f1f85eaa085c034560ac248e384db34fbc7aba8c5a3846c29b3ce8", "historical43Finding": "detector3 unresolved_context/publicSource null; owning proof rejected stale expected lock", - "newCandidate115Unqualified": true + "newCandidate115Unqualified": true, + "sourceInputsChanged": true, + "changedInputPaths": [ + "packages/shared/src/types/work-timeline.ts" + ], + "compileQualification": { + "baselineSourceSha": "124232037eebe73a513907b08f0e96bfdd454c82", + "compiledSourceSha": "6abe5739a713d0a8bb0308b1495846881b82d253", + "acceptedSourceSha": "b734393eff0d7ef85b7ad8abd556d2a66ca3a4e6", + "all506InputGitBlobsMatched": true, + "owningSharedAndPluginSdkTypeScriptBuildsPassed": true, + "changedRuntimeJavaScriptFiles": 0, + "changedDeclarationFiles": [ + "packages/shared/dist/types/work-timeline.d.ts" + ], + "existingAndUnassignedConsumerCompiled": true, + "unknownEventKindRejected": true, + "sourceSdkControlsPassed": 51, + "inputAndBuildCustodyControlsPassed": 20, + "runtimeHeldCasesRemainHeld": true + } }, "toolProposal487": { "corePr": 18734, "coreHead": "50600786f755b6602989f3b0f88cd090ff948196", - "corePRUnmerged": true, - "condition": "Candidate-only reviewed disclosure plus bounded pre-trim guard; no authority change. CorePR18734 remainsunmerged.", + "corePRUnmerged": false, + "condition": "Reviewed disclosure and bounded pre-trim guard from merged CorePR18734; no authority change.", "replay": { "corePR": 18742, "coreHead": "61996a96dd09e90a5788e8711a94fc5ae631f425", - "corePRUnmerged": true, + "corePRUnmerged": false, "condition": "A new Ask-first request only for an authenticated later same-person human intent after the prior decline; same-intent retry and derived or foreign sources remain denied. No approval/execution authority change.", "acceptedDirectSources": [ "issue.comment", @@ -1536,7 +1578,7 @@ }, "candidateLauncherSnapshot": { "path": "fixtures/chat-repair-candidate-launcher.txt", - "targetSha": "ab2019e976076ec0eb60b026844f3d9211cb0208", + "targetSha": "fd5e3b1e5a379524c0d1651ed8273e75ffd86541", "sourcePath": "tests/runner-e2e/launch.ts", "gitBlobOid": "dba1467e211611f37937e9e1fc4c0c5be4329cbc" } @@ -2079,19 +2121,21 @@ "activity/timeline" ], "checks": null, - "oracleVersion": "paperclip.chat-catalog.internal.v1", + "oracleVersion": "paperclip.chat-catalog.work-timeline.v2", "sourceFiles": [ "chat-catalog-domains/internal-definition.ts", "chat-catalog-domains/internal-support.ts", "chat-catalog-domains/internal-activity.ts", "chat-scenarios/activity.yaml", "chat-catalog-domains/internal-work-timeline.ts", + "chat-catalog-domains/internal-timeline-narrative.ts", "chat-catalog-domains/internal-timeline-verifier.ts", "chat-catalog-domains/internal-privacy.ts", "chat-catalog-domains/identity-auth.ts", "chat-catalog-domains/internal-routine-audit.ts", "../e2e/fetch-allowed-port.ts", - "../../server/src/services/native-runtime/status-decision-committer.ts" + "../../server/src/services/native-runtime/status-decision-committer.ts", + "../../server/src/services/work-timeline.ts" ], "screenshot": "final-state.png", "evidenceKind": "private-final-state-png", @@ -3155,9 +3199,12 @@ "questions-and-approvals/tool-review" ], "checks": null, - "oracleVersion": "paperclip.chat-catalog.app-review.v2", + "oracleVersion": "paperclip.chat-catalog.app-review.v3", "sourceFiles": [ "chat-catalog-domains/external-app-review.ts", + "chat-catalog-domains/external-app-review-outcomes.ts", + "chat-catalog-domains/external-app-review-probe.ts", + "../../packages/shared/src/validators/issue.ts", "chat-catalog-domains/external-provider.ts", "chat-catalog-domains/identity-support.ts", "chat-catalog-domains/external-secret-scan.ts", diff --git a/tests/runner-e2e/chat-full-catalog-testops-manifest.test.mjs b/tests/runner-e2e/chat-full-catalog-testops-manifest.test.mjs index 3b44ba66cc9..9dca3e5607f 100644 --- a/tests/runner-e2e/chat-full-catalog-testops-manifest.test.mjs +++ b/tests/runner-e2e/chat-full-catalog-testops-manifest.test.mjs @@ -102,8 +102,8 @@ test('foreign identity, duplicated case and changed run-bound metadata are rejec test('repair source metadata binds the corrected candidate and explicit changed definitions', () => { const repair = manifest.source.repairMetadata; assert.equal(repair.baseTargetSha, '3b66c3c144360ddf32714a0742fd774b8ceafb16'); - assert.equal(repair.previousTargetSha, 'de6d29e025683f5c28125bcaa4f38b41836d8516'); - assert.equal(manifest.targetSha, 'ab2019e976076ec0eb60b026844f3d9211cb0208'); + assert.equal(repair.previousTargetSha, 'ab2019e976076ec0eb60b026844f3d9211cb0208'); + assert.equal(manifest.targetSha, 'fd5e3b1e5a379524c0d1651ed8273e75ffd86541'); assert.equal(repair.definitionSourceSha, manifest.metadataSourceSha); assert.equal(repair.candidateDescriptorSnapshot, 'fixtures/chat-repair-candidate-descriptors.json'); const descriptorBytes = readFileSync(new URL(repair.candidateDescriptorSnapshot, import.meta.url)); @@ -116,6 +116,10 @@ test('repair source metadata binds the corrected candidate and explicit changed "activity.work-timeline", "sourceFiles" ], + [ + "activity.work-timeline", + "oracleVersion" + ], [ "agents.choose-primary", "sourceFiles" @@ -285,7 +289,7 @@ test('repair source metadata binds the corrected candidate and explicit changed assert.deepEqual(row(change.key)[change.field], change.current); assert.notDeepEqual(change.current, change.previous); } - assert.equal(repair.descriptorChanges.length, 41); + assert.equal(repair.descriptorChanges.length, 42); assert.equal(new Set(repair.descriptorChanges.map(change => change.key)).size, 28); assert.deepEqual([...new Set(repair.descriptorChanges.map(change => change.field))].sort(), ['expectedRunCount', 'minimumRunCount', 'oracleVersion', 'setupPolicy', 'sourceFiles']); for (const change of repair.descriptorChanges) { @@ -310,16 +314,18 @@ test('repair source metadata binds the corrected candidate and explicit changed } assert.deepEqual(repair.changedFields.filter(change => ['minimumRuns', 'maximumRuns'].includes(change.field)), [{ key: 'skills.studio-test', field: 'maximumRuns', previous: 2, current: 3 }]); assert.deepEqual(repair.runtimeConditions.studio144Accounting, { key: 'skills.studio-test', previousMinimumRuns: 2, previousMaximumRuns: 2, minimumRuns: 2, maximumRuns: 3, requiredActualAcceptedRuns: 3, originalAttemptTimeoutMs: 540000, humanMessages: 1, condition: 'Initial CEO + real Studio worker + exactly one source-bound completion delivery;2-run no-delivery positive is rejected.' }); - assert.equal(repair.runtimeConditions.toolGuidance106.corePRUnmerged, true); + assert.equal(repair.runtimeConditions.toolGuidance106.corePRUnmerged, false); assert.equal(repair.runtimeConditions.sdkPublicProof.scannerAndCanaryCertificatesUnchanged, true); + assert.equal(repair.runtimeConditions.sdkPublicProof.sourceInputsChanged, true); + assert.equal(repair.runtimeConditions.sdkPublicProof.registrySha256, "9169583e54d165dc8d08b9a9010175d11ca87f3590477a7b23032508020ea039"); assert.equal(repair.runtimeConditions.sdkPublicProof.plugin558StillHeld, true); assert.equal(repair.runtimeConditions.sdkPublicProof.expectedLockSha256, "54a81b2ed3f1f85eaa085c034560ac248e384db34fbc7aba8c5a3846c29b3ce8"); assert.equal(repair.runtimeConditions.sdkPublicProof.runtimeCompilerParserZodIdentitiesUnchanged, true); - assert.equal(repair.runtimeConditions.toolProposal487.corePRUnmerged, true); + assert.equal(repair.runtimeConditions.toolProposal487.corePRUnmerged, false); assert.deepEqual(repair.runtimeConditions.toolProposal487.replay, { "corePR": 18742, "coreHead": "61996a96dd09e90a5788e8711a94fc5ae631f425", - "corePRUnmerged": true, + "corePRUnmerged": false, "condition": "A new Ask-first request only for an authenticated later same-person human intent after the prior decline; same-intent retry and derived or foreign sources remain denied. No approval/execution authority change.", "acceptedDirectSources": [ "issue.comment", diff --git a/tests/runner-e2e/fixtures/chat-repair-candidate-descriptors.json b/tests/runner-e2e/fixtures/chat-repair-candidate-descriptors.json index d5edf51a771..1b74b4ed1f8 100644 --- a/tests/runner-e2e/fixtures/chat-repair-candidate-descriptors.json +++ b/tests/runner-e2e/fixtures/chat-repair-candidate-descriptors.json @@ -249,14 +249,16 @@ "chat-catalog-domains/internal-activity.ts", "chat-scenarios/activity.yaml", "chat-catalog-domains/internal-work-timeline.ts", + "chat-catalog-domains/internal-timeline-narrative.ts", "chat-catalog-domains/internal-timeline-verifier.ts", "chat-catalog-domains/internal-privacy.ts", "chat-catalog-domains/identity-auth.ts", "chat-catalog-domains/internal-routine-audit.ts", "../e2e/fetch-allowed-port.ts", - "../../server/src/services/native-runtime/status-decision-committer.ts" + "../../server/src/services/native-runtime/status-decision-committer.ts", + "../../server/src/services/work-timeline.ts" ], - "oracleVersion": "paperclip.chat-catalog.internal.v1", + "oracleVersion": "paperclip.chat-catalog.work-timeline.v2", "coverageGaps": [], "expectedRunCount": 3, "minimumRunCount": 3, @@ -804,6 +806,9 @@ "scenarioId": "app-permissions.ask-before-action", "sourceFiles": [ "chat-catalog-domains/external-app-review.ts", + "chat-catalog-domains/external-app-review-outcomes.ts", + "chat-catalog-domains/external-app-review-probe.ts", + "../../packages/shared/src/validators/issue.ts", "chat-catalog-domains/external-provider.ts", "chat-catalog-domains/identity-support.ts", "chat-catalog-domains/external-secret-scan.ts", @@ -824,7 +829,7 @@ "../../ui/src/components/task-chat/TaskChatComposer.tsx", "chat-scenarios/app-permissions.yaml" ], - "oracleVersion": "paperclip.chat-catalog.app-review.v2", + "oracleVersion": "paperclip.chat-catalog.app-review.v3", "expectedRunCount": 6, "expectedHumanMessageCount": 3, "minimumRunCount": 6,