@cueloop/daemon 0.1.0-alpha.3 → 0.1.0-alpha.30
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 +13 -5
- package/src/api.test.ts +223 -44
- package/src/api.ts +128 -50
- package/src/client.ts +76 -29
- package/src/herdr-pane.test.ts +146 -0
- package/src/herdr-pane.ts +95 -0
- package/src/index.ts +8 -8
- package/src/protocol.ts +44 -7
- package/src/review.test.ts +243 -0
- package/src/review.ts +208 -0
- package/src/server.test.ts +135 -20
- package/src/server.ts +70 -48
- package/src/share-blob.test.ts +62 -0
- package/src/share-blob.ts +48 -0
- package/src/single-instance.test.ts +28 -11
- package/src/store.ts +5 -5
- package/src/validate.test.ts +152 -12
- package/src/validate.ts +93 -32
package/package.json
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/daemon",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.30",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": "./src/index.ts",
|
|
8
|
-
"./client": "./src/client.ts"
|
|
8
|
+
"./client": "./src/client.ts",
|
|
9
|
+
"./review": "./src/review.ts",
|
|
10
|
+
"./herdr-pane": "./src/herdr-pane.ts",
|
|
11
|
+
"./share-blob": "./src/share-blob.ts"
|
|
9
12
|
},
|
|
10
13
|
"dependencies": {
|
|
11
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
14
|
+
"@cueloop/schema": "0.1.0-alpha.30",
|
|
12
15
|
"valibot": "^1.4.2"
|
|
13
16
|
},
|
|
14
17
|
"publishConfig": {
|
|
@@ -25,5 +28,10 @@
|
|
|
25
28
|
"type": "git",
|
|
26
29
|
"url": "git+https://github.com/mmurakaru/cueloop.git",
|
|
27
30
|
"directory": "packages/daemon"
|
|
28
|
-
}
|
|
29
|
-
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
33
|
+
"bugs": {
|
|
34
|
+
"url": "https://github.com/mmurakaru/cueloop/issues"
|
|
35
|
+
},
|
|
36
|
+
"description": "The cueloop session daemon and its client: socket protocol, atomic store, resumable waits"
|
|
37
|
+
}
|
package/src/api.test.ts
CHANGED
|
@@ -24,71 +24,138 @@ afterEach(() => rmSync(home, { recursive: true, force: true }));
|
|
|
24
24
|
|
|
25
25
|
describe("session lifecycle", () => {
|
|
26
26
|
test("create → get → list", () => {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
// Act
|
|
28
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
29
|
+
|
|
30
|
+
// Assert
|
|
31
|
+
expect(session.status).toBe("pending");
|
|
32
|
+
expect(session.revisions.length).toBe(1);
|
|
33
|
+
expect(core.sessionGet(session.id).id).toBe(session.id);
|
|
31
34
|
expect(core.sessionList({ status: "pending" }).length).toBe(1);
|
|
32
35
|
});
|
|
33
36
|
|
|
34
37
|
test("annotate upserts by id; remove deletes", () => {
|
|
35
|
-
|
|
36
|
-
core.
|
|
38
|
+
// Arrange
|
|
39
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
40
|
+
|
|
41
|
+
// Act
|
|
42
|
+
core.sessionAnnotate(session.id, {
|
|
37
43
|
id: "a1",
|
|
38
44
|
kind: "comment",
|
|
39
45
|
anchor: { quote: "carefully", prefix: "the thing ", suffix: "." },
|
|
40
46
|
body: "How carefully?",
|
|
41
47
|
});
|
|
42
|
-
core.sessionAnnotate(
|
|
48
|
+
core.sessionAnnotate(session.id, {
|
|
43
49
|
id: "a1",
|
|
44
50
|
kind: "comment",
|
|
45
51
|
anchor: { quote: "carefully", prefix: "the thing ", suffix: "." },
|
|
46
52
|
body: "Define carefully.",
|
|
47
53
|
});
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
core.
|
|
51
|
-
expect(core.sessionGet(
|
|
54
|
+
|
|
55
|
+
// Assert
|
|
56
|
+
expect(core.sessionGet(session.id).annotations.length).toBe(1);
|
|
57
|
+
expect(core.sessionGet(session.id).annotations[0]!.body).toBe("Define carefully.");
|
|
58
|
+
|
|
59
|
+
// Act
|
|
60
|
+
core.sessionRemoveAnnotation(session.id, "a1");
|
|
61
|
+
|
|
62
|
+
// Assert
|
|
63
|
+
expect(core.sessionGet(session.id).annotations.length).toBe(0);
|
|
52
64
|
});
|
|
53
65
|
|
|
54
66
|
test("resolve produces feedback.md and maps to the agent contract", () => {
|
|
55
|
-
|
|
56
|
-
core.
|
|
67
|
+
// Arrange
|
|
68
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
69
|
+
core.sessionAnnotate(session.id, {
|
|
57
70
|
id: "a1",
|
|
58
71
|
kind: "comment",
|
|
59
72
|
anchor: { quote: "carefully", prefix: "the thing ", suffix: "." },
|
|
60
73
|
body: "Spell out the steps.",
|
|
61
74
|
});
|
|
62
|
-
|
|
75
|
+
|
|
76
|
+
// Act
|
|
77
|
+
const resolved = core.sessionResolve(session.id, "request_changes", "Needs detail.");
|
|
78
|
+
|
|
79
|
+
// Assert
|
|
63
80
|
expect(resolved.verdict!.feedback).toContain("# Review: request changes");
|
|
64
81
|
expect(resolved.verdict!.feedback).toContain("> carefully");
|
|
65
82
|
expect(verdictResponse(resolved)).toEqual({ allow: false, feedback: resolved.verdict!.feedback });
|
|
83
|
+
|
|
84
|
+
// Arrange
|
|
66
85
|
const approved = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
86
|
+
|
|
87
|
+
// Act
|
|
67
88
|
core.sessionResolve(approved.id, "approve", "LGTM");
|
|
89
|
+
|
|
90
|
+
// Assert
|
|
68
91
|
expect(verdictResponse(core.sessionGet(approved.id)).allow).toBe(true);
|
|
69
92
|
});
|
|
70
93
|
|
|
71
94
|
test("mutating a resolved session throws", () => {
|
|
72
|
-
|
|
73
|
-
core.
|
|
95
|
+
// Arrange
|
|
96
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
97
|
+
core.sessionResolve(session.id, "approve", "");
|
|
98
|
+
|
|
99
|
+
// Assert
|
|
74
100
|
expect(() =>
|
|
75
|
-
core.sessionAnnotate(
|
|
101
|
+
core.sessionAnnotate(session.id, { id: "a1", kind: "comment", anchor: { quote: "x", prefix: "", suffix: "" }, body: "b" }),
|
|
76
102
|
).toThrow("already resolved");
|
|
77
103
|
});
|
|
78
104
|
|
|
79
105
|
test("working copy stores edits and clears on revert or no-op", () => {
|
|
80
|
-
|
|
81
|
-
core.
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
106
|
+
// Arrange
|
|
107
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
108
|
+
|
|
109
|
+
// Act
|
|
110
|
+
core.sessionSetWorkingCopy(session.id, PLAN.content.replace("carefully", "very carefully"));
|
|
111
|
+
|
|
112
|
+
// Assert
|
|
113
|
+
expect(core.sessionGet(session.id).workingCopy).toContain("very carefully");
|
|
114
|
+
|
|
115
|
+
// Act
|
|
116
|
+
core.sessionSetWorkingCopy(session.id, PLAN.content);
|
|
117
|
+
|
|
118
|
+
// Assert
|
|
119
|
+
expect(core.sessionGet(session.id).workingCopy).toBeUndefined();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("viewed paths merge, dedupe, clear on empty, and survive a daemon restart", () => {
|
|
123
|
+
// Arrange
|
|
124
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
125
|
+
|
|
126
|
+
// Act
|
|
127
|
+
core.sessionSetViewed(session.id, ["src/a.ts", "src/b.ts", "src/a.ts"]);
|
|
128
|
+
|
|
129
|
+
// Assert
|
|
130
|
+
expect(core.sessionGet(session.id).viewedPaths).toEqual(["src/a.ts", "src/b.ts"]);
|
|
131
|
+
|
|
132
|
+
// Act
|
|
133
|
+
// merge-additive: a stale client sending only its own new mark loses nothing
|
|
134
|
+
core.sessionSetViewed(session.id, ["src/c.ts"]);
|
|
135
|
+
|
|
136
|
+
// Assert
|
|
137
|
+
expect(core.sessionGet(session.id).viewedPaths).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]);
|
|
138
|
+
// a resumed review reads its progress back after a daemon restart
|
|
139
|
+
const reborn = new DaemonCore(home);
|
|
140
|
+
expect(reborn.sessionGet(session.id).viewedPaths).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]);
|
|
141
|
+
|
|
142
|
+
// Act
|
|
143
|
+
core.sessionSetViewed(session.id, []);
|
|
144
|
+
|
|
145
|
+
// Assert
|
|
146
|
+
expect(core.sessionGet(session.id).viewedPaths).toBeUndefined();
|
|
85
147
|
});
|
|
86
148
|
|
|
87
149
|
test("revision reopens the session and resets working copy + verdict", () => {
|
|
88
|
-
|
|
89
|
-
core.
|
|
90
|
-
core.
|
|
91
|
-
|
|
150
|
+
// Arrange
|
|
151
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
152
|
+
core.sessionSetWorkingCopy(session.id, PLAN.content + "\nedit");
|
|
153
|
+
core.sessionResolve(session.id, "request_changes", "redo");
|
|
154
|
+
|
|
155
|
+
// Act
|
|
156
|
+
const revised = core.sessionSubmitRevision(session.id, "# Plan v2\n");
|
|
157
|
+
|
|
158
|
+
// Assert
|
|
92
159
|
expect(revised.status).toBe("pending");
|
|
93
160
|
expect(revised.verdict).toBeNull();
|
|
94
161
|
expect(revised.workingCopy).toBeUndefined();
|
|
@@ -97,47 +164,154 @@ describe("session lifecycle", () => {
|
|
|
97
164
|
});
|
|
98
165
|
});
|
|
99
166
|
|
|
167
|
+
describe("revision marks addressed annotations", () => {
|
|
168
|
+
const annotate = (sessionId: string, id: string, quote: string, body = "note") =>
|
|
169
|
+
core.sessionAnnotate(sessionId, { id, kind: "comment", anchor: { quote, prefix: "", suffix: "" }, body });
|
|
170
|
+
|
|
171
|
+
test("ids the agent reports are marked addressed by that revision", () => {
|
|
172
|
+
// Arrange
|
|
173
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
174
|
+
annotate(session.id, "a1", "carefully");
|
|
175
|
+
annotate(session.id, "a2", "Context");
|
|
176
|
+
|
|
177
|
+
// Act: the revision keeps both quoted texts, so only the reported id resolves
|
|
178
|
+
const revised = core.sessionSubmitRevision(session.id, PLAN.content + "\nMore.\n", ["a1"]);
|
|
179
|
+
|
|
180
|
+
// Assert
|
|
181
|
+
const [first, second] = revised.annotations;
|
|
182
|
+
expect(first!.resolution).toEqual({ revision: 2, source: "agent" });
|
|
183
|
+
expect(second!.resolution).toBeUndefined();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("a plan annotation whose quoted text vanished is drift-addressed; a surviving quote stays open", () => {
|
|
187
|
+
// Arrange
|
|
188
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
189
|
+
annotate(session.id, "gone", "carefully");
|
|
190
|
+
annotate(session.id, "kept", "Context");
|
|
191
|
+
|
|
192
|
+
// Act: the revision rewrites the "carefully" sentence but keeps the Context heading
|
|
193
|
+
const revised = core.sessionSubmitRevision(session.id, "# Plan\n\n## Context\n\nDo the thing with tests.\n");
|
|
194
|
+
|
|
195
|
+
// Assert
|
|
196
|
+
expect(revised.annotations.find((a) => a.id === "gone")!.resolution).toEqual({ revision: 2, source: "drift" });
|
|
197
|
+
expect(revised.annotations.find((a) => a.id === "kept")!.resolution).toBeUndefined();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("an unknown reported id is ignored, and an already-addressed annotation keeps its first resolution", () => {
|
|
201
|
+
// Arrange
|
|
202
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
203
|
+
annotate(session.id, "a1", "carefully");
|
|
204
|
+
core.sessionSubmitRevision(session.id, PLAN.content, ["a1"]);
|
|
205
|
+
|
|
206
|
+
// Act: a second revision reports a stale id and re-reports the settled one
|
|
207
|
+
const revised = core.sessionSubmitRevision(session.id, PLAN.content, ["a1", "a_never_existed"]);
|
|
208
|
+
|
|
209
|
+
// Assert
|
|
210
|
+
expect(revised.annotations[0]!.resolution).toEqual({ revision: 2, source: "agent" });
|
|
211
|
+
expect(revised.revisions.length).toBe(3);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("a diff revision never drift-addresses - a new patch says nothing about the feedback", () => {
|
|
215
|
+
// Arrange
|
|
216
|
+
const diffArtifact: Artifact = { type: "diff", content: "+++ b/a.ts\n+new line\n", meta: {} };
|
|
217
|
+
const session = core.sessionCreate({ workspace: WS, artifact: diffArtifact });
|
|
218
|
+
annotate(session.id, "d1", "new line");
|
|
219
|
+
|
|
220
|
+
// Act: the revised patch no longer contains the quoted text
|
|
221
|
+
const revised = core.sessionSubmitRevision(session.id, "+++ b/b.ts\n+other\n");
|
|
222
|
+
|
|
223
|
+
// Assert
|
|
224
|
+
expect(revised.annotations[0]!.resolution).toBeUndefined();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("the next feedback document omits addressed annotations and teaches the addressed-ids call", () => {
|
|
228
|
+
// Arrange
|
|
229
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
230
|
+
annotate(session.id, "settled", "carefully");
|
|
231
|
+
annotate(session.id, "open", "Context", "needs a diagram");
|
|
232
|
+
core.sessionSubmitRevision(session.id, PLAN.content, ["settled"]);
|
|
233
|
+
|
|
234
|
+
// Act
|
|
235
|
+
const resolved = core.sessionResolve(session.id, "request_changes", "one left");
|
|
236
|
+
|
|
237
|
+
// Assert
|
|
238
|
+
expect(resolved.verdict!.feedback).not.toContain("settled");
|
|
239
|
+
expect(resolved.verdict!.feedback).toContain("annotation id: `open`");
|
|
240
|
+
expect(resolved.verdict!.feedback).toContain(`submit-revision ${session.id}`);
|
|
241
|
+
expect(resolved.verdict!.feedback).toContain("--addressed");
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
100
245
|
describe("the wait contract: verdicts outlive waits", () => {
|
|
101
246
|
test("wait resolves when the verdict arrives", async () => {
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
core.
|
|
247
|
+
// Arrange
|
|
248
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
249
|
+
const wait = core.sessionWait(session.id, 5_000);
|
|
250
|
+
|
|
251
|
+
// Act
|
|
252
|
+
core.sessionResolve(session.id, "approve", "");
|
|
105
253
|
const resolved = await wait;
|
|
254
|
+
|
|
255
|
+
// Assert
|
|
106
256
|
expect(resolved!.verdict!.kind).toBe("approve");
|
|
107
257
|
});
|
|
108
258
|
|
|
109
259
|
test("wait times out to null; a later wait collects the stored verdict", async () => {
|
|
110
|
-
|
|
111
|
-
const
|
|
260
|
+
// Arrange
|
|
261
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
262
|
+
|
|
263
|
+
// Act
|
|
264
|
+
const first = await core.sessionWait(session.id, 30);
|
|
265
|
+
|
|
266
|
+
// Assert
|
|
112
267
|
expect(first).toBeNull();
|
|
113
|
-
|
|
114
|
-
|
|
268
|
+
|
|
269
|
+
// Act
|
|
270
|
+
core.sessionResolve(session.id, "request_changes", "later");
|
|
271
|
+
const second = await core.sessionWait(session.id, 30);
|
|
272
|
+
|
|
273
|
+
// Assert
|
|
115
274
|
expect(second!.verdict!.summary).toBe("later");
|
|
116
275
|
});
|
|
117
276
|
|
|
118
277
|
test("multiple waiters all resolve", async () => {
|
|
119
|
-
|
|
120
|
-
const
|
|
121
|
-
core.
|
|
278
|
+
// Arrange
|
|
279
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
280
|
+
const waits = [core.sessionWait(session.id, 5_000), core.sessionWait(session.id, 5_000)];
|
|
281
|
+
|
|
282
|
+
// Act
|
|
283
|
+
core.sessionResolve(session.id, "approve", "");
|
|
122
284
|
const results = await Promise.all(waits);
|
|
123
|
-
|
|
285
|
+
|
|
286
|
+
// Assert
|
|
287
|
+
expect(results.every((result) => result?.status === "resolved")).toBe(true);
|
|
124
288
|
});
|
|
125
289
|
});
|
|
126
290
|
|
|
127
291
|
describe("persistence and recovery", () => {
|
|
128
292
|
test("sessions survive a daemon restart", () => {
|
|
129
|
-
|
|
130
|
-
core.
|
|
293
|
+
// Arrange
|
|
294
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
295
|
+
core.sessionResolve(session.id, "approve", "done");
|
|
296
|
+
|
|
297
|
+
// Act
|
|
131
298
|
const reborn = new DaemonCore(home);
|
|
132
|
-
const recovered = reborn.sessionGet(
|
|
299
|
+
const recovered = reborn.sessionGet(session.id);
|
|
300
|
+
|
|
301
|
+
// Assert
|
|
133
302
|
expect(recovered.verdict!.summary).toBe("done");
|
|
134
303
|
});
|
|
135
304
|
|
|
136
305
|
test("corrupt records are skipped, not fatal, and never deleted", async () => {
|
|
306
|
+
// Arrange
|
|
137
307
|
core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
138
308
|
await Bun.write(join(home, "sessions", "broken.json"), "{ not json");
|
|
139
309
|
const store = new SessionStore(home);
|
|
310
|
+
|
|
311
|
+
// Act
|
|
140
312
|
const report = store.recover();
|
|
313
|
+
|
|
314
|
+
// Assert
|
|
141
315
|
expect(report.recovered.length).toBe(1);
|
|
142
316
|
expect(report.skipped.length).toBe(1);
|
|
143
317
|
expect(await Bun.file(join(home, "sessions", "broken.json")).exists()).toBe(true);
|
|
@@ -146,11 +320,16 @@ describe("persistence and recovery", () => {
|
|
|
146
320
|
|
|
147
321
|
describe("events", () => {
|
|
148
322
|
test("lifecycle events fire in order", () => {
|
|
323
|
+
// Arrange
|
|
149
324
|
const seen: string[] = [];
|
|
150
|
-
core.onEvent((
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
core.
|
|
325
|
+
core.onEvent((event) => seen.push(event.event));
|
|
326
|
+
|
|
327
|
+
// Act
|
|
328
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
329
|
+
core.sessionAnnotate(session.id, { id: "a1", kind: "comment", anchor: { quote: "thing", prefix: "", suffix: "" }, body: "b" });
|
|
330
|
+
core.sessionResolve(session.id, "approve", "");
|
|
331
|
+
|
|
332
|
+
// Assert
|
|
154
333
|
expect(seen).toEqual(["session.created", "inbox.changed", "session.updated", "session.resolved", "inbox.changed"]);
|
|
155
334
|
});
|
|
156
335
|
});
|
package/src/api.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The daemon's method surface
|
|
2
|
+
* The daemon's method surface. Transport-independent: the socket
|
|
3
3
|
* server and the in-process test harness both call these handlers.
|
|
4
4
|
* The wait contract: verdicts outlive waits - session.wait long-polls,
|
|
5
5
|
* and a verdict resolved while nobody waited is delivered on next contact.
|
|
@@ -8,9 +8,14 @@
|
|
|
8
8
|
import {
|
|
9
9
|
SCHEMA_VERSION,
|
|
10
10
|
feedbackForSession,
|
|
11
|
+
isAddressed,
|
|
12
|
+
isAgentNote,
|
|
13
|
+
parseBlocks,
|
|
14
|
+
resolveAnchor,
|
|
11
15
|
verdictAllows,
|
|
12
16
|
type Annotation,
|
|
13
17
|
type Artifact,
|
|
18
|
+
type Identity,
|
|
14
19
|
type ReviewSession,
|
|
15
20
|
type Verdict,
|
|
16
21
|
type VerdictKind,
|
|
@@ -31,7 +36,7 @@ export interface DaemonEvent {
|
|
|
31
36
|
sessionId: string;
|
|
32
37
|
}
|
|
33
38
|
|
|
34
|
-
type EventListener = (
|
|
39
|
+
type EventListener = (event: DaemonEvent) => void;
|
|
35
40
|
|
|
36
41
|
export class DaemonCore {
|
|
37
42
|
readonly store: SessionStore;
|
|
@@ -50,12 +55,12 @@ export class DaemonCore {
|
|
|
50
55
|
}
|
|
51
56
|
|
|
52
57
|
private emit(event: EventName, sessionId: string): void {
|
|
53
|
-
for (const
|
|
58
|
+
for (const listener of this.listeners) listener({ event, sessionId });
|
|
54
59
|
}
|
|
55
60
|
|
|
56
61
|
/** True when nothing awaits a verdict - drives idle-exit. */
|
|
57
62
|
hasPendingSessions(): boolean {
|
|
58
|
-
return this.store.list().some((
|
|
63
|
+
return this.store.list().some((session) => session.status === "pending");
|
|
59
64
|
}
|
|
60
65
|
|
|
61
66
|
sessionCreate(params: { workspace: WorkspaceKey; artifact: Artifact }): ReviewSession {
|
|
@@ -78,16 +83,16 @@ export class DaemonCore {
|
|
|
78
83
|
}
|
|
79
84
|
|
|
80
85
|
sessionGet(id: string): ReviewSession {
|
|
81
|
-
const
|
|
82
|
-
if (!
|
|
83
|
-
return
|
|
86
|
+
const session = this.store.get(id);
|
|
87
|
+
if (!session) throw new DaemonError("not_found", `no session ${id}`);
|
|
88
|
+
return session;
|
|
84
89
|
}
|
|
85
90
|
|
|
86
91
|
sessionList(filter?: { status?: "pending" | "resolved"; workspace?: Partial<WorkspaceKey> }): ReviewSession[] {
|
|
87
|
-
return this.store.list().filter((
|
|
88
|
-
if (filter?.status &&
|
|
89
|
-
if (filter?.workspace?.repoRoot &&
|
|
90
|
-
if (filter?.workspace?.branch &&
|
|
92
|
+
return this.store.list().filter((session) => {
|
|
93
|
+
if (filter?.status && session.status !== filter.status) return false;
|
|
94
|
+
if (filter?.workspace?.repoRoot && session.workspace.repoRoot !== filter.workspace.repoRoot) return false;
|
|
95
|
+
if (filter?.workspace?.branch && session.workspace.branch !== filter.workspace.branch) return false;
|
|
91
96
|
return true;
|
|
92
97
|
});
|
|
93
98
|
}
|
|
@@ -98,16 +103,16 @@ export class DaemonCore {
|
|
|
98
103
|
* still pending - the caller re-polls later; the verdict is never lost).
|
|
99
104
|
*/
|
|
100
105
|
sessionWait(id: string, timeoutMs: number): Promise<ReviewSession | null> {
|
|
101
|
-
const
|
|
102
|
-
if (
|
|
106
|
+
const current = this.sessionGet(id);
|
|
107
|
+
if (current.status === "resolved") return Promise.resolve(current);
|
|
103
108
|
return new Promise((resolve) => {
|
|
104
109
|
const list = this.waiters.get(id) ?? [];
|
|
105
110
|
let done = false;
|
|
106
111
|
const timer = setTimeout(() => {
|
|
107
112
|
if (done) return;
|
|
108
113
|
done = true;
|
|
109
|
-
const
|
|
110
|
-
if (
|
|
114
|
+
const waiterIndex = list.indexOf(waiter);
|
|
115
|
+
if (waiterIndex !== -1) list.splice(waiterIndex, 1);
|
|
111
116
|
resolve(null);
|
|
112
117
|
}, timeoutMs);
|
|
113
118
|
const waiter = (session: ReviewSession) => {
|
|
@@ -122,72 +127,145 @@ export class DaemonCore {
|
|
|
122
127
|
}
|
|
123
128
|
|
|
124
129
|
sessionAnnotate(id: string, annotation: Omit<Annotation, "createdAt">): ReviewSession {
|
|
125
|
-
const
|
|
126
|
-
const existing =
|
|
130
|
+
const session = this.mutable(id);
|
|
131
|
+
const existing = session.annotations.findIndex((candidate) => candidate.id === annotation.id);
|
|
127
132
|
const full: Annotation = { ...annotation, createdAt: new Date().toISOString() };
|
|
128
|
-
if (existing === -1)
|
|
129
|
-
else
|
|
130
|
-
this.store.upsert(
|
|
133
|
+
if (existing === -1) session.annotations.push(full);
|
|
134
|
+
else session.annotations[existing] = { ...full, createdAt: session.annotations[existing]!.createdAt };
|
|
135
|
+
this.store.upsert(session);
|
|
131
136
|
this.emit("session.updated", id);
|
|
132
|
-
return
|
|
137
|
+
return session;
|
|
133
138
|
}
|
|
134
139
|
|
|
135
140
|
sessionRemoveAnnotation(id: string, annotationId: string): ReviewSession {
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
this.store.upsert(
|
|
141
|
+
const session = this.mutable(id);
|
|
142
|
+
session.annotations = session.annotations.filter((candidate) => candidate.id !== annotationId);
|
|
143
|
+
this.store.upsert(session);
|
|
139
144
|
this.emit("session.updated", id);
|
|
140
|
-
return
|
|
145
|
+
return session;
|
|
141
146
|
}
|
|
142
147
|
|
|
143
148
|
/** The reviewer's working copy; undefined clears it (revert all edits). */
|
|
144
149
|
sessionSetWorkingCopy(id: string, workingCopy: string | undefined): ReviewSession {
|
|
145
|
-
const
|
|
146
|
-
if (workingCopy === undefined || workingCopy ===
|
|
147
|
-
else
|
|
148
|
-
this.store.upsert(
|
|
150
|
+
const session = this.mutable(id);
|
|
151
|
+
if (workingCopy === undefined || workingCopy === session.artifact.content) delete session.workingCopy;
|
|
152
|
+
else session.workingCopy = workingCopy;
|
|
153
|
+
this.store.upsert(session);
|
|
154
|
+
this.emit("session.updated", id);
|
|
155
|
+
return session;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The guided walk's viewed marks. Merge-additive: walking only ever adds
|
|
160
|
+
* marks, so concurrent or stale clients converge instead of overwriting
|
|
161
|
+
* each other. An empty array is the explicit reset.
|
|
162
|
+
*/
|
|
163
|
+
sessionSetViewed(id: string, viewedPaths: string[]): ReviewSession {
|
|
164
|
+
const session = this.mutable(id);
|
|
165
|
+
if (viewedPaths.length === 0) delete session.viewedPaths;
|
|
166
|
+
else session.viewedPaths = [...new Set([...(session.viewedPaths ?? []), ...viewedPaths])];
|
|
167
|
+
this.store.upsert(session);
|
|
168
|
+
this.emit("session.updated", id);
|
|
169
|
+
return session;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
sessionSetShareId(id: string, shareId: string): ReviewSession {
|
|
173
|
+
const session = this.mutable(id);
|
|
174
|
+
session.shareId = shareId;
|
|
175
|
+
this.store.upsert(session);
|
|
176
|
+
this.emit("session.updated", id);
|
|
177
|
+
return session;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Remove a session for good (inbox delete); resolved or pending, both go. */
|
|
181
|
+
sessionDelete(id: string): void {
|
|
182
|
+
if (!this.store.delete(id)) throw new DaemonError("not_found", `no session ${id}`);
|
|
183
|
+
this.emit("inbox.changed", id);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Merge a share's collaborator state back into the local session: annotations
|
|
188
|
+
* union by id with existing ones (the planner's) winning, and the participant
|
|
189
|
+
* registry union by id with the incoming identity winning (a collaborator is
|
|
190
|
+
* the authority on their own name). This is how a teammate's name reaches the
|
|
191
|
+
* planner after a pull, alongside their notes.
|
|
192
|
+
*/
|
|
193
|
+
sessionMergeShared(id: string, incoming: { annotations: Annotation[]; participants?: Identity[] }): ReviewSession {
|
|
194
|
+
const session = this.mutable(id);
|
|
195
|
+
const known = new Set(session.annotations.map((annotation) => annotation.id));
|
|
196
|
+
for (const annotation of incoming.annotations) if (!known.has(annotation.id)) session.annotations.push(annotation);
|
|
197
|
+
if (incoming.participants?.length) {
|
|
198
|
+
const registry = new Map((session.participants ?? []).map((participant) => [participant.id, participant]));
|
|
199
|
+
for (const participant of incoming.participants) registry.set(participant.id, participant);
|
|
200
|
+
session.participants = [...registry.values()];
|
|
201
|
+
}
|
|
202
|
+
this.store.upsert(session);
|
|
149
203
|
this.emit("session.updated", id);
|
|
150
|
-
return
|
|
204
|
+
return session;
|
|
151
205
|
}
|
|
152
206
|
|
|
153
207
|
sessionResolve(id: string, verdictKind: VerdictKind, summary: string): ReviewSession {
|
|
154
|
-
const
|
|
208
|
+
const session = this.mutable(id);
|
|
155
209
|
const verdict: Verdict = {
|
|
156
210
|
kind: verdictKind,
|
|
157
211
|
summary,
|
|
158
|
-
feedback: feedbackForSession(
|
|
212
|
+
feedback: feedbackForSession(session, verdictKind, summary),
|
|
159
213
|
resolvedAt: new Date().toISOString(),
|
|
160
214
|
};
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
this.store.upsert(
|
|
215
|
+
session.verdict = verdict;
|
|
216
|
+
session.status = "resolved";
|
|
217
|
+
this.store.upsert(session);
|
|
164
218
|
const parked = this.waiters.get(id) ?? [];
|
|
165
219
|
this.waiters.delete(id);
|
|
166
|
-
for (const
|
|
220
|
+
for (const parkedWaiter of parked) parkedWaiter(session);
|
|
167
221
|
this.emit("session.resolved", id);
|
|
168
222
|
this.emit("inbox.changed", id);
|
|
169
|
-
return
|
|
223
|
+
return session;
|
|
170
224
|
}
|
|
171
225
|
|
|
172
|
-
/**
|
|
173
|
-
|
|
174
|
-
|
|
226
|
+
/**
|
|
227
|
+
* Agent resubmits: new revision becomes the artifact, session reopens.
|
|
228
|
+
* Annotations the agent reports as acted on (by id) are marked addressed,
|
|
229
|
+
* and for plan revisions any still-open annotation whose quoted text no
|
|
230
|
+
* longer resolves is marked addressed too ("drift" - the line it pointed at
|
|
231
|
+
* was rewritten). Addressed is a marker, never a delete: the reviewer's
|
|
232
|
+
* rail hides them behind a count, and the next feedback document omits them.
|
|
233
|
+
* Unknown ids are ignored, so a stale id in the agent's list never fails
|
|
234
|
+
* the resubmit.
|
|
235
|
+
*/
|
|
236
|
+
sessionSubmitRevision(id: string, content: string, addressedAnnotationIds: string[] = []): ReviewSession {
|
|
237
|
+
const session = this.sessionGet(id);
|
|
175
238
|
const now = new Date().toISOString();
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
239
|
+
const revisionNumber = session.revisions.length + 1;
|
|
240
|
+
session.revisions.push({ revision: revisionNumber, content, submittedAt: now });
|
|
241
|
+
session.artifact = { ...session.artifact, content };
|
|
242
|
+
delete session.workingCopy;
|
|
243
|
+
session.verdict = null;
|
|
244
|
+
session.status = "pending";
|
|
245
|
+
|
|
246
|
+
const reportedIds = new Set(addressedAnnotationIds);
|
|
247
|
+
// drift assist applies to plans only: a diff revision is a whole new
|
|
248
|
+
// patch, where a vanished quote says nothing about the feedback
|
|
249
|
+
const revisedBlocks = session.artifact.type === "plan" ? parseBlocks(content) : null;
|
|
250
|
+
for (const annotation of session.annotations) {
|
|
251
|
+
if (isAddressed(annotation) || isAgentNote(annotation)) continue;
|
|
252
|
+
if (reportedIds.has(annotation.id)) {
|
|
253
|
+
annotation.resolution = { revision: revisionNumber, source: "agent" };
|
|
254
|
+
} else if (revisedBlocks !== null && resolveAnchor(annotation.anchor, revisedBlocks) === null) {
|
|
255
|
+
annotation.resolution = { revision: revisionNumber, source: "drift" };
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
this.store.upsert(session);
|
|
182
260
|
this.emit("session.revised", id);
|
|
183
261
|
this.emit("inbox.changed", id);
|
|
184
|
-
return
|
|
262
|
+
return session;
|
|
185
263
|
}
|
|
186
264
|
|
|
187
265
|
private mutable(id: string): ReviewSession {
|
|
188
|
-
const
|
|
189
|
-
if (
|
|
190
|
-
return
|
|
266
|
+
const session = this.sessionGet(id);
|
|
267
|
+
if (session.status === "resolved") throw new DaemonError("resolved", `session ${id} is already resolved`);
|
|
268
|
+
return session;
|
|
191
269
|
}
|
|
192
270
|
}
|
|
193
271
|
|