@cueloop/daemon 0.1.0-alpha.62 → 0.1.0-alpha.64

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueloop/daemon",
3
- "version": "0.1.0-alpha.62",
3
+ "version": "0.1.0-alpha.64",
4
4
  "description": "The cueloop session daemon and its client: socket protocol, atomic store, resumable waits",
5
5
  "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
6
  "bugs": {
@@ -23,6 +23,7 @@
23
23
  "./review": "./src/review.ts",
24
24
  "./working-tree": "./src/working-tree.ts",
25
25
  "./store": "./src/store.ts",
26
+ "./validate": "./src/validate.ts",
26
27
  "./paths": "./src/paths.ts",
27
28
  "./retention": "./src/retention.ts",
28
29
  "./herdr-pane": "./src/herdr-pane.ts",
@@ -33,7 +34,7 @@
33
34
  "access": "public"
34
35
  },
35
36
  "dependencies": {
36
- "@cueloop/schema": "0.1.0-alpha.62",
37
+ "@cueloop/schema": "0.1.0-alpha.64",
37
38
  "valibot": "^1.4.2"
38
39
  },
39
40
  "engines": {
package/src/api.ts CHANGED
@@ -442,7 +442,7 @@ function isLiveDiffSession(session: ReviewSession): boolean {
442
442
  }
443
443
 
444
444
  /** Convenience for adapters: map a resolved session to the agent contract. */
445
- export function verdictResponse(session: ReviewSession): { allow: boolean; feedback: string } {
445
+ export function verdictResponse(session: ReviewSession) {
446
446
  if (!session.verdict) throw new DaemonError("pending", "session has no verdict");
447
447
 
448
448
  return { allow: verdictAllows(session.verdict.kind), feedback: session.verdict.feedback };
@@ -6,6 +6,8 @@
6
6
  * This is the single source of truth the server dispatch enforces.
7
7
  */
8
8
 
9
+ import * as v from "valibot";
10
+
9
11
  export type DaemonRole = "owner" | "collaborator" | "agent";
10
12
 
11
13
  /** Methods a non-owner role may call: read the session and annotate, plus connection plumbing. */
@@ -24,7 +26,8 @@ export function roleAllowsMethod(role: DaemonRole, method: string): boolean {
24
26
  return role === "owner" || NON_OWNER_METHODS.has(method);
25
27
  }
26
28
 
27
- /** Parse an untrusted role string, defaulting unknown values to the capped "agent". */
28
- export function asDaemonRole(value: unknown): DaemonRole {
29
- return value === "owner" || value === "collaborator" ? value : "agent";
29
+ export function asDaemonRole(value: Parameters<typeof v.safeParse>[1]): DaemonRole {
30
+ const result = v.safeParse(v.picklist(["owner", "collaborator"]), value);
31
+
32
+ return result.success ? result.output : "agent";
30
33
  }
package/src/client.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { existsSync, rmSync } from "node:fs";
8
+ import * as v from "valibot";
8
9
  import type {
9
10
  Annotation,
10
11
  Artifact,
@@ -13,10 +14,18 @@ import type {
13
14
  VerdictKind,
14
15
  WorkspaceKey,
15
16
  } from "@cueloop/schema";
16
- import { BackpressureWriter, LineBuffer, type EventFrame, type Response } from "./protocol";
17
+ import {
18
+ BackpressureWriter,
19
+ LineBuffer,
20
+ parseInboundFrame,
21
+ type EventFrame,
22
+ type Request,
23
+ type Response,
24
+ } from "./protocol";
17
25
  import type { DaemonRole } from "./capabilities";
18
26
  import type { HerdrTabHandle } from "./herdr-tab-store";
19
27
  import { cueloopHome, socketPath } from "./paths";
28
+ import { SessionRecordSchema } from "./validate";
20
29
 
21
30
  export type { EventFrame } from "./protocol";
22
31
 
@@ -28,7 +37,15 @@ export interface ConnectOptions {
28
37
  role?: DaemonRole;
29
38
  }
30
39
 
31
- type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void };
40
+ type PendingRequest = {
41
+ resolve: (value: Response["result"]) => void;
42
+ reject: (error: Error) => void;
43
+ };
44
+
45
+ const EmptyResultSchema = v.object({});
46
+ const PingResultSchema = v.object({ pid: v.number() });
47
+ const RefreshDiffResultSchema = v.object({ changed: v.boolean() });
48
+ const HerdrTabResultSchema = v.nullable(v.object({ tabId: v.string(), paneId: v.string() }));
32
49
 
33
50
  /**
34
51
  * The session verbs the review controller drives. DaemonClient is the local
@@ -128,9 +145,10 @@ export class DaemonClient implements SessionClient {
128
145
  this.writer = new BackpressureWriter(this.socket);
129
146
  // Verify liveness: a dead socket file accepts connects on some platforms
130
147
  // only to fail later, so a ping is the actual handshake.
131
- await this.request("daemon.ping", {}, 2_000);
148
+ await this.request("daemon.ping", {}, PingResultSchema, 2_000);
132
149
  // Cap this connection's role for the daemon's capability gate (owner is the default).
133
- if (this.role !== "owner") await this.request("daemon.hello", { role: this.role }, 2_000);
150
+ if (this.role !== "owner")
151
+ await this.request("daemon.hello", { role: this.role }, EmptyResultSchema, 2_000);
134
152
  }
135
153
 
136
154
  onEvent(listener: (event: EventFrame) => void): () => void {
@@ -140,10 +158,10 @@ export class DaemonClient implements SessionClient {
140
158
  }
141
159
 
142
160
  private routeInboundFrame(line: string): void {
143
- let frame: Response | EventFrame;
161
+ let frame;
144
162
 
145
163
  try {
146
- frame = JSON.parse(line) as Response | EventFrame;
164
+ frame = parseInboundFrame(line);
147
165
  } catch {
148
166
  return;
149
167
  }
@@ -161,11 +179,16 @@ export class DaemonClient implements SessionClient {
161
179
  else pendingRequest.resolve(frame.result);
162
180
  }
163
181
 
164
- request<T = unknown>(method: string, params: unknown, timeoutMs = 30_000): Promise<T> {
182
+ request<TOutput>(
183
+ method: string,
184
+ params: Request["params"],
185
+ resultSchema: v.GenericSchema<unknown, TOutput>,
186
+ timeoutMs = 30_000,
187
+ ): Promise<TOutput> {
165
188
  if (this.closed || !this.socket) return Promise.reject(new Error("not connected"));
166
189
  const id = this.nextId++;
167
190
 
168
- return new Promise<T>((resolve, reject) => {
191
+ return new Promise<TOutput>((resolve, reject) => {
169
192
  const timer = setTimeout(() => {
170
193
  this.pending.delete(id);
171
194
  reject(new Error(`request ${method} timed out`));
@@ -174,7 +197,7 @@ export class DaemonClient implements SessionClient {
174
197
  this.pending.set(id, {
175
198
  resolve: (value) => {
176
199
  clearTimeout(timer);
177
- resolve(value as T);
200
+ resolve(v.parse(resultSchema, value));
178
201
  },
179
202
  reject: (error) => {
180
203
  clearTimeout(timer);
@@ -192,79 +215,88 @@ export class DaemonClient implements SessionClient {
192
215
 
193
216
  // ── typed verbs ─────────────────────────────
194
217
  ping(): Promise<{ pid: number }> {
195
- return this.request("daemon.ping", {});
218
+ return this.request("daemon.ping", {}, PingResultSchema);
196
219
  }
197
220
  subscribe(): Promise<void> {
198
- return this.request("events.subscribe", {});
221
+ return this.request("events.subscribe", {}, EmptyResultSchema).then(() => undefined);
199
222
  }
200
223
  sessionCreate(workspace: WorkspaceKey, artifact: Artifact): Promise<ReviewSession> {
201
- return this.request("session.create", { workspace, artifact });
224
+ return this.request("session.create", { workspace, artifact }, SessionRecordSchema);
202
225
  }
203
226
  sessionGet(id: string): Promise<ReviewSession> {
204
- return this.request("session.get", { id });
227
+ return this.request("session.get", { id }, SessionRecordSchema);
205
228
  }
206
229
  sessionList(filter?: { status?: "pending" | "resolved" }): Promise<ReviewSession[]> {
207
- return this.request("session.list", { filter });
230
+ return this.request("session.list", { filter }, v.array(SessionRecordSchema));
208
231
  }
209
232
  /** Long-poll; null = still pending after timeoutMs (re-poll to collect). */
210
233
  sessionWait(id: string, timeoutMs: number): Promise<ReviewSession | null> {
211
- return this.request("session.wait", { id, timeoutMs }, timeoutMs + 10_000);
234
+ return this.request(
235
+ "session.wait",
236
+ { id, timeoutMs },
237
+ v.nullable(SessionRecordSchema),
238
+ timeoutMs + 10_000,
239
+ );
212
240
  }
213
241
  sessionAnnotate(
214
242
  id: string,
215
243
  annotation: Omit<Annotation, "createdAt">,
216
244
  authorName?: string,
217
245
  ): Promise<ReviewSession> {
218
- return this.request("session.annotate", { id, annotation, authorName });
246
+ return this.request("session.annotate", { id, annotation, authorName }, SessionRecordSchema);
219
247
  }
220
248
  sessionRemoveAnnotation(id: string, annotationId: string): Promise<ReviewSession> {
221
- return this.request("session.removeAnnotation", { id, annotationId });
249
+ return this.request("session.removeAnnotation", { id, annotationId }, SessionRecordSchema);
222
250
  }
223
251
  sessionSetWorkingCopy(id: string, workingCopy: string | undefined): Promise<ReviewSession> {
224
- return this.request("session.setWorkingCopy", { id, workingCopy });
252
+ return this.request("session.setWorkingCopy", { id, workingCopy }, SessionRecordSchema);
225
253
  }
226
254
  sessionSetViewed(id: string, viewedPaths: string[]): Promise<ReviewSession> {
227
- return this.request("session.setViewed", { id, viewedPaths });
255
+ return this.request("session.setViewed", { id, viewedPaths }, SessionRecordSchema);
228
256
  }
229
257
  /** Re-capture a diff session's working tree; changed=true when the patch moved and an event fired. */
230
258
  sessionRefreshDiff(id: string): Promise<{ changed: boolean }> {
231
- return this.request("session.refreshDiff", { id });
259
+ return this.request("session.refreshDiff", { id }, RefreshDiffResultSchema);
232
260
  }
233
261
  sessionSetShareId(id: string, shareId: string): Promise<ReviewSession> {
234
- return this.request("session.setShareId", { id, shareId });
262
+ return this.request("session.setShareId", { id, shareId }, SessionRecordSchema);
235
263
  }
236
264
  sessionMergeShared(
237
265
  id: string,
238
266
  incoming: { annotations: Annotation[]; participants?: Identity[] },
239
267
  ): Promise<ReviewSession> {
240
- return this.request("session.mergeShared", { id, ...incoming });
268
+ return this.request("session.mergeShared", { id, ...incoming }, SessionRecordSchema);
241
269
  }
242
270
  sessionDelete(id: string): Promise<void> {
243
- return this.request("session.delete", { id });
271
+ return this.request("session.delete", { id }, EmptyResultSchema).then(() => undefined);
244
272
  }
245
273
  /** Local sessions have no collaborator self-name; the share client owns this. */
246
274
  sessionSetSelfName(id: string, _name: string): Promise<ReviewSession> {
247
275
  return this.sessionGet(id);
248
276
  }
249
277
  sessionResolve(id: string, verdictKind: VerdictKind, summary: string): Promise<ReviewSession> {
250
- return this.request("session.resolve", { id, verdictKind, summary });
278
+ return this.request("session.resolve", { id, verdictKind, summary }, SessionRecordSchema);
251
279
  }
252
280
  sessionSubmitRevision(
253
281
  id: string,
254
282
  content: string,
255
283
  addressedAnnotationIds: string[] = [],
256
284
  ): Promise<ReviewSession> {
257
- return this.request("session.submitRevision", { id, content, addressedAnnotationIds });
285
+ return this.request(
286
+ "session.submitRevision",
287
+ { id, content, addressedAnnotationIds },
288
+ SessionRecordSchema,
289
+ );
258
290
  }
259
291
  /** herdr adapter scratch: the tab opened for a review; local-only, off the SessionClient contract. */
260
292
  herdrGetTab(id: string): Promise<HerdrTabHandle | null> {
261
- return this.request("herdr.getTab", { id });
293
+ return this.request("herdr.getTab", { id }, HerdrTabResultSchema);
262
294
  }
263
295
  async herdrSetTab(id: string, handle: HerdrTabHandle): Promise<void> {
264
- await this.request("herdr.setTab", { id, ...handle });
296
+ await this.request("herdr.setTab", { id, ...handle }, EmptyResultSchema);
265
297
  }
266
298
  shutdown(): Promise<void> {
267
- return this.request("daemon.shutdown", {});
299
+ return this.request("daemon.shutdown", {}, EmptyResultSchema).then(() => undefined);
268
300
  }
269
301
  }
270
302
 
@@ -64,10 +64,7 @@ export class DiffWatcher {
64
64
  }
65
65
  // a watcher error (the root is deleted mid-review) must not take down the
66
66
  // daemon; the inherited EventEmitter `on` is absent from Bun's FSWatcher type
67
- (handle as FSWatcher & { on(event: "error", listener: (error: Error) => void): void }).on(
68
- "error",
69
- () => {},
70
- );
67
+ handle.on("error", () => {});
71
68
  this.repoWatches.set(repoRoot, { handle, sessionIds: new Set([sessionId]), debounce: null });
72
69
  }
73
70
 
@@ -16,7 +16,7 @@ const dir = mkdtempSync(join(tmpdir(), "cueloop-herdr-pane-"));
16
16
  * pane_id and tab_id (herdr 0.8.2 shape), and answers `pane get` alive or dead
17
17
  * per `paneAlive` so the liveness branch can be exercised.
18
18
  */
19
- function makeStub(name: string, paneAlive = false): { binPath: string; logPath: string } {
19
+ function makeStub(name: string, paneAlive = false) {
20
20
  const logPath = join(dir, `${name}.log`);
21
21
  const binPath = join(dir, `${name}.sh`);
22
22
  const paneGet = paneAlive ? `printf '{"result":{"pane":{"pane_id":"w1:p2"}}}'` : "exit 1";
@@ -44,16 +44,13 @@ function readLines(logPath: string): string[] {
44
44
  }
45
45
 
46
46
  /** In-memory persistence double: records what openHerdrPaneForReview saves. */
47
- function fakePersistence(initial: HerdrTabHandle | null = null): {
48
- persistence: HerdrTabPersistence;
49
- saved: () => HerdrTabHandle | null;
50
- } {
47
+ function fakePersistence(initial: HerdrTabHandle | null = null) {
51
48
  let stored = initial;
52
49
 
53
50
  return {
54
51
  persistence: {
55
52
  herdrGetTab: async () => stored,
56
- herdrSetTab: async (_sessionId, handle) => {
53
+ herdrSetTab: async (_sessionId: string, handle: HerdrTabHandle) => {
57
54
  stored = handle;
58
55
  },
59
56
  },
package/src/herdr-pane.ts CHANGED
@@ -7,9 +7,22 @@
7
7
  */
8
8
 
9
9
  import { detectHerdr, type HerdrEnv, type ReviewSession } from "@cueloop/schema";
10
+ import * as v from "valibot";
10
11
  import type { HerdrTabHandle } from "./herdr-tab-store";
11
12
 
12
13
  const HERDR_SPAWN_TIMEOUT_MS = 2000;
14
+ const CreatedTabSchema = v.object({
15
+ result: v.optional(
16
+ v.object({
17
+ root_pane: v.optional(
18
+ v.object({ pane_id: v.optional(v.string()), tab_id: v.optional(v.string()) }),
19
+ ),
20
+ }),
21
+ ),
22
+ });
23
+ const PaneResultSchema = v.object({
24
+ result: v.optional(v.object({ pane: v.optional(v.unknown()) })),
25
+ });
13
26
 
14
27
  export interface OpenHerdrPaneOptions {
15
28
  sessionId: string;
@@ -40,11 +53,11 @@ export function openHerdrPane(options: OpenHerdrPaneOptions): HerdrTabHandle | n
40
53
  );
41
54
 
42
55
  if (created.exitCode !== 0) return null;
43
- const parsed = JSON.parse(created.stdout.toString()) as {
44
- result?: { root_pane?: { pane_id?: string; tab_id?: string } };
45
- };
46
- const paneId = parsed.result?.root_pane?.pane_id;
47
- const tabId = parsed.result?.root_pane?.tab_id;
56
+ const parsed = v.safeParse(CreatedTabSchema, JSON.parse(created.stdout.toString()));
57
+
58
+ if (!parsed.success) return null;
59
+ const paneId = parsed.output.result?.root_pane?.pane_id;
60
+ const tabId = parsed.output.result?.root_pane?.tab_id;
48
61
 
49
62
  if (!paneId || !tabId) return null;
50
63
  // A fresh tab hosts a plain shell, so the review is typed in like a human.
@@ -77,9 +90,9 @@ function herdrPaneAlive(binPath: string, paneId: string): boolean {
77
90
  });
78
91
 
79
92
  if (got.exitCode !== 0) return false;
80
- const parsed = JSON.parse(got.stdout.toString()) as { result?: { pane?: unknown } };
93
+ const parsed = v.safeParse(PaneResultSchema, JSON.parse(got.stdout.toString()));
81
94
 
82
- return parsed.result?.pane != null;
95
+ return parsed.success && parsed.output.result?.pane != null;
83
96
  } catch {
84
97
  return false;
85
98
  }
@@ -9,7 +9,7 @@ import { launchHarnessInSplit } from "./herdr-split";
9
9
  const dir = mkdtempSync(join(tmpdir(), "cueloop-herdr-split-"));
10
10
 
11
11
  /** A stub herdr binary that logs argv and prints the pane-split result (pane_id of the new split). */
12
- function makeStub(name: string, splitOk = true): { binPath: string; logPath: string } {
12
+ function makeStub(name: string, splitOk = true) {
13
13
  const logPath = join(dir, `${name}.log`);
14
14
  const binPath = join(dir, `${name}.sh`);
15
15
  const splitResult = splitOk
@@ -7,8 +7,12 @@
7
7
  */
8
8
 
9
9
  import { detectHerdr, type HerdrEnv } from "@cueloop/schema";
10
+ import * as v from "valibot";
10
11
 
11
12
  const HERDR_SPAWN_TIMEOUT_MS = 2000;
13
+ const SplitPaneSchema = v.object({
14
+ result: v.optional(v.object({ pane: v.optional(v.object({ pane_id: v.optional(v.string()) })) })),
15
+ });
12
16
 
13
17
  /** How much of the current pane the launched harness split takes (herdr --ratio). */
14
18
  const HARNESS_SPLIT_RATIO = "0.4";
@@ -55,10 +59,10 @@ export function launchHarnessInSplit(
55
59
  );
56
60
 
57
61
  if (split.exitCode !== 0) return false;
58
- const parsed = JSON.parse(split.stdout.toString()) as {
59
- result?: { pane?: { pane_id?: string } };
60
- };
61
- const paneId = parsed.result?.pane?.pane_id;
62
+ const parsed = v.safeParse(SplitPaneSchema, JSON.parse(split.stdout.toString()));
63
+
64
+ if (!parsed.success) return false;
65
+ const paneId = parsed.output.result?.pane?.pane_id;
62
66
 
63
67
  if (!paneId) return false;
64
68
  sendText(herdr.binPath, paneId, options.command);
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { readFileSync, renameSync, writeFileSync } from "node:fs";
9
+ import * as v from "valibot";
9
10
  import { herdrTabsPath } from "./paths";
10
11
 
11
12
  /** The tab and its root pane: pane id checks liveness, tab id focuses it. */
@@ -14,15 +15,11 @@ export interface HerdrTabHandle {
14
15
  paneId: string;
15
16
  }
16
17
 
17
- /** Guard a persisted entry - both ids must be present strings, or it is dropped. */
18
- function isHerdrTabHandle(value: unknown): value is HerdrTabHandle {
19
- return (
20
- typeof value === "object" &&
21
- value !== null &&
22
- typeof (value as HerdrTabHandle).tabId === "string" &&
23
- typeof (value as HerdrTabHandle).paneId === "string"
24
- );
25
- }
18
+ const HerdrTabHandleSchema = v.object({
19
+ tabId: v.string(),
20
+ paneId: v.string(),
21
+ });
22
+ const HerdrTabsSchema = v.record(v.string(), v.unknown());
26
23
 
27
24
  export class HerdrTabStore {
28
25
  private tabs = new Map<string, HerdrTabHandle>();
@@ -35,12 +32,12 @@ export class HerdrTabStore {
35
32
 
36
33
  private load(): void {
37
34
  try {
38
- const parsed: unknown = JSON.parse(readFileSync(this.path, "utf8"));
35
+ const parsed = v.parse(HerdrTabsSchema, JSON.parse(readFileSync(this.path, "utf8")));
39
36
 
40
- if (typeof parsed !== "object" || parsed === null) return;
41
37
  for (const [sessionId, value] of Object.entries(parsed)) {
42
- if (isHerdrTabHandle(value))
43
- this.tabs.set(sessionId, { tabId: value.tabId, paneId: value.paneId });
38
+ const handle = v.safeParse(HerdrTabHandleSchema, value);
39
+
40
+ if (handle.success) this.tabs.set(sessionId, handle.output);
44
41
  }
45
42
  } catch {
46
43
  // absent, unreadable, or malformed: start empty - a stale tab just reopens on resubmit
package/src/protocol.ts CHANGED
@@ -4,6 +4,8 @@
4
4
  * Events (push, after events.subscribe): { event, sessionId }
5
5
  */
6
6
 
7
+ import * as v from "valibot";
8
+
7
9
  export interface Request {
8
10
  id: number;
9
11
  method: string;
@@ -23,6 +25,29 @@ export interface EventFrame {
23
25
 
24
26
  export type Frame = Response | EventFrame;
25
27
 
28
+ const RequestSchema = v.object({
29
+ id: v.number(),
30
+ method: v.string(),
31
+ params: v.optional(v.unknown()),
32
+ });
33
+ const ResponseSchema = v.object({
34
+ id: v.number(),
35
+ result: v.optional(v.unknown()),
36
+ error: v.optional(v.object({ code: v.string(), message: v.string() })),
37
+ });
38
+ const EventFrameSchema = v.object({
39
+ event: v.string(),
40
+ sessionId: v.string(),
41
+ });
42
+
43
+ export function parseRequestFrame(line: string): Request {
44
+ return v.parse(RequestSchema, JSON.parse(line));
45
+ }
46
+
47
+ export function parseInboundFrame(line: string): Frame {
48
+ return v.parse(v.union([ResponseSchema, EventFrameSchema]), JSON.parse(line));
49
+ }
50
+
26
51
  /** Incremental NDJSON splitter; tolerates partial writes. */
27
52
  export class LineBuffer {
28
53
  private buffered = "";
@@ -48,7 +48,7 @@ describe("resolveCleanupPeriodDays", () => {
48
48
  const missing = join(tempDir("cueloop-retention-"), "config.toml");
49
49
 
50
50
  // Act
51
- const days = resolveCleanupPeriodDays({ CUELOOP_CONFIG: missing } as NodeJS.ProcessEnv);
51
+ const days = resolveCleanupPeriodDays({ CUELOOP_CONFIG: missing });
52
52
 
53
53
  // Assert
54
54
  expect(days).toBe(DEFAULT_CLEANUP_PERIOD_DAYS);
@@ -61,7 +61,7 @@ describe("resolveCleanupPeriodDays", () => {
61
61
  writeFileSync(path, "[cleanup]\nperiod_days = 7\n");
62
62
 
63
63
  // Act
64
- const days = resolveCleanupPeriodDays({ CUELOOP_CONFIG: path } as NodeJS.ProcessEnv);
64
+ const days = resolveCleanupPeriodDays({ CUELOOP_CONFIG: path });
65
65
 
66
66
  // Assert
67
67
  expect(days).toBe(7);
@@ -74,7 +74,7 @@ describe("resolveCleanupPeriodDays", () => {
74
74
  writeFileSync(path, '[cleanup]\nperiod_days = "soon"\n');
75
75
 
76
76
  // Act
77
- const days = resolveCleanupPeriodDays({ CUELOOP_CONFIG: path } as NodeJS.ProcessEnv);
77
+ const days = resolveCleanupPeriodDays({ CUELOOP_CONFIG: path });
78
78
 
79
79
  // Assert
80
80
  expect(days).toBe(DEFAULT_CLEANUP_PERIOD_DAYS);
package/src/retention.ts CHANGED
@@ -20,7 +20,7 @@ const RefineStateSchema = v.object({
20
20
 
21
21
  export type RefineState = v.InferOutput<typeof RefineStateSchema>;
22
22
 
23
- export function parseRefineState(raw: unknown): Map<string, string> {
23
+ export function parseRefineState(raw: Parameters<typeof v.safeParse>[1]): Map<string, string> {
24
24
  const parsed = v.safeParse(RefineStateSchema, raw);
25
25
 
26
26
  if (!parsed.success) return new Map();
@@ -159,6 +159,30 @@ describe("openReview", () => {
159
159
  expect(other.id).not.toBe(first.id);
160
160
  });
161
161
 
162
+ test("the same agentSessionId with a different type opens a new session, never a type mismatch", async () => {
163
+ // Arrange - the agent session's plan review is open
164
+ const plan = await openReview(client, {
165
+ type: "plan",
166
+ content: PLAN,
167
+ cwd: home,
168
+ agentSessionId: "agent-cross",
169
+ });
170
+
171
+ // Act - the same agent session now submits a reply for review
172
+ const reply = await openReview(client, {
173
+ type: "reply",
174
+ content: "# Findings\n\nThe cache is fine.\n",
175
+ cwd: home,
176
+ agentSessionId: "agent-cross",
177
+ });
178
+
179
+ // Assert - a fresh reply session; the plan session keeps its type and history
180
+ expect(reply.id).not.toBe(plan.id);
181
+ expect(reply.session.artifact.type).toBe("reply");
182
+ expect(reply.session.revisions.length).toBe(1);
183
+ expect((await client.sessionGet(plan.id)).artifact.type).toBe("plan");
184
+ });
185
+
162
186
  test("per-file notes land as note annotations anchored at the file path", async () => {
163
187
  // Act
164
188
  const review = await openReview(client, {
package/src/review.ts CHANGED
@@ -245,10 +245,14 @@ export async function openReview(
245
245
  const cwd = options.cwd ?? process.cwd();
246
246
  const workspace = options.workspace ?? (await resolveWorkspace(cwd));
247
247
 
248
- // Resubmits from the same agent session become revisions, not new sessions.
248
+ // Resubmits from the same agent session become revisions, not new sessions -
249
+ // but only within the same primitive: a different artifact type is a new
250
+ // review, never a silent type mismatch on the old session.
249
251
  if (options.agentSessionId !== undefined) {
250
252
  const existing = (await client.sessionList()).find(
251
- (candidate) => candidate.artifact.meta.agentSessionId === options.agentSessionId,
253
+ (candidate) =>
254
+ candidate.artifact.meta.agentSessionId === options.agentSessionId &&
255
+ candidate.artifact.type === options.type,
252
256
  );
253
257
 
254
258
  if (existing !== undefined) {
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { mkdtempSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import * as v from "valibot";
5
6
  import { DaemonServer } from "./server";
6
7
  import { DaemonClient, DaemonClientError } from "./client";
7
8
  import type { Artifact, WorkspaceKey } from "@cueloop/schema";
@@ -54,12 +55,8 @@ describe("socket round-trip", () => {
54
55
  });
55
56
 
56
57
  test("errors carry codes across the wire", async () => {
57
- expect(client.sessionGet("nope")).rejects.toBeInstanceOf(DaemonClientError);
58
- try {
59
- await client.sessionGet("nope");
60
- } catch (error) {
61
- expect((error as DaemonClientError).code).toBe("not_found");
62
- }
58
+ await expect(client.sessionGet("nope")).rejects.toBeInstanceOf(DaemonClientError);
59
+ await expect(client.sessionGet("nope")).rejects.toHaveProperty("code", "not_found");
63
60
  });
64
61
 
65
62
  test("events push to subscribed connections only", async () => {
@@ -85,38 +82,27 @@ describe("socket round-trip", () => {
85
82
 
86
83
  test("malformed requests get structured errors and never wedge the daemon", async () => {
87
84
  // missing required params
88
- try {
89
- await client.request("session.create", { artifact: { type: "plan", content: "x" } });
90
- throw new Error("should have thrown");
91
- } catch (error) {
92
- expect((error as DaemonClientError).code).toBe("invalid_params");
93
- }
85
+ await expect(
86
+ client.request("session.create", { artifact: { type: "plan", content: "x" } }, v.object({})),
87
+ ).rejects.toHaveProperty("code", "invalid_params");
94
88
  // wrong types
95
- try {
96
- await client.request("session.wait", { id: 42 });
97
- throw new Error("should have thrown");
98
- } catch (error) {
99
- expect((error as DaemonClientError).code).toBe("invalid_params");
100
- }
89
+ await expect(client.request("session.wait", { id: 42 }, v.object({}))).rejects.toHaveProperty(
90
+ "code",
91
+ "invalid_params",
92
+ );
101
93
  // unknown method
102
- try {
103
- await client.request("session.nuke", {});
104
- throw new Error("should have thrown");
105
- } catch (error) {
106
- expect((error as DaemonClientError).code).toBe("unknown_method");
107
- }
108
- const prototypeMethods = await Promise.allSettled([
109
- client.request("__proto__", {}),
110
- client.request("toString", {}),
111
- client.request("constructor", {}),
112
- ]);
113
-
114
- for (const result of prototypeMethods) {
115
- expect(result.status).toBe("rejected");
116
- expect(((result as PromiseRejectedResult).reason as DaemonClientError).code).toBe(
117
- "unknown_method",
118
- );
119
- }
94
+ await expect(client.request("session.nuke", {}, v.object({}))).rejects.toHaveProperty(
95
+ "code",
96
+ "unknown_method",
97
+ );
98
+ await Promise.all(
99
+ ["__proto__", "toString", "constructor"].map((method) =>
100
+ expect(client.request(method, {}, v.object({}))).rejects.toHaveProperty(
101
+ "code",
102
+ "unknown_method",
103
+ ),
104
+ ),
105
+ );
120
106
  // the daemon is still fully alive afterwards
121
107
  const session = await client.sessionCreate(WS, PLAN);
122
108
 
@@ -181,15 +167,11 @@ describe("socket round-trip", () => {
181
167
  // Given a session
182
168
  const session = await client.sessionCreate(WS, PLAN);
183
169
 
184
- try {
185
- // When a malformed annotation is merged
186
- // @ts-expect-error intentionally malformed annotation (empty id, missing fields)
187
- await client.sessionMergeShared(session.id, { annotations: [{ id: "" }] });
188
- throw new Error("should have thrown");
189
- } catch (error) {
190
- // Then it is rejected at the wire boundary
191
- expect((error as DaemonClientError).code).toBe("invalid_params");
192
- }
170
+ // When a malformed annotation is merged, it is rejected at the wire boundary
171
+ // @ts-expect-error intentionally malformed annotation (empty id, missing fields)
172
+ const merge = client.sessionMergeShared(session.id, { annotations: [{ id: "" }] });
173
+
174
+ await expect(merge).rejects.toHaveProperty("code", "invalid_params");
193
175
  });
194
176
 
195
177
  test("session.delete removes a session over the wire", async () => {
@@ -200,13 +182,8 @@ describe("socket round-trip", () => {
200
182
  await client.sessionDelete(session.id);
201
183
 
202
184
  // Then it is gone, and deleting an unknown id is a not_found error
203
- expect(client.sessionGet(session.id)).rejects.toBeInstanceOf(DaemonClientError);
204
- try {
205
- await client.sessionDelete("ses_missing");
206
- throw new Error("should have thrown");
207
- } catch (error) {
208
- expect((error as DaemonClientError).code).toBe("not_found");
209
- }
185
+ await expect(client.sessionGet(session.id)).rejects.toBeInstanceOf(DaemonClientError);
186
+ await expect(client.sessionDelete("ses_missing")).rejects.toHaveProperty("code", "not_found");
210
187
  });
211
188
 
212
189
  test("two clients see the same state (thin-renderer model)", async () => {
package/src/server.ts CHANGED
@@ -18,7 +18,13 @@ import { DaemonCore, type DaemonEvent } from "./api";
18
18
  import { DaemonError } from "./errors";
19
19
  import { roleAllowsMethod, type DaemonRole } from "./capabilities";
20
20
  import { isKnownMethod, parseParams, type MethodName } from "./validate";
21
- import { BackpressureWriter, LineBuffer, type Request } from "./protocol";
21
+ import {
22
+ BackpressureWriter,
23
+ LineBuffer,
24
+ parseRequestFrame,
25
+ type Request,
26
+ type Response,
27
+ } from "./protocol";
22
28
  import { cueloopHome, lockPath, pidPath, socketPath } from "./paths";
23
29
 
24
30
  interface Connection {
@@ -28,7 +34,7 @@ interface Connection {
28
34
  role: DaemonRole;
29
35
  }
30
36
 
31
- type MethodHandler = (connection: Connection, request: Request) => unknown;
37
+ type MethodHandler = (connection: Connection, request: Request) => Response["result"];
32
38
 
33
39
  export interface DaemonOptions {
34
40
  home?: string;
@@ -217,7 +223,7 @@ export class DaemonServer {
217
223
  let request: Request;
218
224
 
219
225
  try {
220
- request = JSON.parse(line) as Request;
226
+ request = parseRequestFrame(line);
221
227
  } catch {
222
228
  connection.write(
223
229
  JSON.stringify({ id: -1, error: { code: "bad_json", message: "unparseable request" } }) +
@@ -340,10 +346,10 @@ export class DaemonServer {
340
346
  },
341
347
  };
342
348
 
343
- private async dispatch(connection: Connection, request: Request): Promise<unknown> {
349
+ private async dispatch(connection: Connection, request: Request): Promise<Response["result"]> {
344
350
  // The wire is untrusted JSON: validate before DaemonCore sees anything.
345
- if (typeof request.method !== "string" || !isKnownMethod(request.method)) {
346
- throw new DaemonError("unknown_method", `unknown method ${String(request.method)}`);
351
+ if (!isKnownMethod(request.method)) {
352
+ throw new DaemonError("unknown_method", `unknown method ${request.method}`);
347
353
  }
348
354
  // Capability gate: a capped role (a review-side agent) cannot escalate past
349
355
  // read + annotate, whatever verb it sends.
package/src/share-blob.ts CHANGED
@@ -51,5 +51,5 @@ export function unpackSessionBlob(bytes: Uint8Array): ReviewSession {
51
51
 
52
52
  if (!parsed.ok) throw new Error(`blob is not a valid session: ${parsed.error}`);
53
53
 
54
- return parsed.value as ReviewSession;
54
+ return parsed.value;
55
55
  }
package/src/store.ts CHANGED
@@ -36,7 +36,7 @@ export class SessionStore {
36
36
  const parsed = validateSessionRecord(JSON.parse(raw));
37
37
 
38
38
  if (!parsed.ok) throw new Error(`invalid record - ${parsed.error}`);
39
- const session = parsed.value as ReviewSession;
39
+ const session: ReviewSession = parsed.value;
40
40
 
41
41
  this.sessions.set(session.id, session);
42
42
  report.recovered.push(session.id);
@@ -19,6 +19,7 @@ import {
19
19
  validateSessionRecord,
20
20
  } from "./validate";
21
21
  import {
22
+ ARTIFACT_TYPES,
22
23
  SCHEMA_VERSION,
23
24
  type Anchor,
24
25
  type Annotation,
@@ -56,18 +57,22 @@ describe("parseParams", () => {
56
57
  throw new Error("should have thrown");
57
58
  } catch (err) {
58
59
  expect(err).toBeInstanceOf(DaemonError);
59
- expect((err as DaemonError).code).toBe("invalid_params");
60
- expect((err as DaemonError).message).toContain("session.create: workspace");
60
+ if (err instanceof DaemonError) {
61
+ expect(err.code).toBe("invalid_params");
62
+ expect(err.message).toContain("session.create: workspace");
63
+ }
61
64
  }
62
65
  });
63
66
 
64
- test("accepts the reply artifact type", () => {
65
- const params = parseParams("session.create", {
66
- workspace: { repoRoot: "/repo", branch: "main" },
67
- artifact: { type: "reply", content: "# R" },
68
- });
67
+ test("accepts every artifact type in the schema union", () => {
68
+ for (const type of ARTIFACT_TYPES) {
69
+ const params = parseParams("session.create", {
70
+ workspace: { repoRoot: "/repo", branch: "main" },
71
+ artifact: { type, content: "body" },
72
+ });
69
73
 
70
- expect(params.artifact.type).toBe("reply");
74
+ expect(params.artifact.type).toBe(type);
75
+ }
71
76
  });
72
77
 
73
78
  test("rejects an unknown artifact type", () => {
@@ -169,7 +174,7 @@ describe("validateSessionRecord", () => {
169
174
  * @cueloop/schema shows up here too.
170
175
  */
171
176
  describe("wire pins", () => {
172
- const keys = (subject: object) => Object.keys(subject).sort();
177
+ const keys = <Subject extends object>(subject: Subject) => Object.keys(subject).sort();
173
178
  const entryKeys = (schema: { entries: object }) => Object.keys(schema.entries).sort();
174
179
 
175
180
  const fullMeta: Required<ArtifactMeta> = {
package/src/validate.ts CHANGED
@@ -11,6 +11,7 @@
11
11
 
12
12
  import * as v from "valibot";
13
13
  import {
14
+ ARTIFACT_TYPES,
14
15
  SCHEMA_VERSION,
15
16
  type Anchor,
16
17
  type Annotation,
@@ -24,6 +25,7 @@ import {
24
25
  type WorkspaceKey,
25
26
  } from "@cueloop/schema";
26
27
  import { DaemonError } from "./errors";
28
+ import type { Request } from "./protocol";
27
29
 
28
30
  /**
29
31
  * Drift guard for every hand-mirrored shape below. v.object strips keys it
@@ -60,7 +62,9 @@ export const DiffFileContentsSchema = v.object({
60
62
  } satisfies EntriesOf<DiffFileContents>);
61
63
 
62
64
  export const ArtifactSchema = v.object({
63
- type: v.picklist(["plan", "diff", "prototype", "reply"]),
65
+ // Derived from the schema's runtime union: a new primitive extends the wire
66
+ // contract without touching this file.
67
+ type: v.picklist(ARTIFACT_TYPES),
64
68
  content: v.string(),
65
69
  meta: v.optional(ArtifactMetaSchema, {}),
66
70
  files: v.optional(v.array(DiffFileContentsSchema)),
@@ -172,7 +176,7 @@ export function isKnownMethod(method: string): method is MethodName {
172
176
  /** Validate params for a method, or throw a DaemonError the client can read. */
173
177
  export function parseParams<M extends MethodName>(
174
178
  method: M,
175
- params: unknown,
179
+ params: Request["params"],
176
180
  ): v.InferOutput<(typeof Params)[M]> {
177
181
  const result = v.safeParse(Params[method], params ?? {});
178
182
 
@@ -221,7 +225,7 @@ export const SessionRecordSchema = v.object({
221
225
  } satisfies EntriesOf<ReviewSession>);
222
226
 
223
227
  export function validateSessionRecord(
224
- raw: unknown,
228
+ raw: Parameters<typeof v.safeParse>[1],
225
229
  ): { ok: true; value: v.InferOutput<typeof SessionRecordSchema> } | { ok: false; error: string } {
226
230
  const result = v.safeParse(SessionRecordSchema, raw);
227
231