mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
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 <noreply@paperclip.ing>
This commit is contained in:
1 parent
6bd30232c6
commit
ab22821204
36 files changed
+278
-1106
No files matched your search
@@ -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"),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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.`);
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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, {
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
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<boolean>;
|
||||
}
|
||||
|
||||
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<MigrateDefaultSpaceOptions, "instanceId"> = {}): 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<boolean> {
|
||||
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<DefaultSpaceMigrationResult> {
|
||||
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 <id>", "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);
|
||||
});
|
||||
}
|
||||
+1
-15
@@ -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),
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
+20
-63
@@ -204,84 +204,41 @@ pnpm paperclipai heartbeat run --agent-id <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/<id>/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/<id>/config.json`
|
||||
- preflight-checks `http://<server.host>:<server.port>/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/<id>/config.json` recording `activeSpaceId`, the migration timestamp, and the moved paths
|
||||
|
||||
Available flags:
|
||||
|
||||
| Option | Description |
|
||||
|---|---|
|
||||
| `-i, --instance <id>` | 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:
|
||||
|
||||
+5
-5
@@ -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.
|
||||
|
||||
|
||||
+27
-35
@@ -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/<agent-id>/ # default agent workspaces
|
||||
projects/ # project execution workspaces
|
||||
companies/<company-id>/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/<agent-id>/ # default agent workspaces
|
||||
projects/ # project execution workspaces
|
||||
companies/<company-id>/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/<id>/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: <home> | instance: <id> | space: <id> | config: <path>`) 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: <home> | instance: <id> | config: <path>`) 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/<agent-id>`
|
||||
- `~/.paperclip/instances/default/workspaces/<agent-id>`
|
||||
|
||||
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/<company-id>/codex-home`
|
||||
- `~/.paperclip/instances/default/companies/<company-id>/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/<worktree-id>/`, 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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.",
|
||||
"",
|
||||
|
||||
@@ -188,8 +188,6 @@ describe("acpx_local runtime skill isolation", () => {
|
||||
paperclipHome,
|
||||
"instances",
|
||||
paperclipInstanceId,
|
||||
"spaces",
|
||||
"default",
|
||||
"companies",
|
||||
"company-1",
|
||||
"codex-home",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<void> {
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
expandHomePrefix,
|
||||
resolveDefaultBackupDir,
|
||||
resolvePaperclipConfigPathForInstance,
|
||||
} from "@paperclipai/shared/space-paths";
|
||||
} from "@paperclipai/shared/home-paths";
|
||||
|
||||
type PartialConfig = {
|
||||
database?: {
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
resolveDefaultEmbeddedPostgresDir,
|
||||
resolvePaperclipConfigPathForInstance,
|
||||
resolvePaperclipEnvPathForConfig,
|
||||
} from "@paperclipai/shared/space-paths";
|
||||
} from "@paperclipai/shared/home-paths";
|
||||
|
||||
const CONFIG_BASENAME = "config.json";
|
||||
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> {
|
||||
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));
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -779,8 +779,6 @@ describe("claude execute", () => {
|
||||
paperclipHome,
|
||||
"instances",
|
||||
"default",
|
||||
"spaces",
|
||||
"default",
|
||||
"companies",
|
||||
"company-1",
|
||||
"claude-prompt-cache",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string, string>();
|
||||
|
||||
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"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<unknown>();
|
||||
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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user