@cueloop/daemon 0.1.0-alpha.21 → 0.1.0-alpha.23

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,16 +1,17 @@
1
1
  {
2
2
  "name": "@cueloop/daemon",
3
- "version": "0.1.0-alpha.21",
3
+ "version": "0.1.0-alpha.23",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": "./src/index.ts",
8
8
  "./client": "./src/client.ts",
9
9
  "./review": "./src/review.ts",
10
- "./herdr-pane": "./src/herdr-pane.ts"
10
+ "./herdr-pane": "./src/herdr-pane.ts",
11
+ "./share-blob": "./src/share-blob.ts"
11
12
  },
12
13
  "dependencies": {
13
- "@cueloop/schema": "0.1.0-alpha.21",
14
+ "@cueloop/schema": "0.1.0-alpha.23",
14
15
  "valibot": "^1.4.2"
15
16
  },
16
17
  "publishConfig": {
package/src/api.test.ts CHANGED
@@ -24,7 +24,10 @@ afterEach(() => rmSync(home, { recursive: true, force: true }));
24
24
 
25
25
  describe("session lifecycle", () => {
26
26
  test("create → get → list", () => {
27
+ // Act
27
28
  const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
29
+
30
+ // Assert
28
31
  expect(session.status).toBe("pending");
29
32
  expect(session.revisions.length).toBe(1);
30
33
  expect(core.sessionGet(session.id).id).toBe(session.id);
@@ -32,7 +35,10 @@ describe("session lifecycle", () => {
32
35
  });
33
36
 
34
37
  test("annotate upserts by id; remove deletes", () => {
38
+ // Arrange
35
39
  const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
40
+
41
+ // Act
36
42
  core.sessionAnnotate(session.id, {
37
43
  id: "a1",
38
44
  kind: "comment",
@@ -45,13 +51,20 @@ describe("session lifecycle", () => {
45
51
  anchor: { quote: "carefully", prefix: "the thing ", suffix: "." },
46
52
  body: "Define carefully.",
47
53
  });
54
+
55
+ // Assert
48
56
  expect(core.sessionGet(session.id).annotations.length).toBe(1);
49
57
  expect(core.sessionGet(session.id).annotations[0]!.body).toBe("Define carefully.");
58
+
59
+ // Act
50
60
  core.sessionRemoveAnnotation(session.id, "a1");
61
+
62
+ // Assert
51
63
  expect(core.sessionGet(session.id).annotations.length).toBe(0);
52
64
  });
53
65
 
54
66
  test("resolve produces feedback.md and maps to the agent contract", () => {
67
+ // Arrange
55
68
  const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
56
69
  core.sessionAnnotate(session.id, {
57
70
  id: "a1",
@@ -59,50 +72,90 @@ describe("session lifecycle", () => {
59
72
  anchor: { quote: "carefully", prefix: "the thing ", suffix: "." },
60
73
  body: "Spell out the steps.",
61
74
  });
75
+
76
+ // Act
62
77
  const resolved = core.sessionResolve(session.id, "request_changes", "Needs detail.");
78
+
79
+ // Assert
63
80
  expect(resolved.verdict!.feedback).toContain("# Review: request changes");
64
81
  expect(resolved.verdict!.feedback).toContain("> carefully");
65
82
  expect(verdictResponse(resolved)).toEqual({ allow: false, feedback: resolved.verdict!.feedback });
83
+
84
+ // Arrange
66
85
  const approved = core.sessionCreate({ workspace: WS, artifact: PLAN });
86
+
87
+ // Act
67
88
  core.sessionResolve(approved.id, "approve", "LGTM");
89
+
90
+ // Assert
68
91
  expect(verdictResponse(core.sessionGet(approved.id)).allow).toBe(true);
69
92
  });
70
93
 
71
94
  test("mutating a resolved session throws", () => {
95
+ // Arrange
72
96
  const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
73
97
  core.sessionResolve(session.id, "approve", "");
98
+
99
+ // Assert
74
100
  expect(() =>
75
101
  core.sessionAnnotate(session.id, { id: "a1", kind: "comment", anchor: { quote: "x", prefix: "", suffix: "" }, body: "b" }),
76
102
  ).toThrow("already resolved");
77
103
  });
78
104
 
79
105
  test("working copy stores edits and clears on revert or no-op", () => {
106
+ // Arrange
80
107
  const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
108
+
109
+ // Act
81
110
  core.sessionSetWorkingCopy(session.id, PLAN.content.replace("carefully", "very carefully"));
111
+
112
+ // Assert
82
113
  expect(core.sessionGet(session.id).workingCopy).toContain("very carefully");
114
+
115
+ // Act
83
116
  core.sessionSetWorkingCopy(session.id, PLAN.content);
117
+
118
+ // Assert
84
119
  expect(core.sessionGet(session.id).workingCopy).toBeUndefined();
85
120
  });
86
121
 
87
122
  test("viewed paths merge, dedupe, clear on empty, and survive a daemon restart", () => {
123
+ // Arrange
88
124
  const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
125
+
126
+ // Act
89
127
  core.sessionSetViewed(session.id, ["src/a.ts", "src/b.ts", "src/a.ts"]);
128
+
129
+ // Assert
90
130
  expect(core.sessionGet(session.id).viewedPaths).toEqual(["src/a.ts", "src/b.ts"]);
131
+
132
+ // Act
91
133
  // merge-additive: a stale client sending only its own new mark loses nothing
92
134
  core.sessionSetViewed(session.id, ["src/c.ts"]);
135
+
136
+ // Assert
93
137
  expect(core.sessionGet(session.id).viewedPaths).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]);
94
138
  // a resumed review reads its progress back after a daemon restart
95
139
  const reborn = new DaemonCore(home);
96
140
  expect(reborn.sessionGet(session.id).viewedPaths).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]);
141
+
142
+ // Act
97
143
  core.sessionSetViewed(session.id, []);
144
+
145
+ // Assert
98
146
  expect(core.sessionGet(session.id).viewedPaths).toBeUndefined();
99
147
  });
100
148
 
101
149
  test("revision reopens the session and resets working copy + verdict", () => {
150
+ // Arrange
102
151
  const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
103
152
  core.sessionSetWorkingCopy(session.id, PLAN.content + "\nedit");
104
153
  core.sessionResolve(session.id, "request_changes", "redo");
154
+
155
+ // Act
105
156
  const revised = core.sessionSubmitRevision(session.id, "# Plan v2\n");
157
+
158
+ // Assert
106
159
  expect(revised.status).toBe("pending");
107
160
  expect(revised.verdict).toBeNull();
108
161
  expect(revised.workingCopy).toBeUndefined();
@@ -111,47 +164,154 @@ describe("session lifecycle", () => {
111
164
  });
112
165
  });
113
166
 
167
+ describe("revision marks addressed annotations", () => {
168
+ const annotate = (sessionId: string, id: string, quote: string, body = "note") =>
169
+ core.sessionAnnotate(sessionId, { id, kind: "comment", anchor: { quote, prefix: "", suffix: "" }, body });
170
+
171
+ test("ids the agent reports are marked addressed by that revision", () => {
172
+ // Arrange
173
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
174
+ annotate(session.id, "a1", "carefully");
175
+ annotate(session.id, "a2", "Context");
176
+
177
+ // Act: the revision keeps both quoted texts, so only the reported id resolves
178
+ const revised = core.sessionSubmitRevision(session.id, PLAN.content + "\nMore.\n", ["a1"]);
179
+
180
+ // Assert
181
+ const [first, second] = revised.annotations;
182
+ expect(first!.resolution).toEqual({ revision: 2, source: "agent" });
183
+ expect(second!.resolution).toBeUndefined();
184
+ });
185
+
186
+ test("a plan annotation whose quoted text vanished is drift-addressed; a surviving quote stays open", () => {
187
+ // Arrange
188
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
189
+ annotate(session.id, "gone", "carefully");
190
+ annotate(session.id, "kept", "Context");
191
+
192
+ // Act: the revision rewrites the "carefully" sentence but keeps the Context heading
193
+ const revised = core.sessionSubmitRevision(session.id, "# Plan\n\n## Context\n\nDo the thing with tests.\n");
194
+
195
+ // Assert
196
+ expect(revised.annotations.find((a) => a.id === "gone")!.resolution).toEqual({ revision: 2, source: "drift" });
197
+ expect(revised.annotations.find((a) => a.id === "kept")!.resolution).toBeUndefined();
198
+ });
199
+
200
+ test("an unknown reported id is ignored, and an already-addressed annotation keeps its first resolution", () => {
201
+ // Arrange
202
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
203
+ annotate(session.id, "a1", "carefully");
204
+ core.sessionSubmitRevision(session.id, PLAN.content, ["a1"]);
205
+
206
+ // Act: a second revision reports a stale id and re-reports the settled one
207
+ const revised = core.sessionSubmitRevision(session.id, PLAN.content, ["a1", "a_never_existed"]);
208
+
209
+ // Assert
210
+ expect(revised.annotations[0]!.resolution).toEqual({ revision: 2, source: "agent" });
211
+ expect(revised.revisions.length).toBe(3);
212
+ });
213
+
214
+ test("a diff revision never drift-addresses - a new patch says nothing about the feedback", () => {
215
+ // Arrange
216
+ const diffArtifact: Artifact = { type: "diff", content: "+++ b/a.ts\n+new line\n", meta: {} };
217
+ const session = core.sessionCreate({ workspace: WS, artifact: diffArtifact });
218
+ annotate(session.id, "d1", "new line");
219
+
220
+ // Act: the revised patch no longer contains the quoted text
221
+ const revised = core.sessionSubmitRevision(session.id, "+++ b/b.ts\n+other\n");
222
+
223
+ // Assert
224
+ expect(revised.annotations[0]!.resolution).toBeUndefined();
225
+ });
226
+
227
+ test("the next feedback document omits addressed annotations and teaches the addressed-ids call", () => {
228
+ // Arrange
229
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
230
+ annotate(session.id, "settled", "carefully");
231
+ annotate(session.id, "open", "Context", "needs a diagram");
232
+ core.sessionSubmitRevision(session.id, PLAN.content, ["settled"]);
233
+
234
+ // Act
235
+ const resolved = core.sessionResolve(session.id, "request_changes", "one left");
236
+
237
+ // Assert
238
+ expect(resolved.verdict!.feedback).not.toContain("settled");
239
+ expect(resolved.verdict!.feedback).toContain("annotation id: `open`");
240
+ expect(resolved.verdict!.feedback).toContain(`submit-revision ${session.id}`);
241
+ expect(resolved.verdict!.feedback).toContain("--addressed");
242
+ });
243
+ });
244
+
114
245
  describe("the wait contract: verdicts outlive waits", () => {
115
246
  test("wait resolves when the verdict arrives", async () => {
247
+ // Arrange
116
248
  const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
117
249
  const wait = core.sessionWait(session.id, 5_000);
250
+
251
+ // Act
118
252
  core.sessionResolve(session.id, "approve", "");
119
253
  const resolved = await wait;
254
+
255
+ // Assert
120
256
  expect(resolved!.verdict!.kind).toBe("approve");
121
257
  });
122
258
 
123
259
  test("wait times out to null; a later wait collects the stored verdict", async () => {
260
+ // Arrange
124
261
  const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
262
+
263
+ // Act
125
264
  const first = await core.sessionWait(session.id, 30);
265
+
266
+ // Assert
126
267
  expect(first).toBeNull();
268
+
269
+ // Act
127
270
  core.sessionResolve(session.id, "request_changes", "later");
128
271
  const second = await core.sessionWait(session.id, 30);
272
+
273
+ // Assert
129
274
  expect(second!.verdict!.summary).toBe("later");
130
275
  });
131
276
 
132
277
  test("multiple waiters all resolve", async () => {
278
+ // Arrange
133
279
  const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
134
280
  const waits = [core.sessionWait(session.id, 5_000), core.sessionWait(session.id, 5_000)];
281
+
282
+ // Act
135
283
  core.sessionResolve(session.id, "approve", "");
136
284
  const results = await Promise.all(waits);
285
+
286
+ // Assert
137
287
  expect(results.every((result) => result?.status === "resolved")).toBe(true);
138
288
  });
139
289
  });
140
290
 
141
291
  describe("persistence and recovery", () => {
142
292
  test("sessions survive a daemon restart", () => {
293
+ // Arrange
143
294
  const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
144
295
  core.sessionResolve(session.id, "approve", "done");
296
+
297
+ // Act
145
298
  const reborn = new DaemonCore(home);
146
299
  const recovered = reborn.sessionGet(session.id);
300
+
301
+ // Assert
147
302
  expect(recovered.verdict!.summary).toBe("done");
148
303
  });
149
304
 
150
305
  test("corrupt records are skipped, not fatal, and never deleted", async () => {
306
+ // Arrange
151
307
  core.sessionCreate({ workspace: WS, artifact: PLAN });
152
308
  await Bun.write(join(home, "sessions", "broken.json"), "{ not json");
153
309
  const store = new SessionStore(home);
310
+
311
+ // Act
154
312
  const report = store.recover();
313
+
314
+ // Assert
155
315
  expect(report.recovered.length).toBe(1);
156
316
  expect(report.skipped.length).toBe(1);
157
317
  expect(await Bun.file(join(home, "sessions", "broken.json")).exists()).toBe(true);
@@ -160,11 +320,16 @@ describe("persistence and recovery", () => {
160
320
 
161
321
  describe("events", () => {
162
322
  test("lifecycle events fire in order", () => {
323
+ // Arrange
163
324
  const seen: string[] = [];
164
325
  core.onEvent((event) => seen.push(event.event));
326
+
327
+ // Act
165
328
  const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
166
329
  core.sessionAnnotate(session.id, { id: "a1", kind: "comment", anchor: { quote: "thing", prefix: "", suffix: "" }, body: "b" });
167
330
  core.sessionResolve(session.id, "approve", "");
331
+
332
+ // Assert
168
333
  expect(seen).toEqual(["session.created", "inbox.changed", "session.updated", "session.resolved", "inbox.changed"]);
169
334
  });
170
335
  });
package/src/api.ts CHANGED
@@ -8,6 +8,10 @@
8
8
  import {
9
9
  SCHEMA_VERSION,
10
10
  feedbackForSession,
11
+ isAddressed,
12
+ isAgentNote,
13
+ parseBlocks,
14
+ resolveAnchor,
11
15
  verdictAllows,
12
16
  type Annotation,
13
17
  type Artifact,
@@ -183,15 +187,39 @@ export class DaemonCore {
183
187
  return session;
184
188
  }
185
189
 
186
- /** Agent resubmits: new revision becomes the artifact, session reopens. */
187
- sessionSubmitRevision(id: string, content: string): ReviewSession {
190
+ /**
191
+ * Agent resubmits: new revision becomes the artifact, session reopens.
192
+ * Annotations the agent reports as acted on (by id) are marked addressed,
193
+ * and for plan revisions any still-open annotation whose quoted text no
194
+ * longer resolves is marked addressed too ("drift" - the line it pointed at
195
+ * was rewritten). Addressed is a marker, never a delete: the reviewer's
196
+ * rail hides them behind a count, and the next feedback document omits them.
197
+ * Unknown ids are ignored, so a stale id in the agent's list never fails
198
+ * the resubmit.
199
+ */
200
+ sessionSubmitRevision(id: string, content: string, addressedAnnotationIds: string[] = []): ReviewSession {
188
201
  const session = this.sessionGet(id);
189
202
  const now = new Date().toISOString();
190
- session.revisions.push({ revision: session.revisions.length + 1, content, submittedAt: now });
203
+ const revisionNumber = session.revisions.length + 1;
204
+ session.revisions.push({ revision: revisionNumber, content, submittedAt: now });
191
205
  session.artifact = { ...session.artifact, content };
192
206
  delete session.workingCopy;
193
207
  session.verdict = null;
194
208
  session.status = "pending";
209
+
210
+ const reportedIds = new Set(addressedAnnotationIds);
211
+ // drift assist applies to plans only: a diff revision is a whole new
212
+ // patch, where a vanished quote says nothing about the feedback
213
+ const revisedBlocks = session.artifact.type === "plan" ? parseBlocks(content) : null;
214
+ for (const annotation of session.annotations) {
215
+ if (isAddressed(annotation) || isAgentNote(annotation)) continue;
216
+ if (reportedIds.has(annotation.id)) {
217
+ annotation.resolution = { revision: revisionNumber, source: "agent" };
218
+ } else if (revisedBlocks !== null && resolveAnchor(annotation.anchor, revisedBlocks) === null) {
219
+ annotation.resolution = { revision: revisionNumber, source: "drift" };
220
+ }
221
+ }
222
+
195
223
  this.store.upsert(session);
196
224
  this.emit("session.revised", id);
197
225
  this.emit("inbox.changed", id);
package/src/client.ts CHANGED
@@ -9,6 +9,8 @@ import type { Annotation, Artifact, ReviewSession, VerdictKind, WorkspaceKey } f
9
9
  import { BackpressureWriter, LineBuffer, type EventFrame, type Response } from "./protocol";
10
10
  import { cueloopHome, socketPath } from "./paths";
11
11
 
12
+ export type { EventFrame } from "./protocol";
13
+
12
14
  export interface ConnectOptions {
13
15
  home?: string;
14
16
  /** Spawn the daemon when the socket is not alive. */
@@ -17,7 +19,26 @@ export interface ConnectOptions {
17
19
 
18
20
  type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void };
19
21
 
20
- export class DaemonClient {
22
+ /**
23
+ * The session verbs the review controller drives. DaemonClient is the local
24
+ * implementation (unix socket); the sharing gateway supplies an in-memory,
25
+ * blob-backed one. Depending on this interface - not DaemonClient - is what
26
+ * lets the same <App> render a local session or a decrypted share unchanged.
27
+ */
28
+ export interface SessionClient {
29
+ onEvent(listener: (event: EventFrame) => void): () => void;
30
+ subscribe(): Promise<void>;
31
+ sessionGet(id: string): Promise<ReviewSession>;
32
+ sessionList(filter?: { status?: "pending" | "resolved" }): Promise<ReviewSession[]>;
33
+ sessionAnnotate(id: string, annotation: Omit<Annotation, "createdAt">): Promise<ReviewSession>;
34
+ sessionRemoveAnnotation(id: string, annotationId: string): Promise<ReviewSession>;
35
+ sessionSetWorkingCopy(id: string, workingCopy: string | undefined): Promise<ReviewSession>;
36
+ sessionSetViewed(id: string, viewedPaths: string[]): Promise<ReviewSession>;
37
+ sessionResolve(id: string, verdictKind: VerdictKind, summary: string): Promise<ReviewSession>;
38
+ close(): void;
39
+ }
40
+
41
+ export class DaemonClient implements SessionClient {
21
42
  private socket: Awaited<ReturnType<typeof Bun.connect>> | null = null;
22
43
  private writer: BackpressureWriter | null = null;
23
44
  private pending = new Map<number, PendingRequest>();
@@ -165,8 +186,8 @@ export class DaemonClient {
165
186
  sessionResolve(id: string, verdictKind: VerdictKind, summary: string): Promise<ReviewSession> {
166
187
  return this.request("session.resolve", { id, verdictKind, summary });
167
188
  }
168
- sessionSubmitRevision(id: string, content: string): Promise<ReviewSession> {
169
- return this.request("session.submitRevision", { id, content });
189
+ sessionSubmitRevision(id: string, content: string, addressedAnnotationIds: string[] = []): Promise<ReviewSession> {
190
+ return this.request("session.submitRevision", { id, content, addressedAnnotationIds });
170
191
  }
171
192
  shutdown(): Promise<void> {
172
193
  return this.request("daemon.shutdown", {});
@@ -1,11 +1,4 @@
1
- /**
2
- * Creation-side herdr pane opener tests. The herdr binary is stubbed by a
3
- * script that both records its argv to a log file and, for `tab create`,
4
- * prints the JSON the helper reads to learn the new pane id. The gating
5
- * wrapper is exercised for its three outcomes: no-op outside herdr, no-op for
6
- * a revision (the open pane is reused), and a full open for a genuinely new
7
- * session.
8
- */
1
+ /** Creation-side herdr pane opener: a stubbed herdr binary logs its argv, and the gating wrapper is checked for its three outcomes (no-op outside herdr, no-op on revision, full open for a new session). */
9
2
 
10
3
  import { afterAll, describe, expect, test } from "bun:test";
11
4
  import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
@@ -18,15 +11,18 @@ const dir = mkdtempSync(join(tmpdir(), "cueloop-herdr-pane-"));
18
11
 
19
12
  /**
20
13
  * A stub herdr binary. It appends its argv to logPath and, when the verb is
21
- * `tab create`, prints a JSON result carrying paneId on stdout so the helper
22
- * can drive send-text and send-keys.
14
+ * `tab create`, prints a JSON result carrying the pane id on stdout so the
15
+ * helper can drive send-text and send-keys. The printed shape mirrors the
16
+ * REAL herdr `tab create` output ({ result: { root_pane: { pane_id } } },
17
+ * verified against herdr 0.8.0) - an earlier stub invented a different shape
18
+ * and let a parser that never matched real herdr pass the suite.
23
19
  */
24
20
  function makeStub(name: string, paneId = "w1:p2"): { binPath: string; logPath: string } {
25
21
  const logPath = join(dir, `${name}.log`);
26
22
  const binPath = join(dir, `${name}.sh`);
27
23
  writeFileSync(
28
24
  binPath,
29
- `#!/bin/sh\nprintf '%s\\n' "$*" >> "${logPath}"\nif [ "$1" = "tab" ] && [ "$2" = "create" ]; then\n printf '{"result":{"pane":{"id":"${paneId}"}}}'\nfi\n`,
25
+ `#!/bin/sh\nprintf '%s\\n' "$*" >> "${logPath}"\nif [ "$1" = "tab" ] && [ "$2" = "create" ]; then\n printf '{"result":{"root_pane":{"pane_id":"${paneId}"}}}'\nfi\n`,
30
26
  );
31
27
  chmodSync(binPath, 0o755);
32
28
  return { binPath, logPath };
@@ -54,8 +50,13 @@ function newSession(overrides: Partial<ReviewSession> = {}): ReviewSession {
54
50
 
55
51
  describe("openHerdrPane", () => {
56
52
  test("creates a focused tab, types the cueloop command, and presses enter", () => {
53
+ // Arrange
57
54
  const stub = makeStub("open");
55
+
56
+ // Act
58
57
  const ok = openHerdrPane({ sessionId: "ses_abc", cwd: "/repo/work", binPath: stub.binPath, label: "Rollout Plan" });
58
+
59
+ // Assert
59
60
  expect(ok).toBeTrue();
60
61
  expect(readLines(stub.logPath)).toEqual([
61
62
  "tab create --cwd /repo/work --label Rollout Plan --focus",
@@ -65,21 +66,29 @@ describe("openHerdrPane", () => {
65
66
  });
66
67
 
67
68
  test("returns false and never throws on a broken binary", () => {
69
+ // Act
68
70
  const ok = openHerdrPane({
69
71
  sessionId: "ses_abc",
70
72
  cwd: "/repo/work",
71
73
  binPath: join(dir, "missing-bin"),
72
74
  label: "x",
73
75
  });
76
+
77
+ // Assert
74
78
  expect(ok).toBeFalse();
75
79
  });
76
80
 
77
81
  test("bails out when tab create yields no pane id - no send-text, no send-keys", () => {
82
+ // Arrange
78
83
  const logPath = join(dir, "nopane.log");
79
84
  const binPath = join(dir, "nopane.sh");
80
85
  writeFileSync(binPath, `#!/bin/sh\nprintf '%s\\n' "$*" >> "${logPath}"\nprintf '{"result":{}}'\n`);
81
86
  chmodSync(binPath, 0o755);
87
+
88
+ // Act
82
89
  const ok = openHerdrPane({ sessionId: "ses_abc", cwd: "/repo/work", binPath, label: "x" });
90
+
91
+ // Assert
83
92
  expect(ok).toBeFalse();
84
93
  expect(readLines(logPath)).toEqual(["tab create --cwd /repo/work --label x --focus"]);
85
94
  });
@@ -87,9 +96,14 @@ describe("openHerdrPane", () => {
87
96
 
88
97
  describe("openHerdrPaneForReview", () => {
89
98
  test("opens a pane for a genuinely new session inside herdr", () => {
99
+ // Arrange
90
100
  const stub = makeStub("gated-new");
91
101
  const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
102
+
103
+ // Act
92
104
  openHerdrPaneForReview(newSession({ id: "ses_xyz" }), env);
105
+
106
+ // Assert
93
107
  expect(readLines(stub.logPath)).toEqual([
94
108
  "tab create --cwd /repo/work --label Rollout Plan --focus",
95
109
  "pane send-text w1:p2 cueloop ses_xyz",
@@ -98,12 +112,18 @@ describe("openHerdrPaneForReview", () => {
98
112
  });
99
113
 
100
114
  test("no-op outside herdr - no herdr process is spawned", () => {
115
+ // Arrange
101
116
  const stub = makeStub("gated-outside");
117
+
118
+ // Act
102
119
  openHerdrPaneForReview(newSession(), { HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath });
120
+
121
+ // Assert
103
122
  expect(existsSync(stub.logPath)).toBeFalse();
104
123
  });
105
124
 
106
125
  test("no-op for a revision - the already open pane is reused, not spammed", () => {
126
+ // Arrange
107
127
  const stub = makeStub("gated-revision");
108
128
  const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
109
129
  const revised = newSession({
@@ -112,7 +132,11 @@ describe("openHerdrPaneForReview", () => {
112
132
  { revision: 2, content: "# P2", submittedAt: "now" },
113
133
  ],
114
134
  });
135
+
136
+ // Act
115
137
  openHerdrPaneForReview(revised, env);
138
+
139
+ // Assert
116
140
  expect(existsSync(stub.logPath)).toBeFalse();
117
141
  });
118
142
  });
package/src/herdr-pane.ts CHANGED
@@ -38,8 +38,9 @@ export interface OpenHerdrPaneOptions {
38
38
 
39
39
  /**
40
40
  * Open a herdr tab that renders the review. `tab create` prints a JSON result
41
- * shaped like `{ result: { pane: { id } } }` - the same `result.pane` nesting
42
- * focusHerdrPane reads - and the pane id drives the two follow-up steps.
41
+ * shaped like `{ result: { root_pane: { pane_id } } }` (verified against
42
+ * herdr 0.8.0; `pane get` uses a different `result.pane` nesting, which is
43
+ * what focusHerdrPane reads). The pane id drives the two follow-up steps.
43
44
  * Returns true when the full open-and-launch sequence completed, false on any
44
45
  * best-effort bail-out.
45
46
  */
@@ -52,8 +53,8 @@ export function openHerdrPane(options: OpenHerdrPaneOptions): boolean {
52
53
  timeout: HERDR_SPAWN_TIMEOUT_MS,
53
54
  });
54
55
  if (created.exitCode !== 0) return false;
55
- const parsed = JSON.parse(created.stdout.toString()) as { result?: { pane?: { id?: string } } };
56
- const paneId = parsed.result?.pane?.id;
56
+ const parsed = JSON.parse(created.stdout.toString()) as { result?: { root_pane?: { pane_id?: string } } };
57
+ const paneId = parsed.result?.root_pane?.pane_id;
57
58
  if (!paneId) return false;
58
59
  // A fresh tab hosts a plain shell, so the review is typed in like a human.
59
60
  const typed = Bun.spawnSync([binPath, "pane", "send-text", paneId, `cueloop ${sessionId}`], {
@@ -1,9 +1,4 @@
1
- /**
2
- * The shared review core against a real DaemonServer in a temp home:
3
- * workspace resolution, title derivation, open-or-revise by agentSessionId,
4
- * and both awaitVerdict shapes (one long-poll and the chunked loop with
5
- * progress and abort).
6
- */
1
+ /** The shared review core against a real DaemonServer in a temp home: workspace resolution, title derivation, open-or-revise by agentSessionId, and both awaitVerdict shapes (one long-poll and the chunked loop with progress and abort). */
7
2
 
8
3
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
9
4
  import { mkdtempSync, rmSync } from "node:fs";
@@ -41,11 +36,16 @@ describe("resolveWorkspace", () => {
41
36
  test("a git repo resolves to its root and branch", async () => {
42
37
  const repo = mkdtempSync(join(tmpdir(), "cueloop-ws-"));
43
38
  try {
39
+ // Arrange
44
40
  sh(["git", "init", "-q", "-b", "main"], repo);
45
41
  sh(["git", "config", "user.email", "t@t"], repo);
46
42
  sh(["git", "config", "user.name", "t"], repo);
47
43
  sh(["git", "commit", "-q", "--allow-empty", "-m", "init"], repo);
44
+
45
+ // Act
48
46
  const ws = await resolveWorkspace(repo);
47
+
48
+ // Assert
49
49
  expect(ws.branch).toBe("main");
50
50
  // macOS tmpdirs can resolve through /private; compare the tail
51
51
  expect(ws.repoRoot.endsWith(repo.split("/").at(-1)!)).toBe(true);
@@ -55,14 +55,20 @@ describe("resolveWorkspace", () => {
55
55
  });
56
56
 
57
57
  test("outside a repo the cwd itself is the workspace, branch detached", async () => {
58
+ // Act
58
59
  const ws = await resolveWorkspace(home);
60
+
61
+ // Assert
59
62
  expect(ws).toEqual({ repoRoot: home, branch: "detached" });
60
63
  });
61
64
  });
62
65
 
63
66
  describe("openReview", () => {
64
67
  test("shapes the artifact: agent, derived title, cwd", async () => {
68
+ // Act
65
69
  const review = await openReview(client, { type: "plan", content: PLAN, cwd: home, agent: "test-agent" });
70
+
71
+ // Assert
66
72
  const session = review.session;
67
73
  expect(session.status).toBe("pending");
68
74
  expect(session.artifact.type).toBe("plan");
@@ -73,34 +79,56 @@ describe("openReview", () => {
73
79
  });
74
80
 
75
81
  test("an explicit title wins; diffs never derive one from content", async () => {
82
+ // Act
76
83
  const titled = await openReview(client, { type: "plan", content: PLAN, cwd: home, title: "Custom" });
84
+
85
+ // Assert
77
86
  expect(titled.session.artifact.meta.title).toBe("Custom");
87
+
88
+ // Act
78
89
  const diff = await openReview(client, { type: "diff", content: "# not a heading\n", cwd: home });
90
+
91
+ // Assert
79
92
  expect(diff.session.artifact.meta.title).toBeUndefined();
80
93
  });
81
94
 
82
95
  test("a pre-resolved workspace skips git resolution", async () => {
96
+ // Arrange
83
97
  const ws = { repoRoot: "/elsewhere", branch: "feature" };
98
+
99
+ // Act
84
100
  const review = await openReview(client, { type: "diff", content: "d", cwd: home, workspace: ws });
101
+
102
+ // Assert
85
103
  expect(review.session.workspace).toEqual(ws);
86
104
  });
87
105
 
88
106
  test("the same agentSessionId revises instead of opening a new session", async () => {
107
+ // Arrange
89
108
  const first = await openReview(client, { type: "plan", content: PLAN, cwd: home, agentSessionId: "agent-1" });
109
+
110
+ // Act
90
111
  const second = await openReview(client, {
91
112
  type: "plan",
92
113
  content: PLAN + "\nStage three.\n",
93
114
  cwd: home,
94
115
  agentSessionId: "agent-1",
95
116
  });
117
+
118
+ // Assert
96
119
  expect(second.id).toBe(first.id);
97
120
  expect(second.session.revisions.length).toBe(2);
98
121
  expect(second.session.status).toBe("pending");
122
+
123
+ // Act
99
124
  const other = await openReview(client, { type: "plan", content: PLAN, cwd: home, agentSessionId: "agent-2" });
125
+
126
+ // Assert
100
127
  expect(other.id).not.toBe(first.id);
101
128
  });
102
129
 
103
130
  test("per-file notes land as note annotations anchored at the file path", async () => {
131
+ // Act
104
132
  const review = await openReview(client, {
105
133
  type: "diff",
106
134
  content: "d",
@@ -110,6 +138,8 @@ describe("openReview", () => {
110
138
  { path: "src/b.ts", body: "Swaps the line." },
111
139
  ],
112
140
  });
141
+
142
+ // Assert
113
143
  const notes = review.session.annotations;
114
144
  expect(notes.length).toBe(2);
115
145
  expect(notes.every((annotation) => annotation.kind === "note")).toBe(true);
@@ -120,10 +150,17 @@ describe("openReview", () => {
120
150
 
121
151
  describe("awaitVerdict: one long-poll (the hook shape)", () => {
122
152
  test("times out to pending; a later wait collects the stored verdict", async () => {
153
+ // Arrange
123
154
  const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
155
+
156
+ // Assert
124
157
  expect(await review.awaitVerdict({ timeoutMs: 50 })).toBe("pending");
158
+
159
+ // Act
125
160
  await client.sessionResolve(review.id, "approve", "Fine.");
126
161
  const verdict = await review.awaitVerdict({ timeoutMs: 1_000 });
162
+
163
+ // Assert
127
164
  expect(verdict).not.toBe("pending");
128
165
  if (verdict === "pending") throw new Error("unreachable");
129
166
  expect(verdict.allow).toBe(true);
@@ -132,10 +169,15 @@ describe("awaitVerdict: one long-poll (the hook shape)", () => {
132
169
  });
133
170
 
134
171
  test("request_changes maps to allow=false with feedback.md attached", async () => {
172
+ // Arrange
135
173
  const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
136
174
  const waiting = review.awaitVerdict({ timeoutMs: 5_000 });
175
+
176
+ // Act
137
177
  await client.sessionResolve(review.id, "request_changes", "One stage only.");
138
178
  const verdict = await waiting;
179
+
180
+ // Assert
139
181
  if (verdict === "pending") throw new Error("expected a verdict");
140
182
  expect(verdict.allow).toBe(false);
141
183
  expect(verdict.feedback).toContain("# Review: request changes");
@@ -145,6 +187,7 @@ describe("awaitVerdict: one long-poll (the hook shape)", () => {
145
187
 
146
188
  describe("awaitVerdict: chunked loop (the pi shape)", () => {
147
189
  test("onProgress sees fresh sessions between chunks; the verdict ends the loop", async () => {
190
+ // Arrange
148
191
  const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
149
192
  const seen: ReviewSession[] = [];
150
193
  const waiting = review.awaitVerdict({
@@ -152,33 +195,49 @@ describe("awaitVerdict: chunked loop (the pi shape)", () => {
152
195
  pollMs: 100,
153
196
  onProgress: (progress) => seen.push(progress),
154
197
  });
198
+
199
+ // Act
155
200
  await client.sessionAnnotate(review.id, {
156
201
  id: "a1",
157
202
  kind: "comment",
158
203
  anchor: { quote: "two stages", prefix: "in ", suffix: "." },
159
204
  body: "Name them.",
160
205
  });
206
+
207
+ // Assert
161
208
  // give the loop a chunk to observe the annotation before resolving
162
209
  for (let i = 0; i < 100 && !seen.some((snapshot) => snapshot.annotations.length === 1); i++) await Bun.sleep(20);
163
210
  expect(seen.some((snapshot) => snapshot.annotations.length === 1)).toBe(true);
211
+
212
+ // Act
164
213
  await client.sessionResolve(review.id, "request_changes", "Too vague.");
165
214
  const verdict = await waiting;
215
+
216
+ // Assert
166
217
  if (verdict === "pending") throw new Error("expected a verdict");
167
218
  expect(verdict.allow).toBe(false);
168
219
  expect(verdict.session.annotations.length).toBe(1);
169
220
  }, 15_000);
170
221
 
171
222
  test("abort surfaces as pending and leaves the session collectable", async () => {
223
+ // Arrange
172
224
  const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
173
225
  const controller = new AbortController();
174
226
  const waiting = review.awaitVerdict({ timeoutMs: Infinity, pollMs: 100, signal: controller.signal });
227
+
228
+ // Act
175
229
  controller.abort();
230
+
231
+ // Assert
176
232
  expect(await waiting).toBe("pending");
177
233
  expect((await client.sessionGet(review.id)).status).toBe("pending");
178
234
  });
179
235
 
180
236
  test("a finite budget runs out to pending", async () => {
237
+ // Arrange
181
238
  const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
239
+
240
+ // Assert
182
241
  expect(await review.awaitVerdict({ timeoutMs: 120, pollMs: 50 })).toBe("pending");
183
242
  });
184
243
  });
@@ -27,9 +27,16 @@ afterEach(() => {
27
27
 
28
28
  describe("socket round-trip", () => {
29
29
  test("ping and full session flow over the wire", async () => {
30
+ // Assert
30
31
  expect((await client.ping()).pid).toBe(process.pid);
32
+
33
+ // Act
31
34
  const session = await client.sessionCreate(WS, PLAN);
35
+
36
+ // Assert
32
37
  expect(session.status).toBe("pending");
38
+
39
+ // Act
33
40
  await client.sessionAnnotate(session.id, {
34
41
  id: "a1",
35
42
  kind: "comment",
@@ -39,6 +46,8 @@ describe("socket round-trip", () => {
39
46
  const wait = client.sessionWait(session.id, 5_000);
40
47
  await client.sessionResolve(session.id, "request_changes", "Expand it.");
41
48
  const resolved = (await wait)!;
49
+
50
+ // Assert
42
51
  expect(resolved.verdict!.kind).toBe("request_changes");
43
52
  expect(resolved.verdict!.feedback).toContain("More detail please.");
44
53
  });
@@ -53,12 +62,17 @@ describe("socket round-trip", () => {
53
62
  });
54
63
 
55
64
  test("events push to subscribed connections only", async () => {
65
+ // Arrange
56
66
  const observer = await DaemonClient.connect({ home });
57
67
  const seen: string[] = [];
58
68
  observer.onEvent((event) => seen.push(event.event));
59
69
  await observer.subscribe();
70
+
71
+ // Act
60
72
  const session = await client.sessionCreate(WS, PLAN);
61
73
  await client.sessionResolve(session.id, "approve", "");
74
+
75
+ // Assert
62
76
  // events are pushed async over the socket; give the loop a beat
63
77
  await Bun.sleep(50);
64
78
  expect(seen).toContain("session.created");
@@ -94,20 +108,28 @@ describe("socket round-trip", () => {
94
108
  });
95
109
 
96
110
  test("meta fields survive the wire: herdrPane set on create comes back from get", async () => {
111
+ // Act
97
112
  const session = await client.sessionCreate(WS, {
98
113
  type: "plan",
99
114
  content: "# P",
100
115
  meta: { agent: "claude-code", herdrPane: "%7" },
101
116
  });
102
117
  const got = await client.sessionGet(session.id);
118
+
119
+ // Assert
103
120
  expect(got.artifact.meta.herdrPane).toBe("%7");
104
121
  expect(got.artifact.meta.agent).toBe("claude-code");
105
122
  });
106
123
 
107
124
  test("two clients see the same state (thin-renderer model)", async () => {
125
+ // Arrange
108
126
  const second = await DaemonClient.connect({ home });
127
+
128
+ // Act
109
129
  const session = await client.sessionCreate(WS, PLAN);
110
130
  const fromSecond = await second.sessionGet(session.id);
131
+
132
+ // Assert
111
133
  expect(fromSecond.artifact.content).toBe(PLAN.content);
112
134
  second.close();
113
135
  });
@@ -118,15 +140,24 @@ describe("large payloads survive socket backpressure", () => {
118
140
  // Regression: responses larger than one socket write used to truncate
119
141
  // mid-line because the write return value was ignored, so the client
120
142
  // never saw the frame's newline and every later request wedged too.
143
+ // Arrange
121
144
  const bigContent = "# Big plan\n" + "lorem ipsum dolor sit amet, consectetur adipiscing elit\n".repeat(4000);
145
+
146
+ // Act
122
147
  const created = await client.sessionCreate(
123
148
  { repoRoot: "/repo", branch: "main" },
124
149
  { type: "plan", content: bigContent, meta: { title: "big" } },
125
150
  );
126
151
  const fetched = await client.sessionGet(created.id);
152
+
153
+ // Assert
127
154
  expect(fetched.artifact.content).toBe(bigContent);
155
+
156
+ // Act
128
157
  // the connection must still be usable for the next frame
129
158
  const listed = await client.sessionList();
159
+
160
+ // Assert
130
161
  expect(listed.some((session) => session.id === created.id)).toBe(true);
131
162
  }, 15_000);
132
163
  });
package/src/server.ts CHANGED
@@ -251,7 +251,7 @@ export class DaemonServer {
251
251
  }
252
252
  case "session.submitRevision": {
253
253
  const params = parseParams("session.submitRevision", request.params);
254
- return core.sessionSubmitRevision(params.id, params.content);
254
+ return core.sessionSubmitRevision(params.id, params.content, params.addressedAnnotationIds);
255
255
  }
256
256
  default:
257
257
  throw new DaemonError("unknown_method", `unknown method ${request.method}`);
@@ -0,0 +1,62 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { gzipSync } from "node:zlib";
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import type { Artifact, WorkspaceKey } from "@cueloop/schema";
7
+ import { DaemonCore } from "./api";
8
+ import { MAX_BLOB_BYTES, packSessionBlob, unpackSessionBlob } from "./share-blob";
9
+
10
+ const WS: WorkspaceKey = { repoRoot: "/repo", branch: "main" };
11
+ const PLAN: Artifact = { type: "plan", content: "# Plan\n\nDo the thing.\n", meta: {} };
12
+
13
+ let home: string;
14
+ let core: DaemonCore;
15
+
16
+ beforeEach(() => {
17
+ home = mkdtempSync(join(tmpdir(), "cueloop-blob-"));
18
+ core = new DaemonCore(home);
19
+ });
20
+ afterEach(() => rmSync(home, { recursive: true, force: true }));
21
+
22
+ describe("share blob roundtrip", () => {
23
+ test("pack then unpack restores the session with its annotations", () => {
24
+ // Arrange
25
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
26
+ const annotated = core.sessionAnnotate(session.id, {
27
+ id: "a_1",
28
+ kind: "comment",
29
+ anchor: { quote: "the thing", prefix: "", suffix: "" },
30
+ body: "why this?",
31
+ });
32
+
33
+ // Act
34
+ const restored = unpackSessionBlob(packSessionBlob(annotated));
35
+
36
+ // Assert
37
+ expect(restored).toEqual(annotated);
38
+ });
39
+ });
40
+
41
+ describe("unpack rejects bad input", () => {
42
+ test("caps the decompressed size, so a compression bomb throws", () => {
43
+ // Arrange: tiny gzip, huge inflation - the decompression-bomb shape
44
+ const bomb = gzipSync(Buffer.alloc(MAX_BLOB_BYTES + 1, 0x61));
45
+
46
+ // Act / Assert
47
+ expect(() => unpackSessionBlob(bomb)).toThrow(/exceeds|maxOutputLength/i);
48
+ });
49
+
50
+ test("throws on bytes that are not gzip", () => {
51
+ // Act / Assert
52
+ expect(() => unpackSessionBlob(Buffer.from("not gzip at all"))).toThrow(/gzip/i);
53
+ });
54
+
55
+ test("throws on a shape that is not a session", () => {
56
+ // Arrange
57
+ const notASession = gzipSync(Buffer.from(JSON.stringify({ hello: "world" })));
58
+
59
+ // Act / Assert
60
+ expect(() => unpackSessionBlob(notASession)).toThrow(/not a valid session/i);
61
+ });
62
+ });
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The share-blob wire format: how a ReviewSession travels from `cueloop share`
3
+ * to the gateway. The planner's CLI packs (this is the only thing it can do -
4
+ * it holds no encryption key); the gateway unpacks, then encrypts for R2.
5
+ *
6
+ * Format is gzip(JSON). Unpack is the untrusted boundary - the bytes come off
7
+ * an SSH channel from anyone who can reach the `share` user - so it caps the
8
+ * decompressed size (a decompression-bomb guard) and runs the same record
9
+ * validator the daemon store uses, never trusting the shape.
10
+ */
11
+
12
+ import { gzipSync, gunzipSync } from "node:zlib";
13
+ import type { ReviewSession } from "@cueloop/schema";
14
+ import { validateSessionRecord } from "./validate";
15
+
16
+ /** Decompressed ceiling for one shared session (ADR 0004's payload cap). */
17
+ export const MAX_BLOB_BYTES = 1024 * 1024;
18
+
19
+ /** The SSH username `cueloop share` connects as to upload; the gateway routes on it. */
20
+ export const SHARE_UPLOAD_USER = "share";
21
+
22
+ /** Where shares live, in one place: the client uploads here, the gateway prints it. */
23
+ export const DEFAULT_SHARE_HOST = "cueloop.dev";
24
+ export const DEFAULT_SHARE_PORT = 22;
25
+
26
+ /** Serialise + compress a session for upload. */
27
+ export function packSessionBlob(session: ReviewSession): Buffer {
28
+ return gzipSync(Buffer.from(JSON.stringify(session), "utf8"));
29
+ }
30
+
31
+ /** Decompress + validate an uploaded blob, or throw a precise reason. */
32
+ export function unpackSessionBlob(bytes: Uint8Array): ReviewSession {
33
+ let json: string;
34
+ try {
35
+ json = gunzipSync(bytes, { maxOutputLength: MAX_BLOB_BYTES }).toString("utf8");
36
+ } catch (err) {
37
+ throw new Error(`blob is not valid gzip or exceeds ${MAX_BLOB_BYTES} bytes: ${err instanceof Error ? err.message : String(err)}`);
38
+ }
39
+ let raw: unknown;
40
+ try {
41
+ raw = JSON.parse(json);
42
+ } catch {
43
+ throw new Error("blob is not valid JSON");
44
+ }
45
+ const parsed = validateSessionRecord(raw);
46
+ if (!parsed.ok) throw new Error(`blob is not a valid session: ${parsed.error}`);
47
+ return parsed.value as ReviewSession;
48
+ }
@@ -35,21 +35,27 @@ function server(): DaemonServer {
35
35
 
36
36
  describe("one daemon per home", () => {
37
37
  test("the second start is refused instead of stealing the socket", () => {
38
+ // Arrange
38
39
  const first = server();
39
40
  const path = first.start();
40
41
  expect(path).toBe(socketPath(home));
41
42
  const second = server();
43
+
44
+ // Assert
42
45
  expect(second.start()).toBeNull();
43
46
  // the winner's socket survives untouched
44
47
  expect(existsSync(path!)).toBe(true);
45
48
  });
46
49
 
47
50
  test("clients keep talking to the one live daemon after a refused start", async () => {
51
+ // Arrange
48
52
  const first = server();
49
53
  first.start();
50
54
  const created = first.core.sessionCreate({ workspace: WS, artifact: PLAN });
51
55
  server().start(); // refused
52
56
  const client = await DaemonClient.connect({ home });
57
+
58
+ // Assert
53
59
  try {
54
60
  // no split brain: the session created on the winner is visible
55
61
  expect((await client.sessionGet(created.id)).id).toBe(created.id);
@@ -60,29 +66,40 @@ describe("one daemon per home", () => {
60
66
  });
61
67
 
62
68
  test("a stale lock from a crashed daemon is reclaimed", () => {
69
+ // Arrange
63
70
  // a pid that cannot be running (init is never a cueloop daemon, and this
64
71
  // simulates the record a crashed process leaves behind)
65
72
  writeFileSync(lockPath(home), "999999");
66
73
  const daemonServer = server();
74
+
75
+ // Assert
67
76
  expect(daemonServer.start()).toBe(socketPath(home));
68
77
  });
69
78
 
70
79
  test("stopping releases the lock so a restart works", () => {
80
+ // Arrange
71
81
  const first = server();
72
82
  first.start();
83
+
84
+ // Act
73
85
  first.stop();
86
+
87
+ // Assert
74
88
  expect(existsSync(lockPath(home))).toBe(false);
75
89
  const second = server();
76
90
  expect(second.start()).toBe(socketPath(home));
77
91
  });
78
92
 
79
93
  test("concurrent autostarts converge on a single daemon", async () => {
94
+ // Act
80
95
  // several clients race to autostart; all must end up on the same daemon
81
96
  const clients = await Promise.all([
82
97
  DaemonClient.connect({ home, autostart: true }),
83
98
  DaemonClient.connect({ home, autostart: true }),
84
99
  DaemonClient.connect({ home, autostart: true }),
85
100
  ]);
101
+
102
+ // Assert
86
103
  try {
87
104
  const pids = await Promise.all(clients.map((client) => client.ping()));
88
105
  const unique = new Set(pids.map((ping) => ping.pid));
@@ -38,10 +38,13 @@ describe("method allowlist", () => {
38
38
 
39
39
  describe("parseParams", () => {
40
40
  test("accepts a well-formed create and defaults meta", () => {
41
+ // Act
41
42
  const params = parseParams("session.create", {
42
43
  workspace: { repoRoot: "/repo", branch: "main" },
43
44
  artifact: { type: "plan", content: "# P" },
44
45
  });
46
+
47
+ // Assert
45
48
  expect(params.artifact.meta).toEqual({});
46
49
  });
47
50
 
@@ -81,10 +84,13 @@ describe("parseParams", () => {
81
84
  });
82
85
 
83
86
  test("annotation kinds stay open (extension kinds are allowed)", () => {
87
+ // Act
84
88
  const params = parseParams("session.annotate", {
85
89
  id: "s",
86
90
  annotation: { id: "a1", kind: "praise", anchor: { quote: "x" }, body: "nice" },
87
91
  });
92
+
93
+ // Assert
88
94
  expect(params.annotation.kind).toBe("praise");
89
95
  expect(params.annotation.anchor.prefix).toBe("");
90
96
  });
@@ -122,13 +128,19 @@ describe("validateSessionRecord", () => {
122
128
  });
123
129
 
124
130
  test("rejects a foreign schema version with a readable reason", () => {
131
+ // Act
125
132
  const result = validateSessionRecord({ ...record, schemaVersion: "99" });
133
+
134
+ // Assert
126
135
  expect(result.ok).toBe(false);
127
136
  if (!result.ok) expect(result.error).toContain("schemaVersion");
128
137
  });
129
138
 
130
139
  test("rejects a structurally broken record", () => {
140
+ // Act
131
141
  const result = validateSessionRecord({ ...record, revisions: "nope" });
142
+
143
+ // Assert
132
144
  expect(result.ok).toBe(false);
133
145
  });
134
146
  });
@@ -159,6 +171,8 @@ describe("wire pins", () => {
159
171
  anchor: fullAnchor,
160
172
  body: "b",
161
173
  orphan: false,
174
+ author: "SHA256:abc",
175
+ resolution: { revision: 2, source: "agent" },
162
176
  createdAt: "now",
163
177
  };
164
178
  const fullArtifact: Required<Artifact> = { type: "plan", content: "# P", meta: fullMeta };
@@ -198,12 +212,17 @@ describe("wire pins", () => {
198
212
  test("a fully-populated meta survives validation and DaemonCore unchanged", () => {
199
213
  const home = mkdtempSync(join(tmpdir(), "cueloop-val-"));
200
214
  try {
215
+ // Arrange
201
216
  const params = parseParams("session.create", {
202
217
  workspace: fullWorkspace,
203
218
  artifact: { type: "plan", content: "# P", meta: fullMeta },
204
219
  });
205
220
  const core = new DaemonCore(home);
221
+
222
+ // Act
206
223
  const created = core.sessionCreate(params);
224
+
225
+ // Assert
207
226
  expect(core.sessionGet(created.id).artifact.meta).toEqual(fullMeta);
208
227
  } finally {
209
228
  rmSync(home, { recursive: true, force: true });
package/src/validate.ts CHANGED
@@ -72,6 +72,13 @@ export const AnnotationSchema = v.object({
72
72
  anchor: AnchorSchema,
73
73
  body: v.string(),
74
74
  orphan: v.optional(v.boolean()),
75
+ author: v.optional(v.string()),
76
+ resolution: v.optional(
77
+ v.object({
78
+ revision: v.number(),
79
+ source: v.picklist(["agent", "drift"]),
80
+ }),
81
+ ),
75
82
  } satisfies EntriesOf<Omit<Annotation, "createdAt">>);
76
83
 
77
84
  const SessionId = NonEmpty;
@@ -101,7 +108,12 @@ export const Params = {
101
108
  verdictKind: v.picklist(["comment", "approve", "request_changes"]),
102
109
  summary: v.optional(v.string(), ""),
103
110
  }),
104
- "session.submitRevision": v.object({ id: SessionId, content: v.string() }),
111
+ "session.submitRevision": v.object({
112
+ id: SessionId,
113
+ content: v.string(),
114
+ /** Annotation ids the agent acted on; each is marked addressed. */
115
+ addressedAnnotationIds: v.optional(v.array(NonEmpty), []),
116
+ }),
105
117
  "events.subscribe": v.object({}),
106
118
  "daemon.ping": v.object({}),
107
119
  "daemon.shutdown": v.object({}),