@cueloop/daemon 0.1.0-alpha.79 → 0.1.0-alpha.80
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 +4 -3
- package/src/api.test.ts +57 -38
- package/src/api.ts +138 -33
- package/src/capabilities.test.ts +3 -3
- package/src/capabilities.ts +6 -1
- package/src/client.ts +73 -18
- package/src/diff-hot-reload.test.ts +2 -2
- package/src/harness-delivery.test.ts +193 -0
- package/src/harness-state-store.test.ts +65 -0
- package/src/harness-state-store.ts +182 -0
- package/src/herdr-pane.test.ts +1 -1
- package/src/index.ts +1 -1
- package/src/paths.ts +5 -0
- package/src/retention.test.ts +10 -1
- package/src/retention.ts +2 -1
- package/src/server.test.ts +53 -7
- package/src/server.ts +34 -4
- package/src/single-instance.test.ts +34 -0
- package/src/testing/store-conformance.ts +1 -1
- package/src/{review.test.ts → thread-review.test.ts} +48 -48
- package/src/{review.ts → thread-review.ts} +91 -43
- package/src/validate.test.ts +16 -14
- package/src/validate.ts +53 -10
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.80",
|
|
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": {
|
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
"exports": {
|
|
21
21
|
".": "./src/index.ts",
|
|
22
22
|
"./client": "./src/client.ts",
|
|
23
|
-
"./review": "./src/review.ts",
|
|
23
|
+
"./thread-review": "./src/thread-review.ts",
|
|
24
|
+
"./api": "./src/api.ts",
|
|
24
25
|
"./working-tree": "./src/working-tree.ts",
|
|
25
26
|
"./store": "./src/store.ts",
|
|
26
27
|
"./validate": "./src/validate.ts",
|
|
@@ -35,7 +36,7 @@
|
|
|
35
36
|
"access": "public"
|
|
36
37
|
},
|
|
37
38
|
"dependencies": {
|
|
38
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
39
|
+
"@cueloop/schema": "0.1.0-alpha.80",
|
|
39
40
|
"@pierre/diffs": "^1.4.1",
|
|
40
41
|
"effect": "^4.0.0-rc.115",
|
|
41
42
|
"valibot": "^1.4.2"
|
package/src/api.test.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import { DaemonCore,
|
|
5
|
+
import { DaemonCore, messageResponse } from "./api";
|
|
6
6
|
import { ThreadStore } from "./store";
|
|
7
7
|
import { derivePath, tipOf, type Artifact, type WorkspaceKey } from "@cueloop/schema";
|
|
8
8
|
import { MAX_BLOB_BYTES, packSessionBlob, unpackSessionBlob } from "./share-blob";
|
|
@@ -117,31 +117,31 @@ describe("session lifecycle", () => {
|
|
|
117
117
|
});
|
|
118
118
|
|
|
119
119
|
// Act
|
|
120
|
-
const resolved = core.
|
|
120
|
+
const resolved = core.sessionSendMessage(session.id, "changes_requested", "Needs detail.");
|
|
121
121
|
|
|
122
122
|
// Assert
|
|
123
|
-
expect(resolved.
|
|
124
|
-
expect(resolved.
|
|
125
|
-
expect(
|
|
123
|
+
expect(resolved.message!.body).toContain("# Review: changes requested");
|
|
124
|
+
expect(resolved.message!.body).toContain("> carefully");
|
|
125
|
+
expect(messageResponse(resolved)).toEqual({
|
|
126
126
|
allow: false,
|
|
127
|
-
|
|
127
|
+
message: resolved.message!,
|
|
128
128
|
});
|
|
129
129
|
|
|
130
130
|
// Arrange
|
|
131
131
|
const approved = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
132
132
|
|
|
133
133
|
// Act
|
|
134
|
-
core.
|
|
134
|
+
core.sessionSendMessage(approved.id, "approved", "LGTM");
|
|
135
135
|
|
|
136
136
|
// Assert
|
|
137
|
-
expect(
|
|
137
|
+
expect(messageResponse(core.sessionGet(approved.id)).allow).toBe(true);
|
|
138
138
|
});
|
|
139
139
|
|
|
140
140
|
test("mutating a resolved session throws", () => {
|
|
141
141
|
// Arrange
|
|
142
142
|
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
143
143
|
|
|
144
|
-
core.
|
|
144
|
+
core.sessionSendMessage(session.id, "approved", "");
|
|
145
145
|
|
|
146
146
|
// Assert
|
|
147
147
|
expect(() =>
|
|
@@ -217,19 +217,19 @@ describe("session lifecycle", () => {
|
|
|
217
217
|
expect(core.sessionGet(session.id).viewedPaths).toBeUndefined();
|
|
218
218
|
});
|
|
219
219
|
|
|
220
|
-
test("revision reopens the session and resets working copy +
|
|
220
|
+
test("revision reopens the session and resets working copy + message", () => {
|
|
221
221
|
// Arrange
|
|
222
222
|
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
223
223
|
|
|
224
224
|
core.sessionSetWorkingCopy(session.id, PLAN.content + "\nedit");
|
|
225
|
-
core.
|
|
225
|
+
core.sessionSendMessage(session.id, "changes_requested", "redo");
|
|
226
226
|
|
|
227
227
|
// Act
|
|
228
228
|
const revised = core.sessionSubmitRevision(session.id, "# Plan v2\n");
|
|
229
229
|
|
|
230
230
|
// Assert
|
|
231
231
|
expect(revised.status).toBe("pending");
|
|
232
|
-
expect(revised.
|
|
232
|
+
expect(revised.message).toBeNull();
|
|
233
233
|
expect(revised.workingCopy).toBeUndefined();
|
|
234
234
|
expect(revised.revisions.length).toBe(2);
|
|
235
235
|
expect(revised.artifact.content).toBe("# Plan v2\n");
|
|
@@ -345,31 +345,31 @@ describe("revision marks addressed annotations", () => {
|
|
|
345
345
|
core.sessionSubmitRevision(session.id, PLAN.content, ["settled"]);
|
|
346
346
|
|
|
347
347
|
// Act
|
|
348
|
-
const resolved = core.
|
|
348
|
+
const resolved = core.sessionSendMessage(session.id, "changes_requested", "one left");
|
|
349
349
|
|
|
350
350
|
// Assert
|
|
351
|
-
expect(resolved.
|
|
352
|
-
expect(resolved.
|
|
353
|
-
expect(resolved.
|
|
354
|
-
expect(resolved.
|
|
351
|
+
expect(resolved.message!.body).not.toContain("settled");
|
|
352
|
+
expect(resolved.message!.body).toContain("annotation id: `open`");
|
|
353
|
+
expect(resolved.message!.body).toContain(`submit-revision ${session.id}`);
|
|
354
|
+
expect(resolved.message!.body).toContain("--addressed");
|
|
355
355
|
});
|
|
356
356
|
});
|
|
357
357
|
|
|
358
|
-
describe("the wait contract:
|
|
359
|
-
test("wait resolves when the
|
|
358
|
+
describe("the wait contract: messages outlive waits", () => {
|
|
359
|
+
test("wait resolves when the message arrives", async () => {
|
|
360
360
|
// Arrange
|
|
361
361
|
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
362
362
|
const wait = core.sessionWait(session.id, 5_000);
|
|
363
363
|
|
|
364
364
|
// Act
|
|
365
|
-
core.
|
|
365
|
+
core.sessionSendMessage(session.id, "approved", "");
|
|
366
366
|
const resolved = await wait;
|
|
367
367
|
|
|
368
368
|
// Assert
|
|
369
|
-
expect(resolved!.
|
|
369
|
+
expect(resolved!.message!.outcome).toBe("approved");
|
|
370
370
|
});
|
|
371
371
|
|
|
372
|
-
test("wait times out to null; a later wait collects the stored
|
|
372
|
+
test("wait times out to null; a later wait collects the stored message", async () => {
|
|
373
373
|
// Arrange
|
|
374
374
|
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
375
375
|
|
|
@@ -380,11 +380,11 @@ describe("the wait contract: verdicts outlive waits", () => {
|
|
|
380
380
|
expect(first).toBeNull();
|
|
381
381
|
|
|
382
382
|
// Act
|
|
383
|
-
core.
|
|
383
|
+
core.sessionSendMessage(session.id, "changes_requested", "later");
|
|
384
384
|
const second = await core.sessionWait(session.id, 30);
|
|
385
385
|
|
|
386
386
|
// Assert
|
|
387
|
-
expect(second!.
|
|
387
|
+
expect(second!.message!.summary).toBe("later");
|
|
388
388
|
});
|
|
389
389
|
|
|
390
390
|
test("multiple waiters all resolve", async () => {
|
|
@@ -393,7 +393,7 @@ describe("the wait contract: verdicts outlive waits", () => {
|
|
|
393
393
|
const waits = [core.sessionWait(session.id, 5_000), core.sessionWait(session.id, 5_000)];
|
|
394
394
|
|
|
395
395
|
// Act
|
|
396
|
-
core.
|
|
396
|
+
core.sessionSendMessage(session.id, "approved", "");
|
|
397
397
|
const results = await Promise.all(waits);
|
|
398
398
|
|
|
399
399
|
// Assert
|
|
@@ -406,14 +406,14 @@ describe("persistence and recovery", () => {
|
|
|
406
406
|
// Arrange
|
|
407
407
|
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
408
408
|
|
|
409
|
-
core.
|
|
409
|
+
core.sessionSendMessage(session.id, "approved", "done");
|
|
410
410
|
|
|
411
411
|
// Act
|
|
412
412
|
const reborn = new DaemonCore(home);
|
|
413
413
|
const recovered = reborn.sessionGet(session.id);
|
|
414
414
|
|
|
415
415
|
// Assert
|
|
416
|
-
expect(recovered.
|
|
416
|
+
expect(recovered.message!.summary).toBe("done");
|
|
417
417
|
});
|
|
418
418
|
|
|
419
419
|
test("corrupt records are skipped, not fatal, and never deleted", async () => {
|
|
@@ -448,14 +448,14 @@ describe("events", () => {
|
|
|
448
448
|
anchor: { quote: "thing", prefix: "", suffix: "" },
|
|
449
449
|
body: "b",
|
|
450
450
|
});
|
|
451
|
-
core.
|
|
451
|
+
core.sessionSendMessage(session.id, "approved", "");
|
|
452
452
|
|
|
453
453
|
// Assert
|
|
454
454
|
expect(seen).toEqual([
|
|
455
455
|
"session.created",
|
|
456
456
|
"inbox.changed",
|
|
457
457
|
"session.updated",
|
|
458
|
-
"
|
|
458
|
+
"message.sent",
|
|
459
459
|
"inbox.changed",
|
|
460
460
|
]);
|
|
461
461
|
});
|
|
@@ -484,7 +484,7 @@ describe("the session history records every write as an entry", () => {
|
|
|
484
484
|
annotate(created.id, "a2", "Context");
|
|
485
485
|
core.sessionRemoveAnnotation(created.id, "a2");
|
|
486
486
|
core.sessionRemoveAnnotation(created.id, "never-existed");
|
|
487
|
-
core.
|
|
487
|
+
core.sessionSendMessage(created.id, "changes_requested", "tighten");
|
|
488
488
|
const revised = core.sessionSubmitRevision(created.id, PLAN.content + "\nMore.\n", ["a1"]);
|
|
489
489
|
|
|
490
490
|
// Assert: an edit of an existing comment and a removal of an unknown id leave no entry
|
|
@@ -495,7 +495,7 @@ describe("the session history records every write as an entry", () => {
|
|
|
495
495
|
"comment",
|
|
496
496
|
"comment",
|
|
497
497
|
"comment-removed",
|
|
498
|
-
"
|
|
498
|
+
"message",
|
|
499
499
|
"revision",
|
|
500
500
|
]);
|
|
501
501
|
expect(history.branch).toBe("main");
|
|
@@ -653,6 +653,25 @@ describe("curation primitives", () => {
|
|
|
653
653
|
// Assert
|
|
654
654
|
expect(() => core.sessionCurate(created.id, [])).toThrow(/only a diff review/);
|
|
655
655
|
});
|
|
656
|
+
|
|
657
|
+
test("rejects hunk curation after a patch-only diff revision clears file snapshots", () => {
|
|
658
|
+
const created = core.sessionCreate({
|
|
659
|
+
workspace: WS,
|
|
660
|
+
artifact: {
|
|
661
|
+
type: "diff",
|
|
662
|
+
content: "diff --git a/a.ts b/a.ts\n",
|
|
663
|
+
meta: {},
|
|
664
|
+
files: [{ path: "a.ts", status: "modified", oldContents: "old", newContents: "new" }],
|
|
665
|
+
},
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
core.sessionSubmitRevision(created.id, "diff --git a/b.ts b/b.ts\n", [], []);
|
|
669
|
+
|
|
670
|
+
expect(() => core.sessionCurate(created.id, [{ path: "b.ts", hunkIndex: 0 }])).toThrow(
|
|
671
|
+
/hunk curation needs full file contents/,
|
|
672
|
+
);
|
|
673
|
+
expect(core.sessionGet(created.id).workingCopy).toBeUndefined();
|
|
674
|
+
});
|
|
656
675
|
});
|
|
657
676
|
|
|
658
677
|
describe("tree primitives", () => {
|
|
@@ -709,11 +728,11 @@ describe("tree primitives", () => {
|
|
|
709
728
|
core.sessionNavigate(created.id, between);
|
|
710
729
|
|
|
711
730
|
// Act
|
|
712
|
-
const resolved = core.
|
|
731
|
+
const resolved = core.sessionSendMessage(created.id, "changes_requested", "one note");
|
|
713
732
|
|
|
714
733
|
// Assert
|
|
715
|
-
expect(resolved.
|
|
716
|
-
expect(resolved.
|
|
734
|
+
expect(resolved.message!.body).toContain("keep this one");
|
|
735
|
+
expect(resolved.message!.body).not.toContain("not this one");
|
|
717
736
|
});
|
|
718
737
|
|
|
719
738
|
test("a navigate names the branch to stand on, so a move on another branch is one request", () => {
|
|
@@ -846,13 +865,13 @@ describe("tree primitives", () => {
|
|
|
846
865
|
).toEqual(["c1"]);
|
|
847
866
|
});
|
|
848
867
|
|
|
849
|
-
test("a fork is a new pending session on the copied path, without
|
|
868
|
+
test("a fork is a new pending session on the copied path, without message, edits, or share", () => {
|
|
850
869
|
// Arrange: comment, edit, resolve, then a second round
|
|
851
870
|
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
852
871
|
|
|
853
872
|
core.sessionAnnotate(created.id, comment("a1", "first"), "Ana");
|
|
854
873
|
core.sessionSetShareId(created.id, "share-1");
|
|
855
|
-
core.
|
|
874
|
+
core.sessionSendMessage(created.id, "changes_requested", "again");
|
|
856
875
|
core.sessionSubmitRevision(created.id, "# Plan\n\nRound two.\n", ["a1"]);
|
|
857
876
|
core.sessionLabel(created.id, "round two");
|
|
858
877
|
core.sessionSetWorkingCopy(created.id, "# Plan\n\nRound two, edited.\n");
|
|
@@ -864,7 +883,7 @@ describe("tree primitives", () => {
|
|
|
864
883
|
expect(fork.id).not.toBe(created.id);
|
|
865
884
|
expect(fork.parentSessionId).toBe(created.id);
|
|
866
885
|
expect(fork.status).toBe("pending");
|
|
867
|
-
expect(fork.
|
|
886
|
+
expect(fork.message).toBeNull();
|
|
868
887
|
expect(fork.shareId).toBeUndefined();
|
|
869
888
|
expect(fork.workingCopy).toBeUndefined();
|
|
870
889
|
expect(fork.artifact.content).toBe("# Plan\n\nRound two.\n");
|
|
@@ -956,7 +975,7 @@ describe("workbench session", () => {
|
|
|
956
975
|
try {
|
|
957
976
|
const first = await core.workbenchSession(repo);
|
|
958
977
|
|
|
959
|
-
core.
|
|
978
|
+
core.sessionSendMessage(first.id, "changes_requested", "");
|
|
960
979
|
const second = await core.workbenchSession(repo);
|
|
961
980
|
|
|
962
981
|
expect(second.id).not.toBe(first.id);
|
package/src/api.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The daemon's method surface. Transport-independent: the socket
|
|
3
3
|
* server and the in-process test harness both call these handlers.
|
|
4
|
-
* The wait contract:
|
|
5
|
-
* and a
|
|
4
|
+
* The wait contract: messages outlive waits - session.wait long-polls,
|
|
5
|
+
* and a message resolved while nobody waited is delivered on next contact.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import {
|
|
@@ -27,33 +27,39 @@ import {
|
|
|
27
27
|
parseBlocks,
|
|
28
28
|
registerParticipant,
|
|
29
29
|
resolveAnchor,
|
|
30
|
+
newMessageId,
|
|
30
31
|
isBlockCut,
|
|
31
32
|
restoreBlock,
|
|
32
33
|
switchBranch,
|
|
33
|
-
|
|
34
|
+
messageAllows,
|
|
34
35
|
type Annotation,
|
|
35
36
|
type DiffFileStatus,
|
|
37
|
+
type DiffFileContents,
|
|
36
38
|
type HunkRejection,
|
|
37
39
|
type NewEntry,
|
|
38
40
|
type Artifact,
|
|
39
41
|
type ShareLink,
|
|
40
42
|
type Identity,
|
|
43
|
+
type HarnessBinding,
|
|
44
|
+
type Delivery,
|
|
45
|
+
type PendingDelivery,
|
|
41
46
|
type Thread,
|
|
42
47
|
type SessionHistory,
|
|
43
|
-
type
|
|
44
|
-
type
|
|
48
|
+
type Message,
|
|
49
|
+
type MessageOutcome,
|
|
45
50
|
type WorkspaceKey,
|
|
46
51
|
} from "@cueloop/schema";
|
|
47
52
|
import { curateDiff } from "./curate";
|
|
48
53
|
import { ThreadStore, withHistory } from "./store";
|
|
49
54
|
import { pruneExpiredSessions, resolveCleanupPeriodDays } from "./retention";
|
|
50
55
|
import { HerdrTabStore, type HerdrTabHandle } from "./herdr-tab-store";
|
|
56
|
+
import { HarnessStateStore } from "./harness-state-store";
|
|
51
57
|
import { DiffWatcher } from "./diff-watcher";
|
|
52
58
|
import { PrReviewPoller } from "./pr-poller";
|
|
53
59
|
import { prDiff } from "./gh";
|
|
54
60
|
import { workingTreeDiff, workingChangeList, type WorkingTreeDiff } from "./working-tree";
|
|
55
61
|
import { listProjectFiles, readProjectFile } from "./project-files";
|
|
56
|
-
import { resolveWorkspace } from "./review";
|
|
62
|
+
import { resolveWorkspace } from "./thread-review";
|
|
57
63
|
import { DaemonError } from "./errors";
|
|
58
64
|
|
|
59
65
|
/** What a share hands back: the notes and names it collected, and the removals it recorded. */
|
|
@@ -67,7 +73,7 @@ export interface SharedMerge {
|
|
|
67
73
|
export type EventName =
|
|
68
74
|
| "session.created"
|
|
69
75
|
| "session.updated"
|
|
70
|
-
| "
|
|
76
|
+
| "message.sent"
|
|
71
77
|
| "session.revised"
|
|
72
78
|
| "inbox.changed";
|
|
73
79
|
|
|
@@ -83,6 +89,7 @@ type EventListener = (event: DaemonEvent) => void;
|
|
|
83
89
|
export class DaemonCore {
|
|
84
90
|
readonly store: ThreadStore;
|
|
85
91
|
readonly herdrTabs: HerdrTabStore;
|
|
92
|
+
readonly harnessState: HarnessStateStore;
|
|
86
93
|
private waiters = new Map<string, ((session: Thread) => void)[]>();
|
|
87
94
|
private listeners = new Set<EventListener>();
|
|
88
95
|
private seq = 0;
|
|
@@ -102,12 +109,21 @@ export class DaemonCore {
|
|
|
102
109
|
constructor(home: string) {
|
|
103
110
|
this.store = new ThreadStore(home);
|
|
104
111
|
this.store.recover();
|
|
105
|
-
|
|
112
|
+
this.harnessState = new HarnessStateStore(home);
|
|
113
|
+
for (const session of this.store.list()) this.reconcileDeliveries(session);
|
|
114
|
+
pruneExpiredSessions(
|
|
115
|
+
this.store,
|
|
116
|
+
resolveCleanupPeriodDays(),
|
|
117
|
+
Date.now(),
|
|
118
|
+
this.harnessState.pendingThreadIds(),
|
|
119
|
+
);
|
|
106
120
|
this.herdrTabs = new HerdrTabStore(home);
|
|
107
121
|
this.diffWatcher = new DiffWatcher((repoRoot) => void this.refreshDiffsForRepo(repoRoot));
|
|
108
122
|
this.prPoller = new PrReviewPoller((sessionId) => void this.sessionRefreshPrDiff(sessionId));
|
|
109
123
|
// resume hot-reload for diff sessions that survived a daemon restart
|
|
110
|
-
for (const session of this.store.list())
|
|
124
|
+
for (const session of this.store.list()) {
|
|
125
|
+
this.trackLiveDiffSession(session);
|
|
126
|
+
}
|
|
111
127
|
}
|
|
112
128
|
|
|
113
129
|
/** Release the watchers and pollers behind diff hot-reload; call on daemon shutdown. */
|
|
@@ -125,6 +141,81 @@ export class DaemonCore {
|
|
|
125
141
|
this.herdrTabs.set(sessionId, handle);
|
|
126
142
|
}
|
|
127
143
|
|
|
144
|
+
harnessBind(input: {
|
|
145
|
+
threadId: string;
|
|
146
|
+
harness: string;
|
|
147
|
+
harnessSessionId: string;
|
|
148
|
+
}): HarnessBinding {
|
|
149
|
+
this.sessionGet(input.threadId);
|
|
150
|
+
const binding = this.harnessState.bind(input);
|
|
151
|
+
|
|
152
|
+
this.reconcileDeliveries(this.sessionGet(input.threadId));
|
|
153
|
+
|
|
154
|
+
return binding;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
harnessGetBinding(bindingId: string): HarnessBinding {
|
|
158
|
+
const binding = this.harnessState.binding(bindingId);
|
|
159
|
+
|
|
160
|
+
if (!binding) throw new DaemonError("not_found", `no harness binding ${bindingId}`);
|
|
161
|
+
|
|
162
|
+
return binding;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
harnessConsumeApprovedRetry(bindingId: string, messageId: string, content: string): boolean {
|
|
166
|
+
const binding = this.harnessState.binding(bindingId);
|
|
167
|
+
|
|
168
|
+
if (!binding) throw new DaemonError("not_found", `no harness binding ${bindingId}`);
|
|
169
|
+
const session = this.sessionGet(binding.threadId);
|
|
170
|
+
|
|
171
|
+
if (
|
|
172
|
+
session.artifact.type !== "plan" ||
|
|
173
|
+
session.status !== "resolved" ||
|
|
174
|
+
session.message?.id !== messageId ||
|
|
175
|
+
session.message.outcome !== "approved" ||
|
|
176
|
+
session.artifact.content !== content ||
|
|
177
|
+
this.harnessState.submittingBinding(session.id)?.id !== bindingId ||
|
|
178
|
+
!this.harnessState.acknowledged(bindingId, messageId)
|
|
179
|
+
) {
|
|
180
|
+
throw new DaemonError("invalid_state", `no unchanged approved plan for ${bindingId}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return this.harnessState.consumeApprovedRetry(bindingId, messageId);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
deliveryPending(bindingId: string): PendingDelivery[] {
|
|
187
|
+
const binding = this.harnessState.binding(bindingId);
|
|
188
|
+
|
|
189
|
+
if (!binding) throw new DaemonError("not_found", `no harness binding ${bindingId}`);
|
|
190
|
+
const session = this.sessionGet(binding.threadId);
|
|
191
|
+
|
|
192
|
+
return this.harnessState.pending(bindingId).map((delivery) => {
|
|
193
|
+
const historical = session.history?.entries.find(
|
|
194
|
+
(entry) => entry.type === "message" && entry.message.id === delivery.messageId,
|
|
195
|
+
);
|
|
196
|
+
const message =
|
|
197
|
+
session.message?.id === delivery.messageId
|
|
198
|
+
? session.message
|
|
199
|
+
: historical?.type === "message"
|
|
200
|
+
? historical.message
|
|
201
|
+
: null;
|
|
202
|
+
|
|
203
|
+
if (!message || message.id !== delivery.messageId) {
|
|
204
|
+
throw new DaemonError("not_found", `no message ${delivery.messageId}`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return { delivery, message };
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
deliveryAcknowledge(deliveryId: string): Delivery {
|
|
212
|
+
if (!this.harnessState.delivery(deliveryId)) {
|
|
213
|
+
throw new DaemonError("not_found", `no delivery ${deliveryId}`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return this.harnessState.acknowledge(deliveryId);
|
|
217
|
+
}
|
|
218
|
+
|
|
128
219
|
onEvent(listener: EventListener): () => void {
|
|
129
220
|
this.listeners.add(listener);
|
|
130
221
|
|
|
@@ -138,7 +229,7 @@ export class DaemonCore {
|
|
|
138
229
|
for (const listener of this.listeners) listener(frame);
|
|
139
230
|
}
|
|
140
231
|
|
|
141
|
-
/** True when nothing awaits a
|
|
232
|
+
/** True when nothing awaits a message - drives idle-exit. */
|
|
142
233
|
hasPendingSessions(): boolean {
|
|
143
234
|
return this.store.list().some((session) => session.status === "pending");
|
|
144
235
|
}
|
|
@@ -152,13 +243,14 @@ export class DaemonCore {
|
|
|
152
243
|
artifact: params.artifact,
|
|
153
244
|
revisions: [{ revision: 1, content: params.artifact.content, submittedAt: now }],
|
|
154
245
|
annotations: [],
|
|
155
|
-
|
|
246
|
+
message: null,
|
|
156
247
|
status: "pending",
|
|
157
248
|
createdAt: now,
|
|
158
249
|
};
|
|
159
250
|
|
|
160
251
|
session.history = historyFromLinear(session);
|
|
161
252
|
this.store.upsert(session);
|
|
253
|
+
this.reconcileDeliveries(session);
|
|
162
254
|
this.trackLiveDiffSession(session);
|
|
163
255
|
this.emit("session.created", session.id);
|
|
164
256
|
this.emit("inbox.changed", session.id);
|
|
@@ -166,6 +258,15 @@ export class DaemonCore {
|
|
|
166
258
|
return session;
|
|
167
259
|
}
|
|
168
260
|
|
|
261
|
+
private reconcileDeliveries(session: Thread): void {
|
|
262
|
+
if (!session.message) return;
|
|
263
|
+
|
|
264
|
+
const binding = this.harnessState.submittingBinding(session.id);
|
|
265
|
+
|
|
266
|
+
if (binding)
|
|
267
|
+
this.harnessState.enqueue({ bindingId: binding.id, messageId: session.message.id });
|
|
268
|
+
}
|
|
269
|
+
|
|
169
270
|
private newSessionId(now: string): string {
|
|
170
271
|
return `ses_${now.replace(/\D/g, "").slice(0, 14)}_${(++this.seq).toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
|
171
272
|
}
|
|
@@ -194,9 +295,9 @@ export class DaemonCore {
|
|
|
194
295
|
}
|
|
195
296
|
|
|
196
297
|
/**
|
|
197
|
-
* Long-poll for the
|
|
198
|
-
* otherwise parks until
|
|
199
|
-
* still pending - the caller re-polls later; the
|
|
298
|
+
* Long-poll for the message. Resolves immediately when already resolved;
|
|
299
|
+
* otherwise parks until sessionSendMessage fires or timeoutMs elapses (null =
|
|
300
|
+
* still pending - the caller re-polls later; the message is never lost).
|
|
200
301
|
*/
|
|
201
302
|
sessionWait(id: string, timeoutMs: number): Promise<Thread | null> {
|
|
202
303
|
const current = this.sessionGet(id);
|
|
@@ -539,7 +640,7 @@ export class DaemonCore {
|
|
|
539
640
|
if (session.artifact.type !== "diff") {
|
|
540
641
|
throw new DaemonError("invalid_params", "only a diff review is curated by hunk");
|
|
541
642
|
}
|
|
542
|
-
if (!session.artifact.files) {
|
|
643
|
+
if (!session.artifact.files?.length) {
|
|
543
644
|
throw new DaemonError("invalid_params", "hunk curation needs full file contents");
|
|
544
645
|
}
|
|
545
646
|
if (rejections.length === 0) delete session.curation;
|
|
@@ -611,35 +712,38 @@ export class DaemonCore {
|
|
|
611
712
|
return session;
|
|
612
713
|
}
|
|
613
714
|
|
|
614
|
-
|
|
715
|
+
sessionSendMessage(
|
|
615
716
|
id: string,
|
|
616
|
-
|
|
717
|
+
outcome: MessageOutcome,
|
|
617
718
|
summary: string,
|
|
618
719
|
actionBodies?: Record<string, string>,
|
|
619
720
|
): Thread {
|
|
620
721
|
const session = this.mutable(id);
|
|
621
|
-
const
|
|
622
|
-
|
|
722
|
+
const sentAt = new Date().toISOString();
|
|
723
|
+
const message: Message = {
|
|
724
|
+
id: newMessageId(),
|
|
725
|
+
outcome,
|
|
623
726
|
summary,
|
|
624
|
-
|
|
625
|
-
|
|
727
|
+
body: feedbackForSession(session, outcome, summary, actionBodies),
|
|
728
|
+
sentAt,
|
|
626
729
|
};
|
|
627
730
|
|
|
628
|
-
session.
|
|
731
|
+
session.message = message;
|
|
629
732
|
session.status = "resolved";
|
|
630
733
|
const entryId = this.record(session, {
|
|
631
|
-
type: "
|
|
632
|
-
|
|
633
|
-
createdAt:
|
|
734
|
+
type: "message",
|
|
735
|
+
message,
|
|
736
|
+
createdAt: message.sentAt,
|
|
634
737
|
});
|
|
635
738
|
this.store.upsert(session);
|
|
739
|
+
this.reconcileDeliveries(session);
|
|
636
740
|
// a resolved diff review is frozen; stop hot-reloading its working tree
|
|
637
741
|
this.untrackLiveDiffSession(session);
|
|
638
742
|
const parked = this.waiters.get(id) ?? [];
|
|
639
743
|
|
|
640
744
|
this.waiters.delete(id);
|
|
641
745
|
for (const parkedWaiter of parked) parkedWaiter(session);
|
|
642
|
-
this.emit("
|
|
746
|
+
this.emit("message.sent", id, entryId);
|
|
643
747
|
this.emit("inbox.changed", id);
|
|
644
748
|
|
|
645
749
|
return session;
|
|
@@ -659,13 +763,14 @@ export class DaemonCore {
|
|
|
659
763
|
id: string,
|
|
660
764
|
content: string,
|
|
661
765
|
addressedAnnotationIds: string[] = [],
|
|
766
|
+
files?: DiffFileContents[],
|
|
662
767
|
): Thread {
|
|
663
768
|
const session = this.sessionGet(id);
|
|
664
769
|
const now = new Date().toISOString();
|
|
665
770
|
const revisionNumber = session.revisions.length + 1;
|
|
666
771
|
|
|
667
772
|
session.revisions.push({ revision: revisionNumber, content, submittedAt: now });
|
|
668
|
-
session.artifact = { ...session.artifact, content };
|
|
773
|
+
session.artifact = { ...session.artifact, content, files: files ?? session.artifact.files };
|
|
669
774
|
// the agent's revision lands on main wherever its tip sits; the artifact
|
|
670
775
|
// shows the head of the branch the reviewer is on
|
|
671
776
|
const entryId = this.recordOnMain(session, {
|
|
@@ -675,7 +780,7 @@ export class DaemonCore {
|
|
|
675
780
|
createdAt: now,
|
|
676
781
|
});
|
|
677
782
|
delete session.workingCopy;
|
|
678
|
-
session.
|
|
783
|
+
session.message = null;
|
|
679
784
|
session.status = "pending";
|
|
680
785
|
|
|
681
786
|
// a reported root comment addresses its whole discussion: replies are
|
|
@@ -776,7 +881,7 @@ export class DaemonCore {
|
|
|
776
881
|
|
|
777
882
|
/**
|
|
778
883
|
* Copy the current path into a new pending session: its revisions, open
|
|
779
|
-
* comments, labels, and participant names travel;
|
|
884
|
+
* comments, labels, and participant names travel; messages, edits, and the
|
|
780
885
|
* share do not. A resolved session can be forked.
|
|
781
886
|
*/
|
|
782
887
|
sessionFork(id: string): Thread {
|
|
@@ -799,7 +904,7 @@ export class DaemonCore {
|
|
|
799
904
|
})),
|
|
800
905
|
annotations: [],
|
|
801
906
|
history,
|
|
802
|
-
|
|
907
|
+
message: null,
|
|
803
908
|
status: "pending",
|
|
804
909
|
createdAt: now,
|
|
805
910
|
parentSessionId: id,
|
|
@@ -1060,8 +1165,8 @@ function isPrReviewSession(session: Thread): boolean {
|
|
|
1060
1165
|
}
|
|
1061
1166
|
|
|
1062
1167
|
/** Convenience for adapters: map a resolved session to the agent contract. */
|
|
1063
|
-
export function
|
|
1064
|
-
if (!session.
|
|
1168
|
+
export function messageResponse(session: Thread) {
|
|
1169
|
+
if (!session.message) throw new DaemonError("pending", "session has no message");
|
|
1065
1170
|
|
|
1066
|
-
return { allow:
|
|
1171
|
+
return { allow: messageAllows(session.message.outcome), message: session.message };
|
|
1067
1172
|
}
|
package/src/capabilities.test.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type { MethodName } from "./validate";
|
|
|
5
5
|
describe("roleAllowsMethod", () => {
|
|
6
6
|
test("the owner may call every primitive", () => {
|
|
7
7
|
// Assert
|
|
8
|
-
const owned: MethodName[] = ["session.
|
|
8
|
+
const owned: MethodName[] = ["session.sendMessage", "session.submitRevision", "session.delete"];
|
|
9
9
|
|
|
10
10
|
for (const method of owned) expect(roleAllowsMethod("owner", method)).toBe(true);
|
|
11
11
|
});
|
|
@@ -17,7 +17,7 @@ describe("roleAllowsMethod", () => {
|
|
|
17
17
|
for (const method of open) expect(roleAllowsMethod("agent", method)).toBe(true);
|
|
18
18
|
// Assert - denied
|
|
19
19
|
const owned: MethodName[] = [
|
|
20
|
-
"session.
|
|
20
|
+
"session.sendMessage",
|
|
21
21
|
"session.submitRevision",
|
|
22
22
|
"session.setWorkingCopy",
|
|
23
23
|
"session.delete",
|
|
@@ -32,7 +32,7 @@ describe("roleAllowsMethod", () => {
|
|
|
32
32
|
];
|
|
33
33
|
|
|
34
34
|
for (const method of owned) expect(roleAllowsMethod("agent", method)).toBe(false);
|
|
35
|
-
expect(roleAllowsMethod("collaborator", "session.
|
|
35
|
+
expect(roleAllowsMethod("collaborator", "session.sendMessage")).toBe(false);
|
|
36
36
|
// removal and naming are open to every role; the primitive itself scopes a
|
|
37
37
|
// non-owner to the author it acts as
|
|
38
38
|
expect(roleAllowsMethod("agent", "session.removeAnnotation")).toBe(true);
|
package/src/capabilities.ts
CHANGED
|
@@ -58,7 +58,12 @@ const PRIMITIVE_ROLES = {
|
|
|
58
58
|
"session.setShares": "owner",
|
|
59
59
|
"session.delete": "owner",
|
|
60
60
|
"session.mergeShared": "owner",
|
|
61
|
-
"session.
|
|
61
|
+
"session.sendMessage": "owner",
|
|
62
|
+
"harness.bind": "owner",
|
|
63
|
+
"harness.getBinding": "owner",
|
|
64
|
+
"harness.consumeApprovedRetry": "owner",
|
|
65
|
+
"delivery.pending": "owner",
|
|
66
|
+
"delivery.acknowledge": "owner",
|
|
62
67
|
"session.submitRevision": "owner",
|
|
63
68
|
"herdr.getTab": "owner",
|
|
64
69
|
"herdr.setTab": "owner",
|