@cueloop/daemon 0.1.0-alpha.68 → 0.1.0-alpha.69
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 -2
- package/src/api.test.ts +100 -1
- package/src/api.ts +71 -3
- package/src/capabilities.ts +2 -0
- package/src/client.ts +116 -17
- package/src/daemon-version.test.ts +93 -0
- package/src/interruptible-wait.test.ts +127 -0
- package/src/interruptible-wait.ts +66 -0
- package/src/paths.ts +25 -0
- package/src/review.ts +6 -44
- package/src/server.ts +16 -2
- package/src/store.jsonl.test.ts +159 -0
- package/src/store.ts +158 -27
- package/src/validate.test.ts +2 -0
- package/src/validate.ts +13 -0
- package/src/version.ts +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/daemon",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.69",
|
|
4
4
|
"description": "The cueloop session daemon and its client: socket protocol, atomic store, resumable waits",
|
|
5
5
|
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -35,8 +35,9 @@
|
|
|
35
35
|
"access": "public"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
38
|
+
"@cueloop/schema": "0.1.0-alpha.69",
|
|
39
39
|
"@pierre/diffs": "^1.3.6",
|
|
40
|
+
"effect": "^4.0.0-rc.115",
|
|
40
41
|
"valibot": "^1.4.2"
|
|
41
42
|
},
|
|
42
43
|
"engines": {
|
package/src/api.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { DaemonCore, verdictResponse } from "./api";
|
|
@@ -64,6 +64,23 @@ describe("session lifecycle", () => {
|
|
|
64
64
|
expect(core.sessionGet(session.id).annotations.length).toBe(0);
|
|
65
65
|
});
|
|
66
66
|
|
|
67
|
+
test("a welcome-playground note is ephemeral: never persisted", () => {
|
|
68
|
+
// Arrange
|
|
69
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
70
|
+
|
|
71
|
+
// Act
|
|
72
|
+
core.sessionAnnotate(session.id, {
|
|
73
|
+
id: "w1",
|
|
74
|
+
kind: "comment",
|
|
75
|
+
anchor: { quote: "start", prefix: "", suffix: "" },
|
|
76
|
+
target: { kind: "welcome" },
|
|
77
|
+
body: "just trying it",
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Assert - the onboarding playground stores nothing
|
|
81
|
+
expect(core.sessionGet(session.id).annotations.length).toBe(0);
|
|
82
|
+
});
|
|
83
|
+
|
|
67
84
|
test("annotate with an author registers them in the participant registry", () => {
|
|
68
85
|
// Arrange
|
|
69
86
|
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
@@ -896,3 +913,85 @@ describe("tree primitives", () => {
|
|
|
896
913
|
expect(rerouted.annotations[0]!.resolution).toBeUndefined();
|
|
897
914
|
});
|
|
898
915
|
});
|
|
916
|
+
|
|
917
|
+
describe("workbench session", () => {
|
|
918
|
+
function makeRepo(): string {
|
|
919
|
+
const root = mkdtempSync(join(tmpdir(), "cueloop-wb-repo-"));
|
|
920
|
+
|
|
921
|
+
// a unique seed file so each repo's root commit is a distinct SHA (empty commits in the same
|
|
922
|
+
// second hash identically) - the root commit is the per-project key under test
|
|
923
|
+
writeFileSync(join(root, "seed.txt"), root);
|
|
924
|
+
Bun.spawnSync(["git", "-C", root, "init", "-q"]);
|
|
925
|
+
Bun.spawnSync(["git", "-C", root, "config", "user.email", "t@e.st"]);
|
|
926
|
+
Bun.spawnSync(["git", "-C", root, "config", "user.name", "T"]);
|
|
927
|
+
Bun.spawnSync(["git", "-C", root, "add", "-A"]);
|
|
928
|
+
Bun.spawnSync(["git", "-C", root, "commit", "-q", "-m", "seed"]);
|
|
929
|
+
|
|
930
|
+
return root;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
test("creates a diff-backed, workbench-marked thread and attaches idempotently", async () => {
|
|
934
|
+
const repo = makeRepo();
|
|
935
|
+
|
|
936
|
+
try {
|
|
937
|
+
const first = await core.workbenchSession(repo);
|
|
938
|
+
|
|
939
|
+
expect(first.artifact.meta.workbench).toBe(true);
|
|
940
|
+
expect(first.artifact.type).toBe("diff");
|
|
941
|
+
expect(first.workspace.rootCommit).toBeTruthy();
|
|
942
|
+
|
|
943
|
+
// a second call for the same repo returns the same thread, never a duplicate
|
|
944
|
+
const second = await core.workbenchSession(repo);
|
|
945
|
+
|
|
946
|
+
expect(second.id).toBe(first.id);
|
|
947
|
+
expect(core.sessionList().filter((s) => s.artifact.meta.workbench).length).toBe(1);
|
|
948
|
+
} finally {
|
|
949
|
+
rmSync(repo, { recursive: true, force: true });
|
|
950
|
+
}
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
test("a resolved workbench is not reused; a fresh one is created past it", async () => {
|
|
954
|
+
const repo = makeRepo();
|
|
955
|
+
|
|
956
|
+
try {
|
|
957
|
+
const first = await core.workbenchSession(repo);
|
|
958
|
+
|
|
959
|
+
core.sessionResolve(first.id, "comment", "");
|
|
960
|
+
const second = await core.workbenchSession(repo);
|
|
961
|
+
|
|
962
|
+
expect(second.id).not.toBe(first.id);
|
|
963
|
+
expect(second.status).toBe("pending");
|
|
964
|
+
} finally {
|
|
965
|
+
rmSync(repo, { recursive: true, force: true });
|
|
966
|
+
}
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
test("concurrent bare launches for one repo share a single workbench", async () => {
|
|
970
|
+
const repo = makeRepo();
|
|
971
|
+
|
|
972
|
+
try {
|
|
973
|
+
const [a, b] = await Promise.all([core.workbenchSession(repo), core.workbenchSession(repo)]);
|
|
974
|
+
|
|
975
|
+
expect(a.id).toBe(b.id);
|
|
976
|
+
expect(core.sessionList().filter((s) => s.artifact.meta.workbench).length).toBe(1);
|
|
977
|
+
} finally {
|
|
978
|
+
rmSync(repo, { recursive: true, force: true });
|
|
979
|
+
}
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
test("keys the workbench by root commit: different repos get different threads", async () => {
|
|
983
|
+
const repoA = makeRepo();
|
|
984
|
+
const repoB = makeRepo();
|
|
985
|
+
|
|
986
|
+
try {
|
|
987
|
+
const a = await core.workbenchSession(repoA);
|
|
988
|
+
const b = await core.workbenchSession(repoB);
|
|
989
|
+
|
|
990
|
+
expect(a.id).not.toBe(b.id);
|
|
991
|
+
expect(a.workspace.rootCommit).not.toBe(b.workspace.rootCommit);
|
|
992
|
+
} finally {
|
|
993
|
+
rmSync(repoA, { recursive: true, force: true });
|
|
994
|
+
rmSync(repoB, { recursive: true, force: true });
|
|
995
|
+
}
|
|
996
|
+
});
|
|
997
|
+
});
|
package/src/api.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
viewOfPath,
|
|
22
22
|
isAddressed,
|
|
23
23
|
isAgentNote,
|
|
24
|
+
annotationTarget,
|
|
24
25
|
isMarkdownArtifact,
|
|
25
26
|
MAIN_BRANCH,
|
|
26
27
|
parseBlocks,
|
|
@@ -47,7 +48,7 @@ import { SessionStore, withHistory } from "./store";
|
|
|
47
48
|
import { pruneExpiredSessions, resolveCleanupPeriodDays } from "./retention";
|
|
48
49
|
import { HerdrTabStore, type HerdrTabHandle } from "./herdr-tab-store";
|
|
49
50
|
import { DiffWatcher } from "./diff-watcher";
|
|
50
|
-
import { workingTreeDiff, workingChangeList } from "./working-tree";
|
|
51
|
+
import { workingTreeDiff, workingChangeList, type WorkingTreeDiff } from "./working-tree";
|
|
51
52
|
import { listProjectFiles, readProjectFile } from "./project-files";
|
|
52
53
|
import { resolveWorkspace } from "./review";
|
|
53
54
|
import { DaemonError } from "./errors";
|
|
@@ -90,6 +91,8 @@ export class DaemonCore {
|
|
|
90
91
|
* result, so overlapping captures never write an older patch over a newer one.
|
|
91
92
|
*/
|
|
92
93
|
private readonly diffRefreshGenerations = new Map<string, number>();
|
|
94
|
+
/** In-flight workbench creations keyed by project, so concurrent bare launches share one thread. */
|
|
95
|
+
private readonly workbenchCreation = new Map<string, Promise<ReviewSession>>();
|
|
93
96
|
|
|
94
97
|
constructor(home: string) {
|
|
95
98
|
this.store = new SessionStore(home);
|
|
@@ -226,6 +229,8 @@ export class DaemonCore {
|
|
|
226
229
|
authorName?: string,
|
|
227
230
|
): ReviewSession {
|
|
228
231
|
const session = this.mutable(id);
|
|
232
|
+
// a welcome-playground note is ephemeral by design: never persisted, so never fed back
|
|
233
|
+
if (annotationTarget(annotation).kind === "welcome") return session;
|
|
229
234
|
const existing = session.annotations.findIndex((candidate) => candidate.id === annotation.id);
|
|
230
235
|
const full: Annotation = { ...annotation, createdAt: new Date().toISOString() };
|
|
231
236
|
|
|
@@ -363,6 +368,60 @@ export class DaemonCore {
|
|
|
363
368
|
return workingChangeList(cwd);
|
|
364
369
|
}
|
|
365
370
|
|
|
371
|
+
/**
|
|
372
|
+
* The live working-tree diff (HEAD vs working tree, untracked included) at `cwd`:
|
|
373
|
+
* the same unified patch plus full per-file contents a `cueloop diff` captures,
|
|
374
|
+
* so the Changes navigator renders a real diff for any thread. Owner-only.
|
|
375
|
+
*/
|
|
376
|
+
repoDiff(cwd: string): Promise<WorkingTreeDiff> {
|
|
377
|
+
return workingTreeDiff(cwd);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* The per-repo workbench thread for `cwd`: a self-initiated review of the current checkout, keyed by
|
|
382
|
+
* the repo's root-commit identity (a standalone bucket when there is none). Returns the existing one
|
|
383
|
+
* or lazily creates it with the working-tree diff as its artifact, so a bare launch persists its
|
|
384
|
+
* first comment without an agent submission. Owner-only.
|
|
385
|
+
*/
|
|
386
|
+
async workbenchSession(cwd: string): Promise<ReviewSession> {
|
|
387
|
+
const workspace = await resolveWorkspace(cwd);
|
|
388
|
+
const key = workspace.rootCommit ?? "_standalone";
|
|
389
|
+
// an open workbench is reused; a resolved one is immutable and would reject the note, so a fresh
|
|
390
|
+
// one is created past it
|
|
391
|
+
const open = this.store
|
|
392
|
+
.list()
|
|
393
|
+
.find(
|
|
394
|
+
(session) =>
|
|
395
|
+
session.artifact.meta.workbench === true &&
|
|
396
|
+
session.status !== "resolved" &&
|
|
397
|
+
(session.workspace.rootCommit ?? "_standalone") === key,
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
if (open) return open;
|
|
401
|
+
// serialize concurrent bare launches for the same repo onto one creation, so they share a thread
|
|
402
|
+
const inFlight = this.workbenchCreation.get(key);
|
|
403
|
+
|
|
404
|
+
if (inFlight) return inFlight;
|
|
405
|
+
const creation = workingTreeDiff(cwd).then((diff) =>
|
|
406
|
+
this.sessionCreate({
|
|
407
|
+
workspace,
|
|
408
|
+
artifact: {
|
|
409
|
+
type: "diff",
|
|
410
|
+
content: diff.patch,
|
|
411
|
+
files: diff.files,
|
|
412
|
+
meta: { workbench: true, title: "Workbench", cwd },
|
|
413
|
+
},
|
|
414
|
+
}),
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
this.workbenchCreation.set(key, creation);
|
|
418
|
+
try {
|
|
419
|
+
return await creation;
|
|
420
|
+
} finally {
|
|
421
|
+
this.workbenchCreation.delete(key);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
366
425
|
sessionSetShareId(id: string, shareId: string): ReviewSession {
|
|
367
426
|
const session = this.mutable(id);
|
|
368
427
|
|
|
@@ -519,12 +578,17 @@ export class DaemonCore {
|
|
|
519
578
|
return session;
|
|
520
579
|
}
|
|
521
580
|
|
|
522
|
-
sessionResolve(
|
|
581
|
+
sessionResolve(
|
|
582
|
+
id: string,
|
|
583
|
+
verdictKind: VerdictKind,
|
|
584
|
+
summary: string,
|
|
585
|
+
actionBodies?: Record<string, string>,
|
|
586
|
+
): ReviewSession {
|
|
523
587
|
const session = this.mutable(id);
|
|
524
588
|
const verdict: Verdict = {
|
|
525
589
|
kind: verdictKind,
|
|
526
590
|
summary,
|
|
527
|
-
feedback: feedbackForSession(session, verdictKind, summary),
|
|
591
|
+
feedback: feedbackForSession(session, verdictKind, summary, actionBodies),
|
|
528
592
|
resolvedAt: new Date().toISOString(),
|
|
529
593
|
};
|
|
530
594
|
|
|
@@ -601,6 +665,10 @@ export class DaemonCore {
|
|
|
601
665
|
annotation.resolution = { revision: revisionNumber, source: "agent" };
|
|
602
666
|
} else if (
|
|
603
667
|
revisedBlocks !== null &&
|
|
668
|
+
// only a note on the artifact drifts against the artifact's revision; a selector-anchored
|
|
669
|
+
// pixel element or a file-targeted Changes-diff note anchors elsewhere
|
|
670
|
+
annotationTarget(annotation).kind === "artifact" &&
|
|
671
|
+
!annotation.anchor.selector &&
|
|
604
672
|
resolveAnchor(annotation.anchor, revisedBlocks) === null
|
|
605
673
|
) {
|
|
606
674
|
annotation.resolution = { revision: revisionNumber, source: "drift" };
|
package/src/capabilities.ts
CHANGED
|
@@ -43,6 +43,8 @@ const PRIMITIVE_ROLES = {
|
|
|
43
43
|
"repo.files": "owner",
|
|
44
44
|
"repo.fileContents": "owner",
|
|
45
45
|
"repo.changes": "owner",
|
|
46
|
+
"repo.diff": "owner",
|
|
47
|
+
"session.workbench": "owner",
|
|
46
48
|
// the tree is the owner's: collaborators comment on the branch a share follows
|
|
47
49
|
"session.navigate": "owner",
|
|
48
50
|
"session.branch": "owner",
|
package/src/client.ts
CHANGED
|
@@ -29,7 +29,9 @@ import type { SharedMerge } from "./api";
|
|
|
29
29
|
|
|
30
30
|
export type { SharedMerge } from "./api";
|
|
31
31
|
import { cueloopHome, ownerTokenPath, socketPath } from "./paths";
|
|
32
|
-
import { Params, SessionRecordSchema } from "./validate";
|
|
32
|
+
import { Params, SessionRecordSchema, DiffFileContentsSchema } from "./validate";
|
|
33
|
+
import type { WorkingTreeDiff } from "./working-tree";
|
|
34
|
+
import { DAEMON_VERSION } from "./version";
|
|
33
35
|
|
|
34
36
|
export type { EventFrame } from "./protocol";
|
|
35
37
|
|
|
@@ -49,7 +51,9 @@ type PendingRequest = {
|
|
|
49
51
|
};
|
|
50
52
|
|
|
51
53
|
const EmptyResultSchema = v.object({});
|
|
52
|
-
|
|
54
|
+
// version is optional: a daemon from before the handshake carried one reads as
|
|
55
|
+
// undefined, which never equals this build - so it is treated as stale and replaced
|
|
56
|
+
const PingResultSchema = v.object({ pid: v.number(), version: v.optional(v.string()) });
|
|
53
57
|
const RefreshDiffResultSchema = v.object({ changed: v.boolean() });
|
|
54
58
|
const HerdrTabResultSchema = v.nullable(v.object({ tabId: v.string(), paneId: v.string() }));
|
|
55
59
|
|
|
@@ -91,6 +95,10 @@ export interface SessionClient {
|
|
|
91
95
|
repoFileContents?(cwd: string, path: string): Promise<string | null>;
|
|
92
96
|
/** Changed files (path plus git status) in the working tree at `cwd`. */
|
|
93
97
|
repoChanges?(cwd: string): Promise<{ path: string; status: DiffFileStatus }[]>;
|
|
98
|
+
/** The live working-tree diff (patch plus per-file contents) at `cwd`. */
|
|
99
|
+
repoDiff?(cwd: string): Promise<WorkingTreeDiff>;
|
|
100
|
+
/** Find-or-create the per-repo workbench thread for `cwd`, so a bare launch's first comment persists. */
|
|
101
|
+
sessionWorkbench?(cwd: string): Promise<ReviewSession>;
|
|
94
102
|
/** Move a branch's tip (the current one, or `branch` after switching to it) back to an entry on its path; a summary records the abandoned segment. */
|
|
95
103
|
sessionNavigate(
|
|
96
104
|
id: string,
|
|
@@ -110,7 +118,12 @@ export interface SessionClient {
|
|
|
110
118
|
sessionDelete(id: string): Promise<void>;
|
|
111
119
|
/** Record the caller's own identity name (collaborator self-naming on a share). */
|
|
112
120
|
sessionSetSelfName(id: string, name: string): Promise<ReviewSession>;
|
|
113
|
-
sessionResolve(
|
|
121
|
+
sessionResolve(
|
|
122
|
+
id: string,
|
|
123
|
+
verdictKind: VerdictKind,
|
|
124
|
+
summary: string,
|
|
125
|
+
actionBodies?: Record<string, string>,
|
|
126
|
+
): Promise<ReviewSession>;
|
|
114
127
|
close(): void;
|
|
115
128
|
}
|
|
116
129
|
|
|
@@ -145,6 +158,9 @@ export class DaemonClient implements SessionClient {
|
|
|
145
158
|
private socket: Awaited<ReturnType<typeof Bun.connect>> | null = null;
|
|
146
159
|
private writer: BackpressureWriter | null = null;
|
|
147
160
|
private pending = new Map<number, PendingRequest>();
|
|
161
|
+
/** The connected daemon's build version and pid, learned from the ping handshake. */
|
|
162
|
+
private daemonVersion: string | undefined;
|
|
163
|
+
private daemonPid: number | undefined;
|
|
148
164
|
private nextId = 1;
|
|
149
165
|
private eventListeners = new Set<(event: EventFrame) => void>();
|
|
150
166
|
private closed = false;
|
|
@@ -162,14 +178,66 @@ export class DaemonClient implements SessionClient {
|
|
|
162
178
|
client.home = home;
|
|
163
179
|
try {
|
|
164
180
|
await client.dial(path);
|
|
165
|
-
|
|
166
|
-
|
|
181
|
+
// A daemon from an earlier build lingers after an upgrade; talking to it
|
|
182
|
+
// means new client, old behaviour. The owner replaces it so an upgrade
|
|
183
|
+
// never needs a manual restart; without autostart there is nothing to
|
|
184
|
+
// replace it with, so the caller hears exactly why.
|
|
185
|
+
if (client.daemonVersion === DAEMON_VERSION) return client;
|
|
186
|
+
if (!options.autostart) {
|
|
187
|
+
client.close();
|
|
188
|
+
throw new DaemonClientError(
|
|
189
|
+
"version_mismatch",
|
|
190
|
+
`daemon is version ${client.daemonVersion ?? "unknown"}, but this client is ${DAEMON_VERSION}; restart the daemon`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
await client.stopStaleDaemon(path);
|
|
167
194
|
} catch (err) {
|
|
168
195
|
// a live daemon that refused the handshake is not a dead socket: the
|
|
169
196
|
// caller hears why instead of the client replacing a running daemon
|
|
170
197
|
if (!options.autostart || err instanceof DaemonClientError) throw err;
|
|
171
198
|
}
|
|
172
|
-
// Socket dead or
|
|
199
|
+
// Socket dead, absent, or just-replaced: clean a stale file and spawn detached.
|
|
200
|
+
return client.attachFreshDaemon(home, path);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Tear down a daemon from an earlier build so a fresh one can bind: ask it to
|
|
205
|
+
* shut down (owner-gated) or, failing that, signal its pid, then wait for it to
|
|
206
|
+
* release the socket. Best-effort - attachFreshDaemon's stale-socket and
|
|
207
|
+
* stale-lock cleanup recovers even a daemon that never ran its own teardown.
|
|
208
|
+
*/
|
|
209
|
+
private async stopStaleDaemon(path: string): Promise<void> {
|
|
210
|
+
const pid = this.daemonPid;
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
await this.request("daemon.shutdown", {}, EmptyResultSchema, 2_000);
|
|
214
|
+
} catch {
|
|
215
|
+
if (pid !== undefined) {
|
|
216
|
+
try {
|
|
217
|
+
process.kill(pid);
|
|
218
|
+
} catch {}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
this.socket?.end();
|
|
222
|
+
this.resetConnection();
|
|
223
|
+
// the old daemon removes its socket in stop(); wait so the new one binds cleanly
|
|
224
|
+
const deadline = Date.now() + 5_000;
|
|
225
|
+
|
|
226
|
+
while (Date.now() < deadline && existsSync(path)) await Bun.sleep(50);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Reset per-connection state so a fresh dial() can reuse this client instance. */
|
|
230
|
+
private resetConnection(): void {
|
|
231
|
+
this.socket = null;
|
|
232
|
+
this.writer = null;
|
|
233
|
+
this.closed = false;
|
|
234
|
+
for (const pendingRequest of this.pending.values())
|
|
235
|
+
pendingRequest.reject(new Error("daemon connection replaced"));
|
|
236
|
+
this.pending.clear();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Clean any stale socket, spawn a detached daemon, and dial it until it answers. */
|
|
240
|
+
private async attachFreshDaemon(home: string, path: string): Promise<DaemonClient> {
|
|
173
241
|
if (existsSync(path)) rmSync(path, { force: true });
|
|
174
242
|
spawnDaemon(home);
|
|
175
243
|
// Generous: a cold or loaded machine pays for a runtime start before the
|
|
@@ -179,9 +247,9 @@ export class DaemonClient implements SessionClient {
|
|
|
179
247
|
|
|
180
248
|
while (Date.now() < deadline) {
|
|
181
249
|
try {
|
|
182
|
-
await
|
|
250
|
+
await this.dial(path);
|
|
183
251
|
|
|
184
|
-
return
|
|
252
|
+
return this;
|
|
185
253
|
} catch (err) {
|
|
186
254
|
lastError = err;
|
|
187
255
|
await Bun.sleep(50);
|
|
@@ -213,8 +281,12 @@ export class DaemonClient implements SessionClient {
|
|
|
213
281
|
});
|
|
214
282
|
this.writer = new BackpressureWriter(this.socket);
|
|
215
283
|
// Verify liveness: a dead socket file accepts connects on some platforms
|
|
216
|
-
// only to fail later, so a ping is the actual handshake.
|
|
217
|
-
|
|
284
|
+
// only to fail later, so a ping is the actual handshake. It also carries the
|
|
285
|
+
// daemon's build version and pid, so connect() can replace a stale daemon.
|
|
286
|
+
const pong = await this.request("daemon.ping", {}, PingResultSchema, 2_000);
|
|
287
|
+
|
|
288
|
+
this.daemonVersion = pong.version;
|
|
289
|
+
this.daemonPid = pong.pid;
|
|
218
290
|
// Every connection starts as a collaborator; the owner proves itself with
|
|
219
291
|
// the token the daemon wrote into the home it serves, which only the home's
|
|
220
292
|
// user can read. A capped role just names itself.
|
|
@@ -400,6 +472,16 @@ export class DaemonClient implements SessionClient {
|
|
|
400
472
|
v.array(v.object({ path: v.string(), status: v.picklist(["added", "modified", "deleted"]) })),
|
|
401
473
|
);
|
|
402
474
|
}
|
|
475
|
+
repoDiff(cwd: string): Promise<WorkingTreeDiff> {
|
|
476
|
+
return this.request(
|
|
477
|
+
"repo.diff",
|
|
478
|
+
{ cwd },
|
|
479
|
+
v.object({ patch: v.string(), files: v.array(DiffFileContentsSchema) }),
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
sessionWorkbench(cwd: string): Promise<ReviewSession> {
|
|
483
|
+
return this.request("session.workbench", { cwd }, SessionRecordSchema);
|
|
484
|
+
}
|
|
403
485
|
/** Re-capture a diff session's working tree; changed=true when the patch moved and an event fired. */
|
|
404
486
|
sessionRefreshDiff(id: string): Promise<{ changed: boolean }> {
|
|
405
487
|
return this.request("session.refreshDiff", { id }, RefreshDiffResultSchema);
|
|
@@ -417,8 +499,17 @@ export class DaemonClient implements SessionClient {
|
|
|
417
499
|
sessionSetSelfName(id: string, _name: string): Promise<ReviewSession> {
|
|
418
500
|
return this.sessionGet(id);
|
|
419
501
|
}
|
|
420
|
-
sessionResolve(
|
|
421
|
-
|
|
502
|
+
sessionResolve(
|
|
503
|
+
id: string,
|
|
504
|
+
verdictKind: VerdictKind,
|
|
505
|
+
summary: string,
|
|
506
|
+
actionBodies?: Record<string, string>,
|
|
507
|
+
): Promise<ReviewSession> {
|
|
508
|
+
return this.request(
|
|
509
|
+
"session.resolve",
|
|
510
|
+
{ id, verdictKind, summary, actionBodies },
|
|
511
|
+
SessionRecordSchema,
|
|
512
|
+
);
|
|
422
513
|
}
|
|
423
514
|
sessionSubmitRevision(
|
|
424
515
|
id: string,
|
|
@@ -453,14 +544,22 @@ export class DaemonClientError extends Error {
|
|
|
453
544
|
}
|
|
454
545
|
|
|
455
546
|
// A compiled binary re-execs `cueloop daemon --autostart` (idle-exits like main.ts,
|
|
456
|
-
// unlike the never-exiting foreground daemon); from source, bun runs main.ts.
|
|
457
|
-
|
|
547
|
+
// unlike the never-exiting foreground daemon); from source, bun runs main.ts. In
|
|
548
|
+
// dev (CUELOOP_DEV_WATCH=1, source only) it runs under --watch so daemon-code edits
|
|
549
|
+
// reload the daemon without a manual restart - the version handshake only catches
|
|
550
|
+
// release upgrades, not same-version source changes.
|
|
551
|
+
export function daemonSpawnCommand(
|
|
552
|
+
execPath: string,
|
|
553
|
+
moduleUrl: string,
|
|
554
|
+
devWatch = process.env.CUELOOP_DEV_WATCH === "1",
|
|
555
|
+
): string[] {
|
|
458
556
|
const compiled =
|
|
459
557
|
moduleUrl.includes("$bunfs") || moduleUrl.includes("~BUN") || moduleUrl.includes("%7EBUN");
|
|
460
558
|
|
|
461
|
-
return
|
|
462
|
-
|
|
463
|
-
|
|
559
|
+
if (compiled) return [execPath, "daemon", "--autostart"];
|
|
560
|
+
const mainPath = new URL("./main.ts", moduleUrl).pathname;
|
|
561
|
+
|
|
562
|
+
return devWatch ? [execPath, "--watch", "run", mainPath] : [execPath, "run", mainPath];
|
|
464
563
|
}
|
|
465
564
|
|
|
466
565
|
function spawnDaemon(home: string): void {
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Version handshake: a client must never keep talking to a daemon from an
|
|
3
|
+
* earlier build (the stale-daemon-after-upgrade bug). The ping carries the
|
|
4
|
+
* daemon's version; a mismatch without autostart is a clear error, and a
|
|
5
|
+
* matching version connects normally.
|
|
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 { DaemonServer } from "./server";
|
|
13
|
+
import { DaemonClient, DaemonClientError, daemonSpawnCommand } from "./client";
|
|
14
|
+
import { DAEMON_VERSION } from "./version";
|
|
15
|
+
import type { Artifact, WorkspaceKey } from "@cueloop/schema";
|
|
16
|
+
|
|
17
|
+
const WS: WorkspaceKey = { repoRoot: "/repo", branch: "main" };
|
|
18
|
+
const PLAN: Artifact = { type: "plan", content: "# P\n\nBody.\n", meta: {} };
|
|
19
|
+
|
|
20
|
+
let home: string;
|
|
21
|
+
const servers: DaemonServer[] = [];
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-version-"));
|
|
25
|
+
});
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
for (const runningServer of servers.splice(0)) runningServer.stop();
|
|
28
|
+
rmSync(home, { recursive: true, force: true });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function server(version?: string): DaemonServer {
|
|
32
|
+
const daemonServer = new DaemonServer({ home, idleExitMs: 0, version });
|
|
33
|
+
|
|
34
|
+
servers.push(daemonServer);
|
|
35
|
+
|
|
36
|
+
return daemonServer;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("daemon version handshake", () => {
|
|
40
|
+
test("a stale daemon is rejected when there is nothing to replace it", async () => {
|
|
41
|
+
// Arrange - a daemon posing as an earlier build
|
|
42
|
+
server("0.0.0-stale").start();
|
|
43
|
+
|
|
44
|
+
// Act + Assert - without autostart the client cannot restart it, so it says why
|
|
45
|
+
await expect(DaemonClient.connect({ home, autostart: false })).rejects.toMatchObject({
|
|
46
|
+
code: "version_mismatch",
|
|
47
|
+
});
|
|
48
|
+
await expect(DaemonClient.connect({ home, autostart: false })).rejects.toBeInstanceOf(
|
|
49
|
+
DaemonClientError,
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("a current daemon connects and serves normally", async () => {
|
|
54
|
+
// Arrange - a daemon on this build
|
|
55
|
+
server(DAEMON_VERSION).start();
|
|
56
|
+
const client = await DaemonClient.connect({ home, autostart: false });
|
|
57
|
+
|
|
58
|
+
// Act - a round-trip proves the connection is live, not replaced
|
|
59
|
+
const created = await client.sessionCreate(WS, PLAN);
|
|
60
|
+
const listed = await client.sessionList();
|
|
61
|
+
|
|
62
|
+
// Assert
|
|
63
|
+
expect(listed.map((session) => session.id)).toContain(created.id);
|
|
64
|
+
client.close();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("daemon spawn command", () => {
|
|
69
|
+
const SOURCE_URL = "file:///repo/packages/daemon/src/client.ts";
|
|
70
|
+
|
|
71
|
+
test("from source it bun-runs main.ts", () => {
|
|
72
|
+
expect(daemonSpawnCommand("/bin/bun", SOURCE_URL, false)).toEqual([
|
|
73
|
+
"/bin/bun",
|
|
74
|
+
"run",
|
|
75
|
+
"/repo/packages/daemon/src/main.ts",
|
|
76
|
+
]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("dev-watch reloads the daemon on source edits", () => {
|
|
80
|
+
expect(daemonSpawnCommand("/bin/bun", SOURCE_URL, true)).toEqual([
|
|
81
|
+
"/bin/bun",
|
|
82
|
+
"--watch",
|
|
83
|
+
"run",
|
|
84
|
+
"/repo/packages/daemon/src/main.ts",
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("a compiled binary re-execs its own daemon command, never --watch", () => {
|
|
89
|
+
expect(
|
|
90
|
+
daemonSpawnCommand("/usr/local/bin/cueloop", "file:///$bunfs/root/cueloop", true),
|
|
91
|
+
).toEqual(["/usr/local/bin/cueloop", "daemon", "--autostart"]);
|
|
92
|
+
});
|
|
93
|
+
});
|