mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
fix(evals): retain canonical child receipts and company user scope
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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 });
|
||||
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user