[codex] add company work timeline endpoint (#8875)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Operators need visibility into who initiated work, which agents ran, and how tasks were delegated across a company. > - The existing control plane stores the raw data across issues, heartbeat runs, comments, approvals, interactions, and activity logs. > - There was no single company-scoped API response that reconstructed those records into timeline actors, spans, events, and edges for a Gantt-style view. > - This pull request adds that aggregation endpoint behind the same company and issue read authorization model used elsewhere. > - The benefit is that UI work can consume one bounded endpoint instead of reimplementing timeline joins client-side. ## Linked Issues or Issue Description No public GitHub issue exists for this feature. ## Problem or motivation Paperclip stores enough execution and delegation data to show work over time, but consumers need a single endpoint that aggregates it consistently. ## Proposed solution Add `GET /api/companies/:companyId/timeline` with date and entity filters, bounded windows, pagination, actor normalization, run spans, human events, and delegation/assignment edges. ## Alternatives considered Querying each source separately from the UI would duplicate ACL and attribution logic and make client rendering depend on storage details. ## Roadmap alignment This supports operator visibility and auditability, and does not duplicate a listed roadmap item. ## What Changed - Added a `workTimelineService` that aggregates issue candidates from runs, activity, comments, approvals, interactions, and recently touched issues. - Added `GET /api/companies/:companyId/timeline` with `from`, `to`, `userId`, `goalId`, `projectId`, `issueId`, `limit`, and `offset` query parameters. - Enforced company-scope access plus per-issue `issue:read` filtering before emitting spans, events, or edges. - Added 31-day window capping, in-progress span handling for null `finishedAt`, retry/continuation metadata, user-lens subtree filtering, and activity-log run attribution fallback. - Added embedded-Postgres tests for aggregation joins, route behavior, ACL filtering, window capping, and user-lens closure. ## Verification - `pnpm vitest run server/src/__tests__/work-timeline-service.test.ts` - `pnpm exec tsc -p server/tsconfig.json --noEmit` Additional smoke attempted: - `pnpm dev:once` did not start the local app because the existing embedded instance has pending migration drift: Postgres rejected a foreign key on `pipeline_case_blockers.company_id` because that column does not exist. I did not manually alter the embedded database. ## Risks - Medium risk: this introduces a new aggregate endpoint over several tables, so query volume should be watched on very large companies. - The endpoint caps windows and paginates issue candidates to keep the first version bounded. - ACL behavior is fail-closed per issue: unreadable issues are filtered before response rows are emitted. - No migrations or schema changes are included. ## Model Used OpenAI GPT-5 via Codex coding agent, with tool use for repository inspection, editing, local Vitest execution, TypeScript checking, git, and GitHub CLI operations. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
dae6fc72f0
commit
dea7c4e274
|
|
@ -0,0 +1,378 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
approvals,
|
||||
authUsers,
|
||||
companies,
|
||||
createDb,
|
||||
heartbeatRuns,
|
||||
issueApprovals,
|
||||
issueComments,
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { companyRoutes } from "../routes/companies.js";
|
||||
import { normalizeTimelineWindow, workTimelineService } from "../services/work-timeline.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres work timeline tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("work timeline aggregation", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-work-timeline-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(activityLog);
|
||||
await db.delete(issueComments);
|
||||
await db.delete(issueApprovals);
|
||||
await db.delete(approvals);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(issues);
|
||||
await db.delete(agents);
|
||||
await db.delete(authUsers);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: [],
|
||||
memberships: [],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api/companies", companyRoutes(db, {} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
async function seedBase() {
|
||||
const companyId = randomUUID();
|
||||
const userId = "user-1";
|
||||
const agentAId = randomUUID();
|
||||
const agentBId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Timeline Co",
|
||||
issuePrefix: `T${randomUUID().replace(/-/g, "").slice(0, 4).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(authUsers).values({
|
||||
id: userId,
|
||||
name: "User One",
|
||||
email: "user@example.com",
|
||||
emailVerified: true,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
||||
});
|
||||
await db.insert(agents).values([
|
||||
{
|
||||
id: agentAId,
|
||||
companyId,
|
||||
name: "Coder",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
{
|
||||
id: agentBId,
|
||||
companyId,
|
||||
name: "QA",
|
||||
role: "qa",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
]);
|
||||
return { companyId, userId, agentAId, agentBId };
|
||||
}
|
||||
|
||||
it("normalizes timeline windows with a 31 day cap", () => {
|
||||
const result = normalizeTimelineWindow({
|
||||
from: new Date("2026-01-01T00:00:00Z"),
|
||||
to: new Date("2026-03-15T00:00:00Z"),
|
||||
}, new Date("2026-03-15T00:00:00Z"));
|
||||
|
||||
expect(result.capped).toBe(true);
|
||||
expect(result.from.toISOString()).toBe("2026-02-12T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("aggregates runs, human events, approvals, and delegation edges", async () => {
|
||||
const { companyId, userId, agentAId, agentBId } = await seedBase();
|
||||
const parentIssueId = randomUUID();
|
||||
const childIssueId = randomUUID();
|
||||
const contextRunId = randomUUID();
|
||||
const activityRunId = randomUUID();
|
||||
const approvalId = randomUUID();
|
||||
|
||||
await db.insert(issues).values([
|
||||
{
|
||||
id: parentIssueId,
|
||||
companyId,
|
||||
title: "Parent",
|
||||
identifier: "TL-1",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
createdByUserId: userId,
|
||||
assigneeAgentId: agentAId,
|
||||
createdAt: new Date("2026-03-01T10:00:00Z"),
|
||||
updatedAt: new Date("2026-03-01T10:00:00Z"),
|
||||
},
|
||||
{
|
||||
id: childIssueId,
|
||||
companyId,
|
||||
title: "Child",
|
||||
identifier: "TL-2",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
parentId: parentIssueId,
|
||||
createdByAgentId: agentAId,
|
||||
assigneeAgentId: agentBId,
|
||||
createdAt: new Date("2026-03-01T11:00:00Z"),
|
||||
updatedAt: new Date("2026-03-01T11:00:00Z"),
|
||||
},
|
||||
]);
|
||||
await db.insert(heartbeatRuns).values([
|
||||
{
|
||||
id: contextRunId,
|
||||
companyId,
|
||||
agentId: agentBId,
|
||||
status: "running",
|
||||
invocationSource: "issue_assigned",
|
||||
startedAt: new Date("2026-03-01T12:00:00Z"),
|
||||
finishedAt: null,
|
||||
contextSnapshot: { issueId: childIssueId },
|
||||
},
|
||||
{
|
||||
id: activityRunId,
|
||||
companyId,
|
||||
agentId: agentAId,
|
||||
status: "completed",
|
||||
invocationSource: "manual",
|
||||
startedAt: new Date("2026-03-01T12:30:00Z"),
|
||||
finishedAt: new Date("2026-03-01T12:45:00Z"),
|
||||
contextSnapshot: {},
|
||||
},
|
||||
]);
|
||||
await db.insert(activityLog).values([
|
||||
{
|
||||
companyId,
|
||||
actorType: "agent",
|
||||
actorId: agentAId,
|
||||
action: "issue.updated",
|
||||
entityType: "issue",
|
||||
entityId: parentIssueId,
|
||||
agentId: agentAId,
|
||||
runId: activityRunId,
|
||||
createdAt: new Date("2026-03-01T12:35:00Z"),
|
||||
},
|
||||
{
|
||||
companyId,
|
||||
actorType: "user",
|
||||
actorId: userId,
|
||||
action: "issue.assigned",
|
||||
entityType: "issue",
|
||||
entityId: childIssueId,
|
||||
details: { assigneeAgentId: agentBId },
|
||||
createdAt: new Date("2026-03-01T13:00:00Z"),
|
||||
},
|
||||
]);
|
||||
await db.insert(issueComments).values({
|
||||
companyId,
|
||||
issueId: childIssueId,
|
||||
authorUserId: userId,
|
||||
body: "Looks good",
|
||||
createdAt: new Date("2026-03-01T13:30:00Z"),
|
||||
});
|
||||
await db.insert(approvals).values({
|
||||
id: approvalId,
|
||||
companyId,
|
||||
type: "request_board_approval",
|
||||
status: "approved",
|
||||
payload: {},
|
||||
decidedByUserId: userId,
|
||||
decidedAt: new Date("2026-03-01T14:00:00Z"),
|
||||
});
|
||||
await db.insert(issueApprovals).values({ companyId, issueId: childIssueId, approvalId });
|
||||
|
||||
const result = await workTimelineService(db).getTimeline({
|
||||
companyId,
|
||||
from: new Date("2026-03-01T00:00:00Z"),
|
||||
to: new Date("2026-03-02T00:00:00Z"),
|
||||
});
|
||||
|
||||
expect(result.actors.map((actor) => actor.name)).toEqual(expect.arrayContaining(["Coder", "QA", "User One"]));
|
||||
expect(result.spans).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ runId: contextRunId, issueId: childIssueId, end: null, status: "running" }),
|
||||
expect.objectContaining({ runId: activityRunId, issueId: parentIssueId, status: "completed" }),
|
||||
]));
|
||||
expect(result.events.map((event) => event.kind)).toEqual(expect.arrayContaining([
|
||||
"created",
|
||||
"commented",
|
||||
"approved",
|
||||
"delegated",
|
||||
"assigned",
|
||||
]));
|
||||
expect(result.edges).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "delegation", issueId: childIssueId }),
|
||||
expect.objectContaining({ kind: "assignment", issueId: childIssueId }),
|
||||
]));
|
||||
});
|
||||
|
||||
it("applies the user lens as a transitive issue subtree", async () => {
|
||||
const { companyId, userId, agentAId, agentBId } = await seedBase();
|
||||
const rootIssueId = randomUUID();
|
||||
const childIssueId = randomUUID();
|
||||
const unrelatedIssueId = randomUUID();
|
||||
|
||||
await db.insert(issues).values([
|
||||
{
|
||||
id: rootIssueId,
|
||||
companyId,
|
||||
title: "User root",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
createdByUserId: userId,
|
||||
assigneeAgentId: agentAId,
|
||||
createdAt: new Date("2026-03-03T10:00:00Z"),
|
||||
updatedAt: new Date("2026-03-03T10:00:00Z"),
|
||||
},
|
||||
{
|
||||
id: childIssueId,
|
||||
companyId,
|
||||
title: "Delegated child",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
parentId: rootIssueId,
|
||||
createdByAgentId: agentAId,
|
||||
assigneeAgentId: agentBId,
|
||||
createdAt: new Date("2026-03-03T11:00:00Z"),
|
||||
updatedAt: new Date("2026-03-03T11:00:00Z"),
|
||||
},
|
||||
{
|
||||
id: unrelatedIssueId,
|
||||
companyId,
|
||||
title: "Unrelated",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentBId,
|
||||
createdAt: new Date("2026-03-03T12:00:00Z"),
|
||||
updatedAt: new Date("2026-03-03T12:00:00Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await workTimelineService(db).getTimeline({
|
||||
companyId,
|
||||
userId,
|
||||
from: new Date("2026-03-03T00:00:00Z"),
|
||||
to: new Date("2026-03-04T00:00:00Z"),
|
||||
});
|
||||
|
||||
expect(result.events.map((event) => event.issueId)).toEqual(expect.arrayContaining([rootIssueId, childIssueId]));
|
||||
expect(result.events.map((event) => event.issueId)).not.toContain(unrelatedIssueId);
|
||||
});
|
||||
|
||||
it("filters unreadable issues before emitting timeline rows", async () => {
|
||||
const { companyId, agentAId } = await seedBase();
|
||||
const visibleIssueId = randomUUID();
|
||||
const hiddenIssueId = randomUUID();
|
||||
await db.insert(issues).values([
|
||||
{
|
||||
id: visibleIssueId,
|
||||
companyId,
|
||||
title: "Visible",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentAId,
|
||||
createdAt: new Date("2026-03-04T10:00:00Z"),
|
||||
updatedAt: new Date("2026-03-04T10:00:00Z"),
|
||||
},
|
||||
{
|
||||
id: hiddenIssueId,
|
||||
companyId,
|
||||
title: "Denied",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentAId,
|
||||
createdAt: new Date("2026-03-04T11:00:00Z"),
|
||||
updatedAt: new Date("2026-03-04T11:00:00Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await workTimelineService(db).getTimeline({
|
||||
companyId,
|
||||
from: new Date("2026-03-04T00:00:00Z"),
|
||||
to: new Date("2026-03-05T00:00:00Z"),
|
||||
canReadIssue: async (issue) => issue.id !== hiddenIssueId,
|
||||
});
|
||||
|
||||
expect(result.events.map((event) => event.issueId)).toContain(visibleIssueId);
|
||||
expect(result.events.map((event) => event.issueId)).not.toContain(hiddenIssueId);
|
||||
expect(result.pagination.totalIssues).toBe(1);
|
||||
});
|
||||
|
||||
it("serves GET /api/companies/:companyId/timeline", async () => {
|
||||
const { companyId, agentAId } = await seedBase();
|
||||
const issueId = randomUUID();
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: "Route issue",
|
||||
identifier: "TL-9",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentAId,
|
||||
createdAt: new Date("2026-03-05T10:00:00Z"),
|
||||
updatedAt: new Date("2026-03-05T10:00:00Z"),
|
||||
});
|
||||
|
||||
const res = await request(createApp())
|
||||
.get(`/api/companies/${companyId}/timeline`)
|
||||
.query({ from: "2026-03-05T00:00:00Z", to: "2026-03-06T00:00:00Z" });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(res.body.events).toEqual([
|
||||
expect.objectContaining({ kind: "created", issueId }),
|
||||
]);
|
||||
expect(res.body).toEqual(expect.objectContaining({
|
||||
actors: expect.any(Array),
|
||||
spans: expect.any(Array),
|
||||
edges: expect.any(Array),
|
||||
pagination: expect.objectContaining({ totalIssues: 1 }),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { Router, type Request } from "express";
|
||||
import { and, count as countFn, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents as agentsTable } from "@paperclipai/db";
|
||||
import {
|
||||
|
|
@ -27,6 +28,7 @@ import {
|
|||
companyService,
|
||||
feedbackService,
|
||||
logActivity,
|
||||
workTimelineService,
|
||||
} from "../services/index.js";
|
||||
import type { StorageService } from "../storage/types.js";
|
||||
import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js";
|
||||
|
|
@ -57,6 +59,26 @@ export function companyRoutes(db: Db, storage?: StorageService) {
|
|||
return parsed;
|
||||
}
|
||||
|
||||
function parseIntegerQuery(value: unknown, field: string) {
|
||||
if (value === undefined || value === null || value === "") return undefined;
|
||||
const parsed = typeof value === "string" ? Number(value) : NaN;
|
||||
if (!Number.isFinite(parsed)) {
|
||||
throw badRequest(`Invalid ${field} query value`);
|
||||
}
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
|
||||
const timelineQuerySchema = z.object({
|
||||
from: z.string().optional(),
|
||||
to: z.string().optional(),
|
||||
userId: z.string().min(1).optional(),
|
||||
goalId: z.string().uuid().optional(),
|
||||
projectId: z.string().uuid().optional(),
|
||||
issueId: z.string().uuid().optional(),
|
||||
limit: z.string().optional(),
|
||||
offset: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
function assertImportTargetAccess(
|
||||
req: Request,
|
||||
target: { mode: "new_company" } | { mode: "existing_company"; companyId: string },
|
||||
|
|
@ -123,6 +145,60 @@ export function companyRoutes(db: Db, storage?: StorageService) {
|
|||
res.json(await artifacts.list(companyId, query));
|
||||
});
|
||||
|
||||
router.get("/:companyId/timeline", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
||||
const companyScopeDecision = await access.decide({
|
||||
actor: req.actor,
|
||||
action: "company_scope:read",
|
||||
resource: { type: "company", companyId },
|
||||
});
|
||||
if (!companyScopeDecision.allowed) {
|
||||
res.status(403).json({ error: "Timeline is outside this actor's authorization boundary" });
|
||||
return;
|
||||
}
|
||||
|
||||
const query = timelineQuerySchema.parse(req.query);
|
||||
const timeline = workTimelineService(db);
|
||||
const result = await timeline.getTimeline({
|
||||
companyId,
|
||||
from: parseDateQuery(query.from, "from"),
|
||||
to: parseDateQuery(query.to, "to"),
|
||||
userId: query.userId,
|
||||
goalId: query.goalId,
|
||||
projectId: query.projectId,
|
||||
issueId: query.issueId,
|
||||
limit: parseIntegerQuery(query.limit, "limit"),
|
||||
offset: parseIntegerQuery(query.offset, "offset"),
|
||||
canReadIssue: async (issue) => {
|
||||
const decision = await access.decide({
|
||||
actor: req.actor,
|
||||
action: "issue:read",
|
||||
resource: {
|
||||
type: "issue",
|
||||
companyId: issue.companyId,
|
||||
issueId: issue.id,
|
||||
projectId: issue.projectId,
|
||||
parentIssueId: issue.parentId,
|
||||
assigneeAgentId: issue.assigneeAgentId,
|
||||
assigneeUserId: issue.assigneeUserId,
|
||||
status: issue.status,
|
||||
},
|
||||
scope: {
|
||||
issueId: issue.id,
|
||||
projectId: issue.projectId,
|
||||
parentIssueId: issue.parentId,
|
||||
assigneeAgentId: issue.assigneeAgentId,
|
||||
assigneeUserId: issue.assigneeUserId,
|
||||
},
|
||||
});
|
||||
return decision.allowed;
|
||||
},
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.get("/:companyId", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
|
|
|||
|
|
@ -491,6 +491,63 @@ const environmentCustomImageTemplateRollbackResultSchema = z.object({
|
|||
supersededTemplate: environmentCustomImageTemplateSchema,
|
||||
}).strict();
|
||||
|
||||
const workTimelineQuerySchema = z.object({
|
||||
from: z.string().optional(),
|
||||
to: z.string().optional(),
|
||||
userId: z.string().optional(),
|
||||
goalId: z.string().uuid().optional(),
|
||||
projectId: z.string().uuid().optional(),
|
||||
issueId: z.string().uuid().optional(),
|
||||
limit: z.string().optional(),
|
||||
offset: z.string().optional(),
|
||||
}).strict();
|
||||
|
||||
const workTimelineResponseSchema = z.object({
|
||||
actors: z.array(z.object({
|
||||
id: z.string(),
|
||||
type: z.enum(["agent", "user", "system", "plugin"]),
|
||||
name: z.string(),
|
||||
avatar: z.string().nullable().optional(),
|
||||
}).strict()),
|
||||
spans: z.array(z.object({
|
||||
actorId: z.string(),
|
||||
laneHint: z.string().nullable(),
|
||||
runId: z.string(),
|
||||
issueId: z.string(),
|
||||
issueIdentifier: z.string().nullable(),
|
||||
start: z.string(),
|
||||
end: z.string().nullable(),
|
||||
status: z.string(),
|
||||
retryOfRunId: z.string().nullable().optional(),
|
||||
continuationAttempt: z.number().optional(),
|
||||
invocationSource: z.string().nullable().optional(),
|
||||
}).strict()),
|
||||
events: z.array(z.object({
|
||||
actorId: z.string(),
|
||||
kind: z.enum(["created", "commented", "approved", "delegated", "assigned"]),
|
||||
issueId: z.string(),
|
||||
at: z.string(),
|
||||
}).strict()),
|
||||
edges: z.array(z.object({
|
||||
fromActorId: z.string(),
|
||||
toActorId: z.string(),
|
||||
issueId: z.string(),
|
||||
at: z.string(),
|
||||
kind: z.enum(["delegation", "assignment", "mention"]),
|
||||
}).strict()),
|
||||
pagination: z.object({
|
||||
limit: z.number().int().positive(),
|
||||
offset: z.number().int().nonnegative(),
|
||||
totalIssues: z.number().int().nonnegative(),
|
||||
hasMore: z.boolean(),
|
||||
}).strict(),
|
||||
window: z.object({
|
||||
from: z.string(),
|
||||
to: z.string(),
|
||||
capped: z.boolean(),
|
||||
}).strict(),
|
||||
}).strict();
|
||||
|
||||
function paramsSchemaFromPath(routePath: string): z.ZodObject<z.ZodRawShape> | undefined {
|
||||
const names = [...routePath.matchAll(/\{([A-Za-z0-9_]+)\}/g)].map((match) => match[1]);
|
||||
if (names.length === 0) return undefined;
|
||||
|
|
@ -904,6 +961,23 @@ registry.registerPath({
|
|||
},
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/api/companies/{companyId}/timeline",
|
||||
tags: ["companies"],
|
||||
summary: "Get company work timeline",
|
||||
request: {
|
||||
params: z.object({ companyId: z.string() }),
|
||||
query: workTimelineQuerySchema,
|
||||
},
|
||||
responses: {
|
||||
200: r.ok(workTimelineResponseSchema),
|
||||
400: r.badRequest,
|
||||
401: r.unauthorized,
|
||||
403: r.forbidden,
|
||||
},
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "patch",
|
||||
path: "/api/companies/{companyId}",
|
||||
|
|
|
|||
|
|
@ -44,6 +44,15 @@ export {
|
|||
} from "./external-objects.js";
|
||||
export { goalService } from "./goals.js";
|
||||
export { activityService, type ActivityFilters } from "./activity.js";
|
||||
export { workTimelineService, normalizeTimelineWindow } from "./work-timeline.js";
|
||||
export type {
|
||||
WorkTimelineActor,
|
||||
WorkTimelineEdge,
|
||||
WorkTimelineEvent,
|
||||
WorkTimelineQuery,
|
||||
WorkTimelineResult,
|
||||
WorkTimelineSpan,
|
||||
} from "./work-timeline.js";
|
||||
export { approvalService } from "./approvals.js";
|
||||
export { budgetService } from "./budgets.js";
|
||||
export { secretService } from "./secrets.js";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,768 @@
|
|||
import { and, asc, desc, eq, gte, inArray, isNull, lte, or, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
approvals,
|
||||
authUsers,
|
||||
heartbeatRuns,
|
||||
issueApprovals,
|
||||
issueComments,
|
||||
issues,
|
||||
issueThreadInteractions,
|
||||
} from "@paperclipai/db";
|
||||
|
||||
export type TimelineActorType = "agent" | "user" | "system" | "plugin";
|
||||
export type TimelineEventKind = "created" | "commented" | "approved" | "delegated" | "assigned";
|
||||
export type TimelineEdgeKind = "delegation" | "assignment" | "mention";
|
||||
|
||||
export interface WorkTimelineActor {
|
||||
id: string;
|
||||
type: TimelineActorType;
|
||||
name: string;
|
||||
avatar?: string | null;
|
||||
}
|
||||
|
||||
export interface WorkTimelineSpan {
|
||||
actorId: string;
|
||||
laneHint: string | null;
|
||||
runId: string;
|
||||
issueId: string;
|
||||
issueIdentifier: string | null;
|
||||
start: string;
|
||||
end: string | null;
|
||||
status: string;
|
||||
retryOfRunId?: string | null;
|
||||
continuationAttempt?: number;
|
||||
invocationSource?: string | null;
|
||||
}
|
||||
|
||||
export interface WorkTimelineEvent {
|
||||
actorId: string;
|
||||
kind: TimelineEventKind;
|
||||
issueId: string;
|
||||
at: string;
|
||||
}
|
||||
|
||||
export interface WorkTimelineEdge {
|
||||
fromActorId: string;
|
||||
toActorId: string;
|
||||
issueId: string;
|
||||
at: string;
|
||||
kind: TimelineEdgeKind;
|
||||
}
|
||||
|
||||
export interface WorkTimelineQuery {
|
||||
companyId: string;
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
userId?: string;
|
||||
goalId?: string;
|
||||
projectId?: string;
|
||||
issueId?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
canReadIssue?: (issue: WorkTimelineIssueAccessInput) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface WorkTimelineResult {
|
||||
actors: WorkTimelineActor[];
|
||||
spans: WorkTimelineSpan[];
|
||||
events: WorkTimelineEvent[];
|
||||
edges: WorkTimelineEdge[];
|
||||
pagination: {
|
||||
limit: number;
|
||||
offset: number;
|
||||
totalIssues: number;
|
||||
hasMore: boolean;
|
||||
};
|
||||
window: {
|
||||
from: string;
|
||||
to: string;
|
||||
capped: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WorkTimelineIssueAccessInput {
|
||||
id: string;
|
||||
companyId: string;
|
||||
projectId: string | null;
|
||||
parentId: string | null;
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
type IssueRow = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
projectId: string | null;
|
||||
goalId: string | null;
|
||||
parentId: string | null;
|
||||
identifier: string | null;
|
||||
createdByAgentId: string | null;
|
||||
createdByUserId: string | null;
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
const DEFAULT_LIMIT = 200;
|
||||
const MAX_LIMIT = 500;
|
||||
const MAX_WINDOW_MS = 31 * 24 * 60 * 60 * 1000;
|
||||
const DEFAULT_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const MAX_SOURCE_ROWS = 5_000;
|
||||
|
||||
function actorId(type: TimelineActorType, id: string) {
|
||||
return `${type}:${id}`;
|
||||
}
|
||||
|
||||
function normalizeLimit(value: number | undefined) {
|
||||
if (!Number.isFinite(value)) return DEFAULT_LIMIT;
|
||||
return Math.max(1, Math.min(MAX_LIMIT, Math.floor(value ?? DEFAULT_LIMIT)));
|
||||
}
|
||||
|
||||
function normalizeOffset(value: number | undefined) {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.max(0, Math.floor(value ?? 0));
|
||||
}
|
||||
|
||||
export function normalizeTimelineWindow(input: { from?: Date; to?: Date }, now = new Date()) {
|
||||
const rawTo = input.to ?? now;
|
||||
const to = rawTo.getTime() > now.getTime() ? now : rawTo;
|
||||
const requestedFrom = input.from ?? new Date(to.getTime() - DEFAULT_WINDOW_MS);
|
||||
let from = requestedFrom;
|
||||
let capped = false;
|
||||
if (to.getTime() - from.getTime() > MAX_WINDOW_MS) {
|
||||
from = new Date(to.getTime() - MAX_WINDOW_MS);
|
||||
capped = true;
|
||||
}
|
||||
if (from.getTime() > to.getTime()) {
|
||||
from = new Date(to.getTime() - DEFAULT_WINDOW_MS);
|
||||
capped = true;
|
||||
}
|
||||
return { from, to, capped };
|
||||
}
|
||||
|
||||
function dateIso(value: Date | null | undefined) {
|
||||
return value ? value.toISOString() : null;
|
||||
}
|
||||
|
||||
function readString(value: unknown) {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function maybeUuidList(ids: Iterable<string>) {
|
||||
return Array.from(new Set(Array.from(ids).filter((id) => id.length > 0)));
|
||||
}
|
||||
|
||||
function runOverlapsWindow(from: Date, to: Date) {
|
||||
const fromIso = from.toISOString();
|
||||
const toIso = to.toISOString();
|
||||
return and(
|
||||
sql`coalesce(${heartbeatRuns.startedAt}, ${heartbeatRuns.createdAt}) <= ${toIso}::timestamptz`,
|
||||
sql`coalesce(${heartbeatRuns.finishedAt}, ${heartbeatRuns.startedAt}, ${heartbeatRuns.createdAt}) >= ${fromIso}::timestamptz`,
|
||||
);
|
||||
}
|
||||
|
||||
export function workTimelineService(db: Db) {
|
||||
async function collectIssueIds(input: WorkTimelineQuery, from: Date, to: Date) {
|
||||
const ids = new Set<string>();
|
||||
|
||||
if (input.issueId) {
|
||||
ids.add(input.issueId);
|
||||
}
|
||||
|
||||
const filterConditions = [
|
||||
eq(issues.companyId, input.companyId),
|
||||
isNull(issues.hiddenAt),
|
||||
input.goalId ? eq(issues.goalId, input.goalId) : undefined,
|
||||
input.projectId ? eq(issues.projectId, input.projectId) : undefined,
|
||||
input.issueId ? eq(issues.id, input.issueId) : undefined,
|
||||
].filter(Boolean);
|
||||
|
||||
const recentlyTouched = await db
|
||||
.select({ id: issues.id })
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
...filterConditions,
|
||||
or(
|
||||
and(gte(issues.createdAt, from), lte(issues.createdAt, to)),
|
||||
and(gte(issues.updatedAt, from), lte(issues.updatedAt, to)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(issues.updatedAt))
|
||||
.limit(MAX_SOURCE_ROWS);
|
||||
for (const row of recentlyTouched) ids.add(row.id);
|
||||
|
||||
const runContextRows = await db
|
||||
.select({ issueId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'issueId'` })
|
||||
.from(heartbeatRuns)
|
||||
.where(
|
||||
and(
|
||||
eq(heartbeatRuns.companyId, input.companyId),
|
||||
runOverlapsWindow(from, to),
|
||||
sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' is not null`,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(heartbeatRuns.createdAt))
|
||||
.limit(MAX_SOURCE_ROWS);
|
||||
for (const row of runContextRows) {
|
||||
if (row.issueId) ids.add(row.issueId);
|
||||
}
|
||||
|
||||
const activityIssueRows = await db
|
||||
.select({ issueId: activityLog.entityId })
|
||||
.from(activityLog)
|
||||
.where(
|
||||
and(
|
||||
eq(activityLog.companyId, input.companyId),
|
||||
eq(activityLog.entityType, "issue"),
|
||||
gte(activityLog.createdAt, from),
|
||||
lte(activityLog.createdAt, to),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(activityLog.createdAt))
|
||||
.limit(MAX_SOURCE_ROWS);
|
||||
for (const row of activityIssueRows) ids.add(row.issueId);
|
||||
|
||||
const commentIssueRows = await db
|
||||
.select({ issueId: issueComments.issueId })
|
||||
.from(issueComments)
|
||||
.where(
|
||||
and(
|
||||
eq(issueComments.companyId, input.companyId),
|
||||
isNull(issueComments.deletedAt),
|
||||
gte(issueComments.createdAt, from),
|
||||
lte(issueComments.createdAt, to),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(issueComments.createdAt))
|
||||
.limit(MAX_SOURCE_ROWS);
|
||||
for (const row of commentIssueRows) ids.add(row.issueId);
|
||||
|
||||
const interactionIssueRows = await db
|
||||
.select({ issueId: issueThreadInteractions.issueId })
|
||||
.from(issueThreadInteractions)
|
||||
.where(
|
||||
and(
|
||||
eq(issueThreadInteractions.companyId, input.companyId),
|
||||
or(
|
||||
and(gte(issueThreadInteractions.createdAt, from), lte(issueThreadInteractions.createdAt, to)),
|
||||
and(gte(issueThreadInteractions.resolvedAt, from), lte(issueThreadInteractions.resolvedAt, to)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(issueThreadInteractions.createdAt))
|
||||
.limit(MAX_SOURCE_ROWS);
|
||||
for (const row of interactionIssueRows) ids.add(row.issueId);
|
||||
|
||||
const approvalIssueRows = await db
|
||||
.select({ issueId: issueApprovals.issueId })
|
||||
.from(issueApprovals)
|
||||
.innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id))
|
||||
.where(
|
||||
and(
|
||||
eq(issueApprovals.companyId, input.companyId),
|
||||
or(
|
||||
and(gte(approvals.createdAt, from), lte(approvals.createdAt, to)),
|
||||
and(gte(approvals.decidedAt, from), lte(approvals.decidedAt, to)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(approvals.createdAt))
|
||||
.limit(MAX_SOURCE_ROWS);
|
||||
for (const row of approvalIssueRows) ids.add(row.issueId);
|
||||
|
||||
return maybeUuidList(ids);
|
||||
}
|
||||
|
||||
async function loadIssues(input: WorkTimelineQuery, issueIds: string[]) {
|
||||
if (issueIds.length === 0) return [];
|
||||
return db
|
||||
.select({
|
||||
id: issues.id,
|
||||
companyId: issues.companyId,
|
||||
projectId: issues.projectId,
|
||||
goalId: issues.goalId,
|
||||
parentId: issues.parentId,
|
||||
identifier: issues.identifier,
|
||||
createdByAgentId: issues.createdByAgentId,
|
||||
createdByUserId: issues.createdByUserId,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
status: issues.status,
|
||||
createdAt: issues.createdAt,
|
||||
})
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, input.companyId),
|
||||
isNull(issues.hiddenAt),
|
||||
inArray(issues.id, issueIds),
|
||||
input.goalId ? eq(issues.goalId, input.goalId) : undefined,
|
||||
input.projectId ? eq(issues.projectId, input.projectId) : undefined,
|
||||
input.issueId ? eq(issues.id, input.issueId) : undefined,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function applyUserLens(input: WorkTimelineQuery, rows: IssueRow[], from: Date, to: Date) {
|
||||
if (!input.userId) return rows;
|
||||
|
||||
const byId = new Map(rows.map((issue) => [issue.id, issue]));
|
||||
const selected = new Set<string>();
|
||||
for (const issue of rows) {
|
||||
if (issue.createdByUserId === input.userId || issue.assigneeUserId === input.userId) selected.add(issue.id);
|
||||
}
|
||||
|
||||
const [commentRows, approvalRows, interactionRows, activityRows] = await Promise.all([
|
||||
db
|
||||
.select({ issueId: issueComments.issueId })
|
||||
.from(issueComments)
|
||||
.where(
|
||||
and(
|
||||
eq(issueComments.companyId, input.companyId),
|
||||
eq(issueComments.authorUserId, input.userId),
|
||||
isNull(issueComments.deletedAt),
|
||||
gte(issueComments.createdAt, from),
|
||||
lte(issueComments.createdAt, to),
|
||||
),
|
||||
),
|
||||
db
|
||||
.select({ issueId: issueApprovals.issueId })
|
||||
.from(issueApprovals)
|
||||
.innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id))
|
||||
.where(
|
||||
and(
|
||||
eq(issueApprovals.companyId, input.companyId),
|
||||
eq(approvals.decidedByUserId, input.userId),
|
||||
gte(approvals.decidedAt, from),
|
||||
lte(approvals.decidedAt, to),
|
||||
),
|
||||
),
|
||||
db
|
||||
.select({ issueId: issueThreadInteractions.issueId })
|
||||
.from(issueThreadInteractions)
|
||||
.where(
|
||||
and(
|
||||
eq(issueThreadInteractions.companyId, input.companyId),
|
||||
eq(issueThreadInteractions.resolvedByUserId, input.userId),
|
||||
gte(issueThreadInteractions.resolvedAt, from),
|
||||
lte(issueThreadInteractions.resolvedAt, to),
|
||||
),
|
||||
),
|
||||
db
|
||||
.select({ issueId: activityLog.entityId })
|
||||
.from(activityLog)
|
||||
.where(
|
||||
and(
|
||||
eq(activityLog.companyId, input.companyId),
|
||||
eq(activityLog.actorType, "user"),
|
||||
eq(activityLog.actorId, input.userId),
|
||||
eq(activityLog.entityType, "issue"),
|
||||
gte(activityLog.createdAt, from),
|
||||
lte(activityLog.createdAt, to),
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
for (const row of [...commentRows, ...approvalRows, ...interactionRows, ...activityRows]) {
|
||||
selected.add(row.issueId);
|
||||
}
|
||||
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const issue of rows) {
|
||||
if (issue.parentId && selected.has(issue.parentId) && !selected.has(issue.id)) {
|
||||
selected.add(issue.id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rows.filter((issue) => selected.has(issue.id) || byId.get(issue.parentId ?? "") && selected.has(issue.parentId ?? ""));
|
||||
}
|
||||
|
||||
async function loadActorMaps(companyId: string, actorIds: Set<string>) {
|
||||
const agentIds = Array.from(actorIds)
|
||||
.filter((id) => id.startsWith("agent:"))
|
||||
.map((id) => id.slice("agent:".length));
|
||||
const userIds = Array.from(actorIds)
|
||||
.filter((id) => id.startsWith("user:"))
|
||||
.map((id) => id.slice("user:".length));
|
||||
|
||||
const [agentRows, userRows] = await Promise.all([
|
||||
agentIds.length > 0
|
||||
? db
|
||||
.select({ id: agents.id, name: agents.name, icon: agents.icon })
|
||||
.from(agents)
|
||||
.where(and(eq(agents.companyId, companyId), inArray(agents.id, maybeUuidList(agentIds))))
|
||||
: [],
|
||||
userIds.length > 0
|
||||
? db
|
||||
.select({ id: authUsers.id, name: authUsers.name, image: authUsers.image })
|
||||
.from(authUsers)
|
||||
.where(inArray(authUsers.id, maybeUuidList(userIds)))
|
||||
: [],
|
||||
]);
|
||||
|
||||
return {
|
||||
agents: new Map(agentRows.map((agent) => [agent.id, agent])),
|
||||
users: new Map(userRows.map((user) => [user.id, user])),
|
||||
};
|
||||
}
|
||||
|
||||
function actorForIssueCreator(issue: IssueRow) {
|
||||
if (issue.createdByAgentId) return actorId("agent", issue.createdByAgentId);
|
||||
if (issue.createdByUserId) return actorId("user", issue.createdByUserId);
|
||||
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);
|
||||
const offset = normalizeOffset(input.offset);
|
||||
|
||||
const candidateIssueIds = await collectIssueIds(input, from, to);
|
||||
const loadedIssues = await loadIssues(input, candidateIssueIds);
|
||||
const userScopedIssues = await applyUserLens(input, loadedIssues, from, to);
|
||||
const accessibleIssues = input.canReadIssue
|
||||
? (await Promise.all(userScopedIssues.map(async (issue) => ({
|
||||
issue,
|
||||
allowed: await input.canReadIssue?.({
|
||||
id: issue.id,
|
||||
companyId: issue.companyId,
|
||||
projectId: issue.projectId,
|
||||
parentId: issue.parentId,
|
||||
assigneeAgentId: issue.assigneeAgentId,
|
||||
assigneeUserId: issue.assigneeUserId,
|
||||
status: issue.status,
|
||||
}),
|
||||
})))).filter((entry) => entry.allowed).map((entry) => entry.issue)
|
||||
: userScopedIssues;
|
||||
const sortedIssues = accessibleIssues.sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime());
|
||||
const pagedIssues = sortedIssues.slice(offset, offset + limit);
|
||||
const issueById = new Map(pagedIssues.map((issue) => [issue.id, issue]));
|
||||
const readableIssueIds = Array.from(issueById.keys());
|
||||
|
||||
if (readableIssueIds.length === 0) {
|
||||
return {
|
||||
actors: [],
|
||||
spans: [],
|
||||
events: [],
|
||||
edges: [],
|
||||
pagination: { limit, offset, totalIssues: sortedIssues.length, hasMore: offset + limit < sortedIssues.length },
|
||||
window: { from: from.toISOString(), to: to.toISOString(), capped },
|
||||
};
|
||||
}
|
||||
|
||||
const actorIds = new Set<string>();
|
||||
const events: WorkTimelineEvent[] = [];
|
||||
const edges: WorkTimelineEdge[] = [];
|
||||
|
||||
for (const issue of pagedIssues) {
|
||||
const creatorActorId = actorForIssueCreator(issue);
|
||||
actorIds.add(creatorActorId);
|
||||
events.push({
|
||||
actorId: creatorActorId,
|
||||
kind: "created",
|
||||
issueId: issue.id,
|
||||
at: issue.createdAt.toISOString(),
|
||||
});
|
||||
|
||||
const assigneeActorId = actorForIssueAssignee(issue);
|
||||
if (assigneeActorId) {
|
||||
actorIds.add(assigneeActorId);
|
||||
edges.push({
|
||||
fromActorId: creatorActorId,
|
||||
toActorId: assigneeActorId,
|
||||
issueId: issue.id,
|
||||
at: issue.createdAt.toISOString(),
|
||||
kind: "assignment",
|
||||
});
|
||||
}
|
||||
|
||||
const parent = issue.parentId ? issueById.get(issue.parentId) : null;
|
||||
const parentActorId = parent ? actorForIssueAssignee(parent) ?? actorForIssueCreator(parent) : null;
|
||||
if (parentActorId && assigneeActorId && parentActorId !== assigneeActorId) {
|
||||
actorIds.add(parentActorId);
|
||||
edges.push({
|
||||
fromActorId: parentActorId,
|
||||
toActorId: assigneeActorId,
|
||||
issueId: issue.id,
|
||||
at: issue.createdAt.toISOString(),
|
||||
kind: "delegation",
|
||||
});
|
||||
events.push({
|
||||
actorId: parentActorId,
|
||||
kind: "delegated",
|
||||
issueId: issue.id,
|
||||
at: issue.createdAt.toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const [contextRunRows, activityRunRows, commentRows, approvalRows, interactionRows, logRows] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
runId: heartbeatRuns.id,
|
||||
agentId: heartbeatRuns.agentId,
|
||||
issueId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`,
|
||||
status: heartbeatRuns.status,
|
||||
startedAt: heartbeatRuns.startedAt,
|
||||
finishedAt: heartbeatRuns.finishedAt,
|
||||
createdAt: heartbeatRuns.createdAt,
|
||||
retryOfRunId: heartbeatRuns.retryOfRunId,
|
||||
continuationAttempt: heartbeatRuns.continuationAttempt,
|
||||
invocationSource: heartbeatRuns.invocationSource,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.where(
|
||||
and(
|
||||
eq(heartbeatRuns.companyId, input.companyId),
|
||||
runOverlapsWindow(from, to),
|
||||
inArray(sql<string>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`, readableIssueIds),
|
||||
),
|
||||
),
|
||||
db
|
||||
.select({
|
||||
runId: heartbeatRuns.id,
|
||||
agentId: heartbeatRuns.agentId,
|
||||
issueId: activityLog.entityId,
|
||||
status: heartbeatRuns.status,
|
||||
startedAt: heartbeatRuns.startedAt,
|
||||
finishedAt: heartbeatRuns.finishedAt,
|
||||
createdAt: heartbeatRuns.createdAt,
|
||||
retryOfRunId: heartbeatRuns.retryOfRunId,
|
||||
continuationAttempt: heartbeatRuns.continuationAttempt,
|
||||
invocationSource: heartbeatRuns.invocationSource,
|
||||
})
|
||||
.from(activityLog)
|
||||
.innerJoin(heartbeatRuns, eq(activityLog.runId, heartbeatRuns.id))
|
||||
.where(
|
||||
and(
|
||||
eq(activityLog.companyId, input.companyId),
|
||||
eq(activityLog.entityType, "issue"),
|
||||
inArray(activityLog.entityId, readableIssueIds),
|
||||
runOverlapsWindow(from, to),
|
||||
),
|
||||
),
|
||||
db
|
||||
.select({
|
||||
issueId: issueComments.issueId,
|
||||
authorAgentId: issueComments.authorAgentId,
|
||||
authorUserId: issueComments.authorUserId,
|
||||
createdAt: issueComments.createdAt,
|
||||
})
|
||||
.from(issueComments)
|
||||
.where(
|
||||
and(
|
||||
eq(issueComments.companyId, input.companyId),
|
||||
isNull(issueComments.deletedAt),
|
||||
inArray(issueComments.issueId, readableIssueIds),
|
||||
gte(issueComments.createdAt, from),
|
||||
lte(issueComments.createdAt, to),
|
||||
),
|
||||
),
|
||||
db
|
||||
.select({
|
||||
issueId: issueApprovals.issueId,
|
||||
decidedByUserId: approvals.decidedByUserId,
|
||||
decidedAt: approvals.decidedAt,
|
||||
requestedByAgentId: approvals.requestedByAgentId,
|
||||
requestedByUserId: approvals.requestedByUserId,
|
||||
createdAt: approvals.createdAt,
|
||||
})
|
||||
.from(issueApprovals)
|
||||
.innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id))
|
||||
.where(
|
||||
and(
|
||||
eq(issueApprovals.companyId, input.companyId),
|
||||
inArray(issueApprovals.issueId, readableIssueIds),
|
||||
or(
|
||||
and(gte(approvals.createdAt, from), lte(approvals.createdAt, to)),
|
||||
and(gte(approvals.decidedAt, from), lte(approvals.decidedAt, to)),
|
||||
),
|
||||
),
|
||||
),
|
||||
db
|
||||
.select({
|
||||
issueId: issueThreadInteractions.issueId,
|
||||
resolvedByAgentId: issueThreadInteractions.resolvedByAgentId,
|
||||
resolvedByUserId: issueThreadInteractions.resolvedByUserId,
|
||||
resolvedAt: issueThreadInteractions.resolvedAt,
|
||||
createdByAgentId: issueThreadInteractions.createdByAgentId,
|
||||
createdByUserId: issueThreadInteractions.createdByUserId,
|
||||
createdAt: issueThreadInteractions.createdAt,
|
||||
})
|
||||
.from(issueThreadInteractions)
|
||||
.where(
|
||||
and(
|
||||
eq(issueThreadInteractions.companyId, input.companyId),
|
||||
inArray(issueThreadInteractions.issueId, readableIssueIds),
|
||||
or(
|
||||
and(gte(issueThreadInteractions.createdAt, from), lte(issueThreadInteractions.createdAt, to)),
|
||||
and(gte(issueThreadInteractions.resolvedAt, from), lte(issueThreadInteractions.resolvedAt, to)),
|
||||
),
|
||||
),
|
||||
),
|
||||
db
|
||||
.select({
|
||||
issueId: activityLog.entityId,
|
||||
actorType: activityLog.actorType,
|
||||
actorId: activityLog.actorId,
|
||||
action: activityLog.action,
|
||||
details: activityLog.details,
|
||||
createdAt: activityLog.createdAt,
|
||||
})
|
||||
.from(activityLog)
|
||||
.where(
|
||||
and(
|
||||
eq(activityLog.companyId, input.companyId),
|
||||
eq(activityLog.entityType, "issue"),
|
||||
inArray(activityLog.entityId, readableIssueIds),
|
||||
gte(activityLog.createdAt, from),
|
||||
lte(activityLog.createdAt, to),
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
const spanByRunId = new Map<string, WorkTimelineSpan>();
|
||||
for (const row of [...contextRunRows, ...activityRunRows]) {
|
||||
if (!row.issueId || !issueById.has(row.issueId) || spanByRunId.has(row.runId)) continue;
|
||||
const runActorId = actorId("agent", row.agentId);
|
||||
actorIds.add(runActorId);
|
||||
spanByRunId.set(row.runId, {
|
||||
actorId: runActorId,
|
||||
laneHint: row.invocationSource ?? null,
|
||||
runId: row.runId,
|
||||
issueId: row.issueId,
|
||||
issueIdentifier: issueById.get(row.issueId)?.identifier ?? null,
|
||||
start: (row.startedAt ?? row.createdAt).toISOString(),
|
||||
end: dateIso(row.finishedAt),
|
||||
status: row.status,
|
||||
retryOfRunId: row.retryOfRunId ?? null,
|
||||
continuationAttempt: row.continuationAttempt,
|
||||
invocationSource: row.invocationSource ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
for (const row of commentRows) {
|
||||
const commentActorId = row.authorAgentId
|
||||
? actorId("agent", row.authorAgentId)
|
||||
: row.authorUserId
|
||||
? actorId("user", row.authorUserId)
|
||||
: actorId("system", "system");
|
||||
actorIds.add(commentActorId);
|
||||
events.push({ actorId: commentActorId, kind: "commented", issueId: row.issueId, at: row.createdAt.toISOString() });
|
||||
}
|
||||
|
||||
for (const row of approvalRows) {
|
||||
const approvalActorId = row.decidedByUserId
|
||||
? actorId("user", row.decidedByUserId)
|
||||
: row.requestedByAgentId
|
||||
? actorId("agent", row.requestedByAgentId)
|
||||
: row.requestedByUserId
|
||||
? actorId("user", row.requestedByUserId)
|
||||
: actorId("system", "system");
|
||||
actorIds.add(approvalActorId);
|
||||
events.push({
|
||||
actorId: approvalActorId,
|
||||
kind: "approved",
|
||||
issueId: row.issueId,
|
||||
at: (row.decidedAt ?? row.createdAt).toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
for (const row of interactionRows) {
|
||||
const interactionActorId = row.resolvedByUserId
|
||||
? actorId("user", row.resolvedByUserId)
|
||||
: row.resolvedByAgentId
|
||||
? actorId("agent", row.resolvedByAgentId)
|
||||
: row.createdByAgentId
|
||||
? actorId("agent", row.createdByAgentId)
|
||||
: row.createdByUserId
|
||||
? actorId("user", row.createdByUserId)
|
||||
: actorId("system", "system");
|
||||
actorIds.add(interactionActorId);
|
||||
events.push({
|
||||
actorId: interactionActorId,
|
||||
kind: "approved",
|
||||
issueId: row.issueId,
|
||||
at: (row.resolvedAt ?? row.createdAt).toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
actorIds.add(fromActorId);
|
||||
if (row.action.includes("assign")) {
|
||||
events.push({ actorId: fromActorId, kind: "assigned", issueId: row.issueId, at: row.createdAt.toISOString() });
|
||||
const details = row.details && typeof row.details === "object" && !Array.isArray(row.details)
|
||||
? row.details as Record<string, unknown>
|
||||
: {};
|
||||
const targetAgentId = readString(details.assigneeAgentId) ?? readString(details.toAgentId);
|
||||
const targetUserId = readString(details.assigneeUserId) ?? readString(details.toUserId);
|
||||
const toActorId = targetAgentId
|
||||
? actorId("agent", targetAgentId)
|
||||
: targetUserId
|
||||
? actorId("user", targetUserId)
|
||||
: null;
|
||||
if (toActorId) {
|
||||
actorIds.add(toActorId);
|
||||
edges.push({
|
||||
fromActorId,
|
||||
toActorId,
|
||||
issueId: row.issueId,
|
||||
at: row.createdAt.toISOString(),
|
||||
kind: "assignment",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const actorMaps = await loadActorMaps(input.companyId, actorIds);
|
||||
const actors: WorkTimelineActor[] = Array.from(actorIds).map((id) => {
|
||||
const [type, rawId] = id.split(":", 2) as [TimelineActorType, string];
|
||||
if (type === "agent") {
|
||||
const agent = actorMaps.agents.get(rawId);
|
||||
return { id, type, name: agent?.name ?? "Unknown agent", avatar: agent?.icon ?? null };
|
||||
}
|
||||
if (type === "user") {
|
||||
const user = actorMaps.users.get(rawId);
|
||||
return { id, type, name: user?.name ?? rawId, avatar: user?.image ?? null };
|
||||
}
|
||||
return { id, type, name: type === "plugin" ? rawId : "System", avatar: null };
|
||||
});
|
||||
|
||||
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)),
|
||||
pagination: {
|
||||
limit,
|
||||
offset,
|
||||
totalIssues: sortedIssues.length,
|
||||
hasMore: offset + limit < sortedIssues.length,
|
||||
},
|
||||
window: { from: from.toISOString(), to: to.toISOString(), capped },
|
||||
};
|
||||
}
|
||||
|
||||
return { getTimeline };
|
||||
}
|
||||
Loading…
Reference in New Issue