@cueloop/daemon 0.1.0-alpha.78 → 0.1.0-alpha.79
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 +3 -3
- package/src/api.ts +95 -16
- package/src/capabilities.ts +1 -0
- package/src/client.ts +6 -0
- package/src/diff-hot-reload.test.ts +87 -1
- package/src/diff-watcher.ts +196 -24
- package/src/gh.ts +37 -0
- package/src/pr-poller.test.ts +76 -0
- package/src/pr-poller.ts +74 -0
- package/src/server.ts +5 -0
- package/src/store.ts +6 -3
- package/src/validate.test.ts +10 -0
- package/src/validate.ts +12 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/daemon",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.79",
|
|
4
4
|
"description": "The cueloop session daemon and its client: socket protocol, atomic store, resumable waits",
|
|
5
5
|
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -35,8 +35,8 @@
|
|
|
35
35
|
"access": "public"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
39
|
-
"@pierre/diffs": "^1.
|
|
38
|
+
"@cueloop/schema": "0.1.0-alpha.79",
|
|
39
|
+
"@pierre/diffs": "^1.4.1",
|
|
40
40
|
"effect": "^4.0.0-rc.115",
|
|
41
41
|
"valibot": "^1.4.2"
|
|
42
42
|
},
|
package/src/api.ts
CHANGED
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
type HunkRejection,
|
|
37
37
|
type NewEntry,
|
|
38
38
|
type Artifact,
|
|
39
|
+
type ShareLink,
|
|
39
40
|
type Identity,
|
|
40
41
|
type Thread,
|
|
41
42
|
type SessionHistory,
|
|
@@ -48,6 +49,8 @@ import { ThreadStore, withHistory } from "./store";
|
|
|
48
49
|
import { pruneExpiredSessions, resolveCleanupPeriodDays } from "./retention";
|
|
49
50
|
import { HerdrTabStore, type HerdrTabHandle } from "./herdr-tab-store";
|
|
50
51
|
import { DiffWatcher } from "./diff-watcher";
|
|
52
|
+
import { PrReviewPoller } from "./pr-poller";
|
|
53
|
+
import { prDiff } from "./gh";
|
|
51
54
|
import { workingTreeDiff, workingChangeList, type WorkingTreeDiff } from "./working-tree";
|
|
52
55
|
import { listProjectFiles, readProjectFile } from "./project-files";
|
|
53
56
|
import { resolveWorkspace } from "./review";
|
|
@@ -85,6 +88,8 @@ export class DaemonCore {
|
|
|
85
88
|
private seq = 0;
|
|
86
89
|
/** Drives diff hot-reload: watches each live diff session's repo for working-tree changes. */
|
|
87
90
|
private readonly diffWatcher: DiffWatcher;
|
|
91
|
+
/** Drives PR-review hot-reload: polls each open PR review for a moved head. */
|
|
92
|
+
private readonly prPoller: PrReviewPoller;
|
|
88
93
|
/**
|
|
89
94
|
* Per-diff-session capture generation. Bumped when a refresh begins; a
|
|
90
95
|
* capture whose generation is stale by the time it finishes discards its
|
|
@@ -100,13 +105,15 @@ export class DaemonCore {
|
|
|
100
105
|
pruneExpiredSessions(this.store, resolveCleanupPeriodDays(), Date.now());
|
|
101
106
|
this.herdrTabs = new HerdrTabStore(home);
|
|
102
107
|
this.diffWatcher = new DiffWatcher((repoRoot) => void this.refreshDiffsForRepo(repoRoot));
|
|
108
|
+
this.prPoller = new PrReviewPoller((sessionId) => void this.sessionRefreshPrDiff(sessionId));
|
|
103
109
|
// resume hot-reload for diff sessions that survived a daemon restart
|
|
104
|
-
for (const session of this.store.list()) this.
|
|
110
|
+
for (const session of this.store.list()) this.trackLiveDiffSession(session);
|
|
105
111
|
}
|
|
106
112
|
|
|
107
|
-
/** Release the
|
|
113
|
+
/** Release the watchers and pollers behind diff hot-reload; call on daemon shutdown. */
|
|
108
114
|
dispose(): void {
|
|
109
115
|
this.diffWatcher.close();
|
|
116
|
+
this.prPoller.close();
|
|
110
117
|
}
|
|
111
118
|
|
|
112
119
|
/** The herdr tab opened for a review, if any (adapter scratch, not on the session). */
|
|
@@ -152,7 +159,7 @@ export class DaemonCore {
|
|
|
152
159
|
|
|
153
160
|
session.history = historyFromLinear(session);
|
|
154
161
|
this.store.upsert(session);
|
|
155
|
-
this.
|
|
162
|
+
this.trackLiveDiffSession(session);
|
|
156
163
|
this.emit("session.created", session.id);
|
|
157
164
|
this.emit("inbox.changed", session.id);
|
|
158
165
|
|
|
@@ -432,6 +439,21 @@ export class DaemonCore {
|
|
|
432
439
|
return session;
|
|
433
440
|
}
|
|
434
441
|
|
|
442
|
+
/** Replace the thread's share links; the legacy single-share fields are dropped once shares[] is authoritative. */
|
|
443
|
+
sessionSetShares(id: string, shares: ShareLink[]): Thread {
|
|
444
|
+
const session = this.mutable(id);
|
|
445
|
+
|
|
446
|
+
session.shares = shares;
|
|
447
|
+
delete session.shareId;
|
|
448
|
+
delete session.access;
|
|
449
|
+
delete session.owner;
|
|
450
|
+
delete session.shareBranch;
|
|
451
|
+
this.store.upsert(session);
|
|
452
|
+
this.emit("session.updated", id);
|
|
453
|
+
|
|
454
|
+
return session;
|
|
455
|
+
}
|
|
456
|
+
|
|
435
457
|
/** Set the private-share allowlist of GitHub logins; presence marks the share private. */
|
|
436
458
|
sessionSetAccess(id: string, githubLogins: string[]): Thread {
|
|
437
459
|
const session = this.mutable(id);
|
|
@@ -448,7 +470,7 @@ export class DaemonCore {
|
|
|
448
470
|
const session = this.store.get(id);
|
|
449
471
|
|
|
450
472
|
if (!this.store.delete(id)) throw new DaemonError("not_found", `no session ${id}`);
|
|
451
|
-
if (session) this.
|
|
473
|
+
if (session) this.untrackLiveDiffSession(session);
|
|
452
474
|
this.diffRefreshGenerations.delete(id);
|
|
453
475
|
this.herdrTabs.delete(id);
|
|
454
476
|
this.emit("inbox.changed", id);
|
|
@@ -612,7 +634,7 @@ export class DaemonCore {
|
|
|
612
634
|
});
|
|
613
635
|
this.store.upsert(session);
|
|
614
636
|
// a resolved diff review is frozen; stop hot-reloading its working tree
|
|
615
|
-
this.
|
|
637
|
+
this.untrackLiveDiffSession(session);
|
|
616
638
|
const parked = this.waiters.get(id) ?? [];
|
|
617
639
|
|
|
618
640
|
this.waiters.delete(id);
|
|
@@ -789,7 +811,7 @@ export class DaemonCore {
|
|
|
789
811
|
if (source.participants)
|
|
790
812
|
fork.participants = source.participants.map((identity) => ({ ...identity }));
|
|
791
813
|
this.store.upsert(fork);
|
|
792
|
-
this.
|
|
814
|
+
this.trackLiveDiffSession(fork);
|
|
793
815
|
this.emit("session.created", fork.id);
|
|
794
816
|
this.emit("inbox.changed", fork.id);
|
|
795
817
|
|
|
@@ -801,6 +823,8 @@ export class DaemonCore {
|
|
|
801
823
|
const session = this.mutable(id);
|
|
802
824
|
|
|
803
825
|
if (session.artifact.type !== "diff") return { changed: false };
|
|
826
|
+
// a PR review has no local working tree; re-pull it from the PR instead of clobbering it
|
|
827
|
+
if (session.artifact.meta.pr !== undefined) return this.sessionRefreshPrDiff(id);
|
|
804
828
|
const generation = (this.diffRefreshGenerations.get(id) ?? 0) + 1;
|
|
805
829
|
|
|
806
830
|
this.diffRefreshGenerations.set(id, generation);
|
|
@@ -827,11 +851,50 @@ export class DaemonCore {
|
|
|
827
851
|
return { changed: true };
|
|
828
852
|
}
|
|
829
853
|
|
|
830
|
-
/**
|
|
854
|
+
/**
|
|
855
|
+
* Re-pull a PR review's diff when its head moved; broadcasts session.updated
|
|
856
|
+
* only when the patch text changed. A PR review has no local working tree, so
|
|
857
|
+
* it never runs workingTreeDiff - the diff comes from gh.
|
|
858
|
+
*/
|
|
859
|
+
async sessionRefreshPrDiff(id: string): Promise<{ changed: boolean }> {
|
|
860
|
+
const session = this.store.get(id);
|
|
861
|
+
|
|
862
|
+
if (!session || session.status !== "pending" || session.artifact.type !== "diff")
|
|
863
|
+
return { changed: false };
|
|
864
|
+
const pr = session.artifact.meta.pr;
|
|
865
|
+
|
|
866
|
+
if (pr === undefined) return { changed: false };
|
|
867
|
+
const generation = (this.diffRefreshGenerations.get(id) ?? 0) + 1;
|
|
868
|
+
|
|
869
|
+
this.diffRefreshGenerations.set(id, generation);
|
|
870
|
+
const patch = await prDiff(pr);
|
|
871
|
+
|
|
872
|
+
// gh failed (offline, unauthenticated): keep the diff we have, try again next poll
|
|
873
|
+
if (patch === null) return { changed: false };
|
|
874
|
+
// the pull yields the event loop: discard a stale capture or a closed session
|
|
875
|
+
if (this.diffRefreshGenerations.get(id) !== generation) return { changed: false };
|
|
876
|
+
const current = this.store.get(id);
|
|
877
|
+
|
|
878
|
+
if (!current || current.status !== "pending" || current.artifact.type !== "diff")
|
|
879
|
+
return { changed: false };
|
|
880
|
+
if (patch === current.artifact.content) return { changed: false };
|
|
881
|
+
current.artifact = { ...current.artifact, content: patch };
|
|
882
|
+
const history = withHistory(current).history;
|
|
883
|
+
|
|
884
|
+
if (history) current.history = recaptureMainHead(history, patch);
|
|
885
|
+
this.store.upsert(current);
|
|
886
|
+
this.emit("session.updated", id);
|
|
887
|
+
|
|
888
|
+
return { changed: true };
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/** Re-capture every working-tree diff session sharing a repo root (one debounced fs change). */
|
|
831
892
|
private async refreshDiffsForRepo(repoRoot: string): Promise<void> {
|
|
832
893
|
const live = this.store
|
|
833
894
|
.list()
|
|
834
|
-
.filter(
|
|
895
|
+
.filter(
|
|
896
|
+
(session) => isWorkingTreeDiffSession(session) && session.workspace.repoRoot === repoRoot,
|
|
897
|
+
);
|
|
835
898
|
|
|
836
899
|
for (const session of live) {
|
|
837
900
|
// a session resolved or deleted between the change and this tick just skips
|
|
@@ -843,14 +906,17 @@ export class DaemonCore {
|
|
|
843
906
|
}
|
|
844
907
|
}
|
|
845
908
|
|
|
846
|
-
private
|
|
847
|
-
if (
|
|
909
|
+
private trackLiveDiffSession(session: Thread): void {
|
|
910
|
+
if (isWorkingTreeDiffSession(session))
|
|
848
911
|
this.diffWatcher.trackDiffRepo(session.workspace.repoRoot, session.id);
|
|
912
|
+
else if (isPrReviewSession(session))
|
|
913
|
+
this.prPoller.trackPr(session.id, session.artifact.meta.pr!);
|
|
849
914
|
}
|
|
850
915
|
|
|
851
|
-
private
|
|
852
|
-
if (session.artifact.type
|
|
853
|
-
|
|
916
|
+
private untrackLiveDiffSession(session: Thread): void {
|
|
917
|
+
if (session.artifact.type !== "diff") return;
|
|
918
|
+
this.diffWatcher.untrackDiffRepo(session.workspace.repoRoot, session.id);
|
|
919
|
+
this.prPoller.untrackPr(session.id);
|
|
854
920
|
}
|
|
855
921
|
|
|
856
922
|
/**
|
|
@@ -975,9 +1041,22 @@ function forkedAnnotation(annotation: Annotation, source: Thread, fork: Thread):
|
|
|
975
1041
|
: { ...annotation, resolution: { ...resolution, revision: inFork.revision } };
|
|
976
1042
|
}
|
|
977
1043
|
|
|
978
|
-
/** A pending diff
|
|
979
|
-
function
|
|
980
|
-
return
|
|
1044
|
+
/** A pending diff of the local working tree: hot-reloads by watching its repo. A PR review is excluded. */
|
|
1045
|
+
function isWorkingTreeDiffSession(session: Thread): boolean {
|
|
1046
|
+
return (
|
|
1047
|
+
session.status === "pending" &&
|
|
1048
|
+
session.artifact.type === "diff" &&
|
|
1049
|
+
session.artifact.meta.pr === undefined
|
|
1050
|
+
);
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
/** A pending review of a remote PR: hot-reloads by polling the PR head, never by watching a working tree. */
|
|
1054
|
+
function isPrReviewSession(session: Thread): boolean {
|
|
1055
|
+
return (
|
|
1056
|
+
session.status === "pending" &&
|
|
1057
|
+
session.artifact.type === "diff" &&
|
|
1058
|
+
session.artifact.meta.pr !== undefined
|
|
1059
|
+
);
|
|
981
1060
|
}
|
|
982
1061
|
|
|
983
1062
|
/** Convenience for adapters: map a resolved session to the agent contract. */
|
package/src/capabilities.ts
CHANGED
package/src/client.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
Artifact,
|
|
12
12
|
DiffFileStatus,
|
|
13
13
|
HunkRejection,
|
|
14
|
+
ShareLink,
|
|
14
15
|
Thread,
|
|
15
16
|
VerdictKind,
|
|
16
17
|
WorkspaceKey,
|
|
@@ -117,6 +118,7 @@ export interface SessionClient {
|
|
|
117
118
|
/** Copy the current path into a new session; returns the fork. */
|
|
118
119
|
sessionFork(id: string): Promise<Thread>;
|
|
119
120
|
sessionSetShareId(id: string, shareId: string): Promise<Thread>;
|
|
121
|
+
sessionSetShares(id: string, shares: ShareLink[]): Promise<Thread>;
|
|
120
122
|
sessionMergeShared(id: string, incoming: SharedMerge): Promise<Thread>;
|
|
121
123
|
sessionDelete(id: string): Promise<void>;
|
|
122
124
|
/** Record the caller's own identity name (collaborator self-naming on a share). */
|
|
@@ -497,6 +499,10 @@ export class DaemonClient implements SessionClient {
|
|
|
497
499
|
sessionSetShareId(id: string, shareId: string): Promise<Thread> {
|
|
498
500
|
return this.request("session.setShareId", { id, shareId }, ThreadRecordSchema);
|
|
499
501
|
}
|
|
502
|
+
|
|
503
|
+
sessionSetShares(id: string, shares: ShareLink[]): Promise<Thread> {
|
|
504
|
+
return this.request("session.setShares", { id, shares }, ThreadRecordSchema);
|
|
505
|
+
}
|
|
500
506
|
sessionMergeShared(id: string, incoming: SharedMerge): Promise<Thread> {
|
|
501
507
|
return this.request("session.mergeShared", { id, ...incoming }, ThreadRecordSchema);
|
|
502
508
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
8
|
-
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { tmpdir } from "node:os";
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
import { DaemonCore } from "./api";
|
|
@@ -164,6 +164,49 @@ describe("session.refreshDiff", () => {
|
|
|
164
164
|
});
|
|
165
165
|
});
|
|
166
166
|
|
|
167
|
+
describe("PR review sessions", () => {
|
|
168
|
+
test("refresh re-pulls the PR diff via gh and never clobbers it with the working tree", async () => {
|
|
169
|
+
// Given a stub gh that reports a PR diff distinct from the local working tree
|
|
170
|
+
const ghStub = join(repo, "gh-stub.sh");
|
|
171
|
+
|
|
172
|
+
writeFileSync(
|
|
173
|
+
ghStub,
|
|
174
|
+
'#!/bin/sh\ncase "$2" in\n diff) printf "STUB PR DIFF\\n" ;;\n view) printf "sha-2\\n" ;;\nesac\n',
|
|
175
|
+
);
|
|
176
|
+
chmodSync(ghStub, 0o755);
|
|
177
|
+
const previousGh = process.env.CUELOOP_GH;
|
|
178
|
+
|
|
179
|
+
process.env.CUELOOP_GH = ghStub;
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
// Given a PR review over the same repo root, and a local working-tree change present
|
|
183
|
+
const workspace = await resolveWorkspace(repo);
|
|
184
|
+
const artifact: Artifact = {
|
|
185
|
+
type: "diff",
|
|
186
|
+
content: "OLD PR DIFF\n",
|
|
187
|
+
files: [],
|
|
188
|
+
meta: { pr: "org/repo#1" },
|
|
189
|
+
};
|
|
190
|
+
const session = core.sessionCreate({ workspace, artifact });
|
|
191
|
+
|
|
192
|
+
writeFileSync(join(repo, "a.ts"), "export const a = 123;\n");
|
|
193
|
+
|
|
194
|
+
// When the diff is refreshed
|
|
195
|
+
const result = await core.sessionRefreshDiff(session.id);
|
|
196
|
+
|
|
197
|
+
// Then it carries the PR diff from gh, not the local working-tree change
|
|
198
|
+
expect(result.changed).toBe(true);
|
|
199
|
+
const refreshed = core.sessionGet(session.id);
|
|
200
|
+
|
|
201
|
+
expect(refreshed.artifact.content).toContain("STUB PR DIFF");
|
|
202
|
+
expect(refreshed.artifact.content).not.toContain("export const a = 123;");
|
|
203
|
+
} finally {
|
|
204
|
+
if (previousGh === undefined) delete process.env.CUELOOP_GH;
|
|
205
|
+
else process.env.CUELOOP_GH = previousGh;
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
167
210
|
describe("the fs watcher drives hot-reload", () => {
|
|
168
211
|
test("a working-tree change under a live diff session refreshes it in place", async () => {
|
|
169
212
|
// Given a live diff session whose repo the daemon is watching
|
|
@@ -183,4 +226,47 @@ describe("the fs watcher drives hot-reload", () => {
|
|
|
183
226
|
}
|
|
184
227
|
expect(content).toContain("+export const a = 42;");
|
|
185
228
|
}, 12_000);
|
|
229
|
+
|
|
230
|
+
test("watches a directory created after open, so a change inside it refreshes", async () => {
|
|
231
|
+
// Given an open diff session over a repo that had no such directory
|
|
232
|
+
const session = await openDiffSession();
|
|
233
|
+
|
|
234
|
+
// When a new directory appears, the watcher extends into it (a recursive watch got this free)
|
|
235
|
+
mkdirSync(join(repo, "pkg"));
|
|
236
|
+
await Bun.sleep(500);
|
|
237
|
+
writeFileSync(join(repo, "pkg", "inside.ts"), "export const inside = 1;\n");
|
|
238
|
+
|
|
239
|
+
// Then a change inside the new directory drives a re-capture
|
|
240
|
+
const deadline = Date.now() + 8_000;
|
|
241
|
+
let content = core.sessionGet(session.id).artifact.content;
|
|
242
|
+
|
|
243
|
+
while (!content.includes("+export const inside = 1;") && Date.now() < deadline) {
|
|
244
|
+
await Bun.sleep(100);
|
|
245
|
+
content = core.sessionGet(session.id).artifact.content;
|
|
246
|
+
}
|
|
247
|
+
expect(content).toContain("+export const inside = 1;");
|
|
248
|
+
}, 12_000);
|
|
249
|
+
|
|
250
|
+
test("a commit refreshes the diff even though no working-tree file changed", async () => {
|
|
251
|
+
// Given an open diff session showing an uncommitted change to a.ts
|
|
252
|
+
writeFileSync(join(repo, "a.ts"), "export const a = 5;\n");
|
|
253
|
+
const session = await openDiffSession();
|
|
254
|
+
|
|
255
|
+
expect(core.sessionGet(session.id).artifact.content).toContain("+export const a = 5;");
|
|
256
|
+
|
|
257
|
+
// When the change is committed - moving refs/HEAD, touching no working-tree file -
|
|
258
|
+
// the working tree now equals HEAD, so `git diff HEAD` is empty
|
|
259
|
+
git(["add", "a.ts"], repo);
|
|
260
|
+
git(["commit", "-qm", "land a"], repo);
|
|
261
|
+
|
|
262
|
+
// Then the git-metadata watch drives a re-capture and the diff empties out
|
|
263
|
+
const deadline = Date.now() + 8_000;
|
|
264
|
+
let content = core.sessionGet(session.id).artifact.content;
|
|
265
|
+
|
|
266
|
+
while (content.includes("+export const a = 5;") && Date.now() < deadline) {
|
|
267
|
+
await Bun.sleep(100);
|
|
268
|
+
content = core.sessionGet(session.id).artifact.content;
|
|
269
|
+
}
|
|
270
|
+
expect(content).not.toContain("+export const a = 5;");
|
|
271
|
+
}, 12_000);
|
|
186
272
|
});
|
package/src/diff-watcher.ts
CHANGED
|
@@ -2,32 +2,101 @@
|
|
|
2
2
|
* Watches a diff session's repository for working-tree changes and drives the
|
|
3
3
|
* hot-reload: on any change under a repo root that a live diff session tracks,
|
|
4
4
|
* the daemon re-captures that session's diff so the open review updates in place.
|
|
5
|
-
* One
|
|
6
|
-
*
|
|
5
|
+
* One watcher per non-ignored directory (not a recursive watch), so churn under
|
|
6
|
+
* `.git`, `node_modules`, or a .gitignored path never registers a watch at all -
|
|
7
|
+
* that keeps idle cost near zero on Linux, where a recursive watch would open an
|
|
8
|
+
* inotify descriptor per directory. A debounce collapses a burst into one
|
|
9
|
+
* re-capture. Many diff sessions can share a repo.
|
|
7
10
|
*/
|
|
8
11
|
|
|
9
|
-
import { watch, type FSWatcher } from "node:fs";
|
|
10
|
-
import {
|
|
12
|
+
import { watch, readdirSync, statSync, type Dirent, type FSWatcher } from "node:fs";
|
|
13
|
+
import { basename, isAbsolute, join } from "node:path";
|
|
11
14
|
|
|
12
15
|
/** Debounce window: a save or a checkout writes many files in a burst; collapse them into one re-capture. */
|
|
13
16
|
const DIFF_REFRESH_DEBOUNCE_MS = 300;
|
|
14
17
|
|
|
15
18
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* content.
|
|
19
|
+
* Directory names never worth a watch, whatever .gitignore says. `.git` is
|
|
20
|
+
* watched narrowly elsewhere (HEAD, refs, packed-refs) so its own read churn (the
|
|
21
|
+
* index, lock files) cannot loop; `node_modules` churns on installs and is never
|
|
22
|
+
* review content.
|
|
20
23
|
*/
|
|
21
|
-
|
|
22
|
-
const segments = relativePath.split(sep);
|
|
24
|
+
const ALWAYS_IGNORED_DIRS = new Set([".git", "node_modules"]);
|
|
23
25
|
|
|
24
|
-
|
|
26
|
+
/**
|
|
27
|
+
* Absolute paths of the directories git ignores under a repo root, resolved once
|
|
28
|
+
* at watch time from `git ls-files` so a .gitignored tree (dist, build output)
|
|
29
|
+
* never gets a watcher. Empty when git is unavailable - the name check still
|
|
30
|
+
* skips the churny defaults.
|
|
31
|
+
*/
|
|
32
|
+
function ignoredDirectories(repoRoot: string): Set<string> {
|
|
33
|
+
const ignored = new Set<string>();
|
|
34
|
+
const result = spawnGit(
|
|
35
|
+
["ls-files", "--others", "--ignored", "--exclude-standard", "--directory"],
|
|
36
|
+
repoRoot,
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
if (!result || result.exitCode !== 0) return ignored;
|
|
40
|
+
for (const line of result.stdout.toString().split("\n")) {
|
|
41
|
+
const relative = line.trim().replace(/\/$/, "");
|
|
42
|
+
|
|
43
|
+
if (relative.length > 0) ignored.add(join(repoRoot, relative));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return ignored;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Whether git ignores a single path; used for a directory created after the initial walk. */
|
|
50
|
+
function isGitIgnored(repoRoot: string, path: string): boolean {
|
|
51
|
+
return spawnGit(["check-ignore", "-q", path], repoRoot)?.exitCode === 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Run a git command in `cwd`, or null when git is missing or the cwd is gone (spawn throws ENOENT). */
|
|
55
|
+
function spawnGit(args: string[], cwd: string): { exitCode: number; stdout: Buffer } | null {
|
|
56
|
+
try {
|
|
57
|
+
const result = Bun.spawnSync(["git", ...args], { cwd, stdout: "pipe", stderr: "ignore" });
|
|
58
|
+
|
|
59
|
+
return { exitCode: result.exitCode ?? 1, stdout: result.stdout };
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The git-metadata filenames whose change means `git diff HEAD` moved: a checkout, a commit, or a reset. */
|
|
66
|
+
function isRefChange(filename: string): boolean {
|
|
67
|
+
return filename === "HEAD" || filename === "packed-refs";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A repo's git dir (HEAD and the index live here) and common dir (refs and
|
|
72
|
+
* packed-refs live here). They differ inside a linked worktree. Null when the
|
|
73
|
+
* root is not a git repo or git is unavailable.
|
|
74
|
+
*/
|
|
75
|
+
function resolveGitDirs(repoRoot: string): { gitDir: string; commonDir: string } | null {
|
|
76
|
+
const result = spawnGit(["rev-parse", "--absolute-git-dir", "--git-common-dir"], repoRoot);
|
|
77
|
+
|
|
78
|
+
if (!result || result.exitCode !== 0) return null;
|
|
79
|
+
const [gitDir, commonRaw] = result.stdout.toString().trim().split("\n");
|
|
80
|
+
|
|
81
|
+
if (!gitDir) return null;
|
|
82
|
+
const commonDir = commonRaw
|
|
83
|
+
? isAbsolute(commonRaw)
|
|
84
|
+
? commonRaw
|
|
85
|
+
: join(repoRoot, commonRaw)
|
|
86
|
+
: gitDir;
|
|
87
|
+
|
|
88
|
+
return { gitDir, commonDir };
|
|
25
89
|
}
|
|
26
90
|
|
|
27
91
|
interface RepoWatch {
|
|
28
|
-
|
|
92
|
+
/** One watcher per watched working-tree directory plus the narrow git-metadata watches; all close together. */
|
|
93
|
+
handles: FSWatcher[];
|
|
29
94
|
/** Live diff session ids sharing this repo root; the watch closes when the last one leaves. */
|
|
30
95
|
sessionIds: Set<string>;
|
|
96
|
+
/** Absolute paths git ignores under the root, so the walk skips them. */
|
|
97
|
+
ignored: Set<string>;
|
|
98
|
+
/** Directories already watched, so a runtime-created dir is not watched twice. */
|
|
99
|
+
watchedDirs: Set<string>;
|
|
31
100
|
debounce: ReturnType<typeof setTimeout> | null;
|
|
32
101
|
}
|
|
33
102
|
|
|
@@ -51,21 +120,124 @@ export class DiffWatcher {
|
|
|
51
120
|
|
|
52
121
|
return;
|
|
53
122
|
}
|
|
54
|
-
|
|
123
|
+
const repoWatch: RepoWatch = {
|
|
124
|
+
handles: [],
|
|
125
|
+
sessionIds: new Set([sessionId]),
|
|
126
|
+
ignored: ignoredDirectories(repoRoot),
|
|
127
|
+
watchedDirs: new Set(),
|
|
128
|
+
debounce: null,
|
|
129
|
+
};
|
|
55
130
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
131
|
+
this.repoWatches.set(repoRoot, repoWatch);
|
|
132
|
+
|
|
133
|
+
// one watcher per non-ignored working-tree directory; a tracked-file change re-captures
|
|
134
|
+
if (!this.watchTree(repoRoot, repoRoot)) {
|
|
135
|
+
// repo root gone or not watchable on this platform: skip, no hot-reload here
|
|
136
|
+
this.repoWatches.delete(repoRoot);
|
|
137
|
+
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// git metadata: a commit, checkout, or reset moves `git diff HEAD` without
|
|
142
|
+
// touching any working-tree file, so those never reach the tree watch above.
|
|
143
|
+
const gitDirs = resolveGitDirs(repoRoot);
|
|
144
|
+
|
|
145
|
+
if (gitDirs) {
|
|
146
|
+
// HEAD and packed-refs sit beside the index; the index rewrites on our own
|
|
147
|
+
// reads, so only HEAD/packed-refs here - watching the index would loop
|
|
148
|
+
this.watchInto(repoWatch.handles, gitDirs.gitDir, { recursive: false }, (filename) => {
|
|
149
|
+
if (isRefChange(filename)) this.scheduleRepoRefresh(repoRoot);
|
|
60
150
|
});
|
|
151
|
+
// a commit or reset updates refs/heads/<branch>; any ref move re-captures
|
|
152
|
+
this.watchInto(repoWatch.handles, join(gitDirs.commonDir, "refs"), { recursive: true }, () =>
|
|
153
|
+
this.scheduleRepoRefresh(repoRoot),
|
|
154
|
+
);
|
|
155
|
+
if (gitDirs.commonDir !== gitDirs.gitDir)
|
|
156
|
+
this.watchInto(repoWatch.handles, gitDirs.commonDir, { recursive: false }, (filename) => {
|
|
157
|
+
if (isRefChange(filename)) this.scheduleRepoRefresh(repoRoot);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Watch `dir` (non-recursive) and every non-ignored directory under it, adding
|
|
164
|
+
* each to the repo's handles. Returns whether `dir` itself got a watcher, so
|
|
165
|
+
* the caller can tell a dead root from a merely-skipped subtree. A skipped
|
|
166
|
+
* (ignored or already-watched) directory counts as watched, not a failure.
|
|
167
|
+
*/
|
|
168
|
+
private watchTree(repoRoot: string, dir: string): boolean {
|
|
169
|
+
const repoWatch = this.repoWatches.get(repoRoot);
|
|
170
|
+
|
|
171
|
+
if (!repoWatch) return false;
|
|
172
|
+
if (repoWatch.watchedDirs.has(dir)) return true;
|
|
173
|
+
if (ALWAYS_IGNORED_DIRS.has(basename(dir)) || repoWatch.ignored.has(dir)) return true;
|
|
174
|
+
|
|
175
|
+
const opened = this.watchInto(repoWatch.handles, dir, { recursive: false }, (filename) => {
|
|
176
|
+
this.scheduleRepoRefresh(repoRoot);
|
|
177
|
+
// a newly created subdirectory needs its own watch (a recursive watch got this for free)
|
|
178
|
+
this.extendWatch(repoRoot, join(dir, filename));
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
if (!opened) return false;
|
|
182
|
+
repoWatch.watchedDirs.add(dir);
|
|
183
|
+
|
|
184
|
+
let entries: Dirent[];
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
61
188
|
} catch {
|
|
62
|
-
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
for (const entry of entries) {
|
|
192
|
+
// a symlinked directory is skipped so the walk cannot loop
|
|
193
|
+
if (entry.isDirectory() && !entry.isSymbolicLink())
|
|
194
|
+
this.watchTree(repoRoot, join(dir, entry.name));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* After a change names a new path, watch it (and its subtree) when it is a
|
|
202
|
+
* directory git does not ignore. The point-in-time ignored set cannot know a
|
|
203
|
+
* directory created after the walk, so a fresh dir is checked against git live.
|
|
204
|
+
*/
|
|
205
|
+
private extendWatch(repoRoot: string, path: string): void {
|
|
206
|
+
try {
|
|
207
|
+
if (!statSync(path).isDirectory()) return;
|
|
208
|
+
} catch {
|
|
209
|
+
// the path is already gone (a transient temp dir); nothing to watch
|
|
63
210
|
return;
|
|
64
211
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
212
|
+
if (ALWAYS_IGNORED_DIRS.has(basename(path))) return;
|
|
213
|
+
if (isGitIgnored(repoRoot, path)) return;
|
|
214
|
+
this.watchTree(repoRoot, path);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Open one watcher and add it to `handles`; returns whether it opened. A
|
|
219
|
+
* watcher error (the path is deleted mid-review) is swallowed - hot-reload is
|
|
220
|
+
* best-effort and must never take down the daemon. The inherited EventEmitter
|
|
221
|
+
* `on` is absent from Bun's FSWatcher type.
|
|
222
|
+
*/
|
|
223
|
+
private watchInto(
|
|
224
|
+
handles: FSWatcher[],
|
|
225
|
+
path: string,
|
|
226
|
+
options: { recursive: boolean },
|
|
227
|
+
onFile: (filename: string) => void,
|
|
228
|
+
): boolean {
|
|
229
|
+
try {
|
|
230
|
+
const handle = watch(path, options, (_event, filename) => {
|
|
231
|
+
if (filename !== null) onFile(filename.toString());
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
handle.on("error", () => {});
|
|
235
|
+
handles.push(handle);
|
|
236
|
+
|
|
237
|
+
return true;
|
|
238
|
+
} catch {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
69
241
|
}
|
|
70
242
|
|
|
71
243
|
/** Drop one diff session; close the repo watch once no diff session tracks it. */
|
|
@@ -76,7 +248,7 @@ export class DiffWatcher {
|
|
|
76
248
|
repoWatch.sessionIds.delete(sessionId);
|
|
77
249
|
if (repoWatch.sessionIds.size > 0) return;
|
|
78
250
|
if (repoWatch.debounce !== null) clearTimeout(repoWatch.debounce);
|
|
79
|
-
repoWatch.handle.close();
|
|
251
|
+
for (const handle of repoWatch.handles) handle.close();
|
|
80
252
|
this.repoWatches.delete(repoRoot);
|
|
81
253
|
}
|
|
82
254
|
|
|
@@ -95,7 +267,7 @@ export class DiffWatcher {
|
|
|
95
267
|
close(): void {
|
|
96
268
|
for (const repoWatch of this.repoWatches.values()) {
|
|
97
269
|
if (repoWatch.debounce !== null) clearTimeout(repoWatch.debounce);
|
|
98
|
-
repoWatch.handle.close();
|
|
270
|
+
for (const handle of repoWatch.handles) handle.close();
|
|
99
271
|
}
|
|
100
272
|
this.repoWatches.clear();
|
|
101
273
|
}
|
package/src/gh.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// gh CLI reads for PR reviews; auth is delegated to the user's gh. Reads only.
|
|
2
|
+
|
|
3
|
+
const ghBinary = (): string => process.env.CUELOOP_GH || "gh";
|
|
4
|
+
|
|
5
|
+
async function gh(args: string[]): Promise<{ code: number; stdout: string }> {
|
|
6
|
+
const process = Bun.spawn([ghBinary(), ...args], { stdout: "pipe", stderr: "ignore" });
|
|
7
|
+
const stdout = await new Response(process.stdout).text();
|
|
8
|
+
|
|
9
|
+
return { code: await process.exited, stdout };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The PR head commit sha, or null when gh is absent, unauthenticated, or the call fails. */
|
|
13
|
+
export async function prHeadSha(pr: string): Promise<string | null> {
|
|
14
|
+
const { code, stdout } = await gh([
|
|
15
|
+
"pr",
|
|
16
|
+
"view",
|
|
17
|
+
pr,
|
|
18
|
+
"--json",
|
|
19
|
+
"headRefOid",
|
|
20
|
+
"-q",
|
|
21
|
+
".headRefOid",
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
if (code !== 0) return null;
|
|
25
|
+
const sha = stdout.trim();
|
|
26
|
+
|
|
27
|
+
return sha.length > 0 ? sha : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The PR unified diff as gh prints it, or null when the call fails. */
|
|
31
|
+
export async function prDiff(pr: string): Promise<string | null> {
|
|
32
|
+
const { code, stdout } = await gh(["pr", "diff", pr]);
|
|
33
|
+
|
|
34
|
+
if (code !== 0) return null;
|
|
35
|
+
|
|
36
|
+
return stdout;
|
|
37
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { PrReviewPoller } from "./pr-poller";
|
|
3
|
+
|
|
4
|
+
/** Build a poller whose head sha is controllable, with the timer effectively disabled. */
|
|
5
|
+
function makePoller(initialSha: string | null) {
|
|
6
|
+
let sha = initialSha;
|
|
7
|
+
const advanced: string[] = [];
|
|
8
|
+
const poller = new PrReviewPoller(
|
|
9
|
+
(sessionId) => advanced.push(sessionId),
|
|
10
|
+
async () => sha,
|
|
11
|
+
1_000_000,
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
poller,
|
|
16
|
+
advanced,
|
|
17
|
+
setSha: (next: string | null) => {
|
|
18
|
+
sha = next;
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("PrReviewPoller", () => {
|
|
24
|
+
test("the first observation is a silent baseline", async () => {
|
|
25
|
+
const { poller, advanced } = makePoller("sha1");
|
|
26
|
+
|
|
27
|
+
poller.trackPr("s1", "org/repo#1");
|
|
28
|
+
await poller.refreshHead("s1");
|
|
29
|
+
|
|
30
|
+
expect(advanced).toEqual([]);
|
|
31
|
+
poller.close();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("a moved head fires once; an unchanged head stays quiet", async () => {
|
|
35
|
+
const { poller, advanced, setSha } = makePoller("sha1");
|
|
36
|
+
|
|
37
|
+
poller.trackPr("s1", "org/repo#1");
|
|
38
|
+
await poller.refreshHead("s1");
|
|
39
|
+
|
|
40
|
+
setSha("sha2");
|
|
41
|
+
await poller.refreshHead("s1");
|
|
42
|
+
await poller.refreshHead("s1");
|
|
43
|
+
|
|
44
|
+
expect(advanced).toEqual(["s1"]);
|
|
45
|
+
poller.close();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("a failed head read never fires and keeps the last baseline", async () => {
|
|
49
|
+
const { poller, advanced, setSha } = makePoller("sha1");
|
|
50
|
+
|
|
51
|
+
poller.trackPr("s1", "org/repo#1");
|
|
52
|
+
await poller.refreshHead("s1");
|
|
53
|
+
|
|
54
|
+
setSha(null);
|
|
55
|
+
await poller.refreshHead("s1");
|
|
56
|
+
setSha("sha2");
|
|
57
|
+
await poller.refreshHead("s1");
|
|
58
|
+
|
|
59
|
+
expect(advanced).toEqual(["s1"]);
|
|
60
|
+
poller.close();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("untrack stops further checks", async () => {
|
|
64
|
+
const { poller, advanced, setSha } = makePoller("sha1");
|
|
65
|
+
|
|
66
|
+
poller.trackPr("s1", "org/repo#1");
|
|
67
|
+
await poller.refreshHead("s1");
|
|
68
|
+
poller.untrackPr("s1");
|
|
69
|
+
|
|
70
|
+
setSha("sha2");
|
|
71
|
+
await poller.refreshHead("s1");
|
|
72
|
+
|
|
73
|
+
expect(advanced).toEqual([]);
|
|
74
|
+
poller.close();
|
|
75
|
+
});
|
|
76
|
+
});
|
package/src/pr-poller.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Polls each open PR review for new commits so the review hot-reloads when the
|
|
3
|
+
* PR head advances. A remote PR has no push signal, so this checks the head sha
|
|
4
|
+
* on an interval - one cheap gh call - and only asks the caller to re-pull the
|
|
5
|
+
* full diff when the head actually moved. Best-effort: a missing or failing gh
|
|
6
|
+
* leaves the last known head in place and never throws.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { prHeadSha } from "./gh";
|
|
10
|
+
|
|
11
|
+
/** One cheap head-sha check per interval; the expensive full-diff pull happens only on a move. */
|
|
12
|
+
const PR_POLL_INTERVAL_MS = 30_000;
|
|
13
|
+
|
|
14
|
+
interface PrPoll {
|
|
15
|
+
pr: string;
|
|
16
|
+
/** Last head sha seen; null until the baseline check lands, so the first observation never fires. */
|
|
17
|
+
lastSha: string | null;
|
|
18
|
+
timer: ReturnType<typeof setInterval>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class PrReviewPoller {
|
|
22
|
+
private readonly polls = new Map<string, PrPoll>();
|
|
23
|
+
|
|
24
|
+
constructor(
|
|
25
|
+
private readonly onPrAdvance: (sessionId: string) => void,
|
|
26
|
+
private readonly headSha: (pr: string) => Promise<string | null> = prHeadSha,
|
|
27
|
+
private readonly intervalMs: number = PR_POLL_INTERVAL_MS,
|
|
28
|
+
) {}
|
|
29
|
+
|
|
30
|
+
/** Start polling a PR review. Idempotent per session; the baseline check runs at once. */
|
|
31
|
+
trackPr(sessionId: string, pr: string): void {
|
|
32
|
+
if (this.polls.has(sessionId)) return;
|
|
33
|
+
const timer = setInterval(() => void this.refreshHead(sessionId), this.intervalMs);
|
|
34
|
+
// the poll timer must not by itself keep the daemon alive against idle-exit
|
|
35
|
+
timer.unref?.();
|
|
36
|
+
this.polls.set(sessionId, { pr, lastSha: null, timer });
|
|
37
|
+
void this.refreshHead(sessionId);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Check the PR head once; fire onPrAdvance only when it moved off a known baseline. */
|
|
41
|
+
async refreshHead(sessionId: string): Promise<void> {
|
|
42
|
+
const poll = this.polls.get(sessionId);
|
|
43
|
+
|
|
44
|
+
if (!poll) return;
|
|
45
|
+
const sha = await this.headSha(poll.pr).catch(() => null);
|
|
46
|
+
|
|
47
|
+
if (sha === null) return;
|
|
48
|
+
// the await yields; re-read so an untrack during the call is honoured
|
|
49
|
+
const current = this.polls.get(sessionId);
|
|
50
|
+
|
|
51
|
+
if (!current) return;
|
|
52
|
+
if (current.lastSha === null) {
|
|
53
|
+
current.lastSha = sha;
|
|
54
|
+
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (sha === current.lastSha) return;
|
|
58
|
+
current.lastSha = sha;
|
|
59
|
+
this.onPrAdvance(sessionId);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
untrackPr(sessionId: string): void {
|
|
63
|
+
const poll = this.polls.get(sessionId);
|
|
64
|
+
|
|
65
|
+
if (!poll) return;
|
|
66
|
+
clearInterval(poll.timer);
|
|
67
|
+
this.polls.delete(sessionId);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
close(): void {
|
|
71
|
+
for (const poll of this.polls.values()) clearInterval(poll.timer);
|
|
72
|
+
this.polls.clear();
|
|
73
|
+
}
|
|
74
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -430,6 +430,11 @@ export class DaemonServer {
|
|
|
430
430
|
|
|
431
431
|
return this.core.sessionSetShareId(params.id, params.shareId);
|
|
432
432
|
},
|
|
433
|
+
"session.setShares": (_connection, request) => {
|
|
434
|
+
const params = parseParams("session.setShares", request.params);
|
|
435
|
+
|
|
436
|
+
return this.core.sessionSetShares(params.id, params.shares);
|
|
437
|
+
},
|
|
433
438
|
"session.delete": (_connection, request) => {
|
|
434
439
|
this.core.sessionDelete(parseParams("session.delete", request.params).id);
|
|
435
440
|
|
package/src/store.ts
CHANGED
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
writeFileSync,
|
|
25
25
|
} from "node:fs";
|
|
26
26
|
import { dirname, join } from "node:path";
|
|
27
|
-
import { historyFromLinear, type Thread } from "@cueloop/schema";
|
|
27
|
+
import { historyFromLinear, withShareLinks, type Thread } from "@cueloop/schema";
|
|
28
28
|
import { migratedSessionsDir, sessionsDir, threadBucket, threadsDir } from "./paths";
|
|
29
29
|
import { validateThreadRecord } from "./validate";
|
|
30
30
|
|
|
@@ -55,9 +55,12 @@ export interface ThreadRepository {
|
|
|
55
55
|
* reading without a history - migration never loses a record.
|
|
56
56
|
*/
|
|
57
57
|
export function withHistory(session: Thread): Thread {
|
|
58
|
-
|
|
58
|
+
// migrate a legacy single share into shares[] on the same read that fills history
|
|
59
|
+
const migrated = withShareLinks(session);
|
|
59
60
|
|
|
60
|
-
|
|
61
|
+
if (migrated.history || migrated.revisions.length === 0) return migrated;
|
|
62
|
+
|
|
63
|
+
return { ...migrated, history: historyFromLinear(migrated) };
|
|
61
64
|
}
|
|
62
65
|
|
|
63
66
|
/** Records in the order `list()` promises: oldest first. */
|
package/src/validate.test.ts
CHANGED
|
@@ -279,6 +279,16 @@ describe("wire pins", () => {
|
|
|
279
279
|
verdict: fullVerdict,
|
|
280
280
|
status: "pending",
|
|
281
281
|
createdAt: "now",
|
|
282
|
+
shares: [
|
|
283
|
+
{
|
|
284
|
+
id: "p_abc123xy",
|
|
285
|
+
name: "review link",
|
|
286
|
+
requireAuth: true,
|
|
287
|
+
allowlist: ["octocat"],
|
|
288
|
+
owner: "SHA256:owner",
|
|
289
|
+
shareBranch: "main",
|
|
290
|
+
},
|
|
291
|
+
],
|
|
282
292
|
shareId: "p_abc123xy",
|
|
283
293
|
owner: "SHA256:owner",
|
|
284
294
|
access: { githubLogins: ["octocat"] },
|
package/src/validate.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
type Revision,
|
|
26
26
|
type SessionHistory,
|
|
27
27
|
type ShareAccess,
|
|
28
|
+
type ShareLink,
|
|
28
29
|
validateHistory,
|
|
29
30
|
type Verdict,
|
|
30
31
|
type WorkspaceKey,
|
|
@@ -131,6 +132,15 @@ export const IdentitySchema = v.object({
|
|
|
131
132
|
handle: v.optional(v.string()),
|
|
132
133
|
} satisfies EntriesOf<Identity>);
|
|
133
134
|
|
|
135
|
+
export const ShareLinkSchema = v.object({
|
|
136
|
+
id: NonEmpty,
|
|
137
|
+
name: v.optional(v.string()),
|
|
138
|
+
requireAuth: v.boolean(),
|
|
139
|
+
allowlist: v.array(NonEmpty),
|
|
140
|
+
owner: v.optional(v.string()),
|
|
141
|
+
shareBranch: v.optional(v.string()),
|
|
142
|
+
} satisfies EntriesOf<ShareLink>);
|
|
143
|
+
|
|
134
144
|
export const Params = {
|
|
135
145
|
"session.create": v.object({ workspace: WorkspaceSchema, artifact: ArtifactSchema }),
|
|
136
146
|
"session.get": v.object({ id: SessionId }),
|
|
@@ -205,6 +215,7 @@ export const Params = {
|
|
|
205
215
|
"session.fork": v.object({ id: SessionId }),
|
|
206
216
|
"session.refreshDiff": v.object({ id: SessionId }),
|
|
207
217
|
"session.setShareId": v.object({ id: SessionId, shareId: NonEmpty }),
|
|
218
|
+
"session.setShares": v.object({ id: SessionId, shares: v.array(ShareLinkSchema) }),
|
|
208
219
|
"session.setAccess": v.object({ id: SessionId, githubLogins: v.array(NonEmpty) }),
|
|
209
220
|
"session.delete": v.object({ id: SessionId }),
|
|
210
221
|
"session.mergeShared": v.object({
|
|
@@ -348,6 +359,7 @@ export const ThreadRecordSchema = v.object({
|
|
|
348
359
|
createdAt: v.string(),
|
|
349
360
|
shelvedAnnotations: v.optional(v.array(FullAnnotationSchema)),
|
|
350
361
|
parentSessionId: v.optional(v.string()),
|
|
362
|
+
shares: v.optional(v.array(ShareLinkSchema)),
|
|
351
363
|
shareId: v.optional(v.string()),
|
|
352
364
|
shareBranch: v.optional(v.string()),
|
|
353
365
|
owner: v.optional(v.string()),
|