[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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 707 additions and 49 deletions

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 =

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,

View file

@ -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();
});
});
});

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 ? (