@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/src/client.ts
CHANGED
|
@@ -4,16 +4,20 @@
|
|
|
4
4
|
* autostart spawns a detached daemon when the socket is dead, then attaches.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { existsSync, readFileSync
|
|
7
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
8
8
|
import * as v from "valibot";
|
|
9
9
|
import type {
|
|
10
10
|
Annotation,
|
|
11
11
|
Artifact,
|
|
12
12
|
DiffFileStatus,
|
|
13
|
+
DiffFileContents,
|
|
13
14
|
HunkRejection,
|
|
14
15
|
ShareLink,
|
|
15
16
|
Thread,
|
|
16
|
-
|
|
17
|
+
HarnessBinding,
|
|
18
|
+
Delivery,
|
|
19
|
+
PendingDelivery,
|
|
20
|
+
MessageOutcome,
|
|
17
21
|
WorkspaceKey,
|
|
18
22
|
} from "@cueloop/schema";
|
|
19
23
|
import {
|
|
@@ -30,7 +34,14 @@ import type { SharedMerge } from "./api";
|
|
|
30
34
|
|
|
31
35
|
export type { SharedMerge } from "./api";
|
|
32
36
|
import { cueloopHome, ownerTokenPath, socketPath } from "./paths";
|
|
33
|
-
import {
|
|
37
|
+
import {
|
|
38
|
+
Params,
|
|
39
|
+
ThreadRecordSchema,
|
|
40
|
+
DiffFileContentsSchema,
|
|
41
|
+
HarnessBindingSchema,
|
|
42
|
+
DeliverySchema,
|
|
43
|
+
PendingDeliverySchema,
|
|
44
|
+
} from "./validate";
|
|
34
45
|
import type { WorkingTreeDiff } from "./working-tree";
|
|
35
46
|
import { DAEMON_VERSION } from "./version";
|
|
36
47
|
|
|
@@ -64,7 +75,7 @@ const HerdrTabResultSchema = v.nullable(v.object({ tabId: v.string(), paneId: v.
|
|
|
64
75
|
* blob-backed one. Depending on this interface - not DaemonClient - is what
|
|
65
76
|
* lets the same <App> render a local session or a decrypted share unchanged.
|
|
66
77
|
*/
|
|
67
|
-
export interface
|
|
78
|
+
export interface ThreadClient {
|
|
68
79
|
onEvent(listener: (event: EventFrame) => void): () => void;
|
|
69
80
|
subscribe(): Promise<void>;
|
|
70
81
|
sessionGet(id: string): Promise<Thread>;
|
|
@@ -123,12 +134,25 @@ export interface SessionClient {
|
|
|
123
134
|
sessionDelete(id: string): Promise<void>;
|
|
124
135
|
/** Record the caller's own identity name (collaborator self-naming on a share). */
|
|
125
136
|
sessionSetSelfName(id: string, name: string): Promise<Thread>;
|
|
126
|
-
|
|
137
|
+
sessionSendMessage(
|
|
127
138
|
id: string,
|
|
128
|
-
|
|
139
|
+
outcome: MessageOutcome,
|
|
129
140
|
summary: string,
|
|
130
141
|
actionBodies?: Record<string, string>,
|
|
131
142
|
): Promise<Thread>;
|
|
143
|
+
harnessBind?(
|
|
144
|
+
threadId: string,
|
|
145
|
+
harness: string,
|
|
146
|
+
harnessSessionId: string,
|
|
147
|
+
): Promise<HarnessBinding>;
|
|
148
|
+
harnessGetBinding?(bindingId: string): Promise<HarnessBinding>;
|
|
149
|
+
harnessConsumeApprovedRetry?(
|
|
150
|
+
bindingId: string,
|
|
151
|
+
messageId: string,
|
|
152
|
+
content: string,
|
|
153
|
+
): Promise<boolean>;
|
|
154
|
+
deliveryPending?(bindingId: string): Promise<PendingDelivery[]>;
|
|
155
|
+
deliveryAcknowledge?(deliveryId: string): Promise<Delivery>;
|
|
132
156
|
close(): void;
|
|
133
157
|
}
|
|
134
158
|
|
|
@@ -159,7 +183,7 @@ function readOwnerToken(home: string): string | undefined {
|
|
|
159
183
|
return parsed.output;
|
|
160
184
|
}
|
|
161
185
|
|
|
162
|
-
export class DaemonClient implements
|
|
186
|
+
export class DaemonClient implements ThreadClient {
|
|
163
187
|
private socket: Awaited<ReturnType<typeof Bun.connect>> | null = null;
|
|
164
188
|
private writer: BackpressureWriter | null = null;
|
|
165
189
|
private pending = new Map<number, PendingRequest>();
|
|
@@ -201,15 +225,15 @@ export class DaemonClient implements SessionClient {
|
|
|
201
225
|
// caller hears why instead of the client replacing a running daemon
|
|
202
226
|
if (!options.autostart || err instanceof DaemonClientError) throw err;
|
|
203
227
|
}
|
|
204
|
-
// Socket dead, absent, or just-replaced:
|
|
228
|
+
// Socket dead, absent, or just-replaced: let the new daemon own socket cleanup.
|
|
205
229
|
return client.attachFreshDaemon(home, path);
|
|
206
230
|
}
|
|
207
231
|
|
|
208
232
|
/**
|
|
209
233
|
* Tear down a daemon from an earlier build so a fresh one can bind: ask it to
|
|
210
234
|
* shut down (owner-gated) or, failing that, signal its pid, then wait for it to
|
|
211
|
-
* release the socket. Best-effort -
|
|
212
|
-
*
|
|
235
|
+
* release the socket. Best-effort - the next daemon reclaims stale socket
|
|
236
|
+
* and lock files after it takes ownership.
|
|
213
237
|
*/
|
|
214
238
|
private async stopStaleDaemon(path: string): Promise<void> {
|
|
215
239
|
const pid = this.daemonPid;
|
|
@@ -241,9 +265,8 @@ export class DaemonClient implements SessionClient {
|
|
|
241
265
|
this.pending.clear();
|
|
242
266
|
}
|
|
243
267
|
|
|
244
|
-
/**
|
|
268
|
+
/** Spawn a detached daemon and dial the lock owner until it answers. */
|
|
245
269
|
private async attachFreshDaemon(home: string, path: string): Promise<DaemonClient> {
|
|
246
|
-
if (existsSync(path)) rmSync(path, { force: true });
|
|
247
270
|
spawnDaemon(home);
|
|
248
271
|
// Generous: a cold or loaded machine pays for a runtime start before the
|
|
249
272
|
// socket exists, and giving up early looks to callers like a broken daemon.
|
|
@@ -513,30 +536,62 @@ export class DaemonClient implements SessionClient {
|
|
|
513
536
|
sessionSetSelfName(id: string, _name: string): Promise<Thread> {
|
|
514
537
|
return this.sessionGet(id);
|
|
515
538
|
}
|
|
516
|
-
|
|
539
|
+
sessionSendMessage(
|
|
517
540
|
id: string,
|
|
518
|
-
|
|
541
|
+
outcome: MessageOutcome,
|
|
519
542
|
summary: string,
|
|
520
543
|
actionBodies?: Record<string, string>,
|
|
521
544
|
): Promise<Thread> {
|
|
522
545
|
return this.request(
|
|
523
|
-
"session.
|
|
524
|
-
{ id,
|
|
546
|
+
"session.sendMessage",
|
|
547
|
+
{ id, outcome, summary, actionBodies },
|
|
525
548
|
ThreadRecordSchema,
|
|
526
549
|
);
|
|
527
550
|
}
|
|
551
|
+
harnessBind(
|
|
552
|
+
threadId: string,
|
|
553
|
+
harness: string,
|
|
554
|
+
harnessSessionId: string,
|
|
555
|
+
): Promise<HarnessBinding> {
|
|
556
|
+
return this.request(
|
|
557
|
+
"harness.bind",
|
|
558
|
+
{ threadId, harness, harnessSessionId },
|
|
559
|
+
HarnessBindingSchema,
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
harnessGetBinding(bindingId: string): Promise<HarnessBinding> {
|
|
563
|
+
return this.request("harness.getBinding", { bindingId }, HarnessBindingSchema);
|
|
564
|
+
}
|
|
565
|
+
harnessConsumeApprovedRetry(
|
|
566
|
+
bindingId: string,
|
|
567
|
+
messageId: string,
|
|
568
|
+
content: string,
|
|
569
|
+
): Promise<boolean> {
|
|
570
|
+
return this.request(
|
|
571
|
+
"harness.consumeApprovedRetry",
|
|
572
|
+
{ bindingId, messageId, content },
|
|
573
|
+
v.boolean(),
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
deliveryPending(bindingId: string): Promise<PendingDelivery[]> {
|
|
577
|
+
return this.request("delivery.pending", { bindingId }, v.array(PendingDeliverySchema));
|
|
578
|
+
}
|
|
579
|
+
deliveryAcknowledge(deliveryId: string): Promise<Delivery> {
|
|
580
|
+
return this.request("delivery.acknowledge", { deliveryId }, DeliverySchema);
|
|
581
|
+
}
|
|
528
582
|
sessionSubmitRevision(
|
|
529
583
|
id: string,
|
|
530
584
|
content: string,
|
|
531
585
|
addressedAnnotationIds: string[] = [],
|
|
586
|
+
files?: DiffFileContents[],
|
|
532
587
|
): Promise<Thread> {
|
|
533
588
|
return this.request(
|
|
534
589
|
"session.submitRevision",
|
|
535
|
-
{ id, content, addressedAnnotationIds },
|
|
590
|
+
{ id, content, addressedAnnotationIds, files },
|
|
536
591
|
ThreadRecordSchema,
|
|
537
592
|
);
|
|
538
593
|
}
|
|
539
|
-
/** herdr adapter scratch: the tab opened for a review; local-only, off the
|
|
594
|
+
/** herdr adapter scratch: the tab opened for a review; local-only, off the ThreadClient contract. */
|
|
540
595
|
herdrGetTab(id: string): Promise<HerdrTabHandle | null> {
|
|
541
596
|
return this.request("herdr.getTab", { id }, HerdrTabResultSchema);
|
|
542
597
|
}
|
|
@@ -10,7 +10,7 @@ import { tmpdir } from "node:os";
|
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
import { DaemonCore } from "./api";
|
|
12
12
|
import { workingTreeDiff } from "./working-tree";
|
|
13
|
-
import { resolveWorkspace } from "./review";
|
|
13
|
+
import { resolveWorkspace } from "./thread-review";
|
|
14
14
|
import type { Artifact } from "@cueloop/schema";
|
|
15
15
|
|
|
16
16
|
function git(args: string[], cwd: string): void {
|
|
@@ -134,7 +134,7 @@ describe("session.refreshDiff", () => {
|
|
|
134
134
|
// Given a diff session that has been resolved
|
|
135
135
|
const session = await openDiffSession();
|
|
136
136
|
|
|
137
|
-
core.
|
|
137
|
+
core.sessionSendMessage(session.id, "approved", "");
|
|
138
138
|
const resolvedContent = core.sessionGet(session.id).artifact.content;
|
|
139
139
|
|
|
140
140
|
writeFileSync(join(repo, "a.ts"), "export const a = 99;\n");
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { DaemonCore } from "./api";
|
|
6
|
+
import { harnessStatePath } from "./paths";
|
|
7
|
+
|
|
8
|
+
let home: string;
|
|
9
|
+
let cores: DaemonCore[];
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-harness-delivery-"));
|
|
13
|
+
cores = [];
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
for (const core of cores) core.dispose();
|
|
18
|
+
rmSync(home, { recursive: true, force: true });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
function core(): DaemonCore {
|
|
22
|
+
const instance = new DaemonCore(home);
|
|
23
|
+
|
|
24
|
+
cores.push(instance);
|
|
25
|
+
|
|
26
|
+
return instance;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function createPlan(instance: DaemonCore) {
|
|
30
|
+
return instance.sessionCreate({
|
|
31
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
32
|
+
artifact: { type: "plan", content: "# Plan", meta: {} },
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("durable harness delivery", () => {
|
|
37
|
+
test("redelivers after reload until the harness acknowledges the Message", () => {
|
|
38
|
+
const first = core();
|
|
39
|
+
const thread = createPlan(first);
|
|
40
|
+
const binding = first.harnessBind({
|
|
41
|
+
threadId: thread.id,
|
|
42
|
+
harness: "fake",
|
|
43
|
+
harnessSessionId: "fake_1",
|
|
44
|
+
});
|
|
45
|
+
const sent = first.sessionSendMessage(thread.id, "changes_requested", "Add detail.");
|
|
46
|
+
const initial = first.deliveryPending(binding.id);
|
|
47
|
+
|
|
48
|
+
expect(initial).toHaveLength(1);
|
|
49
|
+
expect(initial[0]!.message).toEqual(sent.message!);
|
|
50
|
+
|
|
51
|
+
const reloaded = core();
|
|
52
|
+
const redelivered = reloaded.deliveryPending(binding.id);
|
|
53
|
+
|
|
54
|
+
expect(redelivered).toEqual(initial);
|
|
55
|
+
reloaded.deliveryAcknowledge(redelivered[0]!.delivery.id);
|
|
56
|
+
|
|
57
|
+
expect(core().deliveryPending(binding.id)).toEqual([]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("retains an expired Thread until its pending Message is acknowledged", () => {
|
|
61
|
+
const first = core();
|
|
62
|
+
const thread = createPlan(first);
|
|
63
|
+
const binding = first.harnessBind({
|
|
64
|
+
threadId: thread.id,
|
|
65
|
+
harness: "fake",
|
|
66
|
+
harnessSessionId: "fake_1",
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
first.sessionSendMessage(thread.id, "approved", "Ready.");
|
|
70
|
+
const oldThread = first.sessionGet(thread.id);
|
|
71
|
+
|
|
72
|
+
first.store.upsert({ ...oldThread, createdAt: "2020-01-01T00:00:00.000Z" });
|
|
73
|
+
|
|
74
|
+
const reloaded = core();
|
|
75
|
+
const delivery = reloaded.deliveryPending(binding.id)[0];
|
|
76
|
+
|
|
77
|
+
expect(delivery?.message.body).toContain("Ready.");
|
|
78
|
+
reloaded.deliveryAcknowledge(delivery!.delivery.id);
|
|
79
|
+
|
|
80
|
+
expect(core().store.get(thread.id)).toBeUndefined();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("rebuilds a delivery if the Thread saved its Message before enqueue", () => {
|
|
84
|
+
const first = core();
|
|
85
|
+
const thread = createPlan(first);
|
|
86
|
+
const binding = first.harnessBind({
|
|
87
|
+
threadId: thread.id,
|
|
88
|
+
harness: "fake",
|
|
89
|
+
harnessSessionId: "fake_1",
|
|
90
|
+
});
|
|
91
|
+
const sent = first.sessionSendMessage(thread.id, "approved", "Ready.");
|
|
92
|
+
first.store.upsert({
|
|
93
|
+
...first.sessionGet(thread.id),
|
|
94
|
+
createdAt: "2020-01-01T00:00:00.000Z",
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
writeFileSync(harnessStatePath(home), JSON.stringify({ bindings: [binding], deliveries: [] }));
|
|
98
|
+
|
|
99
|
+
const recovered = core().deliveryPending(binding.id);
|
|
100
|
+
|
|
101
|
+
expect(recovered).toHaveLength(1);
|
|
102
|
+
expect(recovered[0]!.message).toEqual(sent.message!);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("routes an approved retry as a new Message", () => {
|
|
106
|
+
const instance = core();
|
|
107
|
+
const thread = createPlan(instance);
|
|
108
|
+
const binding = instance.harnessBind({
|
|
109
|
+
threadId: thread.id,
|
|
110
|
+
harness: "fake",
|
|
111
|
+
harnessSessionId: "fake_1",
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
instance.sessionSendMessage(thread.id, "changes_requested", "Try once more.");
|
|
115
|
+
const first = instance.deliveryPending(binding.id)[0]!;
|
|
116
|
+
|
|
117
|
+
instance.deliveryAcknowledge(first.delivery.id);
|
|
118
|
+
instance.sessionSubmitRevision(thread.id, "# Plan");
|
|
119
|
+
instance.sessionSendMessage(thread.id, "approved", "Ready.");
|
|
120
|
+
const retry = instance.deliveryPending(binding.id);
|
|
121
|
+
|
|
122
|
+
expect(retry).toHaveLength(1);
|
|
123
|
+
expect(retry[0]!.message.outcome).toBe("approved");
|
|
124
|
+
expect(retry[0]!.message.id).not.toBe(first.message.id);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("keeps an older pending Message available after a Thread revision", () => {
|
|
128
|
+
const instance = core();
|
|
129
|
+
const thread = createPlan(instance);
|
|
130
|
+
const binding = instance.harnessBind({
|
|
131
|
+
threadId: thread.id,
|
|
132
|
+
harness: "fake",
|
|
133
|
+
harnessSessionId: "fake_1",
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
instance.sessionSendMessage(thread.id, "changes_requested", "Add detail.");
|
|
137
|
+
const first = instance.deliveryPending(binding.id)[0]!;
|
|
138
|
+
|
|
139
|
+
instance.sessionSubmitRevision(thread.id, "# Revised plan");
|
|
140
|
+
instance.sessionSendMessage(thread.id, "approved", "Ready.");
|
|
141
|
+
|
|
142
|
+
const pending = core().deliveryPending(binding.id);
|
|
143
|
+
|
|
144
|
+
expect(pending.map((item) => item.message.id)).toEqual([first.message.id, expect.any(String)]);
|
|
145
|
+
expect(pending[0]!.message.body).toBe(first.message.body);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("defaults delivery to the submitting binding only", () => {
|
|
149
|
+
const instance = core();
|
|
150
|
+
const thread = createPlan(instance);
|
|
151
|
+
const submitter = instance.harnessBind({
|
|
152
|
+
threadId: thread.id,
|
|
153
|
+
harness: "fake",
|
|
154
|
+
harnessSessionId: "fake_1",
|
|
155
|
+
});
|
|
156
|
+
const other = instance.harnessBind({
|
|
157
|
+
threadId: thread.id,
|
|
158
|
+
harness: "other",
|
|
159
|
+
harnessSessionId: "other_1",
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
instance.sessionSendMessage(thread.id, "approved", "Ready.");
|
|
163
|
+
|
|
164
|
+
expect(instance.deliveryPending(submitter.id)).toHaveLength(1);
|
|
165
|
+
expect(instance.deliveryPending(other.id)).toEqual([]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("consumes the unchanged approved retry once across daemon reload", () => {
|
|
169
|
+
const instance = core();
|
|
170
|
+
const thread = createPlan(instance);
|
|
171
|
+
const binding = instance.harnessBind({
|
|
172
|
+
threadId: thread.id,
|
|
173
|
+
harness: "fake",
|
|
174
|
+
harnessSessionId: "fake_1",
|
|
175
|
+
});
|
|
176
|
+
const sent = instance.sessionSendMessage(thread.id, "approved", "Ready.");
|
|
177
|
+
const messageId = sent.message!.id;
|
|
178
|
+
|
|
179
|
+
expect(() => instance.harnessConsumeApprovedRetry(binding.id, messageId, "# Plan")).toThrow(
|
|
180
|
+
"no unchanged approved plan",
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
const delivery = instance.deliveryPending(binding.id)[0]!.delivery;
|
|
184
|
+
|
|
185
|
+
instance.deliveryAcknowledge(delivery.id);
|
|
186
|
+
|
|
187
|
+
expect(instance.harnessConsumeApprovedRetry(binding.id, messageId, "# Plan")).toBe(true);
|
|
188
|
+
expect(core().harnessConsumeApprovedRetry(binding.id, messageId, "# Plan")).toBe(false);
|
|
189
|
+
expect(() => core().harnessConsumeApprovedRetry(binding.id, messageId, "# Changed")).toThrow(
|
|
190
|
+
"no unchanged approved plan",
|
|
191
|
+
);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { HarnessStateStore } from "./harness-state-store";
|
|
6
|
+
import { harnessStatePath } from "./paths";
|
|
7
|
+
|
|
8
|
+
let home: string;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-harness-state-"));
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
rmSync(home, { recursive: true, force: true });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("HarnessStateStore", () => {
|
|
19
|
+
test("redelivers an unacknowledged Message after reload", () => {
|
|
20
|
+
const store = new HarnessStateStore(home);
|
|
21
|
+
const binding = store.bind({
|
|
22
|
+
threadId: "ses_1",
|
|
23
|
+
harness: "fake",
|
|
24
|
+
harnessSessionId: "fake_1",
|
|
25
|
+
});
|
|
26
|
+
const delivery = store.enqueue({ bindingId: binding.id, messageId: "msg_1" });
|
|
27
|
+
|
|
28
|
+
expect(store.pending(binding.id)).toEqual([delivery]);
|
|
29
|
+
|
|
30
|
+
const reloaded = new HarnessStateStore(home);
|
|
31
|
+
|
|
32
|
+
expect(reloaded.pending(binding.id)).toEqual([delivery]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("does not redeliver an acknowledged Message", () => {
|
|
36
|
+
const store = new HarnessStateStore(home);
|
|
37
|
+
const binding = store.bind({
|
|
38
|
+
threadId: "ses_1",
|
|
39
|
+
harness: "fake",
|
|
40
|
+
harnessSessionId: "fake_1",
|
|
41
|
+
});
|
|
42
|
+
const delivery = store.enqueue({ bindingId: binding.id, messageId: "msg_1" });
|
|
43
|
+
|
|
44
|
+
store.acknowledge(delivery.id, "2026-09-20T20:00:00.000Z");
|
|
45
|
+
|
|
46
|
+
expect(new HarnessStateStore(home).pending(binding.id)).toEqual([]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("reuses the binding for the same harness session and thread", () => {
|
|
50
|
+
const store = new HarnessStateStore(home);
|
|
51
|
+
const input = {
|
|
52
|
+
threadId: "ses_1",
|
|
53
|
+
harness: "fake",
|
|
54
|
+
harnessSessionId: "fake_1",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
expect(store.bind(input)).toEqual(store.bind(input));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("does not discard pending deliveries when persisted state is corrupt", () => {
|
|
61
|
+
writeFileSync(harnessStatePath(home), "{broken");
|
|
62
|
+
|
|
63
|
+
expect(() => new HarnessStateStore(home)).toThrow();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable harness routing state. Bindings identify where a Thread came from;
|
|
3
|
+
* deliveries record which Message still needs to reach that binding. Keeping
|
|
4
|
+
* both outside Thread records prevents harness identity from leaking into the
|
|
5
|
+
* product primitive and makes redelivery independent of the review lifecycle.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
9
|
+
import type { Delivery, HarnessBinding } from "@cueloop/schema";
|
|
10
|
+
import * as v from "valibot";
|
|
11
|
+
import { harnessStatePath } from "./paths";
|
|
12
|
+
import { DeliverySchema, HarnessBindingSchema } from "./validate";
|
|
13
|
+
|
|
14
|
+
type BindInput = Pick<HarnessBinding, "threadId" | "harness" | "harnessSessionId">;
|
|
15
|
+
type EnqueueInput = Pick<Delivery, "bindingId" | "messageId">;
|
|
16
|
+
|
|
17
|
+
const HarnessStateSchema = v.object({
|
|
18
|
+
bindings: v.array(HarnessBindingSchema),
|
|
19
|
+
deliveries: v.array(DeliverySchema),
|
|
20
|
+
});
|
|
21
|
+
const FileErrorSchema = v.object({ code: v.string() });
|
|
22
|
+
|
|
23
|
+
export class HarnessStateStore {
|
|
24
|
+
private readonly path: string;
|
|
25
|
+
private bindings = new Map<string, HarnessBinding>();
|
|
26
|
+
private deliveries = new Map<string, Delivery>();
|
|
27
|
+
|
|
28
|
+
constructor(home: string) {
|
|
29
|
+
this.path = harnessStatePath(home);
|
|
30
|
+
this.load();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
bind(input: BindInput): HarnessBinding {
|
|
34
|
+
const existing = [...this.bindings.values()].find(
|
|
35
|
+
(binding) =>
|
|
36
|
+
binding.threadId === input.threadId &&
|
|
37
|
+
binding.harness === input.harness &&
|
|
38
|
+
binding.harnessSessionId === input.harnessSessionId,
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
if (existing) return existing;
|
|
42
|
+
const binding: HarnessBinding = {
|
|
43
|
+
...input,
|
|
44
|
+
id: newRoutingId("bind"),
|
|
45
|
+
createdAt: new Date().toISOString(),
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
this.bindings.set(binding.id, binding);
|
|
49
|
+
this.persist();
|
|
50
|
+
|
|
51
|
+
return binding;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
binding(id: string): HarnessBinding | null {
|
|
55
|
+
return this.bindings.get(id) ?? null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
consumeApprovedRetry(bindingId: string, messageId: string): boolean {
|
|
59
|
+
const binding = this.bindings.get(bindingId);
|
|
60
|
+
|
|
61
|
+
if (!binding) throw new Error(`no harness binding ${bindingId}`);
|
|
62
|
+
|
|
63
|
+
if (binding.approvedRetryMessageId === messageId) return false;
|
|
64
|
+
|
|
65
|
+
this.bindings.set(bindingId, { ...binding, approvedRetryMessageId: messageId });
|
|
66
|
+
this.persist();
|
|
67
|
+
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The first binding is the submitter; other bindings need explicit future routing. */
|
|
72
|
+
submittingBinding(threadId: string): HarnessBinding | null {
|
|
73
|
+
return [...this.bindings.values()].find((binding) => binding.threadId === threadId) ?? null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
enqueue(input: EnqueueInput): Delivery {
|
|
77
|
+
const existing = [...this.deliveries.values()].find(
|
|
78
|
+
(delivery) =>
|
|
79
|
+
delivery.bindingId === input.bindingId && delivery.messageId === input.messageId,
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
if (existing) return existing;
|
|
83
|
+
const delivery: Delivery = {
|
|
84
|
+
...input,
|
|
85
|
+
id: newRoutingId("del"),
|
|
86
|
+
status: "pending",
|
|
87
|
+
createdAt: new Date().toISOString(),
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
this.deliveries.set(delivery.id, delivery);
|
|
91
|
+
this.persist();
|
|
92
|
+
|
|
93
|
+
return delivery;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
pending(bindingId: string): Delivery[] {
|
|
97
|
+
return [...this.deliveries.values()].filter(
|
|
98
|
+
(delivery) => delivery.bindingId === bindingId && delivery.status === "pending",
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A pending delivery keeps its Thread's immutable Message body available for redelivery. */
|
|
103
|
+
pendingThreadIds(): Set<string> {
|
|
104
|
+
const ids = new Set<string>();
|
|
105
|
+
|
|
106
|
+
for (const delivery of this.deliveries.values()) {
|
|
107
|
+
if (delivery.status !== "pending") continue;
|
|
108
|
+
const binding = this.bindings.get(delivery.bindingId);
|
|
109
|
+
|
|
110
|
+
if (binding) ids.add(binding.threadId);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return ids;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
acknowledged(bindingId: string, messageId: string): boolean {
|
|
117
|
+
return [...this.deliveries.values()].some(
|
|
118
|
+
(delivery) =>
|
|
119
|
+
delivery.bindingId === bindingId &&
|
|
120
|
+
delivery.messageId === messageId &&
|
|
121
|
+
delivery.status === "acknowledged",
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
delivery(deliveryId: string): Delivery | null {
|
|
126
|
+
return this.deliveries.get(deliveryId) ?? null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
acknowledge(deliveryId: string, acknowledgedAt = new Date().toISOString()): Delivery {
|
|
130
|
+
const delivery = this.deliveries.get(deliveryId);
|
|
131
|
+
|
|
132
|
+
if (!delivery) throw new Error(`no delivery ${deliveryId}`);
|
|
133
|
+
|
|
134
|
+
if (delivery.status === "acknowledged") return delivery;
|
|
135
|
+
const acknowledged: Delivery = {
|
|
136
|
+
...delivery,
|
|
137
|
+
status: "acknowledged",
|
|
138
|
+
acknowledgedAt,
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
this.deliveries.set(deliveryId, acknowledged);
|
|
142
|
+
this.persist();
|
|
143
|
+
|
|
144
|
+
return acknowledged;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private load(): void {
|
|
148
|
+
let serialized: string;
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
serialized = readFileSync(this.path, "utf8");
|
|
152
|
+
} catch (error) {
|
|
153
|
+
const parsed = v.safeParse(FileErrorSchema, error);
|
|
154
|
+
|
|
155
|
+
if (parsed.success && parsed.output.code === "ENOENT") {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const state = v.parse(HarnessStateSchema, JSON.parse(serialized));
|
|
163
|
+
|
|
164
|
+
this.bindings = new Map(state.bindings.map((binding) => [binding.id, binding]));
|
|
165
|
+
this.deliveries = new Map(state.deliveries.map((delivery) => [delivery.id, delivery]));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private persist(): void {
|
|
169
|
+
const temporaryPath = this.path + ".tmp";
|
|
170
|
+
const state = {
|
|
171
|
+
bindings: [...this.bindings.values()],
|
|
172
|
+
deliveries: [...this.deliveries.values()],
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
writeFileSync(temporaryPath, JSON.stringify(state, null, 2));
|
|
176
|
+
renameSync(temporaryPath, this.path);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function newRoutingId(prefix: "bind" | "del"): string {
|
|
181
|
+
return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
182
|
+
}
|
package/src/herdr-pane.test.ts
CHANGED
|
@@ -66,7 +66,7 @@ function newSession(overrides: Partial<Thread> = {}): Thread {
|
|
|
66
66
|
artifact: { type: "plan", content: "# P", meta: { cwd: "/repo/work", title: "Rollout Plan" } },
|
|
67
67
|
revisions: [{ revision: 1, content: "# P", submittedAt: "now" }],
|
|
68
68
|
annotations: [],
|
|
69
|
-
|
|
69
|
+
message: null,
|
|
70
70
|
status: "pending",
|
|
71
71
|
createdAt: "now",
|
|
72
72
|
...overrides,
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The daemon's public surface. Consumers reach the client and the review
|
|
3
|
-
* helpers through the ./client and ./review subpaths; this barrel carries
|
|
3
|
+
* helpers through the ./client and ./thread-review subpaths; this barrel carries
|
|
4
4
|
* only the two names imported bare: the server and the home-dir resolver.
|
|
5
5
|
*/
|
|
6
6
|
|
package/src/paths.ts
CHANGED
|
@@ -64,3 +64,8 @@ export function ownerTokenPath(home = cueloopHome()): string {
|
|
|
64
64
|
export function herdrTabsPath(home = cueloopHome()): string {
|
|
65
65
|
return join(home, "herdr-tabs.json");
|
|
66
66
|
}
|
|
67
|
+
|
|
68
|
+
/** Harness bindings and Message deliveries, stored independently of Thread records. */
|
|
69
|
+
export function harnessStatePath(home = cueloopHome()): string {
|
|
70
|
+
return join(home, "harness-state.json");
|
|
71
|
+
}
|
package/src/retention.test.ts
CHANGED
|
@@ -36,7 +36,7 @@ function session(id: string, createdAt: string, status: SessionStatus = "resolve
|
|
|
36
36
|
artifact: { type: "plan", content: "", meta: {} },
|
|
37
37
|
revisions: [],
|
|
38
38
|
annotations: [],
|
|
39
|
-
|
|
39
|
+
message: null,
|
|
40
40
|
status,
|
|
41
41
|
createdAt,
|
|
42
42
|
};
|
|
@@ -126,6 +126,15 @@ describe("pruneExpiredSessions", () => {
|
|
|
126
126
|
expect(store.get("ses_new")).toBeDefined();
|
|
127
127
|
});
|
|
128
128
|
|
|
129
|
+
test("keeps an expired resolved Thread while its Message is awaiting delivery", () => {
|
|
130
|
+
const store = new ThreadStore(tempDir("cueloop-retention-home-"));
|
|
131
|
+
|
|
132
|
+
store.upsert(session("ses_waiting", daysAgo(40)));
|
|
133
|
+
|
|
134
|
+
expect(pruneExpiredSessions(store, 30, NOW_MS, new Set(["ses_waiting"]))).toEqual([]);
|
|
135
|
+
expect(store.get("ses_waiting")).toBeDefined();
|
|
136
|
+
});
|
|
137
|
+
|
|
129
138
|
test("never deletes an active pending session however old", () => {
|
|
130
139
|
// Arrange
|
|
131
140
|
const home = tempDir("cueloop-retention-home-");
|