@cueloop/daemon 0.1.0-alpha.25 → 0.1.0-alpha.27
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.ts +21 -3
- package/src/client.ts +14 -4
- package/src/server.test.ts +34 -14
- package/src/server.ts +7 -3
- package/src/validate.test.ts +5 -0
- package/src/validate.ts +15 -1
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.27",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"./share-blob": "./src/share-blob.ts"
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
14
|
+
"@cueloop/schema": "0.1.0-alpha.27",
|
|
15
15
|
"valibot": "^1.4.2"
|
|
16
16
|
},
|
|
17
17
|
"publishConfig": {
|
package/src/api.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
verdictAllows,
|
|
16
16
|
type Annotation,
|
|
17
17
|
type Artifact,
|
|
18
|
+
type Identity,
|
|
18
19
|
type ReviewSession,
|
|
19
20
|
type Verdict,
|
|
20
21
|
type VerdictKind,
|
|
@@ -176,11 +177,28 @@ export class DaemonCore {
|
|
|
176
177
|
return session;
|
|
177
178
|
}
|
|
178
179
|
|
|
179
|
-
/**
|
|
180
|
-
|
|
180
|
+
/** Remove a session for good (inbox delete); resolved or pending, both go. */
|
|
181
|
+
sessionDelete(id: string): void {
|
|
182
|
+
if (!this.store.delete(id)) throw new DaemonError("not_found", `no session ${id}`);
|
|
183
|
+
this.emit("inbox.changed", id);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Merge a share's collaborator state back into the local session: annotations
|
|
188
|
+
* union by id with existing ones (the planner's) winning, and the participant
|
|
189
|
+
* registry union by id with the incoming identity winning (a collaborator is
|
|
190
|
+
* the authority on their own name). This is how a teammate's name reaches the
|
|
191
|
+
* planner after a pull, alongside their notes.
|
|
192
|
+
*/
|
|
193
|
+
sessionMergeShared(id: string, incoming: { annotations: Annotation[]; participants?: Identity[] }): ReviewSession {
|
|
181
194
|
const session = this.mutable(id);
|
|
182
195
|
const known = new Set(session.annotations.map((annotation) => annotation.id));
|
|
183
|
-
for (const annotation of incoming) if (!known.has(annotation.id)) session.annotations.push(annotation);
|
|
196
|
+
for (const annotation of incoming.annotations) if (!known.has(annotation.id)) session.annotations.push(annotation);
|
|
197
|
+
if (incoming.participants?.length) {
|
|
198
|
+
const registry = new Map((session.participants ?? []).map((participant) => [participant.id, participant]));
|
|
199
|
+
for (const participant of incoming.participants) registry.set(participant.id, participant);
|
|
200
|
+
session.participants = [...registry.values()];
|
|
201
|
+
}
|
|
184
202
|
this.store.upsert(session);
|
|
185
203
|
this.emit("session.updated", id);
|
|
186
204
|
return session;
|
package/src/client.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { existsSync, rmSync } from "node:fs";
|
|
8
|
-
import type { Annotation, Artifact, ReviewSession, VerdictKind, WorkspaceKey } from "@cueloop/schema";
|
|
8
|
+
import type { Annotation, Artifact, Identity, ReviewSession, VerdictKind, WorkspaceKey } from "@cueloop/schema";
|
|
9
9
|
import { BackpressureWriter, LineBuffer, type EventFrame, type Response } from "./protocol";
|
|
10
10
|
import { cueloopHome, socketPath } from "./paths";
|
|
11
11
|
|
|
@@ -35,7 +35,10 @@ export interface SessionClient {
|
|
|
35
35
|
sessionSetWorkingCopy(id: string, workingCopy: string | undefined): Promise<ReviewSession>;
|
|
36
36
|
sessionSetViewed(id: string, viewedPaths: string[]): Promise<ReviewSession>;
|
|
37
37
|
sessionSetShareId(id: string, shareId: string): Promise<ReviewSession>;
|
|
38
|
-
|
|
38
|
+
sessionMergeShared(id: string, incoming: { annotations: Annotation[]; participants?: Identity[] }): Promise<ReviewSession>;
|
|
39
|
+
sessionDelete(id: string): Promise<void>;
|
|
40
|
+
/** Record the caller's own identity name (collaborator self-naming on a share). */
|
|
41
|
+
sessionSetSelfName(id: string, name: string): Promise<ReviewSession>;
|
|
39
42
|
sessionResolve(id: string, verdictKind: VerdictKind, summary: string): Promise<ReviewSession>;
|
|
40
43
|
close(): void;
|
|
41
44
|
}
|
|
@@ -188,8 +191,15 @@ export class DaemonClient implements SessionClient {
|
|
|
188
191
|
sessionSetShareId(id: string, shareId: string): Promise<ReviewSession> {
|
|
189
192
|
return this.request("session.setShareId", { id, shareId });
|
|
190
193
|
}
|
|
191
|
-
|
|
192
|
-
return this.request("session.
|
|
194
|
+
sessionMergeShared(id: string, incoming: { annotations: Annotation[]; participants?: Identity[] }): Promise<ReviewSession> {
|
|
195
|
+
return this.request("session.mergeShared", { id, ...incoming });
|
|
196
|
+
}
|
|
197
|
+
sessionDelete(id: string): Promise<void> {
|
|
198
|
+
return this.request("session.delete", { id });
|
|
199
|
+
}
|
|
200
|
+
/** Local sessions have no collaborator self-name; the share client owns this. */
|
|
201
|
+
sessionSetSelfName(id: string, _name: string): Promise<ReviewSession> {
|
|
202
|
+
return this.sessionGet(id);
|
|
193
203
|
}
|
|
194
204
|
sessionResolve(id: string, verdictKind: VerdictKind, summary: string): Promise<ReviewSession> {
|
|
195
205
|
return this.request("session.resolve", { id, verdictKind, summary });
|
package/src/server.test.ts
CHANGED
|
@@ -121,41 +121,61 @@ describe("socket round-trip", () => {
|
|
|
121
121
|
expect(got.artifact.meta.agent).toBe("claude-code");
|
|
122
122
|
});
|
|
123
123
|
|
|
124
|
-
test("share-sync verbs round-trip: setShareId persists,
|
|
125
|
-
//
|
|
124
|
+
test("share-sync verbs round-trip: setShareId persists, mergeShared unions notes and identities by id", async () => {
|
|
125
|
+
// Given a shared session with one local note
|
|
126
126
|
const session = await client.sessionCreate(WS, PLAN);
|
|
127
127
|
const anchor = { quote: "Body text", prefix: "", suffix: "." };
|
|
128
|
-
|
|
129
|
-
// Act - stamp the share id, then a local note
|
|
130
128
|
await client.sessionSetShareId(session.id, "p_abc123xy");
|
|
131
129
|
await client.sessionAnnotate(session.id, { id: "a1", kind: "comment", anchor, body: "mine" });
|
|
132
|
-
|
|
133
|
-
// Assert - the share id came back over the wire
|
|
134
130
|
expect((await client.sessionGet(session.id)).shareId).toBe("p_abc123xy");
|
|
135
131
|
|
|
136
|
-
//
|
|
137
|
-
const merged = await client.
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
132
|
+
// When a pull merges an update to the known id, a new collaborator note, and the collaborator's identity
|
|
133
|
+
const merged = await client.sessionMergeShared(session.id, {
|
|
134
|
+
annotations: [
|
|
135
|
+
{ id: "a1", kind: "comment", anchor, body: "should not overwrite", createdAt: "2026-01-01T00:00:00.000Z" },
|
|
136
|
+
{ id: "a2", kind: "comment", anchor, body: "theirs", author: "SHA256:mate", createdAt: "2026-01-01T00:00:00.000Z" },
|
|
137
|
+
],
|
|
138
|
+
participants: [{ id: "SHA256:mate", provider: "ssh", name: "Sam" }],
|
|
139
|
+
});
|
|
141
140
|
|
|
142
|
-
//
|
|
141
|
+
// Then notes union by id (existing a1 kept), and the participant registry carries the collaborator's name
|
|
143
142
|
expect(merged.annotations.map((annotation) => annotation.id).sort()).toEqual(["a1", "a2"]);
|
|
144
143
|
expect(merged.annotations.find((annotation) => annotation.id === "a1")!.body).toBe("mine");
|
|
145
144
|
expect(merged.annotations.find((annotation) => annotation.id === "a2")!.author).toBe("SHA256:mate");
|
|
145
|
+
expect(merged.participants).toEqual([{ id: "SHA256:mate", provider: "ssh", name: "Sam" }]);
|
|
146
146
|
});
|
|
147
147
|
|
|
148
|
-
test("
|
|
148
|
+
test("mergeShared validates each annotation at the socket boundary", async () => {
|
|
149
|
+
// Given a session
|
|
149
150
|
const session = await client.sessionCreate(WS, PLAN);
|
|
150
151
|
try {
|
|
152
|
+
// When a malformed annotation is merged
|
|
151
153
|
// @ts-expect-error intentionally malformed annotation (empty id, missing fields)
|
|
152
|
-
await client.
|
|
154
|
+
await client.sessionMergeShared(session.id, { annotations: [{ id: "" }] });
|
|
153
155
|
throw new Error("should have thrown");
|
|
154
156
|
} catch (error) {
|
|
157
|
+
// Then it is rejected at the wire boundary
|
|
155
158
|
expect((error as DaemonClientError).code).toBe("invalid_params");
|
|
156
159
|
}
|
|
157
160
|
});
|
|
158
161
|
|
|
162
|
+
test("session.delete removes a session over the wire", async () => {
|
|
163
|
+
// Given a created session
|
|
164
|
+
const session = await client.sessionCreate(WS, PLAN);
|
|
165
|
+
|
|
166
|
+
// When it is deleted
|
|
167
|
+
await client.sessionDelete(session.id);
|
|
168
|
+
|
|
169
|
+
// Then it is gone, and deleting an unknown id is a not_found error
|
|
170
|
+
expect(client.sessionGet(session.id)).rejects.toBeInstanceOf(DaemonClientError);
|
|
171
|
+
try {
|
|
172
|
+
await client.sessionDelete("ses_missing");
|
|
173
|
+
throw new Error("should have thrown");
|
|
174
|
+
} catch (error) {
|
|
175
|
+
expect((error as DaemonClientError).code).toBe("not_found");
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
159
179
|
test("two clients see the same state (thin-renderer model)", async () => {
|
|
160
180
|
// Arrange
|
|
161
181
|
const second = await DaemonClient.connect({ home });
|
package/src/server.ts
CHANGED
|
@@ -249,9 +249,13 @@ export class DaemonServer {
|
|
|
249
249
|
const params = parseParams("session.setShareId", request.params);
|
|
250
250
|
return core.sessionSetShareId(params.id, params.shareId);
|
|
251
251
|
}
|
|
252
|
-
case "session.
|
|
253
|
-
|
|
254
|
-
return
|
|
252
|
+
case "session.delete": {
|
|
253
|
+
core.sessionDelete(parseParams("session.delete", request.params).id);
|
|
254
|
+
return {};
|
|
255
|
+
}
|
|
256
|
+
case "session.mergeShared": {
|
|
257
|
+
const params = parseParams("session.mergeShared", request.params);
|
|
258
|
+
return core.sessionMergeShared(params.id, { annotations: params.annotations, participants: params.participants });
|
|
255
259
|
}
|
|
256
260
|
case "session.resolve": {
|
|
257
261
|
const params = parseParams("session.resolve", request.params);
|
package/src/validate.test.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
AnnotationSchema,
|
|
10
10
|
ArtifactMetaSchema,
|
|
11
11
|
ArtifactSchema,
|
|
12
|
+
IdentitySchema,
|
|
12
13
|
RevisionSchema,
|
|
13
14
|
SessionRecordSchema,
|
|
14
15
|
VerdictSchema,
|
|
@@ -23,6 +24,7 @@ import {
|
|
|
23
24
|
type Annotation,
|
|
24
25
|
type Artifact,
|
|
25
26
|
type ArtifactMeta,
|
|
27
|
+
type Identity,
|
|
26
28
|
type ReviewSession,
|
|
27
29
|
type Revision,
|
|
28
30
|
type Verdict,
|
|
@@ -179,6 +181,7 @@ describe("wire pins", () => {
|
|
|
179
181
|
const fullVerdict: Required<Verdict> = { kind: "approve", summary: "", feedback: "", resolvedAt: "now" };
|
|
180
182
|
const fullRevision: Required<Revision> = { revision: 1, content: "# P", submittedAt: "now" };
|
|
181
183
|
const fullWorkspace: Required<WorkspaceKey> = { repoRoot: "/repo", branch: "main" };
|
|
184
|
+
const fullIdentity: Required<Identity> = { id: "SHA256:abc", provider: "ssh", name: "Al", handle: "abc" };
|
|
182
185
|
const fullSession: Required<ReviewSession> = {
|
|
183
186
|
schemaVersion: SCHEMA_VERSION,
|
|
184
187
|
id: "ses_1",
|
|
@@ -193,6 +196,7 @@ describe("wire pins", () => {
|
|
|
193
196
|
createdAt: "now",
|
|
194
197
|
shareId: "p_abc123xy",
|
|
195
198
|
owner: "SHA256:owner",
|
|
199
|
+
participants: [fullIdentity],
|
|
196
200
|
};
|
|
197
201
|
|
|
198
202
|
test("schema key sets match the schema types", () => {
|
|
@@ -205,6 +209,7 @@ describe("wire pins", () => {
|
|
|
205
209
|
expect(entryKeys(AnnotationSchema)).toEqual(keys(wireAnnotation));
|
|
206
210
|
expect(entryKeys(RevisionSchema)).toEqual(keys(fullRevision));
|
|
207
211
|
expect(entryKeys(VerdictSchema)).toEqual(keys(fullVerdict));
|
|
212
|
+
expect(entryKeys(IdentitySchema)).toEqual(keys(fullIdentity));
|
|
208
213
|
expect(entryKeys(SessionRecordSchema)).toEqual(keys(fullSession));
|
|
209
214
|
// persisted annotations carry the stamped createdAt
|
|
210
215
|
const stored = SessionRecordSchema.entries.annotations.item;
|
package/src/validate.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
type Annotation,
|
|
17
17
|
type Artifact,
|
|
18
18
|
type ArtifactMeta,
|
|
19
|
+
type Identity,
|
|
19
20
|
type ReviewSession,
|
|
20
21
|
type Revision,
|
|
21
22
|
type Verdict,
|
|
@@ -86,6 +87,13 @@ const SessionId = NonEmpty;
|
|
|
86
87
|
/** A stored annotation: the wire shape plus the daemon-stamped createdAt. */
|
|
87
88
|
export const FullAnnotationSchema = v.object({ ...AnnotationSchema.entries, createdAt: v.string() } satisfies EntriesOf<Annotation>);
|
|
88
89
|
|
|
90
|
+
export const IdentitySchema = v.object({
|
|
91
|
+
id: NonEmpty,
|
|
92
|
+
provider: v.literal("ssh"),
|
|
93
|
+
name: v.optional(v.string()),
|
|
94
|
+
handle: v.optional(v.string()),
|
|
95
|
+
} satisfies EntriesOf<Identity>);
|
|
96
|
+
|
|
89
97
|
export const Params = {
|
|
90
98
|
"session.create": v.object({ workspace: WorkspaceSchema, artifact: ArtifactSchema }),
|
|
91
99
|
"session.get": v.object({ id: SessionId }),
|
|
@@ -107,7 +115,12 @@ export const Params = {
|
|
|
107
115
|
"session.setWorkingCopy": v.object({ id: SessionId, workingCopy: v.optional(v.string()) }),
|
|
108
116
|
"session.setViewed": v.object({ id: SessionId, viewedPaths: v.array(v.string()) }),
|
|
109
117
|
"session.setShareId": v.object({ id: SessionId, shareId: NonEmpty }),
|
|
110
|
-
"session.
|
|
118
|
+
"session.delete": v.object({ id: SessionId }),
|
|
119
|
+
"session.mergeShared": v.object({
|
|
120
|
+
id: SessionId,
|
|
121
|
+
annotations: v.array(FullAnnotationSchema),
|
|
122
|
+
participants: v.optional(v.array(IdentitySchema)),
|
|
123
|
+
}),
|
|
111
124
|
"session.resolve": v.object({
|
|
112
125
|
id: SessionId,
|
|
113
126
|
verdictKind: v.picklist(["comment", "approve", "request_changes"]),
|
|
@@ -169,6 +182,7 @@ export const SessionRecordSchema = v.object({
|
|
|
169
182
|
createdAt: v.string(),
|
|
170
183
|
shareId: v.optional(v.string()),
|
|
171
184
|
owner: v.optional(v.string()),
|
|
185
|
+
participants: v.optional(v.array(IdentitySchema)),
|
|
172
186
|
} satisfies EntriesOf<ReviewSession>);
|
|
173
187
|
|
|
174
188
|
export function validateSessionRecord(raw: unknown): { ok: true; value: v.InferOutput<typeof SessionRecordSchema> } | { ok: false; error: string } {
|