@cueloop/daemon 0.1.0-alpha.102 → 0.1.0-alpha.104
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 +3 -3
- package/src/agent-tools.ts +6 -0
- package/src/api.ts +65 -8
- package/src/client-reliability.test.ts +2 -0
- package/src/client.ts +25 -2
- package/src/curate.ts +2 -0
- package/src/daemon-sdk.ts +8 -0
- package/src/diff-hot-reload.test.ts +4 -0
- package/src/diff-watcher.test.ts +2 -0
- package/src/diff-watcher.ts +33 -0
- package/src/examples/daemon-sdk.ts +2 -0
- package/src/gh.ts +4 -0
- package/src/ghostty-thread-surface-store.ts +3 -0
- package/src/ghostty-thread-surface.test.ts +2 -0
- package/src/ghostty-thread-surface.ts +6 -0
- package/src/harness-diagnostics.ts +1 -0
- package/src/harness-handoff.test.ts +725 -0
- package/src/harness-handoff.ts +27 -0
- package/src/harness-state-store.ts +5 -0
- package/src/herdr-split.ts +5 -0
- package/src/herdr-thread-surface.ts +14 -0
- package/src/interruptible-wait.ts +3 -0
- package/src/jj-working-tree.ts +9 -0
- package/src/main.ts +1 -0
- package/src/operation-receipts.ts +6 -0
- package/src/pr-poller.ts +7 -0
- package/src/project-files.ts +4 -0
- package/src/protocol.ts +4 -0
- package/src/retention.ts +8 -0
- package/src/server.ts +36 -7
- package/src/share-blob.test.ts +23 -0
- package/src/single-instance.test.ts +3 -0
- package/src/store.ts +11 -0
- package/src/thread-agent-validation.ts +20 -0
- package/src/thread-agent.test.ts +14 -1
- package/src/thread-agent.ts +353 -24
- package/src/thread-review.test.ts +4 -0
- package/src/thread-review.ts +16 -4
- package/src/thread-subscription.test.ts +3 -0
- package/src/thread-subscription.ts +9 -0
- package/src/validate.test.ts +11 -0
- package/src/validate.ts +5 -0
- package/src/vcs-source.ts +6 -0
- package/src/working-tree.ts +4 -0
|
@@ -308,7 +308,9 @@ describe("awaitMessage: one long-poll (the hook shape)", () => {
|
|
|
308
308
|
|
|
309
309
|
// Assert
|
|
310
310
|
expect(message).not.toBe("pending");
|
|
311
|
+
|
|
311
312
|
if (message === "pending") throw new Error("unreachable");
|
|
313
|
+
|
|
312
314
|
expect(message.allow).toBe(true);
|
|
313
315
|
expect(message.message.body).toContain("Fine.");
|
|
314
316
|
expect(message.session.message!.outcome).toBe("approved");
|
|
@@ -325,6 +327,7 @@ describe("awaitMessage: one long-poll (the hook shape)", () => {
|
|
|
325
327
|
|
|
326
328
|
// Assert
|
|
327
329
|
if (message === "pending") throw new Error("expected a message");
|
|
330
|
+
|
|
328
331
|
expect(message.allow).toBe(false);
|
|
329
332
|
expect(message.message.body).toContain("# Review: changes requested");
|
|
330
333
|
expect(message.message.body).toContain("One stage only.");
|
|
@@ -362,6 +365,7 @@ describe("awaitMessage: chunked loop (the pi shape)", () => {
|
|
|
362
365
|
|
|
363
366
|
// Assert
|
|
364
367
|
if (message === "pending") throw new Error("expected a message");
|
|
368
|
+
|
|
365
369
|
expect(message.allow).toBe(false);
|
|
366
370
|
expect(message.session.annotations.length).toBe(1);
|
|
367
371
|
}, 15_000);
|
package/src/thread-review.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
isMarkdownArtifact,
|
|
3
|
+
messageAllows,
|
|
3
4
|
newAnnotationId,
|
|
4
5
|
type Annotation,
|
|
5
6
|
type Artifact,
|
|
@@ -12,13 +13,16 @@ import {
|
|
|
12
13
|
} from "@cueloop/schema";
|
|
13
14
|
import { execFile } from "node:child_process";
|
|
14
15
|
import { promisify } from "node:util";
|
|
15
|
-
import {
|
|
16
|
+
import { DaemonError } from "./errors";
|
|
16
17
|
import { ABORTED, pollUntilResolved, raceAbort } from "./interruptible-wait";
|
|
17
18
|
import { VcsSourceManager } from "./vcs-source";
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
/** Map a resolved session to the agent message contract. */
|
|
21
|
+
export function messageResponse(session: Thread) {
|
|
22
|
+
if (!session.message) throw new DaemonError("pending", "session has no message");
|
|
23
|
+
|
|
24
|
+
return { allow: messageAllows(session.message.outcome), message: session.message };
|
|
25
|
+
}
|
|
22
26
|
|
|
23
27
|
async function git(args: string[], cwd: string): Promise<string | null> {
|
|
24
28
|
try {
|
|
@@ -43,6 +47,7 @@ async function jjRoot(cwd: string): Promise<string | null> {
|
|
|
43
47
|
/** The oldest root commit reachable from HEAD; the project key that survives moving or re-cloning the repo. */
|
|
44
48
|
function earliestRootCommit(revList: string | null): string | undefined {
|
|
45
49
|
if (!revList) return undefined;
|
|
50
|
+
|
|
46
51
|
const roots = revList.split("\n").filter((line) => line.length > 0);
|
|
47
52
|
|
|
48
53
|
// --date-order lists newest first, so the last root is the earliest
|
|
@@ -77,6 +82,7 @@ export async function resolveWorkspace(cwd = process.cwd()): Promise<WorkspaceKe
|
|
|
77
82
|
|
|
78
83
|
// a repo with no commits (or a shallow clone) has no reliable root, so the thread stays standalone
|
|
79
84
|
if (rootCommit) workspace.rootCommit = rootCommit;
|
|
85
|
+
|
|
80
86
|
if (remote) workspace.remote = remote;
|
|
81
87
|
|
|
82
88
|
return workspace;
|
|
@@ -191,6 +197,7 @@ export class ReviewHandle {
|
|
|
191
197
|
|
|
192
198
|
return resolved === null ? "pending" : outcome(resolved);
|
|
193
199
|
}
|
|
200
|
+
|
|
194
201
|
const chunkMs = pollMs ?? 10_000;
|
|
195
202
|
const deadline = Number.isFinite(timeoutMs) ? Date.now() + timeoutMs : undefined;
|
|
196
203
|
|
|
@@ -198,11 +205,13 @@ export class ReviewHandle {
|
|
|
198
205
|
const budget = deadline === undefined ? chunkMs : Math.min(chunkMs, deadline - Date.now());
|
|
199
206
|
|
|
200
207
|
if (budget <= 0 || signal?.aborted) return "pending";
|
|
208
|
+
|
|
201
209
|
const resolved = await raceAbort(this.client.sessionWait(this.session.id, budget), signal);
|
|
202
210
|
|
|
203
211
|
if (resolved === ABORTED) return "pending";
|
|
204
212
|
|
|
205
213
|
if (resolved !== null) return outcome(resolved);
|
|
214
|
+
|
|
206
215
|
// Still pending after this chunk: re-read to surface reviewer progress.
|
|
207
216
|
const current = await raceAbort(this.client.sessionGet(this.session.id), signal);
|
|
208
217
|
|
|
@@ -278,6 +287,7 @@ export async function findExistingReview(
|
|
|
278
287
|
return candidates.find(
|
|
279
288
|
(candidate) => candidate.artifact.meta.vcsChangeId === options.vcsChangeId,
|
|
280
289
|
);
|
|
290
|
+
|
|
281
291
|
if (isJjDiff) {
|
|
282
292
|
const jjCandidates = candidates.filter(hasJjProvenance);
|
|
283
293
|
|
|
@@ -295,6 +305,7 @@ async function matchingDiffCapture(
|
|
|
295
305
|
cwd: string,
|
|
296
306
|
): Promise<{ source?: DiffSource; files?: DiffFileContents[] }> {
|
|
297
307
|
if (options.type !== "diff" || options.pr || !options.content.trim()) return {};
|
|
308
|
+
|
|
298
309
|
try {
|
|
299
310
|
const captured = await new VcsSourceManager().capture(cwd, options.vcs);
|
|
300
311
|
|
|
@@ -347,6 +358,7 @@ export async function openReview(
|
|
|
347
358
|
|
|
348
359
|
return new ReviewHandle(client, revised);
|
|
349
360
|
}
|
|
361
|
+
|
|
350
362
|
let session = await client.sessionCreate(workspace, {
|
|
351
363
|
type: options.type,
|
|
352
364
|
content: options.content,
|
|
@@ -91,6 +91,7 @@ test("reconnect subscribes again and refreshes authoritative state even without
|
|
|
91
91
|
},
|
|
92
92
|
onValue: (state) => {
|
|
93
93
|
values.push(state.artifact.meta.title ?? "Initial");
|
|
94
|
+
|
|
94
95
|
if (connections === 1) initial.resolve();
|
|
95
96
|
else recovered.resolve();
|
|
96
97
|
},
|
|
@@ -126,6 +127,7 @@ test("a malformed protocol frame stops the observer instead of reconnecting", as
|
|
|
126
127
|
open(socket) {
|
|
127
128
|
reads.set(socket, new LineBuffer());
|
|
128
129
|
connections++;
|
|
130
|
+
|
|
129
131
|
if (connections > 1) failed.reject(new Error("Unexpected reconnect"));
|
|
130
132
|
},
|
|
131
133
|
data(socket, bytes) {
|
|
@@ -141,6 +143,7 @@ test("a malformed protocol frame stops the observer instead of reconnecting", as
|
|
|
141
143
|
: {},
|
|
142
144
|
}) + "\n",
|
|
143
145
|
);
|
|
146
|
+
|
|
144
147
|
if (request.method === "events.subscribe") socket.write("{broken\n");
|
|
145
148
|
});
|
|
146
149
|
},
|
|
@@ -34,11 +34,13 @@ export function subscribeThreadState<Value>(options: ThreadSubscriptionOptions<V
|
|
|
34
34
|
|
|
35
35
|
try {
|
|
36
36
|
api = await options.connect();
|
|
37
|
+
|
|
37
38
|
if (stopped || epoch !== generation) {
|
|
38
39
|
api.close();
|
|
39
40
|
|
|
40
41
|
return;
|
|
41
42
|
}
|
|
43
|
+
|
|
42
44
|
connection = api;
|
|
43
45
|
const current = api;
|
|
44
46
|
const refresh = async () => {
|
|
@@ -62,6 +64,7 @@ export function subscribeThreadState<Value>(options: ThreadSubscriptionOptions<V
|
|
|
62
64
|
detach?.();
|
|
63
65
|
current.close();
|
|
64
66
|
}
|
|
67
|
+
|
|
65
68
|
options.onError(error);
|
|
66
69
|
}
|
|
67
70
|
} finally {
|
|
@@ -73,11 +76,13 @@ export function subscribeThreadState<Value>(options: ThreadSubscriptionOptions<V
|
|
|
73
76
|
});
|
|
74
77
|
const offDisconnect = api.onDisconnect((reason) => {
|
|
75
78
|
if (stopped || epoch !== generation) return;
|
|
79
|
+
|
|
76
80
|
++generation;
|
|
77
81
|
detach?.();
|
|
78
82
|
for (const read of reads) read.abort();
|
|
79
83
|
connection = undefined;
|
|
80
84
|
current.close();
|
|
85
|
+
|
|
81
86
|
if (isFatalSubscriptionError(reason)) {
|
|
82
87
|
stopped = true;
|
|
83
88
|
options.onError(reason);
|
|
@@ -89,16 +94,20 @@ export function subscribeThreadState<Value>(options: ThreadSubscriptionOptions<V
|
|
|
89
94
|
offDisconnect();
|
|
90
95
|
};
|
|
91
96
|
await api.subscribe();
|
|
97
|
+
|
|
92
98
|
if (stopped || epoch !== generation) return;
|
|
99
|
+
|
|
93
100
|
options.onConnect?.(api);
|
|
94
101
|
await refresh();
|
|
95
102
|
} catch (error) {
|
|
96
103
|
if (stopped || epoch !== generation) return;
|
|
104
|
+
|
|
97
105
|
++generation;
|
|
98
106
|
detach?.();
|
|
99
107
|
api?.close();
|
|
100
108
|
connection = undefined;
|
|
101
109
|
options.onError(error);
|
|
110
|
+
|
|
102
111
|
if (!(error instanceof Error && isFatalSubscriptionError(error))) schedule();
|
|
103
112
|
}
|
|
104
113
|
};
|
package/src/validate.test.ts
CHANGED
|
@@ -58,6 +58,7 @@ describe("parseParams", () => {
|
|
|
58
58
|
throw new Error("should have thrown");
|
|
59
59
|
} catch (err) {
|
|
60
60
|
expect(err).toBeInstanceOf(DaemonError);
|
|
61
|
+
|
|
61
62
|
if (err instanceof DaemonError) {
|
|
62
63
|
expect(err.code).toBe("invalid_params");
|
|
63
64
|
expect(err.message).toContain("session.create: workspace");
|
|
@@ -180,6 +181,7 @@ describe("validateThreadRecord", () => {
|
|
|
180
181
|
|
|
181
182
|
// Assert
|
|
182
183
|
expect(result.ok).toBe(false);
|
|
184
|
+
|
|
183
185
|
if (!result.ok) expect(result.error).toContain("schemaVersion");
|
|
184
186
|
});
|
|
185
187
|
|
|
@@ -291,6 +293,13 @@ describe("wire pins", () => {
|
|
|
291
293
|
handle: "abc",
|
|
292
294
|
};
|
|
293
295
|
const fullSession: Required<Thread> = {
|
|
296
|
+
agentSnapshot: {
|
|
297
|
+
threadId: "ses_1",
|
|
298
|
+
phase: { kind: "idle" },
|
|
299
|
+
messages: [],
|
|
300
|
+
comments: [],
|
|
301
|
+
tools: [],
|
|
302
|
+
},
|
|
294
303
|
schemaVersion: SCHEMA_VERSION,
|
|
295
304
|
id: "ses_1",
|
|
296
305
|
workspace: fullWorkspace,
|
|
@@ -366,6 +375,7 @@ describe("wire pins", () => {
|
|
|
366
375
|
|
|
367
376
|
// Assert
|
|
368
377
|
expect(parsed.ok).toBe(false);
|
|
378
|
+
|
|
369
379
|
if (!parsed.ok) expect(parsed.error).toContain("history:");
|
|
370
380
|
});
|
|
371
381
|
|
|
@@ -376,6 +386,7 @@ describe("wire pins", () => {
|
|
|
376
386
|
});
|
|
377
387
|
|
|
378
388
|
expect(result.ok).toBe(false);
|
|
389
|
+
|
|
379
390
|
if (!result.ok) {
|
|
380
391
|
expect(result.error).toContain(
|
|
381
392
|
"text Cuts do not match the submitted artifact and working copy",
|
package/src/validate.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import * as v from "valibot";
|
|
13
|
+
import { ThreadAgentSchema } from "./thread-agent-validation";
|
|
13
14
|
import {
|
|
14
15
|
ARTIFACT_TYPES,
|
|
15
16
|
MESSAGE_OUTCOMES,
|
|
@@ -478,6 +479,7 @@ export const SessionHistorySchema = v.pipe(
|
|
|
478
479
|
// the shape is not enough: the tree itself must hold, or a walk hangs or rewires the path
|
|
479
480
|
v.rawCheck(({ dataset, addIssue }) => {
|
|
480
481
|
if (!dataset.typed) return;
|
|
482
|
+
|
|
481
483
|
const problem = validateHistory(dataset.value);
|
|
482
484
|
|
|
483
485
|
if (problem !== null) addIssue({ message: `history: ${problem}` });
|
|
@@ -516,9 +518,11 @@ export const ThreadRecordSchema = v.pipe(
|
|
|
516
518
|
parentSessionId: v.optional(v.string()),
|
|
517
519
|
shares: v.optional(v.array(ShareLinkSchema)),
|
|
518
520
|
participants: v.optional(v.array(IdentitySchema)),
|
|
521
|
+
agentSnapshot: v.optional(ThreadAgentSchema),
|
|
519
522
|
} satisfies EntriesOf<Thread>),
|
|
520
523
|
v.rawCheck(({ dataset, addIssue }) => {
|
|
521
524
|
if (!dataset.typed || !dataset.value.textCuts?.length) return;
|
|
525
|
+
|
|
522
526
|
const { artifact, textCuts, workingCopy } = dataset.value;
|
|
523
527
|
const matchesSource = textCuts.every(
|
|
524
528
|
(cut) => artifact.content.slice(cut.start, cut.end) === cut.quote,
|
|
@@ -536,6 +540,7 @@ export function validateThreadRecord(
|
|
|
536
540
|
const result = v.safeParse(ThreadRecordSchema, raw);
|
|
537
541
|
|
|
538
542
|
if (result.success) return { ok: true, value: result.output };
|
|
543
|
+
|
|
539
544
|
const issue = result.issues[0]!;
|
|
540
545
|
const path = issue.path?.map((pathSegment) => String(pathSegment.key)).join(".") ?? "";
|
|
541
546
|
|
package/src/vcs-source.ts
CHANGED
|
@@ -71,6 +71,7 @@ const ExtensionModuleSchema = v.object({ default: v.function() });
|
|
|
71
71
|
|
|
72
72
|
function readVcsToml(path: string): Partial<VcsConfig> {
|
|
73
73
|
if (!existsSync(path)) return {};
|
|
74
|
+
|
|
74
75
|
try {
|
|
75
76
|
const parsed = v.safeParse(VcsConfigSchema, parseToml(readFileSync(path, "utf8")));
|
|
76
77
|
|
|
@@ -100,6 +101,7 @@ export class VcsSourceManager {
|
|
|
100
101
|
|
|
101
102
|
private async adapters(): Promise<VcsAdapter[]> {
|
|
102
103
|
if (!this.loaded) this.loaded = this.loadExtensions();
|
|
104
|
+
|
|
103
105
|
await this.loaded;
|
|
104
106
|
|
|
105
107
|
return [
|
|
@@ -131,6 +133,7 @@ export class VcsSourceManager {
|
|
|
131
133
|
const parsed = v.safeParse(ExtensionModuleSchema, module);
|
|
132
134
|
|
|
133
135
|
if (!parsed.success) throw new Error("VCS extension must export a default factory");
|
|
136
|
+
|
|
134
137
|
const factory: ExtensionFactory = async (api) => {
|
|
135
138
|
await parsed.output.default(api);
|
|
136
139
|
};
|
|
@@ -177,6 +180,7 @@ export class VcsSourceManager {
|
|
|
177
180
|
|
|
178
181
|
if (!selected && requested === "git" && detected.length === 0)
|
|
179
182
|
return { adapter: gitVcsAdapter, repoRoot: cwd };
|
|
183
|
+
|
|
180
184
|
if (!selected)
|
|
181
185
|
throw new Error(
|
|
182
186
|
`VCS adapter unavailable: ${requested}${this.extensionErrors.length > 0 ? ` (${this.extensionErrors.join("; ")})` : ""}`,
|
|
@@ -184,6 +188,7 @@ export class VcsSourceManager {
|
|
|
184
188
|
|
|
185
189
|
return selected;
|
|
186
190
|
}
|
|
191
|
+
|
|
187
192
|
detected.sort((a, b) => {
|
|
188
193
|
const rootDistance = b.repoRoot.length - a.repoRoot.length;
|
|
189
194
|
|
|
@@ -219,6 +224,7 @@ export class VcsSourceManager {
|
|
|
219
224
|
|
|
220
225
|
if (!selected.adapter.captureChange)
|
|
221
226
|
throw new Error(`VCS adapter ${provider} cannot follow a change ID`);
|
|
227
|
+
|
|
222
228
|
const snapshot = v.parse(
|
|
223
229
|
VcsDiffSnapshotSchema,
|
|
224
230
|
await selected.adapter.captureChange(selected.repoRoot, changeId),
|
package/src/working-tree.ts
CHANGED
|
@@ -17,6 +17,7 @@ function statusFromCode(code: string): DiffFileStatus {
|
|
|
17
17
|
const letter = code.charAt(0);
|
|
18
18
|
|
|
19
19
|
if (letter === "D") return "deleted";
|
|
20
|
+
|
|
20
21
|
if (letter === "A") return "added";
|
|
21
22
|
|
|
22
23
|
return "modified";
|
|
@@ -35,6 +36,7 @@ export async function workingChangeList(
|
|
|
35
36
|
|
|
36
37
|
for (const line of tracked.split("\n")) {
|
|
37
38
|
if (line.trim().length === 0) continue;
|
|
39
|
+
|
|
38
40
|
const parts = line.split("\t");
|
|
39
41
|
|
|
40
42
|
// a rename is "R100<tab>old<tab>new"; the new path (last field) is what the reviewer opens
|
|
@@ -101,11 +103,13 @@ async function trackedFileContents(cwd: string): Promise<DiffFileContents[]> {
|
|
|
101
103
|
cursor += 2;
|
|
102
104
|
continue;
|
|
103
105
|
}
|
|
106
|
+
|
|
104
107
|
const path = tokens[cursor++]!;
|
|
105
108
|
const oldContents = code === "A" ? "" : await headContents(path, cwd);
|
|
106
109
|
const newContents = code === "D" ? "" : await workingContents(path, cwd);
|
|
107
110
|
|
|
108
111
|
if (looksBinary(oldContents) || looksBinary(newContents)) continue;
|
|
112
|
+
|
|
109
113
|
const status = code === "A" ? "added" : code === "D" ? "deleted" : "modified";
|
|
110
114
|
|
|
111
115
|
files.push({ path, oldContents, newContents, status });
|