@cueloop/daemon 0.1.0-alpha.2 → 0.1.0-alpha.20

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/server.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The socket server: binds DaemonCore's methods to the unix socket, owns the
3
- * pidfile, stale-socket cleanup, and the idle-exit timer (#14). File
3
+ * pidfile, stale-socket cleanup, and the idle-exit timer. File
4
4
  * permissions are the local auth: the socket and state dir are 0700/0600.
5
5
  */
6
6
 
@@ -8,10 +8,10 @@ import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, rm
8
8
  import { DaemonCore, type DaemonEvent } from "./api";
9
9
  import { DaemonError } from "./errors";
10
10
  import { isKnownMethod, parseParams } from "./validate";
11
- import { LineBuffer, type Request } from "./protocol";
11
+ import { BackpressureWriter, LineBuffer, type Request } from "./protocol";
12
12
  import { cueloopHome, lockPath, pidPath, socketPath } from "./paths";
13
13
 
14
- interface Conn {
14
+ interface Connection {
15
15
  write(data: string): void;
16
16
  subscribed: boolean;
17
17
  }
@@ -33,20 +33,20 @@ const HELD_HOMES = new Set<string>();
33
33
  export class DaemonServer {
34
34
  readonly core: DaemonCore;
35
35
  readonly home: string;
36
- private conns = new Set<Conn>();
36
+ private connections = new Set<Connection>();
37
37
  private server: ReturnType<typeof Bun.listen> | null = null;
38
38
  private idleTimer: ReturnType<typeof setTimeout> | null = null;
39
39
  private lockFd: number | null = null;
40
40
  private readonly idleExitMs: number;
41
41
  private readonly onIdleExit: () => void;
42
42
 
43
- constructor(opts: DaemonOptions = {}) {
44
- this.home = opts.home ?? cueloopHome();
45
- this.idleExitMs = opts.idleExitMs ?? 15 * 60 * 1000;
46
- this.onIdleExit = opts.onIdleExit ?? (() => process.exit(0));
43
+ constructor(options: DaemonOptions = {}) {
44
+ this.home = options.home ?? cueloopHome();
45
+ this.idleExitMs = options.idleExitMs ?? 15 * 60 * 1000;
46
+ this.onIdleExit = options.onIdleExit ?? (() => process.exit(0));
47
47
  mkdirSync(this.home, { recursive: true, mode: 0o700 });
48
48
  this.core = new DaemonCore(this.home);
49
- this.core.onEvent((e) => this.broadcast(e));
49
+ this.core.onEvent((event) => this.broadcast(event));
50
50
  }
51
51
 
52
52
  /**
@@ -119,22 +119,28 @@ export class DaemonServer {
119
119
  // socket file left behind is stale
120
120
  if (existsSync(path)) rmSync(path, { force: true });
121
121
  const self = this;
122
- this.server = Bun.listen<{ buffer: LineBuffer; conn: Conn }>({
122
+ this.server = Bun.listen<{ buffer: LineBuffer; connection: Connection; writer: BackpressureWriter }>({
123
123
  unix: path,
124
124
  socket: {
125
125
  open(socket) {
126
- const conn: Conn = { write: (d) => socket.write(d), subscribed: false };
127
- socket.data = { buffer: new LineBuffer(), conn };
128
- self.conns.add(conn);
126
+ const writer = new BackpressureWriter(socket);
127
+ const connection: Connection = { write: (data) => writer.write(data), subscribed: false };
128
+ socket.data = { buffer: new LineBuffer(), connection, writer };
129
+ self.connections.add(connection);
129
130
  self.scheduleIdleCheck();
130
131
  },
131
132
  data(socket, data) {
132
133
  socket.data.buffer.push(data.toString(), (line) => {
133
- void self.handleLine(socket.data.conn, line);
134
+ void self.respondToRequestLine(socket.data.connection, line);
134
135
  });
135
136
  },
137
+ drain(socket) {
138
+ // drain can fire before open has attached data on some platforms;
139
+ // a throw here would take the whole daemon down with the socket
140
+ socket.data?.writer.drain();
141
+ },
136
142
  close(socket) {
137
- self.conns.delete(socket.data.conn);
143
+ self.connections.delete(socket.data.connection);
138
144
  self.scheduleIdleCheck();
139
145
  },
140
146
  error() {},
@@ -155,9 +161,9 @@ export class DaemonServer {
155
161
  this.releaseLock();
156
162
  }
157
163
 
158
- private broadcast(e: DaemonEvent): void {
159
- const frame = JSON.stringify(e) + "\n";
160
- for (const c of this.conns) if (c.subscribed) c.write(frame);
164
+ private broadcast(event: DaemonEvent): void {
165
+ const frame = JSON.stringify(event) + "\n";
166
+ for (const connection of this.connections) if (connection.subscribed) connection.write(frame);
161
167
  this.scheduleIdleCheck();
162
168
  }
163
169
 
@@ -166,7 +172,7 @@ export class DaemonServer {
166
172
  if (this.idleExitMs <= 0) return;
167
173
  if (this.idleTimer) clearTimeout(this.idleTimer);
168
174
  this.idleTimer = setTimeout(() => {
169
- if (this.conns.size === 0 && !this.core.hasPendingSessions()) {
175
+ if (this.connections.size === 0 && !this.core.hasPendingSessions()) {
170
176
  this.stop();
171
177
  this.onIdleExit();
172
178
  } else {
@@ -175,31 +181,31 @@ export class DaemonServer {
175
181
  }, this.idleExitMs);
176
182
  }
177
183
 
178
- private async handleLine(conn: Conn, line: string): Promise<void> {
179
- let req: Request;
184
+ private async respondToRequestLine(connection: Connection, line: string): Promise<void> {
185
+ let request: Request;
180
186
  try {
181
- req = JSON.parse(line) as Request;
187
+ request = JSON.parse(line) as Request;
182
188
  } catch {
183
- conn.write(JSON.stringify({ id: -1, error: { code: "bad_json", message: "unparseable request" } }) + "\n");
189
+ connection.write(JSON.stringify({ id: -1, error: { code: "bad_json", message: "unparseable request" } }) + "\n");
184
190
  return;
185
191
  }
186
192
  try {
187
- const result = await this.dispatch(conn, req);
188
- conn.write(JSON.stringify({ id: req.id, result }) + "\n");
193
+ const result = await this.dispatch(connection, request);
194
+ connection.write(JSON.stringify({ id: request.id, result }) + "\n");
189
195
  } catch (err) {
190
196
  const code = err instanceof DaemonError ? err.code : "internal";
191
197
  const message = err instanceof Error ? err.message : String(err);
192
- conn.write(JSON.stringify({ id: req.id, error: { code, message } }) + "\n");
198
+ connection.write(JSON.stringify({ id: request.id, error: { code, message } }) + "\n");
193
199
  }
194
200
  }
195
201
 
196
- private async dispatch(conn: Conn, req: Request): Promise<unknown> {
202
+ private async dispatch(connection: Connection, request: Request): Promise<unknown> {
197
203
  // The wire is untrusted JSON: validate before DaemonCore sees anything.
198
- if (typeof req.method !== "string" || !isKnownMethod(req.method)) {
199
- throw new DaemonError("unknown_method", `unknown method ${String(req.method)}`);
204
+ if (typeof request.method !== "string" || !isKnownMethod(request.method)) {
205
+ throw new DaemonError("unknown_method", `unknown method ${String(request.method)}`);
200
206
  }
201
207
  const core = this.core;
202
- switch (req.method) {
208
+ switch (request.method) {
203
209
  case "daemon.ping":
204
210
  return { pid: process.pid };
205
211
  case "daemon.shutdown":
@@ -209,42 +215,46 @@ export class DaemonServer {
209
215
  }, 10);
210
216
  return {};
211
217
  case "events.subscribe":
212
- conn.subscribed = true;
218
+ connection.subscribed = true;
213
219
  return {};
214
220
  case "session.create": {
215
- const p = parseParams("session.create", req.params);
216
- return core.sessionCreate({ workspace: p.workspace, artifact: p.artifact });
221
+ const params = parseParams("session.create", request.params);
222
+ return core.sessionCreate({ workspace: params.workspace, artifact: params.artifact });
217
223
  }
218
224
  case "session.get":
219
- return core.sessionGet(parseParams("session.get", req.params).id);
225
+ return core.sessionGet(parseParams("session.get", request.params).id);
220
226
  case "session.list":
221
- return core.sessionList(parseParams("session.list", req.params).filter);
227
+ return core.sessionList(parseParams("session.list", request.params).filter);
222
228
  case "session.wait": {
223
- const p = parseParams("session.wait", req.params);
224
- return core.sessionWait(p.id, p.timeoutMs);
229
+ const params = parseParams("session.wait", request.params);
230
+ return core.sessionWait(params.id, params.timeoutMs);
225
231
  }
226
232
  case "session.annotate": {
227
- const p = parseParams("session.annotate", req.params);
228
- return core.sessionAnnotate(p.id, p.annotation);
233
+ const params = parseParams("session.annotate", request.params);
234
+ return core.sessionAnnotate(params.id, params.annotation);
229
235
  }
230
236
  case "session.removeAnnotation": {
231
- const p = parseParams("session.removeAnnotation", req.params);
232
- return core.sessionRemoveAnnotation(p.id, p.annotationId);
237
+ const params = parseParams("session.removeAnnotation", request.params);
238
+ return core.sessionRemoveAnnotation(params.id, params.annotationId);
233
239
  }
234
240
  case "session.setWorkingCopy": {
235
- const p = parseParams("session.setWorkingCopy", req.params);
236
- return core.sessionSetWorkingCopy(p.id, p.workingCopy);
241
+ const params = parseParams("session.setWorkingCopy", request.params);
242
+ return core.sessionSetWorkingCopy(params.id, params.workingCopy);
243
+ }
244
+ case "session.setViewed": {
245
+ const params = parseParams("session.setViewed", request.params);
246
+ return core.sessionSetViewed(params.id, params.viewedPaths);
237
247
  }
238
248
  case "session.resolve": {
239
- const p = parseParams("session.resolve", req.params);
240
- return core.sessionResolve(p.id, p.verdictKind, p.summary);
249
+ const params = parseParams("session.resolve", request.params);
250
+ return core.sessionResolve(params.id, params.verdictKind, params.summary);
241
251
  }
242
252
  case "session.submitRevision": {
243
- const p = parseParams("session.submitRevision", req.params);
244
- return core.sessionSubmitRevision(p.id, p.content);
253
+ const params = parseParams("session.submitRevision", request.params);
254
+ return core.sessionSubmitRevision(params.id, params.content);
245
255
  }
246
256
  default:
247
- throw new DaemonError("unknown_method", `unknown method ${req.method}`);
257
+ throw new DaemonError("unknown_method", `unknown method ${request.method}`);
248
258
  }
249
259
  }
250
260
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Single-instance guarantee (#14): one daemon per home. Two daemons over one
2
+ * Single-instance guarantee: one daemon per home. Two daemons over one
3
3
  * state directory means divergent in-memory sessions and clients seeing
4
4
  * whichever half they connect to.
5
5
  */
@@ -23,14 +23,14 @@ beforeEach(() => {
23
23
  home = mkdtempSync(join(tmpdir(), "cueloop-lock-"));
24
24
  });
25
25
  afterEach(() => {
26
- for (const s of servers.splice(0)) s.stop();
26
+ for (const runningServer of servers.splice(0)) runningServer.stop();
27
27
  rmSync(home, { recursive: true, force: true });
28
28
  });
29
29
 
30
30
  function server(): DaemonServer {
31
- const s = new DaemonServer({ home, idleExitMs: 0 });
32
- servers.push(s);
33
- return s;
31
+ const daemonServer = new DaemonServer({ home, idleExitMs: 0 });
32
+ servers.push(daemonServer);
33
+ return daemonServer;
34
34
  }
35
35
 
36
36
  describe("one daemon per home", () => {
@@ -63,8 +63,8 @@ describe("one daemon per home", () => {
63
63
  // a pid that cannot be running (init is never a cueloop daemon, and this
64
64
  // simulates the record a crashed process leaves behind)
65
65
  writeFileSync(lockPath(home), "999999");
66
- const s = server();
67
- expect(s.start()).toBe(socketPath(home));
66
+ const daemonServer = server();
67
+ expect(daemonServer.start()).toBe(socketPath(home));
68
68
  });
69
69
 
70
70
  test("stopping releases the lock so a restart works", () => {
@@ -84,12 +84,12 @@ describe("one daemon per home", () => {
84
84
  DaemonClient.connect({ home, autostart: true }),
85
85
  ]);
86
86
  try {
87
- const pids = await Promise.all(clients.map((c) => c.ping()));
88
- const unique = new Set(pids.map((p) => p.pid));
87
+ const pids = await Promise.all(clients.map((client) => client.ping()));
88
+ const unique = new Set(pids.map((ping) => ping.pid));
89
89
  expect(unique.size).toBe(1);
90
90
  // and state is shared: one client's session is visible to the others
91
- const s = await clients[0]!.sessionCreate(WS, PLAN);
92
- expect((await clients[2]!.sessionGet(s.id)).id).toBe(s.id);
91
+ const session = await clients[0]!.sessionCreate(WS, PLAN);
92
+ expect((await clients[2]!.sessionGet(session.id)).id).toBe(session.id);
93
93
  } finally {
94
94
  for (const c of clients) c.close();
95
95
  // the autostarted daemon is not one of `servers` - shut it down
package/src/store.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Session persistence (#14): one JSON document per session, every write
2
+ * Session persistence: one JSON document per session, every write
3
3
  * through a temp file + atomic rename so a crash mid-write can never leave
4
4
  * a corrupt record. Recovery is a read-only scan; records that fail to
5
5
  * parse are skipped and reported, never deleted.
@@ -49,15 +49,15 @@ export class SessionStore {
49
49
  }
50
50
 
51
51
  list(): ReviewSession[] {
52
- return [...this.sessions.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
52
+ return [...this.sessions.values()].sort((left, right) => left.createdAt.localeCompare(right.createdAt));
53
53
  }
54
54
 
55
55
  upsert(session: ReviewSession): void {
56
56
  this.sessions.set(session.id, session);
57
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);
58
+ const tempPath = path + ".tmp";
59
+ writeFileSync(tempPath, JSON.stringify(session, null, 2));
60
+ renameSync(tempPath, path);
61
61
  }
62
62
 
63
63
  delete(id: string): boolean {
@@ -1,7 +1,33 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { DaemonCore } from "./api";
2
6
  import { DaemonError } from "./errors";
3
- import { isKnownMethod, parseParams, validateSessionRecord } from "./validate";
4
- import { SCHEMA_VERSION } from "@cueloop/schema";
7
+ import {
8
+ AnchorSchema,
9
+ AnnotationSchema,
10
+ ArtifactMetaSchema,
11
+ ArtifactSchema,
12
+ RevisionSchema,
13
+ SessionRecordSchema,
14
+ VerdictSchema,
15
+ WorkspaceSchema,
16
+ isKnownMethod,
17
+ parseParams,
18
+ validateSessionRecord,
19
+ } from "./validate";
20
+ import {
21
+ SCHEMA_VERSION,
22
+ type Anchor,
23
+ type Annotation,
24
+ type Artifact,
25
+ type ArtifactMeta,
26
+ type ReviewSession,
27
+ type Revision,
28
+ type Verdict,
29
+ type WorkspaceKey,
30
+ } from "@cueloop/schema";
5
31
 
6
32
  describe("method allowlist", () => {
7
33
  test("known and unknown methods", () => {
@@ -12,11 +38,11 @@ describe("method allowlist", () => {
12
38
 
13
39
  describe("parseParams", () => {
14
40
  test("accepts a well-formed create and defaults meta", () => {
15
- const p = parseParams("session.create", {
41
+ const params = parseParams("session.create", {
16
42
  workspace: { repoRoot: "/repo", branch: "main" },
17
43
  artifact: { type: "plan", content: "# P" },
18
44
  });
19
- expect(p.artifact.meta).toEqual({});
45
+ expect(params.artifact.meta).toEqual({});
20
46
  });
21
47
 
22
48
  test("rejects a missing workspace with a pathed message", () => {
@@ -55,12 +81,22 @@ describe("parseParams", () => {
55
81
  });
56
82
 
57
83
  test("annotation kinds stay open (extension kinds are allowed)", () => {
58
- const p = parseParams("session.annotate", {
84
+ const params = parseParams("session.annotate", {
59
85
  id: "s",
60
86
  annotation: { id: "a1", kind: "praise", anchor: { quote: "x" }, body: "nice" },
61
87
  });
62
- expect(p.annotation.kind).toBe("praise");
63
- expect(p.annotation.anchor.prefix).toBe("");
88
+ expect(params.annotation.kind).toBe("praise");
89
+ expect(params.annotation.anchor.prefix).toBe("");
90
+ });
91
+
92
+ test("setViewed takes a full path list and rejects non-string entries", () => {
93
+ expect(parseParams("session.setViewed", { id: "s", viewedPaths: ["src/a.ts", "src/b.ts"] }).viewedPaths).toEqual([
94
+ "src/a.ts",
95
+ "src/b.ts",
96
+ ]);
97
+ expect(parseParams("session.setViewed", { id: "s", viewedPaths: [] }).viewedPaths).toEqual([]);
98
+ expect(() => parseParams("session.setViewed", { id: "s", viewedPaths: [1] })).toThrow(/viewedPaths/);
99
+ expect(() => parseParams("session.setViewed", { id: "s" })).toThrow(/viewedPaths/);
64
100
  });
65
101
 
66
102
  test("null params are treated as empty", () => {
@@ -86,13 +122,91 @@ describe("validateSessionRecord", () => {
86
122
  });
87
123
 
88
124
  test("rejects a foreign schema version with a readable reason", () => {
89
- const r = validateSessionRecord({ ...record, schemaVersion: "99" });
90
- expect(r.ok).toBe(false);
91
- if (!r.ok) expect(r.error).toContain("schemaVersion");
125
+ const result = validateSessionRecord({ ...record, schemaVersion: "99" });
126
+ expect(result.ok).toBe(false);
127
+ if (!result.ok) expect(result.error).toContain("schemaVersion");
92
128
  });
93
129
 
94
130
  test("rejects a structurally broken record", () => {
95
- const r = validateSessionRecord({ ...record, revisions: "nope" });
96
- expect(r.ok).toBe(false);
131
+ const result = validateSessionRecord({ ...record, revisions: "nope" });
132
+ expect(result.ok).toBe(false);
133
+ });
134
+ });
135
+
136
+ /**
137
+ * Wire pins: every schema's entries must cover exactly the keys of the
138
+ * schema type it mirrors. Runtime complement to the compile-time EntriesOf
139
+ * check - the samples are fully populated and typed, so a new field in
140
+ * @cueloop/schema shows up here too.
141
+ */
142
+ describe("wire pins", () => {
143
+ const keys = (subject: object) => Object.keys(subject).sort();
144
+ const entryKeys = (schema: { entries: object }) => Object.keys(schema.entries).sort();
145
+
146
+ const fullMeta: Required<ArtifactMeta> = {
147
+ cwd: "/repo",
148
+ agent: "claude-code",
149
+ agentSessionId: "sess-1",
150
+ planPath: "/repo/plan.md",
151
+ pr: "org/repo#1",
152
+ herdrPane: "%7",
153
+ title: "Plan",
154
+ };
155
+ const fullAnchor: Required<Anchor> = { quote: "q", prefix: "p", suffix: "s", blockIndex: 0, start: 0, end: 1 };
156
+ const fullAnnotation: Required<Annotation> = {
157
+ id: "a1",
158
+ kind: "comment",
159
+ anchor: fullAnchor,
160
+ body: "b",
161
+ orphan: false,
162
+ createdAt: "now",
163
+ };
164
+ const fullArtifact: Required<Artifact> = { type: "plan", content: "# P", meta: fullMeta };
165
+ const fullVerdict: Required<Verdict> = { kind: "approve", summary: "", feedback: "", resolvedAt: "now" };
166
+ const fullRevision: Required<Revision> = { revision: 1, content: "# P", submittedAt: "now" };
167
+ const fullWorkspace: Required<WorkspaceKey> = { repoRoot: "/repo", branch: "main" };
168
+ const fullSession: Required<ReviewSession> = {
169
+ schemaVersion: SCHEMA_VERSION,
170
+ id: "ses_1",
171
+ workspace: fullWorkspace,
172
+ artifact: fullArtifact,
173
+ revisions: [fullRevision],
174
+ annotations: [fullAnnotation],
175
+ workingCopy: "# P edited",
176
+ viewedPaths: ["src/a.ts"],
177
+ verdict: fullVerdict,
178
+ status: "pending",
179
+ createdAt: "now",
180
+ };
181
+
182
+ test("schema key sets match the schema types", () => {
183
+ expect(entryKeys(WorkspaceSchema)).toEqual(keys(fullWorkspace));
184
+ expect(entryKeys(ArtifactMetaSchema)).toEqual(keys(fullMeta));
185
+ expect(entryKeys(ArtifactSchema)).toEqual(keys(fullArtifact));
186
+ expect(entryKeys(AnchorSchema)).toEqual(keys(fullAnchor));
187
+ // wire annotations arrive without createdAt; the daemon stamps it
188
+ const { createdAt: _stamped, ...wireAnnotation } = fullAnnotation;
189
+ expect(entryKeys(AnnotationSchema)).toEqual(keys(wireAnnotation));
190
+ expect(entryKeys(RevisionSchema)).toEqual(keys(fullRevision));
191
+ expect(entryKeys(VerdictSchema)).toEqual(keys(fullVerdict));
192
+ expect(entryKeys(SessionRecordSchema)).toEqual(keys(fullSession));
193
+ // persisted annotations carry the stamped createdAt
194
+ const stored = SessionRecordSchema.entries.annotations.item;
195
+ expect(entryKeys(stored)).toEqual(keys(fullAnnotation));
196
+ });
197
+
198
+ test("a fully-populated meta survives validation and DaemonCore unchanged", () => {
199
+ const home = mkdtempSync(join(tmpdir(), "cueloop-val-"));
200
+ try {
201
+ const params = parseParams("session.create", {
202
+ workspace: fullWorkspace,
203
+ artifact: { type: "plan", content: "# P", meta: fullMeta },
204
+ });
205
+ const core = new DaemonCore(home);
206
+ const created = core.sessionCreate(params);
207
+ expect(core.sessionGet(created.id).artifact.meta).toEqual(fullMeta);
208
+ } finally {
209
+ rmSync(home, { recursive: true, force: true });
210
+ }
97
211
  });
98
212
  });
package/src/validate.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Runtime validation of the wire boundary (#14). Everything arriving over the
2
+ * Runtime validation of the wire boundary. Everything arriving over the
3
3
  * socket is untrusted JSON: adapters, the CLI, extensions, and any script a
4
4
  * user writes all speak this protocol. Validating here means DaemonCore only
5
5
  * ever sees well-formed input, and a malformed request gets a precise error
@@ -10,31 +10,50 @@
10
10
  */
11
11
 
12
12
  import * as v from "valibot";
13
- import { SCHEMA_VERSION } from "@cueloop/schema";
13
+ import {
14
+ SCHEMA_VERSION,
15
+ type Anchor,
16
+ type Annotation,
17
+ type Artifact,
18
+ type ArtifactMeta,
19
+ type ReviewSession,
20
+ type Revision,
21
+ type Verdict,
22
+ type WorkspaceKey,
23
+ } from "@cueloop/schema";
14
24
  import { DaemonError } from "./errors";
15
25
 
26
+ /**
27
+ * Drift guard for every hand-mirrored shape below. v.object strips keys it
28
+ * does not know, so a schema missing a field silently drops that field on
29
+ * the way into DaemonCore. Each entries object is checked with
30
+ * `satisfies EntriesOf<T>`: adding a field to the schema types without
31
+ * mirroring it here (or mirroring a key that does not exist) fails typecheck.
32
+ */
33
+ type EntriesOf<T> = { [K in keyof T]-?: v.GenericSchema<any, any> };
34
+
16
35
  const NonEmpty = v.pipe(v.string(), v.minLength(1));
17
36
 
18
37
  export const WorkspaceSchema = v.object({
19
38
  repoRoot: NonEmpty,
20
39
  branch: NonEmpty,
21
- });
40
+ } satisfies EntriesOf<WorkspaceKey>);
41
+
42
+ export const ArtifactMetaSchema = v.object({
43
+ cwd: v.optional(v.string()),
44
+ agent: v.optional(v.string()),
45
+ agentSessionId: v.optional(v.string()),
46
+ planPath: v.optional(v.string()),
47
+ pr: v.optional(v.string()),
48
+ herdrPane: v.optional(v.string()),
49
+ title: v.optional(v.string()),
50
+ } satisfies EntriesOf<ArtifactMeta>);
22
51
 
23
52
  export const ArtifactSchema = v.object({
24
53
  type: v.picklist(["plan", "diff"]),
25
54
  content: v.string(),
26
- meta: v.optional(
27
- v.object({
28
- cwd: v.optional(v.string()),
29
- agent: v.optional(v.string()),
30
- agentSessionId: v.optional(v.string()),
31
- planPath: v.optional(v.string()),
32
- title: v.optional(v.string()),
33
- pr: v.optional(v.string()),
34
- }),
35
- {},
36
- ),
37
- });
55
+ meta: v.optional(ArtifactMetaSchema, {}),
56
+ } satisfies EntriesOf<Artifact>);
38
57
 
39
58
  export const AnchorSchema = v.object({
40
59
  quote: v.string(),
@@ -43,16 +62,17 @@ export const AnchorSchema = v.object({
43
62
  blockIndex: v.optional(v.number()),
44
63
  start: v.optional(v.number()),
45
64
  end: v.optional(v.number()),
46
- });
65
+ } satisfies EntriesOf<Anchor>);
47
66
 
67
+ /** Wire annotations arrive without createdAt - the daemon stamps it. */
48
68
  export const AnnotationSchema = v.object({
49
69
  id: NonEmpty,
50
- /** Open kind set (#2): built-ins are comment and suggestion. */
70
+ /** Open kind set: built-ins are comment and suggestion. */
51
71
  kind: NonEmpty,
52
72
  anchor: AnchorSchema,
53
73
  body: v.string(),
54
74
  orphan: v.optional(v.boolean()),
55
- });
75
+ } satisfies EntriesOf<Omit<Annotation, "createdAt">>);
56
76
 
57
77
  const SessionId = NonEmpty;
58
78
 
@@ -75,6 +95,7 @@ export const Params = {
75
95
  "session.annotate": v.object({ id: SessionId, annotation: AnnotationSchema }),
76
96
  "session.removeAnnotation": v.object({ id: SessionId, annotationId: NonEmpty }),
77
97
  "session.setWorkingCopy": v.object({ id: SessionId, workingCopy: v.optional(v.string()) }),
98
+ "session.setViewed": v.object({ id: SessionId, viewedPaths: v.array(v.string()) }),
78
99
  "session.resolve": v.object({
79
100
  id: SessionId,
80
101
  verdictKind: v.picklist(["comment", "approve", "request_changes"]),
@@ -97,37 +118,44 @@ export function parseParams<M extends MethodName>(method: M, params: unknown): v
97
118
  const result = v.safeParse(Params[method], params ?? {});
98
119
  if (!result.success) {
99
120
  const issue = result.issues[0]!;
100
- const path = issue.path?.map((p) => String(p.key)).join(".") ?? "";
121
+ const path = issue.path?.map((pathSegment) => String(pathSegment.key)).join(".") ?? "";
101
122
  throw new DaemonError("invalid_params", `${method}: ${path ? path + " - " : ""}${issue.message}`);
102
123
  }
103
124
  return result.output;
104
125
  }
105
126
 
127
+ export const RevisionSchema = v.object({
128
+ revision: v.number(),
129
+ content: v.string(),
130
+ submittedAt: v.string(),
131
+ } satisfies EntriesOf<Revision>);
132
+
133
+ export const VerdictSchema = v.object({
134
+ kind: v.picklist(["comment", "approve", "request_changes"]),
135
+ summary: v.string(),
136
+ feedback: v.string(),
137
+ resolvedAt: v.string(),
138
+ } satisfies EntriesOf<Verdict>);
139
+
106
140
  /** Persisted records are validated on recovery: a bad file is skipped, not fatal. */
107
141
  export const SessionRecordSchema = v.object({
108
142
  schemaVersion: v.literal(SCHEMA_VERSION),
109
143
  id: NonEmpty,
110
144
  workspace: WorkspaceSchema,
111
145
  artifact: ArtifactSchema,
112
- revisions: v.array(v.object({ revision: v.number(), content: v.string(), submittedAt: v.string() })),
113
- annotations: v.array(v.object({ ...AnnotationSchema.entries, createdAt: v.string() })),
146
+ revisions: v.array(RevisionSchema),
147
+ annotations: v.array(v.object({ ...AnnotationSchema.entries, createdAt: v.string() } satisfies EntriesOf<Annotation>)),
114
148
  workingCopy: v.optional(v.string()),
115
- verdict: v.nullable(
116
- v.object({
117
- kind: v.picklist(["comment", "approve", "request_changes"]),
118
- summary: v.string(),
119
- feedback: v.string(),
120
- resolvedAt: v.string(),
121
- }),
122
- ),
149
+ viewedPaths: v.optional(v.array(v.string())),
150
+ verdict: v.nullable(VerdictSchema),
123
151
  status: v.picklist(["pending", "resolved"]),
124
152
  createdAt: v.string(),
125
- });
153
+ } satisfies EntriesOf<ReviewSession>);
126
154
 
127
155
  export function validateSessionRecord(raw: unknown): { ok: true; value: v.InferOutput<typeof SessionRecordSchema> } | { ok: false; error: string } {
128
156
  const result = v.safeParse(SessionRecordSchema, raw);
129
157
  if (result.success) return { ok: true, value: result.output };
130
158
  const issue = result.issues[0]!;
131
- const path = issue.path?.map((p) => String(p.key)).join(".") ?? "";
159
+ const path = issue.path?.map((pathSegment) => String(pathSegment.key)).join(".") ?? "";
132
160
  return { ok: false, error: `${path ? path + ": " : ""}${issue.message}` };
133
161
  }