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