diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index ec1cd7fca0c..c157be51ae7 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -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; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index d8221011a28..02e55f1be26 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index c2e73fb144a..0f9b69e2285 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -749,6 +749,8 @@ export type { IssueExecutionDecision, IssueComment, IssueRecentComments, + IssueChatBootstrap, + IssueChatBootstrapItem, IssueQueuedCommentEntry, IssueQueuedCommentProtocol, IssueQueuedCommentQueue, diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 4fbb6a974dc..a892973b51f 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -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 { + /** 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 { + items: IssueChatBootstrapItem[]; +} diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index fa214eed3e7..8bfd7d4e62a 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -425,6 +425,7 @@ export { export { createIssueSchema, issueSummariesQuerySchema, + issueChatBootstrapQuerySchema, setIssueTitleSchema, type SetIssueTitle, createIssueInputSchema, diff --git a/packages/shared/src/validators/issue-chat-bootstrap.test.ts b/packages/shared/src/validators/issue-chat-bootstrap.test.ts new file mode 100644 index 00000000000..e1ebb9aa94d --- /dev/null +++ b/packages/shared/src/validators/issue-chat-bootstrap.test.ts @@ -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); + }); +}); diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index 54db17c6fc1..caa8b843185 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -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", diff --git a/server/src/__tests__/issue-chat-bootstrap-routes.test.ts b/server/src/__tests__/issue-chat-bootstrap-routes.test.ts new file mode 100644 index 00000000000..2efe1a2302f --- /dev/null +++ b/server/src/__tests__/issue-chat-bootstrap-routes.test.ts @@ -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(); + 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(); + }); +}); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 59fe094d2ed..b019d01c10b 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -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`${heartbeatRuns.contextSnapshot} -> 'queuedCommentSourceWakeIds'`.as("queuedCommentSourceWakeIds"), - status: heartbeatRuns.status, - invocationSource: heartbeatRuns.invocationSource, - triggerDetail: heartbeatRuns.triggerDetail, - contextCommentId: sql`${heartbeatRuns.contextSnapshot} ->> 'commentId'`.as("contextCommentId"), - contextWakeCommentId: sql`${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; diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 3c118e4a9f2..d377db61f49 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -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[0] & { id: string }>( + issue: T, + time: (phase: "documents" | "work_products", read: () => Promise) => Promise = (_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>>) { + 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) => { diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 9494cf8a321..d5e7e645257 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -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", diff --git a/server/src/services/activity.ts b/server/src/services/activity.ts index b3184891459..36ed18b205e 100644 --- a/server/src/services/activity.ts +++ b/server/src/services/activity.ts @@ -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, diff --git a/server/src/services/issue-chat-bootstrap.ts b/server/src/services/issue-chat-bootstrap.ts new file mode 100644 index 00000000000..bc45f10d26f --- /dev/null +++ b/server/src/services/issue-chat-bootstrap.ts @@ -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(items: readonly T[], concurrency: number, mapper: (item: T) => Promise) { + const results = new Array(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(input: { + companyId: string; + refs: readonly string[]; + resolve: (ref: string) => Promise; + filterReadable: (issues: TIssue[]) => Promise; + read: (issue: TIssue) => Promise; + concurrency?: number; +}): Promise> { + 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(); + 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 }] : []; + }); +} diff --git a/server/src/services/issue-run-presence.ts b/server/src/services/issue-run-presence.ts new file mode 100644 index 00000000000..c1817762b13 --- /dev/null +++ b/server/src/services/issue-run-presence.ts @@ -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, + "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["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`${heartbeatRuns.contextSnapshot} -> 'queuedCommentSourceWakeIds'`.as("queuedCommentSourceWakeIds"), + status: heartbeatRuns.status, + invocationSource: heartbeatRuns.invocationSource, + triggerDetail: heartbeatRuns.triggerDetail, + contextCommentId: sql`${heartbeatRuns.contextSnapshot} ->> 'commentId'`.as("contextCommentId"), + contextWakeCommentId: sql`${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 }; +}