fix(evals): retain canonical child receipts and company user scope

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-11-03 12:54:07 -06:00
1 parent 2cb9a62040
commit e6c32a1251
7 files changed
+280 -12

No files matched your search

@@ -0,0 +1,204 @@
import { randomUUID } from "node:crypto";
import type { Express } from "express";
import { eq } from "drizzle-orm";
import request from "supertest";
import { expect, it } from "vitest";
import { activityLog, agents, authUsers, companies, companyMemberships, createDb, documents, documentRevisions, heartbeatRuns, issueComments, issueDocuments, issueThreadInteractions, issues } from "@paperclipai/db";
import { childCreatedByRequest } from "../../services/issue-creation-source.js";
import { issueService } from "../../services/issues.js";
import { createAcceptedPlanDecompositionSchema, createChildIssueSchema } from "@paperclipai/shared";
type Seed = { companyId: string; userId: string; agentAId: string; agentBId: string };
type Fixture = {
getDb: () => ReturnType<typeof createDb>;
seedBase: () => Promise<Seed>;
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);
});
}
@@ -10,6 +10,11 @@ import {
approvals,
authUsers,
companies,
companyMemberships,
documents,
documentRevisions,
issuePlanDecompositions,
issueThreadInteractions,
createDb,
heartbeatRuns,
issueApprovals,
@@ -24,6 +29,7 @@ import { errorHandler } from "../middleware/index.js";
import { issueRoutes } from "../routes/issues.js";
import { companyRoutes } from "../routes/companies.js";
import { buildOpenApiSpec } from "../routes/openapi.js";
import { registerTimelineReceiptBoundaryCases } from "./helpers/work-timeline-receipt-cases.js";
import { normalizeTimelineWindow, workTimelineService } from "../services/work-timeline.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
@@ -50,9 +56,14 @@ describeEmbeddedPostgres("work timeline aggregation", () => {
await db.delete(issueApprovals);
await db.delete(approvals);
await db.delete(heartbeatRuns);
await db.delete(issuePlanDecompositions);
await db.delete(issueThreadInteractions);
await db.delete(issues);
await db.delete(documentRevisions);
await db.delete(documents);
await db.delete(agentWakeupRequests);
await db.delete(agents);
await db.delete(companyMemberships);
await db.delete(authUsers);
await db.delete(companies);
});
@@ -61,11 +72,11 @@ describeEmbeddedPostgres("work timeline aggregation", () => {
await tempDb?.cleanup();
});
function createApp(actorUserId = "local-board", includeIssues = false) {
function createApp(actorUserId = "local-board", includeIssues = false, agentActor?: { companyId: string; agentId: string }) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
(req as any).actor = agentActor ? { type: "agent", source: "agent_key", ...agentActor } : {
type: "board",
userId: actorUserId,
companyIds: [],
@@ -100,6 +111,7 @@ describeEmbeddedPostgres("work timeline aggregation", () => {
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
});
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "operator" });
await db.insert(agents).values([
{
id: agentAId,
@@ -970,4 +982,6 @@ describeEmbeddedPostgres("work timeline aggregation", () => {
expect(await db.select().from(heartbeatRuns)).toEqual([]);
});
registerTimelineReceiptBoundaryCases({ getDb: () => db, seedBase, createApp });
});
+3
View File
@@ -20,6 +20,7 @@ import { monitorPoliciesEqual, applyActorMonitorScheduledBy, assertCanManageIssu
import type { IssuePrivacyConstraints } from "@paperclipai/shared";
import { canActorReadHeartbeatRun } from "../services/heartbeat-run-privacy.js";
import {
childCreatedByRequest,
loadCreationSourceAgent,
loadRunBoundIssue,
resolveIssueCreationSource,
@@ -13119,6 +13120,7 @@ export function issueRoutes(
entityId: issue.id,
details: {
parentId: parent.id,
createdByRequest: childCreatedByRequest(issueId, issue.id),
identifier: issue.identifier,
title: issue.title,
...buildCreateIssueActivityStatusDetails(issue, res),
@@ -13399,6 +13401,7 @@ export function issueRoutes(
entityId: issue.id,
details: {
parentId: sourceIssue.id,
createdByRequest: true,
identifier: issue.identifier,
title: issue.title,
inheritedExecutionWorkspaceFromIssueId: sourceIssue.id,
@@ -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;
}
+6 -1
View File
@@ -9916,11 +9916,16 @@ export function issueService(db: Db) {
);
}
let childReused = false;
const createdChild = await issueService(
tx as unknown as Db,
).createChild(sourceIssue.id, {
...nextChildInput,
executionWorkspaceInheritanceMode: "strategy_only",
onDeduplicated: reason => {
childReused = true;
nextChildInput.onDeduplicated?.(reason);
},
});
const nextIds = [...existingChildIssueIds, createdChild.issue.id];
const now = new Date();
@@ -9950,7 +9955,7 @@ export function issueService(db: Db) {
);
return {
claim: updatedClaim,
createdIssue: createdChild.issue,
createdIssue: childReused ? null : createdChild.issue,
};
});
+31 -8
View File
@@ -6,6 +6,7 @@ import {
agents,
approvals,
authUsers,
companyMemberships,
heartbeatRuns,
issueApprovals,
issueComments,
@@ -119,6 +120,14 @@ function dateIso(value: Date | null | undefined) {
return value ? value.toISOString() : null;
}
function activeTimelineUser(companyId: string | SQL, userId: SQL) {
return sql<boolean>`exists (select 1 from ${companyMemberships}
where ${companyMemberships.companyId} = ${companyId}
and ${companyMemberships.principalType} = 'user'
and ${companyMemberships.principalId} = ${userId}
and ${companyMemberships.status} = 'active')`;
}
/** Scalar ownership facts for bounded recorded creation and change events. */
function timelineOwnershipProjection() {
const record = (value: SQL) => sql`case when jsonb_typeof(${value}) = 'object' then ${value} else '{}'::jsonb end`;
@@ -142,7 +151,8 @@ function timelineOwnershipProjection() {
const sameCompanyAgent = (id: SQL) => sql`exists (select 1 from ${agents}
where ${agents.id} = case when ${id} ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
then ${id}::uuid else null end and ${agents.companyId} = ${activityLog.companyId})`;
const knownUser = (id: SQL) => sql`exists (select 1 from ${authUsers} where ${authUsers.id} = ${id})`;
const knownUser = (id: SQL) => sql`(exists (select 1 from ${authUsers} where ${authUsers.id} = ${id})
and ${activeTimelineUser(sql`${activityLog.companyId}`, id)})`;
const creationOwnerTypes = ["assigneeAgentId", "assigneeUserId"].map(key => sql`${details} ? ${key}
and jsonb_typeof(${details} -> ${key}) in ('string', 'null')`);
const cleared = ([["assigneeAgentId", "toAgentId"], ["assigneeUserId", "toUserId"]] as const).map(([key, alias], index) => sql`(
@@ -152,7 +162,8 @@ function timelineOwnershipProjection() {
recorded: sql<boolean>`coalesce(position('assign' in ${activityLog.action}) > 0 or
(${activityLog.action} = 'issue.updated' and (${sql.join(changed, sql` or `)})), false)`,
cleared: sql<boolean>`coalesce(${sql.join(cleared, sql` or `)}, false)`,
creationRecorded: sql<boolean>`coalesce(${activityLog.action} = 'issue.created'
creationRecorded: sql<boolean>`coalesce((${activityLog.action} = 'issue.created'
or (${activityLog.action} = 'issue.child_created' and ${details} -> 'createdByRequest' = 'true'::jsonb))
and (${sql.join(creationOwnerTypes, sql` and `)})
and ((${creationAgentId} is not null and ${creationUserId} is null and ${sameCompanyAgent(creationAgentId)})
or (${creationUserId} is not null and ${creationAgentId} is null and ${knownUser(creationUserId)}))
@@ -482,7 +493,7 @@ export function workTimelineService(db: Db) {
? db
.select({ id: authUsers.id, name: authUsers.name, image: authUsers.image })
.from(authUsers)
.where(inArray(authUsers.id, maybeUuidList(userIds)))
.where(and(inArray(authUsers.id, maybeUuidList(userIds)), activeTimelineUser(companyId, sql`${authUsers.id}`)))
: [],
]);
@@ -742,12 +753,23 @@ export function workTimelineService(db: Db) {
});
}
// The first visible receipt is selected before validating its facts. A
// later replay must not rescue an unknown original or invent its owner.
const creationReceipts = new Map<string, typeof logRows[number]>();
for (const row of logRows) {
if (row.action !== "issue.created" && row.action !== "issue.child_created") continue;
const first = creationReceipts.get(row.issueId);
if (!first || row.createdAt < first.createdAt) creationReceipts.set(row.issueId, row);
}
for (const row of logRows) {
const logActorType = row.actorType === "agent" || row.actorType === "user" || row.actorType === "plugin"
? row.actorType : "system";
const fromActorId = actorId(logActorType, row.actorId);
if (row.action === "issue.created") {
if (!row.creationRecorded) continue;
if (row.action === "issue.created" || row.action === "issue.child_created") {
const issue = issueById.get(row.issueId);
if (creationReceipts.get(row.issueId) !== row || !row.creationRecorded || !issue
|| issue.createdAt < from || issue.createdAt > to) continue;
const toActorId = row.creationAgentId ? actorId("agent", row.creationAgentId)
: actorId("user", row.creationUserId!);
actorIds.add(fromActorId);
@@ -774,7 +796,8 @@ export function workTimelineService(db: Db) {
}
const actorMaps = await loadActorMaps(input.companyId, actorIds);
const actors: WorkTimelineActor[] = Array.from(actorIds).map((id) => {
const publicActorIds = new Set(Array.from(actorIds).filter(id => !id.startsWith("user:") || actorMaps.users.has(id.slice("user:".length))));
const actors: WorkTimelineActor[] = Array.from(publicActorIds).map((id) => {
const [type, rawId] = id.split(":", 2) as [TimelineActorType, string];
if (type === "agent") {
const agent = actorMaps.agents.get(rawId);
@@ -791,8 +814,8 @@ export function workTimelineService(db: Db) {
return {
actors,
spans: Array.from(spanByRunId.values()).sort((left, right) => left.start.localeCompare(right.start)),
events: events.sort((left, right) => left.at.localeCompare(right.at)),
edges: edges.sort((left, right) => left.at.localeCompare(right.at)),
events: events.filter(event => publicActorIds.has(event.actorId)).sort((left, right) => left.at.localeCompare(right.at)),
edges: edges.filter(edge => publicActorIds.has(edge.fromActorId) && publicActorIds.has(edge.toActorId)).sort((left, right) => left.at.localeCompare(right.at)),
pagination: {
limit,
offset,
+15 -1
View File
@@ -3305,10 +3305,24 @@ route regressions do not qualify the actual model journey. Historical failures
retain their original grades; a missing human handoff narrative still fails.
The timeline API projects initial assignment and delegation only from complete
`issue.created` audit facts inside the requested window. It uses the saved actor,
`issue.created` audit facts or explicitly fresh `issue.child_created` receipts
inside the requested window. Child receipts require `createdByRequest: true`;
false, absent or malformed flags do not establish initial ownership. The first
bounded receipt is selected before validation, and the issue creation time must
also be in the requested window. This is not an all-history completeness proof.
It uses the saved actor,
target and timestamp, and requires a readable recorded parent for delegation.
It does not use current ownership as creation evidence. Missing or malformed
legacy creation details leave initial ownership unknown. The API performs no
all-history ownership-presence query. Recorded canonical and legacy ownership
clears emit `unassigned` events with their actual actor and timestamp, without a
target edge. Event markers are not currently rendered by the timeline chart.
User identities in the timeline require active membership in the requested
company. This gate covers receipt validation, identity lookup, and public actor,
event and edge output, including raw IDs. Canonical audit rows remain unchanged.
The public child and accepted-plan schemas strip idempotency and conversation
keys; private service replay controls do not imply HTTP replay support. The
accepted-plan fresh-child list excludes keyed/title reuse reported by its
existing callback for supported task inputs. This is not a universal internal
conversation-creation provenance guarantee.