@cueloop/daemon 0.1.0-alpha.59 → 0.1.0-alpha.60
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 +2 -2
- package/src/api.test.ts +14 -0
- package/src/api.ts +44 -3
- package/src/client.ts +12 -0
- package/src/diff-hot-reload.test.ts +11 -0
- package/src/diff-watcher.ts +6 -0
- package/src/herdr-pane.test.ts +7 -0
- package/src/herdr-pane.ts +11 -0
- package/src/herdr-split.test.ts +3 -0
- package/src/herdr-split.ts +4 -0
- package/src/herdr-tab-store.test.ts +1 -0
- package/src/herdr-tab-store.ts +2 -0
- package/src/main.ts +1 -0
- package/src/protocol.ts +7 -0
- package/src/retention.test.ts +8 -0
- package/src/retention.ts +16 -0
- package/src/review.test.ts +8 -0
- package/src/review.ts +26 -2
- package/src/server.test.ts +7 -0
- package/src/server.ts +32 -0
- package/src/share-blob.ts +4 -0
- package/src/single-instance.test.ts +10 -0
- package/src/store.ts +6 -0
- package/src/validate.test.ts +12 -0
- package/src/validate.ts +6 -1
- package/src/working-tree.ts +13 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/daemon",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.60",
|
|
4
4
|
"description": "The cueloop session daemon and its client: socket protocol, atomic store, resumable waits",
|
|
5
5
|
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"access": "public"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
36
|
+
"@cueloop/schema": "0.1.0-alpha.60",
|
|
37
37
|
"valibot": "^1.4.2"
|
|
38
38
|
},
|
|
39
39
|
"engines": {
|
package/src/api.test.ts
CHANGED
|
@@ -82,6 +82,7 @@ describe("session lifecycle", () => {
|
|
|
82
82
|
|
|
83
83
|
// Assert
|
|
84
84
|
const stored = core.sessionGet(session.id);
|
|
85
|
+
|
|
85
86
|
expect(stored.annotations[0]!.author).toBe("SHA256:ana");
|
|
86
87
|
expect(stored.participants).toEqual([{ id: "SHA256:ana", provider: "ssh", name: "Ana" }]);
|
|
87
88
|
});
|
|
@@ -89,6 +90,7 @@ describe("session lifecycle", () => {
|
|
|
89
90
|
test("resolve produces feedback.md and maps to the agent contract", () => {
|
|
90
91
|
// Arrange
|
|
91
92
|
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
93
|
+
|
|
92
94
|
core.sessionAnnotate(session.id, {
|
|
93
95
|
id: "a1",
|
|
94
96
|
kind: "comment",
|
|
@@ -120,6 +122,7 @@ describe("session lifecycle", () => {
|
|
|
120
122
|
test("mutating a resolved session throws", () => {
|
|
121
123
|
// Arrange
|
|
122
124
|
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
125
|
+
|
|
123
126
|
core.sessionResolve(session.id, "approve", "");
|
|
124
127
|
|
|
125
128
|
// Assert
|
|
@@ -168,6 +171,7 @@ describe("session lifecycle", () => {
|
|
|
168
171
|
expect(core.sessionGet(session.id).viewedPaths).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]);
|
|
169
172
|
// a resumed review reads its progress back after a daemon restart
|
|
170
173
|
const reborn = new DaemonCore(home);
|
|
174
|
+
|
|
171
175
|
expect(reborn.sessionGet(session.id).viewedPaths).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]);
|
|
172
176
|
|
|
173
177
|
// Act
|
|
@@ -180,6 +184,7 @@ describe("session lifecycle", () => {
|
|
|
180
184
|
test("revision reopens the session and resets working copy + verdict", () => {
|
|
181
185
|
// Arrange
|
|
182
186
|
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
187
|
+
|
|
183
188
|
core.sessionSetWorkingCopy(session.id, PLAN.content + "\nedit");
|
|
184
189
|
core.sessionResolve(session.id, "request_changes", "redo");
|
|
185
190
|
|
|
@@ -207,6 +212,7 @@ describe("revision marks addressed annotations", () => {
|
|
|
207
212
|
test("ids the agent reports are marked addressed by that revision", () => {
|
|
208
213
|
// Arrange
|
|
209
214
|
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
215
|
+
|
|
210
216
|
annotate(session.id, "a1", "carefully");
|
|
211
217
|
annotate(session.id, "a2", "Context");
|
|
212
218
|
|
|
@@ -215,6 +221,7 @@ describe("revision marks addressed annotations", () => {
|
|
|
215
221
|
|
|
216
222
|
// Assert
|
|
217
223
|
const [first, second] = revised.annotations;
|
|
224
|
+
|
|
218
225
|
expect(first!.resolution).toEqual({ revision: 2, source: "agent" });
|
|
219
226
|
expect(second!.resolution).toBeUndefined();
|
|
220
227
|
});
|
|
@@ -222,6 +229,7 @@ describe("revision marks addressed annotations", () => {
|
|
|
222
229
|
test("a plan annotation whose quoted text vanished is drift-addressed; a surviving quote stays open", () => {
|
|
223
230
|
// Arrange
|
|
224
231
|
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
232
|
+
|
|
225
233
|
annotate(session.id, "gone", "carefully");
|
|
226
234
|
annotate(session.id, "kept", "Context");
|
|
227
235
|
|
|
@@ -242,6 +250,7 @@ describe("revision marks addressed annotations", () => {
|
|
|
242
250
|
test("an unknown reported id is ignored, and an already-addressed annotation keeps its first resolution", () => {
|
|
243
251
|
// Arrange
|
|
244
252
|
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
253
|
+
|
|
245
254
|
annotate(session.id, "a1", "carefully");
|
|
246
255
|
core.sessionSubmitRevision(session.id, PLAN.content, ["a1"]);
|
|
247
256
|
|
|
@@ -257,6 +266,7 @@ describe("revision marks addressed annotations", () => {
|
|
|
257
266
|
// Arrange
|
|
258
267
|
const diffArtifact: Artifact = { type: "diff", content: "+++ b/a.ts\n+new line\n", meta: {} };
|
|
259
268
|
const session = core.sessionCreate({ workspace: WS, artifact: diffArtifact });
|
|
269
|
+
|
|
260
270
|
annotate(session.id, "d1", "new line");
|
|
261
271
|
|
|
262
272
|
// Act: the revised patch no longer contains the quoted text
|
|
@@ -269,6 +279,7 @@ describe("revision marks addressed annotations", () => {
|
|
|
269
279
|
test("the next feedback document omits addressed annotations and teaches the addressed-ids call", () => {
|
|
270
280
|
// Arrange
|
|
271
281
|
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
282
|
+
|
|
272
283
|
annotate(session.id, "settled", "carefully");
|
|
273
284
|
annotate(session.id, "open", "Context", "needs a diagram");
|
|
274
285
|
core.sessionSubmitRevision(session.id, PLAN.content, ["settled"]);
|
|
@@ -334,6 +345,7 @@ describe("persistence and recovery", () => {
|
|
|
334
345
|
test("sessions survive a daemon restart", () => {
|
|
335
346
|
// Arrange
|
|
336
347
|
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
348
|
+
|
|
337
349
|
core.sessionResolve(session.id, "approve", "done");
|
|
338
350
|
|
|
339
351
|
// Act
|
|
@@ -364,10 +376,12 @@ describe("events", () => {
|
|
|
364
376
|
test("lifecycle events fire in order", () => {
|
|
365
377
|
// Arrange
|
|
366
378
|
const seen: string[] = [];
|
|
379
|
+
|
|
367
380
|
core.onEvent((event) => seen.push(event.event));
|
|
368
381
|
|
|
369
382
|
// Act
|
|
370
383
|
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
384
|
+
|
|
371
385
|
core.sessionAnnotate(session.id, {
|
|
372
386
|
id: "a1",
|
|
373
387
|
kind: "comment",
|
package/src/api.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
feedbackForSession,
|
|
11
11
|
isAddressed,
|
|
12
12
|
isAgentNote,
|
|
13
|
+
isMarkdownArtifact,
|
|
13
14
|
parseBlocks,
|
|
14
15
|
registerParticipant,
|
|
15
16
|
resolveAnchor,
|
|
@@ -84,6 +85,7 @@ export class DaemonCore {
|
|
|
84
85
|
|
|
85
86
|
onEvent(listener: EventListener): () => void {
|
|
86
87
|
this.listeners.add(listener);
|
|
88
|
+
|
|
87
89
|
return () => this.listeners.delete(listener);
|
|
88
90
|
}
|
|
89
91
|
|
|
@@ -109,16 +111,20 @@ export class DaemonCore {
|
|
|
109
111
|
status: "pending",
|
|
110
112
|
createdAt: now,
|
|
111
113
|
};
|
|
114
|
+
|
|
112
115
|
this.store.upsert(session);
|
|
113
116
|
this.watchIfDiffSession(session);
|
|
114
117
|
this.emit("session.created", session.id);
|
|
115
118
|
this.emit("inbox.changed", session.id);
|
|
119
|
+
|
|
116
120
|
return session;
|
|
117
121
|
}
|
|
118
122
|
|
|
119
123
|
sessionGet(id: string): ReviewSession {
|
|
120
124
|
const session = this.store.get(id);
|
|
125
|
+
|
|
121
126
|
if (!session) throw new DaemonError("not_found", `no session ${id}`);
|
|
127
|
+
|
|
122
128
|
return session;
|
|
123
129
|
}
|
|
124
130
|
|
|
@@ -132,6 +138,7 @@ export class DaemonCore {
|
|
|
132
138
|
return false;
|
|
133
139
|
if (filter?.workspace?.branch && session.workspace.branch !== filter.workspace.branch)
|
|
134
140
|
return false;
|
|
141
|
+
|
|
135
142
|
return true;
|
|
136
143
|
});
|
|
137
144
|
}
|
|
@@ -143,7 +150,9 @@ export class DaemonCore {
|
|
|
143
150
|
*/
|
|
144
151
|
sessionWait(id: string, timeoutMs: number): Promise<ReviewSession | null> {
|
|
145
152
|
const current = this.sessionGet(id);
|
|
153
|
+
|
|
146
154
|
if (current.status === "resolved") return Promise.resolve(current);
|
|
155
|
+
|
|
147
156
|
return new Promise((resolve) => {
|
|
148
157
|
const list = this.waiters.get(id) ?? [];
|
|
149
158
|
let done = false;
|
|
@@ -151,6 +160,7 @@ export class DaemonCore {
|
|
|
151
160
|
if (done) return;
|
|
152
161
|
done = true;
|
|
153
162
|
const waiterIndex = list.indexOf(waiter);
|
|
163
|
+
|
|
154
164
|
if (waiterIndex !== -1) list.splice(waiterIndex, 1);
|
|
155
165
|
resolve(null);
|
|
156
166
|
}, timeoutMs);
|
|
@@ -160,6 +170,7 @@ export class DaemonCore {
|
|
|
160
170
|
clearTimeout(timer);
|
|
161
171
|
resolve(session);
|
|
162
172
|
};
|
|
173
|
+
|
|
163
174
|
list.push(waiter);
|
|
164
175
|
this.waiters.set(id, list);
|
|
165
176
|
});
|
|
@@ -177,6 +188,7 @@ export class DaemonCore {
|
|
|
177
188
|
const session = this.mutable(id);
|
|
178
189
|
const existing = session.annotations.findIndex((candidate) => candidate.id === annotation.id);
|
|
179
190
|
const full: Annotation = { ...annotation, createdAt: new Date().toISOString() };
|
|
191
|
+
|
|
180
192
|
if (existing === -1) session.annotations.push(full);
|
|
181
193
|
else
|
|
182
194
|
session.annotations[existing] = {
|
|
@@ -191,25 +203,30 @@ export class DaemonCore {
|
|
|
191
203
|
).participants;
|
|
192
204
|
this.store.upsert(session);
|
|
193
205
|
this.emit("session.updated", id);
|
|
206
|
+
|
|
194
207
|
return session;
|
|
195
208
|
}
|
|
196
209
|
|
|
197
210
|
sessionRemoveAnnotation(id: string, annotationId: string): ReviewSession {
|
|
198
211
|
const session = this.mutable(id);
|
|
212
|
+
|
|
199
213
|
session.annotations = session.annotations.filter((candidate) => candidate.id !== annotationId);
|
|
200
214
|
this.store.upsert(session);
|
|
201
215
|
this.emit("session.updated", id);
|
|
216
|
+
|
|
202
217
|
return session;
|
|
203
218
|
}
|
|
204
219
|
|
|
205
220
|
/** The reviewer's working copy; undefined clears it (revert all edits). */
|
|
206
221
|
sessionSetWorkingCopy(id: string, workingCopy: string | undefined): ReviewSession {
|
|
207
222
|
const session = this.mutable(id);
|
|
223
|
+
|
|
208
224
|
if (workingCopy === undefined || workingCopy === session.artifact.content)
|
|
209
225
|
delete session.workingCopy;
|
|
210
226
|
else session.workingCopy = workingCopy;
|
|
211
227
|
this.store.upsert(session);
|
|
212
228
|
this.emit("session.updated", id);
|
|
229
|
+
|
|
213
230
|
return session;
|
|
214
231
|
}
|
|
215
232
|
|
|
@@ -220,24 +237,29 @@ export class DaemonCore {
|
|
|
220
237
|
*/
|
|
221
238
|
sessionSetViewed(id: string, viewedPaths: string[]): ReviewSession {
|
|
222
239
|
const session = this.mutable(id);
|
|
240
|
+
|
|
223
241
|
if (viewedPaths.length === 0) delete session.viewedPaths;
|
|
224
242
|
else session.viewedPaths = [...new Set([...(session.viewedPaths ?? []), ...viewedPaths])];
|
|
225
243
|
this.store.upsert(session);
|
|
226
244
|
this.emit("session.updated", id);
|
|
245
|
+
|
|
227
246
|
return session;
|
|
228
247
|
}
|
|
229
248
|
|
|
230
249
|
sessionSetShareId(id: string, shareId: string): ReviewSession {
|
|
231
250
|
const session = this.mutable(id);
|
|
251
|
+
|
|
232
252
|
session.shareId = shareId;
|
|
233
253
|
this.store.upsert(session);
|
|
234
254
|
this.emit("session.updated", id);
|
|
255
|
+
|
|
235
256
|
return session;
|
|
236
257
|
}
|
|
237
258
|
|
|
238
259
|
/** Remove a session for good (inbox delete); resolved or pending, both go. */
|
|
239
260
|
sessionDelete(id: string): void {
|
|
240
261
|
const session = this.store.get(id);
|
|
262
|
+
|
|
241
263
|
if (!this.store.delete(id)) throw new DaemonError("not_found", `no session ${id}`);
|
|
242
264
|
if (session) this.unwatchIfDiffSession(session);
|
|
243
265
|
this.diffRefreshGenerations.delete(id);
|
|
@@ -258,17 +280,20 @@ export class DaemonCore {
|
|
|
258
280
|
): ReviewSession {
|
|
259
281
|
const session = this.mutable(id);
|
|
260
282
|
const known = new Set(session.annotations.map((annotation) => annotation.id));
|
|
283
|
+
|
|
261
284
|
for (const annotation of incoming.annotations)
|
|
262
285
|
if (!known.has(annotation.id)) session.annotations.push(annotation);
|
|
263
286
|
if (incoming.participants?.length) {
|
|
264
287
|
const registry = new Map(
|
|
265
288
|
(session.participants ?? []).map((participant) => [participant.id, participant]),
|
|
266
289
|
);
|
|
290
|
+
|
|
267
291
|
for (const participant of incoming.participants) registry.set(participant.id, participant);
|
|
268
292
|
session.participants = [...registry.values()];
|
|
269
293
|
}
|
|
270
294
|
this.store.upsert(session);
|
|
271
295
|
this.emit("session.updated", id);
|
|
296
|
+
|
|
272
297
|
return session;
|
|
273
298
|
}
|
|
274
299
|
|
|
@@ -280,16 +305,19 @@ export class DaemonCore {
|
|
|
280
305
|
feedback: feedbackForSession(session, verdictKind, summary),
|
|
281
306
|
resolvedAt: new Date().toISOString(),
|
|
282
307
|
};
|
|
308
|
+
|
|
283
309
|
session.verdict = verdict;
|
|
284
310
|
session.status = "resolved";
|
|
285
311
|
this.store.upsert(session);
|
|
286
312
|
// a resolved diff review is frozen; stop hot-reloading its working tree
|
|
287
313
|
this.unwatchIfDiffSession(session);
|
|
288
314
|
const parked = this.waiters.get(id) ?? [];
|
|
315
|
+
|
|
289
316
|
this.waiters.delete(id);
|
|
290
317
|
for (const parkedWaiter of parked) parkedWaiter(session);
|
|
291
318
|
this.emit("session.resolved", id);
|
|
292
319
|
this.emit("inbox.changed", id);
|
|
320
|
+
|
|
293
321
|
return session;
|
|
294
322
|
}
|
|
295
323
|
|
|
@@ -311,6 +339,7 @@ export class DaemonCore {
|
|
|
311
339
|
const session = this.sessionGet(id);
|
|
312
340
|
const now = new Date().toISOString();
|
|
313
341
|
const revisionNumber = session.revisions.length + 1;
|
|
342
|
+
|
|
314
343
|
session.revisions.push({ revision: revisionNumber, content, submittedAt: now });
|
|
315
344
|
session.artifact = { ...session.artifact, content };
|
|
316
345
|
delete session.workingCopy;
|
|
@@ -318,9 +347,11 @@ export class DaemonCore {
|
|
|
318
347
|
session.status = "pending";
|
|
319
348
|
|
|
320
349
|
const reportedIds = new Set(addressedAnnotationIds);
|
|
321
|
-
// drift assist applies to
|
|
322
|
-
// patch, where a vanished quote says nothing about
|
|
323
|
-
|
|
350
|
+
// drift assist applies to markdown artifacts (plan, reply) only: a diff
|
|
351
|
+
// revision is a whole new patch, where a vanished quote says nothing about
|
|
352
|
+
// the feedback
|
|
353
|
+
const revisedBlocks = isMarkdownArtifact(session.artifact.type) ? parseBlocks(content) : null;
|
|
354
|
+
|
|
324
355
|
for (const annotation of session.annotations) {
|
|
325
356
|
if (isAddressed(annotation) || isAgentNote(annotation)) continue;
|
|
326
357
|
if (reportedIds.has(annotation.id)) {
|
|
@@ -336,28 +367,34 @@ export class DaemonCore {
|
|
|
336
367
|
this.store.upsert(session);
|
|
337
368
|
this.emit("session.revised", id);
|
|
338
369
|
this.emit("inbox.changed", id);
|
|
370
|
+
|
|
339
371
|
return session;
|
|
340
372
|
}
|
|
341
373
|
|
|
342
374
|
/** Re-capture a diff session's working tree; broadcasts session.updated only when the patch moved. */
|
|
343
375
|
async sessionRefreshDiff(id: string): Promise<{ changed: boolean }> {
|
|
344
376
|
const session = this.mutable(id);
|
|
377
|
+
|
|
345
378
|
if (session.artifact.type !== "diff") return { changed: false };
|
|
346
379
|
const generation = (this.diffRefreshGenerations.get(id) ?? 0) + 1;
|
|
380
|
+
|
|
347
381
|
this.diffRefreshGenerations.set(id, generation);
|
|
348
382
|
const diff = await workingTreeDiff(session.workspace.repoRoot);
|
|
383
|
+
|
|
349
384
|
// The capture yields the event loop: a newer refresh may have started, or a
|
|
350
385
|
// concurrent resolve/delete may have closed the session. Discard a stale
|
|
351
386
|
// capture, and re-read so a resolved review is never mutated and a deleted
|
|
352
387
|
// one is never revived by this upsert.
|
|
353
388
|
if (this.diffRefreshGenerations.get(id) !== generation) return { changed: false };
|
|
354
389
|
const current = this.store.get(id);
|
|
390
|
+
|
|
355
391
|
if (!current || current.status !== "pending" || current.artifact.type !== "diff")
|
|
356
392
|
return { changed: false };
|
|
357
393
|
if (diff.patch === current.artifact.content) return { changed: false };
|
|
358
394
|
current.artifact = { ...current.artifact, content: diff.patch, files: diff.files };
|
|
359
395
|
this.store.upsert(current);
|
|
360
396
|
this.emit("session.updated", id);
|
|
397
|
+
|
|
361
398
|
return { changed: true };
|
|
362
399
|
}
|
|
363
400
|
|
|
@@ -366,6 +403,7 @@ export class DaemonCore {
|
|
|
366
403
|
const live = this.store
|
|
367
404
|
.list()
|
|
368
405
|
.filter((session) => isLiveDiffSession(session) && session.workspace.repoRoot === repoRoot);
|
|
406
|
+
|
|
369
407
|
for (const session of live) {
|
|
370
408
|
// a session resolved or deleted between the change and this tick just skips
|
|
371
409
|
try {
|
|
@@ -388,8 +426,10 @@ export class DaemonCore {
|
|
|
388
426
|
|
|
389
427
|
private mutable(id: string): ReviewSession {
|
|
390
428
|
const session = this.sessionGet(id);
|
|
429
|
+
|
|
391
430
|
if (session.status === "resolved")
|
|
392
431
|
throw new DaemonError("resolved", `session ${id} is already resolved`);
|
|
432
|
+
|
|
393
433
|
return session;
|
|
394
434
|
}
|
|
395
435
|
}
|
|
@@ -404,5 +444,6 @@ function isLiveDiffSession(session: ReviewSession): boolean {
|
|
|
404
444
|
/** Convenience for adapters: map a resolved session to the agent contract. */
|
|
405
445
|
export function verdictResponse(session: ReviewSession): { allow: boolean; feedback: string } {
|
|
406
446
|
if (!session.verdict) throw new DaemonError("pending", "session has no verdict");
|
|
447
|
+
|
|
407
448
|
return { allow: verdictAllows(session.verdict.kind), feedback: session.verdict.feedback };
|
|
408
449
|
}
|
package/src/client.ts
CHANGED
|
@@ -74,9 +74,11 @@ export class DaemonClient implements SessionClient {
|
|
|
74
74
|
const home = options.home ?? cueloopHome();
|
|
75
75
|
const path = socketPath(home);
|
|
76
76
|
const client = new DaemonClient();
|
|
77
|
+
|
|
77
78
|
client.role = options.role ?? "owner";
|
|
78
79
|
try {
|
|
79
80
|
await client.dial(path);
|
|
81
|
+
|
|
80
82
|
return client;
|
|
81
83
|
} catch (err) {
|
|
82
84
|
if (!options.autostart) throw err;
|
|
@@ -88,9 +90,11 @@ export class DaemonClient implements SessionClient {
|
|
|
88
90
|
// socket exists, and giving up early looks to callers like a broken daemon.
|
|
89
91
|
const deadline = Date.now() + Number(process.env.CUELOOP_START_TIMEOUT_MS ?? 30_000);
|
|
90
92
|
let lastError: unknown;
|
|
93
|
+
|
|
91
94
|
while (Date.now() < deadline) {
|
|
92
95
|
try {
|
|
93
96
|
await client.dial(path);
|
|
97
|
+
|
|
94
98
|
return client;
|
|
95
99
|
} catch (err) {
|
|
96
100
|
lastError = err;
|
|
@@ -102,6 +106,7 @@ export class DaemonClient implements SessionClient {
|
|
|
102
106
|
|
|
103
107
|
private async dial(path: string): Promise<void> {
|
|
104
108
|
const buffer = new LineBuffer();
|
|
109
|
+
|
|
105
110
|
this.socket = await Bun.connect({
|
|
106
111
|
unix: path,
|
|
107
112
|
socket: {
|
|
@@ -130,11 +135,13 @@ export class DaemonClient implements SessionClient {
|
|
|
130
135
|
|
|
131
136
|
onEvent(listener: (event: EventFrame) => void): () => void {
|
|
132
137
|
this.eventListeners.add(listener);
|
|
138
|
+
|
|
133
139
|
return () => this.eventListeners.delete(listener);
|
|
134
140
|
}
|
|
135
141
|
|
|
136
142
|
private routeInboundFrame(line: string): void {
|
|
137
143
|
let frame: Response | EventFrame;
|
|
144
|
+
|
|
138
145
|
try {
|
|
139
146
|
frame = JSON.parse(line) as Response | EventFrame;
|
|
140
147
|
} catch {
|
|
@@ -142,9 +149,11 @@ export class DaemonClient implements SessionClient {
|
|
|
142
149
|
}
|
|
143
150
|
if ("event" in frame) {
|
|
144
151
|
for (const listener of this.eventListeners) listener(frame);
|
|
152
|
+
|
|
145
153
|
return;
|
|
146
154
|
}
|
|
147
155
|
const pendingRequest = this.pending.get(frame.id);
|
|
156
|
+
|
|
148
157
|
if (!pendingRequest) return;
|
|
149
158
|
this.pending.delete(frame.id);
|
|
150
159
|
if (frame.error)
|
|
@@ -155,11 +164,13 @@ export class DaemonClient implements SessionClient {
|
|
|
155
164
|
request<T = unknown>(method: string, params: unknown, timeoutMs = 30_000): Promise<T> {
|
|
156
165
|
if (this.closed || !this.socket) return Promise.reject(new Error("not connected"));
|
|
157
166
|
const id = this.nextId++;
|
|
167
|
+
|
|
158
168
|
return new Promise<T>((resolve, reject) => {
|
|
159
169
|
const timer = setTimeout(() => {
|
|
160
170
|
this.pending.delete(id);
|
|
161
171
|
reject(new Error(`request ${method} timed out`));
|
|
162
172
|
}, timeoutMs);
|
|
173
|
+
|
|
163
174
|
this.pending.set(id, {
|
|
164
175
|
resolve: (value) => {
|
|
165
176
|
clearTimeout(timer);
|
|
@@ -268,6 +279,7 @@ export class DaemonClientError extends Error {
|
|
|
268
279
|
|
|
269
280
|
function spawnDaemon(home: string): void {
|
|
270
281
|
const entry = new URL("./main.ts", import.meta.url).pathname;
|
|
282
|
+
|
|
271
283
|
Bun.spawn([process.execPath, "run", entry], {
|
|
272
284
|
env: { ...process.env, CUELOOP_HOME: home },
|
|
273
285
|
stdio: ["ignore", "ignore", "ignore"],
|
|
@@ -15,6 +15,7 @@ import type { Artifact } from "@cueloop/schema";
|
|
|
15
15
|
|
|
16
16
|
function git(args: string[], cwd: string): void {
|
|
17
17
|
const result = Bun.spawnSync(["git", ...args], { cwd, stdout: "ignore", stderr: "ignore" });
|
|
18
|
+
|
|
18
19
|
if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")} failed in ${cwd}`);
|
|
19
20
|
}
|
|
20
21
|
|
|
@@ -45,6 +46,7 @@ async function openDiffSession() {
|
|
|
45
46
|
const workspace = await resolveWorkspace(repo);
|
|
46
47
|
const diff = await workingTreeDiff(repo);
|
|
47
48
|
const artifact: Artifact = { type: "diff", content: diff.patch, files: diff.files, meta: {} };
|
|
49
|
+
|
|
48
50
|
return core.sessionCreate({ workspace, artifact });
|
|
49
51
|
}
|
|
50
52
|
|
|
@@ -52,6 +54,7 @@ describe("session.refreshDiff", () => {
|
|
|
52
54
|
test("re-captures the working tree and updates the artifact when the patch changed", async () => {
|
|
53
55
|
// Given an open diff session whose working tree then gains a new change
|
|
54
56
|
const session = await openDiffSession();
|
|
57
|
+
|
|
55
58
|
writeFileSync(join(repo, "a.ts"), "export const a = 2;\n");
|
|
56
59
|
|
|
57
60
|
// When the diff is refreshed
|
|
@@ -60,8 +63,10 @@ describe("session.refreshDiff", () => {
|
|
|
60
63
|
// Then the change is reported and the stored artifact carries the fresh patch and files
|
|
61
64
|
expect(result.changed).toBe(true);
|
|
62
65
|
const refreshed = core.sessionGet(session.id);
|
|
66
|
+
|
|
63
67
|
expect(refreshed.artifact.content).toContain("+export const a = 2;");
|
|
64
68
|
const modified = refreshed.artifact.files!.find((file) => file.path === "a.ts")!;
|
|
69
|
+
|
|
65
70
|
expect(modified.newContents).toBe("export const a = 2;\n");
|
|
66
71
|
});
|
|
67
72
|
|
|
@@ -69,6 +74,7 @@ describe("session.refreshDiff", () => {
|
|
|
69
74
|
// Given an open diff session and a listener for session events
|
|
70
75
|
const session = await openDiffSession();
|
|
71
76
|
const events: string[] = [];
|
|
77
|
+
|
|
72
78
|
core.onEvent((event) => events.push(event.event));
|
|
73
79
|
|
|
74
80
|
// When the diff is refreshed without any working-tree change
|
|
@@ -83,6 +89,7 @@ describe("session.refreshDiff", () => {
|
|
|
83
89
|
// Given an open diff session, a listener, and a fresh working-tree change
|
|
84
90
|
const session = await openDiffSession();
|
|
85
91
|
const events: string[] = [];
|
|
92
|
+
|
|
86
93
|
core.onEvent((event) => events.push(event.event));
|
|
87
94
|
writeFileSync(join(repo, "a.ts"), "export const a = 3;\n");
|
|
88
95
|
|
|
@@ -110,8 +117,10 @@ describe("session.refreshDiff", () => {
|
|
|
110
117
|
test("refusing a resolved diff session never mutates it (no revive of a closed review)", async () => {
|
|
111
118
|
// Given a diff session that has been resolved
|
|
112
119
|
const session = await openDiffSession();
|
|
120
|
+
|
|
113
121
|
core.sessionResolve(session.id, "approve", "");
|
|
114
122
|
const resolvedContent = core.sessionGet(session.id).artifact.content;
|
|
123
|
+
|
|
115
124
|
writeFileSync(join(repo, "a.ts"), "export const a = 99;\n");
|
|
116
125
|
|
|
117
126
|
// When a refresh is attempted on the resolved session
|
|
@@ -123,6 +132,7 @@ describe("session.refreshDiff", () => {
|
|
|
123
132
|
test("overlapping refreshes settle to one coherent artifact without error", async () => {
|
|
124
133
|
// Given a live diff session with a fresh working-tree change
|
|
125
134
|
const session = await openDiffSession();
|
|
135
|
+
|
|
126
136
|
writeFileSync(join(repo, "a.ts"), "export const a = 7;\n");
|
|
127
137
|
|
|
128
138
|
// When two refreshes run concurrently over the same session
|
|
@@ -150,6 +160,7 @@ describe("the fs watcher drives hot-reload", () => {
|
|
|
150
160
|
// Watchers can be slow under CI contention, so poll generously.
|
|
151
161
|
const deadline = Date.now() + 8_000;
|
|
152
162
|
let content = core.sessionGet(session.id).artifact.content;
|
|
163
|
+
|
|
153
164
|
while (!content.includes("+export const a = 42;") && Date.now() < deadline) {
|
|
154
165
|
await Bun.sleep(100);
|
|
155
166
|
content = core.sessionGet(session.id).artifact.content;
|
package/src/diff-watcher.ts
CHANGED
|
@@ -20,6 +20,7 @@ const DIFF_REFRESH_DEBOUNCE_MS = 300;
|
|
|
20
20
|
*/
|
|
21
21
|
function isIgnoredWatchPath(relativePath: string): boolean {
|
|
22
22
|
const segments = relativePath.split(sep);
|
|
23
|
+
|
|
23
24
|
return segments.includes(".git") || segments.includes("node_modules");
|
|
24
25
|
}
|
|
25
26
|
|
|
@@ -44,11 +45,14 @@ export class DiffWatcher {
|
|
|
44
45
|
/** Start (or join) watching a repo root for one diff session. Idempotent per (root, session). */
|
|
45
46
|
trackDiffRepo(repoRoot: string, sessionId: string): void {
|
|
46
47
|
const existing = this.repoWatches.get(repoRoot);
|
|
48
|
+
|
|
47
49
|
if (existing) {
|
|
48
50
|
existing.sessionIds.add(sessionId);
|
|
51
|
+
|
|
49
52
|
return;
|
|
50
53
|
}
|
|
51
54
|
let handle: FSWatcher;
|
|
55
|
+
|
|
52
56
|
try {
|
|
53
57
|
handle = watch(repoRoot, { recursive: true }, (_event, filename) => {
|
|
54
58
|
if (filename !== null && isIgnoredWatchPath(filename.toString())) return;
|
|
@@ -70,6 +74,7 @@ export class DiffWatcher {
|
|
|
70
74
|
/** Drop one diff session; close the repo watch once no diff session tracks it. */
|
|
71
75
|
untrackDiffRepo(repoRoot: string, sessionId: string): void {
|
|
72
76
|
const repoWatch = this.repoWatches.get(repoRoot);
|
|
77
|
+
|
|
73
78
|
if (!repoWatch) return;
|
|
74
79
|
repoWatch.sessionIds.delete(sessionId);
|
|
75
80
|
if (repoWatch.sessionIds.size > 0) return;
|
|
@@ -80,6 +85,7 @@ export class DiffWatcher {
|
|
|
80
85
|
|
|
81
86
|
private scheduleRepoRefresh(repoRoot: string): void {
|
|
82
87
|
const repoWatch = this.repoWatches.get(repoRoot);
|
|
88
|
+
|
|
83
89
|
if (!repoWatch) return;
|
|
84
90
|
if (repoWatch.debounce !== null) clearTimeout(repoWatch.debounce);
|
|
85
91
|
repoWatch.debounce = setTimeout(() => {
|
package/src/herdr-pane.test.ts
CHANGED
|
@@ -20,6 +20,7 @@ function makeStub(name: string, paneAlive = false): { binPath: string; logPath:
|
|
|
20
20
|
const logPath = join(dir, `${name}.log`);
|
|
21
21
|
const binPath = join(dir, `${name}.sh`);
|
|
22
22
|
const paneGet = paneAlive ? `printf '{"result":{"pane":{"pane_id":"w1:p2"}}}'` : "exit 1";
|
|
23
|
+
|
|
23
24
|
writeFileSync(
|
|
24
25
|
binPath,
|
|
25
26
|
`#!/bin/sh
|
|
@@ -32,11 +33,13 @@ exit 0
|
|
|
32
33
|
`,
|
|
33
34
|
);
|
|
34
35
|
chmodSync(binPath, 0o755);
|
|
36
|
+
|
|
35
37
|
return { binPath, logPath };
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
function readLines(logPath: string): string[] {
|
|
39
41
|
if (!existsSync(logPath)) return [];
|
|
42
|
+
|
|
40
43
|
return readFileSync(logPath, "utf8").split("\n").filter(Boolean);
|
|
41
44
|
}
|
|
42
45
|
|
|
@@ -46,6 +49,7 @@ function fakePersistence(initial: HerdrTabHandle | null = null): {
|
|
|
46
49
|
saved: () => HerdrTabHandle | null;
|
|
47
50
|
} {
|
|
48
51
|
let stored = initial;
|
|
52
|
+
|
|
49
53
|
return {
|
|
50
54
|
persistence: {
|
|
51
55
|
herdrGetTab: async () => stored,
|
|
@@ -109,6 +113,7 @@ describe("openHerdrPane", () => {
|
|
|
109
113
|
// Arrange
|
|
110
114
|
const logPath = join(dir, "nopane.log");
|
|
111
115
|
const binPath = join(dir, "nopane.sh");
|
|
116
|
+
|
|
112
117
|
writeFileSync(
|
|
113
118
|
binPath,
|
|
114
119
|
`#!/bin/sh\nprintf '%s\\n' "$*" >> "${logPath}"\nprintf '{"result":{}}'\n`,
|
|
@@ -170,6 +175,7 @@ describe("openHerdrPaneForReview", () => {
|
|
|
170
175
|
|
|
171
176
|
// Assert
|
|
172
177
|
const lines = readLines(stub.logPath);
|
|
178
|
+
|
|
173
179
|
expect(lines).toContain("pane get w9:p9");
|
|
174
180
|
expect(lines).toContain("tab focus w9:t9");
|
|
175
181
|
expect(lines.some((line) => line.startsWith("tab create"))).toBeFalse();
|
|
@@ -225,6 +231,7 @@ describe("openHerdrPaneForReview", () => {
|
|
|
225
231
|
|
|
226
232
|
// Assert
|
|
227
233
|
const lines = readLines(stub.logPath);
|
|
234
|
+
|
|
228
235
|
expect(lines).toContain("pane get old:p");
|
|
229
236
|
expect(lines).toContain("tab create --cwd /repo/work --label Rollout Plan --focus");
|
|
230
237
|
expect(store.saved()).toEqual({ tabId: "w1:t2", paneId: "w1:p2" });
|