forked from farhoodlabs/paperclip
[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:
@@ -43,6 +43,7 @@ describe("execution workspace config helpers", () => {
|
||||
teardownCommand: "bash ./scripts/teardown-worktree.sh",
|
||||
cleanupCommand: "pkill -f vite || true",
|
||||
desiredState: null,
|
||||
serviceStates: null,
|
||||
workspaceRuntime: {
|
||||
services: [{ name: "web", command: "pnpm dev", port: 3100 }],
|
||||
},
|
||||
@@ -73,6 +74,7 @@ describe("execution workspace config helpers", () => {
|
||||
teardownCommand: "bash ./scripts/teardown-worktree.sh",
|
||||
cleanupCommand: "pkill -f vite || true",
|
||||
desiredState: null,
|
||||
serviceStates: null,
|
||||
workspaceRuntime: {
|
||||
services: [{ name: "web", command: "pnpm dev" }],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { sidebarPreferenceRoutes } from "../routes/sidebar-preferences.js";
|
||||
|
||||
const mockSidebarPreferenceService = vi.hoisted(() => ({
|
||||
getCompanyOrder: vi.fn(),
|
||||
upsertCompanyOrder: vi.fn(),
|
||||
getProjectOrder: vi.fn(),
|
||||
upsertProjectOrder: vi.fn(),
|
||||
}));
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
sidebarPreferenceService: () => mockSidebarPreferenceService,
|
||||
logActivity: mockLogActivity,
|
||||
}));
|
||||
|
||||
function createApp(actor: Record<string, unknown>) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = actor as never;
|
||||
next();
|
||||
});
|
||||
app.use("/api", sidebarPreferenceRoutes({} as never));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
const ORDERED_IDS = [
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
"22222222-2222-4222-8222-222222222222",
|
||||
];
|
||||
|
||||
describe("sidebar preference routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSidebarPreferenceService.getCompanyOrder.mockResolvedValue({
|
||||
orderedIds: ORDERED_IDS,
|
||||
updatedAt: null,
|
||||
});
|
||||
mockSidebarPreferenceService.upsertCompanyOrder.mockResolvedValue({
|
||||
orderedIds: ORDERED_IDS,
|
||||
updatedAt: null,
|
||||
});
|
||||
mockSidebarPreferenceService.getProjectOrder.mockResolvedValue({
|
||||
orderedIds: ORDERED_IDS,
|
||||
updatedAt: null,
|
||||
});
|
||||
mockSidebarPreferenceService.upsertProjectOrder.mockResolvedValue({
|
||||
orderedIds: ORDERED_IDS,
|
||||
updatedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns company rail order for board users", async () => {
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: ["company-1"],
|
||||
});
|
||||
|
||||
const res = await request(app).get("/api/sidebar-preferences/me");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
orderedIds: ORDERED_IDS,
|
||||
updatedAt: null,
|
||||
});
|
||||
expect(mockSidebarPreferenceService.getCompanyOrder).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
|
||||
it("updates company rail order for board users", async () => {
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: ["company-1"],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.put("/api/sidebar-preferences/me")
|
||||
.send({ orderedIds: ORDERED_IDS });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockSidebarPreferenceService.upsertCompanyOrder).toHaveBeenCalledWith("user-1", ORDERED_IDS);
|
||||
});
|
||||
|
||||
it("returns project order for companies the board user can access", async () => {
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: ["company-1"],
|
||||
});
|
||||
|
||||
const res = await request(app).get("/api/companies/company-1/sidebar-preferences/me");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockSidebarPreferenceService.getProjectOrder).toHaveBeenCalledWith("company-1", "user-1");
|
||||
});
|
||||
|
||||
it("logs project order updates for company-scoped writes", async () => {
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: ["company-1"],
|
||||
runId: "run-1",
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.put("/api/companies/company-1/sidebar-preferences/me")
|
||||
.send({ orderedIds: ORDERED_IDS });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockSidebarPreferenceService.upsertProjectOrder).toHaveBeenCalledWith("company-1", "user-1", ORDERED_IDS);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
{} as never,
|
||||
expect.objectContaining({
|
||||
companyId: "company-1",
|
||||
action: "sidebar_preferences.project_order_updated",
|
||||
details: expect.objectContaining({
|
||||
userId: "user-1",
|
||||
orderedIds: ORDERED_IDS,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects company-scoped reads when the board user lacks company access", async () => {
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: ["company-2"],
|
||||
});
|
||||
|
||||
const res = await request(app).get("/api/companies/company-1/sidebar-preferences/me");
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockSidebarPreferenceService.getProjectOrder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects agent callers", async () => {
|
||||
const app = createApp({
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
source: "agent_key",
|
||||
});
|
||||
|
||||
const res = await request(app).get("/api/sidebar-preferences/me");
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockSidebarPreferenceService.getCompanyOrder).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -19,16 +19,21 @@ import {
|
||||
} from "@paperclipai/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
buildWorkspaceRuntimeDesiredStatePatch,
|
||||
cleanupExecutionWorkspaceArtifacts,
|
||||
ensurePersistedExecutionWorkspaceAvailable,
|
||||
ensureServerWorkspaceLinksCurrent,
|
||||
ensureRuntimeServicesForRun,
|
||||
listConfiguredRuntimeServiceEntries,
|
||||
normalizeAdapterManagedRuntimeServices,
|
||||
reconcilePersistedRuntimeServicesOnStartup,
|
||||
realizeExecutionWorkspace,
|
||||
releaseRuntimeServicesForRun,
|
||||
resetRuntimeServicesForTests,
|
||||
resolveWorkspaceRuntimeReadinessTimeoutSec,
|
||||
resolveShell,
|
||||
sanitizeRuntimeServiceBaseEnv,
|
||||
startRuntimeServicesForWorkspaceControl,
|
||||
stopRuntimeServicesForExecutionWorkspace,
|
||||
type RealizedExecutionWorkspace,
|
||||
} from "../services/workspace-runtime.ts";
|
||||
@@ -367,6 +372,42 @@ describe("realizeExecutionWorkspace", () => {
|
||||
expect(second.branchName).toBe(first.branchName);
|
||||
});
|
||||
|
||||
it("rejects reusing an empty directory that only looks like a worktree because it sits inside the repo", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
const branchName = "PAP-447-add-worktree-support";
|
||||
const poisonedPath = path.join(repoRoot, ".paperclip", "worktrees", branchName);
|
||||
await fs.mkdir(poisonedPath, { recursive: true });
|
||||
|
||||
await expect(
|
||||
realizeExecutionWorkspace({
|
||||
base: {
|
||||
baseCwd: repoRoot,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "HEAD",
|
||||
},
|
||||
config: {
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-447",
|
||||
title: "Add Worktree Support",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(/not a reusable git worktree \(path is not registered in `git worktree list`\)\./);
|
||||
});
|
||||
|
||||
it("reuses the current linked worktree instead of nesting another worktree inside it", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
const branchName = "PAP-1355-worktree-reuse";
|
||||
@@ -408,6 +449,68 @@ describe("realizeExecutionWorkspace", () => {
|
||||
await expect(fs.realpath(realized.worktreePath ?? "")).resolves.toBe(expectedWorktreePath);
|
||||
});
|
||||
|
||||
it("rejects reusing a linked worktree whose branch drifted from the expected issue branch", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
|
||||
const initial = await realizeExecutionWorkspace({
|
||||
base: {
|
||||
baseCwd: repoRoot,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "HEAD",
|
||||
},
|
||||
config: {
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-447",
|
||||
title: "Add Worktree Support",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
});
|
||||
|
||||
await runGit(initial.cwd, ["checkout", "-b", "unexpected-branch"]);
|
||||
|
||||
await expect(
|
||||
realizeExecutionWorkspace({
|
||||
base: {
|
||||
baseCwd: repoRoot,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "HEAD",
|
||||
},
|
||||
config: {
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-447",
|
||||
title: "Add Worktree Support",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(/not a reusable git worktree \(worktree HEAD is on "unexpected-branch" instead of "PAP-447-add-worktree-support"\)\./);
|
||||
});
|
||||
|
||||
it("reuses an already checked out branch from git worktree metadata even when the target path differs", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
const branchName = "PAP-1355-worktree-reuse";
|
||||
@@ -1033,6 +1136,137 @@ describe("realizeExecutionWorkspace", () => {
|
||||
);
|
||||
}, 30_000);
|
||||
|
||||
it("fails instead of writing an unseeded fallback config when worktree init errors after CLI detection succeeds", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-provision-fail-"));
|
||||
const baseRoot = path.join(tempRoot, "base");
|
||||
const worktreeRoot = path.join(tempRoot, "worktree");
|
||||
const fakeBin = path.join(tempRoot, "bin");
|
||||
const fakePnpmPath = path.join(fakeBin, "pnpm");
|
||||
const scriptPath = path.join(worktreeRoot, "provision-worktree.sh");
|
||||
|
||||
try {
|
||||
await fs.mkdir(baseRoot, { recursive: true });
|
||||
await fs.mkdir(worktreeRoot, { recursive: true });
|
||||
await fs.mkdir(fakeBin, { recursive: true });
|
||||
await fs.copyFile(provisionWorktreeScriptPath, scriptPath);
|
||||
await fs.chmod(scriptPath, 0o755);
|
||||
await fs.writeFile(
|
||||
fakePnpmPath,
|
||||
[
|
||||
"#!/bin/sh",
|
||||
"if [ \"$1\" = \"paperclipai\" ] && [ \"$2\" = \"--help\" ]; then",
|
||||
" exit 0",
|
||||
"fi",
|
||||
"if [ \"$1\" = \"paperclipai\" ] && [ \"$2\" = \"worktree\" ] && [ \"$3\" = \"init\" ]; then",
|
||||
" echo \"simulated init failure\" >&2",
|
||||
" exit 42",
|
||||
"fi",
|
||||
"exit 0",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await fs.chmod(fakePnpmPath, 0o755);
|
||||
|
||||
let caught: Error | null = null;
|
||||
try {
|
||||
await execFileAsync(scriptPath, [], {
|
||||
cwd: worktreeRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
|
||||
PAPERCLIP_WORKSPACE_BASE_CWD: baseRoot,
|
||||
PAPERCLIP_WORKSPACE_CWD: worktreeRoot,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
caught = error as Error;
|
||||
}
|
||||
|
||||
expect(caught).toBeTruthy();
|
||||
expect(String(caught)).toContain("simulated init failure");
|
||||
await expect(fs.stat(path.join(worktreeRoot, ".paperclip", "config.json"))).rejects.toThrow();
|
||||
await expect(fs.stat(path.join(worktreeRoot, ".paperclip", ".env"))).rejects.toThrow();
|
||||
} finally {
|
||||
await fs.rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("retries worktree-local pnpm install without a frozen lockfile when the lockfile is outdated", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-outdated-lockfile-"));
|
||||
const baseRoot = path.join(tempRoot, "base");
|
||||
const worktreeRoot = path.join(tempRoot, "worktree");
|
||||
const fakeBin = path.join(tempRoot, "bin");
|
||||
const fakePnpmPath = path.join(fakeBin, "pnpm");
|
||||
const scriptPath = path.join(worktreeRoot, "provision-worktree.sh");
|
||||
|
||||
try {
|
||||
await fs.mkdir(path.join(baseRoot, "node_modules"), { recursive: true });
|
||||
await fs.mkdir(worktreeRoot, { recursive: true });
|
||||
await fs.mkdir(fakeBin, { recursive: true });
|
||||
await fs.copyFile(provisionWorktreeScriptPath, scriptPath);
|
||||
await fs.chmod(scriptPath, 0o755);
|
||||
await fs.writeFile(
|
||||
path.join(worktreeRoot, "package.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "workspace-root",
|
||||
private: true,
|
||||
packageManager: "pnpm@9.15.4",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(worktreeRoot, "pnpm-lock.yaml"),
|
||||
["lockfileVersion: '9.0'", "", "importers:", " .: {}", ""].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
fakePnpmPath,
|
||||
[
|
||||
"#!/bin/sh",
|
||||
"if [ \"$1\" = \"paperclipai\" ] && [ \"$2\" = \"--help\" ]; then",
|
||||
" exit 1",
|
||||
"fi",
|
||||
"if [ \"$1\" = \"install\" ] && [ \"$2\" = \"--frozen-lockfile\" ]; then",
|
||||
" echo \"ERR_PNPM_OUTDATED_LOCKFILE\" >&2",
|
||||
" exit 1",
|
||||
"fi",
|
||||
"if [ \"$1\" = \"install\" ] && [ \"$2\" = \"--no-frozen-lockfile\" ]; then",
|
||||
" mkdir -p \"$PWD/node_modules\"",
|
||||
" : > \"$PWD/node_modules/.retry-success\"",
|
||||
" exit 0",
|
||||
"fi",
|
||||
"exit 0",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await fs.chmod(fakePnpmPath, 0o755);
|
||||
|
||||
const result = await execFileAsync(scriptPath, [], {
|
||||
cwd: worktreeRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
|
||||
PAPERCLIP_WORKSPACE_BASE_CWD: baseRoot,
|
||||
PAPERCLIP_WORKSPACE_CWD: worktreeRoot,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.stderr).toContain("retrying install without --frozen-lockfile");
|
||||
await expect(fs.readFile(path.join(worktreeRoot, "node_modules", ".retry-success"), "utf8")).resolves.toBe("");
|
||||
await expect(fs.readFile(path.join(worktreeRoot, ".paperclip", "config.json"), "utf8")).resolves.toContain(
|
||||
"\"database\"",
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it(
|
||||
"provisions worktree-local pnpm node_modules instead of reusing base-repo links",
|
||||
async () => {
|
||||
@@ -1290,6 +1524,187 @@ describe("realizeExecutionWorkspace", () => {
|
||||
expect(actualHead).toBe(expectedHead);
|
||||
});
|
||||
|
||||
it("reattaches a missing persisted git worktree before manual control starts it", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
const branchName = "PAP-451-restore-persisted-worktree";
|
||||
await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(repoRoot, "scripts", "restore.sh"),
|
||||
[
|
||||
"#!/usr/bin/env bash",
|
||||
"set -euo pipefail",
|
||||
"printf '%s\\n' \"$PAPERCLIP_WORKSPACE_BRANCH\" > .paperclip-restored-branch",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await fs.chmod(path.join(repoRoot, "scripts", "restore.sh"), 0o755);
|
||||
await runGit(repoRoot, ["add", "scripts/restore.sh"]);
|
||||
await runGit(repoRoot, ["commit", "-m", "Add restore script"]);
|
||||
|
||||
await runGit(repoRoot, ["checkout", "-b", branchName]);
|
||||
await fs.writeFile(path.join(repoRoot, "feature.txt"), "persisted\n", "utf8");
|
||||
await runGit(repoRoot, ["add", "feature.txt"]);
|
||||
await runGit(repoRoot, ["commit", "-m", "Add persisted feature"]);
|
||||
const expectedHead = (await execFileAsync("git", ["rev-parse", branchName], { cwd: repoRoot })).stdout.trim();
|
||||
await runGit(repoRoot, ["checkout", "main"]);
|
||||
|
||||
const initial = await realizeExecutionWorkspace({
|
||||
base: {
|
||||
baseCwd: repoRoot,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "HEAD",
|
||||
},
|
||||
config: {
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
||||
provisionCommand: "bash ./scripts/restore.sh",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-451",
|
||||
title: "Restore persisted worktree",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
});
|
||||
|
||||
await fs.rm(initial.cwd, { recursive: true, force: true });
|
||||
|
||||
const restored = await ensurePersistedExecutionWorkspaceAvailable({
|
||||
base: {
|
||||
baseCwd: repoRoot,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "HEAD",
|
||||
},
|
||||
workspace: {
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
cwd: initial.cwd,
|
||||
providerRef: initial.worktreePath,
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
baseRef: "HEAD",
|
||||
branchName,
|
||||
config: {
|
||||
provisionCommand: "bash ./scripts/restore.sh",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-451",
|
||||
title: "Restore persisted worktree",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(restored).not.toBeNull();
|
||||
expect(restored?.cwd).toBe(initial.cwd);
|
||||
await expect(fs.readFile(path.join(initial.cwd, "feature.txt"), "utf8")).resolves.toBe("persisted\n");
|
||||
await expect(fs.readFile(path.join(initial.cwd, ".paperclip-restored-branch"), "utf8")).resolves.toBe(`${branchName}\n`);
|
||||
const actualHead = (await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: initial.cwd })).stdout.trim();
|
||||
expect(actualHead).toBe(expectedHead);
|
||||
});
|
||||
|
||||
it("reprovisions an existing persisted git worktree before manual control starts it", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(repoRoot, "scripts", "restore.sh"),
|
||||
[
|
||||
"#!/usr/bin/env bash",
|
||||
"set -euo pipefail",
|
||||
"printf 'reprovisioned\\n' > .paperclip-restored-state",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await fs.chmod(path.join(repoRoot, "scripts", "restore.sh"), 0o755);
|
||||
await runGit(repoRoot, ["add", "scripts/restore.sh"]);
|
||||
await runGit(repoRoot, ["commit", "-m", "Add reprovision script"]);
|
||||
|
||||
const initial = await realizeExecutionWorkspace({
|
||||
base: {
|
||||
baseCwd: repoRoot,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "HEAD",
|
||||
},
|
||||
config: {
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
||||
provisionCommand: "bash ./scripts/restore.sh",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-452",
|
||||
title: "Reprovision persisted worktree",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
});
|
||||
|
||||
await fs.rm(path.join(initial.cwd, ".paperclip-restored-state"), { force: true });
|
||||
|
||||
await ensurePersistedExecutionWorkspaceAvailable({
|
||||
base: {
|
||||
baseCwd: repoRoot,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "HEAD",
|
||||
},
|
||||
workspace: {
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
cwd: initial.cwd,
|
||||
providerRef: initial.worktreePath,
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
baseRef: "HEAD",
|
||||
branchName: initial.branchName,
|
||||
config: {
|
||||
provisionCommand: "bash ./scripts/restore.sh",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-452",
|
||||
title: "Reprovision persisted worktree",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(fs.readFile(path.join(initial.cwd, ".paperclip-restored-state"), "utf8")).resolves.toBe("reprovisioned\n");
|
||||
});
|
||||
|
||||
it("auto-detects the default branch when baseRef is not configured", async () => {
|
||||
// Create a repo with "master" as default branch (not "main")
|
||||
const repoRoot = await createTempRepo("master");
|
||||
@@ -1977,6 +2392,234 @@ describe("ensureRuntimeServicesForRun", () => {
|
||||
await releaseRuntimeServicesForRun(runId);
|
||||
leasedRunIds.delete(runId);
|
||||
});
|
||||
|
||||
it("starts only the selected workspace-controlled runtime service", async () => {
|
||||
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-control-start-"));
|
||||
const workspace = buildWorkspace(workspaceRoot);
|
||||
|
||||
const services = await startRuntimeServicesForWorkspaceControl({
|
||||
actor: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
issue: null,
|
||||
workspace,
|
||||
executionWorkspaceId: "execution-workspace-control-start",
|
||||
config: {
|
||||
workspaceRuntime: {
|
||||
services: [
|
||||
{
|
||||
name: "web",
|
||||
command:
|
||||
"node -e \"require('node:http').createServer((req,res)=>res.end('web')).listen(Number(process.env.PORT), '127.0.0.1')\"",
|
||||
port: { type: "auto" },
|
||||
readiness: {
|
||||
type: "http",
|
||||
urlTemplate: "http://127.0.0.1:{{port}}",
|
||||
timeoutSec: 10,
|
||||
intervalMs: 100,
|
||||
},
|
||||
lifecycle: "shared",
|
||||
reuseScope: "execution_workspace",
|
||||
},
|
||||
{
|
||||
name: "worker",
|
||||
command:
|
||||
"node -e \"require('node:http').createServer((req,res)=>res.end('worker')).listen(Number(process.env.PORT), '127.0.0.1')\"",
|
||||
port: { type: "auto" },
|
||||
readiness: {
|
||||
type: "http",
|
||||
urlTemplate: "http://127.0.0.1:{{port}}",
|
||||
timeoutSec: 10,
|
||||
intervalMs: 100,
|
||||
},
|
||||
lifecycle: "shared",
|
||||
reuseScope: "execution_workspace",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
adapterEnv: {},
|
||||
serviceIndex: 1,
|
||||
});
|
||||
|
||||
expect(services).toHaveLength(1);
|
||||
expect(services[0]?.serviceName).toBe("worker");
|
||||
await expect(fetch(services[0]!.url!)).resolves.toMatchObject({ ok: true });
|
||||
|
||||
await stopRuntimeServicesForExecutionWorkspace({
|
||||
executionWorkspaceId: "execution-workspace-control-start",
|
||||
workspaceCwd: workspace.cwd,
|
||||
});
|
||||
});
|
||||
|
||||
it("stops only the selected execution workspace runtime service", async () => {
|
||||
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-control-stop-"));
|
||||
const workspace = buildWorkspace(workspaceRoot);
|
||||
|
||||
const services = await startRuntimeServicesForWorkspaceControl({
|
||||
actor: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
issue: null,
|
||||
workspace,
|
||||
executionWorkspaceId: "execution-workspace-control-stop",
|
||||
config: {
|
||||
workspaceRuntime: {
|
||||
services: [
|
||||
{
|
||||
name: "web",
|
||||
command:
|
||||
"node -e \"require('node:http').createServer((req,res)=>res.end('web')).listen(Number(process.env.PORT), '127.0.0.1')\"",
|
||||
port: { type: "auto" },
|
||||
readiness: {
|
||||
type: "http",
|
||||
urlTemplate: "http://127.0.0.1:{{port}}",
|
||||
timeoutSec: 10,
|
||||
intervalMs: 100,
|
||||
},
|
||||
lifecycle: "shared",
|
||||
reuseScope: "execution_workspace",
|
||||
stopPolicy: {
|
||||
type: "manual",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "worker",
|
||||
command:
|
||||
"node -e \"require('node:http').createServer((req,res)=>res.end('worker')).listen(Number(process.env.PORT), '127.0.0.1')\"",
|
||||
port: { type: "auto" },
|
||||
readiness: {
|
||||
type: "http",
|
||||
urlTemplate: "http://127.0.0.1:{{port}}",
|
||||
timeoutSec: 10,
|
||||
intervalMs: 100,
|
||||
},
|
||||
lifecycle: "shared",
|
||||
reuseScope: "execution_workspace",
|
||||
stopPolicy: {
|
||||
type: "manual",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
adapterEnv: {},
|
||||
});
|
||||
|
||||
expect(services).toHaveLength(2);
|
||||
const web = services.find((service) => service.serviceName === "web");
|
||||
const worker = services.find((service) => service.serviceName === "worker");
|
||||
|
||||
await stopRuntimeServicesForExecutionWorkspace({
|
||||
executionWorkspaceId: "execution-workspace-control-stop",
|
||||
workspaceCwd: workspace.cwd,
|
||||
runtimeServiceId: web?.id ?? null,
|
||||
});
|
||||
|
||||
await expect(fetch(web!.url!)).rejects.toThrow();
|
||||
await expect(fetch(worker!.url!)).resolves.toMatchObject({ ok: true });
|
||||
|
||||
await stopRuntimeServicesForExecutionWorkspace({
|
||||
executionWorkspaceId: "execution-workspace-control-stop",
|
||||
workspaceCwd: workspace.cwd,
|
||||
runtimeServiceId: worker?.id ?? null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildWorkspaceRuntimeDesiredStatePatch", () => {
|
||||
it("derives service entries from command-first runtime config", () => {
|
||||
const services = listConfiguredRuntimeServiceEntries({
|
||||
workspaceRuntime: {
|
||||
commands: [
|
||||
{ id: "web", name: "web", kind: "service", command: "pnpm dev" },
|
||||
{ id: "db-migrate", name: "db:migrate", kind: "job", command: "pnpm db:migrate" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(services).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "web",
|
||||
kind: "service",
|
||||
command: "pnpm dev",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves sibling service state when updating a single configured runtime service", () => {
|
||||
const patch = buildWorkspaceRuntimeDesiredStatePatch({
|
||||
config: {
|
||||
workspaceRuntime: {
|
||||
services: [
|
||||
{ name: "web", command: "pnpm dev" },
|
||||
{ name: "worker", command: "pnpm worker" },
|
||||
],
|
||||
},
|
||||
},
|
||||
currentDesiredState: "running",
|
||||
currentServiceStates: null,
|
||||
action: "stop",
|
||||
serviceIndex: 1,
|
||||
});
|
||||
|
||||
expect(patch).toEqual({
|
||||
desiredState: "running",
|
||||
serviceStates: {
|
||||
"0": "running",
|
||||
"1": "stopped",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveWorkspaceRuntimeReadinessTimeoutSec", () => {
|
||||
it("extends the default readiness timeout for dev-server commands", () => {
|
||||
expect(
|
||||
resolveWorkspaceRuntimeReadinessTimeoutSec({
|
||||
command: "pnpm dev",
|
||||
readiness: {
|
||||
type: "http",
|
||||
urlTemplate: "http://127.0.0.1:{{port}}",
|
||||
},
|
||||
}),
|
||||
).toBe(90);
|
||||
expect(
|
||||
resolveWorkspaceRuntimeReadinessTimeoutSec({
|
||||
command: "npm run dev -- --host 127.0.0.1",
|
||||
readiness: {
|
||||
type: "http",
|
||||
urlTemplate: "http://127.0.0.1:{{port}}",
|
||||
},
|
||||
}),
|
||||
).toBe(90);
|
||||
});
|
||||
|
||||
it("keeps explicit readiness timeouts and non-dev defaults unchanged", () => {
|
||||
expect(
|
||||
resolveWorkspaceRuntimeReadinessTimeoutSec({
|
||||
command: "pnpm dev",
|
||||
readiness: {
|
||||
type: "http",
|
||||
timeoutSec: 12,
|
||||
urlTemplate: "http://127.0.0.1:{{port}}",
|
||||
},
|
||||
}),
|
||||
).toBe(12);
|
||||
expect(
|
||||
resolveWorkspaceRuntimeReadinessTimeoutSec({
|
||||
command: "node server.js",
|
||||
readiness: {
|
||||
type: "http",
|
||||
urlTemplate: "http://127.0.0.1:{{port}}",
|
||||
},
|
||||
}),
|
||||
).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveShell (shell fallback)", () => {
|
||||
@@ -1993,13 +2636,18 @@ describe("resolveShell (shell fallback)", () => {
|
||||
});
|
||||
|
||||
it("returns process.env.SHELL when set", () => {
|
||||
process.env.SHELL = "/usr/bin/zsh";
|
||||
expect(resolveShell()).toBe("/usr/bin/zsh");
|
||||
process.env.SHELL = process.execPath;
|
||||
expect(resolveShell()).toBe(process.execPath);
|
||||
});
|
||||
|
||||
it("trims whitespace from SHELL env var", () => {
|
||||
process.env.SHELL = " /usr/bin/fish ";
|
||||
expect(resolveShell()).toBe("/usr/bin/fish");
|
||||
process.env.SHELL = ` ${process.execPath} `;
|
||||
expect(resolveShell()).toBe(process.execPath);
|
||||
});
|
||||
|
||||
it("preserves non-absolute shell names so PATH lookup still works", () => {
|
||||
process.env.SHELL = "zsh";
|
||||
expect(resolveShell()).toBe("zsh");
|
||||
});
|
||||
|
||||
it("falls back to /bin/sh on non-Windows when SHELL is unset", () => {
|
||||
@@ -2031,6 +2679,12 @@ describe("resolveShell (shell fallback)", () => {
|
||||
Object.defineProperty(process, "platform", { value: "win32" });
|
||||
expect(resolveShell()).toBe("sh");
|
||||
});
|
||||
|
||||
it("falls back when SHELL points to a missing absolute path", () => {
|
||||
process.env.SHELL = "/definitely/missing/zsh";
|
||||
Object.defineProperty(process, "platform", { value: "linux" });
|
||||
expect(resolveShell()).toBe("/bin/sh");
|
||||
});
|
||||
});
|
||||
|
||||
describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
|
||||
|
||||
@@ -24,6 +24,7 @@ import { costRoutes } from "./routes/costs.js";
|
||||
import { activityRoutes } from "./routes/activity.js";
|
||||
import { dashboardRoutes } from "./routes/dashboard.js";
|
||||
import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
|
||||
import { sidebarPreferenceRoutes } from "./routes/sidebar-preferences.js";
|
||||
import { inboxDismissalRoutes } from "./routes/inbox-dismissals.js";
|
||||
import { instanceSettingsRoutes } from "./routes/instance-settings.js";
|
||||
import { llmRoutes } from "./routes/llms.js";
|
||||
@@ -167,6 +168,7 @@ export async function createApp(
|
||||
api.use(activityRoutes(db));
|
||||
api.use(dashboardRoutes(db));
|
||||
api.use(sidebarBadgeRoutes(db));
|
||||
api.use(sidebarPreferenceRoutes(db));
|
||||
api.use(inboxDismissalRoutes(db));
|
||||
api.use(instanceSettingsRoutes(db));
|
||||
const hostServicesDisposers = new Map<string, () => void>();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -12,6 +12,7 @@ export { costRoutes } from "./costs.js";
|
||||
export { activityRoutes } from "./activity.js";
|
||||
export { dashboardRoutes } from "./dashboard.js";
|
||||
export { sidebarBadgeRoutes } from "./sidebar-badges.js";
|
||||
export { sidebarPreferenceRoutes } from "./sidebar-preferences.js";
|
||||
export { inboxDismissalRoutes } from "./inbox-dismissals.js";
|
||||
export { llmRoutes } from "./llms.js";
|
||||
export { accessRoutes } from "./access.js";
|
||||
|
||||
+145
-11
@@ -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;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { upsertSidebarOrderPreferenceSchema } from "@paperclipai/shared";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { logActivity, sidebarPreferenceService } from "../services/index.js";
|
||||
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
|
||||
function requireBoardUserId(req: Request, res: Response): string | null {
|
||||
assertBoard(req);
|
||||
if (!req.actor.userId) {
|
||||
res.status(403).json({ error: "Board user context required" });
|
||||
return null;
|
||||
}
|
||||
return req.actor.userId;
|
||||
}
|
||||
|
||||
export function sidebarPreferenceRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const svc = sidebarPreferenceService(db);
|
||||
|
||||
router.get("/sidebar-preferences/me", async (req, res) => {
|
||||
const userId = requireBoardUserId(req, res);
|
||||
if (!userId) return;
|
||||
res.json(await svc.getCompanyOrder(userId));
|
||||
});
|
||||
|
||||
router.put("/sidebar-preferences/me", validate(upsertSidebarOrderPreferenceSchema), async (req, res) => {
|
||||
const userId = requireBoardUserId(req, res);
|
||||
if (!userId) return;
|
||||
res.json(await svc.upsertCompanyOrder(userId, req.body.orderedIds));
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/sidebar-preferences/me", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const userId = requireBoardUserId(req, res);
|
||||
if (!userId) return;
|
||||
res.json(await svc.getProjectOrder(companyId, userId));
|
||||
});
|
||||
|
||||
router.put(
|
||||
"/companies/:companyId/sidebar-preferences/me",
|
||||
validate(upsertSidebarOrderPreferenceSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const userId = requireBoardUserId(req, res);
|
||||
if (!userId) return;
|
||||
|
||||
const result = await svc.upsertProjectOrder(companyId, userId, req.body.orderedIds);
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "sidebar_preferences.project_order_updated",
|
||||
entityType: "company",
|
||||
entityId: companyId,
|
||||
details: {
|
||||
userId,
|
||||
orderedIds: result.orderedIds,
|
||||
},
|
||||
});
|
||||
res.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -192,6 +192,11 @@ export function readExecutionWorkspaceConfig(metadata: Record<string, unknown> |
|
||||
cleanupCommand: readNullableString(raw.cleanupCommand),
|
||||
workspaceRuntime: cloneRecord(raw.workspaceRuntime),
|
||||
desiredState: raw.desiredState === "running" || raw.desiredState === "stopped" ? raw.desiredState : null,
|
||||
serviceStates: isRecord(raw.serviceStates)
|
||||
? Object.fromEntries(
|
||||
Object.entries(raw.serviceStates).filter(([, state]) => state === "running" || state === "stopped"),
|
||||
) as ExecutionWorkspaceConfig["serviceStates"]
|
||||
: null,
|
||||
};
|
||||
|
||||
const hasConfig = Object.values(config).some((value) => {
|
||||
@@ -214,6 +219,7 @@ export function mergeExecutionWorkspaceConfig(
|
||||
cleanupCommand: null,
|
||||
workspaceRuntime: null,
|
||||
desiredState: null,
|
||||
serviceStates: null,
|
||||
};
|
||||
|
||||
if (patch === null) {
|
||||
@@ -232,6 +238,14 @@ export function mergeExecutionWorkspaceConfig(
|
||||
? patch.desiredState
|
||||
: null
|
||||
: current.desiredState,
|
||||
serviceStates:
|
||||
patch.serviceStates !== undefined && isRecord(patch.serviceStates)
|
||||
? Object.fromEntries(
|
||||
Object.entries(patch.serviceStates).filter(([, state]) => state === "running" || state === "stopped"),
|
||||
) as ExecutionWorkspaceConfig["serviceStates"]
|
||||
: patch.serviceStates !== undefined
|
||||
? null
|
||||
: current.serviceStates,
|
||||
};
|
||||
|
||||
const hasConfig = Object.values(nextConfig).some((value) => {
|
||||
@@ -247,6 +261,7 @@ export function mergeExecutionWorkspaceConfig(
|
||||
cleanupCommand: nextConfig.cleanupCommand,
|
||||
workspaceRuntime: nextConfig.workspaceRuntime,
|
||||
desiredState: nextConfig.desiredState,
|
||||
serviceStates: nextConfig.serviceStates ?? null,
|
||||
};
|
||||
} else {
|
||||
delete nextMetadata.config;
|
||||
|
||||
@@ -19,6 +19,7 @@ export { financeService } from "./finance.js";
|
||||
export { heartbeatService } from "./heartbeat.js";
|
||||
export { dashboardService } from "./dashboard.js";
|
||||
export { sidebarBadgeService } from "./sidebar-badges.js";
|
||||
export { sidebarPreferenceService } from "./sidebar-preferences.js";
|
||||
export { inboxDismissalService } from "./inbox-dismissals.js";
|
||||
export { accessService } from "./access.js";
|
||||
export { boardAuthService } from "./board-auth.js";
|
||||
|
||||
@@ -12,6 +12,13 @@ function readDesiredState(value: unknown): ProjectWorkspaceRuntimeConfig["desire
|
||||
return value === "running" || value === "stopped" ? value : null;
|
||||
}
|
||||
|
||||
function readServiceStates(value: unknown): ProjectWorkspaceRuntimeConfig["serviceStates"] {
|
||||
if (!isRecord(value)) return null;
|
||||
const entries = Object.entries(value).filter(([, state]) => state === "running" || state === "stopped");
|
||||
if (entries.length === 0) return null;
|
||||
return Object.fromEntries(entries) as ProjectWorkspaceRuntimeConfig["serviceStates"];
|
||||
}
|
||||
|
||||
export function readProjectWorkspaceRuntimeConfig(
|
||||
metadata: Record<string, unknown> | null | undefined,
|
||||
): ProjectWorkspaceRuntimeConfig | null {
|
||||
@@ -21,9 +28,10 @@ export function readProjectWorkspaceRuntimeConfig(
|
||||
const config: ProjectWorkspaceRuntimeConfig = {
|
||||
workspaceRuntime: cloneRecord(raw.workspaceRuntime),
|
||||
desiredState: readDesiredState(raw.desiredState),
|
||||
serviceStates: readServiceStates(raw.serviceStates),
|
||||
};
|
||||
|
||||
const hasConfig = config.workspaceRuntime !== null || config.desiredState !== null;
|
||||
const hasConfig = config.workspaceRuntime !== null || config.desiredState !== null || config.serviceStates !== null;
|
||||
return hasConfig ? config : null;
|
||||
}
|
||||
|
||||
@@ -35,6 +43,7 @@ export function mergeProjectWorkspaceRuntimeConfig(
|
||||
const current = readProjectWorkspaceRuntimeConfig(metadata) ?? {
|
||||
workspaceRuntime: null,
|
||||
desiredState: null,
|
||||
serviceStates: null,
|
||||
};
|
||||
|
||||
if (patch === null) {
|
||||
@@ -47,9 +56,11 @@ export function mergeProjectWorkspaceRuntimeConfig(
|
||||
patch.workspaceRuntime !== undefined ? cloneRecord(patch.workspaceRuntime) : current.workspaceRuntime,
|
||||
desiredState:
|
||||
patch.desiredState !== undefined ? readDesiredState(patch.desiredState) : current.desiredState,
|
||||
serviceStates:
|
||||
patch.serviceStates !== undefined ? readServiceStates(patch.serviceStates) : current.serviceStates,
|
||||
};
|
||||
|
||||
if (nextConfig.workspaceRuntime === null && nextConfig.desiredState === null) {
|
||||
if (nextConfig.workspaceRuntime === null && nextConfig.desiredState === null && nextConfig.serviceStates === null) {
|
||||
delete nextMetadata.runtimeConfig;
|
||||
} else {
|
||||
nextMetadata.runtimeConfig = nextConfig;
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
companyUserSidebarPreferences,
|
||||
userSidebarPreferences,
|
||||
} from "@paperclipai/db";
|
||||
import type { SidebarOrderPreference } from "@paperclipai/shared";
|
||||
|
||||
function normalizeOrderedIds(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
|
||||
const orderedIds: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const item of value) {
|
||||
if (typeof item !== "string") continue;
|
||||
const trimmed = item.trim();
|
||||
if (!trimmed || seen.has(trimmed)) continue;
|
||||
seen.add(trimmed);
|
||||
orderedIds.push(trimmed);
|
||||
}
|
||||
return orderedIds;
|
||||
}
|
||||
|
||||
function toPreference(orderedIds: unknown, updatedAt: Date | null): SidebarOrderPreference {
|
||||
return {
|
||||
orderedIds: normalizeOrderedIds(orderedIds),
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function sidebarPreferenceService(db: Db) {
|
||||
return {
|
||||
async getCompanyOrder(userId: string): Promise<SidebarOrderPreference> {
|
||||
const row = await db.query.userSidebarPreferences.findFirst({
|
||||
where: eq(userSidebarPreferences.userId, userId),
|
||||
});
|
||||
return toPreference(row?.companyOrder ?? [], row?.updatedAt ?? null);
|
||||
},
|
||||
|
||||
async upsertCompanyOrder(userId: string, orderedIds: string[]): Promise<SidebarOrderPreference> {
|
||||
const now = new Date();
|
||||
const normalized = normalizeOrderedIds(orderedIds);
|
||||
const [row] = await db
|
||||
.insert(userSidebarPreferences)
|
||||
.values({
|
||||
userId,
|
||||
companyOrder: normalized,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [userSidebarPreferences.userId],
|
||||
set: {
|
||||
companyOrder: normalized,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
return toPreference(row?.companyOrder ?? normalized, row?.updatedAt ?? now);
|
||||
},
|
||||
|
||||
async getProjectOrder(companyId: string, userId: string): Promise<SidebarOrderPreference> {
|
||||
const row = await db.query.companyUserSidebarPreferences.findFirst({
|
||||
where: and(
|
||||
eq(companyUserSidebarPreferences.companyId, companyId),
|
||||
eq(companyUserSidebarPreferences.userId, userId),
|
||||
),
|
||||
});
|
||||
return toPreference(row?.projectOrder ?? [], row?.updatedAt ?? null);
|
||||
},
|
||||
|
||||
async upsertProjectOrder(
|
||||
companyId: string,
|
||||
userId: string,
|
||||
orderedIds: string[],
|
||||
): Promise<SidebarOrderPreference> {
|
||||
const now = new Date();
|
||||
const normalized = normalizeOrderedIds(orderedIds);
|
||||
const [row] = await db
|
||||
.insert(companyUserSidebarPreferences)
|
||||
.values({
|
||||
companyId,
|
||||
userId,
|
||||
projectOrder: normalized,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [companyUserSidebarPreferences.companyId, companyUserSidebarPreferences.userId],
|
||||
set: {
|
||||
projectOrder: normalized,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
return toPreference(row?.projectOrder ?? normalized, row?.updatedAt ?? now);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,11 @@ import { setTimeout as delay } from "node:timers/promises";
|
||||
import type { AdapterRuntimeServiceReport } from "@paperclipai/adapter-utils";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { executionWorkspaces, projectWorkspaces, workspaceRuntimeServices } from "@paperclipai/db";
|
||||
import {
|
||||
listWorkspaceServiceCommandDefinitions,
|
||||
type WorkspaceRuntimeDesiredState,
|
||||
type WorkspaceRuntimeServiceStateMap,
|
||||
} from "@paperclipai/shared";
|
||||
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||
import { asNumber, asString, parseObject, renderTemplate } from "../adapters/utils.js";
|
||||
import { resolveHomeAwarePath } from "../home-paths.js";
|
||||
@@ -26,7 +31,11 @@ import { readExecutionWorkspaceConfig } from "./execution-workspaces.js";
|
||||
import { readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js";
|
||||
|
||||
export function resolveShell(): string {
|
||||
return process.env.SHELL?.trim() || (process.platform === "win32" ? "sh" : "/bin/sh");
|
||||
const fallback = process.platform === "win32" ? "sh" : "/bin/sh";
|
||||
const shell = process.env.SHELL?.trim();
|
||||
if (!shell) return fallback;
|
||||
if (path.isAbsolute(shell) && !existsSync(shell)) return fallback;
|
||||
return shell;
|
||||
}
|
||||
|
||||
export interface ExecutionWorkspaceInput {
|
||||
@@ -604,6 +613,56 @@ async function directoryExists(value: string) {
|
||||
return fs.stat(value).then((stats) => stats.isDirectory()).catch(() => false);
|
||||
}
|
||||
|
||||
async function listLinkedGitWorktreePaths(repoRoot: string): Promise<Set<string>> {
|
||||
const output = await runGit(["worktree", "list", "--porcelain"], repoRoot);
|
||||
const paths = new Set<string>();
|
||||
for (const line of output.split("\n")) {
|
||||
if (!line.startsWith("worktree ")) continue;
|
||||
const worktree = line.slice("worktree ".length).trim();
|
||||
if (!worktree) continue;
|
||||
paths.add(path.resolve(worktree));
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
async function validateLinkedGitWorktree(input: {
|
||||
repoRoot: string;
|
||||
worktreePath: string;
|
||||
expectedBranchName: string | null;
|
||||
}): Promise<{ valid: true } | { valid: false; reason: string }> {
|
||||
const resolvedWorktreePath = path.resolve(input.worktreePath);
|
||||
const listedWorktrees = await listLinkedGitWorktreePaths(input.repoRoot);
|
||||
if (!listedWorktrees.has(resolvedWorktreePath)) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: "path is not registered in `git worktree list`",
|
||||
};
|
||||
}
|
||||
|
||||
const worktreeTopLevel = await runGit(["rev-parse", "--show-toplevel"], resolvedWorktreePath).catch(() => null);
|
||||
if (!worktreeTopLevel || path.resolve(worktreeTopLevel) !== resolvedWorktreePath) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: "git resolves this path to a different repository root",
|
||||
};
|
||||
}
|
||||
|
||||
if (input.expectedBranchName) {
|
||||
const currentBranch = await runGit(
|
||||
["symbolic-ref", "--quiet", "--short", "HEAD"],
|
||||
resolvedWorktreePath,
|
||||
).catch(() => null);
|
||||
if (currentBranch !== input.expectedBranchName) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: `worktree HEAD is on "${currentBranch ?? "<detached>"}" instead of "${input.expectedBranchName}"`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
function terminateChildProcess(child: ChildProcess) {
|
||||
if (!child.pid) return;
|
||||
if (process.platform !== "win32") {
|
||||
@@ -777,13 +836,13 @@ async function recordWorkspaceCommandOperation(
|
||||
) {
|
||||
if (!recorder) {
|
||||
await runWorkspaceCommand(input);
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let code: number | null = null;
|
||||
await recorder.recordOperation({
|
||||
const operation = await recorder.recordOperation({
|
||||
phase: input.phase,
|
||||
command: input.command,
|
||||
cwd: input.cwd,
|
||||
@@ -818,7 +877,7 @@ async function recordWorkspaceCommandOperation(
|
||||
},
|
||||
});
|
||||
|
||||
if (code === 0) return;
|
||||
if (code === 0) return operation;
|
||||
|
||||
const details = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n");
|
||||
throw new Error(
|
||||
@@ -1004,18 +1063,32 @@ export async function realizeExecutionWorkspace(input: {
|
||||
};
|
||||
}
|
||||
|
||||
async function validateReusableWorktree(reusablePath: string) {
|
||||
return await validateLinkedGitWorktree({
|
||||
repoRoot,
|
||||
worktreePath: reusablePath,
|
||||
expectedBranchName: branchName,
|
||||
}).catch(() => null);
|
||||
}
|
||||
|
||||
const existingWorktree = await directoryExists(worktreePath);
|
||||
if (existingWorktree && await isGitCheckout(worktreePath)) {
|
||||
return await reuseExistingWorktree(worktreePath);
|
||||
if (existingWorktree) {
|
||||
const validation = await validateReusableWorktree(worktreePath);
|
||||
if (validation?.valid) {
|
||||
return await reuseExistingWorktree(worktreePath);
|
||||
}
|
||||
const reason = validation && !validation.valid ? ` (${validation.reason})` : "";
|
||||
throw new Error(`Configured worktree path "${worktreePath}" already exists and is not a reusable git worktree${reason}.`);
|
||||
}
|
||||
|
||||
const registeredBranchWorktree = await findRegisteredGitWorktreeByBranch(repoRoot, branchName);
|
||||
if (registeredBranchWorktree && await isGitCheckout(registeredBranchWorktree)) {
|
||||
return await reuseExistingWorktree(registeredBranchWorktree);
|
||||
}
|
||||
|
||||
if (existingWorktree) {
|
||||
throw new Error(`Configured worktree path "${worktreePath}" already exists and is not a git worktree.`);
|
||||
if (registeredBranchWorktree) {
|
||||
const validation = await validateReusableWorktree(registeredBranchWorktree);
|
||||
if (validation?.valid) {
|
||||
return await reuseExistingWorktree(registeredBranchWorktree);
|
||||
}
|
||||
const reason = validation && !validation.valid ? ` (${validation.reason})` : "";
|
||||
throw new Error(`Registered worktree for branch "${branchName}" at "${registeredBranchWorktree}" is not reusable${reason}.`);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -1087,6 +1160,147 @@ export async function realizeExecutionWorkspace(input: {
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensurePersistedExecutionWorkspaceAvailable(input: {
|
||||
base: ExecutionWorkspaceInput;
|
||||
workspace: {
|
||||
mode: string | null | undefined;
|
||||
strategyType: string | null | undefined;
|
||||
cwd: string | null | undefined;
|
||||
providerRef: string | null | undefined;
|
||||
projectId: string | null | undefined;
|
||||
projectWorkspaceId: string | null | undefined;
|
||||
repoUrl: string | null | undefined;
|
||||
baseRef: string | null | undefined;
|
||||
branchName: string | null | undefined;
|
||||
config?: {
|
||||
provisionCommand?: string | null;
|
||||
} | null;
|
||||
};
|
||||
issue: ExecutionWorkspaceIssueRef | null;
|
||||
agent: ExecutionWorkspaceAgentRef;
|
||||
recorder?: WorkspaceOperationRecorder | null;
|
||||
}): Promise<RealizedExecutionWorkspace | null> {
|
||||
const cwd = asString(input.workspace.cwd ?? input.workspace.providerRef, "").trim();
|
||||
if (!cwd) return null;
|
||||
|
||||
const strategy = input.workspace.strategyType === "git_worktree" ? "git_worktree" : "project_primary";
|
||||
const realized: RealizedExecutionWorkspace = {
|
||||
baseCwd: input.base.baseCwd,
|
||||
source: input.workspace.mode === "shared_workspace" ? "project_primary" : "task_session",
|
||||
projectId: input.workspace.projectId ?? input.base.projectId,
|
||||
workspaceId: input.workspace.projectWorkspaceId ?? input.base.workspaceId,
|
||||
repoUrl: input.workspace.repoUrl ?? input.base.repoUrl,
|
||||
repoRef: input.workspace.baseRef ?? input.base.repoRef,
|
||||
strategy,
|
||||
cwd,
|
||||
branchName: input.workspace.branchName ?? null,
|
||||
worktreePath: strategy === "git_worktree" ? (input.workspace.providerRef ?? cwd) : null,
|
||||
warnings: [],
|
||||
created: false,
|
||||
};
|
||||
const provisionCommand = asString(input.workspace.config?.provisionCommand, "").trim();
|
||||
|
||||
if (strategy !== "git_worktree") {
|
||||
return realized;
|
||||
}
|
||||
if (await directoryExists(cwd)) {
|
||||
if (provisionCommand) {
|
||||
const repoRoot = await runGit(["rev-parse", "--show-toplevel"], input.base.baseCwd);
|
||||
await provisionExecutionWorktree({
|
||||
strategy: {
|
||||
type: "git_worktree",
|
||||
provisionCommand,
|
||||
},
|
||||
base: input.base,
|
||||
repoRoot,
|
||||
worktreePath: realized.worktreePath ?? cwd,
|
||||
branchName: realized.branchName ?? "",
|
||||
issue: input.issue,
|
||||
agent: input.agent,
|
||||
created: false,
|
||||
recorder: input.recorder ?? null,
|
||||
});
|
||||
}
|
||||
return realized;
|
||||
}
|
||||
|
||||
const repoRoot = await runGit(["rev-parse", "--show-toplevel"], input.base.baseCwd);
|
||||
const worktreePath = realized.worktreePath ?? cwd;
|
||||
const branchName = asString(input.workspace.branchName, "").trim();
|
||||
if (!branchName) {
|
||||
throw new Error(`Execution workspace "${cwd}" is missing and cannot be restored because no branch name is recorded.`);
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(worktreePath), { recursive: true });
|
||||
await runGit(["worktree", "prune"], repoRoot).catch(() => {});
|
||||
|
||||
let created = false;
|
||||
try {
|
||||
await recordGitOperation(input.recorder, {
|
||||
phase: "worktree_prepare",
|
||||
args: ["worktree", "add", worktreePath, branchName],
|
||||
cwd: repoRoot,
|
||||
metadata: {
|
||||
repoRoot,
|
||||
worktreePath,
|
||||
branchName,
|
||||
baseRef: input.workspace.baseRef ?? input.base.repoRef ?? null,
|
||||
created: false,
|
||||
restored: true,
|
||||
},
|
||||
successMessage: `Reattached missing git worktree at ${worktreePath}\n`,
|
||||
failureLabel: `git worktree add ${worktreePath}`,
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
!gitErrorIncludes(error, "invalid reference")
|
||||
&& !gitErrorIncludes(error, "not a commit")
|
||||
&& !gitErrorIncludes(error, "unknown revision")
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
const baseRef = input.workspace.baseRef ?? await detectDefaultBranch(repoRoot) ?? "HEAD";
|
||||
await recordGitOperation(input.recorder, {
|
||||
phase: "worktree_prepare",
|
||||
args: ["worktree", "add", "-b", branchName, worktreePath, baseRef],
|
||||
cwd: repoRoot,
|
||||
metadata: {
|
||||
repoRoot,
|
||||
worktreePath,
|
||||
branchName,
|
||||
baseRef,
|
||||
created: true,
|
||||
restored: true,
|
||||
},
|
||||
successMessage: `Recreated missing git worktree at ${worktreePath}\n`,
|
||||
failureLabel: `git worktree add ${worktreePath}`,
|
||||
});
|
||||
created = true;
|
||||
}
|
||||
|
||||
await provisionExecutionWorktree({
|
||||
strategy: {
|
||||
type: "git_worktree",
|
||||
...(provisionCommand ? { provisionCommand } : {}),
|
||||
},
|
||||
base: input.base,
|
||||
repoRoot,
|
||||
worktreePath,
|
||||
branchName,
|
||||
issue: input.issue,
|
||||
agent: input.agent,
|
||||
created,
|
||||
recorder: input.recorder ?? null,
|
||||
});
|
||||
|
||||
return {
|
||||
...realized,
|
||||
cwd: worktreePath,
|
||||
worktreePath,
|
||||
created,
|
||||
};
|
||||
}
|
||||
|
||||
export async function cleanupExecutionWorkspaceArtifacts(input: {
|
||||
workspace: {
|
||||
id: string;
|
||||
@@ -1380,6 +1594,83 @@ function resolveRuntimeServiceReuseIdentity(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveWorkspaceCommandExecution(input: {
|
||||
command: Record<string, unknown>;
|
||||
workspace: RealizedExecutionWorkspace;
|
||||
agent: ExecutionWorkspaceAgentRef;
|
||||
issue: ExecutionWorkspaceIssueRef | null;
|
||||
adapterEnv: Record<string, string>;
|
||||
}) {
|
||||
const name =
|
||||
asString(input.command.name, "")
|
||||
|| asString(input.command.label, "")
|
||||
|| asString(input.command.title, "")
|
||||
|| "workspace command";
|
||||
const command = asString(input.command.command, "");
|
||||
const templateData = buildTemplateData({
|
||||
workspace: input.workspace,
|
||||
agent: input.agent,
|
||||
issue: input.issue,
|
||||
adapterEnv: input.adapterEnv,
|
||||
port: null,
|
||||
});
|
||||
const cwd = resolveConfiguredPath(
|
||||
renderTemplate(asString(input.command.cwd, "."), templateData),
|
||||
input.workspace.cwd,
|
||||
);
|
||||
const env = {
|
||||
...sanitizeRuntimeServiceBaseEnv(process.env),
|
||||
...input.adapterEnv,
|
||||
...renderRuntimeServiceEnv({
|
||||
envConfig: parseObject(input.command.env),
|
||||
templateData,
|
||||
}),
|
||||
} as Record<string, string>;
|
||||
|
||||
return {
|
||||
name,
|
||||
command,
|
||||
cwd,
|
||||
env,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runWorkspaceJobForControl(input: {
|
||||
actor: ExecutionWorkspaceAgentRef;
|
||||
issue: ExecutionWorkspaceIssueRef | null;
|
||||
workspace: RealizedExecutionWorkspace;
|
||||
command: Record<string, unknown>;
|
||||
adapterEnv?: Record<string, string>;
|
||||
recorder?: WorkspaceOperationRecorder | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}) {
|
||||
const resolved = resolveWorkspaceCommandExecution({
|
||||
command: input.command,
|
||||
workspace: input.workspace,
|
||||
agent: input.actor,
|
||||
issue: input.issue,
|
||||
adapterEnv: input.adapterEnv ?? {},
|
||||
});
|
||||
if (!resolved.command) {
|
||||
throw new Error(`Workspace job "${resolved.name}" is missing command`);
|
||||
}
|
||||
|
||||
await ensureServerWorkspaceLinksCurrent(resolved.cwd);
|
||||
return await recordWorkspaceCommandOperation(input.recorder, {
|
||||
phase: "workspace_provision",
|
||||
command: resolved.command,
|
||||
cwd: resolved.cwd,
|
||||
env: resolved.env,
|
||||
label: `Workspace job "${resolved.name}"`,
|
||||
metadata: {
|
||||
workspaceCommandKind: "job",
|
||||
workspaceCommandName: resolved.name,
|
||||
...(input.metadata ?? {}),
|
||||
},
|
||||
successMessage: `Completed workspace job "${resolved.name}"\n`,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveServiceScopeId(input: {
|
||||
service: Record<string, unknown>;
|
||||
workspace: RealizedExecutionWorkspace;
|
||||
@@ -1406,6 +1697,21 @@ function resolveServiceScopeId(input: {
|
||||
return { scopeType: "run" as const, scopeId: input.runId };
|
||||
}
|
||||
|
||||
function looksLikeWorkspaceDevServerCommand(command: string) {
|
||||
const normalized = command.trim().toLowerCase();
|
||||
if (!normalized) return false;
|
||||
return /(?:^|\s)(?:pnpm|npm|yarn|bun)\s+(?:run\s+)?dev(?:\s|$)/.test(normalized);
|
||||
}
|
||||
|
||||
export function resolveWorkspaceRuntimeReadinessTimeoutSec(service: Record<string, unknown>) {
|
||||
const readiness = parseObject(service.readiness);
|
||||
const explicitTimeoutSec = asNumber(readiness.timeoutSec, 0);
|
||||
if (explicitTimeoutSec > 0) {
|
||||
return Math.max(1, explicitTimeoutSec);
|
||||
}
|
||||
return looksLikeWorkspaceDevServerCommand(asString(service.command, "")) ? 90 : 30;
|
||||
}
|
||||
|
||||
async function waitForReadiness(input: {
|
||||
service: Record<string, unknown>;
|
||||
url: string | null;
|
||||
@@ -1413,7 +1719,7 @@ async function waitForReadiness(input: {
|
||||
const readiness = parseObject(input.service.readiness);
|
||||
const readinessType = asString(readiness.type, "");
|
||||
if (readinessType !== "http" || !input.url) return;
|
||||
const timeoutSec = Math.max(1, asNumber(readiness.timeoutSec, 30));
|
||||
const timeoutSec = resolveWorkspaceRuntimeReadinessTimeoutSec(input.service);
|
||||
const intervalMs = Math.max(100, asNumber(readiness.intervalMs, 500));
|
||||
const deadline = Date.now() + timeoutSec * 1000;
|
||||
let lastError = "service did not become ready";
|
||||
@@ -1735,6 +2041,11 @@ async function startLocalRuntimeService(input: {
|
||||
detached: process.platform !== "win32",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const spawnErrorPromise = new Promise<never>((_, reject) => {
|
||||
child.once("error", (err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
let stderrExcerpt = "";
|
||||
let stdoutExcerpt = "";
|
||||
child.stdout?.on("data", async (chunk) => {
|
||||
@@ -1749,7 +2060,10 @@ async function startLocalRuntimeService(input: {
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForReadiness({ service: input.service, url });
|
||||
await Promise.race([
|
||||
waitForReadiness({ service: input.service, url }),
|
||||
spawnErrorPromise,
|
||||
]);
|
||||
} catch (err) {
|
||||
terminateChildProcess(child);
|
||||
throw new Error(
|
||||
@@ -1913,10 +2227,78 @@ function registerRuntimeService(db: Db | undefined, record: RuntimeServiceRecord
|
||||
}
|
||||
|
||||
function readRuntimeServiceEntries(config: Record<string, unknown>) {
|
||||
const runtime = parseObject(config.workspaceRuntime);
|
||||
return Array.isArray(runtime.services)
|
||||
? runtime.services.filter((entry): entry is Record<string, unknown> => typeof entry === "object" && entry !== null)
|
||||
: [];
|
||||
return listWorkspaceServiceCommandDefinitions(parseObject(config.workspaceRuntime))
|
||||
.map((command) => command.rawConfig);
|
||||
}
|
||||
|
||||
export function listConfiguredRuntimeServiceEntries(config: Record<string, unknown>) {
|
||||
return readRuntimeServiceEntries(config);
|
||||
}
|
||||
|
||||
function readConfiguredServiceStates(config: Record<string, unknown>) {
|
||||
const raw = parseObject(config.serviceStates);
|
||||
const states: WorkspaceRuntimeServiceStateMap = {};
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
if (value === "running" || value === "stopped") {
|
||||
states[key] = value;
|
||||
}
|
||||
}
|
||||
return states;
|
||||
}
|
||||
|
||||
export function buildWorkspaceRuntimeDesiredStatePatch(input: {
|
||||
config: Record<string, unknown>;
|
||||
currentDesiredState: WorkspaceRuntimeDesiredState | null;
|
||||
currentServiceStates: WorkspaceRuntimeServiceStateMap | null | undefined;
|
||||
action: "start" | "stop" | "restart";
|
||||
serviceIndex?: number | null;
|
||||
}): {
|
||||
desiredState: WorkspaceRuntimeDesiredState;
|
||||
serviceStates: WorkspaceRuntimeServiceStateMap | null;
|
||||
} {
|
||||
const configuredServices = listConfiguredRuntimeServiceEntries(input.config);
|
||||
const fallbackState: WorkspaceRuntimeDesiredState = input.currentDesiredState === "running" ? "running" : "stopped";
|
||||
const nextServiceStates: WorkspaceRuntimeServiceStateMap = {};
|
||||
|
||||
for (let index = 0; index < configuredServices.length; index += 1) {
|
||||
nextServiceStates[String(index)] = input.currentServiceStates?.[String(index)] ?? fallbackState;
|
||||
}
|
||||
|
||||
const nextState: WorkspaceRuntimeDesiredState = input.action === "stop" ? "stopped" : "running";
|
||||
if (input.serviceIndex === undefined || input.serviceIndex === null) {
|
||||
for (let index = 0; index < configuredServices.length; index += 1) {
|
||||
nextServiceStates[String(index)] = nextState;
|
||||
}
|
||||
} else if (input.serviceIndex >= 0 && input.serviceIndex < configuredServices.length) {
|
||||
nextServiceStates[String(input.serviceIndex)] = nextState;
|
||||
}
|
||||
|
||||
const desiredState = Object.values(nextServiceStates).some((state) => state === "running") ? "running" : "stopped";
|
||||
|
||||
return {
|
||||
desiredState,
|
||||
serviceStates: Object.keys(nextServiceStates).length > 0 ? nextServiceStates : null,
|
||||
};
|
||||
}
|
||||
|
||||
function selectRuntimeServiceEntries(input: {
|
||||
config: Record<string, unknown>;
|
||||
serviceIndex?: number | null;
|
||||
respectDesiredStates?: boolean;
|
||||
defaultDesiredState?: WorkspaceRuntimeDesiredState | null;
|
||||
serviceStates?: WorkspaceRuntimeServiceStateMap | null;
|
||||
}) {
|
||||
const entries = listConfiguredRuntimeServiceEntries(input.config);
|
||||
const states = input.serviceStates ?? readConfiguredServiceStates(input.config);
|
||||
const fallbackState: WorkspaceRuntimeDesiredState = input.defaultDesiredState === "running" ? "running" : "stopped";
|
||||
|
||||
return entries.filter((_, index) => {
|
||||
if (input.serviceIndex !== undefined && input.serviceIndex !== null) {
|
||||
return index === input.serviceIndex;
|
||||
}
|
||||
if (!input.respectDesiredStates) return true;
|
||||
return (states[String(index)] ?? fallbackState) === "running";
|
||||
});
|
||||
}
|
||||
|
||||
export async function ensureRuntimeServicesForRun(input: {
|
||||
@@ -2011,8 +2393,16 @@ export async function startRuntimeServicesForWorkspaceControl(input: {
|
||||
config: Record<string, unknown>;
|
||||
adapterEnv: Record<string, string>;
|
||||
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||
serviceIndex?: number | null;
|
||||
respectDesiredStates?: boolean;
|
||||
}): Promise<RuntimeServiceRef[]> {
|
||||
const rawServices = readRuntimeServiceEntries(input.config);
|
||||
const rawServices = selectRuntimeServiceEntries({
|
||||
config: input.config,
|
||||
serviceIndex: input.serviceIndex,
|
||||
respectDesiredStates: input.respectDesiredStates,
|
||||
defaultDesiredState: input.config.desiredState === "running" ? "running" : "stopped",
|
||||
serviceStates: readConfiguredServiceStates(input.config),
|
||||
});
|
||||
const refs: RuntimeServiceRef[] = [];
|
||||
const invocationId = input.invocationId ?? randomUUID();
|
||||
|
||||
@@ -2102,10 +2492,12 @@ export async function stopRuntimeServicesForExecutionWorkspace(input: {
|
||||
db?: Db;
|
||||
executionWorkspaceId: string;
|
||||
workspaceCwd?: string | null;
|
||||
runtimeServiceId?: string | null;
|
||||
}) {
|
||||
const normalizedWorkspaceCwd = input.workspaceCwd ? path.resolve(input.workspaceCwd) : null;
|
||||
const matchingServiceIds = Array.from(runtimeServicesById.values())
|
||||
.filter((record) => {
|
||||
if (input.runtimeServiceId) return record.id === input.runtimeServiceId;
|
||||
if (record.executionWorkspaceId === input.executionWorkspaceId) return true;
|
||||
if (!normalizedWorkspaceCwd || !record.cwd) return false;
|
||||
const resolvedCwd = path.resolve(record.cwd);
|
||||
@@ -2121,19 +2513,37 @@ export async function stopRuntimeServicesForExecutionWorkspace(input: {
|
||||
}
|
||||
|
||||
if (input.db) {
|
||||
await markPersistedRuntimeServicesStoppedForExecutionWorkspace({
|
||||
db: input.db,
|
||||
executionWorkspaceId: input.executionWorkspaceId,
|
||||
});
|
||||
if (input.runtimeServiceId) {
|
||||
const now = new Date();
|
||||
await input.db
|
||||
.update(workspaceRuntimeServices)
|
||||
.set({
|
||||
status: "stopped",
|
||||
healthStatus: "unknown",
|
||||
stoppedAt: now,
|
||||
lastUsedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(workspaceRuntimeServices.id, input.runtimeServiceId));
|
||||
} else {
|
||||
await markPersistedRuntimeServicesStoppedForExecutionWorkspace({
|
||||
db: input.db,
|
||||
executionWorkspaceId: input.executionWorkspaceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopRuntimeServicesForProjectWorkspace(input: {
|
||||
db?: Db;
|
||||
projectWorkspaceId: string;
|
||||
runtimeServiceId?: string | null;
|
||||
}) {
|
||||
const matchingServiceIds = Array.from(runtimeServicesById.values())
|
||||
.filter((record) => record.projectWorkspaceId === input.projectWorkspaceId && record.scopeType === "project_workspace")
|
||||
.filter((record) => {
|
||||
if (input.runtimeServiceId) return record.id === input.runtimeServiceId;
|
||||
return record.projectWorkspaceId === input.projectWorkspaceId && record.scopeType === "project_workspace";
|
||||
})
|
||||
.map((record) => record.id);
|
||||
|
||||
for (const serviceId of matchingServiceIds) {
|
||||
@@ -2152,11 +2562,13 @@ export async function stopRuntimeServicesForProjectWorkspace(input: {
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceRuntimeServices.projectWorkspaceId, input.projectWorkspaceId),
|
||||
eq(workspaceRuntimeServices.scopeType, "project_workspace"),
|
||||
inArray(workspaceRuntimeServices.status, ["starting", "running"]),
|
||||
),
|
||||
input.runtimeServiceId
|
||||
? eq(workspaceRuntimeServices.id, input.runtimeServiceId)
|
||||
: and(
|
||||
eq(workspaceRuntimeServices.projectWorkspaceId, input.projectWorkspaceId),
|
||||
eq(workspaceRuntimeServices.scopeType, "project_workspace"),
|
||||
inArray(workspaceRuntimeServices.status, ["starting", "running"]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2292,6 +2704,7 @@ export async function restartDesiredRuntimeServicesOnStartup(db: Db) {
|
||||
const projectWorkspaceRows = await db
|
||||
.select()
|
||||
.from(projectWorkspaces);
|
||||
const projectWorkspaceRowsById = new Map(projectWorkspaceRows.map((row) => [row.id, row] as const));
|
||||
|
||||
for (const row of projectWorkspaceRows) {
|
||||
const runtimeConfig = readProjectWorkspaceRuntimeConfig((row.metadata as Record<string, unknown> | null) ?? null);
|
||||
@@ -2316,8 +2729,13 @@ export async function restartDesiredRuntimeServicesOnStartup(db: Db) {
|
||||
warnings: [],
|
||||
created: false,
|
||||
},
|
||||
config: { workspaceRuntime: runtimeConfig.workspaceRuntime },
|
||||
config: {
|
||||
workspaceRuntime: runtimeConfig.workspaceRuntime,
|
||||
desiredState: runtimeConfig.desiredState,
|
||||
serviceStates: runtimeConfig.serviceStates ?? null,
|
||||
},
|
||||
adapterEnv: {},
|
||||
respectDesiredStates: true,
|
||||
});
|
||||
if (refs.length > 0) restarted += refs.filter((ref) => !ref.reused).length;
|
||||
} catch {
|
||||
@@ -2332,7 +2750,13 @@ export async function restartDesiredRuntimeServicesOnStartup(db: Db) {
|
||||
|
||||
for (const row of executionWorkspaceRows) {
|
||||
const config = readExecutionWorkspaceConfig((row.metadata as Record<string, unknown> | null) ?? null);
|
||||
if (config?.desiredState !== "running" || !config.workspaceRuntime || !row.cwd) continue;
|
||||
const inheritedRuntimeConfig = row.projectWorkspaceId
|
||||
? readProjectWorkspaceRuntimeConfig(
|
||||
(projectWorkspaceRowsById.get(row.projectWorkspaceId)?.metadata as Record<string, unknown> | null) ?? null,
|
||||
)?.workspaceRuntime ?? null
|
||||
: null;
|
||||
const effectiveRuntimeConfig = config?.workspaceRuntime ?? inheritedRuntimeConfig;
|
||||
if (config?.desiredState !== "running" || !effectiveRuntimeConfig || !row.cwd) continue;
|
||||
|
||||
try {
|
||||
const refs = await startRuntimeServicesForWorkspaceControl({
|
||||
@@ -2360,8 +2784,13 @@ export async function restartDesiredRuntimeServicesOnStartup(db: Db) {
|
||||
created: false,
|
||||
},
|
||||
executionWorkspaceId: row.id,
|
||||
config: { workspaceRuntime: config.workspaceRuntime },
|
||||
config: {
|
||||
workspaceRuntime: effectiveRuntimeConfig,
|
||||
desiredState: config.desiredState,
|
||||
serviceStates: config.serviceStates ?? null,
|
||||
},
|
||||
adapterEnv: {},
|
||||
respectDesiredStates: true,
|
||||
});
|
||||
if (refs.length > 0) restarted += refs.filter((ref) => !ref.reused).length;
|
||||
} catch {
|
||||
|
||||
Reference in New Issue
Block a user