@cueloop/daemon 0.1.0-alpha.0
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 +29 -0
- package/src/api.test.ts +156 -0
- package/src/api.ts +200 -0
- package/src/client.ts +183 -0
- package/src/errors.ts +10 -0
- package/src/index.ts +8 -0
- package/src/main.ts +22 -0
- package/src/paths.ts +27 -0
- package/src/protocol.ts +40 -0
- package/src/server.test.ts +103 -0
- package/src/server.ts +250 -0
- package/src/single-instance.test.ts +101 -0
- package/src/store.ts +68 -0
- package/src/validate.test.ts +98 -0
- package/src/validate.ts +133 -0
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cueloop/daemon",
|
|
3
|
+
"version": "0.1.0-alpha.0",
|
|
4
|
+
"license": "Apache-2.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts",
|
|
8
|
+
"./client": "./src/client.ts"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@cueloop/schema": "workspace:*",
|
|
12
|
+
"valibot": "^1.4.2"
|
|
13
|
+
},
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"src",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"engines": {
|
|
22
|
+
"bun": ">=1.3.0"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/mmurakaru/cueloop.git",
|
|
27
|
+
"directory": "packages/daemon"
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/api.test.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { afterEach, beforeEach, 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, verdictResponse } from "./api";
|
|
6
|
+
import { SessionStore } from "./store";
|
|
7
|
+
import type { Artifact, WorkspaceKey } from "@cueloop/schema";
|
|
8
|
+
|
|
9
|
+
const WS: WorkspaceKey = { repoRoot: "/repo", branch: "main" };
|
|
10
|
+
const PLAN: Artifact = {
|
|
11
|
+
type: "plan",
|
|
12
|
+
content: "# Plan\n\n## Context\n\nDo the thing carefully.\n",
|
|
13
|
+
meta: { agent: "test-agent", planPath: "plan.md" },
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
let home: string;
|
|
17
|
+
let core: DaemonCore;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-test-"));
|
|
21
|
+
core = new DaemonCore(home);
|
|
22
|
+
});
|
|
23
|
+
afterEach(() => rmSync(home, { recursive: true, force: true }));
|
|
24
|
+
|
|
25
|
+
describe("session lifecycle", () => {
|
|
26
|
+
test("create → get → list", () => {
|
|
27
|
+
const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
28
|
+
expect(s.status).toBe("pending");
|
|
29
|
+
expect(s.revisions.length).toBe(1);
|
|
30
|
+
expect(core.sessionGet(s.id).id).toBe(s.id);
|
|
31
|
+
expect(core.sessionList({ status: "pending" }).length).toBe(1);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("annotate upserts by id; remove deletes", () => {
|
|
35
|
+
const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
36
|
+
core.sessionAnnotate(s.id, {
|
|
37
|
+
id: "a1",
|
|
38
|
+
kind: "comment",
|
|
39
|
+
anchor: { quote: "carefully", prefix: "the thing ", suffix: "." },
|
|
40
|
+
body: "How carefully?",
|
|
41
|
+
});
|
|
42
|
+
core.sessionAnnotate(s.id, {
|
|
43
|
+
id: "a1",
|
|
44
|
+
kind: "comment",
|
|
45
|
+
anchor: { quote: "carefully", prefix: "the thing ", suffix: "." },
|
|
46
|
+
body: "Define carefully.",
|
|
47
|
+
});
|
|
48
|
+
expect(core.sessionGet(s.id).annotations.length).toBe(1);
|
|
49
|
+
expect(core.sessionGet(s.id).annotations[0]!.body).toBe("Define carefully.");
|
|
50
|
+
core.sessionRemoveAnnotation(s.id, "a1");
|
|
51
|
+
expect(core.sessionGet(s.id).annotations.length).toBe(0);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("resolve produces feedback.md and maps to the agent contract", () => {
|
|
55
|
+
const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
56
|
+
core.sessionAnnotate(s.id, {
|
|
57
|
+
id: "a1",
|
|
58
|
+
kind: "comment",
|
|
59
|
+
anchor: { quote: "carefully", prefix: "the thing ", suffix: "." },
|
|
60
|
+
body: "Spell out the steps.",
|
|
61
|
+
});
|
|
62
|
+
const resolved = core.sessionResolve(s.id, "request_changes", "Needs detail.");
|
|
63
|
+
expect(resolved.verdict!.feedback).toContain("# Review: request changes");
|
|
64
|
+
expect(resolved.verdict!.feedback).toContain("> carefully");
|
|
65
|
+
expect(verdictResponse(resolved)).toEqual({ allow: false, feedback: resolved.verdict!.feedback });
|
|
66
|
+
const approved = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
67
|
+
core.sessionResolve(approved.id, "approve", "LGTM");
|
|
68
|
+
expect(verdictResponse(core.sessionGet(approved.id)).allow).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("mutating a resolved session throws", () => {
|
|
72
|
+
const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
73
|
+
core.sessionResolve(s.id, "approve", "");
|
|
74
|
+
expect(() =>
|
|
75
|
+
core.sessionAnnotate(s.id, { id: "a1", kind: "comment", anchor: { quote: "x", prefix: "", suffix: "" }, body: "b" }),
|
|
76
|
+
).toThrow("already resolved");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("working copy stores edits and clears on revert or no-op", () => {
|
|
80
|
+
const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
81
|
+
core.sessionSetWorkingCopy(s.id, PLAN.content.replace("carefully", "very carefully"));
|
|
82
|
+
expect(core.sessionGet(s.id).workingCopy).toContain("very carefully");
|
|
83
|
+
core.sessionSetWorkingCopy(s.id, PLAN.content);
|
|
84
|
+
expect(core.sessionGet(s.id).workingCopy).toBeUndefined();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("revision reopens the session and resets working copy + verdict", () => {
|
|
88
|
+
const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
89
|
+
core.sessionSetWorkingCopy(s.id, PLAN.content + "\nedit");
|
|
90
|
+
core.sessionResolve(s.id, "request_changes", "redo");
|
|
91
|
+
const revised = core.sessionSubmitRevision(s.id, "# Plan v2\n");
|
|
92
|
+
expect(revised.status).toBe("pending");
|
|
93
|
+
expect(revised.verdict).toBeNull();
|
|
94
|
+
expect(revised.workingCopy).toBeUndefined();
|
|
95
|
+
expect(revised.revisions.length).toBe(2);
|
|
96
|
+
expect(revised.artifact.content).toBe("# Plan v2\n");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("the wait contract: verdicts outlive waits", () => {
|
|
101
|
+
test("wait resolves when the verdict arrives", async () => {
|
|
102
|
+
const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
103
|
+
const wait = core.sessionWait(s.id, 5_000);
|
|
104
|
+
core.sessionResolve(s.id, "approve", "");
|
|
105
|
+
const resolved = await wait;
|
|
106
|
+
expect(resolved!.verdict!.kind).toBe("approve");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("wait times out to null; a later wait collects the stored verdict", async () => {
|
|
110
|
+
const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
111
|
+
const first = await core.sessionWait(s.id, 30);
|
|
112
|
+
expect(first).toBeNull();
|
|
113
|
+
core.sessionResolve(s.id, "request_changes", "later");
|
|
114
|
+
const second = await core.sessionWait(s.id, 30);
|
|
115
|
+
expect(second!.verdict!.summary).toBe("later");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("multiple waiters all resolve", async () => {
|
|
119
|
+
const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
120
|
+
const waits = [core.sessionWait(s.id, 5_000), core.sessionWait(s.id, 5_000)];
|
|
121
|
+
core.sessionResolve(s.id, "approve", "");
|
|
122
|
+
const results = await Promise.all(waits);
|
|
123
|
+
expect(results.every((r) => r?.status === "resolved")).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("persistence and recovery", () => {
|
|
128
|
+
test("sessions survive a daemon restart", () => {
|
|
129
|
+
const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
130
|
+
core.sessionResolve(s.id, "approve", "done");
|
|
131
|
+
const reborn = new DaemonCore(home);
|
|
132
|
+
const recovered = reborn.sessionGet(s.id);
|
|
133
|
+
expect(recovered.verdict!.summary).toBe("done");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("corrupt records are skipped, not fatal, and never deleted", async () => {
|
|
137
|
+
core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
138
|
+
await Bun.write(join(home, "sessions", "broken.json"), "{ not json");
|
|
139
|
+
const store = new SessionStore(home);
|
|
140
|
+
const report = store.recover();
|
|
141
|
+
expect(report.recovered.length).toBe(1);
|
|
142
|
+
expect(report.skipped.length).toBe(1);
|
|
143
|
+
expect(await Bun.file(join(home, "sessions", "broken.json")).exists()).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("events", () => {
|
|
148
|
+
test("lifecycle events fire in order", () => {
|
|
149
|
+
const seen: string[] = [];
|
|
150
|
+
core.onEvent((e) => seen.push(e.event));
|
|
151
|
+
const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
152
|
+
core.sessionAnnotate(s.id, { id: "a1", kind: "comment", anchor: { quote: "thing", prefix: "", suffix: "" }, body: "b" });
|
|
153
|
+
core.sessionResolve(s.id, "approve", "");
|
|
154
|
+
expect(seen).toEqual(["session.created", "inbox.changed", "session.updated", "session.resolved", "inbox.changed"]);
|
|
155
|
+
});
|
|
156
|
+
});
|
package/src/api.ts
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The daemon's method surface (#14). Transport-independent: the socket
|
|
3
|
+
* server and the in-process test harness both call these handlers.
|
|
4
|
+
* The wait contract: verdicts outlive waits - session.wait long-polls,
|
|
5
|
+
* and a verdict resolved while nobody waited is delivered on next contact.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
SCHEMA_VERSION,
|
|
10
|
+
feedbackForSession,
|
|
11
|
+
verdictAllows,
|
|
12
|
+
type Annotation,
|
|
13
|
+
type Artifact,
|
|
14
|
+
type ReviewSession,
|
|
15
|
+
type Verdict,
|
|
16
|
+
type VerdictKind,
|
|
17
|
+
type WorkspaceKey,
|
|
18
|
+
} from "@cueloop/schema";
|
|
19
|
+
import { SessionStore } from "./store";
|
|
20
|
+
import { DaemonError } from "./errors";
|
|
21
|
+
|
|
22
|
+
export type EventName =
|
|
23
|
+
| "session.created"
|
|
24
|
+
| "session.updated"
|
|
25
|
+
| "session.resolved"
|
|
26
|
+
| "session.revised"
|
|
27
|
+
| "inbox.changed";
|
|
28
|
+
|
|
29
|
+
export interface DaemonEvent {
|
|
30
|
+
event: EventName;
|
|
31
|
+
sessionId: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type EventListener = (e: DaemonEvent) => void;
|
|
35
|
+
|
|
36
|
+
export class DaemonCore {
|
|
37
|
+
readonly store: SessionStore;
|
|
38
|
+
private waiters = new Map<string, ((session: ReviewSession) => void)[]>();
|
|
39
|
+
private listeners = new Set<EventListener>();
|
|
40
|
+
private seq = 0;
|
|
41
|
+
|
|
42
|
+
constructor(home: string) {
|
|
43
|
+
this.store = new SessionStore(home);
|
|
44
|
+
this.store.recover();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
onEvent(listener: EventListener): () => void {
|
|
48
|
+
this.listeners.add(listener);
|
|
49
|
+
return () => this.listeners.delete(listener);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private emit(event: EventName, sessionId: string): void {
|
|
53
|
+
for (const l of this.listeners) l({ event, sessionId });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** True when nothing awaits a verdict - drives idle-exit. */
|
|
57
|
+
hasPendingSessions(): boolean {
|
|
58
|
+
return this.store.list().some((s) => s.status === "pending");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
sessionCreate(params: { workspace: WorkspaceKey; artifact: Artifact }): ReviewSession {
|
|
62
|
+
const now = new Date().toISOString();
|
|
63
|
+
const session: ReviewSession = {
|
|
64
|
+
schemaVersion: SCHEMA_VERSION,
|
|
65
|
+
id: `ses_${now.replace(/\D/g, "").slice(0, 14)}_${(++this.seq).toString(36)}${Math.random().toString(36).slice(2, 6)}`,
|
|
66
|
+
workspace: params.workspace,
|
|
67
|
+
artifact: params.artifact,
|
|
68
|
+
revisions: [{ revision: 1, content: params.artifact.content, submittedAt: now }],
|
|
69
|
+
annotations: [],
|
|
70
|
+
verdict: null,
|
|
71
|
+
status: "pending",
|
|
72
|
+
createdAt: now,
|
|
73
|
+
};
|
|
74
|
+
this.store.upsert(session);
|
|
75
|
+
this.emit("session.created", session.id);
|
|
76
|
+
this.emit("inbox.changed", session.id);
|
|
77
|
+
return session;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
sessionGet(id: string): ReviewSession {
|
|
81
|
+
const s = this.store.get(id);
|
|
82
|
+
if (!s) throw new DaemonError("not_found", `no session ${id}`);
|
|
83
|
+
return s;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
sessionList(filter?: { status?: "pending" | "resolved"; workspace?: Partial<WorkspaceKey> }): ReviewSession[] {
|
|
87
|
+
return this.store.list().filter((s) => {
|
|
88
|
+
if (filter?.status && s.status !== filter.status) return false;
|
|
89
|
+
if (filter?.workspace?.repoRoot && s.workspace.repoRoot !== filter.workspace.repoRoot) return false;
|
|
90
|
+
if (filter?.workspace?.branch && s.workspace.branch !== filter.workspace.branch) return false;
|
|
91
|
+
return true;
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Long-poll for the verdict. Resolves immediately when already resolved;
|
|
97
|
+
* otherwise parks until sessionResolve fires or timeoutMs elapses (null =
|
|
98
|
+
* still pending - the caller re-polls later; the verdict is never lost).
|
|
99
|
+
*/
|
|
100
|
+
sessionWait(id: string, timeoutMs: number): Promise<ReviewSession | null> {
|
|
101
|
+
const s = this.sessionGet(id);
|
|
102
|
+
if (s.status === "resolved") return Promise.resolve(s);
|
|
103
|
+
return new Promise((resolve) => {
|
|
104
|
+
const list = this.waiters.get(id) ?? [];
|
|
105
|
+
let done = false;
|
|
106
|
+
const timer = setTimeout(() => {
|
|
107
|
+
if (done) return;
|
|
108
|
+
done = true;
|
|
109
|
+
const idx = list.indexOf(waiter);
|
|
110
|
+
if (idx !== -1) list.splice(idx, 1);
|
|
111
|
+
resolve(null);
|
|
112
|
+
}, timeoutMs);
|
|
113
|
+
const waiter = (session: ReviewSession) => {
|
|
114
|
+
if (done) return;
|
|
115
|
+
done = true;
|
|
116
|
+
clearTimeout(timer);
|
|
117
|
+
resolve(session);
|
|
118
|
+
};
|
|
119
|
+
list.push(waiter);
|
|
120
|
+
this.waiters.set(id, list);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
sessionAnnotate(id: string, annotation: Omit<Annotation, "createdAt">): ReviewSession {
|
|
125
|
+
const s = this.mutable(id);
|
|
126
|
+
const existing = s.annotations.findIndex((a) => a.id === annotation.id);
|
|
127
|
+
const full: Annotation = { ...annotation, createdAt: new Date().toISOString() };
|
|
128
|
+
if (existing === -1) s.annotations.push(full);
|
|
129
|
+
else s.annotations[existing] = { ...full, createdAt: s.annotations[existing]!.createdAt };
|
|
130
|
+
this.store.upsert(s);
|
|
131
|
+
this.emit("session.updated", id);
|
|
132
|
+
return s;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
sessionRemoveAnnotation(id: string, annotationId: string): ReviewSession {
|
|
136
|
+
const s = this.mutable(id);
|
|
137
|
+
s.annotations = s.annotations.filter((a) => a.id !== annotationId);
|
|
138
|
+
this.store.upsert(s);
|
|
139
|
+
this.emit("session.updated", id);
|
|
140
|
+
return s;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The reviewer's working copy; undefined clears it (revert all edits). */
|
|
144
|
+
sessionSetWorkingCopy(id: string, workingCopy: string | undefined): ReviewSession {
|
|
145
|
+
const s = this.mutable(id);
|
|
146
|
+
if (workingCopy === undefined || workingCopy === s.artifact.content) delete s.workingCopy;
|
|
147
|
+
else s.workingCopy = workingCopy;
|
|
148
|
+
this.store.upsert(s);
|
|
149
|
+
this.emit("session.updated", id);
|
|
150
|
+
return s;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
sessionResolve(id: string, verdictKind: VerdictKind, summary: string): ReviewSession {
|
|
154
|
+
const s = this.mutable(id);
|
|
155
|
+
const verdict: Verdict = {
|
|
156
|
+
kind: verdictKind,
|
|
157
|
+
summary,
|
|
158
|
+
feedback: feedbackForSession(s, verdictKind, summary),
|
|
159
|
+
resolvedAt: new Date().toISOString(),
|
|
160
|
+
};
|
|
161
|
+
s.verdict = verdict;
|
|
162
|
+
s.status = "resolved";
|
|
163
|
+
this.store.upsert(s);
|
|
164
|
+
const parked = this.waiters.get(id) ?? [];
|
|
165
|
+
this.waiters.delete(id);
|
|
166
|
+
for (const w of parked) w(s);
|
|
167
|
+
this.emit("session.resolved", id);
|
|
168
|
+
this.emit("inbox.changed", id);
|
|
169
|
+
return s;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Agent resubmits: new revision becomes the artifact, session reopens. */
|
|
173
|
+
sessionSubmitRevision(id: string, content: string): ReviewSession {
|
|
174
|
+
const s = this.sessionGet(id);
|
|
175
|
+
const now = new Date().toISOString();
|
|
176
|
+
s.revisions.push({ revision: s.revisions.length + 1, content, submittedAt: now });
|
|
177
|
+
s.artifact = { ...s.artifact, content };
|
|
178
|
+
delete s.workingCopy;
|
|
179
|
+
s.verdict = null;
|
|
180
|
+
s.status = "pending";
|
|
181
|
+
this.store.upsert(s);
|
|
182
|
+
this.emit("session.revised", id);
|
|
183
|
+
this.emit("inbox.changed", id);
|
|
184
|
+
return s;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private mutable(id: string): ReviewSession {
|
|
188
|
+
const s = this.sessionGet(id);
|
|
189
|
+
if (s.status === "resolved") throw new DaemonError("resolved", `session ${id} is already resolved`);
|
|
190
|
+
return s;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export { DaemonError };
|
|
195
|
+
|
|
196
|
+
/** Convenience for adapters: map a resolved session to the agent contract. */
|
|
197
|
+
export function verdictResponse(session: ReviewSession): { allow: boolean; feedback: string } {
|
|
198
|
+
if (!session.verdict) throw new DaemonError("pending", "session has no verdict");
|
|
199
|
+
return { allow: verdictAllows(session.verdict.kind), feedback: session.verdict.feedback };
|
|
200
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon client: the one library every consumer shares - CLI verbs, the TUI,
|
|
3
|
+
* adapters, and tests. Also owns the lazy-launch story (#14): connect() with
|
|
4
|
+
* autostart spawns a detached daemon when the socket is dead, then attaches.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, rmSync } from "node:fs";
|
|
8
|
+
import type { Annotation, Artifact, ReviewSession, VerdictKind, WorkspaceKey } from "@cueloop/schema";
|
|
9
|
+
import { LineBuffer, type EventFrame, type Response } from "./protocol";
|
|
10
|
+
import { cueloopHome, socketPath } from "./paths";
|
|
11
|
+
|
|
12
|
+
export interface ConnectOptions {
|
|
13
|
+
home?: string;
|
|
14
|
+
/** Spawn the daemon when the socket is not alive. */
|
|
15
|
+
autostart?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type Pending = { resolve: (v: unknown) => void; reject: (e: Error) => void };
|
|
19
|
+
|
|
20
|
+
export class DaemonClient {
|
|
21
|
+
private socket: Awaited<ReturnType<typeof Bun.connect>> | null = null;
|
|
22
|
+
private pending = new Map<number, Pending>();
|
|
23
|
+
private nextId = 1;
|
|
24
|
+
private eventListeners = new Set<(e: EventFrame) => void>();
|
|
25
|
+
private closed = false;
|
|
26
|
+
|
|
27
|
+
static async connect(opts: ConnectOptions = {}): Promise<DaemonClient> {
|
|
28
|
+
const home = opts.home ?? cueloopHome();
|
|
29
|
+
const path = socketPath(home);
|
|
30
|
+
const client = new DaemonClient();
|
|
31
|
+
try {
|
|
32
|
+
await client.dial(path);
|
|
33
|
+
return client;
|
|
34
|
+
} catch (err) {
|
|
35
|
+
if (!opts.autostart) throw err;
|
|
36
|
+
}
|
|
37
|
+
// Socket dead or absent: clean a stale file and spawn the daemon detached.
|
|
38
|
+
if (existsSync(path)) rmSync(path, { force: true });
|
|
39
|
+
spawnDaemon(home);
|
|
40
|
+
// Generous: a cold or loaded machine pays for a runtime start before the
|
|
41
|
+
// socket exists, and giving up early looks to callers like a broken daemon.
|
|
42
|
+
const deadline = Date.now() + Number(process.env.CUELOOP_START_TIMEOUT_MS ?? 30_000);
|
|
43
|
+
let lastErr: unknown;
|
|
44
|
+
while (Date.now() < deadline) {
|
|
45
|
+
try {
|
|
46
|
+
await client.dial(path);
|
|
47
|
+
return client;
|
|
48
|
+
} catch (err) {
|
|
49
|
+
lastErr = err;
|
|
50
|
+
await Bun.sleep(50);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
throw new Error(`daemon did not come up at ${path}: ${String(lastErr)}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private async dial(path: string): Promise<void> {
|
|
57
|
+
const buffer = new LineBuffer();
|
|
58
|
+
const self = this;
|
|
59
|
+
this.socket = await Bun.connect({
|
|
60
|
+
unix: path,
|
|
61
|
+
socket: {
|
|
62
|
+
data(_socket, data) {
|
|
63
|
+
buffer.push(data.toString(), (line) => self.handleFrame(line));
|
|
64
|
+
},
|
|
65
|
+
close() {
|
|
66
|
+
self.closed = true;
|
|
67
|
+
for (const p of self.pending.values()) p.reject(new Error("daemon connection closed"));
|
|
68
|
+
self.pending.clear();
|
|
69
|
+
},
|
|
70
|
+
error() {},
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
// Verify liveness: a dead socket file accepts connects on some platforms
|
|
74
|
+
// only to fail later, so a ping is the actual handshake.
|
|
75
|
+
await this.request("daemon.ping", {}, 2_000);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
onEvent(listener: (e: EventFrame) => void): () => void {
|
|
79
|
+
this.eventListeners.add(listener);
|
|
80
|
+
return () => this.eventListeners.delete(listener);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private handleFrame(line: string): void {
|
|
84
|
+
let frame: Response | EventFrame;
|
|
85
|
+
try {
|
|
86
|
+
frame = JSON.parse(line) as Response | EventFrame;
|
|
87
|
+
} catch {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if ("event" in frame) {
|
|
91
|
+
for (const l of this.eventListeners) l(frame);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const p = this.pending.get(frame.id);
|
|
95
|
+
if (!p) return;
|
|
96
|
+
this.pending.delete(frame.id);
|
|
97
|
+
if (frame.error) p.reject(new DaemonClientError(frame.error.code, frame.error.message));
|
|
98
|
+
else p.resolve(frame.result);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
request<T = unknown>(method: string, params: unknown, timeoutMs = 30_000): Promise<T> {
|
|
102
|
+
if (this.closed || !this.socket) return Promise.reject(new Error("not connected"));
|
|
103
|
+
const id = this.nextId++;
|
|
104
|
+
return new Promise<T>((resolve, reject) => {
|
|
105
|
+
const timer = setTimeout(() => {
|
|
106
|
+
this.pending.delete(id);
|
|
107
|
+
reject(new Error(`request ${method} timed out`));
|
|
108
|
+
}, timeoutMs);
|
|
109
|
+
this.pending.set(id, {
|
|
110
|
+
resolve: (v) => {
|
|
111
|
+
clearTimeout(timer);
|
|
112
|
+
resolve(v as T);
|
|
113
|
+
},
|
|
114
|
+
reject: (e) => {
|
|
115
|
+
clearTimeout(timer);
|
|
116
|
+
reject(e);
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
this.socket!.write(JSON.stringify({ id, method, params }) + "\n");
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
close(): void {
|
|
124
|
+
this.closed = true;
|
|
125
|
+
this.socket?.end();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── typed verbs ─────────────────────────────
|
|
129
|
+
ping(): Promise<{ pid: number }> {
|
|
130
|
+
return this.request("daemon.ping", {});
|
|
131
|
+
}
|
|
132
|
+
subscribe(): Promise<void> {
|
|
133
|
+
return this.request("events.subscribe", {});
|
|
134
|
+
}
|
|
135
|
+
sessionCreate(workspace: WorkspaceKey, artifact: Artifact): Promise<ReviewSession> {
|
|
136
|
+
return this.request("session.create", { workspace, artifact });
|
|
137
|
+
}
|
|
138
|
+
sessionGet(id: string): Promise<ReviewSession> {
|
|
139
|
+
return this.request("session.get", { id });
|
|
140
|
+
}
|
|
141
|
+
sessionList(filter?: { status?: "pending" | "resolved" }): Promise<ReviewSession[]> {
|
|
142
|
+
return this.request("session.list", { filter });
|
|
143
|
+
}
|
|
144
|
+
/** Long-poll; null = still pending after timeoutMs (re-poll to collect). */
|
|
145
|
+
sessionWait(id: string, timeoutMs: number): Promise<ReviewSession | null> {
|
|
146
|
+
return this.request("session.wait", { id, timeoutMs }, timeoutMs + 10_000);
|
|
147
|
+
}
|
|
148
|
+
sessionAnnotate(id: string, annotation: Omit<Annotation, "createdAt">): Promise<ReviewSession> {
|
|
149
|
+
return this.request("session.annotate", { id, annotation });
|
|
150
|
+
}
|
|
151
|
+
sessionRemoveAnnotation(id: string, annotationId: string): Promise<ReviewSession> {
|
|
152
|
+
return this.request("session.removeAnnotation", { id, annotationId });
|
|
153
|
+
}
|
|
154
|
+
sessionSetWorkingCopy(id: string, workingCopy: string | undefined): Promise<ReviewSession> {
|
|
155
|
+
return this.request("session.setWorkingCopy", { id, workingCopy });
|
|
156
|
+
}
|
|
157
|
+
sessionResolve(id: string, verdictKind: VerdictKind, summary: string): Promise<ReviewSession> {
|
|
158
|
+
return this.request("session.resolve", { id, verdictKind, summary });
|
|
159
|
+
}
|
|
160
|
+
sessionSubmitRevision(id: string, content: string): Promise<ReviewSession> {
|
|
161
|
+
return this.request("session.submitRevision", { id, content });
|
|
162
|
+
}
|
|
163
|
+
shutdown(): Promise<void> {
|
|
164
|
+
return this.request("daemon.shutdown", {});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export class DaemonClientError extends Error {
|
|
169
|
+
constructor(
|
|
170
|
+
readonly code: string,
|
|
171
|
+
message: string,
|
|
172
|
+
) {
|
|
173
|
+
super(message);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function spawnDaemon(home: string): void {
|
|
178
|
+
const entry = new URL("./main.ts", import.meta.url).pathname;
|
|
179
|
+
Bun.spawn([process.execPath, "run", entry], {
|
|
180
|
+
env: { ...process.env, CUELOOP_HOME: home },
|
|
181
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
182
|
+
}).unref();
|
|
183
|
+
}
|
package/src/errors.ts
ADDED
package/src/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from "./api";
|
|
2
|
+
export * from "./errors";
|
|
3
|
+
export * from "./validate";
|
|
4
|
+
export * from "./store";
|
|
5
|
+
export * from "./server";
|
|
6
|
+
export * from "./protocol";
|
|
7
|
+
export * from "./paths";
|
|
8
|
+
export { DaemonClient, DaemonClientError, type ConnectOptions } from "./client";
|
package/src/main.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Daemon entry point: run detached by DaemonClient.connect({autostart}). */
|
|
2
|
+
|
|
3
|
+
import { DaemonServer } from "./server";
|
|
4
|
+
|
|
5
|
+
const idleExitMs = process.env.CUELOOP_IDLE_EXIT_MS ? Number(process.env.CUELOOP_IDLE_EXIT_MS) : undefined;
|
|
6
|
+
const server = new DaemonServer({ idleExitMs });
|
|
7
|
+
const path = server.start();
|
|
8
|
+
if (path === null) {
|
|
9
|
+
// another daemon already owns this home; the client will attach to it
|
|
10
|
+
console.log("cueloop daemon already running for this home");
|
|
11
|
+
process.exit(0);
|
|
12
|
+
}
|
|
13
|
+
console.log(`cueloop daemon listening on ${path}`);
|
|
14
|
+
|
|
15
|
+
process.on("SIGINT", () => {
|
|
16
|
+
server.stop();
|
|
17
|
+
process.exit(0);
|
|
18
|
+
});
|
|
19
|
+
process.on("SIGTERM", () => {
|
|
20
|
+
server.stop();
|
|
21
|
+
process.exit(0);
|
|
22
|
+
});
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State-dir resolution. Everything the daemon persists lives under one home
|
|
3
|
+
* directory; tests point CUELOOP_HOME at a temp dir to get full isolation.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
|
|
9
|
+
export function cueloopHome(): string {
|
|
10
|
+
return process.env.CUELOOP_HOME ?? join(homedir(), ".cueloop");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function socketPath(home = cueloopHome()): string {
|
|
14
|
+
return join(home, "cueloop.sock");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function sessionsDir(home = cueloopHome()): string {
|
|
18
|
+
return join(home, "sessions");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function pidPath(home = cueloopHome()): string {
|
|
22
|
+
return join(home, "cueloop.pid");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function lockPath(home = cueloopHome()): string {
|
|
26
|
+
return join(home, "cueloop.lock");
|
|
27
|
+
}
|
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire protocol (#14): newline-delimited JSON over the unix socket.
|
|
3
|
+
* Requests: { id, method, params } Responses: { id, result } | { id, error }
|
|
4
|
+
* Events (push, after events.subscribe): { event, sessionId }
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface Request {
|
|
8
|
+
id: number;
|
|
9
|
+
method: string;
|
|
10
|
+
params?: unknown;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface Response {
|
|
14
|
+
id: number;
|
|
15
|
+
result?: unknown;
|
|
16
|
+
error?: { code: string; message: string };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface EventFrame {
|
|
20
|
+
event: string;
|
|
21
|
+
sessionId: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type Frame = Response | EventFrame;
|
|
25
|
+
|
|
26
|
+
/** Incremental NDJSON splitter; tolerates partial writes. */
|
|
27
|
+
export class LineBuffer {
|
|
28
|
+
private buf = "";
|
|
29
|
+
|
|
30
|
+
push(chunk: string, onLine: (line: string) => void): void {
|
|
31
|
+
this.buf += chunk;
|
|
32
|
+
for (;;) {
|
|
33
|
+
const nl = this.buf.indexOf("\n");
|
|
34
|
+
if (nl === -1) return;
|
|
35
|
+
const line = this.buf.slice(0, nl).trim();
|
|
36
|
+
this.buf = this.buf.slice(nl + 1);
|
|
37
|
+
if (line) onLine(line);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|