@cueloop/daemon 0.1.0-alpha.2 → 0.1.0-alpha.20
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 +11 -5
- package/src/api.test.ts +58 -44
- package/src/api.ts +62 -48
- package/src/client.ts +33 -25
- package/src/index.ts +8 -8
- package/src/protocol.ts +44 -7
- package/src/review.test.ts +184 -0
- package/src/review.ts +208 -0
- package/src/server.test.ts +49 -20
- package/src/server.ts +58 -48
- package/src/single-instance.test.ts +11 -11
- package/src/store.ts +5 -5
- package/src/validate.test.ts +126 -12
- package/src/validate.ts +59 -31
package/package.json
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/daemon",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.20",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": "./src/index.ts",
|
|
8
|
-
"./client": "./src/client.ts"
|
|
8
|
+
"./client": "./src/client.ts",
|
|
9
|
+
"./review": "./src/review.ts"
|
|
9
10
|
},
|
|
10
11
|
"dependencies": {
|
|
11
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
12
|
+
"@cueloop/schema": "0.1.0-alpha.20",
|
|
12
13
|
"valibot": "^1.4.2"
|
|
13
14
|
},
|
|
14
15
|
"publishConfig": {
|
|
@@ -25,5 +26,10 @@
|
|
|
25
26
|
"type": "git",
|
|
26
27
|
"url": "git+https://github.com/mmurakaru/cueloop.git",
|
|
27
28
|
"directory": "packages/daemon"
|
|
28
|
-
}
|
|
29
|
-
|
|
29
|
+
},
|
|
30
|
+
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/mmurakaru/cueloop/issues"
|
|
33
|
+
},
|
|
34
|
+
"description": "The cueloop session daemon and its client: socket protocol, atomic store, resumable waits"
|
|
35
|
+
}
|
package/src/api.test.ts
CHANGED
|
@@ -24,42 +24,42 @@ afterEach(() => rmSync(home, { recursive: true, force: true }));
|
|
|
24
24
|
|
|
25
25
|
describe("session lifecycle", () => {
|
|
26
26
|
test("create → get → list", () => {
|
|
27
|
-
const
|
|
28
|
-
expect(
|
|
29
|
-
expect(
|
|
30
|
-
expect(core.sessionGet(
|
|
27
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
28
|
+
expect(session.status).toBe("pending");
|
|
29
|
+
expect(session.revisions.length).toBe(1);
|
|
30
|
+
expect(core.sessionGet(session.id).id).toBe(session.id);
|
|
31
31
|
expect(core.sessionList({ status: "pending" }).length).toBe(1);
|
|
32
32
|
});
|
|
33
33
|
|
|
34
34
|
test("annotate upserts by id; remove deletes", () => {
|
|
35
|
-
const
|
|
36
|
-
core.sessionAnnotate(
|
|
35
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
36
|
+
core.sessionAnnotate(session.id, {
|
|
37
37
|
id: "a1",
|
|
38
38
|
kind: "comment",
|
|
39
39
|
anchor: { quote: "carefully", prefix: "the thing ", suffix: "." },
|
|
40
40
|
body: "How carefully?",
|
|
41
41
|
});
|
|
42
|
-
core.sessionAnnotate(
|
|
42
|
+
core.sessionAnnotate(session.id, {
|
|
43
43
|
id: "a1",
|
|
44
44
|
kind: "comment",
|
|
45
45
|
anchor: { quote: "carefully", prefix: "the thing ", suffix: "." },
|
|
46
46
|
body: "Define carefully.",
|
|
47
47
|
});
|
|
48
|
-
expect(core.sessionGet(
|
|
49
|
-
expect(core.sessionGet(
|
|
50
|
-
core.sessionRemoveAnnotation(
|
|
51
|
-
expect(core.sessionGet(
|
|
48
|
+
expect(core.sessionGet(session.id).annotations.length).toBe(1);
|
|
49
|
+
expect(core.sessionGet(session.id).annotations[0]!.body).toBe("Define carefully.");
|
|
50
|
+
core.sessionRemoveAnnotation(session.id, "a1");
|
|
51
|
+
expect(core.sessionGet(session.id).annotations.length).toBe(0);
|
|
52
52
|
});
|
|
53
53
|
|
|
54
54
|
test("resolve produces feedback.md and maps to the agent contract", () => {
|
|
55
|
-
const
|
|
56
|
-
core.sessionAnnotate(
|
|
55
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
56
|
+
core.sessionAnnotate(session.id, {
|
|
57
57
|
id: "a1",
|
|
58
58
|
kind: "comment",
|
|
59
59
|
anchor: { quote: "carefully", prefix: "the thing ", suffix: "." },
|
|
60
60
|
body: "Spell out the steps.",
|
|
61
61
|
});
|
|
62
|
-
const resolved = core.sessionResolve(
|
|
62
|
+
const resolved = core.sessionResolve(session.id, "request_changes", "Needs detail.");
|
|
63
63
|
expect(resolved.verdict!.feedback).toContain("# Review: request changes");
|
|
64
64
|
expect(resolved.verdict!.feedback).toContain("> carefully");
|
|
65
65
|
expect(verdictResponse(resolved)).toEqual({ allow: false, feedback: resolved.verdict!.feedback });
|
|
@@ -69,26 +69,40 @@ describe("session lifecycle", () => {
|
|
|
69
69
|
});
|
|
70
70
|
|
|
71
71
|
test("mutating a resolved session throws", () => {
|
|
72
|
-
const
|
|
73
|
-
core.sessionResolve(
|
|
72
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
73
|
+
core.sessionResolve(session.id, "approve", "");
|
|
74
74
|
expect(() =>
|
|
75
|
-
core.sessionAnnotate(
|
|
75
|
+
core.sessionAnnotate(session.id, { id: "a1", kind: "comment", anchor: { quote: "x", prefix: "", suffix: "" }, body: "b" }),
|
|
76
76
|
).toThrow("already resolved");
|
|
77
77
|
});
|
|
78
78
|
|
|
79
79
|
test("working copy stores edits and clears on revert or no-op", () => {
|
|
80
|
-
const
|
|
81
|
-
core.sessionSetWorkingCopy(
|
|
82
|
-
expect(core.sessionGet(
|
|
83
|
-
core.sessionSetWorkingCopy(
|
|
84
|
-
expect(core.sessionGet(
|
|
80
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
81
|
+
core.sessionSetWorkingCopy(session.id, PLAN.content.replace("carefully", "very carefully"));
|
|
82
|
+
expect(core.sessionGet(session.id).workingCopy).toContain("very carefully");
|
|
83
|
+
core.sessionSetWorkingCopy(session.id, PLAN.content);
|
|
84
|
+
expect(core.sessionGet(session.id).workingCopy).toBeUndefined();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("viewed paths merge, dedupe, clear on empty, and survive a daemon restart", () => {
|
|
88
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
89
|
+
core.sessionSetViewed(session.id, ["src/a.ts", "src/b.ts", "src/a.ts"]);
|
|
90
|
+
expect(core.sessionGet(session.id).viewedPaths).toEqual(["src/a.ts", "src/b.ts"]);
|
|
91
|
+
// merge-additive: a stale client sending only its own new mark loses nothing
|
|
92
|
+
core.sessionSetViewed(session.id, ["src/c.ts"]);
|
|
93
|
+
expect(core.sessionGet(session.id).viewedPaths).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]);
|
|
94
|
+
// a resumed review reads its progress back after a daemon restart
|
|
95
|
+
const reborn = new DaemonCore(home);
|
|
96
|
+
expect(reborn.sessionGet(session.id).viewedPaths).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]);
|
|
97
|
+
core.sessionSetViewed(session.id, []);
|
|
98
|
+
expect(core.sessionGet(session.id).viewedPaths).toBeUndefined();
|
|
85
99
|
});
|
|
86
100
|
|
|
87
101
|
test("revision reopens the session and resets working copy + verdict", () => {
|
|
88
|
-
const
|
|
89
|
-
core.sessionSetWorkingCopy(
|
|
90
|
-
core.sessionResolve(
|
|
91
|
-
const revised = core.sessionSubmitRevision(
|
|
102
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
103
|
+
core.sessionSetWorkingCopy(session.id, PLAN.content + "\nedit");
|
|
104
|
+
core.sessionResolve(session.id, "request_changes", "redo");
|
|
105
|
+
const revised = core.sessionSubmitRevision(session.id, "# Plan v2\n");
|
|
92
106
|
expect(revised.status).toBe("pending");
|
|
93
107
|
expect(revised.verdict).toBeNull();
|
|
94
108
|
expect(revised.workingCopy).toBeUndefined();
|
|
@@ -99,37 +113,37 @@ describe("session lifecycle", () => {
|
|
|
99
113
|
|
|
100
114
|
describe("the wait contract: verdicts outlive waits", () => {
|
|
101
115
|
test("wait resolves when the verdict arrives", async () => {
|
|
102
|
-
const
|
|
103
|
-
const wait = core.sessionWait(
|
|
104
|
-
core.sessionResolve(
|
|
116
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
117
|
+
const wait = core.sessionWait(session.id, 5_000);
|
|
118
|
+
core.sessionResolve(session.id, "approve", "");
|
|
105
119
|
const resolved = await wait;
|
|
106
120
|
expect(resolved!.verdict!.kind).toBe("approve");
|
|
107
121
|
});
|
|
108
122
|
|
|
109
123
|
test("wait times out to null; a later wait collects the stored verdict", async () => {
|
|
110
|
-
const
|
|
111
|
-
const first = await core.sessionWait(
|
|
124
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
125
|
+
const first = await core.sessionWait(session.id, 30);
|
|
112
126
|
expect(first).toBeNull();
|
|
113
|
-
core.sessionResolve(
|
|
114
|
-
const second = await core.sessionWait(
|
|
127
|
+
core.sessionResolve(session.id, "request_changes", "later");
|
|
128
|
+
const second = await core.sessionWait(session.id, 30);
|
|
115
129
|
expect(second!.verdict!.summary).toBe("later");
|
|
116
130
|
});
|
|
117
131
|
|
|
118
132
|
test("multiple waiters all resolve", async () => {
|
|
119
|
-
const
|
|
120
|
-
const waits = [core.sessionWait(
|
|
121
|
-
core.sessionResolve(
|
|
133
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
134
|
+
const waits = [core.sessionWait(session.id, 5_000), core.sessionWait(session.id, 5_000)];
|
|
135
|
+
core.sessionResolve(session.id, "approve", "");
|
|
122
136
|
const results = await Promise.all(waits);
|
|
123
|
-
expect(results.every((
|
|
137
|
+
expect(results.every((result) => result?.status === "resolved")).toBe(true);
|
|
124
138
|
});
|
|
125
139
|
});
|
|
126
140
|
|
|
127
141
|
describe("persistence and recovery", () => {
|
|
128
142
|
test("sessions survive a daemon restart", () => {
|
|
129
|
-
const
|
|
130
|
-
core.sessionResolve(
|
|
143
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
144
|
+
core.sessionResolve(session.id, "approve", "done");
|
|
131
145
|
const reborn = new DaemonCore(home);
|
|
132
|
-
const recovered = reborn.sessionGet(
|
|
146
|
+
const recovered = reborn.sessionGet(session.id);
|
|
133
147
|
expect(recovered.verdict!.summary).toBe("done");
|
|
134
148
|
});
|
|
135
149
|
|
|
@@ -147,10 +161,10 @@ describe("persistence and recovery", () => {
|
|
|
147
161
|
describe("events", () => {
|
|
148
162
|
test("lifecycle events fire in order", () => {
|
|
149
163
|
const seen: string[] = [];
|
|
150
|
-
core.onEvent((
|
|
151
|
-
const
|
|
152
|
-
core.sessionAnnotate(
|
|
153
|
-
core.sessionResolve(
|
|
164
|
+
core.onEvent((event) => seen.push(event.event));
|
|
165
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
166
|
+
core.sessionAnnotate(session.id, { id: "a1", kind: "comment", anchor: { quote: "thing", prefix: "", suffix: "" }, body: "b" });
|
|
167
|
+
core.sessionResolve(session.id, "approve", "");
|
|
154
168
|
expect(seen).toEqual(["session.created", "inbox.changed", "session.updated", "session.resolved", "inbox.changed"]);
|
|
155
169
|
});
|
|
156
170
|
});
|
package/src/api.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The daemon's method surface
|
|
2
|
+
* The daemon's method surface. Transport-independent: the socket
|
|
3
3
|
* server and the in-process test harness both call these handlers.
|
|
4
4
|
* The wait contract: verdicts outlive waits - session.wait long-polls,
|
|
5
5
|
* and a verdict resolved while nobody waited is delivered on next contact.
|
|
@@ -31,7 +31,7 @@ export interface DaemonEvent {
|
|
|
31
31
|
sessionId: string;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
type EventListener = (
|
|
34
|
+
type EventListener = (event: DaemonEvent) => void;
|
|
35
35
|
|
|
36
36
|
export class DaemonCore {
|
|
37
37
|
readonly store: SessionStore;
|
|
@@ -50,12 +50,12 @@ export class DaemonCore {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
private emit(event: EventName, sessionId: string): void {
|
|
53
|
-
for (const
|
|
53
|
+
for (const listener of this.listeners) listener({ event, sessionId });
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
/** True when nothing awaits a verdict - drives idle-exit. */
|
|
57
57
|
hasPendingSessions(): boolean {
|
|
58
|
-
return this.store.list().some((
|
|
58
|
+
return this.store.list().some((session) => session.status === "pending");
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
sessionCreate(params: { workspace: WorkspaceKey; artifact: Artifact }): ReviewSession {
|
|
@@ -78,16 +78,16 @@ export class DaemonCore {
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
sessionGet(id: string): ReviewSession {
|
|
81
|
-
const
|
|
82
|
-
if (!
|
|
83
|
-
return
|
|
81
|
+
const session = this.store.get(id);
|
|
82
|
+
if (!session) throw new DaemonError("not_found", `no session ${id}`);
|
|
83
|
+
return session;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
sessionList(filter?: { status?: "pending" | "resolved"; workspace?: Partial<WorkspaceKey> }): ReviewSession[] {
|
|
87
|
-
return this.store.list().filter((
|
|
88
|
-
if (filter?.status &&
|
|
89
|
-
if (filter?.workspace?.repoRoot &&
|
|
90
|
-
if (filter?.workspace?.branch &&
|
|
87
|
+
return this.store.list().filter((session) => {
|
|
88
|
+
if (filter?.status && session.status !== filter.status) return false;
|
|
89
|
+
if (filter?.workspace?.repoRoot && session.workspace.repoRoot !== filter.workspace.repoRoot) return false;
|
|
90
|
+
if (filter?.workspace?.branch && session.workspace.branch !== filter.workspace.branch) return false;
|
|
91
91
|
return true;
|
|
92
92
|
});
|
|
93
93
|
}
|
|
@@ -98,16 +98,16 @@ export class DaemonCore {
|
|
|
98
98
|
* still pending - the caller re-polls later; the verdict is never lost).
|
|
99
99
|
*/
|
|
100
100
|
sessionWait(id: string, timeoutMs: number): Promise<ReviewSession | null> {
|
|
101
|
-
const
|
|
102
|
-
if (
|
|
101
|
+
const current = this.sessionGet(id);
|
|
102
|
+
if (current.status === "resolved") return Promise.resolve(current);
|
|
103
103
|
return new Promise((resolve) => {
|
|
104
104
|
const list = this.waiters.get(id) ?? [];
|
|
105
105
|
let done = false;
|
|
106
106
|
const timer = setTimeout(() => {
|
|
107
107
|
if (done) return;
|
|
108
108
|
done = true;
|
|
109
|
-
const
|
|
110
|
-
if (
|
|
109
|
+
const waiterIndex = list.indexOf(waiter);
|
|
110
|
+
if (waiterIndex !== -1) list.splice(waiterIndex, 1);
|
|
111
111
|
resolve(null);
|
|
112
112
|
}, timeoutMs);
|
|
113
113
|
const waiter = (session: ReviewSession) => {
|
|
@@ -122,72 +122,86 @@ export class DaemonCore {
|
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
sessionAnnotate(id: string, annotation: Omit<Annotation, "createdAt">): ReviewSession {
|
|
125
|
-
const
|
|
126
|
-
const existing =
|
|
125
|
+
const session = this.mutable(id);
|
|
126
|
+
const existing = session.annotations.findIndex((candidate) => candidate.id === annotation.id);
|
|
127
127
|
const full: Annotation = { ...annotation, createdAt: new Date().toISOString() };
|
|
128
|
-
if (existing === -1)
|
|
129
|
-
else
|
|
130
|
-
this.store.upsert(
|
|
128
|
+
if (existing === -1) session.annotations.push(full);
|
|
129
|
+
else session.annotations[existing] = { ...full, createdAt: session.annotations[existing]!.createdAt };
|
|
130
|
+
this.store.upsert(session);
|
|
131
131
|
this.emit("session.updated", id);
|
|
132
|
-
return
|
|
132
|
+
return session;
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
sessionRemoveAnnotation(id: string, annotationId: string): ReviewSession {
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
this.store.upsert(
|
|
136
|
+
const session = this.mutable(id);
|
|
137
|
+
session.annotations = session.annotations.filter((candidate) => candidate.id !== annotationId);
|
|
138
|
+
this.store.upsert(session);
|
|
139
139
|
this.emit("session.updated", id);
|
|
140
|
-
return
|
|
140
|
+
return session;
|
|
141
141
|
}
|
|
142
142
|
|
|
143
143
|
/** The reviewer's working copy; undefined clears it (revert all edits). */
|
|
144
144
|
sessionSetWorkingCopy(id: string, workingCopy: string | undefined): ReviewSession {
|
|
145
|
-
const
|
|
146
|
-
if (workingCopy === undefined || workingCopy ===
|
|
147
|
-
else
|
|
148
|
-
this.store.upsert(
|
|
145
|
+
const session = this.mutable(id);
|
|
146
|
+
if (workingCopy === undefined || workingCopy === session.artifact.content) delete session.workingCopy;
|
|
147
|
+
else session.workingCopy = workingCopy;
|
|
148
|
+
this.store.upsert(session);
|
|
149
149
|
this.emit("session.updated", id);
|
|
150
|
-
return
|
|
150
|
+
return session;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The guided walk's viewed marks. Merge-additive: walking only ever adds
|
|
155
|
+
* marks, so concurrent or stale clients converge instead of overwriting
|
|
156
|
+
* each other. An empty array is the explicit reset.
|
|
157
|
+
*/
|
|
158
|
+
sessionSetViewed(id: string, viewedPaths: string[]): ReviewSession {
|
|
159
|
+
const session = this.mutable(id);
|
|
160
|
+
if (viewedPaths.length === 0) delete session.viewedPaths;
|
|
161
|
+
else session.viewedPaths = [...new Set([...(session.viewedPaths ?? []), ...viewedPaths])];
|
|
162
|
+
this.store.upsert(session);
|
|
163
|
+
this.emit("session.updated", id);
|
|
164
|
+
return session;
|
|
151
165
|
}
|
|
152
166
|
|
|
153
167
|
sessionResolve(id: string, verdictKind: VerdictKind, summary: string): ReviewSession {
|
|
154
|
-
const
|
|
168
|
+
const session = this.mutable(id);
|
|
155
169
|
const verdict: Verdict = {
|
|
156
170
|
kind: verdictKind,
|
|
157
171
|
summary,
|
|
158
|
-
feedback: feedbackForSession(
|
|
172
|
+
feedback: feedbackForSession(session, verdictKind, summary),
|
|
159
173
|
resolvedAt: new Date().toISOString(),
|
|
160
174
|
};
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
this.store.upsert(
|
|
175
|
+
session.verdict = verdict;
|
|
176
|
+
session.status = "resolved";
|
|
177
|
+
this.store.upsert(session);
|
|
164
178
|
const parked = this.waiters.get(id) ?? [];
|
|
165
179
|
this.waiters.delete(id);
|
|
166
|
-
for (const
|
|
180
|
+
for (const parkedWaiter of parked) parkedWaiter(session);
|
|
167
181
|
this.emit("session.resolved", id);
|
|
168
182
|
this.emit("inbox.changed", id);
|
|
169
|
-
return
|
|
183
|
+
return session;
|
|
170
184
|
}
|
|
171
185
|
|
|
172
186
|
/** Agent resubmits: new revision becomes the artifact, session reopens. */
|
|
173
187
|
sessionSubmitRevision(id: string, content: string): ReviewSession {
|
|
174
|
-
const
|
|
188
|
+
const session = this.sessionGet(id);
|
|
175
189
|
const now = new Date().toISOString();
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
delete
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
this.store.upsert(
|
|
190
|
+
session.revisions.push({ revision: session.revisions.length + 1, content, submittedAt: now });
|
|
191
|
+
session.artifact = { ...session.artifact, content };
|
|
192
|
+
delete session.workingCopy;
|
|
193
|
+
session.verdict = null;
|
|
194
|
+
session.status = "pending";
|
|
195
|
+
this.store.upsert(session);
|
|
182
196
|
this.emit("session.revised", id);
|
|
183
197
|
this.emit("inbox.changed", id);
|
|
184
|
-
return
|
|
198
|
+
return session;
|
|
185
199
|
}
|
|
186
200
|
|
|
187
201
|
private mutable(id: string): ReviewSession {
|
|
188
|
-
const
|
|
189
|
-
if (
|
|
190
|
-
return
|
|
202
|
+
const session = this.sessionGet(id);
|
|
203
|
+
if (session.status === "resolved") throw new DaemonError("resolved", `session ${id} is already resolved`);
|
|
204
|
+
return session;
|
|
191
205
|
}
|
|
192
206
|
}
|
|
193
207
|
|
package/src/client.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Daemon client: the one library every consumer shares - CLI verbs, the TUI,
|
|
3
|
-
* adapters, and tests. Also owns the lazy-launch story
|
|
3
|
+
* adapters, and tests. Also owns the lazy-launch story: connect() with
|
|
4
4
|
* autostart spawns a detached daemon when the socket is dead, then attaches.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { existsSync, rmSync } from "node:fs";
|
|
8
8
|
import type { Annotation, Artifact, ReviewSession, VerdictKind, WorkspaceKey } from "@cueloop/schema";
|
|
9
|
-
import { LineBuffer, type EventFrame, type Response } from "./protocol";
|
|
9
|
+
import { BackpressureWriter, LineBuffer, type EventFrame, type Response } from "./protocol";
|
|
10
10
|
import { cueloopHome, socketPath } from "./paths";
|
|
11
11
|
|
|
12
12
|
export interface ConnectOptions {
|
|
@@ -15,24 +15,25 @@ export interface ConnectOptions {
|
|
|
15
15
|
autostart?: boolean;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
type
|
|
18
|
+
type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void };
|
|
19
19
|
|
|
20
20
|
export class DaemonClient {
|
|
21
21
|
private socket: Awaited<ReturnType<typeof Bun.connect>> | null = null;
|
|
22
|
-
private
|
|
22
|
+
private writer: BackpressureWriter | null = null;
|
|
23
|
+
private pending = new Map<number, PendingRequest>();
|
|
23
24
|
private nextId = 1;
|
|
24
|
-
private eventListeners = new Set<(
|
|
25
|
+
private eventListeners = new Set<(event: EventFrame) => void>();
|
|
25
26
|
private closed = false;
|
|
26
27
|
|
|
27
|
-
static async connect(
|
|
28
|
-
const home =
|
|
28
|
+
static async connect(options: ConnectOptions = {}): Promise<DaemonClient> {
|
|
29
|
+
const home = options.home ?? cueloopHome();
|
|
29
30
|
const path = socketPath(home);
|
|
30
31
|
const client = new DaemonClient();
|
|
31
32
|
try {
|
|
32
33
|
await client.dial(path);
|
|
33
34
|
return client;
|
|
34
35
|
} catch (err) {
|
|
35
|
-
if (!
|
|
36
|
+
if (!options.autostart) throw err;
|
|
36
37
|
}
|
|
37
38
|
// Socket dead or absent: clean a stale file and spawn the daemon detached.
|
|
38
39
|
if (existsSync(path)) rmSync(path, { force: true });
|
|
@@ -40,17 +41,17 @@ export class DaemonClient {
|
|
|
40
41
|
// Generous: a cold or loaded machine pays for a runtime start before the
|
|
41
42
|
// socket exists, and giving up early looks to callers like a broken daemon.
|
|
42
43
|
const deadline = Date.now() + Number(process.env.CUELOOP_START_TIMEOUT_MS ?? 30_000);
|
|
43
|
-
let
|
|
44
|
+
let lastError: unknown;
|
|
44
45
|
while (Date.now() < deadline) {
|
|
45
46
|
try {
|
|
46
47
|
await client.dial(path);
|
|
47
48
|
return client;
|
|
48
49
|
} catch (err) {
|
|
49
|
-
|
|
50
|
+
lastError = err;
|
|
50
51
|
await Bun.sleep(50);
|
|
51
52
|
}
|
|
52
53
|
}
|
|
53
|
-
throw new Error(`daemon did not come up at ${path}: ${String(
|
|
54
|
+
throw new Error(`daemon did not come up at ${path}: ${String(lastError)}`);
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
private async dial(path: string): Promise<void> {
|
|
@@ -60,27 +61,31 @@ export class DaemonClient {
|
|
|
60
61
|
unix: path,
|
|
61
62
|
socket: {
|
|
62
63
|
data(_socket, data) {
|
|
63
|
-
buffer.push(data.toString(), (line) => self.
|
|
64
|
+
buffer.push(data.toString(), (line) => self.routeInboundFrame(line));
|
|
65
|
+
},
|
|
66
|
+
drain() {
|
|
67
|
+
self.writer?.drain();
|
|
64
68
|
},
|
|
65
69
|
close() {
|
|
66
70
|
self.closed = true;
|
|
67
|
-
for (const
|
|
71
|
+
for (const pendingRequest of self.pending.values()) pendingRequest.reject(new Error("daemon connection closed"));
|
|
68
72
|
self.pending.clear();
|
|
69
73
|
},
|
|
70
74
|
error() {},
|
|
71
75
|
},
|
|
72
76
|
});
|
|
77
|
+
this.writer = new BackpressureWriter(this.socket);
|
|
73
78
|
// Verify liveness: a dead socket file accepts connects on some platforms
|
|
74
79
|
// only to fail later, so a ping is the actual handshake.
|
|
75
80
|
await this.request("daemon.ping", {}, 2_000);
|
|
76
81
|
}
|
|
77
82
|
|
|
78
|
-
onEvent(listener: (
|
|
83
|
+
onEvent(listener: (event: EventFrame) => void): () => void {
|
|
79
84
|
this.eventListeners.add(listener);
|
|
80
85
|
return () => this.eventListeners.delete(listener);
|
|
81
86
|
}
|
|
82
87
|
|
|
83
|
-
private
|
|
88
|
+
private routeInboundFrame(line: string): void {
|
|
84
89
|
let frame: Response | EventFrame;
|
|
85
90
|
try {
|
|
86
91
|
frame = JSON.parse(line) as Response | EventFrame;
|
|
@@ -88,14 +93,14 @@ export class DaemonClient {
|
|
|
88
93
|
return;
|
|
89
94
|
}
|
|
90
95
|
if ("event" in frame) {
|
|
91
|
-
for (const
|
|
96
|
+
for (const listener of this.eventListeners) listener(frame);
|
|
92
97
|
return;
|
|
93
98
|
}
|
|
94
|
-
const
|
|
95
|
-
if (!
|
|
99
|
+
const pendingRequest = this.pending.get(frame.id);
|
|
100
|
+
if (!pendingRequest) return;
|
|
96
101
|
this.pending.delete(frame.id);
|
|
97
|
-
if (frame.error)
|
|
98
|
-
else
|
|
102
|
+
if (frame.error) pendingRequest.reject(new DaemonClientError(frame.error.code, frame.error.message));
|
|
103
|
+
else pendingRequest.resolve(frame.result);
|
|
99
104
|
}
|
|
100
105
|
|
|
101
106
|
request<T = unknown>(method: string, params: unknown, timeoutMs = 30_000): Promise<T> {
|
|
@@ -107,16 +112,16 @@ export class DaemonClient {
|
|
|
107
112
|
reject(new Error(`request ${method} timed out`));
|
|
108
113
|
}, timeoutMs);
|
|
109
114
|
this.pending.set(id, {
|
|
110
|
-
resolve: (
|
|
115
|
+
resolve: (value) => {
|
|
111
116
|
clearTimeout(timer);
|
|
112
|
-
resolve(
|
|
117
|
+
resolve(value as T);
|
|
113
118
|
},
|
|
114
|
-
reject: (
|
|
119
|
+
reject: (error) => {
|
|
115
120
|
clearTimeout(timer);
|
|
116
|
-
reject(
|
|
121
|
+
reject(error);
|
|
117
122
|
},
|
|
118
123
|
});
|
|
119
|
-
this.
|
|
124
|
+
this.writer!.write(JSON.stringify({ id, method, params }) + "\n");
|
|
120
125
|
});
|
|
121
126
|
}
|
|
122
127
|
|
|
@@ -154,6 +159,9 @@ export class DaemonClient {
|
|
|
154
159
|
sessionSetWorkingCopy(id: string, workingCopy: string | undefined): Promise<ReviewSession> {
|
|
155
160
|
return this.request("session.setWorkingCopy", { id, workingCopy });
|
|
156
161
|
}
|
|
162
|
+
sessionSetViewed(id: string, viewedPaths: string[]): Promise<ReviewSession> {
|
|
163
|
+
return this.request("session.setViewed", { id, viewedPaths });
|
|
164
|
+
}
|
|
157
165
|
sessionResolve(id: string, verdictKind: VerdictKind, summary: string): Promise<ReviewSession> {
|
|
158
166
|
return this.request("session.resolve", { id, verdictKind, summary });
|
|
159
167
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
export
|
|
8
|
-
export {
|
|
1
|
+
/**
|
|
2
|
+
* The daemon's public surface. Consumers reach the client and the review
|
|
3
|
+
* helpers through the ./client and ./review subpaths; this barrel carries
|
|
4
|
+
* only the two names imported bare: the server and the home-dir resolver.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { DaemonServer, type DaemonOptions } from "./server";
|
|
8
|
+
export { cueloopHome } from "./paths";
|
package/src/protocol.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Wire protocol
|
|
2
|
+
* Wire protocol: newline-delimited JSON over the unix socket.
|
|
3
3
|
* Requests: { id, method, params } Responses: { id, result } | { id, error }
|
|
4
4
|
* Events (push, after events.subscribe): { event, sessionId }
|
|
5
5
|
*/
|
|
@@ -25,16 +25,53 @@ export type Frame = Response | EventFrame;
|
|
|
25
25
|
|
|
26
26
|
/** Incremental NDJSON splitter; tolerates partial writes. */
|
|
27
27
|
export class LineBuffer {
|
|
28
|
-
private
|
|
28
|
+
private buffered = "";
|
|
29
29
|
|
|
30
30
|
push(chunk: string, onLine: (line: string) => void): void {
|
|
31
|
-
this.
|
|
31
|
+
this.buffered += chunk;
|
|
32
32
|
for (;;) {
|
|
33
|
-
const
|
|
34
|
-
if (
|
|
35
|
-
const line = this.
|
|
36
|
-
this.
|
|
33
|
+
const newlineIndex = this.buffered.indexOf("\n");
|
|
34
|
+
if (newlineIndex === -1) return;
|
|
35
|
+
const line = this.buffered.slice(0, newlineIndex).trim();
|
|
36
|
+
this.buffered = this.buffered.slice(newlineIndex + 1);
|
|
37
37
|
if (line) onLine(line);
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
|
+
|
|
42
|
+
/** The surface both Bun.listen and Bun.connect sockets satisfy. */
|
|
43
|
+
interface WritableSocket {
|
|
44
|
+
write(data: Buffer): number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Outbound writes honoring socket backpressure. A frame larger than the
|
|
49
|
+
* kernel buffer is written partially by the socket; the unwritten tail must
|
|
50
|
+
* wait for the drain callback or the frame truncates mid-line and the NDJSON
|
|
51
|
+
* stream corrupts for good. Frames never reorder: while a tail is pending,
|
|
52
|
+
* new writes append behind it.
|
|
53
|
+
*/
|
|
54
|
+
export class BackpressureWriter {
|
|
55
|
+
private pending: Buffer | null = null;
|
|
56
|
+
|
|
57
|
+
constructor(private socket: WritableSocket) {}
|
|
58
|
+
|
|
59
|
+
write(data: string): void {
|
|
60
|
+
const bytes = Buffer.from(data, "utf8");
|
|
61
|
+
if (this.pending) {
|
|
62
|
+
this.pending = Buffer.concat([this.pending, bytes]);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const written = Math.max(0, this.socket.write(bytes));
|
|
66
|
+
if (written < bytes.length) this.pending = bytes.subarray(written);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Call from the socket's drain handler. */
|
|
70
|
+
drain(): void {
|
|
71
|
+
if (!this.pending) return;
|
|
72
|
+
const tail = this.pending;
|
|
73
|
+
this.pending = null;
|
|
74
|
+
const written = Math.max(0, this.socket.write(tail));
|
|
75
|
+
if (written < tail.length) this.pending = tail.subarray(written);
|
|
76
|
+
}
|
|
77
|
+
}
|