[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:
Dotta 2026-07-02 12:39:49 -05:00 committed by GitHub
parent dae6fc72f0
commit dea7c4e274
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 1305 additions and 0 deletions

View File

@ -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 }),
}));
});
});

View File

@ -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);

View File

@ -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}",

View File

@ -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";

View File

@ -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 };
}