mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 18:10:39 +09:00
[codex] Improve workspace runtime and navigation ergonomics (#3680)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - That operator experience depends not just on issue chat, but also on how workspaces, inbox groups, and navigation state behave over long-running sessions > - The current branch included a separate cluster of workspace-runtime controls, inbox grouping, sidebar ordering, and worktree lifecycle fixes > - Those changes cross server, shared contracts, database state, and UI navigation, but they still form one coherent operator workflow area > - This pull request isolates the workspace/runtime and navigation ergonomics work into one standalone branch > - The benefit is better workspace recovery and navigation persistence without forcing reviewers through the unrelated issue-detail/chat work ## What Changed - Improved execution workspace and project workspace controls, request wiring, layout, and JSON editor ergonomics - Hardened linked worktree reuse/startup behavior and documented the `worktree repair` flow for recovering linked worktrees safely - Added inbox workspace grouping, mobile collapse, archive undo, keyboard navigation, shared group-header styling, and persisted collapsed-group behavior - Added persistent sidebar order preferences with the supporting DB migration, shared/server contracts, routes, services, hooks, and UI integration - Scoped issue-list preferences by context and added targeted UI/server tests for workspace controls, inbox behavior, sidebar preferences, and worktree validation ## Verification - `pnpm vitest run server/src/__tests__/sidebar-preferences-routes.test.ts ui/src/pages/Inbox.test.tsx ui/src/components/ProjectWorkspaceSummaryCard.test.tsx ui/src/components/WorkspaceRuntimeControls.test.tsx ui/src/api/workspace-runtime-control.test.ts` - `server/src/__tests__/workspace-runtime.test.ts` was attempted, but the embedded Postgres suite self-skipped/hung on this host after reporting an init-script issue, so it is not counted as a local pass here ## Risks - Medium: this branch includes migration-backed preference storage plus worktree/runtime behavior, so merge review should pay attention to state persistence and worktree recovery semantics - The sidebar preference migration is standalone, but it should still be watched for conflicts if another migration lands first ## Model Used - OpenAI Codex coding agent (GPT-5-class runtime in Codex CLI; exact deployed model ID is not exposed in this environment), reasoning enabled, tool use and local code execution enabled ## 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) - [ ] 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:
parent
6e6f538630
commit
e89076148a
64 changed files with 18576 additions and 1063 deletions
|
|
@ -1,15 +1,24 @@
|
|||
import { and, eq } from "drizzle-orm";
|
||||
import { Router } from "express";
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { issues, projects, projectWorkspaces } from "@paperclipai/db";
|
||||
import { updateExecutionWorkspaceSchema } from "@paperclipai/shared";
|
||||
import {
|
||||
findWorkspaceCommandDefinition,
|
||||
matchWorkspaceRuntimeServiceToCommand,
|
||||
updateExecutionWorkspaceSchema,
|
||||
workspaceRuntimeControlTargetSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { executionWorkspaceService, logActivity, workspaceOperationService } from "../services/index.js";
|
||||
import { mergeExecutionWorkspaceConfig, readExecutionWorkspaceConfig } from "../services/execution-workspaces.js";
|
||||
import { parseProjectExecutionWorkspacePolicy } from "../services/execution-workspace-policy.js";
|
||||
import { readProjectWorkspaceRuntimeConfig } from "../services/project-workspace-runtime-config.js";
|
||||
import {
|
||||
buildWorkspaceRuntimeDesiredStatePatch,
|
||||
cleanupExecutionWorkspaceArtifacts,
|
||||
ensurePersistedExecutionWorkspaceAvailable,
|
||||
listConfiguredRuntimeServiceEntries,
|
||||
runWorkspaceJobForControl,
|
||||
startRuntimeServicesForWorkspaceControl,
|
||||
stopRuntimeServicesForExecutionWorkspace,
|
||||
} from "../services/workspace-runtime.js";
|
||||
|
|
@ -72,11 +81,11 @@ export function executionWorkspaceRoutes(db: Db) {
|
|||
res.json(operations);
|
||||
});
|
||||
|
||||
router.post("/execution-workspaces/:id/runtime-services/:action", async (req, res) => {
|
||||
async function handleExecutionWorkspaceRuntimeCommand(req: Request, res: Response) {
|
||||
const id = req.params.id as string;
|
||||
const action = String(req.params.action ?? "").trim().toLowerCase();
|
||||
if (action !== "start" && action !== "stop" && action !== "restart") {
|
||||
res.status(404).json({ error: "Runtime service action not found" });
|
||||
if (action !== "start" && action !== "stop" && action !== "restart" && action !== "run") {
|
||||
res.status(404).json({ error: "Workspace command action not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -89,7 +98,7 @@ export function executionWorkspaceRoutes(db: Db) {
|
|||
|
||||
const workspaceCwd = existing.cwd;
|
||||
if (!workspaceCwd) {
|
||||
res.status(422).json({ error: "Execution workspace needs a local path before Paperclip can manage local runtime services" });
|
||||
res.status(422).json({ error: "Execution workspace needs a local path before Paperclip can run workspace commands" });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -115,10 +124,68 @@ export function executionWorkspaceRoutes(db: Db) {
|
|||
const projectWorkspaceRuntime = readProjectWorkspaceRuntimeConfig(
|
||||
(projectWorkspace?.metadata as Record<string, unknown> | null) ?? null,
|
||||
)?.workspaceRuntime ?? null;
|
||||
const projectPolicy = existing.projectId
|
||||
? await db
|
||||
.select({
|
||||
executionWorkspacePolicy: projects.executionWorkspacePolicy,
|
||||
})
|
||||
.from(projects)
|
||||
.where(
|
||||
and(
|
||||
eq(projects.id, existing.projectId),
|
||||
eq(projects.companyId, existing.companyId),
|
||||
),
|
||||
)
|
||||
.then((rows) => parseProjectExecutionWorkspacePolicy(rows[0]?.executionWorkspacePolicy))
|
||||
: null;
|
||||
const effectiveRuntimeConfig = existing.config?.workspaceRuntime ?? projectWorkspaceRuntime ?? null;
|
||||
const target = req.body as { workspaceCommandId?: string | null; runtimeServiceId?: string | null; serviceIndex?: number | null };
|
||||
const configuredServices = effectiveRuntimeConfig
|
||||
? listConfiguredRuntimeServiceEntries({ workspaceRuntime: effectiveRuntimeConfig })
|
||||
: [];
|
||||
const workspaceCommand = effectiveRuntimeConfig
|
||||
? findWorkspaceCommandDefinition(effectiveRuntimeConfig, target.workspaceCommandId ?? null)
|
||||
: null;
|
||||
if (target.workspaceCommandId && !workspaceCommand) {
|
||||
res.status(404).json({ error: "Workspace command not found for this execution workspace" });
|
||||
return;
|
||||
}
|
||||
if (target.runtimeServiceId && !(existing.runtimeServices ?? []).some((service) => service.id === target.runtimeServiceId)) {
|
||||
res.status(404).json({ error: "Runtime service not found for this execution workspace" });
|
||||
return;
|
||||
}
|
||||
const matchedRuntimeService =
|
||||
workspaceCommand?.kind === "service" && !target.runtimeServiceId
|
||||
? matchWorkspaceRuntimeServiceToCommand(workspaceCommand, existing.runtimeServices ?? [])
|
||||
: null;
|
||||
const selectedRuntimeServiceId = target.runtimeServiceId ?? matchedRuntimeService?.id ?? null;
|
||||
const selectedServiceIndex =
|
||||
workspaceCommand?.kind === "service"
|
||||
? workspaceCommand.serviceIndex
|
||||
: target.serviceIndex ?? null;
|
||||
if (
|
||||
selectedServiceIndex !== undefined
|
||||
&& selectedServiceIndex !== null
|
||||
&& (selectedServiceIndex < 0 || selectedServiceIndex >= configuredServices.length)
|
||||
) {
|
||||
res.status(422).json({ error: "Selected runtime service is not defined in this execution workspace runtime config" });
|
||||
return;
|
||||
}
|
||||
if (workspaceCommand?.kind === "job" && action !== "run") {
|
||||
res.status(422).json({ error: `Workspace job "${workspaceCommand.name}" can only be run` });
|
||||
return;
|
||||
}
|
||||
if (workspaceCommand?.kind === "service" && action === "run") {
|
||||
res.status(422).json({ error: `Workspace service "${workspaceCommand.name}" should be started or restarted, not run` });
|
||||
return;
|
||||
}
|
||||
if (action === "run" && !workspaceCommand) {
|
||||
res.status(422).json({ error: "Select a workspace job to run" });
|
||||
return;
|
||||
}
|
||||
|
||||
if ((action === "start" || action === "restart") && !effectiveRuntimeConfig) {
|
||||
res.status(422).json({ error: "Execution workspace has no runtime service configuration or inherited project workspace default" });
|
||||
res.status(422).json({ error: "Execution workspace has no workspace command configuration or inherited project workspace default" });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -133,13 +200,101 @@ export function executionWorkspaceRoutes(db: Db) {
|
|||
|
||||
const operation = await recorder.recordOperation({
|
||||
phase: action === "stop" ? "workspace_teardown" : "workspace_provision",
|
||||
command: `workspace runtime ${action}`,
|
||||
command: workspaceCommand?.command ?? `workspace command ${action}`,
|
||||
cwd: existing.cwd,
|
||||
metadata: {
|
||||
action,
|
||||
executionWorkspaceId: existing.id,
|
||||
workspaceCommandId: workspaceCommand?.id ?? target.workspaceCommandId ?? null,
|
||||
workspaceCommandKind: workspaceCommand?.kind ?? null,
|
||||
workspaceCommandName: workspaceCommand?.name ?? null,
|
||||
runtimeServiceId: selectedRuntimeServiceId,
|
||||
serviceIndex: selectedServiceIndex,
|
||||
},
|
||||
run: async () => {
|
||||
const ensureWorkspaceAvailable = async () =>
|
||||
await ensurePersistedExecutionWorkspaceAvailable({
|
||||
base: {
|
||||
baseCwd: projectWorkspace?.cwd ?? workspaceCwd,
|
||||
source: existing.mode === "shared_workspace" ? "project_primary" : "task_session",
|
||||
projectId: existing.projectId,
|
||||
workspaceId: existing.projectWorkspaceId,
|
||||
repoUrl: existing.repoUrl,
|
||||
repoRef: existing.baseRef,
|
||||
},
|
||||
workspace: {
|
||||
mode: existing.mode,
|
||||
strategyType: existing.strategyType,
|
||||
cwd: existing.cwd,
|
||||
providerRef: existing.providerRef,
|
||||
projectId: existing.projectId,
|
||||
projectWorkspaceId: existing.projectWorkspaceId,
|
||||
repoUrl: existing.repoUrl,
|
||||
baseRef: existing.baseRef,
|
||||
branchName: existing.branchName,
|
||||
config: {
|
||||
...existing.config,
|
||||
provisionCommand:
|
||||
existing.config?.provisionCommand
|
||||
?? projectPolicy?.workspaceStrategy?.provisionCommand
|
||||
?? null,
|
||||
},
|
||||
},
|
||||
issue: existing.sourceIssueId
|
||||
? {
|
||||
id: existing.sourceIssueId,
|
||||
identifier: null,
|
||||
title: existing.name,
|
||||
}
|
||||
: null,
|
||||
agent: {
|
||||
id: actor.agentId ?? null,
|
||||
name: actor.actorType === "user" ? "Board" : "Agent",
|
||||
companyId: existing.companyId,
|
||||
},
|
||||
recorder,
|
||||
});
|
||||
|
||||
if (action === "run") {
|
||||
if (!workspaceCommand || workspaceCommand.kind !== "job") {
|
||||
throw new Error("Workspace job selection is required");
|
||||
}
|
||||
const availableWorkspace = await ensureWorkspaceAvailable();
|
||||
if (!availableWorkspace) {
|
||||
throw new Error("Execution workspace needs a local path before Paperclip can run workspace commands");
|
||||
}
|
||||
return await runWorkspaceJobForControl({
|
||||
actor: {
|
||||
id: actor.agentId ?? null,
|
||||
name: actor.actorType === "user" ? "Board" : "Agent",
|
||||
companyId: existing.companyId,
|
||||
},
|
||||
issue: existing.sourceIssueId
|
||||
? {
|
||||
id: existing.sourceIssueId,
|
||||
identifier: null,
|
||||
title: existing.name,
|
||||
}
|
||||
: null,
|
||||
workspace: availableWorkspace,
|
||||
command: workspaceCommand.rawConfig,
|
||||
adapterEnv: {},
|
||||
recorder,
|
||||
metadata: {
|
||||
action,
|
||||
executionWorkspaceId: existing.id,
|
||||
workspaceCommandId: workspaceCommand.id,
|
||||
},
|
||||
}).then((nestedOperation) => ({
|
||||
status: "succeeded" as const,
|
||||
exitCode: 0,
|
||||
metadata: {
|
||||
nestedOperationId: nestedOperation?.id ?? null,
|
||||
runtimeServiceCount,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
const onLog = async (stream: "stdout" | "stderr", chunk: string) => {
|
||||
if (stream === "stdout") stdout.push(chunk);
|
||||
else stderr.push(chunk);
|
||||
|
|
@ -150,10 +305,15 @@ export function executionWorkspaceRoutes(db: Db) {
|
|||
db,
|
||||
executionWorkspaceId: existing.id,
|
||||
workspaceCwd,
|
||||
runtimeServiceId: selectedRuntimeServiceId,
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "start" || action === "restart") {
|
||||
const availableWorkspace = await ensureWorkspaceAvailable();
|
||||
if (!availableWorkspace) {
|
||||
throw new Error("Execution workspace needs a local path before Paperclip can manage local runtime services");
|
||||
}
|
||||
const startedServices = await startRuntimeServicesForWorkspaceControl({
|
||||
db,
|
||||
actor: {
|
||||
|
|
@ -168,32 +328,41 @@ export function executionWorkspaceRoutes(db: Db) {
|
|||
title: existing.name,
|
||||
}
|
||||
: null,
|
||||
workspace: {
|
||||
baseCwd: workspaceCwd,
|
||||
source: existing.mode === "shared_workspace" ? "project_primary" : "task_session",
|
||||
projectId: existing.projectId,
|
||||
workspaceId: existing.projectWorkspaceId,
|
||||
repoUrl: existing.repoUrl,
|
||||
repoRef: existing.baseRef,
|
||||
strategy: existing.strategyType === "git_worktree" ? "git_worktree" : "project_primary",
|
||||
cwd: workspaceCwd,
|
||||
branchName: existing.branchName,
|
||||
worktreePath: existing.strategyType === "git_worktree" ? workspaceCwd : null,
|
||||
warnings: [],
|
||||
created: false,
|
||||
},
|
||||
workspace: availableWorkspace,
|
||||
executionWorkspaceId: existing.id,
|
||||
config: { workspaceRuntime: effectiveRuntimeConfig },
|
||||
adapterEnv: {},
|
||||
onLog,
|
||||
serviceIndex: selectedServiceIndex,
|
||||
});
|
||||
runtimeServiceCount = startedServices.length;
|
||||
} else {
|
||||
runtimeServiceCount = 0;
|
||||
runtimeServiceCount = selectedRuntimeServiceId ? Math.max(0, (existing.runtimeServices?.length ?? 1) - 1) : 0;
|
||||
}
|
||||
|
||||
const currentDesiredState: "running" | "stopped" =
|
||||
existing.config?.desiredState
|
||||
?? ((existing.runtimeServices ?? []).some((service) => service.status === "starting" || service.status === "running")
|
||||
? "running"
|
||||
: "stopped");
|
||||
const nextRuntimeState: {
|
||||
desiredState: "running" | "stopped";
|
||||
serviceStates: Record<string, "running" | "stopped"> | null | undefined;
|
||||
} = selectedRuntimeServiceId && (selectedServiceIndex === undefined || selectedServiceIndex === null)
|
||||
? {
|
||||
desiredState: currentDesiredState,
|
||||
serviceStates: existing.config?.serviceStates ?? null,
|
||||
}
|
||||
: buildWorkspaceRuntimeDesiredStatePatch({
|
||||
config: { workspaceRuntime: effectiveRuntimeConfig },
|
||||
currentDesiredState,
|
||||
currentServiceStates: existing.config?.serviceStates ?? null,
|
||||
action,
|
||||
serviceIndex: selectedServiceIndex,
|
||||
});
|
||||
const metadata = mergeExecutionWorkspaceConfig(existing.metadata as Record<string, unknown> | null, {
|
||||
desiredState: action === "stop" ? "stopped" : "running",
|
||||
desiredState: nextRuntimeState.desiredState,
|
||||
serviceStates: nextRuntimeState.serviceStates,
|
||||
});
|
||||
await svc.update(existing.id, { metadata });
|
||||
|
||||
|
|
@ -209,6 +378,9 @@ export function executionWorkspaceRoutes(db: Db) {
|
|||
: "Started execution workspace runtime services.\n",
|
||||
metadata: {
|
||||
runtimeServiceCount,
|
||||
workspaceCommandId: workspaceCommand?.id ?? target.workspaceCommandId ?? null,
|
||||
runtimeServiceId: selectedRuntimeServiceId,
|
||||
serviceIndex: selectedServiceIndex,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
|
@ -231,6 +403,11 @@ export function executionWorkspaceRoutes(db: Db) {
|
|||
entityId: existing.id,
|
||||
details: {
|
||||
runtimeServiceCount,
|
||||
workspaceCommandId: workspaceCommand?.id ?? target.workspaceCommandId ?? null,
|
||||
workspaceCommandKind: workspaceCommand?.kind ?? null,
|
||||
workspaceCommandName: workspaceCommand?.name ?? null,
|
||||
runtimeServiceId: selectedRuntimeServiceId,
|
||||
serviceIndex: selectedServiceIndex,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -238,7 +415,10 @@ export function executionWorkspaceRoutes(db: Db) {
|
|||
workspace,
|
||||
operation,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
router.post("/execution-workspaces/:id/runtime-services/:action", validate(workspaceRuntimeControlTargetSchema), handleExecutionWorkspaceRuntimeCommand);
|
||||
router.post("/execution-workspaces/:id/runtime-commands/:action", validate(workspaceRuntimeControlTargetSchema), handleExecutionWorkspaceRuntimeCommand);
|
||||
|
||||
router.patch("/execution-workspaces/:id", validate(updateExecutionWorkspaceSchema), async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue