diff --git a/server/src/__tests__/routines-routes.test.ts b/server/src/__tests__/routines-routes.test.ts index 1e8806a8..35b6b355 100644 --- a/server/src/__tests__/routines-routes.test.ts +++ b/server/src/__tests__/routines-routes.test.ts @@ -145,6 +145,7 @@ describe("routine routes", () => { registerModuleMocks(); vi.clearAllMocks(); mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); + mockRoutineService.list.mockResolvedValue([routine]); mockRoutineService.create.mockResolvedValue(routine); mockRoutineService.get.mockResolvedValue(routine); mockRoutineService.getTrigger.mockResolvedValue(trigger); @@ -158,6 +159,23 @@ describe("routine routes", () => { mockLogActivity.mockResolvedValue(undefined); }); + it("passes project filters to the routine list service", async () => { + const app = await createApp({ + type: "board", + userId: "board-user", + source: "session", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const res = await request(app) + .get(`/api/companies/${companyId}/routines`) + .query({ projectId }); + + expect(res.status).toBe(200); + expect(mockRoutineService.list).toHaveBeenCalledWith(companyId, { projectId }); + }); + it("requires tasks:assign permission for non-admin board routine creation", async () => { const app = await createApp({ type: "board", diff --git a/server/src/__tests__/routines-service.test.ts b/server/src/__tests__/routines-service.test.ts index e59dce51..45769877 100644 --- a/server/src/__tests__/routines-service.test.ts +++ b/server/src/__tests__/routines-service.test.ts @@ -178,6 +178,39 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { return { companyId, agentId, issueSvc, projectId, routine, svc, wakeups }; } + it("filters listed routines by project", async () => { + const { companyId, agentId, projectId, routine, svc } = await seedFixture(); + const otherProjectId = randomUUID(); + await db.insert(projects).values({ + id: otherProjectId, + companyId, + name: "Other routines", + status: "in_progress", + }); + const otherRoutine = await svc.create( + companyId, + { + projectId: otherProjectId, + goalId: null, + parentIssueId: null, + title: "other project routine", + description: null, + assigneeAgentId: agentId, + priority: "medium", + status: "active", + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + }, + {}, + ); + + const projectRoutines = await svc.list(companyId, { projectId }); + const allRoutines = await svc.list(companyId); + + expect(projectRoutines.map((entry) => entry.id)).toEqual([routine.id]); + expect(allRoutines.map((entry) => entry.id)).toEqual(expect.arrayContaining([routine.id, otherRoutine.id])); + }); + it("creates a fresh execution issue when the previous routine issue is open but idle", async () => { const { companyId, issueSvc, routine, svc } = await seedFixture(); const previousRunId = randomUUID(); diff --git a/server/src/routes/routines.ts b/server/src/routes/routines.ts index 0d78c066..5bae6bdb 100644 --- a/server/src/routes/routines.ts +++ b/server/src/routes/routines.ts @@ -60,7 +60,8 @@ export function routineRoutes( router.get("/companies/:companyId/routines", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); - const result = await svc.list(companyId); + const projectId = typeof req.query.projectId === "string" ? req.query.projectId : undefined; + const result = await svc.list(companyId, { projectId }); res.json(result); }); diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index f12e275c..a632f776 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -1071,11 +1071,17 @@ export function routineService( get: getRoutineById, getTrigger: getTriggerById, - list: async (companyId: string): Promise => { + list: async ( + companyId: string, + filters?: { projectId?: string | null }, + ): Promise => { + const conditions = [eq(routines.companyId, companyId)]; + if (filters?.projectId) conditions.push(eq(routines.projectId, filters.projectId)); + const rows = await db .select() .from(routines) - .where(eq(routines.companyId, companyId)) + .where(and(...conditions)) .orderBy(desc(routines.updatedAt), asc(routines.title)); const routineIds = rows.map((row) => row.id); const [triggersByRoutine, latestRunByRoutine, activeIssueByRoutine] = await Promise.all([ diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 8e6015f8..e29e6efe 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -110,6 +110,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> } /> @@ -306,6 +307,7 @@ export function App() { } /> } /> } /> + } /> }> {boardRoutes()} diff --git a/ui/src/api/routines.ts b/ui/src/api/routines.ts index f6e5099b..0c6c0681 100644 --- a/ui/src/api/routines.ts +++ b/ui/src/api/routines.ts @@ -22,7 +22,12 @@ export interface RotateRoutineTriggerResponse { } export const routinesApi = { - list: (companyId: string) => api.get(`/companies/${companyId}/routines`), + list: (companyId: string, filters?: { projectId?: string | null }) => { + const params = new URLSearchParams(); + if (filters?.projectId) params.set("projectId", filters.projectId); + const query = params.toString(); + return api.get(`/companies/${companyId}/routines${query ? `?${query}` : ""}`); + }, create: (companyId: string, data: Record) => api.post(`/companies/${companyId}/routines`, data), get: (id: string) => api.get(`/routines/${id}`), diff --git a/ui/src/components/IssueWorkspaceCard.tsx b/ui/src/components/IssueWorkspaceCard.tsx index 385b3598..c2f143c4 100644 --- a/ui/src/components/IssueWorkspaceCard.tsx +++ b/ui/src/components/IssueWorkspaceCard.tsx @@ -7,6 +7,7 @@ import { environmentsApi } from "../api/environments"; import { instanceSettingsApi } from "../api/instanceSettings"; import { useCompany } from "../context/CompanyContext"; import { queryKeys } from "../lib/queryKeys"; +import { orderReusableExecutionWorkspaces } from "../lib/reusable-execution-workspaces"; import { cn, projectWorkspaceUrl } from "../lib/utils"; import { Button } from "@/components/ui/button"; import { Check, Copy, GitBranch, FolderOpen, Pencil, X } from "lucide-react"; @@ -237,16 +238,7 @@ export function IssueWorkspaceCard({ }); const deduplicatedReusableWorkspaces = useMemo(() => { - const workspaces = reusableExecutionWorkspaces ?? []; - const seen = new Map(); - for (const ws of workspaces) { - const key = ws.cwd ?? ws.id; - const existing = seen.get(key); - if (!existing || new Date(ws.lastUsedAt) > new Date(existing.lastUsedAt)) { - seen.set(key, ws); - } - } - return Array.from(seen.values()); + return orderReusableExecutionWorkspaces(reusableExecutionWorkspaces ?? []); }, [reusableExecutionWorkspaces]); const selectedReusableExecutionWorkspace = diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 41bbb229..6f8e7d31 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -14,6 +14,7 @@ import { authApi } from "../api/auth"; import { assetsApi } from "../api/assets"; import { buildCompanyUserInlineOptions, buildMarkdownMentionOptions } from "../lib/company-members"; import { queryKeys } from "../lib/queryKeys"; +import { orderReusableExecutionWorkspaces } from "../lib/reusable-execution-workspaces"; import { useProjectOrder } from "../hooks/useProjectOrder"; import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees"; import { getRecentProjectIds, trackRecentProject } from "../lib/recent-projects"; @@ -1003,16 +1004,7 @@ export function NewIssueDialog() { : null; const currentProjectSupportsExecutionWorkspace = Boolean(currentProjectExecutionWorkspacePolicy?.enabled); const deduplicatedReusableWorkspaces = useMemo(() => { - const workspaces = reusableExecutionWorkspaces ?? []; - const seen = new Map(); - for (const ws of workspaces) { - const key = ws.cwd ?? ws.id; - const existing = seen.get(key); - if (!existing || new Date(ws.lastUsedAt) > new Date(existing.lastUsedAt)) { - seen.set(key, ws); - } - } - return Array.from(seen.values()); + return orderReusableExecutionWorkspaces(reusableExecutionWorkspaces ?? []); }, [reusableExecutionWorkspaces]); const selectedReusableExecutionWorkspace = deduplicatedReusableWorkspaces.find( (workspace) => workspace.id === selectedExecutionWorkspaceId, diff --git a/ui/src/components/RoutineRunVariablesDialog.test.tsx b/ui/src/components/RoutineRunVariablesDialog.test.tsx index e3a18861..58d0b170 100644 --- a/ui/src/components/RoutineRunVariablesDialog.test.tsx +++ b/ui/src/components/RoutineRunVariablesDialog.test.tsx @@ -3,7 +3,7 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import type { Agent, Project } from "@paperclipai/shared"; +import type { Agent, ExecutionWorkspace, Project } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { RoutineRunVariablesDialog } from "./RoutineRunVariablesDialog"; @@ -14,6 +14,7 @@ let issueWorkspaceDraft = { executionWorkspaceSettings: { mode: "shared_workspace" }, }; let issueWorkspaceBranchName: string | null = null; +let latestWorkspaceIssue: Record | null = null; vi.mock("../api/instanceSettings", () => ({ instanceSettingsApi: { @@ -26,14 +27,17 @@ vi.mock("./IssueWorkspaceCard", async () => { return { IssueWorkspaceCard: ({ + issue, onDraftChange, }: { + issue: Record; onDraftChange?: ( data: Record, meta: { canSave: boolean; workspaceBranchName?: string | null }, ) => void; }) => { React.useEffect(() => { + latestWorkspaceIssue = issue; issueWorkspaceDraftCalls += 1; if (issueWorkspaceDraftCalls > 20) { throw new Error("IssueWorkspaceCard onDraftChange looped"); @@ -120,6 +124,43 @@ function createAgent(): Agent { }; } +function createExecutionWorkspace(): ExecutionWorkspace { + return { + id: "workspace-1", + companyId: "company-1", + projectId: "project-1", + projectWorkspaceId: "project-workspace-1", + sourceIssueId: null, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "PAP-1634", + status: "active", + cwd: "/tmp/paperclip/PAP-1634", + repoUrl: null, + baseRef: "main", + branchName: "pap-1634-routine-branch", + providerType: "local_fs", + providerRef: null, + derivedFromExecutionWorkspaceId: null, + lastUsedAt: new Date("2026-04-02T00:00:00.000Z"), + openedAt: new Date("2026-04-02T00:00:00.000Z"), + closedAt: null, + cleanupEligibleAt: null, + cleanupReason: null, + config: { + provisionCommand: null, + teardownCommand: null, + cleanupCommand: null, + workspaceRuntime: null, + desiredState: null, + }, + metadata: null, + runtimeServices: [], + createdAt: new Date("2026-04-02T00:00:00.000Z"), + updatedAt: new Date("2026-04-02T00:00:00.000Z"), + }; +} + describe("RoutineRunVariablesDialog", () => { let container: HTMLDivElement; @@ -133,6 +174,7 @@ describe("RoutineRunVariablesDialog", () => { executionWorkspaceSettings: { mode: "shared_workspace" }, }; issueWorkspaceBranchName = null; + latestWorkspaceIssue = null; }); afterEach(() => { @@ -264,4 +306,63 @@ describe("RoutineRunVariablesDialog", () => { root.unmount(); }); }); + + it("prefills the supplied execution workspace for workspace-specific routine runs", async () => { + const workspace = createExecutionWorkspace(); + issueWorkspaceDraft = { + executionWorkspaceId: workspace.id, + executionWorkspacePreference: "reuse_existing", + executionWorkspaceSettings: { mode: "isolated_workspace" }, + }; + issueWorkspaceBranchName = workspace.branchName; + + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + await act(async () => { + root.render( + + {}} + companyId="company-1" + projects={[createProject()]} + agents={[createAgent()]} + defaultProjectId="project-1" + defaultAssigneeAgentId="agent-1" + defaultExecutionWorkspace={workspace} + variables={[]} + isPending={false} + onSubmit={() => {}} + /> + , + ); + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + for (let i = 0; i < 10 && latestWorkspaceIssue === null; i += 1) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } + + expect(latestWorkspaceIssue).toMatchObject({ + executionWorkspaceId: workspace.id, + executionWorkspacePreference: "reuse_existing", + currentExecutionWorkspace: workspace, + projectWorkspaceId: workspace.projectWorkspaceId, + }); + + await act(async () => { + root.unmount(); + }); + }); }); diff --git a/ui/src/components/RoutineRunVariablesDialog.tsx b/ui/src/components/RoutineRunVariablesDialog.tsx index b027b2d5..8129fb28 100644 --- a/ui/src/components/RoutineRunVariablesDialog.tsx +++ b/ui/src/components/RoutineRunVariablesDialog.tsx @@ -2,6 +2,8 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { WORKSPACE_BRANCH_ROUTINE_VARIABLE, type Agent, + type ExecutionWorkspace, + type ExecutionWorkspaceMode, type IssueExecutionWorkspaceSettings, type Project, type RoutineVariable, @@ -56,7 +58,7 @@ function defaultProjectWorkspaceIdForProject(project: Project | null | undefined ?? null; } -function defaultExecutionWorkspaceModeForProject(project: Project | null | undefined) { +function defaultExecutionWorkspaceModeForProject(project: Project | null | undefined): ExecutionWorkspaceMode { const defaultMode = project?.executionWorkspacePolicy?.enabled ? project.executionWorkspacePolicy.defaultMode : null; if ( defaultMode === "isolated_workspace" || @@ -68,19 +70,60 @@ function defaultExecutionWorkspaceModeForProject(project: Project | null | undef return "shared_workspace"; } -function buildInitialWorkspaceConfig(project: Project | null | undefined) { +function issueModeForExistingWorkspace(mode: string | null | undefined): ExecutionWorkspaceMode { + if (mode === "isolated_workspace" || mode === "operator_branch" || mode === "shared_workspace") return mode; + if (mode === "adapter_managed" || mode === "cloud_sandbox") return "agent_default"; + return "shared_workspace"; +} + +function issueWorkspacePreferenceFromDraft(value: unknown, fallback: ExecutionWorkspaceMode): ExecutionWorkspaceMode { + if ( + value === "inherit" || + value === "shared_workspace" || + value === "isolated_workspace" || + value === "operator_branch" || + value === "reuse_existing" || + value === "agent_default" + ) { + return value; + } + return fallback; +} + +type RoutineRunWorkspaceConfig = { + executionWorkspaceId: string | null; + executionWorkspacePreference: ExecutionWorkspaceMode; + executionWorkspaceSettings: IssueExecutionWorkspaceSettings; + projectWorkspaceId: string | null; +}; + +function buildInitialWorkspaceConfig( + project: Project | null | undefined, + defaultExecutionWorkspace?: ExecutionWorkspace | null, +): RoutineRunWorkspaceConfig { + if (defaultExecutionWorkspace && defaultExecutionWorkspace.projectId === project?.id) { + return { + executionWorkspaceId: defaultExecutionWorkspace.id, + executionWorkspacePreference: "reuse_existing", + executionWorkspaceSettings: { + mode: issueModeForExistingWorkspace(defaultExecutionWorkspace.mode), + }, + projectWorkspaceId: defaultExecutionWorkspace.projectWorkspaceId ?? defaultProjectWorkspaceIdForProject(project), + }; + } + const defaultMode = defaultExecutionWorkspaceModeForProject(project); return { executionWorkspaceId: null as string | null, executionWorkspacePreference: defaultMode, - executionWorkspaceSettings: { mode: defaultMode } as IssueExecutionWorkspaceSettings, + executionWorkspaceSettings: { mode: defaultMode }, projectWorkspaceId: defaultProjectWorkspaceIdForProject(project), }; } function workspaceConfigEquals( - a: ReturnType, - b: ReturnType, + a: RoutineRunWorkspaceConfig, + b: RoutineRunWorkspaceConfig, ) { return a.executionWorkspaceId === b.executionWorkspaceId && a.executionWorkspacePreference === b.executionWorkspacePreference @@ -89,15 +132,16 @@ function workspaceConfigEquals( } function applyWorkspaceDraft( - current: ReturnType, + current: RoutineRunWorkspaceConfig, data: Record, ) { const next = { ...current, executionWorkspaceId: (data.executionWorkspaceId as string | null | undefined) ?? null, - executionWorkspacePreference: - (data.executionWorkspacePreference as string | null | undefined) - ?? current.executionWorkspacePreference, + executionWorkspacePreference: issueWorkspacePreferenceFromDraft( + data.executionWorkspacePreference, + current.executionWorkspacePreference, + ), executionWorkspaceSettings: (data.executionWorkspaceSettings as IssueExecutionWorkspaceSettings | null | undefined) ?? current.executionWorkspaceSettings, @@ -143,6 +187,7 @@ export function RoutineRunVariablesDialog({ agents, defaultProjectId, defaultAssigneeAgentId, + defaultExecutionWorkspace, variables, isPending, onSubmit, @@ -155,6 +200,7 @@ export function RoutineRunVariablesDialog({ agents: Agent[]; defaultProjectId?: string | null; defaultAssigneeAgentId?: string | null; + defaultExecutionWorkspace?: ExecutionWorkspace | null; variables: RoutineVariable[]; isPending: boolean; onSubmit: (data: RoutineRunDialogSubmitData) => void; @@ -193,7 +239,8 @@ export function RoutineRunVariablesDialog({ const currentAssignee = selection.assigneeAgentId ? agents.find((agent) => agent.id === selection.assigneeAgentId) ?? null : null; - const [workspaceConfig, setWorkspaceConfig] = useState(() => buildInitialWorkspaceConfig(selectedProject)); + const [workspaceConfig, setWorkspaceConfig] = useState(() => + buildInitialWorkspaceConfig(selectedProject, defaultExecutionWorkspace)); const [workspaceConfigValid, setWorkspaceConfigValid] = useState(true); const [workspaceBranchName, setWorkspaceBranchName] = useState(null); @@ -213,10 +260,13 @@ export function RoutineRunVariablesDialog({ setValues(buildInitialValues(variables)); const nextSelection = buildInitialRunSelection({ defaultAssigneeAgentId, defaultProjectId }); setSelection(nextSelection); - setWorkspaceConfig(buildInitialWorkspaceConfig(projects.find((project) => project.id === nextSelection.projectId) ?? null)); + setWorkspaceConfig(buildInitialWorkspaceConfig( + projects.find((project) => project.id === nextSelection.projectId) ?? null, + defaultExecutionWorkspace, + )); setWorkspaceConfigValid(true); - setWorkspaceBranchName(null); - }, [defaultAssigneeAgentId, defaultProjectId, open, projects, variables]); + setWorkspaceBranchName(defaultExecutionWorkspace?.branchName ?? null); + }, [defaultAssigneeAgentId, defaultExecutionWorkspace, defaultProjectId, open, projects, variables]); const workspaceBranchAutoValue = workspaceSelectionEnabled && workspaceBranchName ? workspaceBranchName @@ -245,9 +295,13 @@ export function RoutineRunVariablesDialog({ executionWorkspaceId: workspaceConfig.executionWorkspaceId, executionWorkspacePreference: workspaceConfig.executionWorkspacePreference, executionWorkspaceSettings: workspaceConfig.executionWorkspaceSettings, - currentExecutionWorkspace: null, + currentExecutionWorkspace: + workspaceConfig.executionWorkspaceId && workspaceConfig.executionWorkspaceId === defaultExecutionWorkspace?.id + ? defaultExecutionWorkspace + : null, }), [ companyId, + defaultExecutionWorkspace, selectedProject?.id, workspaceConfig.executionWorkspaceId, workspaceConfig.executionWorkspacePreference, @@ -271,10 +325,13 @@ export function RoutineRunVariablesDialog({ setWorkspaceConfig((current) => applyWorkspaceDraft(current, data)); setWorkspaceConfigValid((current) => (current === meta.canSave ? current : meta.canSave)); setWorkspaceBranchName((current) => { - const next = meta.workspaceBranchName ?? null; + const defaultWorkspaceBranchName = defaultExecutionWorkspace?.branchName ?? null; + const next = meta.workspaceBranchName + ?? (data.executionWorkspaceId === defaultExecutionWorkspace?.id ? defaultWorkspaceBranchName : null) + ?? null; return current === next ? current : next; }); - }, []); + }, [defaultExecutionWorkspace]); return ( !isPending && onOpenChange(next)}> @@ -349,9 +406,13 @@ export function RoutineRunVariablesDialog({ const project = projects.find((entry) => entry.id === projectId) ?? null; if (projectId) trackRecentProject(projectId); setSelection((current) => ({ ...current, projectId })); - setWorkspaceConfig(buildInitialWorkspaceConfig(project)); + setWorkspaceConfig(buildInitialWorkspaceConfig(project, defaultExecutionWorkspace)); setWorkspaceConfigValid(true); - setWorkspaceBranchName(null); + setWorkspaceBranchName( + defaultExecutionWorkspace && defaultExecutionWorkspace.projectId === project?.id + ? defaultExecutionWorkspace.branchName + : null, + ); }} renderTriggerValue={(option) => option && selectedProject ? ( diff --git a/ui/src/lib/company-routes.test.ts b/ui/src/lib/company-routes.test.ts index d6dc2668..9b778398 100644 --- a/ui/src/lib/company-routes.test.ts +++ b/ui/src/lib/company-routes.test.ts @@ -9,15 +9,22 @@ import { describe("company routes", () => { it("treats execution workspace paths as board routes that need a company prefix", () => { expect(isBoardPathWithoutPrefix("/execution-workspaces/workspace-123")).toBe(true); + expect(isBoardPathWithoutPrefix("/execution-workspaces/workspace-123/routines")).toBe(true); expect(extractCompanyPrefixFromPath("/execution-workspaces/workspace-123")).toBeNull(); expect(applyCompanyPrefix("/execution-workspaces/workspace-123", "PAP")).toBe( "/PAP/execution-workspaces/workspace-123", ); + expect(applyCompanyPrefix("/execution-workspaces/workspace-123/routines", "PAP")).toBe( + "/PAP/execution-workspaces/workspace-123/routines", + ); }); it("normalizes prefixed execution workspace paths back to company-relative paths", () => { expect(toCompanyRelativePath("/PAP/execution-workspaces/workspace-123")).toBe( "/execution-workspaces/workspace-123", ); + expect(toCompanyRelativePath("/PAP/execution-workspaces/workspace-123/routines")).toBe( + "/execution-workspaces/workspace-123/routines", + ); }); }); diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 7290c28a..590af106 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -64,7 +64,8 @@ export const queryKeys = { workProducts: (issueId: string) => ["issues", "work-products", issueId] as const, }, routines: { - list: (companyId: string) => ["routines", companyId] as const, + list: (companyId: string, filters?: { projectId?: string | null }) => + ["routines", companyId, filters?.projectId ?? "__all-projects__"] as const, detail: (id: string) => ["routines", "detail", id] as const, runs: (id: string) => ["routines", "runs", id] as const, activity: (companyId: string, id: string) => ["routines", "activity", companyId, id] as const, diff --git a/ui/src/lib/reusable-execution-workspaces.test.ts b/ui/src/lib/reusable-execution-workspaces.test.ts new file mode 100644 index 00000000..a1c380a2 --- /dev/null +++ b/ui/src/lib/reusable-execution-workspaces.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { orderReusableExecutionWorkspaces, type ReusableExecutionWorkspaceLike } from "./reusable-execution-workspaces"; + +function workspace(overrides: Partial): ReusableExecutionWorkspaceLike { + return { + id: overrides.id ?? "workspace-id", + name: overrides.name ?? "Workspace", + cwd: overrides.cwd ?? null, + lastUsedAt: overrides.lastUsedAt ?? "2026-01-01T00:00:00.000Z", + }; +} + +describe("orderReusableExecutionWorkspaces", () => { + it("puts the most recently used workspace first and sorts the rest alphabetically", () => { + const workspaces = [ + workspace({ id: "charlie", name: "Charlie", lastUsedAt: "2026-01-03T00:00:00.000Z" }), + workspace({ id: "zulu", name: "Zulu", lastUsedAt: "2026-01-05T00:00:00.000Z" }), + workspace({ id: "alpha", name: "Alpha", lastUsedAt: "2026-01-01T00:00:00.000Z" }), + workspace({ id: "bravo", name: "Bravo", lastUsedAt: "2026-01-04T00:00:00.000Z" }), + ]; + + expect(orderReusableExecutionWorkspaces(workspaces).map((item) => item.id)).toEqual([ + "zulu", + "alpha", + "bravo", + "charlie", + ]); + }); + + it("keeps only the latest used workspace for duplicate paths before sorting", () => { + const workspaces = [ + workspace({ + id: "older-duplicate", + name: "Older duplicate", + cwd: "/tmp/shared", + lastUsedAt: "2026-01-01T00:00:00.000Z", + }), + workspace({ id: "beta", name: "Beta", cwd: "/tmp/beta", lastUsedAt: "2026-01-02T00:00:00.000Z" }), + workspace({ + id: "newer-duplicate", + name: "Newer duplicate", + cwd: "/tmp/shared", + lastUsedAt: "2026-01-04T00:00:00.000Z", + }), + workspace({ id: "alpha", name: "Alpha", cwd: "/tmp/alpha", lastUsedAt: "2026-01-03T00:00:00.000Z" }), + ]; + + expect(orderReusableExecutionWorkspaces(workspaces).map((item) => item.id)).toEqual([ + "newer-duplicate", + "alpha", + "beta", + ]); + }); + + it("does not let updatedAt churn outrank the last used workspace", () => { + type WorkspaceWithUpdatedAt = ReusableExecutionWorkspaceLike & { updatedAt: Date | string }; + const workspaces: WorkspaceWithUpdatedAt[] = [ + { + ...workspace({ + id: "recently-used", + name: "Recently used", + cwd: "/tmp/shared", + lastUsedAt: "2026-01-04T00:00:00.000Z", + }), + updatedAt: "2026-01-01T00:00:00.000Z", + }, + { + ...workspace({ + id: "recently-updated", + name: "Recently updated", + cwd: "/tmp/shared", + lastUsedAt: "2026-01-01T00:00:00.000Z", + }), + updatedAt: "2026-01-05T00:00:00.000Z", + }, + ]; + + expect(orderReusableExecutionWorkspaces(workspaces).map((item) => item.id)).toEqual([ + "recently-used", + ]); + }); +}); diff --git a/ui/src/lib/reusable-execution-workspaces.ts b/ui/src/lib/reusable-execution-workspaces.ts new file mode 100644 index 00000000..633c9122 --- /dev/null +++ b/ui/src/lib/reusable-execution-workspaces.ts @@ -0,0 +1,49 @@ +export interface ReusableExecutionWorkspaceLike { + id: string; + name: string; + cwd: string | null; + lastUsedAt: Date | string; +} + +function workspaceLastUsedTime(workspace: Pick) { + const time = new Date(workspace.lastUsedAt).getTime(); + return Number.isFinite(time) ? time : 0; +} + +function compareWorkspaceNames(a: ReusableExecutionWorkspaceLike, b: ReusableExecutionWorkspaceLike) { + const nameCompare = a.name.localeCompare(b.name, undefined, { + numeric: true, + sensitivity: "base", + }); + if (nameCompare !== 0) return nameCompare; + return a.id.localeCompare(b.id); +} + +export function orderReusableExecutionWorkspaces( + workspaces: readonly T[], +): T[] { + const deduplicatedByPath = new Map(); + + for (const workspace of workspaces) { + const key = workspace.cwd ?? workspace.id; + const existing = deduplicatedByPath.get(key); + if (!existing || workspaceLastUsedTime(workspace) > workspaceLastUsedTime(existing)) { + deduplicatedByPath.set(key, workspace); + } + } + + const alphabetized = Array.from(deduplicatedByPath.values()).sort(compareWorkspaceNames); + if (alphabetized.length <= 1) return alphabetized; + + let mostRecentlyUsed = alphabetized[0]!; + for (const workspace of alphabetized.slice(1)) { + if (workspaceLastUsedTime(workspace) > workspaceLastUsedTime(mostRecentlyUsed)) { + mostRecentlyUsed = workspace; + } + } + + return [ + mostRecentlyUsed, + ...alphabetized.filter((workspace) => workspace.id !== mostRecentlyUsed.id), + ]; +} diff --git a/ui/src/lib/workspace-routines.test.ts b/ui/src/lib/workspace-routines.test.ts new file mode 100644 index 00000000..7545de5f --- /dev/null +++ b/ui/src/lib/workspace-routines.test.ts @@ -0,0 +1,69 @@ +import type { RoutineListItem } from "@paperclipai/shared"; +import { describe, expect, it } from "vitest"; +import { + getWorkspaceSpecificRoutineVariableNames, + routineHasWorkspaceSpecificVariables, +} from "./workspace-routines"; + +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-30T00:00:00.000Z"), + updatedAt: new Date("2026-04-30T00:00:00.000Z"), + triggers: [], + lastRun: null, + activeIssue: null, + ...overrides, + }; +} + +describe("workspace routine helpers", () => { + it("matches routines with explicit workspace variables", () => { + const routine = createRoutine({ + variables: [ + { name: "workspaceBranch", label: null, type: "text", defaultValue: null, required: true, options: [] }, + ], + }); + + expect(routineHasWorkspaceSpecificVariables(routine)).toBe(true); + expect(getWorkspaceSpecificRoutineVariableNames(routine)).toEqual(["workspaceBranch"]); + }); + + it("matches routines that reference workspace variables in templates", () => { + const routine = createRoutine({ + title: "Review {{ workspaceBranch }}", + description: "Check branch {{workspaceBranch}}", + }); + + expect(getWorkspaceSpecificRoutineVariableNames(routine)).toEqual(["workspaceBranch"]); + }); + + it("ignores routines with only non-workspace variables", () => { + const routine = createRoutine({ + title: "Review {{repo}}", + variables: [ + { name: "repo", label: null, type: "text", defaultValue: null, required: true, options: [] }, + ], + }); + + expect(routineHasWorkspaceSpecificVariables(routine)).toBe(false); + }); +}); diff --git a/ui/src/lib/workspace-routines.ts b/ui/src/lib/workspace-routines.ts new file mode 100644 index 00000000..ca70e3cb --- /dev/null +++ b/ui/src/lib/workspace-routines.ts @@ -0,0 +1,31 @@ +import { + extractRoutineVariableNames, + WORKSPACE_BRANCH_ROUTINE_VARIABLE, + type RoutineListItem, +} from "@paperclipai/shared"; + +const WORKSPACE_SPECIFIC_ROUTINE_VARIABLES = new Set([ + WORKSPACE_BRANCH_ROUTINE_VARIABLE, +]); + +export function getWorkspaceSpecificRoutineVariableNames(routine: RoutineListItem): string[] { + const names = new Set(); + + for (const variable of routine.variables) { + if (WORKSPACE_SPECIFIC_ROUTINE_VARIABLES.has(variable.name)) { + names.add(variable.name); + } + } + + for (const name of extractRoutineVariableNames([routine.title, routine.description])) { + if (WORKSPACE_SPECIFIC_ROUTINE_VARIABLES.has(name)) { + names.add(name); + } + } + + return [...names]; +} + +export function routineHasWorkspaceSpecificVariables(routine: RoutineListItem): boolean { + return getWorkspaceSpecificRoutineVariableNames(routine).length > 0; +} diff --git a/ui/src/pages/ExecutionWorkspaceDetail.tsx b/ui/src/pages/ExecutionWorkspaceDetail.tsx index 78dd1700..60307348 100644 --- a/ui/src/pages/ExecutionWorkspaceDetail.tsx +++ b/ui/src/pages/ExecutionWorkspaceDetail.tsx @@ -1,8 +1,8 @@ import { useEffect, useMemo, useState } from "react"; import { Link, Navigate, useLocation, useNavigate, useParams } from "@/lib/router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import type { ExecutionWorkspace, Issue, Project, ProjectWorkspace } from "@paperclipai/shared"; -import { ArrowLeft, Copy, ExternalLink, Loader2 } from "lucide-react"; +import type { ExecutionWorkspace, Issue, Project, ProjectWorkspace, RoutineListItem } from "@paperclipai/shared"; +import { ArrowLeft, Copy, ExternalLink, Loader2, Play, Repeat } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardAction } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; @@ -16,8 +16,13 @@ import { executionWorkspacesApi } from "../api/execution-workspaces"; import { heartbeatsApi } from "../api/heartbeats"; import { issuesApi } from "../api/issues"; import { projectsApi } from "../api/projects"; +import { routinesApi } from "../api/routines"; import { IssuesList } from "../components/IssuesList"; import { PageTabBar } from "../components/PageTabBar"; +import { + RoutineRunVariablesDialog, + type RoutineRunDialogSubmitData, +} from "../components/RoutineRunVariablesDialog"; import { buildWorkspaceRuntimeControlSections, WorkspaceRuntimeControls, @@ -25,9 +30,14 @@ import { } from "../components/WorkspaceRuntimeControls"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useCompany } from "../context/CompanyContext"; +import { useToastActions } from "../context/ToastContext"; import { collectLiveIssueIds } from "../lib/liveIssueIds"; import { queryKeys } from "../lib/queryKeys"; import { cn, formatDateTime, issueUrl, projectRouteRef, projectWorkspaceUrl } from "../lib/utils"; +import { + getWorkspaceSpecificRoutineVariableNames, + routineHasWorkspaceSpecificVariables, +} from "../lib/workspace-routines"; type WorkspaceFormState = { name: string; @@ -43,7 +53,7 @@ type WorkspaceFormState = { workspaceRuntime: string; }; -type ExecutionWorkspaceTab = "configuration" | "runtime_logs" | "issues"; +type ExecutionWorkspaceTab = "configuration" | "runtime_logs" | "issues" | "routines"; function resolveExecutionWorkspaceTab(pathname: string, workspaceId: string): ExecutionWorkspaceTab | null { const segments = pathname.split("/").filter(Boolean); @@ -51,6 +61,7 @@ function resolveExecutionWorkspaceTab(pathname: string, workspaceId: string): Ex if (executionWorkspacesIndex === -1 || segments[executionWorkspacesIndex + 1] !== workspaceId) return null; const tab = segments[executionWorkspacesIndex + 2]; if (tab === "issues") return "issues"; + if (tab === "routines") return "routines"; if (tab === "runtime-logs") return "runtime_logs"; if (tab === "configuration") return "configuration"; return null; @@ -80,6 +91,10 @@ function formatJson(value: Record | null | undefined) { return JSON.stringify(value, null, 2); } +function formatOptionalDateTime(value: Date | string | null | undefined) { + return value ? formatDateTime(value) : "Never"; +} + function normalizeText(value: string) { const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; @@ -305,6 +320,188 @@ function ExecutionWorkspaceIssuesList({ ); } +function WorkspaceRoutineRow({ + routine, + variableNames, + runningRoutineId, + onRunNow, +}: { + routine: RoutineListItem; + variableNames: string[]; + runningRoutineId: string | null; + onRunNow: (routine: RoutineListItem) => void; +}) { + const isArchived = routine.status === "archived"; + const isRunning = runningRoutineId === routine.id; + + return ( +
+
+
+ + {routine.title} + + {routine.status !== "active" ? ( + {routine.status} + ) : null} +
+
+ {routine.assigneeAgentId ? "Default agent set" : "Choose agent when running"} + Last run {formatOptionalDateTime(routine.lastRun?.triggeredAt ?? routine.lastTriggeredAt)} + + {variableNames.map((name) => ( + + {name} + + ))} + +
+
+ +
+ ); +} + +function ExecutionWorkspaceRoutinesList({ + workspace, + project, +}: { + workspace: ExecutionWorkspace; + project: Project | null; +}) { + const queryClient = useQueryClient(); + const { pushToast } = useToastActions(); + const [runDialogRoutine, setRunDialogRoutine] = useState(null); + const [runningRoutineId, setRunningRoutineId] = useState(null); + + const { data: routines, isLoading, error } = useQuery({ + queryKey: queryKeys.routines.list(workspace.companyId, { projectId: workspace.projectId }), + queryFn: () => routinesApi.list(workspace.companyId, { projectId: workspace.projectId }), + }); + + const { data: agents } = useQuery({ + queryKey: queryKeys.agents.list(workspace.companyId), + queryFn: () => agentsApi.list(workspace.companyId), + }); + + const workspaceRoutines = useMemo( + () => (routines ?? []).filter(routineHasWorkspaceSpecificVariables), + [routines], + ); + + const runRoutine = useMutation({ + mutationFn: ({ id, data }: { id: string; data?: RoutineRunDialogSubmitData }) => routinesApi.run(id, { + ...(data?.variables && Object.keys(data.variables).length > 0 ? { variables: data.variables } : {}), + ...(data?.assigneeAgentId !== undefined ? { assigneeAgentId: data.assigneeAgentId } : {}), + ...(data?.projectId !== undefined ? { projectId: data.projectId } : {}), + ...(data?.executionWorkspaceId !== undefined ? { executionWorkspaceId: data.executionWorkspaceId } : {}), + ...(data?.executionWorkspacePreference !== undefined + ? { executionWorkspacePreference: data.executionWorkspacePreference } + : {}), + ...(data?.executionWorkspaceSettings !== undefined + ? { executionWorkspaceSettings: data.executionWorkspaceSettings } + : {}), + }), + onMutate: ({ id }) => { + setRunningRoutineId(id); + }, + onSuccess: async (_, { id }) => { + setRunDialogRoutine(null); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["routines", workspace.companyId] }), + queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(id) }), + queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByExecutionWorkspace(workspace.companyId, workspace.id) }), + queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(workspace.companyId) }), + ]); + pushToast({ + title: "Routine started", + body: "Paperclip created a run using this execution workspace.", + tone: "success", + }); + }, + onSettled: () => { + setRunningRoutineId(null); + }, + onError: (mutationError) => { + pushToast({ + title: "Routine run failed", + body: mutationError instanceof Error ? mutationError.message : "Paperclip could not start the routine run.", + tone: "error", + }); + }, + }); + + return ( + <> + + + Workspace routines + + Routines that use workspace-specific variables can be run against this execution workspace. + + + + {isLoading ? ( +

Loading routines...

+ ) : error ? ( +

+ {error instanceof Error ? error.message : "Failed to load routines."} +

+ ) : workspaceRoutines.length === 0 ? ( +
+ +

+ No routines use workspace-specific variables yet. +

+
+ ) : ( +
+ {workspaceRoutines.map((routine) => ( + + ))} +
+ )} +
+
+ + { + if (!next) setRunDialogRoutine(null); + }} + companyId={workspace.companyId} + routineName={runDialogRoutine?.title ?? null} + agents={agents ?? []} + projects={project ? [project] : []} + defaultProjectId={workspace.projectId} + defaultAssigneeAgentId={runDialogRoutine?.assigneeAgentId ?? null} + defaultExecutionWorkspace={workspace} + variables={runDialogRoutine?.variables ?? []} + isPending={runRoutine.isPending} + onSubmit={(data) => { + if (!runDialogRoutine) return; + runRoutine.mutate({ id: runDialogRoutine.id, data }); + }} + /> + + ); +} + export function ExecutionWorkspaceDetail() { const { workspaceId } = useParams<{ workspaceId: string }>(); const location = useLocation(); @@ -469,7 +666,12 @@ export function ExecutionWorkspaceDetail() { let cachedTab: ExecutionWorkspaceTab = "configuration"; try { const storedTab = localStorage.getItem(`paperclip:execution-workspace-tab:${workspaceId}`); - if (storedTab === "issues" || storedTab === "configuration" || storedTab === "runtime_logs") { + if ( + storedTab === "issues" || + storedTab === "routines" || + storedTab === "configuration" || + storedTab === "runtime_logs" + ) { cachedTab = storedTab; } } catch {} @@ -570,6 +772,7 @@ export function ExecutionWorkspaceDetail() { { value: "configuration", label: "Configuration" }, { value: "runtime_logs", label: "Runtime logs" }, { value: "issues", label: "Issues" }, + { value: "routines", label: "Routines" }, ]} align="start" value={activeTab ?? "configuration"} @@ -932,7 +1135,7 @@ export function ExecutionWorkspaceDetail() { )} - ) : ( + ) : activeTab === "issues" ? ( + ) : ( + )}