@cueloop/daemon 0.1.0-alpha.8 → 0.1.0-alpha.81
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 +37 -22
- package/src/api.test.ts +940 -65
- package/src/api.ts +1095 -88
- package/src/capabilities.test.ts +57 -0
- package/src/capabilities.ts +89 -0
- package/src/client.test.ts +24 -0
- package/src/client.ts +669 -86
- package/src/curate.ts +109 -0
- package/src/daemon-version.test.ts +93 -0
- package/src/diff-hot-reload.test.ts +272 -0
- package/src/diff-watcher.ts +274 -0
- package/src/gh.ts +37 -0
- package/src/ghostty-thread-surface-store.test.ts +49 -0
- package/src/ghostty-thread-surface-store.ts +101 -0
- package/src/ghostty-thread-surface.test.ts +398 -0
- package/src/ghostty-thread-surface.ts +267 -0
- package/src/harness-delivery.test.ts +208 -0
- package/src/harness-state-store.test.ts +65 -0
- package/src/harness-state-store.ts +181 -0
- package/src/herdr-split.test.ts +106 -0
- package/src/herdr-split.ts +92 -0
- package/src/herdr-thread-surface-store.test.ts +81 -0
- package/src/herdr-thread-surface-store.ts +64 -0
- package/src/herdr-thread-surface.test.ts +411 -0
- package/src/herdr-thread-surface.ts +355 -0
- package/src/index.ts +8 -9
- package/src/interruptible-wait.test.ts +127 -0
- package/src/interruptible-wait.ts +66 -0
- package/src/main.ts +4 -1
- package/src/paths.ts +49 -0
- package/src/pr-poller.test.ts +76 -0
- package/src/pr-poller.ts +74 -0
- package/src/project-files.test.ts +124 -0
- package/src/project-files.ts +56 -0
- package/src/protocol.ts +79 -7
- package/src/retention.test.ts +191 -0
- package/src/retention.ts +123 -0
- package/src/server.test.ts +432 -42
- package/src/server.ts +423 -89
- package/src/share-blob.test.ts +112 -0
- package/src/share-blob.ts +55 -0
- package/src/single-instance.test.ts +162 -13
- package/src/store.conformance.test.ts +41 -0
- package/src/store.jsonl.test.ts +159 -0
- package/src/store.ts +264 -34
- package/src/testing/store-conformance.ts +194 -0
- package/src/thread-review.test.ts +394 -0
- package/src/thread-review.ts +314 -0
- package/src/thread-surface-config.test.ts +63 -0
- package/src/thread-surface-config.ts +61 -0
- package/src/validate.test.ts +214 -35
- package/src/validate.ts +306 -25
- package/src/version.ts +6 -0
- package/src/working-tree.ts +149 -0
- package/src/review.test.ts +0 -167
- package/src/review.ts +0 -174
|
@@ -0,0 +1,208 @@
|
|
|
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("reload finds only bindings for the active harness conversation", () => {
|
|
38
|
+
const first = core();
|
|
39
|
+
const thread = createPlan(first);
|
|
40
|
+
const current = first.harnessBind({
|
|
41
|
+
threadId: thread.id,
|
|
42
|
+
harness: "pi",
|
|
43
|
+
harnessSessionId: "pi-current",
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
first.harnessBind({ threadId: thread.id, harness: "pi", harnessSessionId: "pi-other" });
|
|
47
|
+
first.harnessBind({ threadId: thread.id, harness: "codex", harnessSessionId: "pi-current" });
|
|
48
|
+
|
|
49
|
+
expect(core().harnessBindingsForSession("pi", "pi-current")).toEqual([current]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("redelivers after reload until the harness acknowledges the Message", () => {
|
|
53
|
+
const first = core();
|
|
54
|
+
const thread = createPlan(first);
|
|
55
|
+
const binding = first.harnessBind({
|
|
56
|
+
threadId: thread.id,
|
|
57
|
+
harness: "fake",
|
|
58
|
+
harnessSessionId: "fake_1",
|
|
59
|
+
});
|
|
60
|
+
const sent = first.sessionSendMessage(thread.id, "changes_requested", "Add detail.");
|
|
61
|
+
const initial = first.deliveryPending(binding.id);
|
|
62
|
+
|
|
63
|
+
expect(initial).toHaveLength(1);
|
|
64
|
+
expect(initial[0]!.message).toEqual(sent.message!);
|
|
65
|
+
|
|
66
|
+
const reloaded = core();
|
|
67
|
+
const redelivered = reloaded.deliveryPending(binding.id);
|
|
68
|
+
|
|
69
|
+
expect(redelivered).toEqual(initial);
|
|
70
|
+
reloaded.deliveryAcknowledge(redelivered[0]!.delivery.id);
|
|
71
|
+
|
|
72
|
+
expect(core().deliveryPending(binding.id)).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("retains an expired Thread until its pending Message is acknowledged", () => {
|
|
76
|
+
const first = core();
|
|
77
|
+
const thread = createPlan(first);
|
|
78
|
+
const binding = first.harnessBind({
|
|
79
|
+
threadId: thread.id,
|
|
80
|
+
harness: "fake",
|
|
81
|
+
harnessSessionId: "fake_1",
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
first.sessionSendMessage(thread.id, "approved", "Ready.");
|
|
85
|
+
const oldThread = first.sessionGet(thread.id);
|
|
86
|
+
|
|
87
|
+
first.store.upsert({ ...oldThread, createdAt: "2020-01-01T00:00:00.000Z" });
|
|
88
|
+
|
|
89
|
+
const reloaded = core();
|
|
90
|
+
const delivery = reloaded.deliveryPending(binding.id)[0];
|
|
91
|
+
|
|
92
|
+
expect(delivery?.message.body).toContain("Ready.");
|
|
93
|
+
reloaded.deliveryAcknowledge(delivery!.delivery.id);
|
|
94
|
+
|
|
95
|
+
expect(core().store.get(thread.id)).toBeUndefined();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("rebuilds a delivery if the Thread saved its Message before enqueue", () => {
|
|
99
|
+
const first = core();
|
|
100
|
+
const thread = createPlan(first);
|
|
101
|
+
const binding = first.harnessBind({
|
|
102
|
+
threadId: thread.id,
|
|
103
|
+
harness: "fake",
|
|
104
|
+
harnessSessionId: "fake_1",
|
|
105
|
+
});
|
|
106
|
+
const sent = first.sessionSendMessage(thread.id, "approved", "Ready.");
|
|
107
|
+
first.store.upsert({
|
|
108
|
+
...first.sessionGet(thread.id),
|
|
109
|
+
createdAt: "2020-01-01T00:00:00.000Z",
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
writeFileSync(harnessStatePath(home), JSON.stringify({ bindings: [binding], deliveries: [] }));
|
|
113
|
+
|
|
114
|
+
const recovered = core().deliveryPending(binding.id);
|
|
115
|
+
|
|
116
|
+
expect(recovered).toHaveLength(1);
|
|
117
|
+
expect(recovered[0]!.message).toEqual(sent.message!);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("routes an approved retry as a new Message", () => {
|
|
121
|
+
const instance = core();
|
|
122
|
+
const thread = createPlan(instance);
|
|
123
|
+
const binding = instance.harnessBind({
|
|
124
|
+
threadId: thread.id,
|
|
125
|
+
harness: "fake",
|
|
126
|
+
harnessSessionId: "fake_1",
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
instance.sessionSendMessage(thread.id, "changes_requested", "Try once more.");
|
|
130
|
+
const first = instance.deliveryPending(binding.id)[0]!;
|
|
131
|
+
|
|
132
|
+
instance.deliveryAcknowledge(first.delivery.id);
|
|
133
|
+
instance.sessionSubmitRevision(thread.id, "# Plan");
|
|
134
|
+
instance.sessionSendMessage(thread.id, "approved", "Ready.");
|
|
135
|
+
const retry = instance.deliveryPending(binding.id);
|
|
136
|
+
|
|
137
|
+
expect(retry).toHaveLength(1);
|
|
138
|
+
expect(retry[0]!.message.outcome).toBe("approved");
|
|
139
|
+
expect(retry[0]!.message.id).not.toBe(first.message.id);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("keeps an older pending Message available after a Thread revision", () => {
|
|
143
|
+
const instance = core();
|
|
144
|
+
const thread = createPlan(instance);
|
|
145
|
+
const binding = instance.harnessBind({
|
|
146
|
+
threadId: thread.id,
|
|
147
|
+
harness: "fake",
|
|
148
|
+
harnessSessionId: "fake_1",
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
instance.sessionSendMessage(thread.id, "changes_requested", "Add detail.");
|
|
152
|
+
const first = instance.deliveryPending(binding.id)[0]!;
|
|
153
|
+
|
|
154
|
+
instance.sessionSubmitRevision(thread.id, "# Revised plan");
|
|
155
|
+
instance.sessionSendMessage(thread.id, "approved", "Ready.");
|
|
156
|
+
|
|
157
|
+
const pending = core().deliveryPending(binding.id);
|
|
158
|
+
|
|
159
|
+
expect(pending.map((item) => item.message.id)).toEqual([first.message.id, expect.any(String)]);
|
|
160
|
+
expect(pending[0]!.message.body).toBe(first.message.body);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("defaults delivery to the submitting binding only", () => {
|
|
164
|
+
const instance = core();
|
|
165
|
+
const thread = createPlan(instance);
|
|
166
|
+
const submitter = instance.harnessBind({
|
|
167
|
+
threadId: thread.id,
|
|
168
|
+
harness: "fake",
|
|
169
|
+
harnessSessionId: "fake_1",
|
|
170
|
+
});
|
|
171
|
+
const other = instance.harnessBind({
|
|
172
|
+
threadId: thread.id,
|
|
173
|
+
harness: "other",
|
|
174
|
+
harnessSessionId: "other_1",
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
instance.sessionSendMessage(thread.id, "approved", "Ready.");
|
|
178
|
+
|
|
179
|
+
expect(instance.deliveryPending(submitter.id)).toHaveLength(1);
|
|
180
|
+
expect(instance.deliveryPending(other.id)).toEqual([]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("consumes the unchanged approved retry once across daemon reload", () => {
|
|
184
|
+
const instance = core();
|
|
185
|
+
const thread = createPlan(instance);
|
|
186
|
+
const binding = instance.harnessBind({
|
|
187
|
+
threadId: thread.id,
|
|
188
|
+
harness: "fake",
|
|
189
|
+
harnessSessionId: "fake_1",
|
|
190
|
+
});
|
|
191
|
+
const sent = instance.sessionSendMessage(thread.id, "approved", "Ready.");
|
|
192
|
+
const messageId = sent.message!.id;
|
|
193
|
+
|
|
194
|
+
expect(() => instance.harnessConsumeApprovedRetry(binding.id, messageId, "# Plan")).toThrow(
|
|
195
|
+
"no unchanged approved plan",
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
const delivery = instance.deliveryPending(binding.id)[0]!.delivery;
|
|
199
|
+
|
|
200
|
+
instance.deliveryAcknowledge(delivery.id);
|
|
201
|
+
|
|
202
|
+
expect(instance.harnessConsumeApprovedRetry(binding.id, messageId, "# Plan")).toBe(true);
|
|
203
|
+
expect(core().harnessConsumeApprovedRetry(binding.id, messageId, "# Plan")).toBe(false);
|
|
204
|
+
expect(() => core().harnessConsumeApprovedRetry(binding.id, messageId, "# Changed")).toThrow(
|
|
205
|
+
"no unchanged approved plan",
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -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,181 @@
|
|
|
1
|
+
import { readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import type { Delivery, HarnessBinding } from "@cueloop/schema";
|
|
3
|
+
import * as v from "valibot";
|
|
4
|
+
import { harnessStatePath } from "./paths";
|
|
5
|
+
import { DeliverySchema, HarnessBindingSchema } from "./validate";
|
|
6
|
+
|
|
7
|
+
type BindInput = Pick<HarnessBinding, "threadId" | "harness" | "harnessSessionId">;
|
|
8
|
+
type EnqueueInput = Pick<Delivery, "bindingId" | "messageId">;
|
|
9
|
+
|
|
10
|
+
const HarnessStateSchema = v.object({
|
|
11
|
+
bindings: v.array(HarnessBindingSchema),
|
|
12
|
+
deliveries: v.array(DeliverySchema),
|
|
13
|
+
});
|
|
14
|
+
const FileErrorSchema = v.object({ code: v.string() });
|
|
15
|
+
|
|
16
|
+
export class HarnessStateStore {
|
|
17
|
+
private readonly path: string;
|
|
18
|
+
private bindings = new Map<string, HarnessBinding>();
|
|
19
|
+
private deliveries = new Map<string, Delivery>();
|
|
20
|
+
|
|
21
|
+
constructor(home: string) {
|
|
22
|
+
this.path = harnessStatePath(home);
|
|
23
|
+
this.load();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
bind(input: BindInput): HarnessBinding {
|
|
27
|
+
const existing = [...this.bindings.values()].find(
|
|
28
|
+
(binding) =>
|
|
29
|
+
binding.threadId === input.threadId &&
|
|
30
|
+
binding.harness === input.harness &&
|
|
31
|
+
binding.harnessSessionId === input.harnessSessionId,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
if (existing) return existing;
|
|
35
|
+
const binding: HarnessBinding = {
|
|
36
|
+
...input,
|
|
37
|
+
id: newRoutingId("bind"),
|
|
38
|
+
createdAt: new Date().toISOString(),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
this.bindings.set(binding.id, binding);
|
|
42
|
+
this.persist();
|
|
43
|
+
|
|
44
|
+
return binding;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
binding(id: string): HarnessBinding | null {
|
|
48
|
+
return this.bindings.get(id) ?? null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
bindingsForSession(harness: string, harnessSessionId: string): HarnessBinding[] {
|
|
52
|
+
return [...this.bindings.values()].filter(
|
|
53
|
+
(binding) => binding.harness === harness && binding.harnessSessionId === harnessSessionId,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
consumeApprovedRetry(bindingId: string, messageId: string): boolean {
|
|
58
|
+
const binding = this.bindings.get(bindingId);
|
|
59
|
+
|
|
60
|
+
if (!binding) throw new Error(`no harness binding ${bindingId}`);
|
|
61
|
+
|
|
62
|
+
if (binding.approvedRetryMessageId === messageId) return false;
|
|
63
|
+
|
|
64
|
+
this.bindings.set(bindingId, { ...binding, approvedRetryMessageId: messageId });
|
|
65
|
+
this.persist();
|
|
66
|
+
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The first binding is the submitter; other bindings need explicit future routing. */
|
|
71
|
+
submittingBinding(threadId: string): HarnessBinding | null {
|
|
72
|
+
return [...this.bindings.values()].find((binding) => binding.threadId === threadId) ?? null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
enqueue(input: EnqueueInput): Delivery {
|
|
76
|
+
const existing = [...this.deliveries.values()].find(
|
|
77
|
+
(delivery) =>
|
|
78
|
+
delivery.bindingId === input.bindingId && delivery.messageId === input.messageId,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
if (existing) return existing;
|
|
82
|
+
const delivery: Delivery = {
|
|
83
|
+
...input,
|
|
84
|
+
id: newRoutingId("del"),
|
|
85
|
+
status: "pending",
|
|
86
|
+
createdAt: new Date().toISOString(),
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
this.deliveries.set(delivery.id, delivery);
|
|
90
|
+
this.persist();
|
|
91
|
+
|
|
92
|
+
return delivery;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
pending(bindingId: string): Delivery[] {
|
|
96
|
+
return [...this.deliveries.values()].filter(
|
|
97
|
+
(delivery) => delivery.bindingId === bindingId && delivery.status === "pending",
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A pending delivery keeps its Thread's immutable Message body available for redelivery. */
|
|
102
|
+
pendingThreadIds(): Set<string> {
|
|
103
|
+
const ids = new Set<string>();
|
|
104
|
+
|
|
105
|
+
for (const delivery of this.deliveries.values()) {
|
|
106
|
+
if (delivery.status !== "pending") continue;
|
|
107
|
+
const binding = this.bindings.get(delivery.bindingId);
|
|
108
|
+
|
|
109
|
+
if (binding) ids.add(binding.threadId);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return ids;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
acknowledged(bindingId: string, messageId: string): boolean {
|
|
116
|
+
return [...this.deliveries.values()].some(
|
|
117
|
+
(delivery) =>
|
|
118
|
+
delivery.bindingId === bindingId &&
|
|
119
|
+
delivery.messageId === messageId &&
|
|
120
|
+
delivery.status === "acknowledged",
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
delivery(deliveryId: string): Delivery | null {
|
|
125
|
+
return this.deliveries.get(deliveryId) ?? null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
acknowledge(deliveryId: string, acknowledgedAt = new Date().toISOString()): Delivery {
|
|
129
|
+
const delivery = this.deliveries.get(deliveryId);
|
|
130
|
+
|
|
131
|
+
if (!delivery) throw new Error(`no delivery ${deliveryId}`);
|
|
132
|
+
|
|
133
|
+
if (delivery.status === "acknowledged") return delivery;
|
|
134
|
+
const acknowledged: Delivery = {
|
|
135
|
+
...delivery,
|
|
136
|
+
status: "acknowledged",
|
|
137
|
+
acknowledgedAt,
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
this.deliveries.set(deliveryId, acknowledged);
|
|
141
|
+
this.persist();
|
|
142
|
+
|
|
143
|
+
return acknowledged;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private load(): void {
|
|
147
|
+
let serialized: string;
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
serialized = readFileSync(this.path, "utf8");
|
|
151
|
+
} catch (error) {
|
|
152
|
+
const parsed = v.safeParse(FileErrorSchema, error);
|
|
153
|
+
|
|
154
|
+
if (parsed.success && parsed.output.code === "ENOENT") {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const state = v.parse(HarnessStateSchema, JSON.parse(serialized));
|
|
162
|
+
|
|
163
|
+
this.bindings = new Map(state.bindings.map((binding) => [binding.id, binding]));
|
|
164
|
+
this.deliveries = new Map(state.deliveries.map((delivery) => [delivery.id, delivery]));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private persist(): void {
|
|
168
|
+
const temporaryPath = this.path + ".tmp";
|
|
169
|
+
const state = {
|
|
170
|
+
bindings: [...this.bindings.values()],
|
|
171
|
+
deliveries: [...this.deliveries.values()],
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
writeFileSync(temporaryPath, JSON.stringify(state, null, 2));
|
|
175
|
+
renameSync(temporaryPath, this.path);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function newRoutingId(prefix: "bind" | "del"): string {
|
|
180
|
+
return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
181
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/** launchHarnessInSplit: a stubbed herdr binary logs its argv, and the launcher splits the current pane, runs the harness in the new pane, and optionally types the plan-context seed - a no-op outside herdr. */
|
|
2
|
+
|
|
3
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
4
|
+
import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { launchHarnessInSplit } from "./herdr-split";
|
|
8
|
+
|
|
9
|
+
const dir = mkdtempSync(join(tmpdir(), "cueloop-herdr-split-"));
|
|
10
|
+
|
|
11
|
+
/** A stub herdr binary that logs argv and prints the pane-split result (pane_id of the new split). */
|
|
12
|
+
function makeStub(name: string, splitOk = true) {
|
|
13
|
+
const logPath = join(dir, `${name}.log`);
|
|
14
|
+
const binPath = join(dir, `${name}.sh`);
|
|
15
|
+
const splitResult = splitOk
|
|
16
|
+
? `printf '{"result":{"pane":{"pane_id":"w1:p9"}}}'`
|
|
17
|
+
: `printf '{"result":{}}'`;
|
|
18
|
+
|
|
19
|
+
writeFileSync(
|
|
20
|
+
binPath,
|
|
21
|
+
`#!/bin/sh
|
|
22
|
+
printf '%s\\n' "$*" >> "${logPath}"
|
|
23
|
+
if [ "$1" = "pane" ] && [ "$2" = "split" ]; then ${splitResult}; fi
|
|
24
|
+
exit 0
|
|
25
|
+
`,
|
|
26
|
+
);
|
|
27
|
+
chmodSync(binPath, 0o755);
|
|
28
|
+
|
|
29
|
+
return { binPath, logPath };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readLines(logPath: string): string[] {
|
|
33
|
+
if (!existsSync(logPath)) return [];
|
|
34
|
+
|
|
35
|
+
return readFileSync(logPath, "utf8").split("\n").filter(Boolean);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("launchHarnessInSplit", () => {
|
|
39
|
+
test("splits the current pane and runs the harness in the new pane", () => {
|
|
40
|
+
// Arrange
|
|
41
|
+
const stub = makeStub("run");
|
|
42
|
+
const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
|
|
43
|
+
|
|
44
|
+
// Act
|
|
45
|
+
const launched = launchHarnessInSplit({ command: "cc", cwd: "/repo/work" }, env);
|
|
46
|
+
|
|
47
|
+
// Assert
|
|
48
|
+
expect(launched).toBeTrue();
|
|
49
|
+
expect(readLines(stub.logPath)).toEqual([
|
|
50
|
+
"pane split w1:p1 --direction right --ratio 0.4 --cwd /repo/work",
|
|
51
|
+
"pane send-text w1:p9 cc",
|
|
52
|
+
"pane send-keys w1:p9 enter",
|
|
53
|
+
]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("types the plan-context seed into the split after launching", () => {
|
|
57
|
+
// Arrange
|
|
58
|
+
const stub = makeStub("seeded");
|
|
59
|
+
const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
|
|
60
|
+
|
|
61
|
+
// Act
|
|
62
|
+
launchHarnessInSplit({ command: "pi", cwd: "/repo", seedText: "read plan ses_1" }, env);
|
|
63
|
+
|
|
64
|
+
// Assert
|
|
65
|
+
expect(readLines(stub.logPath)).toEqual([
|
|
66
|
+
"pane split w1:p1 --direction right --ratio 0.4 --cwd /repo",
|
|
67
|
+
"pane send-text w1:p9 pi",
|
|
68
|
+
"pane send-keys w1:p9 enter",
|
|
69
|
+
"pane send-text w1:p9 read plan ses_1",
|
|
70
|
+
]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("no-op outside herdr - returns false and spawns nothing", () => {
|
|
74
|
+
// Arrange
|
|
75
|
+
const stub = makeStub("outside");
|
|
76
|
+
|
|
77
|
+
// Act
|
|
78
|
+
const launched = launchHarnessInSplit(
|
|
79
|
+
{ command: "cc", cwd: "/repo" },
|
|
80
|
+
{ HERDR_BIN_PATH: stub.binPath },
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
// Assert
|
|
84
|
+
expect(launched).toBeFalse();
|
|
85
|
+
expect(existsSync(stub.logPath)).toBeFalse();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("returns false when the split yields no pane id", () => {
|
|
89
|
+
// Arrange
|
|
90
|
+
const stub = makeStub("nopane", false);
|
|
91
|
+
const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
|
|
92
|
+
|
|
93
|
+
// Act
|
|
94
|
+
const launched = launchHarnessInSplit({ command: "cc", cwd: "/repo" }, env);
|
|
95
|
+
|
|
96
|
+
// Assert - the split ran but no harness was launched into a missing pane
|
|
97
|
+
expect(launched).toBeFalse();
|
|
98
|
+
expect(readLines(stub.logPath)).toEqual([
|
|
99
|
+
"pane split w1:p1 --direction right --ratio 0.4 --cwd /repo",
|
|
100
|
+
]);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
afterAll(() => {
|
|
105
|
+
rmSync(dir, { recursive: true, force: true, maxRetries: 3 });
|
|
106
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Launches a bring-your-own review harness (claude code / pi / codex) in a herdr
|
|
3
|
+
* split beside the current pane - the Agent rail's "run it right here" spawn, so
|
|
4
|
+
* a reviewer chats with an agent about the plan without leaving the review tab.
|
|
5
|
+
* Best-effort and herdr-only: a no-op returning false outside herdr or on any
|
|
6
|
+
* spawn failure, each spawn bounded by a timeout.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { detectHerdr, type HerdrEnv } from "@cueloop/schema";
|
|
10
|
+
import * as v from "valibot";
|
|
11
|
+
|
|
12
|
+
const HERDR_SPAWN_TIMEOUT_MS = 2000;
|
|
13
|
+
const SplitPaneSchema = v.object({
|
|
14
|
+
result: v.optional(v.object({ pane: v.optional(v.object({ pane_id: v.optional(v.string()) })) })),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
/** How much of the current pane the launched harness split takes (herdr --ratio). */
|
|
18
|
+
const HARNESS_SPLIT_RATIO = "0.4";
|
|
19
|
+
|
|
20
|
+
export interface LaunchHarnessInSplitOptions {
|
|
21
|
+
/** The harness launch command run in the split, e.g. "cc", "pi", "codex". */
|
|
22
|
+
command: string;
|
|
23
|
+
/** Working directory for the split - the reviewed session's cwd. */
|
|
24
|
+
cwd: string;
|
|
25
|
+
/**
|
|
26
|
+
* A plan-context briefing typed into the split after the harness command, left
|
|
27
|
+
* unsubmitted for the reviewer to send. Best-effort: on a slow harness start it
|
|
28
|
+
* may reach the shell instead, so the launch never depends on it.
|
|
29
|
+
*/
|
|
30
|
+
seedText?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Split the current pane and run the harness in it. Returns true when the split
|
|
35
|
+
* opened, false on any best-effort bail-out. No-op outside herdr.
|
|
36
|
+
*/
|
|
37
|
+
export function launchHarnessInSplit(
|
|
38
|
+
options: LaunchHarnessInSplitOptions,
|
|
39
|
+
env: HerdrEnv = process.env,
|
|
40
|
+
): boolean {
|
|
41
|
+
const herdr = detectHerdr(env);
|
|
42
|
+
|
|
43
|
+
if (!herdr) return false;
|
|
44
|
+
try {
|
|
45
|
+
const split = Bun.spawnSync(
|
|
46
|
+
[
|
|
47
|
+
herdr.binPath,
|
|
48
|
+
"pane",
|
|
49
|
+
"split",
|
|
50
|
+
herdr.paneId,
|
|
51
|
+
"--direction",
|
|
52
|
+
"right",
|
|
53
|
+
"--ratio",
|
|
54
|
+
HARNESS_SPLIT_RATIO,
|
|
55
|
+
"--cwd",
|
|
56
|
+
options.cwd,
|
|
57
|
+
],
|
|
58
|
+
{ stdout: "pipe", stderr: "ignore", timeout: HERDR_SPAWN_TIMEOUT_MS },
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
if (split.exitCode !== 0) return false;
|
|
62
|
+
const parsed = v.safeParse(SplitPaneSchema, JSON.parse(split.stdout.toString()));
|
|
63
|
+
|
|
64
|
+
if (!parsed.success) return false;
|
|
65
|
+
const paneId = parsed.output.result?.pane?.pane_id;
|
|
66
|
+
|
|
67
|
+
if (!paneId) return false;
|
|
68
|
+
sendText(herdr.binPath, paneId, options.command);
|
|
69
|
+
sendKeys(herdr.binPath, paneId, "enter");
|
|
70
|
+
if (options.seedText) sendText(herdr.binPath, paneId, options.seedText);
|
|
71
|
+
|
|
72
|
+
return true;
|
|
73
|
+
} catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function sendText(binPath: string, paneId: string, text: string): void {
|
|
79
|
+
Bun.spawnSync([binPath, "pane", "send-text", paneId, text], {
|
|
80
|
+
stdout: "ignore",
|
|
81
|
+
stderr: "ignore",
|
|
82
|
+
timeout: HERDR_SPAWN_TIMEOUT_MS,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function sendKeys(binPath: string, paneId: string, keys: string): void {
|
|
87
|
+
Bun.spawnSync([binPath, "pane", "send-keys", paneId, keys], {
|
|
88
|
+
stdout: "ignore",
|
|
89
|
+
stderr: "ignore",
|
|
90
|
+
timeout: HERDR_SPAWN_TIMEOUT_MS,
|
|
91
|
+
});
|
|
92
|
+
}
|