@agent-native/core 0.84.0 → 0.84.2

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 (152) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +13 -0
  3. package/corpus/core/docs/content/evals.mdx +34 -12
  4. package/corpus/core/docs/content/locales/ar-SA/evals.mdx +7 -0
  5. package/corpus/core/docs/content/locales/de-DE/evals.mdx +8 -0
  6. package/corpus/core/docs/content/locales/es-ES/evals.mdx +8 -0
  7. package/corpus/core/docs/content/locales/fr-FR/evals.mdx +8 -0
  8. package/corpus/core/docs/content/locales/hi-IN/evals.mdx +8 -0
  9. package/corpus/core/docs/content/locales/ja-JP/evals.mdx +7 -0
  10. package/corpus/core/docs/content/locales/ko-KR/evals.mdx +7 -0
  11. package/corpus/core/docs/content/locales/pt-BR/evals.mdx +8 -0
  12. package/corpus/core/docs/content/locales/zh-CN/evals.mdx +7 -0
  13. package/corpus/core/docs/content/locales/zh-TW/evals.mdx +7 -0
  14. package/corpus/core/docs/design/durable-agent-runs.md +34 -35
  15. package/corpus/core/package.json +1 -1
  16. package/corpus/core/src/agent/engine/builder-engine.ts +81 -9
  17. package/corpus/core/src/agent/run-manager.ts +8 -7
  18. package/corpus/core/src/cli/design-connect.ts +175 -0
  19. package/corpus/core/src/cli/skills.ts +5 -7
  20. package/corpus/core/src/client/AgentPanel.tsx +8 -0
  21. package/corpus/core/src/client/MultiTabAssistantChat.tsx +4 -1
  22. package/corpus/core/src/client/agent-chat-adapter.ts +45 -15
  23. package/corpus/core/src/client/sse-event-processor.ts +31 -4
  24. package/corpus/core/src/eval/define-eval.ts +4 -1
  25. package/corpus/core/src/eval/report.ts +23 -8
  26. package/corpus/core/src/eval/runner.ts +41 -6
  27. package/corpus/core/src/eval/types.ts +11 -0
  28. package/corpus/core/src/templates/workspace-core/.agents/skills/reliable-mutations/SKILL.md +15 -17
  29. package/corpus/templates/content/AGENTS.md +9 -2
  30. package/corpus/templates/content/actions/_database-row-batch.ts +230 -0
  31. package/corpus/templates/content/actions/_database-utils.ts +17 -9
  32. package/corpus/templates/content/actions/_notion-action-utils.ts +26 -0
  33. package/corpus/templates/content/actions/connect-notion-status.ts +13 -4
  34. package/corpus/templates/content/actions/create-and-link-notion-page.ts +23 -0
  35. package/corpus/templates/content/actions/delete-database-items.ts +55 -0
  36. package/corpus/templates/content/actions/delete-document.ts +4 -4
  37. package/corpus/templates/content/actions/disconnect-notion.ts +16 -0
  38. package/corpus/templates/content/actions/duplicate-database-item.ts +1 -1
  39. package/corpus/templates/content/actions/duplicate-database-items.ts +191 -0
  40. package/corpus/templates/content/actions/link-notion-page.ts +10 -7
  41. package/corpus/templates/content/actions/list-notion-links.ts +3 -4
  42. package/corpus/templates/content/actions/pull-notion-page.ts +7 -9
  43. package/corpus/templates/content/actions/push-notion-page.ts +7 -9
  44. package/corpus/templates/content/actions/refresh-notion-sync-status.ts +24 -0
  45. package/corpus/templates/content/actions/resolve-notion-sync-conflict.ts +23 -0
  46. package/corpus/templates/content/actions/search-notion-pages.ts +67 -0
  47. package/corpus/templates/content/actions/unlink-notion-page.ts +23 -0
  48. package/corpus/templates/content/app/components/editor/DocumentDatabase.tsx +27 -34
  49. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +16 -13
  50. package/corpus/templates/content/app/components/editor/DocumentToolbar.tsx +10 -10
  51. package/corpus/templates/content/app/components/editor/NotionConflictBanner.tsx +1 -1
  52. package/corpus/templates/content/app/components/editor/NotionSyncBar.tsx +18 -9
  53. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +27 -34
  54. package/corpus/templates/content/app/components/editor/database/navigation-state.ts +10 -4
  55. package/corpus/templates/content/app/components/sidebar/NotionButton.tsx +32 -17
  56. package/corpus/templates/content/app/hooks/use-content-database.ts +35 -0
  57. package/corpus/templates/content/app/hooks/use-notion.ts +108 -99
  58. package/corpus/templates/content/package.json +3 -0
  59. package/corpus/templates/content/parity/README.md +69 -0
  60. package/corpus/templates/content/parity/eval-scenarios.ts +118 -0
  61. package/corpus/templates/content/parity/exceptions.allowlist.ts +17 -0
  62. package/corpus/templates/content/parity/matrix.md +28 -0
  63. package/corpus/templates/content/parity/matrix.ts +606 -0
  64. package/corpus/templates/content/parity/matrix.types.ts +42 -0
  65. package/corpus/templates/content/parity/parity-evals.eval.ts +4 -0
  66. package/corpus/templates/content/parity/render-matrix.ts +90 -0
  67. package/corpus/templates/content/parity/scenario-to-eval.ts +57 -0
  68. package/corpus/templates/content/shared/api.ts +11 -0
  69. package/corpus/templates/design/actions/apply-motion-edit.ts +50 -18
  70. package/corpus/templates/design/actions/get-motion-timeline.ts +4 -14
  71. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +156 -12
  72. package/corpus/templates/design/app/components/design/MotionDock.tsx +2 -3
  73. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +217 -75
  74. package/corpus/templates/design/app/components/design/QuestionFlow.tsx +39 -39
  75. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +558 -37
  76. package/corpus/templates/design/app/components/design/bridge/hit-test.bridge.ts +75 -3
  77. package/corpus/templates/design/app/components/design/types.ts +13 -0
  78. package/corpus/templates/design/app/components/layout/Layout.tsx +1 -0
  79. package/corpus/templates/design/app/i18n/zh-TW.ts +17 -0
  80. package/corpus/templates/design/app/i18n-data.ts +245 -0
  81. package/corpus/templates/design/app/pages/DesignEditor.tsx +762 -153
  82. package/corpus/templates/design/changelog/2026-06-30-copying-or-dragging-screen-elements-onto-the-infinite-canvas.md +6 -0
  83. package/corpus/templates/design/changelog/2026-06-30-design-questions-now-use-tighter-editor-typography-and-contr.md +6 -0
  84. package/corpus/templates/design/changelog/2026-06-30-element-drags-can-be-cancelled-with-escape-before-they-commi.md +6 -0
  85. package/corpus/templates/design/changelog/2026-06-30-pending-visual-style-edits-now-warn-before-you-leave-the-edi.md +6 -0
  86. package/corpus/templates/design/changelog/2026-06-30-visual-style-drags-stay-live-while-pending-edits-can-be-appl.md +6 -0
  87. package/corpus/templates/design/changelog/2026-07-01-design-chat-no-longer-shows-a-redundant-context-tab-above-the-composer.md +6 -0
  88. package/corpus/templates/design/changelog/2026-07-01-motion-track-creation-no-longer-fails-in-local-editors.md +6 -0
  89. package/corpus/templates/design/server/plugins/db.ts +10 -0
  90. package/dist/agent/engine/builder-engine.d.ts.map +1 -1
  91. package/dist/agent/engine/builder-engine.js +61 -10
  92. package/dist/agent/engine/builder-engine.js.map +1 -1
  93. package/dist/agent/run-manager.d.ts +8 -7
  94. package/dist/agent/run-manager.d.ts.map +1 -1
  95. package/dist/agent/run-manager.js +8 -7
  96. package/dist/agent/run-manager.js.map +1 -1
  97. package/dist/cli/design-connect.d.ts +2 -0
  98. package/dist/cli/design-connect.d.ts.map +1 -1
  99. package/dist/cli/design-connect.js +140 -0
  100. package/dist/cli/design-connect.js.map +1 -1
  101. package/dist/cli/skills.d.ts.map +1 -1
  102. package/dist/cli/skills.js +5 -7
  103. package/dist/cli/skills.js.map +1 -1
  104. package/dist/client/AgentPanel.d.ts +5 -1
  105. package/dist/client/AgentPanel.d.ts.map +1 -1
  106. package/dist/client/AgentPanel.js +4 -4
  107. package/dist/client/AgentPanel.js.map +1 -1
  108. package/dist/client/MultiTabAssistantChat.d.ts +3 -1
  109. package/dist/client/MultiTabAssistantChat.d.ts.map +1 -1
  110. package/dist/client/MultiTabAssistantChat.js +2 -2
  111. package/dist/client/MultiTabAssistantChat.js.map +1 -1
  112. package/dist/client/agent-chat-adapter.d.ts.map +1 -1
  113. package/dist/client/agent-chat-adapter.js +37 -14
  114. package/dist/client/agent-chat-adapter.js.map +1 -1
  115. package/dist/client/sse-event-processor.d.ts +14 -1
  116. package/dist/client/sse-event-processor.d.ts.map +1 -1
  117. package/dist/client/sse-event-processor.js +21 -5
  118. package/dist/client/sse-event-processor.js.map +1 -1
  119. package/dist/collab/routes.d.ts +1 -1
  120. package/dist/eval/define-eval.d.ts.map +1 -1
  121. package/dist/eval/define-eval.js +2 -1
  122. package/dist/eval/define-eval.js.map +1 -1
  123. package/dist/eval/report.d.ts.map +1 -1
  124. package/dist/eval/report.js +19 -4
  125. package/dist/eval/report.js.map +1 -1
  126. package/dist/eval/runner.d.ts.map +1 -1
  127. package/dist/eval/runner.js +38 -6
  128. package/dist/eval/runner.js.map +1 -1
  129. package/dist/eval/types.d.ts +11 -0
  130. package/dist/eval/types.d.ts.map +1 -1
  131. package/dist/eval/types.js.map +1 -1
  132. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  133. package/dist/observability/routes.d.ts +2 -2
  134. package/dist/progress/routes.d.ts +1 -1
  135. package/dist/resources/handlers.d.ts +3 -3
  136. package/dist/server/transcribe-voice.d.ts +1 -1
  137. package/dist/templates/workspace-core/.agents/skills/reliable-mutations/SKILL.md +15 -17
  138. package/docs/content/evals.mdx +34 -12
  139. package/docs/content/locales/ar-SA/evals.mdx +7 -0
  140. package/docs/content/locales/de-DE/evals.mdx +8 -0
  141. package/docs/content/locales/es-ES/evals.mdx +8 -0
  142. package/docs/content/locales/fr-FR/evals.mdx +8 -0
  143. package/docs/content/locales/hi-IN/evals.mdx +8 -0
  144. package/docs/content/locales/ja-JP/evals.mdx +7 -0
  145. package/docs/content/locales/ko-KR/evals.mdx +7 -0
  146. package/docs/content/locales/pt-BR/evals.mdx +8 -0
  147. package/docs/content/locales/zh-CN/evals.mdx +7 -0
  148. package/docs/content/locales/zh-TW/evals.mdx +7 -0
  149. package/docs/design/durable-agent-runs.md +34 -35
  150. package/package.json +1 -1
  151. package/src/templates/workspace-core/.agents/skills/reliable-mutations/SKILL.md +15 -17
  152. package/corpus/templates/content/server/routes/api/notion/disconnect.post.ts +0 -12
@@ -78,6 +78,20 @@ export async function scoreEval(
78
78
  const threshold =
79
79
  opts.thresholdOverride ?? evalCase.threshold ?? DEFAULT_EVAL_THRESHOLD;
80
80
 
81
+ if (evalCase.skipReason) {
82
+ return {
83
+ eval: evalCase.name,
84
+ threshold,
85
+ scores: [],
86
+ status: "skipped",
87
+ skipReason: evalCase.skipReason,
88
+ // Keep the legacy boolean gate friendly: skipped rows do not fail CI.
89
+ passed: true,
90
+ avgScore: 0,
91
+ durationMs: 0,
92
+ };
93
+ }
94
+
81
95
  let run: AgentRunOutput;
82
96
  if (evalCase.run) {
83
97
  run = await evalCase.run({
@@ -104,6 +118,7 @@ export async function scoreEval(
104
118
  scores,
105
119
  // A run that errored, or any sub-threshold scorer, fails the case.
106
120
  passed: run.ok && scores.every((s) => s.passed),
121
+ status: run.ok && scores.every((s) => s.passed) ? "passed" : "failed",
107
122
  avgScore,
108
123
  durationMs: run.durationMs,
109
124
  error: run.ok ? undefined : run.error,
@@ -120,14 +135,18 @@ export async function runEvals(
120
135
  for (const evalCase of evals) {
121
136
  const row = await scoreEval(evalCase, runner, opts);
122
137
  results.push(row);
123
- if (opts.persist) await persistEvalRow(row).catch(() => {});
138
+ if (opts.persist && row.status !== "skipped") {
139
+ await persistEvalRow(row).catch(() => {});
140
+ }
124
141
  }
125
142
 
126
143
  const passed = results.filter((r) => r.passed).length;
144
+ const skipped = results.filter((r) => r.status === "skipped").length;
127
145
  return {
128
146
  total: results.length,
129
147
  passed,
130
- failed: results.length - passed,
148
+ failed: results.filter((r) => r.status !== "skipped" && !r.passed).length,
149
+ skipped,
131
150
  results,
132
151
  };
133
152
  }
@@ -301,12 +320,15 @@ export async function runEvalSuite(
301
320
  evals = loaded.evals;
302
321
  }
303
322
 
323
+ const needsRunner = evals.some((evalCase) => !evalCase.skipReason);
304
324
  const runner =
305
325
  opts.runner ??
306
- (await createAgentRunner({
307
- actions: opts.actions ?? (await discoverActions(cwd)),
308
- systemPrompt: opts.systemPrompt,
309
- }));
326
+ (needsRunner
327
+ ? await createAgentRunner({
328
+ actions: opts.actions ?? (await discoverActions(cwd)),
329
+ systemPrompt: opts.systemPrompt,
330
+ })
331
+ : createInertRunner());
310
332
 
311
333
  const report = await runEvals(evals, runner, {
312
334
  thresholdOverride: opts.thresholdOverride,
@@ -332,3 +354,16 @@ async function discoverActions(
332
354
  return {};
333
355
  }
334
356
  }
357
+
358
+ function createInertRunner(): AgentRunner {
359
+ return {
360
+ engine: {} as AgentRunner["engine"],
361
+ model: "inert",
362
+ async runAgent() {
363
+ throw new Error("Eval unexpectedly requested the agent runner");
364
+ },
365
+ analyzeContext() {
366
+ throw new Error("Eval unexpectedly requested analyze context");
367
+ },
368
+ };
369
+ }
@@ -133,6 +133,12 @@ export interface EvalRunContext {
133
133
  export interface Eval {
134
134
  name: string;
135
135
  input: EvalInput;
136
+ /**
137
+ * Skip this case without running the agent or scorers. Use for opt-in
138
+ * suites that need secrets, live provider credentials, or manual/nightly
139
+ * gates. Skipped rows are reported distinctly and do not fail the suite.
140
+ */
141
+ skipReason?: string;
136
142
  /**
137
143
  * Optional override for how the agent is run for this case. Defaults to the
138
144
  * runner's headless `runAgent`. Use this to do custom setup (seed data,
@@ -159,6 +165,10 @@ export interface EvalResultRow {
159
165
  eval: string;
160
166
  threshold: number;
161
167
  scores: ScorerResult[];
168
+ /** Explicit row status for reports. Older consumers can still read `passed`. */
169
+ status?: "passed" | "failed" | "skipped";
170
+ /** Human-readable reason when `status` is `skipped`. */
171
+ skipReason?: string;
162
172
  /** True only when every scorer passed. */
163
173
  passed: boolean;
164
174
  /** Mean of the scorer scores, for at-a-glance ranking. */
@@ -173,5 +183,6 @@ export interface EvalRunReport {
173
183
  total: number;
174
184
  passed: number;
175
185
  failed: number;
186
+ skipped?: number;
176
187
  results: EvalResultRow[];
177
188
  }
@@ -2,9 +2,9 @@
2
2
  name: reliable-mutations
3
3
  description: >-
4
4
  How the agent must perform writes so they actually persist under the hosted
5
- ~40s run budget. Use whenever you create, update, delete, or batch-write app
6
- data — especially "do this for many items" loops, or any task where the user
7
- expects N things to end up saved.
5
+ foreground run budget and long-running background handoffs. Use whenever you
6
+ create, update, delete, or batch-write app data — especially "do this for many
7
+ items" loops, or any task where the user expects N things to end up saved.
8
8
  ---
9
9
 
10
10
  # Reliable Mutations
@@ -18,15 +18,12 @@ tool ✓ alone.
18
18
 
19
19
  ## Why
20
20
 
21
- Hosted agent runs have a ~40s soft budget (it exists to hand off cleanly under
22
- the upstream gateway/function walls it is correct and is not raisable; see the
23
- durable-agent-runs design doc). When you loop many sequential writes inside one
24
- turn, the budget can cut you off mid-loop. The run resumes in a new chunk, but
25
- the resume often re-does earlier steps instead of advancing, so the loop churns
26
- and the *saved* result ends up partial — or empty — even though each individual
27
- tool call appeared to succeed. The user gets told "done" while the data says
28
- otherwise. One atomic call commits or fails as a unit; verification turns a
29
- hopeful ✓ into a fact.
21
+ Hosted foreground agent runs have a short soft budget so they can hand off
22
+ cleanly under synchronous serverless walls. Durable background runs get a much
23
+ longer budget, but they still should not rely on loops of many small writes:
24
+ continuations can retry the same intent and leave partial state if each item is
25
+ committed separately. One atomic call commits or fails as a unit; verification
26
+ turns a hopeful into a fact.
30
27
 
31
28
  ## How
32
29
 
@@ -34,11 +31,12 @@ hopeful ✓ into a fact.
34
31
  many, set all, bulk update), pass the full batch in one call so it commits
35
32
  atomically. Check the action surface for a batch/plural form before reaching
36
33
  for a loop.
37
- 2. **Do not loop many small writes under the run budget.** A sequence of N
38
- per-item writes in one turn will race the ~40s cutoff and can leave partial
39
- or no state. If no batch action exists, that is a gap in the action layer —
40
- add or extend an action that accepts the batch (see the `actions` skill)
41
- rather than papering over it with a loop.
34
+ 2. **Do not loop many small writes under any run budget.** A sequence of N
35
+ per-item writes can still leave partial or no state when a foreground run
36
+ hands off, a background run continues, or an upstream provider fails. If no
37
+ batch action exists, that is a gap in the action layer add or extend an
38
+ action that accepts the batch (see the `actions` skill) rather than papering
39
+ over it with a loop.
42
40
  3. **Verify the end state after writing.** Re-read the data (a list/read action,
43
41
  a count query) and confirm the result matches intent — the right number of
44
42
  rows, the expected ids/fields. Do this before you tell the user it worked.
@@ -121,7 +121,9 @@ cd templates/content && pnpm action <name> [args]
121
121
  | `validate-builder-source-execution` | `--databaseId <id>` or `--documentId <id> --changeSetId <id> [--idempotencyKey <key>]` | Validate/replay a prepared Builder execution gate locally as a dry run; never calls Builder |
122
122
  | `execute-builder-source-execution` | `--databaseId <id>` or `--documentId <id> --changeSetId <id> [--idempotencyKey <key>] [--pushModeConfirmation autosave\|draft\|publish]` | Execute a guarded live Builder write only when approved, validated, enabled, idempotent, and targeting `agent-native-blog-article-test` |
123
123
  | `add-database-item` | `--databaseId <id> [--title <text>] [--propertyValues <json>]` | Add a page row to a database, optionally seeding property values |
124
- | `duplicate-database-item` | `--itemId <id>` or `--documentId <id> [--title <text>]` | Duplicate a database row page and its stored property values |
124
+ | `duplicate-database-item` | `--itemId <id>` or `--documentId <id> [--title <text>]` | Duplicate exactly one database row page and its stored property values; for two or more rows, use `duplicate-database-items` once |
125
+ | `duplicate-database-items` | `--databaseId <id>` or `--documentId <databaseDocumentId> --itemIds <json-array>` or `--documentIds <json-array>` | Preferred multi-row duplicate action; duplicate multiple database row pages atomically and return ordered duplicate item/document IDs |
126
+ | `delete-database-items` | `--databaseId <id>` or `--documentId <databaseDocumentId> --itemIds <json-array>` or `--documentIds <json-array>` | Preferred multi-row delete action; delete multiple database row pages atomically while preserving `delete-document` admin semantics |
125
127
  | `move-database-item` | `--itemId <id>` or `--documentId <id> --position <number>` | Move a database row page to a new zero-based table position |
126
128
  | `update-content-database-view` | `--databaseId <id> --viewConfig <json>` | Persist database views, sorts, filters, hidden properties, and view settings |
127
129
  | `list-document-properties` | `--documentId <id> [--format json]` | List Notion-style property definitions and values for a document |
@@ -568,11 +570,16 @@ multi-select, users can add a new board group from the board itself; this
568
570
  appends a new option to the grouped property definition.
569
571
  Use
570
572
  `create-content-database`, `get-content-database`,
571
- `add-database-item`, `duplicate-database-item`, `move-database-item`,
573
+ `add-database-item`, `duplicate-database-item`, `duplicate-database-items`,
574
+ `delete-database-items`, `move-database-item`,
572
575
  `update-content-database-view`, `list-document-properties`,
573
576
  `configure-document-property`, `set-document-property`,
574
577
  `duplicate-document-property`, and `delete-document-property`; do not edit
575
578
  property rows or view config via raw SQL when an action can do it.
579
+ When targeting more than one database row, call `duplicate-database-items` or
580
+ `delete-database-items` once with a native JSON array of `itemIds` or
581
+ `documentIds`. Do not loop `duplicate-database-item` or `delete-document` for
582
+ multi-row duplicate/delete requests.
576
583
 
577
584
  ## UI Components
578
585
 
@@ -0,0 +1,230 @@
1
+ import { and, asc, eq, inArray, isNull, or, sql } from "drizzle-orm";
2
+ import { z } from "zod";
3
+
4
+ import { getDb, schema } from "../server/db/index.js";
5
+
6
+ export const DATABASE_ROW_BATCH_LIMIT = 100;
7
+
8
+ export const databaseRowBatchSchema = z
9
+ .object({
10
+ databaseId: z.string().optional().describe("Content database ID"),
11
+ documentId: z
12
+ .string()
13
+ .optional()
14
+ .describe("Content database backing document ID"),
15
+ itemIds: z
16
+ .array(z.string())
17
+ .max(DATABASE_ROW_BATCH_LIMIT)
18
+ .optional()
19
+ .describe(
20
+ 'Native JSON array of database row item IDs to mutate in one batch, for example ["item_1", "item_2"].',
21
+ ),
22
+ documentIds: z
23
+ .array(z.string())
24
+ .max(DATABASE_ROW_BATCH_LIMIT)
25
+ .optional()
26
+ .describe(
27
+ 'Native JSON array of database row document IDs to mutate in one batch, for example ["doc_1", "doc_2"].',
28
+ ),
29
+ })
30
+ .superRefine((value, ctx) => {
31
+ if (!value.databaseId && !value.documentId) {
32
+ ctx.addIssue({
33
+ code: z.ZodIssueCode.custom,
34
+ message: "Either databaseId or documentId is required.",
35
+ });
36
+ }
37
+ const total =
38
+ (value.itemIds?.length ?? 0) + (value.documentIds?.length ?? 0);
39
+ if (total === 0) {
40
+ ctx.addIssue({
41
+ code: z.ZodIssueCode.custom,
42
+ message: "At least one itemId or documentId is required.",
43
+ });
44
+ }
45
+ if (total > DATABASE_ROW_BATCH_LIMIT) {
46
+ ctx.addIssue({
47
+ code: z.ZodIssueCode.custom,
48
+ message: `Database row batch is limited to ${DATABASE_ROW_BATCH_LIMIT} rows.`,
49
+ });
50
+ }
51
+ });
52
+
53
+ export type DatabaseRowBatchInput = z.infer<typeof databaseRowBatchSchema>;
54
+
55
+ export type DatabaseRowBatchRow = {
56
+ item: typeof schema.contentDatabaseItems.$inferSelect;
57
+ database: typeof schema.contentDatabases.$inferSelect;
58
+ document: typeof schema.documents.$inferSelect;
59
+ };
60
+
61
+ function findDuplicates(values: string[]) {
62
+ const seen = new Set<string>();
63
+ const duplicates = new Set<string>();
64
+ for (const value of values) {
65
+ if (seen.has(value)) duplicates.add(value);
66
+ seen.add(value);
67
+ }
68
+ return [...duplicates];
69
+ }
70
+
71
+ function positionCaseSql(
72
+ idColumn: unknown,
73
+ fallbackColumn: unknown,
74
+ orderedIds: string[],
75
+ ) {
76
+ const cases = orderedIds.map((id, index) => sql`WHEN ${id} THEN ${index}`);
77
+ return sql<number>`CASE ${idColumn} ${sql.join(cases, sql` `)} ELSE ${fallbackColumn} END`;
78
+ }
79
+
80
+ export async function resolveDatabaseRowsForBatch(
81
+ input: DatabaseRowBatchInput,
82
+ ): Promise<{
83
+ database: typeof schema.contentDatabases.$inferSelect;
84
+ rows: DatabaseRowBatchRow[];
85
+ }> {
86
+ const itemIds = input.itemIds ?? [];
87
+ const documentIds = input.documentIds ?? [];
88
+ const requestedCount = itemIds.length + documentIds.length;
89
+ if (!input.databaseId && !input.documentId) {
90
+ throw new Error("Either databaseId or documentId is required.");
91
+ }
92
+ if (requestedCount === 0) {
93
+ throw new Error("At least one itemId or documentId is required.");
94
+ }
95
+ if (requestedCount > DATABASE_ROW_BATCH_LIMIT) {
96
+ throw new Error(
97
+ `Database row batch is limited to ${DATABASE_ROW_BATCH_LIMIT} rows.`,
98
+ );
99
+ }
100
+ const duplicateItemIds = findDuplicates(itemIds);
101
+ const duplicateDocumentIds = findDuplicates(documentIds);
102
+ if (duplicateItemIds.length > 0 || duplicateDocumentIds.length > 0) {
103
+ throw new Error("Duplicate database row IDs are not allowed in one batch.");
104
+ }
105
+
106
+ const db = getDb();
107
+ const [database] = await db
108
+ .select()
109
+ .from(schema.contentDatabases)
110
+ .where(
111
+ and(
112
+ input.databaseId
113
+ ? eq(schema.contentDatabases.id, input.databaseId)
114
+ : undefined,
115
+ input.documentId
116
+ ? eq(schema.contentDatabases.documentId, input.documentId)
117
+ : undefined,
118
+ isNull(schema.contentDatabases.deletedAt),
119
+ ),
120
+ );
121
+
122
+ if (!database) throw new Error("Database not found.");
123
+
124
+ const rowPredicates = [];
125
+ if (itemIds.length > 0) {
126
+ rowPredicates.push(inArray(schema.contentDatabaseItems.id, itemIds));
127
+ }
128
+ if (documentIds.length > 0) {
129
+ rowPredicates.push(
130
+ inArray(schema.contentDatabaseItems.documentId, documentIds),
131
+ );
132
+ }
133
+
134
+ const requestedRows = await db
135
+ .select({
136
+ item: schema.contentDatabaseItems,
137
+ database: schema.contentDatabases,
138
+ document: schema.documents,
139
+ })
140
+ .from(schema.contentDatabaseItems)
141
+ .innerJoin(
142
+ schema.contentDatabases,
143
+ eq(schema.contentDatabases.id, schema.contentDatabaseItems.databaseId),
144
+ )
145
+ .innerJoin(
146
+ schema.documents,
147
+ eq(schema.documents.id, schema.contentDatabaseItems.documentId),
148
+ )
149
+ .where(
150
+ and(
151
+ eq(schema.contentDatabaseItems.databaseId, database.id),
152
+ rowPredicates.length === 1 ? rowPredicates[0] : or(...rowPredicates),
153
+ isNull(schema.contentDatabases.deletedAt),
154
+ ),
155
+ )
156
+ .orderBy(asc(schema.contentDatabaseItems.position));
157
+
158
+ const itemIdMatches = new Set(requestedRows.map((row) => row.item.id));
159
+ const documentIdMatches = new Set(
160
+ requestedRows.map((row) => row.item.documentId),
161
+ );
162
+ const missingItemIds = itemIds.filter((id) => !itemIdMatches.has(id));
163
+ const missingDocumentIds = documentIds.filter(
164
+ (id) => !documentIdMatches.has(id),
165
+ );
166
+ if (missingItemIds.length > 0 || missingDocumentIds.length > 0) {
167
+ throw new Error("All requested rows must exist in the target database.");
168
+ }
169
+
170
+ if (requestedRows.length !== requestedCount) {
171
+ throw new Error("Duplicate database row references are not allowed.");
172
+ }
173
+
174
+ const rowIds = requestedRows.map((row) => row.item.id);
175
+ if (new Set(rowIds).size !== rowIds.length) {
176
+ throw new Error("Duplicate database row references are not allowed.");
177
+ }
178
+
179
+ return { database, rows: requestedRows };
180
+ }
181
+
182
+ export async function renumberDatabaseRows(
183
+ db: ReturnType<typeof getDb>,
184
+ database: typeof schema.contentDatabases.$inferSelect,
185
+ now: string,
186
+ ) {
187
+ const rows = await db
188
+ .select()
189
+ .from(schema.contentDatabaseItems)
190
+ .where(eq(schema.contentDatabaseItems.databaseId, database.id))
191
+ .orderBy(asc(schema.contentDatabaseItems.position));
192
+ if (rows.length === 0) return;
193
+
194
+ const itemIds = rows.map((row) => row.id);
195
+ const documentIds = rows.map((row) => row.documentId);
196
+ await db
197
+ .update(schema.contentDatabaseItems)
198
+ .set({
199
+ position: positionCaseSql(
200
+ schema.contentDatabaseItems.id,
201
+ schema.contentDatabaseItems.position,
202
+ itemIds,
203
+ ),
204
+ updatedAt: now,
205
+ })
206
+ .where(
207
+ and(
208
+ eq(schema.contentDatabaseItems.databaseId, database.id),
209
+ inArray(schema.contentDatabaseItems.id, itemIds),
210
+ ),
211
+ );
212
+
213
+ await db
214
+ .update(schema.documents)
215
+ .set({
216
+ position: positionCaseSql(
217
+ schema.documents.id,
218
+ schema.documents.position,
219
+ documentIds,
220
+ ),
221
+ updatedAt: now,
222
+ })
223
+ .where(
224
+ and(
225
+ eq(schema.documents.ownerEmail, database.ownerEmail),
226
+ eq(schema.documents.parentId, database.documentId),
227
+ inArray(schema.documents.id, documentIds),
228
+ ),
229
+ );
230
+ }
@@ -285,8 +285,8 @@ export async function isSoftDeletedDatabaseDocument(documentId: string) {
285
285
  export async function getDatabaseByDocumentId(
286
286
  documentId: string,
287
287
  options: { includeDeleted?: boolean } = {},
288
+ db = getDb(),
288
289
  ) {
289
- const db = getDb();
290
290
  const clauses = [eq(schema.contentDatabases.documentId, documentId)];
291
291
  if (!options.includeDeleted) {
292
292
  clauses.push(isNull(schema.contentDatabases.deletedAt));
@@ -301,8 +301,8 @@ export async function getDatabaseByDocumentId(
301
301
  export async function getDatabaseItemByDocumentId(
302
302
  documentId: string,
303
303
  options: { includeDeleted?: boolean } = {},
304
+ db = getDb(),
304
305
  ) {
305
- const db = getDb();
306
306
  const clauses = [eq(schema.contentDatabaseItems.documentId, documentId)];
307
307
  if (!options.includeDeleted) {
308
308
  clauses.push(isNull(schema.contentDatabases.deletedAt));
@@ -332,11 +332,15 @@ export async function getDatabaseItemByDocumentId(
332
332
  export async function deleteDatabaseDataForDocument(
333
333
  documentId: string,
334
334
  ownerEmail: string,
335
+ db = getDb(),
335
336
  ) {
336
- const db = getDb();
337
- const database = await getDatabaseByDocumentId(documentId, {
338
- includeDeleted: true,
339
- });
337
+ const database = await getDatabaseByDocumentId(
338
+ documentId,
339
+ {
340
+ includeDeleted: true,
341
+ },
342
+ db,
343
+ );
340
344
  if (database) {
341
345
  const definitions = await db
342
346
  .select({ id: schema.documentPropertyDefinitions.id })
@@ -395,9 +399,13 @@ export async function deleteDatabaseDataForDocument(
395
399
  .where(eq(schema.contentDatabases.id, database.id));
396
400
  }
397
401
 
398
- const item = await getDatabaseItemByDocumentId(documentId, {
399
- includeDeleted: true,
400
- });
402
+ const item = await getDatabaseItemByDocumentId(
403
+ documentId,
404
+ {
405
+ includeDeleted: true,
406
+ },
407
+ db,
408
+ );
401
409
  if (item) {
402
410
  await db
403
411
  .delete(schema.contentDatabaseBodyHydrationQueue)
@@ -0,0 +1,26 @@
1
+ import {
2
+ getRequestOrgId,
3
+ getRequestUserEmail,
4
+ } from "@agent-native/core/server";
5
+ import { assertAccess } from "@agent-native/core/sharing";
6
+
7
+ export function getCurrentNotionOwner() {
8
+ const owner = getRequestUserEmail();
9
+ if (!owner) throw new Error("no authenticated user");
10
+ return owner;
11
+ }
12
+
13
+ export async function getNotionDocumentOwner(documentId: string) {
14
+ const userEmail = getCurrentNotionOwner();
15
+ await assertAccess("document", documentId, "editor", {
16
+ userEmail,
17
+ orgId: getRequestOrgId(),
18
+ });
19
+ return userEmail;
20
+ }
21
+
22
+ export function resolveDocumentId(args: { documentId?: string; id?: string }) {
23
+ const documentId = args.documentId || args.id;
24
+ if (!documentId) throw new Error("documentId is required");
25
+ return documentId;
26
+ }
@@ -1,21 +1,30 @@
1
1
  import { defineAction } from "@agent-native/core";
2
- import { getRequestUserEmail } from "@agent-native/core/server";
2
+ import { resolveSecret } from "@agent-native/core/server";
3
3
  import { z } from "zod";
4
4
 
5
5
  import { getNotionConnectionForOwner } from "../server/lib/notion.js";
6
+ import { getCurrentNotionOwner } from "./_notion-action-utils.js";
6
7
 
7
8
  export default defineAction({
8
9
  description: "Check Notion connection status for the current user.",
9
10
  schema: z.object({}),
10
- http: false,
11
+ http: { method: "GET" },
11
12
  run: async () => {
12
- const owner = getRequestUserEmail();
13
- if (!owner) throw new Error("no authenticated user");
13
+ const owner = getCurrentNotionOwner();
14
14
  const connection = await getNotionConnectionForOwner(owner);
15
+ const hasOAuthCredentials = Boolean(
16
+ (await resolveSecret("NOTION_CLIENT_ID")) &&
17
+ (await resolveSecret("NOTION_CLIENT_SECRET")),
18
+ );
19
+
15
20
  return {
16
21
  connected: Boolean(connection),
17
22
  workspaceName: connection?.workspaceName ?? null,
18
23
  workspaceId: connection?.workspaceId ?? null,
24
+ authUrl: null,
25
+ error:
26
+ connection || hasOAuthCredentials ? undefined : "missing_credentials",
27
+ mode: connection ? ("oauth" as const) : null,
19
28
  };
20
29
  },
21
30
  });
@@ -0,0 +1,23 @@
1
+ import { defineAction } from "@agent-native/core";
2
+ import { z } from "zod";
3
+
4
+ import { createAndLinkNotionPage } from "../server/lib/notion-sync.js";
5
+ import {
6
+ getNotionDocumentOwner,
7
+ resolveDocumentId,
8
+ } from "./_notion-action-utils.js";
9
+
10
+ export default defineAction({
11
+ description: "Create a Notion page from a Content document and link it.",
12
+ schema: z.object({
13
+ documentId: z.string().optional().describe("Document ID (required)"),
14
+ id: z.string().optional().describe("Alias for --documentId"),
15
+ parentPageIdOrUrl: z.string().optional(),
16
+ }),
17
+ http: { method: "POST" },
18
+ run: async (args) => {
19
+ const documentId = resolveDocumentId(args);
20
+ const owner = await getNotionDocumentOwner(documentId);
21
+ return createAndLinkNotionPage(owner, documentId, args.parentPageIdOrUrl);
22
+ },
23
+ });
@@ -0,0 +1,55 @@
1
+ import { defineAction } from "@agent-native/core";
2
+ import { writeAppState } from "@agent-native/core/application-state";
3
+ import { assertAccess } from "@agent-native/core/sharing";
4
+
5
+ import { getDb } from "../server/db/index.js";
6
+ import {
7
+ databaseRowBatchSchema,
8
+ renumberDatabaseRows,
9
+ resolveDatabaseRowsForBatch,
10
+ } from "./_database-row-batch.js";
11
+ import { getContentDatabaseResponse } from "./_database-utils.js";
12
+ import { deleteDocumentRecursive } from "./delete-document.js";
13
+
14
+ export default defineAction({
15
+ description:
16
+ "Delete multiple page rows from a content database in one atomic batch. Use this for two or more selected/named rows instead of looping delete-document.",
17
+ schema: databaseRowBatchSchema,
18
+ run: async (args) => {
19
+ const db = getDb();
20
+ const { database, rows } = await resolveDatabaseRowsForBatch(args);
21
+
22
+ await assertAccess("document", database.documentId, "editor");
23
+ for (const row of rows) {
24
+ await assertAccess("document", row.document.id, "admin");
25
+ }
26
+
27
+ const deletedItemIds = rows.map((row) => row.item.id);
28
+ const deletedDocumentIds = rows.map((row) => row.document.id);
29
+ const now = new Date().toISOString();
30
+
31
+ await db.transaction(async (tx) => {
32
+ for (const row of rows) {
33
+ await deleteDocumentRecursive(
34
+ tx as unknown as ReturnType<typeof getDb>,
35
+ row.document.id,
36
+ row.document.ownerEmail,
37
+ );
38
+ }
39
+ await renumberDatabaseRows(
40
+ tx as unknown as ReturnType<typeof getDb>,
41
+ database,
42
+ now,
43
+ );
44
+ });
45
+
46
+ await writeAppState("refresh-signal", { ts: Date.now() });
47
+
48
+ return {
49
+ ...(await getContentDatabaseResponse(database.id)),
50
+ deletedItemIds,
51
+ deletedDocumentIds,
52
+ deletedCount: deletedItemIds.length,
53
+ };
54
+ },
55
+ });
@@ -12,7 +12,7 @@ import {
12
12
  isContentLocalFileMode,
13
13
  } from "./_local-file-documents.js";
14
14
 
15
- async function deleteRecursive(
15
+ export async function deleteDocumentRecursive(
16
16
  db: ReturnType<typeof getDb>,
17
17
  id: string,
18
18
  ownerEmail: string,
@@ -29,11 +29,11 @@ async function deleteRecursive(
29
29
 
30
30
  const deleted: string[] = [];
31
31
  for (const child of children) {
32
- deleted.push(...(await deleteRecursive(db, child.id, ownerEmail)));
32
+ deleted.push(...(await deleteDocumentRecursive(db, child.id, ownerEmail)));
33
33
  }
34
34
 
35
35
  // Delete database membership/schema, sync links, versions, shares, then document.
36
- await deleteDatabaseDataForDocument(id, ownerEmail);
36
+ await deleteDatabaseDataForDocument(id, ownerEmail, db);
37
37
  await db
38
38
  .delete(schema.documentSyncLinks)
39
39
  .where(
@@ -86,7 +86,7 @@ export default defineAction({
86
86
  const existing = access.resource;
87
87
 
88
88
  const db = getDb();
89
- const deleted = await deleteRecursive(
89
+ const deleted = await deleteDocumentRecursive(
90
90
  db,
91
91
  id,
92
92
  existing.ownerEmail as string,