@cueloop/daemon 0.1.0-alpha.59 → 0.1.0-alpha.60

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/src/server.ts CHANGED
@@ -73,16 +73,20 @@ export class DaemonServer {
73
73
  private acquireLock(): boolean {
74
74
  if (HELD_HOMES.has(this.home)) return false; // another instance here owns it
75
75
  const path = lockPath(this.home);
76
+
76
77
  for (let attempt = 0; attempt < 3; attempt++) {
77
78
  try {
78
79
  // "wx" fails when the file exists - the atomic part of the handshake
79
80
  const fd = openSync(path, "wx");
81
+
80
82
  writeFileSync(fd, String(process.pid));
81
83
  this.lockFd = fd;
82
84
  HELD_HOMES.add(this.home);
85
+
83
86
  return true;
84
87
  } catch {
85
88
  let ownerPid = 0;
89
+
86
90
  try {
87
91
  ownerPid = Number(readFileSync(path, "utf8").trim());
88
92
  } catch {
@@ -92,6 +96,7 @@ export class DaemonServer {
92
96
  if (ownerPid && ownerPid !== process.pid) {
93
97
  try {
94
98
  process.kill(ownerPid, 0); // throws when the pid is gone
99
+
95
100
  return false; // a live daemon owns this home
96
101
  } catch {
97
102
  // stale lock from a crashed daemon
@@ -106,6 +111,7 @@ export class DaemonServer {
106
111
  }
107
112
  }
108
113
  }
114
+
109
115
  return false;
110
116
  }
111
117
 
@@ -129,6 +135,7 @@ export class DaemonServer {
129
135
  start(): string | null {
130
136
  if (!this.acquireLock()) return null;
131
137
  const path = socketPath(this.home);
138
+
132
139
  // safe now: holding the lock means no live daemon owns this home, so any
133
140
  // socket file left behind is stale
134
141
  if (existsSync(path)) rmSync(path, { force: true });
@@ -146,6 +153,7 @@ export class DaemonServer {
146
153
  subscribed: false,
147
154
  role: "owner",
148
155
  };
156
+
149
157
  socket.data = { buffer: new LineBuffer(), connection, writer };
150
158
  this.connections.add(connection);
151
159
  this.scheduleIdleCheck();
@@ -170,6 +178,7 @@ export class DaemonServer {
170
178
  chmodSync(path, 0o600);
171
179
  writeFileSync(pidPath(this.home), String(process.pid));
172
180
  this.scheduleIdleCheck();
181
+
173
182
  return path;
174
183
  }
175
184
 
@@ -185,6 +194,7 @@ export class DaemonServer {
185
194
 
186
195
  private broadcast(event: DaemonEvent): void {
187
196
  const frame = JSON.stringify(event) + "\n";
197
+
188
198
  for (const connection of this.connections) if (connection.subscribed) connection.write(frame);
189
199
  this.scheduleIdleCheck();
190
200
  }
@@ -205,6 +215,7 @@ export class DaemonServer {
205
215
 
206
216
  private async respondToRequestLine(connection: Connection, line: string): Promise<void> {
207
217
  let request: Request;
218
+
208
219
  try {
209
220
  request = JSON.parse(line) as Request;
210
221
  } catch {
@@ -212,14 +223,17 @@ export class DaemonServer {
212
223
  JSON.stringify({ id: -1, error: { code: "bad_json", message: "unparseable request" } }) +
213
224
  "\n",
214
225
  );
226
+
215
227
  return;
216
228
  }
217
229
  try {
218
230
  const result = await this.dispatch(connection, request);
231
+
219
232
  connection.write(JSON.stringify({ id: request.id, result }) + "\n");
220
233
  } catch (err) {
221
234
  const code = err instanceof DaemonError ? err.code : "internal";
222
235
  const message = err instanceof Error ? err.message : String(err);
236
+
223
237
  connection.write(JSON.stringify({ id: request.id, error: { code, message } }) + "\n");
224
238
  }
225
239
  }
@@ -228,6 +242,7 @@ export class DaemonServer {
228
242
  "daemon.ping": () => ({ pid: process.pid }),
229
243
  "daemon.hello": (connection, request) => {
230
244
  connection.role = parseParams("daemon.hello", request.params).role;
245
+
231
246
  return {};
232
247
  },
233
248
  "daemon.shutdown": () => {
@@ -235,14 +250,17 @@ export class DaemonServer {
235
250
  this.stop();
236
251
  this.onIdleExit();
237
252
  }, 10);
253
+
238
254
  return {};
239
255
  },
240
256
  "events.subscribe": (connection) => {
241
257
  connection.subscribed = true;
258
+
242
259
  return {};
243
260
  },
244
261
  "session.create": (_connection, request) => {
245
262
  const params = parseParams("session.create", request.params);
263
+
246
264
  return this.core.sessionCreate({ workspace: params.workspace, artifact: params.artifact });
247
265
  },
248
266
  "session.get": (_connection, request) =>
@@ -251,38 +269,47 @@ export class DaemonServer {
251
269
  this.core.sessionList(parseParams("session.list", request.params).filter),
252
270
  "session.wait": (_connection, request) => {
253
271
  const params = parseParams("session.wait", request.params);
272
+
254
273
  return this.core.sessionWait(params.id, params.timeoutMs);
255
274
  },
256
275
  "session.annotate": (_connection, request) => {
257
276
  const params = parseParams("session.annotate", request.params);
277
+
258
278
  return this.core.sessionAnnotate(params.id, params.annotation, params.authorName);
259
279
  },
260
280
  "session.removeAnnotation": (_connection, request) => {
261
281
  const params = parseParams("session.removeAnnotation", request.params);
282
+
262
283
  return this.core.sessionRemoveAnnotation(params.id, params.annotationId);
263
284
  },
264
285
  "session.setWorkingCopy": (_connection, request) => {
265
286
  const params = parseParams("session.setWorkingCopy", request.params);
287
+
266
288
  return this.core.sessionSetWorkingCopy(params.id, params.workingCopy);
267
289
  },
268
290
  "session.setViewed": (_connection, request) => {
269
291
  const params = parseParams("session.setViewed", request.params);
292
+
270
293
  return this.core.sessionSetViewed(params.id, params.viewedPaths);
271
294
  },
272
295
  "session.refreshDiff": (_connection, request) => {
273
296
  const params = parseParams("session.refreshDiff", request.params);
297
+
274
298
  return this.core.sessionRefreshDiff(params.id);
275
299
  },
276
300
  "session.setShareId": (_connection, request) => {
277
301
  const params = parseParams("session.setShareId", request.params);
302
+
278
303
  return this.core.sessionSetShareId(params.id, params.shareId);
279
304
  },
280
305
  "session.delete": (_connection, request) => {
281
306
  this.core.sessionDelete(parseParams("session.delete", request.params).id);
307
+
282
308
  return {};
283
309
  },
284
310
  "session.mergeShared": (_connection, request) => {
285
311
  const params = parseParams("session.mergeShared", request.params);
312
+
286
313
  return this.core.sessionMergeShared(params.id, {
287
314
  annotations: params.annotations,
288
315
  participants: params.participants,
@@ -290,10 +317,12 @@ export class DaemonServer {
290
317
  },
291
318
  "session.resolve": (_connection, request) => {
292
319
  const params = parseParams("session.resolve", request.params);
320
+
293
321
  return this.core.sessionResolve(params.id, params.verdictKind, params.summary);
294
322
  },
295
323
  "session.submitRevision": (_connection, request) => {
296
324
  const params = parseParams("session.submitRevision", request.params);
325
+
297
326
  return this.core.sessionSubmitRevision(
298
327
  params.id,
299
328
  params.content,
@@ -304,7 +333,9 @@ export class DaemonServer {
304
333
  this.core.herdrGetTab(parseParams("herdr.getTab", request.params).id),
305
334
  "herdr.setTab": (_connection, request) => {
306
335
  const params = parseParams("herdr.setTab", request.params);
336
+
307
337
  this.core.herdrSetTab(params.id, { tabId: params.tabId, paneId: params.paneId });
338
+
308
339
  return {};
309
340
  },
310
341
  };
@@ -322,6 +353,7 @@ export class DaemonServer {
322
353
  if (!Object.hasOwn(this.handlers, request.method)) {
323
354
  throw new DaemonError("unknown_method", `unknown method ${request.method}`);
324
355
  }
356
+
325
357
  return this.handlers[request.method](connection, request);
326
358
  }
327
359
  }
package/src/share-blob.ts CHANGED
@@ -31,6 +31,7 @@ export function packSessionBlob(session: ReviewSession): Buffer {
31
31
  /** Decompress + validate an uploaded blob, or throw a precise reason. */
32
32
  export function unpackSessionBlob(bytes: Uint8Array): ReviewSession {
33
33
  let json: string;
34
+
34
35
  try {
35
36
  json = gunzipSync(bytes, { maxOutputLength: MAX_BLOB_BYTES }).toString("utf8");
36
37
  } catch (err) {
@@ -40,12 +41,15 @@ export function unpackSessionBlob(bytes: Uint8Array): ReviewSession {
40
41
  );
41
42
  }
42
43
  let raw: unknown;
44
+
43
45
  try {
44
46
  raw = JSON.parse(json);
45
47
  } catch {
46
48
  throw new Error("blob is not valid JSON");
47
49
  }
48
50
  const parsed = validateSessionRecord(raw);
51
+
49
52
  if (!parsed.ok) throw new Error(`blob is not a valid session: ${parsed.error}`);
53
+
50
54
  return parsed.value as ReviewSession;
51
55
  }
@@ -29,7 +29,9 @@ afterEach(() => {
29
29
 
30
30
  function server(): DaemonServer {
31
31
  const daemonServer = new DaemonServer({ home, idleExitMs: 0 });
32
+
32
33
  servers.push(daemonServer);
34
+
33
35
  return daemonServer;
34
36
  }
35
37
 
@@ -38,6 +40,7 @@ describe("one daemon per home", () => {
38
40
  // Arrange
39
41
  const first = server();
40
42
  const path = first.start();
43
+
41
44
  expect(path).toBe(socketPath(home));
42
45
  const second = server();
43
46
 
@@ -50,8 +53,10 @@ describe("one daemon per home", () => {
50
53
  test("clients keep talking to the one live daemon after a refused start", async () => {
51
54
  // Arrange
52
55
  const first = server();
56
+
53
57
  first.start();
54
58
  const created = first.core.sessionCreate({ workspace: WS, artifact: PLAN });
59
+
55
60
  server().start(); // refused
56
61
  const client = await DaemonClient.connect({ home });
57
62
 
@@ -79,6 +84,7 @@ describe("one daemon per home", () => {
79
84
  test("stopping releases the lock so a restart works", () => {
80
85
  // Arrange
81
86
  const first = server();
87
+
82
88
  first.start();
83
89
 
84
90
  // Act
@@ -87,6 +93,7 @@ describe("one daemon per home", () => {
87
93
  // Assert
88
94
  expect(existsSync(lockPath(home))).toBe(false);
89
95
  const second = server();
96
+
90
97
  expect(second.start()).toBe(socketPath(home));
91
98
  });
92
99
 
@@ -103,14 +110,17 @@ describe("one daemon per home", () => {
103
110
  try {
104
111
  const pids = await Promise.all(clients.map((client) => client.ping()));
105
112
  const unique = new Set(pids.map((ping) => ping.pid));
113
+
106
114
  expect(unique.size).toBe(1);
107
115
  // and state is shared: one client's session is visible to the others
108
116
  const session = await clients[0]!.sessionCreate(WS, PLAN);
117
+
109
118
  expect((await clients[2]!.sessionGet(session.id)).id).toBe(session.id);
110
119
  } finally {
111
120
  for (const c of clients) c.close();
112
121
  // the autostarted daemon is not one of `servers` - shut it down
113
122
  const admin = await DaemonClient.connect({ home });
123
+
114
124
  await admin.shutdown();
115
125
  admin.close();
116
126
  }
package/src/store.ts CHANGED
@@ -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
 
@@ -58,6 +62,7 @@ export class SessionStore {
58
62
  this.sessions.set(session.id, session);
59
63
  const path = join(this.dir, `${session.id}.json`);
60
64
  const tempPath = path + ".tmp";
65
+
61
66
  writeFileSync(tempPath, JSON.stringify(session, null, 2));
62
67
  renameSync(tempPath, path);
63
68
  }
@@ -65,6 +70,7 @@ export class SessionStore {
65
70
  delete(id: string): boolean {
66
71
  if (!this.sessions.delete(id)) return false;
67
72
  rmSync(join(this.dir, `${id}.json`), { force: true });
73
+
68
74
  return true;
69
75
  }
70
76
  }
@@ -61,6 +61,15 @@ describe("parseParams", () => {
61
61
  }
62
62
  });
63
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
+
64
73
  test("rejects an unknown artifact type", () => {
65
74
  expect(() =>
66
75
  parseParams("session.create", {
@@ -236,6 +245,7 @@ describe("wire pins", () => {
236
245
  expect(entryKeys(AnchorSchema)).toEqual(keys(fullAnchor));
237
246
  // wire annotations arrive without createdAt; the daemon stamps it
238
247
  const { createdAt: _stamped, ...wireAnnotation } = fullAnnotation;
248
+
239
249
  expect(entryKeys(AnnotationSchema)).toEqual(keys(wireAnnotation));
240
250
  expect(entryKeys(RevisionSchema)).toEqual(keys(fullRevision));
241
251
  expect(entryKeys(VerdictSchema)).toEqual(keys(fullVerdict));
@@ -243,11 +253,13 @@ describe("wire pins", () => {
243
253
  expect(entryKeys(SessionRecordSchema)).toEqual(keys(fullSession));
244
254
  // persisted annotations carry the stamped createdAt
245
255
  const stored = SessionRecordSchema.entries.annotations.item;
256
+
246
257
  expect(entryKeys(stored)).toEqual(keys(fullAnnotation));
247
258
  });
248
259
 
249
260
  test("a fully-populated meta survives validation and DaemonCore unchanged", () => {
250
261
  const home = mkdtempSync(join(tmpdir(), "cueloop-val-"));
262
+
251
263
  try {
252
264
  // Arrange
253
265
  const params = parseParams("session.create", {
package/src/validate.ts CHANGED
@@ -60,7 +60,7 @@ export const DiffFileContentsSchema = v.object({
60
60
  } satisfies EntriesOf<DiffFileContents>);
61
61
 
62
62
  export const ArtifactSchema = v.object({
63
- type: v.picklist(["plan", "diff", "prototype"]),
63
+ type: v.picklist(["plan", "diff", "prototype", "reply"]),
64
64
  content: v.string(),
65
65
  meta: v.optional(ArtifactMetaSchema, {}),
66
66
  files: v.optional(v.array(DiffFileContentsSchema)),
@@ -175,14 +175,17 @@ export function parseParams<M extends MethodName>(
175
175
  params: unknown,
176
176
  ): v.InferOutput<(typeof Params)[M]> {
177
177
  const result = v.safeParse(Params[method], params ?? {});
178
+
178
179
  if (!result.success) {
179
180
  const issue = result.issues[0]!;
180
181
  const path = issue.path?.map((pathSegment) => String(pathSegment.key)).join(".") ?? "";
182
+
181
183
  throw new DaemonError(
182
184
  "invalid_params",
183
185
  `${method}: ${path ? path + " - " : ""}${issue.message}`,
184
186
  );
185
187
  }
188
+
186
189
  return result.output;
187
190
  }
188
191
 
@@ -221,8 +224,10 @@ export function validateSessionRecord(
221
224
  raw: unknown,
222
225
  ): { ok: true; value: v.InferOutput<typeof SessionRecordSchema> } | { ok: false; error: string } {
223
226
  const result = v.safeParse(SessionRecordSchema, raw);
227
+
224
228
  if (result.success) return { ok: true, value: result.output };
225
229
  const issue = result.issues[0]!;
226
230
  const path = issue.path?.map((pathSegment) => String(pathSegment.key)).join(".") ?? "";
231
+
227
232
  return { ok: false, error: `${path ? path + ": " : ""}${issue.message}` };
228
233
  }
@@ -6,7 +6,9 @@ import type { DiffFileContents } from "@cueloop/schema";
6
6
  async function git(args: string[], cwd: string): Promise<string | null> {
7
7
  const gitProcess = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "ignore" });
8
8
  const stdout = await new Response(gitProcess.stdout).text();
9
+
9
10
  if ((await gitProcess.exited) !== 0) return null;
11
+
10
12
  return stdout.trim();
11
13
  }
12
14
 
@@ -18,6 +20,7 @@ async function headContents(path: string, cwd: string): Promise<string> {
18
20
  stderr: "ignore",
19
21
  });
20
22
  const stdout = await new Response(gitProcess.stdout).text();
23
+
21
24
  return (await gitProcess.exited) === 0 ? stdout : "";
22
25
  }
23
26
 
@@ -35,6 +38,7 @@ function looksBinary(text: string): boolean {
35
38
  for (let index = 0; index < text.length; index++) {
36
39
  if (text.charCodeAt(index) === 0) return true;
37
40
  }
41
+
38
42
  return false;
39
43
  }
40
44
 
@@ -51,8 +55,10 @@ async function trackedFileContents(cwd: string): Promise<DiffFileContents[]> {
51
55
  const tokens = nameStatus.split("\0").filter((token) => token.length > 0);
52
56
  const files: DiffFileContents[] = [];
53
57
  let cursor = 0;
58
+
54
59
  while (cursor < tokens.length) {
55
60
  const code = tokens[cursor++]![0];
61
+
56
62
  if (code === "R" || code === "C") {
57
63
  cursor += 2;
58
64
  continue;
@@ -60,10 +66,13 @@ async function trackedFileContents(cwd: string): Promise<DiffFileContents[]> {
60
66
  const path = tokens[cursor++]!;
61
67
  const oldContents = code === "A" ? "" : await headContents(path, cwd);
62
68
  const newContents = code === "D" ? "" : await workingContents(path, cwd);
69
+
63
70
  if (looksBinary(oldContents) || looksBinary(newContents)) continue;
64
71
  const status = code === "A" ? "added" : code === "D" ? "deleted" : "modified";
72
+
65
73
  files.push({ path, oldContents, newContents, status });
66
74
  }
75
+
67
76
  return files;
68
77
  }
69
78
 
@@ -79,6 +88,7 @@ export async function workingTreeDiff(cwd = process.cwd()): Promise<WorkingTreeD
79
88
  // untracked files included by default
80
89
  const untrackedList = (await git(["ls-files", "--others", "--exclude-standard"], cwd)) ?? "";
81
90
  let untracked = "";
91
+
82
92
  for (const file of untrackedList.split("\n").filter(Boolean)) {
83
93
  // `git diff --no-index` exits 1 when the files differ - that is success here
84
94
  const gitProcess = Bun.spawn(["git", "diff", "--no-index", "--", "/dev/null", file], {
@@ -88,13 +98,16 @@ export async function workingTreeDiff(cwd = process.cwd()): Promise<WorkingTreeD
88
98
  });
89
99
  const stdout = await new Response(gitProcess.stdout).text();
90
100
  const code = await gitProcess.exited;
101
+
91
102
  if ((code === 0 || code === 1) && stdout.trim()) {
92
103
  untracked += stdout.trim() + "\n";
93
104
  const newContents = await workingContents(file, cwd);
105
+
94
106
  if (!looksBinary(newContents)) {
95
107
  files.push({ path: file, oldContents: "", newContents, status: "added" });
96
108
  }
97
109
  }
98
110
  }
111
+
99
112
  return { patch: [tracked, untracked].filter(Boolean).join("\n"), files };
100
113
  }