mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-15 18:56:05 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - People open tasks from the task list to see the latest messages and to reply to an agent > - Before the task chat shows its first frame, it waits for about ten separate reads: the issue, recent comments, comment input facts, recent activity, recent runs, interactions, attachments, live runs and the active run > - On a busy instance each read waits for the database, and the slowest read decides when the chat appears. A cold open takes 1.5–4 s on staging > - This pull request adds one read that returns all of that data for up to ten tasks, in the same JSON shapes as the existing endpoints, plus a single-task form > - The benefit is that the task list can preload its top tasks, and a cold open needs one round trip instead of about ten. The UI change is in the stacked pull request ## Linked Issues or Issue Description **Problem or motivation** A task open waits for about ten reads. With the database under load, these reads queue, and a cold open takes seconds. The list cannot preload a task cheaply, because each task costs about ten requests. **Proposed solution** `GET /api/companies/:companyId/issues/chat-bootstrap?ids=` returns the first-frame data for up to 10 tasks. `GET /api/issues/:ref/chat-bootstrap` returns it for one task. Each field equals the matching single endpoint's JSON exactly. **Alternatives considered** - Prefetch the existing endpoints for each list row. For 30 rows this sends about 210 requests to a database that is already the bottleneck. - A new projection with different shapes. This would duplicate client merge logic, and freshness rules could drift. **Roadmap alignment** This is part of the task-loading performance plan in #20276. It follows #20277, #20284 and #20286. ## What Changed - `packages/shared`: - `ISSUE_CHAT_BOOTSTRAP_MAX_IDS` (10). - `issueChatBootstrapQuerySchema`: UUIDs or identifiers, normalized, deduplicated and strict. - The `IssueChatBootstrap` and `IssueChatBootstrapItem` types. - New `server/src/services/issue-chat-bootstrap.ts`: - Resolves the refs inside the company and applies the `issue:read` decision to each task. - Reads the tasks with a concurrency of 2, and returns them in request order. - `server/src/routes/issues.ts`: - The core issue read and the recent-comments read are now shared helpers, so the bootstrap and the single endpoints use the same code. The core view keeps its Server-Timing header. - Adds the batch route and the single-task route. - Excludes the single-task route from the issue ETag middleware, so a fetch without validators always gets a 200 with `Cache-Control: private, no-store`. - New `server/src/services/issue-run-presence.ts`: - The live-runs and active-run payload builders, moved from `agents.ts` without change. - The existing routes call them, and their behavior is unchanged. - `server/src/routes/openapi.ts`: entries for both routes. ## Verification - `cd server && npx vitest run src/__tests__/issue-chat-bootstrap-routes.test.ts` passes 17 tests. They run on embedded Postgres with privacy enforcement on. They cover: - Each field deep-equals its single endpoint's JSON, for an ordinary task and for a conversation task. - The single-task route equals the batch item, by UUID and by identifier. It is never answered with a 304. - Items keep the request order, with UUIDs and identifiers mixed. - Private, other-company, missing and unknown tasks are omitted, and none of their data appears. The single-task route returns 404 for them. - Malformed, extra or repeated parameters return 400. More than 10 ids also return 400. - A task-bridge key gets 403. - A 10-task request writes nothing: no inserts, updates or deletes, and no activity-log or comment changes. - The issue, agent, activity, live-run, cold-open, summaries, ETag and OpenAPI route suites pass: 56 files, 1,771 tests. - `cd server && pnpm typecheck` passes. - Query cost, local embedded Postgres, one 10-task request: about 49 SQL statements per task, 55–130 ms in total. A test fails if it goes above 60 per task. ## Risks - **Database load:** the stacked UI preloads up to 30 tasks, about 1,500 statements at idle time. This is the same work as opening those tasks, but it is done before the person clicks. The work runs in sequential requests of 10, with 2 tasks at a time. Most of the cost is existing run decoration. Live runs and the active run decorate the same running run, so that work runs twice. Sharing it is a follow-up. - **Stricter access:** live runs and the active run now apply the `issue:read` policy inside this endpoint. The existing routes keep their company-only check. - Hidden tasks are returned, as `GET /issues/:id` returns them. - No migration and no persisted-state change. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used in Claude Code with tool use (shell, browser automation, file editing, subagents). ## 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 - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [ ] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
302 lines
19 KiB
TypeScript
302 lines
19 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import express from "express";
|
|
import request from "supertest";
|
|
import { afterAll, afterEach, beforeAll, beforeEach, expect, it, vi } from "vitest";
|
|
import { eq } from "drizzle-orm";
|
|
import {
|
|
activityLog, agents, assets, heartbeatRuns, issueAttachments, issueComments, issues, issueThreadInteractions,
|
|
} from "@paperclipai/db";
|
|
import { issueRoutes } from "../routes/issues.js";
|
|
import { activityRoutes } from "../routes/activity.js";
|
|
import { agentRoutes } from "../routes/agents.js";
|
|
import { errorHandler } from "../middleware/index.js";
|
|
import { describeEmbeddedPostgres, seedCompanyWithBoardAccess, useEmbeddedPostgres, type BoardActor } from "./helpers/route-test-harness.js";
|
|
|
|
describeEmbeddedPostgres("task chat bootstrap", () => {
|
|
const ctx = useEmbeddedPostgres("paperclip-issue-chat-bootstrap-");
|
|
const oldPrivacy = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
|
beforeAll(() => { process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce"; });
|
|
afterAll(() => {
|
|
if (oldPrivacy === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
|
else process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = oldPrivacy;
|
|
});
|
|
// Run decorations (output silence) are relative to now; hold the clock so the
|
|
// single endpoints and the bootstrap see the same instant.
|
|
beforeEach(() => { vi.useFakeTimers({ toFake: ["Date"], now: new Date("2026-11-09T12:00:00.000Z") }); });
|
|
afterEach(() => { vi.useRealTimers(); });
|
|
|
|
function app(actor: BoardActor | Express.Request["actor"]) {
|
|
const result = express();
|
|
result.use(express.json());
|
|
result.use((req, _res, next) => { req.actor = actor as Express.Request["actor"]; next(); });
|
|
result.use("/api", issueRoutes(ctx.db, {} as never));
|
|
result.use("/api", activityRoutes(ctx.db));
|
|
result.use("/api", agentRoutes(ctx.db));
|
|
result.use(errorHandler);
|
|
return result;
|
|
}
|
|
const url = (companyId: string) => `/api/companies/${companyId}/issues/chat-bootstrap`;
|
|
|
|
let issueCounter = 0;
|
|
/** A task with comments, their input facts, other activity, a running run, an interaction and an attachment. */
|
|
async function seedTask(companyId: string, options: { conversation?: boolean; description?: string | null; userId: string }) {
|
|
const [agent] = await ctx.db.insert(agents).values({ companyId, name: `Agent ${randomUUID().slice(0, 6)}`, role: "engineer", adapterType: "process" }).returning();
|
|
const issueId = randomUUID();
|
|
const runId = randomUUID();
|
|
const number = ++issueCounter;
|
|
const [company] = await ctx.db.execute<{ issue_prefix: string }>(
|
|
`select issue_prefix from companies where id = '${companyId}'` as never,
|
|
) as unknown as Array<{ issue_prefix: string }>;
|
|
const identifier = `${company!.issue_prefix}-${number}`;
|
|
await ctx.db.insert(issues).values({
|
|
id: issueId, companyId, identifier, issueNumber: number, title: `Task ${number}`,
|
|
description: options.description === undefined ? "Do the thing" : options.description,
|
|
status: "in_progress", assigneeAgentId: agent!.id,
|
|
...(options.conversation ? { conversationAgentId: agent!.id, conversationUserId: options.userId, conversationState: "waiting" } : {}),
|
|
});
|
|
await ctx.db.insert(heartbeatRuns).values({
|
|
id: runId, companyId, agentId: agent!.id, issueId, status: "running", runtimeMode: "native",
|
|
contextSnapshot: { issueId }, startedAt: new Date("2026-11-09T11:59:00.000Z"),
|
|
});
|
|
await ctx.db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId));
|
|
const comments = Array.from({ length: 7 }, (_, i) => ({
|
|
id: randomUUID(), companyId, issueId,
|
|
...(i % 3 === 0
|
|
? { authorType: "user" as const, authorUserId: options.userId }
|
|
: { authorType: "agent" as const, authorAgentId: agent!.id, createdByRunId: runId }),
|
|
body: `Message ${i}`, createdAt: new Date(Date.UTC(2026, 10, 9, 10, i)),
|
|
}));
|
|
await ctx.db.insert(issueComments).values(comments);
|
|
await ctx.db.insert(activityLog).values([
|
|
...comments.map((comment) => ({
|
|
companyId, actorType: "user", actorId: options.userId, action: "issue.comment_added",
|
|
entityType: "issue", entityId: issueId, details: { commentId: comment.id }, createdAt: comment.createdAt,
|
|
})),
|
|
{ companyId, actorType: "user", actorId: options.userId, action: "issue.updated", entityType: "issue", entityId: issueId, details: { status: "in_progress" } },
|
|
]);
|
|
await ctx.db.insert(issueThreadInteractions).values({
|
|
companyId, issueId, kind: "ask_user_questions", status: "pending", effectiveResolverPolicy: "human_only",
|
|
sourceRunId: runId,
|
|
payload: { version: 1, questions: [{ id: "q1", prompt: "Ship it?", selectionMode: "single", options: [{ id: "yes", label: "Yes" }] }] },
|
|
});
|
|
const [asset] = await ctx.db.insert(assets).values({
|
|
companyId, provider: "local_disk", objectKey: `assets/${randomUUID()}`, contentType: "text/plain", byteSize: 5, sha256: "abc", originalFilename: "notes.txt",
|
|
}).returning();
|
|
await ctx.db.insert(issueAttachments).values({ companyId, issueId, assetId: asset!.id, issueCommentId: comments[0]!.id });
|
|
return { issueId, identifier, runId, agentId: agent!.id, commentIds: comments.map((comment) => comment.id) };
|
|
}
|
|
|
|
/** The payloads the chat reads today, one request each. */
|
|
async function singleEndpoints(server: express.Express, ref: string) {
|
|
const get = async (path: string) => {
|
|
const res = await request(server).get(path);
|
|
expect(res.status, `${path}: ${JSON.stringify(res.body)}`).toBe(200);
|
|
return res.body;
|
|
};
|
|
const recentComments = await get(`/api/issues/${ref}/recent-comments`);
|
|
const commentIds = (recentComments.comments as Array<{ id: string }>).map((comment) => comment.id).sort();
|
|
return {
|
|
issue: await get(`/api/issues/${ref}?view=core`),
|
|
recentComments,
|
|
commentInputFacts: await get(`/api/issues/${ref}/activity?view=comment-inputs&commentIds=${commentIds.join(",")}`),
|
|
recentActivity: await get(`/api/issues/${ref}/activity?view=recent`),
|
|
recentRuns: await get(`/api/issues/${ref}/runs?view=recent`),
|
|
interactions: await get(`/api/issues/${ref}/interactions`),
|
|
attachments: await get(`/api/issues/${ref}/attachments`),
|
|
liveRuns: await get(`/api/issues/${ref}/live-runs`),
|
|
activeRun: await get(`/api/issues/${ref}/active-run`),
|
|
};
|
|
}
|
|
|
|
it("serves each field exactly as its single endpoint does, for an ordinary task and a conversation", async () => {
|
|
const f = await seedCompanyWithBoardAccess(ctx.db, "Bootstrap");
|
|
const ordinary = await seedTask(f.companyId, { userId: f.userId, description: null });
|
|
const conversation = await seedTask(f.companyId, { userId: f.userId, conversation: true });
|
|
const server = app(f.actor);
|
|
|
|
const res = await request(server).get(url(f.companyId)).query({ ids: `${ordinary.issueId},${conversation.issueId}` });
|
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
|
expect(res.headers["cache-control"]).toBe("private, no-store");
|
|
expect(res.body.items.map((item: { ref: string }) => item.ref)).toEqual([ordinary.issueId, conversation.issueId]);
|
|
|
|
for (const [index, task] of [ordinary, conversation].entries()) {
|
|
const item = res.body.items[index];
|
|
const expected = await singleEndpoints(server, task.issueId);
|
|
expect(Object.keys(item).sort()).toEqual(["ref", ...Object.keys(expected)].sort());
|
|
for (const [field, value] of Object.entries(expected)) expect(item[field], field).toEqual(value);
|
|
// The fixture exercises every field, so equality is not vacuous.
|
|
expect(item.recentComments.comments.length).toBeGreaterThan(0);
|
|
expect(item.commentInputFacts.length).toBe(item.recentComments.comments.length);
|
|
expect(item.recentActivity.length).toBeGreaterThan(item.commentInputFacts.length);
|
|
expect(item.recentRuns.map((run: { runId: string }) => run.runId)).toEqual([task.runId]);
|
|
expect(item.interactions).toHaveLength(1);
|
|
expect(item.attachments).toEqual([expect.objectContaining({ contentPath: expect.stringMatching(/^\/api\/attachments\//) })]);
|
|
expect(item.liveRuns.map((run: { id: string }) => run.id)).toEqual([task.runId]);
|
|
expect(item.activeRun).toMatchObject({ id: task.runId, agentId: task.agentId });
|
|
}
|
|
// The two branches of recent comments differ: a conversation keeps a contiguous window.
|
|
expect(res.body.items[1].recentComments.comments).toHaveLength(7);
|
|
expect(res.body.items[1].recentComments.oldestCommentId).toBeDefined();
|
|
expect(res.body.items[0].recentComments).not.toHaveProperty("oldestCommentId");
|
|
});
|
|
|
|
it("follows request order with mixed UUIDs and identifiers, and reads a task named twice once per ref", async () => {
|
|
const f = await seedCompanyWithBoardAccess(ctx.db, "Order");
|
|
const a = await seedTask(f.companyId, { userId: f.userId });
|
|
const b = await seedTask(f.companyId, { userId: f.userId });
|
|
const ids = [b.identifier.toLowerCase(), a.issueId.toUpperCase(), b.issueId, ` ${b.identifier} `];
|
|
const res = await request(app(f.actor)).get(url(f.companyId)).query({ ids: ids.join(",") }).expect(200);
|
|
// Normalized and deduplicated by the contract; the same task under two refs is returned for each ref.
|
|
expect(res.body.items.map((item: { ref: string; issue: { id: string } }) => [item.ref, item.issue.id])).toEqual([
|
|
[b.identifier, b.issueId], [a.issueId, a.issueId], [b.issueId, b.issueId],
|
|
]);
|
|
expect(res.body.items[0].issue).toEqual(res.body.items[2].issue);
|
|
});
|
|
|
|
it("omits foreign, private, missing and unknown tasks and never discloses their data", async () => {
|
|
const f = await seedCompanyWithBoardAccess(ctx.db, "Visible");
|
|
const foreign = await seedCompanyWithBoardAccess(ctx.db, "Foreign");
|
|
const visible = await seedTask(f.companyId, { userId: f.userId });
|
|
const foreignTask = await seedTask(foreign.companyId, { userId: foreign.userId });
|
|
const privateTask = await seedTask(f.companyId, { userId: "someone-else" });
|
|
await ctx.db.update(issues).set({
|
|
visibility: "private", privacyRootIssueId: privateTask.issueId, responsibleUserId: "someone-else", createdByUserId: "someone-else",
|
|
}).where(eq(issues.id, privateTask.issueId));
|
|
|
|
const ids = [privateTask.issueId, foreignTask.issueId, foreignTask.identifier, visible.issueId, randomUUID(), "NOPE-999"];
|
|
const res = await request(app(f.actor)).get(url(f.companyId)).query({ ids: ids.join(",") }).expect(200);
|
|
expect(res.body.items.map((item: { ref: string }) => item.ref)).toEqual([visible.issueId]);
|
|
const body = JSON.stringify(res.body);
|
|
for (const hidden of [privateTask, foreignTask]) {
|
|
expect(body).not.toContain(hidden.issueId);
|
|
expect(body).not.toContain(hidden.runId);
|
|
for (const commentId of hidden.commentIds) expect(body).not.toContain(commentId);
|
|
}
|
|
// A private task is denied by the read policy on its single endpoint too.
|
|
await request(app(f.actor)).get(`/api/issues/${privateTask.issueId}/recent-comments`).expect(404);
|
|
// Another company's bootstrap is out of reach.
|
|
await request(app(f.actor)).get(url(foreign.companyId)).query({ ids: foreignTask.issueId }).expect(403);
|
|
});
|
|
|
|
it("serves one task by UUID or identifier as exactly the batch item, never as a 304", async () => {
|
|
const f = await seedCompanyWithBoardAccess(ctx.db, "Single");
|
|
const ordinary = await seedTask(f.companyId, { userId: f.userId });
|
|
const conversation = await seedTask(f.companyId, { userId: f.userId, conversation: true });
|
|
const server = app(f.actor);
|
|
for (const task of [ordinary, conversation]) {
|
|
const batch = await request(server).get(url(f.companyId)).query({ ids: `${task.issueId},${task.identifier}` }).expect(200);
|
|
const byUuid = await request(server).get(`/api/issues/${task.issueId.toUpperCase()}/chat-bootstrap`).expect(200);
|
|
expect(byUuid.headers["cache-control"]).toBe("private, no-store");
|
|
// Not the issue-detail ETag middleware (which would set `must-revalidate`); a fetch without validators is a 200.
|
|
expect(byUuid.body).toEqual(batch.body.items[0]);
|
|
expect(byUuid.body.ref).toBe(task.issueId);
|
|
const byIdentifier = await request(server).get(`/api/issues/${task.identifier.toLowerCase()}/chat-bootstrap`).expect(200);
|
|
expect(byIdentifier.body).toEqual(batch.body.items[1]);
|
|
expect(byIdentifier.body.ref).toBe(task.identifier);
|
|
// And each field is the single endpoint's payload.
|
|
const expected = await singleEndpoints(server, task.issueId);
|
|
for (const [field, value] of Object.entries(expected)) expect(byUuid.body[field], field).toEqual(value);
|
|
}
|
|
});
|
|
|
|
it("returns 404 for a missing, private or other-company task through the single-task route", async () => {
|
|
const f = await seedCompanyWithBoardAccess(ctx.db, "Single404");
|
|
const foreign = await seedCompanyWithBoardAccess(ctx.db, "Single404 foreign");
|
|
const foreignTask = await seedTask(foreign.companyId, { userId: foreign.userId });
|
|
const privateTask = await seedTask(f.companyId, { userId: "someone-else" });
|
|
await ctx.db.update(issues).set({
|
|
visibility: "private", privacyRootIssueId: privateTask.issueId, responsibleUserId: "someone-else", createdByUserId: "someone-else",
|
|
}).where(eq(issues.id, privateTask.issueId));
|
|
const server = app(f.actor);
|
|
for (const ref of [privateTask.issueId, privateTask.identifier, foreignTask.issueId, foreignTask.identifier, randomUUID(), "NOPE-999", "not a ref"]) {
|
|
const res = await request(server).get(`/api/issues/${encodeURIComponent(ref)}/chat-bootstrap`);
|
|
expect(res.status, ref).toBe(404);
|
|
expect(JSON.stringify(res.body)).not.toContain("Task ");
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
"", "?ids=", "?ids=,", "?ids=nope", "?ids=not-a-uuid-or-identifier!", "?ids[]=x", "?ids[x]=x",
|
|
`?ids=${randomUUID()}&extra=1`, `?ids=${randomUUID()}&ids=${randomUUID()}`,
|
|
`?ids=${Array.from({ length: 11 }, () => randomUUID()).join(",")}`,
|
|
])("rejects invalid query %s", async (query) => {
|
|
const f = await seedCompanyWithBoardAccess(ctx.db, "Invalid");
|
|
await request(app(f.actor)).get(url(f.companyId) + query).expect(400);
|
|
});
|
|
|
|
it("rejects task-bridge keys and serves ordinary same-company agents", async () => {
|
|
const f = await seedCompanyWithBoardAccess(ctx.db, "Bridge");
|
|
const task = await seedTask(f.companyId, { userId: f.userId });
|
|
const actor: Express.Request["actor"] = { type: "agent", agentId: task.agentId, companyId: f.companyId, source: "agent_key" };
|
|
const res = await request(app(actor)).get(url(f.companyId)).query({ ids: task.issueId }).expect(200);
|
|
expect(res.body.items).toHaveLength(1);
|
|
await request(app({ ...actor, keyScope: { kind: "task_bridge", issueId: task.issueId } } as Express.Request["actor"]))
|
|
.get(url(f.companyId)).query({ ids: task.issueId }).expect(403);
|
|
});
|
|
|
|
it("is read-only and bounds the database work for ten tasks", async () => {
|
|
const f = await seedCompanyWithBoardAccess(ctx.db, "Cost");
|
|
const tasks = [];
|
|
for (let i = 0; i < 10; i++) tasks.push(await seedTask(f.companyId, { userId: f.userId, conversation: i % 2 === 0 }));
|
|
const server = app(f.actor);
|
|
const activityBefore = await ctx.db.select({ id: activityLog.id }).from(activityLog).where(eq(activityLog.companyId, f.companyId));
|
|
const commentsBefore = await ctx.db.select({ id: issueComments.id }).from(issueComments).where(eq(issueComments.companyId, f.companyId));
|
|
|
|
const statements: string[] = [];
|
|
const client = ctx.db.$client as unknown as { options: { debug?: unknown } };
|
|
const previousDebug = client.options.debug;
|
|
client.options.debug = (_connection: number, query: string) => { statements.push(query); };
|
|
const started = performance.now();
|
|
let res;
|
|
try {
|
|
res = await request(server).get(url(f.companyId)).query({ ids: tasks.map((task) => task.issueId).join(",") });
|
|
} finally {
|
|
client.options.debug = previousDebug;
|
|
}
|
|
const elapsedMs = performance.now() - started;
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.items).toHaveLength(10);
|
|
// Reported for the PR; the bound guards against an accidental N+1 inside a task.
|
|
process.stdout.write(`${JSON.stringify({ chatBootstrap: { tasks: 10, statements: statements.length, perTask: statements.length / 10, elapsedMs: Math.round(elapsedMs) } })}\n`);
|
|
if (process.env.CHAT_BOOTSTRAP_DUMP) {
|
|
const counts = new Map<string, number>();
|
|
for (const statement of statements) {
|
|
const key = statement.replace(/\s+/g, " ").slice(0, 140);
|
|
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
}
|
|
process.stdout.write(`${JSON.stringify([...counts].sort((a, b) => b[1] - a[1]), null, 1)}\n`);
|
|
}
|
|
expect(statements.length / 10).toBeLessThan(60);
|
|
expect(statements.some((statement) => /^\s*(insert|update|delete)\b/i.test(statement))).toBe(false);
|
|
|
|
expect(await ctx.db.select({ id: activityLog.id }).from(activityLog).where(eq(activityLog.companyId, f.companyId))).toHaveLength(activityBefore.length);
|
|
expect(await ctx.db.select({ id: issueComments.id }).from(issueComments).where(eq(issueComments.companyId, f.companyId))).toHaveLength(commentsBefore.length);
|
|
});
|
|
|
|
it("does not start the run liveness backfill for finished runs", async () => {
|
|
const f = await seedCompanyWithBoardAccess(ctx.db, "Backfill");
|
|
const task = await seedTask(f.companyId, { userId: f.userId });
|
|
const [run] = await ctx.db.select({ agentId: heartbeatRuns.agentId }).from(heartbeatRuns).where(eq(heartbeatRuns.issueId, task.issueId));
|
|
// A finished run without a liveness state is what `/runs` backfills in the background.
|
|
const finishedRunId = randomUUID();
|
|
await ctx.db.insert(heartbeatRuns).values({
|
|
id: finishedRunId, companyId: f.companyId, agentId: run!.agentId, issueId: task.issueId, status: "succeeded", runtimeMode: "native",
|
|
contextSnapshot: { issueId: task.issueId }, startedAt: new Date("2026-11-09T11:00:00.000Z"), finishedAt: new Date("2026-11-09T11:05:00.000Z"),
|
|
});
|
|
const server = app(f.actor);
|
|
|
|
expect((await request(server).get(url(f.companyId)).query({ ids: task.issueId })).status).toBe(200);
|
|
expect((await request(server).get(`/api/issues/${task.issueId}/chat-bootstrap`)).status).toBe(200);
|
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
const [after] = await ctx.db.select({ livenessState: heartbeatRuns.livenessState }).from(heartbeatRuns).where(eq(heartbeatRuns.id, finishedRunId));
|
|
expect(after!.livenessState).toBeNull();
|
|
|
|
// Control: the task page's own runs read still schedules it.
|
|
expect((await request(server).get(`/api/issues/${task.issueId}/runs`).query({ view: "recent" })).status).toBe(200);
|
|
await expect.poll(async () => {
|
|
const [row] = await ctx.db.select({ livenessState: heartbeatRuns.livenessState }).from(heartbeatRuns).where(eq(heartbeatRuns.id, finishedRunId));
|
|
return row!.livenessState;
|
|
}, { timeout: 5_000 }).not.toBeNull();
|
|
});
|
|
});
|