diff --git a/.agents/skills/documentation-guide/SKILL.md b/.agents/skills/documentation-guide/SKILL.md
index 03bcb75..a62f4c0 100644
--- a/.agents/skills/documentation-guide/SKILL.md
+++ b/.agents/skills/documentation-guide/SKILL.md
@@ -81,6 +81,7 @@ If the content needs multiple sentences of caveats, explains how an internal mec
## Writing principles
- Lead with the user outcome, then the command or action needed.
+- State what the software does rather than listing what it does not do; reserve "does not" statements for the rare case where behavior genuinely contradicts the most obvious expectation.
- Prefer concrete guidance over internal type, module, or orchestration terminology.
- Explain implementation details only when they help users make a decision or recover from a failure.
- Distinguish supported behavior from recommendations and prospective behavior.
diff --git a/.changeset/remove-legacy-archive-migration.md b/.changeset/remove-legacy-archive-migration.md
new file mode 100644
index 0000000..b6dd83e
--- /dev/null
+++ b/.changeset/remove-legacy-archive-migration.md
@@ -0,0 +1,7 @@
+---
+"@jmfederico/pi-web": patch
+---
+
+Remove the legacy session archive migration from session daemon startup. Each `PI_WEB_DATA_DIR` data directory is independent: pointing PI WEB at a new data directory starts there with empty registries and no session archives.
+
+You are only affected if you have session archives created before July 2026 in the default `~/.pi-web` data directory and you newly set a custom `PI_WEB_DATA_DIR`. To carry those archives over, stop PI WEB, then copy `archived-sessions.json` and the `archived-sessions/` directory from the old data directory into the new one.
diff --git a/docs/config.html b/docs/config.html
index da45276..aff0352 100644
--- a/docs/config.html
+++ b/docs/config.html
@@ -415,7 +415,7 @@
PI_WEB_DATA_DIR |
Process/env |
Not supported locally |
- Restart services before changing; moves managed state location |
+ Restart services before changing; existing state stays in the previous data directory |
| Session daemon socket |
diff --git a/docs/config.md b/docs/config.md
index f28cf05..fbf97a2 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -141,6 +141,8 @@ Rows with JSON key `—` are runtime-only environment variables, not config-file
`PI_WEB_DATA_DIR` sets the root for PI WEB-managed runtime state and defaults to `~/.pi-web`. Unless a more specific path override is configured, PI WEB stores its project and machine registries, locally discovered plugins, default session-daemon socket, and session archives beneath this root.
+Each data directory is independent: after pointing PI WEB at a new root, it starts there with empty registries and no session archives. To carry session archives over, stop PI WEB, then copy `archived-sessions.json` and the `archived-sessions/` directory from the old data directory into the new one before starting it again.
+
This setting does not change the PI WEB config file selected by `PI_WEB_CONFIG` or Pi-owned state such as the active session files selected by `PI_CODING_AGENT_SESSION_DIR`.
### External path access
diff --git a/src/server/sessiond.ts b/src/server/sessiond.ts
index 45d8311..456c3f7 100644
--- a/src/server/sessiond.ts
+++ b/src/server/sessiond.ts
@@ -27,7 +27,6 @@ import { getPiWebRuntimeComponent } from "./piWebStatus.js";
import { SESSIOND_RUNTIME_CAPABILITIES } from "../shared/capabilities.js";
import { agentSessionDirEnvKeys, effectivePiWebConfig, maxUploadBytes, offlineModeEnabled } from "../config.js";
import { createActiveAgentProfileDescriptor } from "../sessiond/activeAgentProfile.js";
-import { runSessionDaemonStartup } from "./sessiond/sessionDaemonStartup.js";
import { sessionServiceDependencies } from "./sessiond/sessionServiceDependencies.js";
const daemonEnvironment: NodeJS.ProcessEnv = Object.freeze({ ...process.env });
@@ -40,125 +39,130 @@ const activeAgentProfile = createActiveAgentProfileDescriptor({
const app = Fastify({ logger: true, bodyLimit: maxUploadBytes(daemonEnvironment, config) });
await app.register(fastifyWebsocket);
-await runSessionDaemonStartup({
- logger: app.log,
- async createRuntime() {
- const eventHub = new SessionEventHub();
- const notificationStore = new SessionNotificationStore();
- const unreadStore = new SessionUnreadStore({
- persistence: new FileSessionUnreadPersistence(),
- onPersistenceError(operation, error) {
- app.log.error({ err: error, operation }, "session unread persistence failed");
- },
- });
- await unreadStore.load();
- const workspaceActivity = new WorkspaceActivityService(eventHub);
- const auth = await AuthService.create({ agentDir: activeAgentProfile.dir, logger: app.log });
- // Capture providers registered by global extensions while the runtime is
- // still mutable, then freeze every later extension-provider mutation before
- // any real session can load project resources.
- await bootstrapAndFreezeGlobalExtensionProviders(auth.runtime, activeAgentProfile.dir, app.log);
- // The shared model runtime is constructed offline so request paths never
- // wait on provider-catalog fetches; this is the single bounded network
- // refresher, and auth changes (login/logout) ask it for a prompt run. It
- // stays fully inert when the operator asked for offline behavior.
- const catalogRefresher = new ModelCatalogRefresher({
- runtime: auth.runtime,
- logger: app.log,
- offline: offlineModeEnabled(daemonEnvironment),
- });
- catalogRefresher.start();
- auth.subscribe(() => { catalogRefresher.requestRefresh(); });
- // Cross-workspace session relationships are reported regardless of whether
- // agents may spawn sessions: children can predate a config change, and the
- // session tree should stay honest about them either way.
- const projectWorkspaceDeps = { projects: new ProjectService(new ProjectStore()), workspaces: new WorkspaceService() };
- const projectWorkspaces = new RegisteredProjectWorkspaceCwds(projectWorkspaceDeps);
- const spawnTargets = config.spawnSessions ? new ProjectScopedSpawnTargetResolver(projectWorkspaceDeps) : undefined;
- const sessions = new PiSessionService(eventHub, sessionServiceDependencies({
- modelRuntime: auth.runtime,
+const runtime = await createSessionDaemonRuntime();
+registerSessionDaemonRoutes(runtime);
+await listenSessionDaemon(runtime);
+
+type SessionDaemonRuntime = Awaited>;
+
+async function createSessionDaemonRuntime() {
+ const eventHub = new SessionEventHub();
+ const notificationStore = new SessionNotificationStore();
+ const unreadStore = new SessionUnreadStore({
+ persistence: new FileSessionUnreadPersistence(),
+ onPersistenceError(operation, error) {
+ app.log.error({ err: error, operation }, "session unread persistence failed");
+ },
+ });
+ await unreadStore.load();
+ const workspaceActivity = new WorkspaceActivityService(eventHub);
+ const auth = await AuthService.create({ agentDir: activeAgentProfile.dir, logger: app.log });
+ // Capture providers registered by global extensions while the runtime is
+ // still mutable, then freeze every later extension-provider mutation before
+ // any real session can load project resources.
+ await bootstrapAndFreezeGlobalExtensionProviders(auth.runtime, activeAgentProfile.dir, app.log);
+ // The shared model runtime is constructed offline so request paths never
+ // wait on provider-catalog fetches; this is the single bounded network
+ // refresher, and auth changes (login/logout) ask it for a prompt run. It
+ // stays fully inert when the operator asked for offline behavior.
+ const catalogRefresher = new ModelCatalogRefresher({
+ runtime: auth.runtime,
+ logger: app.log,
+ offline: offlineModeEnabled(daemonEnvironment),
+ });
+ catalogRefresher.start();
+ auth.subscribe(() => { catalogRefresher.requestRefresh(); });
+ // Cross-workspace session relationships are reported regardless of whether
+ // agents may spawn sessions: children can predate a config change, and the
+ // session tree should stay honest about them either way.
+ const projectWorkspaceDeps = { projects: new ProjectService(new ProjectStore()), workspaces: new WorkspaceService() };
+ const projectWorkspaces = new RegisteredProjectWorkspaceCwds(projectWorkspaceDeps);
+ const spawnTargets = config.spawnSessions ? new ProjectScopedSpawnTargetResolver(projectWorkspaceDeps) : undefined;
+ const sessions = new PiSessionService(eventHub, sessionServiceDependencies({
+ modelRuntime: auth.runtime,
+ agentDir: activeAgentProfile.dir,
+ workspaceActivity,
+ logger: app.log,
+ ...(spawnTargets === undefined ? {} : { spawnTargets }),
+ projectWorkspaces,
+ subsessionsEnabled: config.subsessions,
+ askUserEnabled: config.askUser,
+ notificationStore,
+ unreadStore,
+ catalogRefreshStatus: catalogRefresher,
+ sessionManager: createPiSessionManagerGateway({
agentDir: activeAgentProfile.dir,
- workspaceActivity,
- logger: app.log,
- ...(spawnTargets === undefined ? {} : { spawnTargets }),
- projectWorkspaces,
- subsessionsEnabled: config.subsessions,
- askUserEnabled: config.askUser,
- notificationStore,
- unreadStore,
- catalogRefreshStatus: catalogRefresher,
- sessionManager: createPiSessionManagerGateway({
- agentDir: activeAgentProfile.dir,
- env: daemonEnvironment,
- sessionDirEnvKeys: activeAgentProfile.sessionDirEnvKeys,
- }),
- }));
- auth.subscribe((change) => { sessions.applyAuthChange(change); });
- const terminals = new TerminalService(eventHub, workspaceActivity);
- const runtimeComponent = Object.freeze({
- ...getPiWebRuntimeComponent("sessiond", SESSIOND_RUNTIME_CAPABILITIES),
- activeAgentProfile,
- });
- return { eventHub, workspaceActivity, auth, sessions, terminals, unreadStore, activeAgentProfile, runtimeComponent, catalogRefresher };
- },
- registerRoutes({ eventHub, workspaceActivity, auth, sessions, terminals, runtimeComponent }) {
- registerWorkspaceActivityRoutes(app, workspaceActivity);
- registerAuthRoutes(app, auth);
- registerSessionRoutes(app, sessions, eventHub);
- registerTerminalRoutes(app, terminals);
+ env: daemonEnvironment,
+ sessionDirEnvKeys: activeAgentProfile.sessionDirEnvKeys,
+ }),
+ }));
+ auth.subscribe((change) => { sessions.applyAuthChange(change); });
+ const terminals = new TerminalService(eventHub, workspaceActivity);
+ const runtimeComponent = Object.freeze({
+ ...getPiWebRuntimeComponent("sessiond", SESSIOND_RUNTIME_CAPABILITIES),
+ activeAgentProfile,
+ });
+ return { eventHub, workspaceActivity, auth, sessions, terminals, unreadStore, activeAgentProfile, runtimeComponent, catalogRefresher };
+}
- app.get("/health", () => ({
- ok: true,
- activeSessions: sessions.activeCount(),
- checkedAt: new Date().toISOString(),
- version: {
- component: runtimeComponent.component,
- label: runtimeComponent.label,
- ...(runtimeComponent.runtimeVersion === undefined ? {} : { runtimeVersion: runtimeComponent.runtimeVersion }),
- stale: false,
- available: runtimeComponent.available,
- },
- }));
+function registerSessionDaemonRoutes({ eventHub, workspaceActivity, auth, sessions, terminals, runtimeComponent }: SessionDaemonRuntime): void {
+ registerWorkspaceActivityRoutes(app, workspaceActivity);
+ registerAuthRoutes(app, auth);
+ registerSessionRoutes(app, sessions, eventHub);
+ registerTerminalRoutes(app, terminals);
- app.get("/runtime", () => runtimeComponent);
- },
- async listen({ auth, sessions, terminals, unreadStore, catalogRefresher }) {
- let shuttingDown = false;
- async function shutdown(signal: NodeJS.Signals): Promise {
- if (shuttingDown) return;
- shuttingDown = true;
- app.log.info({ signal }, "shutting down session daemon");
- const attempt = async (operation: string, run: () => void | Promise): Promise => {
- try {
- await run();
- } catch (error: unknown) {
- process.exitCode = 1;
- app.log.error({ err: error, operation }, "session daemon shutdown operation failed");
- }
- };
- await attempt("dispose terminals", () => { terminals.dispose(); });
- await attempt("dispose catalog refresher", () => { catalogRefresher.dispose(); });
- await attempt("dispose auth", () => { auth.dispose(); });
- await attempt("dispose sessions", () => sessions.dispose());
- await attempt("flush session unread state", () => unreadStore.flush());
- await attempt("close server", () => app.close());
- }
+ app.get("/health", () => ({
+ ok: true,
+ activeSessions: sessions.activeCount(),
+ checkedAt: new Date().toISOString(),
+ version: {
+ component: runtimeComponent.component,
+ label: runtimeComponent.label,
+ ...(runtimeComponent.runtimeVersion === undefined ? {} : { runtimeVersion: runtimeComponent.runtimeVersion }),
+ stale: false,
+ available: runtimeComponent.available,
+ },
+ }));
- process.once("SIGINT", (signal) => { void shutdown(signal); });
- process.once("SIGTERM", (signal) => { void shutdown(signal); });
+ app.get("/runtime", () => runtimeComponent);
+}
- const portValue = daemonEnvironment["PI_WEB_SESSIOND_PORT"];
- const port = portValue !== undefined && portValue !== "" ? Number(portValue) : undefined;
- const host = daemonEnvironment["PI_WEB_SESSIOND_HOST"] ?? "127.0.0.1";
+async function listenSessionDaemon({ auth, sessions, terminals, unreadStore, catalogRefresher }: SessionDaemonRuntime): Promise {
+ let shuttingDown = false;
+ async function shutdown(signal: NodeJS.Signals): Promise {
+ if (shuttingDown) return;
+ shuttingDown = true;
+ app.log.info({ signal }, "shutting down session daemon");
+ const attempt = async (operation: string, run: () => void | Promise): Promise => {
+ try {
+ await run();
+ } catch (error: unknown) {
+ process.exitCode = 1;
+ app.log.error({ err: error, operation }, "session daemon shutdown operation failed");
+ }
+ };
+ await attempt("dispose terminals", () => { terminals.dispose(); });
+ await attempt("dispose catalog refresher", () => { catalogRefresher.dispose(); });
+ await attempt("dispose auth", () => { auth.dispose(); });
+ await attempt("dispose sessions", () => sessions.dispose());
+ await attempt("flush session unread state", () => unreadStore.flush());
+ await attempt("close server", () => app.close());
+ }
- if (port !== undefined) {
- await app.listen({ port, host });
- } else {
- const path = sessiondSocketPath();
- await mkdir(dirname(path), { recursive: true });
- await rm(path, { force: true });
- await app.listen({ path });
- process.on("exit", () => void rm(path, { force: true }));
- }
- },
-});
+ process.once("SIGINT", (signal) => { void shutdown(signal); });
+ process.once("SIGTERM", (signal) => { void shutdown(signal); });
+
+ const portValue = daemonEnvironment["PI_WEB_SESSIOND_PORT"];
+ const port = portValue !== undefined && portValue !== "" ? Number(portValue) : undefined;
+ const host = daemonEnvironment["PI_WEB_SESSIOND_HOST"] ?? "127.0.0.1";
+
+ if (port !== undefined) {
+ await app.listen({ port, host });
+ } else {
+ const path = sessiondSocketPath();
+ await mkdir(dirname(path), { recursive: true });
+ await rm(path, { force: true });
+ await app.listen({ path });
+ process.on("exit", () => void rm(path, { force: true }));
+ }
+}
diff --git a/src/server/sessiond/sessionDaemonStartup.test.ts b/src/server/sessiond/sessionDaemonStartup.test.ts
deleted file mode 100644
index 6296131..0000000
--- a/src/server/sessiond/sessionDaemonStartup.test.ts
+++ /dev/null
@@ -1,261 +0,0 @@
-import { existsSync, readFileSync } from "node:fs";
-import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { afterEach, describe, expect, it, vi } from "vitest";
-import {
- migrateLegacySessionArchive,
- type LegacySessionArchiveMigrationOptions,
- type LegacySessionArchiveMigrationResult,
-} from "../sessions/sessionArchiveMigration.js";
-import { runSessionDaemonStartup } from "./sessionDaemonStartup.js";
-
-const tempRoots: string[] = [];
-
-describe("session daemon archive migration startup", () => {
- afterEach(async () => {
- await Promise.all(tempRoots.splice(0).map((path) => rm(path, { recursive: true, force: true })));
- });
-
- it("finishes an eligible migration before constructing PiSessionService, registering archive routes, or listening", async () => {
- const fixture = await createLegacyArchiveFixture();
- const logger = createLogger();
- const boundaries: string[] = [];
- const runtime = { ready: true };
-
- const startedRuntime = await runSessionDaemonStartup({
- logger,
- migrateArchive: () => migrateLegacySessionArchive(fixture.options),
- createRuntime() {
- boundaries.push("construct-session-service");
- expectMigrationComplete(fixture);
- return runtime;
- },
- registerRoutes(createdRuntime) {
- boundaries.push("register-archive-routes");
- expect(createdRuntime).toBe(runtime);
- expectMigrationComplete(fixture);
- },
- listen(createdRuntime) {
- boundaries.push("listen");
- expect(createdRuntime).toBe(runtime);
- expectMigrationComplete(fixture);
- return Promise.resolve();
- },
- });
-
- expect(startedRuntime).toBe(runtime);
- expect(boundaries).toEqual([
- "construct-session-service",
- "register-archive-routes",
- "listen",
- ]);
- expect(logger.info).toHaveBeenCalledWith(
- { archiveFileCount: 1 },
- "migrated legacy session archive to the configured PI_WEB_DATA_DIR",
- );
- });
-
- it("does not initialize archive consumers while a mutation-free eligibility check is pending", async () => {
- const logger = createLogger();
- const migration = deferred();
- const boundaries: string[] = [];
-
- const startup = runSessionDaemonStartup({
- logger,
- migrateArchive: () => migration.promise,
- createRuntime() {
- boundaries.push("construct-session-service");
- return { ready: true };
- },
- registerRoutes() {
- boundaries.push("register-archive-routes");
- },
- listen() {
- boundaries.push("listen");
- return Promise.resolve();
- },
- });
-
- expect(boundaries).toEqual([]);
- migration.resolve({ status: "skipped", reason: "legacy-index-missing" });
- await startup;
-
- expect(boundaries).toEqual([
- "construct-session-service",
- "register-archive-routes",
- "listen",
- ]);
- expect(logger.debug).toHaveBeenCalledWith(
- { reason: "legacy-index-missing" },
- "legacy session archive migration is not eligible; continuing session daemon startup",
- );
- });
-
- it("warns and continues normal startup when eligibility inspection is inconclusive", async () => {
- const logger = createLogger();
- const inspectionError = Object.assign(new Error("permission denied"), { code: "EACCES" });
- const createRuntime = vi.fn(() => ({ ready: true }));
- const registerRoutes = vi.fn();
- const listen = vi.fn(() => Promise.resolve());
-
- await runSessionDaemonStartup({
- logger,
- migrateArchive: () => Promise.resolve({
- status: "skipped",
- reason: "inspection-failed",
- error: inspectionError,
- }),
- createRuntime,
- registerRoutes,
- listen,
- });
-
- expect(logger.warn).toHaveBeenCalledWith(
- { err: inspectionError, reason: "inspection-failed" },
- "could not inspect legacy session archive migration eligibility; continuing session daemon startup without migration",
- );
- expect(createRuntime).toHaveBeenCalledOnce();
- expect(registerRoutes).toHaveBeenCalledOnce();
- expect(listen).toHaveBeenCalledOnce();
- });
-
- it("logs an eligible migration failure and stops before archive consumers can mutate destination state", async () => {
- const logger = createLogger();
- const migrationError = new Error("copy failed");
- const rollbackError = new Error("rollback failed");
- const createRuntime = vi.fn(() => ({ ready: true }));
- const registerRoutes = vi.fn();
- const listen = vi.fn(() => Promise.resolve());
-
- await expect(runSessionDaemonStartup({
- logger,
- migrateArchive: () => Promise.resolve({
- status: "failed",
- phase: "publish-files",
- error: migrationError,
- rollbackErrors: [rollbackError],
- }),
- createRuntime,
- registerRoutes,
- listen,
- })).rejects.toThrow("Legacy session archive migration failed during publish-files; session daemon startup stopped");
-
- expect(logger.error).toHaveBeenCalledWith(
- {
- err: migrationError,
- phase: "publish-files",
- rollbackErrorCount: 1,
- rollbackErrors: [rollbackError],
- },
- "legacy session archive migration failed before commit and rollback was incomplete; stopping session daemon startup",
- );
- expect(createRuntime).not.toHaveBeenCalled();
- expect(registerRoutes).not.toHaveBeenCalled();
- expect(listen).not.toHaveBeenCalled();
- });
-
- it("warns but starts from the committed destination when legacy cleanup is incomplete", async () => {
- const logger = createLogger();
- const cleanupError = new Error("legacy index could not be removed");
- const createRuntime = vi.fn(() => ({ ready: true }));
- const registerRoutes = vi.fn();
- const listen = vi.fn(() => Promise.resolve());
-
- await runSessionDaemonStartup({
- logger,
- migrateArchive: () => Promise.resolve({
- status: "migrated",
- archiveFileCount: 2,
- cleanup: "incomplete",
- cleanupErrors: [cleanupError],
- }),
- createRuntime,
- registerRoutes,
- listen,
- });
-
- expect(logger.warn).toHaveBeenCalledWith(
- {
- archiveFileCount: 2,
- cleanupErrorCount: 1,
- cleanupErrors: [cleanupError],
- },
- "legacy session archive migration committed but cleanup was incomplete; continuing with the migrated destination archive",
- );
- expect(createRuntime).toHaveBeenCalledOnce();
- expect(registerRoutes).toHaveBeenCalledOnce();
- expect(listen).toHaveBeenCalledOnce();
- });
-});
-
-interface LegacyArchiveFixture {
- legacyIndexPath: string;
- legacyFilePath: string;
- destinationIndexPath: string;
- destinationFilePath: string;
- options: LegacySessionArchiveMigrationOptions;
-}
-
-async function createLegacyArchiveFixture(): Promise {
- const root = await mkdtemp(join(tmpdir(), "pi-web-sessiond-startup-"));
- tempRoots.push(root);
- const homeDir = join(root, "home");
- const legacyRoot = join(homeDir, ".pi-web");
- const legacyArchiveDir = join(legacyRoot, "archived-sessions");
- const legacyIndexPath = join(legacyRoot, "archived-sessions.json");
- const legacyFilePath = join(legacyArchiveDir, "legacy-session.jsonl");
- const destinationRoot = join(root, "managed-state");
- const destinationIndexPath = join(destinationRoot, "archived-sessions.json");
- const destinationFilePath = join(destinationRoot, "archived-sessions", "legacy-session.jsonl");
-
- await mkdir(legacyArchiveDir, { recursive: true });
- await writeFile(legacyFilePath, "legacy session\n", "utf8");
- await writeFile(legacyIndexPath, `${JSON.stringify({
- sessions: [{
- sessionId: "legacy-session",
- cwd: join(root, "workspace"),
- archivedAt: "2026-01-01T00:00:00.000Z",
- archivePath: legacyFilePath,
- }],
- }, null, 2)}\n`, "utf8");
-
- return {
- legacyIndexPath,
- legacyFilePath,
- destinationIndexPath,
- destinationFilePath,
- options: {
- env: { PI_WEB_DATA_DIR: destinationRoot },
- cwd: root,
- homeDir,
- createAttemptId: () => "sessiond-startup-test",
- },
- };
-}
-
-function expectMigrationComplete(fixture: LegacyArchiveFixture): void {
- expect(existsSync(fixture.legacyIndexPath)).toBe(false);
- expect(existsSync(fixture.legacyFilePath)).toBe(false);
- expect(readFileSync(fixture.destinationFilePath, "utf8")).toBe("legacy session\n");
- expect(JSON.parse(readFileSync(fixture.destinationIndexPath, "utf8"))).toMatchObject({
- sessions: [{ archivePath: fixture.destinationFilePath }],
- });
-}
-
-function createLogger() {
- return {
- debug: vi.fn(),
- info: vi.fn(),
- warn: vi.fn(),
- error: vi.fn(),
- };
-}
-
-function deferred() {
- let resolve!: (value: T | PromiseLike) => void;
- const promise = new Promise((resolvePromise) => {
- resolve = resolvePromise;
- });
- return { promise, resolve };
-}
diff --git a/src/server/sessiond/sessionDaemonStartup.ts b/src/server/sessiond/sessionDaemonStartup.ts
deleted file mode 100644
index ef3f197..0000000
--- a/src/server/sessiond/sessionDaemonStartup.ts
+++ /dev/null
@@ -1,95 +0,0 @@
-import {
- migrateLegacySessionArchive,
- type LegacySessionArchiveMigrationResult,
-} from "../sessions/sessionArchiveMigration.js";
-
-export interface SessionDaemonStartupLogger {
- debug(details: Record, message: string): void;
- info(details: Record, message: string): void;
- warn(details: Record, message: string): void;
- error(details: Record, message: string): void;
-}
-
-export interface SessionDaemonStartupSteps {
- logger: SessionDaemonStartupLogger;
- createRuntime(): Runtime | Promise;
- registerRoutes(runtime: Runtime): void;
- listen(runtime: Runtime): Promise;
- migrateArchive?: () => Promise;
-}
-
-/**
- * Keeps archive migration ahead of every archive-state consumer in sessiond.
- * A failed eligible migration stops startup so runtime writes cannot make a
- * clean retry ambiguous; mutation-free eligibility skips still start normally.
- */
-export async function runSessionDaemonStartup(
- steps: SessionDaemonStartupSteps,
-): Promise {
- const result = await (steps.migrateArchive ?? migrateLegacySessionArchive)();
- reportMigrationResult(result, steps.logger);
-
- if (result.status === "failed") {
- throw new Error(
- `Legacy session archive migration failed during ${result.phase}; session daemon startup stopped`,
- { cause: result.error },
- );
- }
-
- const runtime = await steps.createRuntime();
- steps.registerRoutes(runtime);
- await steps.listen(runtime);
- return runtime;
-}
-
-function reportMigrationResult(
- result: LegacySessionArchiveMigrationResult,
- logger: SessionDaemonStartupLogger,
-): void {
- if (result.status === "skipped") {
- if (result.reason === "inspection-failed") {
- logger.warn(
- { err: result.error, reason: result.reason },
- "could not inspect legacy session archive migration eligibility; continuing session daemon startup without migration",
- );
- } else {
- logger.debug(
- { reason: result.reason },
- "legacy session archive migration is not eligible; continuing session daemon startup",
- );
- }
- return;
- }
-
- if (result.status === "failed") {
- logger.error(
- {
- err: result.error,
- phase: result.phase,
- rollbackErrorCount: result.rollbackErrors.length,
- rollbackErrors: result.rollbackErrors,
- },
- result.rollbackErrors.length === 0
- ? "legacy session archive migration failed before commit; stopping session daemon startup"
- : "legacy session archive migration failed before commit and rollback was incomplete; stopping session daemon startup",
- );
- return;
- }
-
- if (result.cleanup === "incomplete") {
- logger.warn(
- {
- archiveFileCount: result.archiveFileCount,
- cleanupErrorCount: result.cleanupErrors.length,
- cleanupErrors: result.cleanupErrors,
- },
- "legacy session archive migration committed but cleanup was incomplete; continuing with the migrated destination archive",
- );
- return;
- }
-
- logger.info(
- { archiveFileCount: result.archiveFileCount },
- "migrated legacy session archive to the configured PI_WEB_DATA_DIR",
- );
-}
diff --git a/src/server/sessions/sessionArchiveMigration.test.ts b/src/server/sessions/sessionArchiveMigration.test.ts
deleted file mode 100644
index 876534c..0000000
--- a/src/server/sessions/sessionArchiveMigration.test.ts
+++ /dev/null
@@ -1,555 +0,0 @@
-import { constants } from "node:fs";
-import {
- access,
- appendFile,
- copyFile,
- link as createHardLink,
- lstat,
- mkdtemp,
- mkdir,
- readFile,
- readdir,
- realpath,
- rm,
- unlink,
- writeFile,
-} from "node:fs/promises";
-import { tmpdir } from "node:os";
-import { dirname, join, resolve, sep } from "node:path";
-import { afterEach, describe, expect, it } from "vitest";
-import {
- inspectLegacySessionArchiveMigration,
- migrateLegacySessionArchive,
- type LegacySessionArchiveMigrationOptions,
-} from "./sessionArchiveMigration.js";
-
-const tempRoots: string[] = [];
-
-describe("legacy session archive migration preflight", () => {
- afterEach(async () => {
- await Promise.all(tempRoots.splice(0).map((path) => rm(path, { recursive: true, force: true })));
- });
-
- it("proves the ordinary legacy layout eligible without creating destination state", async () => {
- const fixture = await createLegacyArchiveFixture();
-
- await expect(inspectLegacySessionArchiveMigration(fixture.options)).resolves.toEqual({
- status: "eligible",
- legacyIndexPath: fixture.legacyIndexPath,
- destinationIndexPath: fixture.destinationIndexPath,
- archiveFileCount: 1,
- });
- expect(await exists(fixture.destinationRoot)).toBe(false);
- await expect(readFile(fixture.legacyFilePath, "utf8")).resolves.toBe("legacy session\n");
- });
-
- it("skips when PI_WEB_DATA_DIR is not explicitly configured", async () => {
- const fixture = await createLegacyArchiveFixture();
-
- await expect(migrateLegacySessionArchive({ ...fixture.options, env: {} })).resolves.toEqual({
- status: "skipped",
- reason: "data-dir-not-configured",
- });
- await expectLegacyStateUntouched(fixture);
- expect(await exists(fixture.destinationRoot)).toBe(false);
- });
-
- it("skips when the configured data root resolves to the legacy root", async () => {
- const fixture = await createLegacyArchiveFixture();
-
- await expect(migrateLegacySessionArchive({
- ...fixture.options,
- env: { PI_WEB_DATA_DIR: fixture.legacyRoot },
- })).resolves.toEqual({ status: "skipped", reason: "data-dir-not-distinct" });
- await expectLegacyStateUntouched(fixture);
- });
-
- it("skips when no legacy index exists without adopting loose legacy files", async () => {
- const fixture = await createLegacyArchiveFixture();
- await unlink(fixture.legacyIndexPath);
-
- await expect(migrateLegacySessionArchive(fixture.options)).resolves.toEqual({
- status: "skipped",
- reason: "legacy-index-missing",
- });
- await expect(readFile(fixture.legacyFilePath, "utf8")).resolves.toBe("legacy session\n");
- await expect(readFile(fixture.activeFilePath, "utf8")).resolves.toBe("active session\n");
- expect(await exists(fixture.destinationRoot)).toBe(false);
- });
-
- it("skips malformed legacy indexes without creating destination state", async () => {
- const fixture = await createLegacyArchiveFixture();
- await writeFile(fixture.legacyIndexPath, "not json\n", "utf8");
-
- await expect(migrateLegacySessionArchive(fixture.options)).resolves.toEqual({
- status: "skipped",
- reason: "legacy-index-invalid",
- });
- await expect(readFile(fixture.legacyIndexPath, "utf8")).resolves.toBe("not json\n");
- expect(await exists(fixture.destinationRoot)).toBe(false);
- });
-
- it("treats inconclusive filesystem inspection as a mutation-free skip", async () => {
- const fixture = await createLegacyArchiveFixture();
- const inspectionError = Object.assign(new Error("inspection denied"), { code: "EACCES" });
-
- const result = await migrateLegacySessionArchive({
- ...fixture.options,
- fileSystem: {
- lstat: (path) => path === fixture.destinationIndexPath
- ? Promise.reject(inspectionError)
- : lstat(path),
- },
- });
-
- expect(result).toMatchObject({
- status: "skipped",
- reason: "inspection-failed",
- error: inspectionError,
- });
- await expectLegacyStateUntouched(fixture);
- expect(await exists(fixture.destinationRoot)).toBe(false);
- });
-
- it("does not merge with or overwrite an existing destination index", async () => {
- const fixture = await createLegacyArchiveFixture();
- await mkdir(fixture.destinationRoot, { recursive: true });
- await writeFile(fixture.destinationIndexPath, "destination owner\n", "utf8");
-
- await expect(migrateLegacySessionArchive(fixture.options)).resolves.toEqual({
- status: "skipped",
- reason: "destination-index-exists",
- });
- await expect(readFile(fixture.destinationIndexPath, "utf8")).resolves.toBe("destination owner\n");
- await expectLegacyStateUntouched(fixture);
- });
-
- it("leaves an interrupted pre-commit file untouched instead of adopting a non-empty destination archive", async () => {
- const fixture = await createLegacyArchiveFixture({ createDestinationArchive: true });
- await writeFile(fixture.destinationFilePath, "partial destination copy\n", "utf8");
-
- await expect(migrateLegacySessionArchive(fixture.options)).resolves.toEqual({
- status: "skipped",
- reason: "destination-archive-not-empty-or-invalid",
- });
- await expect(readFile(fixture.destinationFilePath, "utf8")).resolves.toBe("partial destination copy\n");
- await expectLegacyStateUntouched(fixture);
- });
-
- it("skips archive paths that are not direct regular files in the legacy archive directory", async () => {
- const fixture = await createLegacyArchiveFixture();
- const outsidePath = join(fixture.root, "outside.jsonl");
- await writeFile(outsidePath, "outside\n", "utf8");
- const firstRecord = requiredRecord(fixture.document.sessions, 0);
- const changedDocument = {
- ...fixture.document,
- sessions: [{ ...firstRecord, archivePath: outsidePath }, ...fixture.document.sessions.slice(1)],
- };
- await writeArchiveIndex(fixture.legacyIndexPath, changedDocument);
-
- await expect(migrateLegacySessionArchive(fixture.options)).resolves.toEqual({
- status: "skipped",
- reason: "legacy-archive-layout-invalid",
- });
- await expect(readFile(outsidePath, "utf8")).resolves.toBe("outside\n");
- await expect(readFile(fixture.legacyFilePath, "utf8")).resolves.toBe("legacy session\n");
- expect(await exists(fixture.destinationRoot)).toBe(false);
- });
-
- it("uses Windows case-insensitive path semantics without weakening Linux containment", async () => {
- const fixture = await createLegacyArchiveFixture();
- const casedArchivePath = resolve(fixture.legacyFilePath.toUpperCase());
- const firstRecord = requiredRecord(fixture.document.sessions, 0);
- await writeArchiveIndex(fixture.legacyIndexPath, {
- ...fixture.document,
- sessions: [
- { ...firstRecord, archivePath: casedArchivePath },
- ...fixture.document.sessions.slice(1),
- ],
- });
- const mapCaseVariant = (path: string): string => path.toLowerCase() === casedArchivePath.toLowerCase()
- ? fixture.legacyFilePath
- : path;
- const fileSystem = {
- lstat: (path: string) => lstat(mapCaseVariant(path)),
- realpath: (path: string) => realpath(mapCaseVariant(path)),
- };
-
- await expect(inspectLegacySessionArchiveMigration({
- ...fixture.options,
- platform: "linux",
- fileSystem,
- })).resolves.toEqual({ status: "skipped", reason: "legacy-archive-layout-invalid" });
- await expect(inspectLegacySessionArchiveMigration({
- ...fixture.options,
- platform: "win32",
- fileSystem,
- })).resolves.toEqual({
- status: "eligible",
- legacyIndexPath: fixture.legacyIndexPath,
- destinationIndexPath: fixture.destinationIndexPath,
- archiveFileCount: 1,
- });
- });
-
- it("skips legacy directories containing unindexed entries", async () => {
- const fixture = await createLegacyArchiveFixture();
- const unexpectedPath = join(fixture.legacyArchiveDir, "unexpected.tmp");
- await writeFile(unexpectedPath, "unexpected\n", "utf8");
-
- await expect(migrateLegacySessionArchive(fixture.options)).resolves.toEqual({
- status: "skipped",
- reason: "legacy-archive-layout-invalid",
- });
- await expect(readFile(unexpectedPath, "utf8")).resolves.toBe("unexpected\n");
- await expectLegacyStateUntouched(fixture);
- expect(await exists(fixture.destinationRoot)).toBe(false);
- });
-
- it("skips duplicate session IDs", async () => {
- const fixture = await createLegacyArchiveFixture();
- const firstRecord = requiredRecord(fixture.document.sessions, 0);
- const secondRecord = requiredRecord(fixture.document.sessions, 1);
- await writeArchiveIndex(fixture.legacyIndexPath, {
- ...fixture.document,
- sessions: [firstRecord, { ...secondRecord, sessionId: firstRecord["sessionId"] }],
- });
-
- await expect(migrateLegacySessionArchive(fixture.options)).resolves.toEqual({
- status: "skipped",
- reason: "archive-record-conflict",
- });
- await expect(readFile(fixture.legacyFilePath, "utf8")).resolves.toBe("legacy session\n");
- expect(await exists(fixture.destinationRoot)).toBe(false);
- });
-
- it("skips duplicate source and destination file mappings", async () => {
- const fixture = await createLegacyArchiveFixture();
- const firstRecord = requiredRecord(fixture.document.sessions, 0);
- await writeArchiveIndex(fixture.legacyIndexPath, {
- ...fixture.document,
- sessions: [firstRecord, { ...firstRecord, sessionId: "second-file-record" }],
- });
-
- await expect(migrateLegacySessionArchive(fixture.options)).resolves.toEqual({
- status: "skipped",
- reason: "archive-record-conflict",
- });
- await expectLegacyStateUntouched({
- ...fixture,
- sourceIndexContents: await readFile(fixture.legacyIndexPath, "utf8"),
- });
- expect(await exists(fixture.destinationRoot)).toBe(false);
- });
-});
-
-describe("legacy session archive migration execution", () => {
- afterEach(async () => {
- await Promise.all(tempRoots.splice(0).map((path) => rm(path, { recursive: true, force: true })));
- });
-
- it("copies across the source boundary, atomically publishes the rewritten index, then removes legacy state", async () => {
- const fixture = await createLegacyArchiveFixture({ createDestinationArchive: true });
- const copies: { source: string; destination: string; mode: number }[] = [];
- const links: { source: string; destination: string }[] = [];
-
- await expect(migrateLegacySessionArchive({
- ...fixture.options,
- fileSystem: {
- copyFile: async (source, destination, mode) => {
- copies.push({ source, destination, mode });
- await copyFile(source, destination, mode);
- },
- link: async (source, destination) => {
- links.push({ source, destination });
- await createHardLink(source, destination);
- },
- unlink: async (path) => {
- if (path === fixture.legacyFilePath || path === fixture.legacyIndexPath) {
- expect(await exists(fixture.destinationIndexPath)).toBe(true);
- }
- await unlink(path);
- },
- },
- })).resolves.toEqual({ status: "migrated", archiveFileCount: 1, cleanup: "complete" });
-
- expect(copies).toHaveLength(2);
- expect(copies[0]).toMatchObject({ source: fixture.legacyFilePath, mode: constants.COPYFILE_EXCL });
- expect(copies[0]?.destination).toContain(".archived-sessions-migration-test-attempt");
- expect(copies[1]).toMatchObject({ destination: fixture.destinationFilePath, mode: constants.COPYFILE_EXCL });
- expect(links).toEqual([{
- source: join(fixture.destinationRoot, ".archived-sessions-migration-test-attempt", "archived-sessions.json"),
- destination: fixture.destinationIndexPath,
- }]);
- await expect(readFile(fixture.destinationFilePath, "utf8")).resolves.toBe("legacy session\n");
-
- const migratedDocument: unknown = JSON.parse(await readFile(fixture.destinationIndexPath, "utf8"));
- const firstRecord = requiredRecord(fixture.document.sessions, 0);
- expect(migratedDocument).toEqual({
- ...fixture.document,
- sessions: [
- { ...firstRecord, archivePath: fixture.destinationFilePath },
- requiredRecord(fixture.document.sessions, 1),
- ],
- });
- expect(await exists(fixture.legacyIndexPath)).toBe(false);
- expect(await exists(fixture.legacyFilePath)).toBe(false);
- expect(await exists(fixture.legacyArchiveDir)).toBe(false);
- await expect(readFile(fixture.activeFilePath, "utf8")).resolves.toBe("active session\n");
- expect(new Set(await readdir(fixture.destinationRoot))).toEqual(new Set([
- "archived-sessions",
- "archived-sessions.json",
- ]));
- });
-
- it("retries safely when an interrupted staging-only attempt left an unowned sibling tree", async () => {
- const fixture = await createLegacyArchiveFixture();
- const abandonedFile = join(
- fixture.destinationRoot,
- ".archived-sessions-migration-interrupted-attempt",
- "files",
- "abandoned.jsonl",
- );
- await mkdir(dirname(abandonedFile), { recursive: true });
- await writeFile(abandonedFile, "unowned staging data\n", "utf8");
-
- await expect(migrateLegacySessionArchive(fixture.options)).resolves.toEqual({
- status: "migrated",
- archiveFileCount: 1,
- cleanup: "complete",
- });
-
- await expect(readFile(abandonedFile, "utf8")).resolves.toBe("unowned staging data\n");
- await expect(readFile(fixture.destinationFilePath, "utf8")).resolves.toBe("legacy session\n");
- expect(await exists(fixture.destinationIndexPath)).toBe(true);
- expect(await exists(fixture.legacyIndexPath)).toBe(false);
- });
-
- it("rolls back destination artifacts and preserves all legacy state when staged-copy verification fails", async () => {
- const fixture = await createLegacyArchiveFixture();
-
- const result = await migrateLegacySessionArchive({
- ...fixture.options,
- fileSystem: {
- copyFile: async (source, destination, mode) => {
- await copyFile(source, destination, mode);
- if (source === fixture.legacyFilePath) await appendFile(destination, "corrupt", "utf8");
- },
- },
- });
-
- expect(result).toMatchObject({ status: "failed", phase: "stage", rollbackErrors: [] });
- await expectLegacyStateUntouched(fixture);
- expect(await exists(fixture.destinationIndexPath)).toBe(false);
- await expect(readdir(fixture.destinationRoot)).resolves.toEqual([]);
- });
-
- it("revalidates source files before commit and rolls back if one changes during migration", async () => {
- const fixture = await createLegacyArchiveFixture();
-
- const result = await migrateLegacySessionArchive({
- ...fixture.options,
- fileSystem: {
- copyFile: async (source, destination, mode) => {
- await copyFile(source, destination, mode);
- if (destination === fixture.destinationFilePath) {
- await appendFile(fixture.legacyFilePath, "changed during migration\n", "utf8");
- }
- },
- },
- });
-
- expect(result).toMatchObject({ status: "failed", phase: "commit-index", rollbackErrors: [] });
- await expect(readFile(fixture.legacyIndexPath, "utf8")).resolves.toBe(fixture.sourceIndexContents);
- await expect(readFile(fixture.legacyFilePath, "utf8")).resolves.toBe(
- "legacy session\nchanged during migration\n",
- );
- await expect(readFile(fixture.activeFilePath, "utf8")).resolves.toBe("active session\n");
- expect(await exists(fixture.destinationIndexPath)).toBe(false);
- expect(await exists(fixture.destinationArchiveDir)).toBe(false);
- });
-
- it("rolls back published files but never source state when atomic index publication fails", async () => {
- const fixture = await createLegacyArchiveFixture();
- const publicationError = Object.assign(new Error("link failed"), { code: "EIO" });
-
- const result = await migrateLegacySessionArchive({
- ...fixture.options,
- fileSystem: {
- link: () => Promise.reject(publicationError),
- },
- });
-
- expect(result).toMatchObject({
- status: "failed",
- phase: "commit-index",
- error: publicationError,
- rollbackErrors: [],
- });
- await expectLegacyStateUntouched(fixture);
- expect(await exists(fixture.destinationIndexPath)).toBe(false);
- expect(await exists(fixture.destinationArchiveDir)).toBe(false);
- await expect(readdir(fixture.destinationRoot)).resolves.toEqual([]);
- });
-
- it("does not overwrite a destination index that appears at the atomic commit boundary", async () => {
- const fixture = await createLegacyArchiveFixture();
- const publicationError = Object.assign(new Error("destination index won the race"), { code: "EEXIST" });
-
- const result = await migrateLegacySessionArchive({
- ...fixture.options,
- fileSystem: {
- link: async (_source, destination) => {
- await writeFile(destination, "destination owner\n", { encoding: "utf8", flag: "wx" });
- throw publicationError;
- },
- },
- });
-
- expect(result).toMatchObject({
- status: "failed",
- phase: "commit-index",
- error: publicationError,
- rollbackErrors: [],
- });
- await expect(readFile(fixture.destinationIndexPath, "utf8")).resolves.toBe("destination owner\n");
- expect(await exists(fixture.destinationArchiveDir)).toBe(false);
- await expectLegacyStateUntouched(fixture);
- });
-
- it("keeps the committed destination authoritative when legacy cleanup fails", async () => {
- const fixture = await createLegacyArchiveFixture();
- const cleanupError = Object.assign(new Error("source cleanup failed"), { code: "EACCES" });
-
- const result = await migrateLegacySessionArchive({
- ...fixture.options,
- fileSystem: {
- unlink: async (path) => {
- if (path === fixture.legacyFilePath) throw cleanupError;
- await unlink(path);
- },
- },
- });
-
- expect(result).toMatchObject({
- status: "migrated",
- cleanup: "incomplete",
- cleanupErrors: [cleanupError],
- });
- await expect(readFile(fixture.destinationFilePath, "utf8")).resolves.toBe("legacy session\n");
- expect(await exists(fixture.destinationIndexPath)).toBe(true);
- await expectLegacyStateUntouched(fixture);
- });
-});
-
-interface LegacyArchiveFixture {
- root: string;
- legacyRoot: string;
- legacyIndexPath: string;
- legacyArchiveDir: string;
- legacyFilePath: string;
- activeFilePath: string;
- destinationRoot: string;
- destinationIndexPath: string;
- destinationArchiveDir: string;
- destinationFilePath: string;
- sourceIndexContents: string;
- document: { marker: string; sessions: Record[] };
- options: LegacySessionArchiveMigrationOptions;
-}
-
-async function createLegacyArchiveFixture(
- setup: { createDestinationArchive?: boolean } = {},
-): Promise {
- const root = await mkdtemp(join(tmpdir(), "pi-web-archive-migration-"));
- tempRoots.push(root);
- const homeDir = join(root, "home");
- const legacyRoot = join(homeDir, ".pi-web");
- const legacyIndexPath = join(legacyRoot, "archived-sessions.json");
- const legacyArchiveDir = join(legacyRoot, "archived-sessions");
- const legacyFilePath = join(legacyArchiveDir, "2026-01-01_file-session.jsonl");
- const activeFilePath = join(root, "active", "2026-01-01_file-session.jsonl");
- const destinationRoot = join(root, "managed-state");
- const destinationIndexPath = join(destinationRoot, "archived-sessions.json");
- const destinationArchiveDir = join(destinationRoot, "archived-sessions");
- const destinationFilePath = join(destinationArchiveDir, "2026-01-01_file-session.jsonl");
- const document = {
- marker: "preserve root metadata",
- sessions: [
- {
- sessionId: "file-session",
- cwd: `${join(root, "workspace")}${sep}..${sep}workspace`,
- archivedAt: "2026-01-01T00:02:00.000Z",
- originalPath: activeFilePath,
- archivePath: legacyFilePath,
- created: "2026-01-01T00:00:00.000Z",
- modified: "2026-01-01T00:01:00.000Z",
- messageCount: 2,
- firstMessage: "hello",
- name: "Legacy file session",
- customMetadata: { preserved: true },
- },
- {
- sessionId: "metadata-only",
- cwd: "/workspace",
- archivedAt: "2026-01-02T00:00:00.000Z",
- name: "Metadata only",
- },
- ],
- };
-
- await mkdir(legacyArchiveDir, { recursive: true });
- await mkdir(join(root, "active"), { recursive: true });
- await writeFile(legacyFilePath, "legacy session\n", "utf8");
- await writeFile(activeFilePath, "active session\n", "utf8");
- const sourceIndexContents = await writeArchiveIndex(legacyIndexPath, document);
- if (setup.createDestinationArchive === true) await mkdir(destinationArchiveDir, { recursive: true });
-
- return {
- root,
- legacyRoot,
- legacyIndexPath,
- legacyArchiveDir,
- legacyFilePath,
- activeFilePath,
- destinationRoot,
- destinationIndexPath,
- destinationArchiveDir,
- destinationFilePath,
- sourceIndexContents,
- document,
- options: {
- env: { PI_WEB_DATA_DIR: destinationRoot },
- cwd: root,
- homeDir,
- createAttemptId: () => "test-attempt",
- },
- };
-}
-
-async function writeArchiveIndex(path: string, document: unknown): Promise {
- const contents = `${JSON.stringify(document, null, 2)}\n`;
- await writeFile(path, contents, "utf8");
- return contents;
-}
-
-async function expectLegacyStateUntouched(fixture: LegacyArchiveFixture): Promise {
- await expect(readFile(fixture.legacyIndexPath, "utf8")).resolves.toBe(fixture.sourceIndexContents);
- await expect(readFile(fixture.legacyFilePath, "utf8")).resolves.toBe("legacy session\n");
- await expect(readFile(fixture.activeFilePath, "utf8")).resolves.toBe("active session\n");
-}
-
-function requiredRecord(records: Record[], index: number): Record {
- const record = records[index];
- if (record === undefined) throw new Error(`Missing fixture record ${index.toString()}`);
- return record;
-}
-
-async function exists(path: string): Promise {
- try {
- await access(path, constants.F_OK);
- return true;
- } catch {
- return false;
- }
-}
diff --git a/src/server/sessions/sessionArchiveMigration.ts b/src/server/sessions/sessionArchiveMigration.ts
deleted file mode 100644
index 4d43c5c..0000000
--- a/src/server/sessions/sessionArchiveMigration.ts
+++ /dev/null
@@ -1,781 +0,0 @@
-import { randomUUID } from "node:crypto";
-import { constants, type Dirent, type Stats } from "node:fs";
-import {
- copyFile,
- link,
- lstat,
- mkdir,
- open,
- readFile,
- readdir,
- realpath,
- rm,
- rmdir,
- unlink,
- writeFile,
-} from "node:fs/promises";
-import { homedir } from "node:os";
-import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
-import { piWebDataDir } from "../../config.js";
-import { parseSessionArchiveFile, type ArchivedSessionRecord } from "./sessionArchiveStore.js";
-
-export type LegacySessionArchiveMigrationSkipReason =
- | "data-dir-not-configured"
- | "data-dir-not-distinct"
- | "legacy-index-missing"
- | "legacy-index-invalid"
- | "destination-index-exists"
- | "destination-archive-not-empty-or-invalid"
- | "legacy-archive-layout-invalid"
- | "archive-record-conflict"
- | "inspection-failed";
-
-export interface LegacySessionArchiveMigrationSkipped {
- status: "skipped";
- reason: LegacySessionArchiveMigrationSkipReason;
- error?: unknown;
-}
-
-export type LegacySessionArchiveMigrationPreflight =
- | LegacySessionArchiveMigrationSkipped
- | {
- status: "eligible";
- legacyIndexPath: string;
- destinationIndexPath: string;
- archiveFileCount: number;
- };
-
-export type LegacySessionArchiveMigrationPhase = "stage" | "publish-files" | "commit-index";
-
-export type LegacySessionArchiveMigrationResult =
- | LegacySessionArchiveMigrationSkipped
- | {
- status: "failed";
- phase: LegacySessionArchiveMigrationPhase;
- error: unknown;
- rollbackErrors: unknown[];
- }
- | {
- status: "migrated";
- archiveFileCount: number;
- cleanup: "complete";
- }
- | {
- status: "migrated";
- archiveFileCount: number;
- cleanup: "incomplete";
- cleanupErrors: unknown[];
- };
-
-export interface SessionArchiveMigrationReadHandle {
- read(buffer: Buffer, offset: number, length: number, position: null): Promise<{ bytesRead: number }>;
- close(): Promise;
-}
-
-export interface SessionArchiveMigrationFileSystem {
- lstat(path: string): Promise;
- realpath(path: string): Promise;
- readFile(path: string): Promise;
- readdir(path: string): Promise;
- mkdir(path: string, options?: { recursive?: boolean }): Promise;
- copyFile(source: string, destination: string, mode: number): Promise;
- writeFile(path: string, contents: string): Promise;
- link(source: string, destination: string): Promise;
- unlink(path: string): Promise;
- rmdir(path: string): Promise;
- rmOwnedTree(path: string): Promise;
- open(path: string): Promise;
-}
-
-export interface LegacySessionArchiveMigrationOptions {
- env?: NodeJS.ProcessEnv;
- cwd?: string;
- homeDir?: string;
- platform?: NodeJS.Platform;
- createAttemptId?: () => string;
- fileSystem?: Partial;
-}
-
-interface PlannedArchiveFile {
- sourcePath: string;
- destinationPath: string;
- fileName: string;
-}
-
-interface MigrationPlan {
- legacyRoot: string;
- canonicalLegacyRoot: string;
- legacyIndexPath: string;
- legacyArchiveDir: string;
- canonicalLegacyArchiveDir: string;
- legacyArchiveDirExists: boolean;
- destinationRoot: string;
- canonicalDestinationRoot: string;
- destinationIndexPath: string;
- destinationArchiveDir: string;
- canonicalDestinationArchiveDir: string;
- destinationArchiveDirExists: boolean;
- sourceIndexContents: string;
- destinationIndexContents: string;
- files: PlannedArchiveFile[];
- platform: NodeJS.Platform;
-}
-
-interface InternalEligiblePreflight {
- status: "eligible";
- plan: MigrationPlan;
-}
-
-type InternalPreflight = LegacySessionArchiveMigrationSkipped | InternalEligiblePreflight;
-
-const defaultFileSystem: SessionArchiveMigrationFileSystem = {
- lstat: async (path) => lstat(path),
- realpath: async (path) => realpath(path),
- readFile: async (path) => readFile(path, "utf8"),
- readdir: async (path) => readdir(path, { withFileTypes: true }),
- mkdir: async (path, options) => {
- await mkdir(path, options);
- },
- copyFile: async (source, destination, mode) => copyFile(source, destination, mode),
- writeFile: async (path, contents) => {
- await writeFile(path, contents, { encoding: "utf8", flag: "wx" });
- },
- link: async (source, destination) => link(source, destination),
- unlink: async (path) => unlink(path),
- rmdir: async (path) => rmdir(path),
- rmOwnedTree: async (path) => {
- await rm(path, { recursive: true, force: true });
- },
- open: async (path) => open(path, "r"),
-};
-
-export async function inspectLegacySessionArchiveMigration(
- options: LegacySessionArchiveMigrationOptions = {},
-): Promise {
- const preflight = await buildMigrationPreflight(options, migrationFileSystem(options));
- if (preflight.status === "skipped") return preflight;
- return {
- status: "eligible",
- legacyIndexPath: preflight.plan.legacyIndexPath,
- destinationIndexPath: preflight.plan.destinationIndexPath,
- archiveFileCount: preflight.plan.files.length,
- };
-}
-
-export async function migrateLegacySessionArchive(
- options: LegacySessionArchiveMigrationOptions = {},
-): Promise {
- const fileSystem = migrationFileSystem(options);
- const preflight = await buildMigrationPreflight(options, fileSystem);
- if (preflight.status === "skipped") return preflight;
-
- const plan = preflight.plan;
- let phase: LegacySessionArchiveMigrationPhase = "stage";
- let stagingRoot: string | undefined;
- let stagingCreated = false;
- let destinationArchiveCreated = false;
- const ownedDestinationFiles: string[] = [];
-
- try {
- await fileSystem.mkdir(plan.destinationRoot, { recursive: true });
- const currentDestinationRoot = await canonicalizeAllowMissing(plan.destinationRoot, fileSystem);
- if (!pathsEqual(currentDestinationRoot, plan.canonicalDestinationRoot, plan.platform)) {
- throw new Error("Destination data root changed after migration preflight");
- }
- await assertDestinationStateUnchanged(plan, fileSystem);
-
- const attemptId = safeAttemptId((options.createAttemptId ?? randomUUID)());
- // Keep staging beside, not inside, the authoritative archive directory so an
- // interrupted staging copy cannot be mistaken for destination archive data.
- stagingRoot = join(plan.destinationRoot, `.archived-sessions-migration-${attemptId}`);
- await fileSystem.mkdir(stagingRoot);
- stagingCreated = true;
- const stagingFilesDir = join(stagingRoot, "files");
- await fileSystem.mkdir(stagingFilesDir);
-
- const stagedFiles = new Map();
- for (const file of plan.files) {
- const stagedPath = join(stagingFilesDir, file.fileName);
- await fileSystem.copyFile(file.sourcePath, stagedPath, constants.COPYFILE_EXCL);
- if (!await filesEqual(file.sourcePath, stagedPath, fileSystem)) {
- throw new Error(`Staged archive file verification failed: ${file.fileName}`);
- }
- stagedFiles.set(file.destinationPath, stagedPath);
- }
-
- const stagedIndexPath = join(stagingRoot, "archived-sessions.json");
- await fileSystem.writeFile(stagedIndexPath, plan.destinationIndexContents);
- if (await fileSystem.readFile(stagedIndexPath) !== plan.destinationIndexContents) {
- throw new Error("Staged archive index verification failed");
- }
-
- phase = "publish-files";
- await assertDestinationStateUnchanged(plan, fileSystem);
- if (plan.files.length > 0 && !plan.destinationArchiveDirExists) {
- await fileSystem.mkdir(plan.destinationArchiveDir);
- destinationArchiveCreated = true;
- }
-
- for (const file of plan.files) {
- const stagedPath = stagedFiles.get(file.destinationPath);
- if (stagedPath === undefined) throw new Error(`Missing staged archive file: ${file.fileName}`);
- try {
- await fileSystem.copyFile(stagedPath, file.destinationPath, constants.COPYFILE_EXCL);
- ownedDestinationFiles.push(file.destinationPath);
- } catch (error: unknown) {
- if (!isNodeErrorWithCode(error, "EEXIST")) ownedDestinationFiles.push(file.destinationPath);
- throw error;
- }
- if (!await filesEqual(stagedPath, file.destinationPath, fileSystem)) {
- throw new Error(`Published archive file verification failed: ${file.fileName}`);
- }
- }
-
- phase = "commit-index";
- await assertPlanReadyToCommit(plan, stagedIndexPath, fileSystem);
- // A same-directory hard link atomically publishes the already-verified index
- // and, unlike rename on POSIX, fails rather than replacing an existing index.
- await fileSystem.link(stagedIndexPath, plan.destinationIndexPath);
- } catch (error: unknown) {
- const rollbackErrors = await rollbackUncommittedDestination({
- stagingRoot,
- stagingCreated,
- destinationArchiveDir: plan.destinationArchiveDir,
- destinationArchiveCreated,
- ownedDestinationFiles,
- }, fileSystem);
- return { status: "failed", phase, error, rollbackErrors };
- }
-
- const cleanupErrors: unknown[] = [];
- try {
- await fileSystem.rmOwnedTree(stagingRoot);
- } catch (error: unknown) {
- cleanupErrors.push(error);
- }
- cleanupErrors.push(...await removeCommittedLegacyState(plan, fileSystem));
-
- return cleanupErrors.length === 0
- ? { status: "migrated", archiveFileCount: plan.files.length, cleanup: "complete" }
- : { status: "migrated", archiveFileCount: plan.files.length, cleanup: "incomplete", cleanupErrors };
-}
-
-async function buildMigrationPreflight(
- options: LegacySessionArchiveMigrationOptions,
- fileSystem: SessionArchiveMigrationFileSystem,
-): Promise {
- const env = options.env ?? process.env;
- const configuredDataDir = env["PI_WEB_DATA_DIR"];
- if (configuredDataDir === undefined || configuredDataDir.trim() === "") {
- return migrationSkipped("data-dir-not-configured");
- }
-
- const cwd = options.cwd ?? process.cwd();
- const platform = options.platform ?? process.platform;
- const legacyRoot = resolve(options.homeDir ?? homedir(), ".pi-web");
- const destinationRoot = piWebDataDir(env, cwd);
- const legacyIndexPath = join(legacyRoot, "archived-sessions.json");
- const legacyArchiveDir = join(legacyRoot, "archived-sessions");
- const destinationIndexPath = join(destinationRoot, "archived-sessions.json");
- const destinationArchiveDir = join(destinationRoot, "archived-sessions");
-
- try {
- const canonicalLegacyRoot = await canonicalizeAllowMissing(legacyRoot, fileSystem);
- const canonicalDestinationRoot = await canonicalizeAllowMissing(destinationRoot, fileSystem);
- if (pathsEqual(canonicalLegacyRoot, canonicalDestinationRoot, platform)) {
- return migrationSkipped("data-dir-not-distinct");
- }
-
- const legacyIndexStats = await lstatIfExists(legacyIndexPath, fileSystem);
- if (legacyIndexStats === undefined) return migrationSkipped("legacy-index-missing");
- if (!legacyIndexStats.isFile() || legacyIndexStats.isSymbolicLink()) {
- return migrationSkipped("legacy-index-invalid");
- }
-
- const sourceIndexContents = await fileSystem.readFile(legacyIndexPath);
- const parsedDocument = parseArchiveDocument(sourceIndexContents);
- if (parsedDocument === undefined) return migrationSkipped("legacy-index-invalid");
-
- if (await lstatIfExists(destinationIndexPath, fileSystem) !== undefined) {
- return migrationSkipped("destination-index-exists");
- }
-
- const destinationArchiveStats = await lstatIfExists(destinationArchiveDir, fileSystem);
- let destinationArchiveDirExists = false;
- if (destinationArchiveStats !== undefined) {
- if (!destinationArchiveStats.isDirectory() || destinationArchiveStats.isSymbolicLink()) {
- return migrationSkipped("destination-archive-not-empty-or-invalid");
- }
- if ((await fileSystem.readdir(destinationArchiveDir)).length !== 0) {
- return migrationSkipped("destination-archive-not-empty-or-invalid");
- }
- destinationArchiveDirExists = true;
- }
-
- const legacyArchiveStats = await lstatIfExists(legacyArchiveDir, fileSystem);
- if (legacyArchiveStats !== undefined && (!legacyArchiveStats.isDirectory() || legacyArchiveStats.isSymbolicLink())) {
- return migrationSkipped("legacy-archive-layout-invalid");
- }
- const legacyArchiveDirExists = legacyArchiveStats !== undefined;
- const canonicalLegacyArchiveDir = legacyArchiveDirExists
- ? await fileSystem.realpath(legacyArchiveDir)
- : await canonicalizeAllowMissing(legacyArchiveDir, fileSystem);
- const canonicalDestinationArchiveDir = destinationArchiveDirExists
- ? await fileSystem.realpath(destinationArchiveDir)
- : join(canonicalDestinationRoot, "archived-sessions");
- if (pathsOverlap(canonicalLegacyArchiveDir, canonicalDestinationArchiveDir, platform)) {
- return migrationSkipped("data-dir-not-distinct");
- }
-
- const plannedFiles = await planArchiveFiles({
- sessions: parsedDocument.sessions,
- legacyArchiveDir,
- canonicalLegacyArchiveDir,
- legacyArchiveDirExists,
- destinationArchiveDir,
- canonicalDestinationArchiveDir,
- platform,
- }, fileSystem);
- if (plannedFiles.status === "skipped") return plannedFiles;
-
- if (!await legacyDirectoryMatchesPlan(legacyArchiveDir, legacyArchiveDirExists, plannedFiles.files, platform, fileSystem)) {
- return migrationSkipped("legacy-archive-layout-invalid");
- }
-
- const rewrittenSessions = parsedDocument.rawSessions.map((record, index) => {
- const destinationPath = plannedFiles.destinationPathsByRecord[index];
- return destinationPath === undefined ? record : { ...record, archivePath: destinationPath };
- });
- const destinationIndexContents = `${JSON.stringify({ ...parsedDocument.rawDocument, sessions: rewrittenSessions }, null, 2)}\n`;
-
- return {
- status: "eligible",
- plan: {
- legacyRoot,
- canonicalLegacyRoot,
- legacyIndexPath,
- legacyArchiveDir,
- canonicalLegacyArchiveDir,
- legacyArchiveDirExists,
- destinationRoot,
- canonicalDestinationRoot,
- destinationIndexPath,
- destinationArchiveDir,
- canonicalDestinationArchiveDir,
- destinationArchiveDirExists,
- sourceIndexContents,
- destinationIndexContents,
- files: plannedFiles.files,
- platform,
- },
- };
- } catch (error: unknown) {
- return migrationSkipped("inspection-failed", error);
- }
-}
-
-interface ParsedArchiveDocument {
- rawDocument: Record;
- rawSessions: Record[];
- sessions: ArchivedSessionRecord[];
-}
-
-function parseArchiveDocument(contents: string): ParsedArchiveDocument | undefined {
- try {
- const value: unknown = JSON.parse(contents);
- if (!isRecord(value)) return undefined;
- const rawSessions = recordArray(value["sessions"]);
- if (rawSessions === undefined) return undefined;
- const archive = parseSessionArchiveFile(value);
- return { rawDocument: value, rawSessions, sessions: archive.sessions };
- } catch {
- return undefined;
- }
-}
-
-async function planArchiveFiles(
- input: {
- sessions: ArchivedSessionRecord[];
- legacyArchiveDir: string;
- canonicalLegacyArchiveDir: string;
- legacyArchiveDirExists: boolean;
- destinationArchiveDir: string;
- canonicalDestinationArchiveDir: string;
- platform: NodeJS.Platform;
- },
- fileSystem: SessionArchiveMigrationFileSystem,
-): Promise {
- const sessionIds = new Set();
- const sourcePaths = new Set();
- const destinationPaths = new Set();
- const destinationPathsByRecord: (string | undefined)[] = Array.from({ length: input.sessions.length });
- const files: PlannedArchiveFile[] = [];
-
- for (const [index, session] of input.sessions.entries()) {
- if (session.sessionId.trim() === "" || sessionIds.has(session.sessionId)) {
- return migrationSkipped("archive-record-conflict");
- }
- sessionIds.add(session.sessionId);
-
- if (session.archivePath === undefined) continue;
- if (!input.legacyArchiveDirExists || !isAbsolute(session.archivePath)) {
- return migrationSkipped("legacy-archive-layout-invalid");
- }
-
- const sourcePath = resolve(session.archivePath);
- if (!pathsEqual(dirname(sourcePath), input.legacyArchiveDir, input.platform)) {
- return migrationSkipped("legacy-archive-layout-invalid");
- }
- const sourceStats = await lstatIfExists(sourcePath, fileSystem);
- if (sourceStats === undefined || !sourceStats.isFile() || sourceStats.isSymbolicLink()) {
- return migrationSkipped("legacy-archive-layout-invalid");
- }
- const canonicalSourcePath = await fileSystem.realpath(sourcePath);
- if (!pathsEqual(dirname(canonicalSourcePath), input.canonicalLegacyArchiveDir, input.platform)) {
- return migrationSkipped("legacy-archive-layout-invalid");
- }
-
- const fileName = basename(sourcePath);
- const destinationPath = join(input.destinationArchiveDir, fileName);
- const canonicalDestinationPath = join(input.canonicalDestinationArchiveDir, fileName);
- const sourceKey = pathKey(canonicalSourcePath, input.platform);
- const destinationKey = collisionKey(canonicalDestinationPath);
- if (sourcePaths.has(sourceKey) || destinationPaths.has(destinationKey)) {
- return migrationSkipped("archive-record-conflict");
- }
- sourcePaths.add(sourceKey);
- destinationPaths.add(destinationKey);
- destinationPathsByRecord[index] = destinationPath;
- files.push({ sourcePath, destinationPath, fileName });
- }
-
- return { status: "planned", files, destinationPathsByRecord };
-}
-
-async function legacyDirectoryMatchesPlan(
- legacyArchiveDir: string,
- legacyArchiveDirExists: boolean,
- files: PlannedArchiveFile[],
- platform: NodeJS.Platform,
- fileSystem: SessionArchiveMigrationFileSystem,
-): Promise {
- const archiveStats = await lstatIfExists(legacyArchiveDir, fileSystem);
- if (!legacyArchiveDirExists) return archiveStats === undefined && files.length === 0;
- if (archiveStats === undefined || !archiveStats.isDirectory() || archiveStats.isSymbolicLink()) return false;
- const entries = await fileSystem.readdir(legacyArchiveDir);
- if (entries.some((entry) => !entry.isFile() || entry.isSymbolicLink())) return false;
- const actualNames = new Set(entries.map((entry) => pathNameKey(entry.name, platform)));
- const expectedNames = new Set(files.map((file) => pathNameKey(file.fileName, platform)));
- if (actualNames.size !== entries.length || expectedNames.size !== files.length || actualNames.size !== expectedNames.size) return false;
- return [...expectedNames].every((name) => actualNames.has(name));
-}
-
-async function assertDestinationStateUnchanged(
- plan: MigrationPlan,
- fileSystem: SessionArchiveMigrationFileSystem,
-): Promise {
- if (await lstatIfExists(plan.destinationIndexPath, fileSystem) !== undefined) {
- throw new Error("Destination archive index appeared after migration preflight");
- }
- const archiveStats = await lstatIfExists(plan.destinationArchiveDir, fileSystem);
- if (!plan.destinationArchiveDirExists) {
- if (archiveStats !== undefined) throw new Error("Destination archive directory appeared after migration preflight");
- return;
- }
- if (archiveStats === undefined || !archiveStats.isDirectory() || archiveStats.isSymbolicLink()) {
- throw new Error("Destination archive directory changed after migration preflight");
- }
- if ((await fileSystem.readdir(plan.destinationArchiveDir)).length !== 0) {
- throw new Error("Destination archive directory is no longer empty");
- }
-}
-
-async function assertPlanReadyToCommit(
- plan: MigrationPlan,
- stagedIndexPath: string,
- fileSystem: SessionArchiveMigrationFileSystem,
-): Promise {
- const currentLegacyRoot = await canonicalizeAllowMissing(plan.legacyRoot, fileSystem);
- const currentDestinationRoot = await canonicalizeAllowMissing(plan.destinationRoot, fileSystem);
- if (!pathsEqual(currentLegacyRoot, plan.canonicalLegacyRoot, plan.platform)
- || !pathsEqual(currentDestinationRoot, plan.canonicalDestinationRoot, plan.platform)) {
- throw new Error("Archive data root changed during migration");
- }
- const legacyIndexStats = await lstatIfExists(plan.legacyIndexPath, fileSystem);
- if (legacyIndexStats === undefined || !legacyIndexStats.isFile() || legacyIndexStats.isSymbolicLink()) {
- throw new Error("Legacy archive index changed during migration");
- }
- if (await fileSystem.readFile(plan.legacyIndexPath) !== plan.sourceIndexContents) {
- throw new Error("Legacy archive index changed during migration");
- }
- if (!await legacyDirectoryMatchesPlan(plan.legacyArchiveDir, plan.legacyArchiveDirExists, plan.files, plan.platform, fileSystem)) {
- throw new Error("Legacy archive directory changed during migration");
- }
- if (plan.legacyArchiveDirExists
- && !pathsEqual(await fileSystem.realpath(plan.legacyArchiveDir), plan.canonicalLegacyArchiveDir, plan.platform)) {
- throw new Error("Legacy archive directory changed during migration");
- }
- if (await lstatIfExists(plan.destinationIndexPath, fileSystem) !== undefined) {
- throw new Error("Destination archive index appeared during migration");
- }
- if (await fileSystem.readFile(stagedIndexPath) !== plan.destinationIndexContents) {
- throw new Error("Staged archive index changed during migration");
- }
-
- if (plan.files.length === 0) {
- const destinationStats = await lstatIfExists(plan.destinationArchiveDir, fileSystem);
- if (plan.destinationArchiveDirExists) {
- if (destinationStats === undefined || !destinationStats.isDirectory() || destinationStats.isSymbolicLink()
- || !pathsEqual(await fileSystem.realpath(plan.destinationArchiveDir), plan.canonicalDestinationArchiveDir, plan.platform)
- || (await fileSystem.readdir(plan.destinationArchiveDir)).length !== 0) {
- throw new Error("Destination archive directory changed during migration");
- }
- } else if (destinationStats !== undefined) {
- throw new Error("Destination archive directory appeared during migration");
- }
- return;
- }
-
- const destinationStats = await lstatIfExists(plan.destinationArchiveDir, fileSystem);
- if (destinationStats === undefined || !destinationStats.isDirectory() || destinationStats.isSymbolicLink()) {
- throw new Error("Destination archive directory is not a real directory");
- }
- if (!pathsEqual(await fileSystem.realpath(plan.destinationArchiveDir), plan.canonicalDestinationArchiveDir, plan.platform)) {
- throw new Error("Destination archive directory changed during migration");
- }
- const destinationEntries = await fileSystem.readdir(plan.destinationArchiveDir);
- if (destinationEntries.some((entry) => !entry.isFile() || entry.isSymbolicLink())) {
- throw new Error("Destination archive directory contains an unexpected entry");
- }
- const actualNames = new Set(destinationEntries.map((entry) => pathNameKey(entry.name, plan.platform)));
- const expectedNames = new Set(plan.files.map((file) => pathNameKey(file.fileName, plan.platform)));
- if (actualNames.size !== destinationEntries.length || actualNames.size !== expectedNames.size
- || ![...expectedNames].every((name) => actualNames.has(name))) {
- throw new Error("Destination archive directory does not match the migration plan");
- }
-
- for (const file of plan.files) {
- const sourceStats = await lstatIfExists(file.sourcePath, fileSystem);
- const destinationFileStats = await lstatIfExists(file.destinationPath, fileSystem);
- if (sourceStats === undefined || !sourceStats.isFile() || sourceStats.isSymbolicLink()
- || destinationFileStats === undefined || !destinationFileStats.isFile() || destinationFileStats.isSymbolicLink()) {
- throw new Error(`Archive file changed during migration: ${file.fileName}`);
- }
- if (!await filesEqual(file.sourcePath, file.destinationPath, fileSystem)) {
- throw new Error(`Archive file verification failed before commit: ${file.fileName}`);
- }
- }
-}
-
-async function rollbackUncommittedDestination(
- state: {
- stagingRoot: string | undefined;
- stagingCreated: boolean;
- destinationArchiveDir: string;
- destinationArchiveCreated: boolean;
- ownedDestinationFiles: string[];
- },
- fileSystem: SessionArchiveMigrationFileSystem,
-): Promise {
- const errors: unknown[] = [];
- for (const path of [...state.ownedDestinationFiles].reverse()) {
- try {
- await removeFileIfPresent(path, fileSystem);
- } catch (error: unknown) {
- errors.push(error);
- }
- }
- if (state.destinationArchiveCreated) {
- try {
- await removeDirectoryIfPresent(state.destinationArchiveDir, fileSystem);
- } catch (error: unknown) {
- errors.push(error);
- }
- }
- if (state.stagingCreated && state.stagingRoot !== undefined) {
- try {
- await fileSystem.rmOwnedTree(state.stagingRoot);
- } catch (error: unknown) {
- errors.push(error);
- }
- }
- return errors;
-}
-
-async function removeCommittedLegacyState(
- plan: MigrationPlan,
- fileSystem: SessionArchiveMigrationFileSystem,
-): Promise {
- const errors: unknown[] = [];
- // Remove the index last. If any file/directory cleanup fails, the complete
- // legacy index remains as a recovery marker while the destination is valid.
- for (const file of plan.files) {
- try {
- await removeFileIfPresent(file.sourcePath, fileSystem);
- } catch (error: unknown) {
- errors.push(error);
- return errors;
- }
- }
- if (plan.legacyArchiveDirExists) {
- try {
- await removeDirectoryIfPresent(plan.legacyArchiveDir, fileSystem);
- } catch (error: unknown) {
- errors.push(error);
- return errors;
- }
- }
- try {
- await removeFileIfPresent(plan.legacyIndexPath, fileSystem);
- } catch (error: unknown) {
- errors.push(error);
- }
- return errors;
-}
-
-async function filesEqual(
- firstPath: string,
- secondPath: string,
- fileSystem: SessionArchiveMigrationFileSystem,
-): Promise {
- const first = await fileSystem.open(firstPath);
- try {
- const second = await fileSystem.open(secondPath);
- try {
- const firstBuffer = Buffer.allocUnsafe(64 * 1024);
- const secondBuffer = Buffer.allocUnsafe(64 * 1024);
- for (;;) {
- const [firstBytes, secondBytes] = await Promise.all([
- readChunk(first, firstBuffer),
- readChunk(second, secondBuffer),
- ]);
- if (firstBytes !== secondBytes) return false;
- if (firstBytes === 0) return true;
- if (!firstBuffer.subarray(0, firstBytes).equals(secondBuffer.subarray(0, secondBytes))) return false;
- }
- } finally {
- await second.close();
- }
- } finally {
- await first.close();
- }
-}
-
-async function readChunk(handle: SessionArchiveMigrationReadHandle, buffer: Buffer): Promise {
- let total = 0;
- while (total < buffer.length) {
- const { bytesRead } = await handle.read(buffer, total, buffer.length - total, null);
- if (bytesRead === 0) break;
- total += bytesRead;
- }
- return total;
-}
-
-async function canonicalizeAllowMissing(path: string, fileSystem: SessionArchiveMigrationFileSystem): Promise {
- let cursor = resolve(path);
- const missingParts: string[] = [];
- for (;;) {
- try {
- const canonical = await fileSystem.realpath(cursor);
- return resolve(canonical, ...missingParts);
- } catch (error: unknown) {
- if (!isNodeErrorWithCode(error, "ENOENT")) throw error;
- if (await lstatIfExists(cursor, fileSystem) !== undefined) {
- throw new Error(`Cannot resolve existing path: ${cursor}`, { cause: error });
- }
- const parent = dirname(cursor);
- if (parent === cursor) throw error;
- missingParts.unshift(basename(cursor));
- cursor = parent;
- }
- }
-}
-
-async function lstatIfExists(path: string, fileSystem: SessionArchiveMigrationFileSystem): Promise {
- try {
- return await fileSystem.lstat(path);
- } catch (error: unknown) {
- if (isNodeErrorWithCode(error, "ENOENT")) return undefined;
- throw error;
- }
-}
-
-async function removeFileIfPresent(path: string, fileSystem: SessionArchiveMigrationFileSystem): Promise {
- try {
- await fileSystem.unlink(path);
- } catch (error: unknown) {
- if (!isNodeErrorWithCode(error, "ENOENT")) throw error;
- }
-}
-
-async function removeDirectoryIfPresent(path: string, fileSystem: SessionArchiveMigrationFileSystem): Promise {
- try {
- await fileSystem.rmdir(path);
- } catch (error: unknown) {
- if (!isNodeErrorWithCode(error, "ENOENT")) throw error;
- }
-}
-
-function migrationFileSystem(options: LegacySessionArchiveMigrationOptions): SessionArchiveMigrationFileSystem {
- return { ...defaultFileSystem, ...options.fileSystem };
-}
-
-function migrationSkipped(
- reason: LegacySessionArchiveMigrationSkipReason,
- error?: unknown,
-): LegacySessionArchiveMigrationSkipped {
- return error === undefined ? { status: "skipped", reason } : { status: "skipped", reason, error };
-}
-
-function pathsOverlap(first: string, second: string, platform: NodeJS.Platform): boolean {
- return pathContains(first, second, platform) || pathContains(second, first, platform);
-}
-
-function pathContains(parent: string, candidate: string, platform: NodeJS.Platform): boolean {
- const parentPath = pathKey(parent, platform);
- const candidatePath = pathKey(candidate, platform);
- const pathFromParent = relative(parentPath, candidatePath);
- const firstSegment = pathFromParent.split(/[\\/]/, 1)[0];
- return pathFromParent === "" || (firstSegment !== ".." && !isAbsolute(pathFromParent));
-}
-
-function pathsEqual(first: string, second: string, platform: NodeJS.Platform): boolean {
- return pathKey(first, platform) === pathKey(second, platform);
-}
-
-function pathKey(path: string, platform: NodeJS.Platform): string {
- const normalized = resolve(path);
- return platform === "win32" ? normalized.toLowerCase() : normalized;
-}
-
-function pathNameKey(name: string, platform: NodeJS.Platform): string {
- return platform === "win32" ? name.toLowerCase() : name;
-}
-
-function collisionKey(path: string): string {
- // Conservatively reject case/normalization variants even when the source
- // platform happens to allow them; the destination filesystem may not.
- return resolve(path).normalize("NFC").toLowerCase();
-}
-
-function safeAttemptId(value: string): string {
- return value.replace(/[^a-zA-Z0-9._-]/g, "_") || "attempt";
-}
-
-function recordArray(value: unknown): Record[] | undefined {
- if (!Array.isArray(value)) return undefined;
- const records: Record[] = [];
- for (const item of value) {
- if (!isRecord(item)) return undefined;
- records.push(item);
- }
- return records;
-}
-
-function isRecord(value: unknown): value is Record {
- return typeof value === "object" && value !== null && !Array.isArray(value);
-}
-
-function isNodeErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException {
- return error instanceof Error && "code" in error && error.code === code;
-}