@cueloop/daemon 0.1.0-alpha.11 → 0.1.0-alpha.110

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 (85) hide show
  1. package/README.md +7 -0
  2. package/SDK.md +102 -0
  3. package/package.json +44 -22
  4. package/src/agent-tools.ts +104 -0
  5. package/src/agent-turn.test.ts +24 -0
  6. package/src/agent-turn.ts +34 -0
  7. package/src/api.test.ts +1081 -65
  8. package/src/api.ts +1483 -90
  9. package/src/capabilities.test.ts +57 -0
  10. package/src/capabilities.ts +95 -0
  11. package/src/client-errors.ts +24 -0
  12. package/src/client-reliability.test.ts +191 -0
  13. package/src/client.test.ts +37 -0
  14. package/src/client.ts +844 -95
  15. package/src/curate.ts +111 -0
  16. package/src/daemon-sdk-effect.ts +113 -0
  17. package/src/daemon-sdk.test.ts +306 -0
  18. package/src/daemon-sdk.ts +405 -0
  19. package/src/daemon-version.test.ts +142 -0
  20. package/src/diff-hot-reload.test.ts +423 -0
  21. package/src/diff-watcher.test.ts +165 -0
  22. package/src/diff-watcher.ts +363 -0
  23. package/src/examples/daemon-sdk.ts +57 -0
  24. package/src/gh.test.ts +59 -0
  25. package/src/gh.ts +78 -0
  26. package/src/ghostty-thread-surface-store.test.ts +49 -0
  27. package/src/ghostty-thread-surface-store.ts +104 -0
  28. package/src/ghostty-thread-surface.test.ts +400 -0
  29. package/src/ghostty-thread-surface.ts +273 -0
  30. package/src/harness-delivery.test.ts +269 -0
  31. package/src/harness-diagnostics.test.ts +26 -0
  32. package/src/harness-diagnostics.ts +31 -0
  33. package/src/harness-handoff.test.ts +725 -0
  34. package/src/harness-handoff.ts +27 -0
  35. package/src/harness-state-store.test.ts +93 -0
  36. package/src/harness-state-store.ts +218 -0
  37. package/src/herdr-split.test.ts +106 -0
  38. package/src/herdr-split.ts +90 -0
  39. package/src/herdr-thread-surface-store.test.ts +81 -0
  40. package/src/herdr-thread-surface-store.ts +66 -0
  41. package/src/herdr-thread-surface.test.ts +465 -0
  42. package/src/herdr-thread-surface.ts +392 -0
  43. package/src/index.ts +8 -9
  44. package/src/interruptible-wait.test.ts +127 -0
  45. package/src/interruptible-wait.ts +69 -0
  46. package/src/jj-working-tree.ts +140 -0
  47. package/src/main.ts +5 -1
  48. package/src/operation-receipts.test.ts +111 -0
  49. package/src/operation-receipts.ts +104 -0
  50. package/src/paths.ts +49 -0
  51. package/src/pr-poller.test.ts +102 -0
  52. package/src/pr-poller.ts +80 -0
  53. package/src/project-files.test.ts +134 -0
  54. package/src/project-files.ts +72 -0
  55. package/src/protocol.ts +82 -7
  56. package/src/retention.test.ts +191 -0
  57. package/src/retention.ts +131 -0
  58. package/src/sdk-identities.ts +35 -0
  59. package/src/server.test.ts +502 -42
  60. package/src/server.ts +660 -88
  61. package/src/share-blob.test.ts +142 -0
  62. package/src/share-blob.ts +57 -0
  63. package/src/shared-agent-protocol.ts +30 -0
  64. package/src/single-instance.test.ts +165 -13
  65. package/src/store.conformance.test.ts +41 -0
  66. package/src/store.jsonl.test.ts +173 -0
  67. package/src/store.ts +278 -34
  68. package/src/testing/store-conformance.ts +192 -0
  69. package/src/thread-agent-validation.ts +141 -0
  70. package/src/thread-agent.test.ts +732 -0
  71. package/src/thread-agent.ts +1171 -0
  72. package/src/thread-review.test.ts +487 -0
  73. package/src/thread-review.ts +394 -0
  74. package/src/thread-subscription.test.ts +177 -0
  75. package/src/thread-subscription.ts +138 -0
  76. package/src/thread-surface-config.test.ts +63 -0
  77. package/src/thread-surface-config.ts +61 -0
  78. package/src/validate.test.ts +290 -37
  79. package/src/validate.ts +427 -33
  80. package/src/vcs-source.test.ts +180 -0
  81. package/src/vcs-source.ts +235 -0
  82. package/src/version.ts +6 -0
  83. package/src/working-tree.ts +154 -0
  84. package/src/review.test.ts +0 -167
  85. package/src/review.ts +0 -174
package/src/validate.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Runtime validation of the wire boundary (#14). Everything arriving over the
2
+ * Runtime validation of the wire boundary. Everything arriving over the
3
3
  * socket is untrusted JSON: adapters, the CLI, extensions, and any script a
4
4
  * user writes all speak this protocol. Validating here means DaemonCore only
5
5
  * ever sees well-formed input, and a malformed request gets a precise error
@@ -10,18 +10,38 @@
10
10
  */
11
11
 
12
12
  import * as v from "valibot";
13
+ import { ThreadAgentSchema } from "./thread-agent-validation";
13
14
  import {
15
+ ARTIFACT_TYPES,
16
+ MESSAGE_OUTCOMES,
17
+ WORKFLOW_KINDS,
14
18
  SCHEMA_VERSION,
15
19
  type Anchor,
16
20
  type Annotation,
21
+ type AnnotationTarget,
17
22
  type Artifact,
18
23
  type ArtifactMeta,
19
- type ReviewSession,
24
+ type DiffFileContents,
25
+ type Identity,
26
+ type Thread,
27
+ type TextCut,
28
+ type HunkRejection,
29
+ type HarnessBinding,
30
+ type Delivery,
31
+ type PendingDelivery,
20
32
  type Revision,
21
- type Verdict,
33
+ REVIEW_SEVERITIES,
34
+ type ReviewComment,
35
+ type SessionHistory,
36
+ type ShareLink,
37
+ validateHistory,
38
+ type Message,
22
39
  type WorkspaceKey,
40
+ applyTextCuts,
23
41
  } from "@cueloop/schema";
24
42
  import { DaemonError } from "./errors";
43
+ import type { Request } from "./protocol";
44
+ import { AgentCommentSchema } from "./thread-agent-validation";
25
45
 
26
46
  /**
27
47
  * Drift guard for every hand-mirrored shape below. v.object strips keys it
@@ -34,25 +54,69 @@ type EntriesOf<T> = { [K in keyof T]-?: v.GenericSchema<any, any> };
34
54
 
35
55
  const NonEmpty = v.pipe(v.string(), v.minLength(1));
36
56
 
57
+ const TextCutSchema = v.object({
58
+ start: v.pipe(v.number(), v.integer(), v.minValue(0)),
59
+ end: v.pipe(v.number(), v.integer(), v.minValue(1)),
60
+ quote: NonEmpty,
61
+ } satisfies EntriesOf<TextCut>);
62
+
63
+ const TextCutsSchema = v.pipe(
64
+ v.array(TextCutSchema),
65
+ v.check(
66
+ (cuts) =>
67
+ cuts.every(
68
+ (cut, index) =>
69
+ cut.end - cut.start === cut.quote.length &&
70
+ (index === 0 || cuts[index - 1]!.end <= cut.start),
71
+ ),
72
+ "text Cuts must be ordered, non-overlapping, and match their quote lengths",
73
+ ),
74
+ );
75
+
37
76
  export const WorkspaceSchema = v.object({
38
77
  repoRoot: NonEmpty,
39
78
  branch: NonEmpty,
79
+ rootCommit: v.optional(NonEmpty),
80
+ remote: v.optional(NonEmpty),
40
81
  } satisfies EntriesOf<WorkspaceKey>);
41
82
 
42
83
  export const ArtifactMetaSchema = v.object({
84
+ workflow: v.optional(v.picklist(WORKFLOW_KINDS)),
43
85
  cwd: v.optional(v.string()),
86
+ vcs: v.optional(NonEmpty),
87
+ vcsChangeId: v.optional(NonEmpty),
88
+ vcsRevisionId: v.optional(NonEmpty),
44
89
  agent: v.optional(v.string()),
45
90
  agentSessionId: v.optional(v.string()),
46
91
  planPath: v.optional(v.string()),
92
+ prototypePath: v.optional(v.string()),
47
93
  pr: v.optional(v.string()),
94
+ prBrief: v.optional(v.string()),
95
+ prBaseSha: v.optional(NonEmpty),
96
+ prHeadSha: v.optional(NonEmpty),
97
+ prRefreshBaseSha: v.optional(NonEmpty),
98
+ prRefreshHeadSha: v.optional(NonEmpty),
99
+ prUrl: v.optional(NonEmpty),
48
100
  herdrPane: v.optional(v.string()),
49
101
  title: v.optional(v.string()),
102
+ workbench: v.optional(v.boolean()),
103
+ snapshot: v.optional(v.boolean()),
50
104
  } satisfies EntriesOf<ArtifactMeta>);
51
105
 
106
+ export const DiffFileContentsSchema = v.object({
107
+ path: NonEmpty,
108
+ oldContents: v.string(),
109
+ newContents: v.string(),
110
+ status: v.picklist(["added", "modified", "deleted"]),
111
+ } satisfies EntriesOf<DiffFileContents>);
112
+
52
113
  export const ArtifactSchema = v.object({
53
- type: v.picklist(["plan", "diff"]),
114
+ // Derived from the schema's runtime union: a new primitive extends the wire
115
+ // contract without touching this file.
116
+ type: v.picklist(ARTIFACT_TYPES),
54
117
  content: v.string(),
55
118
  meta: v.optional(ArtifactMetaSchema, {}),
119
+ files: v.optional(v.array(DiffFileContentsSchema)),
56
120
  } satisfies EntriesOf<Artifact>);
57
121
 
58
122
  export const AnchorSchema = v.object({
@@ -60,23 +124,119 @@ export const AnchorSchema = v.object({
60
124
  prefix: v.optional(v.string(), ""),
61
125
  suffix: v.optional(v.string(), ""),
62
126
  blockIndex: v.optional(v.number()),
127
+ endBlockIndex: v.optional(v.number()),
63
128
  start: v.optional(v.number()),
64
129
  end: v.optional(v.number()),
130
+ selector: v.optional(v.string()),
65
131
  } satisfies EntriesOf<Anchor>);
66
132
 
133
+ /** The surface a note was made on; absent means the reviewed artifact. */
134
+ export const AnnotationTargetSchema: v.GenericSchema<AnnotationTarget> = v.variant("kind", [
135
+ v.object({ kind: v.literal("artifact") }),
136
+ v.object({ kind: v.literal("file"), path: NonEmpty, rev: v.picklist(["worktree", "head"]) }),
137
+ v.object({ kind: v.literal("welcome") }),
138
+ ]);
139
+
67
140
  /** Wire annotations arrive without createdAt - the daemon stamps it. */
68
141
  export const AnnotationSchema = v.object({
69
142
  id: NonEmpty,
70
- /** Open kind set (#2): built-ins are comment and suggestion. */
143
+ /** Open kind set: the built-in is comment. */
71
144
  kind: NonEmpty,
72
145
  anchor: AnchorSchema,
146
+ target: v.optional(AnnotationTargetSchema),
73
147
  body: v.string(),
74
148
  orphan: v.optional(v.boolean()),
149
+ author: v.optional(v.string()),
150
+ reviewComment: v.optional(
151
+ v.object({
152
+ severity: v.picklist(REVIEW_SEVERITIES),
153
+ title: NonEmpty,
154
+ path: NonEmpty,
155
+ line: v.pipe(v.number(), v.integer(), v.minValue(1)),
156
+ startLine: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1))),
157
+ startAnchor: v.optional(AnchorSchema),
158
+ side: v.picklist(["LEFT", "RIGHT"]),
159
+ suggestion: v.optional(v.string()),
160
+ prompt: v.optional(v.string()),
161
+ } satisfies EntriesOf<ReviewComment>),
162
+ ),
163
+ replyTo: v.optional(NonEmpty),
164
+ resolution: v.optional(
165
+ v.object({
166
+ revision: v.number(),
167
+ source: v.picklist(["agent", "drift"]),
168
+ }),
169
+ ),
75
170
  } satisfies EntriesOf<Omit<Annotation, "createdAt">>);
76
171
 
77
172
  const SessionId = NonEmpty;
78
173
 
174
+ /** A stored annotation: the wire shape plus the daemon-stamped createdAt. */
175
+ export const FullAnnotationSchema = v.object({
176
+ ...AnnotationSchema.entries,
177
+ createdAt: v.string(),
178
+ } satisfies EntriesOf<Annotation>);
179
+
180
+ export const IdentitySchema = v.object({
181
+ id: NonEmpty,
182
+ provider: v.picklist(["ssh", "github"]),
183
+ name: v.optional(v.string()),
184
+ handle: v.optional(v.string()),
185
+ } satisfies EntriesOf<Identity>);
186
+
187
+ export const ShareLinkSchema = v.object({
188
+ id: NonEmpty,
189
+ name: v.optional(v.string()),
190
+ requireAuth: v.boolean(),
191
+ allowlist: v.array(NonEmpty),
192
+ agentEnabled: v.optional(v.boolean()),
193
+ owner: v.optional(v.string()),
194
+ shareBranch: v.optional(v.string()),
195
+ } satisfies EntriesOf<ShareLink>);
196
+
197
+ export const HarnessBindingSchema = v.object({
198
+ id: NonEmpty,
199
+ threadId: NonEmpty,
200
+ harness: NonEmpty,
201
+ harnessSessionId: NonEmpty,
202
+ createdAt: NonEmpty,
203
+ approvedRetryMessageId: v.optional(NonEmpty),
204
+ } satisfies EntriesOf<HarnessBinding>);
205
+
206
+ export const DeliverySchema = v.object({
207
+ id: NonEmpty,
208
+ messageId: NonEmpty,
209
+ bindingId: NonEmpty,
210
+ status: v.picklist(["pending", "acknowledged"]),
211
+ createdAt: NonEmpty,
212
+ acknowledgedAt: v.optional(NonEmpty),
213
+ } satisfies EntriesOf<Delivery>);
214
+
79
215
  export const Params = {
216
+ "agent.configure": v.object({
217
+ id: SessionId,
218
+ configId: v.optional(NonEmpty),
219
+ value: v.optional(NonEmpty),
220
+ }),
221
+ "agent.get": v.object({ id: SessionId }),
222
+ "agent.prompt": v.object({
223
+ id: SessionId,
224
+ text: v.pipe(v.string(), v.maxLength(65_536)),
225
+ context: v.optional(v.pipe(v.string(), v.maxLength(32_768))),
226
+ retry: v.optional(NonEmpty),
227
+ commentId: v.optional(NonEmpty),
228
+ inputOnly: v.optional(v.boolean()),
229
+ discussion: v.optional(v.pipe(v.string(), v.maxLength(320_000))),
230
+ operationId: v.optional(v.pipe(NonEmpty, v.maxLength(128))),
231
+ }),
232
+ "agent.reply": v.object({
233
+ id: SessionId,
234
+ commentId: NonEmpty,
235
+ body: v.pipe(NonEmpty, v.maxLength(32_768)),
236
+ }),
237
+ "agent.cancel": v.object({ id: SessionId }),
238
+ "agent.comment": v.object({ id: SessionId, comment: AgentCommentSchema }),
239
+ "agent.permission": v.object({ id: SessionId, requestId: NonEmpty, optionId: NonEmpty }),
80
240
  "session.create": v.object({ workspace: WorkspaceSchema, artifact: ArtifactSchema }),
81
241
  "session.get": v.object({ id: SessionId }),
82
242
  "session.list": v.object({
@@ -90,20 +250,142 @@ export const Params = {
90
250
  "session.wait": v.object({
91
251
  id: SessionId,
92
252
  // 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),
253
+ timeoutMs: v.optional(
254
+ v.pipe(v.number(), v.minValue(0), v.maxValue(24 * 60 * 60 * 1000)),
255
+ 60_000,
256
+ ),
257
+ }),
258
+ "session.annotate": v.object({
259
+ id: SessionId,
260
+ annotation: AnnotationSchema,
261
+ authorName: v.optional(v.string()),
262
+ }),
263
+ // "session.comment" is the primary annotate method; "session.annotate" stays an accepted alias
264
+ "session.comment": v.object({
265
+ id: SessionId,
266
+ annotation: AnnotationSchema,
267
+ authorName: v.optional(v.string()),
94
268
  }),
95
- "session.annotate": v.object({ id: SessionId, annotation: AnnotationSchema }),
96
269
  "session.removeAnnotation": v.object({ id: SessionId, annotationId: NonEmpty }),
97
- "session.setWorkingCopy": v.object({ id: SessionId, workingCopy: v.optional(v.string()) }),
98
- "session.resolve": v.object({
270
+ "session.setParticipantName": v.object({ id: SessionId, author: NonEmpty, name: NonEmpty }),
271
+ "session.setWorkingCopy": v.object({
272
+ id: SessionId,
273
+ workingCopy: v.optional(v.string()),
274
+ textCuts: v.optional(TextCutsSchema),
275
+ }),
276
+ "session.cutBlock": v.object({
277
+ id: SessionId,
278
+ blockIndex: v.pipe(v.number(), v.integer(), v.minValue(0)),
279
+ }),
280
+ "session.restoreBlock": v.object({
99
281
  id: SessionId,
100
- verdictKind: v.picklist(["comment", "approve", "request_changes"]),
282
+ baseBlockIndex: v.pipe(v.number(), v.integer(), v.minValue(0)),
283
+ line: v.optional(v.pipe(v.number(), v.integer(), v.minValue(0))),
284
+ }),
285
+ "session.curate": v.object({
286
+ id: SessionId,
287
+ rejections: v.array(
288
+ v.object({
289
+ path: NonEmpty,
290
+ hunkIndex: v.pipe(v.number(), v.integer(), v.minValue(0)),
291
+ changeIndex: v.optional(v.pipe(v.number(), v.integer(), v.minValue(0))),
292
+ } satisfies EntriesOf<HunkRejection>),
293
+ ),
294
+ }),
295
+ "session.setViewed": v.object({ id: SessionId, viewedPaths: v.array(v.string()) }),
296
+ "session.setTitle": v.object({ id: SessionId, title: v.string() }),
297
+ "session.projectFiles": v.object({ id: SessionId }),
298
+ "session.fileContents": v.object({ id: SessionId, path: NonEmpty }),
299
+ "repo.files": v.object({ cwd: NonEmpty }),
300
+ "repo.fileContents": v.object({ cwd: NonEmpty, path: NonEmpty }),
301
+ "repo.changes": v.object({ cwd: NonEmpty }),
302
+ "repo.diff": v.object({ cwd: NonEmpty, vcs: v.optional(NonEmpty) }),
303
+ "session.workbench": v.object({ cwd: NonEmpty }),
304
+ "session.workbenchReview": v.object({ cwd: NonEmpty }),
305
+ "session.navigate": v.object({
306
+ id: SessionId,
307
+ entryId: NonEmpty,
308
+ summary: v.optional(v.string()),
309
+ // stand on this branch first, so a move on another branch is one request
310
+ branch: v.optional(NonEmpty),
311
+ }),
312
+ "session.branch": v.object({ id: SessionId, name: NonEmpty }),
313
+ "session.switch": v.object({ id: SessionId, branch: NonEmpty }),
314
+ "session.label": v.object({ id: SessionId, label: NonEmpty }),
315
+ "session.fork": v.object({ id: SessionId }),
316
+ "session.refreshDiff": v.object({ id: SessionId }),
317
+ "session.setShares": v.object({ id: SessionId, shares: v.array(ShareLinkSchema) }),
318
+ "session.delete": v.object({ id: SessionId }),
319
+ "session.mergeShared": v.object({
320
+ id: SessionId,
321
+ shareId: v.optional(NonEmpty),
322
+ annotations: v.array(FullAnnotationSchema),
323
+ participants: v.optional(v.array(IdentitySchema)),
324
+ // the removal entries a share recorded, carried by id so a merge applies each once
325
+ removals: v.optional(
326
+ v.array(v.object({ id: NonEmpty, annotationId: NonEmpty, createdAt: v.string() })),
327
+ ),
328
+ }),
329
+ "session.sendMessage": v.object({
330
+ id: SessionId,
331
+ outcome: v.picklist(MESSAGE_OUTCOMES),
101
332
  summary: v.optional(v.string(), ""),
333
+ actionBodies: v.optional(v.record(v.string(), v.string())),
334
+ operationId: v.optional(v.pipe(NonEmpty, v.maxLength(128))),
335
+ }),
336
+ "harness.bind": v.object({
337
+ threadId: SessionId,
338
+ harness: NonEmpty,
339
+ harnessSessionId: NonEmpty,
340
+ }),
341
+ "harness.getBinding": v.object({ bindingId: NonEmpty }),
342
+ "harness.bindingsForSession": v.object({ harness: NonEmpty, harnessSessionId: NonEmpty }),
343
+ "harness.consumeApprovedRetry": v.object({
344
+ bindingId: NonEmpty,
345
+ messageId: NonEmpty,
346
+ content: v.string(),
347
+ }),
348
+ "delivery.pending": v.object({ bindingId: NonEmpty }),
349
+ "delivery.acknowledge": v.object({ deliveryId: NonEmpty }),
350
+ "session.submitRevision": v.object({
351
+ id: SessionId,
352
+ content: v.string(),
353
+ files: v.optional(v.array(DiffFileContentsSchema)),
354
+ source: v.optional(
355
+ v.object({
356
+ vcs: NonEmpty,
357
+ changeId: v.optional(NonEmpty),
358
+ revisionId: v.optional(NonEmpty),
359
+ baseRevisionId: v.optional(NonEmpty),
360
+ }),
361
+ ),
362
+ /** Annotation ids the agent acted on; each is marked addressed. */
363
+ addressedAnnotationIds: v.optional(v.array(NonEmpty), []),
102
364
  }),
103
- "session.submitRevision": v.object({ id: SessionId, content: v.string() }),
104
365
  "events.subscribe": v.object({}),
105
366
  "daemon.ping": v.object({}),
367
+ // the owner token proves ownership; without it a request for owner stays a collaborator
368
+ "daemon.hello": v.object({
369
+ role: v.picklist(["owner", "collaborator", "agent"]),
370
+ clientVersion: v.optional(v.string()),
371
+ token: v.optional(v.string()),
372
+ // the author a non-owner acts as, bound once for the connection
373
+ author: v.optional(NonEmpty),
374
+ }),
106
375
  "daemon.shutdown": v.object({}),
376
+ // Herdr terminal handles stay outside canonical Thread records.
377
+ "herdr.getThreadSurface": v.object({ id: SessionId }),
378
+ "herdr.setThreadSurface": v.object({
379
+ id: SessionId,
380
+ tabId: NonEmpty,
381
+ paneId: NonEmpty,
382
+ mode: v.optional(v.picklist(["tab", "pane"])),
383
+ layout: v.optional(v.literal("pair")),
384
+ }),
385
+ "ghostty.getThreadSurface": v.object({ id: SessionId }),
386
+ "ghostty.setThreadSurface": v.object({ id: SessionId, terminalId: NonEmpty }),
387
+ "ghostty.claimThreadSurface": v.object({ id: SessionId }),
388
+ "ghostty.releaseThreadSurface": v.object({ id: SessionId }),
107
389
  } as const;
108
390
 
109
391
  export type MethodName = keyof typeof Params;
@@ -113,13 +395,22 @@ export function isKnownMethod(method: string): method is MethodName {
113
395
  }
114
396
 
115
397
  /** 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]> {
398
+ export function parseParams<M extends MethodName>(
399
+ method: M,
400
+ params: Request["params"],
401
+ ): v.InferOutput<(typeof Params)[M]> {
117
402
  const result = v.safeParse(Params[method], params ?? {});
403
+
118
404
  if (!result.success) {
119
405
  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}`);
406
+ const path = issue.path?.map((pathSegment) => String(pathSegment.key)).join(".") ?? "";
407
+
408
+ throw new DaemonError(
409
+ "invalid_params",
410
+ `${method}: ${path ? path + " - " : ""}${issue.message}`,
411
+ );
122
412
  }
413
+
123
414
  return result.output;
124
415
  }
125
416
 
@@ -127,33 +418,136 @@ export const RevisionSchema = v.object({
127
418
  revision: v.number(),
128
419
  content: v.string(),
129
420
  submittedAt: v.string(),
421
+ files: v.optional(v.array(DiffFileContentsSchema)),
422
+ source: v.optional(
423
+ v.object({
424
+ vcs: NonEmpty,
425
+ changeId: v.optional(NonEmpty),
426
+ revisionId: v.optional(NonEmpty),
427
+ baseRevisionId: v.optional(NonEmpty),
428
+ }),
429
+ ),
130
430
  } satisfies EntriesOf<Revision>);
131
431
 
132
- export const VerdictSchema = v.object({
133
- kind: v.picklist(["comment", "approve", "request_changes"]),
432
+ export const MessageSchema = v.object({
433
+ id: NonEmpty,
434
+ outcome: v.picklist(MESSAGE_OUTCOMES),
134
435
  summary: v.string(),
135
- feedback: v.string(),
136
- resolvedAt: v.string(),
137
- } satisfies EntriesOf<Verdict>);
436
+ body: v.string(),
437
+ annotations: v.optional(v.array(FullAnnotationSchema)),
438
+ sentAt: v.string(),
439
+ } satisfies EntriesOf<Message>);
138
440
 
139
- /** Persisted records are validated on recovery: a bad file is skipped, not fatal. */
140
- export const SessionRecordSchema = v.object({
141
- schemaVersion: v.literal(SCHEMA_VERSION),
441
+ export const PendingDeliverySchema = v.object({
442
+ delivery: DeliverySchema,
443
+ message: MessageSchema,
444
+ } satisfies EntriesOf<PendingDelivery>);
445
+
446
+ const EntryBaseEntries = {
142
447
  id: NonEmpty,
143
- workspace: WorkspaceSchema,
144
- artifact: ArtifactSchema,
145
- revisions: v.array(RevisionSchema),
146
- annotations: v.array(v.object({ ...AnnotationSchema.entries, createdAt: v.string() } satisfies EntriesOf<Annotation>)),
147
- workingCopy: v.optional(v.string()),
148
- verdict: v.nullable(VerdictSchema),
149
- status: v.picklist(["pending", "resolved"]),
448
+ parentId: v.nullable(NonEmpty),
150
449
  createdAt: v.string(),
151
- } satisfies EntriesOf<ReviewSession>);
450
+ };
451
+
452
+ /** One record of a session's history; the tree pointers plus the entry's own fields. */
453
+ export const SessionEntrySchema = v.variant("type", [
454
+ v.object({
455
+ ...EntryBaseEntries,
456
+ type: v.literal("revision"),
457
+ by: v.picklist(["agent", "reviewer"]),
458
+ content: v.string(),
459
+ textCuts: v.optional(TextCutsSchema),
460
+ }),
461
+ v.object({ ...EntryBaseEntries, type: v.literal("comment"), annotationId: NonEmpty }),
462
+ v.object({ ...EntryBaseEntries, type: v.literal("comment-removed"), annotationId: NonEmpty }),
463
+ v.object({ ...EntryBaseEntries, type: v.literal("message"), message: MessageSchema }),
464
+ v.object({
465
+ ...EntryBaseEntries,
466
+ type: v.literal("branch-summary"),
467
+ text: v.string(),
468
+ abandoned: v.array(NonEmpty),
469
+ }),
470
+ ]);
471
+
472
+ export const SessionHistorySchema = v.pipe(
473
+ v.object({
474
+ entries: v.array(SessionEntrySchema),
475
+ tips: v.record(v.string(), NonEmpty),
476
+ branch: NonEmpty,
477
+ labels: v.record(v.string(), v.string()),
478
+ } satisfies EntriesOf<SessionHistory>),
479
+ // the shape is not enough: the tree itself must hold, or a walk hangs or rewires the path
480
+ v.rawCheck(({ dataset, addIssue }) => {
481
+ if (!dataset.typed) return;
482
+
483
+ const problem = validateHistory(dataset.value);
484
+
485
+ if (problem !== null) addIssue({ message: `history: ${problem}` });
486
+ }),
487
+ );
488
+
489
+ /** Persisted records are validated on recovery: a bad file is skipped, not fatal. */
490
+ export const ThreadRecordSchema = v.pipe(
491
+ v.strictObject({
492
+ schemaVersion: v.literal(SCHEMA_VERSION),
493
+ id: NonEmpty,
494
+ workspace: WorkspaceSchema,
495
+ artifact: ArtifactSchema,
496
+ revisions: v.array(RevisionSchema),
497
+ annotations: v.array(FullAnnotationSchema),
498
+ history: v.optional(SessionHistorySchema),
499
+ curation: v.optional(
500
+ v.array(
501
+ v.object({
502
+ path: NonEmpty,
503
+ hunkIndex: v.number(),
504
+ changeIndex: v.optional(v.number()),
505
+ } satisfies EntriesOf<HunkRejection>),
506
+ ),
507
+ ),
508
+ workingCopy: v.optional(v.string()),
509
+ textCuts: v.optional(TextCutsSchema),
510
+ viewedPaths: v.optional(v.array(v.string())),
511
+ messageOperations: v.optional(
512
+ v.array(v.object({ operationId: NonEmpty, fingerprint: NonEmpty, result: MessageSchema })),
513
+ ),
514
+ message: v.nullable(MessageSchema),
515
+ status: v.picklist(["pending", "resolved"]),
516
+ createdAt: v.string(),
517
+ shelvedAnnotations: v.optional(v.array(FullAnnotationSchema)),
518
+ parentSessionId: v.optional(v.string()),
519
+ shares: v.optional(v.array(ShareLinkSchema)),
520
+ participants: v.optional(v.array(IdentitySchema)),
521
+ agentSnapshot: v.optional(ThreadAgentSchema),
522
+ } satisfies EntriesOf<Thread>),
523
+ v.rawCheck(({ dataset, addIssue }) => {
524
+ if (!dataset.typed || !dataset.value.textCuts?.length) return;
525
+
526
+ const { artifact, textCuts, workingCopy } = dataset.value;
527
+ const matchesSource = textCuts.every(
528
+ (cut) => artifact.content.slice(cut.start, cut.end) === cut.quote,
529
+ );
530
+
531
+ if (!matchesSource || workingCopy !== applyTextCuts(artifact.content, textCuts)) {
532
+ addIssue({ message: "text Cuts do not match the submitted artifact and working copy" });
533
+ }
534
+ }),
535
+ );
536
+
537
+ export function validateThreadRecord(
538
+ raw: Parameters<typeof v.safeParse>[1],
539
+ ): { ok: true; value: v.InferOutput<typeof ThreadRecordSchema> } | { ok: false; error: string } {
540
+ const result = v.safeParse(ThreadRecordSchema, raw);
152
541
 
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
542
  if (result.success) return { ok: true, value: result.output };
543
+
156
544
  const issue = result.issues[0]!;
157
- const path = issue.path?.map((p) => String(p.key)).join(".") ?? "";
545
+ const path = issue.path?.map((pathSegment) => String(pathSegment.key)).join(".") ?? "";
546
+
158
547
  return { ok: false, error: `${path ? path + ": " : ""}${issue.message}` };
159
548
  }
549
+
550
+ /** @deprecated use ThreadRecordSchema */
551
+ export const SessionRecordSchema = ThreadRecordSchema;
552
+ /** @deprecated use validateThreadRecord */
553
+ export const validateSessionRecord = validateThreadRecord;