Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,13 @@ STORAGE_PROVIDER=local
# Set this only to force a specific driver (e.g. deterministic tests).
# Options: bun, node
# LIBREDB_SQLITE_DRIVER=node
# On-disk SQLite statements run in a killable child process so Cancel and the agent
# read-only deadline can stop them. Set "0" (also "false", "no", "off") to fall back to
# the synchronous driver, which blocks the server thread and has no Cancel.
# LIBREDB_SQLITE_WORKER=0
# A watchdog (milliseconds) on each child-process request. A request that does not answer
# in this time is failed and the child is replaced. Unset = no watchdog.
# LIBREDB_SQLITE_WORKER_TIMEOUT_MS=30000

# ===========================================
# Oracle Thick-mode Client (advanced)
Expand Down
84 changes: 44 additions & 40 deletions docs/AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,36 +91,38 @@ Two companion pages carry what this one deliberately does not:

## Table of Contents

- [Turning it on](#turning-it-on)
- [What a run is](#what-a-run-is)
- [The conversation a run belongs to](#the-conversation-a-run-belongs-to)
- [What a plan run knows](#what-a-plan-run-knows)
- [What the inventory is an inventory OF](#what-the-inventory-is-an-inventory-of)
- [The statement a plan run drafts](#the-statement-a-plan-run-drafts)
- [Durability and resume](#durability-and-resume)
- [A drive that dies before the loop](#a-drive-that-dies-before-the-loop)
- [The tool set](#the-tool-set)
- [The query-optimization template](#the-query-optimization-template)
- [The database-assessment template](#the-database-assessment-template)
- [The operations template](#the-operations-template)
- [The data-analysis template](#the-data-analysis-template)
- [Presenting an answer](#presenting-an-answer)
- [Handing the answer to the editor (auto-execute)](#handing-the-answer-to-the-editor-auto-execute)
- [What the fence is proved to hold against](#what-the-fence-is-proved-to-hold-against)
- [What bounds a run](#what-bounds-a-run)
- [Supported models](#supported-models)
- [The model side](#the-model-side)
- [What a refused model looks like in the app](#what-a-refused-model-looks-like-in-the-app)
- [Whether the run answered](#whether-the-run-answered)
- [The eval harness](#the-eval-harness)
- [What the removed AI panels did that a run does not](#what-the-removed-ai-panels-did-that-a-run-does-not)
- [HTTP surface](#http-surface)
- [The surface in the app](#the-surface-in-the-app)
- [Deployment](#deployment)
- [Package boundary](#package-boundary)
- [Module map](#module-map)
- [Known limitations](#known-limitations)
- [Related documentation](#related-documentation)
- [Agent Runtime — LibreDB Studio](#agent-runtime--libredb-studio)
- [Table of Contents](#table-of-contents)
- [Turning it on](#turning-it-on)
- [What a run is](#what-a-run-is)
- [The conversation a run belongs to](#the-conversation-a-run-belongs-to)
- [What a plan run knows](#what-a-plan-run-knows)
- [What the inventory is an inventory OF](#what-the-inventory-is-an-inventory-of)
- [The statement a plan run drafts](#the-statement-a-plan-run-drafts)
- [Durability and resume](#durability-and-resume)
- [A drive that dies before the loop](#a-drive-that-dies-before-the-loop)
- [The tool set](#the-tool-set)
- [The query-optimization template](#the-query-optimization-template)
- [The database-assessment template](#the-database-assessment-template)
- [The operations template](#the-operations-template)
- [The data-analysis template](#the-data-analysis-template)
- [Presenting an answer](#presenting-an-answer)
- [Handing the answer to the editor (auto-execute)](#handing-the-answer-to-the-editor-auto-execute)
- [What the fence is proved to hold against](#what-the-fence-is-proved-to-hold-against)
- [What bounds a run](#what-bounds-a-run)
- [Supported models](#supported-models)
- [The model side](#the-model-side)
- [What a refused model looks like in the app](#what-a-refused-model-looks-like-in-the-app)
- [Whether the run answered](#whether-the-run-answered)
- [The eval harness](#the-eval-harness)
- [What the removed AI panels did that a run does not](#what-the-removed-ai-panels-did-that-a-run-does-not)
- [HTTP surface](#http-surface)
- [The surface in the app](#the-surface-in-the-app)
- [Deployment](#deployment)
- [Package boundary](#package-boundary)
- [Module map](#module-map)
- [Known limitations](#known-limitations)
- [Related documentation](#related-documentation)

## Turning it on

Expand Down Expand Up @@ -1509,8 +1511,8 @@ function enumerated over all eight combinations by its test):
access path is `index` or `mixed` — never `full-scan`, never `unknown` — and the reported
`estimatedCost` is at most 50 000. **SQLite:** every step a `SEARCH`; any `SCAN`, a mixed plan or
an unreadable one is risky. SQLite is stricter on purpose: `EXPLAIN QUERY PLAN` reports no cost
and no row estimate to weigh, the engine does not preempt a read that overruns, and a runaway read
blocks writers and this application until it finishes. Any other dialect is risky, the same
and no row estimate to weigh, and an overrunning read is stopped only by killing the child process
that runs it, not by an interrupt inside the engine. Any other dialect is risky, the same
fail-closed posture `summarisePlan` takes.

**A plan the server could only PARTLY read is risky too**, which is the same rule one level down.
Expand Down Expand Up @@ -1792,10 +1794,10 @@ it run — `statementTimeoutMs` is clamped down to the time actually remaining,
to the execution layer can never exceed what is left of the run.

What that clamp is worth depends on the engine, and this is the one caveat a budget meter must not
hide: **PostgreSQL preempts an overrunning statement (`SET LOCAL statement_timeout`); SQLite does
not.** There the timeout is a post-execution deadline, so an overrunning SQLite statement is
detected after it finishes rather than being stopped mid-flight, and because the drivers are
synchronous it blocks the runtime while it runs. The rail states this where the meter is shown.
hide: **PostgreSQL preempts an overrunning statement (`SET LOCAL statement_timeout`); SQLite stops
it by killing the child process that runs it.** An overrunning on-disk SQLite statement is killed
mid-flight, so it stops at its timeout rather than running on; the in-memory database has no child
to kill and is refused only once it returns. The rail states this where the meter is shown.

**The repair loop is bounded twice, and the two bounds are different.** A statement that already
failed is never admitted again — keyed on a canonical fingerprint built from the shared SQL span
Expand Down Expand Up @@ -2352,8 +2354,9 @@ Two further rules govern it:
standing in for a capability: pause is offered on a running run and resume on a paused one, and
neither is rendered where the service would refuse it.
- **The meter reports only what is actually enforced** — statements, database time, the run deadline,
repair attempts — and states the SQLite non-preemption caveat rather than implying that an
overrunning statement is cut short. It reports no token budget because none is enforced. A statement that
repair attempts — and states that SQLite stops an overrunning statement by killing its child
process rather than implying the engine interrupts it. It reports no token budget because none is
enforced. A statement that
failed at the database now carries the span the tracker charged for it, so the database-time figure
no longer counts completed reads only, and a schema capture contributes the statements and the span
the tracker charged it. What is left uncounted is the difference between an engine's elapsed time
Expand Down Expand Up @@ -2605,8 +2608,9 @@ They are listed here so the honest boundary is visible from the behaviour docume
from the tracker.

**Inherited from the enforcement layer** — `docs/BACKLOG.md`, section "Agent M1 deferrals (#328)",
entries A1-A3. These bound what any agent statement can be promised: a SQLite statement is not
preempted, so its timeout is post-execution and an overrunning statement blocks the runtime (A1);
entries A1-A3. These bound what any agent statement can be promised: a SQLite statement has no
engine-side interrupt, so stopping one over its timeout means killing the child process that runs
it (A1);
`VACUUM INTO` can create an empty file at a chosen path (A2); out-of-scope **reads** have no
database-native control on either provider — the declared-target allowlist, the statement guard and
the role's own grants are the whole boundary (A3).
Expand Down
30 changes: 2 additions & 28 deletions docs/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ None of it is a GitHub issue.
**Sections**

- [SQL statement reading](#sql-statement-reading) — S2–S7 · 5
- [Drivers and connections](#drivers-and-connections) — D1-D291, U17 · 194
- [Drivers and connections](#drivers-and-connections) — D1-D291, U17 · 193
- [Value interpolation](#value-interpolation) — V1
- [Row editing](#row-editing) — R1–R3 · 3
- [Studio UI and query execution](#studio-ui-and-query-execution) — X2-X27, U2-U113 · 104
Expand All @@ -40,7 +40,7 @@ None of it is a GitHub issue.
- [Security Phase 2 deferrals](#security-phase-2-deferrals) — C3–C11 · 6
- [Security Phase 3 deferrals](#security-phase-3-deferrals) — K4-K8 · 5
- [Security scanner triage](#security-scanner-triage) — SCAN1 · 1
- [Agent M1 deferrals (#328)](#agent-m1-deferrals-328) — A1–A8 · 7
- [Agent M1 deferrals (#328)](#agent-m1-deferrals-328) — A2–A8 · 6
- [Agent M2 deferrals (#329)](#agent-m2-deferrals-329) — B2-B106 · 42
- [Passkey deferrals (#785)](#passkey-deferrals-785) — PK1-PK9 · 8
- [MCP server deferrals (#246)](#mcp-server-deferrals-246)
Expand Down Expand Up @@ -2500,17 +2500,6 @@ Found 2026-10-04 by the review of the #1351 fix; pre-existing, not measured end

**Done when:** on a Materialize connection, search, inventory and the agent's grounding answer for tables, views and materialized views, with the two routine kinds reported as unavailable rather than failing the read.

### D227. A long SQLite statement blocks the whole Studio server, and no cancel or timeout reaches it

`sqlite.ts` runs every statement through `bun:sqlite` or `node:sqlite`, both synchronous, on the server's one JavaScript thread, and neither exposes `sqlite3_interrupt` or a progress handler (Node 24.11's `DatabaseSync` has `setAuthorizer` and neither of those; Bun 1.4.2's `Database` has neither).
So a running statement cannot be stopped from inside the process, and while it runs no other request is served: measured 2026-10-03 on node:sqlite (SQLite 3.50.4), a 300M-row recursive CTE from the editor made `/api/health` answer after 69.7 s instead of 6 ms, and the 60 s query timeout, checked only after the statement returns, did not end it.
Since #1364 the editor no longer offers Cancel there (`supportsQueryCancel: false`, `docs/providers/sqlite.md` section 3.4); the block itself is unchanged.
A1 is the same property on the agent and MCP path.

Found 2026-10-04 while fixing #1364.

**Done when:** SQLite statements run in a worker thread that the provider terminates on cancel and on the query timeout, the SQLite provider implements `cancelQuery`, and a test shows `/api/health` answering while a long statement runs.

### D228. Platform discovery recognises only four engine families

`detectEngine` in `src/lib/seed/discovery-fingerprint.ts` matches the PostgreSQL family, the MySQL family (MySQL, MariaDB, Percona), MongoDB and the Redis family (Redis, Valkey, KeyDB, Dragonfly), and `ENV_ALLOW_LIST` in `docker/discover.mjs` exports only the ten environment keys those need.
Expand Down Expand Up @@ -5601,21 +5590,6 @@ And the code does not exist on `main`: it arrives only if #1070 merges, and a di

Each was decided while building the operation/policy layer, not overlooked.

### A1. A SQLite agent or MCP statement can block the runtime for its whole duration

`sqlite.ts`'s `queryReadOnly` enforces `statementTimeoutMs` as a post-execution deadline: the result of
an overrunning statement is refused, but the statement is never preempted. SQLite has no
transaction-local statement timeout, and neither `bun:sqlite` nor `node:sqlite` exposes
`sqlite3_interrupt` or a progress handler.

Because both drivers are synchronous, a hostile recursive CTE blocks the whole runtime while it runs.
Same property as the normal SQLite query path, but the input source differs in kind: there the SQL
comes from an authenticated operator, here from an agent.

`/api/mcp`'s `run_read_query` reaches the same path, `queryReadOnly` under `agent-read-only`, with SQL from an external MCP client, and `docs/MCP.md` states the risk.

**Done when:** either driver exposes an interrupt or progress hook, or agent and MCP SQLite execution moves to a worker that can be killed on deadline.

### A2. `VACUUM INTO` can create an empty file at an agent-chosen path

The SQLite agent profile's read-only open governs the target database file only. `VACUUM INTO '<path>'`
Expand Down
5 changes: 3 additions & 2 deletions docs/MCP.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,9 +291,10 @@ For the first few seconds after that service starts, `opencode mcp list` can say
| PostgreSQL | Studio stops waiting; the statement runs on until `statement_timeout`, which is set to the time left | The database ends the statement, and the client gets the timeout answer |
| SQL Server | Studio stops waiting; the statement runs until the provider's deadline cancels it | The provider cancels the statement, and the client gets the timeout answer |
| DuckDB | Studio stops waiting; the statement runs to completion and its result is discarded | The client gets the timeout answer near `timeout_ms`, and the statement runs on |
| SQLite | The driver is synchronous, so the whole Studio process, the UI included, waits until the statement ends | The client is answered after the statement ends |
| SQLite | An on-disk statement runs in a killable child process, so the client is answered at `timeout_ms` | The statement is killed at `timeout_ms`; an in-memory statement still runs to completion |

- One MCP query against a large SQLite table stops the Studio process while it runs.
- One MCP query against a large SQLite table no longer stops the Studio process: the read runs in a
child process and is killed at `timeout_ms`; only an in-memory SQLite read still blocks it.
- An MCP call on a LibreDB connection that no Studio session holds open opens the file itself and keeps its exclusive lock until that handle has been idle for 30 minutes.
Every MCP call resets that clock, and meanwhile opening the connection in the Studio editor fails with 503, `LibreDB file is already open by another process (exclusive lock)`.
When the editor opened the file first, MCP borrows its handle and nothing conflicts ([`docs/providers/libredb.md`](providers/libredb.md#421-on-disk-format-locking-and-version-compatibility-02x)).
Expand Down
43 changes: 23 additions & 20 deletions docs/providers/sqlite.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,35 +231,38 @@ A statement with no result columns answers `rowCount` from the driver's `changes
not reset for a statement that changes no row: on node:sqlite a DDL statement or a value-setting
`PRAGMA` reports the previous write's count (D139 in [`docs/BACKLOG.md`](../BACKLOG.md)).

### 3.4 No transactions API, no cancellation, no pool
### 3.4 No transactions API, no pool; cancellation via the child-process worker

Unlike every networked SQL provider, SQLite exposes **no** `beginTransaction`/`commit`/`rollback`/
`queryInTransaction`, **no** `cancelQuery`, and **no** pool/`getPoolStats`. It is a single embedded
handle. (`POST /api/db/transaction` and `/api/db/cancel` are therefore not applicable to SQLite.)
`queryInTransaction` and **no** pool/`getPoolStats` — it is a single embedded handle. Cancellation is
different: on-disk statements run in a child process the provider can kill, so `cancelQuery` and a
preemptive deadline exist there. (`POST /api/db/transaction` is not applicable to SQLite; `POST
/api/db/cancel` is, for on-disk connections.)

Since #464 the client can see that: `supportsTransactions: false`
([§9](#9-capabilities--labels)) is what withholds BEGIN/COMMIT/ROLLBACK and the auto-rolled-back
SANDBOX toggle from the editor toolbar here. Before that flag existed the only gate was
`isTransactionProvider(provider)` inside the route — a runtime shape check the browser cannot read —
so the controls rendered on every connection and the route answered HTTP 400.

Since #1364 the same holds for Cancel. `/api/db/provider-meta` reports `supportsQueryCancel: false`
here, read off the provider by the check the cancel route makes, and the provider declares
`blocksServerWhileRunning: true`. On an engine that cannot cancel, the editor's control reads "Stop
waiting" and only ends the editor's wait; here even that is withheld, because the server answers
nothing else until the statement ends, so the control is shown disabled with the reason on hover.
Before #1364 the button was live, the route answered 400 "Query cancellation is not supported for
this database type", and the editor said "Query Cancelled" anyway.

**A long statement blocks the whole server, and nothing can stop it.** Both drivers run the
statement synchronously on the server's only JavaScript thread, and neither exposes
`sqlite3_interrupt` or a progress handler (checked on Node 24.11, whose `DatabaseSync` offers
`setAuthorizer` but neither of the two, and Bun 1.4.2). So no cancel and no deadline can reach a
running statement: the query timeout is checked after it returns. Measured on 2026-10-03 with
node:sqlite (SQLite 3.50.4): a 300M-row recursive CTE kept running after Cancel, and `/api/health`
answered after 69.7 s instead of the usual 6 ms, so every user of the instance waited with it. Moving
SQLite execution to a worker thread that can be terminated is recorded as D227 in
[`docs/BACKLOG.md`](../BACKLOG.md).
Since #1623, on-disk connections DO cancel. `/api/db/provider-meta` reports `supportsQueryCancel: true`
and `blocksServerWhileRunning: false` there, because the statement runs in a child process the
provider can `SIGKILL` (`src/lib/db/providers/sql/sqlite-worker.ts`). Before #1623 the route answered
400 "Query cancellation is not supported for this database type", and the editor said "Query
Cancelled" anyway.

**A long on-disk statement no longer blocks the whole server (#1623).** The statement runs in a
child process, so while one runs the server keeps answering other requests, `cancelQuery` kills the
child, and the read-only deadline is preemptive. Measured on 2026-10-03, before the worker: a
300M-row recursive CTE kept running after Cancel, and `/api/health` answered after 69.7 s instead of
the usual 6 ms.

**The escape hatch `LIBREDB_SQLITE_WORKER=0`** restores the old synchronous behaviour — the test
suite sets it because Bun 1.4.x hangs a SQLite child process a `bun test` run spawned. With the
worker off, on-disk statements run on the server thread again, `supportsQueryCancel` reads `false`,
`blocksServerWhileRunning` reads `true`, and the read-only timeout is post-execution. `:memory:`
always runs synchronously — a child process cannot share an in-memory database. Node (production)
keeps the worker on.

### 3.5 `endOpenQueryTransaction()` — a transaction a statement left open

Expand Down
2 changes: 1 addition & 1 deletion src/components/agent/AgentRail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2810,7 +2810,7 @@ export const AgentRail = React.memo(function AgentRail({
charged statements, so that spend is not in the figure above.
</>
)}{" "}
On SQLite a statement over its timeout is refused once it returns, not interrupted while it runs.
On SQLite a statement over its timeout is now killed mid-flight, not refused only once it returns.
</span>
</InfoNote>
</span>
Expand Down
Loading
Loading