@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.
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 +4 -3
  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
@@ -353,18 +353,32 @@ function documentReferences(
353
353
  });
354
354
 
355
355
  if (element.type === "database_lens")
356
- return element.useCases.flatMap((useCase) =>
357
- useCase.operations.flatMap((operation) =>
358
- select(operation.source, operation.pins ?? element.pins).map(
359
- (source) => ({
360
- id: operation.id!,
361
- source,
362
- label: operation.label,
363
- peek: true,
364
- }),
356
+ return [
357
+ ...Object.entries(element.stores).flatMap(([storeKey, store]) =>
358
+ Object.entries(store.collections).flatMap(([key, collection]) =>
359
+ collection.source
360
+ ? select(collection.source, element.pins).map((source) => ({
361
+ id: `${element.id}:schema:${storeKey}:${key}`,
362
+ source,
363
+ label: collection.label,
364
+ peek: true,
365
+ }))
366
+ : [],
365
367
  ),
366
368
  ),
367
- );
369
+ ...element.useCases.flatMap((useCase) =>
370
+ useCase.operations.flatMap((operation) =>
371
+ select(operation.source, operation.pins ?? element.pins).map(
372
+ (source) => ({
373
+ id: operation.id!,
374
+ source,
375
+ label: operation.label,
376
+ peek: true,
377
+ }),
378
+ ),
379
+ ),
380
+ ),
381
+ ];
368
382
 
369
383
  return [];
370
384
  });
@@ -226,6 +226,23 @@ export function createReviewApi(
226
226
  const sharedData = shared ? new SharedReviewData(shared) : undefined;
227
227
  const isShared = (id: string) => id.startsWith("shared-");
228
228
 
229
+ const assertWritable = (id: string) => {
230
+ if (isShared(id)) shared?.assertWritable(id);
231
+ else store.assertWritable(id);
232
+ };
233
+
234
+ const reviewAccess =
235
+ (access: "read" | "active" | "edit"): MiddlewareHandler =>
236
+ async (context, next) => {
237
+ const id = context.req.param("id")!;
238
+
239
+ if (access !== "read") assertWritable(id);
240
+
241
+ if (access === "edit" && isShared(id))
242
+ throw new ReviewInputError("Shared reviews are read-only.", 409);
243
+ await next();
244
+ };
245
+
229
246
  const sharedCommandSchema = z.object({
230
247
  operation: z.object({ reviewId: z.string().optional() }),
231
248
  });
@@ -257,13 +274,6 @@ export function createReviewApi(
257
274
 
258
275
  readReview(id, query.version);
259
276
 
260
- if (
261
- context.req.method !== "GET" &&
262
- !/\/(open|source|copy-context|environment)$/.test(context.req.path) &&
263
- !/\/workspaces\/[^/]+\/retry$/.test(context.req.path)
264
- )
265
- throw new ReviewInputError("Shared reviews are read-only.", 409);
266
-
267
277
  return next();
268
278
  };
269
279
 
@@ -378,7 +388,7 @@ export function createReviewApi(
378
388
  ),
379
389
  );
380
390
  });
381
- app.post("/:id/progress", async (context) => {
391
+ app.post("/:id/progress", reviewAccess("edit"), async (context) => {
382
392
  if (!data) throw new ReviewInputError("Source data is unavailable.", 409);
383
393
 
384
394
  const input = progressUpdateSchema.parse(
@@ -483,18 +493,22 @@ export function createReviewApi(
483
493
 
484
494
  // One route per agent tool; the path names the action.
485
495
  for (const action of ["begin", "update", "end"] as const)
486
- app.post(`/:id/activity/${action}`, async (context) => {
487
- const input = await readBoundedRequestJson(context.req.raw);
488
- const id = context.req.param("id");
489
- store.assertExists(id);
496
+ app.post(
497
+ `/:id/activity/${action}`,
498
+ reviewAccess("edit"),
499
+ async (context) => {
500
+ const input = await readBoundedRequestJson(context.req.raw);
501
+ const id = context.req.param("id");
502
+ store.assertExists(id);
490
503
 
491
- return context.json(
492
- store.activity.update(
493
- id,
494
- isJsonObject(input) ? { ...input, action } : input,
495
- ),
496
- );
497
- });
504
+ return context.json(
505
+ store.activity.update(
506
+ id,
507
+ isJsonObject(input) ? { ...input, action } : input,
508
+ ),
509
+ );
510
+ },
511
+ );
498
512
  app.get("/watch", async (context) => {
499
513
  const query = context.req.query("subscriptions");
500
514
 
@@ -663,7 +677,7 @@ export function createReviewApi(
663
677
  }
664
678
  };
665
679
 
666
- app.post("/:id/open", async (context) => {
680
+ app.post("/:id/open", reviewAccess("active"), async (context) => {
667
681
  const id = context.req.param("id");
668
682
 
669
683
  if (isShared(id)) await shared?.assertReady(id);
@@ -753,7 +767,7 @@ export function createReviewApi(
753
767
 
754
768
  return context.json(result);
755
769
  });
756
- app.post("/:id/navigator", async (context) => {
770
+ app.post("/:id/navigator", reviewAccess("edit"), async (context) => {
757
771
  if (remoteCaller(context))
758
772
  throw new ReviewInputError(
759
773
  "Source windows are not available for a review on another machine.",
@@ -886,7 +900,7 @@ export function createReviewApi(
886
900
  : environment,
887
901
  );
888
902
  });
889
- app.post("/:id/environment", async (context) => {
903
+ app.post("/:id/environment", reviewAccess("active"), async (context) => {
890
904
  const input = z
891
905
  .strictObject({ retry: z.boolean().optional() })
892
906
  .parse(await readBoundedRequestJson(context.req.raw));
@@ -913,14 +927,18 @@ export function createReviewApi(
913
927
 
914
928
  return context.json(data.workspaces.list(context.req.param("id")));
915
929
  });
916
- app.post("/:id/workspaces/:workspaceId/retry", async (context) => {
917
- return context.json(
918
- await data.workspaces.retry(
919
- context.req.param("id"),
920
- context.req.param("workspaceId"),
921
- ),
922
- );
923
- });
930
+ app.post(
931
+ "/:id/workspaces/:workspaceId/retry",
932
+ reviewAccess("active"),
933
+ async (context) => {
934
+ return context.json(
935
+ await data.workspaces.retry(
936
+ context.req.param("id"),
937
+ context.req.param("workspaceId"),
938
+ ),
939
+ );
940
+ },
941
+ );
924
942
  app.get("/:id/file", async (context) => {
925
943
  // Browsing can describe binaries; authoring reads still require text.
926
944
  const input = readQuerySchemas.file
@@ -1039,7 +1057,7 @@ export function createReviewApi(
1039
1057
  });
1040
1058
  }
1041
1059
 
1042
- app.post("/:id/copy-context", async (context) => {
1060
+ app.post("/:id/copy-context", reviewAccess("read"), async (context) => {
1043
1061
  const query = readQuerySchemas.get
1044
1062
  .pick({ version: true })
1045
1063
  .extend({ mode: coverageModeSchema })
@@ -1256,7 +1274,7 @@ export function createReviewApi(
1256
1274
  return context.json({ paths: files.slice(0, MENTION_LIMIT) });
1257
1275
  });
1258
1276
 
1259
- app.post("/:id/ask", async (context) => {
1277
+ app.post("/:id/ask", reviewAccess("edit"), async (context) => {
1260
1278
  const reviewId = context.req.param("id");
1261
1279
 
1262
1280
  const { version } = readQuerySchemas.get
@@ -1273,6 +1291,16 @@ export function createReviewApi(
1273
1291
  const id = crypto.randomUUID();
1274
1292
  const createdAt = new Date().toISOString();
1275
1293
 
1294
+ const initialContext = await askContext(
1295
+ input.agent,
1296
+ snapshot,
1297
+ checkout,
1298
+ input.selection,
1299
+ version,
1300
+ );
1301
+
1302
+ assertWritable(reviewId);
1303
+
1276
1304
  const thread = ask.threads.open({
1277
1305
  id,
1278
1306
  reviewId,
@@ -1309,13 +1337,7 @@ export function createReviewApi(
1309
1337
  title: input.selection.title,
1310
1338
  quote: target.kind === "text" ? target.quote : undefined,
1311
1339
  },
1312
- context: await askContext(
1313
- input.agent,
1314
- snapshot,
1315
- checkout,
1316
- input.selection,
1317
- version,
1318
- ),
1340
+ context: initialContext,
1319
1341
  question: input.question,
1320
1342
  });
1321
1343
 
@@ -1332,7 +1354,7 @@ export function createReviewApi(
1332
1354
 
1333
1355
  // The threads of every open Ask in a review, over one connection. Before
1334
1356
  // the route for one thread, which would read `watch` as its id.
1335
- app.post("/:id/ask/watch", async (context) => {
1357
+ app.post("/:id/ask/watch", reviewAccess("read"), async (context) => {
1336
1358
  const reviewId = context.req.param("id");
1337
1359
 
1338
1360
  readReview(reviewId);
@@ -1383,182 +1405,209 @@ export function createReviewApi(
1383
1405
 
1384
1406
  // A saved conversation: attach to it if it is still running, else start
1385
1407
  // the agent and load it, at the commit it was asked about.
1386
- app.post("/:id/ask/:threadId/open", async (context) => {
1387
- const reviewId = context.req.param("id");
1388
- const threadId = context.req.param("threadId");
1389
- const live = ask.threads.get(threadId);
1390
-
1391
- if (live?.reviewId === reviewId) return context.json({ threadId });
1392
- const record = store.askHistory.get(threadId);
1393
-
1394
- const { picks } = askOpenSchema.parse(
1395
- await readBoundedRequestJson(context.req.raw, undefined, {}),
1396
- );
1408
+ app.post(
1409
+ "/:id/ask/:threadId/open",
1410
+ reviewAccess("edit"),
1411
+ async (context) => {
1412
+ const reviewId = context.req.param("id");
1413
+ const threadId = context.req.param("threadId");
1414
+ const live = ask.threads.get(threadId);
1415
+
1416
+ if (live?.reviewId === reviewId) return context.json({ threadId });
1417
+ const record = store.askHistory.get(threadId);
1418
+
1419
+ const { picks } = askOpenSchema.parse(
1420
+ await readBoundedRequestJson(context.req.raw, undefined, {}),
1421
+ );
1397
1422
 
1398
- if (record?.reviewId !== reviewId)
1399
- throw new ReviewInputError("This conversation was not found.", 404);
1423
+ if (record?.reviewId !== reviewId)
1424
+ throw new ReviewInputError("This conversation was not found.", 404);
1400
1425
 
1401
- const snapshot = readReview(reviewId, record.version);
1402
- const checkout = await data.agentCheckout(snapshot);
1403
- const target = record.selection.target;
1426
+ const snapshot = readReview(reviewId, record.version);
1427
+ const checkout = await data.agentCheckout(snapshot);
1428
+ const target = record.selection.target;
1404
1429
 
1405
- ask.threads.open({
1406
- id: record.id,
1407
- reviewId,
1408
- agent: record.agent,
1409
- cwd: checkout.rootPath,
1410
- head: checkout.head,
1411
- selection: {
1412
- title: record.selection.title,
1413
- quote: target.kind === "text" ? target.quote : undefined,
1414
- },
1415
- resume: { sessionId: record.sessionId, entries: record.entries },
1416
- // For a new session, should the agent no longer have this one.
1417
- context: await askContext(
1430
+ const initialContext = await askContext(
1418
1431
  record.agent,
1419
1432
  snapshot,
1420
1433
  checkout,
1421
1434
  record.selection,
1422
1435
  record.version,
1423
- ),
1424
- onSession: (sessionId) =>
1425
- store.askHistory.updateSession(record.id, sessionId),
1426
- onTurn: () => store.askHistory.touch(record.id),
1427
- onSave: (entries) => store.askHistory.saveEntries(record.id, entries),
1428
- picks,
1429
- bypass: record.bypass,
1430
- onBypass: (bypass) => store.askHistory.setBypass(record.id, bypass),
1431
- onOffer: (offer) => store.askHistory.saveOffer(record.agent, offer),
1432
- onTitle: (title) => store.askHistory.rename(record.id, title),
1433
- });
1436
+ );
1434
1437
 
1435
- return context.json({ threadId: record.id });
1436
- });
1438
+ assertWritable(reviewId);
1439
+ ask.threads.open({
1440
+ id: record.id,
1441
+ reviewId,
1442
+ agent: record.agent,
1443
+ cwd: checkout.rootPath,
1444
+ head: checkout.head,
1445
+ selection: {
1446
+ title: record.selection.title,
1447
+ quote: target.kind === "text" ? target.quote : undefined,
1448
+ },
1449
+ resume: { sessionId: record.sessionId, entries: record.entries },
1450
+ // For a new session, should the agent no longer have this one.
1451
+ context: initialContext,
1452
+ onSession: (sessionId) =>
1453
+ store.askHistory.updateSession(record.id, sessionId),
1454
+ onTurn: () => store.askHistory.touch(record.id),
1455
+ onSave: (entries) => store.askHistory.saveEntries(record.id, entries),
1456
+ picks,
1457
+ bypass: record.bypass,
1458
+ onBypass: (bypass) => store.askHistory.setBypass(record.id, bypass),
1459
+ onOffer: (offer) => store.askHistory.saveOffer(record.agent, offer),
1460
+ onTitle: (title) => store.askHistory.rename(record.id, title),
1461
+ });
1437
1462
 
1438
- app.post("/:id/ask/:threadId/prompt", async (context) => {
1439
- const thread = readThread(
1440
- context.req.param("id"),
1441
- context.req.param("threadId"),
1442
- );
1463
+ return context.json({ threadId: record.id });
1464
+ },
1465
+ );
1443
1466
 
1444
- const { question } = askFollowUpSchema.parse(
1445
- await readBoundedRequestJson(context.req.raw, ASK_REQUEST_MAX_BYTES),
1446
- );
1467
+ app.post(
1468
+ "/:id/ask/:threadId/prompt",
1469
+ reviewAccess("edit"),
1470
+ async (context) => {
1471
+ const thread = readThread(
1472
+ context.req.param("id"),
1473
+ context.req.param("threadId"),
1474
+ );
1447
1475
 
1448
- const refusal = thread.askRefusal();
1476
+ const { question } = askFollowUpSchema.parse(
1477
+ await readBoundedRequestJson(context.req.raw, ASK_REQUEST_MAX_BYTES),
1478
+ );
1449
1479
 
1450
- if (refusal) throw new ReviewInputError(refusal, 409);
1480
+ const refusal = thread.askRefusal();
1451
1481
 
1452
- void thread.ask(question);
1482
+ if (refusal) throw new ReviewInputError(refusal, 409);
1453
1483
 
1454
- return context.json({ ok: true });
1455
- });
1484
+ void thread.ask(question);
1456
1485
 
1457
- app.post("/:id/ask/:threadId/permission", async (context) => {
1458
- const thread = readThread(
1459
- context.req.param("id"),
1460
- context.req.param("threadId"),
1461
- );
1486
+ return context.json({ ok: true });
1487
+ },
1488
+ );
1462
1489
 
1463
- const decision = askDecisionSchema.parse(
1464
- await readBoundedRequestJson(context.req.raw),
1465
- );
1490
+ app.post(
1491
+ "/:id/ask/:threadId/permission",
1492
+ reviewAccess("edit"),
1493
+ async (context) => {
1494
+ const thread = readThread(
1495
+ context.req.param("id"),
1496
+ context.req.param("threadId"),
1497
+ );
1466
1498
 
1467
- if (!thread.decide(decision.permissionId, decision.optionId))
1468
- throw new ReviewInputError("This request was already answered.", 409);
1499
+ const decision = askDecisionSchema.parse(
1500
+ await readBoundedRequestJson(context.req.raw),
1501
+ );
1469
1502
 
1470
- return context.json({ ok: true });
1471
- });
1503
+ if (!thread.decide(decision.permissionId, decision.optionId))
1504
+ throw new ReviewInputError("This request was already answered.", 409);
1505
+
1506
+ return context.json({ ok: true });
1507
+ },
1508
+ );
1472
1509
 
1473
1510
  // The files an answer names, as the checkout's own paths, so the panel
1474
1511
  // can open them.
1475
- app.post("/:id/ask/:threadId/files", async (context) => {
1476
- const thread = readThread(
1477
- context.req.param("id"),
1478
- context.req.param("threadId"),
1479
- );
1512
+ app.post(
1513
+ "/:id/ask/:threadId/files",
1514
+ reviewAccess("edit"),
1515
+ async (context) => {
1516
+ const thread = readThread(
1517
+ context.req.param("id"),
1518
+ context.req.param("threadId"),
1519
+ );
1480
1520
 
1481
- const { paths } = askFilesSchema.parse(
1482
- await readBoundedRequestJson(context.req.raw),
1483
- );
1521
+ const { paths } = askFilesSchema.parse(
1522
+ await readBoundedRequestJson(context.req.raw),
1523
+ );
1484
1524
 
1485
- const { cwd, entries } = thread.read();
1525
+ const { cwd, entries } = thread.read();
1486
1526
 
1487
- const touched = entries.flatMap((entry) =>
1488
- entry.kind === "tool"
1489
- ? `${entry.title} ${entry.input ?? ""}`
1490
- .split(/\s+/)
1491
- .flatMap((token) => parseFileRef(token)?.path ?? [])
1492
- : [],
1493
- );
1527
+ const touched = entries.flatMap((entry) =>
1528
+ entry.kind === "tool"
1529
+ ? `${entry.title} ${entry.input ?? ""}`
1530
+ .split(/\s+/)
1531
+ .flatMap((token) => parseFileRef(token)?.path ?? [])
1532
+ : [],
1533
+ );
1494
1534
 
1495
- const files = resolveFileRefs(
1496
- cwd,
1497
- await checkoutFiles(cwd),
1498
- paths,
1499
- touched,
1500
- );
1535
+ const files = resolveFileRefs(
1536
+ cwd,
1537
+ await checkoutFiles(cwd),
1538
+ paths,
1539
+ touched,
1540
+ );
1501
1541
 
1502
- return context.json({
1503
- files: [...files].map(([path, file]) => ({ path, file })),
1504
- });
1505
- });
1542
+ return context.json({
1543
+ files: [...files].map(([path, file]) => ({ path, file })),
1544
+ });
1545
+ },
1546
+ );
1506
1547
 
1507
1548
  // Another model or effort for the next answer.
1508
- app.post("/:id/ask/:threadId/choice", async (context) => {
1509
- const thread = readThread(
1510
- context.req.param("id"),
1511
- context.req.param("threadId"),
1512
- );
1513
-
1514
- const { kind, value } = askChoiceSchema.parse(
1515
- await readBoundedRequestJson(context.req.raw),
1516
- );
1549
+ app.post(
1550
+ "/:id/ask/:threadId/choice",
1551
+ reviewAccess("edit"),
1552
+ async (context) => {
1553
+ const thread = readThread(
1554
+ context.req.param("id"),
1555
+ context.req.param("threadId"),
1556
+ );
1517
1557
 
1518
- if (thread.read().status !== "idle")
1519
- throw new ReviewInputError(
1520
- "Settings can change once the agent finishes answering.",
1521
- 409,
1558
+ const { kind, value } = askChoiceSchema.parse(
1559
+ await readBoundedRequestJson(context.req.raw),
1522
1560
  );
1523
1561
 
1524
- try {
1525
- await thread.choose(kind, value);
1526
- } catch (error) {
1527
- throw new ReviewInputError(errorMessage(error), 409);
1528
- }
1562
+ if (thread.read().status !== "idle")
1563
+ throw new ReviewInputError(
1564
+ "Settings can change once the agent finishes answering.",
1565
+ 409,
1566
+ );
1529
1567
 
1530
- return context.json({ ok: true });
1531
- });
1568
+ try {
1569
+ await thread.choose(kind, value);
1570
+ } catch (error) {
1571
+ throw new ReviewInputError(errorMessage(error), 409);
1572
+ }
1532
1573
 
1533
- // Bypasses permissions from the next answer, or stops.
1534
- app.post("/:id/ask/:threadId/permissions", async (context) => {
1535
- const thread = readThread(
1536
- context.req.param("id"),
1537
- context.req.param("threadId"),
1538
- );
1574
+ return context.json({ ok: true });
1575
+ },
1576
+ );
1539
1577
 
1540
- const { bypass } = askPermitSchema.parse(
1541
- await readBoundedRequestJson(context.req.raw),
1542
- );
1578
+ // Bypasses permissions from the next answer, or stops.
1579
+ app.post(
1580
+ "/:id/ask/:threadId/permissions",
1581
+ reviewAccess("edit"),
1582
+ async (context) => {
1583
+ const thread = readThread(
1584
+ context.req.param("id"),
1585
+ context.req.param("threadId"),
1586
+ );
1543
1587
 
1544
- if (thread.read().status !== "idle")
1545
- throw new ReviewInputError(
1546
- "Settings can change once the agent finishes answering.",
1547
- 409,
1588
+ const { bypass } = askPermitSchema.parse(
1589
+ await readBoundedRequestJson(context.req.raw),
1548
1590
  );
1549
1591
 
1550
- try {
1551
- await thread.permit(bypass);
1552
- } catch (error) {
1553
- throw new ReviewInputError(errorMessage(error), 409);
1554
- }
1592
+ if (thread.read().status !== "idle")
1593
+ throw new ReviewInputError(
1594
+ "Settings can change once the agent finishes answering.",
1595
+ 409,
1596
+ );
1555
1597
 
1556
- return context.json({ ok: true });
1557
- });
1598
+ try {
1599
+ await thread.permit(bypass);
1600
+ } catch (error) {
1601
+ throw new ReviewInputError(errorMessage(error), 409);
1602
+ }
1603
+
1604
+ return context.json({ ok: true });
1605
+ },
1606
+ );
1558
1607
 
1559
1608
  // Starts a failed agent again, as after signing it back in, and asks
1560
1609
  // again what it did not answer. The state reports how that goes.
1561
- app.post("/:id/ask/:threadId/retry", (context) => {
1610
+ app.post("/:id/ask/:threadId/retry", reviewAccess("edit"), (context) => {
1562
1611
  const thread = readThread(
1563
1612
  context.req.param("id"),
1564
1613
  context.req.param("threadId"),
@@ -1574,26 +1623,33 @@ export function createReviewApi(
1574
1623
  return context.json({ ok: true });
1575
1624
  });
1576
1625
 
1577
- app.post("/:id/ask/:threadId/cancel", async (context) => {
1578
- await readThread(
1579
- context.req.param("id"),
1580
- context.req.param("threadId"),
1581
- ).cancel();
1626
+ app.post(
1627
+ "/:id/ask/:threadId/cancel",
1628
+ reviewAccess("read"),
1629
+ async (context) => {
1630
+ await readThread(
1631
+ context.req.param("id"),
1632
+ context.req.param("threadId"),
1633
+ ).cancel();
1582
1634
 
1583
- return context.json({ ok: true });
1584
- });
1635
+ return context.json({ ok: true });
1636
+ },
1637
+ );
1585
1638
 
1586
1639
  // Closing an Ask ends its agent; the conversation stays saved. A tab
1587
1640
  // switched away from or a reload leaves it running, to come back to.
1588
- app.post("/:id/ask/:threadId/close", (context) => {
1641
+ app.post("/:id/ask/:threadId/close", reviewAccess("read"), (context) => {
1589
1642
  readThread(context.req.param("id"), context.req.param("threadId"));
1590
- ask.threads.close(context.req.param("threadId"));
1643
+ ask.threads.close(
1644
+ context.req.param("threadId"),
1645
+ !readReview(context.req.param("id")).trashedAt,
1646
+ );
1591
1647
 
1592
1648
  return context.json({ ok: true });
1593
1649
  });
1594
1650
 
1595
1651
  // Forgets a saved conversation. The agent keeps its own transcript.
1596
- app.delete("/:id/ask/:threadId", (context) => {
1652
+ app.delete("/:id/ask/:threadId", reviewAccess("edit"), (context) => {
1597
1653
  const reviewId = context.req.param("id");
1598
1654
  const threadId = context.req.param("threadId");
1599
1655
 
@@ -1733,6 +1789,11 @@ export function createReviewApi(
1733
1789
  const parsed = commandSchema.parse(input);
1734
1790
 
1735
1791
  if (shared && parsed.operation.type === "delete") {
1792
+ if (!shared.get(parsed.operation.reviewId).snapshot.trashedAt)
1793
+ throw new ReviewInputError(
1794
+ "Move this whiteboard to Trash before deleting it.",
1795
+ 409,
1796
+ );
1736
1797
  await shared.removeLocal(parsed.operation.reviewId);
1737
1798
 
1738
1799
  return context.json({
@@ -1742,11 +1803,22 @@ export function createReviewApi(
1742
1803
  });
1743
1804
  }
1744
1805
 
1745
- if (shared && parsed.operation.type === "attention") {
1746
- await shared.setAttention(
1747
- parsed.operation.reviewId,
1748
- parsed.operation.action,
1749
- );
1806
+ if (
1807
+ shared &&
1808
+ (parsed.operation.type === "attention" ||
1809
+ parsed.operation.type === "lifecycle")
1810
+ ) {
1811
+ if (parsed.operation.type === "lifecycle")
1812
+ await shared.setLifecycle(
1813
+ parsed.operation.reviewId,
1814
+ parsed.operation.state,
1815
+ );
1816
+ else await shared.setAttention(parsed.operation.reviewId);
1817
+
1818
+ const reviewId = parsed.operation.reviewId;
1819
+
1820
+ if (shared.get(reviewId).snapshot.trashedAt)
1821
+ ask?.threads.closeDeletedReviews((id) => id === reviewId);
1750
1822
 
1751
1823
  return context.json({
1752
1824
  reviewId: parsed.operation.reviewId,
@@ -1760,6 +1832,12 @@ export function createReviewApi(
1760
1832
 
1761
1833
  const result = await store.execute(input);
1762
1834
 
1835
+ if (
1836
+ result.deleted ||
1837
+ (result.lifecycle && store.read(result.reviewId).trashedAt)
1838
+ )
1839
+ ask?.threads.closeDeletedReviews((id) => id === result.reviewId);
1840
+
1763
1841
  if (input.operation.type === "edit" && data && !result.deleted) {
1764
1842
  const { quotes, unquoted } = await anchorQuotes(
1765
1843
  result.version > 0