@cueloop/daemon 0.1.0-alpha.8 → 0.1.0-alpha.80

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.
Files changed (50) hide show
  1. package/package.json +34 -22
  2. package/src/api.test.ts +925 -65
  3. package/src/api.ts +1060 -88
  4. package/src/capabilities.test.ts +57 -0
  5. package/src/capabilities.ts +84 -0
  6. package/src/client.test.ts +24 -0
  7. package/src/client.ts +526 -70
  8. package/src/curate.ts +109 -0
  9. package/src/daemon-version.test.ts +93 -0
  10. package/src/diff-hot-reload.test.ts +272 -0
  11. package/src/diff-watcher.ts +274 -0
  12. package/src/gh.ts +37 -0
  13. package/src/harness-delivery.test.ts +193 -0
  14. package/src/harness-state-store.test.ts +65 -0
  15. package/src/harness-state-store.ts +182 -0
  16. package/src/herdr-pane.test.ts +240 -0
  17. package/src/herdr-pane.ts +167 -0
  18. package/src/herdr-split.test.ts +106 -0
  19. package/src/herdr-split.ts +92 -0
  20. package/src/herdr-tab-store.test.ts +96 -0
  21. package/src/herdr-tab-store.ts +66 -0
  22. package/src/index.ts +8 -9
  23. package/src/interruptible-wait.test.ts +127 -0
  24. package/src/interruptible-wait.ts +66 -0
  25. package/src/main.ts +4 -1
  26. package/src/paths.ts +44 -0
  27. package/src/pr-poller.test.ts +76 -0
  28. package/src/pr-poller.ts +74 -0
  29. package/src/project-files.test.ts +124 -0
  30. package/src/project-files.ts +56 -0
  31. package/src/protocol.ts +79 -7
  32. package/src/retention.test.ts +191 -0
  33. package/src/retention.ts +123 -0
  34. package/src/server.test.ts +430 -42
  35. package/src/server.ts +362 -84
  36. package/src/share-blob.test.ts +112 -0
  37. package/src/share-blob.ts +55 -0
  38. package/src/single-instance.test.ts +72 -11
  39. package/src/store.conformance.test.ts +41 -0
  40. package/src/store.jsonl.test.ts +159 -0
  41. package/src/store.ts +264 -34
  42. package/src/testing/store-conformance.ts +194 -0
  43. package/src/thread-review.test.ts +394 -0
  44. package/src/thread-review.ts +324 -0
  45. package/src/validate.test.ts +214 -35
  46. package/src/validate.ts +296 -25
  47. package/src/version.ts +6 -0
  48. package/src/working-tree.ts +149 -0
  49. package/src/review.test.ts +0 -167
  50. package/src/review.ts +0 -174
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The share-blob wire format: how a Thread travels from `cueloop share`
3
+ * to the gateway. The planner's CLI packs (this is the only thing it can do -
4
+ * it holds no encryption key); the gateway unpacks, then encrypts for R2.
5
+ *
6
+ * Format is gzip(JSON). Unpack is the untrusted boundary - the bytes come off
7
+ * an SSH channel from anyone who can reach the `share` user - so it caps the
8
+ * decompressed size (a decompression-bomb guard) and runs the same record
9
+ * validator the daemon store uses, never trusting the shape.
10
+ */
11
+
12
+ import { gzipSync, gunzipSync } from "node:zlib";
13
+ import type { Thread } from "@cueloop/schema";
14
+ import { validateThreadRecord } from "./validate";
15
+
16
+ /** Decompressed ceiling for one shared session (ADR 0004's payload cap). */
17
+ export const MAX_BLOB_BYTES = 1024 * 1024;
18
+
19
+ /** The SSH username `cueloop share` connects as to upload; the gateway routes on it. */
20
+ export const SHARE_UPLOAD_USER = "share";
21
+
22
+ /** Where shares live, in one place: the client uploads here, the gateway prints it. */
23
+ export const DEFAULT_SHARE_HOST = "cueloop.dev";
24
+ export const DEFAULT_SHARE_PORT = 22;
25
+
26
+ /** Serialise + compress a session for upload. */
27
+ export function packSessionBlob(session: Thread): Buffer {
28
+ return gzipSync(Buffer.from(JSON.stringify(session), "utf8"));
29
+ }
30
+
31
+ /** Decompress + validate an uploaded blob, or throw a precise reason. */
32
+ export function unpackSessionBlob(bytes: Uint8Array): Thread {
33
+ let json: string;
34
+
35
+ try {
36
+ json = gunzipSync(bytes, { maxOutputLength: MAX_BLOB_BYTES }).toString("utf8");
37
+ } catch (err) {
38
+ throw new Error(
39
+ `blob is not valid gzip or exceeds ${MAX_BLOB_BYTES} bytes: ${err instanceof Error ? err.message : String(err)}`,
40
+ { cause: err },
41
+ );
42
+ }
43
+ let raw: unknown;
44
+
45
+ try {
46
+ raw = JSON.parse(json);
47
+ } catch {
48
+ throw new Error("blob is not valid JSON");
49
+ }
50
+ const parsed = validateThreadRecord(raw);
51
+
52
+ if (!parsed.ok) throw new Error(`blob is not a valid session: ${parsed.error}`);
53
+
54
+ return parsed.value;
55
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Single-instance guarantee (#14): one daemon per home. Two daemons over one
2
+ * Single-instance guarantee: one daemon per home. Two daemons over one
3
3
  * state directory means divergent in-memory sessions and clients seeing
4
4
  * whichever half they connect to.
5
5
  */
@@ -23,33 +23,44 @@ beforeEach(() => {
23
23
  home = mkdtempSync(join(tmpdir(), "cueloop-lock-"));
24
24
  });
25
25
  afterEach(() => {
26
- for (const s of servers.splice(0)) s.stop();
26
+ for (const runningServer of servers.splice(0)) runningServer.stop();
27
27
  rmSync(home, { recursive: true, force: true });
28
28
  });
29
29
 
30
30
  function server(): DaemonServer {
31
- const s = new DaemonServer({ home, idleExitMs: 0 });
32
- servers.push(s);
33
- return s;
31
+ const daemonServer = new DaemonServer({ home, idleExitMs: 0 });
32
+
33
+ servers.push(daemonServer);
34
+
35
+ return daemonServer;
34
36
  }
35
37
 
36
38
  describe("one daemon per home", () => {
37
39
  test("the second start is refused instead of stealing the socket", () => {
40
+ // Arrange
38
41
  const first = server();
39
42
  const path = first.start();
43
+
40
44
  expect(path).toBe(socketPath(home));
41
45
  const second = server();
46
+
47
+ // Assert
42
48
  expect(second.start()).toBeNull();
43
49
  // the winner's socket survives untouched
44
50
  expect(existsSync(path!)).toBe(true);
45
51
  });
46
52
 
47
53
  test("clients keep talking to the one live daemon after a refused start", async () => {
54
+ // Arrange
48
55
  const first = server();
56
+
49
57
  first.start();
50
58
  const created = first.core.sessionCreate({ workspace: WS, artifact: PLAN });
59
+
51
60
  server().start(); // refused
52
61
  const client = await DaemonClient.connect({ home });
62
+
63
+ // Assert
53
64
  try {
54
65
  // no split brain: the session created on the winner is visible
55
66
  expect((await client.sessionGet(created.id)).id).toBe(created.id);
@@ -60,42 +71,92 @@ describe("one daemon per home", () => {
60
71
  });
61
72
 
62
73
  test("a stale lock from a crashed daemon is reclaimed", () => {
74
+ // Arrange
63
75
  // a pid that cannot be running (init is never a cueloop daemon, and this
64
76
  // simulates the record a crashed process leaves behind)
65
77
  writeFileSync(lockPath(home), "999999");
66
- const s = server();
67
- expect(s.start()).toBe(socketPath(home));
78
+ const daemonServer = server();
79
+
80
+ // Assert
81
+ expect(daemonServer.start()).toBe(socketPath(home));
82
+ });
83
+
84
+ test("an autostarted daemon reclaims a stale socket after taking the lock", async () => {
85
+ writeFileSync(socketPath(home), "stale socket");
86
+ const client = await DaemonClient.connect({ home, autostart: true });
87
+
88
+ try {
89
+ expect((await client.ping()).pid).toBeGreaterThan(0);
90
+ } finally {
91
+ await client.shutdown();
92
+ client.close();
93
+ }
68
94
  });
69
95
 
70
96
  test("stopping releases the lock so a restart works", () => {
97
+ // Arrange
71
98
  const first = server();
99
+
72
100
  first.start();
101
+
102
+ // Act
73
103
  first.stop();
104
+
105
+ // Assert
74
106
  expect(existsSync(lockPath(home))).toBe(false);
75
107
  const second = server();
108
+
76
109
  expect(second.start()).toBe(socketPath(home));
77
110
  });
78
111
 
79
112
  test("concurrent autostarts converge on a single daemon", async () => {
113
+ // Act
80
114
  // several clients race to autostart; all must end up on the same daemon
81
115
  const clients = await Promise.all([
82
116
  DaemonClient.connect({ home, autostart: true }),
83
117
  DaemonClient.connect({ home, autostart: true }),
84
118
  DaemonClient.connect({ home, autostart: true }),
85
119
  ]);
120
+
121
+ // Assert
86
122
  try {
87
- const pids = await Promise.all(clients.map((c) => c.ping()));
88
- const unique = new Set(pids.map((p) => p.pid));
123
+ const pids = await Promise.all(clients.map((client) => client.ping()));
124
+ const unique = new Set(pids.map((ping) => ping.pid));
125
+
89
126
  expect(unique.size).toBe(1);
90
127
  // and state is shared: one client's session is visible to the others
91
- const s = await clients[0]!.sessionCreate(WS, PLAN);
92
- expect((await clients[2]!.sessionGet(s.id)).id).toBe(s.id);
128
+ const session = await clients[0]!.sessionCreate(WS, PLAN);
129
+
130
+ expect((await clients[2]!.sessionGet(session.id)).id).toBe(session.id);
93
131
  } finally {
94
132
  for (const c of clients) c.close();
95
133
  // the autostarted daemon is not one of `servers` - shut it down
96
134
  const admin = await DaemonClient.connect({ home });
135
+
97
136
  await admin.shutdown();
98
137
  admin.close();
99
138
  }
100
139
  }, 60_000);
140
+
141
+ test("autostart joins a daemon that bound its socket after the first dial", async () => {
142
+ const running = server();
143
+ const path = running.start();
144
+ const created = running.core.sessionCreate({ workspace: WS, artifact: PLAN });
145
+ const client = new DaemonClient();
146
+ const previousTimeout = process.env.CUELOOP_START_TIMEOUT_MS;
147
+
148
+ client["home"] = home;
149
+ process.env.CUELOOP_START_TIMEOUT_MS = "250";
150
+
151
+ try {
152
+ await client["attachFreshDaemon"](home, path!);
153
+
154
+ expect((await client.sessionGet(created.id)).id).toBe(created.id);
155
+ expect(existsSync(path!)).toBe(true);
156
+ } finally {
157
+ client.close();
158
+ if (previousTimeout === undefined) delete process.env.CUELOOP_START_TIMEOUT_MS;
159
+ else process.env.CUELOOP_START_TIMEOUT_MS = previousTimeout;
160
+ }
161
+ });
101
162
  });
@@ -0,0 +1,41 @@
1
+ import { mkdtempSync, writeFileSync } from "node:fs";
2
+ import * as v from "valibot";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { MemoryThreadStore, ThreadStore, type ThreadRepository } from "./store";
6
+ import { sessionsDir } from "./paths";
7
+ import { runThreadStoreConformance } from "./testing/store-conformance";
8
+
9
+ const homes = new WeakMap<ThreadRepository, string>();
10
+
11
+ runThreadStoreConformance("file store", {
12
+ open: (records) => {
13
+ const home = mkdtempSync(join(tmpdir(), "cueloop-store-"));
14
+ const store = new ThreadStore(home);
15
+
16
+ // records land on disk the way an earlier daemon would have written them;
17
+ // the file name comes from the record's id when it has one
18
+ records.forEach((record, index) => {
19
+ const named = v.safeParse(v.object({ id: v.string() }), record);
20
+ const id = named.success ? named.output.id : `bad_${index}`;
21
+
22
+ writeFileSync(join(sessionsDir(home), `${id}.json`), JSON.stringify(record));
23
+ });
24
+ homes.set(store, home);
25
+
26
+ return store;
27
+ },
28
+ restart: (store) => {
29
+ const reopened = new ThreadStore(homes.get(store)!);
30
+
31
+ homes.set(reopened, homes.get(store)!);
32
+
33
+ return reopened;
34
+ },
35
+ });
36
+
37
+ runThreadStoreConformance("memory store", {
38
+ open: (records) => new MemoryThreadStore(records),
39
+ // nothing persists past the instance: a restart recovers what it was seeded with
40
+ restart: (store) => new MemoryThreadStore(store.list()),
41
+ });
@@ -0,0 +1,159 @@
1
+ /**
2
+ * The JSONL thread store's own behaviors, beyond the shared repository contract: per-project bucket
3
+ * layout keyed by root commit, one-time migration of the old whole-record files, compaction of a
4
+ * long log, and a crash's torn trailing line being skipped on read.
5
+ */
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import {
9
+ mkdtempSync,
10
+ mkdirSync,
11
+ readdirSync,
12
+ readFileSync,
13
+ writeFileSync,
14
+ appendFileSync,
15
+ existsSync,
16
+ } from "node:fs";
17
+ import { tmpdir } from "node:os";
18
+ import { join } from "node:path";
19
+ import { ThreadStore } from "./store";
20
+ import { migratedSessionsDir, sessionsDir, threadBucket } from "./paths";
21
+ import { createTestThreadRecord } from "./testing/store-conformance";
22
+
23
+ function tmpHome(): string {
24
+ return mkdtempSync(join(tmpdir(), "cueloop-jsonl-"));
25
+ }
26
+
27
+ describe("JSONL thread store", () => {
28
+ test("a thread is written under a per-project bucket keyed by root commit", () => {
29
+ const home = tmpHome();
30
+ const store = new ThreadStore(home);
31
+ store.recover();
32
+ store.upsert(
33
+ createTestThreadRecord("ses_a", "2026-09-01T00:00:00.000Z", {
34
+ workspace: { repoRoot: "/repo", branch: "main", rootCommit: "abcdef0123456789" },
35
+ }),
36
+ );
37
+
38
+ const bucket = threadBucket("abcdef0123456789", home);
39
+ expect(readdirSync(bucket)).toContain("ses_a.jsonl");
40
+ });
41
+
42
+ test("a non-commit rootCommit (path traversal) cannot escape the threads directory", () => {
43
+ const home = tmpHome();
44
+ const escape = threadBucket("../../../etc/cueloop", home);
45
+ const standalone = threadBucket(undefined, home);
46
+
47
+ // a value that is not a real commit SHA is refused to the standalone bucket, never a parent path
48
+ expect(escape).toBe(standalone);
49
+ expect(escape.startsWith(join(home, "threads"))).toBe(true);
50
+ });
51
+
52
+ test("a repo-less thread lands in the standalone bucket", () => {
53
+ const home = tmpHome();
54
+ const store = new ThreadStore(home);
55
+ store.recover();
56
+ store.upsert(createTestThreadRecord("ses_s", "2026-09-01T00:00:00.000Z"));
57
+
58
+ expect(readdirSync(threadBucket(undefined, home))).toContain("ses_s.jsonl");
59
+ });
60
+
61
+ test("boot migrates the old whole-record file and parks the original", () => {
62
+ const home = tmpHome();
63
+ mkdirSync(sessionsDir(home), { recursive: true });
64
+ const legacy = createTestThreadRecord("ses_old", "2026-09-01T00:00:00.000Z");
65
+ writeFileSync(join(sessionsDir(home), "ses_old.json"), JSON.stringify(legacy));
66
+
67
+ const store = new ThreadStore(home);
68
+ const report = store.recover();
69
+
70
+ expect(report.recovered).toEqual(["ses_old"]);
71
+ // the record migrates intact (a history is derived on read, as the old store did)
72
+ expect(store.get("ses_old")?.id).toBe("ses_old");
73
+ expect(store.get("ses_old")?.artifact).toEqual(legacy.artifact);
74
+ // the original is parked, not left in the live legacy dir
75
+ expect(existsSync(join(sessionsDir(home), "ses_old.json"))).toBe(false);
76
+ expect(readdirSync(migratedSessionsDir(home))).toContain("ses_old.json");
77
+ });
78
+
79
+ test("migration never overwrites a newer JSONL with an older legacy snapshot", () => {
80
+ const home = tmpHome();
81
+ // a newer JSONL already holds this thread (e.g. written after a rollback)
82
+ const store = new ThreadStore(home);
83
+ store.recover();
84
+ store.upsert(
85
+ createTestThreadRecord("ses_x", "2026-09-01T00:00:00.000Z", {
86
+ status: "pending",
87
+ workspace: { repoRoot: "/repo", branch: "newer" },
88
+ }),
89
+ );
90
+ // an older legacy JSON for the same thread is restored alongside it
91
+ mkdirSync(sessionsDir(home), { recursive: true });
92
+ writeFileSync(
93
+ join(sessionsDir(home), "ses_x.json"),
94
+ JSON.stringify(
95
+ createTestThreadRecord("ses_x", "2026-09-01T00:00:00.000Z", {
96
+ workspace: { repoRoot: "/repo", branch: "older" },
97
+ }),
98
+ ),
99
+ );
100
+
101
+ const reopened = new ThreadStore(home);
102
+ reopened.recover();
103
+ // the newer JSONL wins; the legacy snapshot is parked, not applied
104
+ expect(reopened.get("ses_x")?.workspace.branch).toBe("newer");
105
+ expect(readdirSync(migratedSessionsDir(home))).toContain("ses_x.json");
106
+ });
107
+
108
+ test("a long log compacts to a single line, and the record survives it", () => {
109
+ const home = tmpHome();
110
+ const store = new ThreadStore(home);
111
+ store.recover();
112
+ // more upserts than the compaction threshold
113
+ for (let iteration = 0; iteration < 60; iteration++) {
114
+ store.upsert(
115
+ createTestThreadRecord("ses_a", "2026-09-01T00:00:00.000Z", {
116
+ status: "pending",
117
+ workspace: { repoRoot: "/repo", branch: `b${iteration}` },
118
+ }),
119
+ );
120
+ }
121
+
122
+ const file = join(threadBucket(undefined, home), "ses_a.jsonl");
123
+ const lines = readFileSync(file, "utf8")
124
+ .split("\n")
125
+ .filter((l) => l.trim());
126
+ expect(lines.length).toBeLessThan(60);
127
+ // the latest snapshot survives the compaction and a restart
128
+ const reopened = new ThreadStore(home);
129
+ reopened.recover();
130
+ expect(reopened.get("ses_a")?.workspace.branch).toBe("b59");
131
+ });
132
+
133
+ test("a crash's torn trailing line is skipped; the last valid snapshot wins", () => {
134
+ const home = tmpHome();
135
+ const store = new ThreadStore(home);
136
+ store.recover();
137
+ store.upsert(createTestThreadRecord("ses_a", "2026-09-01T00:00:00.000Z"));
138
+
139
+ // simulate a crash mid-append: a half-written line at the end
140
+ const file = join(threadBucket(undefined, home), "ses_a.jsonl");
141
+ appendFileSync(file, '{"id":"ses_a","half');
142
+
143
+ const reopened = new ThreadStore(home);
144
+ const report = reopened.recover();
145
+ expect(report.recovered).toEqual(["ses_a"]);
146
+ expect(reopened.get("ses_a")?.id).toBe("ses_a");
147
+
148
+ // an update after a torn recovery must not fuse onto the torn fragment and vanish on restart
149
+ reopened.upsert(
150
+ createTestThreadRecord("ses_a", "2026-09-01T00:00:00.000Z", {
151
+ status: "pending",
152
+ workspace: { repoRoot: "/repo", branch: "after-tear" },
153
+ }),
154
+ );
155
+ const restarted = new ThreadStore(home);
156
+ restarted.recover();
157
+ expect(restarted.get("ses_a")?.workspace.branch).toBe("after-tear");
158
+ });
159
+ });