[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:
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`),
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user