@ljwei-stak/dsh-model-route-bot 0.66.4 → 0.67.0

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 (271) hide show
  1. package/.dsh-plugin/bot-product.mjs +81 -5
  2. package/.dsh-plugin/client.js +17490 -1791
  3. package/README.i18n.yaml +4 -4
  4. package/README.md +409 -364
  5. package/README.zh.md +409 -364
  6. package/apps/integration-client/README.md +31 -7
  7. package/apps/integration-client/api.mjs +14 -3
  8. package/apps/integration-client/binding-editor.jsx +308 -109
  9. package/apps/integration-client/binding-model.mjs +262 -0
  10. package/apps/integration-client/binding.css +144 -0
  11. package/apps/integration-client/bot-model-status.mjs +18 -0
  12. package/apps/integration-client/bot-profile-identity.jsx +197 -0
  13. package/apps/integration-client/bot-profile.css +57 -0
  14. package/apps/integration-client/bot-profile.mjs +97 -0
  15. package/apps/integration-client/bot-settings-dialog.css +18 -0
  16. package/apps/integration-client/bot-settings-dialog.jsx +111 -0
  17. package/apps/integration-client/bot-thread-header-bridge.css +20 -0
  18. package/apps/integration-client/bot-thread-header.jsx +278 -0
  19. package/apps/integration-client/bot-threads-panel.jsx +378 -0
  20. package/apps/integration-client/bot-threads.css +169 -0
  21. package/apps/integration-client/chat-design.mjs +250 -0
  22. package/apps/integration-client/chat-ui.jsx +175 -0
  23. package/apps/integration-client/chat.css +477 -0
  24. package/apps/integration-client/composer-draft-binding.mjs +50 -0
  25. package/apps/integration-client/composer-draft-bound.jsx +14 -0
  26. package/apps/integration-client/composer-draft-host.jsx +142 -0
  27. package/apps/integration-client/composer-draft-session.jsx +287 -0
  28. package/apps/integration-client/composer-draft-witness.mjs +115 -0
  29. package/apps/integration-client/composer-draft.css +4 -0
  30. package/apps/integration-client/composer-draft.mjs +322 -0
  31. package/apps/integration-client/contact-attention.jsx +54 -0
  32. package/apps/integration-client/data-panel.jsx +439 -0
  33. package/apps/integration-client/data.css +69 -0
  34. package/apps/integration-client/desktop-bridge.mjs +1 -0
  35. package/apps/integration-client/desktop-http-socket.mjs +49 -0
  36. package/apps/integration-client/desktop-keyboard.mjs +32 -0
  37. package/apps/integration-client/desktop-pane.jsx +99 -30
  38. package/apps/integration-client/desktop-stream.mjs +13 -3
  39. package/apps/integration-client/desktop-tools.jsx +19 -0
  40. package/apps/integration-client/desktop-touch.mjs +27 -0
  41. package/apps/integration-client/desktop.css +41 -0
  42. package/apps/integration-client/independent-chat-controller.mjs +224 -0
  43. package/apps/integration-client/independent-chat-entry.jsx +61 -0
  44. package/apps/integration-client/independent-chat-navigation.mjs +21 -0
  45. package/apps/integration-client/independent-chat-page.jsx +148 -0
  46. package/apps/integration-client/independent-chat-rich.jsx +64 -0
  47. package/apps/integration-client/independent-chat-rich.mjs +30 -0
  48. package/apps/integration-client/independent-chat.css +73 -0
  49. package/apps/integration-client/index.jsx +24 -16
  50. package/apps/integration-client/library-panel.jsx +4 -1
  51. package/apps/integration-client/licenses/jsqr-1.4.0-LICENSE.txt +202 -0
  52. package/apps/integration-client/main-route-editor.jsx +94 -0
  53. package/apps/integration-client/main-route-model.mjs +100 -0
  54. package/apps/integration-client/model-picker.css +37 -0
  55. package/apps/integration-client/model-picker.jsx +83 -0
  56. package/apps/integration-client/native-activity.css +141 -0
  57. package/apps/integration-client/native-activity.jsx +276 -0
  58. package/apps/integration-client/native-activity.mjs +384 -0
  59. package/apps/integration-client/notifications-panel.jsx +186 -0
  60. package/apps/integration-client/notifications.css +126 -0
  61. package/apps/integration-client/pairing-qr.jsx +79 -0
  62. package/apps/integration-client/panels.jsx +126 -115
  63. package/apps/integration-client/product-slots.jsx +46 -0
  64. package/apps/integration-client/routine-recorder.mjs +23 -2
  65. package/apps/integration-client/secure-pairing.jsx +84 -0
  66. package/apps/integration-client/tasks-panel.jsx +32 -10
  67. package/apps/integration-client/ui-icons.jsx +17 -0
  68. package/apps/integration-client/usage-explorer.jsx +43 -0
  69. package/apps/integration-client/usage-report.mjs +71 -0
  70. package/apps/integration-client/usage.css +34 -0
  71. package/apps/integration-client/web-panel-model.mjs +140 -0
  72. package/apps/integration-client/web-panel-page.jsx +221 -0
  73. package/apps/integration-client/web-panel-pins-live.jsx +28 -0
  74. package/apps/integration-client/web-panel.css +139 -0
  75. package/apps/integration-client/workspace-model.mjs +67 -0
  76. package/apps/integration-client/workspace-navigation.mjs +56 -0
  77. package/apps/integration-client/workspace-ui.jsx +173 -0
  78. package/apps/integration-client/workspace.css +332 -0
  79. package/modules/bot-runtime/SOURCE.json +176 -50
  80. package/modules/bot-runtime/index.mjs +169 -13
  81. package/modules/bot-runtime/product-overlay/OVERLAY.json +128 -2
  82. package/modules/bot-runtime/product-overlay/base/src/client/header.js +244 -0
  83. package/modules/bot-runtime/product-overlay/base/src/client/learning-card.js +202 -0
  84. package/modules/bot-runtime/product-overlay/base/src/client/model-menu.js +371 -0
  85. package/modules/bot-runtime/product-overlay/base/src/client/secret-card.js +363 -0
  86. package/modules/bot-runtime/product-overlay/base/src/client/sources.js +68 -0
  87. package/modules/bot-runtime/product-overlay/base/src/client/turn-error.js +260 -0
  88. package/modules/bot-runtime/product-overlay/base/src/host/channel.js +647 -0
  89. package/modules/bot-runtime/product-overlay/base/src/host/memory.js +2050 -0
  90. package/modules/bot-runtime/product-overlay/base/src/host/models.js +314 -0
  91. package/modules/bot-runtime/product-overlay/base/src/host/parts.js +1473 -0
  92. package/modules/bot-runtime/product-overlay/base/src/host/privacy.js +368 -0
  93. package/modules/bot-runtime/product-overlay/base/src/host/roster.js +776 -0
  94. package/modules/bot-runtime/product-overlay/base/src/host/store.js +512 -0
  95. package/modules/bot-runtime/product-overlay/base/src/host/usage.js +653 -0
  96. package/modules/bot-runtime/product-overlay/patches/src/client/details-drawer.js.patch +48 -2
  97. package/modules/bot-runtime/product-overlay/patches/src/client/header.js.patch +41 -0
  98. package/modules/bot-runtime/product-overlay/patches/src/client/index.js.patch +33 -1
  99. package/modules/bot-runtime/product-overlay/patches/src/client/learning-card.js.patch +20 -0
  100. package/modules/bot-runtime/product-overlay/patches/src/client/model-menu.js.patch +24 -0
  101. package/modules/bot-runtime/product-overlay/patches/src/client/secret-card.js.patch +25 -0
  102. package/modules/bot-runtime/product-overlay/patches/src/client/sources.js.patch +52 -0
  103. package/modules/bot-runtime/product-overlay/patches/src/client/turn-error.js.patch +23 -0
  104. package/modules/bot-runtime/product-overlay/patches/src/host/channel.js.patch +46 -0
  105. package/modules/bot-runtime/product-overlay/patches/src/host/memory.js.patch +11 -0
  106. package/modules/bot-runtime/product-overlay/patches/src/host/models.js.patch +16 -0
  107. package/modules/bot-runtime/product-overlay/patches/src/host/parts.js.patch +95 -0
  108. package/modules/bot-runtime/product-overlay/patches/src/host/privacy.js.patch +11 -0
  109. package/modules/bot-runtime/product-overlay/patches/src/host/roster.js.patch +145 -0
  110. package/modules/bot-runtime/product-overlay/patches/src/host/store.js.patch +68 -0
  111. package/modules/bot-runtime/product-overlay/patches/src/host/usage.js.patch +11 -0
  112. package/modules/bot-runtime/read-model.mjs +19 -4
  113. package/modules/bot-runtime/vendor/ds-bot/src/client/details-drawer.js +15 -5
  114. package/modules/bot-runtime/vendor/ds-bot/src/client/header.js +9 -2
  115. package/modules/bot-runtime/vendor/ds-bot/src/client/index.js +9 -2
  116. package/modules/bot-runtime/vendor/ds-bot/src/client/learning-card.js +2 -2
  117. package/modules/bot-runtime/vendor/ds-bot/src/client/model-menu.js +5 -3
  118. package/modules/bot-runtime/vendor/ds-bot/src/client/secret-card.js +3 -3
  119. package/modules/bot-runtime/vendor/ds-bot/src/client/sources.js +25 -2
  120. package/modules/bot-runtime/vendor/ds-bot/src/client/turn-error.js +3 -2
  121. package/modules/bot-runtime/vendor/ds-bot/src/host/channel.js +14 -2
  122. package/modules/bot-runtime/vendor/ds-bot/src/host/memory.js +1 -1
  123. package/modules/bot-runtime/vendor/ds-bot/src/host/models.js +7 -0
  124. package/modules/bot-runtime/vendor/ds-bot/src/host/parts.js +26 -9
  125. package/modules/bot-runtime/vendor/ds-bot/src/host/privacy.js +1 -1
  126. package/modules/bot-runtime/vendor/ds-bot/src/host/roster.js +112 -3
  127. package/modules/bot-runtime/vendor/ds-bot/src/host/store.js +34 -1
  128. package/modules/bot-runtime/vendor/ds-bot/src/host/usage.js +1 -1
  129. package/modules/independent-runtime/index.mjs +152 -62
  130. package/modules/independent-runtime/remote-tools.mjs +8 -5
  131. package/modules/openmaus-protocol/NOTICE +10 -0
  132. package/modules/openmaus-protocol/README.md +15 -0
  133. package/modules/openmaus-protocol/SOURCE.json +68 -0
  134. package/modules/openmaus-protocol/dist/protocol.mjs +1203 -0
  135. package/modules/openmaus-protocol/index.mjs +1 -0
  136. package/modules/openmaus-protocol/transport.mjs +77 -0
  137. package/modules/openmaus-protocol/vendor/LICENSE +202 -0
  138. package/modules/openmaus-protocol/vendor/companion/src/e2e/README.md +405 -0
  139. package/modules/openmaus-protocol/vendor/companion/src/e2e/credentials.ts +128 -0
  140. package/modules/openmaus-protocol/vendor/companion/src/e2e/descriptor.ts +84 -0
  141. package/modules/openmaus-protocol/vendor/companion/src/e2e/noise.ts +456 -0
  142. package/modules/openmaus-protocol/vendor/companion/src/e2e/phone.ts +104 -0
  143. package/modules/openmaus-protocol/vendor/companion/src/e2e/session.ts +417 -0
  144. package/modules/openmaus-protocol/vendor/companion/src/e2e/vectors/v1.json +2565 -0
  145. package/modules/openmaus-protocol/vendor/companion/src/e2e/vectors/v1.sha256 +1 -0
  146. package/modules/openmaus-protocol/vendor/companion/src/e2e/yamux.ts +335 -0
  147. package/package.json +18 -6
  148. package/packages/bot-acp/README.md +156 -0
  149. package/packages/bot-acp/licenses/LICENSE-OpenMausBot +202 -0
  150. package/packages/bot-acp/licenses/NOTICE.md +27 -0
  151. package/packages/bot-acp/src/acp-adapter.mjs +795 -0
  152. package/packages/bot-acp/src/acp-host.mjs +148 -0
  153. package/packages/bot-acp/src/native-launch.mjs +121 -0
  154. package/packages/bot-acp/src/permission-policy.mjs +283 -0
  155. package/packages/bot-acp/src/rpc-codec.mjs +265 -0
  156. package/packages/bot-acp/src/session-core.mjs +581 -0
  157. package/packages/bot-acp/src/stdio-rpc.mjs +329 -0
  158. package/packages/bot-engines/README.md +281 -0
  159. package/packages/bot-engines/licenses/LICENSE-OpenMausBot +202 -0
  160. package/packages/bot-engines/licenses/NOTICE.md +32 -0
  161. package/packages/bot-engines/src/claude-engine.mjs +772 -0
  162. package/packages/bot-engines/src/claude-llm-adapter.mjs +797 -0
  163. package/packages/bot-engines/src/claude-llm-turn.mjs +292 -0
  164. package/packages/bot-engines/src/claude-permission-broker.mjs +130 -0
  165. package/packages/bot-engines/src/claude-permission-proxy.mjs +87 -0
  166. package/packages/bot-engines/src/claude-protocol.mjs +177 -0
  167. package/packages/bot-engines/src/driver-catalog.mjs +334 -0
  168. package/packages/bot-engines/src/host-loop-request.mjs +95 -0
  169. package/packages/bot-engines/src/host-registrations.mjs +105 -0
  170. package/packages/capability-broker/src/cli-bootstrap.mjs +86 -16
  171. package/packages/capability-broker/src/cli-bridge-response.mjs +80 -0
  172. package/packages/capability-broker/src/cli-stdio-bridge.mjs +21 -8
  173. package/packages/cli-runtime/src/codex-session-core.mjs +4 -0
  174. package/packages/cli-runtime/src/native-cli-output.mjs +57 -0
  175. package/packages/cli-runtime/src/native-peer.ps1 +51 -3
  176. package/packages/cli-runtime/src/native-process.mjs +77 -14
  177. package/packages/model-router-plugin/package.json +1 -1
  178. package/packages/orchestration/src/binding-product.mjs +19 -0
  179. package/packages/orchestration/src/bot-data-actions.mjs +45 -0
  180. package/packages/orchestration/src/bot-data-chart.mjs +109 -0
  181. package/packages/orchestration/src/bot-data-context.mjs +44 -0
  182. package/packages/orchestration/src/bot-data-engine.mjs +809 -0
  183. package/packages/orchestration/src/bot-data-io.mjs +186 -0
  184. package/packages/orchestration/src/bot-data-sql.mjs +195 -0
  185. package/packages/orchestration/src/bot-data-surface.mjs +144 -0
  186. package/packages/orchestration/src/bot-data-tools.mjs +68 -0
  187. package/packages/orchestration/src/bot-data-worker.mjs +228 -0
  188. package/packages/orchestration/src/bot-independent-execution.mjs +195 -0
  189. package/packages/orchestration/src/bot-independent-stream.mjs +74 -0
  190. package/packages/orchestration/src/bot-loop.mjs +152 -19
  191. package/packages/orchestration/src/bot-notifications.mjs +159 -0
  192. package/packages/orchestration/src/bot-policy.mjs +43 -4
  193. package/packages/orchestration/src/bot-profile.mjs +171 -0
  194. package/packages/orchestration/src/bot-routine.mjs +4 -4
  195. package/packages/orchestration/src/bot-runtime-assembly.mjs +255 -8
  196. package/packages/orchestration/src/bot-runtime-switch.mjs +13 -2
  197. package/packages/orchestration/src/bot-serving.mjs +164 -0
  198. package/packages/orchestration/src/bot-session-store.mjs +105 -9
  199. package/packages/orchestration/src/bot-settings.mjs +50 -11
  200. package/packages/orchestration/src/bot-thread-admission.mjs +84 -0
  201. package/packages/orchestration/src/bot-thread-host.mjs +187 -0
  202. package/packages/orchestration/src/bot-threads.mjs +790 -0
  203. package/packages/orchestration/src/data-product-access.mjs +37 -0
  204. package/packages/orchestration/src/data-tool-contract.mjs +291 -0
  205. package/packages/orchestration/src/desktop-http-carrier.mjs +120 -0
  206. package/packages/orchestration/src/desktop-proxy.mjs +311 -0
  207. package/packages/orchestration/src/desktop-sessions.mjs +43 -9
  208. package/packages/orchestration/src/ds-lifecycle.mjs +31 -4
  209. package/packages/orchestration/src/grant-refresh-queue.mjs +17 -0
  210. package/packages/orchestration/src/independent-chat-page.mjs +39 -0
  211. package/packages/orchestration/src/independent-chat-service.mjs +308 -0
  212. package/packages/orchestration/src/independent-context-state.mjs +44 -0
  213. package/packages/orchestration/src/independent-message-wire.mjs +84 -0
  214. package/packages/orchestration/src/independent-tool-catalog.mjs +175 -0
  215. package/packages/orchestration/src/independent-tool-families.mjs +73 -0
  216. package/packages/orchestration/src/independent-tool-host.mjs +344 -0
  217. package/packages/orchestration/src/main-bot-grant.mjs +15 -0
  218. package/packages/orchestration/src/main-model-routes.mjs +208 -0
  219. package/packages/orchestration/src/model-accounting.mjs +99 -35
  220. package/packages/orchestration/src/model-auxiliary-dispatch.mjs +187 -0
  221. package/packages/orchestration/src/model-loop-frame.mjs +179 -0
  222. package/packages/orchestration/src/model-prepared-generation.mjs +175 -0
  223. package/packages/orchestration/src/model-request-bounds.mjs +111 -0
  224. package/packages/orchestration/src/native-activity-contract.mjs +491 -0
  225. package/packages/orchestration/src/native-activity-service.mjs +102 -0
  226. package/packages/orchestration/src/native-activity-store.mjs +249 -0
  227. package/packages/orchestration/src/native-conversation-tools.mjs +78 -0
  228. package/packages/orchestration/src/native-data-tools.mjs +100 -0
  229. package/packages/orchestration/src/native-remote-actions.mjs +742 -0
  230. package/packages/orchestration/src/native-team-tools.mjs +73 -0
  231. package/packages/orchestration/src/pairing-invite.mjs +37 -0
  232. package/packages/orchestration/src/product-composition.mjs +1559 -1001
  233. package/packages/orchestration/src/remote-machines.mjs +82 -14
  234. package/packages/orchestration/src/routine-execution.mjs +5 -3
  235. package/packages/orchestration/src/routine-session.mjs +19 -13
  236. package/packages/orchestration/src/secure-pairing-host.mjs +112 -0
  237. package/packages/orchestration/src/task-journal.mjs +29 -0
  238. package/packages/orchestration/src/task-product.mjs +17 -0
  239. package/packages/orchestration/src/team-assignment-store.mjs +120 -0
  240. package/packages/orchestration/src/team-bridge.mjs +282 -0
  241. package/packages/orchestration/src/team-scheduler.mjs +83 -0
  242. package/packages/orchestration/src/team-sdk-tools.mjs +84 -0
  243. package/packages/orchestration/src/team-tool-contract.mjs +284 -0
  244. package/packages/orchestration/src/ui-host-namespace.mjs +23 -0
  245. package/packages/orchestration/src/web-panel-actions.mjs +103 -0
  246. package/packages/orchestration/src/web-panel-framing.mjs +227 -0
  247. package/packages/orchestration/src/web-panel-store.mjs +206 -0
  248. package/remote-computer-agent/README.md +39 -29
  249. package/remote-computer-agent/build.mjs +3 -0
  250. package/remote-computer-agent/dist/OPENMAUSBOT_LICENSE +202 -0
  251. package/remote-computer-agent/dist/OPENMAUSBOT_NOTICE +10 -0
  252. package/remote-computer-agent/dist/OPENMAUSBOT_SOURCE.json +68 -0
  253. package/remote-computer-agent/dist/cli-stdio-bridge.mjs +215 -25
  254. package/remote-computer-agent/dist/cli-stdio-bridge.mjs.map +4 -4
  255. package/remote-computer-agent/dist/cli.mjs +1688 -168
  256. package/remote-computer-agent/dist/cli.mjs.map +4 -4
  257. package/remote-computer-agent/dist/index.mjs +1579 -122
  258. package/remote-computer-agent/dist/index.mjs.map +4 -4
  259. package/remote-computer-agent/dist/native-peer.ps1 +51 -3
  260. package/remote-computer-agent/package-lock.json +308 -2
  261. package/remote-computer-agent/package.json +2 -1
  262. package/remote-computer-agent/pair-device.ps1 +20 -0
  263. package/remote-computer-agent/setup.ps1 +38 -16
  264. package/remote-computer-agent/src/cli.ts +24 -3
  265. package/remote-computer-agent/src/desktop-session.ts +11 -3
  266. package/remote-computer-agent/src/e2e-server.ts +81 -0
  267. package/remote-computer-agent/src/pairing-page.ts +35 -0
  268. package/remote-computer-agent/src/protocol.ts +25 -25
  269. package/remote-computer-agent/src/secure-pairing.ts +105 -0
  270. package/remote-computer-agent/src/store.ts +20 -5
  271. package/remote-computer-agent/src/transport.ts +39 -15
@@ -0,0 +1,809 @@
1
+ /**
2
+ * The Bot data engine: one SQLite database per Bot, run in a fixed child
3
+ * process, addressed only by a validated Bot id. It is the single place the
4
+ * tools and the panel's actions meet SQLite, and the single place every bound
5
+ * and every cancellation is enforced.
6
+ *
7
+ * Isolation and authority:
8
+ * - A Bot's database path is computed by the Host (`dbPathFor(botId)`) from a
9
+ * validated id; the engine never accepts a path, a target Bot, an endpoint
10
+ * or a program from a caller. The model's tool call derives its Bot id from
11
+ * the live session (botIdFor); the panel's owner action names the Bot the
12
+ * signed-in person selected.
13
+ * - Data enters only through a bounded controlled read (`libraryReader`) or
14
+ * bounded inline content. No OS paths, URLs, DB connection strings.
15
+ * - Every statement is gated (bot-data-sql) before it reaches the child; the
16
+ * child runs a single prepared statement with extension loading disabled.
17
+ * - A query that exceeds its wall clock is cancelled by SIGKILLing the child;
18
+ * an unknown outcome is never replayed and the next call reopens the DB.
19
+ * - Replacing a table (load, materialise) is ONE transaction, so a failed
20
+ * replace leaves the previous table in place instead of a half-written one.
21
+ * - Only DATA_LIMITS.activeWorkersMax Bot workers are resident AT ANY MOMENT,
22
+ * counting workers that are starting or going away but have not confirmed
23
+ * their exit. A Bot without a free slot is refused with engine_unavailable
24
+ * (retryable) — nothing is queued and no write is replayed. An idle slot may
25
+ * be handed over, but only once that worker's exit is CONFIRMED.
26
+ * - A worker that was asked to exit and never confirmed it keeps its slot and
27
+ * this Bot refuses to open another one until the exit really happens;
28
+ * closeAll reports such a Bot as unconfirmed instead of claiming success.
29
+ * - Steps that write the database or the sheet metadata are serialized per Bot,
30
+ * so concurrent steps cannot interleave and lose a source entry or a card.
31
+ * - Only DATA_LIMITS.activeWorkersMax Bot workers stay resident; the least
32
+ * recently used IDLE worker is closed, and a closed/killed worker is awaited
33
+ * to a real exit before a new one opens the same file.
34
+ *
35
+ * The engine is created with injected Host services, so it is testable against
36
+ * an isolated tmp directory and the real better-sqlite3 without any user data.
37
+ */
38
+
39
+ import { fork } from 'node:child_process'
40
+ import { fileURLToPath } from 'node:url'
41
+ import { mkdir, readFile, rename, writeFile, unlink } from 'node:fs/promises'
42
+ import { dirname, join } from 'node:path'
43
+
44
+ import {
45
+ DATA_LIMITS, DataFailure, IDENT, assertBotId, dataError, quoteIdent, sanitizeDataMessage, tableNameFrom,
46
+ } from './bot-data-surface.mjs'
47
+ import { boundRows, gateSql } from './bot-data-sql.mjs'
48
+ import { parseDelimited, parseJsonText, toCsv, boundExport } from './bot-data-io.mjs'
49
+ import { compileChart, shapeChart } from './bot-data-chart.mjs'
50
+
51
+ const WORKER_URL = fileURLToPath(new URL('./bot-data-worker.mjs', import.meta.url))
52
+ const OPEN_TIMEOUT_MS = DATA_LIMITS.openTimeoutMs
53
+ const IDENTIFIER_LIKE = /^\s*("[^"]+"|[A-Za-z_][\w$]*)(\.("[^"]+"|[A-Za-z_][\w$]*)){0,2}\s*$/
54
+ // SQLite has no user schemas, so internal result/card tables live in `main` under a
55
+ // prefix `tableNameFrom` can never produce (it strips leading underscores). Loaded
56
+ // tables therefore never collide with, and never leak as, internal result tables.
57
+ const INTERNAL_PREFIX = '__mb_'
58
+ const cardTable = cardId => `${INTERNAL_PREFIX}card_${cardId}`
59
+ const namedTable = name => `${INTERNAL_PREFIX}q_${tableNameFrom(name)}`
60
+ const isInternal = name => name.startsWith(INTERNAL_PREFIX)
61
+
62
+ /**
63
+ * The fixed worker's environment: the smallest set a plain Node child needs on
64
+ * every platform, plus the marker that makes this file the worker entry. Nothing
65
+ * from the Host's environment (credentials, endpoints, NODE_OPTIONS) crosses
66
+ * over, so the child cannot be re-pointed by the environment it inherits.
67
+ */
68
+ const WORKER_ENV_KEYS = Object.freeze([
69
+ 'Path', 'PATH', 'SYSTEMROOT', 'SystemRoot', 'windir', 'COMSPEC',
70
+ 'TEMP', 'TMP', 'TMPDIR', 'HOME', 'USERPROFILE', 'LANG', 'LC_ALL',
71
+ ])
72
+ const workerEnv = () => {
73
+ const env = { MB_DATA_WORKER: '1' }
74
+ for (const key of WORKER_ENV_KEYS) if (typeof process.env[key] === 'string' && process.env[key] !== '') env[key] = process.env[key]
75
+ return env
76
+ }
77
+
78
+ const fail = (code, message, extra = {}) => { throw new DataFailure(dataError(code, message, extra)) }
79
+ const KNOWN_CODES = new Set(['invalid_input', 'source_not_found', 'source_unsupported', 'sql_error', 'write_refused', 'table_not_found', 'spec_invalid', 'card_not_found', 'output_too_large', 'timeout', 'cancelled', 'engine_unavailable', 'import_failed', 'dataset_conflict', 'unauthorized'])
80
+ const workerFailure = (error, secrets = []) => {
81
+ const code = KNOWN_CODES.has(error?.code) ? error.code : 'sql_error'
82
+ return new DataFailure(dataError(code, sanitizeDataMessage(error?.message ?? error, secrets)))
83
+ }
84
+ const settle = (promise, timeoutMs) => Promise.race([
85
+ promise.then(() => 'exited', () => 'exited'),
86
+ new Promise(resolve => { const timer = setTimeout(() => resolve('timeout'), timeoutMs); timer.unref?.() }),
87
+ ])
88
+ /** A per-key serial queue: the next job runs only after the previous one settles. */
89
+ const serialize = (locks, key, work) => {
90
+ const previous = locks.get(key) ?? Promise.resolve()
91
+ const next = previous.then(work, work)
92
+ const settled = next.then(() => {}, () => {})
93
+ locks.set(key, settled)
94
+ settled.then(() => { if (locks.get(key) === settled) locks.delete(key) })
95
+ return next
96
+ }
97
+
98
+ /** One Bot's child process and its request/timeout bookkeeping. */
99
+ class BotProcess {
100
+ constructor(botId, dbPath, { forkImpl = fork, execPath = process.execPath, now = Date.now, limits = null }) {
101
+ this.botId = botId
102
+ this.dbPath = dbPath
103
+ this.fork = forkImpl
104
+ this.execPath = execPath
105
+ this.now = now
106
+ this.limits = limits
107
+ this.child = null
108
+ this.pending = new Map()
109
+ this.nextId = 1
110
+ this.lastUsed = 0
111
+ this.exitPromise = null
112
+ this.starting = null
113
+ /** The current child's exit event has fired. */
114
+ this.exited = false
115
+ /** A child was asked to exit and never confirmed it: the slot stays taken. */
116
+ this.exitUnconfirmed = false
117
+ /** This worker is being retired or closed: it must not start another child. */
118
+ this.retiring = false
119
+ }
120
+
121
+ /**
122
+ * A fresh child only starts once the previous one has REALLY exited. If the
123
+ * exit is not confirmed within the budget the old child keeps this Bot's slot
124
+ * and no replacement is forked — a half-dead worker must never be doubled.
125
+ */
126
+ async spawn() {
127
+ // The first dataset is allowed before any previous data or sheet exists.
128
+ // This directory is Host-derived; no model supplies this filesystem path.
129
+ try { await mkdir(dirname(this.dbPath), { recursive: true }) }
130
+ catch { throw new DataFailure(dataError('engine_unavailable', 'The data storage directory could not be prepared. Check the Host storage settings.')) }
131
+ if (this.exitUnconfirmed || this.retiring) {
132
+ throw new DataFailure(dataError('engine_unavailable', 'This Bot is giving up its data worker, so no new worker is started for it.', { retryable: true, hint: 'The previous worker has not confirmed its exit; try again shortly.' }))
133
+ }
134
+ if (this.exitPromise) {
135
+ const outcome = await settle(this.exitPromise, DATA_LIMITS.workerExitTimeoutMs)
136
+ if (outcome !== 'exited') {
137
+ this.exitUnconfirmed = true
138
+ throw new DataFailure(dataError('engine_unavailable', 'The previous data worker never confirmed its exit, so no new worker is started for this Bot.', { retryable: true, hint: 'The worker is still going away; try again once it has exited.' }))
139
+ }
140
+ this.exitPromise = null
141
+ }
142
+ // Re-checked after the await: this Bot's slot may have been handed to
143
+ // another Bot while we waited for the exit.
144
+ if (this.retiring || this.exitUnconfirmed) {
145
+ throw new DataFailure(dataError('engine_unavailable', 'This Bot is giving up its data worker, so no new worker is started for it.', { retryable: true, hint: 'The previous worker has not confirmed its exit; try again shortly.' }))
146
+ }
147
+ this.exited = false
148
+ const pending = new Map()
149
+ this.pending = pending
150
+ this.nextId = 1
151
+ const child = this.fork(WORKER_URL, [], {
152
+ env: workerEnv(),
153
+ stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
154
+ execPath: this.execPath,
155
+ // A hidden window on Windows; the marker env var is what makes THIS file
156
+ // the worker entry, never a path or a program from a caller.
157
+ windowsHide: true,
158
+ // The child's own JS heap is capped, so a runaway allocation dies in the
159
+ // child instead of growing the Host.
160
+ execArgv: [`--max-old-space-size=${DATA_LIMITS.workerHeapMb}`],
161
+ })
162
+ this.child = child
163
+ this.lastUsed = this.now()
164
+ this.exitPromise = new Promise(resolve => { child.on('exit', () => resolve('exited')) })
165
+ // Each child's handlers close over THIS child's pending map, so a killed
166
+ // child's exit never rejects the next child's in-flight request.
167
+ child.on('message', message => {
168
+ const entry = pending.get(message.id)
169
+ if (!entry) return
170
+ pending.delete(message.id)
171
+ clearTimeout(entry.timer)
172
+ if (message.ok) entry.resolve(message.value)
173
+ else entry.reject(workerFailure(message.error, [this.dbPath]))
174
+ })
175
+ const died = cause => {
176
+ // Nothing in flight is ever replayed: every pending request fails with the
177
+ // cause, and the next call opens a fresh database from the WAL.
178
+ for (const [, entry] of pending) { clearTimeout(entry.timer); entry.reject(cause) }
179
+ pending.clear()
180
+ if (this.child === child) { this.child = null; this.pending = new Map() }
181
+ }
182
+ child.on('error', error => died(new DataFailure(dataError('engine_unavailable', `Data worker error: ${sanitizeDataMessage(error.message, [this.dbPath])}`))))
183
+ child.on('exit', code => {
184
+ // A real exit is the only thing that frees this Bot to start again.
185
+ this.exited = true
186
+ this.exitUnconfirmed = false
187
+ died(new DataFailure(dataError('engine_unavailable', `Data worker exited unexpectedly (${code ?? 'signal'}).`)))
188
+ })
189
+ try {
190
+ await this.request('open', { dbPath: this.dbPath, limits: this.limits }, OPEN_TIMEOUT_MS)
191
+ } catch (error) {
192
+ // A child that cannot open the database is not left connected: kill it so
193
+ // the next call starts a fresh one.
194
+ if (this.child === child) this.kill()
195
+ throw error
196
+ }
197
+ }
198
+
199
+ ensure() {
200
+ this.lastUsed = this.now()
201
+ if (this.child && this.child.connected) return Promise.resolve()
202
+ if (this.starting) return this.starting
203
+ const attempt = this.spawn()
204
+ this.starting = attempt
205
+ const forget = () => { if (this.starting === attempt) this.starting = null }
206
+ attempt.then(forget, forget)
207
+ return attempt
208
+ }
209
+
210
+ request(op, message, timeoutMs) {
211
+ return new Promise((resolve, reject) => {
212
+ if (!this.child || !this.child.connected) return reject(new DataFailure(dataError('engine_unavailable', 'The data worker is not connected.')))
213
+ const id = this.nextId++
214
+ const timer = setTimeout(() => {
215
+ this.pending.delete(id)
216
+ // SIGKILL interrupts the native SQLite call a worker thread could not.
217
+ this.kill()
218
+ reject(new DataFailure(dataError('timeout', `The statement was cancelled after ${timeoutMs} ms.`, { retryable: true, hint: 'Narrow the query or aggregate first.' })))
219
+ }, timeoutMs)
220
+ timer.unref?.()
221
+ this.pending.set(id, { resolve, reject, timer })
222
+ try { this.child.send({ id, op, ...message }) } catch (error) { this.pending.delete(id); clearTimeout(timer); reject(workerFailure(error, [this.dbPath])) }
223
+ })
224
+ }
225
+
226
+ /** Cancel everything in flight by killing the child; the next call reopens. */
227
+ kill() {
228
+ if (this.child) { try { this.child.kill('SIGKILL') } catch { /* already gone */ } }
229
+ const cause = new DataFailure(dataError('cancelled', 'The statement was cancelled.'))
230
+ for (const [, entry] of this.pending) { clearTimeout(entry.timer); entry.reject(cause) }
231
+ this.pending.clear()
232
+ this.child = null
233
+ }
234
+
235
+ /**
236
+ * May this Bot's slot be handed to another Bot? Only when it holds no
237
+ * statement, is not starting, retiring or going away, and — if its worker is
238
+ * gone — the exit was confirmed.
239
+ */
240
+ reclaimable() {
241
+ if (this.starting !== null || this.retiring || this.exitUnconfirmed || this.pending.size > 0) return false
242
+ return this.child === null ? this.exited === true : this.child.connected === true
243
+ }
244
+
245
+ /**
246
+ * Give this Bot's slot up, but only once the child's exit is CONFIRMED.
247
+ * Returns false when the exit is not confirmed, and then the slot stays taken
248
+ * and this Bot refuses to reopen.
249
+ */
250
+ async retire() {
251
+ this.retiring = true
252
+ if (this.child === null) {
253
+ if (this.exited !== true) this.exitUnconfirmed = true
254
+ return this.exited === true
255
+ }
256
+ const exited = this.exitPromise ?? Promise.resolve('exited')
257
+ this.kill()
258
+ const confirmed = (await settle(exited, DATA_LIMITS.workerExitTimeoutMs)) === 'exited'
259
+ if (!confirmed) {
260
+ // The exit was not confirmed: this Bot keeps its slot and refuses to
261
+ // reopen until the exit really happens.
262
+ this.exitUnconfirmed = true
263
+ this.retiring = false
264
+ }
265
+ return confirmed
266
+ }
267
+
268
+ /** Resolve only after the child process has actually exited; throw otherwise. */
269
+ async close() {
270
+ if (this.child === null && this.exitPromise === null) return
271
+ this.retiring = true
272
+ const exited = this.exitPromise ?? Promise.resolve('exited')
273
+ this.kill()
274
+ const outcome = await settle(exited, DATA_LIMITS.workerExitTimeoutMs)
275
+ if (outcome !== 'exited') {
276
+ // Not closed. The process keeps its slot and this Bot refuses to reopen
277
+ // until the exit really happens.
278
+ this.exitUnconfirmed = true
279
+ this.retiring = false
280
+ throw new DataFailure(dataError('engine_unavailable', 'The data worker did not confirm its exit, so this Bot is not reported as closed.', { retryable: true }))
281
+ }
282
+ this.exitPromise = null
283
+ }
284
+ }
285
+
286
+ /** Atomic JSON write for a Bot's sheet (cards + sources metadata). */
287
+ async function writeSheet(file, sheet) {
288
+ await mkdir(dirname(file), { recursive: true })
289
+ const temp = `${file}.${process.pid}.tmp`
290
+ await writeFile(temp, `${JSON.stringify(sheet)}\n`, 'utf8')
291
+ await rename(temp, file)
292
+ }
293
+
294
+ export function createDataEngine(options = {}) {
295
+ const {
296
+ dataDir,
297
+ dbPathFor = botId => join(dataDir, `${botId}.sqlite`),
298
+ sheetPathFor = botId => join(dataDir, `${botId}.sheet.json`),
299
+ libraryReader = null,
300
+ libraryWriter = null,
301
+ forkImpl = fork,
302
+ execPath = process.execPath,
303
+ sqlTimeoutMs = DATA_LIMITS.sqlTimeoutMs,
304
+ now = Date.now,
305
+ limits = null,
306
+ } = options
307
+ if (typeof dataDir !== 'string' || dataDir === '') throw new TypeError('DATA_ENGINE_REQUIRE_DATA_DIR')
308
+
309
+ const processes = new Map()
310
+ const dbLocks = new Map()
311
+ const sheetLocks = new Map()
312
+ const workerLimits = { maxPageCount: DATA_LIMITS.dbMaxPageCount, hardHeapLimitBytes: DATA_LIMITS.dbHardHeapLimitBytes, softHeapLimitBytes: DATA_LIMITS.dbSoftHeapLimitBytes, ...(limits ?? {}) }
313
+ // Admissions run one at a time, so two Bots asking for the last free slot at the
314
+ // same moment cannot both take it.
315
+ let admissionQueue = Promise.resolve()
316
+ const enqueueAdmission = work => {
317
+ const next = admissionQueue.then(work, work)
318
+ admissionQueue = next.then(() => {}, () => {})
319
+ return next
320
+ }
321
+ const busy = botId => new DataFailure(dataError('engine_unavailable', `Every data worker slot is in use (${DATA_LIMITS.activeWorkersMax} at most), so this Bot cannot start one now.`, { retryable: true, hint: 'Another Bot is using a worker; retry when one is free.' }))
322
+
323
+ /**
324
+ * Hand one Bot's slot to another, but only once that worker has CONFIRMED its
325
+ * exit. A slot whose worker is still going away stays taken.
326
+ */
327
+ const reclaimOne = async keepBotId => {
328
+ const victims = [...processes.values()]
329
+ .filter(entry => entry.botId !== keepBotId && entry.reclaimable() && !dbLocks.has(entry.botId))
330
+ .sort((left, right) => left.lastUsed - right.lastUsed)
331
+ for (const victim of victims) {
332
+ if (await victim.retire()) {
333
+ // The exit is confirmed and the entry is gone from the map, so the slot
334
+ // is genuinely free — only now may another Bot be admitted into it.
335
+ if (processes.get(victim.botId) === victim) processes.delete(victim.botId)
336
+ victim.retiring = false
337
+ return true
338
+ }
339
+ // The exit was not confirmed: this Bot keeps its slot and refuses to
340
+ // reopen, so try the next candidate.
341
+ }
342
+ return false
343
+ }
344
+
345
+ /**
346
+ * Get this Bot's worker, admitting it under the STRICT global cap. A Bot that
347
+ * already holds a slot reuses it (its own respawn never needs a new one); a
348
+ * Bot without one either gets a free slot, reclaims an idle one, or is refused
349
+ * with engine_unavailable — nothing is queued and no write is replayed.
350
+ */
351
+ const admit = botId => {
352
+ assertBotId(botId)
353
+ return enqueueAdmission(async () => {
354
+ const existing = processes.get(botId)
355
+ if (existing) return existing
356
+ while (processes.size >= DATA_LIMITS.activeWorkersMax) {
357
+ if (!(await reclaimOne(botId))) throw busy(botId)
358
+ }
359
+ const entry = new BotProcess(botId, dbPathFor(botId), { forkImpl, execPath, now, limits: workerLimits })
360
+ processes.set(botId, entry)
361
+ return entry
362
+ })
363
+ }
364
+
365
+ /** The Bot's worker if it already holds a slot; never admits one. */
366
+ const residentProcess = botId => { assertBotId(botId); return processes.get(botId) ?? null }
367
+
368
+ const processFor = botId => {
369
+ assertBotId(botId)
370
+ let entry = processes.get(botId)
371
+ if (!entry) { entry = new BotProcess(botId, dbPathFor(botId), { forkImpl, execPath, now, limits: workerLimits }); processes.set(botId, entry) }
372
+ enforceWorkerCap(botId)
373
+ return entry
374
+ }
375
+
376
+ /**
377
+ * Keep at most DATA_LIMITS.activeWorkersMax Bot databases resident. Only idle
378
+ * workers are evicted (least recently used first): a worker with a statement
379
+ * in flight is never cancelled by the cap, so the cap can be exceeded
380
+ * temporarily rather than cancelling real work.
381
+ */
382
+ const enforceWorkerCap = keepBotId => {
383
+ if (processes.size <= DATA_LIMITS.activeWorkersMax) return
384
+ const idle = [...processes.values()].filter(entry => entry.botId !== keepBotId && entry.pending.size === 0)
385
+ .sort((left, right) => left.lastUsed - right.lastUsed)
386
+ for (const victim of idle.slice(0, processes.size - DATA_LIMITS.activeWorkersMax)) {
387
+ processes.delete(victim.botId)
388
+ void victim.close().catch(() => {})
389
+ }
390
+ }
391
+
392
+ const readSheet = async botId => {
393
+ try { return JSON.parse(await readFile(sheetPathFor(botId), 'utf8')) } catch { return { version: 1, botId, cards: [], sources: [], updatedAt: null } }
394
+ }
395
+ /**
396
+ * Read-modify-write a Bot's sheet under that Bot's lock. Two concurrent steps
397
+ * for the same Bot used to overwrite each other's metadata (a lost source
398
+ * entry, or a dropped card); now they are serialized, so the last write still
399
+ * contains every earlier one.
400
+ */
401
+ const saveSheet = (botId, mutate) => serialize(sheetLocks, botId, async () => {
402
+ const sheet = await readSheet(botId)
403
+ const result = mutate(sheet)
404
+ sheet.updatedAt = new Date(now()).toISOString()
405
+ await writeSheet(sheetPathFor(botId), sheet)
406
+ return result
407
+ })
408
+
409
+ /** Resolve a user table name to a catalog relation (quoted) or throw table_not_found. */
410
+ const resolveTable = async (run, name) => {
411
+ const tables = (await run('listTables', {})).filter(table => !isInternal(table.name))
412
+ const wanted = String(name).trim().replace(/^"|"$/g, '')
413
+ const last = wanted.split('.').pop()
414
+ const match = tables.find(table => table.name === wanted) ?? tables.find(table => table.name === last)
415
+ ?? tables.find(table => table.name.toLowerCase() === last.toLowerCase())
416
+ if (!match) {
417
+ const names = tables.map(table => table.name)
418
+ const close = names.filter(candidate => candidate.toLowerCase().includes(last.toLowerCase())).slice(0, 5)
419
+ throw new DataFailure(dataError('table_not_found', `Table ${wanted} does not exist.`, close.length ? { candidates: close } : {}))
420
+ }
421
+ return match.sqlName
422
+ }
423
+
424
+ const forBot = (botId, { signal } = {}) => {
425
+ assertBotId(botId)
426
+ const checkSignal = () => {
427
+ if (signal?.aborted) throw new DataFailure(dataError('cancelled', 'The data call was cancelled. Changes already committed may still be present; inspect the data before repeating a write.'))
428
+ }
429
+ const run = async (op, message, timeoutMs = sqlTimeoutMs) => {
430
+ checkSignal()
431
+ const process = await admit(botId)
432
+ checkSignal()
433
+ const abort = () => process.kill()
434
+ signal?.addEventListener('abort', abort, { once: true })
435
+ try {
436
+ checkSignal()
437
+ await process.ensure()
438
+ checkSignal()
439
+ const result = await process.request(op, message, timeoutMs)
440
+ checkSignal()
441
+ return result
442
+ } catch (error) { checkSignal(); throw error }
443
+ finally { signal?.removeEventListener('abort', abort) }
444
+ }
445
+ const guardedRun = (sql, { mode, maxRows, maxBytes, timeoutMs } = {}) => {
446
+ const kind = gateSql(sql, { allowCreate: true })
447
+ return run('run', { sql, mode: mode ?? (kind.kind === 'read' ? 'read' : 'write'), maxRows, maxBytes }, timeoutMs).catch(error => { throw error instanceof DataFailure ? error : workerFailure(error) })
448
+ }
449
+ /**
450
+ * Every step that writes the database or the sheet metadata runs under this
451
+ * Bot's lock: a multi-step load/show/sql sequence cannot interleave with
452
+ * another one for the same Bot, so a same-named table is never replaced
453
+ * twice and no card or source entry is lost.
454
+ */
455
+ const write = work => {
456
+ checkSignal()
457
+ return serialize(dbLocks, botId, () => { checkSignal(); return work() })
458
+ }
459
+
460
+ /**
461
+ * Re-open a SAVED card by its id. This is a precise read, not a re-run of
462
+ * whatever the caller feels like: the sheet says what the card was, and the
463
+ * result is rebuilt from that — the persisted card table when it still
464
+ * exists, otherwise the card's own stored query. Nothing is invented, and a
465
+ * card whose source is gone fails with a real error instead of an empty grid.
466
+ */
467
+ const reopenCard = async (cardId, input = {}) => {
468
+ const started = now()
469
+ if (!IDENT.test(cardId)) fail('invalid_input', 'A card id is required.')
470
+ const sheet = await readSheet(botId)
471
+ const saved = sheet.cards.find(card => card.id === cardId)
472
+ if (!saved) fail('card_not_found', `Card ${cardId} is not saved for this Bot.`)
473
+ const kind = saved.kind === 'chart' ? 'chart' : 'table'
474
+ const table = quoteIdent(cardTable(cardId))
475
+ const resident = (await run('listTables', {})).some(entry => entry.name === cardTable(cardId))
476
+ const title = input.title ?? saved.title ?? cardId
477
+
478
+ if (kind === 'chart') {
479
+ const sql = typeof saved.sql === 'string' ? saved.sql.trim() : ''
480
+ const spec = saved.chart
481
+ if (!sql || !spec || typeof spec !== 'object' || typeof spec.type !== 'string' || typeof spec.x !== 'string') {
482
+ fail('card_not_found', `Card ${cardId} has no saved chart spec to re-render.`)
483
+ }
484
+ gateSql(sql)
485
+ // Recompute the series from the live database through the card's own
486
+ // compiled aggregation, then restore the card table if it is missing.
487
+ const bounded = await run('run', { sql, mode: 'read', maxRows: DATA_LIMITS.chartMaxMarks, maxBytes: DATA_LIMITS.sqlBytesMax })
488
+ const chart = shapeChart(bounded, spec)
489
+ let columns = bounded.columns
490
+ let rowCount = bounded.rowCount
491
+ if (!resident) {
492
+ const materialised = await run('materialise', { sql, table })
493
+ columns = materialised.columns
494
+ rowCount = materialised.rowCount
495
+ }
496
+ return { id: cardId, kind, title, chart, rowCount, truncated: chart.truncated, columns, elapsedMs: now() - started }
497
+ }
498
+
499
+ if (resident) {
500
+ const described = await run('describe', { target: table })
501
+ const firstPage = await run('page', { target: table, offset: 0, limit: DATA_LIMITS.pageSize })
502
+ return { id: cardId, kind, title, columns: described.columns, rowCount: described.rowCount, rows: firstPage.rows, truncated: described.rowCount > firstPage.rows.length, elapsedMs: now() - started }
503
+ }
504
+ // The card table is gone; rebuild it from the table or query it was made of.
505
+ let materialiseSql = null
506
+ if (typeof saved.sql === 'string' && saved.sql.trim() !== '') materialiseSql = saved.sql
507
+ else if (typeof saved.table === 'string' && saved.table.trim() !== '') materialiseSql = `SELECT * FROM ${await resolveTable(run, saved.table)} LIMIT ${DATA_LIMITS.showTableRowsMax}`
508
+ if (!materialiseSql) fail('card_not_found', `Card ${cardId} has no saved source to rebuild from.`)
509
+ gateSql(materialiseSql)
510
+ const materialised = await run('materialise', { sql: materialiseSql, table })
511
+ const firstPage = await run('page', { target: table, offset: 0, limit: DATA_LIMITS.pageSize })
512
+ return { id: cardId, kind, title, columns: materialised.columns, rowCount: materialised.rowCount, rows: firstPage.rows, truncated: materialised.rowCount > firstPage.rows.length, elapsedMs: now() - started }
513
+ }
514
+
515
+ const api = {
516
+ botId,
517
+
518
+ async load(input = {}) {
519
+ const started = now()
520
+ let text
521
+ let sourceLabel
522
+ if (input.from === 'content') {
523
+ text = String(input.content ?? '')
524
+ sourceLabel = 'inline'
525
+ if (Buffer.byteLength(text) > DATA_LIMITS.loadMaxBytes) fail('source_unsupported', `Inline content exceeds ${DATA_LIMITS.loadMaxBytes} bytes.`)
526
+ } else if (input.from === 'library') {
527
+ if (typeof libraryReader !== 'function') fail('source_unsupported', 'Library import is not wired on this Host.')
528
+ if (typeof input.path !== 'string' || input.path === '') fail('invalid_input', 'A library path is required.')
529
+ try { text = await libraryReader(botId, input.path) } catch (error) { throw new DataFailure(dataError('source_not_found', String(error?.message ?? error))) }
530
+ sourceLabel = `library:${input.path}`
531
+ if (Buffer.byteLength(text) > DATA_LIMITS.loadMaxBytes) fail('source_unsupported', `The library file exceeds ${DATA_LIMITS.loadMaxBytes} bytes.`)
532
+ } else {
533
+ fail('invalid_input', 'data_load needs from: "library" (with a path) or "content" (with the text). No OS paths or URLs.')
534
+ }
535
+
536
+ const format = input.format ?? (input.from === 'library' ? guessFormat(input.path) : 'csv')
537
+ let parsed
538
+ try {
539
+ parsed = format === 'json' ? parseJsonText(text) : parseDelimited(text, { delimiter: format === 'tsv' ? '\t' : (input.delimiter ?? ','), header: input.header !== false })
540
+ } catch (error) { throw error instanceof DataFailure ? error : new DataFailure(dataError('import_failed', String(error?.message ?? error))) }
541
+
542
+ const replace = input.replace !== false
543
+ const existing = new Set((await run('listTables', {})).filter(table => !isInternal(table.name)).map(table => table.name.toLowerCase()))
544
+ const base = tableNameFrom(input.name ?? (input.from === 'library' ? stripExt(input.path) : 'dataset'))
545
+ let name = base
546
+ for (let n = 2; !replace && existing.has(name.toLowerCase()); n++) name = `${base.slice(0, 62 - String(n).length)}_${n}`
547
+ existing.add(name.toLowerCase())
548
+
549
+ const result = await run('load', { table: quoteIdent(name), columns: parsed.columns, sqlTypes: parsed.sqlTypes, rows: parsed.rows })
550
+ const described = await run('describe', { target: quoteIdent(name) })
551
+ const columns = described.columns.map(column => ({ name: column.name, type: column.type }))
552
+ await saveSheet(botId, sheet => {
553
+ sheet.sources = [{ name, kind: format === 'json' ? 'json' : 'csv', source: sourceLabel, rowCount: result.rowCount, columns, loadedAt: new Date(now()).toISOString() }, ...sheet.sources.filter(entry => entry.name !== name)].slice(0, 200)
554
+ })
555
+ return { tables: [{ name, rowCount: result.rowCount, columns, source: sourceLabel }], warnings: [], elapsedMs: now() - started }
556
+ },
557
+
558
+ async describe(target) {
559
+ const started = now()
560
+ if (target === undefined || target === null || target === '') {
561
+ const tables = (await run('listTables', {})).filter(table => !isInternal(table.name))
562
+ const sheet = await readSheet(botId)
563
+ return { rowCount: 0, columns: [], tables: tables.map(table => ({ name: table.name, columns: table.columns })), cards: sheet.cards.map(card => ({ id: card.id, title: card.title, kind: card.kind, rowCount: card.rowCount })), elapsedMs: now() - started }
564
+ }
565
+ if (typeof target !== 'string') fail('invalid_input', 'data_describe takes a table name or a SELECT.')
566
+ if (IDENTIFIER_LIKE.test(target)) {
567
+ const relation = await resolveTable(run, target.trim())
568
+ const described = await run('describe', { target: relation })
569
+ return { name: target.trim(), rowCount: described.rowCount, columns: described.columns, elapsedMs: now() - started }
570
+ }
571
+ const kind = gateSql(target)
572
+ if (kind.kind !== 'read') fail('invalid_input', 'data_describe takes a table name or a SELECT.', { sql: target })
573
+ const bounded = await run('run', { sql: target, mode: 'read', maxRows: DATA_LIMITS.sqlRowsDefault, maxBytes: DATA_LIMITS.describeBytesMax })
574
+ return { sql: target, rowCount: bounded.rowCount, columns: bounded.columns, truncated: bounded.truncated, elapsedMs: now() - started }
575
+ },
576
+
577
+ async sql(input = {}) {
578
+ const started = now()
579
+ if (typeof input.sql !== 'string' || input.sql.trim() === '') fail('invalid_input', 'data_sql needs a sql string.')
580
+ const limit = Math.min(Math.max(1, Math.trunc(input.limit ?? DATA_LIMITS.sqlRowsDefault)), DATA_LIMITS.sqlRowsMax)
581
+ const kind = gateSql(input.sql, { allowCreate: true })
582
+ if (kind.kind === 'create') {
583
+ const target = kind.target.split('.').pop()
584
+ if (!IDENT.test(target)) fail('invalid_input', 'A CREATE target must be a plain table or view name in your own database.')
585
+ const relation = quoteIdent(target)
586
+ if (kind.object === 'view') {
587
+ await run('run', { sql: input.sql, mode: 'write' })
588
+ } else {
589
+ // SQLite has no CREATE OR REPLACE TABLE; a replace drops first.
590
+ if (kind.orReplace) await run('drop', { target: relation })
591
+ await run('run', { sql: input.sql.replace(/CREATE\s+(TEMP(?:ORARY)?\s+)?OR\s+REPLACE\s+TABLE/i, 'CREATE $1TABLE'), mode: 'write' })
592
+ }
593
+ const described = await run('describe', { target: relation })
594
+ return { table: target, columns: described.columns, rowCount: described.rowCount, rows: [], truncated: false, elapsedMs: now() - started }
595
+ }
596
+ if (kind.kind === 'read' && input.name) {
597
+ const name = tableNameFrom(input.name)
598
+ const relation = quoteIdent(namedTable(input.name))
599
+ const materialised = await run('materialise', { sql: input.sql, table: relation })
600
+ const page = await run('run', { sql: `SELECT * FROM ${relation} LIMIT ${limit + 1}`, mode: 'read', maxRows: limit + 1, maxBytes: DATA_LIMITS.sqlBytesMax })
601
+ const bounded = boundRows(page.rows, limit, DATA_LIMITS.sqlBytesMax)
602
+ return { table: name, columns: materialised.columns, rowCount: materialised.rowCount, rows: bounded.rows, truncated: bounded.truncated, elapsedMs: now() - started }
603
+ }
604
+ const page = await run('run', { sql: input.sql, mode: 'read', maxRows: limit + 1, maxBytes: DATA_LIMITS.sqlBytesMax })
605
+ const bounded = boundRows(page.rows, limit, DATA_LIMITS.sqlBytesMax)
606
+ return { table: null, columns: page.columns, rowCount: page.rowCount, rows: bounded.rows, truncated: bounded.truncated || page.rowCount > bounded.rows.length, elapsedMs: now() - started }
607
+ },
608
+
609
+ async show(input = {}) {
610
+ const started = now()
611
+ if (input.cardId !== undefined) return reopenCard(input.cardId, input)
612
+ const kind = input.kind === 'chart' ? 'chart' : 'table'
613
+ let relation
614
+ let sql
615
+ if (input.table !== undefined) {
616
+ relation = await resolveTable(run, input.table)
617
+ } else if (typeof input.sql === 'string' && input.sql.trim() !== '') {
618
+ gateSql(input.sql)
619
+ sql = input.sql
620
+ } else {
621
+ fail('invalid_input', 'data_show needs a table name or a SELECT.')
622
+ }
623
+
624
+ const cardId = `c_${nextCardId(now, botId)}`
625
+ const cardResult = quoteIdent(cardTable(cardId))
626
+ let columns
627
+ let rowCount
628
+ let result = { id: cardId, kind, title: input.title ?? (input.table ?? 'Query'), rowCount: 0, truncated: false }
629
+
630
+ if (kind === 'chart') {
631
+ const source = relation ?? `(${sql})`
632
+ const described = relation ? await run('describe', { target: relation }) : await run('run', { sql: input.sql, mode: 'read', maxRows: 1, maxBytes: DATA_LIMITS.describeBytesMax })
633
+ const columnNames = described.columns.map(column => column.name)
634
+ let compiled
635
+ try { compiled = compileChart(input.chart, columnNames, relation ?? `(${input.sql})`) } catch (error) { throw error instanceof DataFailure ? error : workerFailure(error) }
636
+ const bounded = await run('run', { sql: compiled.sql, mode: 'read', maxRows: DATA_LIMITS.chartMaxMarks, maxBytes: DATA_LIMITS.sqlBytesMax })
637
+ const chart = shapeChart(bounded, input.chart)
638
+ const materialised = await run('materialise', { sql: compiled.sql, table: cardResult })
639
+ columns = materialised.columns
640
+ rowCount = materialised.rowCount
641
+ result = { ...result, chart, rowCount, truncated: chart.truncated, columns }
642
+ await saveSheet(botId, sheet => { sheet.cards = [{ id: cardId, kind, title: result.title, sql: compiled.sql, chart: input.chart, columns, rowCount, by: 'bot', createdAt: new Date(now()).toISOString(), updatedAt: new Date(now()).toISOString() }, ...sheet.cards].slice(0, DATA_LIMITS.sheetCardsMax) })
643
+ return { ...result, elapsedMs: now() - started }
644
+ }
645
+
646
+ // Table card: materialise a bounded copy so it can be paged and exported.
647
+ const materialiseSql = relation ? `SELECT * FROM ${relation} LIMIT ${DATA_LIMITS.showTableRowsMax}` : input.sql
648
+ const materialised = await run('materialise', { sql: materialiseSql, table: cardResult })
649
+ columns = materialised.columns
650
+ rowCount = materialised.rowCount
651
+ const firstPage = await run('page', { target: cardResult, offset: 0, limit: DATA_LIMITS.pageSize })
652
+ result = { ...result, columns, rowCount, rows: firstPage.rows, truncated: rowCount > firstPage.rows.length }
653
+ await saveSheet(botId, sheet => { sheet.cards = [{ id: cardId, kind, title: result.title, sql: relation ? undefined : input.sql, table: relation ? input.table : undefined, columns, rowCount, by: 'bot', createdAt: new Date(now()).toISOString(), updatedAt: new Date(now()).toISOString() }, ...sheet.cards].slice(0, DATA_LIMITS.sheetCardsMax) })
654
+ return { ...result, elapsedMs: now() - started }
655
+ },
656
+
657
+ async page(input = {}) {
658
+ let target
659
+ if (input.cardId) {
660
+ if (!IDENT.test(input.cardId)) fail('invalid_input', 'A card id is required.')
661
+ target = quoteIdent(cardTable(input.cardId))
662
+ } else if (input.table) {
663
+ target = await resolveTable(run, input.table)
664
+ } else {
665
+ fail('invalid_input', 'data page needs a table or cardId.')
666
+ }
667
+ const limit = Math.min(Math.max(1, Math.trunc(input.limit ?? DATA_LIMITS.pageSize)), DATA_LIMITS.pageLimitMax)
668
+ return run('page', { target, offset: Math.max(0, Math.trunc(input.offset ?? 0)), limit, sort: input.sort, filter: input.filter, filterColumn: input.filterColumn })
669
+ },
670
+
671
+ async exportCsv(input = {}) {
672
+ const started = now()
673
+ let relation
674
+ if (input.cardId !== undefined) {
675
+ if (!IDENT.test(input.cardId)) fail('invalid_input', 'A card id is required.')
676
+ relation = quoteIdent(cardTable(input.cardId))
677
+ } else if (input.table) relation = await resolveTable(run, input.table)
678
+ else if (typeof input.sql === 'string' && input.sql.trim() !== '') { gateSql(input.sql); relation = `(${input.sql})` }
679
+ else fail('invalid_input', 'data_export needs a table, a cardId or a SELECT.')
680
+ const described = await run('describe', { target: relation })
681
+ const rows = await run('run', { sql: `SELECT * FROM ${relation} LIMIT ${DATA_LIMITS.showTableRowsMax}`, mode: 'read', maxRows: DATA_LIMITS.showTableRowsMax + 1, maxBytes: DATA_LIMITS.exportMaxBytes })
682
+ const csv = boundExport(toCsv(described.columns, rows.rows))
683
+ const relPath = `exports/${tableNameFrom(input.filename ?? 'export')}.csv`
684
+ // The row counts are two different facts and both are reported: the
685
+ // source's total, and the rows this export actually encoded.
686
+ const exportedRows = rows.rows.length
687
+ const result = {
688
+ format: 'csv',
689
+ sourceRowCount: described.rowCount,
690
+ rowCount: exportedRows,
691
+ bytes: csv.bytes,
692
+ truncated: csv.truncated || rows.truncated || exportedRows < described.rowCount,
693
+ elapsedMs: now() - started,
694
+ }
695
+ if (typeof libraryWriter !== 'function') return { ...result, content: csv.text.slice(0, DATA_LIMITS.exportMaxBytes) }
696
+ // A failed library write is a FAILED export: no path is reported and the
697
+ // caller must not tell the user the file exists.
698
+ checkSignal()
699
+ try {
700
+ const written = await libraryWriter(botId, relPath, csv.text)
701
+ return { ...result, path: relPath, written: { bytes: written?.bytes ?? csv.bytes } }
702
+ } catch (error) {
703
+ const message = sanitizeDataMessage(error?.message ?? error)
704
+ // The Host names the reason when it has a code; otherwise the message
705
+ // decides. Both are reported, so a size limit is never a mystery.
706
+ const code = String(error?.code ?? '')
707
+ const tooLarge = code === 'LIBRARY_FILE_TOO_LARGE' || /too.?large|exceed|字节|超过/i.test(message)
708
+ throw new DataFailure(dataError(tooLarge ? 'output_too_large' : 'write_refused',
709
+ tooLarge
710
+ ? `The export (${csv.bytes} bytes) did not fit the library's single-file limit: ${message}`
711
+ : `The library write failed, so nothing was exported: ${message}`,
712
+ { bytes: csv.bytes, attemptedPath: relPath, sourceRowCount: described.rowCount, rowCount: exportedRows, truncated: result.truncated }))
713
+ }
714
+ },
715
+
716
+ async remove(input = {}) {
717
+ if (input.cardId !== undefined) {
718
+ if (!IDENT.test(input.cardId)) fail('invalid_input', 'A card id is required.')
719
+ await run('drop', { target: quoteIdent(cardTable(input.cardId)) })
720
+ await saveSheet(botId, sheet => { sheet.cards = sheet.cards.filter(card => card.id !== input.cardId) })
721
+ return { removed: 'card', id: input.cardId }
722
+ }
723
+ if (typeof input.table !== 'string' || input.table.trim() === '') fail('invalid_input', 'data remove needs a table name or a cardId.')
724
+ const relation = await resolveTable(run, input.table)
725
+ await run('drop', { target: relation })
726
+ const removed = input.table.trim().replace(/^"|"$/g, '')
727
+ await saveSheet(botId, sheet => { sheet.sources = sheet.sources.filter(entry => entry.name !== removed) })
728
+ return { removed: 'table', name: removed }
729
+ },
730
+
731
+ async listTables() {
732
+ const tables = (await run('listTables', {})).filter(table => !isInternal(table.name))
733
+ return tables.map(table => ({ name: table.name, columns: table.columns }))
734
+ },
735
+
736
+ async catalog() {
737
+ const tables = (await run('listTables', {})).filter(table => !isInternal(table.name))
738
+ const sheet = await readSheet(botId)
739
+ return { tables: tables.map(table => ({ name: table.name, columns: table.columns })), sources: sheet.sources, cards: sheet.cards.map(card => ({ id: card.id, title: card.title, kind: card.kind, rowCount: card.rowCount, updatedAt: card.updatedAt })) }
740
+ },
741
+
742
+ cancel() {
743
+ // A stop request never admits a worker just to kill one.
744
+ residentProcess(botId)?.kill()
745
+ return { cancelled: true }
746
+ },
747
+ }
748
+
749
+ // Reads stay unlocked; every step that writes the database or the sheet
750
+ // metadata runs under this Bot's lock, so two concurrent steps for the same
751
+ // Bot can never interleave (no double replace, no lost card or source entry).
752
+ return Object.freeze({
753
+ botId,
754
+ load: input => write(() => api.load(input)),
755
+ sql: input => write(() => api.sql(input)),
756
+ show: input => write(() => api.show(input)),
757
+ remove: input => write(() => api.remove(input)),
758
+ describe: api.describe,
759
+ page: api.page,
760
+ exportCsv: api.exportCsv,
761
+ listTables: api.listTables,
762
+ catalog: api.catalog,
763
+ cancel: api.cancel,
764
+ })
765
+ }
766
+
767
+ return Object.freeze({
768
+ forBot,
769
+ async deleteBot(botId) {
770
+ const entry = processes.get(botId)
771
+ if (!entry) return { botId, closed: true }
772
+ try {
773
+ await entry.close()
774
+ } catch (error) {
775
+ // The worker never confirmed its exit, so the slot is NOT released.
776
+ return { botId, closed: false, error: error?.error ?? { code: 'engine_unavailable', message: String(error?.message ?? error) } }
777
+ }
778
+ if (processes.get(botId) === entry) processes.delete(botId)
779
+ entry.retiring = false
780
+ return { botId, closed: true }
781
+ },
782
+ /**
783
+ * Close every worker and report the truth: a Bot whose child never confirmed
784
+ * its exit is listed in `unconfirmed`, keeps its slot, and is NOT reported as
785
+ * closed. Nothing here claims a close that did not happen.
786
+ */
787
+ async closeAll() {
788
+ const entries = [...processes.values()]
789
+ const report = await Promise.all(entries.map(async entry => {
790
+ try {
791
+ await entry.close()
792
+ // The slot is only released once the exit is confirmed.
793
+ if (processes.get(entry.botId) === entry) processes.delete(entry.botId)
794
+ entry.retiring = false
795
+ return { botId: entry.botId, closed: true }
796
+ } catch (error) {
797
+ return { botId: entry.botId, closed: false, error: error?.error ?? { code: 'engine_unavailable', message: String(error?.message ?? error) } }
798
+ }
799
+ }))
800
+ return Object.freeze({ closed: report.filter(row => row.closed).map(row => row.botId), unconfirmed: report.filter(row => !row.closed) })
801
+ },
802
+ })
803
+ }
804
+
805
+ let cardCounter = 0
806
+ const nextCardId = (nowFn, botId) => `${nowFn().toString(36)}_${(cardCounter++).toString(36)}`
807
+
808
+ const guessFormat = path => (/\.(json|ndjson|jsonl)$/i.test(path) ? 'json' : /\.tsv$|\.tab$/i.test(path) ? 'tsv' : 'csv')
809
+ const stripExt = path => String(path).replace(/\.[^./\\]+$/, '').split(/[\\/]/).pop() ?? 'dataset'