Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
241 changes: 241 additions & 0 deletions apps/server/src/provider/Drivers/ClaudeSessionRecovery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, describe, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";

import { recoverClaudeSession } from "./ClaudeSessionRecovery.ts";
const encodeTranscriptEntry = Schema.encodeSync(
Schema.fromJsonString(Schema.Struct({ type: Schema.String, cwd: Schema.String })),
);

describe("recoverClaudeSession", () => {
it.effect("copies an orphaned transcript into the current project directory", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fileSystem.makeTempDirectoryScoped();
const configDirectory = path.join(root, ".claude");
const projectsDirectory = path.join(configDirectory, "projects");
const sourceDirectory = path.join(projectsDirectory, "deleted-worktree");
const cwd = path.join(root, "project");
const missingCwd = path.join(root, "deleted-worktree", "apps", "server");
const sessionId = "11111111-1111-4111-8111-111111111111";
const sourceTranscript = path.join(sourceDirectory, `${sessionId}.jsonl`);
const transcript = `${encodeTranscriptEntry({ type: "user", cwd: missingCwd })}\n`;

yield* fileSystem.makeDirectory(sourceDirectory, { recursive: true });
yield* fileSystem.makeDirectory(cwd, { recursive: true });
yield* fileSystem.writeFileString(sourceTranscript, transcript);

const result = yield* recoverClaudeSession({
environment: { CLAUDE_CONFIG_DIR: configDirectory },
cwd,
sessionId,
});
const targetTranscript = path.join(
projectsDirectory,
cwd.replace(/[^a-zA-Z0-9]/g, "-"),
`${sessionId}.jsonl`,
);

assert.equal(result, "rehomed");
assert.equal(yield* fileSystem.readFileString(targetTranscript), transcript);
assert.equal(yield* fileSystem.readFileString(sourceTranscript), transcript);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("leaves a transcript in a live working directory in place", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fileSystem.makeTempDirectoryScoped();
const configDirectory = path.join(root, ".claude");
const sourceDirectory = path.join(configDirectory, "projects", "live-worktree");
const cwd = path.join(root, "project");
const liveCwd = path.join(root, "live-worktree");
const sessionId = "22222222-2222-4222-8222-222222222222";

yield* fileSystem.makeDirectory(sourceDirectory, { recursive: true });
yield* fileSystem.makeDirectory(liveCwd, { recursive: true });
yield* fileSystem.writeFileString(
path.join(sourceDirectory, `${sessionId}.jsonl`),
`${encodeTranscriptEntry({ type: "assistant", cwd: liveCwd })}\n`,
);

const result = yield* recoverClaudeSession({
environment: { CLAUDE_CONFIG_DIR: configDirectory },
cwd,
sessionId,
});

assert.equal(result, "available");
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("ignores non-directory entries in the projects directory", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fileSystem.makeTempDirectoryScoped();
const configDirectory = path.join(root, ".claude");
const projectsDirectory = path.join(configDirectory, "projects");
const sourceDirectory = path.join(projectsDirectory, "deleted-worktree");
const cwd = path.join(root, "project");
const missingCwd = path.join(root, "deleted-worktree");
const sessionId = "44444444-4444-4444-8444-444444444444";

yield* fileSystem.makeDirectory(sourceDirectory, { recursive: true });
yield* fileSystem.makeDirectory(cwd, { recursive: true });
yield* fileSystem.writeFileString(path.join(projectsDirectory, ".DS_Store"), "");
yield* fileSystem.writeFileString(
path.join(sourceDirectory, `${sessionId}.jsonl`),
`${encodeTranscriptEntry({ type: "user", cwd: missingCwd })}\n`,
);

const result = yield* recoverClaudeSession({
environment: { CLAUDE_CONFIG_DIR: configDirectory },
cwd,
sessionId,
});

assert.equal(result, "rehomed");
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("rehomes when the working directory path is now a file", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fileSystem.makeTempDirectoryScoped();
const configDirectory = path.join(root, ".claude");
const sourceDirectory = path.join(configDirectory, "projects", "replaced-worktree");
const cwd = path.join(root, "project");
const replacedCwd = path.join(root, "replaced-worktree");
const sessionId = "77777777-7777-4777-8777-777777777777";

yield* fileSystem.makeDirectory(sourceDirectory, { recursive: true });
yield* fileSystem.makeDirectory(cwd, { recursive: true });
yield* fileSystem.writeFileString(replacedCwd, "");
yield* fileSystem.writeFileString(
path.join(sourceDirectory, `${sessionId}.jsonl`),
`${encodeTranscriptEntry({ type: "user", cwd: replacedCwd })}\n`,
);

const result = yield* recoverClaudeSession({
environment: { CLAUDE_CONFIG_DIR: configDirectory },
cwd,
sessionId,
});

assert.equal(result, "rehomed");
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("rehomes the most recently modified orphaned transcript", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fileSystem.makeTempDirectoryScoped();
const configDirectory = path.join(root, ".claude");
const projectsDirectory = path.join(configDirectory, "projects");
const cwd = path.join(root, "project");
const sessionId = "88888888-8888-4888-8888-888888888888";
const olderDirectory = path.join(projectsDirectory, "aaa-old-worktree");
const newerDirectory = path.join(projectsDirectory, "zzz-new-worktree");

yield* fileSystem.makeDirectory(olderDirectory, { recursive: true });
yield* fileSystem.makeDirectory(newerDirectory, { recursive: true });
yield* fileSystem.makeDirectory(cwd, { recursive: true });
yield* fileSystem.writeFileString(
path.join(olderDirectory, `${sessionId}.jsonl`),
`${encodeTranscriptEntry({ type: "user", cwd: path.join(root, "gone-old") })}\n`,
);
yield* fileSystem.writeFileString(
path.join(newerDirectory, `${sessionId}.jsonl`),
`${encodeTranscriptEntry({ type: "user", cwd: path.join(root, "gone-new") })}\n`,
);
yield* fileSystem.utimes(path.join(olderDirectory, `${sessionId}.jsonl`), 1_000, 1_000);

const result = yield* recoverClaudeSession({
environment: { CLAUDE_CONFIG_DIR: configDirectory },
cwd,
sessionId,
});

assert.equal(result, "rehomed");
const rehomed = yield* fileSystem.readFileString(
path.join(projectsDirectory, cwd.replace(/[^a-zA-Z0-9]/g, "-"), `${sessionId}.jsonl`),
);
assert.equal(rehomed.includes("gone-new"), true);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("leaves an undecodable transcript alone rather than rehoming it", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fileSystem.makeTempDirectoryScoped();
const configDirectory = path.join(root, ".claude");
const sourceDirectory = path.join(configDirectory, "projects", "unknown-worktree");
const cwd = path.join(root, "project");
const sessionId = "66666666-6666-4666-8666-666666666666";

yield* fileSystem.makeDirectory(sourceDirectory, { recursive: true });
yield* fileSystem.makeDirectory(cwd, { recursive: true });
yield* fileSystem.writeFileString(
path.join(sourceDirectory, `${sessionId}.jsonl`),
"not json\n",
);

const result = yield* recoverClaudeSession({
environment: { CLAUDE_CONFIG_DIR: configDirectory },
cwd,
sessionId,
});

assert.equal(result, "missing");
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("reports a missing transcript when no project holds the session", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fileSystem.makeTempDirectoryScoped();
const configDirectory = path.join(root, ".claude");
const otherDirectory = path.join(configDirectory, "projects", "other-project");
const cwd = path.join(root, "project");

yield* fileSystem.makeDirectory(otherDirectory, { recursive: true });
yield* fileSystem.makeDirectory(cwd, { recursive: true });
yield* fileSystem.writeFileString(
path.join(otherDirectory, "99999999-9999-4999-8999-999999999999.jsonl"),
`${encodeTranscriptEntry({ type: "user", cwd })}\n`,
);

const result = yield* recoverClaudeSession({
environment: { CLAUDE_CONFIG_DIR: configDirectory },
cwd,
sessionId: "55555555-5555-4555-8555-555555555555",
});

assert.equal(result, "missing");
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("reports a missing transcript", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const root = yield* fileSystem.makeTempDirectoryScoped();
const result = yield* recoverClaudeSession({
environment: { CLAUDE_CONFIG_DIR: root },
cwd: root,
sessionId: "33333333-3333-4333-8333-333333333333",
});

assert.equal(result, "missing");
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);
});
130 changes: 130 additions & 0 deletions apps/server/src/provider/Drivers/ClaudeSessionRecovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// @effect-diagnostics nodeBuiltinImport:off
import * as NodeOS from "node:os";

import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as FileSystem from "effect/FileSystem";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";

const decodeTranscriptEntry = Schema.decodeUnknownExit(
Schema.fromJsonString(
Schema.Struct({
cwd: Schema.optional(Schema.NullOr(Schema.String)),
}),
),
);

export type ClaudeSessionRecoveryResult = "available" | "rehomed" | "missing";

function latestTranscriptCwd(transcript: string): { readonly cwd: string | undefined } {
let end = transcript.length;
while (end > 0) {
const newline = transcript.lastIndexOf("\n", end - 1);
const decoded = decodeTranscriptEntry(transcript.slice(newline + 1, end));
if (Exit.isSuccess(decoded) && typeof decoded.value.cwd === "string") {
return { cwd: decoded.value.cwd };
}
end = newline;
}
return { cwd: undefined };
}

function claudeProjectDirectoryName(cwd: string): string {
return cwd.replace(/[^a-zA-Z0-9]/g, "-");
}

const isDirectory = Effect.fn("isDirectory")(function* (candidate: string) {
const fileSystem = yield* FileSystem.FileSystem;
return yield* fileSystem.stat(candidate).pipe(
Effect.map((info) => info.type === "Directory"),
Effect.orElseSucceed(() => false),
);
});

const modifiedAtMillis = Effect.fn("modifiedAtMillis")(function* (candidate: string) {
const fileSystem = yield* FileSystem.FileSystem;
return yield* fileSystem.stat(candidate).pipe(
Effect.map((info) =>
Option.match(info.mtime, { onNone: () => 0, onSome: (at) => at.getTime() }),
),
Effect.orElseSucceed(() => 0),
);
});

const readTranscriptTail = Effect.fn("readTranscriptTail")(function* (transcriptPath: string) {
const fileSystem = yield* FileSystem.FileSystem;
const size = (yield* fileSystem.stat(transcriptPath)).size;
const bytesToRead = size < FileSystem.MiB(8) ? size : FileSystem.MiB(8);
return yield* fileSystem
.stream(transcriptPath, {
offset: size - bytesToRead,
bytesToRead,
})
.pipe(
Stream.decodeText(),
Stream.runFold(
() => "",
(content, chunk) => content + chunk,
),
);
});

export const recoverClaudeSession = Effect.fn("recoverClaudeSession")(function* (input: {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
readonly environment: NodeJS.ProcessEnv;
readonly cwd: string;
readonly sessionId: string;
}) {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const configured = input.environment.CLAUDE_CONFIG_DIR?.trim();
const configDirectory = configured
? path.resolve(input.cwd, configured)
: path.join(NodeOS.homedir(), ".claude");
const projectsDirectory = path.join(configDirectory, "projects");
const targetDirectory = path.join(
projectsDirectory,
claudeProjectDirectoryName(path.resolve(input.cwd)),
);
const targetTranscript = path.join(targetDirectory, `${input.sessionId}.jsonl`);

if (yield* fileSystem.exists(targetTranscript)) {
return "available";
}
if (!(yield* fileSystem.exists(projectsDirectory))) {
return "missing";
}

const orphanedTranscripts: Array<{ readonly path: string; readonly modifiedAtMillis: number }> =
[];
for (const entry of yield* fileSystem.readDirectory(projectsDirectory)) {
const transcriptPath = path.join(projectsDirectory, entry, `${input.sessionId}.jsonl`);
if (!(yield* fileSystem.exists(transcriptPath).pipe(Effect.orElseSucceed(() => false)))) {
continue;
}
const { cwd: transcriptCwd } = latestTranscriptCwd(yield* readTranscriptTail(transcriptPath));
if (transcriptCwd === undefined) {
continue;
}
if (yield* isDirectory(transcriptCwd)) {
return "available";
}
Comment thread
cursor[bot] marked this conversation as resolved.
orphanedTranscripts.push({
path: transcriptPath,
modifiedAtMillis: yield* modifiedAtMillis(transcriptPath),
});
Comment thread
chrisdeeming marked this conversation as resolved.
}

const sourceTranscript = orphanedTranscripts.sort(
(left, right) => right.modifiedAtMillis - left.modifiedAtMillis,
)[0]?.path;
if (!sourceTranscript) {
return "missing";
}
Comment thread
cursor[bot] marked this conversation as resolved.

yield* fileSystem.makeDirectory(targetDirectory, { recursive: true });
yield* fileSystem.copyFile(sourceTranscript, targetTranscript);
return "rehomed";
});
Loading
Loading