From dea7c4e274e3212f4d39b5a3a8a5fe93195617a8 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:39:49 -0500 Subject: [PATCH] [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 --- .../__tests__/work-timeline-service.test.ts | 378 +++++++++ server/src/routes/companies.ts | 76 ++ server/src/routes/openapi.ts | 74 ++ server/src/services/index.ts | 9 + server/src/services/work-timeline.ts | 768 ++++++++++++++++++ 5 files changed, 1305 insertions(+) create mode 100644 server/src/__tests__/work-timeline-service.test.ts create mode 100644 server/src/services/work-timeline.ts diff --git a/server/src/__tests__/work-timeline-service.test.ts b/server/src/__tests__/work-timeline-service.test.ts new file mode 100644 index 0000000000..160d063f5d --- /dev/null +++ b/server/src/__tests__/work-timeline-service.test.ts @@ -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; + let tempDb: Awaited> | 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 }), + })); + }); +}); diff --git a/server/src/routes/companies.ts b/server/src/routes/companies.ts index 038ddaa885..15cc0899d9 100644 --- a/server/src/routes/companies.ts +++ b/server/src/routes/companies.ts @@ -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); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 81b9858619..384aa16f2f 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -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 | 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}", diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 20da1cef48..855d3a2652 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -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"; diff --git a/server/src/services/work-timeline.ts b/server/src/services/work-timeline.ts new file mode 100644 index 0000000000..4a60e10542 --- /dev/null +++ b/server/src/services/work-timeline.ts @@ -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; +} + +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) { + 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(); + + 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`${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(); + 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) { + 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 { + 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(); + 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`${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`${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(); + 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 + : {}; + 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 }; +}