@cueloop/daemon 0.1.0-alpha.6 → 0.1.0-alpha.61

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.
@@ -0,0 +1,62 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { gzipSync } from "node:zlib";
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import type { Artifact, WorkspaceKey } from "@cueloop/schema";
7
+ import { DaemonCore } from "./api";
8
+ import { MAX_BLOB_BYTES, packSessionBlob, unpackSessionBlob } from "./share-blob";
9
+
10
+ const WS: WorkspaceKey = { repoRoot: "/repo", branch: "main" };
11
+ const PLAN: Artifact = { type: "plan", content: "# Plan\n\nDo the thing.\n", meta: {} };
12
+
13
+ let home: string;
14
+ let core: DaemonCore;
15
+
16
+ beforeEach(() => {
17
+ home = mkdtempSync(join(tmpdir(), "cueloop-blob-"));
18
+ core = new DaemonCore(home);
19
+ });
20
+ afterEach(() => rmSync(home, { recursive: true, force: true }));
21
+
22
+ describe("share blob roundtrip", () => {
23
+ test("pack then unpack restores the session with its annotations", () => {
24
+ // Arrange
25
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
26
+ const annotated = core.sessionAnnotate(session.id, {
27
+ id: "a_1",
28
+ kind: "comment",
29
+ anchor: { quote: "the thing", prefix: "", suffix: "" },
30
+ body: "why this?",
31
+ });
32
+
33
+ // Act
34
+ const restored = unpackSessionBlob(packSessionBlob(annotated));
35
+
36
+ // Assert
37
+ expect(restored).toEqual(annotated);
38
+ });
39
+ });
40
+
41
+ describe("unpack rejects bad input", () => {
42
+ test("caps the decompressed size, so a compression bomb throws", () => {
43
+ // Arrange: tiny gzip, huge inflation - the decompression-bomb shape
44
+ const bomb = gzipSync(Buffer.alloc(MAX_BLOB_BYTES + 1, 0x61));
45
+
46
+ // Act / Assert
47
+ expect(() => unpackSessionBlob(bomb)).toThrow(/exceeds|maxOutputLength/i);
48
+ });
49
+
50
+ test("throws on bytes that are not gzip", () => {
51
+ // Act / Assert
52
+ expect(() => unpackSessionBlob(Buffer.from("not gzip at all"))).toThrow(/gzip/i);
53
+ });
54
+
55
+ test("throws on a shape that is not a session", () => {
56
+ // Arrange
57
+ const notASession = gzipSync(Buffer.from(JSON.stringify({ hello: "world" })));
58
+
59
+ // Act / Assert
60
+ expect(() => unpackSessionBlob(notASession)).toThrow(/not a valid session/i);
61
+ });
62
+ });
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The share-blob wire format: how a ReviewSession 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 { ReviewSession } from "@cueloop/schema";
14
+ import { validateSessionRecord } 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: ReviewSession): 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): ReviewSession {
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 = validateSessionRecord(raw);
51
+
52
+ if (!parsed.ok) throw new Error(`blob is not a valid session: ${parsed.error}`);
53
+
54
+ return parsed.value as ReviewSession;
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,40 +71,56 @@ 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));
68
82
  });
69
83
 
70
84
  test("stopping releases the lock so a restart works", () => {
85
+ // Arrange
71
86
  const first = server();
87
+
72
88
  first.start();
89
+
90
+ // Act
73
91
  first.stop();
92
+
93
+ // Assert
74
94
  expect(existsSync(lockPath(home))).toBe(false);
75
95
  const second = server();
96
+
76
97
  expect(second.start()).toBe(socketPath(home));
77
98
  });
78
99
 
79
100
  test("concurrent autostarts converge on a single daemon", async () => {
101
+ // Act
80
102
  // several clients race to autostart; all must end up on the same daemon
81
103
  const clients = await Promise.all([
82
104
  DaemonClient.connect({ home, autostart: true }),
83
105
  DaemonClient.connect({ home, autostart: true }),
84
106
  DaemonClient.connect({ home, autostart: true }),
85
107
  ]);
108
+
109
+ // Assert
86
110
  try {
87
- const pids = await Promise.all(clients.map((c) => c.ping()));
88
- const unique = new Set(pids.map((p) => p.pid));
111
+ const pids = await Promise.all(clients.map((client) => client.ping()));
112
+ const unique = new Set(pids.map((ping) => ping.pid));
113
+
89
114
  expect(unique.size).toBe(1);
90
115
  // 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);
116
+ const session = await clients[0]!.sessionCreate(WS, PLAN);
117
+
118
+ expect((await clients[2]!.sessionGet(session.id)).id).toBe(session.id);
93
119
  } finally {
94
120
  for (const c of clients) c.close();
95
121
  // the autostarted daemon is not one of `servers` - shut it down
96
122
  const admin = await DaemonClient.connect({ home });
123
+
97
124
  await admin.shutdown();
98
125
  admin.close();
99
126
  }
package/src/store.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Session persistence (#14): one JSON document per session, every write
2
+ * Session persistence: one JSON document per session, every write
3
3
  * through a temp file + atomic rename so a crash mid-write can never leave
4
4
  * a corrupt record. Recovery is a read-only scan; records that fail to
5
5
  * parse are skipped and reported, never deleted.
@@ -28,19 +28,23 @@ export class SessionStore {
28
28
  /** Read-only scan of the state directory; called once on boot. */
29
29
  recover(): RecoveryReport {
30
30
  const report: RecoveryReport = { recovered: [], skipped: [] };
31
+
31
32
  for (const file of readdirSync(this.dir)) {
32
33
  if (!file.endsWith(".json")) continue;
33
34
  try {
34
35
  const raw = readFileSync(join(this.dir, file), "utf8");
35
36
  const parsed = validateSessionRecord(JSON.parse(raw));
37
+
36
38
  if (!parsed.ok) throw new Error(`invalid record - ${parsed.error}`);
37
39
  const session = parsed.value as ReviewSession;
40
+
38
41
  this.sessions.set(session.id, session);
39
42
  report.recovered.push(session.id);
40
43
  } catch (err) {
41
44
  report.skipped.push({ file, error: err instanceof Error ? err.message : String(err) });
42
45
  }
43
46
  }
47
+
44
48
  return report;
45
49
  }
46
50
 
@@ -49,20 +53,24 @@ export class SessionStore {
49
53
  }
50
54
 
51
55
  list(): ReviewSession[] {
52
- return [...this.sessions.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
56
+ return [...this.sessions.values()].sort((left, right) =>
57
+ left.createdAt.localeCompare(right.createdAt),
58
+ );
53
59
  }
54
60
 
55
61
  upsert(session: ReviewSession): void {
56
62
  this.sessions.set(session.id, session);
57
63
  const path = join(this.dir, `${session.id}.json`);
58
- const tmp = path + ".tmp";
59
- writeFileSync(tmp, JSON.stringify(session, null, 2));
60
- renameSync(tmp, path);
64
+ const tempPath = path + ".tmp";
65
+
66
+ writeFileSync(tempPath, JSON.stringify(session, null, 2));
67
+ renameSync(tempPath, path);
61
68
  }
62
69
 
63
70
  delete(id: string): boolean {
64
71
  if (!this.sessions.delete(id)) return false;
65
72
  rmSync(join(this.dir, `${id}.json`), { force: true });
73
+
66
74
  return true;
67
75
  }
68
76
  }
@@ -1,7 +1,35 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { DaemonCore } from "./api";
2
6
  import { DaemonError } from "./errors";
3
- import { isKnownMethod, parseParams, validateSessionRecord } from "./validate";
4
- import { SCHEMA_VERSION } from "@cueloop/schema";
7
+ import {
8
+ AnchorSchema,
9
+ AnnotationSchema,
10
+ ArtifactMetaSchema,
11
+ ArtifactSchema,
12
+ IdentitySchema,
13
+ RevisionSchema,
14
+ SessionRecordSchema,
15
+ VerdictSchema,
16
+ WorkspaceSchema,
17
+ isKnownMethod,
18
+ parseParams,
19
+ validateSessionRecord,
20
+ } from "./validate";
21
+ import {
22
+ SCHEMA_VERSION,
23
+ type Anchor,
24
+ type Annotation,
25
+ type Artifact,
26
+ type ArtifactMeta,
27
+ type Identity,
28
+ type ReviewSession,
29
+ type Revision,
30
+ type Verdict,
31
+ type WorkspaceKey,
32
+ } from "@cueloop/schema";
5
33
 
6
34
  describe("method allowlist", () => {
7
35
  test("known and unknown methods", () => {
@@ -12,11 +40,14 @@ describe("method allowlist", () => {
12
40
 
13
41
  describe("parseParams", () => {
14
42
  test("accepts a well-formed create and defaults meta", () => {
15
- const p = parseParams("session.create", {
43
+ // Act
44
+ const params = parseParams("session.create", {
16
45
  workspace: { repoRoot: "/repo", branch: "main" },
17
46
  artifact: { type: "plan", content: "# P" },
18
47
  });
19
- expect(p.artifact.meta).toEqual({});
48
+
49
+ // Assert
50
+ expect(params.artifact.meta).toEqual({});
20
51
  });
21
52
 
22
53
  test("rejects a missing workspace with a pathed message", () => {
@@ -30,6 +61,15 @@ describe("parseParams", () => {
30
61
  }
31
62
  });
32
63
 
64
+ test("accepts the reply artifact type", () => {
65
+ const params = parseParams("session.create", {
66
+ workspace: { repoRoot: "/repo", branch: "main" },
67
+ artifact: { type: "reply", content: "# R" },
68
+ });
69
+
70
+ expect(params.artifact.type).toBe("reply");
71
+ });
72
+
33
73
  test("rejects an unknown artifact type", () => {
34
74
  expect(() =>
35
75
  parseParams("session.create", {
@@ -46,21 +86,40 @@ describe("parseParams", () => {
46
86
  test("defaults and clamps the wait timeout", () => {
47
87
  expect(parseParams("session.wait", { id: "ses_1" }).timeoutMs).toBe(60_000);
48
88
  expect(() => parseParams("session.wait", { id: "ses_1", timeoutMs: -5 })).toThrow();
49
- expect(() => parseParams("session.wait", { id: "ses_1", timeoutMs: 99 * 60 * 60 * 1000 })).toThrow();
89
+ expect(() =>
90
+ parseParams("session.wait", { id: "ses_1", timeoutMs: 99 * 60 * 60 * 1000 }),
91
+ ).toThrow();
50
92
  });
51
93
 
52
94
  test("verdict kinds are closed", () => {
53
95
  expect(parseParams("session.resolve", { id: "s", verdictKind: "approve" }).summary).toBe("");
54
- expect(() => parseParams("session.resolve", { id: "s", verdictKind: "lgtm" })).toThrow(/verdictKind/);
96
+ expect(() => parseParams("session.resolve", { id: "s", verdictKind: "lgtm" })).toThrow(
97
+ /verdictKind/,
98
+ );
55
99
  });
56
100
 
57
101
  test("annotation kinds stay open (extension kinds are allowed)", () => {
58
- const p = parseParams("session.annotate", {
102
+ // Act
103
+ const params = parseParams("session.annotate", {
59
104
  id: "s",
60
105
  annotation: { id: "a1", kind: "praise", anchor: { quote: "x" }, body: "nice" },
61
106
  });
62
- expect(p.annotation.kind).toBe("praise");
63
- expect(p.annotation.anchor.prefix).toBe("");
107
+
108
+ // Assert
109
+ expect(params.annotation.kind).toBe("praise");
110
+ expect(params.annotation.anchor.prefix).toBe("");
111
+ });
112
+
113
+ test("setViewed takes a full path list and rejects non-string entries", () => {
114
+ expect(
115
+ parseParams("session.setViewed", { id: "s", viewedPaths: ["src/a.ts", "src/b.ts"] })
116
+ .viewedPaths,
117
+ ).toEqual(["src/a.ts", "src/b.ts"]);
118
+ expect(parseParams("session.setViewed", { id: "s", viewedPaths: [] }).viewedPaths).toEqual([]);
119
+ expect(() => parseParams("session.setViewed", { id: "s", viewedPaths: [1] })).toThrow(
120
+ /viewedPaths/,
121
+ );
122
+ expect(() => parseParams("session.setViewed", { id: "s" })).toThrow(/viewedPaths/);
64
123
  });
65
124
 
66
125
  test("null params are treated as empty", () => {
@@ -86,13 +145,136 @@ describe("validateSessionRecord", () => {
86
145
  });
87
146
 
88
147
  test("rejects a foreign schema version with a readable reason", () => {
89
- const r = validateSessionRecord({ ...record, schemaVersion: "99" });
90
- expect(r.ok).toBe(false);
91
- if (!r.ok) expect(r.error).toContain("schemaVersion");
148
+ // Act
149
+ const result = validateSessionRecord({ ...record, schemaVersion: "99" });
150
+
151
+ // Assert
152
+ expect(result.ok).toBe(false);
153
+ if (!result.ok) expect(result.error).toContain("schemaVersion");
92
154
  });
93
155
 
94
156
  test("rejects a structurally broken record", () => {
95
- const r = validateSessionRecord({ ...record, revisions: "nope" });
96
- expect(r.ok).toBe(false);
157
+ // Act
158
+ const result = validateSessionRecord({ ...record, revisions: "nope" });
159
+
160
+ // Assert
161
+ expect(result.ok).toBe(false);
162
+ });
163
+ });
164
+
165
+ /**
166
+ * Wire pins: every schema's entries must cover exactly the keys of the
167
+ * schema type it mirrors. Runtime complement to the compile-time EntriesOf
168
+ * check - the samples are fully populated and typed, so a new field in
169
+ * @cueloop/schema shows up here too.
170
+ */
171
+ describe("wire pins", () => {
172
+ const keys = (subject: object) => Object.keys(subject).sort();
173
+ const entryKeys = (schema: { entries: object }) => Object.keys(schema.entries).sort();
174
+
175
+ const fullMeta: Required<ArtifactMeta> = {
176
+ cwd: "/repo",
177
+ agent: "claude-code",
178
+ agentSessionId: "sess-1",
179
+ planPath: "/repo/plan.md",
180
+ prototypePath: "/repo/proto.html",
181
+ pr: "org/repo#1",
182
+ herdrPane: "%7",
183
+ title: "Plan",
184
+ };
185
+ const fullAnchor: Required<Anchor> = {
186
+ quote: "q",
187
+ prefix: "p",
188
+ suffix: "s",
189
+ blockIndex: 0,
190
+ start: 0,
191
+ end: 1,
192
+ selector: "div.card",
193
+ };
194
+ const fullAnnotation: Required<Annotation> = {
195
+ id: "a1",
196
+ kind: "comment",
197
+ anchor: fullAnchor,
198
+ body: "b",
199
+ orphan: false,
200
+ author: "SHA256:abc",
201
+ resolution: { revision: 2, source: "agent" },
202
+ createdAt: "now",
203
+ };
204
+ const fullArtifact: Required<Artifact> = {
205
+ type: "plan",
206
+ content: "# P",
207
+ meta: fullMeta,
208
+ files: [{ path: "src/a.ts", oldContents: "old\n", newContents: "new\n", status: "modified" }],
209
+ };
210
+ const fullVerdict: Required<Verdict> = {
211
+ kind: "approve",
212
+ summary: "",
213
+ feedback: "",
214
+ resolvedAt: "now",
215
+ };
216
+ const fullRevision: Required<Revision> = { revision: 1, content: "# P", submittedAt: "now" };
217
+ const fullWorkspace: Required<WorkspaceKey> = { repoRoot: "/repo", branch: "main" };
218
+ const fullIdentity: Required<Identity> = {
219
+ id: "SHA256:abc",
220
+ provider: "ssh",
221
+ name: "Al",
222
+ handle: "abc",
223
+ };
224
+ const fullSession: Required<ReviewSession> = {
225
+ schemaVersion: SCHEMA_VERSION,
226
+ id: "ses_1",
227
+ workspace: fullWorkspace,
228
+ artifact: fullArtifact,
229
+ revisions: [fullRevision],
230
+ annotations: [fullAnnotation],
231
+ workingCopy: "# P edited",
232
+ viewedPaths: ["src/a.ts"],
233
+ verdict: fullVerdict,
234
+ status: "pending",
235
+ createdAt: "now",
236
+ shareId: "p_abc123xy",
237
+ owner: "SHA256:owner",
238
+ participants: [fullIdentity],
239
+ };
240
+
241
+ test("schema key sets match the schema types", () => {
242
+ expect(entryKeys(WorkspaceSchema)).toEqual(keys(fullWorkspace));
243
+ expect(entryKeys(ArtifactMetaSchema)).toEqual(keys(fullMeta));
244
+ expect(entryKeys(ArtifactSchema)).toEqual(keys(fullArtifact));
245
+ expect(entryKeys(AnchorSchema)).toEqual(keys(fullAnchor));
246
+ // wire annotations arrive without createdAt; the daemon stamps it
247
+ const { createdAt: _stamped, ...wireAnnotation } = fullAnnotation;
248
+
249
+ expect(entryKeys(AnnotationSchema)).toEqual(keys(wireAnnotation));
250
+ expect(entryKeys(RevisionSchema)).toEqual(keys(fullRevision));
251
+ expect(entryKeys(VerdictSchema)).toEqual(keys(fullVerdict));
252
+ expect(entryKeys(IdentitySchema)).toEqual(keys(fullIdentity));
253
+ expect(entryKeys(SessionRecordSchema)).toEqual(keys(fullSession));
254
+ // persisted annotations carry the stamped createdAt
255
+ const stored = SessionRecordSchema.entries.annotations.item;
256
+
257
+ expect(entryKeys(stored)).toEqual(keys(fullAnnotation));
258
+ });
259
+
260
+ test("a fully-populated meta survives validation and DaemonCore unchanged", () => {
261
+ const home = mkdtempSync(join(tmpdir(), "cueloop-val-"));
262
+
263
+ try {
264
+ // Arrange
265
+ const params = parseParams("session.create", {
266
+ workspace: fullWorkspace,
267
+ artifact: { type: "plan", content: "# P", meta: fullMeta },
268
+ });
269
+ const core = new DaemonCore(home);
270
+
271
+ // Act
272
+ const created = core.sessionCreate(params);
273
+
274
+ // Assert
275
+ expect(core.sessionGet(created.id).artifact.meta).toEqual(fullMeta);
276
+ } finally {
277
+ rmSync(home, { recursive: true, force: true });
278
+ }
97
279
  });
98
280
  });