[codex] Add workspace routine run tab (#4958)

## Thinking Path

> - Paperclip orchestrates AI agents through reusable execution
workspaces and routines
> - Operators need a fast way to run workspace-aware routines against a
specific execution workspace
> - The existing workspace detail surface showed configuration, runtime
logs, and linked issues, but not routines that depend on workspace
variables
> - Routine runs also needed to prefill the selected execution workspace
so branch variables resolve correctly
> - This pull request adds a workspace routines tab and prefilled
routine-run dialog support
> - The benefit is a tighter workflow for rerunning reviews, smoke
checks, and other workspace-specific routines

## What Changed

- Added an execution workspace `Routines` tab and company-prefixed
routes.
- Listed routines that declare or reference workspace-specific
variables.
- Added `Run now` support that preselects the current execution
workspace in `RoutineRunVariablesDialog`.
- Centralized reusable execution workspace ordering/deduplication for
issue creation and workspace cards.
- Added focused UI helper and dialog regression tests.

## Verification

- `pnpm exec vitest run ui/src/lib/reusable-execution-workspaces.test.ts
ui/src/lib/workspace-routines.test.ts
ui/src/components/RoutineRunVariablesDialog.test.tsx
ui/src/lib/company-routes.test.ts`
- Screenshots were not captured in this PR split; the visible flow is
covered by focused component/helper tests and should get browser QA in
the follow-up issue.

## Risks

- Medium risk: this adds a new workspace detail tab and routine-run
path. It is isolated to workspace-scoped routines and uses existing
routine run APIs.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5 coding agent, tool use and local command
execution. Exact context window was not exposed in the runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-05-01 11:58:15 -05:00
committed by GitHub
parent 570a4206da
commit 2d72292ad6
17 changed files with 707 additions and 49 deletions
@@ -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",
@@ -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();
+2 -1
View File
@@ -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);
});
+8 -2
View File
@@ -1071,11 +1071,17 @@ export function routineService(
get: getRoutineById,
getTrigger: getTriggerById,
list: async (companyId: string): Promise<RoutineListItem[]> => {
list: async (
companyId: string,
filters?: { projectId?: string | null },
): Promise<RoutineListItem[]> => {
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([
+2
View File
@@ -110,6 +110,7 @@ function boardRoutes() {
<Route path="execution-workspaces/:workspaceId/configuration" element={<ExecutionWorkspaceDetail />} />
<Route path="execution-workspaces/:workspaceId/runtime-logs" element={<ExecutionWorkspaceDetail />} />
<Route path="execution-workspaces/:workspaceId/issues" element={<ExecutionWorkspaceDetail />} />
<Route path="execution-workspaces/:workspaceId/routines" element={<ExecutionWorkspaceDetail />} />
<Route path="goals" element={<Goals />} />
<Route path="goals/:goalId" element={<GoalDetail />} />
<Route path="approvals" element={<Navigate to="/approvals/pending" replace />} />
@@ -306,6 +307,7 @@ export function App() {
<Route path="execution-workspaces/:workspaceId/configuration" element={<UnprefixedBoardRedirect />} />
<Route path="execution-workspaces/:workspaceId/runtime-logs" element={<UnprefixedBoardRedirect />} />
<Route path="execution-workspaces/:workspaceId/issues" element={<UnprefixedBoardRedirect />} />
<Route path="execution-workspaces/:workspaceId/routines" element={<UnprefixedBoardRedirect />} />
<Route path=":companyPrefix" element={<Layout />}>
{boardRoutes()}
</Route>
+6 -1
View File
@@ -22,7 +22,12 @@ export interface RotateRoutineTriggerResponse {
}
export const routinesApi = {
list: (companyId: string) => api.get<RoutineListItem[]>(`/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<RoutineListItem[]>(`/companies/${companyId}/routines${query ? `?${query}` : ""}`);
},
create: (companyId: string, data: Record<string, unknown>) =>
api.post<Routine>(`/companies/${companyId}/routines`, data),
get: (id: string) => api.get<RoutineDetail>(`/routines/${id}`),
+2 -10
View File
@@ -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<string, typeof workspaces[number]>();
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 =
+2 -10
View File
@@ -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<string, typeof workspaces[number]>();
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,
@@ -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<string, unknown> | null = null;
vi.mock("../api/instanceSettings", () => ({
instanceSettingsApi: {
@@ -26,14 +27,17 @@ vi.mock("./IssueWorkspaceCard", async () => {
return {
IssueWorkspaceCard: ({
issue,
onDraftChange,
}: {
issue: Record<string, unknown>;
onDraftChange?: (
data: Record<string, unknown>,
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(
<QueryClientProvider client={queryClient}>
<RoutineRunVariablesDialog
open
onOpenChange={() => {}}
companyId="company-1"
projects={[createProject()]}
agents={[createAgent()]}
defaultProjectId="project-1"
defaultAssigneeAgentId="agent-1"
defaultExecutionWorkspace={workspace}
variables={[]}
isPending={false}
onSubmit={() => {}}
/>
</QueryClientProvider>,
);
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();
});
});
});
+79 -18
View File
@@ -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<typeof buildInitialWorkspaceConfig>,
b: ReturnType<typeof buildInitialWorkspaceConfig>,
a: RoutineRunWorkspaceConfig,
b: RoutineRunWorkspaceConfig,
) {
return a.executionWorkspaceId === b.executionWorkspaceId
&& a.executionWorkspacePreference === b.executionWorkspacePreference
@@ -89,15 +132,16 @@ function workspaceConfigEquals(
}
function applyWorkspaceDraft(
current: ReturnType<typeof buildInitialWorkspaceConfig>,
current: RoutineRunWorkspaceConfig,
data: Record<string, unknown>,
) {
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<string | null>(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 (
<Dialog open={open} onOpenChange={(next) => !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 ? (
+7
View File
@@ -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",
);
});
});
+2 -1
View File
@@ -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,
@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import { orderReusableExecutionWorkspaces, type ReusableExecutionWorkspaceLike } from "./reusable-execution-workspaces";
function workspace(overrides: Partial<ReusableExecutionWorkspaceLike>): 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",
]);
});
});
@@ -0,0 +1,49 @@
export interface ReusableExecutionWorkspaceLike {
id: string;
name: string;
cwd: string | null;
lastUsedAt: Date | string;
}
function workspaceLastUsedTime(workspace: Pick<ReusableExecutionWorkspaceLike, "lastUsedAt">) {
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<T extends ReusableExecutionWorkspaceLike>(
workspaces: readonly T[],
): T[] {
const deduplicatedByPath = new Map<string, T>();
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),
];
}
+69
View File
@@ -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> = {}): 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);
});
});
+31
View File
@@ -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<string>();
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;
}
+213 -5
View File
@@ -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<string, unknown> | 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 (
<div className="flex flex-col gap-3 border-b border-border px-3 py-3 last:border-b-0 sm:flex-row sm:items-center">
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex flex-wrap items-center gap-2">
<Link to={`/routines/${routine.id}`} className="truncate text-sm font-medium hover:underline">
{routine.title}
</Link>
{routine.status !== "active" ? (
<span className="text-xs text-muted-foreground">{routine.status}</span>
) : null}
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span>{routine.assigneeAgentId ? "Default agent set" : "Choose agent when running"}</span>
<span>Last run {formatOptionalDateTime(routine.lastRun?.triggeredAt ?? routine.lastTriggeredAt)}</span>
<span className="flex flex-wrap gap-1">
{variableNames.map((name) => (
<span key={name} className="rounded-sm bg-muted px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground">
{name}
</span>
))}
</span>
</div>
</div>
<Button
variant="outline"
size="sm"
className="w-full sm:w-auto"
disabled={isArchived || isRunning}
onClick={() => onRunNow(routine)}
>
{isRunning ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Play className="mr-2 h-4 w-4" />}
{isRunning ? "Running..." : "Run now"}
</Button>
</div>
);
}
function ExecutionWorkspaceRoutinesList({
workspace,
project,
}: {
workspace: ExecutionWorkspace;
project: Project | null;
}) {
const queryClient = useQueryClient();
const { pushToast } = useToastActions();
const [runDialogRoutine, setRunDialogRoutine] = useState<RoutineListItem | null>(null);
const [runningRoutineId, setRunningRoutineId] = useState<string | null>(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 (
<>
<Card className="rounded-none">
<CardHeader>
<CardTitle>Workspace routines</CardTitle>
<CardDescription>
Routines that use workspace-specific variables can be run against this execution workspace.
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<p className="text-sm text-muted-foreground">Loading routines...</p>
) : error ? (
<p className="text-sm text-destructive">
{error instanceof Error ? error.message : "Failed to load routines."}
</p>
) : workspaceRoutines.length === 0 ? (
<div className="flex flex-col items-center gap-2 py-10 text-center">
<Repeat className="h-5 w-5 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
No routines use workspace-specific variables yet.
</p>
</div>
) : (
<div className="rounded-lg border border-border">
{workspaceRoutines.map((routine) => (
<WorkspaceRoutineRow
key={routine.id}
routine={routine}
variableNames={getWorkspaceSpecificRoutineVariableNames(routine)}
runningRoutineId={runningRoutineId}
onRunNow={setRunDialogRoutine}
/>
))}
</div>
)}
</CardContent>
</Card>
<RoutineRunVariablesDialog
open={runDialogRoutine !== null}
onOpenChange={(next) => {
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() {
)}
</CardContent>
</Card>
) : (
) : activeTab === "issues" ? (
<ExecutionWorkspaceIssuesList
companyId={workspace.companyId}
workspaceId={workspace.id}
@@ -941,6 +1144,11 @@ export function ExecutionWorkspaceDetail() {
error={linkedIssuesQuery.error as Error | null}
project={project}
/>
) : (
<ExecutionWorkspaceRoutinesList
workspace={workspace}
project={project}
/>
)}
</div>
<ExecutionWorkspaceCloseDialog