Files
PaperClipAI/server/src/__tests__/work-timeline-service.test.ts
DottaandPaperclip 6a3cbe1c58 fix(ui): load the full selected timeline window (#9576)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The timeline page visualizes company activity across a selected date
window
> - The UI requested only the first paginated issue batch even when the
selected zoom covered seven or thirty days
> - A busy company could therefore render an incomplete timeline while
the controls implied the full window was loaded
> - The timeline query needs to exhaust the API pagination for the
selected date range and combine each page without duplicating shared
timeline records
> - This pull request adds a paginated window loader, merges the
returned timeline data, and covers the multi-page behavior with a
regression test
> - The benefit is that the visible timeline matches the selected zoom
window instead of silently omitting later issues

## Linked Issues or Issue Description

### Pre-submission checklist

- [x] I searched existing open and closed issues and pull requests; no
matching report or implementation was found.
- [x] I reproduced the behavior against the pre-change `master`
implementation.
- [x] I confirmed the error originates in Paperclip's core timeline UI,
not an adapter, provider, or local configuration.

### What happened?

Selecting the default seven-day timeline range loaded only the first API
page (up to 500 issues). Companies with more activity therefore
displayed incomplete data even though the controls showed the full
selected window.

### Expected behavior

The timeline should load all issue pages that fall within the selected
date window.

### Steps to reproduce

1. Open the company timeline for a date range containing more than 500
issues.
2. Keep the default seven-day range or select another multi-day preset.
3. Observe that only the first page of issue-backed timeline data is
shown.

### Paperclip version or commit

Pre-change `master`.

### Deployment mode

Local dev source build. The behavior is not adapter-specific and is
independent of database mode and access context.

### Privacy checklist

- [x] No logs, configuration, personally identifiable information, or
user data are included.

## What Changed

- Added pagination parameters to the timeline API client contract.
- Added a timeline window loader that requests every issue page and
deduplicates actors, spans, events, and edges while preserving
pagination metadata.
- Switched the timeline query to use the complete-window loader.
- Added a regression test proving a 501-issue window loads both API
pages and combines their records.
- Preserved delegation events and edges when parent and child issues
fall on different API pages, with a server regression test.

## Verification

- `pnpm exec vitest run
server/src/__tests__/work-timeline-service.test.ts
ui/src/pages/Timeline.test.tsx` — 16 tests passed.
- `pnpm --filter @paperclipai/ui typecheck` — passed.
- `pnpm --filter @paperclipai/server typecheck` — passed.
- `pnpm check:token-gates` — all gates clean.
- `git diff --check origin/master...HEAD` — passed.
- Remote CI: build, typecheck, both e2e shards, canary, policy,
security, every general/serialized test shard, and the aggregate
`verify` gate passed on head `24784b28e9`.

## Risks

- Low risk: the change is isolated to timeline data loading and has no
schema or API endpoint changes.
- Large date windows now make sequential requests for all issue pages,
increasing request count for very active companies; the 500-item page
size bounds each response.
- Merged records rely on stable identifiers or composite event/edge
keys; the regression test covers cross-page combination and
deduplication behavior.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex using GPT-5.4 with reasoning, repository tool use, shell
execution, and test execution. The runtime does not expose the exact
context-window size.

## 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
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] 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>
2026-07-30 21:40:57 -07:00

542 lines
16 KiB
TypeScript

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,
usageJson: { inputTokens: 120, cachedInputTokens: 30, outputTokens: 50 },
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",
usage: { inputTokens: 120, cachedInputTokens: 30, outputTokens: 50, totalTokens: 200 },
}),
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("preserves delegation edges when parent and child cross a page boundary", async () => {
const { companyId, userId, agentAId, agentBId } = await seedBase();
const parentIssueId = randomUUID();
const childIssueId = randomUUID();
await db.insert(issues).values([
{
id: parentIssueId,
companyId,
title: "Parent",
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",
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"),
},
]);
const result = await workTimelineService(db).getTimeline({
companyId,
from: new Date("2026-03-01T00:00:00Z"),
to: new Date("2026-03-02T00:00:00Z"),
limit: 1,
});
expect(result.pagination).toEqual({
limit: 1,
offset: 0,
totalIssues: 2,
hasMore: true,
});
expect(result.events).toContainEqual(expect.objectContaining({
kind: "delegated",
issueId: childIssueId,
}));
expect(result.edges).toContainEqual(expect.objectContaining({
kind: "delegation",
issueId: childIssueId,
fromActorId: `agent:${agentAId}`,
toActorId: `agent:${agentBId}`,
}));
});
it("does not join activity rows to runs from another company", async () => {
const { companyId, agentAId } = await seedBase();
const otherCompanyId = randomUUID();
const otherAgentId = randomUUID();
const issueId = randomUUID();
const foreignRunId = randomUUID();
await db.insert(companies).values({
id: otherCompanyId,
name: "Other Timeline Co",
issuePrefix: `O${randomUUID().replace(/-/g, "").slice(0, 4).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: otherAgentId,
companyId: otherCompanyId,
name: "Foreign Coder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "Local issue",
status: "todo",
priority: "medium",
assigneeAgentId: agentAId,
createdAt: new Date("2026-03-02T10:00:00Z"),
updatedAt: new Date("2026-03-02T10:00:00Z"),
});
await db.insert(heartbeatRuns).values({
id: foreignRunId,
companyId: otherCompanyId,
agentId: otherAgentId,
status: "completed",
invocationSource: "manual",
startedAt: new Date("2026-03-02T11:00:00Z"),
finishedAt: new Date("2026-03-02T11:15:00Z"),
contextSnapshot: {},
});
await db.insert(activityLog).values({
companyId,
actorType: "agent",
actorId: agentAId,
action: "issue.updated",
entityType: "issue",
entityId: issueId,
agentId: agentAId,
runId: foreignRunId,
createdAt: new Date("2026-03-02T11:05:00Z"),
});
const result = await workTimelineService(db).getTimeline({
companyId,
from: new Date("2026-03-02T00:00:00Z"),
to: new Date("2026-03-03T00:00:00Z"),
});
expect(result.events.map((event) => event.issueId)).toContain(issueId);
expect(result.spans.map((span) => span.runId)).not.toContain(foreignRunId);
});
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("bounds pre-pagination ACL checks", async () => {
const { companyId, agentAId } = await seedBase();
const issueCount = 40;
await db.insert(issues).values(Array.from({ length: issueCount }, (_, index) => ({
id: randomUUID(),
companyId,
title: `Visible ${index}`,
status: "todo",
priority: "medium",
assigneeAgentId: agentAId,
createdAt: new Date(Date.parse("2026-03-04T10:00:00Z") + index * 1000),
updatedAt: new Date(Date.parse("2026-03-04T10:00:00Z") + index * 1000),
})));
let activeChecks = 0;
let maxActiveChecks = 0;
const result = await workTimelineService(db).getTimeline({
companyId,
from: new Date("2026-03-04T00:00:00Z"),
to: new Date("2026-03-05T00:00:00Z"),
limit: issueCount,
canReadIssue: async () => {
activeChecks += 1;
maxActiveChecks = Math.max(maxActiveChecks, activeChecks);
await new Promise((resolve) => setTimeout(resolve, 1));
activeChecks -= 1;
return true;
},
});
expect(result.pagination.totalIssues).toBe(issueCount);
expect(maxActiveChecks).toBeLessThanOrEqual(16);
});
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 }),
}));
});
});