@cueloop/daemon 0.1.0-alpha.63 → 0.1.0-alpha.65
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 +6 -3
- package/src/api.test.ts +479 -1
- package/src/api.ts +453 -28
- package/src/capabilities.test.ts +28 -9
- package/src/capabilities.ts +58 -22
- package/src/client.ts +183 -43
- package/src/curate.ts +109 -0
- package/src/diff-hot-reload.test.ts +16 -0
- package/src/diff-watcher.ts +1 -4
- package/src/herdr-pane.test.ts +4 -7
- package/src/herdr-pane.ts +20 -7
- package/src/herdr-split.test.ts +1 -1
- package/src/herdr-split.ts +8 -4
- package/src/herdr-tab-store.ts +10 -13
- package/src/paths.ts +5 -0
- package/src/protocol.ts +28 -0
- package/src/retention.test.ts +3 -3
- package/src/retention.ts +1 -1
- package/src/review.ts +1 -1
- package/src/server.test.ts +258 -54
- package/src/server.ts +102 -13
- package/src/share-blob.test.ts +51 -1
- package/src/share-blob.ts +1 -1
- package/src/store.conformance.test.ts +41 -0
- package/src/store.ts +89 -12
- package/src/testing/store-conformance.ts +194 -0
- package/src/validate.test.ts +60 -3
- package/src/validate.ts +104 -3
package/src/capabilities.test.ts
CHANGED
|
@@ -1,28 +1,47 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { asDaemonRole, roleAllowsMethod } from "./capabilities";
|
|
2
|
+
import { asDaemonRole, DEFAULT_ROLE, roleAllowsMethod } from "./capabilities";
|
|
3
|
+
import type { MethodName } from "./validate";
|
|
3
4
|
|
|
4
5
|
describe("roleAllowsMethod", () => {
|
|
5
|
-
test("the owner may call every
|
|
6
|
+
test("the owner may call every primitive", () => {
|
|
6
7
|
// Assert
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
const owned: MethodName[] = ["session.resolve", "session.submitRevision", "session.delete"];
|
|
9
|
+
|
|
10
|
+
for (const method of owned) expect(roleAllowsMethod("owner", method)).toBe(true);
|
|
9
11
|
});
|
|
10
12
|
|
|
11
13
|
test("a capped role may read and annotate but not escalate", () => {
|
|
12
14
|
// Assert - allowed
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
const open: MethodName[] = ["session.get", "session.list", "session.wait", "session.annotate"];
|
|
16
|
+
|
|
17
|
+
for (const method of open) expect(roleAllowsMethod("agent", method)).toBe(true);
|
|
15
18
|
// Assert - denied
|
|
16
|
-
|
|
19
|
+
const owned: MethodName[] = [
|
|
17
20
|
"session.resolve",
|
|
18
21
|
"session.submitRevision",
|
|
19
22
|
"session.setWorkingCopy",
|
|
20
23
|
"session.delete",
|
|
21
24
|
"session.setShareId",
|
|
22
25
|
"session.refreshDiff",
|
|
23
|
-
|
|
24
|
-
|
|
26
|
+
"session.create",
|
|
27
|
+
"session.navigate",
|
|
28
|
+
"session.branch",
|
|
29
|
+
"session.switch",
|
|
30
|
+
"session.label",
|
|
31
|
+
"session.fork",
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
for (const method of owned) expect(roleAllowsMethod("agent", method)).toBe(false);
|
|
25
35
|
expect(roleAllowsMethod("collaborator", "session.resolve")).toBe(false);
|
|
36
|
+
// removal and naming are open to every role; the primitive itself scopes a
|
|
37
|
+
// non-owner to the author it acts as
|
|
38
|
+
expect(roleAllowsMethod("agent", "session.removeAnnotation")).toBe(true);
|
|
39
|
+
expect(roleAllowsMethod("agent", "session.setParticipantName")).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("a connection starts as a collaborator, never as the owner", () => {
|
|
43
|
+
// Assert
|
|
44
|
+
expect(DEFAULT_ROLE).toBe("collaborator");
|
|
26
45
|
});
|
|
27
46
|
});
|
|
28
47
|
|
package/src/capabilities.ts
CHANGED
|
@@ -1,30 +1,66 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Daemon
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* Daemon roles: which primitives a connection may call. A connection is a
|
|
3
|
+
* collaborator until it proves ownership with the owner token in daemon.hello
|
|
4
|
+
* - never an owner by default. The owner (the local planner) may call every
|
|
5
|
+
* primitive; a collaborator or a review-side agent is capped to reading the
|
|
6
|
+
* session and adding comments, so a bring-your-own agent cannot resolve,
|
|
7
|
+
* rewrite, cut, share, or delete (ADR 0009). The table below is exhaustive
|
|
8
|
+
* over the primitive schemas: adding a primitive without naming its roles
|
|
9
|
+
* does not compile.
|
|
7
10
|
*/
|
|
8
11
|
|
|
12
|
+
import * as v from "valibot";
|
|
13
|
+
import type { MethodName } from "./validate";
|
|
14
|
+
|
|
9
15
|
export type DaemonRole = "owner" | "collaborator" | "agent";
|
|
10
16
|
|
|
11
|
-
/**
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"session.
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
17
|
+
/** Who may call a primitive: everyone, or the owner only. */
|
|
18
|
+
type Audience = "any" | "owner";
|
|
19
|
+
|
|
20
|
+
const PRIMITIVE_ROLES = {
|
|
21
|
+
"daemon.ping": "any",
|
|
22
|
+
"daemon.hello": "any",
|
|
23
|
+
"daemon.shutdown": "owner",
|
|
24
|
+
"events.subscribe": "any",
|
|
25
|
+
"session.create": "owner",
|
|
26
|
+
"session.get": "any",
|
|
27
|
+
"session.list": "any",
|
|
28
|
+
"session.wait": "any",
|
|
29
|
+
"session.annotate": "any",
|
|
30
|
+
// any role may call it; a non-owner must act on behalf of an author and stays scoped to it
|
|
31
|
+
"session.removeAnnotation": "any",
|
|
32
|
+
"session.setParticipantName": "any",
|
|
33
|
+
"session.setWorkingCopy": "owner",
|
|
34
|
+
"session.cutBlock": "owner",
|
|
35
|
+
"session.restoreBlock": "owner",
|
|
36
|
+
"session.curate": "owner",
|
|
37
|
+
"session.setViewed": "owner",
|
|
38
|
+
// the tree is the owner's: collaborators comment on the branch a share follows
|
|
39
|
+
"session.navigate": "owner",
|
|
40
|
+
"session.branch": "owner",
|
|
41
|
+
"session.switch": "owner",
|
|
42
|
+
"session.label": "owner",
|
|
43
|
+
"session.fork": "owner",
|
|
44
|
+
"session.refreshDiff": "owner",
|
|
45
|
+
"session.setShareId": "owner",
|
|
46
|
+
"session.delete": "owner",
|
|
47
|
+
"session.mergeShared": "owner",
|
|
48
|
+
"session.resolve": "owner",
|
|
49
|
+
"session.submitRevision": "owner",
|
|
50
|
+
"herdr.getTab": "owner",
|
|
51
|
+
"herdr.setTab": "owner",
|
|
52
|
+
} as const satisfies Record<MethodName, Audience>;
|
|
53
|
+
|
|
54
|
+
/** Whether `role` may call `method`. */
|
|
55
|
+
export function roleAllowsMethod(role: DaemonRole, method: MethodName): boolean {
|
|
56
|
+
return role === "owner" || PRIMITIVE_ROLES[method] === "any";
|
|
25
57
|
}
|
|
26
58
|
|
|
27
|
-
/**
|
|
28
|
-
export
|
|
29
|
-
|
|
59
|
+
/** The role a connection starts with, before any proof. */
|
|
60
|
+
export const DEFAULT_ROLE: DaemonRole = "collaborator";
|
|
61
|
+
|
|
62
|
+
export function asDaemonRole(value: Parameters<typeof v.safeParse>[1]): DaemonRole {
|
|
63
|
+
const result = v.safeParse(v.picklist(["owner", "collaborator"]), value);
|
|
64
|
+
|
|
65
|
+
return result.success ? result.output : "agent";
|
|
30
66
|
}
|
package/src/client.ts
CHANGED
|
@@ -1,22 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Daemon client: the one library every consumer shares - CLI
|
|
2
|
+
* Daemon client: the one library every consumer shares - CLI primitives, the TUI,
|
|
3
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
|
-
import { existsSync, rmSync } from "node:fs";
|
|
7
|
+
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
8
|
+
import * as v from "valibot";
|
|
8
9
|
import type {
|
|
9
10
|
Annotation,
|
|
10
11
|
Artifact,
|
|
11
|
-
|
|
12
|
+
HunkRejection,
|
|
12
13
|
ReviewSession,
|
|
13
14
|
VerdictKind,
|
|
14
15
|
WorkspaceKey,
|
|
15
16
|
} from "@cueloop/schema";
|
|
16
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
BackpressureWriter,
|
|
19
|
+
LineBuffer,
|
|
20
|
+
parseInboundFrame,
|
|
21
|
+
type EventFrame,
|
|
22
|
+
type Request,
|
|
23
|
+
type Response,
|
|
24
|
+
} from "./protocol";
|
|
17
25
|
import type { DaemonRole } from "./capabilities";
|
|
18
26
|
import type { HerdrTabHandle } from "./herdr-tab-store";
|
|
19
|
-
import {
|
|
27
|
+
import type { SharedMerge } from "./api";
|
|
28
|
+
|
|
29
|
+
export type { SharedMerge } from "./api";
|
|
30
|
+
import { cueloopHome, ownerTokenPath, socketPath } from "./paths";
|
|
31
|
+
import { Params, SessionRecordSchema } from "./validate";
|
|
20
32
|
|
|
21
33
|
export type { EventFrame } from "./protocol";
|
|
22
34
|
|
|
@@ -26,12 +38,22 @@ export interface ConnectOptions {
|
|
|
26
38
|
autostart?: boolean;
|
|
27
39
|
/** Capability role for this connection; a review-side agent connects capped. Defaults to owner. */
|
|
28
40
|
role?: DaemonRole;
|
|
41
|
+
/** The author a non-owner connection acts as; its comments, removals, and name are bound to it. */
|
|
42
|
+
author?: string;
|
|
29
43
|
}
|
|
30
44
|
|
|
31
|
-
type PendingRequest = {
|
|
45
|
+
type PendingRequest = {
|
|
46
|
+
resolve: (value: Response["result"]) => void;
|
|
47
|
+
reject: (error: Error) => void;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const EmptyResultSchema = v.object({});
|
|
51
|
+
const PingResultSchema = v.object({ pid: v.number() });
|
|
52
|
+
const RefreshDiffResultSchema = v.object({ changed: v.boolean() });
|
|
53
|
+
const HerdrTabResultSchema = v.nullable(v.object({ tabId: v.string(), paneId: v.string() }));
|
|
32
54
|
|
|
33
55
|
/**
|
|
34
|
-
* The session
|
|
56
|
+
* The session primitives the review controller drives. DaemonClient is the local
|
|
35
57
|
* implementation (unix socket); the sharing gateway supplies an in-memory,
|
|
36
58
|
* blob-backed one. Depending on this interface - not DaemonClient - is what
|
|
37
59
|
* lets the same <App> render a local session or a decrypted share unchanged.
|
|
@@ -46,14 +68,32 @@ export interface SessionClient {
|
|
|
46
68
|
annotation: Omit<Annotation, "createdAt">,
|
|
47
69
|
authorName?: string,
|
|
48
70
|
): Promise<ReviewSession>;
|
|
71
|
+
/** Remove a comment; a non-owner connection removes only the comments of the author it is bound to. */
|
|
49
72
|
sessionRemoveAnnotation(id: string, annotationId: string): Promise<ReviewSession>;
|
|
50
73
|
sessionSetWorkingCopy(id: string, workingCopy: string | undefined): Promise<ReviewSession>;
|
|
74
|
+
/** Cut the `blockIndex`-th block of the working copy. */
|
|
75
|
+
sessionCutBlock(id: string, blockIndex: number): Promise<ReviewSession>;
|
|
76
|
+
/** Re-insert the `baseBlockIndex`-th block of the submitted revision before `line` (default: the end). */
|
|
77
|
+
sessionRestoreBlock(id: string, baseBlockIndex: number, line?: number): Promise<ReviewSession>;
|
|
78
|
+
/** Replace a diff review's reject decisions; the working copy follows. */
|
|
79
|
+
sessionCurate(id: string, rejections: HunkRejection[]): Promise<ReviewSession>;
|
|
51
80
|
sessionSetViewed(id: string, viewedPaths: string[]): Promise<ReviewSession>;
|
|
52
|
-
|
|
53
|
-
|
|
81
|
+
/** Move a branch's tip (the current one, or `branch` after switching to it) back to an entry on its path; a summary records the abandoned segment. */
|
|
82
|
+
sessionNavigate(
|
|
54
83
|
id: string,
|
|
55
|
-
|
|
84
|
+
entryId: string,
|
|
85
|
+
summary?: string,
|
|
86
|
+
branch?: string,
|
|
56
87
|
): Promise<ReviewSession>;
|
|
88
|
+
/** Start a branch at the current tip and switch to it. */
|
|
89
|
+
sessionBranch(id: string, name: string): Promise<ReviewSession>;
|
|
90
|
+
sessionSwitch(id: string, branch: string): Promise<ReviewSession>;
|
|
91
|
+
/** Name the current tip as a checkpoint. */
|
|
92
|
+
sessionLabel(id: string, label: string): Promise<ReviewSession>;
|
|
93
|
+
/** Copy the current path into a new session; returns the fork. */
|
|
94
|
+
sessionFork(id: string): Promise<ReviewSession>;
|
|
95
|
+
sessionSetShareId(id: string, shareId: string): Promise<ReviewSession>;
|
|
96
|
+
sessionMergeShared(id: string, incoming: SharedMerge): Promise<ReviewSession>;
|
|
57
97
|
sessionDelete(id: string): Promise<void>;
|
|
58
98
|
/** Record the caller's own identity name (collaborator self-naming on a share). */
|
|
59
99
|
sessionSetSelfName(id: string, name: string): Promise<ReviewSession>;
|
|
@@ -61,6 +101,33 @@ export interface SessionClient {
|
|
|
61
101
|
close(): void;
|
|
62
102
|
}
|
|
63
103
|
|
|
104
|
+
/** What a connection says about itself: its role, the owner token when it claims ownership, the author it acts as otherwise. */
|
|
105
|
+
interface HelloParams {
|
|
106
|
+
role: DaemonRole;
|
|
107
|
+
token?: string;
|
|
108
|
+
author?: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The token as the daemon writes it: 32 random bytes in hex. */
|
|
112
|
+
const OwnerTokenSchema = v.pipe(v.string(), v.trim(), v.regex(/^[0-9a-f]{64}$/));
|
|
113
|
+
|
|
114
|
+
/** The owner token in `home`, or undefined when the daemon there never wrote one. */
|
|
115
|
+
function readOwnerToken(home: string): string | undefined {
|
|
116
|
+
const path = ownerTokenPath(home);
|
|
117
|
+
|
|
118
|
+
if (!existsSync(path)) return undefined;
|
|
119
|
+
const parsed = v.safeParse(OwnerTokenSchema, readFileSync(path, "utf8"));
|
|
120
|
+
|
|
121
|
+
if (!parsed.success) {
|
|
122
|
+
throw new DaemonClientError(
|
|
123
|
+
"invalid_owner_token",
|
|
124
|
+
`${path} is not an owner token; restart the daemon to mint a fresh one`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return parsed.output;
|
|
129
|
+
}
|
|
130
|
+
|
|
64
131
|
export class DaemonClient implements SessionClient {
|
|
65
132
|
private socket: Awaited<ReturnType<typeof Bun.connect>> | null = null;
|
|
66
133
|
private writer: BackpressureWriter | null = null;
|
|
@@ -69,6 +136,8 @@ export class DaemonClient implements SessionClient {
|
|
|
69
136
|
private eventListeners = new Set<(event: EventFrame) => void>();
|
|
70
137
|
private closed = false;
|
|
71
138
|
private role: DaemonRole = "owner";
|
|
139
|
+
private author: string | undefined;
|
|
140
|
+
private home = cueloopHome();
|
|
72
141
|
|
|
73
142
|
static async connect(options: ConnectOptions = {}): Promise<DaemonClient> {
|
|
74
143
|
const home = options.home ?? cueloopHome();
|
|
@@ -76,12 +145,16 @@ export class DaemonClient implements SessionClient {
|
|
|
76
145
|
const client = new DaemonClient();
|
|
77
146
|
|
|
78
147
|
client.role = options.role ?? "owner";
|
|
148
|
+
client.author = options.author;
|
|
149
|
+
client.home = home;
|
|
79
150
|
try {
|
|
80
151
|
await client.dial(path);
|
|
81
152
|
|
|
82
153
|
return client;
|
|
83
154
|
} catch (err) {
|
|
84
|
-
|
|
155
|
+
// a live daemon that refused the handshake is not a dead socket: the
|
|
156
|
+
// caller hears why instead of the client replacing a running daemon
|
|
157
|
+
if (!options.autostart || err instanceof DaemonClientError) throw err;
|
|
85
158
|
}
|
|
86
159
|
// Socket dead or absent: clean a stale file and spawn the daemon detached.
|
|
87
160
|
if (existsSync(path)) rmSync(path, { force: true });
|
|
@@ -128,9 +201,23 @@ export class DaemonClient implements SessionClient {
|
|
|
128
201
|
this.writer = new BackpressureWriter(this.socket);
|
|
129
202
|
// Verify liveness: a dead socket file accepts connects on some platforms
|
|
130
203
|
// only to fail later, so a ping is the actual handshake.
|
|
131
|
-
await this.request("daemon.ping", {}, 2_000);
|
|
132
|
-
//
|
|
133
|
-
|
|
204
|
+
await this.request("daemon.ping", {}, PingResultSchema, 2_000);
|
|
205
|
+
// Every connection starts as a collaborator; the owner proves itself with
|
|
206
|
+
// the token the daemon wrote into the home it serves, which only the home's
|
|
207
|
+
// user can read. A capped role just names itself.
|
|
208
|
+
await this.request("daemon.hello", this.helloParams(), EmptyResultSchema, 2_000);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private helloParams(): HelloParams {
|
|
212
|
+
if (this.role !== "owner") {
|
|
213
|
+
return this.author === undefined
|
|
214
|
+
? { role: this.role }
|
|
215
|
+
: { role: this.role, author: this.author };
|
|
216
|
+
}
|
|
217
|
+
const token = readOwnerToken(this.home);
|
|
218
|
+
|
|
219
|
+
// a daemon from before owner tokens has no file; it still knows the bare hello
|
|
220
|
+
return token === undefined ? { role: "owner" } : { role: "owner", token };
|
|
134
221
|
}
|
|
135
222
|
|
|
136
223
|
onEvent(listener: (event: EventFrame) => void): () => void {
|
|
@@ -140,10 +227,10 @@ export class DaemonClient implements SessionClient {
|
|
|
140
227
|
}
|
|
141
228
|
|
|
142
229
|
private routeInboundFrame(line: string): void {
|
|
143
|
-
let frame
|
|
230
|
+
let frame;
|
|
144
231
|
|
|
145
232
|
try {
|
|
146
|
-
frame =
|
|
233
|
+
frame = parseInboundFrame(line);
|
|
147
234
|
} catch {
|
|
148
235
|
return;
|
|
149
236
|
}
|
|
@@ -161,11 +248,16 @@ export class DaemonClient implements SessionClient {
|
|
|
161
248
|
else pendingRequest.resolve(frame.result);
|
|
162
249
|
}
|
|
163
250
|
|
|
164
|
-
request<
|
|
251
|
+
request<TOutput>(
|
|
252
|
+
method: string,
|
|
253
|
+
params: Request["params"],
|
|
254
|
+
resultSchema: v.GenericSchema<unknown, TOutput>,
|
|
255
|
+
timeoutMs = 30_000,
|
|
256
|
+
): Promise<TOutput> {
|
|
165
257
|
if (this.closed || !this.socket) return Promise.reject(new Error("not connected"));
|
|
166
258
|
const id = this.nextId++;
|
|
167
259
|
|
|
168
|
-
return new Promise<
|
|
260
|
+
return new Promise<TOutput>((resolve, reject) => {
|
|
169
261
|
const timer = setTimeout(() => {
|
|
170
262
|
this.pending.delete(id);
|
|
171
263
|
reject(new Error(`request ${method} timed out`));
|
|
@@ -174,7 +266,7 @@ export class DaemonClient implements SessionClient {
|
|
|
174
266
|
this.pending.set(id, {
|
|
175
267
|
resolve: (value) => {
|
|
176
268
|
clearTimeout(timer);
|
|
177
|
-
resolve(value
|
|
269
|
+
resolve(v.parse(resultSchema, value));
|
|
178
270
|
},
|
|
179
271
|
reject: (error) => {
|
|
180
272
|
clearTimeout(timer);
|
|
@@ -190,81 +282,129 @@ export class DaemonClient implements SessionClient {
|
|
|
190
282
|
this.socket?.end();
|
|
191
283
|
}
|
|
192
284
|
|
|
193
|
-
// ── typed
|
|
285
|
+
// ── typed primitives ─────────────────────────────
|
|
194
286
|
ping(): Promise<{ pid: number }> {
|
|
195
|
-
return this.request("daemon.ping", {});
|
|
287
|
+
return this.request("daemon.ping", {}, PingResultSchema);
|
|
196
288
|
}
|
|
197
289
|
subscribe(): Promise<void> {
|
|
198
|
-
return this.request("events.subscribe", {});
|
|
290
|
+
return this.request("events.subscribe", {}, EmptyResultSchema).then(() => undefined);
|
|
199
291
|
}
|
|
200
292
|
sessionCreate(workspace: WorkspaceKey, artifact: Artifact): Promise<ReviewSession> {
|
|
201
|
-
return this.request("session.create", { workspace, artifact });
|
|
293
|
+
return this.request("session.create", { workspace, artifact }, SessionRecordSchema);
|
|
202
294
|
}
|
|
203
295
|
sessionGet(id: string): Promise<ReviewSession> {
|
|
204
|
-
return this.request("session.get", { id });
|
|
296
|
+
return this.request("session.get", { id }, SessionRecordSchema);
|
|
205
297
|
}
|
|
206
298
|
sessionList(filter?: { status?: "pending" | "resolved" }): Promise<ReviewSession[]> {
|
|
207
|
-
return this.request("session.list", { filter });
|
|
299
|
+
return this.request("session.list", { filter }, v.array(SessionRecordSchema));
|
|
208
300
|
}
|
|
209
301
|
/** Long-poll; null = still pending after timeoutMs (re-poll to collect). */
|
|
210
302
|
sessionWait(id: string, timeoutMs: number): Promise<ReviewSession | null> {
|
|
211
|
-
return this.request(
|
|
303
|
+
return this.request(
|
|
304
|
+
"session.wait",
|
|
305
|
+
{ id, timeoutMs },
|
|
306
|
+
v.nullable(SessionRecordSchema),
|
|
307
|
+
timeoutMs + 10_000,
|
|
308
|
+
);
|
|
212
309
|
}
|
|
213
310
|
sessionAnnotate(
|
|
214
311
|
id: string,
|
|
215
312
|
annotation: Omit<Annotation, "createdAt">,
|
|
216
313
|
authorName?: string,
|
|
217
314
|
): Promise<ReviewSession> {
|
|
218
|
-
return this.request("session.annotate", { id, annotation, authorName });
|
|
315
|
+
return this.request("session.annotate", { id, annotation, authorName }, SessionRecordSchema);
|
|
219
316
|
}
|
|
220
317
|
sessionRemoveAnnotation(id: string, annotationId: string): Promise<ReviewSession> {
|
|
221
|
-
return this.request("session.removeAnnotation", { id, annotationId });
|
|
318
|
+
return this.request("session.removeAnnotation", { id, annotationId }, SessionRecordSchema);
|
|
319
|
+
}
|
|
320
|
+
/** Register a display name for a participant - a collaborator's or an agent's own, on a share or locally. */
|
|
321
|
+
sessionSetParticipantName(id: string, author: string, name: string): Promise<ReviewSession> {
|
|
322
|
+
return this.request("session.setParticipantName", { id, author, name }, SessionRecordSchema);
|
|
222
323
|
}
|
|
223
324
|
sessionSetWorkingCopy(id: string, workingCopy: string | undefined): Promise<ReviewSession> {
|
|
224
|
-
return this.request("session.setWorkingCopy", { id, workingCopy });
|
|
325
|
+
return this.request("session.setWorkingCopy", { id, workingCopy }, SessionRecordSchema);
|
|
326
|
+
}
|
|
327
|
+
sessionCutBlock(id: string, blockIndex: number): Promise<ReviewSession> {
|
|
328
|
+
return this.request("session.cutBlock", { id, blockIndex }, SessionRecordSchema);
|
|
329
|
+
}
|
|
330
|
+
sessionNavigate(
|
|
331
|
+
id: string,
|
|
332
|
+
entryId: string,
|
|
333
|
+
summary?: string,
|
|
334
|
+
branch?: string,
|
|
335
|
+
): Promise<ReviewSession> {
|
|
336
|
+
const params: v.InferInput<(typeof Params)["session.navigate"]> = { id, entryId };
|
|
337
|
+
|
|
338
|
+
if (summary !== undefined) params.summary = summary;
|
|
339
|
+
if (branch !== undefined) params.branch = branch;
|
|
340
|
+
|
|
341
|
+
return this.request("session.navigate", params, SessionRecordSchema);
|
|
342
|
+
}
|
|
343
|
+
sessionBranch(id: string, name: string): Promise<ReviewSession> {
|
|
344
|
+
return this.request("session.branch", { id, name }, SessionRecordSchema);
|
|
345
|
+
}
|
|
346
|
+
sessionSwitch(id: string, branch: string): Promise<ReviewSession> {
|
|
347
|
+
return this.request("session.switch", { id, branch }, SessionRecordSchema);
|
|
348
|
+
}
|
|
349
|
+
sessionLabel(id: string, label: string): Promise<ReviewSession> {
|
|
350
|
+
return this.request("session.label", { id, label }, SessionRecordSchema);
|
|
351
|
+
}
|
|
352
|
+
sessionFork(id: string): Promise<ReviewSession> {
|
|
353
|
+
return this.request("session.fork", { id }, SessionRecordSchema);
|
|
354
|
+
}
|
|
355
|
+
sessionRestoreBlock(id: string, baseBlockIndex: number, line?: number): Promise<ReviewSession> {
|
|
356
|
+
return this.request(
|
|
357
|
+
"session.restoreBlock",
|
|
358
|
+
line === undefined ? { id, baseBlockIndex } : { id, baseBlockIndex, line },
|
|
359
|
+
SessionRecordSchema,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
sessionCurate(id: string, rejections: HunkRejection[]): Promise<ReviewSession> {
|
|
363
|
+
return this.request("session.curate", { id, rejections }, SessionRecordSchema);
|
|
225
364
|
}
|
|
226
365
|
sessionSetViewed(id: string, viewedPaths: string[]): Promise<ReviewSession> {
|
|
227
|
-
return this.request("session.setViewed", { id, viewedPaths });
|
|
366
|
+
return this.request("session.setViewed", { id, viewedPaths }, SessionRecordSchema);
|
|
228
367
|
}
|
|
229
368
|
/** Re-capture a diff session's working tree; changed=true when the patch moved and an event fired. */
|
|
230
369
|
sessionRefreshDiff(id: string): Promise<{ changed: boolean }> {
|
|
231
|
-
return this.request("session.refreshDiff", { id });
|
|
370
|
+
return this.request("session.refreshDiff", { id }, RefreshDiffResultSchema);
|
|
232
371
|
}
|
|
233
372
|
sessionSetShareId(id: string, shareId: string): Promise<ReviewSession> {
|
|
234
|
-
return this.request("session.setShareId", { id, shareId });
|
|
373
|
+
return this.request("session.setShareId", { id, shareId }, SessionRecordSchema);
|
|
235
374
|
}
|
|
236
|
-
sessionMergeShared(
|
|
237
|
-
id
|
|
238
|
-
incoming: { annotations: Annotation[]; participants?: Identity[] },
|
|
239
|
-
): Promise<ReviewSession> {
|
|
240
|
-
return this.request("session.mergeShared", { id, ...incoming });
|
|
375
|
+
sessionMergeShared(id: string, incoming: SharedMerge): Promise<ReviewSession> {
|
|
376
|
+
return this.request("session.mergeShared", { id, ...incoming }, SessionRecordSchema);
|
|
241
377
|
}
|
|
242
378
|
sessionDelete(id: string): Promise<void> {
|
|
243
|
-
return this.request("session.delete", { id });
|
|
379
|
+
return this.request("session.delete", { id }, EmptyResultSchema).then(() => undefined);
|
|
244
380
|
}
|
|
245
381
|
/** Local sessions have no collaborator self-name; the share client owns this. */
|
|
246
382
|
sessionSetSelfName(id: string, _name: string): Promise<ReviewSession> {
|
|
247
383
|
return this.sessionGet(id);
|
|
248
384
|
}
|
|
249
385
|
sessionResolve(id: string, verdictKind: VerdictKind, summary: string): Promise<ReviewSession> {
|
|
250
|
-
return this.request("session.resolve", { id, verdictKind, summary });
|
|
386
|
+
return this.request("session.resolve", { id, verdictKind, summary }, SessionRecordSchema);
|
|
251
387
|
}
|
|
252
388
|
sessionSubmitRevision(
|
|
253
389
|
id: string,
|
|
254
390
|
content: string,
|
|
255
391
|
addressedAnnotationIds: string[] = [],
|
|
256
392
|
): Promise<ReviewSession> {
|
|
257
|
-
return this.request(
|
|
393
|
+
return this.request(
|
|
394
|
+
"session.submitRevision",
|
|
395
|
+
{ id, content, addressedAnnotationIds },
|
|
396
|
+
SessionRecordSchema,
|
|
397
|
+
);
|
|
258
398
|
}
|
|
259
399
|
/** herdr adapter scratch: the tab opened for a review; local-only, off the SessionClient contract. */
|
|
260
400
|
herdrGetTab(id: string): Promise<HerdrTabHandle | null> {
|
|
261
|
-
return this.request("herdr.getTab", { id });
|
|
401
|
+
return this.request("herdr.getTab", { id }, HerdrTabResultSchema);
|
|
262
402
|
}
|
|
263
403
|
async herdrSetTab(id: string, handle: HerdrTabHandle): Promise<void> {
|
|
264
|
-
await this.request("herdr.setTab", { id, ...handle });
|
|
404
|
+
await this.request("herdr.setTab", { id, ...handle }, EmptyResultSchema);
|
|
265
405
|
}
|
|
266
406
|
shutdown(): Promise<void> {
|
|
267
|
-
return this.request("daemon.shutdown", {});
|
|
407
|
+
return this.request("daemon.shutdown", {}, EmptyResultSchema).then(() => undefined);
|
|
268
408
|
}
|
|
269
409
|
}
|
|
270
410
|
|
package/src/curate.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The curated patch of a diff review: the submitted changes minus the hunks and
|
|
3
|
+
* change blocks the reviewer rejected. The daemon computes it from the
|
|
4
|
+
* session's reject decisions so the working copy every client sees is one and
|
|
5
|
+
* the same; the client only maps rows to decisions.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { diffAcceptRejectHunk, parseDiffFromFile, type FileDiffMetadata } from "@pierre/diffs";
|
|
9
|
+
import {
|
|
10
|
+
unifiedDiffText,
|
|
11
|
+
type DiffFileContents,
|
|
12
|
+
type DiffFileStatus,
|
|
13
|
+
type HunkRejection,
|
|
14
|
+
} from "@cueloop/schema";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Context lines around each change. Matching git's default (3) makes the model
|
|
18
|
+
* split hunks like the rendered working-tree patch does; a row is mapped by
|
|
19
|
+
* absolute line number, never by counting hunks, so a whole-hunk reject can
|
|
20
|
+
* only ever span the model hunk it lands in.
|
|
21
|
+
*/
|
|
22
|
+
export const DIFF_CONTEXT = 3;
|
|
23
|
+
|
|
24
|
+
/** Parse one file's full contents into a non-partial diff model. */
|
|
25
|
+
export function parseFileDiff(file: DiffFileContents): FileDiffMetadata {
|
|
26
|
+
return parseDiffFromFile(
|
|
27
|
+
{ name: file.path, contents: file.oldContents },
|
|
28
|
+
{ name: file.path, contents: file.newContents },
|
|
29
|
+
{ context: DIFF_CONTEXT },
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Apply one file's rejections and return its curated patch, or null when the
|
|
35
|
+
* curated content equals the old content (every change rejected - no patch).
|
|
36
|
+
* Whole-hunk rejections supersede change-level ones for the same hunk. Because
|
|
37
|
+
* `diffAcceptRejectHunk` returns a model with the same hunk/content shape, the
|
|
38
|
+
* indices stay valid across calls; they are applied high-to-low regardless, so
|
|
39
|
+
* the order is deterministic.
|
|
40
|
+
*/
|
|
41
|
+
function curateFilePatch(file: DiffFileContents, rejections: HunkRejection[]): string | null {
|
|
42
|
+
let model = parseFileDiff(file);
|
|
43
|
+
const wholeHunks = new Set(
|
|
44
|
+
rejections
|
|
45
|
+
.filter((rejection) => rejection.changeIndex === undefined)
|
|
46
|
+
.map((rejection) => rejection.hunkIndex),
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
for (const hunkIndex of [...wholeHunks].sort((a, b) => b - a)) {
|
|
50
|
+
model = diffAcceptRejectHunk(model, hunkIndex, "reject");
|
|
51
|
+
}
|
|
52
|
+
const changeRejections = rejections
|
|
53
|
+
.filter(
|
|
54
|
+
(rejection) => rejection.changeIndex !== undefined && !wholeHunks.has(rejection.hunkIndex),
|
|
55
|
+
)
|
|
56
|
+
.sort((a, b) => b.hunkIndex - a.hunkIndex || b.changeIndex! - a.changeIndex!);
|
|
57
|
+
|
|
58
|
+
for (const rejection of changeRejections) {
|
|
59
|
+
model = diffAcceptRejectHunk(model, rejection.hunkIndex, {
|
|
60
|
+
type: "reject",
|
|
61
|
+
changeIndex: rejection.changeIndex!,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
const curatedNew = model.additionLines.join("");
|
|
65
|
+
|
|
66
|
+
if (curatedNew === file.oldContents) return null;
|
|
67
|
+
const patch = unifiedDiffText(file.oldContents, curatedNew, file.path);
|
|
68
|
+
|
|
69
|
+
if (patch === null) return null;
|
|
70
|
+
|
|
71
|
+
return withFileStateHeaders(patch, file.path, file.status, curatedNew);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Point the diff headers at /dev/null for a created or fully-deleted file so the
|
|
76
|
+
* curated patch applies. git's status - not empty contents - decides existence,
|
|
77
|
+
* so editing an existing empty file stays a modify; a deletion whose removals
|
|
78
|
+
* were partly restored also falls back to a modify.
|
|
79
|
+
*/
|
|
80
|
+
function withFileStateHeaders(
|
|
81
|
+
patch: string,
|
|
82
|
+
path: string,
|
|
83
|
+
status: DiffFileStatus,
|
|
84
|
+
curatedNew: string,
|
|
85
|
+
): string {
|
|
86
|
+
if (status === "added") return patch.replace(`--- a/${path}`, "--- /dev/null");
|
|
87
|
+
if (status === "deleted" && curatedNew === "") {
|
|
88
|
+
return patch.replace(`+++ b/${path}`, "+++ /dev/null");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return patch;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The curated unified-diff patch of the accepted changes across every file.
|
|
96
|
+
* Files with no net change (unchanged, or with all changes rejected) drop out.
|
|
97
|
+
*/
|
|
98
|
+
export function curateDiff(files: DiffFileContents[], rejections: HunkRejection[]): string {
|
|
99
|
+
const parts: string[] = [];
|
|
100
|
+
|
|
101
|
+
for (const file of files) {
|
|
102
|
+
const fileRejections = rejections.filter((rejection) => rejection.path === file.path);
|
|
103
|
+
const patch = curateFilePatch(file, fileRejections);
|
|
104
|
+
|
|
105
|
+
if (patch) parts.push(patch);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return parts.join("\n");
|
|
109
|
+
}
|
|
@@ -100,6 +100,22 @@ describe("session.refreshDiff", () => {
|
|
|
100
100
|
expect(events).toContain("session.updated");
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
+
test("a tree move after a refresh keeps the fresh patch", async () => {
|
|
104
|
+
// Given a refreshed diff session
|
|
105
|
+
const session = await openDiffSession();
|
|
106
|
+
|
|
107
|
+
writeFileSync(join(repo, "a.ts"), "export const a = 4;\n");
|
|
108
|
+
await core.sessionRefreshDiff(session.id);
|
|
109
|
+
|
|
110
|
+
// When the owner branches off and switches back
|
|
111
|
+
core.sessionBranch(session.id, "alt");
|
|
112
|
+
const shown = core.sessionSwitch(session.id, "main");
|
|
113
|
+
|
|
114
|
+
// Then the artifact still shows the re-captured working tree, and the history has one revision
|
|
115
|
+
expect(shown.artifact.content).toContain("+export const a = 4;");
|
|
116
|
+
expect(shown.history!.entries.filter((entry) => entry.type === "revision")).toHaveLength(1);
|
|
117
|
+
});
|
|
118
|
+
|
|
103
119
|
test("is a no-op for a non-diff (plan) session", async () => {
|
|
104
120
|
// Given a plan session in the same workspace
|
|
105
121
|
const workspace = await resolveWorkspace(repo);
|