@cueloop/daemon 0.1.0-alpha.10 → 0.1.0-alpha.100
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/README.md +7 -0
- package/SDK.md +102 -0
- package/package.json +43 -22
- package/src/agent-tools.ts +98 -0
- package/src/agent-turn.test.ts +24 -0
- package/src/agent-turn.ts +34 -0
- package/src/api.test.ts +1081 -65
- package/src/api.ts +1425 -89
- package/src/capabilities.test.ts +57 -0
- package/src/capabilities.ts +95 -0
- package/src/client-errors.ts +24 -0
- package/src/client-reliability.test.ts +189 -0
- package/src/client.test.ts +37 -0
- package/src/client.ts +820 -94
- package/src/curate.ts +109 -0
- package/src/daemon-sdk-effect.ts +113 -0
- package/src/daemon-sdk.test.ts +306 -0
- package/src/daemon-sdk.ts +397 -0
- package/src/daemon-version.test.ts +142 -0
- package/src/diff-hot-reload.test.ts +419 -0
- package/src/diff-watcher.test.ts +117 -0
- package/src/diff-watcher.ts +326 -0
- package/src/examples/daemon-sdk.ts +55 -0
- package/src/gh.test.ts +59 -0
- package/src/gh.ts +74 -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 +269 -0
- package/src/harness-diagnostics.test.ts +26 -0
- package/src/harness-diagnostics.ts +30 -0
- package/src/harness-state-store.test.ts +93 -0
- package/src/harness-state-store.ts +213 -0
- package/src/herdr-split.test.ts +106 -0
- package/src/herdr-split.ts +85 -0
- package/src/herdr-thread-surface-store.test.ts +81 -0
- package/src/herdr-thread-surface-store.ts +66 -0
- package/src/herdr-thread-surface.test.ts +465 -0
- package/src/herdr-thread-surface.ts +378 -0
- package/src/index.ts +8 -9
- package/src/interruptible-wait.test.ts +127 -0
- package/src/interruptible-wait.ts +66 -0
- package/src/jj-working-tree.ts +131 -0
- package/src/main.ts +4 -1
- package/src/operation-receipts.test.ts +111 -0
- package/src/operation-receipts.ts +91 -0
- package/src/paths.ts +49 -0
- package/src/pr-poller.test.ts +102 -0
- package/src/pr-poller.ts +73 -0
- package/src/project-files.test.ts +134 -0
- package/src/project-files.ts +68 -0
- package/src/protocol.ts +78 -7
- package/src/retention.test.ts +191 -0
- package/src/retention.ts +123 -0
- package/src/sdk-identities.ts +35 -0
- package/src/server.test.ts +456 -42
- package/src/server.ts +601 -88
- package/src/share-blob.test.ts +119 -0
- package/src/share-blob.ts +57 -0
- package/src/single-instance.test.ts +162 -13
- package/src/store.conformance.test.ts +41 -0
- package/src/store.jsonl.test.ts +173 -0
- package/src/store.ts +267 -34
- package/src/testing/store-conformance.ts +192 -0
- package/src/thread-agent-validation.ts +119 -0
- package/src/thread-agent.test.ts +412 -0
- package/src/thread-agent.ts +713 -0
- package/src/thread-review.test.ts +483 -0
- package/src/thread-review.ts +382 -0
- package/src/thread-subscription.test.ts +174 -0
- package/src/thread-subscription.ts +129 -0
- package/src/thread-surface-config.test.ts +63 -0
- package/src/thread-surface-config.ts +61 -0
- package/src/validate.test.ts +279 -37
- package/src/validate.ts +418 -33
- package/src/vcs-source.test.ts +180 -0
- package/src/vcs-source.ts +229 -0
- package/src/version.ts +6 -0
- package/src/working-tree.ts +150 -0
- package/src/review.test.ts +0 -167
- package/src/review.ts +0 -174
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/** Native Jujutsu capture pinned to one commit after its working-copy snapshot. */
|
|
2
|
+
|
|
3
|
+
import type { DiffFileContents, DiffFileStatus } from "@cueloop/schema";
|
|
4
|
+
import type { VcsAdapter, VcsDiffSnapshot } from "@cueloop/extension-api";
|
|
5
|
+
|
|
6
|
+
async function jj(args: string[], cwd: string): Promise<string | null> {
|
|
7
|
+
try {
|
|
8
|
+
const child = Bun.spawn(["jj", ...args], { cwd, stdout: "pipe", stderr: "ignore" });
|
|
9
|
+
const output = await new Response(child.stdout).text();
|
|
10
|
+
|
|
11
|
+
return (await child.exited) === 0 ? output : null;
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const JJ_DIFF_FILES_TEMPLATE = 'status_char ++ "\\x00" ++ path.display() ++ "\\x00"';
|
|
18
|
+
const JJ_FILE_LIST_TEMPLATE = 'path.display() ++ "\\x00"';
|
|
19
|
+
|
|
20
|
+
function jjSummary(text: string): { path: string; status: DiffFileStatus; curatable: boolean }[] {
|
|
21
|
+
const tokens = text.split("\0");
|
|
22
|
+
const changes: { path: string; status: DiffFileStatus; curatable: boolean }[] = [];
|
|
23
|
+
|
|
24
|
+
for (let index = 0; index + 1 < tokens.length; index += 2) {
|
|
25
|
+
const code = tokens[index];
|
|
26
|
+
const path = tokens[index + 1];
|
|
27
|
+
|
|
28
|
+
if (!code || !path) continue;
|
|
29
|
+
changes.push({
|
|
30
|
+
path,
|
|
31
|
+
status: code === "A" ? "added" : code === "D" ? "deleted" : "modified",
|
|
32
|
+
curatable: code === "A" || code === "M" || code === "D",
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return changes;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isBinary(contents: string): boolean {
|
|
40
|
+
return contents.includes("\0");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function jjFileContents(
|
|
44
|
+
root: string,
|
|
45
|
+
revision: string,
|
|
46
|
+
path: string,
|
|
47
|
+
): Promise<string | null> {
|
|
48
|
+
return jj(["--ignore-working-copy", "file", "show", "-r", revision, "--", path], root);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function captureJjRevision(repoRoot: string, revset: string): Promise<VcsDiffSnapshot> {
|
|
52
|
+
const identity = await jj(
|
|
53
|
+
[
|
|
54
|
+
"log",
|
|
55
|
+
"-r",
|
|
56
|
+
revset,
|
|
57
|
+
"-T",
|
|
58
|
+
'change_id ++ "\\n" ++ commit_id ++ "\\n" ++ parents.map(|c| c.commit_id()).join(",") ++ "\\n"',
|
|
59
|
+
"--no-graph",
|
|
60
|
+
],
|
|
61
|
+
repoRoot,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
if (!identity) throw new Error(`Jujutsu diff capture failed: could not resolve ${revset}`);
|
|
65
|
+
const lines = identity.trim().split("\n");
|
|
66
|
+
|
|
67
|
+
if (lines.length !== 3) throw new Error(`Jujutsu change is ambiguous: ${revset}`);
|
|
68
|
+
const [changeId, revisionId, parents] = lines;
|
|
69
|
+
|
|
70
|
+
if (!changeId || !revisionId) throw new Error("Jujutsu diff capture failed: invalid identity");
|
|
71
|
+
const [patch, summary] = await Promise.all([
|
|
72
|
+
jj(["--ignore-working-copy", "diff", "--git", "-r", revisionId], repoRoot),
|
|
73
|
+
jj(["--ignore-working-copy", "diff", "-T", JJ_DIFF_FILES_TEMPLATE, "-r", revisionId], repoRoot),
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
if (patch === null || summary === null)
|
|
77
|
+
throw new Error("Jujutsu diff capture failed: could not read pinned revision");
|
|
78
|
+
const files: DiffFileContents[] = [];
|
|
79
|
+
const parent = parents?.split(",")[0];
|
|
80
|
+
|
|
81
|
+
if (parent && !parents?.includes(",")) {
|
|
82
|
+
for (const change of jjSummary(summary)) {
|
|
83
|
+
if (!change.curatable) continue;
|
|
84
|
+
const oldContents =
|
|
85
|
+
change.status === "added" ? "" : await jjFileContents(repoRoot, parent, change.path);
|
|
86
|
+
const newContents =
|
|
87
|
+
change.status === "deleted" ? "" : await jjFileContents(repoRoot, revisionId, change.path);
|
|
88
|
+
|
|
89
|
+
if (oldContents === null || newContents === null) continue;
|
|
90
|
+
if (isBinary(oldContents) || isBinary(newContents)) continue;
|
|
91
|
+
files.push({ path: change.path, oldContents, newContents, status: change.status });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { patch: patch.replace(/\n+$/, ""), files, source: { changeId, revisionId } };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Built-in jj adapter uses native change and commit IDs in a colocated checkout. */
|
|
99
|
+
export const jjVcsAdapter: VcsAdapter = {
|
|
100
|
+
apiVersion: 1,
|
|
101
|
+
id: "jj",
|
|
102
|
+
async detect(cwd) {
|
|
103
|
+
const root = await jj(["--ignore-working-copy", "root"], cwd);
|
|
104
|
+
|
|
105
|
+
return root?.trim() ?? null;
|
|
106
|
+
},
|
|
107
|
+
captureWorkingDiff(repoRoot) {
|
|
108
|
+
return captureJjRevision(repoRoot, "@");
|
|
109
|
+
},
|
|
110
|
+
async captureChange(repoRoot, changeId) {
|
|
111
|
+
if (!/^[a-z]{32}$/.test(changeId)) throw new Error(`Jujutsu change ID is invalid: ${changeId}`);
|
|
112
|
+
if ((await jj(["status"], repoRoot)) === null)
|
|
113
|
+
throw new Error("Jujutsu diff capture failed: could not snapshot the working copy");
|
|
114
|
+
|
|
115
|
+
return captureJjRevision(repoRoot, changeId);
|
|
116
|
+
},
|
|
117
|
+
async listChanges(repoRoot) {
|
|
118
|
+
const summary = await jj(["diff", "-T", JJ_DIFF_FILES_TEMPLATE, "-r", "@"], repoRoot);
|
|
119
|
+
|
|
120
|
+
if (summary === null) throw new Error("Jujutsu changes list failed");
|
|
121
|
+
|
|
122
|
+
return jjSummary(summary).map(({ path, status }) => ({ path, status }));
|
|
123
|
+
},
|
|
124
|
+
async listFiles(repoRoot) {
|
|
125
|
+
const files = await jj(["file", "list", "-T", JJ_FILE_LIST_TEMPLATE, "-r", "@"], repoRoot);
|
|
126
|
+
|
|
127
|
+
if (files === null) throw new Error("Jujutsu project files failed");
|
|
128
|
+
|
|
129
|
+
return files.split("\0").filter(Boolean).toSorted().slice(0, 5000);
|
|
130
|
+
},
|
|
131
|
+
};
|
package/src/main.ts
CHANGED
|
@@ -2,9 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
import { DaemonServer } from "./server";
|
|
4
4
|
|
|
5
|
-
const idleExitMs = process.env.CUELOOP_IDLE_EXIT_MS
|
|
5
|
+
const idleExitMs = process.env.CUELOOP_IDLE_EXIT_MS
|
|
6
|
+
? Number(process.env.CUELOOP_IDLE_EXIT_MS)
|
|
7
|
+
: undefined;
|
|
6
8
|
const server = new DaemonServer({ idleExitMs });
|
|
7
9
|
const path = server.start();
|
|
10
|
+
|
|
8
11
|
if (path === null) {
|
|
9
12
|
// another daemon already owns this home; the client will attach to it
|
|
10
13
|
console.log("cueloop daemon already running for this home");
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { DaemonCore } from "./api";
|
|
6
|
+
|
|
7
|
+
test("send message operation receipts survive restart and reject changed payloads", () => {
|
|
8
|
+
const home = mkdtempSync(join(tmpdir(), "cueloop-operation-"));
|
|
9
|
+
let core = new DaemonCore(home);
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
const thread = core.sessionCreate({
|
|
13
|
+
workspace: { repoRoot: home, branch: "main" },
|
|
14
|
+
artifact: { type: "plan", content: "Review this", meta: {} },
|
|
15
|
+
});
|
|
16
|
+
const accepted = core.sessionSendMessage(thread.id, "comment", "First", undefined, "op-first");
|
|
17
|
+
const message = structuredClone(accepted.message);
|
|
18
|
+
|
|
19
|
+
core.sessionSendMessage(thread.id, "comment", "Second", undefined, "op-second");
|
|
20
|
+
core.dispose();
|
|
21
|
+
core = new DaemonCore(home);
|
|
22
|
+
const replay = core.sessionSendMessage(thread.id, "comment", "First", undefined, "op-first");
|
|
23
|
+
|
|
24
|
+
expect(replay.message).toEqual(message);
|
|
25
|
+
expect(
|
|
26
|
+
core.sessionGet(thread.id).history?.entries.filter((entry) => entry.type === "message"),
|
|
27
|
+
).toHaveLength(2);
|
|
28
|
+
expect(() =>
|
|
29
|
+
core.sessionSendMessage(thread.id, "comment", "Changed", undefined, "op-first"),
|
|
30
|
+
).toThrow("Operation payload conflict");
|
|
31
|
+
} finally {
|
|
32
|
+
core.dispose();
|
|
33
|
+
rmSync(home, { recursive: true, force: true });
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("receipt capacity never evicts accepted IDs and share blobs omit private receipts", async () => {
|
|
38
|
+
const { packSessionBlob, unpackSessionBlob } = await import("./share-blob");
|
|
39
|
+
const home = mkdtempSync(join(tmpdir(), "cueloop-operation-capacity-"));
|
|
40
|
+
const core = new DaemonCore(home);
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const thread = core.sessionCreate({
|
|
44
|
+
workspace: { repoRoot: home, branch: "main" },
|
|
45
|
+
artifact: { type: "plan", content: "Review", meta: {} },
|
|
46
|
+
});
|
|
47
|
+
const first = structuredClone(
|
|
48
|
+
core.sessionSendMessage(thread.id, "comment", "First", undefined, "first").message,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
for (let index = 1; index < 128; index++)
|
|
52
|
+
core.sessionSendMessage(
|
|
53
|
+
thread.id,
|
|
54
|
+
"comment",
|
|
55
|
+
`Message ${index}`,
|
|
56
|
+
undefined,
|
|
57
|
+
`operation-${index}`,
|
|
58
|
+
);
|
|
59
|
+
expect(() =>
|
|
60
|
+
core.sessionSendMessage(thread.id, "comment", "Overflow", undefined, "overflow"),
|
|
61
|
+
).toThrow("Operation receipt capacity");
|
|
62
|
+
expect(
|
|
63
|
+
core.sessionSendMessage(thread.id, "comment", "First", undefined, "first").message,
|
|
64
|
+
).toEqual(first);
|
|
65
|
+
expect(
|
|
66
|
+
unpackSessionBlob(packSessionBlob(core.sessionGet(thread.id))).messageOperations,
|
|
67
|
+
).toBeUndefined();
|
|
68
|
+
} finally {
|
|
69
|
+
core.dispose();
|
|
70
|
+
rmSync(home, { recursive: true, force: true });
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("failed receipt persistence never acknowledges an in-memory-only review message", async () => {
|
|
75
|
+
const { mkdirSync, readdirSync } = await import("node:fs");
|
|
76
|
+
const { threadsDir } = await import("./paths");
|
|
77
|
+
const home = mkdtempSync(join(tmpdir(), "cueloop-operation-disk-failure-"));
|
|
78
|
+
const core = new DaemonCore(home);
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const thread = core.sessionCreate({
|
|
82
|
+
workspace: { repoRoot: home, branch: "main" },
|
|
83
|
+
artifact: { type: "plan", content: "Review", meta: {} },
|
|
84
|
+
});
|
|
85
|
+
const bucket = readdirSync(threadsDir(home))[0]!;
|
|
86
|
+
const tempPath = join(threadsDir(home), bucket, `${thread.id}.jsonl.tmp`);
|
|
87
|
+
|
|
88
|
+
mkdirSync(tempPath);
|
|
89
|
+
expect(() =>
|
|
90
|
+
core.sessionSendMessage(thread.id, "comment", "Once", undefined, "persist-once"),
|
|
91
|
+
).toThrow();
|
|
92
|
+
expect(core.sessionGet(thread.id).messageOperations).toBeUndefined();
|
|
93
|
+
expect(core.sessionGet(thread.id).message).toBeNull();
|
|
94
|
+
rmSync(tempPath, { recursive: true });
|
|
95
|
+
const accepted = core.sessionSendMessage(
|
|
96
|
+
thread.id,
|
|
97
|
+
"comment",
|
|
98
|
+
"Once",
|
|
99
|
+
undefined,
|
|
100
|
+
"persist-once",
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
expect(accepted.messageOperations).toHaveLength(1);
|
|
104
|
+
expect(
|
|
105
|
+
core.sessionSendMessage(thread.id, "comment", "Once", undefined, "persist-once").message,
|
|
106
|
+
).toEqual(accepted.message);
|
|
107
|
+
} finally {
|
|
108
|
+
core.dispose();
|
|
109
|
+
rmSync(home, { recursive: true, force: true });
|
|
110
|
+
}
|
|
111
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { ThreadAgentState, OperationReceipt, AgentPromptRequest } from "@cueloop/schema";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { DaemonError } from "./errors";
|
|
4
|
+
|
|
5
|
+
/** Hash normalized payloads rather than persisting another copy of private input. */
|
|
6
|
+
export function operationFingerprint(
|
|
7
|
+
payload: readonly (string | null | readonly (readonly [string, string])[])[],
|
|
8
|
+
): string {
|
|
9
|
+
return createHash("sha256").update(JSON.stringify(payload)).digest("hex");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** A duplicate returns its original acceptance even after later operations or restart. */
|
|
13
|
+
export function findOperationReceipt<Result>(
|
|
14
|
+
receipts: readonly OperationReceipt<Result>[] | undefined,
|
|
15
|
+
operationId: string | undefined,
|
|
16
|
+
fingerprint: string,
|
|
17
|
+
): OperationReceipt<Result> | undefined {
|
|
18
|
+
if (!operationId) return;
|
|
19
|
+
const receipt = receipts?.find((entry) => entry.operationId === operationId);
|
|
20
|
+
|
|
21
|
+
if (receipt && receipt.fingerprint !== fingerprint)
|
|
22
|
+
throw new DaemonError(
|
|
23
|
+
"operation_conflict",
|
|
24
|
+
"Operation payload conflict: use a new operation ID for changed input",
|
|
25
|
+
);
|
|
26
|
+
if (!receipt && (receipts?.length ?? 0) >= 128)
|
|
27
|
+
throw new DaemonError(
|
|
28
|
+
"operation_capacity",
|
|
29
|
+
"Operation receipt capacity: create a new Thread before submitting more operations",
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
return receipt;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Each accepted prompt freezes the original batch before it can start a harness turn. */
|
|
36
|
+
export function recordPromptOperation(
|
|
37
|
+
state: ThreadAgentState,
|
|
38
|
+
before: ThreadAgentState,
|
|
39
|
+
input: AgentPromptRequest,
|
|
40
|
+
): void {
|
|
41
|
+
if (!input.operationId) return;
|
|
42
|
+
const previous = new Set(before.submissions?.map((entry) => entry.id));
|
|
43
|
+
const accepted = input.retry
|
|
44
|
+
? [input.retry]
|
|
45
|
+
: (state.submissions ?? []).filter((entry) => !previous.has(entry.id)).map((entry) => entry.id);
|
|
46
|
+
|
|
47
|
+
if (!accepted.length) throw new DaemonError("invalid_params", "Agent operation requires input");
|
|
48
|
+
const fingerprint = promptOperationFingerprint(input);
|
|
49
|
+
|
|
50
|
+
(state.promptOperations ??= []).push({
|
|
51
|
+
operationId: input.operationId,
|
|
52
|
+
fingerprint,
|
|
53
|
+
result: accepted,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Completion is an immutable fact about acceptance, including across explicit retries. */
|
|
58
|
+
export function settlePromptOperations(state: ThreadAgentState): void {
|
|
59
|
+
for (const receipt of state.promptOperations ?? []) {
|
|
60
|
+
if (receipt.outcome || !receipt.result.length) continue;
|
|
61
|
+
const submissions = receipt.result.map((id) =>
|
|
62
|
+
state.submissions?.find((entry) => entry.id === id),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
if (
|
|
66
|
+
submissions.some((entry) => !entry || entry.status === "queued" || entry.status === "running")
|
|
67
|
+
)
|
|
68
|
+
continue;
|
|
69
|
+
receipt.outcome = submissions.some((entry) => entry?.cancelled)
|
|
70
|
+
? "cancelled"
|
|
71
|
+
: submissions.some((entry) => entry?.status === "failed")
|
|
72
|
+
? "failed"
|
|
73
|
+
: "completed";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Prompt identity hashes only explicit input; a retry does not consume newly pending comments. */
|
|
78
|
+
export function findPromptOperationReceipt(
|
|
79
|
+
state: ThreadAgentState,
|
|
80
|
+
input: AgentPromptRequest,
|
|
81
|
+
): OperationReceipt<string[]> | undefined {
|
|
82
|
+
return findOperationReceipt(
|
|
83
|
+
state.promptOperations,
|
|
84
|
+
input.operationId,
|
|
85
|
+
promptOperationFingerprint(input),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function promptOperationFingerprint(input: AgentPromptRequest): string {
|
|
90
|
+
return operationFingerprint([input.text, input.context ?? null, input.retry ?? null]);
|
|
91
|
+
}
|
package/src/paths.ts
CHANGED
|
@@ -18,6 +18,35 @@ export function sessionsDir(home = cueloopHome()): string {
|
|
|
18
18
|
return join(home, "sessions");
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/** Thread files live here, one JSONL per thread, under a per-project bucket. */
|
|
22
|
+
export function threadsDir(home = cueloopHome()): string {
|
|
23
|
+
return join(home, "threads");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A git commit is lowercase hex; anything else is not a real root commit. */
|
|
27
|
+
const COMMIT_SHA = /^[0-9a-f]{7,64}$/;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The per-project bucket for a workspace: its durable root-commit SHA, or a shared standalone bucket.
|
|
31
|
+
* A value that is not a commit SHA (including path-traversal segments an IPC caller could smuggle in)
|
|
32
|
+
* is refused to the standalone bucket, so a bucket name can never escape the threads directory.
|
|
33
|
+
*/
|
|
34
|
+
export function threadBucket(rootCommit: string | undefined, home = cueloopHome()): string {
|
|
35
|
+
const bucket =
|
|
36
|
+
rootCommit && COMMIT_SHA.test(rootCommit) ? rootCommit.slice(0, 12) : "_standalone";
|
|
37
|
+
|
|
38
|
+
return join(threadsDir(home), bucket);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Where the one-time migration parks the old whole-record JSON files, so a downgrade can still read them. */
|
|
42
|
+
export function migratedSessionsDir(home = cueloopHome()): string {
|
|
43
|
+
return join(home, "sessions.migrated");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function reportsDir(home = cueloopHome()): string {
|
|
47
|
+
return join(home, "reports");
|
|
48
|
+
}
|
|
49
|
+
|
|
21
50
|
export function pidPath(home = cueloopHome()): string {
|
|
22
51
|
return join(home, "cueloop.pid");
|
|
23
52
|
}
|
|
@@ -25,3 +54,23 @@ export function pidPath(home = cueloopHome()): string {
|
|
|
25
54
|
export function lockPath(home = cueloopHome()): string {
|
|
26
55
|
return join(home, "cueloop.lock");
|
|
27
56
|
}
|
|
57
|
+
|
|
58
|
+
/** The secret a connection presents to be the owner; written by the daemon, mode 0600. */
|
|
59
|
+
export function ownerTokenPath(home = cueloopHome()): string {
|
|
60
|
+
return join(home, "owner.token");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Adapter scratch for Herdr handles; the disk name stays stable across tab and pane support. */
|
|
64
|
+
export function herdrThreadSurfacesPath(home = cueloopHome()): string {
|
|
65
|
+
return join(home, "herdr-tabs.json");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Ghostty-native terminal IDs are adapter scratch, not Thread state. */
|
|
69
|
+
export function ghosttyThreadSurfacesPath(home = cueloopHome()): string {
|
|
70
|
+
return join(home, "ghostty-thread-surfaces.json");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Harness bindings and Message deliveries, stored independently of Thread records. */
|
|
74
|
+
export function harnessStatePath(home = cueloopHome()): string {
|
|
75
|
+
return join(home, "harness-state.json");
|
|
76
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { PrReviewPoller } from "./pr-poller";
|
|
3
|
+
|
|
4
|
+
/** Build a poller whose head sha is controllable, with the timer effectively disabled. */
|
|
5
|
+
function refs(baseSha: string, headSha: string) {
|
|
6
|
+
return { baseSha, headSha };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function makePoller(initialRefs: ReturnType<typeof refs> | null) {
|
|
10
|
+
let currentRefs = initialRefs;
|
|
11
|
+
const advanced: Array<{ sessionId: string; refs: ReturnType<typeof refs> }> = [];
|
|
12
|
+
const poller = new PrReviewPoller(
|
|
13
|
+
(sessionId, nextRefs) => advanced.push({ sessionId, refs: nextRefs }),
|
|
14
|
+
async () => currentRefs,
|
|
15
|
+
1_000_000,
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
poller,
|
|
20
|
+
advanced,
|
|
21
|
+
setRefs: (next: ReturnType<typeof refs> | null) => {
|
|
22
|
+
currentRefs = next;
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("PrReviewPoller", () => {
|
|
28
|
+
test("the first observation is a silent baseline", async () => {
|
|
29
|
+
const { poller, advanced } = makePoller(refs("base1", "sha1"));
|
|
30
|
+
|
|
31
|
+
poller.trackPr("s1", "org/repo#1");
|
|
32
|
+
await poller.refreshHead("s1");
|
|
33
|
+
|
|
34
|
+
expect(advanced).toEqual([]);
|
|
35
|
+
poller.close();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("a moved head fires once; an unchanged head stays quiet", async () => {
|
|
39
|
+
const { poller, advanced, setRefs } = makePoller(refs("base1", "sha1"));
|
|
40
|
+
|
|
41
|
+
poller.trackPr("s1", "org/repo#1");
|
|
42
|
+
await poller.refreshHead("s1");
|
|
43
|
+
|
|
44
|
+
setRefs(refs("base1", "sha2"));
|
|
45
|
+
await poller.refreshHead("s1");
|
|
46
|
+
await poller.refreshHead("s1");
|
|
47
|
+
|
|
48
|
+
expect(advanced).toEqual([{ sessionId: "s1", refs: refs("base1", "sha2") }]);
|
|
49
|
+
poller.close();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("a persisted reviewed head exposes a move on the first check after restart", async () => {
|
|
53
|
+
const { poller, advanced } = makePoller(refs("base1", "sha2"));
|
|
54
|
+
|
|
55
|
+
poller.trackPr("s1", "https://github.com/org/repo/pull/1", refs("base1", "sha1"));
|
|
56
|
+
await poller.refreshHead("s1");
|
|
57
|
+
|
|
58
|
+
expect(advanced).toEqual([{ sessionId: "s1", refs: refs("base1", "sha2") }]);
|
|
59
|
+
poller.close();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("a failed head read never fires and keeps the last baseline", async () => {
|
|
63
|
+
const { poller, advanced, setRefs } = makePoller(refs("base1", "sha1"));
|
|
64
|
+
|
|
65
|
+
poller.trackPr("s1", "org/repo#1");
|
|
66
|
+
await poller.refreshHead("s1");
|
|
67
|
+
|
|
68
|
+
setRefs(null);
|
|
69
|
+
await poller.refreshHead("s1");
|
|
70
|
+
setRefs(refs("base1", "sha2"));
|
|
71
|
+
await poller.refreshHead("s1");
|
|
72
|
+
|
|
73
|
+
expect(advanced).toEqual([{ sessionId: "s1", refs: refs("base1", "sha2") }]);
|
|
74
|
+
poller.close();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("untrack stops further checks", async () => {
|
|
78
|
+
const { poller, advanced, setRefs } = makePoller(refs("base1", "sha1"));
|
|
79
|
+
|
|
80
|
+
poller.trackPr("s1", "org/repo#1");
|
|
81
|
+
await poller.refreshHead("s1");
|
|
82
|
+
poller.untrackPr("s1");
|
|
83
|
+
|
|
84
|
+
setRefs(refs("base1", "sha2"));
|
|
85
|
+
await poller.refreshHead("s1");
|
|
86
|
+
|
|
87
|
+
expect(advanced).toEqual([]);
|
|
88
|
+
poller.close();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("a moved base fires while the head stays unchanged", async () => {
|
|
92
|
+
const { poller, advanced, setRefs } = makePoller(refs("base1", "sha1"));
|
|
93
|
+
|
|
94
|
+
poller.trackPr("s1", "org/repo#1");
|
|
95
|
+
await poller.refreshHead("s1");
|
|
96
|
+
setRefs(refs("base2", "sha1"));
|
|
97
|
+
await poller.refreshHead("s1");
|
|
98
|
+
|
|
99
|
+
expect(advanced).toEqual([{ sessionId: "s1", refs: refs("base2", "sha1") }]);
|
|
100
|
+
poller.close();
|
|
101
|
+
});
|
|
102
|
+
});
|
package/src/pr-poller.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Polls each open PR review for new commits. A remote PR has no push signal, so
|
|
3
|
+
* this checks its base/head pair on an interval and only exposes refresh when
|
|
4
|
+
* either side moved. Best-effort: a missing or failing gh keeps the last refs.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { prRefs, type PullRequestRefs } from "./gh";
|
|
8
|
+
|
|
9
|
+
/** One cheap refs check per interval; the expensive full-diff pull happens only on a move. */
|
|
10
|
+
const PR_POLL_INTERVAL_MS = 30_000;
|
|
11
|
+
|
|
12
|
+
interface PrPoll {
|
|
13
|
+
pr: string;
|
|
14
|
+
lastRefs: PullRequestRefs | null;
|
|
15
|
+
timer: ReturnType<typeof setInterval>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class PrReviewPoller {
|
|
19
|
+
private readonly polls = new Map<string, PrPoll>();
|
|
20
|
+
|
|
21
|
+
constructor(
|
|
22
|
+
private readonly onPrAdvance: (sessionId: string, refs: PullRequestRefs) => void,
|
|
23
|
+
private readonly refs: (pr: string) => Promise<PullRequestRefs | null> = prRefs,
|
|
24
|
+
private readonly intervalMs: number = PR_POLL_INTERVAL_MS,
|
|
25
|
+
) {}
|
|
26
|
+
|
|
27
|
+
/** Start polling a PR review. Idempotent per session; the persisted reviewed head is the baseline. */
|
|
28
|
+
trackPr(sessionId: string, pr: string, reviewedRefs: PullRequestRefs | null = null): void {
|
|
29
|
+
if (this.polls.has(sessionId)) return;
|
|
30
|
+
const timer = setInterval(() => void this.refreshHead(sessionId), this.intervalMs);
|
|
31
|
+
|
|
32
|
+
// the poll timer must not by itself keep the daemon alive against idle-exit
|
|
33
|
+
timer.unref?.();
|
|
34
|
+
this.polls.set(sessionId, { pr, lastRefs: reviewedRefs, timer });
|
|
35
|
+
void this.refreshHead(sessionId);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Check the PR refs once; fire onPrAdvance only when they moved off a known baseline. */
|
|
39
|
+
async refreshHead(sessionId: string): Promise<void> {
|
|
40
|
+
const poll = this.polls.get(sessionId);
|
|
41
|
+
|
|
42
|
+
if (!poll) return;
|
|
43
|
+
const refs = await this.refs(poll.pr).catch(() => null);
|
|
44
|
+
|
|
45
|
+
if (refs === null) return;
|
|
46
|
+
// the await yields; re-read so an untrack during the call is honoured
|
|
47
|
+
const current = this.polls.get(sessionId);
|
|
48
|
+
|
|
49
|
+
if (!current) return;
|
|
50
|
+
if (current.lastRefs === null) {
|
|
51
|
+
current.lastRefs = refs;
|
|
52
|
+
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (refs.baseSha === current.lastRefs.baseSha && refs.headSha === current.lastRefs.headSha)
|
|
56
|
+
return;
|
|
57
|
+
current.lastRefs = refs;
|
|
58
|
+
this.onPrAdvance(sessionId, refs);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
untrackPr(sessionId: string): void {
|
|
62
|
+
const poll = this.polls.get(sessionId);
|
|
63
|
+
|
|
64
|
+
if (!poll) return;
|
|
65
|
+
clearInterval(poll.timer);
|
|
66
|
+
this.polls.delete(sessionId);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
close(): void {
|
|
70
|
+
for (const poll of this.polls.values()) clearInterval(poll.timer);
|
|
71
|
+
this.polls.clear();
|
|
72
|
+
}
|
|
73
|
+
}
|