@dev.fast/whiteboard 0.2.4 → 0.2.5-preview.20261013.109

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.
Files changed (77) hide show
  1. package/dist/{account-alias-_1_7vROx.js → account-alias-D0hF0sKk.js} +2 -3
  2. package/dist/{agent-cli-BrFqhnzM.js → agent-cli-BBPxwUOd.js} +6 -6
  3. package/dist/agent-cli-YSuhE5X8.js +2 -0
  4. package/dist/{agent-client-B_b1GtUc.js → agent-client-P3J4y8Zn.js} +3 -3
  5. package/dist/{authoring-tools-DvIO906l.js → authoring-tools-D8WXsm_g.js} +11 -10
  6. package/dist/build-info.json +1 -1
  7. package/dist/{cli-gRGxkwc0.js → cli-C0ziP6hg.js} +1 -1
  8. package/dist/{cli-runner-Cjl8h376.js → cli-runner-C1gY-HAI.js} +16 -17
  9. package/dist/cli.js +1 -1
  10. package/dist/{client-BFClfQLO.js → client-DzcSt6pV.js} +63 -13
  11. package/dist/{desktop-discovery-C-Qz54wO.js → desktop-discovery-Ba3dD5Cn.js} +1 -1
  12. package/dist/{headless-host-DcWaxx9P.js → headless-host-BHD_Vg5F.js} +4 -4
  13. package/dist/{local-data-Bm84Xdck.js → local-data-BlERtVm2.js} +281 -75
  14. package/dist/{mcp-BDy9Aybd.js → mcp-t7VN9R3k.js} +4 -4
  15. package/dist/{package-paths-B6-zxvIO.js → package-paths-B9PDoAl6.js} +1 -1
  16. package/dist/{process-error-telemetry-Dzh5a3s_.js → process-error-telemetry-C0swByfp.js} +158 -57
  17. package/dist/{profile-RDEioF8b.js → profile-BsYDBr7t.js} +2 -2
  18. package/dist/profile-j97aRTS7.js +2 -0
  19. package/dist/{review-agent-traces-DNJFYzc8.js → review-home-paths-Y9mYWkCI.js} +2600 -62
  20. package/dist/{review-telemetry-DBy3eVCo.js → review-telemetry-CmM6tCbj.js} +2 -2
  21. package/dist/server/desktop-host.js +35 -46
  22. package/dist/{server-discovery-Dbzn3w6Z.js → server-discovery-ZVi8Ay8J.js} +1 -1
  23. package/dist/sharing/index.d.ts +37 -49
  24. package/dist/sharing/index.js +1 -1
  25. package/dist/{src-CmdiBL20.js → src-DiHP11l3.js} +11 -1
  26. package/dist/{stored-document-migration-BxZWQQjv.js → stored-document-migration-u4L_1VZa.js} +15 -36
  27. package/dist/{tool-failure-DJmTieh5.js → tool-failure-CJHBCHMh.js} +2 -2
  28. package/instructions/scratchpad.md +1 -1
  29. package/package.json +5 -4
  30. package/src/ask/agents.ts +6 -0
  31. package/src/ask/thread-state.ts +17 -1
  32. package/src/ask/thread.ts +116 -22
  33. package/src/ask/threads.ts +8 -2
  34. package/src/error-telemetry.ts +13 -9
  35. package/src/fixtures/blocks/tutorial.json +3 -40
  36. package/src/review-api/README.md +10 -7
  37. package/src/review-api/ask-history.ts +37 -0
  38. package/src/review-api/authoring-tools.ts +8 -5
  39. package/src/review-api/blocks/database_lens.ts +5 -0
  40. package/src/review-api/blocks/tutorial.ts +2 -18
  41. package/src/review-api/comparison-coverage.ts +16 -9
  42. package/src/review-api/component-reference.ts +1 -1
  43. package/src/review-api/document.ts +24 -10
  44. package/src/review-api/http.ts +267 -189
  45. package/src/review-api/local-data.ts +126 -37
  46. package/src/review-api/store-schema.ts +25 -1
  47. package/src/review-api/store.ts +165 -33
  48. package/src/review-api/workspaces.ts +14 -14
  49. package/src/review-api/worktree-events.ts +46 -0
  50. package/src/review-app.ts +1 -1
  51. package/src/review-info.ts +1 -1
  52. package/src/review-stack.ts +2 -2
  53. package/src/server/review-server-core.ts +25 -1
  54. package/src/server/tutorial-service.ts +22 -46
  55. package/src/sharing/export.ts +1 -0
  56. package/src/sharing/import.ts +111 -26
  57. package/tutorial/document.json +27 -86
  58. package/tutorial/lenses.json +10 -0
  59. package/tutorial/runtime-manifest.json +2 -3
  60. package/tutorial/sample-service/src/database/drizzle-orm.d.ts +16 -0
  61. package/tutorial/sample-service/src/database/schema.ts +9 -10
  62. package/tutorial/sample-service/src/inventory/forklift-mood.ts +4 -0
  63. package/tutorial/sample-service/src/inventory/inventory-service.ts +18 -1
  64. package/tutorial/sample-service/src/inventory/warehouse-lore.ts +11 -0
  65. package/tutorial/sample-service/src/orders/order-service.ts +3 -2
  66. package/tutorial/sample-service/src/orders/order.ts +27 -5
  67. package/tutorial/sample-service/src/payments/fraud-vibes.ts +4 -0
  68. package/tutorial/sample-service/src/payments/payment-gateway.ts +19 -2
  69. package/tutorial/sample-service/src/payments/raccoon-ledger.ts +9 -0
  70. package/dist/agent-cli-BR2j-2n5.js +0 -2
  71. package/dist/input-error-OLgB_h31.js +0 -11
  72. package/dist/profile-BQGaX8aX.js +0 -2
  73. package/dist/review-home-paths-6zZH-1c9.js +0 -2560
  74. package/dist/tutorial-trace-DrBvkaeL.js +0 -43
  75. package/src/tutorial-conversation.ts +0 -18
  76. package/tutorial/software-map.json +0 -139
  77. package/tutorial/trace.json +0 -20
@@ -1,5 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
- import { type FSWatcher, existsSync, watch } from "node:fs";
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
- watch?: typeof watch;
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
- watchers: FSWatcher[];
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) for (const watcher of entry.watchers) watcher.close();
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
- watchers: [],
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
- const roots = new Map([
681
- [
682
- vcs.rootPath,
683
- (name: string) =>
684
- prefix === null || rootEventMatters(name, state.tracked),
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 watcher = (this.options.watch ?? watch)(
698
+ const subscription = await (
699
+ this.options.subscribe ??
700
+ (await import("@parcel/watcher")).subscribe
701
+ )(
697
702
  root,
698
- { recursive: true },
699
- (_event, name) => {
700
- if (name === null || matters(String(name))) state.epoch++;
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
- watcher.on("error", () => {
705
- state.epoch++;
706
- state.inspectedEpoch = -1;
707
- state.healthy = false;
708
- watcher.close();
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.watchers.length
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
- for (const entry of this.worktrees.values())
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
- watch?: typeof watch;
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, dismissed_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
  }
@@ -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.enum(["view", "dismiss", "restore"]),
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) => this.assertExists(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.assertExists(reviewId);
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) return structuredClone(live);
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.dismissed_at, repositories.name AS repository_name, repositories.path AS repository_path
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
- dismissedAt: row.dismissed_at ? String(row.dismissed_at) : null,
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.action === "view")
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 dismissed_at=? WHERE review_id=?",
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
- op.action === "dismiss" ? new Date().toISOString() : null,
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 ? () => this.assertMutation(id, previous.version) : undefined,
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
- /** Dismissed reviews, as Home lists them; only a restore clears it, not a view. */
1432
- dismissedIds(): string[] {
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 dismissed_at IS NOT NULL",
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));