@cueloop/daemon 0.1.0-alpha.11 → 0.1.0-alpha.110
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/README.md +7 -0
- package/SDK.md +102 -0
- package/package.json +44 -22
- package/src/agent-tools.ts +104 -0
- package/src/agent-turn.test.ts +24 -0
- package/src/agent-turn.ts +34 -0
- package/src/api.test.ts +1081 -65
- package/src/api.ts +1483 -90
- package/src/capabilities.test.ts +57 -0
- package/src/capabilities.ts +95 -0
- package/src/client-errors.ts +24 -0
- package/src/client-reliability.test.ts +191 -0
- package/src/client.test.ts +37 -0
- package/src/client.ts +844 -95
- package/src/curate.ts +111 -0
- package/src/daemon-sdk-effect.ts +113 -0
- package/src/daemon-sdk.test.ts +306 -0
- package/src/daemon-sdk.ts +405 -0
- package/src/daemon-version.test.ts +142 -0
- package/src/diff-hot-reload.test.ts +423 -0
- package/src/diff-watcher.test.ts +165 -0
- package/src/diff-watcher.ts +363 -0
- package/src/examples/daemon-sdk.ts +57 -0
- package/src/gh.test.ts +59 -0
- package/src/gh.ts +78 -0
- package/src/ghostty-thread-surface-store.test.ts +49 -0
- package/src/ghostty-thread-surface-store.ts +104 -0
- package/src/ghostty-thread-surface.test.ts +400 -0
- package/src/ghostty-thread-surface.ts +273 -0
- package/src/harness-delivery.test.ts +269 -0
- package/src/harness-diagnostics.test.ts +26 -0
- package/src/harness-diagnostics.ts +31 -0
- package/src/harness-handoff.test.ts +725 -0
- package/src/harness-handoff.ts +27 -0
- package/src/harness-state-store.test.ts +93 -0
- package/src/harness-state-store.ts +218 -0
- package/src/herdr-split.test.ts +106 -0
- package/src/herdr-split.ts +90 -0
- package/src/herdr-thread-surface-store.test.ts +81 -0
- package/src/herdr-thread-surface-store.ts +66 -0
- package/src/herdr-thread-surface.test.ts +465 -0
- package/src/herdr-thread-surface.ts +392 -0
- package/src/index.ts +8 -9
- package/src/interruptible-wait.test.ts +127 -0
- package/src/interruptible-wait.ts +69 -0
- package/src/jj-working-tree.ts +140 -0
- package/src/main.ts +5 -1
- package/src/operation-receipts.test.ts +111 -0
- package/src/operation-receipts.ts +104 -0
- package/src/paths.ts +49 -0
- package/src/pr-poller.test.ts +102 -0
- package/src/pr-poller.ts +80 -0
- package/src/project-files.test.ts +134 -0
- package/src/project-files.ts +72 -0
- package/src/protocol.ts +82 -7
- package/src/retention.test.ts +191 -0
- package/src/retention.ts +131 -0
- package/src/sdk-identities.ts +35 -0
- package/src/server.test.ts +502 -42
- package/src/server.ts +660 -88
- package/src/share-blob.test.ts +142 -0
- package/src/share-blob.ts +57 -0
- package/src/shared-agent-protocol.ts +30 -0
- package/src/single-instance.test.ts +165 -13
- package/src/store.conformance.test.ts +41 -0
- package/src/store.jsonl.test.ts +173 -0
- package/src/store.ts +278 -34
- package/src/testing/store-conformance.ts +192 -0
- package/src/thread-agent-validation.ts +141 -0
- package/src/thread-agent.test.ts +732 -0
- package/src/thread-agent.ts +1171 -0
- package/src/thread-review.test.ts +487 -0
- package/src/thread-review.ts +394 -0
- package/src/thread-subscription.test.ts +177 -0
- package/src/thread-subscription.ts +138 -0
- package/src/thread-surface-config.test.ts +63 -0
- package/src/thread-surface-config.ts +61 -0
- package/src/validate.test.ts +290 -37
- package/src/validate.ts +427 -33
- package/src/vcs-source.test.ts +180 -0
- package/src/vcs-source.ts +235 -0
- package/src/version.ts +6 -0
- package/src/working-tree.ts +154 -0
- package/src/review.test.ts +0 -167
- package/src/review.ts +0 -174
package/src/api.ts
CHANGED
|
@@ -1,200 +1,1593 @@
|
|
|
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
|
-
* The wait contract:
|
|
5
|
-
* and a
|
|
4
|
+
* The wait contract: messages outlive waits - session.wait long-polls,
|
|
5
|
+
* and a message resolved while nobody waited is delivered on next contact.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
9
|
SCHEMA_VERSION,
|
|
10
|
+
appendEntry,
|
|
11
|
+
applyTextCuts,
|
|
12
|
+
applyPathView,
|
|
13
|
+
createBranch,
|
|
14
|
+
cutBlock,
|
|
10
15
|
feedbackForSession,
|
|
11
|
-
|
|
16
|
+
forkHistory,
|
|
17
|
+
historyFromLinear,
|
|
18
|
+
HistoryError,
|
|
19
|
+
labelTip,
|
|
20
|
+
navigateTo,
|
|
21
|
+
recaptureMainHead,
|
|
22
|
+
viewOfPath,
|
|
23
|
+
isAddressed,
|
|
24
|
+
isAgentNote,
|
|
25
|
+
annotationTarget,
|
|
26
|
+
isMarkdownArtifact,
|
|
27
|
+
MAIN_BRANCH,
|
|
28
|
+
parseBlocks,
|
|
29
|
+
registerParticipant,
|
|
30
|
+
resolveAnchor,
|
|
31
|
+
newMessageId,
|
|
32
|
+
isBlockCut,
|
|
33
|
+
restoreBlock,
|
|
34
|
+
switchBranch,
|
|
12
35
|
type Annotation,
|
|
36
|
+
type DiffFileStatus,
|
|
37
|
+
type DiffFileContents,
|
|
38
|
+
type DiffSource,
|
|
39
|
+
type HunkRejection,
|
|
40
|
+
type NewEntry,
|
|
13
41
|
type Artifact,
|
|
14
|
-
type
|
|
15
|
-
type
|
|
16
|
-
type
|
|
42
|
+
type ShareLink,
|
|
43
|
+
type Identity,
|
|
44
|
+
type HarnessBinding,
|
|
45
|
+
type Delivery,
|
|
46
|
+
type PendingDelivery,
|
|
47
|
+
type Thread,
|
|
48
|
+
type TextCut,
|
|
49
|
+
type SessionHistory,
|
|
50
|
+
type Message,
|
|
51
|
+
type MessageOutcome,
|
|
17
52
|
type WorkspaceKey,
|
|
18
53
|
} from "@cueloop/schema";
|
|
19
|
-
import {
|
|
54
|
+
import { findOperationReceipt, operationFingerprint } from "./operation-receipts";
|
|
55
|
+
import { curateDiff } from "./curate";
|
|
56
|
+
import { ThreadStore, withHistory } from "./store";
|
|
57
|
+
import { pruneExpiredSessions, resolveCleanupPeriodDays } from "./retention";
|
|
58
|
+
import {
|
|
59
|
+
HerdrThreadSurfaceStore,
|
|
60
|
+
type HerdrThreadSurfaceHandle,
|
|
61
|
+
} from "./herdr-thread-surface-store";
|
|
62
|
+
import {
|
|
63
|
+
GhosttyThreadSurfaceStore,
|
|
64
|
+
type GhosttyThreadSurfaceHandle,
|
|
65
|
+
} from "./ghostty-thread-surface-store";
|
|
66
|
+
import { HarnessStateStore } from "./harness-state-store";
|
|
67
|
+
import { DiffWatcher } from "./diff-watcher";
|
|
68
|
+
import { PrReviewPoller } from "./pr-poller";
|
|
69
|
+
import { prSnapshot } from "./gh";
|
|
70
|
+
import type { VcsDiffSnapshot } from "@cueloop/extension-api";
|
|
71
|
+
import { listProjectFiles, readProjectFile } from "./project-files";
|
|
72
|
+
import { resolveWorkspace } from "./thread-review";
|
|
73
|
+
import { VcsSourceManager } from "./vcs-source";
|
|
20
74
|
import { DaemonError } from "./errors";
|
|
21
75
|
|
|
76
|
+
export { messageResponse } from "./thread-review";
|
|
77
|
+
|
|
78
|
+
/** Reviewer-controlled fields that make an annotation new or edited for delivery. */
|
|
79
|
+
function annotationDeliveryFingerprint(annotation: Annotation | undefined): string {
|
|
80
|
+
if (!annotation) return "";
|
|
81
|
+
|
|
82
|
+
return JSON.stringify({
|
|
83
|
+
kind: annotation.kind,
|
|
84
|
+
anchor: {
|
|
85
|
+
quote: annotation.anchor.quote,
|
|
86
|
+
prefix: annotation.anchor.prefix,
|
|
87
|
+
suffix: annotation.anchor.suffix,
|
|
88
|
+
blockIndex: annotation.anchor.blockIndex,
|
|
89
|
+
endBlockIndex: annotation.anchor.endBlockIndex,
|
|
90
|
+
start: annotation.anchor.start,
|
|
91
|
+
end: annotation.anchor.end,
|
|
92
|
+
selector: annotation.anchor.selector,
|
|
93
|
+
},
|
|
94
|
+
target: annotation.target,
|
|
95
|
+
body: annotation.body,
|
|
96
|
+
author: annotation.author,
|
|
97
|
+
reviewComment: annotation.reviewComment,
|
|
98
|
+
replyTo: annotation.replyTo,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** What a share hands back: the notes and names it collected, and the removals it recorded. */
|
|
103
|
+
export interface SharedMerge {
|
|
104
|
+
shareId?: string;
|
|
105
|
+
annotations: Annotation[];
|
|
106
|
+
participants?: Identity[];
|
|
107
|
+
removals?: Array<{ id: string; annotationId: string; createdAt: string }>;
|
|
108
|
+
}
|
|
109
|
+
|
|
22
110
|
export type EventName =
|
|
23
111
|
| "session.created"
|
|
24
112
|
| "session.updated"
|
|
25
|
-
| "session.
|
|
113
|
+
| "session.deleted"
|
|
114
|
+
| "message.sent"
|
|
26
115
|
| "session.revised"
|
|
27
116
|
| "inbox.changed";
|
|
28
117
|
|
|
29
118
|
export interface DaemonEvent {
|
|
30
119
|
event: EventName;
|
|
31
120
|
sessionId: string;
|
|
121
|
+
entryId?: string;
|
|
32
122
|
}
|
|
33
123
|
|
|
34
|
-
type EventListener = (
|
|
124
|
+
type EventListener = (event: DaemonEvent) => void;
|
|
35
125
|
|
|
36
126
|
export class DaemonCore {
|
|
37
|
-
readonly store:
|
|
38
|
-
|
|
127
|
+
readonly store: ThreadStore;
|
|
128
|
+
readonly herdrThreadSurfaces: HerdrThreadSurfaceStore;
|
|
129
|
+
readonly ghosttyThreadSurfaces: GhosttyThreadSurfaceStore;
|
|
130
|
+
readonly harnessState: HarnessStateStore;
|
|
131
|
+
private waiters = new Map<string, ((session: Thread | null) => void)[]>();
|
|
39
132
|
private listeners = new Set<EventListener>();
|
|
40
133
|
private seq = 0;
|
|
134
|
+
/** Drives diff hot-reload: watches each live diff session's repo for working-tree changes. */
|
|
135
|
+
private readonly diffWatcher: DiffWatcher;
|
|
136
|
+
/** Drives PR-review hot-reload: polls each open PR review for a moved head. */
|
|
137
|
+
private readonly prPoller: PrReviewPoller;
|
|
138
|
+
private readonly vcsSources = new VcsSourceManager();
|
|
139
|
+
/**
|
|
140
|
+
* Per-diff-session capture generation. Bumped when a refresh begins; a
|
|
141
|
+
* capture whose generation is stale by the time it finishes discards its
|
|
142
|
+
* result, so overlapping captures never write an older patch over a newer one.
|
|
143
|
+
*/
|
|
144
|
+
private readonly diffRefreshGenerations = new Map<string, number>();
|
|
145
|
+
/** In-flight workbench creations keyed by project, so concurrent bare launches share one thread. */
|
|
146
|
+
private readonly workbenchCreation = new Map<
|
|
147
|
+
string,
|
|
148
|
+
Promise<{ session: Thread; diff: VcsDiffSnapshot & { vcs: string } }>
|
|
149
|
+
>();
|
|
41
150
|
|
|
42
151
|
constructor(home: string) {
|
|
43
|
-
this.store = new
|
|
152
|
+
this.store = new ThreadStore(home);
|
|
44
153
|
this.store.recover();
|
|
154
|
+
this.harnessState = new HarnessStateStore(home);
|
|
155
|
+
for (const session of this.store.list()) this.reconcileDeliveries(session);
|
|
156
|
+
this.herdrThreadSurfaces = new HerdrThreadSurfaceStore(home);
|
|
157
|
+
this.ghosttyThreadSurfaces = new GhosttyThreadSurfaceStore(home);
|
|
158
|
+
const expiredThreadIds = pruneExpiredSessions(
|
|
159
|
+
this.store,
|
|
160
|
+
resolveCleanupPeriodDays(),
|
|
161
|
+
Date.now(),
|
|
162
|
+
this.harnessState.pendingThreadIds(),
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
for (const threadId of expiredThreadIds) {
|
|
166
|
+
this.herdrThreadSurfaces.delete(threadId);
|
|
167
|
+
this.ghosttyThreadSurfaces.delete(threadId);
|
|
168
|
+
}
|
|
169
|
+
this.harnessState.deleteOrphanedThreadState(
|
|
170
|
+
new Set(this.store.list().map((session) => session.id)),
|
|
171
|
+
);
|
|
172
|
+
this.diffWatcher = new DiffWatcher((repoRoot) => void this.refreshDiffsForRepo(repoRoot));
|
|
173
|
+
this.prPoller = new PrReviewPoller((sessionId, refs) =>
|
|
174
|
+
this.markPrRefreshAvailable(sessionId, refs),
|
|
175
|
+
);
|
|
176
|
+
// resume hot-reload for diff sessions that survived a daemon restart
|
|
177
|
+
for (const session of this.store.list()) {
|
|
178
|
+
this.trackLiveDiffSession(session);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Release the watchers and pollers behind diff hot-reload; call on daemon shutdown. */
|
|
183
|
+
dispose(): void {
|
|
184
|
+
this.diffWatcher.close();
|
|
185
|
+
this.prPoller.close();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The Herdr Thread surface handle, kept outside the canonical Thread. */
|
|
189
|
+
herdrGetThreadSurface(sessionId: string): HerdrThreadSurfaceHandle | null {
|
|
190
|
+
return this.herdrThreadSurfaces.get(sessionId);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
herdrSetThreadSurface(sessionId: string, handle: HerdrThreadSurfaceHandle): void {
|
|
194
|
+
this.herdrThreadSurfaces.set(sessionId, handle);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
ghosttyGetThreadSurface(sessionId: string): GhosttyThreadSurfaceHandle | null {
|
|
198
|
+
return this.ghosttyThreadSurfaces.get(sessionId);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
ghosttySetThreadSurface(sessionId: string, handle: GhosttyThreadSurfaceHandle): void {
|
|
202
|
+
this.ghosttyThreadSurfaces.set(sessionId, handle);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
ghosttyClaimThreadSurface(sessionId: string): boolean {
|
|
206
|
+
return this.ghosttyThreadSurfaces.claim(sessionId);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
ghosttyReleaseThreadSurface(sessionId: string): void {
|
|
210
|
+
this.ghosttyThreadSurfaces.release(sessionId);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
harnessBind(input: {
|
|
214
|
+
threadId: string;
|
|
215
|
+
harness: string;
|
|
216
|
+
harnessSessionId: string;
|
|
217
|
+
}): HarnessBinding {
|
|
218
|
+
this.sessionGet(input.threadId);
|
|
219
|
+
const binding = this.harnessState.bind(input);
|
|
220
|
+
|
|
221
|
+
this.reconcileDeliveries(this.sessionGet(input.threadId));
|
|
222
|
+
|
|
223
|
+
return binding;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
harnessGetBinding(bindingId: string): HarnessBinding {
|
|
227
|
+
const binding = this.harnessState.binding(bindingId);
|
|
228
|
+
|
|
229
|
+
if (!binding) throw new DaemonError("not_found", `no harness binding ${bindingId}`);
|
|
230
|
+
|
|
231
|
+
return binding;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
harnessBindingsForSession(harness: string, harnessSessionId: string): HarnessBinding[] {
|
|
235
|
+
return this.harnessState.bindingsForSession(harness, harnessSessionId);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
harnessConsumeApprovedRetry(bindingId: string, messageId: string, content: string): boolean {
|
|
239
|
+
const binding = this.harnessState.binding(bindingId);
|
|
240
|
+
|
|
241
|
+
if (!binding) throw new DaemonError("not_found", `no harness binding ${bindingId}`);
|
|
242
|
+
|
|
243
|
+
const session = this.sessionGet(binding.threadId);
|
|
244
|
+
|
|
245
|
+
if (
|
|
246
|
+
session.artifact.type !== "plan" ||
|
|
247
|
+
session.status !== "resolved" ||
|
|
248
|
+
session.message?.id !== messageId ||
|
|
249
|
+
session.message.outcome !== "approved" ||
|
|
250
|
+
session.artifact.content !== content ||
|
|
251
|
+
this.harnessState.submittingBinding(session.id)?.id !== bindingId ||
|
|
252
|
+
!this.harnessState.acknowledged(bindingId, messageId)
|
|
253
|
+
) {
|
|
254
|
+
throw new DaemonError("invalid_state", `no unchanged approved plan for ${bindingId}`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return this.harnessState.consumeApprovedRetry(bindingId, messageId);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
deliveryPending(bindingId: string): PendingDelivery[] {
|
|
261
|
+
const binding = this.harnessState.binding(bindingId);
|
|
262
|
+
|
|
263
|
+
if (!binding) throw new DaemonError("not_found", `no harness binding ${bindingId}`);
|
|
264
|
+
|
|
265
|
+
const session = this.sessionGet(binding.threadId);
|
|
266
|
+
|
|
267
|
+
return this.harnessState.pending(bindingId).map((delivery) => {
|
|
268
|
+
const historical = session.history?.entries.find(
|
|
269
|
+
(entry) => entry.type === "message" && entry.message.id === delivery.messageId,
|
|
270
|
+
);
|
|
271
|
+
const message =
|
|
272
|
+
session.message?.id === delivery.messageId
|
|
273
|
+
? session.message
|
|
274
|
+
: historical?.type === "message"
|
|
275
|
+
? historical.message
|
|
276
|
+
: null;
|
|
277
|
+
|
|
278
|
+
if (!message || message.id !== delivery.messageId) {
|
|
279
|
+
throw new DaemonError("not_found", `no message ${delivery.messageId}`);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return { delivery, message };
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
deliveryAcknowledge(deliveryId: string): Delivery {
|
|
287
|
+
if (!this.harnessState.delivery(deliveryId)) {
|
|
288
|
+
throw new DaemonError("not_found", `no delivery ${deliveryId}`);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return this.harnessState.acknowledge(deliveryId);
|
|
45
292
|
}
|
|
46
293
|
|
|
47
294
|
onEvent(listener: EventListener): () => void {
|
|
48
295
|
this.listeners.add(listener);
|
|
296
|
+
|
|
49
297
|
return () => this.listeners.delete(listener);
|
|
50
298
|
}
|
|
51
299
|
|
|
52
|
-
private emit(event: EventName, sessionId: string): void {
|
|
53
|
-
|
|
300
|
+
private emit(event: EventName, sessionId: string, entryId?: string): void {
|
|
301
|
+
const frame: DaemonEvent =
|
|
302
|
+
entryId === undefined ? { event, sessionId } : { event, sessionId, entryId };
|
|
303
|
+
|
|
304
|
+
for (const listener of this.listeners) listener(frame);
|
|
54
305
|
}
|
|
55
306
|
|
|
56
|
-
/** True when nothing awaits a
|
|
307
|
+
/** True when nothing awaits a message - drives idle-exit. */
|
|
57
308
|
hasPendingSessions(): boolean {
|
|
58
|
-
return this.store.list().some((
|
|
309
|
+
return this.store.list().some((session) => session.status === "pending");
|
|
59
310
|
}
|
|
60
311
|
|
|
61
|
-
sessionCreate(params: { workspace: WorkspaceKey; artifact: Artifact }):
|
|
312
|
+
sessionCreate(params: { workspace: WorkspaceKey; artifact: Artifact }): Thread {
|
|
62
313
|
const now = new Date().toISOString();
|
|
63
|
-
const session:
|
|
314
|
+
const session: Thread = {
|
|
64
315
|
schemaVersion: SCHEMA_VERSION,
|
|
65
|
-
id:
|
|
316
|
+
id: this.newSessionId(now),
|
|
66
317
|
workspace: params.workspace,
|
|
67
318
|
artifact: params.artifact,
|
|
68
|
-
revisions: [
|
|
319
|
+
revisions: [
|
|
320
|
+
{
|
|
321
|
+
revision: 1,
|
|
322
|
+
content: params.artifact.content,
|
|
323
|
+
submittedAt: now,
|
|
324
|
+
files: params.artifact.type === "diff" ? params.artifact.files : undefined,
|
|
325
|
+
source: params.artifact.meta.prHeadSha
|
|
326
|
+
? {
|
|
327
|
+
vcs: "git",
|
|
328
|
+
revisionId: params.artifact.meta.prHeadSha,
|
|
329
|
+
baseRevisionId: params.artifact.meta.prBaseSha,
|
|
330
|
+
}
|
|
331
|
+
: params.artifact.meta.vcsRevisionId && params.artifact.meta.vcs
|
|
332
|
+
? {
|
|
333
|
+
vcs: params.artifact.meta.vcs,
|
|
334
|
+
changeId: params.artifact.meta.vcsChangeId,
|
|
335
|
+
revisionId: params.artifact.meta.vcsRevisionId,
|
|
336
|
+
}
|
|
337
|
+
: undefined,
|
|
338
|
+
},
|
|
339
|
+
],
|
|
69
340
|
annotations: [],
|
|
70
|
-
|
|
341
|
+
message: null,
|
|
71
342
|
status: "pending",
|
|
72
343
|
createdAt: now,
|
|
73
344
|
};
|
|
345
|
+
|
|
346
|
+
session.history = historyFromLinear(session);
|
|
74
347
|
this.store.upsert(session);
|
|
348
|
+
this.reconcileDeliveries(session);
|
|
349
|
+
this.trackLiveDiffSession(session);
|
|
75
350
|
this.emit("session.created", session.id);
|
|
76
351
|
this.emit("inbox.changed", session.id);
|
|
352
|
+
|
|
77
353
|
return session;
|
|
78
354
|
}
|
|
79
355
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
356
|
+
private reconcileDeliveries(session: Thread): void {
|
|
357
|
+
if (!session.message) return;
|
|
358
|
+
|
|
359
|
+
const binding = this.harnessState.submittingBinding(session.id);
|
|
360
|
+
|
|
361
|
+
if (binding)
|
|
362
|
+
this.harnessState.enqueue({ bindingId: binding.id, messageId: session.message.id });
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
private newSessionId(now: string): string {
|
|
366
|
+
return `ses_${now.replace(/\D/g, "").slice(0, 14)}_${(++this.seq).toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
|
84
367
|
}
|
|
85
368
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
369
|
+
sessionGet(id: string): Thread {
|
|
370
|
+
const session = this.store.get(id);
|
|
371
|
+
|
|
372
|
+
if (!session) throw new DaemonError("not_found", `no session ${id}`);
|
|
373
|
+
|
|
374
|
+
return session;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
sessionList(filter?: {
|
|
378
|
+
status?: "pending" | "resolved";
|
|
379
|
+
workspace?: Partial<WorkspaceKey>;
|
|
380
|
+
}): Thread[] {
|
|
381
|
+
return this.store.list().filter((session) => {
|
|
382
|
+
if (filter?.status && session.status !== filter.status) return false;
|
|
383
|
+
|
|
384
|
+
if (filter?.workspace?.repoRoot && session.workspace.repoRoot !== filter.workspace.repoRoot)
|
|
385
|
+
return false;
|
|
386
|
+
|
|
387
|
+
if (filter?.workspace?.branch && session.workspace.branch !== filter.workspace.branch)
|
|
388
|
+
return false;
|
|
389
|
+
|
|
91
390
|
return true;
|
|
92
391
|
});
|
|
93
392
|
}
|
|
94
393
|
|
|
95
394
|
/**
|
|
96
|
-
* Long-poll for the
|
|
97
|
-
* otherwise parks until
|
|
98
|
-
* still pending - the caller re-polls later; the
|
|
395
|
+
* Long-poll for the message. Resolves immediately when already resolved;
|
|
396
|
+
* otherwise parks until sessionSendMessage fires or timeoutMs elapses (null =
|
|
397
|
+
* still pending - the caller re-polls later; the message is never lost).
|
|
99
398
|
*/
|
|
100
|
-
sessionWait(id: string, timeoutMs: number): Promise<
|
|
101
|
-
const
|
|
102
|
-
|
|
399
|
+
sessionWait(id: string, timeoutMs: number): Promise<Thread | null> {
|
|
400
|
+
const current = this.sessionGet(id);
|
|
401
|
+
|
|
402
|
+
if (current.status === "resolved") return Promise.resolve(current);
|
|
403
|
+
|
|
103
404
|
return new Promise((resolve) => {
|
|
104
405
|
const list = this.waiters.get(id) ?? [];
|
|
105
406
|
let done = false;
|
|
106
407
|
const timer = setTimeout(() => {
|
|
107
408
|
if (done) return;
|
|
409
|
+
|
|
108
410
|
done = true;
|
|
109
|
-
const
|
|
110
|
-
|
|
411
|
+
const waiterIndex = list.indexOf(waiter);
|
|
412
|
+
|
|
413
|
+
if (waiterIndex !== -1) list.splice(waiterIndex, 1);
|
|
414
|
+
|
|
111
415
|
resolve(null);
|
|
112
416
|
}, timeoutMs);
|
|
113
|
-
const waiter = (session:
|
|
417
|
+
const waiter = (session: Thread | null) => {
|
|
114
418
|
if (done) return;
|
|
419
|
+
|
|
115
420
|
done = true;
|
|
116
421
|
clearTimeout(timer);
|
|
117
422
|
resolve(session);
|
|
118
423
|
};
|
|
424
|
+
|
|
119
425
|
list.push(waiter);
|
|
120
426
|
this.waiters.set(id, list);
|
|
121
427
|
});
|
|
122
428
|
}
|
|
123
429
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
430
|
+
/**
|
|
431
|
+
* `authorName` registers the annotation's author in the participant registry;
|
|
432
|
+
* a bare author id (no name) keeps its short-handle fallback instead.
|
|
433
|
+
*/
|
|
434
|
+
sessionAnnotate(
|
|
435
|
+
id: string,
|
|
436
|
+
annotation: Omit<Annotation, "createdAt">,
|
|
437
|
+
authorName?: string,
|
|
438
|
+
): Thread {
|
|
439
|
+
const session = this.mutable(id);
|
|
440
|
+
|
|
441
|
+
// a welcome-playground note is ephemeral by design: never persisted, so never fed back
|
|
442
|
+
if (annotationTarget(annotation).kind === "welcome") return session;
|
|
443
|
+
|
|
444
|
+
const existing = session.annotations.findIndex((candidate) => candidate.id === annotation.id);
|
|
127
445
|
const full: Annotation = { ...annotation, createdAt: new Date().toISOString() };
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
446
|
+
|
|
447
|
+
let entryId: string | undefined;
|
|
448
|
+
|
|
449
|
+
if (existing === -1) {
|
|
450
|
+
session.annotations.push(full);
|
|
451
|
+
entryId = this.record(session, {
|
|
452
|
+
type: "comment",
|
|
453
|
+
annotationId: full.id,
|
|
454
|
+
createdAt: full.createdAt,
|
|
455
|
+
});
|
|
456
|
+
} else
|
|
457
|
+
session.annotations[existing] = {
|
|
458
|
+
...full,
|
|
459
|
+
createdAt: session.annotations[existing]!.createdAt,
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
if (annotation.author && authorName)
|
|
463
|
+
session.participants = registerParticipant(
|
|
464
|
+
session,
|
|
465
|
+
annotation.author,
|
|
466
|
+
authorName,
|
|
467
|
+
).participants;
|
|
468
|
+
|
|
469
|
+
this.store.upsert(session);
|
|
470
|
+
this.emit("session.updated", id, entryId);
|
|
471
|
+
|
|
472
|
+
return session;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Remove a comment. With `onBehalfOf`, the caller is a collaborator or an
|
|
477
|
+
* agent acting as that author and may remove only that author's comments;
|
|
478
|
+
* the owner (no `onBehalfOf`) may remove any.
|
|
479
|
+
*/
|
|
480
|
+
sessionRemoveAnnotation(id: string, annotationId: string, onBehalfOf?: string): Thread {
|
|
481
|
+
const session = this.mutable(id);
|
|
482
|
+
const target = session.annotations.find((candidate) => candidate.id === annotationId);
|
|
483
|
+
|
|
484
|
+
if (onBehalfOf !== undefined && target !== undefined && target.author !== onBehalfOf) {
|
|
485
|
+
throw new DaemonError("forbidden", `${onBehalfOf} cannot remove another author's comment`);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
session.annotations = session.annotations.filter((candidate) => candidate.id !== annotationId);
|
|
489
|
+
let entryId: string | undefined;
|
|
490
|
+
|
|
491
|
+
if (target !== undefined) {
|
|
492
|
+
// nothing is deleted: a navigate back before the removal shows it again
|
|
493
|
+
session.shelvedAnnotations = [...(session.shelvedAnnotations ?? []), target];
|
|
494
|
+
entryId = this.record(session, {
|
|
495
|
+
type: "comment-removed",
|
|
496
|
+
annotationId,
|
|
497
|
+
createdAt: new Date().toISOString(),
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
this.store.upsert(session);
|
|
502
|
+
this.emit("session.updated", id, entryId);
|
|
503
|
+
|
|
504
|
+
return session;
|
|
133
505
|
}
|
|
134
506
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
507
|
+
/** Register a display name for a participant; how a collaborator or an agent names itself. */
|
|
508
|
+
sessionSetParticipantName(id: string, author: string, name: string): Thread {
|
|
509
|
+
const session = this.mutable(id);
|
|
510
|
+
|
|
511
|
+
session.participants = registerParticipant(session, author, name).participants;
|
|
512
|
+
this.store.upsert(session);
|
|
139
513
|
this.emit("session.updated", id);
|
|
140
|
-
|
|
514
|
+
|
|
515
|
+
return session;
|
|
141
516
|
}
|
|
142
517
|
|
|
143
518
|
/** The reviewer's working copy; undefined clears it (revert all edits). */
|
|
144
|
-
sessionSetWorkingCopy(id: string, workingCopy: string | undefined):
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
519
|
+
sessionSetWorkingCopy(id: string, workingCopy: string | undefined, textCuts?: TextCut[]): Thread {
|
|
520
|
+
const session = this.mutable(id);
|
|
521
|
+
|
|
522
|
+
if (textCuts?.length) this.assertTextCuts(session.artifact.content, workingCopy, textCuts);
|
|
523
|
+
|
|
524
|
+
const entryId = this.applyWorkingCopy(session, workingCopy, textCuts);
|
|
525
|
+
|
|
526
|
+
this.store.upsert(session);
|
|
527
|
+
this.emit("session.updated", id, entryId);
|
|
528
|
+
|
|
529
|
+
return session;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
private assertTextCuts(
|
|
533
|
+
source: string,
|
|
534
|
+
workingCopy: string | undefined,
|
|
535
|
+
textCuts: readonly TextCut[],
|
|
536
|
+
): void {
|
|
537
|
+
let previousEnd = 0;
|
|
538
|
+
const valid = textCuts.every((cut) => {
|
|
539
|
+
const matches =
|
|
540
|
+
cut.start >= previousEnd &&
|
|
541
|
+
cut.end > cut.start &&
|
|
542
|
+
cut.end <= source.length &&
|
|
543
|
+
source.slice(cut.start, cut.end) === cut.quote;
|
|
544
|
+
|
|
545
|
+
previousEnd = cut.end;
|
|
546
|
+
|
|
547
|
+
return matches;
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
if (!valid || workingCopy === undefined || applyTextCuts(source, textCuts) !== workingCopy) {
|
|
551
|
+
throw new DaemonError(
|
|
552
|
+
"invalid_params",
|
|
553
|
+
"text Cuts do not match the submitted artifact and working copy",
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* The guided walk's viewed marks. Merge-additive: walking only ever adds
|
|
560
|
+
* marks, so concurrent or stale clients converge instead of overwriting
|
|
561
|
+
* each other. An empty array is the explicit reset.
|
|
562
|
+
*/
|
|
563
|
+
sessionSetViewed(id: string, viewedPaths: string[]): Thread {
|
|
564
|
+
const session = this.mutable(id);
|
|
565
|
+
|
|
566
|
+
if (viewedPaths.length === 0) delete session.viewedPaths;
|
|
567
|
+
else session.viewedPaths = [...new Set([...(session.viewedPaths ?? []), ...viewedPaths])];
|
|
568
|
+
|
|
569
|
+
this.store.upsert(session);
|
|
570
|
+
this.emit("session.updated", id);
|
|
571
|
+
|
|
572
|
+
return session;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** Rename a session's display title; an empty title clears it back to the derived default. */
|
|
576
|
+
sessionSetTitle(id: string, title: string): Thread {
|
|
577
|
+
const session = this.mutable(id);
|
|
578
|
+
const trimmed = title.trim();
|
|
579
|
+
|
|
580
|
+
if (trimmed.length === 0) delete session.artifact.meta.title;
|
|
581
|
+
else session.artifact.meta.title = trimmed;
|
|
582
|
+
|
|
583
|
+
this.store.upsert(session);
|
|
584
|
+
// the title shows in the Threads sidebar, so a rename is an inbox change, not just a content edit
|
|
585
|
+
this.emit("inbox.changed", id);
|
|
586
|
+
|
|
587
|
+
return session;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** Tracked, repo-relative file paths for the session's workspace; [] when it has no repo. */
|
|
591
|
+
projectFiles(id: string): Promise<string[]> {
|
|
592
|
+
const session = this.sessionGet(id);
|
|
593
|
+
|
|
594
|
+
return this.vcsSources
|
|
595
|
+
.select(session.workspace.repoRoot, session.artifact.meta.vcs ?? "git")
|
|
596
|
+
.then(({ adapter, repoRoot }) => adapter.listFiles(repoRoot))
|
|
597
|
+
.catch(() => listProjectFiles(session.workspace.repoRoot));
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** UTF-8 contents of a repo-relative file in the session's workspace, or null when it cannot be read safely. */
|
|
601
|
+
fileContents(id: string, path: string): Promise<string | null> {
|
|
602
|
+
return readProjectFile(this.sessionGet(id).workspace.repoRoot, path);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** Tracked, repo-relative file paths for the git repo containing `cwd`; [] when it is not a repo. Owner-only. */
|
|
606
|
+
async repoFiles(cwd: string): Promise<string[]> {
|
|
607
|
+
const { adapter, repoRoot } = await this.vcsSources.select(cwd);
|
|
608
|
+
|
|
609
|
+
return adapter.listFiles(repoRoot);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** UTF-8 contents of a repo-relative file in the repo containing `cwd`, or null when unreadable. Owner-only. */
|
|
613
|
+
async repoFileContents(cwd: string, path: string): Promise<string | null> {
|
|
614
|
+
return readProjectFile((await resolveWorkspace(cwd)).repoRoot, path);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** Changed files (repo-relative path plus git status) in the working tree at `cwd`. Owner-only. */
|
|
618
|
+
repoChanges(cwd: string): Promise<{ path: string; status: DiffFileStatus }[]> {
|
|
619
|
+
return this.vcsSources
|
|
620
|
+
.select(cwd)
|
|
621
|
+
.then(({ adapter, repoRoot }) => adapter.listChanges(repoRoot));
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* The live working-tree diff (HEAD vs working tree, untracked included) at `cwd`:
|
|
626
|
+
* the same unified patch plus full per-file contents a `cueloop diff` captures,
|
|
627
|
+
* so the Changes navigator renders a real diff for any thread. Owner-only.
|
|
628
|
+
*/
|
|
629
|
+
repoDiff(cwd: string, vcs?: string): Promise<VcsDiffSnapshot & { vcs: string }> {
|
|
630
|
+
return this.vcsSources.capture(cwd, vcs);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* The per-repo workbench thread for `cwd`: a self-initiated review of the current checkout, keyed by
|
|
635
|
+
* the repo's root-commit identity (a standalone bucket when there is none). Returns the existing one
|
|
636
|
+
* or lazily creates it with the working-tree diff as its artifact, so a bare launch persists its
|
|
637
|
+
* first comment without an agent submission. Owner-only.
|
|
638
|
+
*/
|
|
639
|
+
async workbenchSession(cwd: string): Promise<Thread> {
|
|
640
|
+
const opened = await this.openWorkbench(cwd);
|
|
641
|
+
|
|
642
|
+
return opened.session;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** Return the workbench and its current diff from one capture. */
|
|
646
|
+
async workbenchReview(
|
|
647
|
+
cwd: string,
|
|
648
|
+
): Promise<{ session: Thread; diff: VcsDiffSnapshot & { vcs: string } }> {
|
|
649
|
+
const opened = await this.openWorkbench(cwd);
|
|
650
|
+
|
|
651
|
+
if (opened.diff) return { session: opened.session, diff: opened.diff };
|
|
652
|
+
|
|
653
|
+
return {
|
|
654
|
+
session: opened.session,
|
|
655
|
+
diff: await this.vcsSources.capture(
|
|
656
|
+
opened.session.workspace.repoRoot,
|
|
657
|
+
opened.session.artifact.meta.vcs,
|
|
658
|
+
),
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
private async openWorkbench(
|
|
663
|
+
cwd: string,
|
|
664
|
+
): Promise<{ session: Thread; diff?: VcsDiffSnapshot & { vcs: string } }> {
|
|
665
|
+
const [selected, workspace] = await Promise.all([
|
|
666
|
+
this.vcsSources.select(cwd),
|
|
667
|
+
resolveWorkspace(cwd),
|
|
668
|
+
]);
|
|
669
|
+
|
|
670
|
+
workspace.repoRoot = selected.repoRoot;
|
|
671
|
+
const key = `${workspace.rootCommit ?? selected.repoRoot}:${selected.adapter.id}`;
|
|
672
|
+
// an open workbench is reused; a resolved one is immutable and would reject the note, so a fresh
|
|
673
|
+
// one is created past it
|
|
674
|
+
const open = this.store
|
|
675
|
+
.list()
|
|
676
|
+
.find(
|
|
677
|
+
(session) =>
|
|
678
|
+
session.artifact.meta.workbench === true &&
|
|
679
|
+
session.status !== "resolved" &&
|
|
680
|
+
(session.workspace.rootCommit ?? session.workspace.repoRoot) ===
|
|
681
|
+
(workspace.rootCommit ?? selected.repoRoot) &&
|
|
682
|
+
(session.artifact.meta.vcs ?? "git") === selected.adapter.id,
|
|
683
|
+
);
|
|
684
|
+
|
|
685
|
+
if (open) return { session: open };
|
|
686
|
+
|
|
687
|
+
// serialize concurrent bare launches for the same repo onto one creation, so they share a thread
|
|
688
|
+
const inFlight = this.workbenchCreation.get(key);
|
|
689
|
+
|
|
690
|
+
if (inFlight) return inFlight;
|
|
691
|
+
|
|
692
|
+
const creation = this.vcsSources.capture(cwd, selected.adapter.id).then((diff) => ({
|
|
693
|
+
session: this.sessionCreate({
|
|
694
|
+
workspace,
|
|
695
|
+
artifact: {
|
|
696
|
+
type: "diff",
|
|
697
|
+
content: diff.patch,
|
|
698
|
+
files: diff.files,
|
|
699
|
+
meta: {
|
|
700
|
+
workbench: true,
|
|
701
|
+
title: "Workbench",
|
|
702
|
+
cwd,
|
|
703
|
+
vcs: diff.vcs,
|
|
704
|
+
vcsChangeId: diff.source?.changeId,
|
|
705
|
+
vcsRevisionId: diff.source?.revisionId,
|
|
706
|
+
},
|
|
707
|
+
},
|
|
708
|
+
}),
|
|
709
|
+
diff,
|
|
710
|
+
}));
|
|
711
|
+
|
|
712
|
+
this.workbenchCreation.set(key, creation);
|
|
713
|
+
try {
|
|
714
|
+
return await creation;
|
|
715
|
+
} finally {
|
|
716
|
+
this.workbenchCreation.delete(key);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** Replace the thread's share links. */
|
|
721
|
+
sessionSetShares(id: string, shares: ShareLink[]): Thread {
|
|
722
|
+
const session = this.mutable(id);
|
|
723
|
+
|
|
724
|
+
session.shares = shares;
|
|
725
|
+
this.store.upsert(session);
|
|
726
|
+
this.emit("session.updated", id);
|
|
727
|
+
|
|
728
|
+
return session;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/** Remove a Thread and all local state that refers to it. */
|
|
732
|
+
sessionDelete(id: string): void {
|
|
733
|
+
const session = this.store.get(id);
|
|
734
|
+
|
|
735
|
+
if (!this.store.delete(id)) throw new DaemonError("not_found", `no session ${id}`);
|
|
736
|
+
|
|
737
|
+
if (session) this.untrackLiveDiffSession(session);
|
|
738
|
+
|
|
739
|
+
this.diffRefreshGenerations.delete(id);
|
|
740
|
+
this.herdrThreadSurfaces.delete(id);
|
|
741
|
+
this.ghosttyThreadSurfaces.delete(id);
|
|
742
|
+
this.harnessState.deleteThreadState(id);
|
|
743
|
+
const parked = this.waiters.get(id) ?? [];
|
|
744
|
+
|
|
745
|
+
this.waiters.delete(id);
|
|
746
|
+
for (const parkedWaiter of parked) parkedWaiter(null);
|
|
747
|
+
this.emit("session.deleted", id);
|
|
748
|
+
this.emit("inbox.changed", id);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* Cut one block of the reviewer's working copy - the `blockIndex`-th block
|
|
753
|
+
* of the working text - so it serializes into the diff the agent receives.
|
|
754
|
+
*/
|
|
755
|
+
sessionCutBlock(id: string, blockIndex: number): Thread {
|
|
756
|
+
const session = this.mutable(id);
|
|
757
|
+
const working = session.workingCopy ?? session.artifact.content;
|
|
758
|
+
const block = parseBlocks(working)[blockIndex];
|
|
759
|
+
|
|
760
|
+
if (!block)
|
|
761
|
+
throw new DaemonError("invalid_params", `no block ${blockIndex} in the working copy`);
|
|
762
|
+
|
|
763
|
+
const entryId = this.applyWorkingCopy(session, cutBlock(working, block));
|
|
764
|
+
|
|
765
|
+
this.store.upsert(session);
|
|
766
|
+
this.emit("session.updated", id, entryId);
|
|
767
|
+
|
|
768
|
+
return session;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Re-insert a cut block - the `baseBlockIndex`-th block of the submitted
|
|
773
|
+
* revision - into the working copy before `line` (default: the end). A copy
|
|
774
|
+
* that reads as the submitted revision again is dropped, not stored.
|
|
775
|
+
*/
|
|
776
|
+
sessionRestoreBlock(id: string, baseBlockIndex: number, line?: number): Thread {
|
|
777
|
+
const session = this.mutable(id);
|
|
778
|
+
const base = session.artifact.content;
|
|
779
|
+
const block = parseBlocks(base)[baseBlockIndex];
|
|
780
|
+
|
|
781
|
+
if (!block) {
|
|
782
|
+
throw new DaemonError(
|
|
783
|
+
"invalid_params",
|
|
784
|
+
`no block ${baseBlockIndex} in the submitted revision`,
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
const working = session.workingCopy ?? base;
|
|
789
|
+
|
|
790
|
+
if (!isBlockCut(base, working, block)) {
|
|
791
|
+
throw new DaemonError(
|
|
792
|
+
"invalid_params",
|
|
793
|
+
`block ${baseBlockIndex} of the submitted revision is present in the working copy`,
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
const beforeLine = Math.min(line ?? working.split("\n").length, working.split("\n").length);
|
|
798
|
+
const entryId = this.applyWorkingCopy(session, restoreBlock(base, working, block, beforeLine));
|
|
799
|
+
|
|
800
|
+
this.store.upsert(session);
|
|
801
|
+
this.emit("session.updated", id, entryId);
|
|
802
|
+
|
|
803
|
+
return session;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Replace a diff review's reject decisions; the working copy becomes the
|
|
808
|
+
* patch they leave, or clears when nothing is rejected. Needs the full file
|
|
809
|
+
* contents a working-tree diff carries; a PR diff cannot be curated.
|
|
810
|
+
*/
|
|
811
|
+
sessionCurate(id: string, rejections: HunkRejection[]): Thread {
|
|
812
|
+
const session = this.mutable(id);
|
|
813
|
+
|
|
814
|
+
if (session.artifact.type !== "diff") {
|
|
815
|
+
throw new DaemonError("invalid_params", "only a diff review is curated by hunk");
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
if (!session.artifact.files?.length) {
|
|
819
|
+
throw new DaemonError("invalid_params", "hunk curation needs full file contents");
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
if (rejections.length === 0) delete session.curation;
|
|
823
|
+
else session.curation = rejections;
|
|
824
|
+
|
|
825
|
+
const entryId = this.applyWorkingCopy(
|
|
826
|
+
session,
|
|
827
|
+
rejections.length === 0 ? undefined : curateDiff(session.artifact.files, rejections),
|
|
828
|
+
);
|
|
829
|
+
|
|
830
|
+
this.store.upsert(session);
|
|
831
|
+
this.emit("session.updated", id, entryId);
|
|
832
|
+
|
|
833
|
+
return session;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* Merge a share's state back into the local session. Comments union by
|
|
838
|
+
* annotation id with existing ones (the planner's) winning; removals union by
|
|
839
|
+
* entry id, each shelving its comment once; the participant registry unions
|
|
840
|
+
* by id with the incoming identity winning (a collaborator is the authority
|
|
841
|
+
* on their own name). Everything lands on the branch the share follows, and
|
|
842
|
+
* the record then shows its current path again, wherever the owner stands.
|
|
843
|
+
*/
|
|
844
|
+
sessionMergeShared(id: string, incoming: SharedMerge): Thread {
|
|
845
|
+
const session = this.mutable(id);
|
|
846
|
+
const branch = this.shareBranchOf(session, incoming.shareId);
|
|
847
|
+
const known = new Set(
|
|
848
|
+
[...session.annotations, ...(session.shelvedAnnotations ?? [])].map(
|
|
849
|
+
(annotation) => annotation.id,
|
|
850
|
+
),
|
|
851
|
+
);
|
|
852
|
+
let changed = false;
|
|
853
|
+
|
|
854
|
+
for (const annotation of incoming.annotations) {
|
|
855
|
+
if (known.has(annotation.id)) continue;
|
|
856
|
+
|
|
857
|
+
known.add(annotation.id);
|
|
858
|
+
session.annotations.push(annotation);
|
|
859
|
+
this.recordOn(session, branch, {
|
|
860
|
+
type: "comment",
|
|
861
|
+
annotationId: annotation.id,
|
|
862
|
+
createdAt: annotation.createdAt,
|
|
863
|
+
});
|
|
864
|
+
changed = true;
|
|
865
|
+
}
|
|
866
|
+
const recorded = new Set((session.history?.entries ?? []).map((entry) => entry.id));
|
|
867
|
+
|
|
868
|
+
for (const removal of incoming.removals ?? []) {
|
|
869
|
+
if (recorded.has(removal.id) || !known.has(removal.annotationId)) continue;
|
|
870
|
+
|
|
871
|
+
this.recordOn(session, branch, {
|
|
872
|
+
id: removal.id,
|
|
873
|
+
type: "comment-removed",
|
|
874
|
+
annotationId: removal.annotationId,
|
|
875
|
+
createdAt: removal.createdAt,
|
|
876
|
+
});
|
|
877
|
+
changed = true;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
if (incoming.participants?.length) {
|
|
881
|
+
const registry = new Map(
|
|
882
|
+
(session.participants ?? []).map((participant) => [participant.id, participant]),
|
|
883
|
+
);
|
|
884
|
+
|
|
885
|
+
for (const participant of incoming.participants) registry.set(participant.id, participant);
|
|
886
|
+
session.participants = [...registry.values()];
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
if (changed && session.history) this.refreshView(session, session.history);
|
|
890
|
+
else this.store.upsert(session);
|
|
891
|
+
|
|
149
892
|
this.emit("session.updated", id);
|
|
150
|
-
|
|
893
|
+
|
|
894
|
+
return session;
|
|
151
895
|
}
|
|
152
896
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
897
|
+
sessionSendMessage(
|
|
898
|
+
id: string,
|
|
899
|
+
outcome: MessageOutcome,
|
|
900
|
+
summary: string,
|
|
901
|
+
actionBodies?: Record<string, string>,
|
|
902
|
+
operationId?: string,
|
|
903
|
+
): Thread {
|
|
904
|
+
const current = this.sessionGet(id);
|
|
905
|
+
const fingerprint = operationFingerprint([
|
|
906
|
+
outcome,
|
|
157
907
|
summary,
|
|
158
|
-
|
|
159
|
-
|
|
908
|
+
Object.entries(actionBodies ?? {}).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
|
|
909
|
+
]);
|
|
910
|
+
const receipt = findOperationReceipt(current.messageOperations, operationId, fingerprint);
|
|
911
|
+
|
|
912
|
+
if (receipt) return { ...structuredClone(current), message: structuredClone(receipt.result) };
|
|
913
|
+
|
|
914
|
+
const mutable = this.mutable(id);
|
|
915
|
+
const session = operationId ? structuredClone(mutable) : mutable;
|
|
916
|
+
const sentAt = new Date().toISOString();
|
|
917
|
+
const sentAnnotations = new Map(
|
|
918
|
+
(session.history?.entries ?? [])
|
|
919
|
+
.filter((entry) => entry.type === "message")
|
|
920
|
+
.flatMap((entry) => (entry.type === "message" ? (entry.message.annotations ?? []) : []))
|
|
921
|
+
.map((annotation) => [annotation.id, annotation]),
|
|
922
|
+
);
|
|
923
|
+
const annotations = session.annotations.filter(
|
|
924
|
+
(annotation) =>
|
|
925
|
+
!isAddressed(annotation) &&
|
|
926
|
+
annotationDeliveryFingerprint(sentAnnotations.get(annotation.id)) !==
|
|
927
|
+
annotationDeliveryFingerprint(annotation),
|
|
928
|
+
);
|
|
929
|
+
const message: Message = {
|
|
930
|
+
id: newMessageId(),
|
|
931
|
+
outcome,
|
|
932
|
+
summary,
|
|
933
|
+
body: feedbackForSession(session, outcome, summary, actionBodies, annotations),
|
|
934
|
+
annotations,
|
|
935
|
+
sentAt,
|
|
160
936
|
};
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
937
|
+
|
|
938
|
+
if (operationId)
|
|
939
|
+
(session.messageOperations ??= []).push({ operationId, fingerprint, result: message });
|
|
940
|
+
|
|
941
|
+
session.message = message;
|
|
942
|
+
session.status = outcome === "comment" ? "pending" : "resolved";
|
|
943
|
+
const entryId = this.record(session, {
|
|
944
|
+
type: "message",
|
|
945
|
+
message,
|
|
946
|
+
createdAt: message.sentAt,
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
this.store.upsert(session);
|
|
950
|
+
this.reconcileDeliveries(session);
|
|
951
|
+
this.emit("message.sent", id, entryId);
|
|
952
|
+
|
|
953
|
+
if (outcome === "comment") {
|
|
954
|
+
this.emit("session.updated", id, entryId);
|
|
955
|
+
|
|
956
|
+
return session;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// a resolved diff review is frozen; stop hot-reloading its working tree
|
|
960
|
+
this.untrackLiveDiffSession(session);
|
|
164
961
|
const parked = this.waiters.get(id) ?? [];
|
|
962
|
+
|
|
165
963
|
this.waiters.delete(id);
|
|
166
|
-
for (const
|
|
167
|
-
this.emit("session.resolved", id);
|
|
964
|
+
for (const parkedWaiter of parked) parkedWaiter(session);
|
|
168
965
|
this.emit("inbox.changed", id);
|
|
169
|
-
|
|
966
|
+
|
|
967
|
+
return session;
|
|
170
968
|
}
|
|
171
969
|
|
|
172
|
-
/**
|
|
173
|
-
|
|
174
|
-
|
|
970
|
+
/**
|
|
971
|
+
* Agent resubmits: new revision becomes the artifact, session reopens.
|
|
972
|
+
* Annotations the agent reports as acted on (by id) are marked addressed,
|
|
973
|
+
* and for plan revisions any still-open annotation whose quoted text no
|
|
974
|
+
* longer resolves is marked addressed too ("drift" - the line it pointed at
|
|
975
|
+
* was rewritten). Addressed is a marker, never a delete: the reviewer's
|
|
976
|
+
* rail hides them behind a count, and the next feedback document omits them.
|
|
977
|
+
* Unknown ids are ignored, so a stale id in the agent's list never fails
|
|
978
|
+
* the resubmit.
|
|
979
|
+
*/
|
|
980
|
+
sessionSubmitRevision(
|
|
981
|
+
id: string,
|
|
982
|
+
content: string,
|
|
983
|
+
addressedAnnotationIds: string[] = [],
|
|
984
|
+
files?: DiffFileContents[],
|
|
985
|
+
source?: DiffSource,
|
|
986
|
+
): Thread {
|
|
987
|
+
const session = this.sessionGet(id);
|
|
175
988
|
const now = new Date().toISOString();
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
989
|
+
const revisionNumber = session.revisions.length + 1;
|
|
990
|
+
|
|
991
|
+
session.revisions.push({
|
|
992
|
+
revision: revisionNumber,
|
|
993
|
+
content,
|
|
994
|
+
submittedAt: now,
|
|
995
|
+
source,
|
|
996
|
+
files: session.artifact.type === "diff" ? (files ?? session.artifact.files) : undefined,
|
|
997
|
+
});
|
|
998
|
+
session.artifact = { ...session.artifact, content, files: files ?? session.artifact.files };
|
|
999
|
+
applyRevisionSource(session.artifact, source);
|
|
1000
|
+
// the agent's revision lands on main wherever its tip sits; the artifact
|
|
1001
|
+
// shows the head of the branch the reviewer is on
|
|
1002
|
+
const entryId = this.recordOnMain(session, {
|
|
1003
|
+
type: "revision",
|
|
1004
|
+
by: "agent",
|
|
1005
|
+
content,
|
|
1006
|
+
createdAt: now,
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
delete session.workingCopy;
|
|
1010
|
+
delete session.textCuts;
|
|
1011
|
+
session.message = null;
|
|
1012
|
+
session.status = "pending";
|
|
1013
|
+
|
|
1014
|
+
// a reported root comment addresses its whole discussion: replies are
|
|
1015
|
+
// never listed on their own in the feedback document
|
|
1016
|
+
const reportedIds = new Set(addressedAnnotationIds);
|
|
1017
|
+
|
|
1018
|
+
for (const annotation of session.annotations) {
|
|
1019
|
+
if (annotation.replyTo !== undefined && reportedIds.has(annotation.replyTo)) {
|
|
1020
|
+
reportedIds.add(annotation.id);
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
// drift assist applies to markdown artifacts (plan, reply) only: a diff
|
|
1024
|
+
// revision is a whole new patch, where a vanished quote says nothing about
|
|
1025
|
+
// the feedback
|
|
1026
|
+
const revisedBlocks = isMarkdownArtifact(session.artifact.type) ? parseBlocks(content) : null;
|
|
1027
|
+
|
|
1028
|
+
for (const annotation of session.annotations) {
|
|
1029
|
+
if (isAddressed(annotation) || isAgentNote(annotation)) continue;
|
|
1030
|
+
|
|
1031
|
+
if (reportedIds.has(annotation.id)) {
|
|
1032
|
+
annotation.resolution = { revision: revisionNumber, source: "agent" };
|
|
1033
|
+
} else if (
|
|
1034
|
+
revisedBlocks !== null &&
|
|
1035
|
+
// only a note on the artifact drifts against the artifact's revision; a selector-anchored
|
|
1036
|
+
// pixel element or a file-targeted Changes-diff note anchors elsewhere
|
|
1037
|
+
annotationTarget(annotation).kind === "artifact" &&
|
|
1038
|
+
!annotation.anchor.selector &&
|
|
1039
|
+
resolveAnchor(annotation.anchor, revisedBlocks) === null
|
|
1040
|
+
) {
|
|
1041
|
+
annotation.resolution = { revision: revisionNumber, source: "drift" };
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
this.store.upsert(session);
|
|
1046
|
+
this.emit("session.revised", id, entryId);
|
|
183
1047
|
this.emit("inbox.changed", id);
|
|
184
|
-
|
|
1048
|
+
|
|
1049
|
+
return session;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/**
|
|
1053
|
+
* Move a branch's tip back to an entry on its path - the current branch, or
|
|
1054
|
+
* `branch` after switching to it. The entries after it stay in the tree; a
|
|
1055
|
+
* summary records them as a branch-summary entry at the target. The view
|
|
1056
|
+
* follows the path: on `main`, the agent's next revision lands there.
|
|
1057
|
+
*/
|
|
1058
|
+
sessionNavigate(id: string, entryId: string, summary?: string, branch?: string): Thread {
|
|
1059
|
+
const session = this.mutable(id);
|
|
1060
|
+
const moved = this.tree(id, () => {
|
|
1061
|
+
const history = this.historyOf(session);
|
|
1062
|
+
const standing = branch === undefined ? history : switchBranch(history, branch);
|
|
1063
|
+
|
|
1064
|
+
return navigateTo(standing, entryId, summary === undefined ? {} : { summary });
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
this.refreshView(session, moved);
|
|
1068
|
+
const tip = moved.tips[moved.branch];
|
|
1069
|
+
|
|
1070
|
+
this.emit("session.updated", id, tip === entryId ? undefined : tip);
|
|
1071
|
+
|
|
1072
|
+
return session;
|
|
185
1073
|
}
|
|
186
1074
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
1075
|
+
/** Start a branch at the current tip and switch to it; `main` stays where it is. */
|
|
1076
|
+
sessionBranch(id: string, name: string): Thread {
|
|
1077
|
+
const session = this.mutable(id);
|
|
1078
|
+
|
|
1079
|
+
this.refreshView(
|
|
1080
|
+
session,
|
|
1081
|
+
this.tree(id, () => createBranch(this.historyOf(session), name)),
|
|
1082
|
+
);
|
|
1083
|
+
this.emit("session.updated", id);
|
|
1084
|
+
|
|
1085
|
+
return session;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
sessionSwitch(id: string, branch: string): Thread {
|
|
1089
|
+
const session = this.mutable(id);
|
|
1090
|
+
|
|
1091
|
+
this.refreshView(
|
|
1092
|
+
session,
|
|
1093
|
+
this.tree(id, () => switchBranch(this.historyOf(session), branch)),
|
|
1094
|
+
);
|
|
1095
|
+
this.emit("session.updated", id);
|
|
1096
|
+
|
|
1097
|
+
return session;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/** Name the current tip as a checkpoint to navigate back to. */
|
|
1101
|
+
sessionLabel(id: string, label: string): Thread {
|
|
1102
|
+
const session = this.mutable(id);
|
|
1103
|
+
|
|
1104
|
+
session.history = labelTip(this.historyOf(session), label);
|
|
1105
|
+
this.store.upsert(session);
|
|
1106
|
+
this.emit("session.updated", id);
|
|
1107
|
+
|
|
1108
|
+
return session;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/**
|
|
1112
|
+
* Copy the current path into a new pending session: its revisions, open
|
|
1113
|
+
* comments, labels, and participant names travel; messages, edits, and the
|
|
1114
|
+
* share do not. A resolved session can be forked.
|
|
1115
|
+
*/
|
|
1116
|
+
sessionFork(id: string): Thread {
|
|
1117
|
+
const source = this.sessionGet(id);
|
|
1118
|
+
const history = this.tree(id, () => forkHistory(this.historyOf(source)));
|
|
1119
|
+
const now = new Date().toISOString();
|
|
1120
|
+
const known = [...source.annotations, ...(source.shelvedAnnotations ?? [])];
|
|
1121
|
+
const view = viewOfPath(history, known);
|
|
1122
|
+
const fork: Thread = {
|
|
1123
|
+
schemaVersion: SCHEMA_VERSION,
|
|
1124
|
+
id: this.newSessionId(now),
|
|
1125
|
+
workspace: source.workspace,
|
|
1126
|
+
artifact: { ...source.artifact, content: view.content },
|
|
1127
|
+
revisions: history.entries
|
|
1128
|
+
.filter((entry) => entry.type === "revision")
|
|
1129
|
+
.map((entry, index) => ({
|
|
1130
|
+
revision: index + 1,
|
|
1131
|
+
content: entry.content,
|
|
1132
|
+
submittedAt: entry.createdAt,
|
|
1133
|
+
})),
|
|
1134
|
+
annotations: [],
|
|
1135
|
+
history,
|
|
1136
|
+
message: null,
|
|
1137
|
+
status: "pending",
|
|
1138
|
+
createdAt: now,
|
|
1139
|
+
parentSessionId: id,
|
|
1140
|
+
};
|
|
1141
|
+
|
|
1142
|
+
fork.annotations = view.annotations.map((annotation) =>
|
|
1143
|
+
forkedAnnotation(annotation, source, fork),
|
|
1144
|
+
);
|
|
1145
|
+
|
|
1146
|
+
if (source.participants)
|
|
1147
|
+
fork.participants = source.participants.map((identity) => ({ ...identity }));
|
|
1148
|
+
|
|
1149
|
+
this.store.upsert(fork);
|
|
1150
|
+
this.trackLiveDiffSession(fork);
|
|
1151
|
+
this.emit("session.created", fork.id);
|
|
1152
|
+
this.emit("inbox.changed", fork.id);
|
|
1153
|
+
|
|
1154
|
+
return fork;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/** Re-capture a diff session's working tree; broadcasts session.updated only when the patch moved. */
|
|
1158
|
+
async sessionRefreshDiff(id: string): Promise<{ changed: boolean }> {
|
|
1159
|
+
const session = this.mutable(id);
|
|
1160
|
+
|
|
1161
|
+
if (session.artifact.type !== "diff") return { changed: false };
|
|
1162
|
+
|
|
1163
|
+
// a PR review has no local working tree; re-pull it from the PR instead of clobbering it
|
|
1164
|
+
if (session.artifact.meta.pr !== undefined) return this.sessionRefreshPrDiff(id);
|
|
1165
|
+
|
|
1166
|
+
const generation = (this.diffRefreshGenerations.get(id) ?? 0) + 1;
|
|
1167
|
+
|
|
1168
|
+
this.diffRefreshGenerations.set(id, generation);
|
|
1169
|
+
const pinnedChange =
|
|
1170
|
+
session.artifact.meta.workbench !== true ? session.artifact.meta.vcsChangeId : undefined;
|
|
1171
|
+
const diff = pinnedChange
|
|
1172
|
+
? await this.vcsSources.captureChange(
|
|
1173
|
+
session.workspace.repoRoot,
|
|
1174
|
+
session.artifact.meta.vcs ?? "git",
|
|
1175
|
+
pinnedChange,
|
|
1176
|
+
)
|
|
1177
|
+
: await this.vcsSources.capture(
|
|
1178
|
+
session.workspace.repoRoot,
|
|
1179
|
+
session.artifact.meta.vcs ?? "git",
|
|
1180
|
+
);
|
|
1181
|
+
|
|
1182
|
+
// The capture yields the event loop: a newer refresh may have started, or a
|
|
1183
|
+
// concurrent resolve/delete may have closed the session. Discard a stale
|
|
1184
|
+
// capture, and re-read so a resolved review is never mutated and a deleted
|
|
1185
|
+
// one is never revived by this upsert.
|
|
1186
|
+
if (this.diffRefreshGenerations.get(id) !== generation) return { changed: false };
|
|
1187
|
+
|
|
1188
|
+
const current = this.store.get(id);
|
|
1189
|
+
|
|
1190
|
+
if (!current || current.status !== "pending" || current.artifact.type !== "diff")
|
|
1191
|
+
return { changed: false };
|
|
1192
|
+
|
|
1193
|
+
const contentChanged = diff.patch !== current.artifact.content;
|
|
1194
|
+
const sourceChanged = diff.source?.revisionId !== current.artifact.meta.vcsRevisionId;
|
|
1195
|
+
|
|
1196
|
+
if (!contentChanged && !sourceChanged) return { changed: false };
|
|
1197
|
+
|
|
1198
|
+
current.artifact = {
|
|
1199
|
+
...current.artifact,
|
|
1200
|
+
content: diff.patch,
|
|
1201
|
+
files: diff.files,
|
|
1202
|
+
meta: {
|
|
1203
|
+
...current.artifact.meta,
|
|
1204
|
+
vcs: diff.vcs,
|
|
1205
|
+
vcsChangeId: diff.source?.changeId,
|
|
1206
|
+
vcsRevisionId: diff.source?.revisionId,
|
|
1207
|
+
},
|
|
1208
|
+
};
|
|
1209
|
+
this.recordCapturedDiffRevision(
|
|
1210
|
+
current,
|
|
1211
|
+
diff,
|
|
1212
|
+
Boolean(pinnedChange),
|
|
1213
|
+
contentChanged || sourceChanged,
|
|
1214
|
+
);
|
|
1215
|
+
this.store.upsert(current);
|
|
1216
|
+
this.emit("session.updated", id);
|
|
1217
|
+
|
|
1218
|
+
return { changed: contentChanged };
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
/** Live captures replace their revision; a submitted JJ change gets a new review revision. */
|
|
1222
|
+
private recordCapturedDiffRevision(
|
|
1223
|
+
session: Thread,
|
|
1224
|
+
diff: VcsDiffSnapshot & { vcs: string },
|
|
1225
|
+
pinned: boolean,
|
|
1226
|
+
changed: boolean,
|
|
1227
|
+
): void {
|
|
1228
|
+
if (pinned && changed) {
|
|
1229
|
+
const now = new Date().toISOString();
|
|
1230
|
+
|
|
1231
|
+
session.revisions.push({
|
|
1232
|
+
revision: session.revisions.length + 1,
|
|
1233
|
+
content: diff.patch,
|
|
1234
|
+
submittedAt: now,
|
|
1235
|
+
files: diff.files,
|
|
1236
|
+
source: {
|
|
1237
|
+
vcs: diff.vcs,
|
|
1238
|
+
changeId: diff.source?.changeId,
|
|
1239
|
+
revisionId: diff.source?.revisionId,
|
|
1240
|
+
},
|
|
1241
|
+
});
|
|
1242
|
+
this.recordOnMain(session, {
|
|
1243
|
+
type: "revision",
|
|
1244
|
+
by: "agent",
|
|
1245
|
+
content: diff.patch,
|
|
1246
|
+
createdAt: now,
|
|
1247
|
+
});
|
|
1248
|
+
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
if (changed) {
|
|
1253
|
+
const history = withHistory(session).history;
|
|
1254
|
+
|
|
1255
|
+
if (history) session.history = recaptureMainHead(history, diff.patch);
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
if (pinned) return;
|
|
1259
|
+
|
|
1260
|
+
const liveRevision = session.revisions.at(-1);
|
|
1261
|
+
|
|
1262
|
+
if (liveRevision) {
|
|
1263
|
+
liveRevision.content = diff.patch;
|
|
1264
|
+
liveRevision.files = diff.files;
|
|
1265
|
+
liveRevision.source = diff.source
|
|
1266
|
+
? { vcs: diff.vcs, changeId: diff.source.changeId, revisionId: diff.source.revisionId }
|
|
1267
|
+
: undefined;
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
/**
|
|
1272
|
+
* Re-pull a PR review's diff when its head moved; broadcasts session.updated
|
|
1273
|
+
* only when the patch text changed. A PR review has no local working tree, so
|
|
1274
|
+
* it never runs workingTreeDiff - the diff comes from gh.
|
|
1275
|
+
*/
|
|
1276
|
+
async sessionRefreshPrDiff(id: string): Promise<{ changed: boolean }> {
|
|
1277
|
+
const session = this.store.get(id);
|
|
1278
|
+
|
|
1279
|
+
if (!session || session.status !== "pending" || session.artifact.type !== "diff")
|
|
1280
|
+
return { changed: false };
|
|
1281
|
+
|
|
1282
|
+
const pr = session.artifact.meta.prUrl ?? session.artifact.meta.pr;
|
|
1283
|
+
|
|
1284
|
+
if (pr === undefined) return { changed: false };
|
|
1285
|
+
|
|
1286
|
+
const generation = (this.diffRefreshGenerations.get(id) ?? 0) + 1;
|
|
1287
|
+
|
|
1288
|
+
this.diffRefreshGenerations.set(id, generation);
|
|
1289
|
+
const snapshot = await prSnapshot(pr);
|
|
1290
|
+
|
|
1291
|
+
// gh failed (offline, unauthenticated): keep the diff we have, try again next poll
|
|
1292
|
+
if (snapshot === null) return { changed: false };
|
|
1293
|
+
|
|
1294
|
+
// the pull yields the event loop: discard a stale capture or a closed session
|
|
1295
|
+
if (this.diffRefreshGenerations.get(id) !== generation) return { changed: false };
|
|
1296
|
+
|
|
1297
|
+
const current = this.store.get(id);
|
|
1298
|
+
|
|
1299
|
+
if (!current || current.status !== "pending" || current.artifact.type !== "diff")
|
|
1300
|
+
return { changed: false };
|
|
1301
|
+
|
|
1302
|
+
const contentChanged = snapshot.patch !== current.artifact.content;
|
|
1303
|
+
const sourceChanged =
|
|
1304
|
+
snapshot.baseSha !== current.artifact.meta.prBaseSha ||
|
|
1305
|
+
snapshot.headSha !== current.artifact.meta.prHeadSha;
|
|
1306
|
+
|
|
1307
|
+
if (
|
|
1308
|
+
!contentChanged &&
|
|
1309
|
+
snapshot.baseSha === current.artifact.meta.prBaseSha &&
|
|
1310
|
+
snapshot.headSha === current.artifact.meta.prHeadSha &&
|
|
1311
|
+
current.artifact.meta.prRefreshBaseSha === undefined &&
|
|
1312
|
+
current.artifact.meta.prRefreshHeadSha === undefined
|
|
1313
|
+
)
|
|
1314
|
+
return { changed: false };
|
|
1315
|
+
|
|
1316
|
+
current.artifact = {
|
|
1317
|
+
...current.artifact,
|
|
1318
|
+
content: snapshot.patch,
|
|
1319
|
+
meta: {
|
|
1320
|
+
...current.artifact.meta,
|
|
1321
|
+
prBaseSha: snapshot.baseSha,
|
|
1322
|
+
prHeadSha: snapshot.headSha,
|
|
1323
|
+
prRefreshBaseSha: undefined,
|
|
1324
|
+
prRefreshHeadSha: undefined,
|
|
1325
|
+
},
|
|
1326
|
+
};
|
|
1327
|
+
|
|
1328
|
+
if (contentChanged || sourceChanged) {
|
|
1329
|
+
const now = new Date().toISOString();
|
|
1330
|
+
|
|
1331
|
+
current.revisions.push({
|
|
1332
|
+
revision: current.revisions.length + 1,
|
|
1333
|
+
content: snapshot.patch,
|
|
1334
|
+
submittedAt: now,
|
|
1335
|
+
source: {
|
|
1336
|
+
vcs: "git",
|
|
1337
|
+
baseRevisionId: snapshot.baseSha,
|
|
1338
|
+
revisionId: snapshot.headSha,
|
|
1339
|
+
},
|
|
1340
|
+
});
|
|
1341
|
+
this.recordOnMain(current, {
|
|
1342
|
+
type: "revision",
|
|
1343
|
+
by: "agent",
|
|
1344
|
+
content: snapshot.patch,
|
|
1345
|
+
createdAt: now,
|
|
1346
|
+
});
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
this.store.upsert(current);
|
|
1350
|
+
this.emit("session.updated", id);
|
|
1351
|
+
|
|
1352
|
+
return { changed: contentChanged };
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
/** Record moved PR refs without replacing the review until the reviewer refreshes it. */
|
|
1356
|
+
private markPrRefreshAvailable(id: string, refs: { baseSha: string; headSha: string }): void {
|
|
1357
|
+
const session = this.store.get(id);
|
|
1358
|
+
|
|
1359
|
+
if (!session || session.status !== "pending" || session.artifact.meta.pr === undefined) return;
|
|
1360
|
+
|
|
1361
|
+
if (
|
|
1362
|
+
session.artifact.meta.prBaseSha === refs.baseSha &&
|
|
1363
|
+
session.artifact.meta.prHeadSha === refs.headSha
|
|
1364
|
+
)
|
|
1365
|
+
return;
|
|
1366
|
+
|
|
1367
|
+
session.artifact = {
|
|
1368
|
+
...session.artifact,
|
|
1369
|
+
meta: {
|
|
1370
|
+
...session.artifact.meta,
|
|
1371
|
+
prRefreshBaseSha: refs.baseSha,
|
|
1372
|
+
prRefreshHeadSha: refs.headSha,
|
|
1373
|
+
},
|
|
1374
|
+
};
|
|
1375
|
+
this.store.upsert(session);
|
|
1376
|
+
this.emit("session.updated", id);
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
/** Re-capture every working-tree diff session sharing a repo root (one debounced fs change). */
|
|
1380
|
+
private async refreshDiffsForRepo(repoRoot: string): Promise<void> {
|
|
1381
|
+
const live = this.store
|
|
1382
|
+
.list()
|
|
1383
|
+
.filter(
|
|
1384
|
+
(session) => isWorkingTreeDiffSession(session) && session.workspace.repoRoot === repoRoot,
|
|
1385
|
+
);
|
|
1386
|
+
|
|
1387
|
+
for (const session of live) {
|
|
1388
|
+
// a session resolved or deleted between the change and this tick just skips
|
|
1389
|
+
try {
|
|
1390
|
+
await this.sessionRefreshDiff(session.id);
|
|
1391
|
+
} catch {
|
|
1392
|
+
// no-op: the session is gone or already resolved
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
private trackLiveDiffSession(session: Thread): void {
|
|
1398
|
+
if (isWorkingTreeDiffSession(session))
|
|
1399
|
+
this.diffWatcher.trackDiffRepo(
|
|
1400
|
+
session.workspace.repoRoot,
|
|
1401
|
+
session.id,
|
|
1402
|
+
session.artifact.meta.vcs,
|
|
1403
|
+
);
|
|
1404
|
+
else if (isPrReviewSession(session))
|
|
1405
|
+
this.prPoller.trackPr(
|
|
1406
|
+
session.id,
|
|
1407
|
+
session.artifact.meta.prUrl ?? session.artifact.meta.pr!,
|
|
1408
|
+
session.artifact.meta.prBaseSha && session.artifact.meta.prHeadSha
|
|
1409
|
+
? {
|
|
1410
|
+
baseSha: session.artifact.meta.prBaseSha,
|
|
1411
|
+
headSha: session.artifact.meta.prHeadSha,
|
|
1412
|
+
}
|
|
1413
|
+
: null,
|
|
1414
|
+
);
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
private untrackLiveDiffSession(session: Thread): void {
|
|
1418
|
+
if (session.artifact.type !== "diff") return;
|
|
1419
|
+
|
|
1420
|
+
this.diffWatcher.untrackDiffRepo(session.workspace.repoRoot, session.id);
|
|
1421
|
+
this.prPoller.untrackPr(session.id);
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
/**
|
|
1425
|
+
* Set or clear the working copy and, when the reviewer's text changed, record
|
|
1426
|
+
* it as a reviewer revision on the current branch. Returns that entry's id.
|
|
1427
|
+
*/
|
|
1428
|
+
private applyWorkingCopy(
|
|
1429
|
+
session: Thread,
|
|
1430
|
+
workingCopy: string | undefined,
|
|
1431
|
+
textCuts?: TextCut[],
|
|
1432
|
+
): string | undefined {
|
|
1433
|
+
const before = session.workingCopy ?? session.artifact.content;
|
|
1434
|
+
const next =
|
|
1435
|
+
workingCopy === undefined || workingCopy === session.artifact.content
|
|
1436
|
+
? undefined
|
|
1437
|
+
: workingCopy;
|
|
1438
|
+
|
|
1439
|
+
if (textCuts?.length) session.textCuts = textCuts;
|
|
1440
|
+
else delete session.textCuts;
|
|
1441
|
+
|
|
1442
|
+
if (next === undefined) delete session.workingCopy;
|
|
1443
|
+
else session.workingCopy = next;
|
|
1444
|
+
|
|
1445
|
+
const after = next ?? session.artifact.content;
|
|
1446
|
+
|
|
1447
|
+
if (after === before) return undefined;
|
|
1448
|
+
|
|
1449
|
+
const revision: Extract<NewEntry, { type: "revision" }> = {
|
|
1450
|
+
type: "revision",
|
|
1451
|
+
by: "reviewer",
|
|
1452
|
+
content: after,
|
|
1453
|
+
createdAt: new Date().toISOString(),
|
|
1454
|
+
};
|
|
1455
|
+
|
|
1456
|
+
if (textCuts?.length) revision.textCuts = textCuts;
|
|
1457
|
+
|
|
1458
|
+
return this.record(session, revision);
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
/** The session's history; a record without a revision has none and cannot be moved through. */
|
|
1462
|
+
private historyOf(session: Thread): SessionHistory {
|
|
1463
|
+
const history = withHistory(session).history;
|
|
1464
|
+
|
|
1465
|
+
if (!history) throw new DaemonError("invalid_params", `session ${session.id} has no history`);
|
|
1466
|
+
|
|
1467
|
+
return history;
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
/** Run a tree operation; a refused move is the caller's mistake, not the daemon's. */
|
|
1471
|
+
private tree(id: string, operation: () => SessionHistory): SessionHistory {
|
|
1472
|
+
try {
|
|
1473
|
+
return operation();
|
|
1474
|
+
} catch (error) {
|
|
1475
|
+
if (error instanceof HistoryError) throw new DaemonError("invalid_params", error.message);
|
|
1476
|
+
|
|
1477
|
+
throw error;
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
/** Store a moved history and make the record show its active path. */
|
|
1482
|
+
private refreshView(session: Thread, history: SessionHistory): void {
|
|
1483
|
+
session.history = history;
|
|
1484
|
+
applyPathView(
|
|
1485
|
+
session,
|
|
1486
|
+
viewOfPath(history, [...session.annotations, ...(session.shelvedAnnotations ?? [])]),
|
|
1487
|
+
);
|
|
1488
|
+
this.store.upsert(session);
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
/** Append an entry on the session's current branch; a session without a head has no history to extend. */
|
|
1492
|
+
private record(session: Thread, entry: NewEntry): string | undefined {
|
|
1493
|
+
const history = withHistory(session).history;
|
|
1494
|
+
|
|
1495
|
+
if (!history) return undefined;
|
|
1496
|
+
|
|
1497
|
+
const appended = appendEntry(history, entry);
|
|
1498
|
+
|
|
1499
|
+
session.history = appended.history;
|
|
1500
|
+
|
|
1501
|
+
return appended.entry.id;
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
/** Append an entry on main, leaving the reviewer's current branch where it is. */
|
|
1505
|
+
private recordOnMain(session: Thread, entry: NewEntry): string | undefined {
|
|
1506
|
+
return this.recordOn(session, MAIN_BRANCH, entry);
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
/** Append an entry on a named branch, leaving the reviewer's current branch where it is. */
|
|
1510
|
+
private recordOn(session: Thread, branch: string, entry: NewEntry): string | undefined {
|
|
1511
|
+
const history = withHistory(session).history;
|
|
1512
|
+
|
|
1513
|
+
if (!history) return undefined;
|
|
1514
|
+
|
|
1515
|
+
const appended = appendEntry(switchBranch(history, branch), entry);
|
|
1516
|
+
|
|
1517
|
+
session.history = { ...appended.history, branch: history.branch };
|
|
1518
|
+
|
|
1519
|
+
return appended.entry.id;
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
/** Resolve the selected share link's branch; a missing branch falls back to main. */
|
|
1523
|
+
private shareBranchOf(session: Thread, shareId?: string): string {
|
|
1524
|
+
const link = session.shares?.find((candidate) => candidate.id === shareId);
|
|
1525
|
+
|
|
1526
|
+
if (shareId !== undefined && !link)
|
|
1527
|
+
throw new DaemonError("not_found", `no share link ${shareId}`);
|
|
1528
|
+
|
|
1529
|
+
const branch = link?.shareBranch ?? MAIN_BRANCH;
|
|
1530
|
+
|
|
1531
|
+
return session.history?.tips[branch] === undefined ? MAIN_BRANCH : branch;
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
private mutable(id: string): Thread {
|
|
1535
|
+
const session = this.sessionGet(id);
|
|
1536
|
+
|
|
1537
|
+
if (session.status === "resolved")
|
|
1538
|
+
throw new DaemonError("resolved", `session ${id} is already resolved`);
|
|
1539
|
+
|
|
1540
|
+
return session;
|
|
191
1541
|
}
|
|
192
1542
|
}
|
|
193
1543
|
|
|
194
1544
|
export { DaemonError };
|
|
195
1545
|
|
|
196
|
-
/**
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
1546
|
+
/**
|
|
1547
|
+
* A comment as the fork carries it. An addressed mark names a revision by the
|
|
1548
|
+
* source's numbering; the fork numbers its own, so the mark follows the
|
|
1549
|
+
* revision's text, and a comment addressed by a revision off the fork's path
|
|
1550
|
+
* is open again.
|
|
1551
|
+
*/
|
|
1552
|
+
function forkedAnnotation(annotation: Annotation, source: Thread, fork: Thread): Annotation {
|
|
1553
|
+
const { resolution, ...open } = annotation;
|
|
1554
|
+
|
|
1555
|
+
if (resolution === undefined) return { ...annotation };
|
|
1556
|
+
|
|
1557
|
+
const addressedBy = source.revisions.find(
|
|
1558
|
+
(revision) => revision.revision === resolution.revision,
|
|
1559
|
+
);
|
|
1560
|
+
const inFork = fork.revisions.find((revision) => revision.content === addressedBy?.content);
|
|
1561
|
+
|
|
1562
|
+
return inFork === undefined
|
|
1563
|
+
? open
|
|
1564
|
+
: { ...annotation, resolution: { ...resolution, revision: inFork.revision } };
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
function applyRevisionSource(artifact: Artifact, source?: DiffSource): void {
|
|
1568
|
+
if (source) artifact.meta.vcs = source.vcs;
|
|
1569
|
+
|
|
1570
|
+
if (artifact.type !== "diff") return;
|
|
1571
|
+
|
|
1572
|
+
artifact.meta.vcsChangeId = source?.changeId;
|
|
1573
|
+
artifact.meta.vcsRevisionId = source?.revisionId;
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
/** A pending diff of the local working tree: hot-reloads by watching its repo. A PR review is excluded. */
|
|
1577
|
+
function isWorkingTreeDiffSession(session: Thread): boolean {
|
|
1578
|
+
return (
|
|
1579
|
+
session.status === "pending" &&
|
|
1580
|
+
session.artifact.type === "diff" &&
|
|
1581
|
+
session.artifact.meta.pr === undefined &&
|
|
1582
|
+
!(session.artifact.meta.vcsChangeId && session.artifact.meta.workbench !== true)
|
|
1583
|
+
);
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
/** A pending review of a remote PR: hot-reloads by polling the PR head, never by watching a working tree. */
|
|
1587
|
+
function isPrReviewSession(session: Thread): boolean {
|
|
1588
|
+
return (
|
|
1589
|
+
session.status === "pending" &&
|
|
1590
|
+
session.artifact.type === "diff" &&
|
|
1591
|
+
session.artifact.meta.pr !== undefined
|
|
1592
|
+
);
|
|
200
1593
|
}
|