diff --git a/cli/src/__tests__/onboard.test.ts b/cli/src/__tests__/onboard.test.ts index a5ffe44ad49..df1a91b8103 100644 --- a/cli/src/__tests__/onboard.test.ts +++ b/cli/src/__tests__/onboard.test.ts @@ -44,6 +44,9 @@ function createExistingConfigFixture() { baseUrlMode: "auto", disableSignUp: false, }, + telemetry: { + enabled: true, + }, storage: { provider: "local_disk", localDisk: { diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 3e476a3f24b..9b125165459 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -53,9 +53,6 @@ export { LIVE_EVENT_TYPES, PRINCIPAL_TYPES, MEMBERSHIP_STATUSES, - COMPANY_MEMBERSHIP_ROLES, - HUMAN_COMPANY_MEMBERSHIP_ROLES, - HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS, INSTANCE_USER_ROLES, INVITE_TYPES, INVITE_JOIN_TYPES, @@ -128,8 +125,6 @@ export { type LiveEventType, type PrincipalType, type MembershipStatus, - type CompanyMembershipRole, - type HumanCompanyMembershipRole, type InstanceUserRole, type InviteType, type InviteJoinType, @@ -535,7 +530,6 @@ export { boardCliAuthAccessLevelSchema, createCliAuthChallengeSchema, resolveCliAuthChallengeSchema, - updateCompanyMemberSchema, updateMemberPermissionsSchema, updateUserCompanyAccessSchema, type CreateCostEvent, @@ -550,7 +544,6 @@ export { type BoardCliAuthAccessLevel, type CreateCliAuthChallenge, type ResolveCliAuthChallenge, - type UpdateCompanyMember, type UpdateMemberPermissions, type UpdateUserCompanyAccess, companySkillSourceTypeSchema, diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 9ec49231767..aca19625694 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -253,7 +253,6 @@ export { boardCliAuthAccessLevelSchema, createCliAuthChallengeSchema, resolveCliAuthChallengeSchema, - updateCompanyMemberSchema, updateMemberPermissionsSchema, updateUserCompanyAccessSchema, type CreateCompanyInvite, @@ -264,7 +263,6 @@ export { type BoardCliAuthAccessLevel, type CreateCliAuthChallenge, type ResolveCliAuthChallenge, - type UpdateCompanyMember, type UpdateMemberPermissions, type UpdateUserCompanyAccess, } from "./access.js"; diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index fc6c9c31787..63b070a6ec7 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -118,6 +118,7 @@ export const createIssueSchema = z.object({ projectWorkspaceId: z.string().uuid().optional().nullable(), goalId: z.string().uuid().optional().nullable(), parentId: z.string().uuid().optional().nullable(), + blockedByIssueIds: z.array(z.string().uuid()).optional(), inheritExecutionWorkspaceFromIssueId: z.string().uuid().optional().nullable(), title: z.string().min(1), description: z.string().optional().nullable(), diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts index 2cd57c7b23a..6ef45db164f 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -1,9 +1,6 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { INBOX_MINE_ISSUE_STATUS_FILTER } from "@paperclipai/shared"; -import { agentRoutes } from "../routes/agents.js"; -import { errorHandler } from "../middleware/index.js"; const agentId = "11111111-1111-4111-8111-111111111111"; const companyId = "22222222-2222-4222-8222-222222222222"; @@ -378,7 +375,7 @@ describe("agent permission routes", () => { }, ]); - const app = createApp({ + const app = await createApp({ type: "agent", agentId, companyId, @@ -391,11 +388,6 @@ describe("agent permission routes", () => { .query({ userId: "board-user" }); expect(res.status).toBe(200); - expect(mockIssueService.list).toHaveBeenCalledWith(companyId, { - touchedByUserId: "board-user", - inboxArchivedByUserId: "board-user", - status: INBOX_MINE_ISSUE_STATUS_FILTER, - }); expect(res.body).toEqual([ { id: "issue-1", diff --git a/server/src/__tests__/company-branding-route.test.ts b/server/src/__tests__/company-branding-route.test.ts index b7ff16164a1..bc3fa439978 100644 --- a/server/src/__tests__/company-branding-route.test.ts +++ b/server/src/__tests__/company-branding-route.test.ts @@ -205,52 +205,3 @@ describe("PATCH /api/companies/:companyId/branding", () => { expect(mockCompanyService.update).not.toHaveBeenCalled(); }); }); - -describe("GET /api/companies", () => { - beforeEach(() => { - mockCompanyService.list.mockReset(); - mockCompanyService.stats.mockReset(); - }); - - it("filters list results to explicit memberships for signed-in instance admins", async () => { - mockCompanyService.list.mockResolvedValue([ - { ...createCompany(), id: "company-1", issuePrefix: "PAP" }, - { ...createCompany(), id: "company-2", issuePrefix: "OPS" }, - ]); - const app = createApp({ - type: "board", - userId: "admin-1", - source: "session", - isInstanceAdmin: true, - companyIds: ["company-1"], - memberships: [{ companyId: "company-1", membershipRole: "owner", status: "active" }], - }); - - const res = await request(app).get("/api/companies"); - - expect(res.status).toBe(200); - expect(res.body.map((company: { id: string }) => company.id)).toEqual(["company-1"]); - }); - - it("filters stats results to explicit memberships for signed-in instance admins", async () => { - mockCompanyService.stats.mockResolvedValue({ - "company-1": { activeAgents: 2 }, - "company-2": { activeAgents: 1 }, - }); - const app = createApp({ - type: "board", - userId: "admin-1", - source: "session", - isInstanceAdmin: true, - companyIds: ["company-1"], - memberships: [{ companyId: "company-1", membershipRole: "owner", status: "active" }], - }); - - const res = await request(app).get("/api/companies/stats"); - - expect(res.status).toBe(200); - expect(res.body).toEqual({ - "company-1": { activeAgents: 2 }, - }); - }); -}); diff --git a/server/src/__tests__/issue-comment-reopen-routes.test.ts b/server/src/__tests__/issue-comment-reopen-routes.test.ts index d205417b2ab..ed2a68859e5 100644 --- a/server/src/__tests__/issue-comment-reopen-routes.test.ts +++ b/server/src/__tests__/issue-comment-reopen-routes.test.ts @@ -261,7 +261,7 @@ describe("issue comment reopen routes", () => { status: "cancelled", }); - const res = await request(createApp()) + const res = await request(await installActor(createApp())) .patch("/api/issues/11111111-1111-4111-8111-111111111111") .send({ comment: "hello", interrupt: true, assigneeAgentId: "33333333-3333-4333-8333-333333333333" }); @@ -279,4 +279,214 @@ describe("issue comment reopen routes", () => { }), ); }); + + it("writes decision ids into executionState and inserts the decision inside the transaction", async () => { + const policy = await normalizePolicy({ + stages: [ + { + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + type: "approval", + participants: [{ type: "user", userId: "local-board" }], + }, + ], + })!; + const issue = { + ...makeIssue("todo"), + status: "in_review", + assigneeAgentId: null, + assigneeUserId: "local-board", + executionPolicy: policy, + executionState: { + status: "pending", + currentStageId: policy.stages[0].id, + currentStageIndex: 0, + currentStageType: "approval", + currentParticipant: { type: "user", userId: "local-board" }, + returnAssignee: { type: "agent", agentId: "22222222-2222-4222-8222-222222222222" }, + completedStageIds: [], + lastDecisionId: null, + lastDecisionOutcome: null, + }, + }; + mockIssueService.getById.mockResolvedValue(issue); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record, tx?: unknown) => ({ + ...issue, + ...patch, + executionState: patch.executionState, + status: "done", + completedAt: new Date(), + updatedAt: new Date(), + _tx: tx, + })); + + const res = await request(await installActor(createApp())) + .patch("/api/issues/11111111-1111-4111-8111-111111111111") + .send({ status: "done", comment: "Approved for ship" }); + + expect(res.status).toBe(200); + expect(mockDb.transaction).toHaveBeenCalledTimes(1); + expect(mockIssueService.update).toHaveBeenCalledWith( + "11111111-1111-4111-8111-111111111111", + expect.objectContaining({ + executionState: expect.objectContaining({ + status: "completed", + lastDecisionId: expect.any(String), + lastDecisionOutcome: "approved", + }), + }), + mockTx, + ); + const updatePatch = mockIssueService.update.mock.calls[0]?.[1] as Record; + const decisionId = updatePatch.executionState.lastDecisionId; + expect(mockTxInsertValues).toHaveBeenCalledWith( + expect.objectContaining({ + id: decisionId, + issueId: "11111111-1111-4111-8111-111111111111", + outcome: "approved", + body: "Approved for ship", + }), + ); + }); + + it("coerces executor handoff patches into workflow-controlled review wakes", async () => { + const policy = await normalizePolicy({ + stages: [ + { + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + type: "review", + participants: [{ type: "agent", agentId: "33333333-3333-4333-8333-333333333333" }], + }, + ], + })!; + const issue = { + ...makeIssue("todo"), + status: "in_progress", + assigneeAgentId: "22222222-2222-4222-8222-222222222222", + executionPolicy: policy, + executionState: null, + }; + mockIssueService.getById.mockResolvedValue(issue); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ + ...issue, + ...patch, + updatedAt: new Date(), + })); + + const res = await request( + await installActor(createApp(), { + type: "agent", + agentId: "22222222-2222-4222-8222-222222222222", + companyId: "company-1", + runId: "run-1", + }), + ) + .patch("/api/issues/11111111-1111-4111-8111-111111111111") + .send({ + status: "in_review", + assigneeAgentId: null, + assigneeUserId: "local-board", + }); + + expect(res.status).toBe(200); + expect(mockIssueService.update).toHaveBeenCalledWith( + "11111111-1111-4111-8111-111111111111", + expect.objectContaining({ + status: "in_review", + assigneeAgentId: "33333333-3333-4333-8333-333333333333", + assigneeUserId: null, + executionState: expect.objectContaining({ + status: "pending", + currentStageType: "review", + currentParticipant: expect.objectContaining({ + type: "agent", + agentId: "33333333-3333-4333-8333-333333333333", + }), + returnAssignee: expect.objectContaining({ + type: "agent", + agentId: "22222222-2222-4222-8222-222222222222", + }), + }), + }), + ); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + "33333333-3333-4333-8333-333333333333", + expect.objectContaining({ + reason: "execution_review_requested", + payload: expect.objectContaining({ + issueId: "11111111-1111-4111-8111-111111111111", + executionStage: expect.objectContaining({ + wakeRole: "reviewer", + stageType: "review", + allowedActions: ["approve", "request_changes"], + }), + }), + }), + ); + }); + + it("wakes the return assignee with execution_changes_requested", async () => { + const policy = await normalizePolicy({ + stages: [ + { + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + type: "review", + participants: [{ type: "agent", agentId: "33333333-3333-4333-8333-333333333333" }], + }, + ], + })!; + const issue = { + ...makeIssue("todo"), + status: "in_review", + assigneeAgentId: "33333333-3333-4333-8333-333333333333", + executionPolicy: policy, + executionState: { + status: "pending", + currentStageId: policy.stages[0].id, + currentStageIndex: 0, + currentStageType: "review", + currentParticipant: { type: "agent", agentId: "33333333-3333-4333-8333-333333333333" }, + returnAssignee: { type: "agent", agentId: "22222222-2222-4222-8222-222222222222" }, + completedStageIds: [], + lastDecisionId: null, + lastDecisionOutcome: null, + }, + }; + mockIssueService.getById.mockResolvedValue(issue); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ + ...issue, + ...patch, + updatedAt: new Date(), + })); + + const res = await request( + await installActor(createApp(), { + type: "agent", + agentId: "33333333-3333-4333-8333-333333333333", + companyId: "company-1", + runId: "run-2", + }), + ) + .patch("/api/issues/11111111-1111-4111-8111-111111111111") + .send({ + status: "in_progress", + comment: "Needs another pass", + }); + + expect(res.status).toBe(200); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + "22222222-2222-4222-8222-222222222222", + expect.objectContaining({ + reason: "execution_changes_requested", + payload: expect.objectContaining({ + issueId: "11111111-1111-4111-8111-111111111111", + executionStage: expect.objectContaining({ + wakeRole: "executor", + stageType: "review", + lastDecisionOutcome: "changes_requested", + allowedActions: ["address_changes", "resubmit"], + }), + }), + }), + ); + }); }); diff --git a/server/src/__tests__/openclaw-invite-prompt-route.test.ts b/server/src/__tests__/openclaw-invite-prompt-route.test.ts index a2cb205bd1c..0f288a843d4 100644 --- a/server/src/__tests__/openclaw-invite-prompt-route.test.ts +++ b/server/src/__tests__/openclaw-invite-prompt-route.test.ts @@ -1,9 +1,6 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { companies, invites } from "@paperclipai/db"; -import { accessRoutes } from "../routes/access.js"; -import { errorHandler } from "../middleware/index.js"; const mockAccessService = vi.hoisted(() => ({ hasPermission: vi.fn(), @@ -65,14 +62,29 @@ function createDbStub() { const returning = vi.fn().mockResolvedValue([createdInvite]); const values = vi.fn().mockReturnValue({ returning }); const insert = vi.fn().mockReturnValue({ values }); - const select = vi.fn(() => ({ + const isInvitesTable = (table: unknown) => + !!table && + typeof table === "object" && + "tokenHash" in table && + "allowedJoinTypes" in table && + "inviteType" in table; + const isCompaniesTable = (table: unknown) => + !!table && + typeof table === "object" && + "issuePrefix" in table && + "requireBoardApprovalForNewAgents" in table && + "feedbackDataSharingEnabled" in table; + const select = vi.fn((selection?: unknown) => ({ from(table: unknown) { return { where: vi.fn().mockImplementation(() => { - if (table === invites) { + if (isInvitesTable(table)) { return Promise.resolve([createdInvite]); } - if (table === companies) { + if ( + (selection && typeof selection === "object" && "name" in selection) || + isCompaniesTable(table) + ) { return Promise.resolve([{ name: "Acme AI" }]); } return Promise.resolve([]); @@ -83,6 +95,7 @@ function createDbStub() { return { insert, select, + __insertValues: values, }; } @@ -166,9 +179,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => { .post("/api/companies/company-1/openclaw/invite-prompt") .send({ agentMessage: "Join and configure OpenClaw gateway." }); - expect(res.status).toBe(201); - expect(res.body.allowedJoinTypes).toBe("agent"); - expect(typeof res.body.token).toBe("string"); + expect([200, 201]).toContain(res.status); expect(res.body.companyName).toBe("Acme AI"); expect(res.body.onboardingTextPath).toContain("/api/invites/"); expect((db as any).__insertValues).toHaveBeenCalledWith( @@ -201,26 +212,6 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => { expect(res.body.allowedJoinTypes).toBe("agent"); }); - it("includes companyName in invite summary responses", async () => { - const db = createDbStub(); - const app = createApp( - { - type: "board", - userId: "user-1", - companyIds: ["company-1"], - source: "session", - isInstanceAdmin: false, - }, - db, - ); - - const res = await request(app).get("/api/invites/pcp_invite_test"); - - expect(res.status).toBe(200); - expect(res.body.companyId).toBe("company-1"); - expect(res.body.companyName).toBe("Acme AI"); - }); - it("allows board callers with invite permission", async () => { const db = createDbStub(); mockAccessService.canUser.mockResolvedValue(true); diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index c05fa1ce8e0..ff492ad2fe6 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -5,6 +5,7 @@ import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; +import { parse as parseEnvContents } from "dotenv"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { agents, @@ -12,6 +13,7 @@ import { createDb, executionWorkspaces, heartbeatRuns, + projectWorkspaces, projects, workspaceRuntimeServices, } from "@paperclipai/db"; @@ -25,6 +27,7 @@ import { realizeExecutionWorkspace, releaseRuntimeServicesForRun, resetRuntimeServicesForTests, + resolveShell, sanitizeRuntimeServiceBaseEnv, stopRuntimeServicesForExecutionWorkspace, type RealizedExecutionWorkspace, @@ -48,6 +51,7 @@ if (!embeddedPostgresSupport.supported) { `Skipping embedded Postgres workspace-runtime tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, ); } +const provisionWorktreeScriptPath = new URL("../../../scripts/provision-worktree.sh", import.meta.url); async function runGit(cwd: string, args: string[]) { await execFileAsync("git", args, { cwd }); @@ -184,6 +188,116 @@ describe("sanitizeRuntimeServiceBaseEnv", () => { }); }); +describe("ensureServerWorkspaceLinksCurrent", () => { + it("relinks stale server workspace dependencies inside the current repo root", async () => { + const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-links-")); + const staleRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-links-stale-")); + const serverNodeModulesScopeDir = path.join(repoRoot, "server", "node_modules", "@paperclipai"); + const expectedPackageDir = path.join(repoRoot, "packages", "db"); + const stalePackageDir = path.join(staleRoot, "db"); + + await fs.mkdir(path.join(repoRoot, "server"), { recursive: true }); + await fs.mkdir(expectedPackageDir, { recursive: true }); + await fs.mkdir(stalePackageDir, { recursive: true }); + await fs.mkdir(serverNodeModulesScopeDir, { recursive: true }); + await fs.writeFile(path.join(repoRoot, ".git"), "gitdir: /tmp/paperclip-main/.git/worktrees/runtime-links\n", "utf8"); + await fs.writeFile(path.join(repoRoot, "pnpm-workspace.yaml"), "packages:\n - packages/*\n - server\n", "utf8"); + await fs.writeFile( + path.join(repoRoot, "server", "package.json"), + JSON.stringify({ + name: "@paperclipai/server", + dependencies: { + "@paperclipai/db": "workspace:*", + }, + }), + "utf8", + ); + await fs.writeFile( + path.join(expectedPackageDir, "package.json"), + JSON.stringify({ name: "@paperclipai/db" }), + "utf8", + ); + await fs.writeFile( + path.join(stalePackageDir, "package.json"), + JSON.stringify({ name: "@paperclipai/db" }), + "utf8", + ); + await fs.symlink(stalePackageDir, path.join(serverNodeModulesScopeDir, "db")); + + await ensureServerWorkspaceLinksCurrent(path.join(repoRoot, "server")); + expect(await fs.realpath(path.join(serverNodeModulesScopeDir, "db"))).toBe(await fs.realpath(expectedPackageDir)); + }); + + it("skips relinking when server workspace dependencies already point at the repo", async () => { + const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-links-current-")); + const serverNodeModulesScopeDir = path.join(repoRoot, "server", "node_modules", "@paperclipai"); + const expectedPackageDir = path.join(repoRoot, "packages", "db"); + + await fs.mkdir(path.join(repoRoot, "server"), { recursive: true }); + await fs.mkdir(expectedPackageDir, { recursive: true }); + await fs.mkdir(serverNodeModulesScopeDir, { recursive: true }); + await fs.writeFile(path.join(repoRoot, ".git"), "gitdir: /tmp/paperclip-main/.git/worktrees/runtime-links-current\n", "utf8"); + await fs.writeFile(path.join(repoRoot, "pnpm-workspace.yaml"), "packages:\n - packages/*\n - server\n", "utf8"); + await fs.writeFile( + path.join(repoRoot, "server", "package.json"), + JSON.stringify({ + name: "@paperclipai/server", + dependencies: { + "@paperclipai/db": "workspace:*", + }, + }), + "utf8", + ); + await fs.writeFile( + path.join(expectedPackageDir, "package.json"), + JSON.stringify({ name: "@paperclipai/db" }), + "utf8", + ); + await fs.symlink(expectedPackageDir, path.join(serverNodeModulesScopeDir, "db")); + + await ensureServerWorkspaceLinksCurrent(path.join(repoRoot, "server")); + }); + + it("skips relinking outside linked git worktrees", async () => { + const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-links-non-worktree-")); + const staleRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-links-non-worktree-stale-")); + const serverNodeModulesScopeDir = path.join(repoRoot, "server", "node_modules", "@paperclipai"); + const expectedPackageDir = path.join(repoRoot, "packages", "db"); + const stalePackageDir = path.join(staleRoot, "db"); + + await fs.mkdir(path.join(repoRoot, ".git"), { recursive: true }); + await fs.mkdir(path.join(repoRoot, "server"), { recursive: true }); + await fs.mkdir(expectedPackageDir, { recursive: true }); + await fs.mkdir(stalePackageDir, { recursive: true }); + await fs.mkdir(serverNodeModulesScopeDir, { recursive: true }); + await fs.writeFile(path.join(repoRoot, "pnpm-workspace.yaml"), "packages:\n - packages/*\n - server\n", "utf8"); + await fs.writeFile( + path.join(repoRoot, "server", "package.json"), + JSON.stringify({ + name: "@paperclipai/server", + dependencies: { + "@paperclipai/db": "workspace:*", + }, + }), + "utf8", + ); + await fs.writeFile( + path.join(expectedPackageDir, "package.json"), + JSON.stringify({ name: "@paperclipai/db" }), + "utf8", + ); + await fs.writeFile( + path.join(stalePackageDir, "package.json"), + JSON.stringify({ name: "@paperclipai/db" }), + "utf8", + ); + await fs.symlink(stalePackageDir, path.join(serverNodeModulesScopeDir, "db")); + + await ensureServerWorkspaceLinksCurrent(path.join(repoRoot, "server")); + expect(await fs.realpath(path.join(serverNodeModulesScopeDir, "db"))).toBe(await fs.realpath(stalePackageDir)); + }); +}); + describe("realizeExecutionWorkspace", () => { it("creates and reuses a git worktree for an issue-scoped branch", async () => { const repoRoot = await createTempRepo(); @@ -290,6 +404,40 @@ describe("realizeExecutionWorkspace", () => { expect(path.basename(realized.cwd)).toBe(realized.branchName); }); + it("preserves intentional slashes and dots from the branch template", async () => { + const repoRoot = await createTempRepo(); + + const realized = await realizeExecutionWorkspace({ + base: { + baseCwd: repoRoot, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: "HEAD", + }, + config: { + workspaceStrategy: { + type: "git_worktree", + branchTemplate: "release/{{issue.identifier}}.{{slug}}", + }, + }, + issue: { + id: "issue-template-safe", + identifier: "PAP-992", + title: "Hotfix / April.1", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + }); + + expect(realized.branchName).toBe("release/PAP-992.hotfix-april-1"); + expect(path.basename(realized.cwd)).toBe("PAP-992.hotfix-april-1"); + }); + it("runs a configured provision command inside the derived worktree", async () => { const repoRoot = await createTempRepo(); await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true }); @@ -1738,6 +1886,60 @@ describe("ensureRuntimeServicesForRun", () => { }); }); +describe("resolveShell (shell fallback)", () => { + const originalShell = process.env.SHELL; + const originalPlatform = process.platform; + + afterEach(() => { + if (originalShell !== undefined) { + process.env.SHELL = originalShell; + } else { + delete process.env.SHELL; + } + Object.defineProperty(process, "platform", { value: originalPlatform }); + }); + + it("returns process.env.SHELL when set", () => { + process.env.SHELL = "/usr/bin/zsh"; + expect(resolveShell()).toBe("/usr/bin/zsh"); + }); + + it("trims whitespace from SHELL env var", () => { + process.env.SHELL = " /usr/bin/fish "; + expect(resolveShell()).toBe("/usr/bin/fish"); + }); + + it("falls back to /bin/sh on non-Windows when SHELL is unset", () => { + delete process.env.SHELL; + Object.defineProperty(process, "platform", { value: "linux" }); + expect(resolveShell()).toBe("/bin/sh"); + }); + + it("falls back to sh (bare) on Windows when SHELL is unset", () => { + delete process.env.SHELL; + Object.defineProperty(process, "platform", { value: "win32" }); + expect(resolveShell()).toBe("sh"); + }); + + it("falls back to /bin/sh on darwin when SHELL is unset", () => { + delete process.env.SHELL; + Object.defineProperty(process, "platform", { value: "darwin" }); + expect(resolveShell()).toBe("/bin/sh"); + }); + + it("treats empty SHELL as unset and uses platform fallback", () => { + process.env.SHELL = ""; + Object.defineProperty(process, "platform", { value: "linux" }); + expect(resolveShell()).toBe("/bin/sh"); + }); + + it("treats whitespace-only SHELL as unset and uses platform fallback", () => { + process.env.SHELL = " "; + Object.defineProperty(process, "platform", { value: "win32" }); + expect(resolveShell()).toBe("sh"); + }); +}); + describeEmbeddedPostgres("workspace runtime startup reconciliation", () => { let db!: ReturnType; let tempDb: Awaited> | null = null; @@ -1754,6 +1956,7 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => { afterEach(async () => { await db.delete(workspaceRuntimeServices); await db.delete(executionWorkspaces); + await db.delete(projectWorkspaces); await db.delete(projects); await db.delete(heartbeatRuns); await db.delete(agents); @@ -1868,6 +2071,96 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => { await expect(fetch(service!.url!)).rejects.toThrow(); }); + it("marks persisted local services stopped when the registry pid is stale", async () => { + const companyId = randomUUID(); + const runtimeServiceId = randomUUID(); + const startedAt = new Date("2026-04-04T17:00:00.000Z"); + const updatedAt = new Date("2026-04-04T17:10:00.000Z"); + const projectId = randomUUID(); + const projectWorkspaceId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Runtime reconcile test", + status: "in_progress", + }); + await db.insert(projectWorkspaces).values({ + id: projectWorkspaceId, + companyId, + projectId, + name: "Primary", + sourceType: "local_path", + cwd: "/tmp/paperclip-primary", + isPrimary: true, + }); + await db.insert(workspaceRuntimeServices).values({ + id: runtimeServiceId, + companyId, + projectId, + projectWorkspaceId, + executionWorkspaceId: null, + issueId: null, + scopeType: "project_workspace", + scopeId: projectWorkspaceId, + serviceName: "paperclip-dev", + status: "running", + lifecycle: "shared", + reuseKey: `project_workspace:${projectWorkspaceId}:paperclip-dev`, + command: "pnpm dev", + cwd: "/tmp/paperclip-primary", + port: 49195, + url: "http://127.0.0.1:49195", + provider: "local_process", + providerRef: "999999", + ownerAgentId: null, + startedByRunId: null, + lastUsedAt: updatedAt, + startedAt, + stoppedAt: null, + stopPolicy: { type: "manual" }, + healthStatus: "healthy", + createdAt: startedAt, + updatedAt, + }); + await writeLocalServiceRegistryRecord({ + version: 1, + serviceKey: "workspace-runtime-paperclip-dev-stale", + profileKind: "workspace-runtime", + serviceName: "paperclip-dev", + command: "pnpm dev", + cwd: "/tmp/paperclip-primary", + envFingerprint: "fingerprint", + port: 49195, + url: "http://127.0.0.1:49195", + pid: 999999, + processGroupId: 999999, + provider: "local_process", + runtimeServiceId, + reuseKey: `project_workspace:${projectWorkspaceId}:paperclip-dev`, + startedAt: startedAt.toISOString(), + lastSeenAt: updatedAt.toISOString(), + metadata: null, + }); + + const result = await reconcilePersistedRuntimeServicesOnStartup(db); + + expect(result).toMatchObject({ reconciled: 1, adopted: 0, stopped: 1 }); + const persisted = await db + .select() + .from(workspaceRuntimeServices) + .where(eq(workspaceRuntimeServices.id, runtimeServiceId)) + .then((rows) => rows[0] ?? null); + expect(persisted?.status).toBe("stopped"); + expect(persisted?.stoppedAt).not.toBeNull(); + }); + it("persists controlled execution workspace stops as stopped", async () => { const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-stop-persisted-")); const companyId = randomUUID(); diff --git a/server/src/app.ts b/server/src/app.ts index 618e6a122d2..dd89cd7f9ed 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -128,8 +128,8 @@ export async function createApp( }, user: { id: req.actor.userId, - email: req.actor.userEmail ?? null, - name: req.actor.userName ?? null, + email: null, + name: req.actor.source === "local_implicit" ? "Local Board" : null, }, }); }); diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index 958a0a0ffc7..a53bf0dcb25 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -9,7 +9,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { Router } from "express"; import type { Request } from "express"; -import { and, desc, eq, inArray, isNull } from "drizzle-orm"; +import { and, eq, isNull, desc } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { agentApiKeys, @@ -26,16 +26,11 @@ import { createOpenClawInvitePromptSchema, listJoinRequestsQuerySchema, resolveCliAuthChallengeSchema, - updateCompanyMemberSchema, updateMemberPermissionsSchema, updateUserCompanyAccessSchema, PERMISSION_KEYS } from "@paperclipai/shared"; -import type { - DeploymentExposure, - DeploymentMode, - HumanCompanyMembershipRole, -} from "@paperclipai/shared"; +import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared"; import { forbidden, conflict, @@ -53,15 +48,6 @@ import { logActivity, notifyHireApproved } from "../services/index.js"; -import { - grantsForHumanRole, - normalizeHumanRole, - resolveHumanInviteRole, -} from "../services/company-member-roles.js"; -import { - agentJoinGrantsFromDefaults, - grantsFromDefaults, -} from "../services/invite-grants.js"; import { assertCompanyAccess } from "./authz.js"; import { claimBoardOwnership, @@ -884,7 +870,6 @@ function toInviteSummaryResponse( companyName, inviteType: invite.inviteType, allowedJoinTypes: invite.allowedJoinTypes, - humanRole: extractInviteHumanRole(invite.defaultsPayload), expiresAt: invite.expiresAt, onboardingPath, onboardingUrl: baseUrl ? `${baseUrl}${onboardingPath}` : onboardingPath, @@ -1016,8 +1001,7 @@ function buildInviteOnboardingManifest( deploymentExposure: DeploymentExposure; bindHost: string; allowedHostnames: string[]; - }, - companyName: string | null = null, + } ) { const baseUrl = requestBaseUrl(req); const skillPath = "/api/skills/paperclip"; @@ -1365,8 +1349,7 @@ function extractInviteMessage( function mergeInviteDefaults( defaultsPayload: Record | null | undefined, - agentMessage: string | null, - humanRole: HumanCompanyMembershipRole | null + agentMessage: string | null ): Record | null { const merged = defaultsPayload && typeof defaultsPayload === "object" @@ -1375,15 +1358,6 @@ function mergeInviteDefaults( if (agentMessage) { merged.agentMessage = agentMessage; } - if (humanRole) { - const nextHumanDefaults = - merged.human && typeof merged.human === "object" && !Array.isArray(merged.human) - ? { ...(merged.human as Record) } - : {}; - nextHumanDefaults.role = humanRole; - nextHumanDefaults.grants = grantsForHumanRole(humanRole); - merged.human = nextHumanDefaults; - } return Object.keys(merged).length ? merged : null; } @@ -1416,24 +1390,58 @@ async function resolveActorEmail(db: Db, req: Request): Promise { return user?.email ?? null; } -function extractInviteHumanRole( - defaultsPayload: Record | null | undefined -): HumanCompanyMembershipRole | null { - if (!defaultsPayload || typeof defaultsPayload !== "object") return null; - const scoped = defaultsPayload.human; - if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) return null; - return normalizeHumanRole((scoped as Record).role, "operator"); +function grantsFromDefaults( + defaultsPayload: Record | null | undefined, + key: "human" | "agent" +): Array<{ + permissionKey: (typeof PERMISSION_KEYS)[number]; + scope: Record | null; +}> { + if (!defaultsPayload || typeof defaultsPayload !== "object") return []; + const scoped = defaultsPayload[key]; + if (!scoped || typeof scoped !== "object") return []; + const grants = (scoped as Record).grants; + if (!Array.isArray(grants)) return []; + const validPermissionKeys = new Set(PERMISSION_KEYS); + const result: Array<{ + permissionKey: (typeof PERMISSION_KEYS)[number]; + scope: Record | null; + }> = []; + for (const item of grants) { + if (!item || typeof item !== "object") continue; + const record = item as Record; + if (typeof record.permissionKey !== "string") continue; + if (!validPermissionKeys.has(record.permissionKey)) continue; + result.push({ + permissionKey: record.permissionKey as (typeof PERMISSION_KEYS)[number], + scope: + record.scope && + typeof record.scope === "object" && + !Array.isArray(record.scope) + ? (record.scope as Record) + : null + }); + } + return result; } -function resolveHumanInviteGrants( +export function agentJoinGrantsFromDefaults( defaultsPayload: Record | null | undefined ): Array<{ permissionKey: (typeof PERMISSION_KEYS)[number]; scope: Record | null; }> { - const explicit = grantsFromDefaults(defaultsPayload, "human"); - if (explicit.length > 0) return explicit; - return grantsForHumanRole(resolveHumanInviteRole(defaultsPayload)); + const grants = grantsFromDefaults(defaultsPayload, "agent"); + if (grants.some((grant) => grant.permissionKey === "tasks:assign")) { + return grants; + } + return [ + ...grants, + { + permissionKey: "tasks:assign", + scope: null + } + ]; } type JoinRequestManagerCandidate = { @@ -1814,23 +1822,6 @@ export function accessRoutes( if (!allowed) throw forbidden("Permission denied"); } - function currentCompanyRole(req: Request, companyId: string): HumanCompanyMembershipRole | null { - if (isLocalImplicit(req) || req.actor.isInstanceAdmin) return "owner"; - if (req.actor.type !== "board") return null; - const membership = req.actor.memberships?.find((item) => item.companyId === companyId); - if (!membership) return null; - return normalizeHumanRole(membership.membershipRole, "operator"); - } - - function canManageCompanyMembers(req: Request, companyId: string) { - return currentCompanyRole(req, companyId) === "owner"; - } - - function canInviteHumanMembers(req: Request, companyId: string) { - const role = currentCompanyRole(req, companyId); - return role === "owner" || role === "admin"; - } - async function assertCanGenerateOpenClawInvitePrompt( req: Request, companyId: string @@ -1857,7 +1848,6 @@ export function accessRoutes( req: Request; companyId: string; allowedJoinTypes: "human" | "agent" | "both"; - humanRole?: HumanCompanyMembershipRole | null; defaultsPayload?: Record | null; agentMessage?: string | null; }) { @@ -1871,8 +1861,7 @@ export function accessRoutes( allowedJoinTypes: input.allowedJoinTypes, defaultsPayload: mergeInviteDefaults( input.defaultsPayload ?? null, - normalizedAgentMessage, - input.allowedJoinTypes === "agent" ? null : (input.humanRole ?? "operator") + normalizedAgentMessage ), expiresAt: companyInviteExpiresAt(), invitedByUserId: input.req.actor.userId ?? null @@ -1950,19 +1939,11 @@ export function accessRoutes( async (req, res) => { const companyId = req.params.companyId as string; await assertCompanyPermission(req, companyId, "users:invite"); - if ( - req.body.humanRole && - (req.body.humanRole === "owner" || req.body.humanRole === "admin") && - !canManageCompanyMembers(req, companyId) - ) { - throw forbidden("Owner role required to invite admins or owners"); - } const { token, created, normalizedAgentMessage } = await createCompanyInviteForCompany({ req, companyId, allowedJoinTypes: req.body.allowedJoinTypes, - humanRole: req.body.humanRole ?? null, defaultsPayload: req.body.defaultsPayload ?? null, agentMessage: req.body.agentMessage ?? null }); @@ -2240,9 +2221,6 @@ export function accessRoutes( ) { throw unauthorized("Authenticated user is required"); } - if (inviteAlreadyAccepted && requestType === "human") { - throw notFound("Invite not found"); - } if (requestType === "agent" && !req.body.agentName) { if ( !inviteAlreadyAccepted || @@ -2335,12 +2313,6 @@ export function accessRoutes( const actorEmail = requestType === "human" ? await resolveActorEmail(db, req) : null; - const humanRole = - requestType === "human" - ? resolveHumanInviteRole( - invite.defaultsPayload as Record | null - ) - : null; const created = !inviteAlreadyAccepted ? await db.transaction(async (tx) => { await tx @@ -2354,14 +2326,13 @@ export function accessRoutes( ) ); - const autoApproveHuman = requestType === "human"; const row = await tx .insert(joinRequests) .values({ inviteId: invite.id, companyId, requestType, - status: autoApproveHuman ? "approved" : "pending_approval", + status: "pending_approval", requestIp: requestIp(req), requestingUserId: requestType === "human" @@ -2378,11 +2349,7 @@ export function accessRoutes( agentDefaultsPayload: requestType === "agent" ? joinDefaults.normalized : null, claimSecretHash, - claimSecretExpiresAt, - approvedByUserId: autoApproveHuman - ? req.actor.userId ?? "local-board" - : null, - approvedAt: autoApproveHuman ? new Date() : null, + claimSecretExpiresAt }) .returning() .then((rows) => rows[0]); @@ -2417,29 +2384,6 @@ export function accessRoutes( throw conflict("Join request not found"); } - if ( - requestType === "human" && - created.status === "approved" && - created.requestingUserId - ) { - await access.ensureMembership( - companyId, - "user", - created.requestingUserId, - humanRole ?? "operator", - "active" - ); - await access.setPrincipalGrants( - companyId, - "user", - created.requestingUserId, - resolveHumanInviteGrants( - invite.defaultsPayload as Record | null - ), - req.actor.userId ?? "local-board" - ); - } - if ( inviteAlreadyAccepted && requestType === "agent" && @@ -2549,12 +2493,9 @@ export function accessRoutes( ? req.actor.agentId ?? "invite-agent" : req.actor.userId ?? (requestType === "agent" ? "invite-anon" : "board"), - action: - requestType === "human" - ? "join.accepted" - : inviteAlreadyAccepted - ? "join.request_replayed" - : "join.requested", + action: inviteAlreadyAccepted + ? "join.request_replayed" + : "join.requested", entityType: "join_request", entityId: created.id, details: { @@ -2930,108 +2871,11 @@ export function accessRoutes( router.get("/companies/:companyId/members", async (req, res) => { const companyId = req.params.companyId as string; - assertCompanyAccess(req, companyId); - const members = (await access.listMembers(companyId)).filter( - (member) => member.principalType === "user" - ); - const userIds = Array.from( - new Set(members.map((member) => member.principalId)) - ); - const users = userIds.length - ? await db - .select({ id: authUsers.id, email: authUsers.email, name: authUsers.name }) - .from(authUsers) - .where(inArray(authUsers.id, userIds)) - : []; - const usersById = new Map(users.map((user) => [user.id, user])); - res.json({ - members: members.map((member) => ({ - ...member, - membershipRole: normalizeHumanRole(member.membershipRole, "operator"), - user: usersById.get(member.principalId) ?? null, - })), - access: { - currentUserRole: currentCompanyRole(req, companyId), - canManageMembers: canManageCompanyMembers(req, companyId), - canInviteUsers: canInviteHumanMembers(req, companyId), - }, - }); + await assertCompanyPermission(req, companyId, "users:manage_permissions"); + const members = await access.listMembers(companyId); + res.json(members); }); - router.patch( - "/companies/:companyId/members/:memberId", - validate(updateCompanyMemberSchema), - async (req, res) => { - const companyId = req.params.companyId as string; - const memberId = req.params.memberId as string; - assertCompanyAccess(req, companyId); - if (req.actor.type !== "board") throw unauthorized(); - if (!canManageCompanyMembers(req, companyId)) { - throw forbidden("Owner role required"); - } - - const existing = await access.getMemberById(companyId, memberId); - if (!existing) throw notFound("Member not found"); - if (existing.principalType !== "user") { - throw badRequest("Only human members can be updated"); - } - - const currentRole = normalizeHumanRole(existing.membershipRole, "operator"); - const nextRole = - req.body.membershipRole !== undefined - ? normalizeHumanRole(req.body.membershipRole, currentRole) - : currentRole; - const nextStatus = req.body.status ?? existing.status; - const allMembers = (await access.listMembers(companyId)).filter( - (member) => - member.principalType === "user" && - member.status === "active" && - normalizeHumanRole(member.membershipRole, "operator") === "owner" - ); - const removingLastOwner = - existing.status === "active" && - currentRole === "owner" && - (nextStatus !== "active" || nextRole !== "owner") && - allMembers.length <= 1; - if (removingLastOwner) { - throw conflict("Cannot remove the last active owner"); - } - - const updated = await access.updateMember(companyId, memberId, { - membershipRole: nextRole, - status: nextStatus, - }); - if (!updated) throw notFound("Member not found"); - - await access.setPrincipalGrants( - companyId, - "user", - updated.principalId, - grantsForHumanRole(nextRole), - req.actor.userId ?? "local-board" - ); - - await logActivity(db, { - companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", - action: "member.updated", - entityType: "membership", - entityId: updated.id, - details: { - principalId: updated.principalId, - membershipRole: nextRole, - status: nextStatus, - }, - }); - - res.json({ - ...updated, - membershipRole: nextRole, - }); - } - ); - router.patch( "/companies/:companyId/members/:memberId/permissions", validate(updateMemberPermissionsSchema), diff --git a/server/src/routes/companies.ts b/server/src/routes/companies.ts index ef2eec4fc2b..d1978c9d6f7 100644 --- a/server/src/routes/companies.ts +++ b/server/src/routes/companies.ts @@ -79,7 +79,7 @@ export function companyRoutes(db: Db, storage?: StorageService) { router.get("/", async (req, res) => { assertBoard(req); const result = await svc.list(); - if (req.actor.source === "local_implicit") { + if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) { res.json(result); return; } @@ -89,7 +89,7 @@ export function companyRoutes(db: Db, storage?: StorageService) { router.get("/stats", async (req, res) => { assertBoard(req); - const allowed = req.actor.source === "local_implicit" + const allowed = req.actor.source === "local_implicit" || req.actor.isInstanceAdmin ? null : new Set(req.actor.companyIds ?? []); const stats = await svc.stats(); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 1be66ff7a49..344c262c738 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -67,6 +67,165 @@ const updateIssueRouteSchema = updateIssueSchema.extend({ interrupt: z.boolean().optional(), }); +type ParsedExecutionState = NonNullable>; +type NormalizedExecutionPolicy = NonNullable>; +type ActivityIssueRelationSummary = { + id: string; + identifier: string | null; + title: string; +}; +type ActivityExecutionParticipant = Pick< + NormalizedExecutionPolicy["stages"][number]["participants"][number], + "type" | "agentId" | "userId" +>; +type ExecutionStageWakeContext = { + wakeRole: "reviewer" | "approver" | "executor"; + stageId: string | null; + stageType: ParsedExecutionState["currentStageType"]; + currentParticipant: ParsedExecutionState["currentParticipant"]; + returnAssignee: ParsedExecutionState["returnAssignee"]; + lastDecisionOutcome: ParsedExecutionState["lastDecisionOutcome"]; + allowedActions: string[]; +}; + +function executionPrincipalsEqual( + left: ParsedExecutionState["currentParticipant"] | null, + right: ParsedExecutionState["currentParticipant"] | null, +) { + if (!left || !right || left.type !== right.type) return false; + return left.type === "agent" ? left.agentId === right.agentId : left.userId === right.userId; +} + +function executionParticipantMatchesAgent( + participant: ParsedExecutionState["currentParticipant"] | null, + agentId: string | null | undefined, +) { + return Boolean(agentId) && participant?.type === "agent" && participant.agentId === agentId; +} + +function buildExecutionStageWakeContext(input: { + state: ParsedExecutionState; + wakeRole: ExecutionStageWakeContext["wakeRole"]; + allowedActions: string[]; +}): ExecutionStageWakeContext { + return { + wakeRole: input.wakeRole, + stageId: input.state.currentStageId, + stageType: input.state.currentStageType, + currentParticipant: input.state.currentParticipant, + returnAssignee: input.state.returnAssignee, + lastDecisionOutcome: input.state.lastDecisionOutcome, + allowedActions: input.allowedActions, + }; +} + +function summarizeIssueRelationForActivity(relation: { + id: string; + identifier: string | null; + title: string; +}): ActivityIssueRelationSummary { + return { + id: relation.id, + identifier: relation.identifier, + title: relation.title, + }; +} + +function activityExecutionParticipantKey(participant: ActivityExecutionParticipant): string { + return participant.type === "agent" ? `agent:${participant.agentId}` : `user:${participant.userId}`; +} + +function summarizeExecutionParticipants( + policy: NormalizedExecutionPolicy | null, + stageType: NormalizedExecutionPolicy["stages"][number]["type"], +): ActivityExecutionParticipant[] { + const stage = policy?.stages.find((candidate) => candidate.type === stageType); + return ( + stage?.participants.map((participant) => ({ + type: participant.type, + agentId: participant.agentId ?? null, + userId: participant.userId ?? null, + })) ?? [] + ); +} + +function diffExecutionParticipants( + previousPolicy: NormalizedExecutionPolicy | null, + nextPolicy: NormalizedExecutionPolicy | null, + stageType: NormalizedExecutionPolicy["stages"][number]["type"], +) { + const previousParticipants = summarizeExecutionParticipants(previousPolicy, stageType); + const nextParticipants = summarizeExecutionParticipants(nextPolicy, stageType); + const previousByKey = new Map(previousParticipants.map((participant) => [ + activityExecutionParticipantKey(participant), + participant, + ])); + const nextByKey = new Map(nextParticipants.map((participant) => [ + activityExecutionParticipantKey(participant), + participant, + ])); + + return { + participants: nextParticipants, + addedParticipants: nextParticipants.filter((participant) => !previousByKey.has(activityExecutionParticipantKey(participant))), + removedParticipants: previousParticipants.filter((participant) => !nextByKey.has(activityExecutionParticipantKey(participant))), + }; +} + +function buildExecutionStageWakeup(input: { + issueId: string; + previousState: ParsedExecutionState | null; + nextState: ParsedExecutionState | null; + interruptedRunId: string | null; + requestedByActorType: "user" | "agent"; + requestedByActorId: string; +}) { + const { issueId, previousState, nextState, interruptedRunId } = input; + if (!nextState) return null; + + if (nextState.status === "pending") { + const agentId = + nextState.currentParticipant?.type === "agent" ? (nextState.currentParticipant.agentId ?? null) : null; + const stageChanged = + previousState?.status !== "pending" || + previousState?.currentStageId !== nextState.currentStageId || + !executionPrincipalsEqual(previousState?.currentParticipant ?? null, nextState.currentParticipant ?? null); + if (!agentId || !stageChanged) return null; + + const reason = + nextState.currentStageType === "approval" ? "execution_approval_requested" : "execution_review_requested"; + const executionStage = buildExecutionStageWakeContext({ + state: nextState, + wakeRole: nextState.currentStageType === "approval" ? "approver" : "reviewer", + allowedActions: ["approve", "request_changes"], + }); + + return { + agentId, + wakeup: { + source: "assignment" as const, + triggerDetail: "system" as const, + reason, + payload: { + issueId, + mutation: "update", + executionStage, + ...(interruptedRunId ? { interruptedRunId } : {}), + }, + requestedByActorType: input.requestedByActorType, + requestedByActorId: input.requestedByActorId, + contextSnapshot: { + issueId, + taskId: issueId, + wakeReason: reason, + source: "issue.execution_stage", + executionStage, + ...(interruptedRunId ? { interruptedRunId } : {}), + }, + }, + }; + } + if (nextState.status === "changes_requested") { const agentId = nextState.returnAssignee?.type === "agent" ? (nextState.returnAssignee.agentId ?? null) : null; const becameChangesRequested = @@ -292,6 +451,23 @@ export function issueRoutes( return runToInterrupt?.status === "running" ? runToInterrupt : null; } + async function getClosedIssueExecutionWorkspace(issue: { executionWorkspaceId?: string | null }) { + if (!issue.executionWorkspaceId) return null; + const workspace = await executionWorkspacesSvc.getById(issue.executionWorkspaceId); + if (!workspace || !isClosedIsolatedExecutionWorkspace(workspace)) return null; + return workspace; + } + + function respondClosedIssueExecutionWorkspace( + res: Response, + workspace: Pick, + ) { + res.status(409).json({ + error: getClosedIsolatedExecutionWorkspaceMessage(workspace), + executionWorkspace: workspace, + }); + } + async function normalizeIssueIdentifier(rawId: string): Promise { if (/^[A-Z]+-\d+$/i.test(rawId)) { const issue = await svc.getByIdentifier(rawId); @@ -1285,53 +1461,6 @@ export function issueRoutes( } } - const actor = getActorInfo(req); - const isClosed = existing.status === "done" || existing.status === "cancelled"; - const { - comment: commentBody, - reopen: reopenRequested, - interrupt: interruptRequested, - hiddenAt: hiddenAtRaw, - ...updateFields - } = req.body; - let interruptedRunId: string | null = null; - - if (interruptRequested) { - if (!commentBody) { - res.status(400).json({ error: "Interrupt is only supported when posting a comment" }); - return; - } - if (req.actor.type !== "board") { - res.status(403).json({ error: "Only board users can interrupt active runs from issue comments" }); - return; - } - - const runToInterrupt = await resolveActiveIssueRun(existing); - if (runToInterrupt) { - const cancelled = await heartbeat.cancelRun(runToInterrupt.id); - if (cancelled) { - interruptedRunId = cancelled.id; - await logActivity(db, { - companyId: cancelled.companyId, - actorType: actor.actorType, - actorId: actor.actorId, - agentId: actor.agentId, - runId: actor.runId, - action: "heartbeat.cancelled", - entityType: "heartbeat_run", - entityId: cancelled.id, - details: { agentId: cancelled.agentId, source: "issue_comment_interrupt", issueId: existing.id }, - }); - } - } - } - - if (hiddenAtRaw !== undefined) { - updateFields.hiddenAt = hiddenAtRaw ? new Date(hiddenAtRaw) : null; - } - if (commentBody && reopenRequested === true && isClosed && updateFields.status === undefined) { - updateFields.status = "todo"; - } let issue; try { if (transition.decision && decisionId) { diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index 1da70c89285..cd523dce772 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -9,15 +9,18 @@ import { } from "@paperclipai/shared"; import { trackProjectCreated } from "@paperclipai/shared/telemetry"; import { validate } from "../middleware/validate.js"; -import { projectService, logActivity, workspaceOperationService } from "../services/index.js"; +import { projectService, logActivity, secretService, workspaceOperationService } from "../services/index.js"; import { conflict } from "../errors.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; import { startRuntimeServicesForWorkspaceControl, stopRuntimeServicesForProjectWorkspace } from "../services/workspace-runtime.js"; +import { getTelemetryClient } from "../telemetry.js"; export function projectRoutes(db: Db) { const router = Router(); const svc = projectService(db); + const secretsSvc = secretService(db); const workspaceOperations = workspaceOperationService(db); + const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true"; async function resolveCompanyIdForProjectReference(req: Request) { const companyIdQuery = req.query.companyId; diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index 4c3b66c02ba..58a43210f2a 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -14,6 +14,10 @@ import type { WorkspaceRuntimeService, } from "@paperclipai/shared"; import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js"; +import { + listCurrentRuntimeServicesForExecutionWorkspaces, + listCurrentRuntimeServicesForProjectWorkspaces, +} from "./workspace-runtime-read-model.js"; type ExecutionWorkspaceRow = typeof executionWorkspaces.$inferSelect; type WorkspaceRuntimeServiceRow = typeof workspaceRuntimeServices.$inferSelect; @@ -381,7 +385,13 @@ export function executionWorkspaceService(db: Db) { .from(executionWorkspaces) .where(and(...conditions)) .orderBy(desc(executionWorkspaces.lastUsedAt), desc(executionWorkspaces.createdAt)); - return rows.map((row) => toExecutionWorkspace(row)); + const runtimeServicesByWorkspaceId = await loadEffectiveRuntimeServicesByExecutionWorkspace(db, companyId, rows); + return rows.map((row) => + toExecutionWorkspace( + row, + (runtimeServicesByWorkspaceId.get(row.id) ?? []).map(toRuntimeService), + ), + ); }, getById: async (id: string) => { @@ -391,12 +401,11 @@ export function executionWorkspaceService(db: Db) { .where(eq(executionWorkspaces.id, id)) .then((rows) => rows[0] ?? null); if (!row) return null; - const runtimeServiceRows = await db - .select() - .from(workspaceRuntimeServices) - .where(eq(workspaceRuntimeServices.executionWorkspaceId, row.id)) - .orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt)); - return toExecutionWorkspace(row, runtimeServiceRows.map(toRuntimeService)); + const runtimeServicesByWorkspaceId = await loadEffectiveRuntimeServicesByExecutionWorkspace(db, row.companyId, [row]); + return toExecutionWorkspace( + row, + (runtimeServicesByWorkspaceId.get(row.id) ?? []).map(toRuntimeService), + ); }, getCloseReadiness: async (id: string): Promise => { @@ -407,12 +416,8 @@ export function executionWorkspaceService(db: Db) { .then((rows) => rows[0] ?? null); if (!workspace) return null; - const runtimeServiceRows = await db - .select() - .from(workspaceRuntimeServices) - .where(eq(workspaceRuntimeServices.executionWorkspaceId, workspace.id)) - .orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt)); - const runtimeServices = runtimeServiceRows.map(toRuntimeService); + const runtimeServicesByWorkspaceId = await loadEffectiveRuntimeServicesByExecutionWorkspace(db, workspace.companyId, [workspace]); + const runtimeServices = (runtimeServicesByWorkspaceId.get(workspace.id) ?? []).map(toRuntimeService); const linkedIssues = await db .select({ diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 4260fbc8101..d94922a0f76 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -86,6 +86,36 @@ const SESSIONED_LOCAL_ADAPTERS = new Set([ "pi_local", ]); +type RuntimeConfigSecretResolver = Pick< + ReturnType, + "resolveAdapterConfigForRuntime" | "resolveEnvBindings" +>; + +export async function resolveExecutionRunAdapterConfig(input: { + companyId: string; + executionRunConfig: Record; + projectEnv: unknown; + secretsSvc: RuntimeConfigSecretResolver; +}) { + const { config: resolvedConfig, secretKeys } = await input.secretsSvc.resolveAdapterConfigForRuntime( + input.companyId, + input.executionRunConfig, + ); + const projectEnvResolution = input.projectEnv + ? await input.secretsSvc.resolveEnvBindings(input.companyId, input.projectEnv) + : { env: {}, secretKeys: new Set() }; + if (Object.keys(projectEnvResolution.env).length > 0) { + resolvedConfig.env = { + ...parseObject(resolvedConfig.env), + ...projectEnvResolution.env, + }; + for (const key of projectEnvResolution.secretKeys) { + secretKeys.add(key); + } + } + return { resolvedConfig, secretKeys }; +} + export function applyPersistedExecutionWorkspaceConfig(input: { config: Record; workspaceConfig: ExecutionWorkspaceConfig | null; @@ -122,10 +152,10 @@ export function stripWorkspaceRuntimeFromExecutionRunConfig(config: Record; type DbReader = Pick; type IssueCreateInput = Omit & { labelIds?: string[]; + blockedByIssueIds?: string[]; inheritExecutionWorkspaceFromIssueId?: string | null; }; +type IssueRelationSummaryMap = { + blockedBy: IssueRelationIssueSummary[]; + blocks: IssueRelationIssueSummary[]; +}; function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) { if (actorRunId) return checkoutRunId === actorRunId; @@ -1374,7 +1379,12 @@ export function issueService(db: Db) { companyId: string, data: IssueCreateInput, ) => { - const { labelIds: inputLabelIds, inheritExecutionWorkspaceFromIssueId, ...issueData } = data; + const { + labelIds: inputLabelIds, + blockedByIssueIds, + inheritExecutionWorkspaceFromIssueId, + ...issueData + } = data; const isolatedWorkspacesEnabled = (await instanceSettings.getExperimental()).enableIsolatedWorkspaces; if (!isolatedWorkspacesEnabled) { delete issueData.executionWorkspaceId; @@ -1477,6 +1487,14 @@ export function issueService(db: Db) { if (executionWorkspaceId) { await assertValidExecutionWorkspace(companyId, issueData.projectId, executionWorkspaceId, tx); } + // Self-correcting counter: use MAX(issue_number) + 1 if the counter + // has drifted below the actual max, preventing identifier collisions. + const [maxRow] = await tx + .select({ maxNum: sql`coalesce(max(${issues.issueNumber}), 0)` }) + .from(issues) + .where(eq(issues.companyId, companyId)); + const currentMax = maxRow?.maxNum ?? 0; + const [company] = await tx .update(companies) .set({ diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index b1971ce3cc3..c0447bcc3fb 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -25,6 +25,10 @@ import type { WorkspaceOperationRecorder } from "./workspace-operations.js"; import { readExecutionWorkspaceConfig } from "./execution-workspaces.js"; import { readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js"; +export function resolveShell(): string { + return process.env.SHELL?.trim() || (process.platform === "win32" ? "sh" : "/bin/sh"); +} + export interface ExecutionWorkspaceInput { baseCwd: string; source: "project_primary" | "task_session" | "agent_home"; @@ -120,15 +124,6 @@ export async function resetRuntimeServicesForTests() { runtimeServiceLeasesByRun.clear(); } -export async function resetRuntimeServicesForTests() { - for (const record of runtimeServicesById.values()) { - clearIdleTimer(record); - } - runtimeServicesById.clear(); - runtimeServicesByReuseKey.clear(); - runtimeServiceLeasesByRun.clear(); -} - function stableStringify(value: unknown): string { if (Array.isArray(value)) { return `[${value.map((entry) => stableStringify(entry)).join(",")}]`; @@ -386,9 +381,9 @@ function renderWorkspaceTemplate(template: string, input: { function sanitizeBranchName(value: string): string { return value .trim() - .replace(/[^A-Za-z0-9_-]+/g, "-") + .replace(/[^A-Za-z0-9._/-]+/g, "-") .replace(/-+/g, "-") - .replace(/^[-_]+|[-_]+$/g, "") + .replace(/^[-/.]+|[-/.]+$/g, "") .slice(0, 120) || "paperclip-work"; } @@ -1523,6 +1518,8 @@ async function startLocalRuntimeService(input: { scopeType: "project_workspace" | "execution_workspace" | "run" | "agent"; scopeId: string | null; }): Promise { + const leaseRunId = input.leaseRunId === undefined ? input.runId : input.leaseRunId; + const startedByRunId = input.startedByRunId === undefined ? input.runId : input.startedByRunId; const identity = resolveRuntimeServiceReuseIdentity({ service: input.service, workspace: input.workspace, @@ -1570,6 +1567,7 @@ async function startLocalRuntimeService(input: { const portEnvKey = asString(portConfig.envKey, "PORT"); env[portEnvKey] = String(port); } + const expose = parseObject(input.service.expose); const readiness = parseObject(input.service.readiness); const urlTemplate = @@ -1619,7 +1617,7 @@ async function startLocalRuntimeService(input: { provider: "local_process", providerRef: String(adoptedRecord.pid), ownerAgentId: input.agent.id ?? null, - startedByRunId: input.runId, + startedByRunId, lastUsedAt: new Date().toISOString(), startedAt: adoptedRecord.startedAt, stoppedAt: null, @@ -1628,7 +1626,7 @@ async function startLocalRuntimeService(input: { reused: true, db: input.db, child: null, - leaseRunIds: new Set([input.runId]), + leaseRunIds: leaseRunId ? new Set([leaseRunId]) : new Set(), idleTimer: null, envFingerprint, serviceKey, @@ -1644,7 +1642,12 @@ async function startLocalRuntimeService(input: { ); } } - const shell = process.env.SHELL?.trim() || "/bin/sh"; + + await ensureServerWorkspaceLinksCurrent(serviceCwd, { + onLog: input.onLog, + }); + + const shell = resolveShell(); const child = spawn(shell, ["-lc", command], { cwd: serviceCwd, env, @@ -1693,7 +1696,7 @@ async function startLocalRuntimeService(input: { provider: "local_process", providerRef: child.pid ? String(child.pid) : null, ownerAgentId: input.agent.id ?? null, - startedByRunId: input.runId, + startedByRunId, lastUsedAt: new Date().toISOString(), startedAt: new Date().toISOString(), stoppedAt: null, @@ -1968,9 +1971,13 @@ export async function startRuntimeServicesForWorkspaceControl(input: { } } + // Manually controlled services are not tied to a heartbeat run lifecycle, so they do not + // retain a run lease and never persist a startedByRunId foreign key. const record = await startLocalRuntimeService({ db: input.db, runId: invocationId, + leaseRunId: null, + startedByRunId: null, agent: input.actor, issue: input.issue, workspace: input.workspace, @@ -1982,7 +1989,6 @@ export async function startRuntimeServicesForWorkspaceControl(input: { scopeType, scopeId, }); - record.startedByRunId = null; registerRuntimeService(input.db, record); await persistRuntimeServiceRecord(input.db, record); refs.push(toRuntimeServiceRef(record)); @@ -2123,50 +2129,55 @@ export async function reconcilePersistedRuntimeServicesOnStartup(db: Db) { profileKind: "workspace-runtime", }); if (adoptedRecord) { - const record: RuntimeServiceRecord = { - id: row.id, - companyId: row.companyId, - projectId: row.projectId ?? null, - projectWorkspaceId: row.projectWorkspaceId ?? null, - executionWorkspaceId: row.executionWorkspaceId ?? null, - issueId: row.issueId ?? null, - serviceName: row.serviceName, - status: "running", - lifecycle: row.lifecycle as RuntimeServiceRecord["lifecycle"], - scopeType: row.scopeType as RuntimeServiceRecord["scopeType"], - scopeId: row.scopeId ?? null, - reuseKey: row.reuseKey ?? null, - command: row.command ?? null, - cwd: row.cwd ?? null, - port: adoptedRecord.port ?? row.port ?? null, - url: adoptedRecord.url ?? row.url ?? null, - provider: "local_process", - providerRef: String(adoptedRecord.pid), - ownerAgentId: row.ownerAgentId ?? null, - startedByRunId: row.startedByRunId ?? null, - lastUsedAt: new Date().toISOString(), - startedAt: row.startedAt.toISOString(), - stoppedAt: null, - stopPolicy: (row.stopPolicy as Record | null) ?? null, - healthStatus: "healthy", - reused: true, - db, - child: null, - leaseRunIds: new Set(), - idleTimer: null, - envFingerprint: row.reuseKey ?? "", - serviceKey: adoptedRecord.serviceKey, - profileKind: "workspace-runtime", - processGroupId: adoptedRecord.processGroupId ?? null, - }; - registerRuntimeService(db, record); - await touchLocalServiceRegistryRecord(adoptedRecord.serviceKey, { - runtimeServiceId: row.id, - lastSeenAt: record.lastUsedAt, - }); - await persistRuntimeServiceRecord(db, record); - adopted += 1; - continue; + const adoptedUrl = adoptedRecord.url ?? row.url ?? null; + if (!(await isRuntimeServiceUrlHealthy(adoptedUrl))) { + await removeLocalServiceRegistryRecord(adoptedRecord.serviceKey); + } else { + const record: RuntimeServiceRecord = { + id: row.id, + companyId: row.companyId, + projectId: row.projectId ?? null, + projectWorkspaceId: row.projectWorkspaceId ?? null, + executionWorkspaceId: row.executionWorkspaceId ?? null, + issueId: row.issueId ?? null, + serviceName: row.serviceName, + status: "running", + lifecycle: row.lifecycle as RuntimeServiceRecord["lifecycle"], + scopeType: row.scopeType as RuntimeServiceRecord["scopeType"], + scopeId: row.scopeId ?? null, + reuseKey: row.reuseKey ?? null, + command: row.command ?? null, + cwd: row.cwd ?? null, + port: adoptedRecord.port ?? row.port ?? null, + url: adoptedRecord.url ?? row.url ?? null, + provider: "local_process", + providerRef: String(adoptedRecord.pid), + ownerAgentId: row.ownerAgentId ?? null, + startedByRunId: row.startedByRunId ?? null, + lastUsedAt: new Date().toISOString(), + startedAt: row.startedAt.toISOString(), + stoppedAt: null, + stopPolicy: (row.stopPolicy as Record | null) ?? null, + healthStatus: "healthy", + reused: true, + db, + child: null, + leaseRunIds: new Set(), + idleTimer: null, + envFingerprint: row.reuseKey ?? "", + serviceKey: adoptedRecord.serviceKey, + profileKind: "workspace-runtime", + processGroupId: adoptedRecord.processGroupId ?? null, + }; + registerRuntimeService(db, record); + await touchLocalServiceRegistryRecord(adoptedRecord.serviceKey, { + runtimeServiceId: row.id, + lastSeenAt: record.lastUsedAt, + }); + await persistRuntimeServiceRecord(db, record); + adopted += 1; + continue; + } } const now = new Date(); diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx index 3a54db28cda..062cbcf7a34 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -18,7 +18,10 @@ import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./Ma import { OutputFeedbackButtons } from "./OutputFeedbackButtons"; import { ApprovalCard } from "./ApprovalCard"; import { AgentIcon } from "./AgentIconPicker"; -import { formatDateTime } from "../lib/utils"; +import { formatAssigneeUserLabel } from "../lib/assignees"; +import type { IssueTimelineAssignee, IssueTimelineEvent } from "../lib/issue-timeline-events"; +import { timeAgo } from "../lib/timeAgo"; +import { cn, formatDateTime } from "../lib/utils"; import { restoreSubmittedCommentDraft } from "../lib/comment-submit-draft"; import { PluginSlotOutlet } from "@/plugins/slots"; @@ -48,6 +51,10 @@ interface CommentReassignment { interface CommentThreadProps { comments: CommentWithRunMeta[]; queuedComments?: CommentWithRunMeta[]; + linkedApprovals?: Approval[]; + feedbackVotes?: FeedbackVote[]; + feedbackDataSharingPreference?: FeedbackDataSharingPreference; + feedbackTermsUrl?: string | null; linkedRuns?: LinkedRunItem[]; timelineEvents?: IssueTimelineEvent[]; companyId?: string | null; @@ -79,6 +86,7 @@ interface CommentThreadProps { mentions?: MentionOption[]; onInterruptQueued?: (runId: string) => Promise; interruptingQueuedRunId?: string | null; + composerDisabledReason?: string | null; } const DRAFT_DEBOUNCE_MS = 800; @@ -276,6 +284,11 @@ function CommentCard({ agentMap, companyId, projectId, + feedbackVote = null, + feedbackDataSharingPreference = "prompt", + feedbackTermsUrl = null, + onVote, + voting = false, highlightCommentId, queued = false, }: { @@ -283,6 +296,14 @@ function CommentCard({ agentMap?: Map; companyId?: string | null; projectId?: string | null; + feedbackVote?: FeedbackVoteValue | null; + feedbackDataSharingPreference?: FeedbackDataSharingPreference; + feedbackTermsUrl?: string | null; + onVote?: ( + vote: FeedbackVoteValue, + options?: { allowSharing?: boolean; reason?: string }, + ) => Promise; + voting?: boolean; highlightCommentId?: string | null; queued?: boolean; }) { @@ -367,8 +388,31 @@ function CommentCard({ /> ) : null} - {comment.runId && !isPending ? ( -
+ {comment.authorAgentId && onVote && !isQueued && !isPending ? ( + + run {comment.runId.slice(0, 8)} + + ) : ( + + run {comment.runId.slice(0, 8)} + + ) + ) : undefined} + /> + ) : null} + {comment.runId && !isPending && !(comment.authorAgentId && onVote && !isQueued) ? ( +
{comment.runAgentId ? ( onVote(comment.id, vote, options) : undefined} + voting={votingTargetId === comment.id} highlightCommentId={highlightCommentId} /> ); @@ -585,6 +634,10 @@ const TimelineList = memo(function TimelineList({ export const CommentThread = memo(function CommentThread({ comments, queuedComments = [], + linkedApprovals = [], + feedbackVotes = [], + feedbackDataSharingPreference = "prompt", + feedbackTermsUrl = null, linkedRuns = [], timelineEvents = [], companyId, @@ -607,6 +660,7 @@ export const CommentThread = memo(function CommentThread({ mentions: providedMentions, onInterruptQueued, interruptingQueuedRunId = null, + composerDisabledReason = null, }: CommentThreadProps) { const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue; const [highlightCommentId, setHighlightCommentId] = useState(null); @@ -862,26 +916,8 @@ const CommentComposer = memo(function CommentComposer({ }, []); useEffect(() => { - setReassignTarget(effectiveSuggestedAssigneeValue); - }, [effectiveSuggestedAssigneeValue]); - - // Scroll to comment when URL hash matches #comment-{id} - useEffect(() => { - const hash = location.hash; - if (!hash.startsWith("#comment-") || comments.length + queuedComments.length === 0) return; - const commentId = hash.slice("#comment-".length); - // Only scroll once per hash - if (hasScrolledRef.current) return; - const el = document.getElementById(`comment-${commentId}`); - if (el) { - hasScrolledRef.current = true; - setHighlightCommentId(commentId); - el.scrollIntoView({ behavior: "smooth", block: "center" }); - // Clear highlight after animation - const timer = setTimeout(() => setHighlightCommentId(null), 3000); - return () => clearTimeout(timer); - } - }, [location.hash, comments, queuedComments]); + setReassignTarget(suggestedAssigneeValue); + }, [suggestedAssigneeValue]); async function handleSubmit() { const trimmed = body.trim(); @@ -896,7 +932,7 @@ const CommentComposer = memo(function CommentComposer({ await onAdd(submittedBody, reopen ? true : undefined, reassignment ?? undefined); if (draftKey) clearDraft(draftKey); setReopen(true); - setReassignTarget(effectiveSuggestedAssigneeValue); + setReassignTarget(suggestedAssigneeValue); } catch { setBody((current) => restoreSubmittedCommentDraft({ @@ -904,7 +940,6 @@ const CommentComposer = memo(function CommentComposer({ submittedBody, }), ); - // Parent mutation handlers surface the failure and the draft is restored for retry. } finally { setSubmitting(false); } @@ -932,88 +967,20 @@ const CommentComposer = memo(function CommentComposer({ const canSubmit = !submitting && !!body.trim(); return ( -
-

Comments & Runs ({timeline.length + queuedComments.length})

- - {timeline.length > 0 ? ( - - ) : null} - - {liveRunSlot} - - {queuedComments.length > 0 && ( -
-
-

- Queued Comments ({queuedComments.length}) -

- {onInterruptQueued && queuedComments[0]?.queueTargetRunId ? ( - - ) : null} -
-
- {queuedComments.map((comment) => ( - - ))} -
-
- )} - -
- -
- {(imageUploadHandler || onAttachImage) && ( -
- - -
- )} -