mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
Add workspace routine run tab
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
42a299fb9d
commit
58fd1c1eca
11 files changed
+569
-36
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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,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 ? (
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
@@ -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),
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user