diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index f36b6a3b..12989f72 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -12,6 +12,8 @@ export interface RunProcessResult { timedOut: boolean; stdout: string; stderr: string; + pid: number | null; + startedAt: string | null; } interface RunningProcess { @@ -724,6 +726,7 @@ export async function runChildProcess( graceSec: number; onLog: (stream: "stdout" | "stderr", chunk: string) => Promise; onLogError?: (err: unknown, runId: string, message: string) => void; + onSpawn?: (meta: { pid: number; startedAt: string }) => Promise; stdin?: string; }, ): Promise { @@ -756,12 +759,19 @@ export async function runChildProcess( shell: false, stdio: [opts.stdin != null ? "pipe" : "ignore", "pipe", "pipe"], }) as ChildProcessWithEvents; + const startedAt = new Date().toISOString(); if (opts.stdin != null && child.stdin) { child.stdin.write(opts.stdin); child.stdin.end(); } + if (typeof child.pid === "number" && child.pid > 0 && opts.onSpawn) { + void opts.onSpawn({ pid: child.pid, startedAt }).catch((err) => { + onLogError(err, runId, "failed to record child process metadata"); + }); + } + runningProcesses.set(runId, { child, graceSec: opts.graceSec }); let timedOut = false; @@ -820,6 +830,8 @@ export async function runChildProcess( timedOut, stdout, stderr, + pid: child.pid ?? null, + startedAt, }); }); }); diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index 142e8939..ce89e0e8 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -120,6 +120,7 @@ export interface AdapterExecutionContext { context: Record; onLog: (stream: "stdout" | "stderr", chunk: string) => Promise; onMeta?: (meta: AdapterInvocationMeta) => Promise; + onSpawn?: (meta: { pid: number; startedAt: string }) => Promise; authToken?: string; } @@ -297,7 +298,7 @@ export type TranscriptEntry = | { kind: "thinking"; ts: string; text: string; delta?: boolean } | { kind: "user"; ts: string; text: string } | { kind: "tool_call"; ts: string; name: string; input: unknown; toolUseId?: string } - | { kind: "tool_result"; ts: string; toolUseId: string; content: string; isError: boolean } + | { kind: "tool_result"; ts: string; toolUseId: string; toolName?: string; content: string; isError: boolean } | { kind: "init"; ts: string; model: string; sessionId: string } | { kind: "result"; ts: string; text: string; inputTokens: number; outputTokens: number; cachedTokens: number; costUsd: number; subtype: string; isError: boolean; errors: string[] } | { kind: "stderr"; ts: string; text: string } diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 3d60eb9e..c755c627 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -296,7 +296,7 @@ export async function runClaudeLogin(input: { } export async function execute(ctx: AdapterExecutionContext): Promise { - const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx; + const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx; const promptTemplate = asString( config.promptTemplate, @@ -362,7 +362,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { - const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx; + const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx; const promptTemplate = asString( config.promptTemplate, @@ -398,7 +398,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { if (stream !== "stderr") { await onLog(stream, chunk); @@ -591,7 +592,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { - const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx; + const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx; const promptTemplate = asString( config.promptTemplate, @@ -290,7 +290,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { if (stream !== "stdout") { await onLog(stream, chunk); @@ -520,7 +521,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { - const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx; + const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx; const promptTemplate = asString( config.promptTemplate, @@ -238,7 +238,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise {}); } async connect( diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index 1221ab98..34fdda92 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -89,7 +89,7 @@ async function ensureOpenCodeSkillsInjected( } export async function execute(ctx: AdapterExecutionContext): Promise { - const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx; + const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx; const promptTemplate = asString( config.promptTemplate, @@ -203,7 +203,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise entry.key)); const selectedEntries = skillsEntries.filter((entry) => desiredSet.has(entry.key)); if (selectedEntries.length === 0) return; - const piSkillsHome = path.join(os.homedir(), ".pi", "agent", "skills"); - await fs.mkdir(piSkillsHome, { recursive: true }); + await fs.mkdir(PI_AGENT_SKILLS_DIR, { recursive: true }); const removedSkills = await removeMaintainerOnlySkillSymlinks( - piSkillsHome, + PI_AGENT_SKILLS_DIR, selectedEntries.map((entry) => entry.runtimeName), ); for (const skillName of removedSkills) { await onLog( "stderr", - `[paperclip] Removed maintainer-only Pi skill "${skillName}" from ${piSkillsHome}\n`, + `[paperclip] Removed maintainer-only Pi skill "${skillName}" from ${PI_AGENT_SKILLS_DIR}\n`, ); } for (const entry of selectedEntries) { - const target = path.join(piSkillsHome, entry.runtimeName); + const target = path.join(PI_AGENT_SKILLS_DIR, entry.runtimeName); try { const result = await ensurePaperclipSkillSymlink(entry.source, target); if (result === "skipped") continue; await onLog( "stderr", - `[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} Pi skill "${entry.key}" into ${piSkillsHome}\n`, + `[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} Pi skill "${entry.key}" into ${PI_AGENT_SKILLS_DIR}\n`, ); } catch (err) { await onLog( "stderr", - `[paperclip] Failed to inject Pi skill "${entry.key}" into ${piSkillsHome}: ${err instanceof Error ? err.message : String(err)}\n`, + `[paperclip] Failed to inject Pi skill "${entry.key}" into ${PI_AGENT_SKILLS_DIR}: ${err instanceof Error ? err.message : String(err)}\n`, ); } } @@ -106,7 +106,7 @@ function buildSessionPath(agentId: string, timestamp: string): string { } export async function execute(ctx: AdapterExecutionContext): Promise { - const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx; + const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx; const promptTemplate = asString( config.promptTemplate, @@ -232,7 +232,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) args.push(...extraArgs); - + return args; }; @@ -401,6 +404,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise); + } else { + contentStr = JSON.stringify(content); + } + entries.push({ kind: "tool_result", ts, toolUseId: asString(tr.toolCallId, "unknown"), + toolName: asString(tr.toolName), content: contentStr, isError, }); @@ -130,14 +141,35 @@ export function parsePiStdoutLine(line: string, ts: string): TranscriptEntry[] { if (type === "tool_execution_end") { const toolCallId = asString(parsed.toolCallId); + const toolName = asString(parsed.toolName); const result = parsed.result; const isError = parsed.isError === true; - const contentStr = typeof result === "string" ? result : JSON.stringify(result); + + // Extract text from Pi's content array format + // Can be: {"content": [{"type": "text", "text": "..."}]} or [{"type": "text", "text": "..."}] + let contentStr: string; + if (typeof result === "string") { + contentStr = result; + } else if (Array.isArray(result)) { + // Direct array format: result is [{"type": "text", "text": "..."}] + contentStr = extractTextContent(result as Array<{ type: string; text?: string }>); + } else if (result && typeof result === "object") { + const resultObj = result as Record; + if (Array.isArray(resultObj.content)) { + // Wrapped format: result is {"content": [{"type": "text", "text": "..."}]} + contentStr = extractTextContent(resultObj.content as Array<{ type: string; text?: string }>); + } else { + contentStr = JSON.stringify(result); + } + } else { + contentStr = JSON.stringify(result); + } return [{ kind: "tool_result", ts, toolUseId: toolCallId || "unknown", + toolName, content: contentStr, isError, }]; diff --git a/packages/db/src/migrations/0038_careless_iron_monger.sql b/packages/db/src/migrations/0038_careless_iron_monger.sql new file mode 100644 index 00000000..f17c1f1f --- /dev/null +++ b/packages/db/src/migrations/0038_careless_iron_monger.sql @@ -0,0 +1,5 @@ +ALTER TABLE "heartbeat_runs" ADD COLUMN "process_pid" integer;--> statement-breakpoint +ALTER TABLE "heartbeat_runs" ADD COLUMN "process_started_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "heartbeat_runs" ADD COLUMN "retry_of_run_id" uuid;--> statement-breakpoint +ALTER TABLE "heartbeat_runs" ADD COLUMN "process_loss_retry_count" integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE "heartbeat_runs" ADD CONSTRAINT "heartbeat_runs_retry_of_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("retry_of_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action; \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0038_snapshot.json b/packages/db/src/migrations/meta/0038_snapshot.json index 58a1c2f9..f3cf652b 100644 --- a/packages/db/src/migrations/meta/0038_snapshot.json +++ b/packages/db/src/migrations/meta/0038_snapshot.json @@ -5341,6 +5341,31 @@ "primaryKey": false, "notNull": false }, + "process_pid": { + "name": "process_pid", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "process_started_at": { + "name": "process_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "retry_of_run_id": { + "name": "retry_of_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "process_loss_retry_count": { + "name": "process_loss_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, "context_snapshot": { "name": "context_snapshot", "type": "jsonb", @@ -5430,6 +5455,19 @@ ], "onDelete": "no action", "onUpdate": "no action" + }, + "heartbeat_runs_retry_of_run_id_heartbeat_runs_id_fk": { + "name": "heartbeat_runs_retry_of_run_id_heartbeat_runs_id_fk", + "tableFrom": "heartbeat_runs", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "retry_of_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -11309,4 +11347,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/packages/db/src/migrations/meta/0039_snapshot.json b/packages/db/src/migrations/meta/0039_snapshot.json index 2277d220..1d092919 100644 --- a/packages/db/src/migrations/meta/0039_snapshot.json +++ b/packages/db/src/migrations/meta/0039_snapshot.json @@ -5341,6 +5341,31 @@ "primaryKey": false, "notNull": false }, + "process_pid": { + "name": "process_pid", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "process_started_at": { + "name": "process_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "retry_of_run_id": { + "name": "retry_of_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "process_loss_retry_count": { + "name": "process_loss_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, "context_snapshot": { "name": "context_snapshot", "type": "jsonb", @@ -5430,6 +5455,19 @@ ], "onDelete": "no action", "onUpdate": "no action" + }, + "heartbeat_runs_retry_of_run_id_heartbeat_runs_id_fk": { + "name": "heartbeat_runs_retry_of_run_id_heartbeat_runs_id_fk", + "tableFrom": "heartbeat_runs", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "retry_of_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -11352,4 +11390,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 199b51d0..ef8ad0b8 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -271,8 +271,8 @@ { "idx": 38, "version": "7", - "when": 1773926116580, - "tag": "0038_fat_magneto", + "when": 1773931592563, + "tag": "0038_careless_iron_monger", "breakpoints": true }, { @@ -281,6 +281,13 @@ "when": 1773927102783, "tag": "0039_eager_shotgun", "breakpoints": true + }, + { + "idx": 40, + "version": "7", + "when": 1773926116580, + "tag": "0038_fat_magneto", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/packages/db/src/schema/heartbeat_runs.ts b/packages/db/src/schema/heartbeat_runs.ts index 1557da3d..58a1dcdb 100644 --- a/packages/db/src/schema/heartbeat_runs.ts +++ b/packages/db/src/schema/heartbeat_runs.ts @@ -1,4 +1,4 @@ -import { pgTable, uuid, text, timestamp, jsonb, index, integer, bigint, boolean } from "drizzle-orm/pg-core"; +import { type AnyPgColumn, pgTable, uuid, text, timestamp, jsonb, index, integer, bigint, boolean } from "drizzle-orm/pg-core"; import { companies } from "./companies.js"; import { agents } from "./agents.js"; import { agentWakeupRequests } from "./agent_wakeup_requests.js"; @@ -31,6 +31,12 @@ export const heartbeatRuns = pgTable( stderrExcerpt: text("stderr_excerpt"), errorCode: text("error_code"), externalRunId: text("external_run_id"), + processPid: integer("process_pid"), + processStartedAt: timestamp("process_started_at", { withTimezone: true }), + retryOfRunId: uuid("retry_of_run_id").references((): AnyPgColumn => heartbeatRuns.id, { + onDelete: "set null", + }), + processLossRetryCount: integer("process_loss_retry_count").notNull().default(0), contextSnapshot: jsonb("context_snapshot").$type>(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 5e57dcb5..393bef04 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -164,6 +164,9 @@ export type { InstanceExperimentalSettings, InstanceSettings, Agent, + AgentAccessState, + AgentChainOfCommandEntry, + AgentDetail, AgentPermissions, AgentInstructionsBundleMode, AgentInstructionsFileSummary, diff --git a/packages/shared/src/types/agent.ts b/packages/shared/src/types/agent.ts index 61fe62fc..e938ad4a 100644 --- a/packages/shared/src/types/agent.ts +++ b/packages/shared/src/types/agent.ts @@ -4,6 +4,10 @@ import type { AgentRole, AgentStatus, } from "../constants.js"; +import type { + CompanyMembership, + PrincipalPermissionGrant, +} from "./access.js"; export interface AgentPermissions { canCreateAgents: boolean; @@ -41,6 +45,20 @@ export interface AgentInstructionsBundle { files: AgentInstructionsFileSummary[]; } +export interface AgentAccessState { + canAssignTasks: boolean; + taskAssignSource: "explicit_grant" | "agent_creator" | "ceo_role" | "none"; + membership: CompanyMembership | null; + grants: PrincipalPermissionGrant[]; +} + +export interface AgentChainOfCommandEntry { + id: string; + name: string; + role: AgentRole; + title: string | null; +} + export interface Agent { id: string; companyId: string; @@ -66,6 +84,11 @@ export interface Agent { updatedAt: Date; } +export interface AgentDetail extends Agent { + chainOfCommand: AgentChainOfCommandEntry[]; + access: AgentAccessState; +} + export interface AgentKeyCreated { id: string; name: string; diff --git a/packages/shared/src/types/heartbeat.ts b/packages/shared/src/types/heartbeat.ts index 2e5a2006..c399b6da 100644 --- a/packages/shared/src/types/heartbeat.ts +++ b/packages/shared/src/types/heartbeat.ts @@ -33,6 +33,10 @@ export interface HeartbeatRun { stderrExcerpt: string | null; errorCode: string | null; externalRunId: string | null; + processPid: number | null; + processStartedAt: Date | null; + retryOfRunId: string | null; + processLossRetryCount: number; contextSnapshot: Record | null; createdAt: Date; updatedAt: Date; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index c403d7ae..76742092 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -31,6 +31,9 @@ export type { } from "./adapter-skills.js"; export type { Agent, + AgentAccessState, + AgentChainOfCommandEntry, + AgentDetail, AgentPermissions, AgentInstructionsBundleMode, AgentInstructionsFileSummary, diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts index 1dcc7dbd..d72a76a2 100644 --- a/packages/shared/src/validators/agent.ts +++ b/packages/shared/src/validators/agent.ts @@ -120,6 +120,7 @@ export type TestAdapterEnvironment = z.infer; diff --git a/packages/shared/src/validators/company.ts b/packages/shared/src/validators/company.ts index 7e91a61b..651f5585 100644 --- a/packages/shared/src/validators/company.ts +++ b/packages/shared/src/validators/company.ts @@ -26,6 +26,8 @@ export type UpdateCompany = z.infer; export const updateCompanyBrandingSchema = z .object({ + name: z.string().min(1).optional(), + description: z.string().nullable().optional(), brandColor: brandColorSchema, logoAssetId: logoAssetIdSchema, }) diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts new file mode 100644 index 00000000..3b0f9e78 --- /dev/null +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -0,0 +1,246 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +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"; + +const baseAgent = { + id: agentId, + companyId, + name: "Builder", + urlKey: "builder", + role: "engineer", + title: "Builder", + icon: null, + status: "idle", + reportsTo: null, + capabilities: null, + adapterType: "process", + adapterConfig: {}, + runtimeConfig: {}, + budgetMonthlyCents: 0, + spentMonthlyCents: 0, + pauseReason: null, + pausedAt: null, + permissions: { canCreateAgents: false }, + lastHeartbeatAt: null, + metadata: null, + createdAt: new Date("2026-03-19T00:00:00.000Z"), + updatedAt: new Date("2026-03-19T00:00:00.000Z"), +}; + +const mockAgentService = vi.hoisted(() => ({ + getById: vi.fn(), + create: vi.fn(), + updatePermissions: vi.fn(), + getChainOfCommand: vi.fn(), + resolveByReference: vi.fn(), +})); + +const mockAccessService = vi.hoisted(() => ({ + canUser: vi.fn(), + hasPermission: vi.fn(), + getMembership: vi.fn(), + ensureMembership: vi.fn(), + listPrincipalGrants: vi.fn(), + setPrincipalPermission: vi.fn(), +})); + +const mockApprovalService = vi.hoisted(() => ({ + create: vi.fn(), + getById: vi.fn(), +})); + +const mockBudgetService = vi.hoisted(() => ({ + upsertPolicy: vi.fn(), +})); + +const mockHeartbeatService = vi.hoisted(() => ({ + listTaskSessions: vi.fn(), + resetRuntimeSession: vi.fn(), +})); + +const mockIssueApprovalService = vi.hoisted(() => ({ + linkManyForApproval: vi.fn(), +})); + +const mockIssueService = vi.hoisted(() => ({ + list: vi.fn(), +})); + +const mockSecretService = vi.hoisted(() => ({ + normalizeAdapterConfigForPersistence: vi.fn(), + resolveAdapterConfigForRuntime: vi.fn(), +})); + +const mockWorkspaceOperationService = vi.hoisted(() => ({})); +const mockLogActivity = vi.hoisted(() => vi.fn()); + +vi.mock("../services/index.js", () => ({ + agentService: () => mockAgentService, + accessService: () => mockAccessService, + approvalService: () => mockApprovalService, + budgetService: () => mockBudgetService, + heartbeatService: () => mockHeartbeatService, + issueApprovalService: () => mockIssueApprovalService, + issueService: () => mockIssueService, + logActivity: mockLogActivity, + secretService: () => mockSecretService, + workspaceOperationService: () => mockWorkspaceOperationService, +})); + +function createDbStub() { + return { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + then: vi.fn().mockResolvedValue([{ + id: companyId, + name: "Paperclip", + requireBoardApprovalForNewAgents: false, + }]), + }), + }), + }), + }; +} + +function createApp(actor: Record) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = actor; + next(); + }); + app.use("/api", agentRoutes(createDbStub() as any)); + app.use(errorHandler); + return app; +} + +describe("agent permission routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockAgentService.getById.mockResolvedValue(baseAgent); + mockAgentService.getChainOfCommand.mockResolvedValue([]); + mockAgentService.resolveByReference.mockResolvedValue({ ambiguous: false, agent: baseAgent }); + mockAgentService.create.mockResolvedValue(baseAgent); + mockAgentService.updatePermissions.mockResolvedValue(baseAgent); + mockAccessService.getMembership.mockResolvedValue({ + id: "membership-1", + companyId, + principalType: "agent", + principalId: agentId, + status: "active", + membershipRole: "member", + createdAt: new Date("2026-03-19T00:00:00.000Z"), + updatedAt: new Date("2026-03-19T00:00:00.000Z"), + }); + mockAccessService.listPrincipalGrants.mockResolvedValue([]); + mockAccessService.ensureMembership.mockResolvedValue(undefined); + mockAccessService.setPrincipalPermission.mockResolvedValue(undefined); + mockBudgetService.upsertPolicy.mockResolvedValue(undefined); + mockSecretService.normalizeAdapterConfigForPersistence.mockImplementation(async (_companyId, config) => config); + mockSecretService.resolveAdapterConfigForRuntime.mockImplementation(async (_companyId, config) => ({ config })); + mockLogActivity.mockResolvedValue(undefined); + }); + + it("grants tasks:assign by default when board creates a new agent", async () => { + const app = createApp({ + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const res = await request(app) + .post(`/api/companies/${companyId}/agents`) + .send({ + name: "Builder", + role: "engineer", + adapterType: "process", + adapterConfig: {}, + }); + + expect(res.status).toBe(201); + expect(mockAccessService.ensureMembership).toHaveBeenCalledWith( + companyId, + "agent", + agentId, + "member", + "active", + ); + expect(mockAccessService.setPrincipalPermission).toHaveBeenCalledWith( + companyId, + "agent", + agentId, + "tasks:assign", + true, + "board-user", + ); + }); + + it("exposes explicit task assignment access on agent detail", async () => { + mockAccessService.listPrincipalGrants.mockResolvedValue([ + { + id: "grant-1", + companyId, + principalType: "agent", + principalId: agentId, + permissionKey: "tasks:assign", + scope: null, + grantedByUserId: "board-user", + createdAt: new Date("2026-03-19T00:00:00.000Z"), + updatedAt: new Date("2026-03-19T00:00:00.000Z"), + }, + ]); + + const app = createApp({ + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const res = await request(app).get(`/api/agents/${agentId}`); + + expect(res.status).toBe(200); + expect(res.body.access.canAssignTasks).toBe(true); + expect(res.body.access.taskAssignSource).toBe("explicit_grant"); + }); + + it("keeps task assignment enabled when agent creation privilege is enabled", async () => { + mockAgentService.updatePermissions.mockResolvedValue({ + ...baseAgent, + permissions: { canCreateAgents: true }, + }); + + const app = createApp({ + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const res = await request(app) + .patch(`/api/agents/${agentId}/permissions`) + .send({ canCreateAgents: true, canAssignTasks: false }); + + expect(res.status).toBe(200); + expect(mockAccessService.setPrincipalPermission).toHaveBeenCalledWith( + companyId, + "agent", + agentId, + "tasks:assign", + true, + "board-user", + ); + expect(res.body.access.canAssignTasks).toBe(true); + expect(res.body.access.taskAssignSource).toBe("agent_creator"); + }); +}); diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts new file mode 100644 index 00000000..a5742f42 --- /dev/null +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -0,0 +1,321 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { spawn, type ChildProcess } from "node:child_process"; +import { eq } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + applyPendingMigrations, + createDb, + ensurePostgresDatabase, + agents, + agentWakeupRequests, + companies, + heartbeatRunEvents, + heartbeatRuns, + issues, +} from "@paperclipai/db"; +import { runningProcesses } from "../adapters/index.ts"; +import { heartbeatService } from "../services/heartbeat.ts"; + +type EmbeddedPostgresInstance = { + initialise(): Promise; + start(): Promise; + stop(): Promise; +}; + +type EmbeddedPostgresCtor = new (opts: { + databaseDir: string; + user: string; + password: string; + port: number; + persistent: boolean; + initdbFlags?: string[]; + onLog?: (message: unknown) => void; + onError?: (message: unknown) => void; +}) => EmbeddedPostgresInstance; + +async function getEmbeddedPostgresCtor(): Promise { + const mod = await import("embedded-postgres"); + return mod.default as EmbeddedPostgresCtor; +} + +async function getAvailablePort(): Promise { + return await new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(() => reject(new Error("Failed to allocate test port"))); + return; + } + const { port } = address; + server.close((error) => { + if (error) reject(error); + else resolve(port); + }); + }); + }); +} + +async function startTempDatabase() { + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-heartbeat-recovery-")); + const port = await getAvailablePort(); + const EmbeddedPostgres = await getEmbeddedPostgresCtor(); + const instance = new EmbeddedPostgres({ + databaseDir: dataDir, + user: "paperclip", + password: "paperclip", + port, + persistent: true, + initdbFlags: ["--encoding=UTF8", "--locale=C"], + onLog: () => {}, + onError: () => {}, + }); + await instance.initialise(); + await instance.start(); + + const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`; + await ensurePostgresDatabase(adminConnectionString, "paperclip"); + const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`; + await applyPendingMigrations(connectionString); + return { connectionString, instance, dataDir }; +} + +function spawnAliveProcess() { + return spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { + stdio: "ignore", + }); +} + +describe("heartbeat orphaned process recovery", () => { + let db!: ReturnType; + let instance: EmbeddedPostgresInstance | null = null; + let dataDir = ""; + const childProcesses = new Set(); + + beforeAll(async () => { + const started = await startTempDatabase(); + db = createDb(started.connectionString); + instance = started.instance; + dataDir = started.dataDir; + }, 20_000); + + afterEach(async () => { + runningProcesses.clear(); + for (const child of childProcesses) { + child.kill("SIGKILL"); + } + childProcesses.clear(); + await db.delete(issues); + await db.delete(heartbeatRunEvents); + await db.delete(heartbeatRuns); + await db.delete(agentWakeupRequests); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + for (const child of childProcesses) { + child.kill("SIGKILL"); + } + childProcesses.clear(); + runningProcesses.clear(); + await instance?.stop(); + if (dataDir) { + fs.rmSync(dataDir, { recursive: true, force: true }); + } + }); + + async function seedRunFixture(input?: { + adapterType?: string; + runStatus?: "running" | "queued" | "failed"; + processPid?: number | null; + processLossRetryCount?: number; + includeIssue?: boolean; + runErrorCode?: string | null; + runError?: string | null; + }) { + const companyId = randomUUID(); + const agentId = randomUUID(); + const runId = randomUUID(); + const wakeupRequestId = randomUUID(); + const issueId = randomUUID(); + const now = new Date("2026-03-19T00:00:00.000Z"); + const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`; + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "paused", + adapterType: input?.adapterType ?? "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + + await db.insert(agentWakeupRequests).values({ + id: wakeupRequestId, + companyId, + agentId, + source: "assignment", + triggerDetail: "system", + reason: "issue_assigned", + payload: input?.includeIssue === false ? {} : { issueId }, + status: "claimed", + runId, + claimedAt: now, + }); + + await db.insert(heartbeatRuns).values({ + id: runId, + companyId, + agentId, + invocationSource: "assignment", + triggerDetail: "system", + status: input?.runStatus ?? "running", + wakeupRequestId, + contextSnapshot: input?.includeIssue === false ? {} : { issueId }, + processPid: input?.processPid ?? null, + processLossRetryCount: input?.processLossRetryCount ?? 0, + errorCode: input?.runErrorCode ?? null, + error: input?.runError ?? null, + startedAt: now, + updatedAt: new Date("2026-03-19T00:00:00.000Z"), + }); + + if (input?.includeIssue !== false) { + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Recover local adapter after lost process", + status: "in_progress", + priority: "medium", + assigneeAgentId: agentId, + checkoutRunId: runId, + executionRunId: runId, + issueNumber: 1, + identifier: `${issuePrefix}-1`, + }); + } + + return { companyId, agentId, runId, wakeupRequestId, issueId }; + } + + it("keeps a local run active when the recorded pid is still alive", async () => { + const child = spawnAliveProcess(); + childProcesses.add(child); + expect(child.pid).toBeTypeOf("number"); + + const { runId, wakeupRequestId } = await seedRunFixture({ + processPid: child.pid ?? null, + includeIssue: false, + }); + const heartbeat = heartbeatService(db); + + const result = await heartbeat.reapOrphanedRuns(); + expect(result.reaped).toBe(0); + + const run = await heartbeat.getRun(runId); + expect(run?.status).toBe("running"); + expect(run?.errorCode).toBe("process_detached"); + expect(run?.error).toContain(String(child.pid)); + + const wakeup = await db + .select() + .from(agentWakeupRequests) + .where(eq(agentWakeupRequests.id, wakeupRequestId)) + .then((rows) => rows[0] ?? null); + expect(wakeup?.status).toBe("claimed"); + }); + + it("queues exactly one retry when the recorded local pid is dead", async () => { + const { agentId, runId, issueId } = await seedRunFixture({ + processPid: 999_999_999, + }); + const heartbeat = heartbeatService(db); + + const result = await heartbeat.reapOrphanedRuns(); + expect(result.reaped).toBe(1); + expect(result.runIds).toEqual([runId]); + + const runs = await db + .select() + .from(heartbeatRuns) + .where(eq(heartbeatRuns.agentId, agentId)); + expect(runs).toHaveLength(2); + + const failedRun = runs.find((row) => row.id === runId); + const retryRun = runs.find((row) => row.id !== runId); + expect(failedRun?.status).toBe("failed"); + expect(failedRun?.errorCode).toBe("process_lost"); + expect(retryRun?.status).toBe("queued"); + expect(retryRun?.retryOfRunId).toBe(runId); + expect(retryRun?.processLossRetryCount).toBe(1); + + const issue = await db + .select() + .from(issues) + .where(eq(issues.id, issueId)) + .then((rows) => rows[0] ?? null); + expect(issue?.executionRunId).toBe(retryRun?.id ?? null); + expect(issue?.checkoutRunId).toBe(runId); + }); + + it("does not queue a second retry after the first process-loss retry was already used", async () => { + const { agentId, runId, issueId } = await seedRunFixture({ + processPid: 999_999_999, + processLossRetryCount: 1, + }); + const heartbeat = heartbeatService(db); + + const result = await heartbeat.reapOrphanedRuns(); + expect(result.reaped).toBe(1); + expect(result.runIds).toEqual([runId]); + + const runs = await db + .select() + .from(heartbeatRuns) + .where(eq(heartbeatRuns.agentId, agentId)); + expect(runs).toHaveLength(1); + expect(runs[0]?.status).toBe("failed"); + + const issue = await db + .select() + .from(issues) + .where(eq(issues.id, issueId)) + .then((rows) => rows[0] ?? null); + expect(issue?.executionRunId).toBeNull(); + expect(issue?.checkoutRunId).toBe(runId); + }); + + it("clears the detached warning when the run reports activity again", async () => { + const { runId } = await seedRunFixture({ + includeIssue: false, + runErrorCode: "process_detached", + runError: "Lost in-memory process handle, but child pid 123 is still alive", + }); + const heartbeat = heartbeatService(db); + + const updated = await heartbeat.reportRunActivity(runId); + expect(updated?.errorCode).toBeNull(); + expect(updated?.error).toBeNull(); + + const run = await heartbeat.getRun(runId); + expect(run?.errorCode).toBeNull(); + expect(run?.error).toBeNull(); + }); +}); diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index a966d12b..3e29bb47 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -2450,6 +2450,14 @@ export function accessRoutes( "member", "active" ); + await access.setPrincipalPermission( + companyId, + "agent", + created.id, + "tasks:assign", + true, + req.actor.userId ?? null + ); const grants = grantsFromDefaults( invite.defaultsPayload as Record | null, "agent" diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 80bebc2d..4263f6d9 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -84,6 +84,80 @@ export function agentRoutes(db: Db) { return Boolean((agent.permissions as Record).canCreateAgents); } + async function buildAgentAccessState(agent: NonNullable>>) { + const membership = await access.getMembership(agent.companyId, "agent", agent.id); + const grants = membership + ? await access.listPrincipalGrants(agent.companyId, "agent", agent.id) + : []; + const hasExplicitTaskAssignGrant = grants.some((grant) => grant.permissionKey === "tasks:assign"); + + if (agent.role === "ceo") { + return { + canAssignTasks: true, + taskAssignSource: "ceo_role" as const, + membership, + grants, + }; + } + + if (canCreateAgents(agent)) { + return { + canAssignTasks: true, + taskAssignSource: "agent_creator" as const, + membership, + grants, + }; + } + + if (hasExplicitTaskAssignGrant) { + return { + canAssignTasks: true, + taskAssignSource: "explicit_grant" as const, + membership, + grants, + }; + } + + return { + canAssignTasks: false, + taskAssignSource: "none" as const, + membership, + grants, + }; + } + + async function buildAgentDetail( + agent: NonNullable>>, + options?: { restricted?: boolean }, + ) { + const [chainOfCommand, accessState] = await Promise.all([ + svc.getChainOfCommand(agent.id), + buildAgentAccessState(agent), + ]); + + return { + ...(options?.restricted ? redactForRestrictedAgentView(agent) : agent), + chainOfCommand, + access: accessState, + }; + } + + async function applyDefaultAgentTaskAssignGrant( + companyId: string, + agentId: string, + grantedByUserId: string | null, + ) { + await access.ensureMembership(companyId, "agent", agentId, "member", "active"); + await access.setPrincipalPermission( + companyId, + "agent", + agentId, + "tasks:assign", + true, + grantedByUserId, + ); + } + async function assertCanCreateAgentsForCompany(req: Request, companyId: string) { assertCompanyAccess(req, companyId); if (req.actor.type === "board") { @@ -799,8 +873,7 @@ export function agentRoutes(db: Db) { res.status(404).json({ error: "Agent not found" }); return; } - const chainOfCommand = await svc.getChainOfCommand(agent.id); - res.json({ ...agent, chainOfCommand }); + res.json(await buildAgentDetail(agent)); }); router.get("/agents/me/inbox-lite", async (req, res) => { @@ -842,13 +915,11 @@ export function agentRoutes(db: Db) { if (req.actor.type === "agent" && req.actor.agentId !== id) { const canRead = await actorCanReadConfigurationsForCompany(req, agent.companyId); if (!canRead) { - const chainOfCommand = await svc.getChainOfCommand(agent.id); - res.json({ ...redactForRestrictedAgentView(agent), chainOfCommand }); + res.json(await buildAgentDetail(agent, { restricted: true })); return; } } - const chainOfCommand = await svc.getChainOfCommand(agent.id); - res.json({ ...agent, chainOfCommand }); + res.json(await buildAgentDetail(agent)); }); router.get("/agents/:id/configuration", async (req, res) => { @@ -1122,6 +1193,12 @@ export function agentRoutes(db: Db) { }, }); + await applyDefaultAgentTaskAssignGrant( + companyId, + agent.id, + actor.actorType === "user" ? actor.actorId : null, + ); + if (approval) { await logActivity(db, { companyId, @@ -1197,6 +1274,12 @@ export function agentRoutes(db: Db) { }, }); + await applyDefaultAgentTaskAssignGrant( + companyId, + agent.id, + req.actor.type === "board" ? (req.actor.userId ?? null) : null, + ); + if (agent.budgetMonthlyCents > 0) { await budgets.upsertPolicy( companyId, @@ -1240,6 +1323,18 @@ export function agentRoutes(db: Db) { return; } + const effectiveCanAssignTasks = + agent.role === "ceo" || Boolean(agent.permissions?.canCreateAgents) || req.body.canAssignTasks; + await access.ensureMembership(agent.companyId, "agent", agent.id, "member", "active"); + await access.setPrincipalPermission( + agent.companyId, + "agent", + agent.id, + "tasks:assign", + effectiveCanAssignTasks, + req.actor.type === "board" ? (req.actor.userId ?? null) : null, + ); + const actor = getActorInfo(req); await logActivity(db, { companyId: agent.companyId, @@ -1250,10 +1345,13 @@ export function agentRoutes(db: Db) { action: "agent.permissions_updated", entityType: "agent", entityId: agent.id, - details: req.body, + details: { + canCreateAgents: agent.permissions?.canCreateAgents ?? false, + canAssignTasks: effectiveCanAssignTasks, + }, }); - res.json(agent); + res.json(await buildAgentDetail(agent)); }); router.patch("/agents/:id/instructions-path", validate(updateAgentInstructionsPathSchema), async (req, res) => { diff --git a/server/src/routes/companies.ts b/server/src/routes/companies.ts index a86655bc..4c2d4807 100644 --- a/server/src/routes/companies.ts +++ b/server/src/routes/companies.ts @@ -7,6 +7,7 @@ import { createCompanySchema, updateCompanyBrandingSchema, updateCompanySchema, + updateCompanyBrandingSchema, } from "@paperclipai/shared"; import { forbidden } from "../errors.js"; import { validate } from "../middleware/validate.js"; @@ -90,9 +91,12 @@ export function companyRoutes(db: Db, storage?: StorageService) { }); router.get("/:companyId", async (req, res) => { - assertBoard(req); const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); + // Allow agents (CEO) to read their own company; board always allowed + if (req.actor.type !== "agent") { + assertBoard(req); + } const company = await svc.getById(companyId); if (!company) { res.status(404).json({ error: "Company not found" }); @@ -238,23 +242,44 @@ export function companyRoutes(db: Db, storage?: StorageService) { res.status(201).json(company); }); - router.patch("/:companyId", validate(updateCompanySchema), async (req, res) => { - assertBoard(req); + router.patch("/:companyId", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); - const company = await svc.update(companyId, req.body); + + const actor = getActorInfo(req); + let body: Record; + + if (req.actor.type === "agent") { + // Only CEO agents may update company branding fields + const agentSvc = agentService(db); + const actorAgent = req.actor.agentId ? await agentSvc.getById(req.actor.agentId) : null; + if (!actorAgent || actorAgent.role !== "ceo") { + throw forbidden("Only CEO agents or board users may update company settings"); + } + if (actorAgent.companyId !== companyId) { + throw forbidden("Agent key cannot access another company"); + } + body = updateCompanyBrandingSchema.parse(req.body); + } else { + assertBoard(req); + body = updateCompanySchema.parse(req.body); + } + + const company = await svc.update(companyId, body); if (!company) { res.status(404).json({ error: "Company not found" }); return; } await logActivity(db, { companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, action: "company.updated", entityType: "company", entityId: companyId, - details: req.body, + details: body, }); res.json(company); }); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 6900b085..b5ee52cf 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -826,6 +826,7 @@ export function issueRoutes(db: Db, storage: StorageService) { } if (!(await assertAgentRunCheckoutOwnership(req, res, existing))) return; + const actor = getActorInfo(req); const { comment: commentBody, hiddenAt: hiddenAtRaw, ...updateFields } = req.body; if (hiddenAtRaw !== undefined) { updateFields.hiddenAt = hiddenAtRaw ? new Date(hiddenAtRaw) : null; @@ -863,6 +864,11 @@ export function issueRoutes(db: Db, storage: StorageService) { } await routinesSvc.syncRunStatusForIssue(issue.id); + if (actor.runId) { + await heartbeat.reportRunActivity(actor.runId).catch((err) => + logger.warn({ err, runId: actor.runId }, "failed to clear detached run warning after issue activity")); + } + // Build activity details with previous values for changed fields const previous: Record = {}; for (const key of Object.keys(updateFields)) { @@ -871,7 +877,6 @@ export function issueRoutes(db: Db, storage: StorageService) { } } - const actor = getActorInfo(req); const hasFieldChanges = Object.keys(previous).length > 0; await logActivity(db, { companyId: issue.companyId, @@ -1285,6 +1290,11 @@ export function issueRoutes(db: Db, storage: StorageService) { userId: actor.actorType === "user" ? actor.actorId : undefined, }); + if (actor.runId) { + await heartbeat.reportRunActivity(actor.runId).catch((err) => + logger.warn({ err, runId: actor.runId }, "failed to clear detached run warning after issue comment")); + } + await logActivity(db, { companyId: currentIssue.companyId, actorType: actor.actorType, diff --git a/server/src/services/access.ts b/server/src/services/access.ts index 0ba74d3b..3e30e1ab 100644 --- a/server/src/services/access.ts +++ b/server/src/services/access.ts @@ -279,6 +279,86 @@ export function accessService(db: Db) { return sourceMemberships; } + async function listPrincipalGrants( + companyId: string, + principalType: PrincipalType, + principalId: string, + ) { + return db + .select() + .from(principalPermissionGrants) + .where( + and( + eq(principalPermissionGrants.companyId, companyId), + eq(principalPermissionGrants.principalType, principalType), + eq(principalPermissionGrants.principalId, principalId), + ), + ) + .orderBy(principalPermissionGrants.permissionKey); + } + + async function setPrincipalPermission( + companyId: string, + principalType: PrincipalType, + principalId: string, + permissionKey: PermissionKey, + enabled: boolean, + grantedByUserId: string | null, + scope: Record | null = null, + ) { + if (!enabled) { + await db + .delete(principalPermissionGrants) + .where( + and( + eq(principalPermissionGrants.companyId, companyId), + eq(principalPermissionGrants.principalType, principalType), + eq(principalPermissionGrants.principalId, principalId), + eq(principalPermissionGrants.permissionKey, permissionKey), + ), + ); + return; + } + + await ensureMembership(companyId, principalType, principalId, "member", "active"); + + const existing = await db + .select() + .from(principalPermissionGrants) + .where( + and( + eq(principalPermissionGrants.companyId, companyId), + eq(principalPermissionGrants.principalType, principalType), + eq(principalPermissionGrants.principalId, principalId), + eq(principalPermissionGrants.permissionKey, permissionKey), + ), + ) + .then((rows) => rows[0] ?? null); + + if (existing) { + await db + .update(principalPermissionGrants) + .set({ + scope, + grantedByUserId, + updatedAt: new Date(), + }) + .where(eq(principalPermissionGrants.id, existing.id)); + return; + } + + await db.insert(principalPermissionGrants).values({ + companyId, + principalType, + principalId, + permissionKey, + scope, + grantedByUserId, + createdAt: new Date(), + updatedAt: new Date(), + }); + } + return { isInstanceAdmin, canUser, @@ -294,5 +374,7 @@ export function accessService(db: Db) { listUserCompanyAccess, setUserCompanyAccess, setPrincipalGrants, + listPrincipalGrants, + setPrincipalPermission, }; } diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index 828f68f2..097c31e3 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -3076,6 +3076,15 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { } let created = await agents.create(targetCompany.id, patch); + await access.ensureMembership(targetCompany.id, "agent", created.id, "member", "active"); + await access.setPrincipalPermission( + targetCompany.id, + "agent", + created.id, + "tasks:assign", + true, + actorUserId ?? null, + ); try { const materialized = await instructions.materializeManagedBundle(created, bundleFiles, { clearLegacyPromptTemplate: true, diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 062b55e1..9a351583 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -61,6 +61,7 @@ const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024; const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = 1; const HEARTBEAT_MAX_CONCURRENT_RUNS_MAX = 10; const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext"; +const DETACHED_PROCESS_ERROR_CODE = "process_detached"; const startLocksByAgent = new Map>(); const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__"; const MANAGED_WORKSPACE_GIT_CLONE_TIMEOUT_MS = 10 * 60 * 1000; @@ -164,6 +165,10 @@ const heartbeatRunListColumns = { stderrExcerpt: sql`NULL`.as("stderrExcerpt"), errorCode: heartbeatRuns.errorCode, externalRunId: heartbeatRuns.externalRunId, + processPid: heartbeatRuns.processPid, + processStartedAt: heartbeatRuns.processStartedAt, + retryOfRunId: heartbeatRuns.retryOfRunId, + processLossRetryCount: heartbeatRuns.processLossRetryCount, contextSnapshot: heartbeatRuns.contextSnapshot, createdAt: heartbeatRuns.createdAt, updatedAt: heartbeatRuns.updatedAt, @@ -599,6 +604,26 @@ function isSameTaskScope(left: string | null, right: string | null) { return (left ?? null) === (right ?? null); } +function isTrackedLocalChildProcessAdapter(adapterType: string) { + return SESSIONED_LOCAL_ADAPTERS.has(adapterType); +} + +// A positive liveness check means some process currently owns the PID. +// On Linux, PIDs can be recycled, so this is a best-effort signal rather +// than proof that the original child is still alive. +function isProcessAlive(pid: number | null | undefined) { + if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (code === "EPERM") return true; + if (code === "ESRCH") return false; + return false; + } +} + function truncateDisplayId(value: string | null | undefined, max = 128) { if (!value) return null; return value.length > max ? value.slice(0, max) : value; @@ -1328,6 +1353,156 @@ export function heartbeatService(db: Db) { }); } + async function nextRunEventSeq(runId: string) { + const [row] = await db + .select({ maxSeq: sql`max(${heartbeatRunEvents.seq})` }) + .from(heartbeatRunEvents) + .where(eq(heartbeatRunEvents.runId, runId)); + return Number(row?.maxSeq ?? 0) + 1; + } + + async function persistRunProcessMetadata( + runId: string, + meta: { pid: number; startedAt: string }, + ) { + const startedAt = new Date(meta.startedAt); + return db + .update(heartbeatRuns) + .set({ + processPid: meta.pid, + processStartedAt: Number.isNaN(startedAt.getTime()) ? new Date() : startedAt, + updatedAt: new Date(), + }) + .where(eq(heartbeatRuns.id, runId)) + .returning() + .then((rows) => rows[0] ?? null); + } + + async function clearDetachedRunWarning(runId: string) { + const updated = await db + .update(heartbeatRuns) + .set({ + error: null, + errorCode: null, + updatedAt: new Date(), + }) + .where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.status, "running"), eq(heartbeatRuns.errorCode, DETACHED_PROCESS_ERROR_CODE))) + .returning() + .then((rows) => rows[0] ?? null); + if (!updated) return null; + + await appendRunEvent(updated, await nextRunEventSeq(updated.id), { + eventType: "lifecycle", + stream: "system", + level: "info", + message: "Detached child process reported activity; cleared detached warning", + }); + return updated; + } + + async function enqueueProcessLossRetry( + run: typeof heartbeatRuns.$inferSelect, + agent: typeof agents.$inferSelect, + now: Date, + ) { + const contextSnapshot = parseObject(run.contextSnapshot); + const issueId = readNonEmptyString(contextSnapshot.issueId); + const taskKey = deriveTaskKey(contextSnapshot, null); + const sessionBefore = await resolveSessionBeforeForWakeup(agent, taskKey); + const retryContextSnapshot = { + ...contextSnapshot, + retryOfRunId: run.id, + wakeReason: "process_lost_retry", + retryReason: "process_lost", + }; + + const queued = await db.transaction(async (tx) => { + const wakeupRequest = await tx + .insert(agentWakeupRequests) + .values({ + companyId: run.companyId, + agentId: run.agentId, + source: "automation", + triggerDetail: "system", + reason: "process_lost_retry", + payload: { + ...(issueId ? { issueId } : {}), + retryOfRunId: run.id, + }, + status: "queued", + requestedByActorType: "system", + requestedByActorId: null, + updatedAt: now, + }) + .returning() + .then((rows) => rows[0]); + + const retryRun = await tx + .insert(heartbeatRuns) + .values({ + companyId: run.companyId, + agentId: run.agentId, + invocationSource: "automation", + triggerDetail: "system", + status: "queued", + wakeupRequestId: wakeupRequest.id, + contextSnapshot: retryContextSnapshot, + sessionIdBefore: sessionBefore, + retryOfRunId: run.id, + processLossRetryCount: (run.processLossRetryCount ?? 0) + 1, + updatedAt: now, + }) + .returning() + .then((rows) => rows[0]); + + await tx + .update(agentWakeupRequests) + .set({ + runId: retryRun.id, + updatedAt: now, + }) + .where(eq(agentWakeupRequests.id, wakeupRequest.id)); + + if (issueId) { + await tx + .update(issues) + .set({ + executionRunId: retryRun.id, + executionAgentNameKey: normalizeAgentNameKey(agent.name), + executionLockedAt: now, + updatedAt: now, + }) + .where(and(eq(issues.id, issueId), eq(issues.companyId, run.companyId), eq(issues.executionRunId, run.id))); + } + + return retryRun; + }); + + publishLiveEvent({ + companyId: queued.companyId, + type: "heartbeat.run.queued", + payload: { + runId: queued.id, + agentId: queued.agentId, + invocationSource: queued.invocationSource, + triggerDetail: queued.triggerDetail, + wakeupRequestId: queued.wakeupRequestId, + }, + }); + + await appendRunEvent(queued, 1, { + eventType: "lifecycle", + stream: "system", + level: "warn", + message: "Queued automatic retry after orphaned child process was confirmed dead", + payload: { + retryOfRunId: run.id, + }, + }); + + return queued; + } + function parseHeartbeatPolicy(agent: typeof agents.$inferSelect) { const runtimeConfig = parseObject(agent.runtimeConfig); const heartbeat = parseObject(runtimeConfig.heartbeat); @@ -1455,13 +1630,17 @@ export function heartbeatService(db: Db) { // Find all runs stuck in "running" state (queued runs are legitimately waiting; resumeQueuedRuns handles them) const activeRuns = await db - .select() + .select({ + run: heartbeatRuns, + adapterType: agents.adapterType, + }) .from(heartbeatRuns) + .innerJoin(agents, eq(heartbeatRuns.agentId, agents.id)) .where(eq(heartbeatRuns.status, "running")); const reaped: string[] = []; - for (const run of activeRuns) { + for (const { run, adapterType } of activeRuns) { if (runningProcesses.has(run.id) || activeRunExecutions.has(run.id)) continue; // Apply staleness threshold to avoid false positives @@ -1470,25 +1649,69 @@ export function heartbeatService(db: Db) { if (now.getTime() - refTime < staleThresholdMs) continue; } - await setRunStatus(run.id, "failed", { - error: "Process lost -- server may have restarted", + const tracksLocalChild = isTrackedLocalChildProcessAdapter(adapterType); + if (tracksLocalChild && run.processPid && isProcessAlive(run.processPid)) { + if (run.errorCode !== DETACHED_PROCESS_ERROR_CODE) { + const detachedMessage = `Lost in-memory process handle, but child pid ${run.processPid} is still alive`; + const detachedRun = await setRunStatus(run.id, "running", { + error: detachedMessage, + errorCode: DETACHED_PROCESS_ERROR_CODE, + }); + if (detachedRun) { + await appendRunEvent(detachedRun, await nextRunEventSeq(detachedRun.id), { + eventType: "lifecycle", + stream: "system", + level: "warn", + message: detachedMessage, + payload: { + processPid: run.processPid, + }, + }); + } + } + continue; + } + + const shouldRetry = tracksLocalChild && !!run.processPid && (run.processLossRetryCount ?? 0) < 1; + const baseMessage = run.processPid + ? `Process lost -- child pid ${run.processPid} is no longer running` + : "Process lost -- server may have restarted"; + + let finalizedRun = await setRunStatus(run.id, "failed", { + error: shouldRetry ? `${baseMessage}; retrying once` : baseMessage, errorCode: "process_lost", finishedAt: now, }); await setWakeupStatus(run.wakeupRequestId, "failed", { finishedAt: now, - error: "Process lost -- server may have restarted", + error: shouldRetry ? `${baseMessage}; retrying once` : baseMessage, }); - const updatedRun = await getRun(run.id); - if (updatedRun) { - await appendRunEvent(updatedRun, 1, { - eventType: "lifecycle", - stream: "system", - level: "error", - message: "Process lost -- server may have restarted", - }); - await releaseIssueExecutionAndPromote(updatedRun); + if (!finalizedRun) finalizedRun = await getRun(run.id); + if (!finalizedRun) continue; + + let retriedRun: typeof heartbeatRuns.$inferSelect | null = null; + if (shouldRetry) { + const agent = await getAgent(run.agentId); + if (agent) { + retriedRun = await enqueueProcessLossRetry(finalizedRun, agent, now); + } + } else { + await releaseIssueExecutionAndPromote(finalizedRun); } + + await appendRunEvent(finalizedRun, await nextRunEventSeq(finalizedRun.id), { + eventType: "lifecycle", + stream: "system", + level: "error", + message: shouldRetry + ? `${baseMessage}; queued retry ${retriedRun?.id ?? ""}`.trim() + : baseMessage, + payload: { + ...(run.processPid ? { processPid: run.processPid } : {}), + ...(retriedRun ? { retryRunId: retriedRun.id } : {}), + }, + }); + await finalizeAgentStatus(run.agentId, "failed"); await startNextQueuedRunForAgent(run.agentId); runningProcesses.delete(run.id); @@ -2159,6 +2382,9 @@ export function heartbeatService(db: Db) { context, onLog, onMeta: onAdapterMeta, + onSpawn: async (meta) => { + await persistRunProcessMetadata(run.id, meta); + }, authToken: authToken ?? undefined, }); const adapterManagedRuntimeServices = adapterResult.runtimeServices @@ -3410,6 +3636,8 @@ export function heartbeatService(db: Db) { wakeup: enqueueWakeup, + reportRunActivity: clearDetachedRunWarning, + reapOrphanedRuns, resumeQueuedRuns, diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index 05b42cfb..ee5ac2ae 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -71,6 +71,8 @@ Read enough ancestor/comment context to understand _why_ the task exists and wha **Step 8 — Update status and communicate.** Always include the run ID header. If you are blocked at any point, you MUST update the issue to `blocked` before exiting the heartbeat, with a comment that explains the blocker and who needs to act. +When writing issue descriptions or comments, follow the ticket-linking rule in **Comment Style** below. + ```json PATCH /api/issues/{issueId} Headers: X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID @@ -155,12 +157,19 @@ If you are asked to install a skill for the company or an agent you MUST read: ## Comment Style (Required) -When posting issue comments, use concise markdown with: +When posting issue comments or writing issue descriptions, use concise markdown with: - a short status line - bullets for what changed / what is blocked - links to related entities when available +**Ticket references are links (required):** If you mention another issue identifier such as `PAP-224`, `ZED-24`, or any `{PREFIX}-{NUMBER}` ticket id inside a comment body or issue description, wrap it in a Markdown link: + +- `[PAP-224](/PAP/issues/PAP-224)` +- `[ZED-24](/ZED/issues/ZED-24)` + +Never leave bare ticket ids in issue descriptions or comments when a clickable internal link can be provided. + **Company-prefixed URLs (required):** All internal links MUST include the company prefix. Derive the prefix from any issue identifier you have (e.g., `PAP-315` → prefix is `PAP`). Use this prefix in all UI links: - Issues: `//issues/` (e.g., `/PAP/issues/PAP-224`) @@ -182,7 +191,8 @@ Submitted CTO hire request and linked it for board review. - Approval: [ca6ba09d](/PAP/approvals/ca6ba09d-b558-4a53-a552-e7ef87e54a1b) - Pending agent: [CTO draft](/PAP/agents/cto) -- Source issue: [PC-142](/PAP/issues/PC-142) +- Source issue: [PAP-142](/PAP/issues/PAP-142) +- Depends on: [PAP-224](/PAP/issues/PAP-224) ``` ## Planning (Required when planning requested) diff --git a/skills/paperclip/references/api-reference.md b/skills/paperclip/references/api-reference.md index be4e2143..63293725 100644 --- a/skills/paperclip/references/api-reference.md +++ b/skills/paperclip/references/api-reference.md @@ -346,6 +346,26 @@ GET /api/companies/{companyId}/dashboard — health summary: agent/task counts, Use the dashboard for situational awareness, especially if you're a manager or CEO. +## Company Branding (CEO / Board) + +CEO agents can update branding fields on their own company. Board users can update all fields. + +``` +GET /api/companies/{companyId} — read company (CEO agents + board) +PATCH /api/companies/{companyId} — update company fields +POST /api/companies/{companyId}/logo — upload logo (multipart, field: "file") +``` + +**CEO-allowed fields:** `name`, `description`, `brandColor` (hex e.g. `#FF5733` or null), `logoAssetId` (UUID or null). + +**Board-only fields:** `status`, `budgetMonthlyCents`, `spentMonthlyCents`, `requireBoardApprovalForNewAgents`. + +**Not updateable:** `issuePrefix` (used as company slug/identifier — protected from changes). + +**Logo workflow:** +1. `POST /api/companies/{companyId}/logo` with file upload → returns `{ assetId }`. +2. `PATCH /api/companies/{companyId}` with `{ "logoAssetId": "" }`. + ## OpenClaw Invite Prompt (CEO) Use this endpoint to generate a short-lived OpenClaw onboarding invite prompt: diff --git a/skills/paperclip/references/company-skills.md b/skills/paperclip/references/company-skills.md index 852424cc..719a887e 100644 --- a/skills/paperclip/references/company-skills.md +++ b/skills/paperclip/references/company-skills.md @@ -34,7 +34,42 @@ The canonical model is: ## Install A Skill Into The Company -Import from GitHub, a local path, or a `skills.sh`-style source string: +Import using a **skills.sh URL**, a key-style source string, a GitHub URL, or a local path. + +### Source types (in order of preference) + +| Source format | Example | When to use | +|---|---|---| +| **skills.sh URL** | `https://skills.sh/google-labs-code/stitch-skills/design-md` | When a user gives you a `skills.sh` link. This is the managed skill registry — **always prefer it when available**. | +| **Key-style string** | `google-labs-code/stitch-skills/design-md` | Shorthand for the same skill — `org/repo/skill-name` format. Equivalent to the skills.sh URL. | +| **GitHub URL** | `https://github.com/vercel-labs/agent-browser` | When the skill is in a GitHub repo but not on skills.sh. | +| **Local path** | `/abs/path/to/skill-dir` | When the skill is on disk (dev/testing only). | + +**Critical:** If a user gives you a `https://skills.sh/...` URL, use that URL or its key-style equivalent (`org/repo/skill-name`) as the `source`. Do **not** convert it to a GitHub URL — skills.sh is the managed registry and the source of truth for versioning, discovery, and updates. + +### Example: skills.sh import (preferred) + +```sh +curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/skills/import" \ + -H "Authorization: Bearer $PAPERCLIP_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "source": "https://skills.sh/google-labs-code/stitch-skills/design-md" + }' +``` + +Or equivalently using the key-style string: + +```sh +curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/skills/import" \ + -H "Authorization: Bearer $PAPERCLIP_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "source": "google-labs-code/stitch-skills/design-md" + }' +``` + +### Example: GitHub import ```sh curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/skills/import" \ @@ -45,10 +80,11 @@ curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/skills/ }' ``` -You can also use a source string such as: +You can also use source strings such as: -- `npx skills add https://github.com/vercel-labs/agent-browser --skill agent-browser` +- `google-labs-code/stitch-skills/design-md` - `vercel-labs/agent-browser/agent-browser` +- `npx skills add https://github.com/vercel-labs/agent-browser --skill agent-browser` If the task is to discover skills from the company project workspaces first: diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index f7bcdc5a..ccaf15c0 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -1,5 +1,6 @@ import type { Agent, + AgentDetail, AgentInstructionsBundle, AgentInstructionsFileDetail, AgentSkillSnapshot, @@ -48,6 +49,11 @@ export interface AgentHireResponse { approval: Approval | null; } +export interface AgentPermissionUpdate { + canCreateAgents: boolean; + canAssignTasks: boolean; +} + function withCompanyScope(path: string, companyId?: string) { if (!companyId) return path; const separator = path.includes("?") ? "&" : "?"; @@ -65,7 +71,7 @@ export const agentsApi = { api.get[]>(`/companies/${companyId}/agent-configurations`), get: async (id: string, companyId?: string) => { try { - return await api.get(agentPath(id, companyId)); + return await api.get(agentPath(id, companyId)); } catch (error) { // Backward-compat fallback: if backend shortname lookup reports ambiguity, // resolve using company agent list while ignoring terminated agents. @@ -86,7 +92,7 @@ export const agentsApi = { (agent) => agent.status !== "terminated" && normalizeAgentUrlKey(agent.urlKey) === urlKey, ); if (matches.length !== 1) throw error; - return api.get(agentPath(matches[0]!.id, companyId)); + return api.get(agentPath(matches[0]!.id, companyId)); } }, getConfiguration: (id: string, companyId?: string) => @@ -103,8 +109,8 @@ export const agentsApi = { api.post(`/companies/${companyId}/agent-hires`, data), update: (id: string, data: Record, companyId?: string) => api.patch(agentPath(id, companyId), data), - updatePermissions: (id: string, data: { canCreateAgents: boolean }, companyId?: string) => - api.patch(agentPath(id, companyId, "/permissions"), data), + updatePermissions: (id: string, data: AgentPermissionUpdate, companyId?: string) => + api.patch(agentPath(id, companyId, "/permissions"), data), instructionsBundle: (id: string, companyId?: string) => api.get(agentPath(id, companyId, "/instructions-bundle")), updateInstructionsBundle: ( diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index d7084c29..086c0c13 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -957,7 +957,7 @@ function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestRe /* ---- Internal sub-components ---- */ -const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "cursor"]); +const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor"]); /** Display list includes all real adapter types plus UI-only coming-soon entries. */ const ADAPTER_DISPLAY_LIST: { value: string; label: string; comingSoon: boolean }[] = [ diff --git a/ui/src/components/ApprovalCard.tsx b/ui/src/components/ApprovalCard.tsx index ee0a4163..2123fc57 100644 --- a/ui/src/components/ApprovalCard.tsx +++ b/ui/src/components/ApprovalCard.tsx @@ -2,7 +2,7 @@ import { CheckCircle2, XCircle, Clock } from "lucide-react"; import { Link } from "@/lib/router"; import { Button } from "@/components/ui/button"; import { Identity } from "./Identity"; -import { typeLabel, typeIcon, defaultTypeIcon, ApprovalPayloadRenderer } from "./ApprovalPayload"; +import { approvalLabel, typeIcon, defaultTypeIcon, ApprovalPayloadRenderer } from "./ApprovalPayload"; import { timeAgo } from "../lib/timeAgo"; import type { Approval, Agent } from "@paperclipai/shared"; @@ -32,7 +32,7 @@ export function ApprovalCard({ isPending: boolean; }) { const Icon = typeIcon[approval.type] ?? defaultTypeIcon; - const label = typeLabel[approval.type] ?? approval.type; + const label = approvalLabel(approval.type, approval.payload as Record | null); const showResolutionButtons = approval.type !== "budget_override_required" && (approval.status === "pending" || approval.status === "revision_requested"); diff --git a/ui/src/components/ApprovalPayload.tsx b/ui/src/components/ApprovalPayload.tsx index db7e3674..83b55c73 100644 --- a/ui/src/components/ApprovalPayload.tsx +++ b/ui/src/components/ApprovalPayload.tsx @@ -7,6 +7,15 @@ export const typeLabel: Record = { budget_override_required: "Budget Override", }; +/** Build a contextual label for an approval, e.g. "Hire Agent: Designer" */ +export function approvalLabel(type: string, payload?: Record | null): string { + const base = typeLabel[type] ?? type; + if (type === "hire_agent" && payload?.name) { + return `${base}: ${String(payload.name)}`; + } + return base; +} + export const typeIcon: Record = { hire_agent: UserPlus, approve_ceo_strategy: Lightbulb, diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx index 8e042acf..0e97f31a 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -46,6 +46,7 @@ interface CommentThreadProps { enableReassign?: boolean; reassignOptions?: InlineEntityOption[]; currentAssigneeValue?: string; + suggestedAssigneeValue?: string; mentions?: MentionOption[]; } @@ -269,13 +270,15 @@ export function CommentThread({ enableReassign = false, reassignOptions = [], currentAssigneeValue = "", + suggestedAssigneeValue, mentions: providedMentions, }: CommentThreadProps) { const [body, setBody] = useState(""); const [reopen, setReopen] = useState(true); const [submitting, setSubmitting] = useState(false); const [attaching, setAttaching] = useState(false); - const [reassignTarget, setReassignTarget] = useState(currentAssigneeValue); + const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue; + const [reassignTarget, setReassignTarget] = useState(effectiveSuggestedAssigneeValue); const [highlightCommentId, setHighlightCommentId] = useState(null); const editorRef = useRef(null); const attachInputRef = useRef(null); @@ -337,8 +340,8 @@ export function CommentThread({ }, []); useEffect(() => { - setReassignTarget(currentAssigneeValue); - }, [currentAssigneeValue]); + setReassignTarget(effectiveSuggestedAssigneeValue); + }, [effectiveSuggestedAssigneeValue]); // Scroll to comment when URL hash matches #comment-{id} useEffect(() => { @@ -370,7 +373,7 @@ export function CommentThread({ setBody(""); if (draftKey) clearDraft(draftKey); setReopen(false); - setReassignTarget(currentAssigneeValue); + setReassignTarget(effectiveSuggestedAssigneeValue); } finally { setSubmitting(false); } diff --git a/ui/src/components/NewProjectDialog.tsx b/ui/src/components/NewProjectDialog.tsx index 6378dfda..4561ac93 100644 --- a/ui/src/components/NewProjectDialog.tsx +++ b/ui/src/components/NewProjectDialog.tsx @@ -23,10 +23,13 @@ import { Calendar, Plus, X, - FolderOpen, - Github, - GitBranch, + HelpCircle, } from "lucide-react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { PROJECT_COLORS } from "@paperclipai/shared"; import { cn } from "../lib/utils"; import { MarkdownEditor, type MarkdownEditorRef } from "./MarkdownEditor"; @@ -41,8 +44,6 @@ const projectStatuses = [ { value: "cancelled", label: "Cancelled" }, ]; -type WorkspaceSetup = "none" | "local" | "repo" | "both"; - export function NewProjectDialog() { const { newProjectOpen, closeNewProject } = useDialog(); const { selectedCompanyId, selectedCompany } = useCompany(); @@ -53,7 +54,6 @@ export function NewProjectDialog() { const [goalIds, setGoalIds] = useState([]); const [targetDate, setTargetDate] = useState(""); const [expanded, setExpanded] = useState(false); - const [workspaceSetup, setWorkspaceSetup] = useState("none"); const [workspaceLocalPath, setWorkspaceLocalPath] = useState(""); const [workspaceRepoUrl, setWorkspaceRepoUrl] = useState(""); const [workspaceError, setWorkspaceError] = useState(null); @@ -87,7 +87,6 @@ export function NewProjectDialog() { setGoalIds([]); setTargetDate(""); setExpanded(false); - setWorkspaceSetup("none"); setWorkspaceLocalPath(""); setWorkspaceRepoUrl(""); setWorkspaceError(null); @@ -124,23 +123,16 @@ export function NewProjectDialog() { } }; - const toggleWorkspaceSetup = (next: WorkspaceSetup) => { - setWorkspaceSetup((prev) => (prev === next ? "none" : next)); - setWorkspaceError(null); - }; - async function handleSubmit() { if (!selectedCompanyId || !name.trim()) return; - const localRequired = workspaceSetup === "local" || workspaceSetup === "both"; - const repoRequired = workspaceSetup === "repo" || workspaceSetup === "both"; const localPath = workspaceLocalPath.trim(); const repoUrl = workspaceRepoUrl.trim(); - if (localRequired && !isAbsolutePath(localPath)) { + if (localPath && !isAbsolutePath(localPath)) { setWorkspaceError("Local folder must be a full absolute path."); return; } - if (repoRequired && !isGitHubRepoUrl(repoUrl)) { + if (repoUrl && !isGitHubRepoUrl(repoUrl)) { setWorkspaceError("Repo must use a valid GitHub repo URL."); return; } @@ -157,28 +149,15 @@ export function NewProjectDialog() { ...(targetDate ? { targetDate } : {}), }); - const workspacePayloads: Array> = []; - if (localRequired && repoRequired) { - workspacePayloads.push({ - name: deriveWorkspaceNameFromPath(localPath), - cwd: localPath, - repoUrl, - }); - } else if (localRequired) { - workspacePayloads.push({ - name: deriveWorkspaceNameFromPath(localPath), - cwd: localPath, - }); - } else if (repoRequired) { - workspacePayloads.push({ - name: deriveWorkspaceNameFromRepo(repoUrl), - repoUrl, - }); - } - for (const workspacePayload of workspacePayloads) { - await projectsApi.createWorkspace(created.id, { - ...workspacePayload, - }); + if (localPath || repoUrl) { + const workspacePayload: Record = { + name: localPath + ? deriveWorkspaceNameFromPath(localPath) + : deriveWorkspaceNameFromRepo(repoUrl), + ...(localPath ? { cwd: localPath } : {}), + ...(repoUrl ? { repoUrl } : {}), + }; + await projectsApi.createWorkspace(created.id, workspacePayload); } queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(selectedCompanyId) }); @@ -279,81 +258,52 @@ export function NewProjectDialog() { /> -
-
-

Where will work be done on this project?

-

Add a repo and/or local folder for this project.

-
-
- - - +
+
+
+ + optional + + + + + + Link a GitHub repository so agents can clone, read, and push code for this project. + + +
+ { setWorkspaceRepoUrl(e.target.value); setWorkspaceError(null); }} + placeholder="https://github.com/org/repo" + />
- {(workspaceSetup === "local" || workspaceSetup === "both") && ( -
- -
- setWorkspaceLocalPath(e.target.value)} - placeholder="/absolute/path/to/workspace" - /> - -
+
+
+ + optional + + + + + + Set an absolute path on this machine where local agents will read and write files for this project. + +
- )} - {(workspaceSetup === "repo" || workspaceSetup === "both") && ( -
- +
setWorkspaceRepoUrl(e.target.value)} - placeholder="https://github.com/org/repo" + className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none" + value={workspaceLocalPath} + onChange={(e) => { setWorkspaceLocalPath(e.target.value); setWorkspaceError(null); }} + placeholder="/absolute/path/to/workspace" /> +
- )} +
+ {workspaceError && (

{workspaceError}

)} diff --git a/ui/src/components/transcript/RunTranscriptView.tsx b/ui/src/components/transcript/RunTranscriptView.tsx index 5f42ec0e..cd52dbc1 100644 --- a/ui/src/components/transcript/RunTranscriptView.tsx +++ b/ui/src/components/transcript/RunTranscriptView.tsx @@ -400,7 +400,7 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole type: "tool", ts: entry.ts, endTs: entry.ts, - name: "tool", + name: entry.toolName ?? "tool", toolUseId: entry.toolUseId, input: null, result: entry.content, diff --git a/ui/src/lib/assignees.test.ts b/ui/src/lib/assignees.test.ts index 1ce22ef7..225f7133 100644 --- a/ui/src/lib/assignees.test.ts +++ b/ui/src/lib/assignees.test.ts @@ -4,6 +4,7 @@ import { currentUserAssigneeOption, formatAssigneeUserLabel, parseAssigneeValue, + suggestedCommentAssigneeValue, } from "./assignees"; describe("assignee selection helpers", () => { @@ -50,4 +51,42 @@ describe("assignee selection helpers", () => { expect(formatAssigneeUserLabel("local-board", "someone-else")).toBe("Board"); expect(formatAssigneeUserLabel("user-abcdef", "someone-else")).toBe("user-"); }); + + it("suggests the last non-me commenter without changing the actual assignee encoding", () => { + expect( + suggestedCommentAssigneeValue( + { assigneeUserId: "board-user" }, + [ + { authorUserId: "board-user" }, + { authorAgentId: "agent-123" }, + ], + "board-user", + ), + ).toBe("agent:agent-123"); + }); + + it("falls back to the actual assignee when there is no better commenter hint", () => { + expect( + suggestedCommentAssigneeValue( + { assigneeUserId: "board-user" }, + [{ authorUserId: "board-user" }], + "board-user", + ), + ).toBe("user:board-user"); + }); + + it("skips the current agent when choosing a suggested commenter assignee", () => { + expect( + suggestedCommentAssigneeValue( + { assigneeUserId: "board-user" }, + [ + { authorUserId: "board-user" }, + { authorAgentId: "agent-self" }, + { authorAgentId: "agent-123" }, + ], + null, + "agent-self", + ), + ).toBe("agent:agent-123"); + }); }); diff --git a/ui/src/lib/assignees.ts b/ui/src/lib/assignees.ts index 274bcd40..0dc57e96 100644 --- a/ui/src/lib/assignees.ts +++ b/ui/src/lib/assignees.ts @@ -9,12 +9,43 @@ export interface AssigneeOption { searchText?: string; } +interface CommentAssigneeSuggestionInput { + assigneeAgentId?: string | null; + assigneeUserId?: string | null; +} + +interface CommentAssigneeSuggestionComment { + authorAgentId?: string | null; + authorUserId?: string | null; +} + export function assigneeValueFromSelection(selection: Partial): string { if (selection.assigneeAgentId) return `agent:${selection.assigneeAgentId}`; if (selection.assigneeUserId) return `user:${selection.assigneeUserId}`; return ""; } +export function suggestedCommentAssigneeValue( + issue: CommentAssigneeSuggestionInput, + comments: CommentAssigneeSuggestionComment[] | null | undefined, + currentUserId: string | null | undefined, + currentAgentId?: string | null | undefined, +): string { + if (comments && comments.length > 0 && (currentUserId || currentAgentId)) { + for (let i = comments.length - 1; i >= 0; i--) { + const comment = comments[i]; + if (comment.authorAgentId && comment.authorAgentId !== currentAgentId) { + return assigneeValueFromSelection({ assigneeAgentId: comment.authorAgentId }); + } + if (comment.authorUserId && comment.authorUserId !== currentUserId) { + return assigneeValueFromSelection({ assigneeUserId: comment.authorUserId }); + } + } + } + + return assigneeValueFromSelection(issue); +} + export function parseAssigneeValue(value: string): AssigneeSelection { if (!value) { return { assigneeAgentId: null, assigneeUserId: null }; diff --git a/ui/src/lib/inbox.test.ts b/ui/src/lib/inbox.test.ts index 4821b6cf..2a9f629e 100644 --- a/ui/src/lib/inbox.test.ts +++ b/ui/src/lib/inbox.test.ts @@ -111,6 +111,10 @@ function makeRun(id: string, status: HeartbeatRun["status"], createdAt: string, logCompressed: false, errorCode: null, externalRunId: null, + processPid: null, + processStartedAt: null, + retryOfRunId: null, + processLossRetryCount: 0, stdoutExcerpt: null, stderrExcerpt: null, contextSnapshot: null, @@ -289,7 +293,11 @@ describe("inbox helpers", () => { getInboxWorkItems({ issues: [olderIssue, newerIssue], approvals: [approval], - }).map((item) => item.kind === "issue" ? `issue:${item.issue.id}` : `approval:${item.approval.id}`), + }).map((item) => { + if (item.kind === "issue") return `issue:${item.issue.id}`; + if (item.kind === "approval") return `approval:${item.approval.id}`; + return `run:${item.run.id}`; + }), ).toEqual([ "issue:1", "approval:approval-between", diff --git a/ui/src/lib/inbox.ts b/ui/src/lib/inbox.ts index b9a74f72..98de7055 100644 --- a/ui/src/lib/inbox.ts +++ b/ui/src/lib/inbox.ts @@ -23,6 +23,11 @@ export type InboxWorkItem = kind: "approval"; timestamp: number; approval: Approval; + } + | { + kind: "failed_run"; + timestamp: number; + run: HeartbeatRun; }; export interface InboxBadgeData { @@ -146,9 +151,11 @@ export function approvalActivityTimestamp(approval: Approval): number { export function getInboxWorkItems({ issues, approvals, + failedRuns = [], }: { issues: Issue[]; approvals: Approval[]; + failedRuns?: HeartbeatRun[]; }): InboxWorkItem[] { return [ ...issues.map((issue) => ({ @@ -161,6 +168,11 @@ export function getInboxWorkItems({ timestamp: approvalActivityTimestamp(approval), approval, })), + ...failedRuns.map((run) => ({ + kind: "failed_run" as const, + timestamp: normalizeTimestamp(run.createdAt), + run, + })), ].sort((a, b) => { const timestampDiff = b.timestamp - a.timestamp; if (timestampDiff !== 0) return timestampDiff; diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 0697e08a..432d2094 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -1,7 +1,13 @@ import { useCallback, useEffect, useMemo, useState, useRef } from "react"; import { useParams, useNavigate, Link, Navigate, useBeforeUnload } from "@/lib/router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { agentsApi, type AgentKey, type ClaudeLoginResult } from "../api/agents"; +import { + agentsApi, + type AgentKey, + type ClaudeLoginResult, + type AvailableSkill, + type AgentPermissionUpdate, +} from "../api/agents"; import { companySkillsApi } from "../api/companySkills"; import { budgetsApi } from "../api/budgets"; import { heartbeatsApi } from "../api/heartbeats"; @@ -73,6 +79,7 @@ import { type Agent, type AgentSkillEntry, type AgentSkillSnapshot, + type AgentDetail as AgentDetailRecord, type BudgetPolicySummary, type HeartbeatRun, type HeartbeatRunEvent, @@ -516,7 +523,7 @@ export function AgentDetail() { const setSaveConfigAction = useCallback((fn: (() => void) | null) => { saveConfigActionRef.current = fn; }, []); const setCancelConfigAction = useCallback((fn: (() => void) | null) => { cancelConfigActionRef.current = fn; }, []); - const { data: agent, isLoading, error } = useQuery({ + const { data: agent, isLoading, error } = useQuery({ queryKey: [...queryKeys.agents.detail(routeAgentRef), lookupCompanyId ?? null], queryFn: () => agentsApi.get(routeAgentRef, lookupCompanyId), enabled: canFetchAgent, @@ -704,8 +711,8 @@ export function AgentDetail() { }); const updatePermissions = useMutation({ - mutationFn: (canCreateAgents: boolean) => - agentsApi.updatePermissions(agentLookupRef, { canCreateAgents }, resolvedCompanyId ?? undefined), + mutationFn: (permissions: AgentPermissionUpdate) => + agentsApi.updatePermissions(agentLookupRef, permissions, resolvedCompanyId ?? undefined), onSuccess: () => { setActionError(null); queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) }); @@ -1109,7 +1116,7 @@ function AgentOverview({ agentId, agentRouteId, }: { - agent: Agent; + agent: AgentDetailRecord; runs: HeartbeatRun[]; assignedIssues: { id: string; title: string; status: string; priority: string; identifier?: string | null; createdAt: Date }[]; runtimeState?: AgentRuntimeState; @@ -1266,14 +1273,14 @@ function AgentConfigurePage({ onSavingChange, updatePermissions, }: { - agent: Agent; + agent: AgentDetailRecord; agentId: string; companyId?: string; onDirtyChange: (dirty: boolean) => void; onSaveActionChange: (save: (() => void) | null) => void; onCancelActionChange: (cancel: (() => void) | null) => void; onSavingChange: (saving: boolean) => void; - updatePermissions: { mutate: (canCreate: boolean) => void; isPending: boolean }; + updatePermissions: { mutate: (permissions: AgentPermissionUpdate) => void; isPending: boolean }; }) { const queryClient = useQueryClient(); const [revisionsOpen, setRevisionsOpen] = useState(false); @@ -1377,13 +1384,13 @@ function ConfigurationTab({ hidePromptTemplate, hideInstructionsFile, }: { - agent: Agent; + agent: AgentDetailRecord; companyId?: string; onDirtyChange: (dirty: boolean) => void; onSaveActionChange: (save: (() => void) | null) => void; onCancelActionChange: (cancel: (() => void) | null) => void; onSavingChange: (saving: boolean) => void; - updatePermissions: { mutate: (canCreate: boolean) => void; isPending: boolean }; + updatePermissions: { mutate: (permissions: AgentPermissionUpdate) => void; isPending: boolean }; hidePromptTemplate?: boolean; hideInstructionsFile?: boolean; }) { @@ -1427,6 +1434,19 @@ function ConfigurationTab({ onSavingChange(isConfigSaving); }, [onSavingChange, isConfigSaving]); + const canCreateAgents = Boolean(agent.permissions?.canCreateAgents); + const canAssignTasks = Boolean(agent.access?.canAssignTasks); + const taskAssignSource = agent.access?.taskAssignSource ?? "none"; + const taskAssignLocked = agent.role === "ceo" || canCreateAgents; + const taskAssignHint = + taskAssignSource === "ceo_role" + ? "Enabled automatically for CEO agents." + : taskAssignSource === "agent_creator" + ? "Enabled automatically while this agent can create new agents." + : taskAssignSource === "explicit_grant" + ? "Enabled via explicit company permission grant." + : "Disabled unless explicitly granted."; + return (

Permissions

-
-
- Can create new agents +
+
+
+
Can create new agents
+

+ Lets this agent create or hire agents and implicitly assign tasks. +

+
+
+
+
Can assign tasks
+

+ {taskAssignHint} +

+
+ +
diff --git a/ui/src/pages/ApprovalDetail.tsx b/ui/src/pages/ApprovalDetail.tsx index 741f9657..c3f27de6 100644 --- a/ui/src/pages/ApprovalDetail.tsx +++ b/ui/src/pages/ApprovalDetail.tsx @@ -8,7 +8,7 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; import { StatusBadge } from "../components/StatusBadge"; import { Identity } from "../components/Identity"; -import { typeLabel, typeIcon, defaultTypeIcon, ApprovalPayloadRenderer } from "../components/ApprovalPayload"; +import { approvalLabel, typeIcon, defaultTypeIcon, ApprovalPayloadRenderer } from "../components/ApprovalPayload"; import { PageSkeleton } from "../components/PageSkeleton"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; @@ -203,7 +203,7 @@ export function ApprovalDetail() {
-

{typeLabel[approval.type] ?? approval.type.replace(/_/g, " ")}

+

{approvalLabel(approval.type, approval.payload as Record | null)}

{approval.id}

diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 2ddd4ed4..d2a6ce20 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -18,7 +18,7 @@ import { IssueRow } from "../components/IssueRow"; import { PriorityIcon } from "../components/PriorityIcon"; import { StatusIcon } from "../components/StatusIcon"; import { StatusBadge } from "../components/StatusBadge"; -import { defaultTypeIcon, typeIcon, typeLabel } from "../components/ApprovalPayload"; +import { approvalLabel, defaultTypeIcon, typeIcon } from "../components/ApprovalPayload"; import { timeAgo } from "../lib/timeAgo"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; @@ -33,12 +33,10 @@ import { import { Inbox as InboxIcon, AlertTriangle, - ArrowUpRight, XCircle, X, RotateCcw, } from "lucide-react"; -import { Identity } from "../components/Identity"; import { PageTabBar } from "../components/PageTabBar"; import type { Approval, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared"; import { @@ -64,16 +62,8 @@ type InboxCategoryFilter = type SectionKey = | "work_items" | "join_requests" - | "failed_runs" | "alerts"; -const RUN_SOURCE_LABELS: Record = { - timer: "Scheduled", - assignment: "Assignment", - on_demand: "Manual", - automation: "Automation", -}; - function firstNonEmptyLine(value: string | null | undefined): string | null { if (!value) return null; const line = value.split("\n").map((chunk) => chunk.trim()).find(Boolean); @@ -101,139 +91,102 @@ function readIssueIdFromRun(run: HeartbeatRun): string | null { return null; } -function FailedRunCard({ +function FailedRunInboxRow({ run, issueById, agentName: linkedAgentName, issueLinkState, onDismiss, + onRetry, + isRetrying, }: { run: HeartbeatRun; issueById: Map; agentName: string | null; issueLinkState: unknown; onDismiss: () => void; + onRetry: () => void; + isRetrying: boolean; }) { - const queryClient = useQueryClient(); - const navigate = useNavigate(); const issueId = readIssueIdFromRun(run); const issue = issueId ? issueById.get(issueId) ?? null : null; - const sourceLabel = RUN_SOURCE_LABELS[run.invocationSource] ?? "Manual"; const displayError = runFailureMessage(run); - const retryRun = useMutation({ - mutationFn: async () => { - const payload: Record = {}; - const context = run.contextSnapshot as Record | null; - if (context) { - if (typeof context.issueId === "string" && context.issueId) payload.issueId = context.issueId; - if (typeof context.taskId === "string" && context.taskId) payload.taskId = context.taskId; - if (typeof context.taskKey === "string" && context.taskKey) payload.taskKey = context.taskKey; - } - const result = await agentsApi.wakeup(run.agentId, { - source: "on_demand", - triggerDetail: "manual", - reason: "retry_failed_run", - payload, - }); - if (!("id" in result)) { - throw new Error("Retry was skipped because the agent is not currently invokable."); - } - return result; - }, - onSuccess: (newRun) => { - queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(run.companyId) }); - queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(run.companyId, run.agentId) }); - navigate(`/agents/${run.agentId}/runs/${newRun.id}`); - }, - }); - return ( -
-
- -
- {issue ? ( - - - {issue.identifier ?? issue.id.slice(0, 8)} - - {issue.title} - - ) : ( - - {run.errorCode ? `Error code: ${run.errorCode}` : "No linked issue"} +
+
+ +
); @@ -253,7 +206,7 @@ function ApprovalInboxRow({ isPending: boolean; }) { const Icon = typeIcon[approval.type] ?? defaultTypeIcon; - const label = typeLabel[approval.type] ?? approval.type; + const label = approvalLabel(approval.type, approval.payload as Record | null); const showResolutionButtons = approval.type !== "budget_override_required" && ACTIONABLE_APPROVAL_STATUSES.has(approval.status); @@ -473,13 +426,19 @@ export function Inbox() { const showFailedRunsCategory = allCategoryFilter === "everything" || allCategoryFilter === "failed_runs"; const showAlertsCategory = allCategoryFilter === "everything" || allCategoryFilter === "alerts"; + const failedRunsForTab = useMemo(() => { + if (tab === "all" && !showFailedRunsCategory) return []; + return failedRuns; + }, [failedRuns, tab, showFailedRunsCategory]); + const workItemsToRender = useMemo( () => getInboxWorkItems({ issues: tab === "all" && !showTouchedCategory ? [] : issuesToRender, approvals: tab === "all" && !showApprovalsCategory ? [] : approvalsToRender, + failedRuns: failedRunsForTab, }), - [approvalsToRender, issuesToRender, showApprovalsCategory, showTouchedCategory, tab], + [approvalsToRender, issuesToRender, showApprovalsCategory, showTouchedCategory, tab, failedRunsForTab], ); const agentName = (id: string | null) => { @@ -538,6 +497,46 @@ export function Inbox() { }, }); + const [retryingRunIds, setRetryingRunIds] = useState>(new Set()); + + const retryRunMutation = useMutation({ + mutationFn: async (run: HeartbeatRun) => { + const payload: Record = {}; + const context = run.contextSnapshot as Record | null; + if (context) { + if (typeof context.issueId === "string" && context.issueId) payload.issueId = context.issueId; + if (typeof context.taskId === "string" && context.taskId) payload.taskId = context.taskId; + if (typeof context.taskKey === "string" && context.taskKey) payload.taskKey = context.taskKey; + } + const result = await agentsApi.wakeup(run.agentId, { + source: "on_demand", + triggerDetail: "manual", + reason: "retry_failed_run", + payload, + }); + if (!("id" in result)) { + throw new Error("Retry was skipped because the agent is not currently invokable."); + } + return { newRun: result, originalRun: run }; + }, + onMutate: (run) => { + setRetryingRunIds((prev) => new Set(prev).add(run.id)); + }, + onSuccess: ({ newRun, originalRun }) => { + queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(originalRun.companyId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(originalRun.companyId, originalRun.agentId) }); + navigate(`/agents/${originalRun.agentId}/runs/${newRun.id}`); + }, + onSettled: (_data, _error, run) => { + if (!run) return; + setRetryingRunIds((prev) => { + const next = new Set(prev); + next.delete(run.id); + return next; + }); + }, + }); + const [fadingOutIssues, setFadingOutIssues] = useState>(new Set()); const invalidateInboxIssueQueries = () => { @@ -607,13 +606,6 @@ export function Inbox() { const showWorkItemsSection = workItemsToRender.length > 0; const showJoinRequestsSection = tab === "all" ? showJoinRequestsCategory && hasJoinRequests : tab === "unread" && hasJoinRequests; - const showFailedRunsSection = shouldShowInboxSection({ - tab, - hasItems: hasRunFailures, - showOnRecent: hasRunFailures, - showOnUnread: hasRunFailures, - showOnAll: showFailedRunsCategory && hasRunFailures, - }); const showAlertsSection = shouldShowInboxSection({ tab, hasItems: hasAlerts, @@ -623,7 +615,6 @@ export function Inbox() { }); const visibleSections = [ - showFailedRunsSection ? "failed_runs" : null, showAlertsSection ? "alerts" : null, showJoinRequestsSection ? "join_requests" : null, showWorkItemsSection ? "work_items" : null, @@ -751,6 +742,21 @@ export function Inbox() { ); } + if (item.kind === "failed_run") { + return ( + dismiss(`run:${item.run.id}`)} + onRetry={() => retryRunMutation.mutate(item.run)} + isRetrying={retryingRunIds.has(item.run.id)} + /> + ); + } + const issue = item.issue; const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id); const isFading = fadingOutIssues.has(issue.id); @@ -857,28 +863,6 @@ export function Inbox() { )} - {showFailedRunsSection && ( - <> - {showSeparatorBefore("failed_runs") && } -
-

- Failed Runs -

-
- {failedRuns.map((run) => ( - dismiss(`run:${run.id}`)} - /> - ))} -
-
- - )} {showAlertsSection && ( <> diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index a6ac9c76..fbed1e9c 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -11,6 +11,7 @@ import { useCompany } from "../context/CompanyContext"; import { usePanel } from "../context/PanelContext"; import { useToast } from "../context/ToastContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; +import { assigneeValueFromSelection, suggestedCommentAssigneeValue } from "../lib/assignees"; import { queryKeys } from "../lib/queryKeys"; import { readIssueDetailBreadcrumb } from "../lib/issueDetailBreadcrumb"; import { useProjectOrder } from "../hooks/useProjectOrder"; @@ -206,7 +207,6 @@ export function IssueDetail() { const [detailTab, setDetailTab] = useState("comments"); const [secondaryOpen, setSecondaryOpen] = useState({ approvals: false, - cost: false, }); const [attachmentError, setAttachmentError] = useState(null); const [attachmentDragActive, setAttachmentDragActive] = useState(false); @@ -375,11 +375,15 @@ export function IssueDetail() { return options; }, [agents, currentUserId]); - const currentAssigneeValue = useMemo(() => { - if (issue?.assigneeAgentId) return `agent:${issue.assigneeAgentId}`; - if (issue?.assigneeUserId) return `user:${issue.assigneeUserId}`; - return ""; - }, [issue?.assigneeAgentId, issue?.assigneeUserId]); + const actualAssigneeValue = useMemo( + () => assigneeValueFromSelection(issue ?? {}), + [issue], + ); + + const suggestedAssigneeValue = useMemo( + () => suggestedCommentAssigneeValue(issue ?? {}, comments, currentUserId), + [issue, comments, currentUserId], + ); const commentsWithRunMeta = useMemo(() => { const runMetaByCommentId = new Map(); @@ -1002,7 +1006,8 @@ export function IssueDetail() { draftKey={`paperclip:issue-comment-draft:${issue.id}`} enableReassign reassignOptions={commentReassignOptions} - currentAssigneeValue={currentAssigneeValue} + currentAssigneeValue={actualAssigneeValue} + suggestedAssigneeValue={suggestedAssigneeValue} mentions={mentionOptions} onAdd={async (body, reopen, reassignment) => { if (reassignment) { @@ -1055,6 +1060,30 @@ export function IssueDetail() { + {linkedRuns && linkedRuns.length > 0 && ( +
+
Cost Summary
+ {!issueCostSummary.hasCost && !issueCostSummary.hasTokens ? ( +
No cost data yet.
+ ) : ( +
+ {issueCostSummary.hasCost && ( + + ${issueCostSummary.cost.toFixed(4)} + + )} + {issueCostSummary.hasTokens && ( + + Tokens {formatTokens(issueCostSummary.totalTokens)} + {issueCostSummary.cached > 0 + ? ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)}, cached ${formatTokens(issueCostSummary.cached)})` + : ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)})`} + + )} +
+ )} +
+ )} {!activity || activity.length === 0 ? (

No activity yet.

) : ( @@ -1123,43 +1152,6 @@ export function IssueDetail() { )} - {linkedRuns && linkedRuns.length > 0 && ( - setSecondaryOpen((prev) => ({ ...prev, cost: open }))} - className="rounded-lg border border-border" - > - - Cost Summary - - - -
- {!issueCostSummary.hasCost && !issueCostSummary.hasTokens ? ( -
No cost data yet.
- ) : ( -
- {issueCostSummary.hasCost && ( - - ${issueCostSummary.cost.toFixed(4)} - - )} - {issueCostSummary.hasTokens && ( - - Tokens {formatTokens(issueCostSummary.totalTokens)} - {issueCostSummary.cached > 0 - ? ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)}, cached ${formatTokens(issueCostSummary.cached)})` - : ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)})`} - - )} -
- )} -
-
-
- )} {/* Mobile properties drawer */} diff --git a/ui/src/pages/RoutineDetail.tsx b/ui/src/pages/RoutineDetail.tsx index 93929734..ea825221 100644 --- a/ui/src/pages/RoutineDetail.tsx +++ b/ui/src/pages/RoutineDetail.tsx @@ -51,7 +51,7 @@ import type { RoutineTrigger } from "@paperclipai/shared"; const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"]; const catchUpPolicies = ["skip_missed", "enqueue_missed_with_cap"]; -const triggerKinds = ["schedule", "webhook", "api"]; +const triggerKinds = ["schedule", "webhook"]; const signingModes = ["bearer", "hmac_sha256"]; const routineTabs = ["triggers", "runs", "activity"] as const; const concurrencyPolicyDescriptions: Record = { @@ -907,7 +907,9 @@ export function RoutineDetail() { {triggerKinds.map((kind) => ( - {kind} + + {kind}{kind === "webhook" ? " — COMING SOON" : ""} + ))}