@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.
- package/.dsh-plugin/bot-product.mjs +81 -5
- package/.dsh-plugin/client.js +17490 -1791
- package/README.i18n.yaml +4 -4
- package/README.md +409 -364
- package/README.zh.md +409 -364
- package/apps/integration-client/README.md +31 -7
- package/apps/integration-client/api.mjs +14 -3
- package/apps/integration-client/binding-editor.jsx +308 -109
- package/apps/integration-client/binding-model.mjs +262 -0
- package/apps/integration-client/binding.css +144 -0
- package/apps/integration-client/bot-model-status.mjs +18 -0
- package/apps/integration-client/bot-profile-identity.jsx +197 -0
- package/apps/integration-client/bot-profile.css +57 -0
- package/apps/integration-client/bot-profile.mjs +97 -0
- package/apps/integration-client/bot-settings-dialog.css +18 -0
- package/apps/integration-client/bot-settings-dialog.jsx +111 -0
- package/apps/integration-client/bot-thread-header-bridge.css +20 -0
- package/apps/integration-client/bot-thread-header.jsx +278 -0
- package/apps/integration-client/bot-threads-panel.jsx +378 -0
- package/apps/integration-client/bot-threads.css +169 -0
- package/apps/integration-client/chat-design.mjs +250 -0
- package/apps/integration-client/chat-ui.jsx +175 -0
- package/apps/integration-client/chat.css +477 -0
- package/apps/integration-client/composer-draft-binding.mjs +50 -0
- package/apps/integration-client/composer-draft-bound.jsx +14 -0
- package/apps/integration-client/composer-draft-host.jsx +142 -0
- package/apps/integration-client/composer-draft-session.jsx +287 -0
- package/apps/integration-client/composer-draft-witness.mjs +115 -0
- package/apps/integration-client/composer-draft.css +4 -0
- package/apps/integration-client/composer-draft.mjs +322 -0
- package/apps/integration-client/contact-attention.jsx +54 -0
- package/apps/integration-client/data-panel.jsx +439 -0
- package/apps/integration-client/data.css +69 -0
- package/apps/integration-client/desktop-bridge.mjs +1 -0
- package/apps/integration-client/desktop-http-socket.mjs +49 -0
- package/apps/integration-client/desktop-keyboard.mjs +32 -0
- package/apps/integration-client/desktop-pane.jsx +99 -30
- package/apps/integration-client/desktop-stream.mjs +13 -3
- package/apps/integration-client/desktop-tools.jsx +19 -0
- package/apps/integration-client/desktop-touch.mjs +27 -0
- package/apps/integration-client/desktop.css +41 -0
- package/apps/integration-client/independent-chat-controller.mjs +224 -0
- package/apps/integration-client/independent-chat-entry.jsx +61 -0
- package/apps/integration-client/independent-chat-navigation.mjs +21 -0
- package/apps/integration-client/independent-chat-page.jsx +148 -0
- package/apps/integration-client/independent-chat-rich.jsx +64 -0
- package/apps/integration-client/independent-chat-rich.mjs +30 -0
- package/apps/integration-client/independent-chat.css +73 -0
- package/apps/integration-client/index.jsx +24 -16
- package/apps/integration-client/library-panel.jsx +4 -1
- package/apps/integration-client/licenses/jsqr-1.4.0-LICENSE.txt +202 -0
- package/apps/integration-client/main-route-editor.jsx +94 -0
- package/apps/integration-client/main-route-model.mjs +100 -0
- package/apps/integration-client/model-picker.css +37 -0
- package/apps/integration-client/model-picker.jsx +83 -0
- package/apps/integration-client/native-activity.css +141 -0
- package/apps/integration-client/native-activity.jsx +276 -0
- package/apps/integration-client/native-activity.mjs +384 -0
- package/apps/integration-client/notifications-panel.jsx +186 -0
- package/apps/integration-client/notifications.css +126 -0
- package/apps/integration-client/pairing-qr.jsx +79 -0
- package/apps/integration-client/panels.jsx +126 -115
- package/apps/integration-client/product-slots.jsx +46 -0
- package/apps/integration-client/routine-recorder.mjs +23 -2
- package/apps/integration-client/secure-pairing.jsx +84 -0
- package/apps/integration-client/tasks-panel.jsx +32 -10
- package/apps/integration-client/ui-icons.jsx +17 -0
- package/apps/integration-client/usage-explorer.jsx +43 -0
- package/apps/integration-client/usage-report.mjs +71 -0
- package/apps/integration-client/usage.css +34 -0
- package/apps/integration-client/web-panel-model.mjs +140 -0
- package/apps/integration-client/web-panel-page.jsx +221 -0
- package/apps/integration-client/web-panel-pins-live.jsx +28 -0
- package/apps/integration-client/web-panel.css +139 -0
- package/apps/integration-client/workspace-model.mjs +67 -0
- package/apps/integration-client/workspace-navigation.mjs +56 -0
- package/apps/integration-client/workspace-ui.jsx +173 -0
- package/apps/integration-client/workspace.css +332 -0
- package/modules/bot-runtime/SOURCE.json +176 -50
- package/modules/bot-runtime/index.mjs +169 -13
- package/modules/bot-runtime/product-overlay/OVERLAY.json +128 -2
- package/modules/bot-runtime/product-overlay/base/src/client/header.js +244 -0
- package/modules/bot-runtime/product-overlay/base/src/client/learning-card.js +202 -0
- package/modules/bot-runtime/product-overlay/base/src/client/model-menu.js +371 -0
- package/modules/bot-runtime/product-overlay/base/src/client/secret-card.js +363 -0
- package/modules/bot-runtime/product-overlay/base/src/client/sources.js +68 -0
- package/modules/bot-runtime/product-overlay/base/src/client/turn-error.js +260 -0
- package/modules/bot-runtime/product-overlay/base/src/host/channel.js +647 -0
- package/modules/bot-runtime/product-overlay/base/src/host/memory.js +2050 -0
- package/modules/bot-runtime/product-overlay/base/src/host/models.js +314 -0
- package/modules/bot-runtime/product-overlay/base/src/host/parts.js +1473 -0
- package/modules/bot-runtime/product-overlay/base/src/host/privacy.js +368 -0
- package/modules/bot-runtime/product-overlay/base/src/host/roster.js +776 -0
- package/modules/bot-runtime/product-overlay/base/src/host/store.js +512 -0
- package/modules/bot-runtime/product-overlay/base/src/host/usage.js +653 -0
- package/modules/bot-runtime/product-overlay/patches/src/client/details-drawer.js.patch +48 -2
- package/modules/bot-runtime/product-overlay/patches/src/client/header.js.patch +41 -0
- package/modules/bot-runtime/product-overlay/patches/src/client/index.js.patch +33 -1
- package/modules/bot-runtime/product-overlay/patches/src/client/learning-card.js.patch +20 -0
- package/modules/bot-runtime/product-overlay/patches/src/client/model-menu.js.patch +24 -0
- package/modules/bot-runtime/product-overlay/patches/src/client/secret-card.js.patch +25 -0
- package/modules/bot-runtime/product-overlay/patches/src/client/sources.js.patch +52 -0
- package/modules/bot-runtime/product-overlay/patches/src/client/turn-error.js.patch +23 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/channel.js.patch +46 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/memory.js.patch +11 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/models.js.patch +16 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/parts.js.patch +95 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/privacy.js.patch +11 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/roster.js.patch +145 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/store.js.patch +68 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/usage.js.patch +11 -0
- package/modules/bot-runtime/read-model.mjs +19 -4
- package/modules/bot-runtime/vendor/ds-bot/src/client/details-drawer.js +15 -5
- package/modules/bot-runtime/vendor/ds-bot/src/client/header.js +9 -2
- package/modules/bot-runtime/vendor/ds-bot/src/client/index.js +9 -2
- package/modules/bot-runtime/vendor/ds-bot/src/client/learning-card.js +2 -2
- package/modules/bot-runtime/vendor/ds-bot/src/client/model-menu.js +5 -3
- package/modules/bot-runtime/vendor/ds-bot/src/client/secret-card.js +3 -3
- package/modules/bot-runtime/vendor/ds-bot/src/client/sources.js +25 -2
- package/modules/bot-runtime/vendor/ds-bot/src/client/turn-error.js +3 -2
- package/modules/bot-runtime/vendor/ds-bot/src/host/channel.js +14 -2
- package/modules/bot-runtime/vendor/ds-bot/src/host/memory.js +1 -1
- package/modules/bot-runtime/vendor/ds-bot/src/host/models.js +7 -0
- package/modules/bot-runtime/vendor/ds-bot/src/host/parts.js +26 -9
- package/modules/bot-runtime/vendor/ds-bot/src/host/privacy.js +1 -1
- package/modules/bot-runtime/vendor/ds-bot/src/host/roster.js +112 -3
- package/modules/bot-runtime/vendor/ds-bot/src/host/store.js +34 -1
- package/modules/bot-runtime/vendor/ds-bot/src/host/usage.js +1 -1
- package/modules/independent-runtime/index.mjs +152 -62
- package/modules/independent-runtime/remote-tools.mjs +8 -5
- package/modules/openmaus-protocol/NOTICE +10 -0
- package/modules/openmaus-protocol/README.md +15 -0
- package/modules/openmaus-protocol/SOURCE.json +68 -0
- package/modules/openmaus-protocol/dist/protocol.mjs +1203 -0
- package/modules/openmaus-protocol/index.mjs +1 -0
- package/modules/openmaus-protocol/transport.mjs +77 -0
- package/modules/openmaus-protocol/vendor/LICENSE +202 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/README.md +405 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/credentials.ts +128 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/descriptor.ts +84 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/noise.ts +456 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/phone.ts +104 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/session.ts +417 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/vectors/v1.json +2565 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/vectors/v1.sha256 +1 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/yamux.ts +335 -0
- package/package.json +18 -6
- package/packages/bot-acp/README.md +156 -0
- package/packages/bot-acp/licenses/LICENSE-OpenMausBot +202 -0
- package/packages/bot-acp/licenses/NOTICE.md +27 -0
- package/packages/bot-acp/src/acp-adapter.mjs +795 -0
- package/packages/bot-acp/src/acp-host.mjs +148 -0
- package/packages/bot-acp/src/native-launch.mjs +121 -0
- package/packages/bot-acp/src/permission-policy.mjs +283 -0
- package/packages/bot-acp/src/rpc-codec.mjs +265 -0
- package/packages/bot-acp/src/session-core.mjs +581 -0
- package/packages/bot-acp/src/stdio-rpc.mjs +329 -0
- package/packages/bot-engines/README.md +281 -0
- package/packages/bot-engines/licenses/LICENSE-OpenMausBot +202 -0
- package/packages/bot-engines/licenses/NOTICE.md +32 -0
- package/packages/bot-engines/src/claude-engine.mjs +772 -0
- package/packages/bot-engines/src/claude-llm-adapter.mjs +797 -0
- package/packages/bot-engines/src/claude-llm-turn.mjs +292 -0
- package/packages/bot-engines/src/claude-permission-broker.mjs +130 -0
- package/packages/bot-engines/src/claude-permission-proxy.mjs +87 -0
- package/packages/bot-engines/src/claude-protocol.mjs +177 -0
- package/packages/bot-engines/src/driver-catalog.mjs +334 -0
- package/packages/bot-engines/src/host-loop-request.mjs +95 -0
- package/packages/bot-engines/src/host-registrations.mjs +105 -0
- package/packages/capability-broker/src/cli-bootstrap.mjs +86 -16
- package/packages/capability-broker/src/cli-bridge-response.mjs +80 -0
- package/packages/capability-broker/src/cli-stdio-bridge.mjs +21 -8
- package/packages/cli-runtime/src/codex-session-core.mjs +4 -0
- package/packages/cli-runtime/src/native-cli-output.mjs +57 -0
- package/packages/cli-runtime/src/native-peer.ps1 +51 -3
- package/packages/cli-runtime/src/native-process.mjs +77 -14
- package/packages/model-router-plugin/package.json +1 -1
- package/packages/orchestration/src/binding-product.mjs +19 -0
- package/packages/orchestration/src/bot-data-actions.mjs +45 -0
- package/packages/orchestration/src/bot-data-chart.mjs +109 -0
- package/packages/orchestration/src/bot-data-context.mjs +44 -0
- package/packages/orchestration/src/bot-data-engine.mjs +809 -0
- package/packages/orchestration/src/bot-data-io.mjs +186 -0
- package/packages/orchestration/src/bot-data-sql.mjs +195 -0
- package/packages/orchestration/src/bot-data-surface.mjs +144 -0
- package/packages/orchestration/src/bot-data-tools.mjs +68 -0
- package/packages/orchestration/src/bot-data-worker.mjs +228 -0
- package/packages/orchestration/src/bot-independent-execution.mjs +195 -0
- package/packages/orchestration/src/bot-independent-stream.mjs +74 -0
- package/packages/orchestration/src/bot-loop.mjs +152 -19
- package/packages/orchestration/src/bot-notifications.mjs +159 -0
- package/packages/orchestration/src/bot-policy.mjs +43 -4
- package/packages/orchestration/src/bot-profile.mjs +171 -0
- package/packages/orchestration/src/bot-routine.mjs +4 -4
- package/packages/orchestration/src/bot-runtime-assembly.mjs +255 -8
- package/packages/orchestration/src/bot-runtime-switch.mjs +13 -2
- package/packages/orchestration/src/bot-serving.mjs +164 -0
- package/packages/orchestration/src/bot-session-store.mjs +105 -9
- package/packages/orchestration/src/bot-settings.mjs +50 -11
- package/packages/orchestration/src/bot-thread-admission.mjs +84 -0
- package/packages/orchestration/src/bot-thread-host.mjs +187 -0
- package/packages/orchestration/src/bot-threads.mjs +790 -0
- package/packages/orchestration/src/data-product-access.mjs +37 -0
- package/packages/orchestration/src/data-tool-contract.mjs +291 -0
- package/packages/orchestration/src/desktop-http-carrier.mjs +120 -0
- package/packages/orchestration/src/desktop-proxy.mjs +311 -0
- package/packages/orchestration/src/desktop-sessions.mjs +43 -9
- package/packages/orchestration/src/ds-lifecycle.mjs +31 -4
- package/packages/orchestration/src/grant-refresh-queue.mjs +17 -0
- package/packages/orchestration/src/independent-chat-page.mjs +39 -0
- package/packages/orchestration/src/independent-chat-service.mjs +308 -0
- package/packages/orchestration/src/independent-context-state.mjs +44 -0
- package/packages/orchestration/src/independent-message-wire.mjs +84 -0
- package/packages/orchestration/src/independent-tool-catalog.mjs +175 -0
- package/packages/orchestration/src/independent-tool-families.mjs +73 -0
- package/packages/orchestration/src/independent-tool-host.mjs +344 -0
- package/packages/orchestration/src/main-bot-grant.mjs +15 -0
- package/packages/orchestration/src/main-model-routes.mjs +208 -0
- package/packages/orchestration/src/model-accounting.mjs +99 -35
- package/packages/orchestration/src/model-auxiliary-dispatch.mjs +187 -0
- package/packages/orchestration/src/model-loop-frame.mjs +179 -0
- package/packages/orchestration/src/model-prepared-generation.mjs +175 -0
- package/packages/orchestration/src/model-request-bounds.mjs +111 -0
- package/packages/orchestration/src/native-activity-contract.mjs +491 -0
- package/packages/orchestration/src/native-activity-service.mjs +102 -0
- package/packages/orchestration/src/native-activity-store.mjs +249 -0
- package/packages/orchestration/src/native-conversation-tools.mjs +78 -0
- package/packages/orchestration/src/native-data-tools.mjs +100 -0
- package/packages/orchestration/src/native-remote-actions.mjs +742 -0
- package/packages/orchestration/src/native-team-tools.mjs +73 -0
- package/packages/orchestration/src/pairing-invite.mjs +37 -0
- package/packages/orchestration/src/product-composition.mjs +1559 -1001
- package/packages/orchestration/src/remote-machines.mjs +82 -14
- package/packages/orchestration/src/routine-execution.mjs +5 -3
- package/packages/orchestration/src/routine-session.mjs +19 -13
- package/packages/orchestration/src/secure-pairing-host.mjs +112 -0
- package/packages/orchestration/src/task-journal.mjs +29 -0
- package/packages/orchestration/src/task-product.mjs +17 -0
- package/packages/orchestration/src/team-assignment-store.mjs +120 -0
- package/packages/orchestration/src/team-bridge.mjs +282 -0
- package/packages/orchestration/src/team-scheduler.mjs +83 -0
- package/packages/orchestration/src/team-sdk-tools.mjs +84 -0
- package/packages/orchestration/src/team-tool-contract.mjs +284 -0
- package/packages/orchestration/src/ui-host-namespace.mjs +23 -0
- package/packages/orchestration/src/web-panel-actions.mjs +103 -0
- package/packages/orchestration/src/web-panel-framing.mjs +227 -0
- package/packages/orchestration/src/web-panel-store.mjs +206 -0
- package/remote-computer-agent/README.md +39 -29
- package/remote-computer-agent/build.mjs +3 -0
- package/remote-computer-agent/dist/OPENMAUSBOT_LICENSE +202 -0
- package/remote-computer-agent/dist/OPENMAUSBOT_NOTICE +10 -0
- package/remote-computer-agent/dist/OPENMAUSBOT_SOURCE.json +68 -0
- package/remote-computer-agent/dist/cli-stdio-bridge.mjs +215 -25
- package/remote-computer-agent/dist/cli-stdio-bridge.mjs.map +4 -4
- package/remote-computer-agent/dist/cli.mjs +1688 -168
- package/remote-computer-agent/dist/cli.mjs.map +4 -4
- package/remote-computer-agent/dist/index.mjs +1579 -122
- package/remote-computer-agent/dist/index.mjs.map +4 -4
- package/remote-computer-agent/dist/native-peer.ps1 +51 -3
- package/remote-computer-agent/package-lock.json +308 -2
- package/remote-computer-agent/package.json +2 -1
- package/remote-computer-agent/pair-device.ps1 +20 -0
- package/remote-computer-agent/setup.ps1 +38 -16
- package/remote-computer-agent/src/cli.ts +24 -3
- package/remote-computer-agent/src/desktop-session.ts +11 -3
- package/remote-computer-agent/src/e2e-server.ts +81 -0
- package/remote-computer-agent/src/pairing-page.ts +35 -0
- package/remote-computer-agent/src/protocol.ts +25 -25
- package/remote-computer-agent/src/secure-pairing.ts +105 -0
- package/remote-computer-agent/src/store.ts +20 -5
- 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'
|