From f6f5fee200ae79fdc62683664eb28a23dd5bcede Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Mar 2026 15:54:31 +0200 Subject: [PATCH 1/7] fix: wire parentId query filter into issues list endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parentId parameter on GET /api/companies/:companyId/issues was silently ignored — the filter was never extracted from the query string, never passed to the service layer, and the IssueFilters type did not include it. All other filters (status, assigneeAgentId, projectId, etc.) worked correctly. This caused subtask lookups to return every issue in the company instead of only children of the specified parent. Changes: - Add parentId to IssueFilters interface - Add eq(issues.parentId, filters.parentId) condition in list() - Extract parentId from req.query in the route handler Fixes: LAS-101 --- server/src/routes/issues.ts | 1 + server/src/services/issues.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index e4035dfc..c5c4e29d 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -230,6 +230,7 @@ export function issueRoutes(db: Db, storage: StorageService) { touchedByUserId, unreadForUserId, projectId: req.query.projectId as string | undefined, + parentId: req.query.parentId as string | undefined, labelId: req.query.labelId as string | undefined, q: req.query.q as string | undefined, }); diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index cb258e23..8f34be18 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -53,6 +53,7 @@ export interface IssueFilters { touchedByUserId?: string; unreadForUserId?: string; projectId?: string; + parentId?: string; labelId?: string; q?: string; } @@ -458,6 +459,7 @@ export function issueService(db: Db) { conditions.push(unreadForUserCondition(companyId, unreadForUserId)); } if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId)); + if (filters?.parentId) conditions.push(eq(issues.parentId, filters.parentId)); if (filters?.labelId) { const labeledIssueIds = await db .select({ issueId: issueLabels.issueId }) From dec02225f19d9114246629793e1880575a771dc2 Mon Sep 17 00:00:00 2001 From: Subhendu Kundu Date: Tue, 10 Mar 2026 19:40:22 +0530 Subject: [PATCH 2/7] feat: make attachment content types configurable via env var Add PAPERCLIP_ALLOWED_ATTACHMENT_TYPES env var to configure allowed MIME types for issue attachments and asset uploads. Supports exact types (application/pdf) and wildcard patterns (image/*, text/*). Falls back to the existing image-only defaults when the env var is unset, preserving backward compatibility. - Extract shared module `attachment-types.ts` with `isAllowedContentType()` and `matchesContentType()` (pure, testable) - Update `routes/issues.ts` and `routes/assets.ts` to use shared module - Add unit tests for parsing and wildcard matching Closes #487 --- server/src/__tests__/attachment-types.test.ts | 89 +++++++++++++++++++ server/src/attachment-types.ts | 67 ++++++++++++++ server/src/routes/assets.ts | 16 +--- server/src/routes/issues.ts | 12 +-- 4 files changed, 162 insertions(+), 22 deletions(-) create mode 100644 server/src/__tests__/attachment-types.test.ts create mode 100644 server/src/attachment-types.ts diff --git a/server/src/__tests__/attachment-types.test.ts b/server/src/__tests__/attachment-types.test.ts new file mode 100644 index 00000000..af0a58b3 --- /dev/null +++ b/server/src/__tests__/attachment-types.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from "vitest"; +import { + parseAllowedTypes, + matchesContentType, + DEFAULT_ALLOWED_TYPES, +} from "../attachment-types.js"; + +describe("parseAllowedTypes", () => { + it("returns default image types when input is undefined", () => { + expect(parseAllowedTypes(undefined)).toEqual([...DEFAULT_ALLOWED_TYPES]); + }); + + it("returns default image types when input is empty string", () => { + expect(parseAllowedTypes("")).toEqual([...DEFAULT_ALLOWED_TYPES]); + }); + + it("parses comma-separated types", () => { + expect(parseAllowedTypes("image/*,application/pdf")).toEqual([ + "image/*", + "application/pdf", + ]); + }); + + it("trims whitespace", () => { + expect(parseAllowedTypes(" image/png , application/pdf ")).toEqual([ + "image/png", + "application/pdf", + ]); + }); + + it("lowercases entries", () => { + expect(parseAllowedTypes("Application/PDF")).toEqual(["application/pdf"]); + }); + + it("filters empty segments", () => { + expect(parseAllowedTypes("image/png,,application/pdf,")).toEqual([ + "image/png", + "application/pdf", + ]); + }); +}); + +describe("matchesContentType", () => { + it("matches exact types", () => { + const patterns = ["application/pdf", "image/png"]; + expect(matchesContentType("application/pdf", patterns)).toBe(true); + expect(matchesContentType("image/png", patterns)).toBe(true); + expect(matchesContentType("text/plain", patterns)).toBe(false); + }); + + it("matches /* wildcard patterns", () => { + const patterns = ["image/*"]; + expect(matchesContentType("image/png", patterns)).toBe(true); + expect(matchesContentType("image/jpeg", patterns)).toBe(true); + expect(matchesContentType("image/svg+xml", patterns)).toBe(true); + expect(matchesContentType("application/pdf", patterns)).toBe(false); + }); + + it("matches .* wildcard patterns", () => { + const patterns = ["application/vnd.openxmlformats-officedocument.*"]; + expect( + matchesContentType( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + patterns, + ), + ).toBe(true); + expect( + matchesContentType( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + patterns, + ), + ).toBe(true); + expect(matchesContentType("application/pdf", patterns)).toBe(false); + }); + + it("is case-insensitive", () => { + const patterns = ["application/pdf"]; + expect(matchesContentType("APPLICATION/PDF", patterns)).toBe(true); + expect(matchesContentType("Application/Pdf", patterns)).toBe(true); + }); + + it("combines exact and wildcard patterns", () => { + const patterns = ["image/*", "application/pdf", "text/*"]; + expect(matchesContentType("image/webp", patterns)).toBe(true); + expect(matchesContentType("application/pdf", patterns)).toBe(true); + expect(matchesContentType("text/csv", patterns)).toBe(true); + expect(matchesContentType("application/zip", patterns)).toBe(false); + }); +}); diff --git a/server/src/attachment-types.ts b/server/src/attachment-types.ts new file mode 100644 index 00000000..3f95156b --- /dev/null +++ b/server/src/attachment-types.ts @@ -0,0 +1,67 @@ +/** + * Shared attachment content-type configuration. + * + * By default only image types are allowed. Set the + * `PAPERCLIP_ALLOWED_ATTACHMENT_TYPES` environment variable to a + * comma-separated list of MIME types or wildcard patterns to expand the + * allowed set. + * + * Examples: + * PAPERCLIP_ALLOWED_ATTACHMENT_TYPES=image/*,application/pdf + * PAPERCLIP_ALLOWED_ATTACHMENT_TYPES=image/*,application/pdf,text/* + * + * Supported pattern syntax: + * - Exact types: "application/pdf" + * - Wildcards: "image/*" or "application/vnd.openxmlformats-officedocument.*" + */ + +export const DEFAULT_ALLOWED_TYPES: readonly string[] = [ + "image/png", + "image/jpeg", + "image/jpg", + "image/webp", + "image/gif", +]; + +/** + * Parse a comma-separated list of MIME type patterns into a normalised array. + * Returns the default image-only list when the input is empty or undefined. + */ +export function parseAllowedTypes(raw: string | undefined): string[] { + if (!raw) return [...DEFAULT_ALLOWED_TYPES]; + const parsed = raw + .split(",") + .map((s) => s.trim().toLowerCase()) + .filter((s) => s.length > 0); + return parsed.length > 0 ? parsed : [...DEFAULT_ALLOWED_TYPES]; +} + +/** + * Check whether `contentType` matches any entry in `allowedPatterns`. + * + * Supports exact matches ("application/pdf") and wildcard / prefix + * patterns ("image/*", "application/vnd.openxmlformats-officedocument.*"). + */ +export function matchesContentType(contentType: string, allowedPatterns: string[]): boolean { + const ct = contentType.toLowerCase(); + return allowedPatterns.some((pattern) => { + if (pattern.endsWith("/*") || pattern.endsWith(".*")) { + return ct.startsWith(pattern.slice(0, -1)); + } + return ct === pattern; + }); +} + +// ---------- Module-level singletons read once at startup ---------- + +const allowedPatterns: string[] = parseAllowedTypes( + process.env.PAPERCLIP_ALLOWED_ATTACHMENT_TYPES, +); + +/** Convenience wrapper using the process-level allowed list. */ +export function isAllowedContentType(contentType: string): boolean { + return matchesContentType(contentType, allowedPatterns); +} + +export const MAX_ATTACHMENT_BYTES = + Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024; diff --git a/server/src/routes/assets.ts b/server/src/routes/assets.ts index cde29ada..4c9847fe 100644 --- a/server/src/routes/assets.ts +++ b/server/src/routes/assets.ts @@ -5,22 +5,14 @@ import { createAssetImageMetadataSchema } from "@paperclipai/shared"; import type { StorageService } from "../storage/types.js"; import { assetService, logActivity } from "../services/index.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; - -const MAX_ASSET_IMAGE_BYTES = Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024; -const ALLOWED_IMAGE_CONTENT_TYPES = new Set([ - "image/png", - "image/jpeg", - "image/jpg", - "image/webp", - "image/gif", -]); +import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js"; export function assetRoutes(db: Db, storage: StorageService) { const router = Router(); const svc = assetService(db); const upload = multer({ storage: multer.memoryStorage(), - limits: { fileSize: MAX_ASSET_IMAGE_BYTES, files: 1 }, + limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 }, }); async function runSingleFileUpload(req: Request, res: Response) { @@ -41,7 +33,7 @@ export function assetRoutes(db: Db, storage: StorageService) { } catch (err) { if (err instanceof multer.MulterError) { if (err.code === "LIMIT_FILE_SIZE") { - res.status(422).json({ error: `Image exceeds ${MAX_ASSET_IMAGE_BYTES} bytes` }); + res.status(422).json({ error: `Image exceeds ${MAX_ATTACHMENT_BYTES} bytes` }); return; } res.status(400).json({ error: err.message }); @@ -57,7 +49,7 @@ export function assetRoutes(db: Db, storage: StorageService) { } const contentType = (file.mimetype || "").toLowerCase(); - if (!ALLOWED_IMAGE_CONTENT_TYPES.has(contentType)) { + if (!isAllowedContentType(contentType)) { res.status(422).json({ error: `Unsupported image type: ${contentType || "unknown"}` }); return; } diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index e4035dfc..8e398afc 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -26,15 +26,7 @@ import { logger } from "../middleware/logger.js"; import { forbidden, HttpError, unauthorized } from "../errors.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js"; - -const MAX_ATTACHMENT_BYTES = Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024; -const ALLOWED_ATTACHMENT_CONTENT_TYPES = new Set([ - "image/png", - "image/jpeg", - "image/jpg", - "image/webp", - "image/gif", -]); +import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js"; export function issueRoutes(db: Db, storage: StorageService) { const router = Router(); @@ -1067,7 +1059,7 @@ export function issueRoutes(db: Db, storage: StorageService) { return; } const contentType = (file.mimetype || "").toLowerCase(); - if (!ALLOWED_ATTACHMENT_CONTENT_TYPES.has(contentType)) { + if (!isAllowedContentType(contentType)) { res.status(422).json({ error: `Unsupported attachment type: ${contentType || "unknown"}` }); return; } From 3ff07c23d213eb5b1c8a5841b55e34dd387c4981 Mon Sep 17 00:00:00 2001 From: Subhendu Kundu Date: Tue, 10 Mar 2026 19:54:42 +0530 Subject: [PATCH 3/7] Update server/src/routes/assets.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- server/src/routes/assets.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/routes/assets.ts b/server/src/routes/assets.ts index 4c9847fe..ed8d5944 100644 --- a/server/src/routes/assets.ts +++ b/server/src/routes/assets.ts @@ -50,7 +50,7 @@ export function assetRoutes(db: Db, storage: StorageService) { const contentType = (file.mimetype || "").toLowerCase(); if (!isAllowedContentType(contentType)) { - res.status(422).json({ error: `Unsupported image type: ${contentType || "unknown"}` }); + res.status(422).json({ error: `Unsupported file type: ${contentType || "unknown"}` }); return; } if (file.buffer.length <= 0) { From 1959badde721a07c4e51a8fa05d84602959780d9 Mon Sep 17 00:00:00 2001 From: Subhendu Kundu Date: Tue, 10 Mar 2026 20:01:08 +0530 Subject: [PATCH 4/7] =?UTF-8?q?fix:=20address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20stale=20error=20message=20and=20*=20wildcard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - assets.ts: change "Image exceeds" to "File exceeds" in size-limit error - attachment-types.ts: handle plain "*" as allow-all wildcard pattern - Add test for "*" wildcard (12 tests total) --- server/src/__tests__/attachment-types.test.ts | 8 ++++++++ server/src/attachment-types.ts | 1 + server/src/routes/assets.ts | 2 +- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/server/src/__tests__/attachment-types.test.ts b/server/src/__tests__/attachment-types.test.ts index af0a58b3..5a430102 100644 --- a/server/src/__tests__/attachment-types.test.ts +++ b/server/src/__tests__/attachment-types.test.ts @@ -86,4 +86,12 @@ describe("matchesContentType", () => { expect(matchesContentType("text/csv", patterns)).toBe(true); expect(matchesContentType("application/zip", patterns)).toBe(false); }); + + it("handles plain * as allow-all wildcard", () => { + const patterns = ["*"]; + expect(matchesContentType("image/png", patterns)).toBe(true); + expect(matchesContentType("application/pdf", patterns)).toBe(true); + expect(matchesContentType("text/plain", patterns)).toBe(true); + expect(matchesContentType("application/zip", patterns)).toBe(true); + }); }); diff --git a/server/src/attachment-types.ts b/server/src/attachment-types.ts index 3f95156b..f9625de1 100644 --- a/server/src/attachment-types.ts +++ b/server/src/attachment-types.ts @@ -45,6 +45,7 @@ export function parseAllowedTypes(raw: string | undefined): string[] { export function matchesContentType(contentType: string, allowedPatterns: string[]): boolean { const ct = contentType.toLowerCase(); return allowedPatterns.some((pattern) => { + if (pattern === "*") return true; if (pattern.endsWith("/*") || pattern.endsWith(".*")) { return ct.startsWith(pattern.slice(0, -1)); } diff --git a/server/src/routes/assets.ts b/server/src/routes/assets.ts index ed8d5944..bd2f154d 100644 --- a/server/src/routes/assets.ts +++ b/server/src/routes/assets.ts @@ -33,7 +33,7 @@ export function assetRoutes(db: Db, storage: StorageService) { } catch (err) { if (err instanceof multer.MulterError) { if (err.code === "LIMIT_FILE_SIZE") { - res.status(422).json({ error: `Image exceeds ${MAX_ATTACHMENT_BYTES} bytes` }); + res.status(422).json({ error: `File exceeds ${MAX_ATTACHMENT_BYTES} bytes` }); return; } res.status(400).json({ error: err.message }); From 9c68c1b80b398b76c7924a092fa59a94bfe00139 Mon Sep 17 00:00:00 2001 From: David Ahmann Date: Tue, 10 Mar 2026 12:00:29 -0400 Subject: [PATCH 5/7] server: make approval retries idempotent (#499) --- .../approval-routes-idempotency.test.ts | 110 +++++++++++ .../src/__tests__/approvals-service.test.ts | 110 +++++++++++ server/src/routes/approvals.ts | 185 ++++++++++-------- server/src/services/approvals.ts | 106 ++++++---- 4 files changed, 384 insertions(+), 127 deletions(-) create mode 100644 server/src/__tests__/approval-routes-idempotency.test.ts create mode 100644 server/src/__tests__/approvals-service.test.ts diff --git a/server/src/__tests__/approval-routes-idempotency.test.ts b/server/src/__tests__/approval-routes-idempotency.test.ts new file mode 100644 index 00000000..51181ff5 --- /dev/null +++ b/server/src/__tests__/approval-routes-idempotency.test.ts @@ -0,0 +1,110 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { approvalRoutes } from "../routes/approvals.js"; +import { errorHandler } from "../middleware/index.js"; + +const mockApprovalService = vi.hoisted(() => ({ + list: vi.fn(), + getById: vi.fn(), + create: vi.fn(), + approve: vi.fn(), + reject: vi.fn(), + requestRevision: vi.fn(), + resubmit: vi.fn(), + listComments: vi.fn(), + addComment: vi.fn(), +})); + +const mockHeartbeatService = vi.hoisted(() => ({ + wakeup: vi.fn(), +})); + +const mockIssueApprovalService = vi.hoisted(() => ({ + listIssuesForApproval: vi.fn(), + linkManyForApproval: vi.fn(), +})); + +const mockSecretService = vi.hoisted(() => ({ + normalizeHireApprovalPayloadForPersistence: vi.fn(), +})); + +const mockLogActivity = vi.hoisted(() => vi.fn()); + +vi.mock("../services/index.js", () => ({ + approvalService: () => mockApprovalService, + heartbeatService: () => mockHeartbeatService, + issueApprovalService: () => mockIssueApprovalService, + logActivity: mockLogActivity, + secretService: () => mockSecretService, +})); + +function createApp() { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + userId: "user-1", + companyIds: ["company-1"], + source: "session", + isInstanceAdmin: false, + }; + next(); + }); + app.use("/api", approvalRoutes({} as any)); + app.use(errorHandler); + return app; +} + +describe("approval routes idempotent retries", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockHeartbeatService.wakeup.mockResolvedValue({ id: "wake-1" }); + mockIssueApprovalService.listIssuesForApproval.mockResolvedValue([{ id: "issue-1" }]); + mockLogActivity.mockResolvedValue(undefined); + }); + + it("does not emit duplicate approval side effects when approve is already resolved", async () => { + mockApprovalService.approve.mockResolvedValue({ + approval: { + id: "approval-1", + companyId: "company-1", + type: "hire_agent", + status: "approved", + payload: {}, + requestedByAgentId: "agent-1", + }, + applied: false, + }); + + const res = await request(createApp()) + .post("/api/approvals/approval-1/approve") + .send({}); + + expect(res.status).toBe(200); + expect(mockIssueApprovalService.listIssuesForApproval).not.toHaveBeenCalled(); + expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); + expect(mockLogActivity).not.toHaveBeenCalled(); + }); + + it("does not emit duplicate rejection logs when reject is already resolved", async () => { + mockApprovalService.reject.mockResolvedValue({ + approval: { + id: "approval-1", + companyId: "company-1", + type: "hire_agent", + status: "rejected", + payload: {}, + }, + applied: false, + }); + + const res = await request(createApp()) + .post("/api/approvals/approval-1/reject") + .send({}); + + expect(res.status).toBe(200); + expect(mockLogActivity).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/__tests__/approvals-service.test.ts b/server/src/__tests__/approvals-service.test.ts new file mode 100644 index 00000000..967fd295 --- /dev/null +++ b/server/src/__tests__/approvals-service.test.ts @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { approvalService } from "../services/approvals.js"; + +const mockAgentService = vi.hoisted(() => ({ + activatePendingApproval: vi.fn(), + create: vi.fn(), + terminate: vi.fn(), +})); + +const mockNotifyHireApproved = vi.hoisted(() => vi.fn()); + +vi.mock("../services/agents.js", () => ({ + agentService: vi.fn(() => mockAgentService), +})); + +vi.mock("../services/hire-hook.js", () => ({ + notifyHireApproved: mockNotifyHireApproved, +})); + +type ApprovalRecord = { + id: string; + companyId: string; + type: string; + status: string; + payload: Record; + requestedByAgentId: string | null; +}; + +function createApproval(status: string): ApprovalRecord { + return { + id: "approval-1", + companyId: "company-1", + type: "hire_agent", + status, + payload: { agentId: "agent-1" }, + requestedByAgentId: "requester-1", + }; +} + +function createDbStub(selectResults: ApprovalRecord[][], updateResults: ApprovalRecord[]) { + const selectWhere = vi.fn(); + for (const result of selectResults) { + selectWhere.mockResolvedValueOnce(result); + } + + const from = vi.fn(() => ({ where: selectWhere })); + const select = vi.fn(() => ({ from })); + + const returning = vi.fn().mockResolvedValue(updateResults); + const updateWhere = vi.fn(() => ({ returning })); + const set = vi.fn(() => ({ where: updateWhere })); + const update = vi.fn(() => ({ set })); + + return { + db: { select, update }, + selectWhere, + returning, + }; +} + +describe("approvalService resolution idempotency", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockAgentService.activatePendingApproval.mockResolvedValue(undefined); + mockAgentService.create.mockResolvedValue({ id: "agent-1" }); + mockAgentService.terminate.mockResolvedValue(undefined); + mockNotifyHireApproved.mockResolvedValue(undefined); + }); + + it("treats repeated approve retries as no-ops after another worker resolves the approval", async () => { + const dbStub = createDbStub( + [[createApproval("pending")], [createApproval("approved")]], + [], + ); + + const svc = approvalService(dbStub.db as any); + const result = await svc.approve("approval-1", "board", "ship it"); + + expect(result.applied).toBe(false); + expect(result.approval.status).toBe("approved"); + expect(mockAgentService.activatePendingApproval).not.toHaveBeenCalled(); + expect(mockNotifyHireApproved).not.toHaveBeenCalled(); + }); + + it("treats repeated reject retries as no-ops after another worker resolves the approval", async () => { + const dbStub = createDbStub( + [[createApproval("pending")], [createApproval("rejected")]], + [], + ); + + const svc = approvalService(dbStub.db as any); + const result = await svc.reject("approval-1", "board", "not now"); + + expect(result.applied).toBe(false); + expect(result.approval.status).toBe("rejected"); + expect(mockAgentService.terminate).not.toHaveBeenCalled(); + }); + + it("still performs side effects when the resolution update is newly applied", async () => { + const approved = createApproval("approved"); + const dbStub = createDbStub([[createApproval("pending")]], [approved]); + + const svc = approvalService(dbStub.db as any); + const result = await svc.approve("approval-1", "board", "ship it"); + + expect(result.applied).toBe(true); + expect(mockAgentService.activatePendingApproval).toHaveBeenCalledWith("agent-1"); + expect(mockNotifyHireApproved).toHaveBeenCalledTimes(1); + }); +}); diff --git a/server/src/routes/approvals.ts b/server/src/routes/approvals.ts index f9903435..99d33abd 100644 --- a/server/src/routes/approvals.ts +++ b/server/src/routes/approvals.ts @@ -121,85 +121,92 @@ export function approvalRoutes(db: Db) { router.post("/approvals/:id/approve", validate(resolveApprovalSchema), async (req, res) => { assertBoard(req); const id = req.params.id as string; - const approval = await svc.approve(id, req.body.decidedByUserId ?? "board", req.body.decisionNote); - const linkedIssues = await issueApprovalsSvc.listIssuesForApproval(approval.id); - const linkedIssueIds = linkedIssues.map((issue) => issue.id); - const primaryIssueId = linkedIssueIds[0] ?? null; + const { approval, applied } = await svc.approve( + id, + req.body.decidedByUserId ?? "board", + req.body.decisionNote, + ); - await logActivity(db, { - companyId: approval.companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", - action: "approval.approved", - entityType: "approval", - entityId: approval.id, - details: { - type: approval.type, - requestedByAgentId: approval.requestedByAgentId, - linkedIssueIds, - }, - }); + if (applied) { + const linkedIssues = await issueApprovalsSvc.listIssuesForApproval(approval.id); + const linkedIssueIds = linkedIssues.map((issue) => issue.id); + const primaryIssueId = linkedIssueIds[0] ?? null; - if (approval.requestedByAgentId) { - try { - const wakeRun = await heartbeat.wakeup(approval.requestedByAgentId, { - source: "automation", - triggerDetail: "system", - reason: "approval_approved", - payload: { - approvalId: approval.id, - approvalStatus: approval.status, - issueId: primaryIssueId, - issueIds: linkedIssueIds, - }, - requestedByActorType: "user", - requestedByActorId: req.actor.userId ?? "board", - contextSnapshot: { - source: "approval.approved", - approvalId: approval.id, - approvalStatus: approval.status, - issueId: primaryIssueId, - issueIds: linkedIssueIds, - taskId: primaryIssueId, - wakeReason: "approval_approved", - }, - }); + await logActivity(db, { + companyId: approval.companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "approval.approved", + entityType: "approval", + entityId: approval.id, + details: { + type: approval.type, + requestedByAgentId: approval.requestedByAgentId, + linkedIssueIds, + }, + }); - await logActivity(db, { - companyId: approval.companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", - action: "approval.requester_wakeup_queued", - entityType: "approval", - entityId: approval.id, - details: { - requesterAgentId: approval.requestedByAgentId, - wakeRunId: wakeRun?.id ?? null, - linkedIssueIds, - }, - }); - } catch (err) { - logger.warn( - { - err, - approvalId: approval.id, - requestedByAgentId: approval.requestedByAgentId, - }, - "failed to queue requester wakeup after approval", - ); - await logActivity(db, { - companyId: approval.companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", - action: "approval.requester_wakeup_failed", - entityType: "approval", - entityId: approval.id, - details: { - requesterAgentId: approval.requestedByAgentId, - linkedIssueIds, - error: err instanceof Error ? err.message : String(err), - }, - }); + if (approval.requestedByAgentId) { + try { + const wakeRun = await heartbeat.wakeup(approval.requestedByAgentId, { + source: "automation", + triggerDetail: "system", + reason: "approval_approved", + payload: { + approvalId: approval.id, + approvalStatus: approval.status, + issueId: primaryIssueId, + issueIds: linkedIssueIds, + }, + requestedByActorType: "user", + requestedByActorId: req.actor.userId ?? "board", + contextSnapshot: { + source: "approval.approved", + approvalId: approval.id, + approvalStatus: approval.status, + issueId: primaryIssueId, + issueIds: linkedIssueIds, + taskId: primaryIssueId, + wakeReason: "approval_approved", + }, + }); + + await logActivity(db, { + companyId: approval.companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "approval.requester_wakeup_queued", + entityType: "approval", + entityId: approval.id, + details: { + requesterAgentId: approval.requestedByAgentId, + wakeRunId: wakeRun?.id ?? null, + linkedIssueIds, + }, + }); + } catch (err) { + logger.warn( + { + err, + approvalId: approval.id, + requestedByAgentId: approval.requestedByAgentId, + }, + "failed to queue requester wakeup after approval", + ); + await logActivity(db, { + companyId: approval.companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "approval.requester_wakeup_failed", + entityType: "approval", + entityId: approval.id, + details: { + requesterAgentId: approval.requestedByAgentId, + linkedIssueIds, + error: err instanceof Error ? err.message : String(err), + }, + }); + } } } @@ -209,17 +216,23 @@ export function approvalRoutes(db: Db) { router.post("/approvals/:id/reject", validate(resolveApprovalSchema), async (req, res) => { assertBoard(req); const id = req.params.id as string; - const approval = await svc.reject(id, req.body.decidedByUserId ?? "board", req.body.decisionNote); + const { approval, applied } = await svc.reject( + id, + req.body.decidedByUserId ?? "board", + req.body.decisionNote, + ); - await logActivity(db, { - companyId: approval.companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", - action: "approval.rejected", - entityType: "approval", - entityId: approval.id, - details: { type: approval.type }, - }); + if (applied) { + await logActivity(db, { + companyId: approval.companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "approval.rejected", + entityType: "approval", + entityId: approval.id, + details: { type: approval.type }, + }); + } res.json(redactApprovalPayload(approval)); }); diff --git a/server/src/services/approvals.ts b/server/src/services/approvals.ts index ba2890a3..13e57c8a 100644 --- a/server/src/services/approvals.ts +++ b/server/src/services/approvals.ts @@ -1,4 +1,4 @@ -import { and, asc, eq } from "drizzle-orm"; +import { and, asc, eq, inArray } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { approvalComments, approvals } from "@paperclipai/db"; import { notFound, unprocessable } from "../errors.js"; @@ -8,6 +8,9 @@ import { notifyHireApproved } from "./hire-hook.js"; export function approvalService(db: Db) { const agentsSvc = agentService(db); const canResolveStatuses = new Set(["pending", "revision_requested"]); + const resolvableStatuses = Array.from(canResolveStatuses); + type ApprovalRecord = typeof approvals.$inferSelect; + type ResolutionResult = { approval: ApprovalRecord; applied: boolean }; async function getExistingApproval(id: string) { const existing = await db @@ -19,6 +22,50 @@ export function approvalService(db: Db) { return existing; } + async function resolveApproval( + id: string, + targetStatus: "approved" | "rejected", + decidedByUserId: string, + decisionNote: string | null | undefined, + ): Promise { + const existing = await getExistingApproval(id); + if (!canResolveStatuses.has(existing.status)) { + if (existing.status === targetStatus) { + return { approval: existing, applied: false }; + } + throw unprocessable( + `Only pending or revision requested approvals can be ${targetStatus === "approved" ? "approved" : "rejected"}`, + ); + } + + const now = new Date(); + const updated = await db + .update(approvals) + .set({ + status: targetStatus, + decidedByUserId, + decisionNote: decisionNote ?? null, + decidedAt: now, + updatedAt: now, + }) + .where(and(eq(approvals.id, id), inArray(approvals.status, resolvableStatuses))) + .returning() + .then((rows) => rows[0] ?? null); + + if (updated) { + return { approval: updated, applied: true }; + } + + const latest = await getExistingApproval(id); + if (latest.status === targetStatus) { + return { approval: latest, applied: false }; + } + + throw unprocessable( + `Only pending or revision requested approvals can be ${targetStatus === "approved" ? "approved" : "rejected"}`, + ); + } + return { list: (companyId: string, status?: string) => { const conditions = [eq(approvals.companyId, companyId)]; @@ -41,27 +88,16 @@ export function approvalService(db: Db) { .then((rows) => rows[0]), approve: async (id: string, decidedByUserId: string, decisionNote?: string | null) => { - const existing = await getExistingApproval(id); - if (!canResolveStatuses.has(existing.status)) { - throw unprocessable("Only pending or revision requested approvals can be approved"); - } - - const now = new Date(); - const updated = await db - .update(approvals) - .set({ - status: "approved", - decidedByUserId, - decisionNote: decisionNote ?? null, - decidedAt: now, - updatedAt: now, - }) - .where(eq(approvals.id, id)) - .returning() - .then((rows) => rows[0]); + const { approval: updated, applied } = await resolveApproval( + id, + "approved", + decidedByUserId, + decisionNote, + ); let hireApprovedAgentId: string | null = null; - if (updated.type === "hire_agent") { + const now = new Date(); + if (applied && updated.type === "hire_agent") { const payload = updated.payload as Record; const payloadAgentId = typeof payload.agentId === "string" ? payload.agentId : null; if (payloadAgentId) { @@ -103,30 +139,18 @@ export function approvalService(db: Db) { } } - return updated; + return { approval: updated, applied }; }, reject: async (id: string, decidedByUserId: string, decisionNote?: string | null) => { - const existing = await getExistingApproval(id); - if (!canResolveStatuses.has(existing.status)) { - throw unprocessable("Only pending or revision requested approvals can be rejected"); - } + const { approval: updated, applied } = await resolveApproval( + id, + "rejected", + decidedByUserId, + decisionNote, + ); - const now = new Date(); - const updated = await db - .update(approvals) - .set({ - status: "rejected", - decidedByUserId, - decisionNote: decisionNote ?? null, - decidedAt: now, - updatedAt: now, - }) - .where(eq(approvals.id, id)) - .returning() - .then((rows) => rows[0]); - - if (updated.type === "hire_agent") { + if (applied && updated.type === "hire_agent") { const payload = updated.payload as Record; const payloadAgentId = typeof payload.agentId === "string" ? payload.agentId : null; if (payloadAgentId) { @@ -134,7 +158,7 @@ export function approvalService(db: Db) { } } - return updated; + return { approval: updated, applied }; }, requestRevision: async (id: string, decidedByUserId: string, decisionNote?: string | null) => { From deec68ab163a9726bc99307654d65fdcb17ad9c2 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 10 Mar 2026 12:57:53 -0500 Subject: [PATCH 6/7] Copy seeded secrets key into worktree instances --- cli/src/__tests__/worktree.test.ts | 49 +++++++++++++++++++++++++ cli/src/commands/worktree.ts | 57 +++++++++++++++++++++++++++++- 2 files changed, 105 insertions(+), 1 deletion(-) diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index 18bdabf1..9ce48cf7 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -1,5 +1,8 @@ +import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { copySeededSecretsKey } from "../commands/worktree.js"; import { buildWorktreeConfig, buildWorktreeEnvEntries, @@ -122,4 +125,50 @@ describe("worktree helpers", () => { expect(full.excludedTables).toEqual([]); expect(full.nullifyColumns).toEqual({}); }); + + it("copies the source local_encrypted secrets key into the seeded worktree instance", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-secrets-")); + try { + const sourceConfigPath = path.join(tempRoot, "source", "config.json"); + const sourceKeyPath = path.join(tempRoot, "source", "secrets", "master.key"); + const targetKeyPath = path.join(tempRoot, "target", "secrets", "master.key"); + fs.mkdirSync(path.dirname(sourceKeyPath), { recursive: true }); + fs.writeFileSync(sourceKeyPath, "source-master-key", "utf8"); + + const sourceConfig = buildSourceConfig(); + sourceConfig.secrets.localEncrypted.keyFilePath = sourceKeyPath; + + copySeededSecretsKey({ + sourceConfigPath, + sourceConfig, + sourceEnvEntries: {}, + targetKeyFilePath: targetKeyPath, + }); + + expect(fs.readFileSync(targetKeyPath, "utf8")).toBe("source-master-key"); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("writes the source inline secrets master key into the seeded worktree instance", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-secrets-")); + try { + const sourceConfigPath = path.join(tempRoot, "source", "config.json"); + const targetKeyPath = path.join(tempRoot, "target", "secrets", "master.key"); + + copySeededSecretsKey({ + sourceConfigPath, + sourceConfig: buildSourceConfig(), + sourceEnvEntries: { + PAPERCLIP_SECRETS_MASTER_KEY: "inline-source-master-key", + }, + targetKeyFilePath: targetKeyPath, + }); + + expect(fs.readFileSync(targetKeyPath, "utf8")).toBe("inline-source-master-key"); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); }); diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index f9363ff8..8306c8ed 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync, rmSync } from "node:fs"; +import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { execFileSync } from "node:child_process"; @@ -18,6 +18,7 @@ import { expandHomePrefix } from "../config/home.js"; import type { PaperclipConfig } from "../config/schema.js"; import { readConfig, resolveConfigPath, writeConfig } from "../config/store.js"; import { printPaperclipCliBanner } from "../utils/banner.js"; +import { resolveRuntimeLikePath } from "../utils/path-resolver.js"; import { buildWorktreeConfig, buildWorktreeEnvEntries, @@ -154,6 +155,54 @@ function resolveSourceConnectionString(config: PaperclipConfig, envEntries: Reco return `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`; } +export function copySeededSecretsKey(input: { + sourceConfigPath: string; + sourceConfig: PaperclipConfig; + sourceEnvEntries: Record; + targetKeyFilePath: string; +}): void { + if (input.sourceConfig.secrets.provider !== "local_encrypted") { + return; + } + + mkdirSync(path.dirname(input.targetKeyFilePath), { recursive: true }); + + const sourceInlineMasterKey = + nonEmpty(input.sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY) ?? + nonEmpty(process.env.PAPERCLIP_SECRETS_MASTER_KEY); + if (sourceInlineMasterKey) { + writeFileSync(input.targetKeyFilePath, sourceInlineMasterKey, { + encoding: "utf8", + mode: 0o600, + }); + try { + chmodSync(input.targetKeyFilePath, 0o600); + } catch { + // best effort + } + return; + } + + const sourceKeyFileOverride = + nonEmpty(input.sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY_FILE) ?? + nonEmpty(process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE); + const sourceConfiguredKeyPath = sourceKeyFileOverride ?? input.sourceConfig.secrets.localEncrypted.keyFilePath; + const sourceKeyFilePath = resolveRuntimeLikePath(sourceConfiguredKeyPath, input.sourceConfigPath); + + if (!existsSync(sourceKeyFilePath)) { + throw new Error( + `Cannot seed worktree database because source local_encrypted secrets key was not found at ${sourceKeyFilePath}.`, + ); + } + + copyFileSync(sourceKeyFilePath, input.targetKeyFilePath); + try { + chmodSync(input.targetKeyFilePath, 0o600); + } catch { + // best effort + } +} + async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): Promise { const moduleName = "embedded-postgres"; let EmbeddedPostgres: EmbeddedPostgresCtor; @@ -215,6 +264,12 @@ async function seedWorktreeDatabase(input: { const seedPlan = resolveWorktreeSeedPlan(input.seedMode); const sourceEnvFile = resolvePaperclipEnvFile(input.sourceConfigPath); const sourceEnvEntries = readPaperclipEnvEntries(sourceEnvFile); + copySeededSecretsKey({ + sourceConfigPath: input.sourceConfigPath, + sourceConfig: input.sourceConfig, + sourceEnvEntries, + targetKeyFilePath: input.targetPaths.secretsKeyFilePath, + }); let sourceHandle: EmbeddedPostgresHandle | null = null; let targetHandle: EmbeddedPostgresHandle | null = null; From 12216b5cc60334cb5d30604f3b3a73dd5a6ed241 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 10 Mar 2026 13:50:29 -0500 Subject: [PATCH 7/7] Rebind seeded project workspaces to the current worktree --- cli/src/__tests__/worktree.test.ts | 30 ++++++- cli/src/commands/worktree.ts | 139 ++++++++++++++++++++++++++++- 2 files changed, 165 insertions(+), 4 deletions(-) diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index 9ce48cf7..ac316df2 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { copySeededSecretsKey } from "../commands/worktree.js"; +import { copySeededSecretsKey, rebindWorkspaceCwd } from "../commands/worktree.js"; import { buildWorktreeConfig, buildWorktreeEnvEntries, @@ -171,4 +171,32 @@ describe("worktree helpers", () => { fs.rmSync(tempRoot, { recursive: true, force: true }); } }); + + it("rebinds same-repo workspace paths onto the current worktree root", () => { + expect( + rebindWorkspaceCwd({ + sourceRepoRoot: "/Users/nmurray/paperclip", + targetRepoRoot: "/Users/nmurray/paperclip-pr-432", + workspaceCwd: "/Users/nmurray/paperclip", + }), + ).toBe("/Users/nmurray/paperclip-pr-432"); + + expect( + rebindWorkspaceCwd({ + sourceRepoRoot: "/Users/nmurray/paperclip", + targetRepoRoot: "/Users/nmurray/paperclip-pr-432", + workspaceCwd: "/Users/nmurray/paperclip/packages/db", + }), + ).toBe("/Users/nmurray/paperclip-pr-432/packages/db"); + }); + + it("does not rebind paths outside the source repo root", () => { + expect( + rebindWorkspaceCwd({ + sourceRepoRoot: "/Users/nmurray/paperclip", + targetRepoRoot: "/Users/nmurray/paperclip-pr-432", + workspaceCwd: "/Users/nmurray/other-project", + }), + ).toBeNull(); + }); }); diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index 8306c8ed..4ef43572 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -5,10 +5,13 @@ import { execFileSync } from "node:child_process"; import { createServer } from "node:net"; import * as p from "@clack/prompts"; import pc from "picocolors"; +import { eq } from "drizzle-orm"; import { applyPendingMigrations, + createDb, ensurePostgresDatabase, formatDatabaseBackupResult, + projectWorkspaces, runDatabaseBackup, runDatabaseRestore, } from "@paperclipai/db"; @@ -74,6 +77,20 @@ type EmbeddedPostgresHandle = { stop: () => Promise; }; +type GitWorkspaceInfo = { + root: string; + commonDir: string; +}; + +type SeedWorktreeDatabaseResult = { + backupSummary: string; + reboundWorkspaces: Array<{ + name: string; + fromCwd: string; + toCwd: string; + }>; +}; + function nonEmpty(value: string | null | undefined): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } @@ -133,6 +150,107 @@ function detectGitBranchName(cwd: string): string | null { } } +function detectGitWorkspaceInfo(cwd: string): GitWorkspaceInfo | null { + try { + const root = execFileSync("git", ["rev-parse", "--show-toplevel"], { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + const commonDirRaw = execFileSync("git", ["rev-parse", "--git-common-dir"], { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + return { + root: path.resolve(root), + commonDir: path.resolve(root, commonDirRaw), + }; + } catch { + return null; + } +} + +export function rebindWorkspaceCwd(input: { + sourceRepoRoot: string; + targetRepoRoot: string; + workspaceCwd: string; +}): string | null { + const sourceRepoRoot = path.resolve(input.sourceRepoRoot); + const targetRepoRoot = path.resolve(input.targetRepoRoot); + const workspaceCwd = path.resolve(input.workspaceCwd); + const relative = path.relative(sourceRepoRoot, workspaceCwd); + if (!relative || relative === "") { + return targetRepoRoot; + } + if (relative.startsWith("..") || path.isAbsolute(relative)) { + return null; + } + return path.resolve(targetRepoRoot, relative); +} + +async function rebindSeededProjectWorkspaces(input: { + targetConnectionString: string; + currentCwd: string; +}): Promise { + const targetRepo = detectGitWorkspaceInfo(input.currentCwd); + if (!targetRepo) return []; + + const db = createDb(input.targetConnectionString); + const closableDb = db as typeof db & { + $client?: { end?: (opts?: { timeout?: number }) => Promise }; + }; + + try { + const rows = await db + .select({ + id: projectWorkspaces.id, + name: projectWorkspaces.name, + cwd: projectWorkspaces.cwd, + }) + .from(projectWorkspaces); + + const rebound: SeedWorktreeDatabaseResult["reboundWorkspaces"] = []; + for (const row of rows) { + const workspaceCwd = nonEmpty(row.cwd); + if (!workspaceCwd) continue; + + const sourceRepo = detectGitWorkspaceInfo(workspaceCwd); + if (!sourceRepo) continue; + if (sourceRepo.commonDir !== targetRepo.commonDir) continue; + + const reboundCwd = rebindWorkspaceCwd({ + sourceRepoRoot: sourceRepo.root, + targetRepoRoot: targetRepo.root, + workspaceCwd, + }); + if (!reboundCwd) continue; + + const normalizedCurrent = path.resolve(workspaceCwd); + if (reboundCwd === normalizedCurrent) continue; + if (!existsSync(reboundCwd)) continue; + + await db + .update(projectWorkspaces) + .set({ + cwd: reboundCwd, + updatedAt: new Date(), + }) + .where(eq(projectWorkspaces.id, row.id)); + + rebound.push({ + name: row.name, + fromCwd: normalizedCurrent, + toCwd: reboundCwd, + }); + } + + return rebound; + } finally { + await closableDb.$client?.end?.({ timeout: 5 }).catch(() => undefined); + } +} + function resolveSourceConfigPath(opts: WorktreeInitOptions): string { if (opts.fromConfig) return path.resolve(opts.fromConfig); const sourceHome = path.resolve(expandHomePrefix(opts.fromDataDir ?? "~/.paperclip")); @@ -260,7 +378,7 @@ async function seedWorktreeDatabase(input: { targetPaths: WorktreeLocalPaths; instanceId: string; seedMode: WorktreeSeedMode; -}): Promise { +}): Promise { const seedPlan = resolveWorktreeSeedPlan(input.seedMode); const sourceEnvFile = resolvePaperclipEnvFile(input.sourceConfigPath); const sourceEnvEntries = readPaperclipEnvEntries(sourceEnvFile); @@ -308,8 +426,15 @@ async function seedWorktreeDatabase(input: { backupFile: backup.backupFile, }); await applyPendingMigrations(targetConnectionString); + const reboundWorkspaces = await rebindSeededProjectWorkspaces({ + targetConnectionString, + currentCwd: input.targetPaths.cwd, + }); - return formatDatabaseBackupResult(backup); + return { + backupSummary: formatDatabaseBackupResult(backup), + reboundWorkspaces, + }; } finally { if (targetHandle?.startedByThisProcess) { await targetHandle.stop(); @@ -370,6 +495,7 @@ export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise ${rebound.toCwd}`), + ); + } } p.outro( pc.green(