@cueloop/daemon 0.1.0-alpha.5 → 0.1.0-alpha.51
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 +30 -22
- package/src/api.test.ts +278 -46
- package/src/api.ts +260 -52
- package/src/capabilities.test.ts +38 -0
- package/src/capabilities.ts +30 -0
- package/src/client.ts +128 -36
- package/src/diff-hot-reload.test.ts +159 -0
- package/src/diff-watcher.ts +99 -0
- package/src/herdr-pane.test.ts +236 -0
- package/src/herdr-pane.ts +143 -0
- package/src/herdr-split.test.ts +103 -0
- package/src/herdr-split.ts +84 -0
- package/src/herdr-tab-store.test.ts +95 -0
- package/src/herdr-tab-store.ts +67 -0
- package/src/index.ts +8 -8
- package/src/main.ts +3 -1
- package/src/paths.ts +9 -0
- package/src/protocol.ts +44 -7
- package/src/retention.test.ts +174 -0
- package/src/retention.ts +106 -0
- package/src/review.test.ts +362 -0
- package/src/review.ts +265 -0
- package/src/server.test.ts +151 -20
- package/src/server.ts +122 -55
- package/src/share-blob.test.ts +62 -0
- package/src/share-blob.ts +51 -0
- package/src/single-instance.test.ts +28 -11
- package/src/store.ts +7 -5
- package/src/validate.test.ts +184 -14
- package/src/validate.ts +133 -38
- package/src/working-tree.ts +100 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Single-instance guarantee
|
|
2
|
+
* Single-instance guarantee: one daemon per home. Two daemons over one
|
|
3
3
|
* state directory means divergent in-memory sessions and clients seeing
|
|
4
4
|
* whichever half they connect to.
|
|
5
5
|
*/
|
|
@@ -23,33 +23,39 @@ beforeEach(() => {
|
|
|
23
23
|
home = mkdtempSync(join(tmpdir(), "cueloop-lock-"));
|
|
24
24
|
});
|
|
25
25
|
afterEach(() => {
|
|
26
|
-
for (const
|
|
26
|
+
for (const runningServer of servers.splice(0)) runningServer.stop();
|
|
27
27
|
rmSync(home, { recursive: true, force: true });
|
|
28
28
|
});
|
|
29
29
|
|
|
30
30
|
function server(): DaemonServer {
|
|
31
|
-
const
|
|
32
|
-
servers.push(
|
|
33
|
-
return
|
|
31
|
+
const daemonServer = new DaemonServer({ home, idleExitMs: 0 });
|
|
32
|
+
servers.push(daemonServer);
|
|
33
|
+
return daemonServer;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
describe("one daemon per home", () => {
|
|
37
37
|
test("the second start is refused instead of stealing the socket", () => {
|
|
38
|
+
// Arrange
|
|
38
39
|
const first = server();
|
|
39
40
|
const path = first.start();
|
|
40
41
|
expect(path).toBe(socketPath(home));
|
|
41
42
|
const second = server();
|
|
43
|
+
|
|
44
|
+
// Assert
|
|
42
45
|
expect(second.start()).toBeNull();
|
|
43
46
|
// the winner's socket survives untouched
|
|
44
47
|
expect(existsSync(path!)).toBe(true);
|
|
45
48
|
});
|
|
46
49
|
|
|
47
50
|
test("clients keep talking to the one live daemon after a refused start", async () => {
|
|
51
|
+
// Arrange
|
|
48
52
|
const first = server();
|
|
49
53
|
first.start();
|
|
50
54
|
const created = first.core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
51
55
|
server().start(); // refused
|
|
52
56
|
const client = await DaemonClient.connect({ home });
|
|
57
|
+
|
|
58
|
+
// Assert
|
|
53
59
|
try {
|
|
54
60
|
// no split brain: the session created on the winner is visible
|
|
55
61
|
expect((await client.sessionGet(created.id)).id).toBe(created.id);
|
|
@@ -60,36 +66,47 @@ describe("one daemon per home", () => {
|
|
|
60
66
|
});
|
|
61
67
|
|
|
62
68
|
test("a stale lock from a crashed daemon is reclaimed", () => {
|
|
69
|
+
// Arrange
|
|
63
70
|
// a pid that cannot be running (init is never a cueloop daemon, and this
|
|
64
71
|
// simulates the record a crashed process leaves behind)
|
|
65
72
|
writeFileSync(lockPath(home), "999999");
|
|
66
|
-
const
|
|
67
|
-
|
|
73
|
+
const daemonServer = server();
|
|
74
|
+
|
|
75
|
+
// Assert
|
|
76
|
+
expect(daemonServer.start()).toBe(socketPath(home));
|
|
68
77
|
});
|
|
69
78
|
|
|
70
79
|
test("stopping releases the lock so a restart works", () => {
|
|
80
|
+
// Arrange
|
|
71
81
|
const first = server();
|
|
72
82
|
first.start();
|
|
83
|
+
|
|
84
|
+
// Act
|
|
73
85
|
first.stop();
|
|
86
|
+
|
|
87
|
+
// Assert
|
|
74
88
|
expect(existsSync(lockPath(home))).toBe(false);
|
|
75
89
|
const second = server();
|
|
76
90
|
expect(second.start()).toBe(socketPath(home));
|
|
77
91
|
});
|
|
78
92
|
|
|
79
93
|
test("concurrent autostarts converge on a single daemon", async () => {
|
|
94
|
+
// Act
|
|
80
95
|
// several clients race to autostart; all must end up on the same daemon
|
|
81
96
|
const clients = await Promise.all([
|
|
82
97
|
DaemonClient.connect({ home, autostart: true }),
|
|
83
98
|
DaemonClient.connect({ home, autostart: true }),
|
|
84
99
|
DaemonClient.connect({ home, autostart: true }),
|
|
85
100
|
]);
|
|
101
|
+
|
|
102
|
+
// Assert
|
|
86
103
|
try {
|
|
87
|
-
const pids = await Promise.all(clients.map((
|
|
88
|
-
const unique = new Set(pids.map((
|
|
104
|
+
const pids = await Promise.all(clients.map((client) => client.ping()));
|
|
105
|
+
const unique = new Set(pids.map((ping) => ping.pid));
|
|
89
106
|
expect(unique.size).toBe(1);
|
|
90
107
|
// and state is shared: one client's session is visible to the others
|
|
91
|
-
const
|
|
92
|
-
expect((await clients[2]!.sessionGet(
|
|
108
|
+
const session = await clients[0]!.sessionCreate(WS, PLAN);
|
|
109
|
+
expect((await clients[2]!.sessionGet(session.id)).id).toBe(session.id);
|
|
93
110
|
} finally {
|
|
94
111
|
for (const c of clients) c.close();
|
|
95
112
|
// the autostarted daemon is not one of `servers` - shut it down
|
package/src/store.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Session persistence
|
|
2
|
+
* Session persistence: one JSON document per session, every write
|
|
3
3
|
* through a temp file + atomic rename so a crash mid-write can never leave
|
|
4
4
|
* a corrupt record. Recovery is a read-only scan; records that fail to
|
|
5
5
|
* parse are skipped and reported, never deleted.
|
|
@@ -49,15 +49,17 @@ export class SessionStore {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
list(): ReviewSession[] {
|
|
52
|
-
return [...this.sessions.values()].sort((
|
|
52
|
+
return [...this.sessions.values()].sort((left, right) =>
|
|
53
|
+
left.createdAt.localeCompare(right.createdAt),
|
|
54
|
+
);
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
upsert(session: ReviewSession): void {
|
|
56
58
|
this.sessions.set(session.id, session);
|
|
57
59
|
const path = join(this.dir, `${session.id}.json`);
|
|
58
|
-
const
|
|
59
|
-
writeFileSync(
|
|
60
|
-
renameSync(
|
|
60
|
+
const tempPath = path + ".tmp";
|
|
61
|
+
writeFileSync(tempPath, JSON.stringify(session, null, 2));
|
|
62
|
+
renameSync(tempPath, path);
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
delete(id: string): boolean {
|
package/src/validate.test.ts
CHANGED
|
@@ -1,7 +1,35 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { DaemonCore } from "./api";
|
|
2
6
|
import { DaemonError } from "./errors";
|
|
3
|
-
import {
|
|
4
|
-
|
|
7
|
+
import {
|
|
8
|
+
AnchorSchema,
|
|
9
|
+
AnnotationSchema,
|
|
10
|
+
ArtifactMetaSchema,
|
|
11
|
+
ArtifactSchema,
|
|
12
|
+
IdentitySchema,
|
|
13
|
+
RevisionSchema,
|
|
14
|
+
SessionRecordSchema,
|
|
15
|
+
VerdictSchema,
|
|
16
|
+
WorkspaceSchema,
|
|
17
|
+
isKnownMethod,
|
|
18
|
+
parseParams,
|
|
19
|
+
validateSessionRecord,
|
|
20
|
+
} from "./validate";
|
|
21
|
+
import {
|
|
22
|
+
SCHEMA_VERSION,
|
|
23
|
+
type Anchor,
|
|
24
|
+
type Annotation,
|
|
25
|
+
type Artifact,
|
|
26
|
+
type ArtifactMeta,
|
|
27
|
+
type Identity,
|
|
28
|
+
type ReviewSession,
|
|
29
|
+
type Revision,
|
|
30
|
+
type Verdict,
|
|
31
|
+
type WorkspaceKey,
|
|
32
|
+
} from "@cueloop/schema";
|
|
5
33
|
|
|
6
34
|
describe("method allowlist", () => {
|
|
7
35
|
test("known and unknown methods", () => {
|
|
@@ -12,11 +40,14 @@ describe("method allowlist", () => {
|
|
|
12
40
|
|
|
13
41
|
describe("parseParams", () => {
|
|
14
42
|
test("accepts a well-formed create and defaults meta", () => {
|
|
15
|
-
|
|
43
|
+
// Act
|
|
44
|
+
const params = parseParams("session.create", {
|
|
16
45
|
workspace: { repoRoot: "/repo", branch: "main" },
|
|
17
46
|
artifact: { type: "plan", content: "# P" },
|
|
18
47
|
});
|
|
19
|
-
|
|
48
|
+
|
|
49
|
+
// Assert
|
|
50
|
+
expect(params.artifact.meta).toEqual({});
|
|
20
51
|
});
|
|
21
52
|
|
|
22
53
|
test("rejects a missing workspace with a pathed message", () => {
|
|
@@ -46,21 +77,40 @@ describe("parseParams", () => {
|
|
|
46
77
|
test("defaults and clamps the wait timeout", () => {
|
|
47
78
|
expect(parseParams("session.wait", { id: "ses_1" }).timeoutMs).toBe(60_000);
|
|
48
79
|
expect(() => parseParams("session.wait", { id: "ses_1", timeoutMs: -5 })).toThrow();
|
|
49
|
-
expect(() =>
|
|
80
|
+
expect(() =>
|
|
81
|
+
parseParams("session.wait", { id: "ses_1", timeoutMs: 99 * 60 * 60 * 1000 }),
|
|
82
|
+
).toThrow();
|
|
50
83
|
});
|
|
51
84
|
|
|
52
85
|
test("verdict kinds are closed", () => {
|
|
53
86
|
expect(parseParams("session.resolve", { id: "s", verdictKind: "approve" }).summary).toBe("");
|
|
54
|
-
expect(() => parseParams("session.resolve", { id: "s", verdictKind: "lgtm" })).toThrow(
|
|
87
|
+
expect(() => parseParams("session.resolve", { id: "s", verdictKind: "lgtm" })).toThrow(
|
|
88
|
+
/verdictKind/,
|
|
89
|
+
);
|
|
55
90
|
});
|
|
56
91
|
|
|
57
92
|
test("annotation kinds stay open (extension kinds are allowed)", () => {
|
|
58
|
-
|
|
93
|
+
// Act
|
|
94
|
+
const params = parseParams("session.annotate", {
|
|
59
95
|
id: "s",
|
|
60
96
|
annotation: { id: "a1", kind: "praise", anchor: { quote: "x" }, body: "nice" },
|
|
61
97
|
});
|
|
62
|
-
|
|
63
|
-
|
|
98
|
+
|
|
99
|
+
// Assert
|
|
100
|
+
expect(params.annotation.kind).toBe("praise");
|
|
101
|
+
expect(params.annotation.anchor.prefix).toBe("");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("setViewed takes a full path list and rejects non-string entries", () => {
|
|
105
|
+
expect(
|
|
106
|
+
parseParams("session.setViewed", { id: "s", viewedPaths: ["src/a.ts", "src/b.ts"] })
|
|
107
|
+
.viewedPaths,
|
|
108
|
+
).toEqual(["src/a.ts", "src/b.ts"]);
|
|
109
|
+
expect(parseParams("session.setViewed", { id: "s", viewedPaths: [] }).viewedPaths).toEqual([]);
|
|
110
|
+
expect(() => parseParams("session.setViewed", { id: "s", viewedPaths: [1] })).toThrow(
|
|
111
|
+
/viewedPaths/,
|
|
112
|
+
);
|
|
113
|
+
expect(() => parseParams("session.setViewed", { id: "s" })).toThrow(/viewedPaths/);
|
|
64
114
|
});
|
|
65
115
|
|
|
66
116
|
test("null params are treated as empty", () => {
|
|
@@ -86,13 +136,133 @@ describe("validateSessionRecord", () => {
|
|
|
86
136
|
});
|
|
87
137
|
|
|
88
138
|
test("rejects a foreign schema version with a readable reason", () => {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
139
|
+
// Act
|
|
140
|
+
const result = validateSessionRecord({ ...record, schemaVersion: "99" });
|
|
141
|
+
|
|
142
|
+
// Assert
|
|
143
|
+
expect(result.ok).toBe(false);
|
|
144
|
+
if (!result.ok) expect(result.error).toContain("schemaVersion");
|
|
92
145
|
});
|
|
93
146
|
|
|
94
147
|
test("rejects a structurally broken record", () => {
|
|
95
|
-
|
|
96
|
-
|
|
148
|
+
// Act
|
|
149
|
+
const result = validateSessionRecord({ ...record, revisions: "nope" });
|
|
150
|
+
|
|
151
|
+
// Assert
|
|
152
|
+
expect(result.ok).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Wire pins: every schema's entries must cover exactly the keys of the
|
|
158
|
+
* schema type it mirrors. Runtime complement to the compile-time EntriesOf
|
|
159
|
+
* check - the samples are fully populated and typed, so a new field in
|
|
160
|
+
* @cueloop/schema shows up here too.
|
|
161
|
+
*/
|
|
162
|
+
describe("wire pins", () => {
|
|
163
|
+
const keys = (subject: object) => Object.keys(subject).sort();
|
|
164
|
+
const entryKeys = (schema: { entries: object }) => Object.keys(schema.entries).sort();
|
|
165
|
+
|
|
166
|
+
const fullMeta: Required<ArtifactMeta> = {
|
|
167
|
+
cwd: "/repo",
|
|
168
|
+
agent: "claude-code",
|
|
169
|
+
agentSessionId: "sess-1",
|
|
170
|
+
planPath: "/repo/plan.md",
|
|
171
|
+
prototypePath: "/repo/proto.html",
|
|
172
|
+
pr: "org/repo#1",
|
|
173
|
+
herdrPane: "%7",
|
|
174
|
+
title: "Plan",
|
|
175
|
+
};
|
|
176
|
+
const fullAnchor: Required<Anchor> = {
|
|
177
|
+
quote: "q",
|
|
178
|
+
prefix: "p",
|
|
179
|
+
suffix: "s",
|
|
180
|
+
blockIndex: 0,
|
|
181
|
+
start: 0,
|
|
182
|
+
end: 1,
|
|
183
|
+
selector: "div.card",
|
|
184
|
+
};
|
|
185
|
+
const fullAnnotation: Required<Annotation> = {
|
|
186
|
+
id: "a1",
|
|
187
|
+
kind: "comment",
|
|
188
|
+
anchor: fullAnchor,
|
|
189
|
+
body: "b",
|
|
190
|
+
orphan: false,
|
|
191
|
+
author: "SHA256:abc",
|
|
192
|
+
resolution: { revision: 2, source: "agent" },
|
|
193
|
+
createdAt: "now",
|
|
194
|
+
};
|
|
195
|
+
const fullArtifact: Required<Artifact> = {
|
|
196
|
+
type: "plan",
|
|
197
|
+
content: "# P",
|
|
198
|
+
meta: fullMeta,
|
|
199
|
+
files: [{ path: "src/a.ts", oldContents: "old\n", newContents: "new\n", status: "modified" }],
|
|
200
|
+
};
|
|
201
|
+
const fullVerdict: Required<Verdict> = {
|
|
202
|
+
kind: "approve",
|
|
203
|
+
summary: "",
|
|
204
|
+
feedback: "",
|
|
205
|
+
resolvedAt: "now",
|
|
206
|
+
};
|
|
207
|
+
const fullRevision: Required<Revision> = { revision: 1, content: "# P", submittedAt: "now" };
|
|
208
|
+
const fullWorkspace: Required<WorkspaceKey> = { repoRoot: "/repo", branch: "main" };
|
|
209
|
+
const fullIdentity: Required<Identity> = {
|
|
210
|
+
id: "SHA256:abc",
|
|
211
|
+
provider: "ssh",
|
|
212
|
+
name: "Al",
|
|
213
|
+
handle: "abc",
|
|
214
|
+
};
|
|
215
|
+
const fullSession: Required<ReviewSession> = {
|
|
216
|
+
schemaVersion: SCHEMA_VERSION,
|
|
217
|
+
id: "ses_1",
|
|
218
|
+
workspace: fullWorkspace,
|
|
219
|
+
artifact: fullArtifact,
|
|
220
|
+
revisions: [fullRevision],
|
|
221
|
+
annotations: [fullAnnotation],
|
|
222
|
+
workingCopy: "# P edited",
|
|
223
|
+
viewedPaths: ["src/a.ts"],
|
|
224
|
+
verdict: fullVerdict,
|
|
225
|
+
status: "pending",
|
|
226
|
+
createdAt: "now",
|
|
227
|
+
shareId: "p_abc123xy",
|
|
228
|
+
owner: "SHA256:owner",
|
|
229
|
+
participants: [fullIdentity],
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
test("schema key sets match the schema types", () => {
|
|
233
|
+
expect(entryKeys(WorkspaceSchema)).toEqual(keys(fullWorkspace));
|
|
234
|
+
expect(entryKeys(ArtifactMetaSchema)).toEqual(keys(fullMeta));
|
|
235
|
+
expect(entryKeys(ArtifactSchema)).toEqual(keys(fullArtifact));
|
|
236
|
+
expect(entryKeys(AnchorSchema)).toEqual(keys(fullAnchor));
|
|
237
|
+
// wire annotations arrive without createdAt; the daemon stamps it
|
|
238
|
+
const { createdAt: _stamped, ...wireAnnotation } = fullAnnotation;
|
|
239
|
+
expect(entryKeys(AnnotationSchema)).toEqual(keys(wireAnnotation));
|
|
240
|
+
expect(entryKeys(RevisionSchema)).toEqual(keys(fullRevision));
|
|
241
|
+
expect(entryKeys(VerdictSchema)).toEqual(keys(fullVerdict));
|
|
242
|
+
expect(entryKeys(IdentitySchema)).toEqual(keys(fullIdentity));
|
|
243
|
+
expect(entryKeys(SessionRecordSchema)).toEqual(keys(fullSession));
|
|
244
|
+
// persisted annotations carry the stamped createdAt
|
|
245
|
+
const stored = SessionRecordSchema.entries.annotations.item;
|
|
246
|
+
expect(entryKeys(stored)).toEqual(keys(fullAnnotation));
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("a fully-populated meta survives validation and DaemonCore unchanged", () => {
|
|
250
|
+
const home = mkdtempSync(join(tmpdir(), "cueloop-val-"));
|
|
251
|
+
try {
|
|
252
|
+
// Arrange
|
|
253
|
+
const params = parseParams("session.create", {
|
|
254
|
+
workspace: fullWorkspace,
|
|
255
|
+
artifact: { type: "plan", content: "# P", meta: fullMeta },
|
|
256
|
+
});
|
|
257
|
+
const core = new DaemonCore(home);
|
|
258
|
+
|
|
259
|
+
// Act
|
|
260
|
+
const created = core.sessionCreate(params);
|
|
261
|
+
|
|
262
|
+
// Assert
|
|
263
|
+
expect(core.sessionGet(created.id).artifact.meta).toEqual(fullMeta);
|
|
264
|
+
} finally {
|
|
265
|
+
rmSync(home, { recursive: true, force: true });
|
|
266
|
+
}
|
|
97
267
|
});
|
|
98
268
|
});
|
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
|
|
@@ -10,31 +10,61 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import * as v from "valibot";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
SCHEMA_VERSION,
|
|
15
|
+
type Anchor,
|
|
16
|
+
type Annotation,
|
|
17
|
+
type Artifact,
|
|
18
|
+
type ArtifactMeta,
|
|
19
|
+
type DiffFileContents,
|
|
20
|
+
type Identity,
|
|
21
|
+
type ReviewSession,
|
|
22
|
+
type Revision,
|
|
23
|
+
type Verdict,
|
|
24
|
+
type WorkspaceKey,
|
|
25
|
+
} from "@cueloop/schema";
|
|
14
26
|
import { DaemonError } from "./errors";
|
|
15
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Drift guard for every hand-mirrored shape below. v.object strips keys it
|
|
30
|
+
* does not know, so a schema missing a field silently drops that field on
|
|
31
|
+
* the way into DaemonCore. Each entries object is checked with
|
|
32
|
+
* `satisfies EntriesOf<T>`: adding a field to the schema types without
|
|
33
|
+
* mirroring it here (or mirroring a key that does not exist) fails typecheck.
|
|
34
|
+
*/
|
|
35
|
+
type EntriesOf<T> = { [K in keyof T]-?: v.GenericSchema<any, any> };
|
|
36
|
+
|
|
16
37
|
const NonEmpty = v.pipe(v.string(), v.minLength(1));
|
|
17
38
|
|
|
18
39
|
export const WorkspaceSchema = v.object({
|
|
19
40
|
repoRoot: NonEmpty,
|
|
20
41
|
branch: NonEmpty,
|
|
21
|
-
});
|
|
42
|
+
} satisfies EntriesOf<WorkspaceKey>);
|
|
43
|
+
|
|
44
|
+
export const ArtifactMetaSchema = v.object({
|
|
45
|
+
cwd: v.optional(v.string()),
|
|
46
|
+
agent: v.optional(v.string()),
|
|
47
|
+
agentSessionId: v.optional(v.string()),
|
|
48
|
+
planPath: v.optional(v.string()),
|
|
49
|
+
prototypePath: v.optional(v.string()),
|
|
50
|
+
pr: v.optional(v.string()),
|
|
51
|
+
herdrPane: v.optional(v.string()),
|
|
52
|
+
title: v.optional(v.string()),
|
|
53
|
+
} satisfies EntriesOf<ArtifactMeta>);
|
|
54
|
+
|
|
55
|
+
export const DiffFileContentsSchema = v.object({
|
|
56
|
+
path: NonEmpty,
|
|
57
|
+
oldContents: v.string(),
|
|
58
|
+
newContents: v.string(),
|
|
59
|
+
status: v.picklist(["added", "modified", "deleted"]),
|
|
60
|
+
} satisfies EntriesOf<DiffFileContents>);
|
|
22
61
|
|
|
23
62
|
export const ArtifactSchema = v.object({
|
|
24
|
-
type: v.picklist(["plan", "diff"]),
|
|
63
|
+
type: v.picklist(["plan", "diff", "prototype"]),
|
|
25
64
|
content: v.string(),
|
|
26
|
-
meta: v.optional(
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
});
|
|
65
|
+
meta: v.optional(ArtifactMetaSchema, {}),
|
|
66
|
+
files: v.optional(v.array(DiffFileContentsSchema)),
|
|
67
|
+
} satisfies EntriesOf<Artifact>);
|
|
38
68
|
|
|
39
69
|
export const AnchorSchema = v.object({
|
|
40
70
|
quote: v.string(),
|
|
@@ -43,19 +73,41 @@ export const AnchorSchema = v.object({
|
|
|
43
73
|
blockIndex: v.optional(v.number()),
|
|
44
74
|
start: v.optional(v.number()),
|
|
45
75
|
end: v.optional(v.number()),
|
|
46
|
-
|
|
76
|
+
selector: v.optional(v.string()),
|
|
77
|
+
} satisfies EntriesOf<Anchor>);
|
|
47
78
|
|
|
79
|
+
/** Wire annotations arrive without createdAt - the daemon stamps it. */
|
|
48
80
|
export const AnnotationSchema = v.object({
|
|
49
81
|
id: NonEmpty,
|
|
50
|
-
/** Open kind set
|
|
82
|
+
/** Open kind set: the built-in is comment. */
|
|
51
83
|
kind: NonEmpty,
|
|
52
84
|
anchor: AnchorSchema,
|
|
53
85
|
body: v.string(),
|
|
54
86
|
orphan: v.optional(v.boolean()),
|
|
55
|
-
|
|
87
|
+
author: v.optional(v.string()),
|
|
88
|
+
resolution: v.optional(
|
|
89
|
+
v.object({
|
|
90
|
+
revision: v.number(),
|
|
91
|
+
source: v.picklist(["agent", "drift"]),
|
|
92
|
+
}),
|
|
93
|
+
),
|
|
94
|
+
} satisfies EntriesOf<Omit<Annotation, "createdAt">>);
|
|
56
95
|
|
|
57
96
|
const SessionId = NonEmpty;
|
|
58
97
|
|
|
98
|
+
/** A stored annotation: the wire shape plus the daemon-stamped createdAt. */
|
|
99
|
+
export const FullAnnotationSchema = v.object({
|
|
100
|
+
...AnnotationSchema.entries,
|
|
101
|
+
createdAt: v.string(),
|
|
102
|
+
} satisfies EntriesOf<Annotation>);
|
|
103
|
+
|
|
104
|
+
export const IdentitySchema = v.object({
|
|
105
|
+
id: NonEmpty,
|
|
106
|
+
provider: v.literal("ssh"),
|
|
107
|
+
name: v.optional(v.string()),
|
|
108
|
+
handle: v.optional(v.string()),
|
|
109
|
+
} satisfies EntriesOf<Identity>);
|
|
110
|
+
|
|
59
111
|
export const Params = {
|
|
60
112
|
"session.create": v.object({ workspace: WorkspaceSchema, artifact: ArtifactSchema }),
|
|
61
113
|
"session.get": v.object({ id: SessionId }),
|
|
@@ -70,20 +122,45 @@ export const Params = {
|
|
|
70
122
|
"session.wait": v.object({
|
|
71
123
|
id: SessionId,
|
|
72
124
|
// clamped: a negative or absurd timeout is a client bug, not a daemon one
|
|
73
|
-
timeoutMs: v.optional(
|
|
125
|
+
timeoutMs: v.optional(
|
|
126
|
+
v.pipe(v.number(), v.minValue(0), v.maxValue(24 * 60 * 60 * 1000)),
|
|
127
|
+
60_000,
|
|
128
|
+
),
|
|
129
|
+
}),
|
|
130
|
+
"session.annotate": v.object({
|
|
131
|
+
id: SessionId,
|
|
132
|
+
annotation: AnnotationSchema,
|
|
133
|
+
authorName: v.optional(v.string()),
|
|
74
134
|
}),
|
|
75
|
-
"session.annotate": v.object({ id: SessionId, annotation: AnnotationSchema }),
|
|
76
135
|
"session.removeAnnotation": v.object({ id: SessionId, annotationId: NonEmpty }),
|
|
77
136
|
"session.setWorkingCopy": v.object({ id: SessionId, workingCopy: v.optional(v.string()) }),
|
|
137
|
+
"session.setViewed": v.object({ id: SessionId, viewedPaths: v.array(v.string()) }),
|
|
138
|
+
"session.refreshDiff": v.object({ id: SessionId }),
|
|
139
|
+
"session.setShareId": v.object({ id: SessionId, shareId: NonEmpty }),
|
|
140
|
+
"session.delete": v.object({ id: SessionId }),
|
|
141
|
+
"session.mergeShared": v.object({
|
|
142
|
+
id: SessionId,
|
|
143
|
+
annotations: v.array(FullAnnotationSchema),
|
|
144
|
+
participants: v.optional(v.array(IdentitySchema)),
|
|
145
|
+
}),
|
|
78
146
|
"session.resolve": v.object({
|
|
79
147
|
id: SessionId,
|
|
80
148
|
verdictKind: v.picklist(["comment", "approve", "request_changes"]),
|
|
81
149
|
summary: v.optional(v.string(), ""),
|
|
82
150
|
}),
|
|
83
|
-
"session.submitRevision": v.object({
|
|
151
|
+
"session.submitRevision": v.object({
|
|
152
|
+
id: SessionId,
|
|
153
|
+
content: v.string(),
|
|
154
|
+
/** Annotation ids the agent acted on; each is marked addressed. */
|
|
155
|
+
addressedAnnotationIds: v.optional(v.array(NonEmpty), []),
|
|
156
|
+
}),
|
|
84
157
|
"events.subscribe": v.object({}),
|
|
85
158
|
"daemon.ping": v.object({}),
|
|
159
|
+
"daemon.hello": v.object({ role: v.picklist(["owner", "collaborator", "agent"]) }),
|
|
86
160
|
"daemon.shutdown": v.object({}),
|
|
161
|
+
// herdr adapter scratch: the review's opened tab, kept off the session record.
|
|
162
|
+
"herdr.getTab": v.object({ id: SessionId }),
|
|
163
|
+
"herdr.setTab": v.object({ id: SessionId, tabId: NonEmpty, paneId: NonEmpty }),
|
|
87
164
|
} as const;
|
|
88
165
|
|
|
89
166
|
export type MethodName = keyof typeof Params;
|
|
@@ -93,41 +170,59 @@ export function isKnownMethod(method: string): method is MethodName {
|
|
|
93
170
|
}
|
|
94
171
|
|
|
95
172
|
/** Validate params for a method, or throw a DaemonError the client can read. */
|
|
96
|
-
export function parseParams<M extends MethodName>(
|
|
173
|
+
export function parseParams<M extends MethodName>(
|
|
174
|
+
method: M,
|
|
175
|
+
params: unknown,
|
|
176
|
+
): v.InferOutput<(typeof Params)[M]> {
|
|
97
177
|
const result = v.safeParse(Params[method], params ?? {});
|
|
98
178
|
if (!result.success) {
|
|
99
179
|
const issue = result.issues[0]!;
|
|
100
|
-
const path = issue.path?.map((
|
|
101
|
-
throw new DaemonError(
|
|
180
|
+
const path = issue.path?.map((pathSegment) => String(pathSegment.key)).join(".") ?? "";
|
|
181
|
+
throw new DaemonError(
|
|
182
|
+
"invalid_params",
|
|
183
|
+
`${method}: ${path ? path + " - " : ""}${issue.message}`,
|
|
184
|
+
);
|
|
102
185
|
}
|
|
103
186
|
return result.output;
|
|
104
187
|
}
|
|
105
188
|
|
|
189
|
+
export const RevisionSchema = v.object({
|
|
190
|
+
revision: v.number(),
|
|
191
|
+
content: v.string(),
|
|
192
|
+
submittedAt: v.string(),
|
|
193
|
+
} satisfies EntriesOf<Revision>);
|
|
194
|
+
|
|
195
|
+
export const VerdictSchema = v.object({
|
|
196
|
+
kind: v.picklist(["comment", "approve", "request_changes"]),
|
|
197
|
+
summary: v.string(),
|
|
198
|
+
feedback: v.string(),
|
|
199
|
+
resolvedAt: v.string(),
|
|
200
|
+
} satisfies EntriesOf<Verdict>);
|
|
201
|
+
|
|
106
202
|
/** Persisted records are validated on recovery: a bad file is skipped, not fatal. */
|
|
107
203
|
export const SessionRecordSchema = v.object({
|
|
108
204
|
schemaVersion: v.literal(SCHEMA_VERSION),
|
|
109
205
|
id: NonEmpty,
|
|
110
206
|
workspace: WorkspaceSchema,
|
|
111
207
|
artifact: ArtifactSchema,
|
|
112
|
-
revisions: v.array(
|
|
113
|
-
annotations: v.array(
|
|
208
|
+
revisions: v.array(RevisionSchema),
|
|
209
|
+
annotations: v.array(FullAnnotationSchema),
|
|
114
210
|
workingCopy: v.optional(v.string()),
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
kind: v.picklist(["comment", "approve", "request_changes"]),
|
|
118
|
-
summary: v.string(),
|
|
119
|
-
feedback: v.string(),
|
|
120
|
-
resolvedAt: v.string(),
|
|
121
|
-
}),
|
|
122
|
-
),
|
|
211
|
+
viewedPaths: v.optional(v.array(v.string())),
|
|
212
|
+
verdict: v.nullable(VerdictSchema),
|
|
123
213
|
status: v.picklist(["pending", "resolved"]),
|
|
124
214
|
createdAt: v.string(),
|
|
125
|
-
|
|
215
|
+
shareId: v.optional(v.string()),
|
|
216
|
+
owner: v.optional(v.string()),
|
|
217
|
+
participants: v.optional(v.array(IdentitySchema)),
|
|
218
|
+
} satisfies EntriesOf<ReviewSession>);
|
|
126
219
|
|
|
127
|
-
export function validateSessionRecord(
|
|
220
|
+
export function validateSessionRecord(
|
|
221
|
+
raw: unknown,
|
|
222
|
+
): { ok: true; value: v.InferOutput<typeof SessionRecordSchema> } | { ok: false; error: string } {
|
|
128
223
|
const result = v.safeParse(SessionRecordSchema, raw);
|
|
129
224
|
if (result.success) return { ok: true, value: result.output };
|
|
130
225
|
const issue = result.issues[0]!;
|
|
131
|
-
const path = issue.path?.map((
|
|
226
|
+
const path = issue.path?.map((pathSegment) => String(pathSegment.key)).join(".") ?? "";
|
|
132
227
|
return { ok: false, error: `${path ? path + ": " : ""}${issue.message}` };
|
|
133
228
|
}
|