@cueloop/daemon 0.1.0-alpha.0 → 0.1.0-alpha.100

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