@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
@@ -103,27 +103,27 @@ export class ReviewWorkspaces {
103
103
 
104
104
  this.stop = store.subscribeCatalog(() => {
105
105
  this.collect();
106
- this.releaseDismissed();
106
+ this.releaseTrashed();
107
107
  });
108
108
  this.collect();
109
- this.releaseDismissed();
109
+ this.releaseTrashed();
110
110
  }
111
111
 
112
- // Unset until the first scan, so startup also frees reviews dismissed
112
+ // Unset until the first scan, so startup also frees reviews trashed
113
113
  // while no Desktop was running.
114
- private dismissed?: Set<string>;
114
+ private trashed?: Set<string>;
115
115
  private readonly releasing = new Map<string, Promise<void>>();
116
116
 
117
117
  /**
118
- * Frees a newly dismissed review's checkouts to save disk. The review itself
118
+ * Frees a newly trashed review's checkouts to save disk. The review itself
119
119
  * stays; reopening it rebuilds them. Checkouts rebuilt by peeking at a
120
- * dismissed review are kept until the next startup or dismissal, since
120
+ * trashed review are kept until the next startup or move to Trash, since
121
121
  * the Desktop's canvas tabs and source windows may still show them.
122
122
  */
123
- private releaseDismissed() {
124
- const dismissed = new Set(this.store.dismissedIds());
125
- const released = [...dismissed].filter((id) => !this.dismissed?.has(id));
126
- this.dismissed = dismissed;
123
+ private releaseTrashed() {
124
+ const trashed = new Set(this.store.trashedIds());
125
+ const released = [...trashed].filter((id) => !this.trashed?.has(id));
126
+ this.trashed = trashed;
127
127
 
128
128
  if (!released.length) return;
129
129
 
@@ -162,7 +162,7 @@ export class ReviewWorkspaces {
162
162
  })
163
163
  .catch((error) => {
164
164
  console.error(
165
- `Could not free the pinned checkouts of dismissed review ${reviewId}:`,
165
+ `Could not free the pinned checkouts of trashed review ${reviewId}:`,
166
166
  error,
167
167
  );
168
168
  })
@@ -176,8 +176,8 @@ export class ReviewWorkspaces {
176
176
  }
177
177
 
178
178
  private async releaseCheckouts(reviewId: string, repositories: string[]) {
179
- // Restored since the dismissal: its checkouts may be in use.
180
- if (!this.store.dismissedIds().includes(reviewId)) return;
179
+ // Restored since the move to Trash: its checkouts may be in use.
180
+ if (!this.store.trashedIds().includes(reviewId)) return;
181
181
 
182
182
  const environments = this.all().filter(
183
183
  (item) => item.reviewId === reviewId,
@@ -363,7 +363,7 @@ export class ReviewWorkspaces {
363
363
  if (!environment || environment.state !== "cleanup-failed")
364
364
  throw new ReviewInputError("Cleanup failure not found.", 404);
365
365
 
366
- // A dismissed review keeps its record, so collection would skip it.
366
+ // A trashed review keeps its record, so collection would skip it.
367
367
  if (this.store.has(environment.reviewId))
368
368
  this.release(environment.reviewId);
369
369
  else this.collect(id);
@@ -37,10 +37,56 @@ export function rootEventMatters(
37
37
  return !tracked || tracked.has(parent(file));
38
38
  }
39
39
 
40
+ /** A directory appearing at the edge of the tracked tree may be newly ignored. */
41
+ export function atTrackedEdge(
42
+ name: string,
43
+ tracked: Set<string> | undefined,
44
+ ): boolean {
45
+ const file = normalize(name);
46
+
47
+ return !!tracked && tracked.has(parent(file)) && !tracked.has(file);
48
+ }
49
+
50
+ /** Wholly ignored directories, so watchers can skip them. */
51
+ export async function ignoredDirectories(
52
+ rootPath: string,
53
+ gitDirectory?: string,
54
+ ): Promise<string[]> {
55
+ try {
56
+ const { stdout } = await exec(
57
+ "git",
58
+ [
59
+ "-C",
60
+ rootPath,
61
+ ...(gitDirectory
62
+ ? ["--git-dir", gitDirectory, "--work-tree", rootPath]
63
+ : []),
64
+ "ls-files",
65
+ "-z",
66
+ "--others",
67
+ "--ignored",
68
+ "--exclude-standard",
69
+ "--directory",
70
+ ],
71
+ { maxBuffer: 32 * 1024 * 1024 },
72
+ );
73
+
74
+ return stdout
75
+ .split("\0")
76
+ .filter((entry) => entry.endsWith("/") && entry !== ".jj/")
77
+ .map((entry) => entry.slice(0, -1));
78
+ } catch {
79
+ return [];
80
+ }
81
+ }
82
+
40
83
  // A checkout's own state, per gitrepository-layout(5).
41
84
  const CHECKOUT_STATE =
42
85
  /^(?:index|[A-Z_]+|(?:rebase-merge|rebase-apply|sequencer|refs\/(?:bisect|worktree|rewritten))(?:\/.*)?)$/;
43
86
 
87
+ /** Objects, reflogs, LFS objects and Review's own checkouts; never watched. */
88
+ export const INERT_GIT_PATHS = ["objects", "logs", "lfs", "dev-fast"];
89
+
44
90
  /** Objects, reflogs, locks and other checkouts' state cannot change this one. */
45
91
  export function gitEventMatters(name: string, prefix: string): boolean {
46
92
  const file = normalize(name);
package/src/review-app.ts CHANGED
@@ -100,7 +100,7 @@ export async function runReviewAppPick(
100
100
  const root = await runtime.resolveReviewRoot(input.cwd);
101
101
 
102
102
  const reviews = (await client.read<ReviewApiSummary[]>(""))
103
- .filter((review) => review.repositoryPath === root && !review.dismissedAt)
103
+ .filter((review) => review.repositoryPath === root && !review.trashedAt)
104
104
  .sort((a, b) => b.createdAt.localeCompare(a.createdAt));
105
105
 
106
106
  if (!reviews.length) throw new ReviewAppStateError("No review to show.");
@@ -53,7 +53,7 @@ export async function runReviewInfo(
53
53
  event: "info",
54
54
  reviews: reviews.filter(
55
55
  (review) =>
56
- review.repositoryPath === root && (input.all || !review.dismissedAt),
56
+ review.repositoryPath === root && (input.all || !review.trashedAt),
57
57
  ),
58
58
  };
59
59
  }
@@ -26,7 +26,7 @@ export async function resolveReviewStackLayers(
26
26
  subject: Pick<ReviewApiSummary, "origin"> & { reviewId?: string },
27
27
  reviews: readonly Pick<
28
28
  ReviewApiSummary,
29
- "reviewId" | "title" | "origin" | "pins" | "dismissedAt"
29
+ "reviewId" | "title" | "origin" | "pins" | "trashedAt"
30
30
  >[],
31
31
  runGitHubApi: RunGitHubApi = defaultRunGitHubApi,
32
32
  ): Promise<ReviewStackLayer[]> {
@@ -79,7 +79,7 @@ export async function resolveReviewStackLayers(
79
79
 
80
80
  const review =
81
81
  matches.find((candidate) => candidate.reviewId === subject.reviewId) ??
82
- matches.findLast((candidate) => !candidate.dismissedAt) ??
82
+ matches.findLast((candidate) => !candidate.trashedAt) ??
83
83
  matches.at(-1);
84
84
 
85
85
  return {
@@ -142,6 +142,21 @@ export function createWhiteboardCore(input: WhiteboardCoreInput) {
142
142
  const askThreads =
143
143
  input.ask && new AskThreads(launchAskAgent, input.ask.tools);
144
144
 
145
+ const closeDeletedThreads = () =>
146
+ askThreads?.closeDeletedReviews((id) =>
147
+ id.startsWith("shared-")
148
+ ? !shared?.has(id) || Boolean(shared.get(id).snapshot.trashedAt)
149
+ : !store.has(id) || Boolean(store.read(id).trashedAt),
150
+ );
151
+
152
+ const stopLocal =
153
+ askThreads &&
154
+ store.subscribe((result) => {
155
+ if (result.deleted || result.lifecycle) closeDeletedThreads();
156
+ });
157
+
158
+ const stopShared = askThreads && shared?.subscribe(closeDeletedThreads);
159
+
145
160
  const api = createReviewApi(
146
161
  store,
147
162
  data,
@@ -158,7 +173,16 @@ export function createWhiteboardCore(input: WhiteboardCoreInput) {
158
173
  // A shared store mounts the publisher with the rest of sharing.
159
174
  if (!shared) mountSharingPublisher(api, store, data);
160
175
 
161
- return { app, api, close: () => askThreads?.closeAll() };
176
+ return {
177
+ app,
178
+ api,
179
+ close: () => {
180
+ stopLocal?.();
181
+ stopShared?.();
182
+ closeDeletedThreads();
183
+ askThreads?.closeAll();
184
+ },
185
+ };
162
186
  }
163
187
 
164
188
  /** The Desktop callbacks `createReviewApi` takes, answered over the relay. */
@@ -4,10 +4,8 @@ import path from "node:path";
4
4
 
5
5
  import { resolveRevision } from "@dev.fast/local-vcs";
6
6
  import { writePrivateJsonAtomic } from "@dev.fast/trace-core";
7
- import {
8
- documentSchema,
9
- resourceReferences,
10
- } from "@review/review-api/document";
7
+ import { lensSchema } from "@review/review-api/diff-lenses";
8
+ import { documentSchema } from "@review/review-api/document";
11
9
  import type { LocalReviewData } from "@review/review-api/local-data";
12
10
  import type { ReviewStore, Snapshot } from "@review/review-api/store";
13
11
  import { devReviewHome } from "@review/review-home-paths";
@@ -18,6 +16,8 @@ const authoredSchema = z.strictObject({
18
16
  document: documentSchema,
19
17
  });
20
18
 
19
+ const lensesSchema = z.array(lensSchema.omit({ id: true }));
20
+
21
21
  const pinsSchema = z.strictObject({
22
22
  base: z.string().regex(/^[a-f0-9]{40}$/),
23
23
  head: z.string().regex(/^[a-f0-9]{40}$/),
@@ -30,12 +30,7 @@ const stampSchema = z.object({
30
30
  });
31
31
 
32
32
  export async function readTutorialAssets(assetsRoot: string) {
33
- const names = [
34
- "document.json",
35
- "trace.json",
36
- "software-map.json",
37
- "pins.json",
38
- ];
33
+ const names = ["document.json", "lenses.json", "pins.json"];
39
34
 
40
35
  const bytes = await Promise.all(
41
36
  names.map((name) => readFile(path.join(assetsRoot, name), "utf8")),
@@ -43,9 +38,8 @@ export async function readTutorialAssets(assetsRoot: string) {
43
38
 
44
39
  return {
45
40
  authored: authoredSchema.parse(JSON.parse(bytes[0]!)),
46
- trace: JSON.parse(bytes[1]!),
47
- model: JSON.parse(bytes[2]!),
48
- pins: pinsSchema.parse(JSON.parse(bytes[3]!)),
41
+ lenses: lensesSchema.parse(JSON.parse(bytes[1]!)),
42
+ pins: pinsSchema.parse(JSON.parse(bytes[2]!)),
49
43
  contentHash: createHash("sha256")
50
44
  .update(JSON.stringify(bytes))
51
45
  .digest("hex"),
@@ -68,38 +62,6 @@ export async function createNativeTutorial(input: {
68
62
  assets.pins.head,
69
63
  );
70
64
 
71
- const aliases = new Map<string, string>();
72
- const traceId = randomUUID();
73
- await input.data.upload({
74
- id: traceId,
75
- repositoryId: repository.id,
76
- kind: "trace",
77
- trace: assets.trace,
78
- });
79
- aliases.set("tutorial-trace", traceId);
80
-
81
- for (const side of ["base", "head"] as const) {
82
- const id = randomUUID();
83
- await input.data.upload({
84
- id,
85
- repositoryId: repository.id,
86
- kind: "map",
87
- pins,
88
- side,
89
- model: assets.model,
90
- });
91
- aliases.set(`tutorial-map-${side}`, id);
92
- }
93
-
94
- for (const block of resourceReferences(assets.authored.document)) {
95
- if (block.type === "trace_quote")
96
- block.traceId = aliases.get(block.traceId) ?? block.traceId;
97
-
98
- if (block.type === "software_map")
99
- block.mapVersionId =
100
- aliases.get(block.mapVersionId) ?? block.mapVersionId;
101
- }
102
-
103
65
  const result = await input.store.execute(
104
66
  {
105
67
  operation: {
@@ -111,6 +73,15 @@ export async function createNativeTutorial(input: {
111
73
  { document: assets.authored.document, origin: { tutorial: true } },
112
74
  );
113
75
 
76
+ for (const { title, targets } of assets.lenses)
77
+ await input.store.execute({
78
+ operation: {
79
+ type: "lens_edit",
80
+ reviewId: result.reviewId,
81
+ edit: { type: "insert", title, targets },
82
+ },
83
+ });
84
+
114
85
  return {
115
86
  snapshot: input.store.read(result.reviewId),
116
87
  contentHash: assets.contentHash,
@@ -163,10 +134,15 @@ export function createTutorialService(input: {
163
134
  }
164
135
 
165
136
  async function cleanup() {
166
- for (const reviewId of input.store.tutorialIds())
137
+ for (const reviewId of input.store.tutorialIds()) {
138
+ if (!input.store.read(reviewId).trashedAt)
139
+ await input.store.execute({
140
+ operation: { type: "lifecycle", state: "trash", reviewId },
141
+ });
167
142
  await input.store.execute({
168
143
  operation: { type: "delete", reviewId },
169
144
  });
145
+ }
170
146
 
171
147
  // Windows cannot delete a checkout while its Git reader or watchers
172
148
  // hold it open, and handles can outlive their close briefly.
@@ -68,6 +68,7 @@ export async function exportShare(input: {
68
68
  staleSources: _staleSources,
69
69
  sourceUnavailable: _sourceUnavailable,
70
70
  lastEdit: _lastEdit,
71
+ trashedAt: _trashedAt,
71
72
  ...snapshot
72
73
  } = structuredClone(input.store.read(input.reviewId, input.version));
73
74
 
@@ -251,10 +251,12 @@ export class SharedReviewStore {
251
251
  >();
252
252
  private readonly attention = new Map<
253
253
  string,
254
- { viewedAt: string | null; dismissedAt: string | null }
254
+ { viewedAt: string | null; trashedAt: string | null }
255
255
  >();
256
256
  private readonly listeners = new Set<() => void>();
257
257
  private catalogLoaded = false;
258
+ private readonly purgeTimer: ReturnType<typeof setInterval>;
259
+ private purgePending: Promise<void> | undefined;
258
260
  private readonly downloads = new Set<Promise<void>>();
259
261
 
260
262
  trackImport(job: Promise<void>) {
@@ -263,6 +265,8 @@ export class SharedReviewStore {
263
265
  }
264
266
 
265
267
  async close() {
268
+ clearInterval(this.purgeTimer);
269
+ await this.purgePending;
266
270
  await Promise.allSettled([...this.downloads, ...this.jobs.values()]);
267
271
  }
268
272
 
@@ -280,10 +284,38 @@ export class SharedReviewStore {
280
284
  constructor(
281
285
  readonly root: string,
282
286
  private readonly fetchRepository = fetchPinnedRepository,
283
- ) {}
287
+ ) {
288
+ this.purgeTimer = setInterval(() => {
289
+ void this.purgeExpired().catch((error) =>
290
+ console.error("Could not purge expired shared whiteboards:", error),
291
+ );
292
+ }, 60_000);
293
+ this.purgeTimer.unref();
294
+ }
295
+
296
+ private purgeExpired(): Promise<void> {
297
+ if (this.purgePending) return this.purgePending;
298
+
299
+ const expired = [...this.attention].filter(
300
+ ([, state]) =>
301
+ state.trashedAt &&
302
+ Date.parse(state.trashedAt) <= Date.now() - 30 * 86400000,
303
+ );
304
+
305
+ const pending = (async () => {
306
+ for (const [id] of expired) await this.removeLocal(id);
307
+ })();
308
+
309
+ this.purgePending = pending.finally(() => {
310
+ this.purgePending = undefined;
311
+ });
312
+
313
+ return this.purgePending;
314
+ }
284
315
 
285
316
  connect(store: ReviewStore, data: LocalReviewData) {
286
317
  this.local = { store, data };
318
+ store.askHistory.setExternalWriteGuard((id) => this.assertWritable(id));
287
319
  data.workspaces.attachExternalReviews({
288
320
  has: (id) =>
289
321
  this.has(id) || (!this.catalogLoaded && id.startsWith("shared-")),
@@ -517,33 +549,58 @@ export class SharedReviewStore {
517
549
  "Reopen the share link to fetch its repository.",
518
550
  );
519
551
 
520
- try {
521
- this.attention.set(
522
- id,
523
- z
524
- .strictObject({
525
- viewedAt: z.string().nullable(),
526
- dismissedAt: z.string().nullable(),
527
- })
528
- .parse(
529
- JSON.parse(
530
- await readFile(
531
- path.join(this.root, id, "attention.json"),
532
- "utf8",
533
- ),
534
- ),
535
- ),
536
- );
537
- } catch {
538
- /* Attention is optional local metadata. */
552
+ const attentionPath = path.join(this.root, id, "attention.json");
553
+
554
+ if (existsSync(attentionPath)) {
555
+ const raw = JSON.parse(await readFile(attentionPath, "utf8"));
556
+
557
+ const migrated =
558
+ Object.hasOwn(raw, "dismissedAt") || Object.hasOwn(raw, "deletedAt");
559
+
560
+ const state = z
561
+ .strictObject({
562
+ viewedAt: z.string().nullable(),
563
+ trashedAt: z.string().nullable(),
564
+ })
565
+ .parse(
566
+ migrated
567
+ ? {
568
+ viewedAt: raw.viewedAt,
569
+ trashedAt: Object.hasOwn(raw, "deletedAt")
570
+ ? raw.deletedAt
571
+ : raw.dismissedAt
572
+ ? new Date().toISOString()
573
+ : null,
574
+ }
575
+ : raw,
576
+ );
577
+
578
+ this.attention.set(id, state);
579
+
580
+ if (migrated)
581
+ await writeFile(attentionPath, JSON.stringify(state), {
582
+ mode: 0o600,
583
+ });
539
584
  }
540
585
  }
541
586
 
587
+ await this.purgeExpired();
588
+
542
589
  this.catalogLoaded = true;
543
590
 
544
591
  for (const listener of this.listeners) listener();
545
592
  }
546
593
 
594
+ assertWritable(id: string) {
595
+ const trashedAt = this.attention.get(id)?.trashedAt;
596
+
597
+ if (trashedAt)
598
+ throw new ReviewInputError(
599
+ `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.`,
600
+ 409,
601
+ );
602
+ }
603
+
547
604
  get(id: string) {
548
605
  const bundle = this.loaded.get(id);
549
606
 
@@ -569,6 +626,7 @@ export class SharedReviewStore {
569
626
  snapshot: {
570
627
  ...validated.snapshot,
571
628
  reviewId: id,
629
+ trashedAt: this.attention.get(id)?.trashedAt ?? null,
572
630
  pins: validated.snapshot.pins
573
631
  ? { ...validated.snapshot.pins, repositoryId: repositoryId! }
574
632
  : undefined,
@@ -605,7 +663,7 @@ export class SharedReviewStore {
605
663
  snapshot.version === 0 ? snapshot.createdAt : undefined,
606
664
  repositoryName: "Shared review",
607
665
  viewedAt: this.attention.get(id)?.viewedAt ?? null,
608
- dismissedAt: this.attention.get(id)?.dismissedAt ?? null,
666
+ trashedAt: this.attention.get(id)?.trashedAt ?? null,
609
667
  };
610
668
  })
611
669
  .toArray();
@@ -649,6 +707,7 @@ export class SharedReviewStore {
649
707
  this.repositories.delete(id);
650
708
  this.states.delete(id);
651
709
  await rm(path.join(this.root, id), { recursive: true, force: true });
710
+ this.local?.store.askHistory.removeReview(id);
652
711
  this.loaded.delete(id);
653
712
  this.validated.delete(id);
654
713
  this.attention.delete(id);
@@ -656,18 +715,43 @@ export class SharedReviewStore {
656
715
  for (const listener of this.listeners) listener();
657
716
  }
658
717
 
659
- async setAttention(id: string, action: "view" | "dismiss" | "restore") {
718
+ async setAttention(id: string) {
719
+ await this.updateAttention(id, "view");
720
+ }
721
+
722
+ async setLifecycle(id: string, state: "active" | "trash") {
723
+ await this.updateAttention(id, state);
724
+ }
725
+
726
+ private async updateAttention(
727
+ id: string,
728
+ action: "view" | "active" | "trash",
729
+ ) {
660
730
  this.get(id);
661
731
 
732
+ const trashedAt = this.attention.get(id)?.trashedAt;
733
+
734
+ if (trashedAt && Date.parse(trashedAt) <= Date.now() - 30 * 86400000) {
735
+ await this.removeLocal(id);
736
+ throw new ReviewInputError(
737
+ "The 30-day recovery period has ended. This whiteboard was permanently deleted.",
738
+ 404,
739
+ );
740
+ }
741
+
742
+ if (action === "view") this.assertWritable(id);
743
+
662
744
  const state = this.attention.get(id) ?? {
663
745
  viewedAt: null,
664
- dismissedAt: null,
746
+ trashedAt: null,
665
747
  };
666
748
 
667
749
  if (action === "view") state.viewedAt = new Date().toISOString();
668
750
  else
669
- state.dismissedAt =
670
- action === "dismiss" ? new Date().toISOString() : null;
751
+ state.trashedAt =
752
+ action === "trash"
753
+ ? (state.trashedAt ?? new Date().toISOString())
754
+ : null;
671
755
  await writeFile(
672
756
  path.join(this.root, id, "attention.json"),
673
757
  JSON.stringify(state),
@@ -681,6 +765,7 @@ export class SharedReviewStore {
681
765
  async import(origin: string, shareId: string, bundle: ShareBundle) {
682
766
  const validated = validateShareBundle(bundle);
683
767
  const id = sharedReviewId(origin, shareId);
768
+ this.assertWritable(id);
684
769
  await mkdir(this.root, { recursive: true, mode: 0o700 });
685
770
  const staging = await mkdtemp(path.join(this.root, ".import-"));
686
771