diff --git a/packages/shared/src/execution-workspace-guards.ts b/packages/shared/src/execution-workspace-guards.ts new file mode 100644 index 00000000..5428546a --- /dev/null +++ b/packages/shared/src/execution-workspace-guards.ts @@ -0,0 +1,19 @@ +import type { ExecutionWorkspace } from "./types/workspace-runtime.js"; + +type ExecutionWorkspaceGuardTarget = Pick; + +const CLOSED_EXECUTION_WORKSPACE_STATUSES = new Set(["archived", "cleanup_failed"]); + +export function isClosedIsolatedExecutionWorkspace( + workspace: Pick | null | undefined, +): boolean { + if (!workspace) return false; + if (workspace.mode !== "isolated_workspace") return false; + return workspace.closedAt != null || CLOSED_EXECUTION_WORKSPACE_STATUSES.has(workspace.status); +} + +export function getClosedIsolatedExecutionWorkspaceMessage( + workspace: Pick, +): string { + return `This issue is linked to the closed workspace "${workspace.name}". Move it to an open workspace before adding comments or resuming work.`; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index b0fd87f2..cc6ea42c 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -351,6 +351,11 @@ export { DEFAULT_FEEDBACK_DATA_SHARING_TERMS_VERSION, } from "./types/feedback.js"; +export { + getClosedIsolatedExecutionWorkspaceMessage, + isClosedIsolatedExecutionWorkspace, +} from "./execution-workspace-guards.js"; + export { instanceGeneralSettingsSchema, patchInstanceGeneralSettingsSchema, @@ -595,14 +600,19 @@ export { deriveProjectUrlKey, normalizeProjectUrlKey, hasNonAsciiContent } from export { AGENT_MENTION_SCHEME, PROJECT_MENTION_SCHEME, + SKILL_MENTION_SCHEME, buildAgentMentionHref, buildProjectMentionHref, + buildSkillMentionHref, extractAgentMentionIds, + extractSkillMentionIds, parseAgentMentionHref, parseProjectMentionHref, + parseSkillMentionHref, extractProjectMentionIds, type ParsedAgentMention, type ParsedProjectMention, + type ParsedSkillMention, } from "./project-mentions.js"; export { diff --git a/packages/shared/src/project-mentions.test.ts b/packages/shared/src/project-mentions.test.ts index 55f27369..5a156959 100644 --- a/packages/shared/src/project-mentions.test.ts +++ b/packages/shared/src/project-mentions.test.ts @@ -2,10 +2,13 @@ import { describe, expect, it } from "vitest"; import { buildAgentMentionHref, buildProjectMentionHref, + buildSkillMentionHref, extractAgentMentionIds, extractProjectMentionIds, + extractSkillMentionIds, parseAgentMentionHref, parseProjectMentionHref, + parseSkillMentionHref, } from "./project-mentions.js"; describe("project-mentions", () => { @@ -26,4 +29,13 @@ describe("project-mentions", () => { }); expect(extractAgentMentionIds(`[@CodexCoder](${href})`)).toEqual(["agent-123"]); }); + + it("round-trips skill mentions with slug metadata", () => { + const href = buildSkillMentionHref("skill-123", "release-changelog"); + expect(parseSkillMentionHref(href)).toEqual({ + skillId: "skill-123", + slug: "release-changelog", + }); + expect(extractSkillMentionIds(`[/release-changelog](${href})`)).toEqual(["skill-123"]); + }); }); diff --git a/packages/shared/src/project-mentions.ts b/packages/shared/src/project-mentions.ts index 66be8948..117fad39 100644 --- a/packages/shared/src/project-mentions.ts +++ b/packages/shared/src/project-mentions.ts @@ -1,5 +1,6 @@ export const PROJECT_MENTION_SCHEME = "project://"; export const AGENT_MENTION_SCHEME = "agent://"; +export const SKILL_MENTION_SCHEME = "skill://"; const HEX_COLOR_RE = /^[0-9a-f]{6}$/i; const HEX_COLOR_SHORT_RE = /^[0-9a-f]{3}$/i; @@ -7,7 +8,9 @@ const HEX_COLOR_WITH_HASH_RE = /^#[0-9a-f]{6}$/i; const HEX_COLOR_SHORT_WITH_HASH_RE = /^#[0-9a-f]{3}$/i; const PROJECT_MENTION_LINK_RE = /\[[^\]]*]\((project:\/\/[^)\s]+)\)/gi; const AGENT_MENTION_LINK_RE = /\[[^\]]*]\((agent:\/\/[^)\s]+)\)/gi; +const SKILL_MENTION_LINK_RE = /\[[^\]]*]\((skill:\/\/[^)\s]+)\)/gi; const AGENT_ICON_NAME_RE = /^[a-z0-9-]+$/i; +const SKILL_SLUG_RE = /^[a-z0-9][a-z0-9-]*$/i; export interface ParsedProjectMention { projectId: string; @@ -19,6 +22,11 @@ export interface ParsedAgentMention { icon: string | null; } +export interface ParsedSkillMention { + skillId: string; + slug: string | null; +} + function normalizeHexColor(input: string | null | undefined): string | null { if (!input) return null; const trimmed = input.trim(); @@ -103,6 +111,36 @@ export function parseAgentMentionHref(href: string): ParsedAgentMention | null { }; } +export function buildSkillMentionHref(skillId: string, slug?: string | null): string { + const trimmedSkillId = skillId.trim(); + const normalizedSlug = normalizeSkillSlug(slug ?? null); + if (!normalizedSlug) { + return `${SKILL_MENTION_SCHEME}${trimmedSkillId}`; + } + return `${SKILL_MENTION_SCHEME}${trimmedSkillId}?s=${encodeURIComponent(normalizedSlug)}`; +} + +export function parseSkillMentionHref(href: string): ParsedSkillMention | null { + if (!href.startsWith(SKILL_MENTION_SCHEME)) return null; + + let url: URL; + try { + url = new URL(href); + } catch { + return null; + } + + if (url.protocol !== "skill:") return null; + + const skillId = `${url.hostname}${url.pathname}`.replace(/^\/+/, "").trim(); + if (!skillId) return null; + + return { + skillId, + slug: normalizeSkillSlug(url.searchParams.get("s") ?? url.searchParams.get("slug")), + }; +} + export function extractProjectMentionIds(markdown: string): string[] { if (!markdown) return []; const ids = new Set(); @@ -127,9 +165,28 @@ export function extractAgentMentionIds(markdown: string): string[] { return [...ids]; } +export function extractSkillMentionIds(markdown: string): string[] { + if (!markdown) return []; + const ids = new Set(); + const re = new RegExp(SKILL_MENTION_LINK_RE); + let match: RegExpExecArray | null; + while ((match = re.exec(markdown)) !== null) { + const parsed = parseSkillMentionHref(match[1]); + if (parsed) ids.add(parsed.skillId); + } + return [...ids]; +} + function normalizeAgentIcon(input: string | null | undefined): string | null { if (!input) return null; const trimmed = input.trim().toLowerCase(); if (!trimmed || !AGENT_ICON_NAME_RE.test(trimmed)) return null; return trimmed; } + +function normalizeSkillSlug(input: string | null | undefined): string | null { + if (!input) return null; + const trimmed = input.trim().toLowerCase(); + if (!trimmed || !SKILL_SLUG_RE.test(trimmed)) return null; + return trimmed; +} diff --git a/server/src/__tests__/execution-workspaces-service.test.ts b/server/src/__tests__/execution-workspaces-service.test.ts index d4a50bdc..ca6b38d5 100644 --- a/server/src/__tests__/execution-workspaces-service.test.ts +++ b/server/src/__tests__/execution-workspaces-service.test.ts @@ -12,6 +12,7 @@ import { issues, projectWorkspaces, projects, + workspaceRuntimeServices, } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, @@ -133,6 +134,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { afterEach(async () => { await db.delete(issues); + await db.delete(workspaceRuntimeServices); await db.delete(executionWorkspaces); await db.delete(projectWorkspaces); await db.delete(projects); @@ -322,4 +324,136 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { "git_branch_delete", ])); }, 20_000); + + it("shows inherited shared project runtime services on shared execution workspaces without duplicating old history", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const projectWorkspaceId = randomUUID(); + const executionWorkspaceId = randomUUID(); + const olderServiceId = randomUUID(); + const currentServiceId = randomUUID(); + const reuseKey = `project_workspace:${projectWorkspaceId}:paperclip-dev`; + const startedAt = new Date("2026-04-04T17:00:00.000Z"); + const stoppedAt = new Date("2026-04-04T17:05:00.000Z"); + const runningAt = new Date("2026-04-04T17:10:00.000Z"); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: "PAP", + requireBoardApprovalForNewAgents: false, + }); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Workspaces", + status: "in_progress", + executionWorkspacePolicy: { + enabled: true, + }, + }); + await db.insert(projectWorkspaces).values({ + id: projectWorkspaceId, + companyId, + projectId, + name: "Primary", + sourceType: "local_path", + isPrimary: true, + cwd: "/tmp/paperclip-primary", + metadata: { + runtimeConfig: { + desiredState: "running", + workspaceRuntime: { + services: [{ name: "paperclip-dev", command: "pnpm dev" }], + }, + }, + }, + }); + await db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId, + projectWorkspaceId, + mode: "shared_workspace", + strategyType: "project_primary", + name: "Shared workspace", + status: "active", + providerType: "local_fs", + cwd: "/tmp/paperclip-primary", + }); + await db.insert(workspaceRuntimeServices).values([ + { + id: olderServiceId, + companyId, + projectId, + projectWorkspaceId, + executionWorkspaceId: null, + issueId: null, + scopeType: "project_workspace", + scopeId: projectWorkspaceId, + serviceName: "paperclip-dev", + status: "stopped", + lifecycle: "shared", + reuseKey, + command: "pnpm dev", + cwd: "/tmp/paperclip-primary", + port: 49195, + url: "http://127.0.0.1:49195", + provider: "local_process", + providerRef: "11111", + ownerAgentId: null, + startedByRunId: null, + lastUsedAt: stoppedAt, + startedAt, + stoppedAt, + stopPolicy: { type: "manual" }, + healthStatus: "unknown", + createdAt: startedAt, + updatedAt: stoppedAt, + }, + { + id: currentServiceId, + companyId, + projectId, + projectWorkspaceId, + executionWorkspaceId: null, + issueId: null, + scopeType: "project_workspace", + scopeId: projectWorkspaceId, + serviceName: "paperclip-dev", + status: "running", + lifecycle: "shared", + reuseKey, + command: "pnpm dev", + cwd: "/tmp/paperclip-primary", + port: 49222, + url: "http://127.0.0.1:49222", + provider: "local_process", + providerRef: "22222", + ownerAgentId: null, + startedByRunId: null, + lastUsedAt: runningAt, + startedAt: runningAt, + stoppedAt: null, + stopPolicy: { type: "manual" }, + healthStatus: "healthy", + createdAt: runningAt, + updatedAt: runningAt, + }, + ]); + + const workspace = await svc.getById(executionWorkspaceId); + const listed = await svc.list(companyId, { projectId }); + + expect(workspace?.runtimeServices).toHaveLength(1); + expect(workspace?.runtimeServices?.[0]).toMatchObject({ + id: currentServiceId, + status: "running", + projectWorkspaceId, + executionWorkspaceId: null, + url: "http://127.0.0.1:49222", + }); + expect(listed[0]?.runtimeServices).toHaveLength(1); + expect(listed[0]?.runtimeServices?.[0]?.id).toBe(currentServiceId); + }); }); diff --git a/server/src/__tests__/issue-closed-workspace-routes.test.ts b/server/src/__tests__/issue-closed-workspace-routes.test.ts new file mode 100644 index 00000000..f7f0240c --- /dev/null +++ b/server/src/__tests__/issue-closed-workspace-routes.test.ts @@ -0,0 +1,178 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { issueRoutes } from "../routes/issues.js"; +import { errorHandler } from "../middleware/index.js"; + +const issueId = "11111111-1111-4111-8111-111111111111"; +const closedWorkspaceId = "33333333-3333-4333-8333-333333333333"; +const nextWorkspaceId = "44444444-4444-4444-8444-444444444444"; +const agentId = "22222222-2222-4222-8222-222222222222"; + +const mockIssueService = vi.hoisted(() => ({ + getById: vi.fn(), + update: vi.fn(), + checkout: vi.fn(), + addComment: vi.fn(), +})); + +const mockExecutionWorkspaceService = vi.hoisted(() => ({ + getById: vi.fn(), +})); + +const mockAccessService = vi.hoisted(() => ({ + canUser: vi.fn(), + hasPermission: vi.fn(), +})); + +const mockHeartbeatService = vi.hoisted(() => ({ + wakeup: vi.fn(async () => undefined), + reportRunActivity: vi.fn(async () => undefined), + getRun: vi.fn(async () => null), + getActiveRunForAgent: vi.fn(async () => null), + cancelRun: vi.fn(async () => null), +})); + +const mockProjectService = vi.hoisted(() => ({ + getById: vi.fn(async () => null), +})); + +const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined)); + +vi.mock("../services/index.js", () => ({ + accessService: () => mockAccessService, + agentService: () => ({ + getById: vi.fn(async () => null), + }), + documentService: () => ({}), + executionWorkspaceService: () => mockExecutionWorkspaceService, + feedbackService: () => ({ + listIssueVotesForUser: vi.fn(async () => []), + saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })), + }), + goalService: () => ({ + getDefaultCompanyGoal: vi.fn(async () => null), + getById: vi.fn(async () => null), + }), + heartbeatService: () => mockHeartbeatService, + instanceSettingsService: () => ({ + get: vi.fn(async () => ({ + id: "instance-settings-1", + general: { + censorUsernameInLogs: false, + feedbackDataSharingPreference: "prompt", + }, + })), + listCompanyIds: vi.fn(async () => ["company-1"]), + }), + issueApprovalService: () => ({}), + issueService: () => mockIssueService, + logActivity: mockLogActivity, + projectService: () => mockProjectService, + routineService: () => ({ + syncRunStatusForIssue: vi.fn(async () => undefined), + }), + workProductService: () => ({}), +})); + +function createApp() { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + userId: "local-board", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, + }; + next(); + }); + app.use("/api", issueRoutes({} as any, {} as any)); + app.use(errorHandler); + return app; +} + +function makeIssue() { + return { + id: issueId, + companyId: "company-1", + status: "todo", + priority: "medium", + assigneeAgentId: agentId, + assigneeUserId: null, + createdByUserId: "local-board", + identifier: "PAP-1085", + title: "Closed worktree issue", + projectId: null, + executionRunId: null, + checkoutRunId: null, + executionWorkspaceId: closedWorkspaceId, + }; +} + +function makeClosedWorkspace() { + return { + id: closedWorkspaceId, + name: "PAP-1085-fix-worktree-guard", + mode: "isolated_workspace", + status: "archived", + closedAt: new Date("2026-04-04T17:00:00.000Z"), + }; +} + +describe("closed isolated workspace issue routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockIssueService.getById.mockResolvedValue(makeIssue()); + mockExecutionWorkspaceService.getById.mockResolvedValue(makeClosedWorkspace()); + }); + + it("rejects new issue comments when the linked isolated workspace is closed", async () => { + const res = await request(createApp()) + .post(`/api/issues/${issueId}/comments`) + .send({ body: "hello" }); + + expect(res.status).toBe(409); + expect(res.body.error).toContain("closed workspace"); + expect(mockIssueService.addComment).not.toHaveBeenCalled(); + }); + + it("rejects comment updates when the linked isolated workspace is closed", async () => { + const res = await request(createApp()) + .patch(`/api/issues/${issueId}`) + .send({ comment: "hello" }); + + expect(res.status).toBe(409); + expect(res.body.error).toContain("closed workspace"); + expect(mockIssueService.update).not.toHaveBeenCalled(); + expect(mockIssueService.addComment).not.toHaveBeenCalled(); + }); + + it("rejects checkout when the linked isolated workspace is closed", async () => { + const res = await request(createApp()) + .post(`/api/issues/${issueId}/checkout`) + .send({ + agentId, + expectedStatuses: ["todo", "backlog", "blocked"], + }); + + expect(res.status).toBe(409); + expect(res.body.error).toContain("closed workspace"); + expect(mockIssueService.checkout).not.toHaveBeenCalled(); + }); + + it("still allows non-comment board updates so the issue can be moved to a new workspace", async () => { + mockIssueService.update.mockResolvedValue({ + ...makeIssue(), + executionWorkspaceId: nextWorkspaceId, + }); + + const res = await request(createApp()) + .patch(`/api/issues/${issueId}`) + .send({ executionWorkspaceId: nextWorkspaceId }); + + expect(res.status).toBe(200); + expect(mockIssueService.update).toHaveBeenCalledWith(issueId, { executionWorkspaceId: nextWorkspaceId }); + }); +}); diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index f158a5e9..472f58b0 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -13,6 +13,7 @@ import { createDb, executionWorkspaces, heartbeatRuns, + projectWorkspaces, projects, workspaceRuntimeServices, } from "@paperclipai/db"; @@ -30,6 +31,7 @@ import { stopRuntimeServicesForExecutionWorkspace, type RealizedExecutionWorkspace, } from "../services/workspace-runtime.ts"; +import { writeLocalServiceRegistryRecord } from "../services/local-service-supervisor.ts"; import { resolvePaperclipConfigPath } from "../paths.ts"; import type { WorkspaceOperation } from "@paperclipai/shared"; import type { WorkspaceOperationRecorder } from "../services/workspace-operations.ts"; @@ -1416,6 +1418,7 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => { afterEach(async () => { await db.delete(workspaceRuntimeServices); await db.delete(executionWorkspaces); + await db.delete(projectWorkspaces); await db.delete(projects); await db.delete(heartbeatRuns); await db.delete(agents); @@ -1530,6 +1533,96 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => { await expect(fetch(service!.url!)).rejects.toThrow(); }); + it("marks persisted local services stopped when the registry pid is stale", async () => { + const companyId = randomUUID(); + const runtimeServiceId = randomUUID(); + const startedAt = new Date("2026-04-04T17:00:00.000Z"); + const updatedAt = new Date("2026-04-04T17:10:00.000Z"); + const projectId = randomUUID(); + const projectWorkspaceId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Runtime reconcile test", + status: "in_progress", + }); + await db.insert(projectWorkspaces).values({ + id: projectWorkspaceId, + companyId, + projectId, + name: "Primary", + sourceType: "local_path", + cwd: "/tmp/paperclip-primary", + isPrimary: true, + }); + await db.insert(workspaceRuntimeServices).values({ + id: runtimeServiceId, + companyId, + projectId, + projectWorkspaceId, + executionWorkspaceId: null, + issueId: null, + scopeType: "project_workspace", + scopeId: projectWorkspaceId, + serviceName: "paperclip-dev", + status: "running", + lifecycle: "shared", + reuseKey: `project_workspace:${projectWorkspaceId}:paperclip-dev`, + command: "pnpm dev", + cwd: "/tmp/paperclip-primary", + port: 49195, + url: "http://127.0.0.1:49195", + provider: "local_process", + providerRef: "999999", + ownerAgentId: null, + startedByRunId: null, + lastUsedAt: updatedAt, + startedAt, + stoppedAt: null, + stopPolicy: { type: "manual" }, + healthStatus: "healthy", + createdAt: startedAt, + updatedAt, + }); + await writeLocalServiceRegistryRecord({ + version: 1, + serviceKey: "workspace-runtime-paperclip-dev-stale", + profileKind: "workspace-runtime", + serviceName: "paperclip-dev", + command: "pnpm dev", + cwd: "/tmp/paperclip-primary", + envFingerprint: "fingerprint", + port: 49195, + url: "http://127.0.0.1:49195", + pid: 999999, + processGroupId: 999999, + provider: "local_process", + runtimeServiceId, + reuseKey: `project_workspace:${projectWorkspaceId}:paperclip-dev`, + startedAt: startedAt.toISOString(), + lastSeenAt: updatedAt.toISOString(), + metadata: null, + }); + + const result = await reconcilePersistedRuntimeServicesOnStartup(db); + + expect(result).toMatchObject({ reconciled: 1, adopted: 0, stopped: 1 }); + const persisted = await db + .select() + .from(workspaceRuntimeServices) + .where(eq(workspaceRuntimeServices.id, runtimeServiceId)) + .then((rows) => rows[0] ?? null); + expect(persisted?.status).toBe("stopped"); + expect(persisted?.stoppedAt).not.toBeNull(); + }); + it("persists controlled execution workspace stops as stopped", async () => { const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-stop-persisted-")); const companyId = randomUUID(); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 9551f04d..2898feae 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -19,6 +19,9 @@ import { updateIssueWorkProductSchema, upsertIssueDocumentSchema, updateIssueSchema, + getClosedIsolatedExecutionWorkspaceMessage, + isClosedIsolatedExecutionWorkspace, + type ExecutionWorkspace, } from "@paperclipai/shared"; import { trackAgentTaskCompleted } from "@paperclipai/shared/telemetry"; import { getTelemetryClient } from "../telemetry.js"; @@ -234,6 +237,23 @@ export function issueRoutes( return runToInterrupt?.status === "running" ? runToInterrupt : null; } + async function getClosedIssueExecutionWorkspace(issue: { executionWorkspaceId?: string | null }) { + if (!issue.executionWorkspaceId) return null; + const workspace = await executionWorkspacesSvc.getById(issue.executionWorkspaceId); + if (!workspace || !isClosedIsolatedExecutionWorkspace(workspace)) return null; + return workspace; + } + + function respondClosedIssueExecutionWorkspace( + res: Response, + workspace: Pick, + ) { + res.status(409).json({ + error: getClosedIsolatedExecutionWorkspaceMessage(workspace), + executionWorkspace: workspace, + }); + } + async function normalizeIssueIdentifier(rawId: string): Promise { if (/^[A-Z]+-\d+$/i.test(rawId)) { const issue = await svc.getByIdentifier(rawId); @@ -1083,6 +1103,13 @@ export function issueRoutes( ...updateFields } = req.body; let interruptedRunId: string | null = null; + const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(existing); + const isAgentWorkUpdate = req.actor.type === "agent" && Object.keys(updateFields).length > 0; + + if (closedExecutionWorkspace && (commentBody || isAgentWorkUpdate)) { + respondClosedIssueExecutionWorkspace(res, closedExecutionWorkspace); + return; + } if (interruptRequested) { if (!commentBody) { @@ -1389,6 +1416,12 @@ export function issueRoutes( return; } + const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(issue); + if (closedExecutionWorkspace) { + respondClosedIssueExecutionWorkspace(res, closedExecutionWorkspace); + return; + } + const checkoutRunId = requireAgentRunId(req, res); if (req.actor.type === "agent" && !checkoutRunId) return; const updated = await svc.checkout(id, req.body.agentId, req.body.expectedStatuses, checkoutRunId); @@ -1607,6 +1640,11 @@ export function issueRoutes( } assertCompanyAccess(req, issue.companyId); if (!(await assertAgentRunCheckoutOwnership(req, res, issue))) return; + const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(issue); + if (closedExecutionWorkspace) { + respondClosedIssueExecutionWorkspace(res, closedExecutionWorkspace); + return; + } const actor = getActorInfo(req); const reopenRequested = req.body.reopen === true; diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index a1d3b41d..58a43210 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -14,6 +14,10 @@ import type { WorkspaceRuntimeService, } from "@paperclipai/shared"; import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js"; +import { + listCurrentRuntimeServicesForExecutionWorkspaces, + listCurrentRuntimeServicesForProjectWorkspaces, +} from "./workspace-runtime-read-model.js"; type ExecutionWorkspaceRow = typeof executionWorkspaces.$inferSelect; type WorkspaceRuntimeServiceRow = typeof workspaceRuntimeServices.$inferSelect; @@ -317,6 +321,41 @@ function toExecutionWorkspace( }; } +function usesInheritedProjectRuntimeServices(row: ExecutionWorkspaceRow) { + if (row.mode !== "shared_workspace" || !row.projectWorkspaceId) return false; + return !readExecutionWorkspaceConfig((row.metadata as Record | null) ?? null)?.workspaceRuntime; +} + +async function loadEffectiveRuntimeServicesByExecutionWorkspace( + db: Db, + companyId: string, + rows: ExecutionWorkspaceRow[], +) { + const executionRuntimeServices = await listCurrentRuntimeServicesForExecutionWorkspaces( + db, + companyId, + rows.map((row) => row.id), + ); + const projectWorkspaceIds = rows + .filter((row) => usesInheritedProjectRuntimeServices(row)) + .map((row) => row.projectWorkspaceId) + .filter((value): value is string => Boolean(value)); + const projectRuntimeServices = await listCurrentRuntimeServicesForProjectWorkspaces( + db, + companyId, + [...new Set(projectWorkspaceIds)], + ); + + return new Map( + rows.map((row) => [ + row.id, + usesInheritedProjectRuntimeServices(row) + ? (projectRuntimeServices.get(row.projectWorkspaceId!) ?? []) + : (executionRuntimeServices.get(row.id) ?? []), + ]), + ); +} + export function executionWorkspaceService(db: Db) { return { list: async (companyId: string, filters?: { @@ -346,7 +385,13 @@ export function executionWorkspaceService(db: Db) { .from(executionWorkspaces) .where(and(...conditions)) .orderBy(desc(executionWorkspaces.lastUsedAt), desc(executionWorkspaces.createdAt)); - return rows.map((row) => toExecutionWorkspace(row)); + const runtimeServicesByWorkspaceId = await loadEffectiveRuntimeServicesByExecutionWorkspace(db, companyId, rows); + return rows.map((row) => + toExecutionWorkspace( + row, + (runtimeServicesByWorkspaceId.get(row.id) ?? []).map(toRuntimeService), + ), + ); }, getById: async (id: string) => { @@ -356,12 +401,11 @@ export function executionWorkspaceService(db: Db) { .where(eq(executionWorkspaces.id, id)) .then((rows) => rows[0] ?? null); if (!row) return null; - const runtimeServiceRows = await db - .select() - .from(workspaceRuntimeServices) - .where(eq(workspaceRuntimeServices.executionWorkspaceId, row.id)) - .orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt)); - return toExecutionWorkspace(row, runtimeServiceRows.map(toRuntimeService)); + const runtimeServicesByWorkspaceId = await loadEffectiveRuntimeServicesByExecutionWorkspace(db, row.companyId, [row]); + return toExecutionWorkspace( + row, + (runtimeServicesByWorkspaceId.get(row.id) ?? []).map(toRuntimeService), + ); }, getCloseReadiness: async (id: string): Promise => { @@ -372,12 +416,8 @@ export function executionWorkspaceService(db: Db) { .then((rows) => rows[0] ?? null); if (!workspace) return null; - const runtimeServiceRows = await db - .select() - .from(workspaceRuntimeServices) - .where(eq(workspaceRuntimeServices.executionWorkspaceId, workspace.id)) - .orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt)); - const runtimeServices = runtimeServiceRows.map(toRuntimeService); + const runtimeServicesByWorkspaceId = await loadEffectiveRuntimeServicesByExecutionWorkspace(db, workspace.companyId, [workspace]); + const runtimeServices = (runtimeServicesByWorkspaceId.get(workspace.id) ?? []).map(toRuntimeService); const linkedIssues = await db .select({ diff --git a/server/src/services/local-service-supervisor.ts b/server/src/services/local-service-supervisor.ts index 68dbbdc8..eac87732 100644 --- a/server/src/services/local-service-supervisor.ts +++ b/server/src/services/local-service-supervisor.ts @@ -184,7 +184,31 @@ export async function findLocalServiceRegistryRecordByRuntimeServiceId(input: { const records = await listLocalServiceRegistryRecords( input.profileKind ? { profileKind: input.profileKind } : undefined, ); - return records.find((record) => record.runtimeServiceId === input.runtimeServiceId) ?? null; + const record = records.find((entry) => entry.runtimeServiceId === input.runtimeServiceId) ?? null; + if (!record) return null; + + let candidate = record; + if (!isPidAlive(candidate.pid)) { + const ownerPid = candidate.port ? await readLocalServicePortOwner(candidate.port) : null; + if (!ownerPid) { + await removeLocalServiceRegistryRecord(candidate.serviceKey); + return null; + } + candidate = { + ...candidate, + pid: ownerPid, + processGroupId: candidate.processGroupId && isPidAlive(candidate.processGroupId) ? candidate.processGroupId : ownerPid, + lastSeenAt: new Date().toISOString(), + }; + await writeLocalServiceRegistryRecord(candidate); + } + + if (!(await isLikelyMatchingCommand(candidate))) { + await removeLocalServiceRegistryRecord(record.serviceKey); + return null; + } + + return candidate; } export function isPidAlive(pid: number) { @@ -203,7 +227,10 @@ async function isLikelyMatchingCommand(record: LocalServiceRegistryRecord) { const { stdout } = await execFileAsync("ps", ["-o", "command=", "-p", String(record.pid)]); const commandLine = stdout.trim(); if (!commandLine) return false; - return commandLine.includes(record.command) || commandLine.includes(record.serviceName); + const normalize = (value: string) => value.replace(/["']/g, "").replace(/\s+/g, " ").trim(); + const normalizedCommandLine = normalize(commandLine); + const normalizedRecordedCommand = normalize(record.command); + return normalizedCommandLine.includes(normalizedRecordedCommand) || normalizedCommandLine.includes(record.serviceName); } catch { return true; } diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index db786478..f653ea1a 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/projects.ts @@ -14,7 +14,7 @@ import { type ProjectWorkspace, type WorkspaceRuntimeService, } from "@paperclipai/shared"; -import { listWorkspaceRuntimeServicesForProjectWorkspaces } from "./workspace-runtime.js"; +import { listCurrentRuntimeServicesForProjectWorkspaces } from "./workspace-runtime-read-model.js"; import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js"; import { mergeProjectWorkspaceRuntimeConfig, readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js"; import { resolveManagedProjectWorkspaceDir } from "../home-paths.js"; @@ -223,7 +223,7 @@ async function attachWorkspaces(db: Db, rows: ProjectWithGoals[]): Promise workspace.id), @@ -541,7 +541,7 @@ export function projectService(db: Db) { .where(eq(projectWorkspaces.projectId, projectId)) .orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id)); if (rows.length === 0) return []; - const runtimeServicesByWorkspaceId = await listWorkspaceRuntimeServicesForProjectWorkspaces( + const runtimeServicesByWorkspaceId = await listCurrentRuntimeServicesForProjectWorkspaces( db, rows[0]!.companyId, rows.map((workspace) => workspace.id), diff --git a/server/src/services/workspace-runtime-read-model.ts b/server/src/services/workspace-runtime-read-model.ts new file mode 100644 index 00000000..dba6190d --- /dev/null +++ b/server/src/services/workspace-runtime-read-model.ts @@ -0,0 +1,96 @@ +import type { Db } from "@paperclipai/db"; +import { workspaceRuntimeServices } from "@paperclipai/db"; +import { and, desc, eq, inArray } from "drizzle-orm"; + +type WorkspaceRuntimeServiceRow = typeof workspaceRuntimeServices.$inferSelect; + +function runtimeServiceIdentityKey(row: WorkspaceRuntimeServiceRow) { + if (row.reuseKey) return row.reuseKey; + return [ + row.scopeType, + row.scopeId ?? "", + row.projectWorkspaceId ?? "", + row.executionWorkspaceId ?? "", + row.serviceName, + row.command ?? "", + row.cwd ?? "", + ].join(":"); +} + +export function selectCurrentRuntimeServiceRows(rows: WorkspaceRuntimeServiceRow[]) { + const current = new Map(); + for (const row of rows) { + const identity = runtimeServiceIdentityKey(row); + if (!current.has(identity)) current.set(identity, row); + } + return [...current.values()]; +} + +export async function listCurrentRuntimeServicesForProjectWorkspaces( + db: Db, + companyId: string, + projectWorkspaceIds: string[], +) { + if (projectWorkspaceIds.length === 0) return new Map(); + + const rows = await db + .select() + .from(workspaceRuntimeServices) + .where( + and( + eq(workspaceRuntimeServices.companyId, companyId), + inArray(workspaceRuntimeServices.projectWorkspaceId, projectWorkspaceIds), + eq(workspaceRuntimeServices.scopeType, "project_workspace"), + ), + ) + .orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt)); + + const grouped = new Map(); + for (const row of rows) { + if (!row.projectWorkspaceId) continue; + const existing = grouped.get(row.projectWorkspaceId) ?? []; + existing.push(row); + grouped.set(row.projectWorkspaceId, existing); + } + + return new Map( + Array.from(grouped.entries()).map(([workspaceId, workspaceRows]) => [ + workspaceId, + selectCurrentRuntimeServiceRows(workspaceRows), + ]), + ); +} + +export async function listCurrentRuntimeServicesForExecutionWorkspaces( + db: Db, + companyId: string, + executionWorkspaceIds: string[], +) { + if (executionWorkspaceIds.length === 0) return new Map(); + + const rows = await db + .select() + .from(workspaceRuntimeServices) + .where( + and( + eq(workspaceRuntimeServices.companyId, companyId), + inArray(workspaceRuntimeServices.executionWorkspaceId, executionWorkspaceIds), + ), + ) + .orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt)); + + const grouped = new Map(); + for (const row of rows) { + if (!row.executionWorkspaceId) continue; + const existing = grouped.get(row.executionWorkspaceId) ?? []; + existing.push(row); + grouped.set(row.executionWorkspaceId, existing); + } + + return new Map( + Array.from(grouped.entries()).map(([workspaceId, workspaceRows]) => [ + workspaceId, + selectCurrentRuntimeServiceRows(workspaceRows), + ]), + ); +} diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index a100242e..44040311 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -1081,6 +1081,16 @@ async function waitForReadiness(input: { throw new Error(`Readiness check failed for ${input.url}: ${lastError}`); } +async function isRuntimeServiceUrlHealthy(url: string | null) { + if (!url) return true; + try { + const response = await fetch(url, { signal: AbortSignal.timeout(2_000) }); + return response.ok; + } catch { + return false; + } +} + function toPersistedWorkspaceRuntimeService(record: RuntimeServiceRecord): typeof workspaceRuntimeServices.$inferInsert { return { id: record.id, @@ -1847,50 +1857,55 @@ export async function reconcilePersistedRuntimeServicesOnStartup(db: Db) { profileKind: "workspace-runtime", }); if (adoptedRecord) { - const record: RuntimeServiceRecord = { - id: row.id, - companyId: row.companyId, - projectId: row.projectId ?? null, - projectWorkspaceId: row.projectWorkspaceId ?? null, - executionWorkspaceId: row.executionWorkspaceId ?? null, - issueId: row.issueId ?? null, - serviceName: row.serviceName, - status: "running", - lifecycle: row.lifecycle as RuntimeServiceRecord["lifecycle"], - scopeType: row.scopeType as RuntimeServiceRecord["scopeType"], - scopeId: row.scopeId ?? null, - reuseKey: row.reuseKey ?? null, - command: row.command ?? null, - cwd: row.cwd ?? null, - port: adoptedRecord.port ?? row.port ?? null, - url: adoptedRecord.url ?? row.url ?? null, - provider: "local_process", - providerRef: String(adoptedRecord.pid), - ownerAgentId: row.ownerAgentId ?? null, - startedByRunId: row.startedByRunId ?? null, - lastUsedAt: new Date().toISOString(), - startedAt: row.startedAt.toISOString(), - stoppedAt: null, - stopPolicy: (row.stopPolicy as Record | null) ?? null, - healthStatus: "healthy", - reused: true, - db, - child: null, - leaseRunIds: new Set(), - idleTimer: null, - envFingerprint: row.reuseKey ?? "", - serviceKey: adoptedRecord.serviceKey, - profileKind: "workspace-runtime", - processGroupId: adoptedRecord.processGroupId ?? null, - }; - registerRuntimeService(db, record); - await touchLocalServiceRegistryRecord(adoptedRecord.serviceKey, { - runtimeServiceId: row.id, - lastSeenAt: record.lastUsedAt, - }); - await persistRuntimeServiceRecord(db, record); - adopted += 1; - continue; + const adoptedUrl = adoptedRecord.url ?? row.url ?? null; + if (!(await isRuntimeServiceUrlHealthy(adoptedUrl))) { + await removeLocalServiceRegistryRecord(adoptedRecord.serviceKey); + } else { + const record: RuntimeServiceRecord = { + id: row.id, + companyId: row.companyId, + projectId: row.projectId ?? null, + projectWorkspaceId: row.projectWorkspaceId ?? null, + executionWorkspaceId: row.executionWorkspaceId ?? null, + issueId: row.issueId ?? null, + serviceName: row.serviceName, + status: "running", + lifecycle: row.lifecycle as RuntimeServiceRecord["lifecycle"], + scopeType: row.scopeType as RuntimeServiceRecord["scopeType"], + scopeId: row.scopeId ?? null, + reuseKey: row.reuseKey ?? null, + command: row.command ?? null, + cwd: row.cwd ?? null, + port: adoptedRecord.port ?? row.port ?? null, + url: adoptedRecord.url ?? row.url ?? null, + provider: "local_process", + providerRef: String(adoptedRecord.pid), + ownerAgentId: row.ownerAgentId ?? null, + startedByRunId: row.startedByRunId ?? null, + lastUsedAt: new Date().toISOString(), + startedAt: row.startedAt.toISOString(), + stoppedAt: null, + stopPolicy: (row.stopPolicy as Record | null) ?? null, + healthStatus: "healthy", + reused: true, + db, + child: null, + leaseRunIds: new Set(), + idleTimer: null, + envFingerprint: row.reuseKey ?? "", + serviceKey: adoptedRecord.serviceKey, + profileKind: "workspace-runtime", + processGroupId: adoptedRecord.processGroupId ?? null, + }; + registerRuntimeService(db, record); + await touchLocalServiceRegistryRecord(adoptedRecord.serviceKey, { + runtimeServiceId: row.id, + lastSeenAt: record.lastUsedAt, + }); + await persistRuntimeServiceRecord(db, record); + adopted += 1; + continue; + } } const now = new Date(); diff --git a/ui/src/components/CommentThread.test.tsx b/ui/src/components/CommentThread.test.tsx index 8ba65c60..aa972337 100644 --- a/ui/src/components/CommentThread.test.tsx +++ b/ui/src/components/CommentThread.test.tsx @@ -120,4 +120,28 @@ describe("CommentThread", () => { root.unmount(); }); }); + + it("replaces the composer with a warning when comments are disabled", () => { + const root = createRoot(container); + + act(() => { + root.render( + + {}} + /> + , + ); + }); + + expect(container.textContent).toContain("Workspace is closed."); + expect(container.querySelector('textarea[aria-label="Comment editor"]')).toBeNull(); + expect(container.textContent).not.toContain("Comment"); + + act(() => { + root.unmount(); + }); + }); }); diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx index b0b5c618..79f58702 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -78,6 +78,7 @@ interface CommentThreadProps { mentions?: MentionOption[]; onInterruptQueued?: (runId: string) => Promise; interruptingQueuedRunId?: string | null; + composerDisabledReason?: string | null; } const DRAFT_DEBOUNCE_MS = 800; @@ -569,6 +570,7 @@ export function CommentThread({ mentions: providedMentions, onInterruptQueued, interruptingQueuedRunId = null, + composerDisabledReason = null, }: CommentThreadProps) { const [body, setBody] = useState(""); const [reopen, setReopen] = useState(true); @@ -796,90 +798,96 @@ export function CommentThread({ )} -
- -
- {(imageUploadHandler || onAttachImage) && ( -
- - -
- )} - - {enableReassign && reassignOptions.length > 0 && ( - { - if (!option) return Assignee; - const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null; - const agent = agentId ? agentMap?.get(agentId) : null; - return ( - <> - {agent ? ( - - ) : null} - {option.label} - - ); - }} - renderOption={(option) => { - if (!option.id) return {option.label}; - const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null; - const agent = agentId ? agentMap?.get(agentId) : null; - return ( - <> - {agent ? ( - - ) : null} - {option.label} - - ); - }} - /> - )} - + {composerDisabledReason ? ( +
+ {composerDisabledReason}
-
+ ) : ( +
+ +
+ {(imageUploadHandler || onAttachImage) && ( +
+ + +
+ )} + + {enableReassign && reassignOptions.length > 0 && ( + { + if (!option) return Assignee; + const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null; + const agent = agentId ? agentMap?.get(agentId) : null; + return ( + <> + {agent ? ( + + ) : null} + {option.label} + + ); + }} + renderOption={(option) => { + if (!option.id) return {option.label}; + const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null; + const agent = agentId ? agentMap?.get(agentId) : null; + return ( + <> + {agent ? ( + + ) : null} + {option.label} + + ); + }} + /> + )} + +
+
+ )}
); diff --git a/ui/src/components/ExecutionWorkspaceCloseDialog.tsx b/ui/src/components/ExecutionWorkspaceCloseDialog.tsx index f0547684..78c09562 100644 --- a/ui/src/components/ExecutionWorkspaceCloseDialog.tsx +++ b/ui/src/components/ExecutionWorkspaceCloseDialog.tsx @@ -88,27 +88,27 @@ export function ExecutionWorkspaceCloseDialog({ { if (!closeWorkspace.isPending) onOpenChange(nextOpen); }}> - + {actionLabel} - + Archive {workspaceName} and clean up any owned workspace artifacts. Paperclip keeps the workspace record and issue history, but removes it from active workspace views. {readinessQuery.isLoading ? ( -
- +
+ Checking whether this workspace is safe to close...
) : readinessQuery.error ? ( -
+
{readinessQuery.error instanceof Error ? readinessQuery.error.message : "Failed to inspect workspace close readiness."}
) : readiness ? ( -
-
+
+
{readiness.state === "blocked" ? "Close is blocked" @@ -129,10 +129,10 @@ export function ExecutionWorkspaceCloseDialog({ {blockingIssues.length > 0 ? (
-

Blocking issues

-
+

Blocking issues

+
{blockingIssues.map((issue) => ( -
+
{issue.identifier ?? issue.id} · {issue.title} @@ -147,10 +147,10 @@ export function ExecutionWorkspaceCloseDialog({ {readiness.blockingReasons.length > 0 ? (
-

Blocking reasons

-
diff --git a/ui/src/pages/InstanceGeneralSettings.tsx b/ui/src/pages/InstanceGeneralSettings.tsx index 4b5e6b9c..28e00b29 100644 --- a/ui/src/pages/InstanceGeneralSettings.tsx +++ b/ui/src/pages/InstanceGeneralSettings.tsx @@ -1,10 +1,13 @@ import { useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { PatchInstanceGeneralSettings } from "@paperclipai/shared"; -import { SlidersHorizontal } from "lucide-react"; +import { LogOut, SlidersHorizontal } from "lucide-react"; +import { authApi } from "@/api/auth"; import { instanceSettingsApi } from "@/api/instanceSettings"; +import { Button } from "../components/ui/button"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; +import { ToggleSwitch } from "@/components/ui/toggle-switch"; import { cn } from "../lib/utils"; const FEEDBACK_TERMS_URL = import.meta.env.VITE_FEEDBACK_TERMS_URL?.trim() || "https://paperclip.ing/tos"; @@ -14,6 +17,16 @@ export function InstanceGeneralSettings() { const queryClient = useQueryClient(); const [actionError, setActionError] = useState(null); + const signOutMutation = useMutation({ + mutationFn: () => authApi.signOut(), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.auth.session }); + }, + onError: (error) => { + setActionError(error instanceof Error ? error.message : "Failed to sign out."); + }, + }); + useEffect(() => { setBreadcrumbs([ { label: "Instance Settings" }, @@ -83,28 +96,12 @@ export function InstanceGeneralSettings() { default.

- + aria-label="Toggle username log censoring" + />
@@ -117,24 +114,12 @@ export function InstanceGeneralSettings() { toggling panels. This is off by default.

- + aria-label="Toggle keyboard shortcuts" + />
@@ -213,6 +198,26 @@ export function InstanceGeneralSettings() {

+ +
+
+
+

Sign out

+

+ Sign out of this Paperclip instance. You will be redirected to the login page. +

+
+ +
+
); } diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index d1f16ab7..d16a3103 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -71,8 +71,16 @@ import { SlidersHorizontal, Trash2, } from "lucide-react"; -import type { ActivityEvent } from "@paperclipai/shared"; -import type { Agent, FeedbackVote, Issue, IssueAttachment, IssueComment } from "@paperclipai/shared"; +import { + getClosedIsolatedExecutionWorkspaceMessage, + isClosedIsolatedExecutionWorkspace, + type ActivityEvent, + type Agent, + type FeedbackVote, + type Issue, + type IssueAttachment, + type IssueComment, +} from "@paperclipai/shared"; type CommentReassignment = IssueCommentReassignment; type IssueDetailComment = (IssueComment | OptimisticIssueComment) & { @@ -306,6 +314,12 @@ export function IssueDetail() { enabled: !!issueId, }); const resolvedCompanyId = issue?.companyId ?? selectedCompanyId; + const commentComposerDisabledReason = useMemo(() => { + if (!issue?.currentExecutionWorkspace || !isClosedIsolatedExecutionWorkspace(issue.currentExecutionWorkspace)) { + return null; + } + return getClosedIsolatedExecutionWorkspaceMessage(issue.currentExecutionWorkspace); + }, [issue?.currentExecutionWorkspace]); const { data: comments } = useQuery({ queryKey: queryKeys.issues.comments(issueId!), @@ -1522,6 +1536,7 @@ export function IssueDetail() { await interruptQueuedComment.mutateAsync(runId); }} interruptingQueuedRunId={interruptQueuedComment.isPending ? runningIssueRun?.id ?? null : null} + composerDisabledReason={commentComposerDisabledReason} onVote={async (commentId, vote, options) => { await feedbackVoteMutation.mutateAsync({ targetType: "issue_comment", diff --git a/ui/src/pages/ProjectWorkspaceDetail.tsx b/ui/src/pages/ProjectWorkspaceDetail.tsx index 5831ec82..a5fe8ac1 100644 --- a/ui/src/pages/ProjectWorkspaceDetail.tsx +++ b/ui/src/pages/ProjectWorkspaceDetail.tsx @@ -61,6 +61,10 @@ function readText(value: string | null | undefined) { return value ?? ""; } +function hasActiveRuntimeServices(workspace: ProjectWorkspace | null | undefined) { + return (workspace?.runtimeServices ?? []).some((service) => service.status === "starting" || service.status === "running"); +} + function formatJson(value: Record | null | undefined) { if (!value || Object.keys(value).length === 0) return ""; return JSON.stringify(value, null, 2); @@ -624,7 +628,7 @@ export function ProjectWorkspaceDetail() { variant="outline" size="sm" className="w-full sm:w-auto" - disabled={controlRuntimeServices.isPending || (workspace.runtimeServices?.length ?? 0) === 0} + disabled={controlRuntimeServices.isPending || !hasActiveRuntimeServices(workspace)} onClick={() => controlRuntimeServices.mutate("stop")} > Stop diff --git a/ui/src/pages/RoutineDetail.tsx b/ui/src/pages/RoutineDetail.tsx index 55dc32f4..c1ca92e7 100644 --- a/ui/src/pages/RoutineDetail.tsx +++ b/ui/src/pages/RoutineDetail.tsx @@ -27,6 +27,7 @@ import { useToast } from "../context/ToastContext"; import { queryKeys } from "../lib/queryKeys"; import { buildRoutineTriggerPatch } from "../lib/routine-trigger-patch"; import { timeAgo } from "../lib/timeAgo"; +import { ToggleSwitch } from "@/components/ui/toggle-switch"; import { EmptyState } from "../components/EmptyState"; import { PageSkeleton } from "../components/PageSkeleton"; import { AgentIcon } from "../components/AgentIconPicker"; @@ -710,24 +711,13 @@ export function RoutineDetail() { }} disabled={runRoutine.isPending} /> - + aria-label={automationEnabled ? "Pause automatic triggers" : "Enable automatic triggers"} + /> {automationLabel} diff --git a/ui/src/pages/Routines.test.tsx b/ui/src/pages/Routines.test.tsx new file mode 100644 index 00000000..1d591dd7 --- /dev/null +++ b/ui/src/pages/Routines.test.tsx @@ -0,0 +1,367 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { Issue, RoutineListItem } from "@paperclipai/shared"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Routines, buildRoutineGroups } from "./Routines"; + +let currentSearch = ""; + +const navigateMock = vi.fn(); +const routinesListMock = vi.fn<(companyId: string) => Promise>(); +const issuesListMock = vi.fn<(companyId: string, filters?: Record) => Promise>(); +const issuesListRenderMock = vi.fn(({ issues }: { issues: Issue[] }) => ( +
{issues.map((issue) => issue.title).join(", ")}
+)); + +vi.mock("@/lib/router", () => ({ + useNavigate: () => navigateMock, + useLocation: () => ({ pathname: "/routines", search: currentSearch ? `?${currentSearch}` : "", hash: "" }), + useSearchParams: () => [new URLSearchParams(currentSearch), vi.fn()], +})); + +vi.mock("../context/CompanyContext", () => ({ + useCompany: () => ({ selectedCompanyId: "company-1" }), +})); + +vi.mock("../context/BreadcrumbContext", () => ({ + useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }), +})); + +vi.mock("../context/ToastContext", () => ({ + useToast: () => ({ pushToast: vi.fn() }), +})); + +vi.mock("../api/routines", () => ({ + routinesApi: { + list: (companyId: string) => routinesListMock(companyId), + create: vi.fn(), + update: vi.fn(), + run: vi.fn(), + }, +})); + +vi.mock("../api/issues", () => ({ + issuesApi: { + list: (companyId: string, filters?: Record) => issuesListMock(companyId, filters), + update: vi.fn(), + }, +})); + +vi.mock("../api/agents", () => ({ + agentsApi: { + list: vi.fn(async () => [ + { + id: "agent-1", + companyId: "company-1", + name: "Agent One", + role: "engineer", + title: null, + status: "active", + reportsTo: null, + capabilities: null, + adapterType: "process", + adapterConfig: {}, + contextMode: "thin", + budgetMonthlyCents: 0, + spentMonthlyCents: 0, + lastHeartbeatAt: null, + icon: "code", + metadata: null, + createdAt: new Date("2026-04-01T00:00:00.000Z"), + updatedAt: new Date("2026-04-01T00:00:00.000Z"), + urlKey: "agent-one", + pauseReason: null, + pausedAt: null, + permissions: null, + }, + { + id: "agent-2", + companyId: "company-1", + name: "Agent Two", + role: "engineer", + title: null, + status: "active", + reportsTo: null, + capabilities: null, + adapterType: "process", + adapterConfig: {}, + contextMode: "thin", + budgetMonthlyCents: 0, + spentMonthlyCents: 0, + lastHeartbeatAt: null, + icon: "code", + metadata: null, + createdAt: new Date("2026-04-01T00:00:00.000Z"), + updatedAt: new Date("2026-04-01T00:00:00.000Z"), + urlKey: "agent-two", + pauseReason: null, + pausedAt: null, + permissions: null, + }, + ]), + }, +})); + +vi.mock("../api/projects", () => ({ + projectsApi: { + list: vi.fn(async () => [ + { + id: "project-1", + companyId: "company-1", + urlKey: "project-alpha", + goalId: null, + goalIds: [], + goals: [], + name: "Project Alpha", + description: null, + status: "in_progress", + leadAgentId: null, + targetDate: null, + color: "#22c55e", + pauseReason: null, + pausedAt: null, + archivedAt: null, + executionWorkspacePolicy: null, + codebase: null, + workspaces: [], + primaryWorkspace: null, + createdAt: new Date("2026-04-01T00:00:00.000Z"), + updatedAt: new Date("2026-04-01T00:00:00.000Z"), + }, + { + id: "project-2", + companyId: "company-1", + urlKey: "project-beta", + goalId: null, + goalIds: [], + goals: [], + name: "Project Beta", + description: null, + status: "in_progress", + leadAgentId: null, + targetDate: null, + color: "#38bdf8", + pauseReason: null, + pausedAt: null, + archivedAt: null, + executionWorkspacePolicy: null, + codebase: null, + workspaces: [], + primaryWorkspace: null, + createdAt: new Date("2026-04-01T00:00:00.000Z"), + updatedAt: new Date("2026-04-01T00:00:00.000Z"), + }, + ]), + }, +})); + +vi.mock("../api/instanceSettings", () => ({ + instanceSettingsApi: { + getExperimental: vi.fn(async () => ({ enableIsolatedWorkspaces: false })), + }, +})); + +vi.mock("../api/heartbeats", () => ({ + heartbeatsApi: { + liveRunsForCompany: vi.fn(async () => []), + }, +})); + +vi.mock("../components/IssuesList", () => ({ + IssuesList: (props: { issues: Issue[] }) => issuesListRenderMock(props), +})); + +vi.mock("../components/PageTabBar", () => ({ + PageTabBar: ({ items }: { items: Array<{ label: string }> }) => ( +
{items.map((item) => item.label).join(", ")}
+ ), +})); + +vi.mock("@/components/ui/tabs", () => ({ + Tabs: ({ children }: { children: unknown }) =>
{children as never}
, + TabsContent: ({ children }: { children: unknown }) =>
{children as never}
, +})); + +vi.mock("../components/MarkdownEditor", () => ({ + MarkdownEditor: () =>
, +})); + +vi.mock("../components/InlineEntitySelector", () => ({ + InlineEntitySelector: () => , +})); + +vi.mock("../components/RoutineRunVariablesDialog", () => ({ + RoutineRunVariablesDialog: () => null, + routineRunNeedsConfiguration: () => false, +})); + +vi.mock("../components/RoutineVariablesEditor", () => ({ + RoutineVariablesEditor: () => null, + RoutineVariablesHint: () => null, +})); + +vi.mock("../components/AgentIconPicker", () => ({ + AgentIcon: () => , +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function createRoutine(overrides: Partial): RoutineListItem { + return { + id: "routine-1", + companyId: "company-1", + projectId: "project-1", + goalId: null, + parentIssueId: null, + title: "Routine title", + description: null, + assigneeAgentId: "agent-1", + priority: "medium", + status: "active", + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + variables: [], + createdByAgentId: null, + createdByUserId: null, + updatedByAgentId: null, + updatedByUserId: null, + lastTriggeredAt: null, + lastEnqueuedAt: null, + createdAt: new Date("2026-04-01T00:00:00.000Z"), + updatedAt: new Date("2026-04-01T00:00:00.000Z"), + triggers: [], + lastRun: null, + activeIssue: null, + ...overrides, + }; +} + +function createIssue(overrides: Partial = {}): Issue { + return { + id: "issue-1", + identifier: "PAP-1000", + companyId: "company-1", + projectId: "project-1", + projectWorkspaceId: null, + goalId: null, + parentId: null, + title: "Routine execution issue", + description: null, + status: "todo", + priority: "medium", + assigneeAgentId: "agent-1", + assigneeUserId: null, + createdByAgentId: null, + createdByUserId: null, + issueNumber: 1000, + originKind: "routine_execution", + originId: "routine-1", + originRunId: null, + requestDepth: 0, + billingCode: null, + assigneeAdapterOverrides: null, + executionWorkspaceId: null, + executionWorkspacePreference: null, + executionWorkspaceSettings: null, + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + startedAt: null, + completedAt: null, + cancelledAt: null, + hiddenAt: null, + createdAt: new Date("2026-04-01T00:00:00.000Z"), + updatedAt: new Date("2026-04-01T00:00:00.000Z"), + labels: [], + labelIds: [], + myLastTouchAt: null, + lastExternalCommentAt: null, + lastActivityAt: new Date("2026-04-01T00:00:00.000Z"), + isUnreadForMe: false, + ...overrides, + }; +} + +async function flush() { + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); +} + +describe("Routines page", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + currentSearch = ""; + navigateMock.mockReset(); + routinesListMock.mockReset(); + issuesListMock.mockReset(); + issuesListRenderMock.mockClear(); + localStorage.clear(); + }); + + afterEach(() => { + container.remove(); + document.body.innerHTML = ""; + }); + + it("groups routines by project using project names for the section labels", () => { + const groups = buildRoutineGroups( + [ + createRoutine({ id: "routine-1", title: "Morning sync", projectId: "project-1" }), + createRoutine({ id: "routine-2", title: "Weekly digest", projectId: "project-2", assigneeAgentId: "agent-2" }), + ], + "project", + new Map([ + ["project-1", { name: "Project Alpha" }], + ["project-2", { name: "Project Beta" }], + ]), + new Map([ + ["agent-1", { name: "Agent One" }], + ["agent-2", { name: "Agent Two" }], + ]), + ); + + expect(groups.map((group) => group.label)).toEqual(["Project Alpha", "Project Beta"]); + expect(groups[0]?.items.map((item) => item.title)).toEqual(["Morning sync"]); + expect(groups[1]?.items.map((item) => item.title)).toEqual(["Weekly digest"]); + }); + + it("shows recent runs through the issues list scoped to routine execution issues", async () => { + currentSearch = "tab=runs"; + routinesListMock.mockResolvedValue([createRoutine({ id: "routine-1" })]); + issuesListMock.mockResolvedValue([ + createIssue({ id: "issue-1", title: "Routine execution A" }), + createIssue({ id: "issue-2", title: "Routine execution B", identifier: "PAP-1001", issueNumber: 1001 }), + ]); + + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + + await act(async () => { + root.render( + + + , + ); + await flush(); + }); + + expect(issuesListMock).toHaveBeenCalledWith("company-1", { originKind: "routine_execution" }); + + await act(async () => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/pages/Routines.tsx b/ui/src/pages/Routines.tsx index fc856d72..85e32796 100644 --- a/ui/src/pages/Routines.tsx +++ b/ui/src/pages/Routines.tsx @@ -1,18 +1,25 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { startTransition, useEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useNavigate } from "@/lib/router"; -import { ChevronDown, ChevronRight, MoreHorizontal, Play, Plus, Repeat } from "lucide-react"; +import { useNavigate, useSearchParams } from "@/lib/router"; +import { Check, ChevronDown, ChevronRight, Layers, MoreHorizontal, Plus, Repeat } from "lucide-react"; import { routinesApi } from "../api/routines"; import { instanceSettingsApi } from "../api/instanceSettings"; import { agentsApi } from "../api/agents"; import { projectsApi } from "../api/projects"; +import { issuesApi } from "../api/issues"; +import { heartbeatsApi } from "../api/heartbeats"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useToast } from "../context/ToastContext"; import { queryKeys } from "../lib/queryKeys"; +import { groupBy } from "../lib/groupBy"; +import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb"; import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees"; +import { ToggleSwitch } from "@/components/ui/toggle-switch"; import { EmptyState } from "../components/EmptyState"; +import { IssuesList } from "../components/IssuesList"; import { PageSkeleton } from "../components/PageSkeleton"; +import { PageTabBar } from "../components/PageTabBar"; import { AgentIcon } from "../components/AgentIconPicker"; import { InlineEntitySelector, type InlineEntityOption } from "../components/InlineEntitySelector"; import { MarkdownEditor, type MarkdownEditorRef } from "../components/MarkdownEditor"; @@ -33,6 +40,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Select, SelectContent, @@ -40,6 +48,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Tabs, TabsContent } from "@/components/ui/tabs"; import type { RoutineListItem, RoutineVariable } from "@paperclipai/shared"; const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"]; @@ -70,11 +79,203 @@ function nextRoutineStatus(currentStatus: string, enabled: boolean) { return enabled ? "active" : "paused"; } +type RoutinesTab = "routines" | "runs"; +type RoutineGroupBy = "none" | "project" | "assignee"; + +type RoutineViewState = { + groupBy: RoutineGroupBy; + collapsedGroups: string[]; +}; + +type RoutineGroup = { + key: string; + label: string | null; + items: RoutineListItem[]; +}; + +const defaultRoutineViewState: RoutineViewState = { + groupBy: "none", + collapsedGroups: [], +}; + +function getRoutineViewState(key: string): RoutineViewState { + try { + const raw = localStorage.getItem(key); + if (raw) return { ...defaultRoutineViewState, ...JSON.parse(raw) }; + } catch { + // Ignore malformed local state and fall back to defaults. + } + return { ...defaultRoutineViewState }; +} + +function saveRoutineViewState(key: string, state: RoutineViewState) { + localStorage.setItem(key, JSON.stringify(state)); +} + +function formatRoutineRunStatus(value: string | null | undefined) { + if (!value) return null; + return value.replaceAll("_", " "); +} + +export function buildRoutineGroups( + routines: RoutineListItem[], + groupByValue: RoutineGroupBy, + projectById: Map, + agentById: Map, +): RoutineGroup[] { + if (groupByValue === "none") { + return [{ key: "__all", label: null, items: routines }]; + } + + if (groupByValue === "project") { + const groups = groupBy(routines, (routine) => routine.projectId ?? "__no_project"); + return Object.keys(groups) + .sort((left, right) => { + const leftLabel = left === "__no_project" ? "No project" : (projectById.get(left)?.name ?? "Unknown project"); + const rightLabel = right === "__no_project" ? "No project" : (projectById.get(right)?.name ?? "Unknown project"); + return leftLabel.localeCompare(rightLabel); + }) + .map((key) => ({ + key, + label: key === "__no_project" ? "No project" : (projectById.get(key)?.name ?? "Unknown project"), + items: groups[key]!, + })); + } + + const groups = groupBy(routines, (routine) => routine.assigneeAgentId ?? "__unassigned"); + return Object.keys(groups) + .sort((left, right) => { + const leftLabel = left === "__unassigned" ? "Unassigned" : (agentById.get(left)?.name ?? "Unknown agent"); + const rightLabel = right === "__unassigned" ? "Unassigned" : (agentById.get(right)?.name ?? "Unknown agent"); + return leftLabel.localeCompare(rightLabel); + }) + .map((key) => ({ + key, + label: key === "__unassigned" ? "Unassigned" : (agentById.get(key)?.name ?? "Unknown agent"), + items: groups[key]!, + })); +} + +function buildRoutinesTabHref(tab: RoutinesTab) { + return tab === "runs" ? "/routines?tab=runs" : "/routines"; +} + +function RoutineListRow({ + routine, + projectById, + agentById, + runningRoutineId, + statusMutationRoutineId, + onNavigate, + onRunNow, + onToggleEnabled, + onToggleArchived, +}: { + routine: RoutineListItem; + projectById: Map; + agentById: Map; + runningRoutineId: string | null; + statusMutationRoutineId: string | null; + onNavigate: (routineId: string) => void; + onRunNow: (routine: RoutineListItem) => void; + onToggleEnabled: (routine: RoutineListItem, enabled: boolean) => void; + onToggleArchived: (routine: RoutineListItem) => void; +}) { + const enabled = routine.status === "active"; + const isArchived = routine.status === "archived"; + const isStatusPending = statusMutationRoutineId === routine.id; + const project = routine.projectId ? projectById.get(routine.projectId) ?? null : null; + const agent = routine.assigneeAgentId ? agentById.get(routine.assigneeAgentId) ?? null : null; + + return ( +
onNavigate(routine.id)} + > +
+
+ {routine.title} + {(isArchived || routine.status === "paused") ? ( + + {isArchived ? "archived" : "paused"} + + ) : null} +
+
+ + + {project?.name ?? "Unknown project"} + + + {agent?.icon ? : null} + {agent?.name ?? "Unknown agent"} + + + {formatLastRunTimestamp(routine.lastRun?.triggeredAt)} + {routine.lastRun ? ` · ${formatRoutineRunStatus(routine.lastRun.status)}` : ""} + +
+
+ +
event.stopPropagation()}> +
+ onToggleEnabled(routine, enabled)} + disabled={isStatusPending || isArchived} + aria-label={enabled ? `Disable ${routine.title}` : `Enable ${routine.title}`} + /> + + {isArchived ? "Archived" : enabled ? "On" : "Off"} + +
+ + + + + + + onNavigate(routine.id)}> + Edit + + onRunNow(routine)} + > + {runningRoutineId === routine.id ? "Running..." : "Run now"} + + + onToggleEnabled(routine, enabled)} + disabled={isStatusPending || isArchived} + > + {enabled ? "Pause" : "Enable"} + + onToggleArchived(routine)} + disabled={isStatusPending} + > + {routine.status === "archived" ? "Restore" : "Archive"} + + + +
+
+ ); +} + export function Routines() { const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const queryClient = useQueryClient(); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const { pushToast } = useToast(); const descriptionEditorRef = useRef(null); const titleInputRef = useRef(null); @@ -85,6 +286,7 @@ export function Routines() { const [runDialogRoutine, setRunDialogRoutine] = useState(null); const [composerOpen, setComposerOpen] = useState(false); const [advancedOpen, setAdvancedOpen] = useState(false); + const activeTab: RoutinesTab = searchParams.get("tab") === "runs" ? "runs" : "routines"; const [draft, setDraft] = useState<{ title: string; description: string; @@ -104,11 +306,19 @@ export function Routines() { catchUpPolicy: "skip_missed", variables: [], }); + const routineViewStateKey = selectedCompanyId + ? `paperclip:routines-view:${selectedCompanyId}` + : "paperclip:routines-view"; + const [routineViewState, setRoutineViewState] = useState(() => getRoutineViewState(routineViewStateKey)); useEffect(() => { setBreadcrumbs([{ label: "Routines" }]); }, [setBreadcrumbs]); + useEffect(() => { + setRoutineViewState(getRoutineViewState(routineViewStateKey)); + }, [routineViewStateKey]); + const { data: routines, isLoading, error } = useQuery({ queryKey: queryKeys.routines.list(selectedCompanyId!), queryFn: () => routinesApi.list(selectedCompanyId!), @@ -129,6 +339,17 @@ export function Routines() { queryFn: () => instanceSettingsApi.getExperimental(), retry: false, }); + const { data: routineExecutionIssues, isLoading: recentRunsLoading, error: recentRunsError } = useQuery({ + queryKey: [...queryKeys.issues.list(selectedCompanyId!), "routine-executions"], + queryFn: () => issuesApi.list(selectedCompanyId!, { originKind: "routine_execution" }), + enabled: !!selectedCompanyId && activeTab === "runs", + }); + const { data: liveRuns } = useQuery({ + queryKey: queryKeys.liveRuns(selectedCompanyId!), + queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!), + enabled: !!selectedCompanyId && activeTab === "runs", + refetchInterval: 5000, + }); useEffect(() => { autoResizeTextarea(titleInputRef.current); @@ -162,6 +383,13 @@ export function Routines() { navigate(`/routines/${routine.id}?tab=triggers`); }, }); + const updateIssue = useMutation({ + mutationFn: ({ id, data }: { id: string; data: Record }) => + issuesApi.update(id, data), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: [...queryKeys.issues.list(selectedCompanyId!), "routine-executions"] }); + }, + }); const updateRoutineStatus = useMutation({ mutationFn: ({ id, status }: { id: string; status: string }) => routinesApi.update(id, { status }), @@ -249,10 +477,45 @@ export function Routines() { () => new Map((projects ?? []).map((project) => [project.id, project])), [projects], ); + const liveIssueIds = useMemo(() => { + const ids = new Set(); + for (const run of liveRuns ?? []) { + if (run.issueId) ids.add(run.issueId); + } + return ids; + }, [liveRuns]); + const routineGroups = useMemo( + () => buildRoutineGroups(routines ?? [], routineViewState.groupBy, projectById, agentById), + [agentById, projectById, routineViewState.groupBy, routines], + ); + const recentRunsIssueLinkState = useMemo( + () => + createIssueDetailLocationState( + "Recent Runs", + buildRoutinesTabHref("runs"), + "issues", + ), + [], + ); const runDialogProject = runDialogRoutine?.projectId ? projectById.get(runDialogRoutine.projectId) ?? null : null; const currentAssignee = draft.assigneeAgentId ? agentById.get(draft.assigneeAgentId) ?? null : null; const currentProject = draft.projectId ? projectById.get(draft.projectId) ?? null : null; + function updateRoutineView(patch: Partial) { + setRoutineViewState((current) => { + const next = { ...current, ...patch }; + saveRoutineViewState(routineViewStateKey, next); + return next; + }); + } + + function handleTabChange(tab: string) { + const nextTab = tab === "runs" ? "runs" : "routines"; + startTransition(() => { + navigate(buildRoutinesTabHref(nextTab)); + }); + } + function handleRunNow(routine: RoutineListItem) { const project = routine.projectId ? projectById.get(routine.projectId) ?? null : null; const needsConfiguration = routineRunNeedsConfiguration({ @@ -267,6 +530,20 @@ export function Routines() { runRoutine.mutate({ id: routine.id, data: {} }); } + function handleToggleEnabled(routine: RoutineListItem, enabled: boolean) { + updateRoutineStatus.mutate({ + id: routine.id, + status: nextRoutineStatus(routine.status, !enabled), + }); + } + + function handleToggleArchived(routine: RoutineListItem) { + updateRoutineStatus.mutate({ + id: routine.id, + status: routine.status === "archived" ? "active" : "archived", + }); + } + if (!selectedCompanyId) { return ; } @@ -293,6 +570,68 @@ export function Routines() {
+ + + +
+

+ {(routines ?? []).length} routine{(routines ?? []).length === 1 ? "" : "s"} +

+ + + + + +
+ {([ + ["project", "Project"], + ["assignee", "Agent"], + ["none", "None"], + ] as const).map(([value, label]) => ( + + ))} +
+
+
+
+
+ + updateIssue.mutate({ id, data })} + /> + +
+ { @@ -560,165 +899,64 @@ export function Routines() { ) : null} -
- {(routines ?? []).length === 0 ? ( -
- -
- ) : ( -
- - - - - - - - - - - - {(routines ?? []).map((routine) => { - const enabled = routine.status === "active"; - const isArchived = routine.status === "archived"; - const isStatusPending = statusMutationRoutineId === routine.id; - return ( - navigate(`/routines/${routine.id}`)} - > - - - - - - - - ); - })} - -
NameProjectAgentLast runEnabled -
-
- - {routine.title} - - {(isArchived || routine.status === "paused") && ( -
- {isArchived ? "archived" : "paused"} -
- )} -
-
- {routine.projectId ? ( -
- - {projectById.get(routine.projectId)?.name ?? "Unknown"} -
- ) : ( - - )} -
- {routine.assigneeAgentId ? (() => { - const agent = agentById.get(routine.assigneeAgentId); - return agent ? ( -
- - {agent.name} -
- ) : ( - Unknown - ); - })() : ( - - )} -
-
{formatLastRunTimestamp(routine.lastRun?.triggeredAt)}
- {routine.lastRun ? ( -
{routine.lastRun.status.replaceAll("_", " ")}
- ) : null} -
e.stopPropagation()}> -
- - - {isArchived ? "Archived" : enabled ? "On" : "Off"} - -
-
e.stopPropagation()}> - - - - - - navigate(`/routines/${routine.id}`)}> - Edit - - handleRunNow(routine)} - > - {runningRoutineId === routine.id ? "Running..." : "Run now"} - - - - updateRoutineStatus.mutate({ - id: routine.id, - status: enabled ? "paused" : "active", - }) - } - disabled={isStatusPending || isArchived} - > - {enabled ? "Pause" : "Enable"} - - - updateRoutineStatus.mutate({ - id: routine.id, - status: routine.status === "archived" ? "active" : "archived", - }) - } - disabled={isStatusPending} - > - {routine.status === "archived" ? "Restore" : "Archive"} - - - -
-
- )} -
+ {activeTab === "routines" ? ( +
+ {(routines ?? []).length === 0 ? ( +
+ +
+ ) : ( +
+ {routineGroups.map((group) => ( + { + updateRoutineView({ + collapsedGroups: open + ? routineViewState.collapsedGroups.filter((item) => item !== group.key) + : [...routineViewState.collapsedGroups, group.key], + }); + }} + > + {group.label ? ( +
+ + + + {group.label} + + + + {group.items.length} + +
+ ) : null} + + {group.items.map((routine) => ( + navigate(`/routines/${routineId}`)} + onRunNow={handleRunNow} + onToggleEnabled={handleToggleEnabled} + onToggleArchived={handleToggleArchived} + /> + ))} + +
+ ))} +
+ )} +
+ ) : null}