@agent-native/core 0.227.0 → 0.228.0-nightly-20261111004142

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 (190) hide show
  1. package/dist/action.d.ts +6 -2
  2. package/dist/agent/engine/builder-engine.js +6 -1
  3. package/dist/agent/engine/tool-call-journal-seed.d.ts +2 -0
  4. package/dist/agent/engine/tool-call-journal-seed.js +14 -0
  5. package/dist/agent/harness/store.js +14 -0
  6. package/dist/agent/production-agent.js +257 -17
  7. package/dist/agent/reaped-run-thread.js +6 -0
  8. package/dist/agent/run-manager.js +6 -0
  9. package/dist/agent/tool-call-journal.d.ts +4 -0
  10. package/dist/agent/tool-call-journal.js +12 -0
  11. package/dist/agent/types.d.ts +7 -1
  12. package/dist/app-config/agent.d.ts +3 -0
  13. package/dist/app-config/agent.js +7 -0
  14. package/dist/app-config/schema.d.ts +3 -0
  15. package/dist/application-state/handlers.d.ts +3 -3
  16. package/dist/application-state/handlers.js +6 -1
  17. package/dist/application-state/index.d.ts +3 -2
  18. package/dist/application-state/index.js +3 -2
  19. package/dist/application-state/last-used-tab.d.ts +11 -0
  20. package/dist/application-state/last-used-tab.js +43 -0
  21. package/dist/application-state/script-helpers.d.ts +26 -0
  22. package/dist/application-state/script-helpers.js +83 -15
  23. package/dist/application-state/store.d.ts +8 -0
  24. package/dist/application-state/store.js +16 -0
  25. package/dist/cli/agent.js +7 -3
  26. package/dist/cli/code-agent-executor.d.ts +1 -0
  27. package/dist/cli/code-agent-executor.js +37 -2
  28. package/dist/cli/code.js +3 -1
  29. package/dist/client/agent-sidebar-state.d.ts +2 -0
  30. package/dist/client/browser-tab-use.d.ts +9 -0
  31. package/dist/client/browser-tab-use.js +112 -0
  32. package/dist/client/chat/runtime.js +3 -1
  33. package/dist/client/error-capture.js +33 -1
  34. package/dist/client/navigation/index.d.ts +1 -0
  35. package/dist/client/navigation/index.js +1 -0
  36. package/dist/client/resources/mcp-integration-logos.js +16 -0
  37. package/dist/client/review/use-review.d.ts +28 -0
  38. package/dist/client/review/use-review.js +41 -12
  39. package/dist/client/route-state.js +2 -0
  40. package/dist/client/sse-event-processor.d.ts +1 -0
  41. package/dist/client/sse-event-processor.js +7 -1
  42. package/dist/client/use-db-sync.d.ts +8 -1
  43. package/dist/client/use-db-sync.js +6 -5
  44. package/dist/coding-tools/run-code.js +12 -2
  45. package/dist/coding-tools/sandbox/executions-store.d.ts +1 -1
  46. package/dist/coding-tools/sandbox/executions-store.js +9 -3
  47. package/dist/collab/routes.d.ts +1 -1
  48. package/dist/data-programs/execute.js +1 -1
  49. package/dist/db/schema-snapshot.generated.js +17 -3
  50. package/dist/extensions/url-safety.d.ts +1 -3
  51. package/dist/extensions/url-safety.js +23 -23
  52. package/dist/file-upload/actions/upload-image.d.ts +5 -5
  53. package/dist/file-upload/index.d.ts +1 -1
  54. package/dist/file-upload/index.js +1 -1
  55. package/dist/file-upload/s3-client.d.ts +1 -0
  56. package/dist/file-upload/s3-client.js +70 -13
  57. package/dist/file-upload/s3.d.ts +9 -0
  58. package/dist/file-upload/s3.js +14 -1
  59. package/dist/framework-package-versions.json +8 -8
  60. package/dist/jobs/run-history.js +10 -3
  61. package/dist/jobs/scheduler.js +5 -36
  62. package/dist/localization/core-messages/supplemental/ar-SA.js +35 -0
  63. package/dist/localization/core-messages/supplemental/de-DE.js +35 -0
  64. package/dist/localization/core-messages/supplemental/en-US.js +35 -0
  65. package/dist/localization/core-messages/supplemental/es-ES.js +35 -0
  66. package/dist/localization/core-messages/supplemental/fr-FR.js +35 -0
  67. package/dist/localization/core-messages/supplemental/hi-IN.js +35 -0
  68. package/dist/localization/core-messages/supplemental/ja-JP.js +35 -0
  69. package/dist/localization/core-messages/supplemental/ko-KR.js +35 -0
  70. package/dist/localization/core-messages/supplemental/pt-BR.js +35 -0
  71. package/dist/localization/core-messages/supplemental/zh-CN.js +35 -0
  72. package/dist/localization/core-messages/supplemental/zh-TW.js +35 -0
  73. package/dist/localization/default-messages.d.ts +35 -0
  74. package/dist/localization/default-messages.js +35 -0
  75. package/dist/localization/mcp-settings-messages.d.ts +35 -0
  76. package/dist/mcp/actions/list-mcp-client-connections.d.ts +8 -0
  77. package/dist/mcp/actions/list-mcp-client-connections.js +39 -0
  78. package/dist/mcp/browser-tab-target.d.ts +16 -0
  79. package/dist/mcp/browser-tab-target.js +58 -0
  80. package/dist/mcp/build-server.d.ts +1 -0
  81. package/dist/mcp/build-server.js +38 -7
  82. package/dist/mcp/call-attribution.d.ts +4 -1
  83. package/dist/mcp/call-attribution.js +3 -1
  84. package/dist/mcp/client-connections.d.ts +6 -0
  85. package/dist/mcp/client-connections.js +91 -0
  86. package/dist/mcp/connect-route.js +12 -2
  87. package/dist/mcp/connect-store.d.ts +3 -0
  88. package/dist/mcp/connect-store.js +10 -1
  89. package/dist/mcp/direct-action-call.js +1 -0
  90. package/dist/mcp/oauth-store.d.ts +6 -0
  91. package/dist/mcp/oauth-store.js +23 -0
  92. package/dist/mcp/oauth-token.d.ts +1 -0
  93. package/dist/mcp/oauth-token.js +1 -0
  94. package/dist/mcp/server.js +9 -20
  95. package/dist/mcp-client/config.d.ts +2 -0
  96. package/dist/mcp-client/manager.js +23 -0
  97. package/dist/mcp-client/workspace-servers.js +42 -10
  98. package/dist/notifications/routes.d.ts +3 -3
  99. package/dist/org/handlers.d.ts +1 -1
  100. package/dist/org/handlers.js +21 -17
  101. package/dist/provider-api/actions/custom-provider-registration.d.ts +12 -12
  102. package/dist/provider-api/actions/provider-api.d.ts +8 -8
  103. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  104. package/dist/provider-api/index.js +17 -36
  105. package/dist/review/actions/create-review-comment.d.ts +7 -0
  106. package/dist/review/actions/create-review-comment.js +29 -3
  107. package/dist/review/actions/delete-review-comment.js +2 -2
  108. package/dist/review/actions/list-review-comments.d.ts +3 -0
  109. package/dist/review/actions/list-review-comments.js +41 -17
  110. package/dist/review/actions/react-to-review-comment.js +2 -2
  111. package/dist/review/actions/reply-review-comment.d.ts +4 -0
  112. package/dist/review/actions/reply-review-comment.js +15 -6
  113. package/dist/review/actions/update-review-comment-anchor.d.ts +6 -0
  114. package/dist/review/actions/update-review-comment-anchor.js +39 -10
  115. package/dist/review/actions/update-review-comment.d.ts +3 -0
  116. package/dist/review/actions/update-review-comment.js +28 -3
  117. package/dist/review/errors.d.ts +33 -0
  118. package/dist/review/errors.js +51 -0
  119. package/dist/review/index.d.ts +5 -4
  120. package/dist/review/index.js +3 -2
  121. package/dist/review/registry.d.ts +51 -1
  122. package/dist/review/registry.js +117 -0
  123. package/dist/review/store.d.ts +59 -14
  124. package/dist/review/store.js +622 -101
  125. package/dist/review/suggestions/actions.js +65 -10
  126. package/dist/review/suggestions/actor-kind.d.ts +5 -0
  127. package/dist/review/suggestions/actor-kind.js +20 -0
  128. package/dist/review/suggestions/store.js +16 -2
  129. package/dist/review/suggestions/types.d.ts +13 -0
  130. package/dist/review/types.d.ts +89 -0
  131. package/dist/scripts/db/dev-query-proxy.d.ts +2 -0
  132. package/dist/scripts/db/dev-query-proxy.js +4 -1
  133. package/dist/scripts/db/query.js +3 -0
  134. package/dist/scripts/dev/shell.js +4 -0
  135. package/dist/scripts/dev-session.d.ts +31 -0
  136. package/dist/scripts/dev-session.js +58 -0
  137. package/dist/scripts/resources/list.js +53 -4
  138. package/dist/scripts/runner.js +22 -4
  139. package/dist/search/query.d.ts +11 -5
  140. package/dist/search/query.js +52 -15
  141. package/dist/search/tokenize.d.ts +1 -0
  142. package/dist/search/tokenize.js +3 -0
  143. package/dist/secrets/api-keys.d.ts +4 -0
  144. package/dist/secrets/api-keys.js +5 -0
  145. package/dist/secrets/register.d.ts +2 -0
  146. package/dist/secrets/routes.d.ts +2 -2
  147. package/dist/secrets/routes.js +85 -6
  148. package/dist/secrets/storage.d.ts +2 -0
  149. package/dist/secrets/storage.js +34 -9
  150. package/dist/secrets/substitution.d.ts +3 -2
  151. package/dist/secrets/substitution.js +56 -7
  152. package/dist/server/action-discovery.js +5 -0
  153. package/dist/server/action-routes.js +8 -6
  154. package/dist/server/agent-chat/prompt-resources.js +117 -79
  155. package/dist/server/agent-chat/script-entries.js +15 -8
  156. package/dist/server/agent-chat-plugin.js +9 -4
  157. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  158. package/dist/server/agent-engine-ollama-models-route.d.ts +1 -1
  159. package/dist/server/agents-bundle.d.ts +3 -1
  160. package/dist/server/agents-bundle.js +4 -4
  161. package/dist/server/csrf.js +1 -0
  162. package/dist/server/dev-action-bridge.d.ts +2 -0
  163. package/dist/server/dev-action-bridge.js +34 -5
  164. package/dist/server/first-touch-route.d.ts +1 -1
  165. package/dist/server/http-response-telemetry.js +4 -0
  166. package/dist/server/loopback.js +3 -1
  167. package/dist/server/request-context.d.ts +26 -0
  168. package/dist/shared/app-state-routes.d.ts +7 -0
  169. package/dist/shared/app-state-routes.js +7 -0
  170. package/dist/shared/mcp-client-connections.d.ts +41 -0
  171. package/dist/shared/mcp-client-connections.js +18 -0
  172. package/dist/shared/mcp-connect-content.d.ts +18 -0
  173. package/dist/shared/mcp-connect-content.js +200 -172
  174. package/dist/shared/runtime-config.js +5 -1
  175. package/dist/templates/chat/.agents/skills/context-awareness/SKILL.md +13 -0
  176. package/dist/templates/default/.agents/skills/context-awareness/SKILL.md +13 -0
  177. package/dist/templates/workspace-core/.agents/skills/context-awareness/SKILL.md +13 -0
  178. package/dist/tracking/providers.d.ts +7 -0
  179. package/dist/tracking/providers.js +39 -17
  180. package/dist/triggers/condition-evaluator.d.ts +2 -0
  181. package/dist/triggers/condition-evaluator.js +93 -3
  182. package/dist/triggers/dispatcher.js +24 -6
  183. package/docs/AGENTS.md +16 -16
  184. package/docs/content/create-with-an-agent.mdx +10 -4
  185. package/docs/content/durable-resume.mdx +11 -2
  186. package/docs/content/toolkit-comments-review.mdx +54 -1
  187. package/package.json +5 -4
  188. package/src/templates/chat/.agents/skills/context-awareness/SKILL.md +13 -0
  189. package/src/templates/default/.agents/skills/context-awareness/SKILL.md +13 -0
  190. package/src/templates/workspace-core/.agents/skills/context-awareness/SKILL.md +13 -0
@@ -2,6 +2,8 @@ import { getDbExec } from "../db/client.js";
2
2
  import { ensureColumnExists, ensureIndexExists, ensureTableExists, } from "../db/ddl-guard.js";
3
3
  import { sql } from "../db/sql.js";
4
4
  import { defineStore } from "../db/store-registry.js";
5
+ import { ReviewAnchorError, ReviewCommentLinkConflictError, ReviewCommentRevisionConflictError, ReviewCursorError, } from "./errors.js";
6
+ import { hasReviewCommentWriteHook, normalizeReviewAnchor, runReviewCommentWriteHook, } from "./registry.js";
5
7
  export const reviewStore = defineStore({
6
8
  id: "review",
7
9
  migrations: [
@@ -111,6 +113,32 @@ export const reviewStore = defineStore({
111
113
  await ensureIndexExists("idx_agent_review_statuses_resource", indexes[5]);
112
114
  },
113
115
  },
116
+ {
117
+ // What every app's comments need from one table: the surface a thread
118
+ // sits on, a typed and versioned anchor, a revision that goes up on
119
+ // every change, and links to the same comments in other systems. Each
120
+ // column is nullable or has a default, so code deployed before this
121
+ // keeps inserting.
122
+ name: "storage-contract",
123
+ sql: [
124
+ "ALTER TABLE agent_review_comments ADD COLUMN IF NOT EXISTS surface_id TEXT",
125
+ "ALTER TABLE agent_review_comments ADD COLUMN IF NOT EXISTS anchor_kind TEXT",
126
+ "ALTER TABLE agent_review_comments ADD COLUMN IF NOT EXISTS anchor_version INTEGER",
127
+ "ALTER TABLE agent_review_comments ADD COLUMN IF NOT EXISTS revision INTEGER NOT NULL DEFAULT 1",
128
+ `CREATE TABLE IF NOT EXISTS agent_review_comment_links (
129
+ provider TEXT NOT NULL,
130
+ external_kind TEXT NOT NULL,
131
+ external_id TEXT NOT NULL,
132
+ comment_id TEXT NOT NULL,
133
+ resource_type TEXT NOT NULL,
134
+ resource_id TEXT NOT NULL,
135
+ created_at TEXT NOT NULL,
136
+ PRIMARY KEY (provider, external_kind, external_id)
137
+ )`,
138
+ `CREATE INDEX IF NOT EXISTS idx_agent_review_comment_links_comment
139
+ ON agent_review_comment_links (comment_id)`,
140
+ ],
141
+ },
114
142
  ],
115
143
  });
116
144
  export function ensureReviewTables() {
@@ -257,13 +285,36 @@ export async function filterUnmutedReviewThreadRecipients(threadId, recipients)
257
285
  const muted = new Set(rows.map((row) => String(row.user_email)));
258
286
  return recipients.filter((email) => !muted.has(email));
259
287
  }
288
+ /**
289
+ * Runs a comment write in one transaction, so the resource's write hook
290
+ * commits or rolls back with it. Pass the resource type when it's known.
291
+ */
292
+ function inReviewWrite(resourceType, write) {
293
+ const client = getDbExec();
294
+ if (client.transaction)
295
+ return client.transaction(write);
296
+ assertReviewWriteCanRollBack(resourceType);
297
+ return write(client);
298
+ }
299
+ /**
300
+ * A write hook refuses a write by throwing, which only a transaction can
301
+ * undo. Every client core ships has transactions; one without them, such as a
302
+ * test double, is refused a write to a hooked resource before anything
303
+ * changes. A write that names no resource is checked against the resources
304
+ * it would touch, read first, so another resource's hook doesn't refuse it.
305
+ */
306
+ function assertReviewWriteCanRollBack(...resourceTypes) {
307
+ if (resourceTypes.some((type) => hasReviewCommentWriteHook(type))) {
308
+ throw new Error("This database client has no transactions, so it can't run a comment write that has a write hook.");
309
+ }
310
+ }
260
311
  export async function insertReviewComment(input) {
261
312
  await ensureReviewTables();
262
- return insertReviewCommentWithClient(input, getDbExec());
313
+ return inReviewWrite(input.resourceType, (tx) => insertReviewCommentWithClient(input, tx));
263
314
  }
264
315
  export async function insertReviewCommentIdempotently(input) {
265
316
  await ensureReviewTables();
266
- return insertReviewCommentIdempotentlyWithClient(input, getDbExec());
317
+ return inReviewWrite(input.resourceType, (tx) => insertReviewCommentIdempotentlyWithClient(input, tx));
267
318
  }
268
319
  export async function reviewCommentNotificationCompleted(id) {
269
320
  await ensureReviewTables();
@@ -420,6 +471,7 @@ export async function insertReviewReply(input, routeTarget, resource) {
420
471
  };
421
472
  if (client.transaction)
422
473
  return client.transaction(insertAndRoute);
474
+ assertReviewWriteCanRollBack(resource.resourceType);
423
475
  // raw-sql-ok: the helper's statement is constant text; the ids are bound.
424
476
  await assertLiveReviewRoot(client, input.threadId, resource);
425
477
  const reply = await insertReviewCommentWithClient(input, client);
@@ -463,6 +515,7 @@ export async function insertReviewReplyIdempotently(input, routeTarget, resource
463
515
  };
464
516
  if (client.transaction)
465
517
  return client.transaction(insertAndRoute);
518
+ assertReviewWriteCanRollBack(resource.resourceType);
466
519
  // raw-sql-ok: the helper's statement is constant text; the ids are bound.
467
520
  await assertLiveReviewRoot(client, input.threadId, resource, input.id);
468
521
  const result = await insertReviewCommentIdempotentlyWithClient({ ...input, replyRouteTarget: routeTarget }, client);
@@ -496,11 +549,41 @@ async function assertMatchingReplyRouteTarget(client, commentId, routeTarget) {
496
549
  }
497
550
  }
498
551
  export async function insertReviewCommentWithClient(input, client) {
499
- return (await writeReviewCommentWithClient(input, client)).comment;
552
+ return (await writeReviewCommentWithClient(normalizeReviewCommentInput(input), client)).comment;
553
+ }
554
+ /**
555
+ * The input with its anchor checked against the resource's anchor kinds. A
556
+ * reply carries its thread's anchor as stored, so a thread begun before its
557
+ * resource declared kinds can still be answered.
558
+ */
559
+ function normalizeReviewCommentInput(input) {
560
+ if (input.parentCommentId)
561
+ return input;
562
+ return {
563
+ ...input,
564
+ ...normalizeReviewAnchor(input.resourceType, input),
565
+ };
500
566
  }
567
+ let lastReviewCommentWrittenAt = 0;
568
+ /**
569
+ * When a new comment is written. Comments written in the same millisecond
570
+ * list by their random ids, so on one server each new comment is stamped at
571
+ * least a millisecond after the last, keeping them in the order they were
572
+ * written. A clock that moved back a second or more, such as a test's fake
573
+ * clock, is taken as it is.
574
+ */
575
+ function reviewCommentWriteTime() {
576
+ const now = Date.now();
577
+ lastReviewCommentWrittenAt =
578
+ now > lastReviewCommentWrittenAt || lastReviewCommentWrittenAt - now >= 1000
579
+ ? now
580
+ : lastReviewCommentWrittenAt + 1;
581
+ return new Date(lastReviewCommentWrittenAt).toISOString();
582
+ }
583
+ /** Writes an already normalized comment, then runs the resource's hook. */
501
584
  async function writeReviewCommentWithClient(input, client, onConflictDoNothing = false) {
502
585
  const id = input.id ?? createReviewId("comment");
503
- const now = new Date().toISOString();
586
+ const now = reviewCommentWriteTime();
504
587
  const comment = {
505
588
  id,
506
589
  resourceType: input.resourceType,
@@ -508,9 +591,13 @@ async function writeReviewCommentWithClient(input, client, onConflictDoNothing =
508
591
  threadId: input.threadId ?? id,
509
592
  parentCommentId: input.parentCommentId ?? null,
510
593
  targetId: input.targetId ?? null,
594
+ surfaceId: input.surfaceId ?? null,
511
595
  kind: input.kind ?? "comment",
512
596
  status: "open",
513
597
  anchor: input.anchor ?? null,
598
+ anchorKind: input.anchorKind ?? null,
599
+ anchorVersion: input.anchorVersion ?? null,
600
+ revision: 1,
514
601
  body: input.body,
515
602
  authorEmail: input.authorEmail ?? null,
516
603
  authorName: input.authorName ?? null,
@@ -534,6 +621,7 @@ async function writeReviewCommentWithClient(input, client, onConflictDoNothing =
534
621
  ? input.metadata.resolutionNote
535
622
  : null,
536
623
  };
624
+ // raw-sql-ok: the column list is constant text; every value is bound.
537
625
  const result = await client.execute({
538
626
  sql: `INSERT INTO agent_review_comments (
539
627
  id,
@@ -542,9 +630,13 @@ async function writeReviewCommentWithClient(input, client, onConflictDoNothing =
542
630
  thread_id,
543
631
  parent_comment_id,
544
632
  target_id,
633
+ surface_id,
545
634
  kind,
546
635
  status,
547
636
  anchor_json,
637
+ anchor_kind,
638
+ anchor_version,
639
+ revision,
548
640
  body,
549
641
  author_email,
550
642
  author_name,
@@ -563,7 +655,7 @@ async function writeReviewCommentWithClient(input, client, onConflictDoNothing =
563
655
  created_at,
564
656
  updated_at,
565
657
  metadata_json
566
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)${onConflictDoNothing ? " ON CONFLICT (id) DO NOTHING" : ""}`,
658
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)${onConflictDoNothing ? " ON CONFLICT (id) DO NOTHING" : ""}`,
567
659
  args: [
568
660
  comment.id,
569
661
  comment.resourceType,
@@ -571,9 +663,13 @@ async function writeReviewCommentWithClient(input, client, onConflictDoNothing =
571
663
  comment.threadId,
572
664
  comment.parentCommentId,
573
665
  comment.targetId,
666
+ comment.surfaceId,
574
667
  comment.kind,
575
668
  comment.status,
576
669
  stringifyOptionalJson(comment.anchor),
670
+ comment.anchorKind,
671
+ comment.anchorVersion,
672
+ comment.revision,
577
673
  comment.body,
578
674
  comment.authorEmail,
579
675
  comment.authorName,
@@ -594,15 +690,22 @@ async function writeReviewCommentWithClient(input, client, onConflictDoNothing =
594
690
  stringifyOptionalJson(comment.metadata),
595
691
  ],
596
692
  });
597
- return { comment, replayed: (result.rowsAffected ?? 1) < 1 };
693
+ const replayed = result.rowsAffected < 1;
694
+ if (!replayed) {
695
+ await runReviewCommentWriteHook(comment.resourceType, { type: "created", comment }, client);
696
+ }
697
+ return { comment, replayed };
598
698
  }
599
- async function insertReviewCommentIdempotentlyWithClient(input, client) {
600
- const submitted = reviewCommentFromInput(input);
601
- const existing = await getReviewCommentByIdWithClient(client, submitted.id);
699
+ async function insertReviewCommentIdempotentlyWithClient(rawInput, client) {
700
+ // A retry is read the way its first submission was stored, so a change to
701
+ // the resource's anchor kinds since then doesn't turn it into a conflict.
702
+ const existing = await getReviewCommentByIdWithClient(client, rawInput.id);
602
703
  if (existing) {
603
- assertMatchingReviewCommentReceipt(existing, submitted);
704
+ assertMatchingReviewCommentReceipt(existing, reviewCommentFromInput(replayedReviewCommentInput(rawInput, existing)));
604
705
  return { comment: existing, replayed: true };
605
706
  }
707
+ const input = { ...normalizeReviewCommentInput(rawInput), id: rawInput.id };
708
+ const submitted = reviewCommentFromInput(input);
606
709
  const insertion = await writeReviewCommentWithClient(input, client, true);
607
710
  if (!insertion.replayed)
608
711
  return insertion;
@@ -613,6 +716,40 @@ async function insertReviewCommentIdempotentlyWithClient(input, client) {
613
716
  assertMatchingReviewCommentReceipt(receipt, submitted);
614
717
  return { comment: receipt, replayed: true };
615
718
  }
719
+ /**
720
+ * A retried submission normalized as its stored comment was: untyped if it
721
+ * was stored untyped, and otherwise at its stored anchor version unless the
722
+ * retry names one. A stored kind the resource no longer reads leaves the
723
+ * retry as sent, so a retry of what was stored still finds it and any other
724
+ * still conflicts.
725
+ */
726
+ function replayedReviewCommentInput(input, stored) {
727
+ if (input.parentCommentId)
728
+ return input;
729
+ if (stored.anchorKind == null &&
730
+ input.anchorKind == null &&
731
+ input.anchorVersion == null) {
732
+ return { ...input, anchorKind: null, anchorVersion: null };
733
+ }
734
+ const replayed = {
735
+ ...input,
736
+ anchorVersion: input.anchorVersion ??
737
+ ((input.anchorKind ?? null) === (stored.anchorKind ?? null)
738
+ ? (stored.anchorVersion ?? null)
739
+ : null),
740
+ };
741
+ try {
742
+ return normalizeReviewCommentInput(replayed);
743
+ }
744
+ catch (error) {
745
+ if (!(error instanceof ReviewAnchorError) ||
746
+ stored.anchorKind == null ||
747
+ input.anchorKind !== stored.anchorKind) {
748
+ throw error;
749
+ }
750
+ return replayed;
751
+ }
752
+ }
616
753
  function reviewCommentFromInput(input) {
617
754
  const id = input.id ?? createReviewId("comment");
618
755
  return {
@@ -622,9 +759,13 @@ function reviewCommentFromInput(input) {
622
759
  threadId: input.threadId ?? id,
623
760
  parentCommentId: input.parentCommentId ?? null,
624
761
  targetId: input.targetId ?? null,
762
+ surfaceId: input.surfaceId ?? null,
625
763
  kind: input.kind ?? "comment",
626
764
  status: "open",
627
765
  anchor: input.anchor ?? null,
766
+ anchorKind: input.anchorKind ?? null,
767
+ anchorVersion: input.anchorVersion ?? null,
768
+ revision: 1,
628
769
  body: input.body,
629
770
  authorEmail: input.authorEmail ?? null,
630
771
  authorName: input.authorName ?? null,
@@ -671,8 +812,11 @@ function assertMatchingReviewCommentReceipt(existing, submitted) {
671
812
  "threadId",
672
813
  "parentCommentId",
673
814
  "targetId",
815
+ "surfaceId",
674
816
  "kind",
675
817
  "anchor",
818
+ "anchorKind",
819
+ "anchorVersion",
676
820
  "body",
677
821
  "authorEmail",
678
822
  "createdBy",
@@ -701,6 +845,13 @@ function stableReviewReceiptJson(value) {
701
845
  /** Leave out deleted comments. Neither takes a parameter. */
702
846
  const LIVE_COMMENT_FILTERS = ["deleted_at IS NULL", "status <> 'deleted'"];
703
847
  export async function queryReviewComments(input) {
848
+ return selectReviewComments(input);
849
+ }
850
+ /**
851
+ * The comments a query asks for. `lookahead` reads that many past the limit,
852
+ * so a page can tell whether another follows even at the largest limit.
853
+ */
854
+ async function selectReviewComments(input, lookahead = 0) {
704
855
  await ensureReviewTables();
705
856
  const client = getDbExec();
706
857
  const { clause, params } = input.bypassScope
@@ -744,6 +895,15 @@ export async function queryReviewComments(input) {
744
895
  filterParams.push(input.targetId, input.resourceType, input.resourceId, input.targetId, ...rootScopeParams);
745
896
  }
746
897
  }
898
+ if (input.surfaceId !== undefined) {
899
+ if (input.surfaceId === null) {
900
+ filters.push("surface_id IS NULL");
901
+ }
902
+ else {
903
+ filters.push("surface_id = ?");
904
+ filterParams.push(input.surfaceId);
905
+ }
906
+ }
747
907
  if (input.rootOnly) {
748
908
  filters.push("parent_comment_id IS NULL");
749
909
  }
@@ -764,6 +924,9 @@ export async function queryReviewComments(input) {
764
924
  ? `(${resolutionFilters.join(" OR ")})`
765
925
  : "1 = 0");
766
926
  }
927
+ if (input.after && input.newestFirst) {
928
+ throw new ReviewCursorError("Comment lists page oldest first only.");
929
+ }
767
930
  if (input.newestFirst && !input.rootOnly) {
768
931
  // A root deleted before deletes took replies along still anchors its live
769
932
  // replies, so they list here as they do oldest first.
@@ -808,6 +971,16 @@ export async function queryReviewComments(input) {
808
971
  });
809
972
  return (result.rows ?? []).map(mapCommentRow);
810
973
  }
974
+ // A page starts after the last comment of the one before, in the same
975
+ // created-then-id order, so a run of comments written in the same
976
+ // millisecond is neither split nor repeated.
977
+ const afterSql = input.after
978
+ ? "(created_at > ? OR (created_at = ? AND id > ?))"
979
+ : null;
980
+ const afterParams = input.after
981
+ ? [input.after.createdAt, input.after.createdAt, input.after.id]
982
+ : [];
983
+ // raw-sql-ok: the columns and filters are constant text; the values are bound.
811
984
  const selectSql = input.rootOnly
812
985
  ? `SELECT ${commentColumns()}
813
986
  FROM (
@@ -819,20 +992,107 @@ export async function queryReviewComments(input) {
819
992
  FROM agent_review_comments AS comment
820
993
  WHERE ${filters.join(" AND ")}
821
994
  ) AS distinct_review_threads
822
- WHERE review_thread_rank = 1`
995
+ WHERE review_thread_rank = 1${afterSql ? ` AND ${afterSql}` : ""}`
823
996
  : `SELECT ${commentColumns()}
824
997
  FROM agent_review_comments AS comment
825
- WHERE ${filters.join(" AND ")}`;
998
+ WHERE ${[...filters, ...(afterSql ? [afterSql] : [])].join(" AND ")}`;
826
999
  const order = input.newestFirst ? "DESC" : "ASC";
1000
+ // raw-sql-ok: the order is ASC or DESC and the rest constant; values are bound.
827
1001
  const result = await client.execute({
828
1002
  sql: `${selectSql}
829
- ORDER BY created_at ${order}${input.rootOnly ? `, id ${order}` : ""}
1003
+ ORDER BY created_at ${order}, id ${order}
830
1004
  LIMIT ?`,
831
- args: [...filterParams, clampLimit(input.limit)],
1005
+ args: [
1006
+ ...filterParams,
1007
+ ...afterParams,
1008
+ clampLimit(input.limit) + lookahead,
1009
+ ],
832
1010
  });
833
1011
  const rows = result.rows ?? [];
834
1012
  return (input.newestFirst ? [...rows].reverse() : rows).map(mapCommentRow);
835
1013
  }
1014
+ /**
1015
+ * One page of a resource's comments, oldest first, and the cursor for the
1016
+ * next. Unlike a plain list, which stops at its limit, every comment is
1017
+ * reachable page by page.
1018
+ */
1019
+ export async function queryReviewCommentPage(input) {
1020
+ const { cursor, ...query } = input;
1021
+ if (query.newestFirst) {
1022
+ throw new ReviewCursorError("Comment lists page oldest first only.");
1023
+ }
1024
+ const limit = clampLimit(query.limit);
1025
+ const queryKey = await reviewCommentQueryKey(query);
1026
+ // One more than the page, to know whether another follows.
1027
+ const rows = await selectReviewComments({
1028
+ ...query,
1029
+ after: cursor ? decodeReviewCommentCursor(cursor, queryKey) : undefined,
1030
+ limit,
1031
+ }, 1);
1032
+ const comments = rows.slice(0, limit);
1033
+ const last = rows.length > limit ? comments[comments.length - 1] : undefined;
1034
+ return {
1035
+ comments,
1036
+ nextCursor: last ? encodeReviewCommentCursor(queryKey, last) : null,
1037
+ };
1038
+ }
1039
+ const REVIEW_CURSOR_PREFIX = "c1~";
1040
+ /**
1041
+ * Names a list by what it holds: its resource, filters and reader, but not
1042
+ * its page size. A cursor carries it, so one from another list is refused
1043
+ * rather than silently skipping what that list holds before its position.
1044
+ */
1045
+ async function reviewCommentQueryKey(query) {
1046
+ const identity = JSON.stringify({
1047
+ resourceType: query.resourceType,
1048
+ resourceId: query.resourceId,
1049
+ scope: query.bypassScope
1050
+ ? null
1051
+ : {
1052
+ userEmail: query.scope.userEmail ?? null,
1053
+ orgId: query.scope.orgId ?? null,
1054
+ },
1055
+ includeResolved: query.includeResolved === true,
1056
+ includeDeleted: query.includeDeleted === true,
1057
+ // A filter left out drops its key, so "any" differs from `null`.
1058
+ targetId: query.targetId,
1059
+ surfaceId: query.surfaceId,
1060
+ rootOnly: query.rootOnly === true,
1061
+ unconsumedOnly: query.unconsumedOnly === true,
1062
+ resolutionTargets: query.resolutionTargets
1063
+ ? [
1064
+ ...new Set(query.resolutionTargets.map((target) => JSON.stringify(target))),
1065
+ ].sort()
1066
+ : undefined,
1067
+ });
1068
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(identity));
1069
+ return Array.from(new Uint8Array(digest).slice(0, 8), (byte) => byte.toString(16).padStart(2, "0")).join("");
1070
+ }
1071
+ /**
1072
+ * A cursor names its list, then the last comment of a page: its time and its
1073
+ * ID.
1074
+ */
1075
+ function encodeReviewCommentCursor(queryKey, position) {
1076
+ return `${REVIEW_CURSOR_PREFIX}${queryKey}~${position.createdAt}~${position.id}`;
1077
+ }
1078
+ function decodeReviewCommentCursor(cursor, queryKey) {
1079
+ const listPrefix = `${REVIEW_CURSOR_PREFIX}${queryKey}~`;
1080
+ if (cursor.startsWith(REVIEW_CURSOR_PREFIX) &&
1081
+ !cursor.startsWith(listPrefix)) {
1082
+ throw new ReviewCursorError("This cursor is from another comment list. Start the list again.");
1083
+ }
1084
+ const rest = cursor.startsWith(listPrefix)
1085
+ ? cursor.slice(listPrefix.length)
1086
+ : "";
1087
+ // The time never holds a `~`, so the first one after it starts the ID.
1088
+ const split = rest.indexOf("~");
1089
+ const createdAt = split > 0 ? rest.slice(0, split) : "";
1090
+ const id = split > 0 ? rest.slice(split + 1) : "";
1091
+ if (!id || Number.isNaN(Date.parse(createdAt))) {
1092
+ throw new ReviewCursorError("Not a comment list cursor.");
1093
+ }
1094
+ return { createdAt, id };
1095
+ }
836
1096
  export async function getReviewThreadSummary(input) {
837
1097
  await ensureReviewTables();
838
1098
  const { clause, params } = input.bypassScope
@@ -860,6 +1120,13 @@ export async function getReviewThreadSummary(input) {
860
1120
  filterParams.push(input.targetId);
861
1121
  }
862
1122
  }
1123
+ if (input.surfaceId === null) {
1124
+ filters.push("surface_id IS NULL");
1125
+ }
1126
+ else if (input.surfaceId !== undefined) {
1127
+ filters.push("surface_id = ?");
1128
+ filterParams.push(input.surfaceId);
1129
+ }
863
1130
  const result = await getDbExec().execute({
864
1131
  sql: `SELECT COUNT(DISTINCT thread_id) AS open_count,
865
1132
  COUNT(DISTINCT CASE
@@ -913,30 +1180,110 @@ export async function getReviewThreadRoot(threadId, resource, scope, options = {
913
1180
  const row = result.rows?.[0];
914
1181
  return row ? mapCommentRow(row) : null;
915
1182
  }
916
- export async function updateReviewCommentAnchor(input) {
917
- await ensureReviewTables();
918
- const result = await getDbExec().execute({
919
- sql: `UPDATE agent_review_comments
920
- SET anchor_json = ?, updated_at = ?
921
- WHERE id = ?
922
- AND resource_type = ?
923
- AND resource_id = ?
924
- AND deleted_at IS NULL`,
1183
+ /**
1184
+ * Takes a live comment `FOR UPDATE`, so a change made from what was read here
1185
+ * can't lose a concurrent one.
1186
+ */
1187
+ async function lockLiveReviewComment(client, id, resource) {
1188
+ // raw-sql-ok: the columns and resource clause are constant; values are bound.
1189
+ const result = await client.execute({
1190
+ sql: `SELECT ${commentColumns()}
1191
+ FROM agent_review_comments
1192
+ WHERE id = ?
1193
+ ${resource ? "AND resource_type = ? AND resource_id = ?" : ""}
1194
+ AND deleted_at IS NULL
1195
+ FOR UPDATE`,
925
1196
  args: [
926
- stringifyOptionalJson(input.anchor),
927
- new Date().toISOString(),
928
- input.commentId,
929
- input.resourceType,
930
- input.resourceId,
1197
+ id,
1198
+ ...(resource ? [resource.resourceType, resource.resourceId] : []),
931
1199
  ],
932
1200
  });
933
- return result.rowsAffected ?? 0;
1201
+ const row = result.rows[0];
1202
+ return row ? mapCommentRow(row) : null;
1203
+ }
1204
+ /**
1205
+ * Changes a comment's body, mentions or anchor. Checks the anchor against its
1206
+ * kind, refuses a stale `expectedRevision`, moves the revision on, and runs
1207
+ * the resource's hook. Returns null when there's no live comment to change.
1208
+ */
1209
+ async function writeReviewCommentChange(client, id, input, resource, options) {
1210
+ const current = await lockLiveReviewComment(client, id, resource);
1211
+ if (!current)
1212
+ return null;
1213
+ const revision = current.revision ?? 1;
1214
+ if (options.expectedRevision !== undefined &&
1215
+ options.expectedRevision !== revision) {
1216
+ throw new ReviewCommentRevisionConflictError(id, revision);
1217
+ }
1218
+ const assignments = [];
1219
+ if (input.body !== undefined) {
1220
+ assignments.push(sql `body = ${sql.value(input.body)}`);
1221
+ }
1222
+ if (input.mentions !== undefined) {
1223
+ assignments.push(sql `mentions_json = ${sql.value(stringifyOptionalJson(input.mentions))}`);
1224
+ }
1225
+ if (input.anchor !== undefined) {
1226
+ // A moved anchor is written now, so at its kind's current version unless
1227
+ // the caller says otherwise.
1228
+ const anchor = normalizeReviewAnchor(current.resourceType, {
1229
+ anchor: input.anchor,
1230
+ // Clearing the anchor clears its kind too; null isn't parsed as one.
1231
+ anchorKind: input.anchorKind !== undefined
1232
+ ? input.anchorKind
1233
+ : input.anchor === null
1234
+ ? null
1235
+ : (current.anchorKind ?? null),
1236
+ anchorVersion: input.anchorVersion ?? null,
1237
+ });
1238
+ assignments.push(sql `anchor_json = ${sql.value(stringifyOptionalJson(anchor.anchor))}`, sql `anchor_kind = ${sql.value(anchor.anchorKind)}`, sql `anchor_version = ${sql.value(anchor.anchorVersion)}`);
1239
+ }
1240
+ if (!assignments.length)
1241
+ return current;
1242
+ // The row lock holds the revision inside a transaction. The update checks
1243
+ // it again, so a client without one can't write over a change made after
1244
+ // its read, whether or not the caller named a revision.
1245
+ const result = await client.execute(sql `UPDATE agent_review_comments
1246
+ SET ${sql.join(assignments, sql `, `)},
1247
+ revision = revision + 1,
1248
+ updated_at = ${sql.value(new Date().toISOString())}
1249
+ WHERE id = ${sql.value(id)} AND revision = ${sql.value(revision)}`);
1250
+ if (result.rowsAffected < 1) {
1251
+ const latest = await getReviewCommentByIdWithClient(client, id);
1252
+ throw new ReviewCommentRevisionConflictError(id, latest?.revision ?? 1);
1253
+ }
1254
+ const updated = await getReviewCommentByIdWithClient(client, id);
1255
+ if (!updated)
1256
+ throw new Error("Review comment not found after update");
1257
+ const changesContent = input.body !== undefined || input.mentions !== undefined;
1258
+ // An edit that also moves the anchor tells the hook both, so a hook keeping
1259
+ // anchor or surface state still hears of the move.
1260
+ const events = changesContent
1261
+ ? input.anchor === undefined
1262
+ ? ["edited"]
1263
+ : ["edited", "anchor-moved"]
1264
+ : ["anchor-moved"];
1265
+ for (const type of events) {
1266
+ await runReviewCommentWriteHook(updated.resourceType, { type, comment: updated, actorEmail: options.actorEmail ?? null }, client);
1267
+ }
1268
+ return updated;
934
1269
  }
935
1270
  export async function resolveReviewThread(threadId, resolvedBy, resource, resolutionNote, status = "resolved") {
936
1271
  await ensureReviewTables();
937
1272
  const client = getDbExec();
938
1273
  const resolve = (tx) => resolveReviewThreadWithClient(tx, threadId, resolvedBy, resource, resolutionNote, status);
939
- return client.transaction ? client.transaction(resolve) : resolve(client);
1274
+ if (client.transaction)
1275
+ return client.transaction(resolve);
1276
+ assertReviewWriteCanRollBack(...(resource
1277
+ ? [resource.resourceType]
1278
+ : await getReviewThreadResourceTypes(client, threadId)));
1279
+ return resolve(client);
1280
+ }
1281
+ /** The resources a thread's live comments sit on; a thread ID can repeat. */
1282
+ async function getReviewThreadResourceTypes(client, threadId) {
1283
+ const { rows } = await client.execute(sql `SELECT DISTINCT resource_type
1284
+ FROM agent_review_comments
1285
+ WHERE thread_id = ${sql.value(threadId)} AND deleted_at IS NULL`);
1286
+ return rows.map((row) => String(row.resource_type));
940
1287
  }
941
1288
  export async function resolveReviewThreadWithClient(client, threadId, resolvedBy, resource, resolutionNote, status = "resolved") {
942
1289
  if (status === "open" && resolutionNote !== undefined) {
@@ -950,6 +1297,7 @@ export async function resolveReviewThreadWithClient(client, threadId, resolvedBy
950
1297
  const resourceClause = resource
951
1298
  ? "AND resource_type = ? AND resource_id = ?"
952
1299
  : "";
1300
+ // raw-sql-ok: the resource clause is constant text; the ids are bound.
953
1301
  const root = await client.execute({
954
1302
  sql: `SELECT metadata_json
955
1303
  FROM agent_review_comments
@@ -973,32 +1321,88 @@ export async function resolveReviewThreadWithClient(client, threadId, resolvedBy
973
1321
  : resolutionNote === undefined
974
1322
  ? undefined
975
1323
  : { ...(rootMetadata ?? {}), resolutionNote };
976
- const metadataAssignment = nextRootMetadata === undefined
977
- ? ""
978
- : ", metadata_json = CASE WHEN parent_comment_id IS NULL THEN ? ELSE metadata_json END";
979
- const result = await client.execute({
980
- sql: `UPDATE agent_review_comments
981
- SET status = ?,
982
- resolved_by = CASE WHEN ? = 'resolved' THEN ? ELSE NULL END,
983
- resolved_at = CASE WHEN ? = 'resolved' THEN ? ELSE NULL END,
984
- updated_at = ?
985
- ${metadataAssignment}
986
- WHERE thread_id = ? AND deleted_at IS NULL ${resourceClause}`,
1324
+ const noteChanges = nextRootMetadata !== undefined &&
1325
+ stringifyOptionalJson(nextRootMetadata) !==
1326
+ stringifyOptionalJson(rootMetadata);
1327
+ // Only comments not already in this state change, so resolving a resolved
1328
+ // thread again moves no revision and fires no hook; a new resolution note
1329
+ // still rewrites the first comment, keeping who resolved it and when.
1330
+ // A thread ID can repeat across resources, so a call that names none counts
1331
+ // each resource apart and runs each changed one's hook.
1332
+ // raw-sql-ok: the resource clause is constant text; the ids are bound.
1333
+ const counts = await client.execute({
1334
+ sql: `SELECT resource_type, resource_id, COUNT(*) AS live,
1335
+ COALESCE(SUM(CASE WHEN status <> ? THEN 1 ELSE 0 END), 0) AS changing
1336
+ FROM agent_review_comments
1337
+ WHERE thread_id = ? AND deleted_at IS NULL ${resourceClause}
1338
+ GROUP BY resource_type, resource_id
1339
+ ORDER BY resource_type, resource_id`,
987
1340
  args: [
988
1341
  status,
989
- status,
990
- resolvedBy ?? null,
991
- status,
992
- status === "resolved" ? now : null,
993
- now,
994
- ...(nextRootMetadata === undefined
995
- ? []
996
- : [stringifyOptionalJson(nextRootMetadata)]),
997
1342
  threadId,
998
1343
  ...(resource ? [resource.resourceType, resource.resourceId] : []),
999
1344
  ],
1000
1345
  });
1001
- return result.rowsAffected ?? 0;
1346
+ const perResource = counts.rows.map((row) => ({
1347
+ resourceType: String(row.resource_type),
1348
+ resourceId: String(row.resource_id),
1349
+ live: Number(row.live),
1350
+ changing: Number(row.changing),
1351
+ }));
1352
+ const live = perResource.reduce((sum, row) => sum + row.live, 0);
1353
+ const changing = perResource.reduce((sum, row) => sum + row.changing, 0);
1354
+ if (changing > 0 || noteChanges) {
1355
+ const metadataAssignment = noteChanges
1356
+ ? ", metadata_json = CASE WHEN parent_comment_id IS NULL THEN ? ELSE metadata_json END"
1357
+ : "";
1358
+ // raw-sql-ok: the clauses are constant text; every value is bound.
1359
+ await client.execute({
1360
+ sql: `UPDATE agent_review_comments
1361
+ SET status = ?,
1362
+ resolved_by = CASE
1363
+ WHEN status = ? THEN resolved_by
1364
+ WHEN ? = 'resolved' THEN ?
1365
+ ELSE NULL
1366
+ END,
1367
+ resolved_at = CASE
1368
+ WHEN status = ? THEN resolved_at
1369
+ WHEN ? = 'resolved' THEN ?
1370
+ ELSE NULL
1371
+ END,
1372
+ revision = revision + 1,
1373
+ updated_at = ?
1374
+ ${metadataAssignment}
1375
+ WHERE thread_id = ? AND deleted_at IS NULL
1376
+ AND (status <> ?${noteChanges ? " OR parent_comment_id IS NULL" : ""})
1377
+ ${resourceClause}`,
1378
+ args: [
1379
+ status,
1380
+ status,
1381
+ status,
1382
+ resolvedBy ?? null,
1383
+ status,
1384
+ status,
1385
+ status === "resolved" ? now : null,
1386
+ now,
1387
+ ...(noteChanges ? [stringifyOptionalJson(nextRootMetadata)] : []),
1388
+ threadId,
1389
+ status,
1390
+ ...(resource ? [resource.resourceType, resource.resourceId] : []),
1391
+ ],
1392
+ });
1393
+ }
1394
+ for (const changed of perResource) {
1395
+ if (changed.changing < 1)
1396
+ continue;
1397
+ await runReviewCommentWriteHook(changed.resourceType, {
1398
+ type: status === "resolved" ? "resolved" : "reopened",
1399
+ resourceType: changed.resourceType,
1400
+ resourceId: changed.resourceId,
1401
+ threadId,
1402
+ actorEmail: resolvedBy ?? null,
1403
+ }, client);
1404
+ }
1405
+ return live;
1002
1406
  }
1003
1407
  function withoutResolutionNote(metadata) {
1004
1408
  if (!metadata || !("resolutionNote" in metadata)) {
@@ -1023,11 +1427,27 @@ export async function routeReviewThread(threadId, resolutionTarget, resource) {
1023
1427
  }
1024
1428
  async function routeReviewThreadWithClient(client, threadId, resolutionTarget, resource) {
1025
1429
  const now = new Date().toISOString();
1430
+ // Every open first comment still counts as routed, but only one whose route
1431
+ // changes, or that the agent already took and is sent back to it, moves its
1432
+ // revision; a caller's next edit at the revision it read still goes through.
1026
1433
  const result = await client.execute({
1027
1434
  sql: `UPDATE agent_review_comments
1028
1435
  SET resolution_target = ?,
1029
1436
  consumed_at = CASE WHEN ? = 'agent' THEN NULL ELSE consumed_at END,
1030
- updated_at = ?
1437
+ revision = CASE
1438
+ WHEN resolution_target IS NULL
1439
+ OR resolution_target <> ?
1440
+ OR (? = 'agent' AND consumed_at IS NOT NULL)
1441
+ THEN revision + 1
1442
+ ELSE revision
1443
+ END,
1444
+ updated_at = CASE
1445
+ WHEN resolution_target IS NULL
1446
+ OR resolution_target <> ?
1447
+ OR (? = 'agent' AND consumed_at IS NOT NULL)
1448
+ THEN ?
1449
+ ELSE updated_at
1450
+ END
1031
1451
  WHERE thread_id = ?
1032
1452
  AND resource_type = ?
1033
1453
  AND resource_id = ?
@@ -1035,6 +1455,10 @@ async function routeReviewThreadWithClient(client, threadId, resolutionTarget, r
1035
1455
  AND status = 'open'
1036
1456
  AND deleted_at IS NULL`,
1037
1457
  args: [
1458
+ resolutionTarget,
1459
+ resolutionTarget,
1460
+ resolutionTarget,
1461
+ resolutionTarget,
1038
1462
  resolutionTarget,
1039
1463
  resolutionTarget,
1040
1464
  now,
@@ -1087,13 +1511,14 @@ export async function deleteReviewComment(id, deletedBy, options = {}) {
1087
1511
  const client = getDbExec();
1088
1512
  const deleteIn = async (tx) => {
1089
1513
  // raw-sql-ok: the statement is constant text; the id is bound.
1090
- const { rows: roots } = await tx.execute({
1091
- sql: `SELECT thread_id, resource_type, resource_id
1514
+ const { rows: targets } = await tx.execute({
1515
+ sql: `SELECT thread_id, resource_type, resource_id, parent_comment_id
1092
1516
  FROM agent_review_comments
1093
- WHERE id = ? AND parent_comment_id IS NULL`,
1517
+ WHERE id = ?`,
1094
1518
  args: [id],
1095
1519
  });
1096
- const root = roots[0];
1520
+ const target = targets[0];
1521
+ const root = target && target.parent_comment_id == null ? target : null;
1097
1522
  if (root) {
1098
1523
  // A reply still being written holds its root `FOR SHARE`. Taking every
1099
1524
  // first comment in the thread `FOR UPDATE` first waits that out, so
@@ -1130,18 +1555,47 @@ export async function deleteReviewComment(id, deletedBy, options = {}) {
1130
1555
  ? comment.author_email
1131
1556
  : null));
1132
1557
  }
1133
- let deleted = await clearDeletedReviewComment(tx, id, actor, now);
1558
+ // The hook hears only of what this delete cleared; a comment deleted on
1559
+ // its own meanwhile already told it.
1560
+ const deletedIds = [];
1561
+ if ((await clearDeletedReviewComment(tx, id, actor, now)) > 0) {
1562
+ deletedIds.push(id);
1563
+ }
1134
1564
  // Another comment with no parent can have its metadata changed while this
1135
1565
  // runs, as resolving writes a note to each, so it gets the same rechecked
1136
1566
  // update as the root.
1137
1567
  for (const other of rest.filter((comment) => !comment.parent_comment_id)) {
1138
- deleted += await clearDeletedReviewComment(tx, String(other.id), actor, now);
1568
+ const otherId = String(other.id);
1569
+ if ((await clearDeletedReviewComment(tx, otherId, actor, now)) > 0) {
1570
+ deletedIds.push(otherId);
1571
+ }
1572
+ }
1573
+ deletedIds.push(...(await clearDeletedReviewReplies(tx, rest.filter((comment) => comment.parent_comment_id), actor, now)));
1574
+ if (target && deletedIds.length > 0) {
1575
+ const resourceType = String(target.resource_type);
1576
+ await runReviewCommentWriteHook(resourceType, {
1577
+ type: "deleted",
1578
+ resourceType,
1579
+ resourceId: String(target.resource_id),
1580
+ threadId: String(target.thread_id),
1581
+ commentIds: deletedIds,
1582
+ actorEmail: actor,
1583
+ }, tx);
1139
1584
  }
1140
- const replies = rest.filter((comment) => comment.parent_comment_id);
1141
- const deletedReplies = await clearDeletedReviewReplies(tx, replies, actor, now);
1142
- return deleted + deletedReplies;
1585
+ return deletedIds.length;
1143
1586
  };
1144
- return client.transaction ? client.transaction(deleteIn) : deleteIn(client);
1587
+ if (client.transaction)
1588
+ return client.transaction(deleteIn);
1589
+ // A comment's resource never changes, so it can be read ahead of the delete.
1590
+ const resourceType = await getReviewCommentResourceType(client, id);
1591
+ if (resourceType !== null)
1592
+ assertReviewWriteCanRollBack(resourceType);
1593
+ return deleteIn(client);
1594
+ }
1595
+ async function getReviewCommentResourceType(client, id) {
1596
+ const { rows } = await client.execute(sql `SELECT resource_type FROM agent_review_comments WHERE id = ${sql.value(id)} LIMIT 1`);
1597
+ const row = rows[0];
1598
+ return row ? String(row.resource_type) : null;
1145
1599
  }
1146
1600
  /** A `type` or `kind` that marks a comment's metadata as a resolution note. */
1147
1601
  const RESOLUTION_NOTE_MARKERS = new Set([
@@ -1223,6 +1677,7 @@ async function clearDeletedReviewComment(client, id, deletedBy, now) {
1223
1677
  anchor_json = NULL,
1224
1678
  metadata_json = ${metadata},
1225
1679
  deleted_by = ${sql.value(deletedBy)},
1680
+ revision = revision + 1,
1226
1681
  deleted_at = ${sql.value(now)},
1227
1682
  updated_at = ${sql.value(now)}
1228
1683
  WHERE id = ${sql.value(id)}
@@ -1241,10 +1696,10 @@ const DELETED_REPLIES_PER_STATEMENT = 1_000;
1241
1696
  * metadata, read under the thread's lock, is still current here. A reply can
1242
1697
  * still be deleted on its own in between, as that takes no thread lock, so
1243
1698
  * only those still live are cleared, and one deleted meanwhile keeps who
1244
- * deleted it and when.
1699
+ * deleted it and when. Returns the ids it cleared.
1245
1700
  */
1246
1701
  async function clearDeletedReviewReplies(client, replies, deletedBy, now) {
1247
- let deleted = 0;
1702
+ const deleted = [];
1248
1703
  for (let start = 0; start < replies.length; start += DELETED_REPLIES_PER_STATEMENT) {
1249
1704
  const batch = replies.slice(start, start + DELETED_REPLIES_PER_STATEMENT);
1250
1705
  const rewrites = batch.flatMap((reply) => {
@@ -1265,11 +1720,15 @@ async function clearDeletedReviewReplies(client, replies, deletedBy, now) {
1265
1720
  anchor_json = NULL,
1266
1721
  metadata_json = ${metadata},
1267
1722
  deleted_by = ${sql.value(deletedBy)},
1723
+ revision = revision + 1,
1268
1724
  deleted_at = ${sql.value(now)},
1269
1725
  updated_at = ${sql.value(now)}
1270
1726
  WHERE id IN ${sql.in(batch.map((reply) => String(reply.id)))}
1271
- AND deleted_at IS NULL`);
1272
- deleted += result.rowsAffected;
1727
+ AND deleted_at IS NULL
1728
+ RETURNING id`);
1729
+ for (const row of result.rows) {
1730
+ deleted.push(String(row.id));
1731
+ }
1273
1732
  }
1274
1733
  return deleted;
1275
1734
  }
@@ -1288,41 +1747,17 @@ export async function listReviewThreadParticipantEmails(input) {
1288
1747
  .map((row) => nullableString(row.author_email))
1289
1748
  .filter((email) => Boolean(email));
1290
1749
  }
1291
- export async function updateReviewComment(id, input, resource) {
1750
+ export async function updateReviewComment(id, input, resource, options = {}) {
1292
1751
  await ensureReviewTables();
1293
- const assignments = [];
1294
- const args = [];
1295
- if (input.body !== undefined) {
1296
- assignments.push("body = ?");
1297
- args.push(input.body);
1298
- }
1299
- if (input.anchor !== undefined) {
1300
- assignments.push("anchor_json = ?");
1301
- args.push(stringifyOptionalJson(input.anchor));
1302
- }
1303
- if (input.mentions !== undefined) {
1304
- assignments.push("mentions_json = ?");
1305
- args.push(stringifyOptionalJson(input.mentions));
1306
- }
1307
- if (!assignments.length)
1308
- return getReviewCommentById(id, {}, { bypassScope: true });
1309
- assignments.push("updated_at = ?");
1310
- args.push(new Date().toISOString());
1311
- const resourceClause = resource
1312
- ? " AND resource_type = ? AND resource_id = ?"
1313
- : "";
1314
- args.push(id);
1315
- if (resource)
1316
- args.push(resource.resourceType, resource.resourceId);
1317
- const result = await getDbExec().execute({
1318
- sql: `UPDATE agent_review_comments
1319
- SET ${assignments.join(", ")}
1320
- WHERE id = ? AND deleted_at IS NULL${resourceClause}`,
1321
- args,
1322
- });
1323
- if ((result.rowsAffected ?? 0) < 1)
1324
- return null;
1325
- return getReviewCommentById(id, {}, { bypassScope: true });
1752
+ const client = getDbExec();
1753
+ const write = (tx) => writeReviewCommentChange(tx, id, input, resource, options);
1754
+ if (client.transaction)
1755
+ return client.transaction(write);
1756
+ // A comment's resource never changes, so it can be read ahead of the edit.
1757
+ const resourceType = resource?.resourceType ?? (await getReviewCommentResourceType(client, id));
1758
+ if (resourceType !== null)
1759
+ assertReviewWriteCanRollBack(resourceType);
1760
+ return write(client);
1326
1761
  }
1327
1762
  export async function consumeReviewFeedback(ids, consumedAt = new Date().toISOString(), resource) {
1328
1763
  if (!ids.length) {
@@ -1350,8 +1785,15 @@ export async function consumeReviewFeedback(ids, consumedAt = new Date().toISOSt
1350
1785
  ORDER BY id
1351
1786
  FOR UPDATE`);
1352
1787
  const result = await tx.execute(sql `UPDATE agent_review_comments
1353
- SET consumed_at = ${sql.value(consumedAt)},
1354
- updated_at = ${sql.value(consumedAt)}
1788
+ SET consumed_at = COALESCE(consumed_at, ${sql.value(consumedAt)}),
1789
+ revision = CASE
1790
+ WHEN consumed_at IS NULL THEN revision + 1
1791
+ ELSE revision
1792
+ END,
1793
+ updated_at = CASE
1794
+ WHEN consumed_at IS NULL THEN ${sql.value(consumedAt)}
1795
+ ELSE updated_at
1796
+ END
1355
1797
  WHERE id IN ${sql.in(ids)}
1356
1798
  ${onResource}`);
1357
1799
  return result.rowsAffected;
@@ -1455,10 +1897,79 @@ export async function getReviewStatus(resourceType, resourceId, scope, options =
1455
1897
  const row = result.rows?.[0];
1456
1898
  return row ? mapStatusRow(row) : null;
1457
1899
  }
1900
+ /**
1901
+ * Links a comment to the same comment or thread in another system, such as a
1902
+ * Notion comment or discussion, so an import can skip what it already copied.
1903
+ * Linking the same outside ID to the same comment again changes nothing;
1904
+ * linking it to another comment is refused. Links outlive a deleted comment,
1905
+ * so a later import doesn't bring it back.
1906
+ */
1907
+ export async function linkReviewComment(input, client) {
1908
+ await ensureReviewTables();
1909
+ const db = client ?? getDbExec();
1910
+ const comment = await getReviewCommentByIdWithClient(db, input.commentId);
1911
+ if (!comment)
1912
+ throw new Error("Review comment not found");
1913
+ await db.execute(sql `INSERT INTO agent_review_comment_links
1914
+ (provider, external_kind, external_id, comment_id, resource_type,
1915
+ resource_id, created_at)
1916
+ VALUES (${sql.value(input.provider)}, ${sql.value(input.externalKind)},
1917
+ ${sql.value(input.externalId)}, ${sql.value(input.commentId)},
1918
+ ${sql.value(comment.resourceType)}, ${sql.value(comment.resourceId)},
1919
+ ${sql.value(new Date().toISOString())})
1920
+ ON CONFLICT (provider, external_kind, external_id) DO NOTHING`);
1921
+ const link = await findReviewCommentLink(input, db);
1922
+ if (!link || link.commentId !== input.commentId) {
1923
+ throw new ReviewCommentLinkConflictError(input.provider, input.externalKind, input.externalId);
1924
+ }
1925
+ return link;
1926
+ }
1927
+ /** The comment an outside ID is linked to, if any. */
1928
+ export async function findReviewCommentLink(input, client) {
1929
+ await ensureReviewTables();
1930
+ const result = await (client ?? getDbExec()).execute(sql `SELECT provider,
1931
+ external_kind, external_id, comment_id, resource_type, resource_id,
1932
+ created_at
1933
+ FROM agent_review_comment_links
1934
+ WHERE provider = ${sql.value(input.provider)}
1935
+ AND external_kind = ${sql.value(input.externalKind)}
1936
+ AND external_id = ${sql.value(input.externalId)}`);
1937
+ const row = result.rows[0];
1938
+ return row ? mapLinkRow(row) : null;
1939
+ }
1940
+ /** Every outside link on these comments, optionally from one system. */
1941
+ export async function listReviewCommentLinks(commentIds, provider) {
1942
+ const ids = [...new Set(commentIds)];
1943
+ if (!ids.length)
1944
+ return [];
1945
+ await ensureReviewTables();
1946
+ const fromProvider = provider
1947
+ ? sql `AND provider = ${sql.value(provider)}`
1948
+ : sql ``;
1949
+ const result = await getDbExec().execute(sql `SELECT provider, external_kind,
1950
+ external_id, comment_id, resource_type, resource_id, created_at
1951
+ FROM agent_review_comment_links
1952
+ WHERE comment_id IN ${sql.in(ids)}
1953
+ ${fromProvider}
1954
+ ORDER BY comment_id, provider, external_kind, external_id`);
1955
+ return result.rows.map((row) => mapLinkRow(row));
1956
+ }
1957
+ function mapLinkRow(row) {
1958
+ return {
1959
+ provider: String(row.provider),
1960
+ externalKind: String(row.external_kind),
1961
+ externalId: String(row.external_id),
1962
+ commentId: String(row.comment_id),
1963
+ resourceType: String(row.resource_type),
1964
+ resourceId: String(row.resource_id),
1965
+ createdAt: String(row.created_at),
1966
+ };
1967
+ }
1458
1968
  export function __resetReviewInitForTests() {
1459
1969
  reviewStore.reset();
1460
1970
  }
1461
1971
  function commentColumns() {
1972
+ // raw-sql-ok: a constant column list.
1462
1973
  return [
1463
1974
  "id",
1464
1975
  "resource_type",
@@ -1466,9 +1977,13 @@ function commentColumns() {
1466
1977
  "thread_id",
1467
1978
  "parent_comment_id",
1468
1979
  "target_id",
1980
+ "surface_id",
1469
1981
  "kind",
1470
1982
  "status",
1471
1983
  "anchor_json",
1984
+ "anchor_kind",
1985
+ "anchor_version",
1986
+ "revision",
1472
1987
  "body",
1473
1988
  "author_email",
1474
1989
  "author_name",
@@ -1527,9 +2042,15 @@ function mapCommentRow(row) {
1527
2042
  threadId: String(row.thread_id),
1528
2043
  parentCommentId: nullableString(row.parent_comment_id),
1529
2044
  targetId: nullableString(row.target_id),
2045
+ surfaceId: nullableString(row.surface_id),
1530
2046
  kind: normalizeKind(row.kind),
1531
2047
  status: normalizeCommentStatus(row.status),
1532
2048
  anchor: parseOptionalJson(row.anchor_json),
2049
+ anchorKind: nullableString(row.anchor_kind),
2050
+ anchorVersion: row.anchor_version === null || row.anchor_version === undefined
2051
+ ? null
2052
+ : Number(row.anchor_version),
2053
+ revision: Number(row.revision ?? 1),
1533
2054
  body: String(row.body),
1534
2055
  authorEmail: nullableString(row.author_email),
1535
2056
  authorName: nullableString(row.author_name),