@cueloop/daemon 0.1.0-alpha.7 → 0.1.0-alpha.70

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.
Files changed (42) hide show
  1. package/package.json +33 -22
  2. package/src/api.test.ts +891 -50
  3. package/src/api.ts +849 -72
  4. package/src/capabilities.test.ts +57 -0
  5. package/src/capabilities.ts +77 -0
  6. package/src/client.test.ts +24 -0
  7. package/src/client.ts +459 -69
  8. package/src/curate.ts +109 -0
  9. package/src/daemon-version.test.ts +93 -0
  10. package/src/diff-hot-reload.test.ts +186 -0
  11. package/src/diff-watcher.ts +102 -0
  12. package/src/herdr-pane.test.ts +240 -0
  13. package/src/herdr-pane.ts +167 -0
  14. package/src/herdr-split.test.ts +106 -0
  15. package/src/herdr-split.ts +92 -0
  16. package/src/herdr-tab-store.test.ts +96 -0
  17. package/src/herdr-tab-store.ts +66 -0
  18. package/src/index.ts +8 -9
  19. package/src/interruptible-wait.test.ts +127 -0
  20. package/src/interruptible-wait.ts +66 -0
  21. package/src/main.ts +4 -1
  22. package/src/paths.ts +39 -0
  23. package/src/project-files.test.ts +124 -0
  24. package/src/project-files.ts +56 -0
  25. package/src/protocol.ts +79 -7
  26. package/src/retention.test.ts +182 -0
  27. package/src/retention.ts +122 -0
  28. package/src/review.test.ts +255 -28
  29. package/src/review.ts +164 -62
  30. package/src/server.test.ts +381 -39
  31. package/src/server.ts +322 -84
  32. package/src/share-blob.test.ts +112 -0
  33. package/src/share-blob.ts +55 -0
  34. package/src/single-instance.test.ts +38 -11
  35. package/src/store.conformance.test.ts +41 -0
  36. package/src/store.jsonl.test.ts +159 -0
  37. package/src/store.ts +261 -34
  38. package/src/testing/store-conformance.ts +194 -0
  39. package/src/validate.test.ts +174 -28
  40. package/src/validate.ts +226 -16
  41. package/src/version.ts +6 -0
  42. package/src/working-tree.ts +149 -0
@@ -9,21 +9,24 @@ import {
9
9
  AnnotationSchema,
10
10
  ArtifactMetaSchema,
11
11
  ArtifactSchema,
12
+ IdentitySchema,
12
13
  RevisionSchema,
13
- SessionRecordSchema,
14
+ ThreadRecordSchema,
14
15
  VerdictSchema,
15
16
  WorkspaceSchema,
16
17
  isKnownMethod,
17
18
  parseParams,
18
- validateSessionRecord,
19
+ validateThreadRecord,
19
20
  } from "./validate";
20
21
  import {
22
+ ARTIFACT_TYPES,
21
23
  SCHEMA_VERSION,
22
24
  type Anchor,
23
25
  type Annotation,
24
26
  type Artifact,
25
27
  type ArtifactMeta,
26
- type ReviewSession,
28
+ type Identity,
29
+ type Thread,
27
30
  type Revision,
28
31
  type Verdict,
29
32
  type WorkspaceKey,
@@ -38,11 +41,14 @@ describe("method allowlist", () => {
38
41
 
39
42
  describe("parseParams", () => {
40
43
  test("accepts a well-formed create and defaults meta", () => {
41
- const p = parseParams("session.create", {
44
+ // Act
45
+ const params = parseParams("session.create", {
42
46
  workspace: { repoRoot: "/repo", branch: "main" },
43
47
  artifact: { type: "plan", content: "# P" },
44
48
  });
45
- expect(p.artifact.meta).toEqual({});
49
+
50
+ // Assert
51
+ expect(params.artifact.meta).toEqual({});
46
52
  });
47
53
 
48
54
  test("rejects a missing workspace with a pathed message", () => {
@@ -51,8 +57,21 @@ describe("parseParams", () => {
51
57
  throw new Error("should have thrown");
52
58
  } catch (err) {
53
59
  expect(err).toBeInstanceOf(DaemonError);
54
- expect((err as DaemonError).code).toBe("invalid_params");
55
- 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
+ }
64
+ }
65
+ });
66
+
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
+ });
73
+
74
+ expect(params.artifact.type).toBe(type);
56
75
  }
57
76
  });
58
77
 
@@ -72,21 +91,40 @@ describe("parseParams", () => {
72
91
  test("defaults and clamps the wait timeout", () => {
73
92
  expect(parseParams("session.wait", { id: "ses_1" }).timeoutMs).toBe(60_000);
74
93
  expect(() => parseParams("session.wait", { id: "ses_1", timeoutMs: -5 })).toThrow();
75
- expect(() => parseParams("session.wait", { id: "ses_1", timeoutMs: 99 * 60 * 60 * 1000 })).toThrow();
94
+ expect(() =>
95
+ parseParams("session.wait", { id: "ses_1", timeoutMs: 99 * 60 * 60 * 1000 }),
96
+ ).toThrow();
76
97
  });
77
98
 
78
99
  test("verdict kinds are closed", () => {
79
100
  expect(parseParams("session.resolve", { id: "s", verdictKind: "approve" }).summary).toBe("");
80
- expect(() => parseParams("session.resolve", { id: "s", verdictKind: "lgtm" })).toThrow(/verdictKind/);
101
+ expect(() => parseParams("session.resolve", { id: "s", verdictKind: "lgtm" })).toThrow(
102
+ /verdictKind/,
103
+ );
81
104
  });
82
105
 
83
106
  test("annotation kinds stay open (extension kinds are allowed)", () => {
84
- const p = parseParams("session.annotate", {
107
+ // Act
108
+ const params = parseParams("session.annotate", {
85
109
  id: "s",
86
110
  annotation: { id: "a1", kind: "praise", anchor: { quote: "x" }, body: "nice" },
87
111
  });
88
- expect(p.annotation.kind).toBe("praise");
89
- expect(p.annotation.anchor.prefix).toBe("");
112
+
113
+ // Assert
114
+ expect(params.annotation.kind).toBe("praise");
115
+ expect(params.annotation.anchor.prefix).toBe("");
116
+ });
117
+
118
+ test("setViewed takes a full path list and rejects non-string entries", () => {
119
+ expect(
120
+ parseParams("session.setViewed", { id: "s", viewedPaths: ["src/a.ts", "src/b.ts"] })
121
+ .viewedPaths,
122
+ ).toEqual(["src/a.ts", "src/b.ts"]);
123
+ expect(parseParams("session.setViewed", { id: "s", viewedPaths: [] }).viewedPaths).toEqual([]);
124
+ expect(() => parseParams("session.setViewed", { id: "s", viewedPaths: [1] })).toThrow(
125
+ /viewedPaths/,
126
+ );
127
+ expect(() => parseParams("session.setViewed", { id: "s" })).toThrow(/viewedPaths/);
90
128
  });
91
129
 
92
130
  test("null params are treated as empty", () => {
@@ -94,7 +132,7 @@ describe("parseParams", () => {
94
132
  });
95
133
  });
96
134
 
97
- describe("validateSessionRecord", () => {
135
+ describe("validateThreadRecord", () => {
98
136
  const record = {
99
137
  schemaVersion: SCHEMA_VERSION,
100
138
  id: "ses_1",
@@ -108,29 +146,35 @@ describe("validateSessionRecord", () => {
108
146
  };
109
147
 
110
148
  test("accepts a valid record", () => {
111
- expect(validateSessionRecord(record).ok).toBe(true);
149
+ expect(validateThreadRecord(record).ok).toBe(true);
112
150
  });
113
151
 
114
152
  test("rejects a foreign schema version with a readable reason", () => {
115
- const r = validateSessionRecord({ ...record, schemaVersion: "99" });
116
- expect(r.ok).toBe(false);
117
- if (!r.ok) expect(r.error).toContain("schemaVersion");
153
+ // Act
154
+ const result = validateThreadRecord({ ...record, schemaVersion: "99" });
155
+
156
+ // Assert
157
+ expect(result.ok).toBe(false);
158
+ if (!result.ok) expect(result.error).toContain("schemaVersion");
118
159
  });
119
160
 
120
161
  test("rejects a structurally broken record", () => {
121
- const r = validateSessionRecord({ ...record, revisions: "nope" });
122
- expect(r.ok).toBe(false);
162
+ // Act
163
+ const result = validateThreadRecord({ ...record, revisions: "nope" });
164
+
165
+ // Assert
166
+ expect(result.ok).toBe(false);
123
167
  });
124
168
  });
125
169
 
126
170
  /**
127
- * Wire pins (#70): every schema's entries must cover exactly the keys of the
171
+ * Wire pins: every schema's entries must cover exactly the keys of the
128
172
  * schema type it mirrors. Runtime complement to the compile-time EntriesOf
129
173
  * check - the samples are fully populated and typed, so a new field in
130
174
  * @cueloop/schema shows up here too.
131
175
  */
132
176
  describe("wire pins", () => {
133
- const keys = (o: object) => Object.keys(o).sort();
177
+ const keys = <Subject extends object>(subject: Subject) => Object.keys(subject).sort();
134
178
  const entryKeys = (schema: { entries: object }) => Object.keys(schema.entries).sort();
135
179
 
136
180
  const fullMeta: Required<ArtifactMeta> = {
@@ -138,36 +182,129 @@ describe("wire pins", () => {
138
182
  agent: "claude-code",
139
183
  agentSessionId: "sess-1",
140
184
  planPath: "/repo/plan.md",
185
+ prototypePath: "/repo/proto.html",
141
186
  pr: "org/repo#1",
142
187
  herdrPane: "%7",
143
188
  title: "Plan",
189
+ workbench: false,
190
+ };
191
+ const fullAnchor: Required<Anchor> = {
192
+ quote: "q",
193
+ prefix: "p",
194
+ suffix: "s",
195
+ blockIndex: 0,
196
+ endBlockIndex: 0,
197
+ start: 0,
198
+ end: 1,
199
+ selector: "div.card",
144
200
  };
145
- const fullAnchor: Required<Anchor> = { quote: "q", prefix: "p", suffix: "s", blockIndex: 0, start: 0, end: 1 };
146
201
  const fullAnnotation: Required<Annotation> = {
147
202
  id: "a1",
148
203
  kind: "comment",
149
204
  anchor: fullAnchor,
205
+ target: { kind: "file", path: "src/x.ts", rev: "worktree" },
150
206
  body: "b",
151
207
  orphan: false,
208
+ author: "SHA256:abc",
209
+ replyTo: "a0",
210
+ resolution: { revision: 2, source: "agent" },
152
211
  createdAt: "now",
153
212
  };
154
- const fullArtifact: Required<Artifact> = { type: "plan", content: "# P", meta: fullMeta };
155
- const fullVerdict: Required<Verdict> = { kind: "approve", summary: "", feedback: "", resolvedAt: "now" };
213
+ const fullArtifact: Required<Artifact> = {
214
+ type: "plan",
215
+ content: "# P",
216
+ meta: fullMeta,
217
+ files: [{ path: "src/a.ts", oldContents: "old\n", newContents: "new\n", status: "modified" }],
218
+ };
219
+ const fullVerdict: Required<Verdict> = {
220
+ kind: "approve",
221
+ summary: "",
222
+ feedback: "",
223
+ resolvedAt: "now",
224
+ };
156
225
  const fullRevision: Required<Revision> = { revision: 1, content: "# P", submittedAt: "now" };
157
- const fullWorkspace: Required<WorkspaceKey> = { repoRoot: "/repo", branch: "main" };
158
- const fullSession: Required<ReviewSession> = {
226
+ const fullWorkspace: Required<WorkspaceKey> = {
227
+ repoRoot: "/repo",
228
+ branch: "main",
229
+ rootCommit: "11e7abbcf507a13ae5e4e7559c4b42dbb52237fe",
230
+ remote: "git@github.com:acme/repo.git",
231
+ };
232
+ const fullIdentity: Required<Identity> = {
233
+ id: "SHA256:abc",
234
+ provider: "ssh",
235
+ name: "Al",
236
+ handle: "abc",
237
+ };
238
+ const fullSession: Required<Thread> = {
159
239
  schemaVersion: SCHEMA_VERSION,
160
240
  id: "ses_1",
161
241
  workspace: fullWorkspace,
162
242
  artifact: fullArtifact,
163
243
  revisions: [fullRevision],
164
244
  annotations: [fullAnnotation],
245
+ history: {
246
+ entries: [
247
+ {
248
+ id: "e1",
249
+ parentId: null,
250
+ type: "revision",
251
+ by: "agent",
252
+ content: "# P",
253
+ createdAt: "now",
254
+ },
255
+ { id: "e2", parentId: "e1", type: "comment", annotationId: "a1", createdAt: "now" },
256
+ { id: "e3", parentId: "e2", type: "comment-removed", annotationId: "a1", createdAt: "now" },
257
+ { id: "e4", parentId: "e3", type: "verdict", verdict: fullVerdict, createdAt: "now" },
258
+ {
259
+ id: "e5",
260
+ parentId: "e4",
261
+ type: "branch-summary",
262
+ text: "tried a thing",
263
+ abandoned: ["e3"],
264
+ createdAt: "now",
265
+ },
266
+ ],
267
+ tips: { main: "e5" },
268
+ branch: "main",
269
+ labels: { e1: "start" },
270
+ },
271
+ curation: [{ path: "src/a.ts", hunkIndex: 0, changeIndex: 1 }],
272
+ shelvedAnnotations: [fullAnnotation],
273
+ parentSessionId: "ses_0",
274
+ shareBranch: "main",
165
275
  workingCopy: "# P edited",
276
+ viewedPaths: ["src/a.ts"],
166
277
  verdict: fullVerdict,
167
278
  status: "pending",
168
279
  createdAt: "now",
280
+ shareId: "p_abc123xy",
281
+ owner: "SHA256:owner",
282
+ participants: [fullIdentity],
169
283
  };
170
284
 
285
+ test("a history whose tree is broken is refused even when every entry is well-formed", () => {
286
+ // Arrange: a parent cycle
287
+ const record = {
288
+ ...fullSession,
289
+ history: {
290
+ entries: [
291
+ { id: "a", parentId: "b", type: "revision", by: "agent", content: "x", createdAt: "now" },
292
+ { id: "b", parentId: "a", type: "comment", annotationId: "a1", createdAt: "now" },
293
+ ],
294
+ tips: { main: "b" },
295
+ branch: "main",
296
+ labels: {},
297
+ },
298
+ };
299
+
300
+ // Act
301
+ const parsed = validateThreadRecord(record);
302
+
303
+ // Assert
304
+ expect(parsed.ok).toBe(false);
305
+ if (!parsed.ok) expect(parsed.error).toContain("history:");
306
+ });
307
+
171
308
  test("schema key sets match the schema types", () => {
172
309
  expect(entryKeys(WorkspaceSchema)).toEqual(keys(fullWorkspace));
173
310
  expect(entryKeys(ArtifactMetaSchema)).toEqual(keys(fullMeta));
@@ -175,24 +312,33 @@ describe("wire pins", () => {
175
312
  expect(entryKeys(AnchorSchema)).toEqual(keys(fullAnchor));
176
313
  // wire annotations arrive without createdAt; the daemon stamps it
177
314
  const { createdAt: _stamped, ...wireAnnotation } = fullAnnotation;
315
+
178
316
  expect(entryKeys(AnnotationSchema)).toEqual(keys(wireAnnotation));
179
317
  expect(entryKeys(RevisionSchema)).toEqual(keys(fullRevision));
180
318
  expect(entryKeys(VerdictSchema)).toEqual(keys(fullVerdict));
181
- expect(entryKeys(SessionRecordSchema)).toEqual(keys(fullSession));
319
+ expect(entryKeys(IdentitySchema)).toEqual(keys(fullIdentity));
320
+ expect(entryKeys(ThreadRecordSchema)).toEqual(keys(fullSession));
182
321
  // persisted annotations carry the stamped createdAt
183
- const stored = SessionRecordSchema.entries.annotations.item;
322
+ const stored = ThreadRecordSchema.entries.annotations.item;
323
+
184
324
  expect(entryKeys(stored)).toEqual(keys(fullAnnotation));
185
325
  });
186
326
 
187
327
  test("a fully-populated meta survives validation and DaemonCore unchanged", () => {
188
328
  const home = mkdtempSync(join(tmpdir(), "cueloop-val-"));
329
+
189
330
  try {
331
+ // Arrange
190
332
  const params = parseParams("session.create", {
191
333
  workspace: fullWorkspace,
192
334
  artifact: { type: "plan", content: "# P", meta: fullMeta },
193
335
  });
194
336
  const core = new DaemonCore(home);
337
+
338
+ // Act
195
339
  const created = core.sessionCreate(params);
340
+
341
+ // Assert
196
342
  expect(core.sessionGet(created.id).artifact.meta).toEqual(fullMeta);
197
343
  } finally {
198
344
  rmSync(home, { recursive: true, force: true });
package/src/validate.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Runtime validation of the wire boundary (#14). Everything arriving over the
2
+ * Runtime validation of the wire boundary. Everything arriving over the
3
3
  * socket is untrusted JSON: adapters, the CLI, extensions, and any script a
4
4
  * user writes all speak this protocol. Validating here means DaemonCore only
5
5
  * ever sees well-formed input, and a malformed request gets a precise error
@@ -11,17 +11,25 @@
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,
18
+ type AnnotationTarget,
17
19
  type Artifact,
18
20
  type ArtifactMeta,
19
- type ReviewSession,
21
+ type DiffFileContents,
22
+ type Identity,
23
+ type Thread,
24
+ type HunkRejection,
20
25
  type Revision,
26
+ type SessionHistory,
27
+ validateHistory,
21
28
  type Verdict,
22
29
  type WorkspaceKey,
23
30
  } from "@cueloop/schema";
24
31
  import { DaemonError } from "./errors";
32
+ import type { Request } from "./protocol";
25
33
 
26
34
  /**
27
35
  * Drift guard for every hand-mirrored shape below. v.object strips keys it
@@ -37,6 +45,8 @@ const NonEmpty = v.pipe(v.string(), v.minLength(1));
37
45
  export const WorkspaceSchema = v.object({
38
46
  repoRoot: NonEmpty,
39
47
  branch: NonEmpty,
48
+ rootCommit: v.optional(NonEmpty),
49
+ remote: v.optional(NonEmpty),
40
50
  } satisfies EntriesOf<WorkspaceKey>);
41
51
 
42
52
  export const ArtifactMetaSchema = v.object({
@@ -44,15 +54,27 @@ export const ArtifactMetaSchema = v.object({
44
54
  agent: v.optional(v.string()),
45
55
  agentSessionId: v.optional(v.string()),
46
56
  planPath: v.optional(v.string()),
57
+ prototypePath: v.optional(v.string()),
47
58
  pr: v.optional(v.string()),
48
59
  herdrPane: v.optional(v.string()),
49
60
  title: v.optional(v.string()),
61
+ workbench: v.optional(v.boolean()),
50
62
  } satisfies EntriesOf<ArtifactMeta>);
51
63
 
64
+ export const DiffFileContentsSchema = v.object({
65
+ path: NonEmpty,
66
+ oldContents: v.string(),
67
+ newContents: v.string(),
68
+ status: v.picklist(["added", "modified", "deleted"]),
69
+ } satisfies EntriesOf<DiffFileContents>);
70
+
52
71
  export const ArtifactSchema = v.object({
53
- type: v.picklist(["plan", "diff"]),
72
+ // Derived from the schema's runtime union: a new primitive extends the wire
73
+ // contract without touching this file.
74
+ type: v.picklist(ARTIFACT_TYPES),
54
75
  content: v.string(),
55
76
  meta: v.optional(ArtifactMetaSchema, {}),
77
+ files: v.optional(v.array(DiffFileContentsSchema)),
56
78
  } satisfies EntriesOf<Artifact>);
57
79
 
58
80
  export const AnchorSchema = v.object({
@@ -60,22 +82,53 @@ export const AnchorSchema = v.object({
60
82
  prefix: v.optional(v.string(), ""),
61
83
  suffix: v.optional(v.string(), ""),
62
84
  blockIndex: v.optional(v.number()),
85
+ endBlockIndex: v.optional(v.number()),
63
86
  start: v.optional(v.number()),
64
87
  end: v.optional(v.number()),
88
+ selector: v.optional(v.string()),
65
89
  } satisfies EntriesOf<Anchor>);
66
90
 
91
+ /** The surface a note was made on; absent means the reviewed artifact. */
92
+ export const AnnotationTargetSchema: v.GenericSchema<AnnotationTarget> = v.variant("kind", [
93
+ v.object({ kind: v.literal("artifact") }),
94
+ v.object({ kind: v.literal("file"), path: NonEmpty, rev: v.picklist(["worktree", "head"]) }),
95
+ v.object({ kind: v.literal("welcome") }),
96
+ ]);
97
+
67
98
  /** Wire annotations arrive without createdAt - the daemon stamps it. */
68
99
  export const AnnotationSchema = v.object({
69
100
  id: NonEmpty,
70
- /** Open kind set (#2): built-ins are comment and suggestion. */
101
+ /** Open kind set: the built-in is comment. */
71
102
  kind: NonEmpty,
72
103
  anchor: AnchorSchema,
104
+ target: v.optional(AnnotationTargetSchema),
73
105
  body: v.string(),
74
106
  orphan: v.optional(v.boolean()),
107
+ author: v.optional(v.string()),
108
+ replyTo: v.optional(NonEmpty),
109
+ resolution: v.optional(
110
+ v.object({
111
+ revision: v.number(),
112
+ source: v.picklist(["agent", "drift"]),
113
+ }),
114
+ ),
75
115
  } satisfies EntriesOf<Omit<Annotation, "createdAt">>);
76
116
 
77
117
  const SessionId = NonEmpty;
78
118
 
119
+ /** A stored annotation: the wire shape plus the daemon-stamped createdAt. */
120
+ export const FullAnnotationSchema = v.object({
121
+ ...AnnotationSchema.entries,
122
+ createdAt: v.string(),
123
+ } satisfies EntriesOf<Annotation>);
124
+
125
+ export const IdentitySchema = v.object({
126
+ id: NonEmpty,
127
+ provider: v.literal("ssh"),
128
+ name: v.optional(v.string()),
129
+ handle: v.optional(v.string()),
130
+ } satisfies EntriesOf<Identity>);
131
+
79
132
  export const Params = {
80
133
  "session.create": v.object({ workspace: WorkspaceSchema, artifact: ArtifactSchema }),
81
134
  "session.get": v.object({ id: SessionId }),
@@ -90,20 +143,101 @@ export const Params = {
90
143
  "session.wait": v.object({
91
144
  id: SessionId,
92
145
  // clamped: a negative or absurd timeout is a client bug, not a daemon one
93
- timeoutMs: v.optional(v.pipe(v.number(), v.minValue(0), v.maxValue(24 * 60 * 60 * 1000)), 60_000),
146
+ timeoutMs: v.optional(
147
+ v.pipe(v.number(), v.minValue(0), v.maxValue(24 * 60 * 60 * 1000)),
148
+ 60_000,
149
+ ),
150
+ }),
151
+ "session.annotate": v.object({
152
+ id: SessionId,
153
+ annotation: AnnotationSchema,
154
+ authorName: v.optional(v.string()),
155
+ }),
156
+ // "session.comment" is the primary annotate method; "session.annotate" stays an accepted alias
157
+ "session.comment": v.object({
158
+ id: SessionId,
159
+ annotation: AnnotationSchema,
160
+ authorName: v.optional(v.string()),
94
161
  }),
95
- "session.annotate": v.object({ id: SessionId, annotation: AnnotationSchema }),
96
162
  "session.removeAnnotation": v.object({ id: SessionId, annotationId: NonEmpty }),
163
+ "session.setParticipantName": v.object({ id: SessionId, author: NonEmpty, name: NonEmpty }),
97
164
  "session.setWorkingCopy": v.object({ id: SessionId, workingCopy: v.optional(v.string()) }),
165
+ "session.cutBlock": v.object({
166
+ id: SessionId,
167
+ blockIndex: v.pipe(v.number(), v.integer(), v.minValue(0)),
168
+ }),
169
+ "session.restoreBlock": v.object({
170
+ id: SessionId,
171
+ baseBlockIndex: v.pipe(v.number(), v.integer(), v.minValue(0)),
172
+ line: v.optional(v.pipe(v.number(), v.integer(), v.minValue(0))),
173
+ }),
174
+ "session.curate": v.object({
175
+ id: SessionId,
176
+ rejections: v.array(
177
+ v.object({
178
+ path: NonEmpty,
179
+ hunkIndex: v.pipe(v.number(), v.integer(), v.minValue(0)),
180
+ changeIndex: v.optional(v.pipe(v.number(), v.integer(), v.minValue(0))),
181
+ } satisfies EntriesOf<HunkRejection>),
182
+ ),
183
+ }),
184
+ "session.setViewed": v.object({ id: SessionId, viewedPaths: v.array(v.string()) }),
185
+ "session.setTitle": v.object({ id: SessionId, title: v.string() }),
186
+ "session.projectFiles": v.object({ id: SessionId }),
187
+ "session.fileContents": v.object({ id: SessionId, path: NonEmpty }),
188
+ "repo.files": v.object({ cwd: NonEmpty }),
189
+ "repo.fileContents": v.object({ cwd: NonEmpty, path: NonEmpty }),
190
+ "repo.changes": v.object({ cwd: NonEmpty }),
191
+ "repo.diff": v.object({ cwd: NonEmpty }),
192
+ "session.workbench": v.object({ cwd: NonEmpty }),
193
+ "session.navigate": v.object({
194
+ id: SessionId,
195
+ entryId: NonEmpty,
196
+ summary: v.optional(v.string()),
197
+ // stand on this branch first, so a move on another branch is one request
198
+ branch: v.optional(NonEmpty),
199
+ }),
200
+ "session.branch": v.object({ id: SessionId, name: NonEmpty }),
201
+ "session.switch": v.object({ id: SessionId, branch: NonEmpty }),
202
+ "session.label": v.object({ id: SessionId, label: NonEmpty }),
203
+ "session.fork": v.object({ id: SessionId }),
204
+ "session.refreshDiff": v.object({ id: SessionId }),
205
+ "session.setShareId": v.object({ id: SessionId, shareId: NonEmpty }),
206
+ "session.delete": v.object({ id: SessionId }),
207
+ "session.mergeShared": v.object({
208
+ id: SessionId,
209
+ annotations: v.array(FullAnnotationSchema),
210
+ participants: v.optional(v.array(IdentitySchema)),
211
+ // the removal entries a share recorded, carried by id so a merge applies each once
212
+ removals: v.optional(
213
+ v.array(v.object({ id: NonEmpty, annotationId: NonEmpty, createdAt: v.string() })),
214
+ ),
215
+ }),
98
216
  "session.resolve": v.object({
99
217
  id: SessionId,
100
218
  verdictKind: v.picklist(["comment", "approve", "request_changes"]),
101
219
  summary: v.optional(v.string(), ""),
220
+ actionBodies: v.optional(v.record(v.string(), v.string())),
221
+ }),
222
+ "session.submitRevision": v.object({
223
+ id: SessionId,
224
+ content: v.string(),
225
+ /** Annotation ids the agent acted on; each is marked addressed. */
226
+ addressedAnnotationIds: v.optional(v.array(NonEmpty), []),
102
227
  }),
103
- "session.submitRevision": v.object({ id: SessionId, content: v.string() }),
104
228
  "events.subscribe": v.object({}),
105
229
  "daemon.ping": v.object({}),
230
+ // the owner token proves ownership; without it a request for owner stays a collaborator
231
+ "daemon.hello": v.object({
232
+ role: v.picklist(["owner", "collaborator", "agent"]),
233
+ token: v.optional(v.string()),
234
+ // the author a non-owner acts as, bound once for the connection
235
+ author: v.optional(NonEmpty),
236
+ }),
106
237
  "daemon.shutdown": v.object({}),
238
+ // herdr adapter scratch: the review's opened tab, kept off the session record.
239
+ "herdr.getTab": v.object({ id: SessionId }),
240
+ "herdr.setTab": v.object({ id: SessionId, tabId: NonEmpty, paneId: NonEmpty }),
107
241
  } as const;
108
242
 
109
243
  export type MethodName = keyof typeof Params;
@@ -113,13 +247,22 @@ export function isKnownMethod(method: string): method is MethodName {
113
247
  }
114
248
 
115
249
  /** Validate params for a method, or throw a DaemonError the client can read. */
116
- export function parseParams<M extends MethodName>(method: M, params: unknown): v.InferOutput<(typeof Params)[M]> {
250
+ export function parseParams<M extends MethodName>(
251
+ method: M,
252
+ params: Request["params"],
253
+ ): v.InferOutput<(typeof Params)[M]> {
117
254
  const result = v.safeParse(Params[method], params ?? {});
255
+
118
256
  if (!result.success) {
119
257
  const issue = result.issues[0]!;
120
- const path = issue.path?.map((p) => String(p.key)).join(".") ?? "";
121
- throw new DaemonError("invalid_params", `${method}: ${path ? path + " - " : ""}${issue.message}`);
258
+ const path = issue.path?.map((pathSegment) => String(pathSegment.key)).join(".") ?? "";
259
+
260
+ throw new DaemonError(
261
+ "invalid_params",
262
+ `${method}: ${path ? path + " - " : ""}${issue.message}`,
263
+ );
122
264
  }
265
+
123
266
  return result.output;
124
267
  }
125
268
 
@@ -136,24 +279,91 @@ export const VerdictSchema = v.object({
136
279
  resolvedAt: v.string(),
137
280
  } satisfies EntriesOf<Verdict>);
138
281
 
282
+ const EntryBaseEntries = {
283
+ id: NonEmpty,
284
+ parentId: v.nullable(NonEmpty),
285
+ createdAt: v.string(),
286
+ };
287
+
288
+ /** One record of a session's history; the tree pointers plus the entry's own fields. */
289
+ export const SessionEntrySchema = v.variant("type", [
290
+ v.object({
291
+ ...EntryBaseEntries,
292
+ type: v.literal("revision"),
293
+ by: v.picklist(["agent", "reviewer"]),
294
+ content: v.string(),
295
+ }),
296
+ v.object({ ...EntryBaseEntries, type: v.literal("comment"), annotationId: NonEmpty }),
297
+ v.object({ ...EntryBaseEntries, type: v.literal("comment-removed"), annotationId: NonEmpty }),
298
+ v.object({ ...EntryBaseEntries, type: v.literal("verdict"), verdict: VerdictSchema }),
299
+ v.object({
300
+ ...EntryBaseEntries,
301
+ type: v.literal("branch-summary"),
302
+ text: v.string(),
303
+ abandoned: v.array(NonEmpty),
304
+ }),
305
+ ]);
306
+
307
+ export const SessionHistorySchema = v.pipe(
308
+ v.object({
309
+ entries: v.array(SessionEntrySchema),
310
+ tips: v.record(v.string(), NonEmpty),
311
+ branch: NonEmpty,
312
+ labels: v.record(v.string(), v.string()),
313
+ } satisfies EntriesOf<SessionHistory>),
314
+ // the shape is not enough: the tree itself must hold, or a walk hangs or rewires the path
315
+ v.rawCheck(({ dataset, addIssue }) => {
316
+ if (!dataset.typed) return;
317
+ const problem = validateHistory(dataset.value);
318
+
319
+ if (problem !== null) addIssue({ message: `history: ${problem}` });
320
+ }),
321
+ );
322
+
139
323
  /** Persisted records are validated on recovery: a bad file is skipped, not fatal. */
140
- export const SessionRecordSchema = v.object({
324
+ export const ThreadRecordSchema = v.object({
141
325
  schemaVersion: v.literal(SCHEMA_VERSION),
142
326
  id: NonEmpty,
143
327
  workspace: WorkspaceSchema,
144
328
  artifact: ArtifactSchema,
145
329
  revisions: v.array(RevisionSchema),
146
- annotations: v.array(v.object({ ...AnnotationSchema.entries, createdAt: v.string() } satisfies EntriesOf<Annotation>)),
330
+ annotations: v.array(FullAnnotationSchema),
331
+ history: v.optional(SessionHistorySchema),
332
+ curation: v.optional(
333
+ v.array(
334
+ v.object({
335
+ path: NonEmpty,
336
+ hunkIndex: v.number(),
337
+ changeIndex: v.optional(v.number()),
338
+ } satisfies EntriesOf<HunkRejection>),
339
+ ),
340
+ ),
147
341
  workingCopy: v.optional(v.string()),
342
+ viewedPaths: v.optional(v.array(v.string())),
148
343
  verdict: v.nullable(VerdictSchema),
149
344
  status: v.picklist(["pending", "resolved"]),
150
345
  createdAt: v.string(),
151
- } satisfies EntriesOf<ReviewSession>);
346
+ shelvedAnnotations: v.optional(v.array(FullAnnotationSchema)),
347
+ parentSessionId: v.optional(v.string()),
348
+ shareId: v.optional(v.string()),
349
+ shareBranch: v.optional(v.string()),
350
+ owner: v.optional(v.string()),
351
+ participants: v.optional(v.array(IdentitySchema)),
352
+ } satisfies EntriesOf<Thread>);
353
+
354
+ export function validateThreadRecord(
355
+ raw: Parameters<typeof v.safeParse>[1],
356
+ ): { ok: true; value: v.InferOutput<typeof ThreadRecordSchema> } | { ok: false; error: string } {
357
+ const result = v.safeParse(ThreadRecordSchema, raw);
152
358
 
153
- export function validateSessionRecord(raw: unknown): { ok: true; value: v.InferOutput<typeof SessionRecordSchema> } | { ok: false; error: string } {
154
- const result = v.safeParse(SessionRecordSchema, raw);
155
359
  if (result.success) return { ok: true, value: result.output };
156
360
  const issue = result.issues[0]!;
157
- const path = issue.path?.map((p) => String(p.key)).join(".") ?? "";
361
+ const path = issue.path?.map((pathSegment) => String(pathSegment.key)).join(".") ?? "";
362
+
158
363
  return { ok: false, error: `${path ? path + ": " : ""}${issue.message}` };
159
364
  }
365
+
366
+ /** @deprecated use ThreadRecordSchema */
367
+ export const SessionRecordSchema = ThreadRecordSchema;
368
+ /** @deprecated use validateThreadRecord */
369
+ export const validateSessionRecord = validateThreadRecord;