@cueloop/daemon 0.1.0-alpha.16 → 0.1.0-alpha.17
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 +2 -2
- package/src/api.test.ts +52 -52
- package/src/api.ts +56 -62
- package/src/client.ts +23 -23
- package/src/protocol.ts +7 -7
- package/src/review.test.ts +12 -12
- package/src/review.ts +30 -30
- package/src/server.test.ts +22 -22
- package/src/server.ts +49 -49
- package/src/single-instance.test.ts +11 -11
- package/src/store.ts +5 -5
- package/src/validate.test.ts +12 -12
- package/src/validate.ts +4 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/daemon",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.17",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"./review": "./src/review.ts"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
12
|
+
"@cueloop/schema": "0.1.0-alpha.17",
|
|
13
13
|
"valibot": "^1.4.2"
|
|
14
14
|
},
|
|
15
15
|
"publishConfig": {
|
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,40 +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
85
|
});
|
|
86
86
|
|
|
87
87
|
test("viewed paths merge, dedupe, clear on empty, and survive a daemon restart", () => {
|
|
88
|
-
const
|
|
89
|
-
core.sessionSetViewed(
|
|
90
|
-
expect(core.sessionGet(
|
|
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
91
|
// merge-additive: a stale client sending only its own new mark loses nothing
|
|
92
|
-
core.sessionSetViewed(
|
|
93
|
-
expect(core.sessionGet(
|
|
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
94
|
// a resumed review reads its progress back after a daemon restart
|
|
95
95
|
const reborn = new DaemonCore(home);
|
|
96
|
-
expect(reborn.sessionGet(
|
|
97
|
-
core.sessionSetViewed(
|
|
98
|
-
expect(core.sessionGet(
|
|
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();
|
|
99
99
|
});
|
|
100
100
|
|
|
101
101
|
test("revision reopens the session and resets working copy + verdict", () => {
|
|
102
|
-
const
|
|
103
|
-
core.sessionSetWorkingCopy(
|
|
104
|
-
core.sessionResolve(
|
|
105
|
-
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");
|
|
106
106
|
expect(revised.status).toBe("pending");
|
|
107
107
|
expect(revised.verdict).toBeNull();
|
|
108
108
|
expect(revised.workingCopy).toBeUndefined();
|
|
@@ -113,37 +113,37 @@ describe("session lifecycle", () => {
|
|
|
113
113
|
|
|
114
114
|
describe("the wait contract: verdicts outlive waits", () => {
|
|
115
115
|
test("wait resolves when the verdict arrives", async () => {
|
|
116
|
-
const
|
|
117
|
-
const wait = core.sessionWait(
|
|
118
|
-
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", "");
|
|
119
119
|
const resolved = await wait;
|
|
120
120
|
expect(resolved!.verdict!.kind).toBe("approve");
|
|
121
121
|
});
|
|
122
122
|
|
|
123
123
|
test("wait times out to null; a later wait collects the stored verdict", async () => {
|
|
124
|
-
const
|
|
125
|
-
const first = await core.sessionWait(
|
|
124
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
125
|
+
const first = await core.sessionWait(session.id, 30);
|
|
126
126
|
expect(first).toBeNull();
|
|
127
|
-
core.sessionResolve(
|
|
128
|
-
const second = await core.sessionWait(
|
|
127
|
+
core.sessionResolve(session.id, "request_changes", "later");
|
|
128
|
+
const second = await core.sessionWait(session.id, 30);
|
|
129
129
|
expect(second!.verdict!.summary).toBe("later");
|
|
130
130
|
});
|
|
131
131
|
|
|
132
132
|
test("multiple waiters all resolve", async () => {
|
|
133
|
-
const
|
|
134
|
-
const waits = [core.sessionWait(
|
|
135
|
-
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", "");
|
|
136
136
|
const results = await Promise.all(waits);
|
|
137
|
-
expect(results.every((
|
|
137
|
+
expect(results.every((result) => result?.status === "resolved")).toBe(true);
|
|
138
138
|
});
|
|
139
139
|
});
|
|
140
140
|
|
|
141
141
|
describe("persistence and recovery", () => {
|
|
142
142
|
test("sessions survive a daemon restart", () => {
|
|
143
|
-
const
|
|
144
|
-
core.sessionResolve(
|
|
143
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
144
|
+
core.sessionResolve(session.id, "approve", "done");
|
|
145
145
|
const reborn = new DaemonCore(home);
|
|
146
|
-
const recovered = reborn.sessionGet(
|
|
146
|
+
const recovered = reborn.sessionGet(session.id);
|
|
147
147
|
expect(recovered.verdict!.summary).toBe("done");
|
|
148
148
|
});
|
|
149
149
|
|
|
@@ -161,10 +161,10 @@ describe("persistence and recovery", () => {
|
|
|
161
161
|
describe("events", () => {
|
|
162
162
|
test("lifecycle events fire in order", () => {
|
|
163
163
|
const seen: string[] = [];
|
|
164
|
-
core.onEvent((
|
|
165
|
-
const
|
|
166
|
-
core.sessionAnnotate(
|
|
167
|
-
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", "");
|
|
168
168
|
expect(seen).toEqual(["session.created", "inbox.changed", "session.updated", "session.resolved", "inbox.changed"]);
|
|
169
169
|
});
|
|
170
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,92 +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
151
|
}
|
|
152
152
|
|
|
153
153
|
/**
|
|
154
|
-
* The guided walk's viewed marks.
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
* the record never accumulates repeats from racing clients.
|
|
158
|
-
*/
|
|
159
|
-
/**
|
|
160
|
-
* Merge-additive: walking only ever adds marks, so concurrent or stale
|
|
161
|
-
* clients converge instead of overwriting each other. An empty array is the
|
|
162
|
-
* explicit reset.
|
|
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.
|
|
163
157
|
*/
|
|
164
158
|
sessionSetViewed(id: string, viewedPaths: string[]): ReviewSession {
|
|
165
|
-
const
|
|
166
|
-
if (viewedPaths.length === 0) delete
|
|
167
|
-
else
|
|
168
|
-
this.store.upsert(
|
|
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);
|
|
169
163
|
this.emit("session.updated", id);
|
|
170
|
-
return
|
|
164
|
+
return session;
|
|
171
165
|
}
|
|
172
166
|
|
|
173
167
|
sessionResolve(id: string, verdictKind: VerdictKind, summary: string): ReviewSession {
|
|
174
|
-
const
|
|
168
|
+
const session = this.mutable(id);
|
|
175
169
|
const verdict: Verdict = {
|
|
176
170
|
kind: verdictKind,
|
|
177
171
|
summary,
|
|
178
|
-
feedback: feedbackForSession(
|
|
172
|
+
feedback: feedbackForSession(session, verdictKind, summary),
|
|
179
173
|
resolvedAt: new Date().toISOString(),
|
|
180
174
|
};
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
this.store.upsert(
|
|
175
|
+
session.verdict = verdict;
|
|
176
|
+
session.status = "resolved";
|
|
177
|
+
this.store.upsert(session);
|
|
184
178
|
const parked = this.waiters.get(id) ?? [];
|
|
185
179
|
this.waiters.delete(id);
|
|
186
|
-
for (const
|
|
180
|
+
for (const parkedWaiter of parked) parkedWaiter(session);
|
|
187
181
|
this.emit("session.resolved", id);
|
|
188
182
|
this.emit("inbox.changed", id);
|
|
189
|
-
return
|
|
183
|
+
return session;
|
|
190
184
|
}
|
|
191
185
|
|
|
192
186
|
/** Agent resubmits: new revision becomes the artifact, session reopens. */
|
|
193
187
|
sessionSubmitRevision(id: string, content: string): ReviewSession {
|
|
194
|
-
const
|
|
188
|
+
const session = this.sessionGet(id);
|
|
195
189
|
const now = new Date().toISOString();
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
delete
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
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);
|
|
202
196
|
this.emit("session.revised", id);
|
|
203
197
|
this.emit("inbox.changed", id);
|
|
204
|
-
return
|
|
198
|
+
return session;
|
|
205
199
|
}
|
|
206
200
|
|
|
207
201
|
private mutable(id: string): ReviewSession {
|
|
208
|
-
const
|
|
209
|
-
if (
|
|
210
|
-
return
|
|
202
|
+
const session = this.sessionGet(id);
|
|
203
|
+
if (session.status === "resolved") throw new DaemonError("resolved", `session ${id} is already resolved`);
|
|
204
|
+
return session;
|
|
211
205
|
}
|
|
212
206
|
}
|
|
213
207
|
|
package/src/client.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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
|
|
|
@@ -15,25 +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
22
|
private writer: BackpressureWriter | null = null;
|
|
23
|
-
private pending = new Map<number,
|
|
23
|
+
private pending = new Map<number, PendingRequest>();
|
|
24
24
|
private nextId = 1;
|
|
25
|
-
private eventListeners = new Set<(
|
|
25
|
+
private eventListeners = new Set<(event: EventFrame) => void>();
|
|
26
26
|
private closed = false;
|
|
27
27
|
|
|
28
|
-
static async connect(
|
|
29
|
-
const home =
|
|
28
|
+
static async connect(options: ConnectOptions = {}): Promise<DaemonClient> {
|
|
29
|
+
const home = options.home ?? cueloopHome();
|
|
30
30
|
const path = socketPath(home);
|
|
31
31
|
const client = new DaemonClient();
|
|
32
32
|
try {
|
|
33
33
|
await client.dial(path);
|
|
34
34
|
return client;
|
|
35
35
|
} catch (err) {
|
|
36
|
-
if (!
|
|
36
|
+
if (!options.autostart) throw err;
|
|
37
37
|
}
|
|
38
38
|
// Socket dead or absent: clean a stale file and spawn the daemon detached.
|
|
39
39
|
if (existsSync(path)) rmSync(path, { force: true });
|
|
@@ -41,17 +41,17 @@ export class DaemonClient {
|
|
|
41
41
|
// Generous: a cold or loaded machine pays for a runtime start before the
|
|
42
42
|
// socket exists, and giving up early looks to callers like a broken daemon.
|
|
43
43
|
const deadline = Date.now() + Number(process.env.CUELOOP_START_TIMEOUT_MS ?? 30_000);
|
|
44
|
-
let
|
|
44
|
+
let lastError: unknown;
|
|
45
45
|
while (Date.now() < deadline) {
|
|
46
46
|
try {
|
|
47
47
|
await client.dial(path);
|
|
48
48
|
return client;
|
|
49
49
|
} catch (err) {
|
|
50
|
-
|
|
50
|
+
lastError = err;
|
|
51
51
|
await Bun.sleep(50);
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
|
-
throw new Error(`daemon did not come up at ${path}: ${String(
|
|
54
|
+
throw new Error(`daemon did not come up at ${path}: ${String(lastError)}`);
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
private async dial(path: string): Promise<void> {
|
|
@@ -61,14 +61,14 @@ export class DaemonClient {
|
|
|
61
61
|
unix: path,
|
|
62
62
|
socket: {
|
|
63
63
|
data(_socket, data) {
|
|
64
|
-
buffer.push(data.toString(), (line) => self.
|
|
64
|
+
buffer.push(data.toString(), (line) => self.routeInboundFrame(line));
|
|
65
65
|
},
|
|
66
66
|
drain() {
|
|
67
67
|
self.writer?.drain();
|
|
68
68
|
},
|
|
69
69
|
close() {
|
|
70
70
|
self.closed = true;
|
|
71
|
-
for (const
|
|
71
|
+
for (const pendingRequest of self.pending.values()) pendingRequest.reject(new Error("daemon connection closed"));
|
|
72
72
|
self.pending.clear();
|
|
73
73
|
},
|
|
74
74
|
error() {},
|
|
@@ -80,12 +80,12 @@ export class DaemonClient {
|
|
|
80
80
|
await this.request("daemon.ping", {}, 2_000);
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
onEvent(listener: (
|
|
83
|
+
onEvent(listener: (event: EventFrame) => void): () => void {
|
|
84
84
|
this.eventListeners.add(listener);
|
|
85
85
|
return () => this.eventListeners.delete(listener);
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
private
|
|
88
|
+
private routeInboundFrame(line: string): void {
|
|
89
89
|
let frame: Response | EventFrame;
|
|
90
90
|
try {
|
|
91
91
|
frame = JSON.parse(line) as Response | EventFrame;
|
|
@@ -93,14 +93,14 @@ export class DaemonClient {
|
|
|
93
93
|
return;
|
|
94
94
|
}
|
|
95
95
|
if ("event" in frame) {
|
|
96
|
-
for (const
|
|
96
|
+
for (const listener of this.eventListeners) listener(frame);
|
|
97
97
|
return;
|
|
98
98
|
}
|
|
99
|
-
const
|
|
100
|
-
if (!
|
|
99
|
+
const pendingRequest = this.pending.get(frame.id);
|
|
100
|
+
if (!pendingRequest) return;
|
|
101
101
|
this.pending.delete(frame.id);
|
|
102
|
-
if (frame.error)
|
|
103
|
-
else
|
|
102
|
+
if (frame.error) pendingRequest.reject(new DaemonClientError(frame.error.code, frame.error.message));
|
|
103
|
+
else pendingRequest.resolve(frame.result);
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
request<T = unknown>(method: string, params: unknown, timeoutMs = 30_000): Promise<T> {
|
|
@@ -112,13 +112,13 @@ export class DaemonClient {
|
|
|
112
112
|
reject(new Error(`request ${method} timed out`));
|
|
113
113
|
}, timeoutMs);
|
|
114
114
|
this.pending.set(id, {
|
|
115
|
-
resolve: (
|
|
115
|
+
resolve: (value) => {
|
|
116
116
|
clearTimeout(timer);
|
|
117
|
-
resolve(
|
|
117
|
+
resolve(value as T);
|
|
118
118
|
},
|
|
119
|
-
reject: (
|
|
119
|
+
reject: (error) => {
|
|
120
120
|
clearTimeout(timer);
|
|
121
|
-
reject(
|
|
121
|
+
reject(error);
|
|
122
122
|
},
|
|
123
123
|
});
|
|
124
124
|
this.writer!.write(JSON.stringify({ id, method, params }) + "\n");
|
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,15 +25,15 @@ 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
|
}
|
package/src/review.test.ts
CHANGED
|
@@ -33,8 +33,8 @@ afterEach(() => {
|
|
|
33
33
|
});
|
|
34
34
|
|
|
35
35
|
function sh(args: string[], cwd: string): void {
|
|
36
|
-
const
|
|
37
|
-
if (
|
|
36
|
+
const gitResult = Bun.spawnSync(args, { cwd, stdout: "ignore", stderr: "ignore" });
|
|
37
|
+
if (gitResult.exitCode !== 0) throw new Error(`${args.join(" ")} failed`);
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
describe("resolveWorkspace", () => {
|
|
@@ -63,13 +63,13 @@ describe("resolveWorkspace", () => {
|
|
|
63
63
|
describe("openReview", () => {
|
|
64
64
|
test("shapes the artifact: agent, derived title, cwd", async () => {
|
|
65
65
|
const review = await openReview(client, { type: "plan", content: PLAN, cwd: home, agent: "test-agent" });
|
|
66
|
-
const
|
|
67
|
-
expect(
|
|
68
|
-
expect(
|
|
69
|
-
expect(
|
|
70
|
-
expect(
|
|
71
|
-
expect(
|
|
72
|
-
expect(
|
|
66
|
+
const session = review.session;
|
|
67
|
+
expect(session.status).toBe("pending");
|
|
68
|
+
expect(session.artifact.type).toBe("plan");
|
|
69
|
+
expect(session.artifact.meta.agent).toBe("test-agent");
|
|
70
|
+
expect(session.artifact.meta.title).toBe("Rollout Plan");
|
|
71
|
+
expect(session.artifact.meta.cwd).toBe(home);
|
|
72
|
+
expect(session.workspace).toEqual({ repoRoot: home, branch: "detached" });
|
|
73
73
|
});
|
|
74
74
|
|
|
75
75
|
test("an explicit title wins; diffs never derive one from content", async () => {
|
|
@@ -150,7 +150,7 @@ describe("awaitVerdict: chunked loop (the pi shape)", () => {
|
|
|
150
150
|
const waiting = review.awaitVerdict({
|
|
151
151
|
timeoutMs: Infinity,
|
|
152
152
|
pollMs: 100,
|
|
153
|
-
onProgress: (
|
|
153
|
+
onProgress: (progress) => seen.push(progress),
|
|
154
154
|
});
|
|
155
155
|
await client.sessionAnnotate(review.id, {
|
|
156
156
|
id: "a1",
|
|
@@ -159,8 +159,8 @@ describe("awaitVerdict: chunked loop (the pi shape)", () => {
|
|
|
159
159
|
body: "Name them.",
|
|
160
160
|
});
|
|
161
161
|
// give the loop a chunk to observe the annotation before resolving
|
|
162
|
-
for (let i = 0; i < 100 && !seen.some((
|
|
163
|
-
expect(seen.some((
|
|
162
|
+
for (let i = 0; i < 100 && !seen.some((snapshot) => snapshot.annotations.length === 1); i++) await Bun.sleep(20);
|
|
163
|
+
expect(seen.some((snapshot) => snapshot.annotations.length === 1)).toBe(true);
|
|
164
164
|
await client.sessionResolve(review.id, "request_changes", "Too vague.");
|
|
165
165
|
const verdict = await waiting;
|
|
166
166
|
if (verdict === "pending") throw new Error("expected a verdict");
|
package/src/review.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* The shared review core: one open/wait/verdict path for every consumer -
|
|
3
3
|
* CLI commands and agent adapters. openReview resolves the workspace, shapes
|
|
4
4
|
* the artifact, and opens-or-revises by agentSessionId; awaitVerdict maps the
|
|
5
|
-
* wait contract
|
|
5
|
+
* wait contract onto the agent contract through verdictResponse. An
|
|
6
6
|
* adapter keeps only two bespoke parts: parsing its host's event shape and
|
|
7
7
|
* serializing the decision in its host's contract.
|
|
8
8
|
*/
|
|
@@ -26,16 +26,16 @@ async function git(args: string[], cwd: string): Promise<string | null> {
|
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
/** Workspace key resolution
|
|
29
|
+
/** Workspace key resolution: repo root + branch from the cwd. */
|
|
30
30
|
export async function resolveWorkspace(cwd = process.cwd()): Promise<WorkspaceKey> {
|
|
31
31
|
const repoRoot = (await git(["rev-parse", "--show-toplevel"], cwd)) ?? cwd;
|
|
32
32
|
const branch = (await git(["rev-parse", "--abbrev-ref", "HEAD"], cwd)) ?? "detached";
|
|
33
33
|
return { repoRoot, branch };
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
function firstHeading(
|
|
37
|
-
const
|
|
38
|
-
return
|
|
36
|
+
function firstHeading(markdown: string): string | undefined {
|
|
37
|
+
const headingMatch = markdown.match(/^#\s+(.+)$/m);
|
|
38
|
+
return headingMatch?.[1]?.trim();
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
export interface OpenReviewOptions {
|
|
@@ -115,13 +115,13 @@ function raceAbort<T>(promise: Promise<T>, signal: AbortSignal | undefined): Pro
|
|
|
115
115
|
};
|
|
116
116
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
117
117
|
promise.then(
|
|
118
|
-
(
|
|
118
|
+
(value) => {
|
|
119
119
|
signal.removeEventListener("abort", onAbort);
|
|
120
|
-
resolve(
|
|
120
|
+
resolve(value);
|
|
121
121
|
},
|
|
122
|
-
(
|
|
122
|
+
(error) => {
|
|
123
123
|
signal.removeEventListener("abort", onAbort);
|
|
124
|
-
reject(
|
|
124
|
+
reject(error);
|
|
125
125
|
},
|
|
126
126
|
);
|
|
127
127
|
});
|
|
@@ -140,11 +140,11 @@ export class ReviewHandle {
|
|
|
140
140
|
/**
|
|
141
141
|
* Block on the verdict. "pending" means the budget ran out or the signal
|
|
142
142
|
* aborted - the session stays open and the verdict is collectable later
|
|
143
|
-
* (verdicts outlive waits
|
|
143
|
+
* (verdicts outlive waits). With only timeoutMs this is one long-poll;
|
|
144
144
|
* pollMs/onProgress/signal switch to the chunked loop.
|
|
145
145
|
*/
|
|
146
|
-
async awaitVerdict(
|
|
147
|
-
const { timeoutMs, pollMs, onProgress, signal } =
|
|
146
|
+
async awaitVerdict(options: AwaitVerdictOptions): Promise<VerdictOutcome | "pending"> {
|
|
147
|
+
const { timeoutMs, pollMs, onProgress, signal } = options;
|
|
148
148
|
if (pollMs === undefined && onProgress === undefined && signal === undefined) {
|
|
149
149
|
const resolved = await this.client.sessionWait(this.session.id, timeoutMs);
|
|
150
150
|
return resolved === null ? "pending" : outcome(resolved);
|
|
@@ -170,38 +170,38 @@ function outcome(session: ReviewSession): VerdictOutcome {
|
|
|
170
170
|
}
|
|
171
171
|
|
|
172
172
|
/** Open a review session (or revise the agent session's existing one) and hand back the wait surface. */
|
|
173
|
-
export async function openReview(client: DaemonClient,
|
|
174
|
-
const cwd =
|
|
175
|
-
const workspace =
|
|
173
|
+
export async function openReview(client: DaemonClient, options: OpenReviewOptions): Promise<ReviewHandle> {
|
|
174
|
+
const cwd = options.cwd ?? process.cwd();
|
|
175
|
+
const workspace = options.workspace ?? (await resolveWorkspace(cwd));
|
|
176
176
|
// Resubmits from the same agent session become revisions, not new sessions.
|
|
177
|
-
if (
|
|
177
|
+
if (options.agentSessionId !== undefined) {
|
|
178
178
|
const existing = (await client.sessionList()).find(
|
|
179
|
-
(
|
|
179
|
+
(candidate) => candidate.artifact.meta.agentSessionId === options.agentSessionId,
|
|
180
180
|
);
|
|
181
181
|
if (existing !== undefined) {
|
|
182
|
-
let revised = await client.sessionSubmitRevision(existing.id,
|
|
183
|
-
if (
|
|
184
|
-
await attachNotes(client, revised.id,
|
|
182
|
+
let revised = await client.sessionSubmitRevision(existing.id, options.content);
|
|
183
|
+
if (options.notes?.length) {
|
|
184
|
+
await attachNotes(client, revised.id, options.notes);
|
|
185
185
|
revised = await client.sessionGet(revised.id);
|
|
186
186
|
}
|
|
187
187
|
return new ReviewHandle(client, revised);
|
|
188
188
|
}
|
|
189
189
|
}
|
|
190
190
|
let session = await client.sessionCreate(workspace, {
|
|
191
|
-
type:
|
|
192
|
-
content:
|
|
191
|
+
type: options.type,
|
|
192
|
+
content: options.content,
|
|
193
193
|
meta: {
|
|
194
|
-
agent:
|
|
195
|
-
agentSessionId:
|
|
196
|
-
planPath:
|
|
197
|
-
pr:
|
|
198
|
-
herdrPane:
|
|
199
|
-
title:
|
|
194
|
+
agent: options.agent,
|
|
195
|
+
agentSessionId: options.agentSessionId,
|
|
196
|
+
planPath: options.planPath,
|
|
197
|
+
pr: options.pr,
|
|
198
|
+
herdrPane: options.herdrPane,
|
|
199
|
+
title: options.title ?? (options.type === "plan" ? firstHeading(options.content) : undefined),
|
|
200
200
|
cwd,
|
|
201
201
|
},
|
|
202
202
|
});
|
|
203
|
-
if (
|
|
204
|
-
await attachNotes(client, session.id,
|
|
203
|
+
if (options.notes?.length) {
|
|
204
|
+
await attachNotes(client, session.id, options.notes);
|
|
205
205
|
session = await client.sessionGet(session.id);
|
|
206
206
|
}
|
|
207
207
|
return new ReviewHandle(client, session);
|
package/src/server.test.ts
CHANGED
|
@@ -28,16 +28,16 @@ afterEach(() => {
|
|
|
28
28
|
describe("socket round-trip", () => {
|
|
29
29
|
test("ping and full session flow over the wire", async () => {
|
|
30
30
|
expect((await client.ping()).pid).toBe(process.pid);
|
|
31
|
-
const
|
|
32
|
-
expect(
|
|
33
|
-
await client.sessionAnnotate(
|
|
31
|
+
const session = await client.sessionCreate(WS, PLAN);
|
|
32
|
+
expect(session.status).toBe("pending");
|
|
33
|
+
await client.sessionAnnotate(session.id, {
|
|
34
34
|
id: "a1",
|
|
35
35
|
kind: "comment",
|
|
36
36
|
anchor: { quote: "Body text", prefix: "", suffix: "." },
|
|
37
37
|
body: "More detail please.",
|
|
38
38
|
});
|
|
39
|
-
const wait = client.sessionWait(
|
|
40
|
-
await client.sessionResolve(
|
|
39
|
+
const wait = client.sessionWait(session.id, 5_000);
|
|
40
|
+
await client.sessionResolve(session.id, "request_changes", "Expand it.");
|
|
41
41
|
const resolved = (await wait)!;
|
|
42
42
|
expect(resolved.verdict!.kind).toBe("request_changes");
|
|
43
43
|
expect(resolved.verdict!.feedback).toContain("More detail please.");
|
|
@@ -47,18 +47,18 @@ describe("socket round-trip", () => {
|
|
|
47
47
|
expect(client.sessionGet("nope")).rejects.toBeInstanceOf(DaemonClientError);
|
|
48
48
|
try {
|
|
49
49
|
await client.sessionGet("nope");
|
|
50
|
-
} catch (
|
|
51
|
-
expect((
|
|
50
|
+
} catch (error) {
|
|
51
|
+
expect((error as DaemonClientError).code).toBe("not_found");
|
|
52
52
|
}
|
|
53
53
|
});
|
|
54
54
|
|
|
55
55
|
test("events push to subscribed connections only", async () => {
|
|
56
56
|
const observer = await DaemonClient.connect({ home });
|
|
57
57
|
const seen: string[] = [];
|
|
58
|
-
observer.onEvent((
|
|
58
|
+
observer.onEvent((event) => seen.push(event.event));
|
|
59
59
|
await observer.subscribe();
|
|
60
|
-
const
|
|
61
|
-
await client.sessionResolve(
|
|
60
|
+
const session = await client.sessionCreate(WS, PLAN);
|
|
61
|
+
await client.sessionResolve(session.id, "approve", "");
|
|
62
62
|
// events are pushed async over the socket; give the loop a beat
|
|
63
63
|
await Bun.sleep(50);
|
|
64
64
|
expect(seen).toContain("session.created");
|
|
@@ -71,43 +71,43 @@ describe("socket round-trip", () => {
|
|
|
71
71
|
try {
|
|
72
72
|
await client.request("session.create", { artifact: { type: "plan", content: "x" } });
|
|
73
73
|
throw new Error("should have thrown");
|
|
74
|
-
} catch (
|
|
75
|
-
expect((
|
|
74
|
+
} catch (error) {
|
|
75
|
+
expect((error as DaemonClientError).code).toBe("invalid_params");
|
|
76
76
|
}
|
|
77
77
|
// wrong types
|
|
78
78
|
try {
|
|
79
79
|
await client.request("session.wait", { id: 42 });
|
|
80
80
|
throw new Error("should have thrown");
|
|
81
|
-
} catch (
|
|
82
|
-
expect((
|
|
81
|
+
} catch (error) {
|
|
82
|
+
expect((error as DaemonClientError).code).toBe("invalid_params");
|
|
83
83
|
}
|
|
84
84
|
// unknown method
|
|
85
85
|
try {
|
|
86
86
|
await client.request("session.nuke", {});
|
|
87
87
|
throw new Error("should have thrown");
|
|
88
|
-
} catch (
|
|
89
|
-
expect((
|
|
88
|
+
} catch (error) {
|
|
89
|
+
expect((error as DaemonClientError).code).toBe("unknown_method");
|
|
90
90
|
}
|
|
91
91
|
// the daemon is still fully alive afterwards
|
|
92
|
-
const
|
|
93
|
-
expect((await client.sessionGet(
|
|
92
|
+
const session = await client.sessionCreate(WS, PLAN);
|
|
93
|
+
expect((await client.sessionGet(session.id)).id).toBe(session.id);
|
|
94
94
|
});
|
|
95
95
|
|
|
96
96
|
test("meta fields survive the wire: herdrPane set on create comes back from get", async () => {
|
|
97
|
-
const
|
|
97
|
+
const session = await client.sessionCreate(WS, {
|
|
98
98
|
type: "plan",
|
|
99
99
|
content: "# P",
|
|
100
100
|
meta: { agent: "claude-code", herdrPane: "%7" },
|
|
101
101
|
});
|
|
102
|
-
const got = await client.sessionGet(
|
|
102
|
+
const got = await client.sessionGet(session.id);
|
|
103
103
|
expect(got.artifact.meta.herdrPane).toBe("%7");
|
|
104
104
|
expect(got.artifact.meta.agent).toBe("claude-code");
|
|
105
105
|
});
|
|
106
106
|
|
|
107
107
|
test("two clients see the same state (thin-renderer model)", async () => {
|
|
108
108
|
const second = await DaemonClient.connect({ home });
|
|
109
|
-
const
|
|
110
|
-
const fromSecond = await second.sessionGet(
|
|
109
|
+
const session = await client.sessionCreate(WS, PLAN);
|
|
110
|
+
const fromSecond = await second.sessionGet(session.id);
|
|
111
111
|
expect(fromSecond.artifact.content).toBe(PLAN.content);
|
|
112
112
|
second.close();
|
|
113
113
|
});
|
package/src/server.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The socket server: binds DaemonCore's methods to the unix socket, owns the
|
|
3
|
-
* pidfile, stale-socket cleanup, and the idle-exit timer
|
|
3
|
+
* pidfile, stale-socket cleanup, and the idle-exit timer. File
|
|
4
4
|
* permissions are the local auth: the socket and state dir are 0700/0600.
|
|
5
5
|
*/
|
|
6
6
|
|
|
@@ -11,7 +11,7 @@ import { isKnownMethod, parseParams } from "./validate";
|
|
|
11
11
|
import { BackpressureWriter, LineBuffer, type Request } from "./protocol";
|
|
12
12
|
import { cueloopHome, lockPath, pidPath, socketPath } from "./paths";
|
|
13
13
|
|
|
14
|
-
interface
|
|
14
|
+
interface Connection {
|
|
15
15
|
write(data: string): void;
|
|
16
16
|
subscribed: boolean;
|
|
17
17
|
}
|
|
@@ -33,20 +33,20 @@ const HELD_HOMES = new Set<string>();
|
|
|
33
33
|
export class DaemonServer {
|
|
34
34
|
readonly core: DaemonCore;
|
|
35
35
|
readonly home: string;
|
|
36
|
-
private
|
|
36
|
+
private connections = new Set<Connection>();
|
|
37
37
|
private server: ReturnType<typeof Bun.listen> | null = null;
|
|
38
38
|
private idleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
39
39
|
private lockFd: number | null = null;
|
|
40
40
|
private readonly idleExitMs: number;
|
|
41
41
|
private readonly onIdleExit: () => void;
|
|
42
42
|
|
|
43
|
-
constructor(
|
|
44
|
-
this.home =
|
|
45
|
-
this.idleExitMs =
|
|
46
|
-
this.onIdleExit =
|
|
43
|
+
constructor(options: DaemonOptions = {}) {
|
|
44
|
+
this.home = options.home ?? cueloopHome();
|
|
45
|
+
this.idleExitMs = options.idleExitMs ?? 15 * 60 * 1000;
|
|
46
|
+
this.onIdleExit = options.onIdleExit ?? (() => process.exit(0));
|
|
47
47
|
mkdirSync(this.home, { recursive: true, mode: 0o700 });
|
|
48
48
|
this.core = new DaemonCore(this.home);
|
|
49
|
-
this.core.onEvent((
|
|
49
|
+
this.core.onEvent((event) => this.broadcast(event));
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
/**
|
|
@@ -119,19 +119,19 @@ export class DaemonServer {
|
|
|
119
119
|
// socket file left behind is stale
|
|
120
120
|
if (existsSync(path)) rmSync(path, { force: true });
|
|
121
121
|
const self = this;
|
|
122
|
-
this.server = Bun.listen<{ buffer: LineBuffer;
|
|
122
|
+
this.server = Bun.listen<{ buffer: LineBuffer; connection: Connection; writer: BackpressureWriter }>({
|
|
123
123
|
unix: path,
|
|
124
124
|
socket: {
|
|
125
125
|
open(socket) {
|
|
126
126
|
const writer = new BackpressureWriter(socket);
|
|
127
|
-
const
|
|
128
|
-
socket.data = { buffer: new LineBuffer(),
|
|
129
|
-
self.
|
|
127
|
+
const connection: Connection = { write: (data) => writer.write(data), subscribed: false };
|
|
128
|
+
socket.data = { buffer: new LineBuffer(), connection, writer };
|
|
129
|
+
self.connections.add(connection);
|
|
130
130
|
self.scheduleIdleCheck();
|
|
131
131
|
},
|
|
132
132
|
data(socket, data) {
|
|
133
133
|
socket.data.buffer.push(data.toString(), (line) => {
|
|
134
|
-
void self.
|
|
134
|
+
void self.respondToRequestLine(socket.data.connection, line);
|
|
135
135
|
});
|
|
136
136
|
},
|
|
137
137
|
drain(socket) {
|
|
@@ -140,7 +140,7 @@ export class DaemonServer {
|
|
|
140
140
|
socket.data?.writer.drain();
|
|
141
141
|
},
|
|
142
142
|
close(socket) {
|
|
143
|
-
self.
|
|
143
|
+
self.connections.delete(socket.data.connection);
|
|
144
144
|
self.scheduleIdleCheck();
|
|
145
145
|
},
|
|
146
146
|
error() {},
|
|
@@ -161,9 +161,9 @@ export class DaemonServer {
|
|
|
161
161
|
this.releaseLock();
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
-
private broadcast(
|
|
165
|
-
const frame = JSON.stringify(
|
|
166
|
-
for (const
|
|
164
|
+
private broadcast(event: DaemonEvent): void {
|
|
165
|
+
const frame = JSON.stringify(event) + "\n";
|
|
166
|
+
for (const connection of this.connections) if (connection.subscribed) connection.write(frame);
|
|
167
167
|
this.scheduleIdleCheck();
|
|
168
168
|
}
|
|
169
169
|
|
|
@@ -172,7 +172,7 @@ export class DaemonServer {
|
|
|
172
172
|
if (this.idleExitMs <= 0) return;
|
|
173
173
|
if (this.idleTimer) clearTimeout(this.idleTimer);
|
|
174
174
|
this.idleTimer = setTimeout(() => {
|
|
175
|
-
if (this.
|
|
175
|
+
if (this.connections.size === 0 && !this.core.hasPendingSessions()) {
|
|
176
176
|
this.stop();
|
|
177
177
|
this.onIdleExit();
|
|
178
178
|
} else {
|
|
@@ -181,31 +181,31 @@ export class DaemonServer {
|
|
|
181
181
|
}, this.idleExitMs);
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
-
private async
|
|
185
|
-
let
|
|
184
|
+
private async respondToRequestLine(connection: Connection, line: string): Promise<void> {
|
|
185
|
+
let request: Request;
|
|
186
186
|
try {
|
|
187
|
-
|
|
187
|
+
request = JSON.parse(line) as Request;
|
|
188
188
|
} catch {
|
|
189
|
-
|
|
189
|
+
connection.write(JSON.stringify({ id: -1, error: { code: "bad_json", message: "unparseable request" } }) + "\n");
|
|
190
190
|
return;
|
|
191
191
|
}
|
|
192
192
|
try {
|
|
193
|
-
const result = await this.dispatch(
|
|
194
|
-
|
|
193
|
+
const result = await this.dispatch(connection, request);
|
|
194
|
+
connection.write(JSON.stringify({ id: request.id, result }) + "\n");
|
|
195
195
|
} catch (err) {
|
|
196
196
|
const code = err instanceof DaemonError ? err.code : "internal";
|
|
197
197
|
const message = err instanceof Error ? err.message : String(err);
|
|
198
|
-
|
|
198
|
+
connection.write(JSON.stringify({ id: request.id, error: { code, message } }) + "\n");
|
|
199
199
|
}
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
-
private async dispatch(
|
|
202
|
+
private async dispatch(connection: Connection, request: Request): Promise<unknown> {
|
|
203
203
|
// The wire is untrusted JSON: validate before DaemonCore sees anything.
|
|
204
|
-
if (typeof
|
|
205
|
-
throw new DaemonError("unknown_method", `unknown method ${String(
|
|
204
|
+
if (typeof request.method !== "string" || !isKnownMethod(request.method)) {
|
|
205
|
+
throw new DaemonError("unknown_method", `unknown method ${String(request.method)}`);
|
|
206
206
|
}
|
|
207
207
|
const core = this.core;
|
|
208
|
-
switch (
|
|
208
|
+
switch (request.method) {
|
|
209
209
|
case "daemon.ping":
|
|
210
210
|
return { pid: process.pid };
|
|
211
211
|
case "daemon.shutdown":
|
|
@@ -215,46 +215,46 @@ export class DaemonServer {
|
|
|
215
215
|
}, 10);
|
|
216
216
|
return {};
|
|
217
217
|
case "events.subscribe":
|
|
218
|
-
|
|
218
|
+
connection.subscribed = true;
|
|
219
219
|
return {};
|
|
220
220
|
case "session.create": {
|
|
221
|
-
const
|
|
222
|
-
return core.sessionCreate({ workspace:
|
|
221
|
+
const params = parseParams("session.create", request.params);
|
|
222
|
+
return core.sessionCreate({ workspace: params.workspace, artifact: params.artifact });
|
|
223
223
|
}
|
|
224
224
|
case "session.get":
|
|
225
|
-
return core.sessionGet(parseParams("session.get",
|
|
225
|
+
return core.sessionGet(parseParams("session.get", request.params).id);
|
|
226
226
|
case "session.list":
|
|
227
|
-
return core.sessionList(parseParams("session.list",
|
|
227
|
+
return core.sessionList(parseParams("session.list", request.params).filter);
|
|
228
228
|
case "session.wait": {
|
|
229
|
-
const
|
|
230
|
-
return core.sessionWait(
|
|
229
|
+
const params = parseParams("session.wait", request.params);
|
|
230
|
+
return core.sessionWait(params.id, params.timeoutMs);
|
|
231
231
|
}
|
|
232
232
|
case "session.annotate": {
|
|
233
|
-
const
|
|
234
|
-
return core.sessionAnnotate(
|
|
233
|
+
const params = parseParams("session.annotate", request.params);
|
|
234
|
+
return core.sessionAnnotate(params.id, params.annotation);
|
|
235
235
|
}
|
|
236
236
|
case "session.removeAnnotation": {
|
|
237
|
-
const
|
|
238
|
-
return core.sessionRemoveAnnotation(
|
|
237
|
+
const params = parseParams("session.removeAnnotation", request.params);
|
|
238
|
+
return core.sessionRemoveAnnotation(params.id, params.annotationId);
|
|
239
239
|
}
|
|
240
240
|
case "session.setWorkingCopy": {
|
|
241
|
-
const
|
|
242
|
-
return core.sessionSetWorkingCopy(
|
|
241
|
+
const params = parseParams("session.setWorkingCopy", request.params);
|
|
242
|
+
return core.sessionSetWorkingCopy(params.id, params.workingCopy);
|
|
243
243
|
}
|
|
244
244
|
case "session.setViewed": {
|
|
245
|
-
const
|
|
246
|
-
return core.sessionSetViewed(
|
|
245
|
+
const params = parseParams("session.setViewed", request.params);
|
|
246
|
+
return core.sessionSetViewed(params.id, params.viewedPaths);
|
|
247
247
|
}
|
|
248
248
|
case "session.resolve": {
|
|
249
|
-
const
|
|
250
|
-
return core.sessionResolve(
|
|
249
|
+
const params = parseParams("session.resolve", request.params);
|
|
250
|
+
return core.sessionResolve(params.id, params.verdictKind, params.summary);
|
|
251
251
|
}
|
|
252
252
|
case "session.submitRevision": {
|
|
253
|
-
const
|
|
254
|
-
return core.sessionSubmitRevision(
|
|
253
|
+
const params = parseParams("session.submitRevision", request.params);
|
|
254
|
+
return core.sessionSubmitRevision(params.id, params.content);
|
|
255
255
|
}
|
|
256
256
|
default:
|
|
257
|
-
throw new DaemonError("unknown_method", `unknown method ${
|
|
257
|
+
throw new DaemonError("unknown_method", `unknown method ${request.method}`);
|
|
258
258
|
}
|
|
259
259
|
}
|
|
260
260
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Single-instance guarantee
|
|
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,14 +23,14 @@ beforeEach(() => {
|
|
|
23
23
|
home = mkdtempSync(join(tmpdir(), "cueloop-lock-"));
|
|
24
24
|
});
|
|
25
25
|
afterEach(() => {
|
|
26
|
-
for (const
|
|
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
|
|
32
|
-
servers.push(
|
|
33
|
-
return
|
|
31
|
+
const daemonServer = new DaemonServer({ home, idleExitMs: 0 });
|
|
32
|
+
servers.push(daemonServer);
|
|
33
|
+
return daemonServer;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
describe("one daemon per home", () => {
|
|
@@ -63,8 +63,8 @@ describe("one daemon per home", () => {
|
|
|
63
63
|
// a pid that cannot be running (init is never a cueloop daemon, and this
|
|
64
64
|
// simulates the record a crashed process leaves behind)
|
|
65
65
|
writeFileSync(lockPath(home), "999999");
|
|
66
|
-
const
|
|
67
|
-
expect(
|
|
66
|
+
const daemonServer = server();
|
|
67
|
+
expect(daemonServer.start()).toBe(socketPath(home));
|
|
68
68
|
});
|
|
69
69
|
|
|
70
70
|
test("stopping releases the lock so a restart works", () => {
|
|
@@ -84,12 +84,12 @@ describe("one daemon per home", () => {
|
|
|
84
84
|
DaemonClient.connect({ home, autostart: true }),
|
|
85
85
|
]);
|
|
86
86
|
try {
|
|
87
|
-
const pids = await Promise.all(clients.map((
|
|
88
|
-
const unique = new Set(pids.map((
|
|
87
|
+
const pids = await Promise.all(clients.map((client) => client.ping()));
|
|
88
|
+
const unique = new Set(pids.map((ping) => ping.pid));
|
|
89
89
|
expect(unique.size).toBe(1);
|
|
90
90
|
// and state is shared: one client's session is visible to the others
|
|
91
|
-
const
|
|
92
|
-
expect((await clients[2]!.sessionGet(
|
|
91
|
+
const session = await clients[0]!.sessionCreate(WS, PLAN);
|
|
92
|
+
expect((await clients[2]!.sessionGet(session.id)).id).toBe(session.id);
|
|
93
93
|
} finally {
|
|
94
94
|
for (const c of clients) c.close();
|
|
95
95
|
// the autostarted daemon is not one of `servers` - shut it down
|
package/src/store.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Session persistence
|
|
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.
|
|
@@ -49,15 +49,15 @@ export class SessionStore {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
list(): ReviewSession[] {
|
|
52
|
-
return [...this.sessions.values()].sort((
|
|
52
|
+
return [...this.sessions.values()].sort((left, right) => left.createdAt.localeCompare(right.createdAt));
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
upsert(session: ReviewSession): void {
|
|
56
56
|
this.sessions.set(session.id, session);
|
|
57
57
|
const path = join(this.dir, `${session.id}.json`);
|
|
58
|
-
const
|
|
59
|
-
writeFileSync(
|
|
60
|
-
renameSync(
|
|
58
|
+
const tempPath = path + ".tmp";
|
|
59
|
+
writeFileSync(tempPath, JSON.stringify(session, null, 2));
|
|
60
|
+
renameSync(tempPath, path);
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
delete(id: string): boolean {
|
package/src/validate.test.ts
CHANGED
|
@@ -38,11 +38,11 @@ describe("method allowlist", () => {
|
|
|
38
38
|
|
|
39
39
|
describe("parseParams", () => {
|
|
40
40
|
test("accepts a well-formed create and defaults meta", () => {
|
|
41
|
-
const
|
|
41
|
+
const params = parseParams("session.create", {
|
|
42
42
|
workspace: { repoRoot: "/repo", branch: "main" },
|
|
43
43
|
artifact: { type: "plan", content: "# P" },
|
|
44
44
|
});
|
|
45
|
-
expect(
|
|
45
|
+
expect(params.artifact.meta).toEqual({});
|
|
46
46
|
});
|
|
47
47
|
|
|
48
48
|
test("rejects a missing workspace with a pathed message", () => {
|
|
@@ -81,12 +81,12 @@ describe("parseParams", () => {
|
|
|
81
81
|
});
|
|
82
82
|
|
|
83
83
|
test("annotation kinds stay open (extension kinds are allowed)", () => {
|
|
84
|
-
const
|
|
84
|
+
const params = parseParams("session.annotate", {
|
|
85
85
|
id: "s",
|
|
86
86
|
annotation: { id: "a1", kind: "praise", anchor: { quote: "x" }, body: "nice" },
|
|
87
87
|
});
|
|
88
|
-
expect(
|
|
89
|
-
expect(
|
|
88
|
+
expect(params.annotation.kind).toBe("praise");
|
|
89
|
+
expect(params.annotation.anchor.prefix).toBe("");
|
|
90
90
|
});
|
|
91
91
|
|
|
92
92
|
test("setViewed takes a full path list and rejects non-string entries", () => {
|
|
@@ -122,25 +122,25 @@ describe("validateSessionRecord", () => {
|
|
|
122
122
|
});
|
|
123
123
|
|
|
124
124
|
test("rejects a foreign schema version with a readable reason", () => {
|
|
125
|
-
const
|
|
126
|
-
expect(
|
|
127
|
-
if (!
|
|
125
|
+
const result = validateSessionRecord({ ...record, schemaVersion: "99" });
|
|
126
|
+
expect(result.ok).toBe(false);
|
|
127
|
+
if (!result.ok) expect(result.error).toContain("schemaVersion");
|
|
128
128
|
});
|
|
129
129
|
|
|
130
130
|
test("rejects a structurally broken record", () => {
|
|
131
|
-
const
|
|
132
|
-
expect(
|
|
131
|
+
const result = validateSessionRecord({ ...record, revisions: "nope" });
|
|
132
|
+
expect(result.ok).toBe(false);
|
|
133
133
|
});
|
|
134
134
|
});
|
|
135
135
|
|
|
136
136
|
/**
|
|
137
|
-
* Wire pins
|
|
137
|
+
* Wire pins: every schema's entries must cover exactly the keys of the
|
|
138
138
|
* schema type it mirrors. Runtime complement to the compile-time EntriesOf
|
|
139
139
|
* check - the samples are fully populated and typed, so a new field in
|
|
140
140
|
* @cueloop/schema shows up here too.
|
|
141
141
|
*/
|
|
142
142
|
describe("wire pins", () => {
|
|
143
|
-
const keys = (
|
|
143
|
+
const keys = (subject: object) => Object.keys(subject).sort();
|
|
144
144
|
const entryKeys = (schema: { entries: object }) => Object.keys(schema.entries).sort();
|
|
145
145
|
|
|
146
146
|
const fullMeta: Required<ArtifactMeta> = {
|
package/src/validate.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Runtime validation of the wire boundary
|
|
2
|
+
* Runtime validation of the wire boundary. Everything arriving over the
|
|
3
3
|
* socket is untrusted JSON: adapters, the CLI, extensions, and any script a
|
|
4
4
|
* user writes all speak this protocol. Validating here means DaemonCore only
|
|
5
5
|
* ever sees well-formed input, and a malformed request gets a precise error
|
|
@@ -67,7 +67,7 @@ export const AnchorSchema = v.object({
|
|
|
67
67
|
/** Wire annotations arrive without createdAt - the daemon stamps it. */
|
|
68
68
|
export const AnnotationSchema = v.object({
|
|
69
69
|
id: NonEmpty,
|
|
70
|
-
/** Open kind set
|
|
70
|
+
/** Open kind set: built-ins are comment and suggestion. */
|
|
71
71
|
kind: NonEmpty,
|
|
72
72
|
anchor: AnchorSchema,
|
|
73
73
|
body: v.string(),
|
|
@@ -118,7 +118,7 @@ export function parseParams<M extends MethodName>(method: M, params: unknown): v
|
|
|
118
118
|
const result = v.safeParse(Params[method], params ?? {});
|
|
119
119
|
if (!result.success) {
|
|
120
120
|
const issue = result.issues[0]!;
|
|
121
|
-
const path = issue.path?.map((
|
|
121
|
+
const path = issue.path?.map((pathSegment) => String(pathSegment.key)).join(".") ?? "";
|
|
122
122
|
throw new DaemonError("invalid_params", `${method}: ${path ? path + " - " : ""}${issue.message}`);
|
|
123
123
|
}
|
|
124
124
|
return result.output;
|
|
@@ -156,6 +156,6 @@ export function validateSessionRecord(raw: unknown): { ok: true; value: v.InferO
|
|
|
156
156
|
const result = v.safeParse(SessionRecordSchema, raw);
|
|
157
157
|
if (result.success) return { ok: true, value: result.output };
|
|
158
158
|
const issue = result.issues[0]!;
|
|
159
|
-
const path = issue.path?.map((
|
|
159
|
+
const path = issue.path?.map((pathSegment) => String(pathSegment.key)).join(".") ?? "";
|
|
160
160
|
return { ok: false, error: `${path ? path + ": " : ""}${issue.message}` };
|
|
161
161
|
}
|