@cueloop/daemon 0.1.0-alpha.15 → 0.1.0-alpha.16

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.15",
3
+ "version": "0.1.0-alpha.16",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -9,7 +9,7 @@
9
9
  "./review": "./src/review.ts"
10
10
  },
11
11
  "dependencies": {
12
- "@cueloop/schema": "0.1.0-alpha.15",
12
+ "@cueloop/schema": "0.1.0-alpha.16",
13
13
  "valibot": "^1.4.2"
14
14
  },
15
15
  "publishConfig": {
package/src/api.test.ts CHANGED
@@ -84,6 +84,20 @@ describe("session lifecycle", () => {
84
84
  expect(core.sessionGet(s.id).workingCopy).toBeUndefined();
85
85
  });
86
86
 
87
+ test("viewed paths merge, dedupe, clear on empty, and survive a daemon restart", () => {
88
+ const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
89
+ core.sessionSetViewed(s.id, ["src/a.ts", "src/b.ts", "src/a.ts"]);
90
+ expect(core.sessionGet(s.id).viewedPaths).toEqual(["src/a.ts", "src/b.ts"]);
91
+ // merge-additive: a stale client sending only its own new mark loses nothing
92
+ core.sessionSetViewed(s.id, ["src/c.ts"]);
93
+ expect(core.sessionGet(s.id).viewedPaths).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]);
94
+ // a resumed review reads its progress back after a daemon restart
95
+ const reborn = new DaemonCore(home);
96
+ expect(reborn.sessionGet(s.id).viewedPaths).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]);
97
+ core.sessionSetViewed(s.id, []);
98
+ expect(core.sessionGet(s.id).viewedPaths).toBeUndefined();
99
+ });
100
+
87
101
  test("revision reopens the session and resets working copy + verdict", () => {
88
102
  const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
89
103
  core.sessionSetWorkingCopy(s.id, PLAN.content + "\nedit");
package/src/api.ts CHANGED
@@ -150,6 +150,26 @@ export class DaemonCore {
150
150
  return s;
151
151
  }
152
152
 
153
+ /**
154
+ * The guided walk's viewed marks. The full list replaces the stored one
155
+ * (last write wins - one reviewer walks at a time); an empty list clears
156
+ * the field so untouched records stay lean. Duplicates collapse here so
157
+ * the record never accumulates repeats from racing clients.
158
+ */
159
+ /**
160
+ * Merge-additive: walking only ever adds marks, so concurrent or stale
161
+ * clients converge instead of overwriting each other. An empty array is the
162
+ * explicit reset.
163
+ */
164
+ sessionSetViewed(id: string, viewedPaths: string[]): ReviewSession {
165
+ const s = this.mutable(id);
166
+ if (viewedPaths.length === 0) delete s.viewedPaths;
167
+ else s.viewedPaths = [...new Set([...(s.viewedPaths ?? []), ...viewedPaths])];
168
+ this.store.upsert(s);
169
+ this.emit("session.updated", id);
170
+ return s;
171
+ }
172
+
153
173
  sessionResolve(id: string, verdictKind: VerdictKind, summary: string): ReviewSession {
154
174
  const s = this.mutable(id);
155
175
  const verdict: Verdict = {
package/src/client.ts CHANGED
@@ -159,6 +159,9 @@ export class DaemonClient {
159
159
  sessionSetWorkingCopy(id: string, workingCopy: string | undefined): Promise<ReviewSession> {
160
160
  return this.request("session.setWorkingCopy", { id, workingCopy });
161
161
  }
162
+ sessionSetViewed(id: string, viewedPaths: string[]): Promise<ReviewSession> {
163
+ return this.request("session.setViewed", { id, viewedPaths });
164
+ }
162
165
  sessionResolve(id: string, verdictKind: VerdictKind, summary: string): Promise<ReviewSession> {
163
166
  return this.request("session.resolve", { id, verdictKind, summary });
164
167
  }
@@ -99,6 +99,23 @@ describe("openReview", () => {
99
99
  const other = await openReview(client, { type: "plan", content: PLAN, cwd: home, agentSessionId: "agent-2" });
100
100
  expect(other.id).not.toBe(first.id);
101
101
  });
102
+
103
+ test("per-file notes land as note annotations anchored at the file path", async () => {
104
+ const review = await openReview(client, {
105
+ type: "diff",
106
+ content: "d",
107
+ cwd: home,
108
+ notes: [
109
+ { path: "src/a.ts", body: "Renames b and adds c." },
110
+ { path: "src/b.ts", body: "Swaps the line." },
111
+ ],
112
+ });
113
+ const notes = review.session.annotations;
114
+ expect(notes.length).toBe(2);
115
+ expect(notes.every((annotation) => annotation.kind === "note")).toBe(true);
116
+ expect(notes.map((annotation) => annotation.anchor.quote)).toEqual(["src/a.ts", "src/b.ts"]);
117
+ expect(notes[0]!.body).toBe("Renames b and adds c.");
118
+ });
102
119
  });
103
120
 
104
121
  describe("awaitVerdict: one long-poll (the hook shape)", () => {
package/src/review.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * serializing the decision in its host's contract.
8
8
  */
9
9
 
10
- import type { ArtifactType, ReviewSession, WorkspaceKey } from "@cueloop/schema";
10
+ import { newAnnotationId, type ArtifactType, type ReviewSession, type WorkspaceKey } from "@cueloop/schema";
11
11
  import { verdictResponse } from "./api";
12
12
  import type { DaemonClient } from "./client";
13
13
 
@@ -53,6 +53,31 @@ export interface OpenReviewOptions {
53
53
  herdrPane?: string;
54
54
  /** Defaults to the plan's first markdown heading; diffs get no derived title. */
55
55
  title?: string;
56
+ /**
57
+ * Per-file agent notes for diff sessions: the submitting agent's own
58
+ * explanation of each file's change, in dead prose. Stored as annotations
59
+ * with kind "note" anchored at the file path, so they render as regular
60
+ * rail cards and feed the guided walk's agent-note block.
61
+ */
62
+ notes?: ReviewNote[];
63
+ }
64
+
65
+ /** The note contract: one note per changed file, anchored by the file path. */
66
+ export interface ReviewNote {
67
+ path: string;
68
+ body: string;
69
+ }
70
+
71
+ /** Anchor a note at its file: quote = the path, no context selectors. */
72
+ async function attachNotes(client: DaemonClient, sessionId: string, notes: ReviewNote[]): Promise<void> {
73
+ for (const note of notes) {
74
+ await client.sessionAnnotate(sessionId, {
75
+ id: newAnnotationId(),
76
+ kind: "note",
77
+ anchor: { quote: note.path, prefix: "", suffix: "" },
78
+ body: note.body,
79
+ });
80
+ }
56
81
  }
57
82
 
58
83
  export interface AwaitVerdictOptions {
@@ -154,10 +179,15 @@ export async function openReview(client: DaemonClient, opts: OpenReviewOptions):
154
179
  (s) => s.artifact.meta.agentSessionId === opts.agentSessionId,
155
180
  );
156
181
  if (existing !== undefined) {
157
- return new ReviewHandle(client, await client.sessionSubmitRevision(existing.id, opts.content));
182
+ let revised = await client.sessionSubmitRevision(existing.id, opts.content);
183
+ if (opts.notes?.length) {
184
+ await attachNotes(client, revised.id, opts.notes);
185
+ revised = await client.sessionGet(revised.id);
186
+ }
187
+ return new ReviewHandle(client, revised);
158
188
  }
159
189
  }
160
- const session = await client.sessionCreate(workspace, {
190
+ let session = await client.sessionCreate(workspace, {
161
191
  type: opts.type,
162
192
  content: opts.content,
163
193
  meta: {
@@ -170,5 +200,9 @@ export async function openReview(client: DaemonClient, opts: OpenReviewOptions):
170
200
  cwd,
171
201
  },
172
202
  });
203
+ if (opts.notes?.length) {
204
+ await attachNotes(client, session.id, opts.notes);
205
+ session = await client.sessionGet(session.id);
206
+ }
173
207
  return new ReviewHandle(client, session);
174
208
  }
package/src/server.ts CHANGED
@@ -241,6 +241,10 @@ export class DaemonServer {
241
241
  const p = parseParams("session.setWorkingCopy", req.params);
242
242
  return core.sessionSetWorkingCopy(p.id, p.workingCopy);
243
243
  }
244
+ case "session.setViewed": {
245
+ const p = parseParams("session.setViewed", req.params);
246
+ return core.sessionSetViewed(p.id, p.viewedPaths);
247
+ }
244
248
  case "session.resolve": {
245
249
  const p = parseParams("session.resolve", req.params);
246
250
  return core.sessionResolve(p.id, p.verdictKind, p.summary);
@@ -89,6 +89,16 @@ describe("parseParams", () => {
89
89
  expect(p.annotation.anchor.prefix).toBe("");
90
90
  });
91
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/);
100
+ });
101
+
92
102
  test("null params are treated as empty", () => {
93
103
  expect(parseParams("daemon.ping", null)).toEqual({});
94
104
  });
@@ -163,6 +173,7 @@ describe("wire pins", () => {
163
173
  revisions: [fullRevision],
164
174
  annotations: [fullAnnotation],
165
175
  workingCopy: "# P edited",
176
+ viewedPaths: ["src/a.ts"],
166
177
  verdict: fullVerdict,
167
178
  status: "pending",
168
179
  createdAt: "now",
package/src/validate.ts CHANGED
@@ -95,6 +95,7 @@ export const Params = {
95
95
  "session.annotate": v.object({ id: SessionId, annotation: AnnotationSchema }),
96
96
  "session.removeAnnotation": v.object({ id: SessionId, annotationId: NonEmpty }),
97
97
  "session.setWorkingCopy": v.object({ id: SessionId, workingCopy: v.optional(v.string()) }),
98
+ "session.setViewed": v.object({ id: SessionId, viewedPaths: v.array(v.string()) }),
98
99
  "session.resolve": v.object({
99
100
  id: SessionId,
100
101
  verdictKind: v.picklist(["comment", "approve", "request_changes"]),
@@ -145,6 +146,7 @@ export const SessionRecordSchema = v.object({
145
146
  revisions: v.array(RevisionSchema),
146
147
  annotations: v.array(v.object({ ...AnnotationSchema.entries, createdAt: v.string() } satisfies EntriesOf<Annotation>)),
147
148
  workingCopy: v.optional(v.string()),
149
+ viewedPaths: v.optional(v.array(v.string())),
148
150
  verdict: v.nullable(VerdictSchema),
149
151
  status: v.picklist(["pending", "resolved"]),
150
152
  createdAt: v.string(),