@cueloop/daemon 0.1.0-alpha.7 → 0.1.0-alpha.71
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 +33 -22
- package/src/api.test.ts +891 -50
- package/src/api.ts +849 -72
- package/src/capabilities.test.ts +57 -0
- package/src/capabilities.ts +77 -0
- package/src/client.test.ts +24 -0
- package/src/client.ts +459 -69
- package/src/curate.ts +109 -0
- package/src/daemon-version.test.ts +93 -0
- package/src/diff-hot-reload.test.ts +186 -0
- package/src/diff-watcher.ts +102 -0
- package/src/herdr-pane.test.ts +240 -0
- package/src/herdr-pane.ts +167 -0
- package/src/herdr-split.test.ts +106 -0
- package/src/herdr-split.ts +92 -0
- package/src/herdr-tab-store.test.ts +96 -0
- package/src/herdr-tab-store.ts +66 -0
- package/src/index.ts +8 -9
- package/src/interruptible-wait.test.ts +127 -0
- package/src/interruptible-wait.ts +66 -0
- package/src/main.ts +4 -1
- package/src/paths.ts +39 -0
- package/src/project-files.test.ts +124 -0
- package/src/project-files.ts +56 -0
- package/src/protocol.ts +79 -7
- package/src/retention.test.ts +182 -0
- package/src/retention.ts +122 -0
- package/src/review.test.ts +255 -28
- package/src/review.ts +164 -62
- package/src/server.test.ts +381 -39
- package/src/server.ts +322 -84
- package/src/share-blob.test.ts +112 -0
- package/src/share-blob.ts +55 -0
- package/src/single-instance.test.ts +38 -11
- package/src/store.conformance.test.ts +41 -0
- package/src/store.jsonl.test.ts +159 -0
- package/src/store.ts +261 -34
- package/src/testing/store-conformance.ts +194 -0
- package/src/validate.test.ts +174 -28
- package/src/validate.ts +226 -16
- package/src/version.ts +6 -0
- package/src/working-tree.ts +149 -0
package/src/validate.test.ts
CHANGED
|
@@ -9,21 +9,24 @@ import {
|
|
|
9
9
|
AnnotationSchema,
|
|
10
10
|
ArtifactMetaSchema,
|
|
11
11
|
ArtifactSchema,
|
|
12
|
+
IdentitySchema,
|
|
12
13
|
RevisionSchema,
|
|
13
|
-
|
|
14
|
+
ThreadRecordSchema,
|
|
14
15
|
VerdictSchema,
|
|
15
16
|
WorkspaceSchema,
|
|
16
17
|
isKnownMethod,
|
|
17
18
|
parseParams,
|
|
18
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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(() =>
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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("
|
|
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(
|
|
149
|
+
expect(validateThreadRecord(record).ok).toBe(true);
|
|
112
150
|
});
|
|
113
151
|
|
|
114
152
|
test("rejects a foreign schema version with a readable reason", () => {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
122
|
-
|
|
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
|
|
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 = (
|
|
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> = {
|
|
155
|
-
|
|
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> = {
|
|
158
|
-
|
|
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(
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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>(
|
|
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((
|
|
121
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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((
|
|
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;
|