@cueloop/daemon 0.1.0-alpha.11 → 0.1.0-alpha.110
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/SDK.md +102 -0
- package/package.json +44 -22
- package/src/agent-tools.ts +104 -0
- package/src/agent-turn.test.ts +24 -0
- package/src/agent-turn.ts +34 -0
- package/src/api.test.ts +1081 -65
- package/src/api.ts +1483 -90
- package/src/capabilities.test.ts +57 -0
- package/src/capabilities.ts +95 -0
- package/src/client-errors.ts +24 -0
- package/src/client-reliability.test.ts +191 -0
- package/src/client.test.ts +37 -0
- package/src/client.ts +844 -95
- package/src/curate.ts +111 -0
- package/src/daemon-sdk-effect.ts +113 -0
- package/src/daemon-sdk.test.ts +306 -0
- package/src/daemon-sdk.ts +405 -0
- package/src/daemon-version.test.ts +142 -0
- package/src/diff-hot-reload.test.ts +423 -0
- package/src/diff-watcher.test.ts +165 -0
- package/src/diff-watcher.ts +363 -0
- package/src/examples/daemon-sdk.ts +57 -0
- package/src/gh.test.ts +59 -0
- package/src/gh.ts +78 -0
- package/src/ghostty-thread-surface-store.test.ts +49 -0
- package/src/ghostty-thread-surface-store.ts +104 -0
- package/src/ghostty-thread-surface.test.ts +400 -0
- package/src/ghostty-thread-surface.ts +273 -0
- package/src/harness-delivery.test.ts +269 -0
- package/src/harness-diagnostics.test.ts +26 -0
- package/src/harness-diagnostics.ts +31 -0
- package/src/harness-handoff.test.ts +725 -0
- package/src/harness-handoff.ts +27 -0
- package/src/harness-state-store.test.ts +93 -0
- package/src/harness-state-store.ts +218 -0
- package/src/herdr-split.test.ts +106 -0
- package/src/herdr-split.ts +90 -0
- package/src/herdr-thread-surface-store.test.ts +81 -0
- package/src/herdr-thread-surface-store.ts +66 -0
- package/src/herdr-thread-surface.test.ts +465 -0
- package/src/herdr-thread-surface.ts +392 -0
- package/src/index.ts +8 -9
- package/src/interruptible-wait.test.ts +127 -0
- package/src/interruptible-wait.ts +69 -0
- package/src/jj-working-tree.ts +140 -0
- package/src/main.ts +5 -1
- package/src/operation-receipts.test.ts +111 -0
- package/src/operation-receipts.ts +104 -0
- package/src/paths.ts +49 -0
- package/src/pr-poller.test.ts +102 -0
- package/src/pr-poller.ts +80 -0
- package/src/project-files.test.ts +134 -0
- package/src/project-files.ts +72 -0
- package/src/protocol.ts +82 -7
- package/src/retention.test.ts +191 -0
- package/src/retention.ts +131 -0
- package/src/sdk-identities.ts +35 -0
- package/src/server.test.ts +502 -42
- package/src/server.ts +660 -88
- package/src/share-blob.test.ts +142 -0
- package/src/share-blob.ts +57 -0
- package/src/shared-agent-protocol.ts +30 -0
- package/src/single-instance.test.ts +165 -13
- package/src/store.conformance.test.ts +41 -0
- package/src/store.jsonl.test.ts +173 -0
- package/src/store.ts +278 -34
- package/src/testing/store-conformance.ts +192 -0
- package/src/thread-agent-validation.ts +141 -0
- package/src/thread-agent.test.ts +732 -0
- package/src/thread-agent.ts +1171 -0
- package/src/thread-review.test.ts +487 -0
- package/src/thread-review.ts +394 -0
- package/src/thread-subscription.test.ts +177 -0
- package/src/thread-subscription.ts +138 -0
- package/src/thread-surface-config.test.ts +63 -0
- package/src/thread-surface-config.ts +61 -0
- package/src/validate.test.ts +290 -37
- package/src/validate.ts +427 -33
- package/src/vcs-source.test.ts +180 -0
- package/src/vcs-source.ts +235 -0
- package/src/version.ts +6 -0
- package/src/working-tree.ts +154 -0
- package/src/review.test.ts +0 -167
- package/src/review.ts +0 -174
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The JSONL thread store's own behaviors, beyond the shared repository contract: per-project bucket
|
|
3
|
+
* layout keyed by root commit, one-time migration of the old whole-record files, compaction of a
|
|
4
|
+
* long log, and a crash's torn trailing line being skipped on read.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "bun:test";
|
|
8
|
+
import {
|
|
9
|
+
mkdtempSync,
|
|
10
|
+
mkdirSync,
|
|
11
|
+
readdirSync,
|
|
12
|
+
readFileSync,
|
|
13
|
+
writeFileSync,
|
|
14
|
+
appendFileSync,
|
|
15
|
+
existsSync,
|
|
16
|
+
} from "node:fs";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { ThreadStore } from "./store";
|
|
20
|
+
import { migratedSessionsDir, sessionsDir, threadBucket } from "./paths";
|
|
21
|
+
import { createTestThreadRecord } from "./testing/store-conformance";
|
|
22
|
+
|
|
23
|
+
function tmpHome(): string {
|
|
24
|
+
return mkdtempSync(join(tmpdir(), "cueloop-jsonl-"));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("JSONL thread store", () => {
|
|
28
|
+
test("a thread is written under a per-project bucket keyed by root commit", () => {
|
|
29
|
+
const home = tmpHome();
|
|
30
|
+
const store = new ThreadStore(home);
|
|
31
|
+
|
|
32
|
+
store.recover();
|
|
33
|
+
store.upsert(
|
|
34
|
+
createTestThreadRecord("ses_a", "2026-09-01T00:00:00.000Z", {
|
|
35
|
+
workspace: { repoRoot: "/repo", branch: "main", rootCommit: "abcdef0123456789" },
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const bucket = threadBucket("abcdef0123456789", home);
|
|
40
|
+
|
|
41
|
+
expect(readdirSync(bucket)).toContain("ses_a.jsonl");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("a non-commit rootCommit (path traversal) cannot escape the threads directory", () => {
|
|
45
|
+
const home = tmpHome();
|
|
46
|
+
const escape = threadBucket("../../../etc/cueloop", home);
|
|
47
|
+
const standalone = threadBucket(undefined, home);
|
|
48
|
+
|
|
49
|
+
// a value that is not a real commit SHA is refused to the standalone bucket, never a parent path
|
|
50
|
+
expect(escape).toBe(standalone);
|
|
51
|
+
expect(escape.startsWith(join(home, "threads"))).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("a repo-less thread lands in the standalone bucket", () => {
|
|
55
|
+
const home = tmpHome();
|
|
56
|
+
const store = new ThreadStore(home);
|
|
57
|
+
|
|
58
|
+
store.recover();
|
|
59
|
+
store.upsert(createTestThreadRecord("ses_s", "2026-09-01T00:00:00.000Z"));
|
|
60
|
+
|
|
61
|
+
expect(readdirSync(threadBucket(undefined, home))).toContain("ses_s.jsonl");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("boot migrates the old whole-record file and parks the original", () => {
|
|
65
|
+
const home = tmpHome();
|
|
66
|
+
|
|
67
|
+
mkdirSync(sessionsDir(home), { recursive: true });
|
|
68
|
+
const legacy = createTestThreadRecord("ses_old", "2026-09-01T00:00:00.000Z");
|
|
69
|
+
|
|
70
|
+
writeFileSync(join(sessionsDir(home), "ses_old.json"), JSON.stringify(legacy));
|
|
71
|
+
|
|
72
|
+
const store = new ThreadStore(home);
|
|
73
|
+
const report = store.recover();
|
|
74
|
+
|
|
75
|
+
expect(report.recovered).toEqual(["ses_old"]);
|
|
76
|
+
// the record migrates intact (a history is derived on read, as the old store did)
|
|
77
|
+
expect(store.get("ses_old")?.id).toBe("ses_old");
|
|
78
|
+
expect(store.get("ses_old")?.artifact).toEqual(legacy.artifact);
|
|
79
|
+
// the original is parked, not left in the live legacy dir
|
|
80
|
+
expect(existsSync(join(sessionsDir(home), "ses_old.json"))).toBe(false);
|
|
81
|
+
expect(readdirSync(migratedSessionsDir(home))).toContain("ses_old.json");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("migration never overwrites a newer JSONL with an older legacy snapshot", () => {
|
|
85
|
+
const home = tmpHome();
|
|
86
|
+
// a newer JSONL already holds this thread (e.g. written after a rollback)
|
|
87
|
+
const store = new ThreadStore(home);
|
|
88
|
+
|
|
89
|
+
store.recover();
|
|
90
|
+
store.upsert(
|
|
91
|
+
createTestThreadRecord("ses_x", "2026-09-01T00:00:00.000Z", {
|
|
92
|
+
status: "pending",
|
|
93
|
+
workspace: { repoRoot: "/repo", branch: "newer" },
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
// an older legacy JSON for the same thread is restored alongside it
|
|
97
|
+
mkdirSync(sessionsDir(home), { recursive: true });
|
|
98
|
+
writeFileSync(
|
|
99
|
+
join(sessionsDir(home), "ses_x.json"),
|
|
100
|
+
JSON.stringify(
|
|
101
|
+
createTestThreadRecord("ses_x", "2026-09-01T00:00:00.000Z", {
|
|
102
|
+
workspace: { repoRoot: "/repo", branch: "older" },
|
|
103
|
+
}),
|
|
104
|
+
),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const reopened = new ThreadStore(home);
|
|
108
|
+
|
|
109
|
+
reopened.recover();
|
|
110
|
+
// the newer JSONL wins; the legacy snapshot is parked, not applied
|
|
111
|
+
expect(reopened.get("ses_x")?.workspace.branch).toBe("newer");
|
|
112
|
+
expect(readdirSync(migratedSessionsDir(home))).toContain("ses_x.json");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("a long log compacts to a single line, and the record survives it", () => {
|
|
116
|
+
const home = tmpHome();
|
|
117
|
+
const store = new ThreadStore(home);
|
|
118
|
+
|
|
119
|
+
store.recover();
|
|
120
|
+
// more upserts than the compaction threshold
|
|
121
|
+
for (let iteration = 0; iteration < 60; iteration++) {
|
|
122
|
+
store.upsert(
|
|
123
|
+
createTestThreadRecord("ses_a", "2026-09-01T00:00:00.000Z", {
|
|
124
|
+
status: "pending",
|
|
125
|
+
workspace: { repoRoot: "/repo", branch: `b${iteration}` },
|
|
126
|
+
}),
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const file = join(threadBucket(undefined, home), "ses_a.jsonl");
|
|
131
|
+
const lines = readFileSync(file, "utf8")
|
|
132
|
+
.split("\n")
|
|
133
|
+
.filter((l) => l.trim());
|
|
134
|
+
|
|
135
|
+
expect(lines.length).toBeLessThan(60);
|
|
136
|
+
// the latest snapshot survives the compaction and a restart
|
|
137
|
+
const reopened = new ThreadStore(home);
|
|
138
|
+
|
|
139
|
+
reopened.recover();
|
|
140
|
+
expect(reopened.get("ses_a")?.workspace.branch).toBe("b59");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("a crash's torn trailing line is skipped; the last valid snapshot wins", () => {
|
|
144
|
+
const home = tmpHome();
|
|
145
|
+
const store = new ThreadStore(home);
|
|
146
|
+
|
|
147
|
+
store.recover();
|
|
148
|
+
store.upsert(createTestThreadRecord("ses_a", "2026-09-01T00:00:00.000Z"));
|
|
149
|
+
|
|
150
|
+
// simulate a crash mid-append: a half-written line at the end
|
|
151
|
+
const file = join(threadBucket(undefined, home), "ses_a.jsonl");
|
|
152
|
+
|
|
153
|
+
appendFileSync(file, '{"id":"ses_a","half');
|
|
154
|
+
|
|
155
|
+
const reopened = new ThreadStore(home);
|
|
156
|
+
const report = reopened.recover();
|
|
157
|
+
|
|
158
|
+
expect(report.recovered).toEqual(["ses_a"]);
|
|
159
|
+
expect(reopened.get("ses_a")?.id).toBe("ses_a");
|
|
160
|
+
|
|
161
|
+
// an update after a torn recovery must not fuse onto the torn fragment and vanish on restart
|
|
162
|
+
reopened.upsert(
|
|
163
|
+
createTestThreadRecord("ses_a", "2026-09-01T00:00:00.000Z", {
|
|
164
|
+
status: "pending",
|
|
165
|
+
workspace: { repoRoot: "/repo", branch: "after-tear" },
|
|
166
|
+
}),
|
|
167
|
+
);
|
|
168
|
+
const restarted = new ThreadStore(home);
|
|
169
|
+
|
|
170
|
+
restarted.recover();
|
|
171
|
+
expect(restarted.get("ses_a")?.workspace.branch).toBe("after-tear");
|
|
172
|
+
});
|
|
173
|
+
});
|
package/src/store.ts
CHANGED
|
@@ -1,68 +1,312 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Thread persistence: one append-only JSONL log per thread, under a per-project bucket keyed by the
|
|
3
|
+
* repo's durable root commit (a shared bucket for repo-less threads). Each mutation appends a
|
|
4
|
+
* snapshot line and the last valid line wins, so a crash's torn tail is simply skipped on read; the
|
|
5
|
+
* log is compacted to one line on the first write, once it grows long, and when the thread resolves.
|
|
6
|
+
* Boot migrates the old whole-record JSON files into this layout and parks the originals. Records
|
|
7
|
+
* that fail to parse are skipped and reported, never deleted; records from before histories existed
|
|
8
|
+
* are given one on read.
|
|
9
|
+
*
|
|
10
|
+
* `ThreadRepository` is the contract every adapter satisfies; the conformance
|
|
11
|
+
* suite in ./testing/store-conformance.ts pins it for the file store and the
|
|
12
|
+
* in-memory store alike.
|
|
6
13
|
*/
|
|
7
14
|
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
15
|
+
import {
|
|
16
|
+
appendFileSync,
|
|
17
|
+
existsSync,
|
|
18
|
+
mkdirSync,
|
|
19
|
+
readdirSync,
|
|
20
|
+
readFileSync,
|
|
21
|
+
renameSync,
|
|
22
|
+
rmSync,
|
|
23
|
+
statSync,
|
|
24
|
+
writeFileSync,
|
|
25
|
+
} from "node:fs";
|
|
26
|
+
import { dirname, join } from "node:path";
|
|
27
|
+
import { historyFromLinear, type Thread } from "@cueloop/schema";
|
|
28
|
+
import { migratedSessionsDir, sessionsDir, threadBucket, threadsDir } from "./paths";
|
|
29
|
+
import { validateThreadRecord } from "./validate";
|
|
30
|
+
|
|
31
|
+
/** A thread's log is rewritten to a single snapshot line once it grows past this many, so an
|
|
32
|
+
* active thread's file never bloats unbounded while writes stay cheap appends in between. */
|
|
33
|
+
const COMPACT_THRESHOLD = 40;
|
|
13
34
|
|
|
14
35
|
export interface RecoveryReport {
|
|
15
36
|
recovered: string[];
|
|
16
37
|
skipped: { file: string; error: string }[];
|
|
17
38
|
}
|
|
18
39
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
40
|
+
/** What the daemon needs from session storage. */
|
|
41
|
+
export interface ThreadRepository {
|
|
42
|
+
recover(): RecoveryReport;
|
|
43
|
+
get(id: string): Thread | undefined;
|
|
44
|
+
list(): Thread[];
|
|
45
|
+
upsert(session: Thread): void;
|
|
46
|
+
delete(id: string): boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A record as it is read: a history is derived for records written without
|
|
51
|
+
* one. A record with no revision has no head to derive from and keeps
|
|
52
|
+
* reading without a history - migration never loses a record.
|
|
53
|
+
*/
|
|
54
|
+
export function withHistory(session: Thread): Thread {
|
|
55
|
+
if (session.history || session.revisions.length === 0) return session;
|
|
22
56
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
57
|
+
return { ...session, history: historyFromLinear(session) };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Records in the order `list()` promises: oldest first. */
|
|
61
|
+
function oldestFirst(sessions: Iterable<Thread>): Thread[] {
|
|
62
|
+
return [...sessions].sort((left, right) => left.createdAt.localeCompare(right.createdAt));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class ThreadStore implements ThreadRepository {
|
|
66
|
+
private sessions = new Map<string, Thread>();
|
|
67
|
+
/** id -> its JSONL path (the bucket depends on the record's root commit, so it is tracked). */
|
|
68
|
+
private files = new Map<string, string>();
|
|
69
|
+
/** id -> lines in its log, so upsert knows when to compact instead of append. */
|
|
70
|
+
private lineCounts = new Map<string, number>();
|
|
71
|
+
|
|
72
|
+
constructor(private readonly home: string) {
|
|
73
|
+
mkdirSync(threadsDir(home), { recursive: true });
|
|
74
|
+
// the legacy dir is where a pre-upgrade daemon wrote; ensure it exists so its scan is uniform
|
|
75
|
+
mkdirSync(sessionsDir(home), { recursive: true });
|
|
26
76
|
}
|
|
27
77
|
|
|
28
|
-
/** Read-only scan of the state directory; called once on boot. */
|
|
29
78
|
recover(): RecoveryReport {
|
|
30
79
|
const report: RecoveryReport = { recovered: [], skipped: [] };
|
|
31
|
-
|
|
80
|
+
|
|
81
|
+
this.migrateLegacy(report);
|
|
82
|
+
const root = threadsDir(this.home);
|
|
83
|
+
|
|
84
|
+
for (const bucket of readdirSync(root)) {
|
|
85
|
+
const bucketPath = join(root, bucket);
|
|
86
|
+
|
|
87
|
+
if (!statSync(bucketPath).isDirectory()) continue;
|
|
88
|
+
|
|
89
|
+
for (const file of readdirSync(bucketPath)) {
|
|
90
|
+
if (!file.endsWith(".jsonl")) continue;
|
|
91
|
+
|
|
92
|
+
const filePath = join(bucketPath, file);
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const { session, lines, torn } = readThread(filePath);
|
|
96
|
+
|
|
97
|
+
this.sessions.set(session.id, session);
|
|
98
|
+
this.files.set(session.id, filePath);
|
|
99
|
+
this.lineCounts.set(session.id, lines);
|
|
100
|
+
report.recovered.push(session.id);
|
|
101
|
+
|
|
102
|
+
// a torn trailing fragment would fuse with the next append and lose that update, so rewrite
|
|
103
|
+
// the log to a single clean snapshot line now that the last valid record is in hand
|
|
104
|
+
if (torn) this.writeWhole(session);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
report.skipped.push({
|
|
107
|
+
file,
|
|
108
|
+
error: error instanceof Error ? error.message : String(error),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return report;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** One-time upgrade: write each old whole-record JSON as a thread log and park the original, so
|
|
118
|
+
* the threads scan below recovers it. Invalid records are reported and left in place, never lost. */
|
|
119
|
+
private migrateLegacy(report: RecoveryReport): void {
|
|
120
|
+
const legacyDir = sessionsDir(this.home);
|
|
121
|
+
|
|
122
|
+
if (!existsSync(legacyDir)) return;
|
|
123
|
+
|
|
124
|
+
const parked = migratedSessionsDir(this.home);
|
|
125
|
+
|
|
126
|
+
for (const file of readdirSync(legacyDir)) {
|
|
32
127
|
if (!file.endsWith(".json")) continue;
|
|
128
|
+
|
|
129
|
+
const legacyPath = join(legacyDir, file);
|
|
130
|
+
let session: Thread;
|
|
131
|
+
|
|
33
132
|
try {
|
|
34
|
-
const
|
|
35
|
-
|
|
133
|
+
const parsed = validateThreadRecord(JSON.parse(readFileSync(legacyPath, "utf8")));
|
|
134
|
+
|
|
36
135
|
if (!parsed.ok) throw new Error(`invalid record - ${parsed.error}`);
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
136
|
+
|
|
137
|
+
session = withHistory(parsed.value);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
report.skipped.push({
|
|
140
|
+
file,
|
|
141
|
+
error: error instanceof Error ? error.message : String(error),
|
|
142
|
+
});
|
|
143
|
+
continue;
|
|
42
144
|
}
|
|
145
|
+
const targetPath = join(
|
|
146
|
+
threadBucket(session.workspace.rootCommit, this.home),
|
|
147
|
+
`${session.id}.jsonl`,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
// after a rollback a newer JSONL can already hold this thread; never overwrite it with the
|
|
151
|
+
// older legacy snapshot - the existing log stays authoritative and the threads scan recovers it
|
|
152
|
+
if (!existsSync(targetPath)) this.writeWhole(session);
|
|
153
|
+
|
|
154
|
+
// forget the in-memory tracking migration seeded: the threads scan is the one recovery path
|
|
155
|
+
this.files.delete(session.id);
|
|
156
|
+
this.lineCounts.delete(session.id);
|
|
157
|
+
mkdirSync(parked, { recursive: true });
|
|
158
|
+
renameSync(legacyPath, join(parked, file));
|
|
43
159
|
}
|
|
44
|
-
return report;
|
|
45
160
|
}
|
|
46
161
|
|
|
47
|
-
|
|
162
|
+
/** Rewrite the thread to a single snapshot line, atomically - the first write and every compaction. */
|
|
163
|
+
private writeWhole(session: Thread): void {
|
|
164
|
+
const filePath =
|
|
165
|
+
this.files.get(session.id) ??
|
|
166
|
+
join(threadBucket(session.workspace.rootCommit, this.home), `${session.id}.jsonl`);
|
|
167
|
+
|
|
168
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
169
|
+
const tempPath = filePath + ".tmp";
|
|
170
|
+
|
|
171
|
+
writeFileSync(tempPath, JSON.stringify(session) + "\n");
|
|
172
|
+
renameSync(tempPath, filePath);
|
|
173
|
+
this.files.set(session.id, filePath);
|
|
174
|
+
this.lineCounts.set(session.id, 1);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
get(id: string): Thread | undefined {
|
|
48
178
|
return this.sessions.get(id);
|
|
49
179
|
}
|
|
50
180
|
|
|
51
|
-
list():
|
|
52
|
-
return
|
|
181
|
+
list(): Thread[] {
|
|
182
|
+
return oldestFirst(this.sessions.values());
|
|
53
183
|
}
|
|
54
184
|
|
|
55
|
-
upsert(session:
|
|
185
|
+
upsert(session: Thread): void {
|
|
186
|
+
const filePath = this.files.get(session.id);
|
|
187
|
+
const lines = this.lineCounts.get(session.id) ?? 0;
|
|
188
|
+
|
|
189
|
+
// the first write, an over-long log, and a resolved (now immutable) thread all compact to one
|
|
190
|
+
// line; every other mutation is a cheap append that a torn tail on crash simply drops on read
|
|
191
|
+
if (
|
|
192
|
+
filePath === undefined ||
|
|
193
|
+
lines >= COMPACT_THRESHOLD ||
|
|
194
|
+
session.status === "resolved" ||
|
|
195
|
+
session.messageOperations?.length
|
|
196
|
+
) {
|
|
197
|
+
this.writeWhole(session);
|
|
198
|
+
this.sessions.set(session.id, session);
|
|
199
|
+
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
appendFileSync(filePath, JSON.stringify(session) + "\n");
|
|
204
|
+
this.lineCounts.set(session.id, lines + 1);
|
|
56
205
|
this.sessions.set(session.id, session);
|
|
57
|
-
const path = join(this.dir, `${session.id}.json`);
|
|
58
|
-
const tmp = path + ".tmp";
|
|
59
|
-
writeFileSync(tmp, JSON.stringify(session, null, 2));
|
|
60
|
-
renameSync(tmp, path);
|
|
61
206
|
}
|
|
62
207
|
|
|
63
208
|
delete(id: string): boolean {
|
|
64
209
|
if (!this.sessions.delete(id)) return false;
|
|
65
|
-
|
|
210
|
+
|
|
211
|
+
const filePath = this.files.get(id);
|
|
212
|
+
|
|
213
|
+
if (filePath) rmSync(filePath, { force: true });
|
|
214
|
+
|
|
215
|
+
this.files.delete(id);
|
|
216
|
+
this.lineCounts.delete(id);
|
|
217
|
+
|
|
66
218
|
return true;
|
|
67
219
|
}
|
|
68
220
|
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Read a thread log: the last valid snapshot line wins, so a crash's torn trailing line is skipped.
|
|
224
|
+
* `torn` is true when that winning line was not the last physical line - the caller rewrites the log
|
|
225
|
+
* so the next append starts on a clean boundary instead of fusing onto the torn fragment.
|
|
226
|
+
*/
|
|
227
|
+
function readThread(filePath: string) {
|
|
228
|
+
const allLines = readFileSync(filePath, "utf8")
|
|
229
|
+
.split("\n")
|
|
230
|
+
.filter((line) => line.trim().length > 0);
|
|
231
|
+
|
|
232
|
+
for (let index = allLines.length - 1; index >= 0; index--) {
|
|
233
|
+
let record: unknown;
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
record = JSON.parse(allLines[index]!);
|
|
237
|
+
} catch {
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
const parsed = validateThreadRecord(record);
|
|
241
|
+
|
|
242
|
+
if (parsed.ok) {
|
|
243
|
+
return {
|
|
244
|
+
session: withHistory(parsed.value),
|
|
245
|
+
lines: allLines.length,
|
|
246
|
+
torn: index !== allLines.length - 1,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
throw new Error("no valid record line");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The in-memory adapter: the same contract with nothing on disk. `seed` stands
|
|
255
|
+
* in for what a file store finds on recovery, so validation and migration are
|
|
256
|
+
* exercised the same way.
|
|
257
|
+
*/
|
|
258
|
+
export class MemoryThreadStore implements ThreadRepository {
|
|
259
|
+
private sessions = new Map<string, Thread>();
|
|
260
|
+
|
|
261
|
+
constructor(private readonly seed: unknown[] = []) {}
|
|
262
|
+
|
|
263
|
+
recover(): RecoveryReport {
|
|
264
|
+
const report: RecoveryReport = { recovered: [], skipped: [] };
|
|
265
|
+
|
|
266
|
+
this.seed.forEach((record, index) => {
|
|
267
|
+
const parsed = validateThreadRecord(record);
|
|
268
|
+
|
|
269
|
+
if (!parsed.ok) {
|
|
270
|
+
report.skipped.push({ file: `seed[${index}]`, error: `invalid record - ${parsed.error}` });
|
|
271
|
+
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const session = withHistory(parsed.value);
|
|
276
|
+
|
|
277
|
+
this.sessions.set(session.id, session);
|
|
278
|
+
report.recovered.push(session.id);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
return report;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
get(id: string): Thread | undefined {
|
|
285
|
+
return this.sessions.get(id);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
list(): Thread[] {
|
|
289
|
+
return oldestFirst(this.sessions.values());
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
upsert(session: Thread): void {
|
|
293
|
+
this.sessions.set(session.id, session);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
delete(id: string): boolean {
|
|
297
|
+
return this.sessions.delete(id);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** @deprecated use ThreadRepository */
|
|
302
|
+
export type SessionRepository = ThreadRepository;
|
|
303
|
+
/** @deprecated use ThreadStore */
|
|
304
|
+
export const SessionStore = ThreadStore;
|
|
305
|
+
|
|
306
|
+
/** @deprecated use ThreadStore */
|
|
307
|
+
export type SessionStore = ThreadStore;
|
|
308
|
+
/** @deprecated use MemoryThreadStore */
|
|
309
|
+
export const MemorySessionStore = MemoryThreadStore;
|
|
310
|
+
|
|
311
|
+
/** @deprecated use MemoryThreadStore */
|
|
312
|
+
export type MemorySessionStore = MemoryThreadStore;
|