@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueloop/daemon",
3
- "version": "0.1.0-alpha.68",
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.68",
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(id: string, verdictKind: VerdictKind, summary: string): ReviewSession {
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" };
@@ -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
- const PingResultSchema = v.object({ pid: v.number() });
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(id: string, verdictKind: VerdictKind, summary: string): Promise<ReviewSession>;
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
- return client;
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 absent: clean a stale file and spawn the daemon detached.
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 client.dial(path);
250
+ await this.dial(path);
183
251
 
184
- return client;
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
- await this.request("daemon.ping", {}, PingResultSchema, 2_000);
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(id: string, verdictKind: VerdictKind, summary: string): Promise<ReviewSession> {
421
- return this.request("session.resolve", { id, verdictKind, summary }, SessionRecordSchema);
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
- export function daemonSpawnCommand(execPath: string, moduleUrl: string): string[] {
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 compiled
462
- ? [execPath, "daemon", "--autostart"]
463
- : [execPath, "run", new URL("./main.ts", moduleUrl).pathname];
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
+ });