@cueloop/daemon 0.1.0-alpha.8 → 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 +34 -22
- package/src/api.test.ts +925 -65
- package/src/api.ts +1060 -88
- package/src/capabilities.test.ts +57 -0
- package/src/capabilities.ts +84 -0
- package/src/client.test.ts +24 -0
- package/src/client.ts +526 -70
- 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/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 +240 -0
- package/src/herdr-pane.ts +167 -0
- package/src/herdr-split.test.ts +106 -0
- package/src/herdr-split.ts +92 -0
- package/src/herdr-tab-store.test.ts +96 -0
- package/src/herdr-tab-store.ts +66 -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 +44 -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 +430 -42
- package/src/server.ts +362 -84
- package/src/share-blob.test.ts +112 -0
- package/src/share-blob.ts +55 -0
- package/src/single-instance.test.ts +72 -11
- 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 +324 -0
- package/src/validate.test.ts +214 -35
- package/src/validate.ts +296 -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,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
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/** Creation-side herdr pane opener: a stubbed herdr binary logs its argv, openHerdrPane returns the tab handle, and openHerdrPaneForReview opens+records for a new review, focuses a still-alive recorded tab, and reopens a dead one - collision-free by pane id. */
|
|
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 type { Thread } from "@cueloop/schema";
|
|
8
|
+
import { openHerdrPane, openHerdrPaneForReview } from "./herdr-pane";
|
|
9
|
+
import type { HerdrTabPersistence } from "./herdr-pane";
|
|
10
|
+
import type { HerdrTabHandle } from "./herdr-tab-store";
|
|
11
|
+
|
|
12
|
+
const dir = mkdtempSync(join(tmpdir(), "cueloop-herdr-pane-"));
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A stub herdr binary. It logs argv, prints the tab-create result carrying both
|
|
16
|
+
* pane_id and tab_id (herdr 0.8.2 shape), and answers `pane get` alive or dead
|
|
17
|
+
* per `paneAlive` so the liveness branch can be exercised.
|
|
18
|
+
*/
|
|
19
|
+
function makeStub(name: string, paneAlive = false) {
|
|
20
|
+
const logPath = join(dir, `${name}.log`);
|
|
21
|
+
const binPath = join(dir, `${name}.sh`);
|
|
22
|
+
const paneGet = paneAlive ? `printf '{"result":{"pane":{"pane_id":"w1:p2"}}}'` : "exit 1";
|
|
23
|
+
|
|
24
|
+
writeFileSync(
|
|
25
|
+
binPath,
|
|
26
|
+
`#!/bin/sh
|
|
27
|
+
printf '%s\\n' "$*" >> "${logPath}"
|
|
28
|
+
if [ "$1" = "tab" ] && [ "$2" = "create" ]; then
|
|
29
|
+
printf '{"result":{"root_pane":{"pane_id":"w1:p2","tab_id":"w1:t2"}}}'
|
|
30
|
+
fi
|
|
31
|
+
if [ "$1" = "pane" ] && [ "$2" = "get" ]; then ${paneGet}; fi
|
|
32
|
+
exit 0
|
|
33
|
+
`,
|
|
34
|
+
);
|
|
35
|
+
chmodSync(binPath, 0o755);
|
|
36
|
+
|
|
37
|
+
return { binPath, logPath };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function readLines(logPath: string): string[] {
|
|
41
|
+
if (!existsSync(logPath)) return [];
|
|
42
|
+
|
|
43
|
+
return readFileSync(logPath, "utf8").split("\n").filter(Boolean);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** In-memory persistence double: records what openHerdrPaneForReview saves. */
|
|
47
|
+
function fakePersistence(initial: HerdrTabHandle | null = null) {
|
|
48
|
+
let stored = initial;
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
persistence: {
|
|
52
|
+
herdrGetTab: async () => stored,
|
|
53
|
+
herdrSetTab: async (_sessionId: string, handle: HerdrTabHandle) => {
|
|
54
|
+
stored = handle;
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
saved: () => stored,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function newSession(overrides: Partial<Thread> = {}): Thread {
|
|
62
|
+
return {
|
|
63
|
+
schemaVersion: "1",
|
|
64
|
+
id: "ses_new1",
|
|
65
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
66
|
+
artifact: { type: "plan", content: "# P", meta: { cwd: "/repo/work", title: "Rollout Plan" } },
|
|
67
|
+
revisions: [{ revision: 1, content: "# P", submittedAt: "now" }],
|
|
68
|
+
annotations: [],
|
|
69
|
+
message: null,
|
|
70
|
+
status: "pending",
|
|
71
|
+
createdAt: "now",
|
|
72
|
+
...overrides,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe("openHerdrPane", () => {
|
|
77
|
+
test("creates a focused tab, launches the review, and returns the handle", () => {
|
|
78
|
+
// Arrange
|
|
79
|
+
const stub = makeStub("open");
|
|
80
|
+
|
|
81
|
+
// Act
|
|
82
|
+
const handle = openHerdrPane({
|
|
83
|
+
sessionId: "ses_abc",
|
|
84
|
+
cwd: "/repo/work",
|
|
85
|
+
binPath: stub.binPath,
|
|
86
|
+
label: "Rollout Plan",
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Assert
|
|
90
|
+
expect(handle).toEqual({ tabId: "w1:t2", paneId: "w1:p2" });
|
|
91
|
+
expect(readLines(stub.logPath)).toEqual([
|
|
92
|
+
"tab create --cwd /repo/work --label Rollout Plan --focus",
|
|
93
|
+
"pane send-text w1:p2 cueloop ses_abc",
|
|
94
|
+
"pane send-keys w1:p2 enter",
|
|
95
|
+
]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("returns null and never throws on a broken binary", () => {
|
|
99
|
+
expect(
|
|
100
|
+
openHerdrPane({
|
|
101
|
+
sessionId: "ses_abc",
|
|
102
|
+
cwd: "/repo/work",
|
|
103
|
+
binPath: join(dir, "missing-bin"),
|
|
104
|
+
label: "x",
|
|
105
|
+
}),
|
|
106
|
+
).toBeNull();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("returns null when tab create yields no ids - no send-text, no send-keys", () => {
|
|
110
|
+
// Arrange
|
|
111
|
+
const logPath = join(dir, "nopane.log");
|
|
112
|
+
const binPath = join(dir, "nopane.sh");
|
|
113
|
+
|
|
114
|
+
writeFileSync(
|
|
115
|
+
binPath,
|
|
116
|
+
`#!/bin/sh\nprintf '%s\\n' "$*" >> "${logPath}"\nprintf '{"result":{}}'\n`,
|
|
117
|
+
);
|
|
118
|
+
chmodSync(binPath, 0o755);
|
|
119
|
+
|
|
120
|
+
// Act
|
|
121
|
+
const handle = openHerdrPane({ sessionId: "ses_abc", cwd: "/repo/work", binPath, label: "x" });
|
|
122
|
+
|
|
123
|
+
// Assert
|
|
124
|
+
expect(handle).toBeNull();
|
|
125
|
+
expect(readLines(logPath)).toEqual(["tab create --cwd /repo/work --label x --focus"]);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("openHerdrPaneForReview", () => {
|
|
130
|
+
test("opens and records a tab for a review with no recorded tab", async () => {
|
|
131
|
+
// Arrange
|
|
132
|
+
const stub = makeStub("gated-new");
|
|
133
|
+
const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
|
|
134
|
+
const store = fakePersistence(null);
|
|
135
|
+
|
|
136
|
+
// Act
|
|
137
|
+
await openHerdrPaneForReview(newSession({ id: "ses_xyz" }), store.persistence, env);
|
|
138
|
+
|
|
139
|
+
// Assert
|
|
140
|
+
expect(readLines(stub.logPath)).toEqual([
|
|
141
|
+
"tab create --cwd /repo/work --label Rollout Plan --focus",
|
|
142
|
+
"pane send-text w1:p2 cueloop ses_xyz",
|
|
143
|
+
"pane send-keys w1:p2 enter",
|
|
144
|
+
]);
|
|
145
|
+
expect(store.saved()).toEqual({ tabId: "w1:t2", paneId: "w1:p2" });
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("no-op outside herdr - no herdr process is spawned", async () => {
|
|
149
|
+
// Arrange
|
|
150
|
+
const stub = makeStub("gated-outside");
|
|
151
|
+
const store = fakePersistence(null);
|
|
152
|
+
|
|
153
|
+
// Act
|
|
154
|
+
await openHerdrPaneForReview(newSession(), store.persistence, {
|
|
155
|
+
HERDR_PANE_ID: "w1:p1",
|
|
156
|
+
HERDR_BIN_PATH: stub.binPath,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// Assert
|
|
160
|
+
expect(existsSync(stub.logPath)).toBeFalse();
|
|
161
|
+
expect(store.saved()).toBeNull();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("focuses the recorded tab when its pane is still alive, without reopening", async () => {
|
|
165
|
+
// Arrange
|
|
166
|
+
const stub = makeStub("gated-alive", true);
|
|
167
|
+
const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
|
|
168
|
+
const store = fakePersistence({ tabId: "w9:t9", paneId: "w9:p9" });
|
|
169
|
+
|
|
170
|
+
// Act
|
|
171
|
+
await openHerdrPaneForReview(newSession(), store.persistence, env);
|
|
172
|
+
|
|
173
|
+
// Assert
|
|
174
|
+
const lines = readLines(stub.logPath);
|
|
175
|
+
|
|
176
|
+
expect(lines).toContain("pane get w9:p9");
|
|
177
|
+
expect(lines).toContain("tab focus w9:t9");
|
|
178
|
+
expect(lines.some((line) => line.startsWith("tab create"))).toBeFalse();
|
|
179
|
+
expect(store.saved()).toEqual({ tabId: "w9:t9", paneId: "w9:p9" });
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("a persistence failure never escapes - review creation stays best-effort", async () => {
|
|
183
|
+
// Arrange - the daemon store rejects; the opener must still resolve quietly
|
|
184
|
+
const stub = makeStub("gated-persist-fail");
|
|
185
|
+
const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
|
|
186
|
+
const persistence: HerdrTabPersistence = {
|
|
187
|
+
herdrGetTab: async () => null,
|
|
188
|
+
herdrSetTab: async () => {
|
|
189
|
+
throw new Error("disk full");
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
// Act & Assert - no throw
|
|
194
|
+
await openHerdrPaneForReview(newSession(), persistence, env);
|
|
195
|
+
expect(readLines(stub.logPath).some((line) => line.startsWith("tab create"))).toBeTrue();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("a recall failure (stale daemon) still opens a fresh tab", async () => {
|
|
199
|
+
// Arrange - a daemon predating the herdr-tab primitives rejects the recall
|
|
200
|
+
const stub = makeStub("gated-recall-fail");
|
|
201
|
+
const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
|
|
202
|
+
const persistence: HerdrTabPersistence = {
|
|
203
|
+
herdrGetTab: async () => {
|
|
204
|
+
throw new Error("unknown method herdr.getTab");
|
|
205
|
+
},
|
|
206
|
+
herdrSetTab: async () => {},
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// Act
|
|
210
|
+
await openHerdrPaneForReview(newSession({ id: "ses_stale" }), persistence, env);
|
|
211
|
+
|
|
212
|
+
// Assert - the recall throwing must not abort the open
|
|
213
|
+
expect(readLines(stub.logPath)).toEqual([
|
|
214
|
+
"tab create --cwd /repo/work --label Rollout Plan --focus",
|
|
215
|
+
"pane send-text w1:p2 cueloop ses_stale",
|
|
216
|
+
"pane send-keys w1:p2 enter",
|
|
217
|
+
]);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("reopens and re-records when the recorded pane is dead", async () => {
|
|
221
|
+
// Arrange
|
|
222
|
+
const stub = makeStub("gated-dead", false);
|
|
223
|
+
const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
|
|
224
|
+
const store = fakePersistence({ tabId: "old:t", paneId: "old:p" });
|
|
225
|
+
|
|
226
|
+
// Act
|
|
227
|
+
await openHerdrPaneForReview(newSession({ id: "ses_re" }), store.persistence, env);
|
|
228
|
+
|
|
229
|
+
// Assert
|
|
230
|
+
const lines = readLines(stub.logPath);
|
|
231
|
+
|
|
232
|
+
expect(lines).toContain("pane get old:p");
|
|
233
|
+
expect(lines).toContain("tab create --cwd /repo/work --label Rollout Plan --focus");
|
|
234
|
+
expect(store.saved()).toEqual({ tabId: "w1:t2", paneId: "w1:p2" });
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
afterAll(() => {
|
|
239
|
+
rmSync(dir, { recursive: true, force: true, maxRetries: 3 });
|
|
240
|
+
});
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opens a herdr tab that renders a review, from the creation sites (the Claude
|
|
3
|
+
* Code hook and `cueloop session create`) that run inside a herdr pane. On a
|
|
4
|
+
* resubmit it reopens only when the recorded pane is dead, so a live tab is
|
|
5
|
+
* focused rather than duplicated. Best-effort: any spawn failure is swallowed
|
|
6
|
+
* and never blocks review creation; each spawn is bounded by a timeout.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { detectHerdr, type HerdrEnv, type Thread } from "@cueloop/schema";
|
|
10
|
+
import * as v from "valibot";
|
|
11
|
+
import type { HerdrTabHandle } from "./herdr-tab-store";
|
|
12
|
+
|
|
13
|
+
const HERDR_SPAWN_TIMEOUT_MS = 2000;
|
|
14
|
+
const CreatedTabSchema = v.object({
|
|
15
|
+
result: v.optional(
|
|
16
|
+
v.object({
|
|
17
|
+
root_pane: v.optional(
|
|
18
|
+
v.object({ pane_id: v.optional(v.string()), tab_id: v.optional(v.string()) }),
|
|
19
|
+
),
|
|
20
|
+
}),
|
|
21
|
+
),
|
|
22
|
+
});
|
|
23
|
+
const PaneResultSchema = v.object({
|
|
24
|
+
result: v.optional(v.object({ pane: v.optional(v.unknown()) })),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export interface OpenHerdrPaneOptions {
|
|
28
|
+
sessionId: string;
|
|
29
|
+
cwd: string;
|
|
30
|
+
binPath: string;
|
|
31
|
+
label: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The daemon-side handle store, as the two methods this module needs. DaemonClient satisfies it. */
|
|
35
|
+
export interface HerdrTabPersistence {
|
|
36
|
+
herdrGetTab(sessionId: string): Promise<HerdrTabHandle | null>;
|
|
37
|
+
herdrSetTab(sessionId: string, handle: HerdrTabHandle): Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Open a focused tab and launch the review in it. `tab create` prints
|
|
42
|
+
* `{ result: { root_pane: { pane_id, tab_id } } }` (herdr 0.8.2); both ids are
|
|
43
|
+
* returned so a later resubmit can liveness-check and focus. Null on any
|
|
44
|
+
* best-effort bail-out.
|
|
45
|
+
*/
|
|
46
|
+
export function openHerdrPane(options: OpenHerdrPaneOptions): HerdrTabHandle | null {
|
|
47
|
+
const { sessionId, cwd, binPath, label } = options;
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const created = Bun.spawnSync(
|
|
51
|
+
[binPath, "tab", "create", "--cwd", cwd, "--label", label, "--focus"],
|
|
52
|
+
{ stdout: "pipe", stderr: "ignore", timeout: HERDR_SPAWN_TIMEOUT_MS },
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
if (created.exitCode !== 0) return null;
|
|
56
|
+
const parsed = v.safeParse(CreatedTabSchema, JSON.parse(created.stdout.toString()));
|
|
57
|
+
|
|
58
|
+
if (!parsed.success) return null;
|
|
59
|
+
const paneId = parsed.output.result?.root_pane?.pane_id;
|
|
60
|
+
const tabId = parsed.output.result?.root_pane?.tab_id;
|
|
61
|
+
|
|
62
|
+
if (!paneId || !tabId) return null;
|
|
63
|
+
// A fresh tab hosts a plain shell, so the review is typed in like a human.
|
|
64
|
+
const typed = Bun.spawnSync([binPath, "pane", "send-text", paneId, `cueloop ${sessionId}`], {
|
|
65
|
+
stdout: "ignore",
|
|
66
|
+
stderr: "ignore",
|
|
67
|
+
timeout: HERDR_SPAWN_TIMEOUT_MS,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
if (typed.exitCode !== 0) return null;
|
|
71
|
+
const entered = Bun.spawnSync([binPath, "pane", "send-keys", paneId, "enter"], {
|
|
72
|
+
stdout: "ignore",
|
|
73
|
+
stderr: "ignore",
|
|
74
|
+
timeout: HERDR_SPAWN_TIMEOUT_MS,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return entered.exitCode === 0 ? { tabId, paneId } : null;
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** True when the pane still exists (`herdr pane get` returns it). */
|
|
84
|
+
function herdrPaneAlive(binPath: string, paneId: string): boolean {
|
|
85
|
+
try {
|
|
86
|
+
const got = Bun.spawnSync([binPath, "pane", "get", paneId], {
|
|
87
|
+
stdout: "pipe",
|
|
88
|
+
stderr: "ignore",
|
|
89
|
+
timeout: HERDR_SPAWN_TIMEOUT_MS,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
if (got.exitCode !== 0) return false;
|
|
93
|
+
const parsed = v.safeParse(PaneResultSchema, JSON.parse(got.stdout.toString()));
|
|
94
|
+
|
|
95
|
+
return parsed.success && parsed.output.result?.pane != null;
|
|
96
|
+
} catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Bring an existing review tab to the front; best-effort. */
|
|
102
|
+
function focusHerdrTab(binPath: string, tabId: string): void {
|
|
103
|
+
try {
|
|
104
|
+
Bun.spawnSync([binPath, "tab", "focus", tabId], {
|
|
105
|
+
stdout: "ignore",
|
|
106
|
+
stderr: "ignore",
|
|
107
|
+
timeout: HERDR_SPAWN_TIMEOUT_MS,
|
|
108
|
+
});
|
|
109
|
+
} catch {
|
|
110
|
+
// best-effort
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Ensure a herdr tab is rendering this review. Inside herdr only: focus the
|
|
116
|
+
* recorded tab if its pane is still alive, otherwise open a fresh one and record
|
|
117
|
+
* its handle. No-op outside herdr.
|
|
118
|
+
*/
|
|
119
|
+
export async function openHerdrPaneForReview(
|
|
120
|
+
session: Thread,
|
|
121
|
+
persistence: HerdrTabPersistence,
|
|
122
|
+
env: HerdrEnv = process.env,
|
|
123
|
+
): Promise<void> {
|
|
124
|
+
const herdr = detectHerdr(env);
|
|
125
|
+
|
|
126
|
+
if (!herdr) return;
|
|
127
|
+
const recorded = await recallHerdrTab(persistence, session.id);
|
|
128
|
+
|
|
129
|
+
if (recorded && herdrPaneAlive(herdr.binPath, recorded.paneId)) {
|
|
130
|
+
focusHerdrTab(herdr.binPath, recorded.tabId);
|
|
131
|
+
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const opened = openHerdrPane({
|
|
135
|
+
sessionId: session.id,
|
|
136
|
+
cwd: session.artifact.meta.cwd ?? process.cwd(),
|
|
137
|
+
binPath: herdr.binPath,
|
|
138
|
+
label: session.artifact.meta.title ?? session.id,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
if (opened) await rememberHerdrTab(persistence, session.id, opened);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The recorded handle, or null on any recall failure - a stale daemon must still open a fresh tab, not abort. */
|
|
145
|
+
async function recallHerdrTab(
|
|
146
|
+
persistence: HerdrTabPersistence,
|
|
147
|
+
sessionId: string,
|
|
148
|
+
): Promise<HerdrTabHandle | null> {
|
|
149
|
+
try {
|
|
150
|
+
return await persistence.herdrGetTab(sessionId);
|
|
151
|
+
} catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Record the handle for later liveness checks; a store failure loses dedup, never the already-open tab. */
|
|
157
|
+
async function rememberHerdrTab(
|
|
158
|
+
persistence: HerdrTabPersistence,
|
|
159
|
+
sessionId: string,
|
|
160
|
+
handle: HerdrTabHandle,
|
|
161
|
+
): Promise<void> {
|
|
162
|
+
try {
|
|
163
|
+
await persistence.herdrSetTab(sessionId, handle);
|
|
164
|
+
} catch {
|
|
165
|
+
// stale daemon: the tab is already shown; only the liveness-dedup handle is lost
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -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
|
+
});
|