Add workspace routine run tab

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-05-01 10:09:08 -05:00
1 parent 42a299fb9d
commit 58fd1c1eca
11 files changed
+569 -36

No files matched your search

+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>
+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();
});
});
});
+48 -10
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import {
WORKSPACE_BRANCH_ROUTINE_VARIABLE,
type Agent,
type ExecutionWorkspace,
type IssueExecutionWorkspaceSettings,
type Project,
type RoutineVariable,
@@ -68,7 +69,27 @@ function defaultExecutionWorkspaceModeForProject(project: Project | null | undef
return "shared_workspace";
}
function buildInitialWorkspaceConfig(project: Project | null | undefined) {
function issueModeForExistingWorkspace(mode: string | null | undefined) {
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 buildInitialWorkspaceConfig(
project: Project | null | undefined,
defaultExecutionWorkspace?: ExecutionWorkspace | null,
) {
if (defaultExecutionWorkspace && defaultExecutionWorkspace.projectId === project?.id) {
return {
executionWorkspaceId: defaultExecutionWorkspace.id,
executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: {
mode: issueModeForExistingWorkspace(defaultExecutionWorkspace.mode),
} as IssueExecutionWorkspaceSettings,
projectWorkspaceId: defaultExecutionWorkspace.projectWorkspaceId ?? defaultProjectWorkspaceIdForProject(project),
};
}
const defaultMode = defaultExecutionWorkspaceModeForProject(project);
return {
executionWorkspaceId: null as string | null,
@@ -143,6 +164,7 @@ export function RoutineRunVariablesDialog({
agents,
defaultProjectId,
defaultAssigneeAgentId,
defaultExecutionWorkspace,
variables,
isPending,
onSubmit,
@@ -155,6 +177,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 +216,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 +237,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 +272,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 +302,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 +383,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",
);
});
});
@@ -0,0 +1,44 @@
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,
updatedAt: overrides.updatedAt ?? "2026-01-01T00:00:00.000Z",
};
}
describe("orderReusableExecutionWorkspaces", () => {
it("puts the most recently updated workspace first and sorts the rest alphabetically", () => {
const workspaces = [
workspace({ id: "charlie", name: "Charlie", updatedAt: "2026-01-03T00:00:00.000Z" }),
workspace({ id: "zulu", name: "Zulu", updatedAt: "2026-01-05T00:00:00.000Z" }),
workspace({ id: "alpha", name: "Alpha", updatedAt: "2026-01-01T00:00:00.000Z" }),
workspace({ id: "bravo", name: "Bravo", updatedAt: "2026-01-04T00:00:00.000Z" }),
];
expect(orderReusableExecutionWorkspaces(workspaces).map((item) => item.id)).toEqual([
"zulu",
"alpha",
"bravo",
"charlie",
]);
});
it("keeps only the latest updated workspace for duplicate paths before sorting", () => {
const workspaces = [
workspace({ id: "older-duplicate", name: "Older duplicate", cwd: "/tmp/shared", updatedAt: "2026-01-01T00:00:00.000Z" }),
workspace({ id: "beta", name: "Beta", cwd: "/tmp/beta", updatedAt: "2026-01-02T00:00:00.000Z" }),
workspace({ id: "newer-duplicate", name: "Newer duplicate", cwd: "/tmp/shared", updatedAt: "2026-01-04T00:00:00.000Z" }),
workspace({ id: "alpha", name: "Alpha", cwd: "/tmp/alpha", updatedAt: "2026-01-03T00:00:00.000Z" }),
];
expect(orderReusableExecutionWorkspaces(workspaces).map((item) => item.id)).toEqual([
"newer-duplicate",
"alpha",
"beta",
]);
});
});
@@ -0,0 +1,49 @@
export interface ReusableExecutionWorkspaceLike {
id: string;
name: string;
cwd: string | null;
updatedAt: Date | string;
}
function workspaceUpdatedTime(workspace: Pick<ReusableExecutionWorkspaceLike, "updatedAt">) {
const time = new Date(workspace.updatedAt).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 || workspaceUpdatedTime(workspace) > workspaceUpdatedTime(existing)) {
deduplicatedByPath.set(key, workspace);
}
}
const alphabetized = Array.from(deduplicatedByPath.values()).sort(compareWorkspaceNames);
if (alphabetized.length <= 1) return alphabetized;
let mostRecentlyUpdated = alphabetized[0]!;
for (const workspace of alphabetized.slice(1)) {
if (workspaceUpdatedTime(workspace) > workspaceUpdatedTime(mostRecentlyUpdated)) {
mostRecentlyUpdated = workspace;
}
}
return [
mostRecentlyUpdated,
...alphabetized.filter((workspace) => workspace.id !== mostRecentlyUpdated.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),
queryFn: () => routinesApi.list(workspace.companyId),
});
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: queryKeys.routines.list(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