Skip to content

Commit a6b8a5f

Browse files
authored
Keep agent state layout self-contained and safely ignored (#123)
FileSnapshotStore now accepts a top-level state directory, resolves it at construction time, owns the <root>/sessions layout, and best-effort appends that root to the containing worktree's .gitignore. Consumers no longer precompute sessions directories or maintain ignore entries independently. This keeps session storage stable even if process.cwd() changes after store construction, and it aligns the shipped release notes with the public rootDir/sessionsDir getter contract. Constraint: Preserve #124's edge-safe runtime import changes while rebasing onto current main Rejected: Leave relative rootDir values as-is | contradicted the resolved-path contract and made later cwd changes affect save/load paths Confidence: high Scope-risk: moderate Directive: Do not reintroduce caller-managed /sessions paths without updating downstream env names and migration notes Tested: pnpm run typecheck; pnpm run test; pnpm run build; pnpm run check Not-tested: Live model-backed minimal-agent session
1 parent 54125d0 commit a6b8a5f

16 files changed

Lines changed: 779 additions & 35 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@ai-sdk-tool/harness": patch
3+
"@plugsuits/minimal-agent": patch
4+
"@plugsuits/tgbot": patch
5+
"plugsuits": patch
6+
---
7+
8+
`FileSnapshotStore` now takes a top-level directory (e.g. `.plugsuits`, `.minimal-agent`) and manages its internal layout itself: session snapshots land in `<root>/sessions/*.jsonl`. The public `rootDir` / `sessionsDir` getters expose the resolved paths for consumers that want to co-locate related files (e.g. session memory).
9+
10+
When the root directory lives inside a git worktree (detected by a sibling `.git` marker, directory or file), the store appends the top-level directory to that worktree's `.gitignore` if not already listed. The update is concurrency-safe: an exclusive `.gitignore.lock` (via `openSync(path, "wx")`) serializes writers, and the content swap is atomic (temp-file + rename). Stale locks older than 30s are reclaimed so a crashed writer can't wedge the next caller. The file's existing line-ending convention is preserved (LF or CRLF), and the helper refuses to write to any ancestor `.gitignore` that is not at a verified worktree root — so it cannot accidentally modify a parent repo's or a user's home-level ignore file. Disable with `new FileSnapshotStore(dir, { autoGitignore: false })`.
11+
12+
Env var migrations (no backward compatibility):
13+
14+
- `minimal-agent`: `SESSION_DIR``MINIMAL_AGENT_DIR` (default `.minimal-agent`)
15+
- `tgbot`: `SESSION_DIR``TGBOT_DIR` (default `<tmpdir>/tgbot`)
16+
17+
CEA now constructs its store with `.plugsuits` as the top-level directory and derives its session-memory path from `store.sessionsDir`.
18+
19+
The previously-undocumented `getFilePath` fallback for unencoded session filenames has been removed; session files always live at `<sessionsDir>/<encodeSessionId(sessionId)>.jsonl`.

AGENTS.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,17 @@ const history = new CheckpointHistory({ compaction: {...} });
120120

121121
// 2. File-persisted (recommended for most consumers)
122122
import { FileSnapshotStore } from "@ai-sdk-tool/harness/sessions";
123-
const store = new FileSnapshotStore(".plugsuits/sessions");
123+
// Pass a top-level directory (e.g. ".plugsuits", ".minimal-agent").
124+
// The store manages its internal layout: <root>/sessions/*.jsonl
125+
// Auto-gitignore: when the root dir lives inside a git worktree (detected by
126+
// a sibling `.git` marker, dir or file), the store appends the top-level dir
127+
// to that worktree's `.gitignore` if not already listed. The update is atomic
128+
// (lockfile + temp-file rename) and preserves LF/CRLF line endings. It
129+
// refuses to modify any ancestor `.gitignore` that is not at a verified
130+
// worktree root. Disable with `{ autoGitignore: false }`.
131+
const store = new FileSnapshotStore(".plugsuits");
132+
// store.sessionsDir is resolved, e.g. "<cwd>/.plugsuits/sessions"
133+
// (use for co-located files).
124134
const history = await CheckpointHistory.fromSnapshot(store, sessionId, { compaction });
125135
// After each turn — caller decides when:
126136
await store.save(sessionId, history.snapshot());

packages/cea/src/entrypoints/main.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { mkdirSync, readFileSync } from "node:fs";
1+
import { readFileSync } from "node:fs";
22
import { dirname, join } from "node:path";
33
import { fileURLToPath } from "node:url";
44
import {
@@ -142,16 +142,14 @@ if (!sessionManagerScope.__ceaSessionManager) {
142142
sessionManagerScope.__ceaSessionManager = new SessionManager();
143143
}
144144
const sessionManager = sessionManagerScope.__ceaSessionManager;
145-
const sessionStoreBaseDir = join(process.cwd(), ".plugsuits", "sessions");
146-
mkdirSync(sessionStoreBaseDir, { recursive: true });
147-
const store = new FileSnapshotStore(sessionStoreBaseDir);
145+
const store = new FileSnapshotStore(join(process.cwd(), ".plugsuits"));
148146
const userPreferencesBundle = createUserPreferencesStore();
149147
configurePreferencesPersistence({
150148
bundle: userPreferencesBundle.bundle,
151149
workspaceStore: userPreferencesBundle.workspaceStore,
152150
});
153151
const resolveSessionMemoryStorePath = (sessionId: string): string =>
154-
join(sessionStoreBaseDir, sessionId, "session-memory.md");
152+
join(store.sessionsDir, sessionId, "session-memory.md");
155153
const createSessionMemoryStore = (sessionId: string): FileMemoryStore =>
156154
new FileMemoryStore(resolveSessionMemoryStorePath(sessionId));
157155
let messageHistory: CheckpointHistory;

packages/harness/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ const assistant = defineAgent({
3838
const runtime = await createAgentRuntime({
3939
name: "my-assistant",
4040
agents: [assistant],
41-
persistence: { snapshotStore: new FileSnapshotStore(".sessions") },
41+
persistence: { snapshotStore: new FileSnapshotStore(".my-assistant") },
4242
});
4343

4444
const session = await runtime.openSession();

packages/harness/src/file-snapshot-store.test.ts

Lines changed: 106 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,13 @@
1-
import { appendFileSync, mkdtempSync, rmSync } from "node:fs";
1+
import {
2+
appendFileSync,
3+
existsSync,
4+
mkdirSync,
5+
mkdtempSync,
6+
readFileSync,
7+
realpathSync,
8+
rmSync,
9+
writeFileSync,
10+
} from "node:fs";
211
import { tmpdir } from "node:os";
312
import { join } from "node:path";
413
import { afterEach, beforeEach, describe, expect, it } from "vitest";
@@ -11,7 +20,7 @@ describe("FileSnapshotStore", () => {
1120

1221
beforeEach(() => {
1322
tmpDir = mkdtempSync(join(tmpdir(), "file-snapshot-store-test-"));
14-
store = new FileSnapshotStore(tmpDir);
23+
store = new FileSnapshotStore(tmpDir, { autoGitignore: false });
1524
});
1625

1726
afterEach(() => {
@@ -180,9 +189,9 @@ describe("FileSnapshotStore", () => {
180189
});
181190
});
182191

183-
it("loads existing legacy JSONL sessions", async () => {
184-
const sessionId = "legacy_session";
185-
const filePath = join(tmpDir, `${sessionId}.jsonl`);
192+
it("loads existing JSONL sessions written in legacy line formats", async () => {
193+
const sessionId = "legacy-session";
194+
const filePath = join(tmpDir, "sessions", `${sessionId}.jsonl`);
186195

187196
appendFileSync(
188197
filePath,
@@ -223,6 +232,98 @@ describe("FileSnapshotStore", () => {
223232
});
224233
});
225234

235+
it("exposes rootDir and sessionsDir paths", () => {
236+
expect(store.rootDir).toBe(tmpDir);
237+
expect(store.sessionsDir).toBe(join(tmpDir, "sessions"));
238+
expect(existsSync(store.sessionsDir)).toBe(true);
239+
});
240+
241+
it("resolves relative root dirs at construction time", async () => {
242+
const originalCwd = process.cwd();
243+
const worktree = realpathSync(
244+
mkdtempSync(join(tmpdir(), "file-snapshot-relative-"))
245+
);
246+
const otherDir = realpathSync(
247+
mkdtempSync(join(tmpdir(), "file-snapshot-other-"))
248+
);
249+
try {
250+
process.chdir(worktree);
251+
const relativeStore = new FileSnapshotStore(".state", {
252+
autoGitignore: false,
253+
});
254+
255+
expect(relativeStore.rootDir).toBe(join(worktree, ".state"));
256+
expect(relativeStore.sessionsDir).toBe(
257+
join(worktree, ".state", "sessions")
258+
);
259+
260+
process.chdir(otherDir);
261+
await relativeStore.save("session-abc", {
262+
messages: [],
263+
revision: 0,
264+
contextLimit: 0,
265+
systemPromptTokens: 0,
266+
toolSchemasTokens: 0,
267+
});
268+
269+
expect(
270+
existsSync(join(worktree, ".state", "sessions", "session-abc.jsonl"))
271+
).toBe(true);
272+
expect(
273+
existsSync(join(otherDir, ".state", "sessions", "session-abc.jsonl"))
274+
).toBe(false);
275+
} finally {
276+
process.chdir(originalCwd);
277+
rmSync(worktree, { recursive: true, force: true });
278+
rmSync(otherDir, { recursive: true, force: true });
279+
}
280+
});
281+
282+
it("writes sessions into the <root>/sessions subdirectory", async () => {
283+
const snapshot: HistorySnapshot = {
284+
messages: [],
285+
revision: 0,
286+
contextLimit: 0,
287+
systemPromptTokens: 0,
288+
toolSchemasTokens: 0,
289+
};
290+
await store.save("session-abc", snapshot);
291+
292+
const sessionFile = join(tmpDir, "sessions", "session-abc.jsonl");
293+
expect(existsSync(sessionFile)).toBe(true);
294+
});
295+
296+
it("appends the top-level dir to the worktree .gitignore by default", () => {
297+
const worktree = realpathSync(
298+
mkdtempSync(join(tmpdir(), "file-snapshot-worktree-"))
299+
);
300+
try {
301+
mkdirSync(join(worktree, ".git"));
302+
const gitignorePath = join(worktree, ".gitignore");
303+
writeFileSync(gitignorePath, "node_modules/\n", "utf8");
304+
const root = join(worktree, ".test-store");
305+
new FileSnapshotStore(root);
306+
const contents = readFileSync(gitignorePath, "utf8");
307+
expect(contents).toContain(".test-store");
308+
} finally {
309+
rmSync(worktree, { recursive: true, force: true });
310+
}
311+
});
312+
313+
it("skips gitignore update when root dir is outside any git worktree", () => {
314+
const outside = realpathSync(
315+
mkdtempSync(join(tmpdir(), "file-snapshot-no-repo-"))
316+
);
317+
try {
318+
const gitignorePath = join(outside, ".gitignore");
319+
writeFileSync(gitignorePath, "node_modules/\n", "utf8");
320+
new FileSnapshotStore(join(outside, ".test-store"));
321+
expect(readFileSync(gitignorePath, "utf8")).toBe("node_modules/\n");
322+
} finally {
323+
rmSync(outside, { recursive: true, force: true });
324+
}
325+
});
326+
226327
it("writes snapshot atomically over existing content", async () => {
227328
await store.save("session", {
228329
messages: [

packages/harness/src/file-snapshot-store.ts

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,38 @@ import {
66
rmSync,
77
writeFileSync,
88
} from "node:fs";
9-
import { join } from "node:path";
9+
import { join, resolve } from "node:path";
1010
import type { MessageLine, SessionFileLine } from "./compaction-types";
11+
import { ensureDirIgnoredByGit } from "./gitignore-sync";
1112
import type { HistorySnapshot } from "./history-snapshot";
1213
import { encodeSessionId, type SessionData } from "./session-store";
1314
import type { SnapshotStore } from "./snapshot-store";
1415
import { createRuntimeUUID } from "./uuid";
1516

17+
export const SESSIONS_SUBDIR = "sessions";
18+
19+
export interface FileSnapshotStoreOptions {
20+
/**
21+
* When true (default), append the configured top-level directory to the
22+
* nearest ancestor `.gitignore` if not already listed. Best-effort — never
23+
* fails initialization. Set to `false` to disable, e.g. in tests or when
24+
* writing outside of a git worktree on purpose.
25+
*/
26+
autoGitignore?: boolean;
27+
}
28+
1629
export class FileSnapshotStore implements SnapshotStore {
17-
private readonly baseDir: string;
30+
readonly rootDir: string;
31+
readonly sessionsDir: string;
1832

19-
constructor(baseDir: string) {
20-
mkdirSync(baseDir, { recursive: true });
21-
this.baseDir = baseDir;
33+
constructor(rootDir: string, options: FileSnapshotStoreOptions = {}) {
34+
this.rootDir = resolve(rootDir);
35+
this.sessionsDir = join(this.rootDir, SESSIONS_SUBDIR);
36+
mkdirSync(this.sessionsDir, { recursive: true });
37+
38+
if (options.autoGitignore !== false) {
39+
ensureDirIgnoredByGit(this.rootDir);
40+
}
2241
}
2342

2443
load(sessionId: string): Promise<HistorySnapshot | null> {
@@ -109,17 +128,7 @@ export class FileSnapshotStore implements SnapshotStore {
109128

110129
private getFilePath(sessionId: string): string {
111130
const encoded = encodeSessionId(sessionId);
112-
const primary = join(this.baseDir, `${encoded}.jsonl`);
113-
if (existsSync(primary)) {
114-
return primary;
115-
}
116-
117-
const legacy = join(this.baseDir, `${sessionId}.jsonl`);
118-
if (existsSync(legacy)) {
119-
return legacy;
120-
}
121-
122-
return primary;
131+
return join(this.sessionsDir, `${encoded}.jsonl`);
123132
}
124133

125134
private loadSession(sessionId: string): SessionData | null {
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { ensureGitignoreEntry } from "./gitignore-sync.ts";
2+
3+
const [, , gitignorePath, entry] = process.argv;
4+
if (!(gitignorePath && entry)) {
5+
process.exit(2);
6+
}
7+
8+
ensureGitignoreEntry(gitignorePath, entry);
9+
process.exit(0);

0 commit comments

Comments
 (0)