@cueloop/daemon 0.1.0-alpha.64 → 0.1.0-alpha.66
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 +5 -3
- package/src/api.test.ts +479 -1
- package/src/api.ts +452 -27
- package/src/capabilities.test.ts +28 -9
- package/src/capabilities.ts +52 -19
- package/src/client.ts +126 -18
- package/src/curate.ts +109 -0
- package/src/diff-hot-reload.test.ts +16 -0
- package/src/herdr-pane.test.ts +1 -1
- package/src/paths.ts +5 -0
- package/src/protocol.ts +3 -0
- package/src/review.ts +25 -4
- package/src/server.test.ts +229 -2
- package/src/server.ts +90 -7
- package/src/share-blob.test.ts +51 -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 +61 -1
- package/src/validate.ts +103 -1
package/src/capabilities.ts
CHANGED
|
@@ -1,31 +1,64 @@
|
|
|
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
|
|
|
9
12
|
import * as v from "valibot";
|
|
13
|
+
import type { MethodName } from "./validate";
|
|
10
14
|
|
|
11
15
|
export type DaemonRole = "owner" | "collaborator" | "agent";
|
|
12
16
|
|
|
13
|
-
/**
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"session.
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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";
|
|
27
57
|
}
|
|
28
58
|
|
|
59
|
+
/** The role a connection starts with, before any proof. */
|
|
60
|
+
export const DEFAULT_ROLE: DaemonRole = "collaborator";
|
|
61
|
+
|
|
29
62
|
export function asDaemonRole(value: Parameters<typeof v.safeParse>[1]): DaemonRole {
|
|
30
63
|
const result = v.safeParse(v.picklist(["owner", "collaborator"]), value);
|
|
31
64
|
|
package/src/client.ts
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
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
8
|
import * as v from "valibot";
|
|
9
9
|
import type {
|
|
10
10
|
Annotation,
|
|
11
11
|
Artifact,
|
|
12
|
-
|
|
12
|
+
HunkRejection,
|
|
13
13
|
ReviewSession,
|
|
14
14
|
VerdictKind,
|
|
15
15
|
WorkspaceKey,
|
|
@@ -24,8 +24,11 @@ import {
|
|
|
24
24
|
} from "./protocol";
|
|
25
25
|
import type { DaemonRole } from "./capabilities";
|
|
26
26
|
import type { HerdrTabHandle } from "./herdr-tab-store";
|
|
27
|
-
import {
|
|
28
|
-
|
|
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";
|
|
29
32
|
|
|
30
33
|
export type { EventFrame } from "./protocol";
|
|
31
34
|
|
|
@@ -35,6 +38,8 @@ export interface ConnectOptions {
|
|
|
35
38
|
autostart?: boolean;
|
|
36
39
|
/** Capability role for this connection; a review-side agent connects capped. Defaults to owner. */
|
|
37
40
|
role?: DaemonRole;
|
|
41
|
+
/** The author a non-owner connection acts as; its comments, removals, and name are bound to it. */
|
|
42
|
+
author?: string;
|
|
38
43
|
}
|
|
39
44
|
|
|
40
45
|
type PendingRequest = {
|
|
@@ -48,7 +53,7 @@ const RefreshDiffResultSchema = v.object({ changed: v.boolean() });
|
|
|
48
53
|
const HerdrTabResultSchema = v.nullable(v.object({ tabId: v.string(), paneId: v.string() }));
|
|
49
54
|
|
|
50
55
|
/**
|
|
51
|
-
* The session
|
|
56
|
+
* The session primitives the review controller drives. DaemonClient is the local
|
|
52
57
|
* implementation (unix socket); the sharing gateway supplies an in-memory,
|
|
53
58
|
* blob-backed one. Depending on this interface - not DaemonClient - is what
|
|
54
59
|
* lets the same <App> render a local session or a decrypted share unchanged.
|
|
@@ -63,14 +68,32 @@ export interface SessionClient {
|
|
|
63
68
|
annotation: Omit<Annotation, "createdAt">,
|
|
64
69
|
authorName?: string,
|
|
65
70
|
): Promise<ReviewSession>;
|
|
71
|
+
/** Remove a comment; a non-owner connection removes only the comments of the author it is bound to. */
|
|
66
72
|
sessionRemoveAnnotation(id: string, annotationId: string): Promise<ReviewSession>;
|
|
67
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>;
|
|
68
80
|
sessionSetViewed(id: string, viewedPaths: string[]): Promise<ReviewSession>;
|
|
69
|
-
|
|
70
|
-
|
|
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(
|
|
71
83
|
id: string,
|
|
72
|
-
|
|
84
|
+
entryId: string,
|
|
85
|
+
summary?: string,
|
|
86
|
+
branch?: string,
|
|
73
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>;
|
|
74
97
|
sessionDelete(id: string): Promise<void>;
|
|
75
98
|
/** Record the caller's own identity name (collaborator self-naming on a share). */
|
|
76
99
|
sessionSetSelfName(id: string, name: string): Promise<ReviewSession>;
|
|
@@ -78,6 +101,33 @@ export interface SessionClient {
|
|
|
78
101
|
close(): void;
|
|
79
102
|
}
|
|
80
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
|
+
|
|
81
131
|
export class DaemonClient implements SessionClient {
|
|
82
132
|
private socket: Awaited<ReturnType<typeof Bun.connect>> | null = null;
|
|
83
133
|
private writer: BackpressureWriter | null = null;
|
|
@@ -86,6 +136,8 @@ export class DaemonClient implements SessionClient {
|
|
|
86
136
|
private eventListeners = new Set<(event: EventFrame) => void>();
|
|
87
137
|
private closed = false;
|
|
88
138
|
private role: DaemonRole = "owner";
|
|
139
|
+
private author: string | undefined;
|
|
140
|
+
private home = cueloopHome();
|
|
89
141
|
|
|
90
142
|
static async connect(options: ConnectOptions = {}): Promise<DaemonClient> {
|
|
91
143
|
const home = options.home ?? cueloopHome();
|
|
@@ -93,12 +145,16 @@ export class DaemonClient implements SessionClient {
|
|
|
93
145
|
const client = new DaemonClient();
|
|
94
146
|
|
|
95
147
|
client.role = options.role ?? "owner";
|
|
148
|
+
client.author = options.author;
|
|
149
|
+
client.home = home;
|
|
96
150
|
try {
|
|
97
151
|
await client.dial(path);
|
|
98
152
|
|
|
99
153
|
return client;
|
|
100
154
|
} catch (err) {
|
|
101
|
-
|
|
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;
|
|
102
158
|
}
|
|
103
159
|
// Socket dead or absent: clean a stale file and spawn the daemon detached.
|
|
104
160
|
if (existsSync(path)) rmSync(path, { force: true });
|
|
@@ -146,9 +202,22 @@ export class DaemonClient implements SessionClient {
|
|
|
146
202
|
// Verify liveness: a dead socket file accepts connects on some platforms
|
|
147
203
|
// only to fail later, so a ping is the actual handshake.
|
|
148
204
|
await this.request("daemon.ping", {}, PingResultSchema, 2_000);
|
|
149
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
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 };
|
|
152
221
|
}
|
|
153
222
|
|
|
154
223
|
onEvent(listener: (event: EventFrame) => void): () => void {
|
|
@@ -213,7 +282,7 @@ export class DaemonClient implements SessionClient {
|
|
|
213
282
|
this.socket?.end();
|
|
214
283
|
}
|
|
215
284
|
|
|
216
|
-
// ── typed
|
|
285
|
+
// ── typed primitives ─────────────────────────────
|
|
217
286
|
ping(): Promise<{ pid: number }> {
|
|
218
287
|
return this.request("daemon.ping", {}, PingResultSchema);
|
|
219
288
|
}
|
|
@@ -248,9 +317,51 @@ export class DaemonClient implements SessionClient {
|
|
|
248
317
|
sessionRemoveAnnotation(id: string, annotationId: string): Promise<ReviewSession> {
|
|
249
318
|
return this.request("session.removeAnnotation", { id, annotationId }, SessionRecordSchema);
|
|
250
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);
|
|
323
|
+
}
|
|
251
324
|
sessionSetWorkingCopy(id: string, workingCopy: string | undefined): Promise<ReviewSession> {
|
|
252
325
|
return this.request("session.setWorkingCopy", { id, workingCopy }, SessionRecordSchema);
|
|
253
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);
|
|
364
|
+
}
|
|
254
365
|
sessionSetViewed(id: string, viewedPaths: string[]): Promise<ReviewSession> {
|
|
255
366
|
return this.request("session.setViewed", { id, viewedPaths }, SessionRecordSchema);
|
|
256
367
|
}
|
|
@@ -261,10 +372,7 @@ export class DaemonClient implements SessionClient {
|
|
|
261
372
|
sessionSetShareId(id: string, shareId: string): Promise<ReviewSession> {
|
|
262
373
|
return this.request("session.setShareId", { id, shareId }, SessionRecordSchema);
|
|
263
374
|
}
|
|
264
|
-
sessionMergeShared(
|
|
265
|
-
id: string,
|
|
266
|
-
incoming: { annotations: Annotation[]; participants?: Identity[] },
|
|
267
|
-
): Promise<ReviewSession> {
|
|
375
|
+
sessionMergeShared(id: string, incoming: SharedMerge): Promise<ReviewSession> {
|
|
268
376
|
return this.request("session.mergeShared", { id, ...incoming }, SessionRecordSchema);
|
|
269
377
|
}
|
|
270
378
|
sessionDelete(id: string): Promise<void> {
|
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);
|
package/src/herdr-pane.test.ts
CHANGED
|
@@ -196,7 +196,7 @@ describe("openHerdrPaneForReview", () => {
|
|
|
196
196
|
});
|
|
197
197
|
|
|
198
198
|
test("a recall failure (stale daemon) still opens a fresh tab", async () => {
|
|
199
|
-
// Arrange - a daemon predating the herdr-tab
|
|
199
|
+
// Arrange - a daemon predating the herdr-tab primitives rejects the recall
|
|
200
200
|
const stub = makeStub("gated-recall-fail");
|
|
201
201
|
const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
|
|
202
202
|
const persistence: HerdrTabPersistence = {
|
package/src/paths.ts
CHANGED
|
@@ -30,6 +30,11 @@ export function lockPath(home = cueloopHome()): string {
|
|
|
30
30
|
return join(home, "cueloop.lock");
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/** The secret a connection presents to be the owner; written by the daemon, mode 0600. */
|
|
34
|
+
export function ownerTokenPath(home = cueloopHome()): string {
|
|
35
|
+
return join(home, "owner.token");
|
|
36
|
+
}
|
|
37
|
+
|
|
33
38
|
/** Adapter scratch: herdr tab handles keyed by session id, kept out of the core session record. */
|
|
34
39
|
export function herdrTabsPath(home = cueloopHome()): string {
|
|
35
40
|
return join(home, "herdr-tabs.json");
|
package/src/protocol.ts
CHANGED
|
@@ -21,6 +21,8 @@ export interface Response {
|
|
|
21
21
|
export interface EventFrame {
|
|
22
22
|
event: string;
|
|
23
23
|
sessionId: string;
|
|
24
|
+
/** The history entry the change appended, when it appended one. */
|
|
25
|
+
entryId?: string;
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
export type Frame = Response | EventFrame;
|
|
@@ -38,6 +40,7 @@ const ResponseSchema = v.object({
|
|
|
38
40
|
const EventFrameSchema = v.object({
|
|
39
41
|
event: v.string(),
|
|
40
42
|
sessionId: v.string(),
|
|
43
|
+
entryId: v.optional(v.string()),
|
|
41
44
|
});
|
|
42
45
|
|
|
43
46
|
export function parseRequestFrame(line: string): Request {
|
package/src/review.ts
CHANGED
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
import { verdictResponse } from "./api";
|
|
19
19
|
import type { DaemonClient } from "./client";
|
|
20
20
|
|
|
21
|
-
// Adapters and CLI
|
|
21
|
+
// Adapters and CLI primitives reach the verdict mapping through this module too,
|
|
22
22
|
// so a session obtained outside a ReviewHandle maps the same way.
|
|
23
23
|
export { verdictResponse };
|
|
24
24
|
|
|
@@ -35,12 +35,33 @@ async function git(args: string[], cwd: string): Promise<string | null> {
|
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
/**
|
|
38
|
+
/** The oldest root commit reachable from HEAD; the project key that survives moving or re-cloning the repo. */
|
|
39
|
+
function earliestRootCommit(revList: string | null): string | undefined {
|
|
40
|
+
if (!revList) return undefined;
|
|
41
|
+
const roots = revList.split("\n").filter((line) => line.length > 0);
|
|
42
|
+
|
|
43
|
+
// --date-order lists newest first, so the last root is the earliest
|
|
44
|
+
return roots.at(-1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Workspace key resolution: repo root, branch, and the project identity (root commit + remote) from the cwd. */
|
|
39
48
|
export async function resolveWorkspace(cwd = process.cwd()): Promise<WorkspaceKey> {
|
|
40
49
|
const repoRoot = (await git(["rev-parse", "--show-toplevel"], cwd)) ?? cwd;
|
|
41
50
|
const branch = (await git(["rev-parse", "--abbrev-ref", "HEAD"], cwd)) ?? "detached";
|
|
42
|
-
|
|
43
|
-
|
|
51
|
+
// a shallow clone's oldest commit is the graft boundary, not the true root, so
|
|
52
|
+
// it would key a different project than a full clone - leave it unset instead
|
|
53
|
+
const shallow = (await git(["rev-parse", "--is-shallow-repository"], cwd)) === "true";
|
|
54
|
+
const rootCommit = shallow
|
|
55
|
+
? undefined
|
|
56
|
+
: earliestRootCommit(await git(["rev-list", "--max-parents=0", "--date-order", "HEAD"], cwd));
|
|
57
|
+
const remote = await git(["remote", "get-url", "origin"], cwd);
|
|
58
|
+
|
|
59
|
+
const workspace: WorkspaceKey = { repoRoot, branch };
|
|
60
|
+
// a repo with no commits (or a shallow clone) has no reliable root, so the thread stays standalone
|
|
61
|
+
if (rootCommit) workspace.rootCommit = rootCommit;
|
|
62
|
+
if (remote) workspace.remote = remote;
|
|
63
|
+
|
|
64
|
+
return workspace;
|
|
44
65
|
}
|
|
45
66
|
|
|
46
67
|
function firstHeading(markdown: string): string | undefined {
|