@cueloop/daemon 0.1.0-alpha.7 → 0.1.0-alpha.71
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 +33 -22
- package/src/api.test.ts +891 -50
- package/src/api.ts +849 -72
- package/src/capabilities.test.ts +57 -0
- package/src/capabilities.ts +77 -0
- package/src/client.test.ts +24 -0
- package/src/client.ts +459 -69
- package/src/curate.ts +109 -0
- package/src/daemon-version.test.ts +93 -0
- package/src/diff-hot-reload.test.ts +186 -0
- package/src/diff-watcher.ts +102 -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 +39 -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 +182 -0
- package/src/retention.ts +122 -0
- package/src/review.test.ts +255 -28
- package/src/review.ts +164 -62
- package/src/server.test.ts +381 -39
- package/src/server.ts +322 -84
- package/src/share-blob.test.ts +112 -0
- package/src/share-blob.ts +55 -0
- package/src/single-instance.test.ts +38 -11
- package/src/store.conformance.test.ts +41 -0
- package/src/store.jsonl.test.ts +159 -0
- package/src/store.ts +261 -34
- package/src/testing/store-conformance.ts +194 -0
- package/src/validate.test.ts +174 -28
- package/src/validate.ts +226 -16
- package/src/version.ts +6 -0
- package/src/working-tree.ts +149 -0
package/src/review.ts
CHANGED
|
@@ -2,16 +2,24 @@
|
|
|
2
2
|
* The shared review core: one open/wait/verdict path for every consumer -
|
|
3
3
|
* CLI commands and agent adapters. openReview resolves the workspace, shapes
|
|
4
4
|
* the artifact, and opens-or-revises by agentSessionId; awaitVerdict maps the
|
|
5
|
-
* wait contract
|
|
5
|
+
* wait contract onto the agent contract through verdictResponse. An
|
|
6
6
|
* adapter keeps only two bespoke parts: parsing its host's event shape and
|
|
7
7
|
* serializing the decision in its host's contract.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import
|
|
10
|
+
import {
|
|
11
|
+
isMarkdownArtifact,
|
|
12
|
+
newAnnotationId,
|
|
13
|
+
type ArtifactType,
|
|
14
|
+
type DiffFileContents,
|
|
15
|
+
type Thread,
|
|
16
|
+
type WorkspaceKey,
|
|
17
|
+
} from "@cueloop/schema";
|
|
11
18
|
import { verdictResponse } from "./api";
|
|
12
19
|
import type { DaemonClient } from "./client";
|
|
20
|
+
import { ABORTED, pollUntilResolved, raceAbort } from "./interruptible-wait";
|
|
13
21
|
|
|
14
|
-
// Adapters and CLI
|
|
22
|
+
// Adapters and CLI primitives reach the verdict mapping through this module too,
|
|
15
23
|
// so a session obtained outside a ReviewHandle maps the same way.
|
|
16
24
|
export { verdictResponse };
|
|
17
25
|
|
|
@@ -19,23 +27,48 @@ async function git(args: string[], cwd: string): Promise<string | null> {
|
|
|
19
27
|
try {
|
|
20
28
|
const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "ignore" });
|
|
21
29
|
const out = await new Response(proc.stdout).text();
|
|
30
|
+
|
|
22
31
|
if ((await proc.exited) !== 0) return null;
|
|
32
|
+
|
|
23
33
|
return out.trim();
|
|
24
34
|
} catch {
|
|
25
35
|
return null;
|
|
26
36
|
}
|
|
27
37
|
}
|
|
28
38
|
|
|
29
|
-
/**
|
|
39
|
+
/** The oldest root commit reachable from HEAD; the project key that survives moving or re-cloning the repo. */
|
|
40
|
+
function earliestRootCommit(revList: string | null): string | undefined {
|
|
41
|
+
if (!revList) return undefined;
|
|
42
|
+
const roots = revList.split("\n").filter((line) => line.length > 0);
|
|
43
|
+
|
|
44
|
+
// --date-order lists newest first, so the last root is the earliest
|
|
45
|
+
return roots.at(-1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Workspace key resolution: repo root, branch, and the project identity (root commit + remote) from the cwd. */
|
|
30
49
|
export async function resolveWorkspace(cwd = process.cwd()): Promise<WorkspaceKey> {
|
|
31
50
|
const repoRoot = (await git(["rev-parse", "--show-toplevel"], cwd)) ?? cwd;
|
|
32
51
|
const branch = (await git(["rev-parse", "--abbrev-ref", "HEAD"], cwd)) ?? "detached";
|
|
33
|
-
|
|
52
|
+
// a shallow clone's oldest commit is the graft boundary, not the true root, so
|
|
53
|
+
// it would key a different project than a full clone - leave it unset instead
|
|
54
|
+
const shallow = (await git(["rev-parse", "--is-shallow-repository"], cwd)) === "true";
|
|
55
|
+
const rootCommit = shallow
|
|
56
|
+
? undefined
|
|
57
|
+
: earliestRootCommit(await git(["rev-list", "--max-parents=0", "--date-order", "HEAD"], cwd));
|
|
58
|
+
const remote = await git(["remote", "get-url", "origin"], cwd);
|
|
59
|
+
|
|
60
|
+
const workspace: WorkspaceKey = { repoRoot, branch };
|
|
61
|
+
// a repo with no commits (or a shallow clone) has no reliable root, so the thread stays standalone
|
|
62
|
+
if (rootCommit) workspace.rootCommit = rootCommit;
|
|
63
|
+
if (remote) workspace.remote = remote;
|
|
64
|
+
|
|
65
|
+
return workspace;
|
|
34
66
|
}
|
|
35
67
|
|
|
36
|
-
function firstHeading(
|
|
37
|
-
const
|
|
38
|
-
|
|
68
|
+
function firstHeading(markdown: string): string | undefined {
|
|
69
|
+
const headingMatch = markdown.match(/^#\s+(.+)$/m);
|
|
70
|
+
|
|
71
|
+
return headingMatch?.[1]?.trim();
|
|
39
72
|
}
|
|
40
73
|
|
|
41
74
|
export interface OpenReviewOptions {
|
|
@@ -49,10 +82,45 @@ export interface OpenReviewOptions {
|
|
|
49
82
|
/** When set, a resubmit from the same agent session becomes a revision, not a new session. */
|
|
50
83
|
agentSessionId?: string;
|
|
51
84
|
planPath?: string;
|
|
85
|
+
prototypePath?: string;
|
|
52
86
|
pr?: string;
|
|
53
87
|
herdrPane?: string;
|
|
54
|
-
/**
|
|
88
|
+
/**
|
|
89
|
+
* Full file contents per changed file for a working-tree diff, carried onto
|
|
90
|
+
* the artifact so hunk curation produces an exactly applyable patch.
|
|
91
|
+
*/
|
|
92
|
+
files?: DiffFileContents[];
|
|
93
|
+
/** Defaults to a markdown artifact's first heading (plan, reply); diffs get no derived title. */
|
|
55
94
|
title?: string;
|
|
95
|
+
/**
|
|
96
|
+
* Per-file agent notes for diff sessions: the submitting agent's own
|
|
97
|
+
* explanation of each file's change, in dead prose. Stored as annotations
|
|
98
|
+
* with kind "note" anchored at the file path, so they render as regular
|
|
99
|
+
* rail cards and feed the guided walk's agent-note block.
|
|
100
|
+
*/
|
|
101
|
+
notes?: ReviewNote[];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The note contract: one note per changed file, anchored by the file path. */
|
|
105
|
+
export interface ReviewNote {
|
|
106
|
+
path: string;
|
|
107
|
+
body: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Anchor a note at its file: quote = the path, no context selectors. */
|
|
111
|
+
async function attachNotes(
|
|
112
|
+
client: DaemonClient,
|
|
113
|
+
sessionId: string,
|
|
114
|
+
notes: ReviewNote[],
|
|
115
|
+
): Promise<void> {
|
|
116
|
+
for (const note of notes) {
|
|
117
|
+
await client.sessionAnnotate(sessionId, {
|
|
118
|
+
id: newAnnotationId(),
|
|
119
|
+
kind: "note",
|
|
120
|
+
anchor: { quote: note.path, prefix: "", suffix: "" },
|
|
121
|
+
body: note.body,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
56
124
|
}
|
|
57
125
|
|
|
58
126
|
export interface AwaitVerdictOptions {
|
|
@@ -61,7 +129,7 @@ export interface AwaitVerdictOptions {
|
|
|
61
129
|
/** Chunk length for the poll loop; between chunks the session is re-read for progress. */
|
|
62
130
|
pollMs?: number;
|
|
63
131
|
/** Called with the fresh session after each chunk that is still pending. */
|
|
64
|
-
onProgress?: (session:
|
|
132
|
+
onProgress?: (session: Thread) => void;
|
|
65
133
|
signal?: AbortSignal;
|
|
66
134
|
}
|
|
67
135
|
|
|
@@ -69,43 +137,13 @@ export interface AwaitVerdictOptions {
|
|
|
69
137
|
export interface VerdictOutcome {
|
|
70
138
|
allow: boolean;
|
|
71
139
|
feedback: string;
|
|
72
|
-
session:
|
|
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
|
-
});
|
|
140
|
+
session: Thread;
|
|
103
141
|
}
|
|
104
142
|
|
|
105
143
|
export class ReviewHandle {
|
|
106
144
|
constructor(
|
|
107
145
|
private readonly client: DaemonClient,
|
|
108
|
-
readonly session:
|
|
146
|
+
readonly session: Thread,
|
|
109
147
|
) {}
|
|
110
148
|
|
|
111
149
|
get id(): string {
|
|
@@ -115,60 +153,124 @@ export class ReviewHandle {
|
|
|
115
153
|
/**
|
|
116
154
|
* Block on the verdict. "pending" means the budget ran out or the signal
|
|
117
155
|
* aborted - the session stays open and the verdict is collectable later
|
|
118
|
-
* (verdicts outlive waits
|
|
156
|
+
* (verdicts outlive waits). With only timeoutMs this is one long-poll;
|
|
119
157
|
* pollMs/onProgress/signal switch to the chunked loop.
|
|
120
158
|
*/
|
|
121
|
-
async awaitVerdict(
|
|
122
|
-
const { timeoutMs, pollMs, onProgress, signal } =
|
|
159
|
+
async awaitVerdict(options: AwaitVerdictOptions): Promise<VerdictOutcome | "pending"> {
|
|
160
|
+
const { timeoutMs, pollMs, onProgress, signal } = options;
|
|
161
|
+
|
|
123
162
|
if (pollMs === undefined && onProgress === undefined && signal === undefined) {
|
|
124
163
|
const resolved = await this.client.sessionWait(this.session.id, timeoutMs);
|
|
164
|
+
|
|
125
165
|
return resolved === null ? "pending" : outcome(resolved);
|
|
126
166
|
}
|
|
127
167
|
const chunkMs = pollMs ?? 10_000;
|
|
128
168
|
const deadline = Number.isFinite(timeoutMs) ? Date.now() + timeoutMs : undefined;
|
|
169
|
+
|
|
129
170
|
for (;;) {
|
|
130
171
|
const budget = deadline === undefined ? chunkMs : Math.min(chunkMs, deadline - Date.now());
|
|
172
|
+
|
|
131
173
|
if (budget <= 0 || signal?.aborted) return "pending";
|
|
132
174
|
const resolved = await raceAbort(this.client.sessionWait(this.session.id, budget), signal);
|
|
175
|
+
|
|
133
176
|
if (resolved === ABORTED) return "pending";
|
|
134
177
|
if (resolved !== null) return outcome(resolved);
|
|
135
178
|
// Still pending after this chunk: re-read to surface reviewer progress.
|
|
136
179
|
const current = await raceAbort(this.client.sessionGet(this.session.id), signal);
|
|
180
|
+
|
|
137
181
|
if (current === ABORTED) return "pending";
|
|
138
182
|
onProgress?.(current);
|
|
139
183
|
}
|
|
140
184
|
}
|
|
141
185
|
}
|
|
142
186
|
|
|
143
|
-
function outcome(session:
|
|
187
|
+
function outcome(session: Thread): VerdictOutcome {
|
|
144
188
|
return { ...verdictResponse(session), session };
|
|
145
189
|
}
|
|
146
190
|
|
|
191
|
+
export interface AwaitResolveOptions {
|
|
192
|
+
/** Long-poll chunk length; the wait re-arms each chunk until resolved or aborted. Default 30s. */
|
|
193
|
+
pollMs?: number;
|
|
194
|
+
/** Abort the wait (the harness session shut down); resolves to null. */
|
|
195
|
+
signal?: AbortSignal;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Park until a review session resolves, then return the verdict outcome; null
|
|
200
|
+
* when the signal aborts first. Where ReviewHandle.awaitVerdict needs the handle
|
|
201
|
+
* that opened the review, this needs only a session id - so a background waiter
|
|
202
|
+
* that woke on a session it did not open (a detached Claude Code / Codex waiter,
|
|
203
|
+
* or pi's session_start listener) can collect the same verdict. This is the
|
|
204
|
+
* wake seam every non-blocking adapter builds on. Loops the daemon long-poll, so
|
|
205
|
+
* a verdict that lands between chunks is never missed and a session already
|
|
206
|
+
* resolved returns on the first chunk. The held connection also keeps the daemon
|
|
207
|
+
* off its idle-exit path for the whole wait.
|
|
208
|
+
*/
|
|
209
|
+
export async function awaitResolve(
|
|
210
|
+
client: DaemonClient,
|
|
211
|
+
sessionId: string,
|
|
212
|
+
options: AwaitResolveOptions = {},
|
|
213
|
+
): Promise<VerdictOutcome | null> {
|
|
214
|
+
const chunkMs = options.pollMs ?? 30_000;
|
|
215
|
+
const resolved = await pollUntilResolved(
|
|
216
|
+
() => client.sessionWait(sessionId, chunkMs),
|
|
217
|
+
options.signal,
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
return resolved === null ? null : outcome(resolved);
|
|
221
|
+
}
|
|
222
|
+
|
|
147
223
|
/** Open a review session (or revise the agent session's existing one) and hand back the wait surface. */
|
|
148
|
-
export async function openReview(
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
224
|
+
export async function openReview(
|
|
225
|
+
client: DaemonClient,
|
|
226
|
+
options: OpenReviewOptions,
|
|
227
|
+
): Promise<ReviewHandle> {
|
|
228
|
+
const cwd = options.cwd ?? process.cwd();
|
|
229
|
+
const workspace = options.workspace ?? (await resolveWorkspace(cwd));
|
|
230
|
+
|
|
231
|
+
// Resubmits from the same agent session become revisions, not new sessions -
|
|
232
|
+
// but only within the same primitive: a different artifact type is a new
|
|
233
|
+
// review, never a silent type mismatch on the old session.
|
|
234
|
+
if (options.agentSessionId !== undefined) {
|
|
153
235
|
const existing = (await client.sessionList()).find(
|
|
154
|
-
(
|
|
236
|
+
(candidate) =>
|
|
237
|
+
candidate.artifact.meta.agentSessionId === options.agentSessionId &&
|
|
238
|
+
candidate.artifact.type === options.type,
|
|
155
239
|
);
|
|
240
|
+
|
|
156
241
|
if (existing !== undefined) {
|
|
157
|
-
|
|
242
|
+
let revised = await client.sessionSubmitRevision(existing.id, options.content);
|
|
243
|
+
|
|
244
|
+
if (options.notes?.length) {
|
|
245
|
+
await attachNotes(client, revised.id, options.notes);
|
|
246
|
+
revised = await client.sessionGet(revised.id);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return new ReviewHandle(client, revised);
|
|
158
250
|
}
|
|
159
251
|
}
|
|
160
|
-
|
|
161
|
-
type:
|
|
162
|
-
content:
|
|
252
|
+
let session = await client.sessionCreate(workspace, {
|
|
253
|
+
type: options.type,
|
|
254
|
+
content: options.content,
|
|
255
|
+
files: options.files,
|
|
163
256
|
meta: {
|
|
164
|
-
agent:
|
|
165
|
-
agentSessionId:
|
|
166
|
-
planPath:
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
257
|
+
agent: options.agent,
|
|
258
|
+
agentSessionId: options.agentSessionId,
|
|
259
|
+
planPath: options.planPath,
|
|
260
|
+
prototypePath: options.prototypePath,
|
|
261
|
+
pr: options.pr,
|
|
262
|
+
herdrPane: options.herdrPane,
|
|
263
|
+
title:
|
|
264
|
+
options.title ??
|
|
265
|
+
(isMarkdownArtifact(options.type) ? firstHeading(options.content) : undefined),
|
|
170
266
|
cwd,
|
|
171
267
|
},
|
|
172
268
|
});
|
|
269
|
+
|
|
270
|
+
if (options.notes?.length) {
|
|
271
|
+
await attachNotes(client, session.id, options.notes);
|
|
272
|
+
session = await client.sessionGet(session.id);
|
|
273
|
+
}
|
|
274
|
+
|
|
173
275
|
return new ReviewHandle(client, session);
|
|
174
276
|
}
|