fix(activity): compose qualified recorded ownership timeline source

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-11-03 10:34:58 -06:00
1 parent d113cd3fde
commit 07f71dbd75
6 files changed
+257 -94

No files matched your search

-8
View File
@@ -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,
+2 -1
View File
@@ -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"] }))));
});
});
+53 -59
View File
@@ -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" });
}
+9
View File
@@ -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",