feat(server): chat bootstrap reads for instant task opens (#20355)

## 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>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-09 15:05:41 -06:00
1 parent 282503b5b3
commit 1baac27888
14 files changed
+735 -109

No files matched your search

+2
View File
@@ -1,5 +1,7 @@
export const COMPANY_STATUSES = ["active", "paused", "archived"] as const;
export const ISSUE_SUMMARIES_MAX_IDS = 5;
/** Tasks per chat bootstrap request. The list preloads larger sets in chunks. */
export const ISSUE_CHAT_BOOTSTRAP_MAX_IDS = 10;
export type CompanyStatus = (typeof COMPANY_STATUSES)[number];
export const DEPLOYMENT_MODES = ["local_trusted", "authenticated"] as const;
+4
View File
@@ -368,6 +368,7 @@ export {
ISSUE_STATUSES,
ISSUE_DISPLAY_STATUSES,
ISSUE_SUMMARIES_MAX_IDS,
ISSUE_CHAT_BOOTSTRAP_MAX_IDS,
INBOX_MINE_ISSUE_STATUSES,
INBOX_MINE_ISSUE_STATUS_FILTER,
ISSUE_PRIORITIES,
@@ -1152,6 +1153,8 @@ export type {
IssueExecutionDecision,
IssueComment,
IssueRecentComments,
IssueChatBootstrap,
IssueChatBootstrapItem,
IssueQueuedCommentEntry,
IssueQueuedCommentProtocol,
IssueQueuedCommentQueue,
@@ -1973,6 +1976,7 @@ export {
type CompanySearchQuery,
createIssueSchema,
issueSummariesQuerySchema,
issueChatBootstrapQuerySchema,
setIssueTitleSchema,
type SetIssueTitle,
createIssueInputSchema,
+2
View File
@@ -749,6 +749,8 @@ export type {
IssueExecutionDecision,
IssueComment,
IssueRecentComments,
IssueChatBootstrap,
IssueChatBootstrapItem,
IssueQueuedCommentEntry,
IssueQueuedCommentProtocol,
IssueQueuedCommentQueue,
+28
View File
@@ -38,6 +38,7 @@ import type {
IssueThreadInteractionStatus,
IssueStatus,
} from "../constants.js";
import type { ActivityEvent } from "./activity.js";
import type { Goal } from "./goal.js";
import type { Project, ProjectWorkspace } from "./project.js";
import type { ExecutionWorkspace, IssueExecutionWorkspaceSettings } from "./workspace-runtime.js";
@@ -1851,3 +1852,30 @@ export interface IssueRecentComments {
comments: IssueComment[];
hasMore: boolean;
}
/**
* Everything the compact task chat reads before its first frame, for one task,
* in the same shapes as the individual endpoints:
* `GET /issues/:id?view=core`, `/recent-comments`, `/activity?view=recent`,
* `/activity?view=comment-inputs` (for the recent comments), `/runs?view=recent`,
* `/interactions`, `/attachments`, `/live-runs` and `/active-run`.
* Run payloads are typed by their consumers.
*/
export interface IssueChatBootstrapItem<TRun = unknown, TLiveRun = unknown, TActiveRun = unknown> {
/** The reference the caller asked for, normalized (UUID lowercase, identifier uppercase). */
ref: string;
issue: Issue;
recentComments: IssueRecentComments;
commentInputFacts: ActivityEvent[];
recentActivity: ActivityEvent[];
recentRuns: TRun[];
interactions: IssueThreadInteraction[];
attachments: IssueAttachment[];
liveRuns: TLiveRun[];
activeRun: TActiveRun | null;
}
/** `GET /companies/:companyId/issues/chat-bootstrap?ids=`; unreadable or missing tasks are omitted. */
export interface IssueChatBootstrap<TRun = unknown, TLiveRun = unknown, TActiveRun = unknown> {
items: IssueChatBootstrapItem<TRun, TLiveRun, TActiveRun>[];
}
+1
View File
@@ -425,6 +425,7 @@ export {
export {
createIssueSchema,
issueSummariesQuerySchema,
issueChatBootstrapQuerySchema,
setIssueTitleSchema,
type SetIssueTitle,
createIssueInputSchema,
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { ISSUE_CHAT_BOOTSTRAP_MAX_IDS } from "../constants.js";
import { issueChatBootstrapQuerySchema } from "./issue.js";
describe("issueChatBootstrapQuerySchema", () => {
it("accepts UUIDs and task identifiers, normalized and deduplicated", () => {
const uuid = "5C0F1C2E-1111-4222-8333-444455556666";
expect(issueChatBootstrapQuerySchema.parse({ ids: `${uuid}, abc-12,ABC-12,${uuid.toLowerCase()}` })).toEqual({
ids: [uuid.toLowerCase(), "ABC-12"],
});
});
it("rejects values that are neither a UUID nor an identifier", () => {
expect(issueChatBootstrapQuerySchema.safeParse({ ids: "abc-12,../etc" }).success).toBe(false);
expect(issueChatBootstrapQuerySchema.safeParse({ ids: "" }).success).toBe(false);
});
it("bounds one request and allows no other query keys", () => {
const ids = Array.from({ length: ISSUE_CHAT_BOOTSTRAP_MAX_IDS + 1 }, (_, i) => `ABC-${i + 1}`).join(",");
expect(issueChatBootstrapQuerySchema.safeParse({ ids }).success).toBe(false);
expect(issueChatBootstrapQuerySchema.safeParse({ ids: "ABC-1", extra: "1" }).success).toBe(false);
});
});
+24 -1
View File
@@ -2,7 +2,8 @@ import { CHAT_PROVIDERS } from "../types/chat-channels.js";
import { taskWorkspaceSelectionSchema } from "./task-workspace.js";
import { z } from "zod";
import { maxStringLength } from "./text.js";
import { ISSUE_SUMMARIES_MAX_IDS } from "../constants.js";
import { ISSUE_CHAT_BOOTSTRAP_MAX_IDS, ISSUE_SUMMARIES_MAX_IDS } from "../constants.js";
import { normalizeIssueIdentifier } from "../issue-references.js";
import {
ISSUE_EXECUTION_DECISION_OUTCOMES,
@@ -54,6 +55,28 @@ export const issueSummariesQuerySchema = z.object({
.transform((ids) => [...new Set(ids)]),
}).strict();
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/** A task reference as the UI routes use it: a UUID or an identifier such as `ABC-12`. */
function normalizeIssueRef(value: string): string | null {
const trimmed = value.trim();
if (UUID_RE.test(trimmed)) return trimmed.toLowerCase();
return normalizeIssueIdentifier(trimmed);
}
export const issueChatBootstrapQuerySchema = z.object({
ids: z.string()
.transform((value, ctx) => {
const refs = value.split(",").filter((ref) => ref.trim()).map(normalizeIssueRef);
if (refs.some((ref) => ref === null)) {
ctx.addIssue({ code: "custom", message: "Each id must be a task UUID or identifier" });
return z.NEVER;
}
return [...new Set(refs as string[])];
})
.pipe(z.array(z.string()).min(1).max(ISSUE_CHAT_BOOTSTRAP_MAX_IDS)),
}).strict();
export const issueBlockedInboxStateSchema = z.enum([
"needs_attention",
"awaiting_decision",
@@ -0,0 +1,301 @@
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();
});
});
+4 -91
View File
@@ -57,6 +57,7 @@ import { getNativeReviewAssignment, readNativeReviewAssignmentContext, type Nati
import { paperclipRunnerTransitionConfig, normalizeLegacyRunnerProvider, isPaperclipRunnerProvider, PAPERCLIP_RUNNER_ACPX_PROFILES } from "@paperclipai/adapter-utils";
import { applyMcpReasoningEffort } from "../services/public-mcp/agent-config.js";
import { executionProjectionForRun, executionProjectionsForRuns } from "../services/execution-projection.js";
import { issueRunPresenceService } from "../services/issue-run-presence.js";
import { selectDashboardRunIds } from "../services/dashboard-run-selection.js";
import { Router, type NextFunction, type Request, type Response } from "express";
import { generateKeyPairSync, randomUUID } from "node:crypto";
@@ -791,6 +792,7 @@ export function agentRoutes(
const heartbeat = heartbeatService(db, {
pluginWorkerManager: options.pluginWorkerManager,
});
const issueRunPresence = issueRunPresenceService(db, { heartbeat, getAgentById: (id) => svc.getById(id) });
const providerTraces = providerTraceStore(db);
const traceExpiryCleanup = providerTraces.cleanupExpired?.();
void traceExpiryCleanup?.catch((error) => {
@@ -7848,56 +7850,7 @@ export function agentRoutes(
"Issue not found",
);
if (!issue) return;
const liveRuns = await db
.select({
id: heartbeatRuns.id,
runtimeMode: heartbeatRuns.runtimeMode,
wakeupRequestId: heartbeatRuns.wakeupRequestId,
queuedCommentSourceWakeIds: sql<string[] | null>`${heartbeatRuns.contextSnapshot} -> 'queuedCommentSourceWakeIds'`.as("queuedCommentSourceWakeIds"),
status: heartbeatRuns.status,
invocationSource: heartbeatRuns.invocationSource,
triggerDetail: heartbeatRuns.triggerDetail,
contextCommentId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'commentId'`.as("contextCommentId"),
contextWakeCommentId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'wakeCommentId'`.as("contextWakeCommentId"),
startedAt: heartbeatRuns.startedAt,
finishedAt: heartbeatRuns.finishedAt,
createdAt: heartbeatRuns.createdAt,
agentId: heartbeatRuns.agentId,
agentName: agentsTable.name,
agentAppearance: agentsTable.appearance,
adapterType: agentsTable.adapterType,
logBytes: heartbeatRuns.logBytes,
livenessState: heartbeatRuns.livenessState,
livenessReason: heartbeatRuns.livenessReason,
continuationAttempt: heartbeatRuns.continuationAttempt,
lastUsefulActionAt: heartbeatRuns.lastUsefulActionAt,
nextAction: heartbeatRuns.nextAction,
lastOutputAt: heartbeatRuns.lastOutputAt,
lastOutputSeq: heartbeatRuns.lastOutputSeq,
lastOutputStream: heartbeatRuns.lastOutputStream,
lastOutputBytes: heartbeatRuns.lastOutputBytes,
processStartedAt: heartbeatRuns.processStartedAt,
})
.from(heartbeatRuns)
.innerJoin(agentsTable, eq(heartbeatRuns.agentId, agentsTable.id))
.where(
and(
eq(heartbeatRuns.companyId, issue.companyId),
inArray(heartbeatRuns.status, ["queued", "running"]),
eq(heartbeatRuns.issueId, issue.id),
),
)
.orderBy(desc(heartbeatRuns.createdAt));
const projections = await executionProjectionsForRuns(db, issue.companyId, liveRuns.map(run => run.id));
res.json(await Promise.all(liveRuns.map(async (run) => ({
...heartbeat.decorateActiveRunStatus(run, { companyId: issue.companyId, issueId: issue.id }),
agentAppearance: resolveAgentAppearance(run.agentAppearance, run.agentId),
avatarUrl: agentAvatarUrl(resolveAgentAppearance(run.agentAppearance, run.agentId), 512),
execution: projections.get(run.id) ?? null,
outputSilence: await heartbeat.buildRunOutputSilence({ ...run, companyId: issue.companyId }),
}))));
res.json(await issueRunPresence.liveRuns(issue));
});
router.get("/issues/:issueId/execution", async (req, res) => {
@@ -7921,47 +7874,7 @@ export function agentRoutes(
"Issue not found",
);
if (!issue) return;
let run = issue.executionRunId ? await heartbeat.getRunIssueSummary(issue.executionRunId) : null;
if (
run &&
(
(run.status !== "queued" && run.status !== "running") ||
run.issueId !== issue.id
)
) {
run = null;
}
if (!run && issue.assigneeAgentId && issue.status === "in_progress") {
const candidateRun = await heartbeat.getActiveRunIssueSummaryForAgent(issue.assigneeAgentId);
const candidateIssueId = asNonEmptyString(candidateRun?.issueId);
if (candidateRun && candidateIssueId === issue.id) {
run = candidateRun;
}
}
if (!run) {
res.json(null);
return;
}
const agent = await svc.getById(run.agentId);
if (!agent) {
res.json(null);
return;
}
const decoratedRun = heartbeat.decorateActiveRunStatus(run, { companyId: issue.companyId, issueId: issue.id });
res.json({
...decoratedRun,
execution: await executionProjectionForRun(db, issue.companyId, run.id),
agentId: agent.id,
agentName: agent.name,
agentAppearance: agent.appearance,
avatarUrl: agent.avatarUrl,
adapterType: agent.adapterType,
outputSilence: await heartbeat.buildRunOutputSilence({ ...run, companyId: issue.companyId }),
});
res.json(await issueRunPresence.activeRun(issue));
});
return router;
+127 -15
View File
@@ -13,7 +13,10 @@ import { getHeartbeatRunReadDecisions, redactHeartbeatRunListRow } from "../serv
import { taskInterruptionPresentation, taskInterruptionPresentations } from "../services/task-interruption-presentation.js";
import { parseObject } from "../adapters/utils.js";
import { prepareRestrictedWorkspaceRetry } from "../services/restricted-workspace-retry.js";
import { issueSummariesQuerySchema, type IssueSummary } from "@paperclipai/shared";
import { issueChatBootstrapQuerySchema, issueSummariesQuerySchema, type IssueSummary } from "@paperclipai/shared";
import { readIssueChatBootstrapItems } from "../services/issue-chat-bootstrap.js";
import { issueRunPresenceService } from "../services/issue-run-presence.js";
import { activityService } from "../services/activity.js";
import { listIssueSummaries } from "../services/issue-summaries.js";
import { listIssueStatusBlockerAttention } from "../services/issue-status-presentation.js";
import { assertNoCaseHandoffMutation } from "../services/heartbeat/case-handoff-lifecycle.js";
@@ -3573,6 +3576,8 @@ export function issueRoutes(
const recoveryActionsSvc = issueRecoveryActionService(db);
const executionWorkspacesSvc = executionWorkspaceServiceDirect(db, { pluginWorkerManager: opts.pluginWorkerManager });
const workProductsSvc = workProductService(db);
const activitySvc = activityService(db);
const issueRunPresence = issueRunPresenceService(db, { heartbeat, getAgentById: (id) => agentsSvc.getById(id) });
const documentsSvc = documentService(db);
const artifactReviewDocumentsSvc = artifactReviewDocumentService(db, storage);
const companySkillsSvc = companySkillService(db);
@@ -3707,7 +3712,8 @@ export function issueRoutes(
const issueDetailEtag = privateJsonEtag();
router.use((req, res, next) => {
if (/^\/issues\/[^/]+(?:\/|$)/.test(req.path)) {
// The chat bootstrap is `no-store`: always a fresh body, never a 304.
if (/^\/issues\/[^/]+(?:\/|$)/.test(req.path) && !/^\/issues\/[^/]+\/chat-bootstrap\/?$/.test(req.path)) {
issueDetailEtag(req, res, next);
return;
}
@@ -8595,6 +8601,106 @@ export function issueRoutes(
res.json(summaries);
});
/** `GET /issues/:id?view=core`, shared with the chat bootstrap. */
async function readIssueCore<T extends Parameters<typeof documentsSvc.getIssueDocumentPayload>[0] & { id: string }>(
issue: T,
time: <R>(phase: "documents" | "work_products", read: () => Promise<R>) => Promise<R> = (_phase, read) => read(),
) {
const [documentPayload, workProducts] = await Promise.all([
time("documents", () => documentsSvc.getIssueDocumentPayload(issue)),
time("work_products", () => workProductsSvc.listForIssue(issue.id)),
]);
return { ...issue, ...documentPayload, workProducts };
}
/**
* What the task chat reads before its first frame, for up to ten tasks, in
* the exact shapes of the individual task endpoints. Read-only: no activity,
* no break-glass. Unreadable tasks are omitted like `/issues/summaries`.
*/
router.get("/companies/:companyId/issues/chat-bootstrap", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
if (isTaskBridgeKeyActor(req)) {
res.status(403).json({ error: "Task bridge keys cannot use company-wide issue list APIs" });
return;
}
const parsed = issueChatBootstrapQuerySchema.safeParse(req.query);
if (!parsed.success) {
res.status(400).json({ error: "ids must be the only query parameter and contain one to ten comma-separated task UUIDs or identifiers" });
return;
}
const items = await readIssueChatBootstrapItems({
companyId,
refs: parsed.data.ids,
resolve: (ref) => (isUuidLike(ref) ? svc.getById(ref) : svc.getByIdentifier(ref)),
filterReadable: (rows) => filterIssuesForActor(req, rows),
read: (issue) => readChatBootstrapPayload(req, issue),
});
res.setHeader("Cache-Control", "private, no-store");
res.json({ items });
});
/**
* The single-task chat bootstrap, for a hard load of a task URL before the
* page knows its company. The same item as the batch route; 404 when the
* task is missing or unreadable. Read-only and without break-glass. The
* issue-detail ETag middleware skips this path, so it is never a 304.
*/
// `:ref`, not `:id`: the `id` param hook rewrites identifiers to UUIDs, and
// the item must echo the caller's reference.
router.get("/issues/:ref/chat-bootstrap", async (req, res) => {
const raw = (req.params.ref as string).trim();
const ref = isUuidLike(raw) ? raw.toLowerCase() : normalizeIssueReferenceIdentifier(raw);
if (!ref) {
res.status(404).json(issueNotFoundBody(req));
return;
}
const issue = await getAccessibleResource(req, res, getIssueById(req, ref), "Issue not found", issueNotFoundBody(req));
if (!issue) return;
if (!(await assertIssueReadAllowed(req, res, issue, { finalForAgents: true }))) return;
const payload = await readChatBootstrapPayload(req, issue);
res.setHeader("Cache-Control", "private, no-store");
res.json({ ref, ...payload });
});
/**
* One task's chat bootstrap payload, shared by the batch and single-task
* routes. Each field is the payload of the named single endpoint; the caller
* has already resolved the task and authorized the read.
*/
async function readChatBootstrapPayload(req: Request, issue: NonNullable<Awaited<ReturnType<typeof svc.getById>>>) {
const viewerUserId = req.actor.type === "board" ? getActorInfo(req).actorId : null;
const [issueCore, recentCommentsAndFacts, recentActivity, recentRuns, interactions, attachments, liveRuns, activeRun] = await Promise.all([
readIssueCore(issue),
readRecentComments(issue).then(async (recentComments) => ({
recentComments,
// `/activity?view=comment-inputs` for the saved recent comments, as the chat requests them.
commentInputFacts: await activitySvc.inputFactsForIssue(
issue.companyId,
issue.id,
recentComments.comments.map((comment) => comment.id).sort(),
),
})),
activitySvc.forIssue(issue.id, 50),
activitySvc.runsForIssue(issue.companyId, issue.id, viewerUserId, 2, undefined, { skipLivenessBackfill: true }),
issueThreadInteractionService(db).listForIssue(issue.id),
svc.listAttachments(issue.id).then((rows) => rows.map(withContentPath)),
issueRunPresence.liveRuns(issue),
issueRunPresence.activeRun(issue),
]);
return {
issue: issueCore,
...recentCommentsAndFacts,
recentActivity,
recentRuns,
interactions,
attachments,
liveRuns,
activeRun,
};
}
async function prepareIssueListForActor(
req: Request,
companyId: string,
@@ -9803,12 +9909,9 @@ export function issueRoutes(
// projection before first paint, independently of slow recovery, workspace
// and related-task enrichment. This never refreshes external providers.
if (req.query.view === "core") {
const [documentPayload, workProducts] = await Promise.all([
timing.time("documents", () => documentsSvc.getIssueDocumentPayload(issue)),
timing.time("work_products", () => workProductsSvc.listForIssue(issue.id)),
]);
const core = await readIssueCore(issue, timing.time);
res.setHeader("Server-Timing", timing.header());
res.json({ ...issue, ...documentPayload, workProducts });
res.json(core);
return;
}
const inboxArchiveFieldsPromise =
@@ -17251,19 +17354,22 @@ export function issueRoutes(
res.json(result);
});
router.get("/issues/:id/recent-comments", async (req, res) => {
const issue = await getAccessibleResource(req, res, getIssueById(req, req.params.id as string), "Issue not found");
if (!issue || !(await assertIssueReadAllowed(req, res, issue))) return;
/** `GET /issues/:id/recent-comments`, shared with the chat bootstrap. */
async function readRecentComments(issue: {
id: string;
companyId: string;
description?: string | null;
conversationAgentId?: string | null;
}) {
if (issue.conversationAgentId) {
const recent = await svc.listComments(issue.id, { companyId: issue.companyId, order: "desc", limit: 21 });
const comments = recent.slice(0, 20);
res.json(await runRedactions.redactForIssue(issue.companyId, issue.id, {
return runRedactions.redactForIssue(issue.companyId, issue.id, {
comments,
latestCommentId: comments[0]?.id ?? null,
oldestCommentId: comments.at(-1)?.id ?? null,
hasMore: recent.length > 20,
}));
return;
});
}
const [recent, human, firstInstruction] = await Promise.all([
svc.listComments(issue.id, { companyId: issue.companyId, order: "desc", limit: 5 }),
@@ -17273,11 +17379,17 @@ export function issueRoutes(
: svc.listComments(issue.id, { companyId: issue.companyId, order: "asc", limit: 1, humanOnly: true }),
]);
const comments = [...new Map([...firstInstruction, ...human, ...recent.slice(0, 4)].map((comment) => [comment.id, comment])).values()];
res.json(await runRedactions.redactForIssue(issue.companyId, issue.id, {
return runRedactions.redactForIssue(issue.companyId, issue.id, {
comments,
latestCommentId: recent[0]?.id ?? null,
hasMore: recent.length > 4,
}));
});
}
router.get("/issues/:id/recent-comments", async (req, res) => {
const issue = await getAccessibleResource(req, res, getIssueById(req, req.params.id as string), "Issue not found");
if (!issue || !(await assertIssueReadAllowed(req, res, issue))) return;
res.json(await readRecentComments(issue));
});
router.get("/issues/:id/comments", async (req, res) => {
+23
View File
@@ -4374,6 +4374,29 @@ registry.registerPath({
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden },
});
registry.registerPath({
method: "get",
path: "/api/companies/{companyId}/issues/chat-bootstrap",
tags: ["issues"],
summary: "Read what the task chat needs before its first frame, for up to ten tasks",
description: "Accepts only the ids query parameter: one to ten comma-separated task UUIDs or identifiers, normalized (UUID lowercase, identifier uppercase) and deduplicated. Returns { items } in request order; each item has the normalized ref plus issue (GET /issues/{id}?view=core), recentComments (/recent-comments), commentInputFacts (/activity?view=comment-inputs for the recent comments), recentActivity (/activity?view=recent), recentRuns (/runs?view=recent), interactions, attachments, liveRuns and activeRun, each in that endpoint's shape. Missing, foreign and unreadable tasks are omitted. Read-only, without break-glass; private, no-store. Task-bridge keys are rejected.",
request: {
params: z.object({ companyId: z.string() }),
query: z.object({ ids: z.string().min(1).describe("One to ten comma-separated task UUIDs or identifiers; other query parameters are rejected") }),
},
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden },
});
registry.registerPath({
method: "get",
path: "/api/issues/{ref}/chat-bootstrap",
tags: ["issues"],
summary: "Read what the task chat needs before its first frame, for one task",
description: "The single-task form of /companies/{companyId}/issues/chat-bootstrap for a hard load of a task URL. Returns one item (not wrapped in items), identical to the batch item for the same ref. 404 when the task is missing or unreadable. Read-only, without break-glass; private, no-store.",
request: { params: z.object({ ref: z.string().describe("A task UUID or identifier") }) },
responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound },
});
registry.registerPath({
method: "post",
path: "/api/companies/{companyId}/issues",
+12 -2
View File
@@ -443,9 +443,19 @@ export function activityService(db: Db) {
return limit === undefined ? query : query.limit(limit);
},
runsForIssue: async (companyId: string, issueId: string, viewerUserId: string | null = null, limit?: number, selectedRunIds?: string[]) => {
runsForIssue: async (
companyId: string,
issueId: string,
viewerUserId: string | null = null,
limit?: number,
selectedRunIds?: string[],
// Read-only callers (the chat bootstrap) skip the background liveness
// backfill: it writes to runs and outlives the request's own limits.
// The task page's own `/runs` read still schedules it.
options: { skipLivenessBackfill?: boolean } = {},
) => {
if (selectedRunIds && !selectedRunIds.length) return [];
scheduleRunLivenessBackfill(companyId, issueId);
if (!options.skipLivenessBackfill) scheduleRunLivenessBackfill(companyId, issueId);
const runsQuery = db
.select({
runId: heartbeatRuns.id,
@@ -0,0 +1,52 @@
/**
* Tasks read at the same time by one chat bootstrap request. Each task already
* runs its own reads in parallel, so this bounds the database work a single
* request can start when the database is the bottleneck.
*/
export const ISSUE_CHAT_BOOTSTRAP_TASK_CONCURRENCY = 2;
async function mapWithConcurrency<T, R>(items: readonly T[], concurrency: number, mapper: (item: T) => Promise<R>) {
const results = new Array<R>(items.length);
let next = 0;
const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
while (next < items.length) {
const index = next++;
results[index] = await mapper(items[index]!);
}
});
await Promise.all(workers);
return results;
}
/**
* Resolve task references, keep the readable ones and read each in request
* order. Missing, foreign and unreadable references are omitted without a
* trace. The caller owns company scoping, authorization and the per-task
* payloads, which must match the individual task endpoints. A task named twice
* (by UUID and by identifier) is read once and returned under each reference.
*/
export async function readIssueChatBootstrapItems<TIssue extends { id: string; companyId: string }, TPayload extends object>(input: {
companyId: string;
refs: readonly string[];
resolve: (ref: string) => Promise<TIssue | null | undefined>;
filterReadable: (issues: TIssue[]) => Promise<TIssue[]>;
read: (issue: TIssue) => Promise<TPayload>;
concurrency?: number;
}): Promise<Array<{ ref: string } & TPayload>> {
const concurrency = input.concurrency ?? ISSUE_CHAT_BOOTSTRAP_TASK_CONCURRENCY;
const resolved = await mapWithConcurrency(input.refs, concurrency, async (ref) => {
const issue = await input.resolve(ref);
return issue && issue.companyId === input.companyId ? { ref, issue } : null;
});
const found = resolved.filter((entry): entry is { ref: string; issue: TIssue } => entry !== null);
const issuesById = new Map<string, TIssue>();
for (const entry of found) if (!issuesById.has(entry.issue.id)) issuesById.set(entry.issue.id, entry.issue);
const readable = await input.filterReadable([...issuesById.values()]);
const payloads = new Map(
(await mapWithConcurrency(readable, concurrency, async (issue) => [issue.id, await input.read(issue)] as const)),
);
return found.flatMap(({ ref, issue }) => {
const payload = payloads.get(issue.id);
return payload ? [{ ref, ...payload }] : [];
});
}
+132
View File
@@ -0,0 +1,132 @@
import { and, desc, eq, inArray, sql } from "drizzle-orm";
import { agents as agentsTable, heartbeatRuns, type Db } from "@paperclipai/db";
import { agentAvatarUrl, resolveAgentAppearance } from "@paperclipai/shared";
import { executionProjectionForRun, executionProjectionsForRuns } from "./execution-projection.js";
import type { heartbeatService } from "./heartbeat.js";
import type { agentService } from "./agents.js";
type HeartbeatReads = Pick<
ReturnType<typeof heartbeatService>,
"decorateActiveRunStatus" | "buildRunOutputSilence" | "getRunIssueSummary" | "getActiveRunIssueSummaryForAgent"
>;
export interface IssueRunPresenceIssue {
id: string;
companyId: string;
status: string;
assigneeAgentId: string | null;
executionRunId?: string | null;
}
function asNonEmptyString(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
/**
* The run presence a task shows in its chat: queued/running runs and the active
* run. Callers own resolving the task and authorizing the read; these builders
* only read. Used by `/issues/:id/live-runs`, `/issues/:id/active-run` and the
* task chat bootstrap so every path serves the same payload.
*/
export function issueRunPresenceService(
db: Db,
deps: {
heartbeat: HeartbeatReads;
getAgentById: ReturnType<typeof agentService>["getById"];
},
) {
const { heartbeat } = deps;
async function liveRuns(issue: IssueRunPresenceIssue) {
const rows = await db
.select({
id: heartbeatRuns.id,
runtimeMode: heartbeatRuns.runtimeMode,
wakeupRequestId: heartbeatRuns.wakeupRequestId,
queuedCommentSourceWakeIds: sql<string[] | null>`${heartbeatRuns.contextSnapshot} -> 'queuedCommentSourceWakeIds'`.as("queuedCommentSourceWakeIds"),
status: heartbeatRuns.status,
invocationSource: heartbeatRuns.invocationSource,
triggerDetail: heartbeatRuns.triggerDetail,
contextCommentId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'commentId'`.as("contextCommentId"),
contextWakeCommentId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'wakeCommentId'`.as("contextWakeCommentId"),
startedAt: heartbeatRuns.startedAt,
finishedAt: heartbeatRuns.finishedAt,
createdAt: heartbeatRuns.createdAt,
agentId: heartbeatRuns.agentId,
agentName: agentsTable.name,
agentAppearance: agentsTable.appearance,
adapterType: agentsTable.adapterType,
logBytes: heartbeatRuns.logBytes,
livenessState: heartbeatRuns.livenessState,
livenessReason: heartbeatRuns.livenessReason,
continuationAttempt: heartbeatRuns.continuationAttempt,
lastUsefulActionAt: heartbeatRuns.lastUsefulActionAt,
nextAction: heartbeatRuns.nextAction,
lastOutputAt: heartbeatRuns.lastOutputAt,
lastOutputSeq: heartbeatRuns.lastOutputSeq,
lastOutputStream: heartbeatRuns.lastOutputStream,
lastOutputBytes: heartbeatRuns.lastOutputBytes,
processStartedAt: heartbeatRuns.processStartedAt,
})
.from(heartbeatRuns)
.innerJoin(agentsTable, eq(heartbeatRuns.agentId, agentsTable.id))
.where(
and(
eq(heartbeatRuns.companyId, issue.companyId),
inArray(heartbeatRuns.status, ["queued", "running"]),
eq(heartbeatRuns.issueId, issue.id),
),
)
.orderBy(desc(heartbeatRuns.createdAt));
const projections = await executionProjectionsForRuns(db, issue.companyId, rows.map(run => run.id));
return Promise.all(rows.map(async (run) => ({
...heartbeat.decorateActiveRunStatus(run, { companyId: issue.companyId, issueId: issue.id }),
agentAppearance: resolveAgentAppearance(run.agentAppearance, run.agentId),
avatarUrl: agentAvatarUrl(resolveAgentAppearance(run.agentAppearance, run.agentId), 512),
execution: projections.get(run.id) ?? null,
outputSilence: await heartbeat.buildRunOutputSilence({ ...run, companyId: issue.companyId }),
})));
}
async function activeRun(issue: IssueRunPresenceIssue) {
let run = issue.executionRunId ? await heartbeat.getRunIssueSummary(issue.executionRunId) : null;
if (
run &&
(
(run.status !== "queued" && run.status !== "running") ||
run.issueId !== issue.id
)
) {
run = null;
}
if (!run && issue.assigneeAgentId && issue.status === "in_progress") {
const candidateRun = await heartbeat.getActiveRunIssueSummaryForAgent(issue.assigneeAgentId);
const candidateIssueId = asNonEmptyString(candidateRun?.issueId);
if (candidateRun && candidateIssueId === issue.id) {
run = candidateRun;
}
}
if (!run) return null;
const agent = await deps.getAgentById(run.agentId);
if (!agent) return null;
const decoratedRun = heartbeat.decorateActiveRunStatus(run, { companyId: issue.companyId, issueId: issue.id });
return {
...decoratedRun,
execution: await executionProjectionForRun(db, issue.companyId, run.id),
agentId: agent.id,
agentName: agent.name,
agentAppearance: agent.appearance,
avatarUrl: agent.avatarUrl,
adapterType: agent.adapterType,
outputSilence: await heartbeat.buildRunOutputSilence({ ...run, companyId: issue.companyId }),
};
}
return { liveRuns, activeRun };
}