@cueloop/daemon 0.1.0-alpha.0 → 0.1.0-alpha.100

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.
Files changed (80) hide show
  1. package/README.md +7 -0
  2. package/SDK.md +102 -0
  3. package/package.json +43 -16
  4. package/src/agent-tools.ts +98 -0
  5. package/src/agent-turn.test.ts +24 -0
  6. package/src/agent-turn.ts +34 -0
  7. package/src/api.test.ts +1081 -65
  8. package/src/api.ts +1425 -89
  9. package/src/capabilities.test.ts +57 -0
  10. package/src/capabilities.ts +95 -0
  11. package/src/client-errors.ts +24 -0
  12. package/src/client-reliability.test.ts +189 -0
  13. package/src/client.test.ts +37 -0
  14. package/src/client.ts +820 -94
  15. package/src/curate.ts +109 -0
  16. package/src/daemon-sdk-effect.ts +113 -0
  17. package/src/daemon-sdk.test.ts +306 -0
  18. package/src/daemon-sdk.ts +397 -0
  19. package/src/daemon-version.test.ts +142 -0
  20. package/src/diff-hot-reload.test.ts +419 -0
  21. package/src/diff-watcher.test.ts +117 -0
  22. package/src/diff-watcher.ts +326 -0
  23. package/src/examples/daemon-sdk.ts +55 -0
  24. package/src/gh.test.ts +59 -0
  25. package/src/gh.ts +74 -0
  26. package/src/ghostty-thread-surface-store.test.ts +49 -0
  27. package/src/ghostty-thread-surface-store.ts +101 -0
  28. package/src/ghostty-thread-surface.test.ts +398 -0
  29. package/src/ghostty-thread-surface.ts +267 -0
  30. package/src/harness-delivery.test.ts +269 -0
  31. package/src/harness-diagnostics.test.ts +26 -0
  32. package/src/harness-diagnostics.ts +30 -0
  33. package/src/harness-state-store.test.ts +93 -0
  34. package/src/harness-state-store.ts +213 -0
  35. package/src/herdr-split.test.ts +106 -0
  36. package/src/herdr-split.ts +85 -0
  37. package/src/herdr-thread-surface-store.test.ts +81 -0
  38. package/src/herdr-thread-surface-store.ts +66 -0
  39. package/src/herdr-thread-surface.test.ts +465 -0
  40. package/src/herdr-thread-surface.ts +378 -0
  41. package/src/index.ts +8 -8
  42. package/src/interruptible-wait.test.ts +127 -0
  43. package/src/interruptible-wait.ts +66 -0
  44. package/src/jj-working-tree.ts +131 -0
  45. package/src/main.ts +4 -1
  46. package/src/operation-receipts.test.ts +111 -0
  47. package/src/operation-receipts.ts +91 -0
  48. package/src/paths.ts +49 -0
  49. package/src/pr-poller.test.ts +102 -0
  50. package/src/pr-poller.ts +73 -0
  51. package/src/project-files.test.ts +134 -0
  52. package/src/project-files.ts +68 -0
  53. package/src/protocol.ts +78 -7
  54. package/src/retention.test.ts +191 -0
  55. package/src/retention.ts +123 -0
  56. package/src/sdk-identities.ts +35 -0
  57. package/src/server.test.ts +465 -40
  58. package/src/server.ts +601 -88
  59. package/src/share-blob.test.ts +119 -0
  60. package/src/share-blob.ts +57 -0
  61. package/src/single-instance.test.ts +162 -13
  62. package/src/store.conformance.test.ts +41 -0
  63. package/src/store.jsonl.test.ts +173 -0
  64. package/src/store.ts +267 -34
  65. package/src/testing/store-conformance.ts +192 -0
  66. package/src/thread-agent-validation.ts +119 -0
  67. package/src/thread-agent.test.ts +412 -0
  68. package/src/thread-agent.ts +713 -0
  69. package/src/thread-review.test.ts +483 -0
  70. package/src/thread-review.ts +382 -0
  71. package/src/thread-subscription.test.ts +174 -0
  72. package/src/thread-subscription.ts +129 -0
  73. package/src/thread-surface-config.test.ts +63 -0
  74. package/src/thread-surface-config.ts +61 -0
  75. package/src/validate.test.ts +366 -21
  76. package/src/validate.ts +458 -47
  77. package/src/vcs-source.test.ts +180 -0
  78. package/src/vcs-source.ts +229 -0
  79. package/src/version.ts +6 -0
  80. package/src/working-tree.ts +150 -0
package/src/store.ts CHANGED
@@ -1,68 +1,301 @@
1
1
  /**
2
- * Session persistence (#14): one JSON document per session, every write
3
- * through a temp file + atomic rename so a crash mid-write can never leave
4
- * a corrupt record. Recovery is a read-only scan; records that fail to
5
- * parse are skipped and reported, never deleted.
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 { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
9
- import { join } from "node:path";
10
- import type { ReviewSession } from "@cueloop/schema";
11
- import { validateSessionRecord } from "./validate";
12
- import { sessionsDir } from "./paths";
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
- export class SessionStore {
20
- private sessions = new Map<string, ReviewSession>();
21
- private readonly dir: string;
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;
56
+
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>();
22
71
 
23
- constructor(home: string) {
24
- this.dir = sessionsDir(home);
25
- mkdirSync(this.dir, { recursive: true });
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
- for (const file of readdirSync(this.dir)) {
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
+ for (const file of readdirSync(bucketPath)) {
89
+ if (!file.endsWith(".jsonl")) continue;
90
+ const filePath = join(bucketPath, file);
91
+
92
+ try {
93
+ const { session, lines, torn } = readThread(filePath);
94
+
95
+ this.sessions.set(session.id, session);
96
+ this.files.set(session.id, filePath);
97
+ this.lineCounts.set(session.id, lines);
98
+ report.recovered.push(session.id);
99
+ // a torn trailing fragment would fuse with the next append and lose that update, so rewrite
100
+ // the log to a single clean snapshot line now that the last valid record is in hand
101
+ if (torn) this.writeWhole(session);
102
+ } catch (error) {
103
+ report.skipped.push({
104
+ file,
105
+ error: error instanceof Error ? error.message : String(error),
106
+ });
107
+ }
108
+ }
109
+ }
110
+
111
+ return report;
112
+ }
113
+
114
+ /** One-time upgrade: write each old whole-record JSON as a thread log and park the original, so
115
+ * the threads scan below recovers it. Invalid records are reported and left in place, never lost. */
116
+ private migrateLegacy(report: RecoveryReport): void {
117
+ const legacyDir = sessionsDir(this.home);
118
+
119
+ if (!existsSync(legacyDir)) return;
120
+ const parked = migratedSessionsDir(this.home);
121
+
122
+ for (const file of readdirSync(legacyDir)) {
32
123
  if (!file.endsWith(".json")) continue;
124
+ const legacyPath = join(legacyDir, file);
125
+ let session: Thread;
126
+
33
127
  try {
34
- const raw = readFileSync(join(this.dir, file), "utf8");
35
- const parsed = validateSessionRecord(JSON.parse(raw));
128
+ const parsed = validateThreadRecord(JSON.parse(readFileSync(legacyPath, "utf8")));
129
+
36
130
  if (!parsed.ok) throw new Error(`invalid record - ${parsed.error}`);
37
- const session = parsed.value as ReviewSession;
38
- this.sessions.set(session.id, session);
39
- report.recovered.push(session.id);
40
- } catch (err) {
41
- report.skipped.push({ file, error: err instanceof Error ? err.message : String(err) });
131
+ session = withHistory(parsed.value);
132
+ } catch (error) {
133
+ report.skipped.push({
134
+ file,
135
+ error: error instanceof Error ? error.message : String(error),
136
+ });
137
+ continue;
42
138
  }
139
+ const targetPath = join(
140
+ threadBucket(session.workspace.rootCommit, this.home),
141
+ `${session.id}.jsonl`,
142
+ );
143
+
144
+ // after a rollback a newer JSONL can already hold this thread; never overwrite it with the
145
+ // older legacy snapshot - the existing log stays authoritative and the threads scan recovers it
146
+ if (!existsSync(targetPath)) this.writeWhole(session);
147
+ // forget the in-memory tracking migration seeded: the threads scan is the one recovery path
148
+ this.files.delete(session.id);
149
+ this.lineCounts.delete(session.id);
150
+ mkdirSync(parked, { recursive: true });
151
+ renameSync(legacyPath, join(parked, file));
43
152
  }
44
- return report;
45
153
  }
46
154
 
47
- get(id: string): ReviewSession | undefined {
155
+ /** Rewrite the thread to a single snapshot line, atomically - the first write and every compaction. */
156
+ private writeWhole(session: Thread): void {
157
+ const filePath =
158
+ this.files.get(session.id) ??
159
+ join(threadBucket(session.workspace.rootCommit, this.home), `${session.id}.jsonl`);
160
+
161
+ mkdirSync(dirname(filePath), { recursive: true });
162
+ const tempPath = filePath + ".tmp";
163
+
164
+ writeFileSync(tempPath, JSON.stringify(session) + "\n");
165
+ renameSync(tempPath, filePath);
166
+ this.files.set(session.id, filePath);
167
+ this.lineCounts.set(session.id, 1);
168
+ }
169
+
170
+ get(id: string): Thread | undefined {
48
171
  return this.sessions.get(id);
49
172
  }
50
173
 
51
- list(): ReviewSession[] {
52
- return [...this.sessions.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
174
+ list(): Thread[] {
175
+ return oldestFirst(this.sessions.values());
53
176
  }
54
177
 
55
- upsert(session: ReviewSession): void {
178
+ upsert(session: Thread): void {
179
+ const filePath = this.files.get(session.id);
180
+ const lines = this.lineCounts.get(session.id) ?? 0;
181
+
182
+ // the first write, an over-long log, and a resolved (now immutable) thread all compact to one
183
+ // line; every other mutation is a cheap append that a torn tail on crash simply drops on read
184
+ if (
185
+ filePath === undefined ||
186
+ lines >= COMPACT_THRESHOLD ||
187
+ session.status === "resolved" ||
188
+ session.messageOperations?.length
189
+ ) {
190
+ this.writeWhole(session);
191
+ this.sessions.set(session.id, session);
192
+
193
+ return;
194
+ }
195
+ appendFileSync(filePath, JSON.stringify(session) + "\n");
196
+ this.lineCounts.set(session.id, lines + 1);
56
197
  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
198
  }
62
199
 
63
200
  delete(id: string): boolean {
64
201
  if (!this.sessions.delete(id)) return false;
65
- rmSync(join(this.dir, `${id}.json`), { force: true });
202
+ const filePath = this.files.get(id);
203
+
204
+ if (filePath) rmSync(filePath, { force: true });
205
+ this.files.delete(id);
206
+ this.lineCounts.delete(id);
207
+
66
208
  return true;
67
209
  }
68
210
  }
211
+
212
+ /**
213
+ * Read a thread log: the last valid snapshot line wins, so a crash's torn trailing line is skipped.
214
+ * `torn` is true when that winning line was not the last physical line - the caller rewrites the log
215
+ * so the next append starts on a clean boundary instead of fusing onto the torn fragment.
216
+ */
217
+ function readThread(filePath: string) {
218
+ const allLines = readFileSync(filePath, "utf8")
219
+ .split("\n")
220
+ .filter((line) => line.trim().length > 0);
221
+
222
+ for (let index = allLines.length - 1; index >= 0; index--) {
223
+ let record: unknown;
224
+
225
+ try {
226
+ record = JSON.parse(allLines[index]!);
227
+ } catch {
228
+ continue;
229
+ }
230
+ const parsed = validateThreadRecord(record);
231
+
232
+ if (parsed.ok) {
233
+ return {
234
+ session: withHistory(parsed.value),
235
+ lines: allLines.length,
236
+ torn: index !== allLines.length - 1,
237
+ };
238
+ }
239
+ }
240
+ throw new Error("no valid record line");
241
+ }
242
+
243
+ /**
244
+ * The in-memory adapter: the same contract with nothing on disk. `seed` stands
245
+ * in for what a file store finds on recovery, so validation and migration are
246
+ * exercised the same way.
247
+ */
248
+ export class MemoryThreadStore implements ThreadRepository {
249
+ private sessions = new Map<string, Thread>();
250
+
251
+ constructor(private readonly seed: unknown[] = []) {}
252
+
253
+ recover(): RecoveryReport {
254
+ const report: RecoveryReport = { recovered: [], skipped: [] };
255
+
256
+ this.seed.forEach((record, index) => {
257
+ const parsed = validateThreadRecord(record);
258
+
259
+ if (!parsed.ok) {
260
+ report.skipped.push({ file: `seed[${index}]`, error: `invalid record - ${parsed.error}` });
261
+
262
+ return;
263
+ }
264
+ const session = withHistory(parsed.value);
265
+
266
+ this.sessions.set(session.id, session);
267
+ report.recovered.push(session.id);
268
+ });
269
+
270
+ return report;
271
+ }
272
+
273
+ get(id: string): Thread | undefined {
274
+ return this.sessions.get(id);
275
+ }
276
+
277
+ list(): Thread[] {
278
+ return oldestFirst(this.sessions.values());
279
+ }
280
+
281
+ upsert(session: Thread): void {
282
+ this.sessions.set(session.id, session);
283
+ }
284
+
285
+ delete(id: string): boolean {
286
+ return this.sessions.delete(id);
287
+ }
288
+ }
289
+
290
+ /** @deprecated use ThreadRepository */
291
+ export type SessionRepository = ThreadRepository;
292
+ /** @deprecated use ThreadStore */
293
+ export const SessionStore = ThreadStore;
294
+
295
+ /** @deprecated use ThreadStore */
296
+ export type SessionStore = ThreadStore;
297
+ /** @deprecated use MemoryThreadStore */
298
+ export const MemorySessionStore = MemoryThreadStore;
299
+
300
+ /** @deprecated use MemoryThreadStore */
301
+ export type MemorySessionStore = MemoryThreadStore;
@@ -0,0 +1,192 @@
1
+ /**
2
+ * The session repository contract as executable cases. Every adapter runs the
3
+ * same suite: what a store returns, in which order, what survives a restart,
4
+ * what a bad record does, and how a record without a history reads.
5
+ *
6
+ * `restart` gives back a fresh adapter over the same persisted state (or the
7
+ * same seed) so recovery is tested through the contract, never past it.
8
+ */
9
+
10
+ import { describe, expect, test } from "bun:test";
11
+ import { SCHEMA_VERSION, type Thread } from "@cueloop/schema";
12
+ import type { ThreadRepository } from "../store";
13
+
14
+ export interface StoreHarness {
15
+ open: (records: unknown[]) => ThreadRepository;
16
+ restart: (store: ThreadRepository) => ThreadRepository;
17
+ }
18
+
19
+ export function createTestThreadRecord(
20
+ id: string,
21
+ createdAt: string,
22
+ overrides: Partial<Thread> = {},
23
+ ): Thread {
24
+ return {
25
+ schemaVersion: SCHEMA_VERSION,
26
+ id,
27
+ workspace: { repoRoot: "/repo", branch: "main" },
28
+ artifact: { type: "plan", content: `# ${id}`, meta: {} },
29
+ revisions: [{ revision: 1, content: `# ${id}`, submittedAt: createdAt }],
30
+ annotations: [],
31
+ message: null,
32
+ status: "pending",
33
+ createdAt,
34
+ ...overrides,
35
+ };
36
+ }
37
+
38
+ export function runThreadStoreConformance(name: string, harness: StoreHarness): void {
39
+ describe(`${name} - session repository contract`, () => {
40
+ test("a stored session reads back whole and lists oldest first", () => {
41
+ // Arrange
42
+ const store = harness.open([]);
43
+
44
+ store.recover();
45
+ const newer = createTestThreadRecord("ses_b", "2026-09-02T00:00:00.000Z");
46
+ const older = createTestThreadRecord("ses_a", "2026-09-01T00:00:00.000Z");
47
+
48
+ // Act
49
+ store.upsert(newer);
50
+ store.upsert(older);
51
+
52
+ // Assert
53
+ expect(store.get("ses_a")).toEqual(older);
54
+ expect(store.list().map((session) => session.id)).toEqual(["ses_a", "ses_b"]);
55
+ expect(store.get("ses_zzz")).toBeUndefined();
56
+ });
57
+
58
+ test("upsert replaces by id and delete reports whether anything was removed", () => {
59
+ // Arrange
60
+ const store = harness.open([]);
61
+
62
+ store.recover();
63
+ store.upsert(createTestThreadRecord("ses_a", "2026-09-01T00:00:00.000Z"));
64
+
65
+ // Act
66
+ store.upsert(
67
+ createTestThreadRecord("ses_a", "2026-09-01T00:00:00.000Z", { status: "resolved" }),
68
+ );
69
+
70
+ // Assert
71
+ expect(store.list()).toHaveLength(1);
72
+ expect(store.get("ses_a")?.status).toBe("resolved");
73
+ expect(store.delete("ses_a")).toBe(true);
74
+ expect(store.delete("ses_a")).toBe(false);
75
+ expect(store.list()).toEqual([]);
76
+ });
77
+
78
+ test("what was upserted survives a restart, deletions included", () => {
79
+ // Arrange
80
+ const store = harness.open([]);
81
+
82
+ store.recover();
83
+ store.upsert(createTestThreadRecord("ses_keep", "2026-09-01T00:00:00.000Z"));
84
+ store.upsert(createTestThreadRecord("ses_gone", "2026-09-02T00:00:00.000Z"));
85
+ store.delete("ses_gone");
86
+
87
+ // Act
88
+ const reopened = harness.restart(store);
89
+ const report = reopened.recover();
90
+
91
+ // Assert
92
+ expect(report.recovered).toEqual(["ses_keep"]);
93
+ expect(reopened.get("ses_keep")?.artifact.content).toBe("# ses_keep");
94
+ expect(reopened.get("ses_gone")).toBeUndefined();
95
+ });
96
+
97
+ test("a record without a history reads as a one-branch tree whose head is the artifact", () => {
98
+ // Arrange: written before histories existed, with two revisions and a comment
99
+ const legacy = createTestThreadRecord("ses_old", "2026-09-01T00:00:00.000Z", {
100
+ artifact: { type: "plan", content: "v2", meta: {} },
101
+ revisions: [
102
+ { revision: 1, content: "v1", submittedAt: "2026-09-01T00:00:00.000Z" },
103
+ { revision: 2, content: "v2", submittedAt: "2026-09-01T02:00:00.000Z" },
104
+ ],
105
+ annotations: [
106
+ {
107
+ id: "a1",
108
+ kind: "comment",
109
+ anchor: { quote: "v1", prefix: "", suffix: "" },
110
+ body: "why",
111
+ createdAt: "2026-09-01T01:00:00.000Z",
112
+ },
113
+ ],
114
+ });
115
+ const store = harness.open([legacy]);
116
+
117
+ // Act
118
+ const report = store.recover();
119
+ const history = store.get("ses_old")?.history;
120
+
121
+ // Assert
122
+ expect(report.recovered).toEqual(["ses_old"]);
123
+ expect(history?.branch).toBe("main");
124
+ expect(history?.entries.map((entry) => entry.type)).toEqual([
125
+ "revision",
126
+ "comment",
127
+ "revision",
128
+ ]);
129
+ expect(history?.entries.at(-1)).toMatchObject({ type: "revision", content: "v2" });
130
+ expect(history?.tips.main).toBe(history?.entries.at(-1)?.id);
131
+ });
132
+
133
+ test("a record with no revision still recovers, without a history", () => {
134
+ // Arrange
135
+ const store = harness.open([
136
+ createTestThreadRecord("ses_empty", "2026-09-01T00:00:00.000Z", { revisions: [] }),
137
+ ]);
138
+
139
+ // Act
140
+ const report = store.recover();
141
+
142
+ // Assert
143
+ expect(report.recovered).toEqual(["ses_empty"]);
144
+ expect(store.get("ses_empty")?.history).toBeUndefined();
145
+ });
146
+
147
+ test("a record that already carries a history keeps it untouched", () => {
148
+ // Arrange
149
+ const withTree = createTestThreadRecord("ses_tree", "2026-09-01T00:00:00.000Z", {
150
+ history: {
151
+ entries: [
152
+ {
153
+ id: "root",
154
+ parentId: null,
155
+ type: "revision",
156
+ by: "agent",
157
+ content: "# ses_tree",
158
+ createdAt: "2026-09-01T00:00:00.000Z",
159
+ },
160
+ ],
161
+ tips: { main: "root", ramble: "root" },
162
+ branch: "ramble",
163
+ labels: { root: "start" },
164
+ },
165
+ });
166
+ const store = harness.open([withTree]);
167
+
168
+ // Act
169
+ store.recover();
170
+
171
+ // Assert
172
+ expect(store.get("ses_tree")?.history).toEqual(withTree.history);
173
+ });
174
+
175
+ test("a record that fails validation is skipped and reported, never dropped from the report", () => {
176
+ // Arrange
177
+ const store = harness.open([
178
+ createTestThreadRecord("ses_good", "2026-09-01T00:00:00.000Z"),
179
+ { schemaVersion: SCHEMA_VERSION, id: "ses_bad" },
180
+ ]);
181
+
182
+ // Act
183
+ const report = store.recover();
184
+
185
+ // Assert
186
+ expect(report.recovered).toEqual(["ses_good"]);
187
+ expect(report.skipped).toHaveLength(1);
188
+ expect(report.skipped[0]!.error).toContain("invalid record");
189
+ expect(store.get("ses_bad")).toBeUndefined();
190
+ });
191
+ });
192
+ }
@@ -0,0 +1,119 @@
1
+ import * as v from "valibot";
2
+ import type { ThreadAgentState } from "@cueloop/schema";
3
+
4
+ const AgentAnchorSchema = v.object({
5
+ quote: v.pipe(v.string(), v.minLength(1), v.maxLength(32_768)),
6
+ prefix: v.string(),
7
+ suffix: v.string(),
8
+ blockIndex: v.number(),
9
+ start: v.number(),
10
+ end: v.number(),
11
+ endBlockIndex: v.optional(v.number()),
12
+ });
13
+
14
+ /** Validate agent comments at the socket and persisted transcript boundaries. */
15
+ export const AgentCommentSchema = v.object({
16
+ id: v.string(),
17
+ messageId: v.string(),
18
+ anchor: AgentAnchorSchema,
19
+ body: v.pipe(v.string(), v.minLength(1), v.maxLength(32_768)),
20
+ sent: v.boolean(),
21
+ replyTo: v.optional(v.string()),
22
+ author: v.optional(v.string()),
23
+ });
24
+
25
+ /** Validate persisted agent transcripts independently of submitted artifacts. */
26
+ const StoredAgentSchema = v.object({
27
+ threadId: v.string(),
28
+ fxSessionId: v.optional(v.string()),
29
+ harness: v.optional(
30
+ v.object({ id: v.string(), label: v.string(), sessionId: v.optional(v.string()) }),
31
+ ),
32
+ phase: v.variant("kind", [
33
+ v.object({ kind: v.literal("idle") }),
34
+ v.object({ kind: v.literal("running") }),
35
+ v.object({ kind: v.literal("failed"), error: v.string() }),
36
+ v.object({
37
+ kind: v.literal("permission"),
38
+ permission: v.object({
39
+ id: v.string(),
40
+ title: v.string(),
41
+ options: v.array(
42
+ v.object({
43
+ optionId: v.string(),
44
+ name: v.string(),
45
+ kind: v.string(),
46
+ }),
47
+ ),
48
+ }),
49
+ }),
50
+ ]),
51
+ messages: v.array(
52
+ v.object({
53
+ id: v.string(),
54
+ role: v.picklist(["user", "agent"]),
55
+ text: v.string(),
56
+ complete: v.boolean(),
57
+ revision: v.number(),
58
+ submissionId: v.optional(v.string()),
59
+ }),
60
+ ),
61
+ tools: v.array(
62
+ v.object({
63
+ id: v.string(),
64
+ turnId: v.string(),
65
+ title: v.string(),
66
+ kind: v.string(),
67
+ status: v.picklist(["pending", "in_progress", "completed", "failed", "cancelled"]),
68
+ output: v.string(),
69
+ locations: v.array(v.object({ path: v.string(), line: v.optional(v.number()) })),
70
+ }),
71
+ ),
72
+ comments: v.array(AgentCommentSchema),
73
+ submissions: v.optional(
74
+ v.array(
75
+ v.object({
76
+ id: v.string(),
77
+ commentId: v.optional(v.string()),
78
+ messageId: v.optional(v.string()),
79
+ prompt: v.string(),
80
+ quote: v.optional(v.string()),
81
+ context: v.optional(v.string()),
82
+ status: v.picklist(["queued", "running", "completed", "failed"]),
83
+ cancelled: v.optional(v.boolean()),
84
+ }),
85
+ ),
86
+ ),
87
+ promptOperations: v.optional(
88
+ v.array(
89
+ v.object({
90
+ operationId: v.string(),
91
+ fingerprint: v.string(),
92
+ result: v.array(v.string()),
93
+ outcome: v.optional(v.picklist(["completed", "failed", "cancelled"])),
94
+ }),
95
+ ),
96
+ ),
97
+ configOptions: v.optional(
98
+ v.array(
99
+ v.object({
100
+ id: v.string(),
101
+ name: v.string(),
102
+ category: v.optional(v.string()),
103
+ currentValue: v.string(),
104
+ options: v.array(v.object({ value: v.string(), name: v.string() })),
105
+ }),
106
+ ),
107
+ ),
108
+ });
109
+
110
+ /** Read the first fx prototype's records without discarding their session identity. */
111
+ export const ThreadAgentSchema: v.GenericSchema<ThreadAgentState> = v.pipe(
112
+ StoredAgentSchema,
113
+ v.transform(({ fxSessionId, ...state }) => ({
114
+ ...state,
115
+ harness:
116
+ state.harness ??
117
+ (fxSessionId ? { id: "fx", label: "fx", sessionId: fxSessionId } : undefined),
118
+ })),
119
+ );