Skip to content

Commit a905f33

Browse files
committed
fix(server): recover provider sessions when resume history is missing
1 parent 7251f1a commit a905f33

15 files changed

Lines changed: 952 additions & 35 deletions
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
import * as NodeServices from "@effect/platform-node/NodeServices";
2+
import { assert, describe, it } from "@effect/vitest";
3+
import * as Effect from "effect/Effect";
4+
import * as FileSystem from "effect/FileSystem";
5+
import * as Path from "effect/Path";
6+
import * as Schema from "effect/Schema";
7+
8+
import { recoverClaudeSession } from "./ClaudeSessionRecovery.ts";
9+
const encodeTranscriptEntry = Schema.encodeSync(
10+
Schema.fromJsonString(Schema.Struct({ type: Schema.String, cwd: Schema.String })),
11+
);
12+
13+
describe("recoverClaudeSession", () => {
14+
it.effect("copies an orphaned transcript into the current project directory", () =>
15+
Effect.gen(function* () {
16+
const fileSystem = yield* FileSystem.FileSystem;
17+
const path = yield* Path.Path;
18+
const root = yield* fileSystem.makeTempDirectoryScoped();
19+
const configDirectory = path.join(root, ".claude");
20+
const projectsDirectory = path.join(configDirectory, "projects");
21+
const sourceDirectory = path.join(projectsDirectory, "deleted-worktree");
22+
const cwd = path.join(root, "project");
23+
const missingCwd = path.join(root, "deleted-worktree", "apps", "server");
24+
const sessionId = "11111111-1111-4111-8111-111111111111";
25+
const sourceTranscript = path.join(sourceDirectory, `${sessionId}.jsonl`);
26+
const transcript = `${encodeTranscriptEntry({ type: "user", cwd: missingCwd })}\n`;
27+
28+
yield* fileSystem.makeDirectory(sourceDirectory, { recursive: true });
29+
yield* fileSystem.makeDirectory(cwd, { recursive: true });
30+
yield* fileSystem.writeFileString(sourceTranscript, transcript);
31+
32+
const result = yield* recoverClaudeSession({
33+
environment: { CLAUDE_CONFIG_DIR: configDirectory },
34+
cwd,
35+
sessionId,
36+
});
37+
const targetTranscript = path.join(
38+
projectsDirectory,
39+
cwd.replace(/[^a-zA-Z0-9]/g, "-"),
40+
`${sessionId}.jsonl`,
41+
);
42+
43+
assert.equal(result, "rehomed");
44+
assert.equal(yield* fileSystem.readFileString(targetTranscript), transcript);
45+
assert.equal(yield* fileSystem.readFileString(sourceTranscript), transcript);
46+
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
47+
);
48+
49+
it.effect("leaves a transcript in a live working directory in place", () =>
50+
Effect.gen(function* () {
51+
const fileSystem = yield* FileSystem.FileSystem;
52+
const path = yield* Path.Path;
53+
const root = yield* fileSystem.makeTempDirectoryScoped();
54+
const configDirectory = path.join(root, ".claude");
55+
const sourceDirectory = path.join(configDirectory, "projects", "live-worktree");
56+
const cwd = path.join(root, "project");
57+
const liveCwd = path.join(root, "live-worktree");
58+
const sessionId = "22222222-2222-4222-8222-222222222222";
59+
60+
yield* fileSystem.makeDirectory(sourceDirectory, { recursive: true });
61+
yield* fileSystem.makeDirectory(liveCwd, { recursive: true });
62+
yield* fileSystem.writeFileString(
63+
path.join(sourceDirectory, `${sessionId}.jsonl`),
64+
`${encodeTranscriptEntry({ type: "assistant", cwd: liveCwd })}\n`,
65+
);
66+
67+
const result = yield* recoverClaudeSession({
68+
environment: { CLAUDE_CONFIG_DIR: configDirectory },
69+
cwd,
70+
sessionId,
71+
});
72+
73+
assert.equal(result, "available");
74+
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
75+
);
76+
77+
it.effect("ignores non-directory entries in the projects directory", () =>
78+
Effect.gen(function* () {
79+
const fileSystem = yield* FileSystem.FileSystem;
80+
const path = yield* Path.Path;
81+
const root = yield* fileSystem.makeTempDirectoryScoped();
82+
const configDirectory = path.join(root, ".claude");
83+
const projectsDirectory = path.join(configDirectory, "projects");
84+
const sourceDirectory = path.join(projectsDirectory, "deleted-worktree");
85+
const cwd = path.join(root, "project");
86+
const missingCwd = path.join(root, "deleted-worktree");
87+
const sessionId = "44444444-4444-4444-8444-444444444444";
88+
89+
yield* fileSystem.makeDirectory(sourceDirectory, { recursive: true });
90+
yield* fileSystem.makeDirectory(cwd, { recursive: true });
91+
yield* fileSystem.writeFileString(path.join(projectsDirectory, ".DS_Store"), "");
92+
yield* fileSystem.writeFileString(
93+
path.join(sourceDirectory, `${sessionId}.jsonl`),
94+
`${encodeTranscriptEntry({ type: "user", cwd: missingCwd })}\n`,
95+
);
96+
97+
const result = yield* recoverClaudeSession({
98+
environment: { CLAUDE_CONFIG_DIR: configDirectory },
99+
cwd,
100+
sessionId,
101+
});
102+
103+
assert.equal(result, "rehomed");
104+
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
105+
);
106+
107+
it.effect("rehomes when the working directory path is now a file", () =>
108+
Effect.gen(function* () {
109+
const fileSystem = yield* FileSystem.FileSystem;
110+
const path = yield* Path.Path;
111+
const root = yield* fileSystem.makeTempDirectoryScoped();
112+
const configDirectory = path.join(root, ".claude");
113+
const sourceDirectory = path.join(configDirectory, "projects", "replaced-worktree");
114+
const cwd = path.join(root, "project");
115+
const replacedCwd = path.join(root, "replaced-worktree");
116+
const sessionId = "77777777-7777-4777-8777-777777777777";
117+
118+
yield* fileSystem.makeDirectory(sourceDirectory, { recursive: true });
119+
yield* fileSystem.makeDirectory(cwd, { recursive: true });
120+
yield* fileSystem.writeFileString(replacedCwd, "");
121+
yield* fileSystem.writeFileString(
122+
path.join(sourceDirectory, `${sessionId}.jsonl`),
123+
`${encodeTranscriptEntry({ type: "user", cwd: replacedCwd })}\n`,
124+
);
125+
126+
const result = yield* recoverClaudeSession({
127+
environment: { CLAUDE_CONFIG_DIR: configDirectory },
128+
cwd,
129+
sessionId,
130+
});
131+
132+
assert.equal(result, "rehomed");
133+
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
134+
);
135+
136+
it.effect("rehomes the most recently modified orphaned transcript", () =>
137+
Effect.gen(function* () {
138+
const fileSystem = yield* FileSystem.FileSystem;
139+
const path = yield* Path.Path;
140+
const root = yield* fileSystem.makeTempDirectoryScoped();
141+
const configDirectory = path.join(root, ".claude");
142+
const projectsDirectory = path.join(configDirectory, "projects");
143+
const cwd = path.join(root, "project");
144+
const sessionId = "88888888-8888-4888-8888-888888888888";
145+
const olderDirectory = path.join(projectsDirectory, "aaa-old-worktree");
146+
const newerDirectory = path.join(projectsDirectory, "zzz-new-worktree");
147+
148+
yield* fileSystem.makeDirectory(olderDirectory, { recursive: true });
149+
yield* fileSystem.makeDirectory(newerDirectory, { recursive: true });
150+
yield* fileSystem.makeDirectory(cwd, { recursive: true });
151+
yield* fileSystem.writeFileString(
152+
path.join(olderDirectory, `${sessionId}.jsonl`),
153+
`${encodeTranscriptEntry({ type: "user", cwd: path.join(root, "gone-old") })}\n`,
154+
);
155+
yield* fileSystem.writeFileString(
156+
path.join(newerDirectory, `${sessionId}.jsonl`),
157+
`${encodeTranscriptEntry({ type: "user", cwd: path.join(root, "gone-new") })}\n`,
158+
);
159+
yield* fileSystem.utimes(path.join(olderDirectory, `${sessionId}.jsonl`), 1_000, 1_000);
160+
161+
const result = yield* recoverClaudeSession({
162+
environment: { CLAUDE_CONFIG_DIR: configDirectory },
163+
cwd,
164+
sessionId,
165+
});
166+
167+
assert.equal(result, "rehomed");
168+
const rehomed = yield* fileSystem.readFileString(
169+
path.join(projectsDirectory, cwd.replace(/[^a-zA-Z0-9]/g, "-"), `${sessionId}.jsonl`),
170+
);
171+
assert.equal(rehomed.includes("gone-new"), true);
172+
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
173+
);
174+
175+
it.effect("leaves an undecodable transcript alone rather than rehoming it", () =>
176+
Effect.gen(function* () {
177+
const fileSystem = yield* FileSystem.FileSystem;
178+
const path = yield* Path.Path;
179+
const root = yield* fileSystem.makeTempDirectoryScoped();
180+
const configDirectory = path.join(root, ".claude");
181+
const sourceDirectory = path.join(configDirectory, "projects", "unknown-worktree");
182+
const cwd = path.join(root, "project");
183+
const sessionId = "66666666-6666-4666-8666-666666666666";
184+
185+
yield* fileSystem.makeDirectory(sourceDirectory, { recursive: true });
186+
yield* fileSystem.makeDirectory(cwd, { recursive: true });
187+
yield* fileSystem.writeFileString(
188+
path.join(sourceDirectory, `${sessionId}.jsonl`),
189+
"not json\n",
190+
);
191+
192+
const result = yield* recoverClaudeSession({
193+
environment: { CLAUDE_CONFIG_DIR: configDirectory },
194+
cwd,
195+
sessionId,
196+
});
197+
198+
assert.equal(result, "missing");
199+
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
200+
);
201+
202+
it.effect("reports a missing transcript when no project holds the session", () =>
203+
Effect.gen(function* () {
204+
const fileSystem = yield* FileSystem.FileSystem;
205+
const path = yield* Path.Path;
206+
const root = yield* fileSystem.makeTempDirectoryScoped();
207+
const configDirectory = path.join(root, ".claude");
208+
const otherDirectory = path.join(configDirectory, "projects", "other-project");
209+
const cwd = path.join(root, "project");
210+
211+
yield* fileSystem.makeDirectory(otherDirectory, { recursive: true });
212+
yield* fileSystem.makeDirectory(cwd, { recursive: true });
213+
yield* fileSystem.writeFileString(
214+
path.join(otherDirectory, "99999999-9999-4999-8999-999999999999.jsonl"),
215+
`${encodeTranscriptEntry({ type: "user", cwd })}\n`,
216+
);
217+
218+
const result = yield* recoverClaudeSession({
219+
environment: { CLAUDE_CONFIG_DIR: configDirectory },
220+
cwd,
221+
sessionId: "55555555-5555-4555-8555-555555555555",
222+
});
223+
224+
assert.equal(result, "missing");
225+
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
226+
);
227+
228+
it.effect("reports a missing transcript", () =>
229+
Effect.gen(function* () {
230+
const fileSystem = yield* FileSystem.FileSystem;
231+
const root = yield* fileSystem.makeTempDirectoryScoped();
232+
const result = yield* recoverClaudeSession({
233+
environment: { CLAUDE_CONFIG_DIR: root },
234+
cwd: root,
235+
sessionId: "33333333-3333-4333-8333-333333333333",
236+
});
237+
238+
assert.equal(result, "missing");
239+
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
240+
);
241+
});
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
// @effect-diagnostics nodeBuiltinImport:off
2+
import * as NodeOS from "node:os";
3+
4+
import * as Effect from "effect/Effect";
5+
import * as Exit from "effect/Exit";
6+
import * as FileSystem from "effect/FileSystem";
7+
import * as Option from "effect/Option";
8+
import * as Path from "effect/Path";
9+
import * as Schema from "effect/Schema";
10+
import * as Stream from "effect/Stream";
11+
12+
const decodeTranscriptEntry = Schema.decodeUnknownExit(
13+
Schema.fromJsonString(
14+
Schema.Struct({
15+
cwd: Schema.optional(Schema.NullOr(Schema.String)),
16+
}),
17+
),
18+
);
19+
20+
export type ClaudeSessionRecoveryResult = "available" | "rehomed" | "missing";
21+
22+
function latestTranscriptCwd(transcript: string): { readonly cwd: string | undefined } {
23+
let end = transcript.length;
24+
while (end > 0) {
25+
const newline = transcript.lastIndexOf("\n", end - 1);
26+
const decoded = decodeTranscriptEntry(transcript.slice(newline + 1, end));
27+
if (Exit.isSuccess(decoded) && typeof decoded.value.cwd === "string") {
28+
return { cwd: decoded.value.cwd };
29+
}
30+
end = newline;
31+
}
32+
return { cwd: undefined };
33+
}
34+
35+
function claudeProjectDirectoryName(cwd: string): string {
36+
return cwd.replace(/[^a-zA-Z0-9]/g, "-");
37+
}
38+
39+
const isDirectory = Effect.fn("isDirectory")(function* (candidate: string) {
40+
const fileSystem = yield* FileSystem.FileSystem;
41+
return yield* fileSystem.stat(candidate).pipe(
42+
Effect.map((info) => info.type === "Directory"),
43+
Effect.orElseSucceed(() => false),
44+
);
45+
});
46+
47+
const modifiedAtMillis = Effect.fn("modifiedAtMillis")(function* (candidate: string) {
48+
const fileSystem = yield* FileSystem.FileSystem;
49+
return yield* fileSystem.stat(candidate).pipe(
50+
Effect.map((info) =>
51+
Option.match(info.mtime, { onNone: () => 0, onSome: (at) => at.getTime() }),
52+
),
53+
Effect.orElseSucceed(() => 0),
54+
);
55+
});
56+
57+
const readTranscriptTail = Effect.fn("readTranscriptTail")(function* (transcriptPath: string) {
58+
const fileSystem = yield* FileSystem.FileSystem;
59+
const size = (yield* fileSystem.stat(transcriptPath)).size;
60+
const bytesToRead = size < FileSystem.MiB(8) ? size : FileSystem.MiB(8);
61+
return yield* fileSystem
62+
.stream(transcriptPath, {
63+
offset: size - bytesToRead,
64+
bytesToRead,
65+
})
66+
.pipe(
67+
Stream.decodeText(),
68+
Stream.runFold(
69+
() => "",
70+
(content, chunk) => content + chunk,
71+
),
72+
);
73+
});
74+
75+
export const recoverClaudeSession = Effect.fn("recoverClaudeSession")(function* (input: {
76+
readonly environment: NodeJS.ProcessEnv;
77+
readonly cwd: string;
78+
readonly sessionId: string;
79+
}) {
80+
const fileSystem = yield* FileSystem.FileSystem;
81+
const path = yield* Path.Path;
82+
const configured = input.environment.CLAUDE_CONFIG_DIR?.trim();
83+
const configDirectory = configured
84+
? path.resolve(input.cwd, configured)
85+
: path.join(NodeOS.homedir(), ".claude");
86+
const projectsDirectory = path.join(configDirectory, "projects");
87+
const targetDirectory = path.join(
88+
projectsDirectory,
89+
claudeProjectDirectoryName(path.resolve(input.cwd)),
90+
);
91+
const targetTranscript = path.join(targetDirectory, `${input.sessionId}.jsonl`);
92+
93+
if (yield* fileSystem.exists(targetTranscript)) {
94+
return "available";
95+
}
96+
if (!(yield* fileSystem.exists(projectsDirectory))) {
97+
return "missing";
98+
}
99+
100+
const orphanedTranscripts: Array<{ readonly path: string; readonly modifiedAtMillis: number }> =
101+
[];
102+
for (const entry of yield* fileSystem.readDirectory(projectsDirectory)) {
103+
const transcriptPath = path.join(projectsDirectory, entry, `${input.sessionId}.jsonl`);
104+
if (!(yield* fileSystem.exists(transcriptPath).pipe(Effect.orElseSucceed(() => false)))) {
105+
continue;
106+
}
107+
const { cwd: transcriptCwd } = latestTranscriptCwd(yield* readTranscriptTail(transcriptPath));
108+
if (transcriptCwd === undefined) {
109+
continue;
110+
}
111+
if (yield* isDirectory(transcriptCwd)) {
112+
return "available";
113+
}
114+
orphanedTranscripts.push({
115+
path: transcriptPath,
116+
modifiedAtMillis: yield* modifiedAtMillis(transcriptPath),
117+
});
118+
}
119+
120+
const sourceTranscript = orphanedTranscripts.sort(
121+
(left, right) => right.modifiedAtMillis - left.modifiedAtMillis,
122+
)[0]?.path;
123+
if (!sourceTranscript) {
124+
return "missing";
125+
}
126+
127+
yield* fileSystem.makeDirectory(targetDirectory, { recursive: true });
128+
yield* fileSystem.copyFile(sourceTranscript, targetTranscript);
129+
return "rehomed";
130+
});

0 commit comments

Comments
 (0)