@dev.fast/whiteboard 0.2.4 → 0.2.5-preview.20261013.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/dist/{account-alias-_1_7vROx.js → account-alias-D0hF0sKk.js} +2 -3
- package/dist/{agent-cli-BrFqhnzM.js → agent-cli-BBPxwUOd.js} +6 -6
- package/dist/agent-cli-YSuhE5X8.js +2 -0
- package/dist/{agent-client-B_b1GtUc.js → agent-client-P3J4y8Zn.js} +3 -3
- package/dist/{authoring-tools-DvIO906l.js → authoring-tools-D8WXsm_g.js} +11 -10
- package/dist/build-info.json +1 -1
- package/dist/{cli-gRGxkwc0.js → cli-C0ziP6hg.js} +1 -1
- package/dist/{cli-runner-Cjl8h376.js → cli-runner-C1gY-HAI.js} +16 -17
- package/dist/cli.js +1 -1
- package/dist/{client-BFClfQLO.js → client-DzcSt6pV.js} +63 -13
- package/dist/{desktop-discovery-C-Qz54wO.js → desktop-discovery-Ba3dD5Cn.js} +1 -1
- package/dist/{headless-host-DcWaxx9P.js → headless-host-BHD_Vg5F.js} +4 -4
- package/dist/{local-data-Bm84Xdck.js → local-data-BlERtVm2.js} +281 -75
- package/dist/{mcp-BDy9Aybd.js → mcp-t7VN9R3k.js} +4 -4
- package/dist/{package-paths-B6-zxvIO.js → package-paths-B9PDoAl6.js} +1 -1
- package/dist/{process-error-telemetry-Dzh5a3s_.js → process-error-telemetry-C0swByfp.js} +158 -57
- package/dist/{profile-RDEioF8b.js → profile-BsYDBr7t.js} +2 -2
- package/dist/profile-j97aRTS7.js +2 -0
- package/dist/{review-agent-traces-DNJFYzc8.js → review-home-paths-Y9mYWkCI.js} +2600 -62
- package/dist/{review-telemetry-DBy3eVCo.js → review-telemetry-CmM6tCbj.js} +2 -2
- package/dist/server/desktop-host.js +35 -46
- package/dist/{server-discovery-Dbzn3w6Z.js → server-discovery-ZVi8Ay8J.js} +1 -1
- package/dist/sharing/index.d.ts +37 -49
- package/dist/sharing/index.js +1 -1
- package/dist/{src-CmdiBL20.js → src-DiHP11l3.js} +11 -1
- package/dist/{stored-document-migration-BxZWQQjv.js → stored-document-migration-u4L_1VZa.js} +15 -36
- package/dist/{tool-failure-DJmTieh5.js → tool-failure-CJHBCHMh.js} +2 -2
- package/instructions/scratchpad.md +1 -1
- package/package.json +4 -3
- package/src/ask/agents.ts +6 -0
- package/src/ask/thread-state.ts +17 -1
- package/src/ask/thread.ts +116 -22
- package/src/ask/threads.ts +8 -2
- package/src/error-telemetry.ts +13 -9
- package/src/fixtures/blocks/tutorial.json +3 -40
- package/src/review-api/README.md +10 -7
- package/src/review-api/ask-history.ts +37 -0
- package/src/review-api/authoring-tools.ts +8 -5
- package/src/review-api/blocks/database_lens.ts +5 -0
- package/src/review-api/blocks/tutorial.ts +2 -18
- package/src/review-api/comparison-coverage.ts +16 -9
- package/src/review-api/component-reference.ts +1 -1
- package/src/review-api/document.ts +24 -10
- package/src/review-api/http.ts +267 -189
- package/src/review-api/local-data.ts +126 -37
- package/src/review-api/store-schema.ts +25 -1
- package/src/review-api/store.ts +165 -33
- package/src/review-api/workspaces.ts +14 -14
- package/src/review-api/worktree-events.ts +46 -0
- package/src/review-app.ts +1 -1
- package/src/review-info.ts +1 -1
- package/src/review-stack.ts +2 -2
- package/src/server/review-server-core.ts +25 -1
- package/src/server/tutorial-service.ts +22 -46
- package/src/sharing/export.ts +1 -0
- package/src/sharing/import.ts +111 -26
- package/tutorial/document.json +27 -86
- package/tutorial/lenses.json +10 -0
- package/tutorial/runtime-manifest.json +2 -3
- package/tutorial/sample-service/src/database/drizzle-orm.d.ts +16 -0
- package/tutorial/sample-service/src/database/schema.ts +9 -10
- package/tutorial/sample-service/src/inventory/forklift-mood.ts +4 -0
- package/tutorial/sample-service/src/inventory/inventory-service.ts +18 -1
- package/tutorial/sample-service/src/inventory/warehouse-lore.ts +11 -0
- package/tutorial/sample-service/src/orders/order-service.ts +3 -2
- package/tutorial/sample-service/src/orders/order.ts +27 -5
- package/tutorial/sample-service/src/payments/fraud-vibes.ts +4 -0
- package/tutorial/sample-service/src/payments/payment-gateway.ts +19 -2
- package/tutorial/sample-service/src/payments/raccoon-ledger.ts +9 -0
- package/dist/agent-cli-BR2j-2n5.js +0 -2
- package/dist/input-error-OLgB_h31.js +0 -11
- package/dist/profile-BQGaX8aX.js +0 -2
- package/dist/review-home-paths-6zZH-1c9.js +0 -2560
- package/dist/tutorial-trace-DrBvkaeL.js +0 -43
- package/src/tutorial-conversation.ts +0 -18
- package/tutorial/software-map.json +0 -139
- package/tutorial/trace.json +0 -20
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
import {
|
|
2
|
+
import { existsSync, statSync } from "node:fs";
|
|
3
3
|
import { mkdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { promisify } from "node:util";
|
|
@@ -30,6 +30,11 @@ import type {
|
|
|
30
30
|
StructuralDiffEvent,
|
|
31
31
|
} from "@dev.fast/review-protocol";
|
|
32
32
|
import { writePrivateJsonAtomic } from "@dev.fast/trace-core";
|
|
33
|
+
import type {
|
|
34
|
+
AsyncSubscription,
|
|
35
|
+
Event as WatcherEvent,
|
|
36
|
+
subscribe,
|
|
37
|
+
} from "@parcel/watcher";
|
|
33
38
|
import { textIncludesQuote } from "@review/evidence.js";
|
|
34
39
|
import { isMissingFileError } from "@review/fs-utils.js";
|
|
35
40
|
import { reviewManagedCheckoutRoot } from "@review/review-checkout-paths.js";
|
|
@@ -88,8 +93,11 @@ import {
|
|
|
88
93
|
import { traceSchema } from "./trace-schema.js";
|
|
89
94
|
import { ReviewWorkspaces } from "./workspaces.js";
|
|
90
95
|
import {
|
|
96
|
+
INERT_GIT_PATHS,
|
|
97
|
+
atTrackedEdge,
|
|
91
98
|
gitDirectoryPrefix,
|
|
92
99
|
gitEventMatters,
|
|
100
|
+
ignoredDirectories,
|
|
93
101
|
rootEventMatters,
|
|
94
102
|
trackedDirectories,
|
|
95
103
|
} from "./worktree-events.js";
|
|
@@ -569,7 +577,7 @@ export class LocalReviewData {
|
|
|
569
577
|
blobReaderIdleTimeoutMs?: number;
|
|
570
578
|
workspaceDatabase?: string;
|
|
571
579
|
manageWorkspaces?: boolean;
|
|
572
|
-
|
|
580
|
+
subscribe?: typeof subscribe;
|
|
573
581
|
/** gh, git and GitHub API access for pull request targets. */
|
|
574
582
|
pullRequests?: PullRequestDeps;
|
|
575
583
|
} = {},
|
|
@@ -640,10 +648,12 @@ export class LocalReviewData {
|
|
|
640
648
|
{
|
|
641
649
|
epoch: number;
|
|
642
650
|
inspectedEpoch: number;
|
|
643
|
-
|
|
651
|
+
subscriptions: Map<string, AsyncSubscription>;
|
|
644
652
|
healthy: boolean;
|
|
645
653
|
inspection?: Omit<Awaited<ReturnType<typeof inspectWorktree>>, "files">;
|
|
646
654
|
tracked?: Set<string>;
|
|
655
|
+
/** Rereads ignored directories after one may have appeared. */
|
|
656
|
+
ignoring?: NodeJS.Timeout;
|
|
647
657
|
/** Fork points by base ref, valid for the inspected epoch. */
|
|
648
658
|
forks: Map<string, Promise<{ ref: string; commit: string }>>;
|
|
649
659
|
}
|
|
@@ -652,7 +662,7 @@ export class LocalReviewData {
|
|
|
652
662
|
private forgetWorktree(repositoryId: string) {
|
|
653
663
|
const entry = this.worktrees.get(repositoryId);
|
|
654
664
|
|
|
655
|
-
if (entry)
|
|
665
|
+
if (entry) void unwatch(entry);
|
|
656
666
|
this.worktrees.delete(repositoryId);
|
|
657
667
|
}
|
|
658
668
|
|
|
@@ -663,12 +673,13 @@ export class LocalReviewData {
|
|
|
663
673
|
entry = {
|
|
664
674
|
epoch: 0,
|
|
665
675
|
inspectedEpoch: -1,
|
|
666
|
-
|
|
676
|
+
subscriptions: new Map(),
|
|
667
677
|
healthy: true,
|
|
668
678
|
forks: new Map(),
|
|
669
679
|
};
|
|
670
680
|
this.worktrees.set(repositoryId, entry);
|
|
671
681
|
const state = entry;
|
|
682
|
+
const current = () => this.worktrees.get(repositoryId) === state;
|
|
672
683
|
const common = await gitCommonDir(vcs.rootPath);
|
|
673
684
|
|
|
674
685
|
// jj snapshots untracked files, so every event can matter there.
|
|
@@ -677,49 +688,113 @@ export class LocalReviewData {
|
|
|
677
688
|
? await gitDirectoryPrefix(vcs.rootPath, common)
|
|
678
689
|
: null;
|
|
679
690
|
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
]);
|
|
687
|
-
|
|
688
|
-
if (common)
|
|
689
|
-
roots.set(
|
|
690
|
-
common,
|
|
691
|
-
(name) => prefix === null || gitEventMatters(name, prefix),
|
|
692
|
-
);
|
|
693
|
-
|
|
694
|
-
for (const [root, matters] of roots) {
|
|
691
|
+
// Linux watches each directory, so skip what cannot matter.
|
|
692
|
+
const watch = async (
|
|
693
|
+
root: string,
|
|
694
|
+
ignore: string[],
|
|
695
|
+
matters: (name: string, event: WatcherEvent) => boolean,
|
|
696
|
+
) => {
|
|
695
697
|
try {
|
|
696
|
-
const
|
|
698
|
+
const subscription = await (
|
|
699
|
+
this.options.subscribe ??
|
|
700
|
+
(await import("@parcel/watcher")).subscribe
|
|
701
|
+
)(
|
|
697
702
|
root,
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
703
|
+
(error, events) => {
|
|
704
|
+
if (error) {
|
|
705
|
+
state.healthy = false;
|
|
706
|
+
const failed = state.subscriptions.get(root);
|
|
707
|
+
|
|
708
|
+
if (failed) void release(failed);
|
|
709
|
+
state.subscriptions.delete(root);
|
|
710
|
+
} else if (
|
|
711
|
+
// Every event, since matters also notices new ignored directories.
|
|
712
|
+
events
|
|
713
|
+
.map((event) =>
|
|
714
|
+
matters(path.relative(root, event.path), event),
|
|
715
|
+
)
|
|
716
|
+
.includes(true)
|
|
717
|
+
)
|
|
718
|
+
state.epoch++;
|
|
701
719
|
},
|
|
720
|
+
{ ignore },
|
|
702
721
|
);
|
|
703
722
|
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
watcher.unref();
|
|
711
|
-
state.watchers.push(watcher);
|
|
723
|
+
if (!current()) return void release(subscription);
|
|
724
|
+
const previous = state.subscriptions.get(root);
|
|
725
|
+
|
|
726
|
+
state.subscriptions.set(root, subscription);
|
|
727
|
+
|
|
728
|
+
if (previous) void release(previous);
|
|
712
729
|
} catch {
|
|
713
730
|
state.healthy = false;
|
|
714
731
|
}
|
|
715
|
-
}
|
|
732
|
+
};
|
|
733
|
+
|
|
734
|
+
const rootIgnore = async () =>
|
|
735
|
+
[
|
|
736
|
+
...(common ? [".git"] : []),
|
|
737
|
+
...(await ignoredDirectories(
|
|
738
|
+
vcs.rootPath,
|
|
739
|
+
vcs.kind === "jj" ? (common ?? undefined) : undefined,
|
|
740
|
+
)),
|
|
741
|
+
].map((directory) => path.join(vcs.rootPath, directory));
|
|
742
|
+
|
|
743
|
+
let ignored = await rootIgnore();
|
|
744
|
+
|
|
745
|
+
const refreshIgnored = () => {
|
|
746
|
+
clearTimeout(state.ignoring);
|
|
747
|
+
state.ignoring = setTimeout(async () => {
|
|
748
|
+
const next = await rootIgnore();
|
|
749
|
+
|
|
750
|
+
if (!current() || next.join("\0") === ignored.join("\0")) return;
|
|
751
|
+
ignored = next;
|
|
752
|
+
|
|
753
|
+
// Parcel keeps watches an overlapping subscription shares, so
|
|
754
|
+
// unsubscribe first and treat the gap as a change.
|
|
755
|
+
const previous = state.subscriptions.get(vcs.rootPath);
|
|
756
|
+
|
|
757
|
+
state.subscriptions.delete(vcs.rootPath);
|
|
758
|
+
|
|
759
|
+
if (previous) await release(previous);
|
|
760
|
+
await watch(vcs.rootPath, ignored, rootMatters);
|
|
761
|
+
state.epoch++;
|
|
762
|
+
}, 1000);
|
|
763
|
+
state.ignoring.unref();
|
|
764
|
+
};
|
|
765
|
+
|
|
766
|
+
const rootMatters = (name: string, event: WatcherEvent) => {
|
|
767
|
+
if (
|
|
768
|
+
path.basename(name) === ".gitignore" ||
|
|
769
|
+
(event.type === "create" &&
|
|
770
|
+
atTrackedEdge(name, state.tracked) &&
|
|
771
|
+
statSync(event.path, { throwIfNoEntry: false })?.isDirectory())
|
|
772
|
+
)
|
|
773
|
+
refreshIgnored();
|
|
774
|
+
|
|
775
|
+
return prefix === null || rootEventMatters(name, state.tracked);
|
|
776
|
+
};
|
|
777
|
+
|
|
778
|
+
await Promise.all([
|
|
779
|
+
watch(vcs.rootPath, ignored, rootMatters),
|
|
780
|
+
common &&
|
|
781
|
+
watch(
|
|
782
|
+
common,
|
|
783
|
+
INERT_GIT_PATHS.map((directory) => path.join(common, directory)),
|
|
784
|
+
(name) => {
|
|
785
|
+
if (name === path.join("info", "exclude")) refreshIgnored();
|
|
786
|
+
|
|
787
|
+
return prefix === null || gitEventMatters(name, prefix);
|
|
788
|
+
},
|
|
789
|
+
),
|
|
790
|
+
]);
|
|
716
791
|
}
|
|
717
792
|
|
|
718
793
|
if (
|
|
719
794
|
entry.inspection &&
|
|
720
795
|
entry.inspectedEpoch === entry.epoch &&
|
|
721
796
|
entry.healthy &&
|
|
722
|
-
entry.
|
|
797
|
+
entry.subscriptions.size
|
|
723
798
|
)
|
|
724
799
|
return { ...entry.inspection, forks: entry.forks };
|
|
725
800
|
|
|
@@ -742,8 +817,7 @@ export class LocalReviewData {
|
|
|
742
817
|
this.closed = true;
|
|
743
818
|
await this.workspaceManager?.close();
|
|
744
819
|
|
|
745
|
-
|
|
746
|
-
for (const watcher of entry.watchers) watcher.close();
|
|
820
|
+
await Promise.all([...this.worktrees.values()].map(unwatch));
|
|
747
821
|
this.worktrees.clear();
|
|
748
822
|
const readers = [...this.readers.values()];
|
|
749
823
|
|
|
@@ -1809,12 +1883,27 @@ function inputError<T>(run: () => T): T {
|
|
|
1809
1883
|
}
|
|
1810
1884
|
}
|
|
1811
1885
|
|
|
1886
|
+
async function unwatch(entry: {
|
|
1887
|
+
subscriptions: Map<string, AsyncSubscription>;
|
|
1888
|
+
ignoring?: NodeJS.Timeout;
|
|
1889
|
+
}) {
|
|
1890
|
+
clearTimeout(entry.ignoring);
|
|
1891
|
+
const subscriptions = [...entry.subscriptions.values()];
|
|
1892
|
+
|
|
1893
|
+
entry.subscriptions.clear();
|
|
1894
|
+
await Promise.all(subscriptions.map(release));
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
/** Linux fails to unwatch a directory that is already gone. */
|
|
1898
|
+
const release = (subscription: AsyncSubscription) =>
|
|
1899
|
+
subscription.unsubscribe().catch(() => {});
|
|
1900
|
+
|
|
1812
1901
|
export function openLocalReviewStore(
|
|
1813
1902
|
databasePath: string,
|
|
1814
1903
|
options: {
|
|
1815
1904
|
blobReaderIdleTimeoutMs?: number;
|
|
1816
1905
|
manageWorkspaces?: boolean;
|
|
1817
|
-
|
|
1906
|
+
subscribe?: typeof subscribe;
|
|
1818
1907
|
pullRequests?: PullRequestDeps;
|
|
1819
1908
|
} = {},
|
|
1820
1909
|
) {
|
|
@@ -8,7 +8,7 @@ export function initializeReviewStoreSchema(db: DatabaseSync): void {
|
|
|
8
8
|
CREATE TABLE IF NOT EXISTS versions(review_id TEXT REFERENCES reviews(id), version INTEGER, snapshot TEXT NOT NULL,
|
|
9
9
|
PRIMARY KEY(review_id,version));
|
|
10
10
|
DROP TABLE IF EXISTS receipts;
|
|
11
|
-
CREATE TABLE IF NOT EXISTS review_attention(review_id TEXT PRIMARY KEY REFERENCES reviews(id), viewed_at TEXT,
|
|
11
|
+
CREATE TABLE IF NOT EXISTS review_attention(review_id TEXT PRIMARY KEY REFERENCES reviews(id), viewed_at TEXT, deleted_at TEXT);
|
|
12
12
|
CREATE TABLE IF NOT EXISTS repositories(id TEXT PRIMARY KEY, path TEXT NOT NULL UNIQUE, name TEXT NOT NULL);
|
|
13
13
|
CREATE TABLE IF NOT EXISTS resources(id TEXT PRIMARY KEY, repository_id TEXT NOT NULL REFERENCES repositories(id),
|
|
14
14
|
kind TEXT NOT NULL, mime_type TEXT NOT NULL, data BLOB NOT NULL);
|
|
@@ -19,4 +19,28 @@ export function initializeReviewStoreSchema(db: DatabaseSync): void {
|
|
|
19
19
|
CREATE TABLE IF NOT EXISTS server_identity(one INTEGER PRIMARY KEY CHECK(one=1), id TEXT NOT NULL);
|
|
20
20
|
DROP TABLE IF EXISTS authoring_drafts;
|
|
21
21
|
`);
|
|
22
|
+
|
|
23
|
+
db.exec("BEGIN IMMEDIATE");
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
// Migrate the former Archive timestamp without losing recoverable boards.
|
|
27
|
+
const attentionColumns = db
|
|
28
|
+
.prepare("PRAGMA table_info(review_attention)")
|
|
29
|
+
.all();
|
|
30
|
+
|
|
31
|
+
if (attentionColumns.some((column) => column.name === "dismissed_at")) {
|
|
32
|
+
db.exec(
|
|
33
|
+
"ALTER TABLE review_attention RENAME COLUMN dismissed_at TO deleted_at",
|
|
34
|
+
);
|
|
35
|
+
// Archive had no retention limit. Give migrated boards the full recovery window.
|
|
36
|
+
db.prepare(
|
|
37
|
+
"UPDATE review_attention SET deleted_at=? WHERE deleted_at IS NOT NULL",
|
|
38
|
+
).run(new Date().toISOString());
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
db.exec("COMMIT");
|
|
42
|
+
} catch (error) {
|
|
43
|
+
db.exec("ROLLBACK");
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
22
46
|
}
|
package/src/review-api/store.ts
CHANGED
|
@@ -83,10 +83,15 @@ const activityId = z
|
|
|
83
83
|
export const commandSchema = z.strictObject({
|
|
84
84
|
operation: z.discriminatedUnion("type", [
|
|
85
85
|
z.strictObject({ type: z.literal("delete"), reviewId }),
|
|
86
|
+
z.strictObject({
|
|
87
|
+
type: z.literal("lifecycle"),
|
|
88
|
+
reviewId,
|
|
89
|
+
state: z.enum(["active", "trash"]),
|
|
90
|
+
}),
|
|
86
91
|
z.strictObject({
|
|
87
92
|
type: z.literal("attention"),
|
|
88
93
|
reviewId,
|
|
89
|
-
action: z.
|
|
94
|
+
action: z.literal("view"),
|
|
90
95
|
}),
|
|
91
96
|
z.strictObject({
|
|
92
97
|
type: z.literal("create"),
|
|
@@ -156,6 +161,8 @@ export interface SnapshotOrigin {
|
|
|
156
161
|
}
|
|
157
162
|
|
|
158
163
|
export interface Snapshot {
|
|
164
|
+
/** Current lifecycle state, projected onto every version when read. */
|
|
165
|
+
trashedAt?: string | null;
|
|
159
166
|
shared?: { login?: string; sharedAt?: number; cloneUrl?: string };
|
|
160
167
|
reviewId: string;
|
|
161
168
|
version: number;
|
|
@@ -200,6 +207,7 @@ export interface Result {
|
|
|
200
207
|
type?: Element["type"] | "lens";
|
|
201
208
|
children?: WrittenComponent[];
|
|
202
209
|
attention?: true;
|
|
210
|
+
lifecycle?: true;
|
|
203
211
|
deleted?: true;
|
|
204
212
|
warnings?: string[];
|
|
205
213
|
}
|
|
@@ -257,6 +265,7 @@ export class ReviewStore {
|
|
|
257
265
|
private readonly liveSources = new Map<string, Snapshot>();
|
|
258
266
|
private refreshTimer?: ReturnType<typeof setInterval>;
|
|
259
267
|
private refreshSubscribers = 0;
|
|
268
|
+
private readonly purgeTimer: ReturnType<typeof setInterval>;
|
|
260
269
|
|
|
261
270
|
watchWorktrees(): () => void {
|
|
262
271
|
this.refreshSubscribers++;
|
|
@@ -326,6 +335,7 @@ export class ReviewStore {
|
|
|
326
335
|
|
|
327
336
|
const run = this.pending.then(async () => {
|
|
328
337
|
for (const summary of this.list()) {
|
|
338
|
+
if (summary.trashedAt) continue;
|
|
329
339
|
const snapshot = this.read(summary.reviewId, summary.version);
|
|
330
340
|
|
|
331
341
|
try {
|
|
@@ -379,6 +389,7 @@ export class ReviewStore {
|
|
|
379
389
|
private readonly externalChanges: ReturnType<typeof setInterval>;
|
|
380
390
|
private observedDataVersion: number;
|
|
381
391
|
private observedVersions = new Map<string, number>();
|
|
392
|
+
private observedDeletions = new Map<string, string>();
|
|
382
393
|
subscribeCatalog(listener: () => void) {
|
|
383
394
|
this.catalogListeners.add(listener);
|
|
384
395
|
|
|
@@ -409,11 +420,18 @@ export class ReviewStore {
|
|
|
409
420
|
// WAL plus a busy timeout: another host on the same home waits instead of failing.
|
|
410
421
|
this.db = new DatabaseSync(databasePath, { timeout: 5000 });
|
|
411
422
|
initializeReviewStoreSchema(this.db);
|
|
412
|
-
this.activity = new ReviewActivity(this.db, (id) =>
|
|
423
|
+
this.activity = new ReviewActivity(this.db, (id) =>
|
|
424
|
+
this.assertWritable(id),
|
|
425
|
+
);
|
|
413
426
|
this.askHistory = new AskHistory(this.db);
|
|
414
427
|
|
|
428
|
+
this.purgeExpired();
|
|
429
|
+
this.purgeTimer = setInterval(() => this.purgeExpired(), 60_000);
|
|
430
|
+
this.purgeTimer.unref();
|
|
431
|
+
|
|
415
432
|
this.observedDataVersion = this.dataVersion();
|
|
416
433
|
this.observedVersions = this.currentVersions();
|
|
434
|
+
this.observedDeletions = this.currentDeletions();
|
|
417
435
|
this.externalChanges = setInterval(
|
|
418
436
|
() => this.refreshExternalChanges(),
|
|
419
437
|
250,
|
|
@@ -434,6 +452,16 @@ export class ReviewStore {
|
|
|
434
452
|
);
|
|
435
453
|
}
|
|
436
454
|
|
|
455
|
+
private currentDeletions() {
|
|
456
|
+
return new Map(
|
|
457
|
+
this.db
|
|
458
|
+
.prepare(
|
|
459
|
+
"SELECT review_id,deleted_at FROM review_attention WHERE deleted_at IS NOT NULL",
|
|
460
|
+
)
|
|
461
|
+
.all()
|
|
462
|
+
.map((row) => [String(row.review_id), String(row.deleted_at)]),
|
|
463
|
+
);
|
|
464
|
+
}
|
|
437
465
|
private refreshExternalChanges() {
|
|
438
466
|
const version = this.dataVersion();
|
|
439
467
|
|
|
@@ -441,11 +469,22 @@ export class ReviewStore {
|
|
|
441
469
|
this.observedDataVersion = version;
|
|
442
470
|
const current = this.currentVersions();
|
|
443
471
|
const previous = this.observedVersions;
|
|
472
|
+
const deletions = this.currentDeletions();
|
|
473
|
+
const previousDeletions = this.observedDeletions;
|
|
474
|
+
this.observedDeletions = deletions;
|
|
444
475
|
this.observedVersions = current;
|
|
445
476
|
|
|
446
|
-
for (const [reviewId, savedVersion] of current)
|
|
477
|
+
for (const [reviewId, savedVersion] of current) {
|
|
447
478
|
if (previous.get(reviewId) !== savedVersion)
|
|
448
479
|
this.notify({ reviewId, version: savedVersion });
|
|
480
|
+
else if (previousDeletions.get(reviewId) !== deletions.get(reviewId))
|
|
481
|
+
this.notify({
|
|
482
|
+
reviewId,
|
|
483
|
+
version: savedVersion,
|
|
484
|
+
lifecycle: true,
|
|
485
|
+
attention: true,
|
|
486
|
+
});
|
|
487
|
+
}
|
|
449
488
|
|
|
450
489
|
for (const [reviewId, savedVersion] of previous)
|
|
451
490
|
if (!current.has(reviewId)) {
|
|
@@ -490,10 +529,11 @@ export class ReviewStore {
|
|
|
490
529
|
files: { path: string; fingerprint: string; scope: Coverage }[],
|
|
491
530
|
viewed: boolean,
|
|
492
531
|
): void {
|
|
493
|
-
this.
|
|
532
|
+
this.assertWritable(reviewId);
|
|
494
533
|
this.db.exec("BEGIN IMMEDIATE");
|
|
495
534
|
|
|
496
535
|
try {
|
|
536
|
+
this.assertWritable(reviewId);
|
|
497
537
|
const current = this.viewedCoverage(reviewId);
|
|
498
538
|
|
|
499
539
|
for (const file of files) {
|
|
@@ -630,6 +670,7 @@ export class ReviewStore {
|
|
|
630
670
|
this.closing = true;
|
|
631
671
|
clearInterval(this.externalChanges);
|
|
632
672
|
clearInterval(this.refreshTimer);
|
|
673
|
+
clearInterval(this.purgeTimer);
|
|
633
674
|
await this.pending;
|
|
634
675
|
this.listeners.clear();
|
|
635
676
|
this.catalogListeners.clear();
|
|
@@ -669,6 +710,62 @@ export class ReviewStore {
|
|
|
669
710
|
404,
|
|
670
711
|
);
|
|
671
712
|
}
|
|
713
|
+
assertWritable(id: string) {
|
|
714
|
+
this.assertExists(id);
|
|
715
|
+
const trashedAt = this.trashTime(id);
|
|
716
|
+
|
|
717
|
+
if (trashedAt)
|
|
718
|
+
throw new ReviewInputError(
|
|
719
|
+
`This whiteboard was moved to Trash on ${new Date(trashedAt).toLocaleDateString("en-US", { month: "short", day: "numeric" })}. Restore it with session_lifecycle({"sessionId":"${id}","state":"active"}) to continue.`,
|
|
720
|
+
409,
|
|
721
|
+
);
|
|
722
|
+
}
|
|
723
|
+
private trashTime(id: string): string | null {
|
|
724
|
+
const row = this.db
|
|
725
|
+
.prepare("SELECT deleted_at FROM review_attention WHERE review_id=?")
|
|
726
|
+
.get(id);
|
|
727
|
+
|
|
728
|
+
return row?.deleted_at ? String(row.deleted_at) : null;
|
|
729
|
+
}
|
|
730
|
+
private removePermanently(id: string) {
|
|
731
|
+
for (const table of [
|
|
732
|
+
"ask_conversations",
|
|
733
|
+
"authoring_presences",
|
|
734
|
+
"review_coverage",
|
|
735
|
+
"review_attention",
|
|
736
|
+
"versions",
|
|
737
|
+
])
|
|
738
|
+
this.db.prepare(`DELETE FROM ${table} WHERE review_id=?`).run(id);
|
|
739
|
+
this.db.prepare("DELETE FROM reviews WHERE id=?").run(id);
|
|
740
|
+
this.liveSources.delete(id);
|
|
741
|
+
}
|
|
742
|
+
private purgeExpired() {
|
|
743
|
+
const cutoff = new Date(Date.now() - 30 * 86400000).toISOString();
|
|
744
|
+
const expired: Result[] = [];
|
|
745
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
746
|
+
|
|
747
|
+
try {
|
|
748
|
+
for (const row of this.db
|
|
749
|
+
.prepare(
|
|
750
|
+
"SELECT review_id,version FROM review_attention JOIN reviews ON reviews.id=review_id WHERE deleted_at <= ?",
|
|
751
|
+
)
|
|
752
|
+
.all(cutoff)) {
|
|
753
|
+
const reviewId = String(row.review_id);
|
|
754
|
+
expired.push({ reviewId, version: Number(row.version), deleted: true });
|
|
755
|
+
this.removePermanently(reviewId);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
this.db.exec("COMMIT");
|
|
759
|
+
} catch (error) {
|
|
760
|
+
this.db.exec("ROLLBACK");
|
|
761
|
+
throw error;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
for (const result of expired) {
|
|
765
|
+
this.activity.deleted(result.reviewId);
|
|
766
|
+
this.notify(result);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
672
769
|
read(id: string, version?: number): Snapshot {
|
|
673
770
|
const row =
|
|
674
771
|
version === undefined
|
|
@@ -719,7 +816,10 @@ export class ReviewStore {
|
|
|
719
816
|
};
|
|
720
817
|
const live = version === undefined ? this.liveSources.get(id) : undefined;
|
|
721
818
|
|
|
722
|
-
if (live?.version === snapshot.version)
|
|
819
|
+
if (live?.version === snapshot.version)
|
|
820
|
+
return { ...structuredClone(live), trashedAt: this.trashTime(id) };
|
|
821
|
+
|
|
822
|
+
snapshot.trashedAt = this.trashTime(id);
|
|
723
823
|
|
|
724
824
|
return snapshot;
|
|
725
825
|
}
|
|
@@ -772,7 +872,7 @@ export class ReviewStore {
|
|
|
772
872
|
.prepare(
|
|
773
873
|
`SELECT json_remove(versions.snapshot,'$.document') AS summary,
|
|
774
874
|
(SELECT json_extract(first.snapshot,'$.createdAt') FROM versions AS first WHERE first.review_id=reviews.id ORDER BY first.version LIMIT 1) AS first_created_at,
|
|
775
|
-
review_attention.viewed_at, review_attention.
|
|
875
|
+
review_attention.viewed_at, review_attention.deleted_at, repositories.name AS repository_name, repositories.path AS repository_path
|
|
776
876
|
FROM reviews
|
|
777
877
|
JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
|
|
778
878
|
LEFT JOIN review_attention ON review_attention.review_id=reviews.id
|
|
@@ -820,7 +920,7 @@ export class ReviewStore {
|
|
|
820
920
|
? String(row.repository_name)
|
|
821
921
|
: (summary.pins?.repositoryId ?? ""),
|
|
822
922
|
viewedAt: row.viewed_at ? String(row.viewed_at) : null,
|
|
823
|
-
|
|
923
|
+
trashedAt: row.deleted_at ? String(row.deleted_at) : null,
|
|
824
924
|
working: this.activity.isWorking(summary.reviewId),
|
|
825
925
|
};
|
|
826
926
|
|
|
@@ -923,6 +1023,14 @@ export class ReviewStore {
|
|
|
923
1023
|
|
|
924
1024
|
const run = this.pending.then(async () => {
|
|
925
1025
|
const op = command.operation;
|
|
1026
|
+
this.purgeExpired();
|
|
1027
|
+
|
|
1028
|
+
if (
|
|
1029
|
+
op.type !== "create" &&
|
|
1030
|
+
op.type !== "delete" &&
|
|
1031
|
+
op.type !== "lifecycle"
|
|
1032
|
+
)
|
|
1033
|
+
this.assertWritable(op.reviewId);
|
|
926
1034
|
|
|
927
1035
|
// The scratchpad is edited and restored like a review, and nothing else.
|
|
928
1036
|
if (
|
|
@@ -992,6 +1100,14 @@ export class ReviewStore {
|
|
|
992
1100
|
}
|
|
993
1101
|
|
|
994
1102
|
if (op.type === "delete") {
|
|
1103
|
+
this.assertExists(op.reviewId);
|
|
1104
|
+
|
|
1105
|
+
if (!this.trashTime(op.reviewId))
|
|
1106
|
+
throw new ReviewInputError(
|
|
1107
|
+
"Move this whiteboard to Trash before deleting it.",
|
|
1108
|
+
409,
|
|
1109
|
+
);
|
|
1110
|
+
|
|
995
1111
|
const result: Result = {
|
|
996
1112
|
reviewId: op.reviewId,
|
|
997
1113
|
version: this.read(op.reviewId).version,
|
|
@@ -1000,30 +1116,21 @@ export class ReviewStore {
|
|
|
1000
1116
|
|
|
1001
1117
|
this.commitCommand(
|
|
1002
1118
|
result,
|
|
1003
|
-
() =>
|
|
1004
|
-
for (const table of [
|
|
1005
|
-
"ask_conversations",
|
|
1006
|
-
"authoring_presences",
|
|
1007
|
-
"review_coverage",
|
|
1008
|
-
"review_attention",
|
|
1009
|
-
"versions",
|
|
1010
|
-
])
|
|
1011
|
-
this.db
|
|
1012
|
-
.prepare(`DELETE FROM ${table} WHERE review_id=?`)
|
|
1013
|
-
.run(op.reviewId);
|
|
1014
|
-
this.db.prepare("DELETE FROM reviews WHERE id=?").run(op.reviewId);
|
|
1015
|
-
},
|
|
1119
|
+
() => this.removePermanently(op.reviewId),
|
|
1016
1120
|
() => this.assertMutation(op.reviewId, result.version),
|
|
1017
1121
|
);
|
|
1018
1122
|
|
|
1019
1123
|
return result;
|
|
1020
1124
|
}
|
|
1021
1125
|
|
|
1022
|
-
if (op.type === "attention") {
|
|
1126
|
+
if (op.type === "attention" || op.type === "lifecycle") {
|
|
1023
1127
|
const result: Result = {
|
|
1024
1128
|
reviewId: op.reviewId,
|
|
1025
1129
|
version: this.read(op.reviewId).version,
|
|
1026
1130
|
attention: true,
|
|
1131
|
+
...(op.type === "lifecycle" && {
|
|
1132
|
+
lifecycle: true as const,
|
|
1133
|
+
}),
|
|
1027
1134
|
};
|
|
1028
1135
|
|
|
1029
1136
|
this.commitCommand(result, () => {
|
|
@@ -1033,21 +1140,22 @@ export class ReviewStore {
|
|
|
1033
1140
|
)
|
|
1034
1141
|
.run(op.reviewId);
|
|
1035
1142
|
|
|
1036
|
-
if (op.
|
|
1143
|
+
if (op.type === "attention")
|
|
1037
1144
|
this.db
|
|
1038
1145
|
.prepare(
|
|
1039
1146
|
"UPDATE review_attention SET viewed_at=? WHERE review_id=?",
|
|
1040
1147
|
)
|
|
1041
1148
|
.run(new Date().toISOString(), op.reviewId);
|
|
1042
|
-
else
|
|
1149
|
+
else {
|
|
1150
|
+
const timestamp =
|
|
1151
|
+
op.state === "trash" ? new Date().toISOString() : null;
|
|
1152
|
+
|
|
1043
1153
|
this.db
|
|
1044
1154
|
.prepare(
|
|
1045
|
-
"UPDATE review_attention SET
|
|
1155
|
+
"UPDATE review_attention SET deleted_at=CASE WHEN ? IS NULL THEN NULL ELSE COALESCE(deleted_at, ?) END WHERE review_id=?",
|
|
1046
1156
|
)
|
|
1047
|
-
.run(
|
|
1048
|
-
|
|
1049
|
-
op.reviewId,
|
|
1050
|
-
);
|
|
1157
|
+
.run(timestamp, timestamp, op.reviewId);
|
|
1158
|
+
}
|
|
1051
1159
|
});
|
|
1052
1160
|
|
|
1053
1161
|
return result;
|
|
@@ -1067,6 +1175,8 @@ export class ReviewStore {
|
|
|
1067
1175
|
? createdSnapshot(id, op, resolvedTarget, fromPullRequest?.title)
|
|
1068
1176
|
: structuredClone(previous!);
|
|
1069
1177
|
|
|
1178
|
+
delete snapshot.trashedAt;
|
|
1179
|
+
|
|
1070
1180
|
// Overlay state: a degraded read must not persist unavailability.
|
|
1071
1181
|
delete snapshot.sourceUnavailable;
|
|
1072
1182
|
// Each version describes only its own edit.
|
|
@@ -1257,7 +1367,12 @@ export class ReviewStore {
|
|
|
1257
1367
|
)
|
|
1258
1368
|
.run(id, snapshot.version, JSON.stringify(snapshot));
|
|
1259
1369
|
},
|
|
1260
|
-
previous
|
|
1370
|
+
previous
|
|
1371
|
+
? () => {
|
|
1372
|
+
this.assertWritable(id);
|
|
1373
|
+
this.assertMutation(id, previous.version);
|
|
1374
|
+
}
|
|
1375
|
+
: undefined,
|
|
1261
1376
|
op.type === "edit" || op.type === "lens_edit"
|
|
1262
1377
|
? {
|
|
1263
1378
|
surface: op.type === "edit" ? "document" : "lenses",
|
|
@@ -1297,6 +1412,14 @@ export class ReviewStore {
|
|
|
1297
1412
|
? []
|
|
1298
1413
|
: this.reviewsForPullRequest(op.pullRequestUrl);
|
|
1299
1414
|
|
|
1415
|
+
if (existing) {
|
|
1416
|
+
try {
|
|
1417
|
+
this.assertReusableReview(existing);
|
|
1418
|
+
} catch (error) {
|
|
1419
|
+
return Promise.reject(error);
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1300
1423
|
return this.providers.resolvePullRequest(op.pullRequestUrl, {
|
|
1301
1424
|
id: op.repositoryId,
|
|
1302
1425
|
preferred: existing && this.read(existing).pins?.repositoryId,
|
|
@@ -1315,6 +1438,13 @@ export class ReviewStore {
|
|
|
1315
1438
|
.all(pullRequestKey(url))
|
|
1316
1439
|
.map((row) => String(row.id));
|
|
1317
1440
|
}
|
|
1441
|
+
private assertReusableReview(reviewId: string) {
|
|
1442
|
+
if (this.trashTime(reviewId))
|
|
1443
|
+
throw new ReviewInputError(
|
|
1444
|
+
`This PR's whiteboard is in Trash. Restore it to continue:\n\nsession_lifecycle({"sessionId":"${reviewId}","state":"active"})`,
|
|
1445
|
+
409,
|
|
1446
|
+
);
|
|
1447
|
+
}
|
|
1318
1448
|
/** The answer to a create that found its PR's review. Its target stays:
|
|
1319
1449
|
* moving it would silently point existing links at different code. */
|
|
1320
1450
|
private existingReview(
|
|
@@ -1324,6 +1454,8 @@ export class ReviewStore {
|
|
|
1324
1454
|
): Result {
|
|
1325
1455
|
const snapshot = this.read(reviewId);
|
|
1326
1456
|
|
|
1457
|
+
this.assertReusableReview(reviewId);
|
|
1458
|
+
|
|
1327
1459
|
const headMoved =
|
|
1328
1460
|
snapshot.pins?.repositoryId !== requested.repositoryId ||
|
|
1329
1461
|
snapshot.pins?.head !== requested.head;
|
|
@@ -1413,7 +1545,7 @@ export class ReviewStore {
|
|
|
1413
1545
|
else if (!result.attention)
|
|
1414
1546
|
this.observedVersions.set(result.reviewId, result.version);
|
|
1415
1547
|
|
|
1416
|
-
if (!result.attention)
|
|
1548
|
+
if (!result.attention || result.lifecycle)
|
|
1417
1549
|
for (const listener of this.listeners)
|
|
1418
1550
|
try {
|
|
1419
1551
|
listener(result);
|
|
@@ -1428,11 +1560,11 @@ export class ReviewStore {
|
|
|
1428
1560
|
// The saved command must remain successful if a viewer disconnects.
|
|
1429
1561
|
}
|
|
1430
1562
|
}
|
|
1431
|
-
/**
|
|
1432
|
-
|
|
1563
|
+
/** Viewing a review in Trash does not restore it. */
|
|
1564
|
+
trashedIds(): string[] {
|
|
1433
1565
|
return this.db
|
|
1434
1566
|
.prepare(
|
|
1435
|
-
"SELECT review_id FROM review_attention WHERE
|
|
1567
|
+
"SELECT review_id FROM review_attention WHERE deleted_at IS NOT NULL",
|
|
1436
1568
|
)
|
|
1437
1569
|
.all()
|
|
1438
1570
|
.map((row) => String(row.review_id));
|