@cueloop/daemon 0.1.0-alpha.68 → 0.1.0-alpha.69

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/src/store.ts CHANGED
@@ -1,21 +1,37 @@
1
1
  /**
2
- * Session persistence: one JSON record per session, written whole through a
3
- * temp file and an atomic rename so a crash never leaves a torn file. Recovery
4
- * is a read-only scan of the state directory; records that fail to parse are
5
- * skipped and reported, never deleted, and records from before histories
6
- * existed are given one on read.
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.
7
9
  *
8
10
  * `SessionRepository` is the contract every adapter satisfies; the conformance
9
11
  * suite in ./testing/store-conformance.ts pins it for the file store and the
10
12
  * in-memory store alike.
11
13
  */
12
14
 
13
- import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
14
- import { join } from "node:path";
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";
15
27
  import { historyFromLinear, type ReviewSession } from "@cueloop/schema";
16
- import { sessionsDir } from "./paths";
28
+ import { migratedSessionsDir, sessionsDir, threadBucket, threadsDir } from "./paths";
17
29
  import { validateSessionRecord } from "./validate";
18
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;
34
+
19
35
  export interface RecoveryReport {
20
36
  recovered: string[];
21
37
  skipped: { file: string; error: string }[];
@@ -51,33 +67,106 @@ function oldestFirst(sessions: Iterable<ReviewSession>): ReviewSession[] {
51
67
 
52
68
  export class SessionStore implements SessionRepository {
53
69
  private sessions = new Map<string, ReviewSession>();
54
- private readonly dir: string;
70
+ /** id -> its JSONL path (the bucket depends on the record's root commit, so it is tracked). */
71
+ private files = new Map<string, string>();
72
+ /** id -> lines in its log, so upsert knows when to compact instead of append. */
73
+ private lineCounts = new Map<string, number>();
55
74
 
56
- constructor(home: string) {
57
- this.dir = sessionsDir(home);
58
- mkdirSync(this.dir, { recursive: true });
75
+ constructor(private readonly home: string) {
76
+ mkdirSync(threadsDir(home), { recursive: true });
77
+ // the legacy dir is where a pre-upgrade daemon wrote; ensure it exists so its scan is uniform
78
+ mkdirSync(sessionsDir(home), { recursive: true });
59
79
  }
60
80
 
61
81
  recover(): RecoveryReport {
62
82
  const report: RecoveryReport = { recovered: [], skipped: [] };
63
83
 
64
- for (const file of readdirSync(this.dir)) {
84
+ this.migrateLegacy(report);
85
+ const root = threadsDir(this.home);
86
+
87
+ for (const bucket of readdirSync(root)) {
88
+ const bucketPath = join(root, bucket);
89
+
90
+ if (!statSync(bucketPath).isDirectory()) continue;
91
+ for (const file of readdirSync(bucketPath)) {
92
+ if (!file.endsWith(".jsonl")) continue;
93
+ const filePath = join(bucketPath, file);
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
+ // a torn trailing fragment would fuse with the next append and lose that update, so rewrite
102
+ // the log to a single clean snapshot line now that the last valid record is in hand
103
+ if (torn) this.writeWhole(session);
104
+ } catch (error) {
105
+ report.skipped.push({
106
+ file,
107
+ error: error instanceof Error ? error.message : String(error),
108
+ });
109
+ }
110
+ }
111
+ }
112
+
113
+ return report;
114
+ }
115
+
116
+ /** One-time upgrade: write each old whole-record JSON as a thread log and park the original, so
117
+ * the threads scan below recovers it. Invalid records are reported and left in place, never lost. */
118
+ private migrateLegacy(report: RecoveryReport): void {
119
+ const legacyDir = sessionsDir(this.home);
120
+
121
+ if (!existsSync(legacyDir)) return;
122
+ const parked = migratedSessionsDir(this.home);
123
+
124
+ for (const file of readdirSync(legacyDir)) {
65
125
  if (!file.endsWith(".json")) continue;
126
+ const legacyPath = join(legacyDir, file);
127
+ let session: ReviewSession;
128
+
66
129
  try {
67
- const raw = readFileSync(join(this.dir, file), "utf8");
68
- const parsed = validateSessionRecord(JSON.parse(raw));
130
+ const parsed = validateSessionRecord(JSON.parse(readFileSync(legacyPath, "utf8")));
69
131
 
70
132
  if (!parsed.ok) throw new Error(`invalid record - ${parsed.error}`);
71
- const session = withHistory(parsed.value);
72
-
73
- this.sessions.set(session.id, session);
74
- report.recovered.push(session.id);
75
- } catch (err) {
76
- report.skipped.push({ file, error: err instanceof Error ? err.message : String(err) });
133
+ session = withHistory(parsed.value);
134
+ } catch (error) {
135
+ report.skipped.push({
136
+ file,
137
+ error: error instanceof Error ? error.message : String(error),
138
+ });
139
+ continue;
77
140
  }
141
+ const targetPath = join(
142
+ threadBucket(session.workspace.rootCommit, this.home),
143
+ `${session.id}.jsonl`,
144
+ );
145
+
146
+ // after a rollback a newer JSONL can already hold this thread; never overwrite it with the
147
+ // older legacy snapshot - the existing log stays authoritative and the threads scan recovers it
148
+ if (!existsSync(targetPath)) this.writeWhole(session);
149
+ // forget the in-memory tracking migration seeded: the threads scan is the one recovery path
150
+ this.files.delete(session.id);
151
+ this.lineCounts.delete(session.id);
152
+ mkdirSync(parked, { recursive: true });
153
+ renameSync(legacyPath, join(parked, file));
78
154
  }
155
+ }
79
156
 
80
- return report;
157
+ /** Rewrite the thread to a single snapshot line, atomically - the first write and every compaction. */
158
+ private writeWhole(session: ReviewSession): void {
159
+ const filePath =
160
+ this.files.get(session.id) ??
161
+ join(threadBucket(session.workspace.rootCommit, this.home), `${session.id}.jsonl`);
162
+
163
+ mkdirSync(dirname(filePath), { recursive: true });
164
+ const tempPath = filePath + ".tmp";
165
+
166
+ writeFileSync(tempPath, JSON.stringify(session) + "\n");
167
+ renameSync(tempPath, filePath);
168
+ this.files.set(session.id, filePath);
169
+ this.lineCounts.set(session.id, 1);
81
170
  }
82
171
 
83
172
  get(id: string): ReviewSession | undefined {
@@ -90,21 +179,63 @@ export class SessionStore implements SessionRepository {
90
179
 
91
180
  upsert(session: ReviewSession): void {
92
181
  this.sessions.set(session.id, session);
93
- const path = join(this.dir, `${session.id}.json`);
94
- const tempPath = path + ".tmp";
182
+ const filePath = this.files.get(session.id);
183
+ const lines = this.lineCounts.get(session.id) ?? 0;
184
+
185
+ // the first write, an over-long log, and a resolved (now immutable) thread all compact to one
186
+ // line; every other mutation is a cheap append that a torn tail on crash simply drops on read
187
+ if (filePath === undefined || lines >= COMPACT_THRESHOLD || session.status === "resolved") {
188
+ this.writeWhole(session);
95
189
 
96
- writeFileSync(tempPath, JSON.stringify(session, null, 2));
97
- renameSync(tempPath, path);
190
+ return;
191
+ }
192
+ appendFileSync(filePath, JSON.stringify(session) + "\n");
193
+ this.lineCounts.set(session.id, lines + 1);
98
194
  }
99
195
 
100
196
  delete(id: string): boolean {
101
197
  if (!this.sessions.delete(id)) return false;
102
- rmSync(join(this.dir, `${id}.json`), { force: true });
198
+ const filePath = this.files.get(id);
199
+
200
+ if (filePath) rmSync(filePath, { force: true });
201
+ this.files.delete(id);
202
+ this.lineCounts.delete(id);
103
203
 
104
204
  return true;
105
205
  }
106
206
  }
107
207
 
208
+ /**
209
+ * Read a thread log: the last valid snapshot line wins, so a crash's torn trailing line is skipped.
210
+ * `torn` is true when that winning line was not the last physical line - the caller rewrites the log
211
+ * so the next append starts on a clean boundary instead of fusing onto the torn fragment.
212
+ */
213
+ function readThread(filePath: string) {
214
+ const allLines = readFileSync(filePath, "utf8")
215
+ .split("\n")
216
+ .filter((line) => line.trim().length > 0);
217
+
218
+ for (let index = allLines.length - 1; index >= 0; index--) {
219
+ let record: unknown;
220
+
221
+ try {
222
+ record = JSON.parse(allLines[index]!);
223
+ } catch {
224
+ continue;
225
+ }
226
+ const parsed = validateSessionRecord(record);
227
+
228
+ if (parsed.ok) {
229
+ return {
230
+ session: withHistory(parsed.value),
231
+ lines: allLines.length,
232
+ torn: index !== allLines.length - 1,
233
+ };
234
+ }
235
+ }
236
+ throw new Error("no valid record line");
237
+ }
238
+
108
239
  /**
109
240
  * The in-memory adapter: the same contract with nothing on disk. `seed` stands
110
241
  * in for what a file store finds on recovery, so validation and migration are
@@ -186,6 +186,7 @@ describe("wire pins", () => {
186
186
  pr: "org/repo#1",
187
187
  herdrPane: "%7",
188
188
  title: "Plan",
189
+ workbench: false,
189
190
  };
190
191
  const fullAnchor: Required<Anchor> = {
191
192
  quote: "q",
@@ -201,6 +202,7 @@ describe("wire pins", () => {
201
202
  id: "a1",
202
203
  kind: "comment",
203
204
  anchor: fullAnchor,
205
+ target: { kind: "file", path: "src/x.ts", rev: "worktree" },
204
206
  body: "b",
205
207
  orphan: false,
206
208
  author: "SHA256:abc",
package/src/validate.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  SCHEMA_VERSION,
16
16
  type Anchor,
17
17
  type Annotation,
18
+ type AnnotationTarget,
18
19
  type Artifact,
19
20
  type ArtifactMeta,
20
21
  type DiffFileContents,
@@ -57,6 +58,7 @@ export const ArtifactMetaSchema = v.object({
57
58
  pr: v.optional(v.string()),
58
59
  herdrPane: v.optional(v.string()),
59
60
  title: v.optional(v.string()),
61
+ workbench: v.optional(v.boolean()),
60
62
  } satisfies EntriesOf<ArtifactMeta>);
61
63
 
62
64
  export const DiffFileContentsSchema = v.object({
@@ -86,12 +88,20 @@ export const AnchorSchema = v.object({
86
88
  selector: v.optional(v.string()),
87
89
  } satisfies EntriesOf<Anchor>);
88
90
 
91
+ /** The surface a note was made on; absent means the reviewed artifact. */
92
+ export const AnnotationTargetSchema: v.GenericSchema<AnnotationTarget> = v.variant("kind", [
93
+ v.object({ kind: v.literal("artifact") }),
94
+ v.object({ kind: v.literal("file"), path: NonEmpty, rev: v.picklist(["worktree", "head"]) }),
95
+ v.object({ kind: v.literal("welcome") }),
96
+ ]);
97
+
89
98
  /** Wire annotations arrive without createdAt - the daemon stamps it. */
90
99
  export const AnnotationSchema = v.object({
91
100
  id: NonEmpty,
92
101
  /** Open kind set: the built-in is comment. */
93
102
  kind: NonEmpty,
94
103
  anchor: AnchorSchema,
104
+ target: v.optional(AnnotationTargetSchema),
95
105
  body: v.string(),
96
106
  orphan: v.optional(v.boolean()),
97
107
  author: v.optional(v.string()),
@@ -172,6 +182,8 @@ export const Params = {
172
182
  "repo.files": v.object({ cwd: NonEmpty }),
173
183
  "repo.fileContents": v.object({ cwd: NonEmpty, path: NonEmpty }),
174
184
  "repo.changes": v.object({ cwd: NonEmpty }),
185
+ "repo.diff": v.object({ cwd: NonEmpty }),
186
+ "session.workbench": v.object({ cwd: NonEmpty }),
175
187
  "session.navigate": v.object({
176
188
  id: SessionId,
177
189
  entryId: NonEmpty,
@@ -199,6 +211,7 @@ export const Params = {
199
211
  id: SessionId,
200
212
  verdictKind: v.picklist(["comment", "approve", "request_changes"]),
201
213
  summary: v.optional(v.string(), ""),
214
+ actionBodies: v.optional(v.record(v.string(), v.string())),
202
215
  }),
203
216
  "session.submitRevision": v.object({
204
217
  id: SessionId,
package/src/version.ts ADDED
@@ -0,0 +1,6 @@
1
+ /** The daemon's build version, read from the package manifest - the handshake
2
+ * uses it so a client never keeps talking to a daemon from an earlier build. */
3
+
4
+ import manifest from "../package.json" with { type: "json" };
5
+
6
+ export const DAEMON_VERSION: string = manifest.version;