@cueloop/daemon 0.1.0-alpha.9 → 0.1.0-alpha.90
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 +39 -22
- package/src/api.test.ts +1060 -65
- package/src/api.ts +1398 -89
- 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 +142 -0
- package/src/diff-hot-reload.test.ts +419 -0
- package/src/diff-watcher.ts +293 -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 +268 -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 +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/jj-working-tree.ts +131 -0
- package/src/main.ts +4 -1
- 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 +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 +453 -42
- package/src/server.ts +433 -88
- 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 +483 -0
- package/src/thread-review.ts +398 -0
- package/src/thread-surface-config.test.ts +63 -0
- package/src/thread-surface-config.ts +61 -0
- package/src/validate.test.ts +282 -37
- package/src/validate.ts +398 -33
- package/src/vcs-source.test.ts +180 -0
- package/src/vcs-source.ts +228 -0
- 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
package/src/review.test.ts
DELETED
|
@@ -1,167 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The shared review core against a real DaemonServer in a temp home:
|
|
3
|
-
* workspace resolution, title derivation, open-or-revise by agentSessionId,
|
|
4
|
-
* and both awaitVerdict shapes (one long-poll and the chunked loop with
|
|
5
|
-
* progress and abort).
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
9
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
10
|
-
import { tmpdir } from "node:os";
|
|
11
|
-
import { join } from "node:path";
|
|
12
|
-
import type { ReviewSession } from "@cueloop/schema";
|
|
13
|
-
import { DaemonServer } from "./server";
|
|
14
|
-
import { DaemonClient } from "./client";
|
|
15
|
-
import { openReview, resolveWorkspace } from "./review";
|
|
16
|
-
|
|
17
|
-
const PLAN = "# Rollout Plan\n\nShip it in two stages.\n";
|
|
18
|
-
|
|
19
|
-
let home: string;
|
|
20
|
-
let server: DaemonServer;
|
|
21
|
-
let client: DaemonClient;
|
|
22
|
-
|
|
23
|
-
beforeEach(async () => {
|
|
24
|
-
home = mkdtempSync(join(tmpdir(), "cueloop-review-core-"));
|
|
25
|
-
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
26
|
-
server.start();
|
|
27
|
-
client = await DaemonClient.connect({ home });
|
|
28
|
-
});
|
|
29
|
-
afterEach(() => {
|
|
30
|
-
client.close();
|
|
31
|
-
server.stop();
|
|
32
|
-
rmSync(home, { recursive: true, force: true });
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
function sh(args: string[], cwd: string): void {
|
|
36
|
-
const p = Bun.spawnSync(args, { cwd, stdout: "ignore", stderr: "ignore" });
|
|
37
|
-
if (p.exitCode !== 0) throw new Error(`${args.join(" ")} failed`);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
describe("resolveWorkspace", () => {
|
|
41
|
-
test("a git repo resolves to its root and branch", async () => {
|
|
42
|
-
const repo = mkdtempSync(join(tmpdir(), "cueloop-ws-"));
|
|
43
|
-
try {
|
|
44
|
-
sh(["git", "init", "-q", "-b", "main"], repo);
|
|
45
|
-
sh(["git", "config", "user.email", "t@t"], repo);
|
|
46
|
-
sh(["git", "config", "user.name", "t"], repo);
|
|
47
|
-
sh(["git", "commit", "-q", "--allow-empty", "-m", "init"], repo);
|
|
48
|
-
const ws = await resolveWorkspace(repo);
|
|
49
|
-
expect(ws.branch).toBe("main");
|
|
50
|
-
// macOS tmpdirs can resolve through /private; compare the tail
|
|
51
|
-
expect(ws.repoRoot.endsWith(repo.split("/").at(-1)!)).toBe(true);
|
|
52
|
-
} finally {
|
|
53
|
-
rmSync(repo, { recursive: true, force: true });
|
|
54
|
-
}
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
test("outside a repo the cwd itself is the workspace, branch detached", async () => {
|
|
58
|
-
const ws = await resolveWorkspace(home);
|
|
59
|
-
expect(ws).toEqual({ repoRoot: home, branch: "detached" });
|
|
60
|
-
});
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
describe("openReview", () => {
|
|
64
|
-
test("shapes the artifact: agent, derived title, cwd", async () => {
|
|
65
|
-
const review = await openReview(client, { type: "plan", content: PLAN, cwd: home, agent: "test-agent" });
|
|
66
|
-
const s = review.session;
|
|
67
|
-
expect(s.status).toBe("pending");
|
|
68
|
-
expect(s.artifact.type).toBe("plan");
|
|
69
|
-
expect(s.artifact.meta.agent).toBe("test-agent");
|
|
70
|
-
expect(s.artifact.meta.title).toBe("Rollout Plan");
|
|
71
|
-
expect(s.artifact.meta.cwd).toBe(home);
|
|
72
|
-
expect(s.workspace).toEqual({ repoRoot: home, branch: "detached" });
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
test("an explicit title wins; diffs never derive one from content", async () => {
|
|
76
|
-
const titled = await openReview(client, { type: "plan", content: PLAN, cwd: home, title: "Custom" });
|
|
77
|
-
expect(titled.session.artifact.meta.title).toBe("Custom");
|
|
78
|
-
const diff = await openReview(client, { type: "diff", content: "# not a heading\n", cwd: home });
|
|
79
|
-
expect(diff.session.artifact.meta.title).toBeUndefined();
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
test("a pre-resolved workspace skips git resolution", async () => {
|
|
83
|
-
const ws = { repoRoot: "/elsewhere", branch: "feature" };
|
|
84
|
-
const review = await openReview(client, { type: "diff", content: "d", cwd: home, workspace: ws });
|
|
85
|
-
expect(review.session.workspace).toEqual(ws);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
test("the same agentSessionId revises instead of opening a new session", async () => {
|
|
89
|
-
const first = await openReview(client, { type: "plan", content: PLAN, cwd: home, agentSessionId: "agent-1" });
|
|
90
|
-
const second = await openReview(client, {
|
|
91
|
-
type: "plan",
|
|
92
|
-
content: PLAN + "\nStage three.\n",
|
|
93
|
-
cwd: home,
|
|
94
|
-
agentSessionId: "agent-1",
|
|
95
|
-
});
|
|
96
|
-
expect(second.id).toBe(first.id);
|
|
97
|
-
expect(second.session.revisions.length).toBe(2);
|
|
98
|
-
expect(second.session.status).toBe("pending");
|
|
99
|
-
const other = await openReview(client, { type: "plan", content: PLAN, cwd: home, agentSessionId: "agent-2" });
|
|
100
|
-
expect(other.id).not.toBe(first.id);
|
|
101
|
-
});
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
describe("awaitVerdict: one long-poll (the hook shape)", () => {
|
|
105
|
-
test("times out to pending; a later wait collects the stored verdict", async () => {
|
|
106
|
-
const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
|
|
107
|
-
expect(await review.awaitVerdict({ timeoutMs: 50 })).toBe("pending");
|
|
108
|
-
await client.sessionResolve(review.id, "approve", "Fine.");
|
|
109
|
-
const verdict = await review.awaitVerdict({ timeoutMs: 1_000 });
|
|
110
|
-
expect(verdict).not.toBe("pending");
|
|
111
|
-
if (verdict === "pending") throw new Error("unreachable");
|
|
112
|
-
expect(verdict.allow).toBe(true);
|
|
113
|
-
expect(verdict.feedback).toContain("Fine.");
|
|
114
|
-
expect(verdict.session.verdict!.kind).toBe("approve");
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
test("request_changes maps to allow=false with feedback.md attached", async () => {
|
|
118
|
-
const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
|
|
119
|
-
const waiting = review.awaitVerdict({ timeoutMs: 5_000 });
|
|
120
|
-
await client.sessionResolve(review.id, "request_changes", "One stage only.");
|
|
121
|
-
const verdict = await waiting;
|
|
122
|
-
if (verdict === "pending") throw new Error("expected a verdict");
|
|
123
|
-
expect(verdict.allow).toBe(false);
|
|
124
|
-
expect(verdict.feedback).toContain("# Review: request changes");
|
|
125
|
-
expect(verdict.feedback).toContain("One stage only.");
|
|
126
|
-
});
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
describe("awaitVerdict: chunked loop (the pi shape)", () => {
|
|
130
|
-
test("onProgress sees fresh sessions between chunks; the verdict ends the loop", async () => {
|
|
131
|
-
const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
|
|
132
|
-
const seen: ReviewSession[] = [];
|
|
133
|
-
const waiting = review.awaitVerdict({
|
|
134
|
-
timeoutMs: Infinity,
|
|
135
|
-
pollMs: 100,
|
|
136
|
-
onProgress: (s) => seen.push(s),
|
|
137
|
-
});
|
|
138
|
-
await client.sessionAnnotate(review.id, {
|
|
139
|
-
id: "a1",
|
|
140
|
-
kind: "comment",
|
|
141
|
-
anchor: { quote: "two stages", prefix: "in ", suffix: "." },
|
|
142
|
-
body: "Name them.",
|
|
143
|
-
});
|
|
144
|
-
// give the loop a chunk to observe the annotation before resolving
|
|
145
|
-
for (let i = 0; i < 100 && !seen.some((s) => s.annotations.length === 1); i++) await Bun.sleep(20);
|
|
146
|
-
expect(seen.some((s) => s.annotations.length === 1)).toBe(true);
|
|
147
|
-
await client.sessionResolve(review.id, "request_changes", "Too vague.");
|
|
148
|
-
const verdict = await waiting;
|
|
149
|
-
if (verdict === "pending") throw new Error("expected a verdict");
|
|
150
|
-
expect(verdict.allow).toBe(false);
|
|
151
|
-
expect(verdict.session.annotations.length).toBe(1);
|
|
152
|
-
}, 15_000);
|
|
153
|
-
|
|
154
|
-
test("abort surfaces as pending and leaves the session collectable", async () => {
|
|
155
|
-
const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
|
|
156
|
-
const controller = new AbortController();
|
|
157
|
-
const waiting = review.awaitVerdict({ timeoutMs: Infinity, pollMs: 100, signal: controller.signal });
|
|
158
|
-
controller.abort();
|
|
159
|
-
expect(await waiting).toBe("pending");
|
|
160
|
-
expect((await client.sessionGet(review.id)).status).toBe("pending");
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
test("a finite budget runs out to pending", async () => {
|
|
164
|
-
const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
|
|
165
|
-
expect(await review.awaitVerdict({ timeoutMs: 120, pollMs: 50 })).toBe("pending");
|
|
166
|
-
});
|
|
167
|
-
});
|
package/src/review.ts
DELETED
|
@@ -1,174 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The shared review core: one open/wait/verdict path for every consumer -
|
|
3
|
-
* CLI commands and agent adapters. openReview resolves the workspace, shapes
|
|
4
|
-
* the artifact, and opens-or-revises by agentSessionId; awaitVerdict maps the
|
|
5
|
-
* wait contract (#14) onto the agent contract through verdictResponse. An
|
|
6
|
-
* adapter keeps only two bespoke parts: parsing its host's event shape and
|
|
7
|
-
* serializing the decision in its host's contract.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import type { ArtifactType, ReviewSession, WorkspaceKey } from "@cueloop/schema";
|
|
11
|
-
import { verdictResponse } from "./api";
|
|
12
|
-
import type { DaemonClient } from "./client";
|
|
13
|
-
|
|
14
|
-
// Adapters and CLI verbs reach the verdict mapping through this module too,
|
|
15
|
-
// so a session obtained outside a ReviewHandle maps the same way.
|
|
16
|
-
export { verdictResponse };
|
|
17
|
-
|
|
18
|
-
async function git(args: string[], cwd: string): Promise<string | null> {
|
|
19
|
-
try {
|
|
20
|
-
const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "ignore" });
|
|
21
|
-
const out = await new Response(proc.stdout).text();
|
|
22
|
-
if ((await proc.exited) !== 0) return null;
|
|
23
|
-
return out.trim();
|
|
24
|
-
} catch {
|
|
25
|
-
return null;
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** Workspace key resolution (#9): repo root + branch from the cwd. */
|
|
30
|
-
export async function resolveWorkspace(cwd = process.cwd()): Promise<WorkspaceKey> {
|
|
31
|
-
const repoRoot = (await git(["rev-parse", "--show-toplevel"], cwd)) ?? cwd;
|
|
32
|
-
const branch = (await git(["rev-parse", "--abbrev-ref", "HEAD"], cwd)) ?? "detached";
|
|
33
|
-
return { repoRoot, branch };
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function firstHeading(md: string): string | undefined {
|
|
37
|
-
const m = md.match(/^#\s+(.+)$/m);
|
|
38
|
-
return m?.[1]?.trim();
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export interface OpenReviewOptions {
|
|
42
|
-
type: ArtifactType;
|
|
43
|
-
content: string;
|
|
44
|
-
/** Workspace resolution root and meta.cwd; defaults to process.cwd(). */
|
|
45
|
-
cwd?: string;
|
|
46
|
-
/** Pre-resolved workspace key; skips git resolution when the caller already has it. */
|
|
47
|
-
workspace?: WorkspaceKey;
|
|
48
|
-
agent?: string;
|
|
49
|
-
/** When set, a resubmit from the same agent session becomes a revision, not a new session. */
|
|
50
|
-
agentSessionId?: string;
|
|
51
|
-
planPath?: string;
|
|
52
|
-
pr?: string;
|
|
53
|
-
herdrPane?: string;
|
|
54
|
-
/** Defaults to the plan's first markdown heading; diffs get no derived title. */
|
|
55
|
-
title?: string;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export interface AwaitVerdictOptions {
|
|
59
|
-
/** Total wait budget; Infinity keeps polling until resolved or aborted. */
|
|
60
|
-
timeoutMs: number;
|
|
61
|
-
/** Chunk length for the poll loop; between chunks the session is re-read for progress. */
|
|
62
|
-
pollMs?: number;
|
|
63
|
-
/** Called with the fresh session after each chunk that is still pending. */
|
|
64
|
-
onProgress?: (session: ReviewSession) => void;
|
|
65
|
-
signal?: AbortSignal;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** A resolved review mapped onto the agent contract, plus the full session. */
|
|
69
|
-
export interface VerdictOutcome {
|
|
70
|
-
allow: boolean;
|
|
71
|
-
feedback: string;
|
|
72
|
-
session: ReviewSession;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** Sentinel distinguishing an abort from any daemon response. */
|
|
76
|
-
const ABORTED = Symbol("aborted");
|
|
77
|
-
|
|
78
|
-
function raceAbort<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T | typeof ABORTED> {
|
|
79
|
-
if (!signal) return promise;
|
|
80
|
-
if (signal.aborted) {
|
|
81
|
-
promise.catch(() => {});
|
|
82
|
-
return Promise.resolve(ABORTED);
|
|
83
|
-
}
|
|
84
|
-
return new Promise<T | typeof ABORTED>((resolve, reject) => {
|
|
85
|
-
const onAbort = () => {
|
|
86
|
-
// The daemon request keeps running until the client closes; swallow its
|
|
87
|
-
// eventual rejection so the abort path never leaks an unhandled error.
|
|
88
|
-
promise.catch(() => {});
|
|
89
|
-
resolve(ABORTED);
|
|
90
|
-
};
|
|
91
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
92
|
-
promise.then(
|
|
93
|
-
(v) => {
|
|
94
|
-
signal.removeEventListener("abort", onAbort);
|
|
95
|
-
resolve(v);
|
|
96
|
-
},
|
|
97
|
-
(e) => {
|
|
98
|
-
signal.removeEventListener("abort", onAbort);
|
|
99
|
-
reject(e);
|
|
100
|
-
},
|
|
101
|
-
);
|
|
102
|
-
});
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
export class ReviewHandle {
|
|
106
|
-
constructor(
|
|
107
|
-
private readonly client: DaemonClient,
|
|
108
|
-
readonly session: ReviewSession,
|
|
109
|
-
) {}
|
|
110
|
-
|
|
111
|
-
get id(): string {
|
|
112
|
-
return this.session.id;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* Block on the verdict. "pending" means the budget ran out or the signal
|
|
117
|
-
* aborted - the session stays open and the verdict is collectable later
|
|
118
|
-
* (verdicts outlive waits, #14). With only timeoutMs this is one long-poll;
|
|
119
|
-
* pollMs/onProgress/signal switch to the chunked loop.
|
|
120
|
-
*/
|
|
121
|
-
async awaitVerdict(opts: AwaitVerdictOptions): Promise<VerdictOutcome | "pending"> {
|
|
122
|
-
const { timeoutMs, pollMs, onProgress, signal } = opts;
|
|
123
|
-
if (pollMs === undefined && onProgress === undefined && signal === undefined) {
|
|
124
|
-
const resolved = await this.client.sessionWait(this.session.id, timeoutMs);
|
|
125
|
-
return resolved === null ? "pending" : outcome(resolved);
|
|
126
|
-
}
|
|
127
|
-
const chunkMs = pollMs ?? 10_000;
|
|
128
|
-
const deadline = Number.isFinite(timeoutMs) ? Date.now() + timeoutMs : undefined;
|
|
129
|
-
for (;;) {
|
|
130
|
-
const budget = deadline === undefined ? chunkMs : Math.min(chunkMs, deadline - Date.now());
|
|
131
|
-
if (budget <= 0 || signal?.aborted) return "pending";
|
|
132
|
-
const resolved = await raceAbort(this.client.sessionWait(this.session.id, budget), signal);
|
|
133
|
-
if (resolved === ABORTED) return "pending";
|
|
134
|
-
if (resolved !== null) return outcome(resolved);
|
|
135
|
-
// Still pending after this chunk: re-read to surface reviewer progress.
|
|
136
|
-
const current = await raceAbort(this.client.sessionGet(this.session.id), signal);
|
|
137
|
-
if (current === ABORTED) return "pending";
|
|
138
|
-
onProgress?.(current);
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
function outcome(session: ReviewSession): VerdictOutcome {
|
|
144
|
-
return { ...verdictResponse(session), session };
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/** Open a review session (or revise the agent session's existing one) and hand back the wait surface. */
|
|
148
|
-
export async function openReview(client: DaemonClient, opts: OpenReviewOptions): Promise<ReviewHandle> {
|
|
149
|
-
const cwd = opts.cwd ?? process.cwd();
|
|
150
|
-
const workspace = opts.workspace ?? (await resolveWorkspace(cwd));
|
|
151
|
-
// Resubmits from the same agent session become revisions, not new sessions.
|
|
152
|
-
if (opts.agentSessionId !== undefined) {
|
|
153
|
-
const existing = (await client.sessionList()).find(
|
|
154
|
-
(s) => s.artifact.meta.agentSessionId === opts.agentSessionId,
|
|
155
|
-
);
|
|
156
|
-
if (existing !== undefined) {
|
|
157
|
-
return new ReviewHandle(client, await client.sessionSubmitRevision(existing.id, opts.content));
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
const session = await client.sessionCreate(workspace, {
|
|
161
|
-
type: opts.type,
|
|
162
|
-
content: opts.content,
|
|
163
|
-
meta: {
|
|
164
|
-
agent: opts.agent,
|
|
165
|
-
agentSessionId: opts.agentSessionId,
|
|
166
|
-
planPath: opts.planPath,
|
|
167
|
-
pr: opts.pr,
|
|
168
|
-
herdrPane: opts.herdrPane,
|
|
169
|
-
title: opts.title ?? (opts.type === "plan" ? firstHeading(opts.content) : undefined),
|
|
170
|
-
cwd,
|
|
171
|
-
},
|
|
172
|
-
});
|
|
173
|
-
return new ReviewHandle(client, session);
|
|
174
|
-
}
|