From ab22821204889acc0e169ad3da325975f6f2008e Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 10 May 2026 06:38:53 -0500 Subject: [PATCH] Remove root-level local spaces reorg Keep local Paperclip runtime paths under the instance root and remove the root-level spaces CLI/migration surface. Preserve plugin-scoped space concepts outside this host/runtime PR. Co-Authored-By: Paperclip --- cli/src/__tests__/data-dir.test.ts | 5 +- cli/src/__tests__/home-paths.test.ts | 3 +- cli/src/__tests__/onboard.test.ts | 29 +- cli/src/__tests__/spaces.test.ts | 218 -------------- cli/src/commands/configure.ts | 4 - cli/src/commands/onboard.ts | 7 +- cli/src/commands/spaces.ts | 269 ------------------ cli/src/config/home.ts | 16 +- cli/src/index.ts | 2 - doc/CLI.md | 83 ++---- doc/DATABASE.md | 10 +- doc/DEVELOPING.md | 62 ++-- doc/SPEC-implementation.md | 6 +- packages/adapter-utils/src/server-utils.ts | 76 ++--- .../acpx-local/src/server/execute.test.ts | 2 - .../adapters/acpx-local/src/server/execute.ts | 5 +- .../claude-local/src/server/claude-config.ts | 9 +- .../claude-local/src/server/prompt-cache.ts | 7 +- .../codex-local/src/server/codex-home.ts | 9 +- packages/db/src/backup.ts | 2 +- packages/db/src/runtime-config.test.ts | 12 +- packages/db/src/runtime-config.ts | 2 +- packages/shared/src/home-paths.test.ts | 36 +++ packages/shared/src/home-paths.ts | 92 ++++++ packages/shared/src/space-paths.test.ts | 74 ----- packages/shared/src/space-paths.ts | 247 ---------------- .../agent-instructions-service.test.ts | 14 - .../__tests__/claude-local-execute.test.ts | 2 - .../src/__tests__/codex-local-execute.test.ts | 4 - server/src/home-paths.ts | 17 +- server/src/services/agent-instructions.ts | 4 +- server/src/services/company-skills.ts | 4 +- server/src/services/feedback.ts | 4 +- server/src/services/recovery/service.ts | 40 ++- server/src/services/run-log-store.ts | 4 +- .../services/workspace-operation-log-store.ts | 4 +- 36 files changed, 278 insertions(+), 1106 deletions(-) delete mode 100644 cli/src/__tests__/spaces.test.ts delete mode 100644 cli/src/commands/spaces.ts create mode 100644 packages/shared/src/home-paths.test.ts create mode 100644 packages/shared/src/home-paths.ts delete mode 100644 packages/shared/src/space-paths.test.ts delete mode 100644 packages/shared/src/space-paths.ts diff --git a/cli/src/__tests__/data-dir.test.ts b/cli/src/__tests__/data-dir.test.ts index b4674781966..520c71fd920 100644 --- a/cli/src/__tests__/data-dir.test.ts +++ b/cli/src/__tests__/data-dir.test.ts @@ -12,7 +12,6 @@ describe("applyDataDirOverride", () => { delete process.env.PAPERCLIP_CONFIG; delete process.env.PAPERCLIP_CONTEXT; delete process.env.PAPERCLIP_INSTANCE_ID; - delete process.env.PAPERCLIP_SPACE_ID; }); afterEach(() => { @@ -30,7 +29,7 @@ describe("applyDataDirOverride", () => { expect(home).toBe(expectedHome); expect(process.env.PAPERCLIP_HOME).toBe(expectedHome); expect(process.env.PAPERCLIP_CONFIG).toBe( - path.resolve(expectedHome, "instances", "default", "spaces", "default", "config.json"), + path.resolve(expectedHome, "instances", "default", "config.json"), ); expect(process.env.PAPERCLIP_CONTEXT).toBe(path.resolve(expectedHome, "context.json")); expect(process.env.PAPERCLIP_INSTANCE_ID).toBe("default"); @@ -47,7 +46,7 @@ describe("applyDataDirOverride", () => { expect(home).toBe(path.resolve("/tmp/paperclip-alt")); expect(process.env.PAPERCLIP_INSTANCE_ID).toBe("dev_1"); expect(process.env.PAPERCLIP_CONFIG).toBe( - path.resolve("/tmp/paperclip-alt", "instances", "dev_1", "spaces", "default", "config.json"), + path.resolve("/tmp/paperclip-alt", "instances", "dev_1", "config.json"), ); }); diff --git a/cli/src/__tests__/home-paths.test.ts b/cli/src/__tests__/home-paths.test.ts index 53ae028a4aa..f06d2c2222e 100644 --- a/cli/src/__tests__/home-paths.test.ts +++ b/cli/src/__tests__/home-paths.test.ts @@ -24,8 +24,7 @@ describe("home path resolution", () => { const paths = describeLocalInstancePaths(); expect(paths.homeDir).toBe(home); expect(paths.instanceId).toBe("default"); - expect(paths.activeSpaceId).toBe("default"); - expect(paths.configPath).toBe(path.resolve(home, "instances", "default", "spaces", "default", "config.json")); + expect(paths.configPath).toBe(path.resolve(home, "instances", "default", "config.json")); }); it("supports PAPERCLIP_HOME and explicit instance ids", () => { diff --git a/cli/src/__tests__/onboard.test.ts b/cli/src/__tests__/onboard.test.ts index 851d476e17c..0c694f4be83 100644 --- a/cli/src/__tests__/onboard.test.ts +++ b/cli/src/__tests__/onboard.test.ts @@ -89,7 +89,6 @@ describe("onboard", () => { delete process.env.PAPERCLIP_HOME; delete process.env.PAPERCLIP_CONFIG; delete process.env.PAPERCLIP_INSTANCE_ID; - delete process.env.PAPERCLIP_SPACE_ID; delete process.env.PAPERCLIP_BIND; delete process.env.PAPERCLIP_BIND_HOST; delete process.env.PAPERCLIP_TAILNET_BIND_HOST; @@ -135,8 +134,8 @@ describe("onboard", () => { expect(raw.server.host).toBe("127.0.0.1"); }); - it("creates default-space config and data paths for a fresh PAPERCLIP_HOME", async () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-onboard-space-")); + it("creates instance-root config and data paths for a fresh PAPERCLIP_HOME", async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-onboard-home-")); const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-onboard-cwd-")); process.chdir(cwd); process.env.PAPERCLIP_HOME = home; @@ -144,24 +143,16 @@ describe("onboard", () => { await onboard({ yes: true, invokedByRun: true }); const instanceRoot = path.join(home, "instances", "default"); - const spaceRoot = path.join(instanceRoot, "spaces", "default"); - const configPath = path.join(spaceRoot, "config.json"); - const registryPath = path.join(instanceRoot, "config.json"); + const configPath = path.join(instanceRoot, "config.json"); const raw = JSON.parse(fs.readFileSync(configPath, "utf8")) as PaperclipConfig; - const registry = JSON.parse(fs.readFileSync(registryPath, "utf8")) as { activeSpaceId?: string }; - expect(registry.activeSpaceId).toBe("default"); - expect(raw.database.embeddedPostgresDataDir).toBe(path.join(spaceRoot, "db")); - expect(raw.database.backup.dir).toBe(path.join(spaceRoot, "data", "backups")); - expect(raw.logging.logDir).toBe(path.join(spaceRoot, "logs")); - expect(raw.storage.localDisk.baseDir).toBe(path.join(spaceRoot, "data", "storage")); - expect(raw.secrets.localEncrypted.keyFilePath).toBe(path.join(spaceRoot, "secrets", "master.key")); - expect(fs.existsSync(path.join(spaceRoot, ".env"))).toBe(true); - expect(fs.existsSync(path.join(spaceRoot, "secrets", "master.key"))).toBe(true); - - for (const rootName of ["db", "data", "secrets", "logs", "workspaces", "projects", "companies"]) { - expect(fs.existsSync(path.join(instanceRoot, rootName))).toBe(false); - } + expect(raw.database.embeddedPostgresDataDir).toBe(path.join(instanceRoot, "db")); + expect(raw.database.backup.dir).toBe(path.join(instanceRoot, "data", "backups")); + expect(raw.logging.logDir).toBe(path.join(instanceRoot, "logs")); + expect(raw.storage.localDisk.baseDir).toBe(path.join(instanceRoot, "data", "storage")); + expect(raw.secrets.localEncrypted.keyFilePath).toBe(path.join(instanceRoot, "secrets", "master.key")); + expect(fs.existsSync(path.join(instanceRoot, ".env"))).toBe(true); + expect(fs.existsSync(path.join(instanceRoot, "secrets", "master.key"))).toBe(true); }); it("supports authenticated/private quickstart bind presets", async () => { diff --git a/cli/src/__tests__/spaces.test.ts b/cli/src/__tests__/spaces.test.ts deleted file mode 100644 index 7cb200aed4a..00000000000 --- a/cli/src/__tests__/spaces.test.ts +++ /dev/null @@ -1,218 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { defaultServerRunningCheck, migrateDefaultSpaceInstall } from "../commands/spaces.js"; -import { readConfig, resolveConfigPath } from "../config/store.js"; -import type { PaperclipConfig } from "../config/schema.js"; - -const ORIGINAL_ENV = { ...process.env }; -const ORIGINAL_CWD = process.cwd(); - -function writeJson(filePath: string, value: unknown): void { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); -} - -function writeText(filePath: string, value: string): void { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, value); -} - -function createLegacyInstallFixture() { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-space-migrate-home-")); - const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-space-migrate-cwd-")); - process.chdir(cwd); - process.env.PAPERCLIP_HOME = home; - delete process.env.PAPERCLIP_CONFIG; - delete process.env.PAPERCLIP_INSTANCE_ID; - delete process.env.PAPERCLIP_SPACE_ID; - - const instanceRoot = path.join(home, "instances", "default"); - const spaceRoot = path.join(instanceRoot, "spaces", "default"); - const config: PaperclipConfig = { - $meta: { - version: 1, - updatedAt: "2026-05-09T00:00:00.000Z", - source: "onboard", - }, - database: { - mode: "embedded-postgres", - embeddedPostgresDataDir: path.join(instanceRoot, "db"), - embeddedPostgresPort: 54329, - backup: { - enabled: true, - intervalMinutes: 60, - retentionDays: 30, - dir: path.join(instanceRoot, "data", "backups"), - }, - }, - logging: { - mode: "file", - logDir: path.join(instanceRoot, "logs"), - }, - server: { - deploymentMode: "local_trusted", - exposure: "private", - host: "127.0.0.1", - port: 3100, - allowedHostnames: [], - serveUi: true, - }, - auth: { - baseUrlMode: "auto", - disableSignUp: false, - }, - telemetry: { - enabled: true, - }, - storage: { - provider: "local_disk", - localDisk: { - baseDir: path.join(instanceRoot, "data", "storage"), - }, - s3: { - bucket: "paperclip", - region: "us-east-1", - prefix: "", - forcePathStyle: false, - }, - }, - secrets: { - provider: "local_encrypted", - strictMode: false, - localEncrypted: { - keyFilePath: path.join(instanceRoot, "secrets", "master.key"), - }, - }, - }; - - writeJson(path.join(instanceRoot, "config.json"), config); - writeText(path.join(instanceRoot, ".env"), "PAPERCLIP_AGENT_JWT_SECRET=test-secret\n"); - writeText(path.join(instanceRoot, "db", "PG_VERSION"), "17\n"); - writeText(path.join(instanceRoot, "data", "storage", "hello.txt"), "hello\n"); - writeText(path.join(instanceRoot, "logs", "server.log"), "log\n"); - writeText(path.join(instanceRoot, "secrets", "master.key"), "01234567890123456789012345678901"); - writeText(path.join(instanceRoot, "workspaces", "agent-1", "README.md"), "workspace\n"); - writeText(path.join(instanceRoot, "projects", "company", "project", "repo", "README.md"), "project\n"); - writeText(path.join(instanceRoot, "companies", "company-1", "codex-home", "config.toml"), "model=\"x\"\n"); - writeText(path.join(instanceRoot, "codex-home", "config.toml"), "model=\"shared\"\n"); - - return { instanceRoot, spaceRoot }; -} - -beforeEach(() => { - process.env = { ...ORIGINAL_ENV }; -}); - -afterEach(() => { - process.env = { ...ORIGINAL_ENV }; - process.chdir(ORIGINAL_CWD); - vi.unstubAllGlobals(); -}); - -describe("spaces migrate-default", () => { - it("moves a legacy root-shaped install into spaces/default and rewrites config paths", async () => { - const fixture = createLegacyInstallFixture(); - - const result = await migrateDefaultSpaceInstall({ - serverRunningCheck: async () => false, - }); - - expect(result.status).toBe("migrated"); - expect(result.movedPaths).toEqual([ - "config.json", - ".env", - "db", - "data", - "logs", - "secrets", - "workspaces", - "projects", - "companies", - "codex-home", - ]); - - expect(fs.existsSync(path.join(fixture.instanceRoot, "db"))).toBe(false); - expect(fs.readFileSync(path.join(fixture.spaceRoot, ".env"), "utf8")).toContain("PAPERCLIP_AGENT_JWT_SECRET"); - expect(fs.readFileSync(path.join(fixture.spaceRoot, "db", "PG_VERSION"), "utf8")).toBe("17\n"); - expect(fs.readFileSync(path.join(fixture.spaceRoot, "data", "storage", "hello.txt"), "utf8")).toBe("hello\n"); - expect(fs.readFileSync(path.join(fixture.spaceRoot, "companies", "company-1", "codex-home", "config.toml"), "utf8")).toContain("model"); - expect(fs.readFileSync(path.join(fixture.spaceRoot, "codex-home", "config.toml"), "utf8")).toContain("shared"); - - const migratedConfig = JSON.parse(fs.readFileSync(path.join(fixture.spaceRoot, "config.json"), "utf8")) as PaperclipConfig; - expect(migratedConfig.database.embeddedPostgresDataDir).toBe(path.join(fixture.spaceRoot, "db")); - expect(migratedConfig.database.backup.dir).toBe(path.join(fixture.spaceRoot, "data", "backups")); - expect(migratedConfig.logging.logDir).toBe(path.join(fixture.spaceRoot, "logs")); - expect(migratedConfig.storage.localDisk.baseDir).toBe(path.join(fixture.spaceRoot, "data", "storage")); - expect(migratedConfig.secrets.localEncrypted.keyFilePath).toBe(path.join(fixture.spaceRoot, "secrets", "master.key")); - - const marker = JSON.parse(fs.readFileSync(path.join(fixture.instanceRoot, "config.json"), "utf8")) as { - activeSpaceId?: string; - defaultSpaceMigration?: { sourceRoot?: string; destinationRoot?: string; movedPaths?: string[] }; - }; - expect(marker.activeSpaceId).toBe("default"); - expect(marker.defaultSpaceMigration).toMatchObject({ - sourceRoot: fixture.instanceRoot, - destinationRoot: fixture.spaceRoot, - movedPaths: result.movedPaths, - }); - - expect(resolveConfigPath()).toBe(path.join(fixture.spaceRoot, "config.json")); - expect(readConfig()?.database.embeddedPostgresDataDir).toBe(path.join(fixture.spaceRoot, "db")); - }); - - it("refuses to merge when destination data already exists", async () => { - const fixture = createLegacyInstallFixture(); - writeText(path.join(fixture.spaceRoot, "db", "PG_VERSION"), "existing\n"); - - await expect(migrateDefaultSpaceInstall({ - serverRunningCheck: async () => false, - })).rejects.toThrow(/destination paths already exist:[\s\S]*db/); - - expect(fs.readFileSync(path.join(fixture.instanceRoot, "db", "PG_VERSION"), "utf8")).toBe("17\n"); - expect(fs.readFileSync(path.join(fixture.spaceRoot, "db", "PG_VERSION"), "utf8")).toBe("existing\n"); - }); - - it("refuses to migrate when the server appears to be running", async () => { - const fixture = createLegacyInstallFixture(); - - await expect(migrateDefaultSpaceInstall({ - serverRunningCheck: async () => true, - })).rejects.toThrow(/server appears to be running/); - - expect(fs.existsSync(path.join(fixture.instanceRoot, "config.json"))).toBe(true); - expect(fs.existsSync(path.join(fixture.spaceRoot, "config.json"))).toBe(false); - }); - - it("checks the destination space config when the instance root only has a space registry", async () => { - const fixture = createLegacyInstallFixture(); - const destinationConfig = JSON.parse(fs.readFileSync(path.join(fixture.instanceRoot, "config.json"), "utf8")) as PaperclipConfig; - fs.rmSync(path.join(fixture.instanceRoot, "config.json")); - writeJson(path.join(fixture.instanceRoot, "config.json"), { - $meta: { version: 1, updatedAt: "2026-05-10T00:00:00.000Z", source: "system" }, - activeSpaceId: "default", - spaces: [{ id: "default", root: "spaces/default", createdAt: "2026-05-10T00:00:00.000Z" }], - }); - writeJson(path.join(fixture.spaceRoot, "config.json"), { - ...destinationConfig, - server: { - ...destinationConfig.server, - port: 43210, - }, - }); - const fetchMock = vi.fn(async () => ({ status: 204 })); - vi.stubGlobal("fetch", fetchMock); - - await expect(defaultServerRunningCheck({ - instanceId: "default", - sourceRoot: fixture.instanceRoot, - destinationRoot: fixture.spaceRoot, - legacyRuntimeConfig: false, - sourcePathNames: [], - conflicts: [], - })).resolves.toBe(true); - - expect(fetchMock).toHaveBeenCalledWith("http://127.0.0.1:43210/api/health", expect.any(Object)); - }); -}); diff --git a/cli/src/commands/configure.ts b/cli/src/commands/configure.ts index 4b96d10a3f0..b7d2dcbc07d 100644 --- a/cli/src/commands/configure.ts +++ b/cli/src/commands/configure.ts @@ -15,7 +15,6 @@ import { resolveDefaultLogsDir, resolvePaperclipInstanceId, } from "../config/home.js"; -import { ensureDefaultSpaceRegistry } from "@paperclipai/shared/space-paths"; import { printPaperclipCliBanner } from "../utils/banner.js"; type Section = "llm" | "database" | "logging" | "server" | "storage" | "secrets"; @@ -178,9 +177,6 @@ export async function configure(opts: { config.$meta.updatedAt = new Date().toISOString(); config.$meta.source = "configure"; - if (configPath === resolveConfigPath()) { - ensureDefaultSpaceRegistry({ source: "configure" }); - } writeConfig(config, opts.config); p.log.success(`${SECTION_LABELS[section]} configuration updated.`); diff --git a/cli/src/commands/onboard.ts b/cli/src/commands/onboard.ts index c0f409092ee..62158e05bbf 100644 --- a/cli/src/commands/onboard.ts +++ b/cli/src/commands/onboard.ts @@ -36,7 +36,6 @@ import { resolveDefaultLogsDir, resolvePaperclipInstanceId, } from "../config/home.js"; -import { ensureDefaultSpaceRegistry } from "@paperclipai/shared/space-paths"; import { bootstrapCeoInvite } from "./auth-bootstrap-ceo.js"; import { printPaperclipCliBanner } from "../utils/banner.js"; import { @@ -334,7 +333,7 @@ export async function onboard(opts: OnboardOptions): Promise { const instance = describeLocalInstancePaths(resolvePaperclipInstanceId()); p.log.message( pc.dim( - `Local home: ${instance.homeDir} | instance: ${instance.instanceId} | space: ${instance.activeSpaceId} | config: ${configPath}`, + `Local home: ${instance.homeDir} | instance: ${instance.instanceId} | config: ${configPath}`, ), ); @@ -626,10 +625,6 @@ export async function onboard(opts: OnboardOptions): Promise { p.log.message(pc.dim(`Using existing local secrets key file at ${keyResult.path}`)); } - if (configPath === resolveConfigPath()) { - ensureDefaultSpaceRegistry({ source: "onboard" }); - } - writeConfig(config, opts.config); if (tc) trackInstallCompleted(tc, { diff --git a/cli/src/commands/spaces.ts b/cli/src/commands/spaces.ts deleted file mode 100644 index d443f650532..00000000000 --- a/cli/src/commands/spaces.ts +++ /dev/null @@ -1,269 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { Command } from "commander"; -import pc from "picocolors"; -import { - DEFAULT_PAPERCLIP_SPACE_ID, - DEFAULT_SPACE_ADAPTER_LOCAL_PATH_NAMES, - DEFAULT_SPACE_OWNED_PATH_NAMES, - createDefaultSpaceRegistry, - expandHomePrefix, - isPaperclipRuntimeConfig, - resolvePaperclipInstanceConfigPath, - resolvePaperclipInstanceId, - resolvePaperclipInstanceRoot, - resolvePaperclipSpacesRoot, -} from "@paperclipai/shared/space-paths"; - -type JsonObject = Record; - -export interface DefaultSpaceMigrationPlan { - instanceId: string; - sourceRoot: string; - destinationRoot: string; - legacyRuntimeConfig: boolean; - sourcePathNames: string[]; - conflicts: Array<{ pathName: string; sourcePath: string; destinationPath: string }>; -} - -export interface DefaultSpaceMigrationResult { - status: "migrated" | "noop" | "dry_run"; - plan: DefaultSpaceMigrationPlan; - movedPaths: string[]; - markerPath: string | null; -} - -export interface MigrateDefaultSpaceOptions { - instanceId?: string; - dryRun?: boolean; - skipServerCheck?: boolean; - serverRunningCheck?: (plan: DefaultSpaceMigrationPlan) => Promise; -} - -const MIGRATION_PATH_NAMES = [ - ...DEFAULT_SPACE_OWNED_PATH_NAMES, - ...DEFAULT_SPACE_ADAPTER_LOCAL_PATH_NAMES, -] as const; - -function readJsonIfPresent(filePath: string): unknown | null { - if (!fs.existsSync(filePath)) return null; - try { - return JSON.parse(fs.readFileSync(filePath, "utf8")); - } catch { - return null; - } -} - -function isObject(value: unknown): value is JsonObject { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function isPathWithin(rootPath: string, candidatePath: string): boolean { - const root = path.resolve(rootPath); - const candidate = path.resolve(candidatePath); - const relative = path.relative(root, candidate); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); -} - -function rewriteSpaceRootString(value: string, sourceRoot: string, destinationRoot: string): string { - const resolved = path.resolve(expandHomePrefix(value)); - if (!isPathWithin(sourceRoot, resolved)) return value; - const relative = path.relative(path.resolve(sourceRoot), resolved); - return path.resolve(destinationRoot, relative); -} - -function rewriteConfigPaths(value: unknown, sourceRoot: string, destinationRoot: string): unknown { - if (typeof value === "string") return rewriteSpaceRootString(value, sourceRoot, destinationRoot); - if (Array.isArray(value)) return value.map((entry) => rewriteConfigPaths(entry, sourceRoot, destinationRoot)); - if (!isObject(value)) return value; - return Object.fromEntries( - Object.entries(value).map(([key, entry]) => [key, rewriteConfigPaths(entry, sourceRoot, destinationRoot)]), - ); -} - -function writeJson(filePath: string, value: unknown, mode = 0o600): void { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, { mode }); -} - -function atomicWriteJson(filePath: string, value: unknown, mode = 0o600): void { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - const tmpPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${process.pid}.tmp`); - writeJson(tmpPath, value, mode); - fs.renameSync(tmpPath, filePath); -} - -export function buildDefaultSpaceMigrationPlan(options: Pick = {}): DefaultSpaceMigrationPlan { - const instanceId = resolvePaperclipInstanceId(options.instanceId); - const sourceRoot = resolvePaperclipInstanceRoot({ instanceId }); - const destinationRoot = path.resolve(resolvePaperclipSpacesRoot({ instanceId }), DEFAULT_PAPERCLIP_SPACE_ID); - const instanceConfigPath = resolvePaperclipInstanceConfigPath({ instanceId }); - const legacyRuntimeConfig = isPaperclipRuntimeConfig(readJsonIfPresent(instanceConfigPath)); - - const sourcePathNames = MIGRATION_PATH_NAMES.filter((pathName) => { - if (pathName === "config.json" && !legacyRuntimeConfig) return false; - return fs.existsSync(path.join(sourceRoot, pathName)); - }); - - const conflicts = sourcePathNames - .map((pathName) => ({ - pathName, - sourcePath: path.join(sourceRoot, pathName), - destinationPath: path.join(destinationRoot, pathName), - })) - .filter((entry) => fs.existsSync(entry.destinationPath)); - - return { - instanceId, - sourceRoot, - destinationRoot, - legacyRuntimeConfig, - sourcePathNames, - conflicts, - }; -} - -function readServerEndpointFromConfig(configPath: string): { host: string; port: number } | null { - const parsed = readJsonIfPresent(configPath); - if (!isObject(parsed)) return null; - const server = parsed.server; - if (!isObject(server)) return null; - const rawHost = typeof server.host === "string" && server.host.trim().length > 0 - ? server.host.trim() - : "127.0.0.1"; - const host = rawHost === "0.0.0.0" || rawHost === "::" ? "127.0.0.1" : rawHost; - const port = typeof server.port === "number" && Number.isInteger(server.port) ? server.port : 3100; - if (port <= 0 || port > 65535) return null; - return { host, port }; -} - -export async function defaultServerRunningCheck(plan: DefaultSpaceMigrationPlan): Promise { - const endpoint = - readServerEndpointFromConfig(path.join(plan.sourceRoot, "config.json")) ?? - readServerEndpointFromConfig(path.join(plan.destinationRoot, "config.json")); - if (!endpoint) return false; - - const host = endpoint.host.includes(":") ? `[${endpoint.host}]` : endpoint.host; - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 500); - try { - const response = await fetch(`http://${host}:${endpoint.port}/api/health`, { - signal: controller.signal, - }); - return response.status >= 200 && response.status < 500; - } catch { - return false; - } finally { - clearTimeout(timeout); - } -} - -function assertMigrationPreflight(plan: DefaultSpaceMigrationPlan): void { - if (plan.conflicts.length === 0) return; - const details = plan.conflicts - .map((conflict) => `- ${conflict.pathName}: ${conflict.sourcePath} -> ${conflict.destinationPath}`) - .join("\n"); - throw new Error(`Cannot migrate default space because destination paths already exist:\n${details}`); -} - -function moveConfigWithRewrittenPaths(sourcePath: string, destinationPath: string, sourceRoot: string, destinationRoot: string): void { - const parsed = JSON.parse(fs.readFileSync(sourcePath, "utf8")) as unknown; - const rewritten = rewriteConfigPaths(parsed, sourceRoot, destinationRoot); - atomicWriteJson(destinationPath, rewritten); - fs.unlinkSync(sourcePath); -} - -function writeMigrationMarker(plan: DefaultSpaceMigrationPlan, movedPaths: string[]): string { - const migratedAt = new Date().toISOString(); - const registry = createDefaultSpaceRegistry("system"); - registry.$meta.updatedAt = migratedAt; - registry.defaultSpaceMigration = { - migratedAt, - sourceRoot: plan.sourceRoot, - destinationRoot: plan.destinationRoot, - movedPaths, - }; - - const markerPath = path.join(plan.sourceRoot, "config.json"); - writeJson(markerPath, registry); - return markerPath; -} - -export async function migrateDefaultSpaceInstall( - options: MigrateDefaultSpaceOptions = {}, -): Promise { - const plan = buildDefaultSpaceMigrationPlan(options); - - if (plan.sourcePathNames.length === 0) { - return { status: "noop", plan, movedPaths: [], markerPath: null }; - } - - assertMigrationPreflight(plan); - - const serverRunningCheck = options.serverRunningCheck ?? defaultServerRunningCheck; - if (!options.skipServerCheck && await serverRunningCheck(plan)) { - throw new Error( - `Cannot migrate default space while a Paperclip server appears to be running for ${plan.sourceRoot}. Stop Paperclip and retry.`, - ); - } - - if (options.dryRun) { - return { status: "dry_run", plan, movedPaths: plan.sourcePathNames, markerPath: null }; - } - - fs.mkdirSync(plan.destinationRoot, { recursive: true }); - const movedPaths: string[] = []; - - for (const pathName of plan.sourcePathNames) { - const sourcePath = path.join(plan.sourceRoot, pathName); - const destinationPath = path.join(plan.destinationRoot, pathName); - fs.mkdirSync(path.dirname(destinationPath), { recursive: true }); - - if (pathName === "config.json") { - moveConfigWithRewrittenPaths(sourcePath, destinationPath, plan.sourceRoot, plan.destinationRoot); - } else { - fs.renameSync(sourcePath, destinationPath); - } - movedPaths.push(pathName); - } - - const markerPath = writeMigrationMarker(plan, movedPaths); - return { status: "migrated", plan, movedPaths, markerPath }; -} - -function printMigrationResult(result: DefaultSpaceMigrationResult): void { - if (result.status === "noop") { - console.log(pc.dim(`No legacy default-space data found under ${result.plan.sourceRoot}.`)); - return; - } - - const verb = result.status === "dry_run" ? "Would migrate" : "Migrated"; - console.log(`${pc.green(verb)} default space:`); - console.log(` ${pc.dim("from")} ${result.plan.sourceRoot}`); - console.log(` ${pc.dim("to")} ${result.plan.destinationRoot}`); - for (const pathName of result.movedPaths) { - console.log(` ${pc.dim("-")} ${pathName}`); - } - if (result.markerPath) { - console.log(` ${pc.dim("marker")} ${result.markerPath}`); - } -} - -export function registerSpacesCommands(program: Command): void { - const spaces = program.command("spaces").description("Manage local Paperclip spaces"); - - spaces - .command("migrate-default") - .description("Offline migration from legacy root-shaped instance data into spaces/default") - .option("-i, --instance ", "Local instance id (default: default)") - .option("--dry-run", "Show the migration plan without moving files", false) - .option("--skip-server-check", "Skip the local /api/health preflight", false) - .action(async (opts: { instance?: string; dryRun?: boolean; skipServerCheck?: boolean }) => { - const result = await migrateDefaultSpaceInstall({ - instanceId: opts.instance, - dryRun: opts.dryRun, - skipServerCheck: opts.skipServerCheck, - }); - printMigrationResult(result); - }); -} diff --git a/cli/src/config/home.ts b/cli/src/config/home.ts index 0b93a70ed26..86f1ab4a2cb 100644 --- a/cli/src/config/home.ts +++ b/cli/src/config/home.ts @@ -11,26 +11,19 @@ import { resolvePaperclipHomeDir, resolvePaperclipInstanceId, resolvePaperclipInstanceRoot as resolveSharedPaperclipInstanceRoot, - resolvePaperclipSpaceId, - resolvePaperclipSpaceRoot as resolveSharedPaperclipSpaceRoot, -} from "@paperclipai/shared/space-paths"; +} from "@paperclipai/shared/home-paths"; export { expandHomePrefix, resolveHomeAwarePath, resolvePaperclipHomeDir, resolvePaperclipInstanceId, - resolvePaperclipSpaceId, }; export function resolvePaperclipInstanceRoot(instanceId?: string): string { return resolveSharedPaperclipInstanceRoot({ instanceId }); } -export function resolvePaperclipSpaceRoot(instanceId?: string): string { - return resolveSharedPaperclipSpaceRoot({ instanceId }); -} - export function resolveDefaultConfigPath(instanceId?: string): string { return resolvePaperclipConfigPathForInstance({ instanceId }); } @@ -66,17 +59,10 @@ export function resolveDefaultBackupDir(instanceId?: string): string { export function describeLocalInstancePaths(instanceId?: string) { const resolvedInstanceId = resolvePaperclipInstanceId(instanceId); const instanceRoot = resolvePaperclipInstanceRoot(resolvedInstanceId); - const activeSpaceId = resolvePaperclipSpaceId({ instanceId: resolvedInstanceId }); - const activeSpaceRoot = resolveSharedPaperclipSpaceRoot({ - instanceId: resolvedInstanceId, - spaceId: activeSpaceId, - }); return { homeDir: resolvePaperclipHomeDir(), instanceId: resolvedInstanceId, instanceRoot, - activeSpaceId, - activeSpaceRoot, configPath: resolveDefaultConfigPath(resolvedInstanceId), embeddedPostgresDataDir: resolveDefaultEmbeddedPostgresDir(resolvedInstanceId), backupDir: resolveDefaultBackupDir(resolvedInstanceId), diff --git a/cli/src/index.ts b/cli/src/index.ts index a718f02e409..f1a2084a727 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -25,7 +25,6 @@ import { initTelemetryFromConfigFile, flushTelemetry } from "./telemetry.js"; import { registerWorktreeCommands } from "./commands/worktree.js"; import { registerPluginCommands } from "./commands/client/plugin.js"; import { registerClientAuthCommands } from "./commands/client/auth.js"; -import { registerSpacesCommands } from "./commands/spaces.js"; import { cliVersion } from "./version.js"; const program = new Command(); @@ -153,7 +152,6 @@ registerSecretCommands(program); registerWorktreeCommands(program); registerEnvLabCommands(program); registerPluginCommands(program); -registerSpacesCommands(program); const auth = program.command("auth").description("Authentication and bootstrap utilities"); diff --git a/doc/CLI.md b/doc/CLI.md index bbc4bb871f4..3ce01142706 100644 --- a/doc/CLI.md +++ b/doc/CLI.md @@ -204,84 +204,41 @@ pnpm paperclipai heartbeat run --agent-id [--api-base http://localhos ## Local Storage Defaults -Local Paperclip data is split between an instance root and one or more spaces inside it. The default install creates a single space called `default` under `spaces/default`. +Local Paperclip data lives under the selected instance root. `PAPERCLIP_HOME` chooses the home directory and `PAPERCLIP_INSTANCE_ID` chooses the instance. ```text ~/.paperclip/ # PAPERCLIP_HOME └── instances/ └── default/ # instance root (PAPERCLIP_INSTANCE_ID) - ├── config.json # space registry (activeSpaceId, known spaces) - └── spaces/ - └── default/ # active space root (PAPERCLIP_SPACE_ID) - ├── config.json # runtime config for this space - ├── .env # space-scoped env file - ├── db/ # embedded PostgreSQL data - ├── data/ - │ ├── storage/ # local_disk uploads - │ └── backups/ # automatic DB backups - ├── logs/ - ├── secrets/ - │ └── master.key # local_encrypted master key - ├── workspaces/ # default agent workspaces - ├── projects/ # project execution workspaces - ├── companies/ # per-company adapter homes (e.g. codex-home) - └── codex-home/ # per-instance codex home (when not company-scoped) + ├── config.json # runtime config + ├── .env # instance env file + ├── db/ # embedded PostgreSQL data + ├── data/ + │ ├── storage/ # local_disk uploads + │ └── backups/ # automatic DB backups + ├── logs/ + ├── secrets/ + │ └── master.key # local_encrypted master key + ├── workspaces/ # default agent workspaces + ├── projects/ # project execution workspaces + ├── companies/ # per-company adapter homes (e.g. codex-home) + └── codex-home/ # per-instance codex home (when not company-scoped) ``` Default paths for the canonical install: -- instance registry: `~/.paperclip/instances/default/config.json` -- space config: `~/.paperclip/instances/default/spaces/default/config.json` -- embedded db: `~/.paperclip/instances/default/spaces/default/db` -- logs: `~/.paperclip/instances/default/spaces/default/logs` -- storage: `~/.paperclip/instances/default/spaces/default/data/storage` -- secrets key: `~/.paperclip/instances/default/spaces/default/secrets/master.key` +- config: `~/.paperclip/instances/default/config.json` +- embedded db: `~/.paperclip/instances/default/db` +- logs: `~/.paperclip/instances/default/logs` +- storage: `~/.paperclip/instances/default/data/storage` +- secrets key: `~/.paperclip/instances/default/secrets/master.key` -The instance root holds only cross-space metadata. All runtime state — database, storage, secrets, logs, agent workspaces — lives inside the active space. - -Override base home, instance, or active space with env vars: +Override base home or instance with env vars: ```sh PAPERCLIP_HOME=/custom/home PAPERCLIP_INSTANCE_ID=dev pnpm paperclipai run -PAPERCLIP_SPACE_ID=staging pnpm paperclipai run # use an alternate space inside the same instance ``` -`PAPERCLIP_SPACE_ID` overrides whatever `activeSpaceId` is recorded in the instance registry. When unset, Paperclip resolves the active space from `instances//config.json` and falls back to `default`. - -## Migrating a Legacy Default-Space Install - -Earlier Paperclip versions stored default-space data directly at the instance root (for example `~/.paperclip/instances/default/db`). Those installs continue to start through a compatibility resolver, but new code paths assume `spaces/default`. Migrate explicitly with: - -```sh -# Stop Paperclip first; the migration refuses to run while a local server is up. -pnpm paperclipai spaces migrate-default - -# Preview the move without touching files. -pnpm paperclipai spaces migrate-default --dry-run - -# Operate on a non-default instance. -pnpm paperclipai spaces migrate-default --instance dev -``` - -The command: - -- detects a legacy root-shaped install by inspecting `instances//config.json` -- preflight-checks `http://:/api/health` and refuses to run when the server responds -- refuses to merge if the destination already contains conflicting paths under `spaces/default/` -- moves only known space-owned paths: `config.json`, `.env`, `db`, `data`, `logs`, `secrets`, `workspaces`, `projects`, `companies`, and the legacy top-level `codex-home` -- rewrites absolute paths inside the migrated `config.json` from the instance root to the new space root (`embeddedPostgresDataDir`, backup `dir`, `logging.logDir`, `storage.localDisk.baseDir`, `secrets.localEncrypted.keyFilePath`) -- writes the registry/marker back at `instances//config.json` recording `activeSpaceId`, the migration timestamp, and the moved paths - -Available flags: - -| Option | Description | -|---|---| -| `-i, --instance ` | Local instance id (default: `default`) | -| `--dry-run` | Show the migration plan without moving files | -| `--skip-server-check` | Skip the `/api/health` preflight (only for offline installs) | - -Restart Paperclip after a successful migration. Subsequent commands resolve the active space root from the new registry automatically. - ## Storage Configuration Configure storage provider and settings: diff --git a/doc/DATABASE.md b/doc/DATABASE.md index 65886a62e7e..2e0ad661358 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -12,12 +12,12 @@ pnpm dev That's it. On first start the server: -1. Creates a `~/.paperclip/instances/default/spaces/default/db/` directory for storage +1. Creates a `~/.paperclip/instances/default/db/` directory for storage 2. Ensures the `paperclip` database exists 3. Runs migrations automatically for empty databases 4. Starts serving requests -Data persists across restarts in `~/.paperclip/instances/default/spaces/default/db/`. To reset local dev data, delete that directory. Older installs that still keep data at `~/.paperclip/instances/default/db/` continue to start through the legacy resolver — see [Migrating a Legacy Default-Space Install](CLI.md#migrating-a-legacy-default-space-install). +Data persists across restarts in `~/.paperclip/instances/default/db/`. To reset local dev data, delete that directory. If you need to apply pending migrations manually, run: @@ -137,7 +137,7 @@ The database mode is controlled by `DATABASE_URL`: | `DATABASE_URL` | Mode | |---|---| -| Not set | Embedded PostgreSQL (`~/.paperclip/instances/default/spaces/default/db/`) | +| Not set | Embedded PostgreSQL (`~/.paperclip/instances/default/db/`) | | `postgres://...localhost...` | Local Docker PostgreSQL | | `postgres://...supabase.com...` | Hosted Supabase | @@ -169,8 +169,8 @@ Paperclip stores secret metadata and versions in: For local/default installs, the active provider is `local_encrypted`: - Secret material is encrypted at rest with a local master key. -- Default key file: `~/.paperclip/instances/default/spaces/default/secrets/master.key` (auto-created if missing). -- CLI config location: `~/.paperclip/instances/default/spaces/default/config.json` under `secrets.localEncrypted.keyFilePath`. (The instance-level `~/.paperclip/instances/default/config.json` is the cross-space registry that records which space is active.) +- Default key file: `~/.paperclip/instances/default/secrets/master.key` (auto-created if missing). +- CLI config location: `~/.paperclip/instances/default/config.json` under `secrets.localEncrypted.keyFilePath`. - Backup/restore requires both the database metadata and the local master key file; either artifact alone is insufficient. - The server best-effort enforces `0600` key file permissions and provider health reports permission warnings. diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index eff89e061ae..f41465e2928 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -159,42 +159,36 @@ For a separate review-oriented container that keeps `codex`/`claude` login state ## Local Instance Layout -Every local install nests instance metadata, the active space, and runtime state under `~/.paperclip/`: +Every local install keeps runtime state directly under the selected instance root: ```text -~/.paperclip/instances/default/ # instance root (cross-space metadata only) - config.json # space registry (activeSpaceId, known spaces) - spaces/ - default/ # active space root - config.json # space runtime config - .env # space-scoped env file - db/ # embedded PostgreSQL data - data/ - storage/ # local_disk uploads - backups/ # automatic DB backups - logs/ - secrets/master.key # local_encrypted master key - workspaces// # default agent workspaces - projects/ # project execution workspaces - companies//codex-home/ # per-company codex_local home +~/.paperclip/instances/default/ # instance root + config.json # runtime config + .env # instance env file + db/ # embedded PostgreSQL data + data/ + storage/ # local_disk uploads + backups/ # automatic DB backups + logs/ + secrets/master.key # local_encrypted master key + workspaces// # default agent workspaces + projects/ # project execution workspaces + companies//codex-home/ # per-company codex_local home ``` -The instance root no longer doubles as the default space. New installs created by `paperclipai onboard`, `paperclipai run`, and `paperclipai configure` always write the registry at `instances//config.json` and put runtime data under `spaces/default/`. To migrate an older root-shaped install in place, see [Migrating a Legacy Default-Space Install](CLI.md#migrating-a-legacy-default-space-install). - -`PAPERCLIP_HOME`, `PAPERCLIP_INSTANCE_ID`, and `PAPERCLIP_SPACE_ID` override the home root, instance id, and active space respectively. `paperclipai onboard` echoes the resolved values in its banner (`Local home: | instance: | space: | config: `) so you can confirm where state will land before continuing. +`PAPERCLIP_HOME` and `PAPERCLIP_INSTANCE_ID` override the home root and instance id respectively. `paperclipai onboard` echoes the resolved values in its banner (`Local home: | instance: | config: `) so you can confirm where state will land before continuing. ## Database in Dev (Auto-Handled) For local development, leave `DATABASE_URL` unset. The server will automatically use embedded PostgreSQL and persist data at: -- `~/.paperclip/instances/default/spaces/default/db` +- `~/.paperclip/instances/default/db` -Override home, instance, or space: +Override home or instance: ```sh PAPERCLIP_HOME=/custom/path PAPERCLIP_INSTANCE_ID=dev pnpm paperclipai run -PAPERCLIP_SPACE_ID=staging pnpm paperclipai run ``` No Docker or external database is required for this mode. @@ -203,7 +197,7 @@ No Docker or external database is required for this mode. For local development, the default storage provider is `local_disk`, which persists uploaded images/attachments at: -- `~/.paperclip/instances/default/spaces/default/data/storage` +- `~/.paperclip/instances/default/data/storage` Configure storage provider/settings: @@ -213,15 +207,15 @@ pnpm paperclipai configure --section storage ## Default Agent Workspaces -When a local agent run has no resolved project/session workspace, Paperclip falls back to an agent home workspace under the active space: +When a local agent run has no resolved project/session workspace, Paperclip falls back to an agent home workspace under the instance root: -- `~/.paperclip/instances/default/spaces/default/workspaces/` +- `~/.paperclip/instances/default/workspaces/` -This path honors `PAPERCLIP_HOME`, `PAPERCLIP_INSTANCE_ID`, and `PAPERCLIP_SPACE_ID` in non-default setups. +This path honors `PAPERCLIP_HOME` and `PAPERCLIP_INSTANCE_ID` in non-default setups. -For `codex_local`, Paperclip also manages a per-company Codex home under the active space and seeds it from the shared Codex login/config home (`$CODEX_HOME` or `~/.codex`): +For `codex_local`, Paperclip also manages a per-company Codex home under the instance root and seeds it from the shared Codex login/config home (`$CODEX_HOME` or `~/.codex`): -- `~/.paperclip/instances/default/spaces/default/companies//codex-home` +- `~/.paperclip/instances/default/companies//codex-home` If the `codex` CLI is not installed or not on `PATH`, `codex_local` agent runs fail at execution time with a clear adapter error. Quota polling uses a short-lived `codex app-server` subprocess: when `codex` cannot be spawned, that provider reports `ok: false` in aggregated quota results and the API server keeps running (it must not exit on a missing binary). @@ -307,13 +301,13 @@ paperclipai worktree init --from-data-dir ~/.paperclip paperclipai worktree init --force ``` -Repair an already-created repo-managed worktree and reseed its isolated instance from the main default install. Point `--from-config` at the active space config (`spaces/default/config.json` for post-migration installs; the legacy `~/.paperclip/instances/default/config.json` still works on root-shaped installs that haven't run `paperclipai spaces migrate-default` yet): +Repair an already-created repo-managed worktree and reseed its isolated instance from the main default install. Point `--from-config` at the instance config: ```sh cd /path/to/paperclip/.paperclip/worktrees/PAP-884-ai-commits-component pnpm paperclipai worktree init --force --seed-mode minimal \ --name PAP-884-ai-commits-component \ - --from-config ~/.paperclip/instances/default/spaces/default/config.json + --from-config ~/.paperclip/instances/default/config.json ``` That rewrites the worktree-local `.paperclip/config.json` + `.paperclip/.env`, recreates the isolated instance under `~/.paperclip-worktrees/instances//`, and preserves the git worktree contents themselves. @@ -438,12 +432,10 @@ Expected: To wipe local dev data and start fresh: ```sh -rm -rf ~/.paperclip/instances/default/spaces/default/db +rm -rf ~/.paperclip/instances/default/db pnpm dev ``` -If you migrated from a legacy root-shaped install and still have a stale `~/.paperclip/instances/default/db`, remove that too — but only after confirming `paperclipai spaces migrate-default` ran and the active database lives under `spaces/default/`. - ## Optional: Use External Postgres If you set `DATABASE_URL`, the server will use that instead of embedded PostgreSQL. @@ -457,7 +449,7 @@ schemas. Defaults: - enabled - every 60 minutes - retain 30 days -- backup dir: `~/.paperclip/instances/default/spaces/default/data/backups` +- backup dir: `~/.paperclip/instances/default/data/backups` Configure these in: @@ -488,7 +480,7 @@ those providers are enabled. Agent env vars now support secret references. By default, secret values are stored with local encryption and only secret refs are persisted in agent config. -- Default local key path: `~/.paperclip/instances/default/spaces/default/secrets/master.key` +- Default local key path: `~/.paperclip/instances/default/secrets/master.key` - Override key material directly: `PAPERCLIP_SECRETS_MASTER_KEY` - Override key file path: `PAPERCLIP_SECRETS_MASTER_KEY_FILE` - Back up the key file and database together; either one alone is not enough to restore local encrypted secrets. diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 8ae2df78d66..22075e0453a 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -93,11 +93,11 @@ V1 implementation extends this baseline into a company-centric, governance-aware ## 6.2 Data Stores - Primary: PostgreSQL -- Local default: embedded PostgreSQL at `~/.paperclip/instances/default/spaces/default/db` +- Local default: embedded PostgreSQL at `~/.paperclip/instances/default/db` - Optional local prod-like: Docker Postgres - Optional hosted: Supabase/Postgres-compatible - File/object storage: - - local default: `~/.paperclip/instances/default/spaces/default/data/storage` (`local_disk`) + - local default: `~/.paperclip/instances/default/data/storage` (`local_disk`) - cloud: S3-compatible object storage (`s3`) ## 6.3 Background Processing @@ -785,7 +785,7 @@ Required UX behaviors: - Node 20+ - `DATABASE_URL` optional -- if unset, auto-use embedded PostgreSQL under `~/.paperclip/instances/default/spaces/default/db` +- if unset, auto-use embedded PostgreSQL under `~/.paperclip/instances/default/db` ## 15.2 Migrations diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index cbaf9002354..4624f6371bf 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -1,6 +1,6 @@ import { spawn, type ChildProcess } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; -import { constants as fsConstants, existsSync, promises as fs, readFileSync, type Dirent } from "node:fs"; +import { constants as fsConstants, promises as fs, type Dirent } from "node:fs"; import os from "node:os"; import path from "node:path"; import { sanitizeRemoteExecutionEnv } from "./remote-execution-env.js"; @@ -80,62 +80,7 @@ export const MAX_CAPTURE_BYTES = 4 * 1024 * 1024; export const MAX_EXCERPT_BYTES = 32 * 1024; const TERMINAL_RESULT_SCAN_OVERLAP_CHARS = 64 * 1024; const DEFAULT_PAPERCLIP_INSTANCE_ID = "default"; -const DEFAULT_PAPERCLIP_SPACE_ID = "default"; -const PAPERCLIP_CONFIG_BASENAME = "config.json"; const PATH_SEGMENT_RE = /^[a-zA-Z0-9_-]+$/; - -function readJsonIfPresent(filePath: string): unknown | null { - if (!existsSync(filePath)) return null; - try { - return JSON.parse(readFileSync(filePath, "utf8")); - } catch { - return null; - } -} - -function isObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function isPaperclipRuntimeConfig(value: unknown): boolean { - return isObject(value) && isObject(value.database) && isObject(value.server); -} - -export function resolvePaperclipSpaceRootForAdapter(input: { - homeDir?: string; - instanceId?: string; - spaceId?: string; - env?: NodeJS.ProcessEnv; -} = {}): string { - const env = input.env ?? process.env; - const homeRaw = input.homeDir?.trim() || env.PAPERCLIP_HOME?.trim(); - const expandedHome = - homeRaw === "~" - ? os.homedir() - : homeRaw?.startsWith("~/") - ? path.resolve(os.homedir(), homeRaw.slice(2)) - : homeRaw; - const homeDir = path.resolve(expandedHome || path.resolve(os.homedir(), ".paperclip")); - const instanceId = input.instanceId?.trim() || env.PAPERCLIP_INSTANCE_ID?.trim() || DEFAULT_PAPERCLIP_INSTANCE_ID; - if (!PATH_SEGMENT_RE.test(instanceId)) throw new Error(`Invalid PAPERCLIP_INSTANCE_ID '${instanceId}'.`); - - const instanceRoot = path.resolve(homeDir, "instances", instanceId); - const instanceConfigPath = path.resolve(instanceRoot, PAPERCLIP_CONFIG_BASENAME); - const instanceConfig = readJsonIfPresent(instanceConfigPath); - const registrySpaceId = isObject(instanceConfig) && !isPaperclipRuntimeConfig(instanceConfig) - ? instanceConfig.activeSpaceId - : null; - const spaceId = input.spaceId?.trim() || - env.PAPERCLIP_SPACE_ID?.trim() || - (typeof registrySpaceId === "string" && PATH_SEGMENT_RE.test(registrySpaceId) ? registrySpaceId : null) || - DEFAULT_PAPERCLIP_SPACE_ID; - if (!PATH_SEGMENT_RE.test(spaceId)) throw new Error(`Invalid PAPERCLIP_SPACE_ID '${spaceId}'.`); - - if (spaceId === DEFAULT_PAPERCLIP_SPACE_ID && isPaperclipRuntimeConfig(instanceConfig)) { - return instanceRoot; - } - return path.resolve(instanceRoot, "spaces", spaceId); -} const SENSITIVE_ENV_KEY = /(key|token|secret|password|passwd|authorization|cookie)/i; const REDACTED_LOG_VALUE = "***REDACTED***"; const PAPERCLIP_SKILL_ROOT_RELATIVE_CANDIDATES = [ @@ -146,6 +91,25 @@ const MATERIALIZED_SKILL_SENTINEL = ".paperclip-materialized-skill.json"; const MATERIALIZED_SKILL_LOCK_OWNER = "owner.json"; const MATERIALIZED_SKILL_LOCK_STALE_MS = 30_000; +function expandHomePrefix(value: string): string { + if (value === "~") return os.homedir(); + if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2)); + return value; +} + +export function resolvePaperclipInstanceRootForAdapter(input: { + homeDir?: string; + instanceId?: string; + env?: NodeJS.ProcessEnv; +} = {}): string { + const env = input.env ?? process.env; + const homeRaw = input.homeDir?.trim() || env.PAPERCLIP_HOME?.trim(); + const homeDir = path.resolve(homeRaw ? expandHomePrefix(homeRaw) : path.resolve(os.homedir(), ".paperclip")); + const instanceId = input.instanceId?.trim() || env.PAPERCLIP_INSTANCE_ID?.trim() || DEFAULT_PAPERCLIP_INSTANCE_ID; + if (!PATH_SEGMENT_RE.test(instanceId)) throw new Error(`Invalid PAPERCLIP_INSTANCE_ID '${instanceId}'.`); + return path.resolve(homeDir, "instances", instanceId); +} + export const DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE = [ "You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.", "", diff --git a/packages/adapters/acpx-local/src/server/execute.test.ts b/packages/adapters/acpx-local/src/server/execute.test.ts index ad88db57e0a..b009b1bcb13 100644 --- a/packages/adapters/acpx-local/src/server/execute.test.ts +++ b/packages/adapters/acpx-local/src/server/execute.test.ts @@ -188,8 +188,6 @@ describe("acpx_local runtime skill isolation", () => { paperclipHome, "instances", paperclipInstanceId, - "spaces", - "default", "companies", "company-1", "codex-home", diff --git a/packages/adapters/acpx-local/src/server/execute.ts b/packages/adapters/acpx-local/src/server/execute.ts index f1d0d637bfe..4914af4499e 100644 --- a/packages/adapters/acpx-local/src/server/execute.ts +++ b/packages/adapters/acpx-local/src/server/execute.ts @@ -21,8 +21,8 @@ import { readPaperclipIssueWorkModeFromContext, renderPaperclipWakePrompt, renderTemplate, + resolvePaperclipInstanceRootForAdapter, resolvePaperclipDesiredSkillNames, - resolvePaperclipSpaceRootForAdapter, rewriteWorkspaceCwdEnvVarsForExecution, shapePaperclipWorkspaceEnvForExecution, stringifyPaperclipWakePayload, @@ -116,10 +116,9 @@ function shortHash(value: unknown): string { function defaultPaperclipInstanceDir(): string { const home = process.env.PAPERCLIP_HOME?.trim() || path.join(os.homedir(), ".paperclip"); const instanceId = process.env.PAPERCLIP_INSTANCE_ID?.trim() || "default"; - return resolvePaperclipSpaceRootForAdapter({ + return resolvePaperclipInstanceRootForAdapter({ homeDir: home, instanceId, - spaceId: process.env.PAPERCLIP_SPACE_ID?.trim() || undefined, }); } diff --git a/packages/adapters/claude-local/src/server/claude-config.ts b/packages/adapters/claude-local/src/server/claude-config.ts index b323f6cc073..c7c0a88e529 100644 --- a/packages/adapters/claude-local/src/server/claude-config.ts +++ b/packages/adapters/claude-local/src/server/claude-config.ts @@ -3,7 +3,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import type { AdapterExecutionContext } from "@paperclipai/adapter-utils"; -import { resolvePaperclipSpaceRootForAdapter } from "@paperclipai/adapter-utils/server-utils"; +import { resolvePaperclipInstanceRootForAdapter } from "@paperclipai/adapter-utils/server-utils"; const SEEDED_SHARED_FILES = [ ".credentials.json", @@ -92,15 +92,14 @@ export function resolveManagedClaudeConfigSeedDir( env: NodeJS.ProcessEnv, companyId?: string, ): string { - const spaceRoot = resolvePaperclipSpaceRootForAdapter({ + const instanceRoot = resolvePaperclipInstanceRootForAdapter({ homeDir: nonEmpty(env.PAPERCLIP_HOME) ?? undefined, instanceId: nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? undefined, - spaceId: nonEmpty(env.PAPERCLIP_SPACE_ID) ?? undefined, env, }); return companyId - ? path.resolve(spaceRoot, "companies", companyId, "claude-config-seed") - : path.resolve(spaceRoot, "claude-config-seed"); + ? path.resolve(instanceRoot, "companies", companyId, "claude-config-seed") + : path.resolve(instanceRoot, "claude-config-seed"); } export async function prepareClaudeConfigSeed( diff --git a/packages/adapters/claude-local/src/server/prompt-cache.ts b/packages/adapters/claude-local/src/server/prompt-cache.ts index 91a1ad6f515..19c18809a53 100644 --- a/packages/adapters/claude-local/src/server/prompt-cache.ts +++ b/packages/adapters/claude-local/src/server/prompt-cache.ts @@ -5,7 +5,7 @@ import { createHash, type Hash } from "node:crypto"; import type { AdapterExecutionContext } from "@paperclipai/adapter-utils"; import { ensurePaperclipSkillSymlink, - resolvePaperclipSpaceRootForAdapter, + resolvePaperclipInstanceRootForAdapter, type PaperclipSkillEntry, } from "@paperclipai/adapter-utils/server-utils"; @@ -26,14 +26,13 @@ function resolveManagedClaudePromptCacheRoot( env: NodeJS.ProcessEnv, companyId: string, ): string { - const spaceRoot = resolvePaperclipSpaceRootForAdapter({ + const instanceRoot = resolvePaperclipInstanceRootForAdapter({ homeDir: nonEmpty(env.PAPERCLIP_HOME) ?? undefined, instanceId: nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? undefined, - spaceId: nonEmpty(env.PAPERCLIP_SPACE_ID) ?? undefined, env, }); return path.resolve( - spaceRoot, + instanceRoot, "companies", companyId, "claude-prompt-cache", diff --git a/packages/adapters/codex-local/src/server/codex-home.ts b/packages/adapters/codex-local/src/server/codex-home.ts index be9213e3326..0cb737bb28f 100644 --- a/packages/adapters/codex-local/src/server/codex-home.ts +++ b/packages/adapters/codex-local/src/server/codex-home.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import type { AdapterExecutionContext } from "@paperclipai/adapter-utils"; -import { resolvePaperclipSpaceRootForAdapter } from "@paperclipai/adapter-utils/server-utils"; +import { resolvePaperclipInstanceRootForAdapter } from "@paperclipai/adapter-utils/server-utils"; const TRUTHY_ENV_RE = /^(1|true|yes|on)$/i; const COPIED_SHARED_FILES = ["config.json", "config.toml", "instructions.md"] as const; @@ -31,15 +31,14 @@ export function resolveManagedCodexHomeDir( env: NodeJS.ProcessEnv, companyId?: string, ): string { - const spaceRoot = resolvePaperclipSpaceRootForAdapter({ + const instanceRoot = resolvePaperclipInstanceRootForAdapter({ homeDir: nonEmpty(env.PAPERCLIP_HOME) ?? undefined, instanceId: nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? undefined, - spaceId: nonEmpty(env.PAPERCLIP_SPACE_ID) ?? undefined, env, }); return companyId - ? path.resolve(spaceRoot, "companies", companyId, "codex-home") - : path.resolve(spaceRoot, "codex-home"); + ? path.resolve(instanceRoot, "companies", companyId, "codex-home") + : path.resolve(instanceRoot, "codex-home"); } async function ensureParentDir(target: string): Promise { diff --git a/packages/db/src/backup.ts b/packages/db/src/backup.ts index 40ab3e134e4..13633a70dca 100644 --- a/packages/db/src/backup.ts +++ b/packages/db/src/backup.ts @@ -5,7 +5,7 @@ import { expandHomePrefix, resolveDefaultBackupDir, resolvePaperclipConfigPathForInstance, -} from "@paperclipai/shared/space-paths"; +} from "@paperclipai/shared/home-paths"; type PartialConfig = { database?: { diff --git a/packages/db/src/runtime-config.test.ts b/packages/db/src/runtime-config.test.ts index a29248c7b08..ce5e58c9dbc 100644 --- a/packages/db/src/runtime-config.test.ts +++ b/packages/db/src/runtime-config.test.ts @@ -106,25 +106,23 @@ describe("resolveDatabaseTarget", () => { }); }); - it("uses spaces/default for a fresh default embedded postgres target", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-db-space-")); + it("uses the instance root for a fresh default embedded postgres target", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-db-home-")); const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-db-cwd-")); process.chdir(cwd); process.env.PAPERCLIP_HOME = home; delete process.env.PAPERCLIP_CONFIG; delete process.env.DATABASE_URL; - delete process.env.PAPERCLIP_INSTANCE_ID; - delete process.env.PAPERCLIP_SPACE_ID; const target = resolveDatabaseTarget(); expect(target).toMatchObject({ mode: "embedded-postgres", - dataDir: path.join(home, "instances", "default", "spaces", "default", "db"), + dataDir: path.join(home, "instances", "default", "db"), port: 54329, source: "embedded-postgres@54329", - configPath: path.join(home, "instances", "default", "spaces", "default", "config.json"), - envPath: path.join(home, "instances", "default", "spaces", "default", ".env"), + configPath: path.join(home, "instances", "default", "config.json"), + envPath: path.join(home, "instances", "default", ".env"), }); }); }); diff --git a/packages/db/src/runtime-config.ts b/packages/db/src/runtime-config.ts index 6e8740ef78d..3ad275c074f 100644 --- a/packages/db/src/runtime-config.ts +++ b/packages/db/src/runtime-config.ts @@ -5,7 +5,7 @@ import { resolveDefaultEmbeddedPostgresDir, resolvePaperclipConfigPathForInstance, resolvePaperclipEnvPathForConfig, -} from "@paperclipai/shared/space-paths"; +} from "@paperclipai/shared/home-paths"; const CONFIG_BASENAME = "config.json"; diff --git a/packages/shared/src/home-paths.test.ts b/packages/shared/src/home-paths.test.ts new file mode 100644 index 00000000000..81235c17c37 --- /dev/null +++ b/packages/shared/src/home-paths.test.ts @@ -0,0 +1,36 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + resolveDefaultBackupDir, + resolveDefaultEmbeddedPostgresDir, + resolveDefaultLogsDir, + resolveDefaultSecretsKeyFilePath, + resolveDefaultStorageDir, + resolvePaperclipConfigPathForInstance, + resolvePaperclipInstanceRoot, +} from "./home-paths.js"; + +const ORIGINAL_ENV = { ...process.env }; + +afterEach(() => { + process.env = { ...ORIGINAL_ENV }; +}); + +describe("home path resolution", () => { + it("resolves config and runtime data directly under the instance root", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-home-paths-")); + process.env.PAPERCLIP_HOME = home; + delete process.env.PAPERCLIP_INSTANCE_ID; + + const instanceRoot = path.join(home, "instances", "default"); + expect(resolvePaperclipInstanceRoot()).toBe(instanceRoot); + expect(resolvePaperclipConfigPathForInstance()).toBe(path.join(instanceRoot, "config.json")); + expect(resolveDefaultEmbeddedPostgresDir()).toBe(path.join(instanceRoot, "db")); + expect(resolveDefaultBackupDir()).toBe(path.join(instanceRoot, "data", "backups")); + expect(resolveDefaultLogsDir()).toBe(path.join(instanceRoot, "logs")); + expect(resolveDefaultStorageDir()).toBe(path.join(instanceRoot, "data", "storage")); + expect(resolveDefaultSecretsKeyFilePath()).toBe(path.join(instanceRoot, "secrets", "master.key")); + }); +}); diff --git a/packages/shared/src/home-paths.ts b/packages/shared/src/home-paths.ts new file mode 100644 index 00000000000..fa4f1c44065 --- /dev/null +++ b/packages/shared/src/home-paths.ts @@ -0,0 +1,92 @@ +import os from "node:os"; +import path from "node:path"; + +export const DEFAULT_PAPERCLIP_INSTANCE_ID = "default"; +export const PAPERCLIP_CONFIG_BASENAME = "config.json"; +export const PAPERCLIP_ENV_FILENAME = ".env"; + +const PATH_SEGMENT_RE = /^[a-zA-Z0-9_-]+$/; + +export function expandHomePrefix(value: string): string { + if (value === "~") return os.homedir(); + if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2)); + return value; +} + +export function resolvePaperclipHomeDir(homeOverride?: string): string { + const raw = homeOverride?.trim() || process.env.PAPERCLIP_HOME?.trim(); + if (raw) return path.resolve(expandHomePrefix(raw)); + return path.resolve(os.homedir(), ".paperclip"); +} + +export function resolvePaperclipInstanceId(instanceIdOverride?: string): string { + const raw = instanceIdOverride?.trim() || process.env.PAPERCLIP_INSTANCE_ID?.trim() || DEFAULT_PAPERCLIP_INSTANCE_ID; + if (!PATH_SEGMENT_RE.test(raw)) { + throw new Error(`Invalid PAPERCLIP_INSTANCE_ID '${raw}'.`); + } + return raw; +} + +export function resolvePaperclipInstanceRoot(input: { + homeDir?: string; + instanceId?: string; +} = {}): string { + return path.resolve(resolvePaperclipHomeDir(input.homeDir), "instances", resolvePaperclipInstanceId(input.instanceId)); +} + +export function resolvePaperclipInstanceConfigPath(input: { + homeDir?: string; + instanceId?: string; +} = {}): string { + return path.resolve(resolvePaperclipInstanceRoot(input), PAPERCLIP_CONFIG_BASENAME); +} + +export function resolvePaperclipConfigPathForInstance(input: { + homeDir?: string; + instanceId?: string; +} = {}): string { + return resolvePaperclipInstanceConfigPath(input); +} + +export function resolvePaperclipEnvPathForConfig(configPath: string): string { + return path.resolve(path.dirname(configPath), PAPERCLIP_ENV_FILENAME); +} + +export function resolveDefaultEmbeddedPostgresDir(input: { + homeDir?: string; + instanceId?: string; +} = {}): string { + return path.resolve(resolvePaperclipInstanceRoot(input), "db"); +} + +export function resolveDefaultLogsDir(input: { + homeDir?: string; + instanceId?: string; +} = {}): string { + return path.resolve(resolvePaperclipInstanceRoot(input), "logs"); +} + +export function resolveDefaultSecretsKeyFilePath(input: { + homeDir?: string; + instanceId?: string; +} = {}): string { + return path.resolve(resolvePaperclipInstanceRoot(input), "secrets", "master.key"); +} + +export function resolveDefaultStorageDir(input: { + homeDir?: string; + instanceId?: string; +} = {}): string { + return path.resolve(resolvePaperclipInstanceRoot(input), "data", "storage"); +} + +export function resolveDefaultBackupDir(input: { + homeDir?: string; + instanceId?: string; +} = {}): string { + return path.resolve(resolvePaperclipInstanceRoot(input), "data", "backups"); +} + +export function resolveHomeAwarePath(value: string): string { + return path.resolve(expandHomePrefix(value)); +} diff --git a/packages/shared/src/space-paths.test.ts b/packages/shared/src/space-paths.test.ts deleted file mode 100644 index bc213f02c4b..00000000000 --- a/packages/shared/src/space-paths.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { - createDefaultSpaceRegistry, - resolveDefaultEmbeddedPostgresDir, - resolvePaperclipConfigPathForInstance, - resolvePaperclipInstanceConfigPath, - resolvePaperclipInstanceRoot, - resolvePaperclipSpaceRoot, -} from "./space-paths.js"; - -const ORIGINAL_ENV = { ...process.env }; - -function writeJson(filePath: string, value: unknown) { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`); -} - -afterEach(() => { - process.env = { ...ORIGINAL_ENV }; -}); - -describe("space path resolution", () => { - it("resolves default-space config and data under spaces/default for fresh installs", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-space-paths-")); - process.env.PAPERCLIP_HOME = home; - delete process.env.PAPERCLIP_INSTANCE_ID; - delete process.env.PAPERCLIP_SPACE_ID; - - expect(resolvePaperclipConfigPathForInstance()).toBe( - path.join(home, "instances", "default", "spaces", "default", "config.json"), - ); - expect(resolveDefaultEmbeddedPostgresDir()).toBe( - path.join(home, "instances", "default", "spaces", "default", "db"), - ); - }); - - it("uses the active space pointer from the instance registry", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-space-registry-")); - process.env.PAPERCLIP_HOME = home; - process.env.PAPERCLIP_INSTANCE_ID = "default"; - delete process.env.PAPERCLIP_SPACE_ID; - writeJson(resolvePaperclipInstanceConfigPath(), { - ...createDefaultSpaceRegistry("system"), - activeSpaceId: "dev", - spaces: [{ id: "dev", root: "spaces/dev", createdAt: "2026-05-09T00:00:00.000Z" }], - }); - - expect(resolvePaperclipSpaceRoot()).toBe(path.join(home, "instances", "default", "spaces", "dev")); - expect(resolvePaperclipConfigPathForInstance()).toBe( - path.join(home, "instances", "default", "spaces", "dev", "config.json"), - ); - }); - - it("treats legacy root-shaped default installs as the active default space", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-space-legacy-")); - process.env.PAPERCLIP_HOME = home; - process.env.PAPERCLIP_INSTANCE_ID = "default"; - delete process.env.PAPERCLIP_SPACE_ID; - const legacyConfigPath = path.join(home, "instances", "default", "config.json"); - writeJson(legacyConfigPath, { - database: {}, - server: {}, - }); - - expect(resolvePaperclipSpaceRoot()).toBe(resolvePaperclipInstanceRoot()); - expect(resolvePaperclipConfigPathForInstance()).toBe(legacyConfigPath); - expect(resolveDefaultEmbeddedPostgresDir()).toBe( - path.join(home, "instances", "default", "db"), - ); - }); -}); diff --git a/packages/shared/src/space-paths.ts b/packages/shared/src/space-paths.ts deleted file mode 100644 index ba03ff236a7..00000000000 --- a/packages/shared/src/space-paths.ts +++ /dev/null @@ -1,247 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -export const DEFAULT_PAPERCLIP_INSTANCE_ID = "default"; -export const DEFAULT_PAPERCLIP_SPACE_ID = "default"; -export const PAPERCLIP_CONFIG_BASENAME = "config.json"; -export const PAPERCLIP_ENV_FILENAME = ".env"; -export const DEFAULT_SPACE_OWNED_PATH_NAMES = [ - PAPERCLIP_CONFIG_BASENAME, - PAPERCLIP_ENV_FILENAME, - "db", - "data", - "logs", - "secrets", - "workspaces", - "projects", - "companies", -] as const; -export const DEFAULT_SPACE_ADAPTER_LOCAL_PATH_NAMES = ["codex-home"] as const; - -const PATH_SEGMENT_RE = /^[a-zA-Z0-9_-]+$/; - -export interface PaperclipSpaceRegistry { - $meta: { - version: 1; - updatedAt: string; - source: "onboard" | "configure" | "doctor" | "system"; - }; - activeSpaceId: string; - spaces: Array<{ - id: string; - root: string; - createdAt: string; - }>; - defaultSpaceMigration?: { - migratedAt: string; - sourceRoot: string; - destinationRoot: string; - movedPaths: string[]; - }; -} - -export function expandHomePrefix(value: string): string { - if (value === "~") return os.homedir(); - if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2)); - return value; -} - -export function resolvePaperclipHomeDir(homeOverride?: string): string { - const raw = homeOverride?.trim() || process.env.PAPERCLIP_HOME?.trim(); - if (raw) return path.resolve(expandHomePrefix(raw)); - return path.resolve(os.homedir(), ".paperclip"); -} - -export function resolvePaperclipInstanceId(instanceIdOverride?: string): string { - const raw = instanceIdOverride?.trim() || process.env.PAPERCLIP_INSTANCE_ID?.trim() || DEFAULT_PAPERCLIP_INSTANCE_ID; - if (!PATH_SEGMENT_RE.test(raw)) { - throw new Error(`Invalid PAPERCLIP_INSTANCE_ID '${raw}'.`); - } - return raw; -} - -export function resolvePaperclipInstanceRoot(input: { - homeDir?: string; - instanceId?: string; -} = {}): string { - return path.resolve(resolvePaperclipHomeDir(input.homeDir), "instances", resolvePaperclipInstanceId(input.instanceId)); -} - -export function resolvePaperclipInstanceConfigPath(input: { - homeDir?: string; - instanceId?: string; -} = {}): string { - return path.resolve(resolvePaperclipInstanceRoot(input), PAPERCLIP_CONFIG_BASENAME); -} - -function readJsonIfPresent(filePath: string): unknown | null { - if (!fs.existsSync(filePath)) return null; - try { - return JSON.parse(fs.readFileSync(filePath, "utf8")); - } catch { - return null; - } -} - -function isObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -export function isPaperclipRuntimeConfig(value: unknown): boolean { - return isObject(value) && isObject(value.database) && isObject(value.server); -} - -function readActiveSpaceIdFromRegistry(instanceConfigPath: string): string | null { - const parsed = readJsonIfPresent(instanceConfigPath); - if (!isObject(parsed) || isPaperclipRuntimeConfig(parsed)) return null; - const activeSpaceId = parsed.activeSpaceId; - return typeof activeSpaceId === "string" && PATH_SEGMENT_RE.test(activeSpaceId) - ? activeSpaceId - : null; -} - -export function hasLegacyDefaultSpaceInstall(input: { - homeDir?: string; - instanceId?: string; -} = {}): boolean { - return isPaperclipRuntimeConfig(readJsonIfPresent(resolvePaperclipInstanceConfigPath(input))); -} - -export function resolvePaperclipSpaceId(input: { - homeDir?: string; - instanceId?: string; - spaceId?: string; -} = {}): string { - const raw = - input.spaceId?.trim() || - process.env.PAPERCLIP_SPACE_ID?.trim() || - readActiveSpaceIdFromRegistry(resolvePaperclipInstanceConfigPath(input)) || - DEFAULT_PAPERCLIP_SPACE_ID; - if (!PATH_SEGMENT_RE.test(raw)) { - throw new Error(`Invalid PAPERCLIP_SPACE_ID '${raw}'.`); - } - return raw; -} - -export function resolvePaperclipSpacesRoot(input: { - homeDir?: string; - instanceId?: string; -} = {}): string { - return path.resolve(resolvePaperclipInstanceRoot(input), "spaces"); -} - -export function resolvePaperclipSpaceRoot(input: { - homeDir?: string; - instanceId?: string; - spaceId?: string; -} = {}): string { - const spaceId = resolvePaperclipSpaceId(input); - if (spaceId === DEFAULT_PAPERCLIP_SPACE_ID && hasLegacyDefaultSpaceInstall(input)) { - return resolvePaperclipInstanceRoot(input); - } - return path.resolve(resolvePaperclipSpacesRoot(input), spaceId); -} - -export function resolvePaperclipSpaceConfigPath(input: { - homeDir?: string; - instanceId?: string; - spaceId?: string; -} = {}): string { - return path.resolve(resolvePaperclipSpaceRoot(input), PAPERCLIP_CONFIG_BASENAME); -} - -export function resolvePaperclipConfigPathForInstance(input: { - homeDir?: string; - instanceId?: string; - spaceId?: string; -} = {}): string { - const spaceConfigPath = resolvePaperclipSpaceConfigPath(input); - if (fs.existsSync(spaceConfigPath)) return spaceConfigPath; - - const instanceConfigPath = resolvePaperclipInstanceConfigPath(input); - if (isPaperclipRuntimeConfig(readJsonIfPresent(instanceConfigPath))) return instanceConfigPath; - - return spaceConfigPath; -} - -export function resolvePaperclipEnvPathForConfig(configPath: string): string { - return path.resolve(path.dirname(configPath), PAPERCLIP_ENV_FILENAME); -} - -export function resolveDefaultEmbeddedPostgresDir(input: { - homeDir?: string; - instanceId?: string; - spaceId?: string; -} = {}): string { - return path.resolve(resolvePaperclipSpaceRoot(input), "db"); -} - -export function resolveDefaultLogsDir(input: { - homeDir?: string; - instanceId?: string; - spaceId?: string; -} = {}): string { - return path.resolve(resolvePaperclipSpaceRoot(input), "logs"); -} - -export function resolveDefaultSecretsKeyFilePath(input: { - homeDir?: string; - instanceId?: string; - spaceId?: string; -} = {}): string { - return path.resolve(resolvePaperclipSpaceRoot(input), "secrets", "master.key"); -} - -export function resolveDefaultStorageDir(input: { - homeDir?: string; - instanceId?: string; - spaceId?: string; -} = {}): string { - return path.resolve(resolvePaperclipSpaceRoot(input), "data", "storage"); -} - -export function resolveDefaultBackupDir(input: { - homeDir?: string; - instanceId?: string; - spaceId?: string; -} = {}): string { - return path.resolve(resolvePaperclipSpaceRoot(input), "data", "backups"); -} - -export function createDefaultSpaceRegistry(source: PaperclipSpaceRegistry["$meta"]["source"]): PaperclipSpaceRegistry { - const now = new Date().toISOString(); - return { - $meta: { - version: 1, - updatedAt: now, - source, - }, - activeSpaceId: DEFAULT_PAPERCLIP_SPACE_ID, - spaces: [ - { - id: DEFAULT_PAPERCLIP_SPACE_ID, - root: path.join("spaces", DEFAULT_PAPERCLIP_SPACE_ID), - createdAt: now, - }, - ], - }; -} - -export function ensureDefaultSpaceRegistry(input: { - source: PaperclipSpaceRegistry["$meta"]["source"]; - homeDir?: string; - instanceId?: string; -}): string { - const registryPath = resolvePaperclipInstanceConfigPath(input); - if (fs.existsSync(registryPath)) return registryPath; - fs.mkdirSync(path.dirname(registryPath), { recursive: true }); - fs.writeFileSync(registryPath, `${JSON.stringify(createDefaultSpaceRegistry(input.source), null, 2)}\n`, { - mode: 0o600, - }); - return registryPath; -} - -export function resolveHomeAwarePath(value: string): string { - return path.resolve(expandHomePrefix(value)); -} diff --git a/server/src/__tests__/agent-instructions-service.test.ts b/server/src/__tests__/agent-instructions-service.test.ts index 567a5ed3f85..67eea3ca85d 100644 --- a/server/src/__tests__/agent-instructions-service.test.ts +++ b/server/src/__tests__/agent-instructions-service.test.ts @@ -69,8 +69,6 @@ describe("agent instructions service", () => { paperclipHome, "instances", "test-instance", - "spaces", - "default", "companies", "company-1", "agents", @@ -89,8 +87,6 @@ describe("agent instructions service", () => { paperclipHome, "instances", "test-instance", - "spaces", - "default", "companies", "company-1", "agents", @@ -176,8 +172,6 @@ describe("agent instructions service", () => { paperclipHome, "instances", "test-instance", - "spaces", - "default", "companies", "company-1", "agents", @@ -211,8 +205,6 @@ describe("agent instructions service", () => { paperclipHome, "instances", "test-instance", - "spaces", - "default", "companies", "company-1", "agents", @@ -256,8 +248,6 @@ describe("agent instructions service", () => { paperclipHome, "instances", "test-instance", - "spaces", - "default", "companies", "company-1", "agents", @@ -298,8 +288,6 @@ describe("agent instructions service", () => { paperclipHome, "instances", "test-instance", - "spaces", - "default", "companies", "company-1", "agents", @@ -342,8 +330,6 @@ describe("agent instructions service", () => { paperclipHome, "instances", "test-instance", - "spaces", - "default", "companies", "company-1", "agents", diff --git a/server/src/__tests__/claude-local-execute.test.ts b/server/src/__tests__/claude-local-execute.test.ts index 1173dabb57e..73eeb82554b 100644 --- a/server/src/__tests__/claude-local-execute.test.ts +++ b/server/src/__tests__/claude-local-execute.test.ts @@ -779,8 +779,6 @@ describe("claude execute", () => { paperclipHome, "instances", "default", - "spaces", - "default", "companies", "company-1", "claude-prompt-cache", diff --git a/server/src/__tests__/codex-local-execute.test.ts b/server/src/__tests__/codex-local-execute.test.ts index 54b4b3ca3f6..0f78f6773a1 100644 --- a/server/src/__tests__/codex-local-execute.test.ts +++ b/server/src/__tests__/codex-local-execute.test.ts @@ -104,8 +104,6 @@ describe("codex execute", () => { paperclipHome, "instances", "default", - "spaces", - "default", "companies", "company-1", "codex-home", @@ -1054,8 +1052,6 @@ describe("codex execute", () => { paperclipHome, "instances", "worktree-1", - "spaces", - "default", "companies", "company-1", "codex-home", diff --git a/server/src/home-paths.ts b/server/src/home-paths.ts index 0a5be471a9e..cf274a2a7ba 100644 --- a/server/src/home-paths.ts +++ b/server/src/home-paths.ts @@ -13,8 +13,7 @@ import { resolvePaperclipHomeDir, resolvePaperclipInstanceId, resolvePaperclipInstanceRoot, - resolvePaperclipSpaceRoot, -} from "@paperclipai/shared/space-paths"; +} from "@paperclipai/shared/home-paths"; export { expandHomePrefix, @@ -22,18 +21,10 @@ export { resolvePaperclipHomeDir, resolvePaperclipInstanceId, resolvePaperclipInstanceRoot, - resolvePaperclipSpaceRoot, }; -const defaultConfigPathCache = new Map(); - export function resolveDefaultConfigPath(): string { - const cacheKey = `${process.env.PAPERCLIP_HOME ?? ""}\0${process.env.PAPERCLIP_INSTANCE_ID ?? ""}`; - const cached = defaultConfigPathCache.get(cacheKey); - if (cached) return cached; - const resolved = resolvePaperclipConfigPathForInstance(); - defaultConfigPathCache.set(cacheKey, resolved); - return resolved; + return resolvePaperclipConfigPathForInstance(); } export function resolveDefaultEmbeddedPostgresDir(): string { @@ -61,7 +52,7 @@ export function resolveDefaultAgentWorkspaceDir(agentId: string): string { if (!PATH_SEGMENT_RE.test(trimmed)) { throw new Error(`Invalid agent id for workspace path '${agentId}'.`); } - return path.resolve(resolvePaperclipSpaceRoot(), "workspaces", trimmed); + return path.resolve(resolvePaperclipInstanceRoot(), "workspaces", trimmed); } function sanitizeFriendlyPathSegment(value: string | null | undefined, fallback = "_default"): string { @@ -84,7 +75,7 @@ export function resolveManagedProjectWorkspaceDir(input: { throw new Error("Managed project workspace path requires companyId and projectId."); } return path.resolve( - resolvePaperclipSpaceRoot(), + resolvePaperclipInstanceRoot(), "projects", sanitizeFriendlyPathSegment(companyId, "company"), sanitizeFriendlyPathSegment(projectId, "project"), diff --git a/server/src/services/agent-instructions.ts b/server/src/services/agent-instructions.ts index b6d3490e213..6dcbb38f047 100644 --- a/server/src/services/agent-instructions.ts +++ b/server/src/services/agent-instructions.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { notFound, unprocessable } from "../errors.js"; -import { resolveHomeAwarePath, resolvePaperclipSpaceRoot } from "../home-paths.js"; +import { resolveHomeAwarePath, resolvePaperclipInstanceRoot } from "../home-paths.js"; const ENTRY_FILE_DEFAULT = "AGENTS.md"; const MODE_KEY = "instructionsBundleMode"; @@ -132,7 +132,7 @@ function resolvePathWithinRoot(rootPath: string, relativePath: string): string { function resolveManagedInstructionsRoot(agent: AgentLike): string { return path.resolve( - resolvePaperclipSpaceRoot(), + resolvePaperclipInstanceRoot(), "companies", agent.companyId, "agents", diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index b5a66a7a1f7..0f5af2db4bb 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -27,7 +27,7 @@ import type { CompanySkillUsageAgent, } from "@paperclipai/shared"; import { normalizeAgentUrlKey } from "@paperclipai/shared"; -import { resolvePaperclipSpaceRoot } from "../home-paths.js"; +import { resolvePaperclipInstanceRoot } from "../home-paths.js"; import { notFound, unprocessable } from "../errors.js"; import { ghFetch, gitHubApiBase, resolveRawGitHubUrl } from "./github-fetch.js"; import { agentService } from "./agents.js"; @@ -1383,7 +1383,7 @@ export async function findMissingLocalSkillIds( } function resolveManagedSkillsRoot(companyId: string) { - return path.resolve(resolvePaperclipSpaceRoot(), "skills", companyId); + return path.resolve(resolvePaperclipInstanceRoot(), "skills", companyId); } function resolveLocalSkillFilePath(skill: CompanySkill, relativePath: string) { diff --git a/server/src/services/feedback.ts b/server/src/services/feedback.ts index befbb0ec864..4df0ca625b4 100644 --- a/server/src/services/feedback.ts +++ b/server/src/services/feedback.ts @@ -35,7 +35,7 @@ import { type FeedbackTraceTargetSummary, type FeedbackVoteValue, } from "@paperclipai/shared"; -import { resolveHomeAwarePath, resolvePaperclipSpaceRoot } from "../home-paths.js"; +import { resolveHomeAwarePath, resolvePaperclipInstanceRoot } from "../home-paths.js"; import { notFound, unprocessable } from "../errors.js"; import { agentInstructionsService } from "./agent-instructions.js"; import { @@ -391,7 +391,7 @@ async function buildCodexTraceFiles(input: { } const managedRoot = path.join( - resolvePaperclipSpaceRoot(), + resolvePaperclipInstanceRoot(), "companies", input.companyId, "codex-home", diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index 3aee7d2b3f1..a1f77290c38 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -927,24 +927,36 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) ].join("\n"); } - function isUniqueStaleRunEvaluationConflict(error: unknown) { - if (!error || typeof error !== "object") return false; - const maybe = error as { code?: string; constraint?: string; message?: string }; - return maybe.code === "23505" && - ( - maybe.constraint === "issues_active_stale_run_evaluation_uq" || - typeof maybe.message === "string" && maybe.message.includes("issues_active_stale_run_evaluation_uq") + function collectDatabaseErrorChain(error: unknown): Array<{ code?: string; constraint?: string; message?: string }> { + let current = error; + const seen = new Set(); + const chain: Array<{ code?: string; constraint?: string; message?: string }> = []; + while (current && typeof current === "object" && !seen.has(current)) { + seen.add(current); + const maybe = current as { code?: string; constraint?: string; message?: string; cause?: unknown }; + if (typeof maybe.code === "string" || typeof maybe.constraint === "string" || typeof maybe.message === "string") { + chain.push(maybe); + } + current = maybe.cause; + } + return chain; + } + + function isUniqueConstraintConflict(error: unknown, constraintName: string) { + const chain = collectDatabaseErrorChain(error); + return chain.some((entry) => entry.code === "23505") && + chain.some((entry) => + entry.constraint === constraintName || + typeof entry.message === "string" && entry.message.includes(constraintName) ); } + function isUniqueStaleRunEvaluationConflict(error: unknown) { + return isUniqueConstraintConflict(error, "issues_active_stale_run_evaluation_uq"); + } + function isUniqueStrandedIssueRecoveryConflict(error: unknown) { - if (!error || typeof error !== "object") return false; - const maybe = error as { code?: string; constraint?: string; message?: string }; - return maybe.code === "23505" && - ( - maybe.constraint === "issues_active_stranded_issue_recovery_uq" || - typeof maybe.message === "string" && maybe.message.includes("issues_active_stranded_issue_recovery_uq") - ); + return isUniqueConstraintConflict(error, "issues_active_stranded_issue_recovery_uq"); } async function ensureSourceIssueBlockedByStaleEvaluation(input: { diff --git a/server/src/services/run-log-store.ts b/server/src/services/run-log-store.ts index c00d33a418f..9c6a814287d 100644 --- a/server/src/services/run-log-store.ts +++ b/server/src/services/run-log-store.ts @@ -2,7 +2,7 @@ import { createReadStream, promises as fs } from "node:fs"; import path from "node:path"; import { createHash } from "node:crypto"; import { notFound } from "../errors.js"; -import { resolvePaperclipSpaceRoot } from "../home-paths.js"; +import { resolvePaperclipInstanceRoot } from "../home-paths.js"; export type RunLogStoreType = "local_file"; @@ -151,7 +151,7 @@ let cachedStore: RunLogStore | null = null; export function getRunLogStore() { if (cachedStore) return cachedStore; - const basePath = process.env.RUN_LOG_BASE_PATH ?? path.resolve(resolvePaperclipSpaceRoot(), "data", "run-logs"); + const basePath = process.env.RUN_LOG_BASE_PATH ?? path.resolve(resolvePaperclipInstanceRoot(), "data", "run-logs"); cachedStore = createLocalFileRunLogStore(basePath); return cachedStore; } diff --git a/server/src/services/workspace-operation-log-store.ts b/server/src/services/workspace-operation-log-store.ts index 4695014fe52..379dab62d2c 100644 --- a/server/src/services/workspace-operation-log-store.ts +++ b/server/src/services/workspace-operation-log-store.ts @@ -2,7 +2,7 @@ import { createReadStream, promises as fs } from "node:fs"; import path from "node:path"; import { createHash } from "node:crypto"; import { notFound } from "../errors.js"; -import { resolvePaperclipSpaceRoot } from "../home-paths.js"; +import { resolvePaperclipInstanceRoot } from "../home-paths.js"; export type WorkspaceOperationLogStoreType = "local_file"; @@ -150,7 +150,7 @@ let cachedStore: WorkspaceOperationLogStore | null = null; export function getWorkspaceOperationLogStore() { if (cachedStore) return cachedStore; const basePath = process.env.WORKSPACE_OPERATION_LOG_BASE_PATH - ?? path.resolve(resolvePaperclipSpaceRoot(), "data", "workspace-operation-logs"); + ?? path.resolve(resolvePaperclipInstanceRoot(), "data", "workspace-operation-logs"); cachedStore = createLocalFileWorkspaceOperationLogStore(basePath); return cachedStore; }