[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:
Dotta 2026-04-14 12:57:11 -05:00 committed by GitHub
parent 6e6f538630
commit e89076148a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
64 changed files with 18576 additions and 1063 deletions

View file

@ -1,18 +1,27 @@
import { Router, type Request } from "express";
import { Router, type Request, type Response } from "express";
import type { Db } from "@paperclipai/db";
import {
createProjectSchema,
createProjectWorkspaceSchema,
findWorkspaceCommandDefinition,
isUuidLike,
matchWorkspaceRuntimeServiceToCommand,
updateProjectSchema,
updateProjectWorkspaceSchema,
workspaceRuntimeControlTargetSchema,
} from "@paperclipai/shared";
import { trackProjectCreated } from "@paperclipai/shared/telemetry";
import { validate } from "../middleware/validate.js";
import { projectService, logActivity, secretService, workspaceOperationService } from "../services/index.js";
import { conflict } from "../errors.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
import { startRuntimeServicesForWorkspaceControl, stopRuntimeServicesForProjectWorkspace } from "../services/workspace-runtime.js";
import {
buildWorkspaceRuntimeDesiredStatePatch,
listConfiguredRuntimeServiceEntries,
runWorkspaceJobForControl,
startRuntimeServicesForWorkspaceControl,
stopRuntimeServicesForProjectWorkspace,
} from "../services/workspace-runtime.js";
import { getTelemetryClient } from "../telemetry.js";
export function projectRoutes(db: Db) {
@ -259,12 +268,12 @@ export function projectRoutes(db: Db) {
},
);
router.post("/projects/:id/workspaces/:workspaceId/runtime-services/:action", async (req, res) => {
async function handleProjectWorkspaceRuntimeCommand(req: Request, res: Response) {
const id = req.params.id as string;
const workspaceId = req.params.workspaceId 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;
}
@ -283,13 +292,55 @@ export function projectRoutes(db: Db) {
const workspaceCwd = workspace.cwd;
if (!workspaceCwd) {
res.status(422).json({ error: "Project workspace needs a local path before Paperclip can manage local runtime services" });
res.status(422).json({ error: "Project workspace needs a local path before Paperclip can run workspace commands" });
return;
}
const runtimeConfig = workspace.runtimeConfig?.workspaceRuntime ?? null;
const target = req.body as { workspaceCommandId?: string | null; runtimeServiceId?: string | null; serviceIndex?: number | null };
const configuredServices = runtimeConfig ? listConfiguredRuntimeServiceEntries({ workspaceRuntime: runtimeConfig }) : [];
const workspaceCommand = runtimeConfig
? findWorkspaceCommandDefinition(runtimeConfig, target.workspaceCommandId ?? null)
: null;
if (target.workspaceCommandId && !workspaceCommand) {
res.status(404).json({ error: "Workspace command not found for this project workspace" });
return;
}
if (target.runtimeServiceId && !(workspace.runtimeServices ?? []).some((service) => service.id === target.runtimeServiceId)) {
res.status(404).json({ error: "Runtime service not found for this project workspace" });
return;
}
const matchedRuntimeService =
workspaceCommand?.kind === "service" && !target.runtimeServiceId
? matchWorkspaceRuntimeServiceToCommand(workspaceCommand, workspace.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 project 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") && !runtimeConfig) {
res.status(422).json({ error: "Project workspace has no runtime service configuration" });
res.status(422).json({ error: "Project workspace has no workspace command configuration" });
return;
}
@ -301,14 +352,63 @@ export function projectRoutes(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: workspace.cwd,
metadata: {
action,
projectId: project.id,
projectWorkspaceId: workspace.id,
workspaceCommandId: workspaceCommand?.id ?? target.workspaceCommandId ?? null,
workspaceCommandKind: workspaceCommand?.kind ?? null,
workspaceCommandName: workspaceCommand?.name ?? null,
runtimeServiceId: selectedRuntimeServiceId,
serviceIndex: selectedServiceIndex,
},
run: async () => {
if (action === "run") {
if (!workspaceCommand || workspaceCommand.kind !== "job") {
throw new Error("Workspace job selection is required");
}
return await runWorkspaceJobForControl({
actor: {
id: actor.agentId ?? null,
name: actor.actorType === "user" ? "Board" : "Agent",
companyId: project.companyId,
},
issue: null,
workspace: {
baseCwd: workspaceCwd,
source: "project_primary",
projectId: project.id,
workspaceId: workspace.id,
repoUrl: workspace.repoUrl,
repoRef: workspace.repoRef,
strategy: "project_primary",
cwd: workspaceCwd,
branchName: workspace.defaultRef ?? workspace.repoRef ?? null,
worktreePath: null,
warnings: [],
created: false,
},
command: workspaceCommand.rawConfig,
adapterEnv: {},
recorder,
metadata: {
action,
projectId: project.id,
projectWorkspaceId: workspace.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);
@ -318,6 +418,7 @@ export function projectRoutes(db: Db) {
await stopRuntimeServicesForProjectWorkspace({
db,
projectWorkspaceId: workspace.id,
runtimeServiceId: selectedRuntimeServiceId,
});
}
@ -347,15 +448,37 @@ export function projectRoutes(db: Db) {
config: { workspaceRuntime: runtimeConfig },
adapterEnv: {},
onLog,
serviceIndex: selectedServiceIndex,
});
runtimeServiceCount = startedServices.length;
} else {
runtimeServiceCount = 0;
runtimeServiceCount = selectedRuntimeServiceId ? Math.max(0, (workspace.runtimeServices?.length ?? 1) - 1) : 0;
}
const currentDesiredState: "running" | "stopped" =
workspace.runtimeConfig?.desiredState
?? ((workspace.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: workspace.runtimeConfig?.serviceStates ?? null,
}
: buildWorkspaceRuntimeDesiredStatePatch({
config: { workspaceRuntime: runtimeConfig },
currentDesiredState,
currentServiceStates: workspace.runtimeConfig?.serviceStates ?? null,
action,
serviceIndex: selectedServiceIndex,
});
await svc.updateWorkspace(project.id, workspace.id, {
runtimeConfig: {
desiredState: action === "stop" ? "stopped" : "running",
desiredState: nextRuntimeState.desiredState,
serviceStates: nextRuntimeState.serviceStates,
},
});
@ -371,6 +494,9 @@ export function projectRoutes(db: Db) {
: "Started project workspace runtime services.\n",
metadata: {
runtimeServiceCount,
workspaceCommandId: workspaceCommand?.id ?? target.workspaceCommandId ?? null,
runtimeServiceId: selectedRuntimeServiceId,
serviceIndex: selectedServiceIndex,
},
};
},
@ -389,6 +515,11 @@ export function projectRoutes(db: Db) {
details: {
projectWorkspaceId: workspace.id,
runtimeServiceCount,
workspaceCommandId: workspaceCommand?.id ?? target.workspaceCommandId ?? null,
workspaceCommandKind: workspaceCommand?.kind ?? null,
workspaceCommandName: workspaceCommand?.name ?? null,
runtimeServiceId: selectedRuntimeServiceId,
serviceIndex: selectedServiceIndex,
},
});
@ -396,7 +527,10 @@ export function projectRoutes(db: Db) {
workspace: updatedWorkspace,
operation,
});
});
}
router.post("/projects/:id/workspaces/:workspaceId/runtime-services/:action", validate(workspaceRuntimeControlTargetSchema), handleProjectWorkspaceRuntimeCommand);
router.post("/projects/:id/workspaces/:workspaceId/runtime-commands/:action", validate(workspaceRuntimeControlTargetSchema), handleProjectWorkspaceRuntimeCommand);
router.delete("/projects/:id/workspaces/:workspaceId", async (req, res) => {
const id = req.params.id as string;