mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-23 23:47:52 +00:00
perf(sqlite): reuse admission across workers and reopens (#167739)
* perf(sqlite): checkpoint shared admission across workers (WIP) * test(sqlite): align worker fixtures with shared admission * perf(sqlite): avoid repeated quarantine reads in retained scopes * test(sqlite): align first-admission coverage and deletion fixtures Route retained existing-state writer coverage through the host-owned worker project, preserve first-admission incompatibility refusal, and match the final deletion-authority contract. Keep the gateway test under its inherited line count by moving existing pure fixture helpers. Regenerate assertion allowances without adding production assertions. * build(sqlite): include shared admission in compiled wrappers Include the thirteen transitive admission modules required by the native wrapper import-closure proof. Carry the existing birthtime through the worker validation fixture to preserve its physical-file identity. * test(sqlite): use the canonical settlement owner in publication proof * test(sqlite): exercise worker failure through its owner * fix(sqlite): preserve cleanup and explicit maintenance validation * fix(sqlite): retain revocation and scoped worker admission * fix(sqlite): preserve migration and read-cache owners * fix(sqlite): preserve maintenance and bootstrap admission * fix(sqlite): keep temporary trigger admission connection-local * fix(sqlite): preserve unavailable discovery target isolation * fix(sqlite): preserve rollback and cleanup admission boundaries * fix(sqlite): preserve admission custody and explicit update inspection * fix(sqlite): preserve admission custody across cleanup and creation * fix(pr): retain receipt dependency in wrapper inventory * fix(sqlite): preserve first-creation admission authority
This commit is contained in:
parent
e7eece5bae
commit
bb7d32e55d
226 changed files with 10134 additions and 3187 deletions
|
|
@ -56,7 +56,7 @@ Update instructions at their owner instead of adding competing rules here.
|
|||
- State and caches use SQLite, not new JSON/JSONL/sidecar stores; files are for named user artifacts, imports/exports, attachments, logs, backups, or external-tool contracts.
|
||||
- Kysely for ordinary SQLite; raw SQL only for schema, migrations, bootstrap, and justified primitives. Write transactions are synchronous: plan async work first, reread authoritative rows, then write; no Promise or `await` in transaction callbacks.
|
||||
- Database access runs in worker threads (read-only worker scope, SQLite writer broker), never on the Gateway main thread, which only awaits results and installs published facts. Exceptions: boot admission, migrations, Doctor/CLI one-shots, lock primitives. Existing main-thread access is legacy: never add more; migrate what you touch.
|
||||
- Schema/integrity/index checks run once at admission and in the migration owner; runtime carries admitted facts and never re-queries them, and cache freshness probes observe foreign commits as [database schemas](docs/reference/database-schemas.md) defines. Per-call checks are legacy: never add; migrate when touched.
|
||||
- SQLite format, schema-version, integrity, canonical-index, and table-existence checks run once per physical database per process load, shared with all workers and handles. This includes quarantine-store format; existing indexed durable quarantine-row guards stay current so another process's confirmed corruption is observed. Reuse admitted format facts across opens, scopes, requests, and idle close; identify replacement files by volume, inode, and birthtime with `fstat`, not SQL. Migration/repair owners publish new facts after their DDL; Doctor and explicit verification retain their checks. Cached-row freshness probes still observe foreign commits as [database schemas](docs/reference/database-schemas.md) defines. Never add repeated format checks; remove them when touched.
|
||||
- Privileged actions need current owner-held authority: revalidate after awaited work and right before side effects. Tokens, signatures, expiry, and matching IDs alone do not prove live authority.
|
||||
- Core owns shared message tools, action vocabulary, and dispatch; channels own account, security, conversation, and transport contracts. Keep typed command/approval/URL/action distinctions until encoding; never infer commands from raw strings.
|
||||
- Carry prepared facts through hot paths; reuse process-stable plugin metadata and lifecycle-owned caches; never repeatedly load registries or freshness-poll files. Preserve lazy module boundaries and verify relevant builds on the authorized host.
|
||||
|
|
|
|||
|
|
@ -2568,7 +2568,6 @@ src/state/local-onboarding-state.ts 1
|
|||
src/state/openclaw-agent-db-permissions.ts 1
|
||||
src/state/openclaw-agent-db-registry-listing.ts 2
|
||||
src/state/openclaw-agent-db-session-migrations.ts 1
|
||||
src/state/openclaw-quarantine-store.ts 1
|
||||
src/state/openclaw-state-db-delivery-queue-backfill.ts 1
|
||||
src/state/openclaw-state-db-legacy-backfills.ts 4
|
||||
src/state/openclaw-state-db-schema-helpers.ts 1
|
||||
|
|
|
|||
|
|
@ -6657,7 +6657,6 @@
|
|||
["src/config/sessions/session-history-worker-errors.test-support.ts","./disk-budget-runtime.js","96a65b3e512a97eba31cc8e5ed1fa56260dfff775469ff1bb329454dbaafdc39",1]
|
||||
["src/config/sessions/session-history-worker-errors.test-support.ts","./session-accessor.sqlite-exact-read.js","1e84b077c2aed79b63160020fb76d506a01ac6cbe78685ea27272d4e894cae80",1]
|
||||
["src/config/sessions/session-history-worker-errors.test-support.ts","./session-sharing-store.js","d93387a76c2b387d2225899320c50734ff8d78387a34173f26a4290bb794fd66",1]
|
||||
["src/config/sessions/session-history-worker-errors.test-support.ts","./session-transcript-hydration.worker.js","e1b02784fd81a97e71650beaece130d3771e123f4011a105c0fef338d2e44364",1]
|
||||
["src/config/sessions/session-store-target-inventory.test.ts","./targets-read-availability.js","703ea4103b77aee18350705892581a8e996f72ef21ae4d7cdb5a0606230e24cc",1]
|
||||
["src/config/sessions/transcript-resolve.runtime.test.ts","./transcript.js","a9a3538ede0e4276a9cc437c63137d88daca3506765e3892a5612812147ee487",1]
|
||||
["src/config/state-dir-dotenv.test.ts","../logging/subsystem.js","6b154f74290d6c84838b4a5b1e90ccd0fb1ccc3f52262c70817046790e4337ab",1]
|
||||
|
|
|
|||
|
|
@ -445,7 +445,7 @@ Use `isLoopbackHost(host)` when a plugin must accept only the local machine. It
|
|||
| `plugin-sdk/session-discussion` | External session discussion provider contracts, registration, and canonical Control UI session path building |
|
||||
| `plugin-sdk/session-transcript-runtime` | Private-local after July 2026; Transcript identity, bounded raw and visible cursors, scoped target/read/write helpers, read-only native transcript catalog pages with portable sender attribution, visible message-entry projection, update publishing, write locks, and transcript memory hit keys |
|
||||
| `plugin-sdk/sqlite-runtime` | Private-local after July 2026; SQLite agent-schema, path, transaction, and shared-handle borrowing helpers for first-party runtime. Type-only `Generated` and `Selectable` model generated columns and selected rows in Kysely table definitions. `compileSqliteQueryBindings` compiles fixed Kysely SQL with fresh bindings for caller-owned native statements; statement lifetime stays with the caller. `iterateSqliteQuerySync` streams Kysely query rows for incremental decoding; consume the iterator before closing its database. `sqliteStringSet` binds string membership as one SQLite JSON table-valued query parameter, preserving one query snapshot without a variable-count placeholder list. `borrowOpenClawAgentDatabase` returns `{ db, release }`; active borrows prevent cache eviction, `release()` does not close the handle, and explicit owner disposal still revokes it. `withOpenClawAgentDatabaseAsync` admits a connection asynchronously and retains its original identity through the operation’s settlement; it does not move callback execution off the caller thread. |
|
||||
| `plugin-sdk/sqlite-worker-runtime` | Private-local native SQLite, Kysely query, synchronous transaction, and operation-admission primitives for worker backends and their shared helpers. `runSqliteDeferredTransactionSync` groups synchronous reads into one SQLite snapshot; its callback must not return a Promise. Imports existing owners directly so workers do not load host database lifecycle code. Host store creation and lifecycle coordination remain on `sqlite-runtime`. |
|
||||
| `plugin-sdk/sqlite-worker-runtime` | Private-local native SQLite, Kysely query, synchronous transaction, and operation-admission primitives for worker backends and their shared helpers. `runSqliteDeferredTransactionSync` groups synchronous reads into one SQLite snapshot; its callback must not return a Promise. `admitSqliteSchema` admits a tracked connection to the shared physical catalog; `tableExists` consumes those facts without another catalog query. `getSqliteDatabaseAdmission`, `publishSqliteDatabaseAdmission`, and `SqliteDatabaseAdmissionKey` share owner-validated facts once per physical database per process, including worker reopen; publish after successful initialization or migration, and mark schema-dependent facts with `schemaDependent: true`. Imports existing owners directly so workers do not load host database lifecycle code. Host store creation and lifecycle coordination remain on `sqlite-runtime`. |
|
||||
| `plugin-sdk/cron-store-runtime` | Private-local after July 2026; Cron store path/load/save helpers |
|
||||
| `plugin-sdk/state-paths` | Canonical config/state/OAuth path helpers. `resolveConfigPath(env?, stateDir?, homedir?)` returns the selected local config path without loading config or opening the state database. It honors `OPENCLAW_CONFIG_PATH` (including home-relative expansion), the selected state directory, and existing legacy config candidates before canonical fallback. It may inspect filesystem existence; it does not create files, validate config contents, or establish path ownership/permission authority. Use it for pre-config native bootstrap selection instead of copying path policy; callers still enforce their filesystem and lifecycle admission checks. |
|
||||
| `plugin-sdk/plugin-state-runtime` | Private-local after July 2026; Plugin-scoped keyed-state and BLOB contracts plus connection pragma, verified WAL maintenance, and atomic STRICT-schema migration helpers. Plugin-state leases were removed; use SQLite transactions and keyed stores instead |
|
||||
|
|
|
|||
|
|
@ -17,9 +17,14 @@ readers install their lock-wait timeout at connection open.
|
|||
Non-mutating WAL observations reuse the loaded library's admitted capability;
|
||||
they still observe current WAL frames and read freshness on each use. Quarantine
|
||||
decision readers and writers set their existing lock-wait timeout at connection
|
||||
open; each decision reads the current schema version and quarantine row in one
|
||||
SQLite snapshot and validates any recorded file generation. WAL safety, quarantine authority, and recovery
|
||||
behavior are unchanged.
|
||||
open. The quarantine store's format is admitted once per physical database per
|
||||
process, while existing guards retain the indexed durable quarantine-row lookup
|
||||
in one SQLite snapshot and validate any recorded file generation.
|
||||
A separate inspection process can admit a target before the Gateway's verifier
|
||||
confirms corruption; its next guard must observe that recorded refusal even when
|
||||
the target's physical identity is unchanged. Quarantine rows retain their pathname
|
||||
scope, and aliases still share physical format, schema, and integrity facts.
|
||||
The persisted schema, WAL safety, and recovery behavior are unchanged.
|
||||
|
||||
Managed writes publish committed facts before their public observers. Private
|
||||
receipts distinguish explicit absence from incomplete coverage and preserve known
|
||||
|
|
@ -27,18 +32,39 @@ commits independently of reply delivery. See
|
|||
[committed facts and completeness](/reference/database-schemas/worker-access#committed-facts-and-completeness)
|
||||
for ordering, rollback, and the writer families that still retain native guards.
|
||||
|
||||
Schema-version, integrity, canonical-index, and table-existence checks belong to open/admission and the migration owner after migrations; runtime paths must carry admitted schema facts with the handle, never re-query them, and use fresh `PRAGMA data_version` checks to observe foreign commits on the next unpinned read while preserving active SQLite snapshots. Existing per-call checks are legacy and must be migrated when touched.
|
||||
SQLite format, schema-version, integrity, canonical-index, and
|
||||
table-existence validation runs once per physical database per process load,
|
||||
on its first admission. The admitted facts are shared with all workers and
|
||||
handles, including later opens and reopens after idle close. File identity uses
|
||||
volume, inode, and stable birthtime checked with `fstat`, not SQL. A replaced or
|
||||
restored file needs its own first validation. Migration and repair owners validate
|
||||
their changes and publish the new facts after successful DDL settlement; later
|
||||
runtime consumers do not recheck them. Doctor and explicit verification retain
|
||||
their checks, and observed corruption still revokes admission.
|
||||
|
||||
Shared-state and agent read-only connections reuse bounded prepared statements under their native connection lifecycle. Prepared-statement reuse alone does not retain query results. Read admission shares one freshness check within its synchronous operation; schema-fact lookups reuse the admitted handle without checking again. Write transactions refresh after acquiring `BEGIN`, before consuming those facts. Explicit fresh checks always execute, even inside another read operation. A foreign commit compares the schema and user versions before retaining or replacing schema facts, preserving active SQLite snapshots. Closing or replacing the connection clears retained statements and facts.
|
||||
Shared-state and agent read-only connections reuse bounded prepared statements
|
||||
under their native connection lifecycle. Prepared-statement reuse alone does not
|
||||
retain query results. Read admission shares one `PRAGMA data_version` freshness
|
||||
check within its synchronous operation; schema-fact lookups use the process's
|
||||
admitted facts without SQL. Write transactions refresh cached row facts after
|
||||
acquiring `BEGIN`. Explicit row-freshness checks still execute, even inside another
|
||||
read operation. Foreign commits invalidate cached row results without querying
|
||||
`schema_version`, `user_version`, or the catalog again. Active SQLite snapshots
|
||||
keep their view until they end. Closing a connection clears its prepared statements
|
||||
and row caches, while physical-database admission survives for the process lifetime.
|
||||
|
||||
Shared-state content-version checks reuse their value at the admitted connection
|
||||
revision. Foreign commits, local writes, schema changes, and connection closure
|
||||
invalidate reuse. Native transactions, pinned snapshots, unadmitted handles, and
|
||||
dynamic authorizers continue reading the marker directly. Cold admission still
|
||||
checks freshness after catalog capture before validating the supported version.
|
||||
Schema versions, stored bytes, and upgrade or downgrade behavior are unchanged.
|
||||
Shared-state content-version checks reuse the physical database's admitted version
|
||||
facts across handles and workers. Ordinary commits, transactions, and connection
|
||||
closure do not force another marker read. The migration owner publishes replacement
|
||||
facts with its committed schema. Observed DDL without replacement facts expires
|
||||
the content-version admission: an unchanged catalog shape does not prove that the
|
||||
`config_machine_state` marker survived. The next owner lookup validates it once,
|
||||
and later opens reuse the result. A historical snapshot keeps its own marker facts;
|
||||
publication requires a positive match with the current committed admission. Each
|
||||
caller still applies its published-version floor. Schema versions, stored bytes,
|
||||
and upgrade or downgrade behavior are unchanged.
|
||||
|
||||
New agent readers share the initial freshness probe with schema validation;
|
||||
New agent readers reuse the process's schema admission and probe row freshness;
|
||||
subsequent unpinned uses still probe again. Shared-state worker reads keep admission
|
||||
and query execution in the same freshness scope. Point transcript statistics, mutation clocks, pending-archive checks, and
|
||||
hot/cold watermarks use a single statement's snapshot; composite reads retain
|
||||
|
|
@ -50,23 +76,23 @@ lock-wait budgets, schemas, stored data, and update behavior are unchanged.
|
|||
|
||||
The admitted catalog includes index names and trigger definitions alongside tables.
|
||||
Canonical session validation consumes these definitions without another catalog scan.
|
||||
Canonical index admission shares the schema contract reader's batched metadata snapshot
|
||||
First canonical index admission shares the schema contract reader's batched metadata snapshot
|
||||
instead of querying each table and index separately. Shadowed PRAGMA names retain
|
||||
native inspection, and authorization, drift detection, transactional repair, and
|
||||
integrity checks remain unchanged.
|
||||
native inspection during that validation. Authorization, initial drift detection,
|
||||
transactional repair, and integrity checks remain with their existing owners.
|
||||
First-use schema owners skip additive DDL only when all their tables and indexes
|
||||
are present in the current facts. Foreign schema changes, local DDL, rollback, and
|
||||
connection replacement invalidate those facts through the same connection owner;
|
||||
missing objects still use the existing installation transaction. Unadmitted and
|
||||
authorizer-controlled connections retain their native checks. Schemas, stored
|
||||
bytes, and update behavior are unchanged.
|
||||
are present in the published facts. Missing objects use the existing installation
|
||||
transaction, which publishes the new schema facts on commit; rollback cannot
|
||||
publish a schema that did not commit. Ordinary connection replacement and foreign
|
||||
data commits do not repeat schema validation. Schemas, stored bytes, and update
|
||||
behavior are unchanged.
|
||||
|
||||
Schema facts gathered within a managed read operation survive data-only transaction
|
||||
settlement. The next operation still checks foreign commits. Unmanaged transaction
|
||||
snapshots, and sibling schema publications observed inside an active transaction,
|
||||
discard their facts when that snapshot ends.
|
||||
Admitted schema facts survive data-only transaction settlement. The next operation
|
||||
still checks foreign commits for cached row freshness. Transaction-local views of
|
||||
schema facts end with their SQLite snapshot; the next transaction consumes the
|
||||
process's published facts without repeating validation.
|
||||
|
||||
Progress-card writes reuse the transaction's admitted table facts. The schema owner creates the lazy table only when it is absent, so warm writes preserve schema facts for that handle and its local siblings. First use after rollback or a foreign schema change still creates missing storage through normal write admission. Stored cards, revision tombstones, schema versions, and upgrade or downgrade behavior are unchanged.
|
||||
Progress-card writes reuse the transaction's admitted table facts. The schema owner creates the lazy table only when it is absent and publishes the committed facts for every handle and worker. A rolled-back installation remains absent until the next normal installation transaction. Stored cards, revision tombstones, schema versions, and upgrade or downgrade behavior are unchanged.
|
||||
|
||||
The agent-database execution owner retains up to four idle physical-agent executors in least-recently-used order. Borrowing an executor refreshes its independent 30-minute idle timeout; a fifth idle executor evicts the least recently used one. Configuration changes to the agent roster or storage paths stop warm retention and drain affected executors after their last borrower settles. Already-admitted work retains its original physical store; new requests resolve the current configuration. Explicit database closure and Gateway shutdown still revoke and drain the existing lifecycle resources. This changes no schema, stored bytes, or update behavior.
|
||||
|
||||
|
|
@ -78,20 +104,18 @@ worker transfers so alias publication revokes superseded proof while preserving
|
|||
acknowledged copies. Later revocation still refuses publication. Schemas, stored
|
||||
bytes, and update behavior are unchanged.
|
||||
|
||||
Retaining an already-open agent handle holds its lifetime without querying SQLite. Its read or transaction owner refreshes schema facts when consuming data; canonical readiness owns the freshness check before reusing its clean-store decision.
|
||||
Retaining an already-open agent handle holds its lifetime without querying SQLite. Its read or transaction owner refreshes cached row facts when consuming data; schema facts come from process-wide admission. Canonical readiness owns the row-freshness check before reusing its clean-store decision.
|
||||
|
||||
Agent ownership metadata follows the admitted snapshot revision as well. Unchanged
|
||||
reads, including reads within one transaction or pinned snapshot, reuse the handle's
|
||||
metadata. Foreign commits on the next unpinned use, local mutations, rollback, and
|
||||
schema changes require a new ownership read. Dynamic authorizers keep querying
|
||||
the metadata. This changes no schema, stored bytes, or update behavior.
|
||||
|
||||
The shared-state content-version marker uses the same admitted read revision.
|
||||
Unchanged reads reuse its successful result; foreign commits, local writes,
|
||||
rollback, schema changes, and connection disposal invalidate reuse. Transactions
|
||||
and pinned snapshots reuse the marker at their admitted revision;
|
||||
authorizer-controlled reads still query it. Version validation and upgrade or
|
||||
downgrade behavior are unchanged.
|
||||
The agent ID and role in `schema_meta` describe the physical store's fixed schema
|
||||
owner. Creation and migration validate and publish these facts for every handle
|
||||
and worker; ordinary commits do not require another ownership-metadata read.
|
||||
Observed DDL without owner-published replacement metadata expires that admission,
|
||||
even when the recreated table has the same definition: its primary row must be
|
||||
validated once by the next owner lookup. Historical metadata stays with its
|
||||
snapshot until positively matched to current committed admission. External row
|
||||
edits do not reassign an admitted store. Live session permissions, lease ownership,
|
||||
and caller authority retain their current-row checks. Dynamic authorizers retain
|
||||
native metadata reads. This changes no schema, stored bytes, or update behavior.
|
||||
|
||||
Registry discovery reuses successful migration checks for the admitted schema
|
||||
generation. The minute retention sweep reads deletion history in a worker and
|
||||
|
|
@ -118,7 +142,8 @@ synchronous domain read. ACP metadata reuses at most 128 rows per connection
|
|||
under that revision; foreign commits, local writes, schema changes, and close
|
||||
invalidate reuse. Transactions and pinned or authorizer-controlled reads still
|
||||
query SQLite. Supplied shared-state writers reuse their selected handle and check
|
||||
schema and ownership after `BEGIN`, without a duplicate pre-transaction row read.
|
||||
ownership after `BEGIN`, without a duplicate pre-transaction row read or schema
|
||||
validation.
|
||||
Stored bytes, schemas, permissions, and update behavior are unchanged.
|
||||
|
||||
Exact entry and participant readers retain their last result at the admitted
|
||||
|
|
|
|||
|
|
@ -9,6 +9,20 @@ title: "Integrity, troubleshooting, and recovery"
|
|||
|
||||
## Integrity checks
|
||||
|
||||
Admission validates SQLite format, schema, canonical indexes, and required
|
||||
integrity once per physical database per process load. The quarantine store's
|
||||
format follows the same rule. Existing guards retain the indexed durable
|
||||
quarantine-row lookup so another process's recorded corruption is observed.
|
||||
The process shares admitted format and integrity facts with all handles and
|
||||
worker isolates. Later opens, read scopes, and writer reopens after idle close
|
||||
do not repeat format or integrity validation SQL. File identity
|
||||
uses volume, inode, and stable birthtime checked with `fstat`; a replaced or
|
||||
restored file requires its own first admission. Migration and repair owners keep
|
||||
their checks and publish new facts after successful DDL settlement. Doctor and
|
||||
explicit verification keep their independent checks. Proven corruption still
|
||||
revokes admission; current ownership and cached-row freshness are separate from
|
||||
format validation.
|
||||
|
||||
Gateway agent inspections share a five-second foreground wait. Unfinished stores
|
||||
remain unavailable while the startup admission owner completes their inspection
|
||||
and session/model preparation after the listener is ready. Other agents and the
|
||||
|
|
@ -187,14 +201,15 @@ unconfirmed close, read-only release, missing lease, changed file, mismatched
|
|||
path, or missing matching verification. An interrupted release leaves its lease
|
||||
for the next admission to diagnose.
|
||||
|
||||
| When | Check |
|
||||
| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Every open | Validate the `schema_meta` table and primary metadata row |
|
||||
| Writable agent open and Gateway readiness | Run full integrity and foreign-key checks without reusable proof, except native WAL admission with no receipt or proven same-boot process death |
|
||||
| Same-process agent reopen | Reuse current file-bound runtime proof without another integrity or quick check; recheck owner, version, schema, and canonical indexes |
|
||||
| Clean same-version agent restart | Recheck owner, version, schema, and canonical indexes; queue a child-process `quick_check` and foreign-key check after the Gateway is listening |
|
||||
| Before a pending migration | Run a full integrity, foreign-key, role, schema, and index scan |
|
||||
| Doctor, backup verification, and compaction | Run the full scan before accepting or rewriting the database |
|
||||
| When | Check |
|
||||
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| First physical-database admission in a process | Validate format, schema metadata, and canonical indexes; share the admitted facts with all handles and workers |
|
||||
| First writable agent admission and Gateway readiness | Run required integrity and foreign-key checks without reusable proof; native WAL admission with no receipt or proven same-boot process death defers that scan |
|
||||
| Same-process reopen or new worker | Reuse file-bound admitted facts without schema, version, catalog, or integrity SQL; retain current ownership and durable quarantine-row checks |
|
||||
| Clean same-version agent restart | Recheck owner, version, schema, and canonical indexes; queue a child-process `quick_check` and foreign-key check after the Gateway is listening |
|
||||
| Before a pending migration | Run a full integrity, foreign-key, role, schema, and index scan |
|
||||
| After a migration or repair | The migration or repair owner validates its changes and publishes committed facts; later consumers do not repeat the checks |
|
||||
| Doctor, backup verification, and compaction | Run the full scan before accepting or rewriting the database |
|
||||
|
||||
The existing quarantine store keeps a reconstructible `agent_integrity_verifications`
|
||||
record: canonical database path, device, inode, OpenClaw version, verification time, and
|
||||
|
|
@ -216,8 +231,8 @@ including stale admission and unsettled Worker cleanup. A successful native clos
|
|||
with a reader-blocked checkpoint keeps verification dirty and preserves live
|
||||
runtime proof. A later last writer can certify that verification after a completed
|
||||
checkpoint and native close; failed close or uncertain storage errors revoke both.
|
||||
Cold opens and restarts
|
||||
still require matching clean-close metadata or the admission gate.
|
||||
The first open in a new process still requires matching clean-close metadata or
|
||||
the admission gate. Idle close within the same process preserves admitted facts.
|
||||
Cleanup workers and native agent execution workers borrow that proof under their
|
||||
existing writer admission. Cleanup workers return new verification to the Gateway
|
||||
after they finish.
|
||||
|
|
@ -225,7 +240,7 @@ Reclamation retains one Worker connection per database, so alternating agents
|
|||
reuse their admitted handles. Requests still share the archive FIFO. Each Worker
|
||||
retires after 30 idle minutes, on database close, or when idle under critical
|
||||
memory pressure; failed cleanup retains its original lease until settlement.
|
||||
Integrity revocation, schema checks, and update behavior are unchanged.
|
||||
Integrity revocation and update behavior are unchanged.
|
||||
During a one-way Gateway shutdown drain, idle native execution and retained
|
||||
reclamation connections close immediately. Active executions close when their
|
||||
final borrower releases them; active reclamation requests settle before closing.
|
||||
|
|
@ -307,11 +322,12 @@ above, or override explicit process-local revocation. Older readers can
|
|||
ignore the nullable column; backup and rollback retain its existing row lifetime.
|
||||
|
||||
Database replacement, quarantine, and failed admission discard applicable
|
||||
verification. Doctor maintenance discards remembered runtime verification after
|
||||
verification. Ordinary native close does not. Doctor maintenance discards remembered runtime verification after
|
||||
draining agent connections and before raw maintenance can run. Pending migrations
|
||||
still run full checks, and canonical index
|
||||
repairs verify their result before committing. Schema, ownership, and current
|
||||
write authority are never borrowed from the integrity result.
|
||||
repairs verify their result before committing, then publish the new admitted
|
||||
facts. Ownership and current write authority are never borrowed from the
|
||||
integrity result.
|
||||
|
||||
Shared-state runtime opens and automatic startup preparation converge supported
|
||||
schema additions and preserve atomic upgrades from older schema versions. A
|
||||
|
|
|
|||
|
|
@ -51,19 +51,33 @@ delete reactions for removed message identities in the same transaction, while
|
|||
a reset makes old-instance rows inert. Downgrade leaves the table intact and disables Control UI reactions until
|
||||
a supporting build returns. No transcript backfill or rewrite is required.
|
||||
|
||||
Admitted agent and cached shared-state handles retain their schema version and
|
||||
table facts. The handle owner revokes these facts after local DDL or transaction
|
||||
rollback. A fresh `PRAGMA data_version` check observes foreign commits on the next
|
||||
unpinned read, even within the same event-loop turn. On a foreign commit, the owner
|
||||
compares `schema_version` and `user_version` in one pinned snapshot and retains
|
||||
facts and their revision when both are unchanged. Data-only commits therefore
|
||||
avoid table and column scans while version-only changes still trigger refusal
|
||||
when the stored version is newer than the running build. Actual
|
||||
SQLite read snapshots retain their view until they end; the next read then observes
|
||||
committed changes. Canonical session validation uses the same schema revision.
|
||||
Unchanged versions reuse parsed schema facts and prepared statements without
|
||||
repeating schema scans. Migration and snapshot consistency checks remain fresh reads.
|
||||
This changes no stored schema, migration, durability, or update behavior.
|
||||
The first admission validates each physical database's schema version and shape
|
||||
once per process load. Agent, shared-state, and other SQLite owners share those
|
||||
admitted facts with every later handle and worker, including reopens after idle
|
||||
close. A replaced file needs first admission for its new physical identity.
|
||||
Migration and repair owners validate their changes and publish new facts after
|
||||
successful DDL settlement; rollback cannot publish an uncommitted schema.
|
||||
The `schema_meta` role and agent ID describe the physical store's fixed schema
|
||||
owner. Creation and migration validate and publish them; changing that row through
|
||||
external SQL does not reassign an admitted store to another agent. Session
|
||||
permissions, leases, and other live authority remain current-row decisions.
|
||||
|
||||
Catalog shape equality cannot prove that the `schema_meta` primary row or the
|
||||
`config_machine_state` content-version marker survived DDL. Observed DDL without
|
||||
owner-published replacement facts expires these row-backed admissions. The next
|
||||
owner lookup validates them once; later handles and opens reuse the result.
|
||||
Historical metadata remains private to its snapshot unless the owner positively
|
||||
matches it to the current committed admission. Rollback restores both staged
|
||||
publication and connection-local version facts.
|
||||
|
||||
A fresh `PRAGMA data_version` check still observes foreign commits on the next
|
||||
unpinned read, even within the same event-loop turn. It invalidates cached row
|
||||
facts without rereading `schema_version`, `user_version`, or the catalog. SQLite
|
||||
read snapshots retain their view until they end; the next read then observes
|
||||
committed rows. Initial admission still refuses unsupported versions. Doctor,
|
||||
explicit verification, migration, and snapshot consistency checks retain their
|
||||
own contracts. This changes no stored schema, migration, durability, or update
|
||||
behavior.
|
||||
|
||||
The nullable requester-authority columns on GitHub publication lifecycle and
|
||||
repository receipts require [state schema 18](/reference/database-schemas/state-schema-history#state-schema-18).
|
||||
|
|
|
|||
|
|
@ -26,6 +26,67 @@ paths are migration debt, not a pattern to extend. The
|
|||
[migration inventory](/reference/database-schemas/worker-access-inventory) separates
|
||||
candidate main-thread paths from SQL already executing in workers.
|
||||
|
||||
SQLite format, schema-version, integrity, canonical-index, and
|
||||
table-existence validation runs once per physical database per process load,
|
||||
shared with all workers. The first opener publishes admitted facts through the
|
||||
existing worker bootstrap and broker channel. Every later handle, worker isolate,
|
||||
scope, and request reuses those facts, including a writer reopened after idle
|
||||
close. Identity is the file's volume, inode, and stable birthtime, checked with
|
||||
`fstat`; path equality or SQL version probes do not establish physical identity.
|
||||
A replaced or restored file requires its own first validation.
|
||||
The identity owner retains a descriptor for the process lifetime and checks its
|
||||
`fstat` identity against the selected path at open. On Linux, where Node may report
|
||||
change time as birthtime, the existing normalized unknown-birthtime value remains
|
||||
in use; retaining the original descriptor prevents that inode from being reused
|
||||
for a replacement file. This descriptor holds identity custody; it is separate
|
||||
from SQLite's native connection descriptor.
|
||||
|
||||
Quarantine guards retain their indexed durable row lookup by target pathname.
|
||||
A separate inspection process can admit a file before the Gateway's verifier
|
||||
confirms corruption and records quarantine. The inspecting process must observe
|
||||
that new refusal at its next guard even though the file's physical identity is
|
||||
unchanged. The quarantine store's schema, version, and installation are still
|
||||
admitted once per physical database per process, and aliases share the target's
|
||||
physical format, schema, and integrity facts. The persisted schema is unchanged.
|
||||
|
||||
The host owns retained identity descriptors so worker retirement cannot close them
|
||||
and release a sibling SQLite connection's POSIX locks. Workers outside OpenClaw's
|
||||
admission transport can consume inherited host facts; an unshared first open
|
||||
retains its native validation checks. Fact-only worker channels do not authorize
|
||||
file creation. Production writer stores open through the host-only broker, which
|
||||
checks current request/access authority and the captured target immediately before
|
||||
creating the file. Nested worker relays share admission facts but do not expose
|
||||
writer-store creation through the public runtime or Plugin SDK. Joined snapshot, deletion, and maintenance owners release identity
|
||||
custody after native consumers drain and before removing files. Managed restore
|
||||
retains the original files and their identity custody instead of closing those
|
||||
descriptors while holding an exclusive SQLite transaction. NOCOW maintenance first
|
||||
proves that no other descriptor for that file remains in the process.
|
||||
|
||||
Migration and repair owners retain their checks and publish replacement facts
|
||||
after their DDL commits. Ordinary runtime consumers do not revalidate that result.
|
||||
Doctor and explicit verification keep their independent checks, and proven
|
||||
corruption still revokes admission. `PRAGMA data_version` probes continue to
|
||||
refresh cached row facts and preserve SQLite snapshots; a foreign commit does not
|
||||
trigger another format check. Current caller authority, lease ownership, and
|
||||
canonical session-row validation retain their own lifecycles.
|
||||
|
||||
Raw savepoints, batches containing transaction-control statements, and synchronous
|
||||
native statement callbacks can retain a historical snapshot before its schema
|
||||
view is known. These views may need a private catalog capture, as may readers
|
||||
overlapping our own unsettled DDL. Historical metadata stays with that snapshot
|
||||
unless the owner positively matches it to current committed admission; stale
|
||||
adoption cannot replace the shared catalog. Matching catalog definitions alone
|
||||
cannot prove that ownership or content-version rows survived unknown DDL, so their
|
||||
next owner lookup validates once unless the writer published replacement facts.
|
||||
Managed read scopes reuse admitted snapshot facts. These exceptions do not add
|
||||
format polling to ordinary opens, worker requests, or transaction framing.
|
||||
|
||||
Native statement garbage collection and worker exit must settle outstanding
|
||||
snapshot and mutation ownership through the existing lifecycle owners before
|
||||
shared facts become reusable. Abandoned JavaScript references or an IPC disconnect
|
||||
alone do not establish native settlement; this uses lifecycle completion, not a
|
||||
new polling loop.
|
||||
|
||||
Ordinary durable entry patches use the existing agent writer by default. Reply
|
||||
settings, compaction accounting, titles, voice sessions, and bundled lifecycle
|
||||
producers carry source predicates through preparation into that writer. The worker
|
||||
|
|
@ -455,10 +516,10 @@ and deletion-fence query remain T1. These changes introduce no schema, stored
|
|||
data, retention, durability, permission, or update migration.
|
||||
|
||||
Gateway deleted-agent checks batch ACP metadata candidates through the shared-state
|
||||
reader only for free ACP keys whose agent is absent. When a foreign commit requires
|
||||
the content-version marker to be refreshed, the reader fetches that marker and the
|
||||
first metadata cohort in one statement. Admission validates the marker before
|
||||
consuming metadata; the existing read revision owns subsequent row reuse.
|
||||
reader only for free ACP keys whose agent is absent. When initial admission needs
|
||||
the content-version marker, the reader fetches it and the first metadata cohort in
|
||||
one statement. Later reads reuse admitted version facts; the existing read revision
|
||||
still owns ACP row freshness and reuse.
|
||||
Reset rebinding uses the ACP
|
||||
writer after the session entry commits, retaining that entry writer's physical
|
||||
custody and accepted cleanup authority until the metadata settles. If the session
|
||||
|
|
@ -552,12 +613,13 @@ independent retargets keep their foreground history-reader concurrency.
|
|||
These scheduling changes require no schema, stored-data, configuration, or update
|
||||
migration.
|
||||
|
||||
Worker read-only agent connections load existing file-bound canonical validation receipts
|
||||
at admission, before a read transaction begins. Reopening a reader then validates
|
||||
pending keys without repeating a complete session inventory. Copied or replaced
|
||||
files still require their own validation; canonical receipts never grant integrity
|
||||
proof. Writer FIFO ordering, current-authority checks, schemas, and update behavior
|
||||
remain unchanged.
|
||||
Worker read-only agent connections load the process's file-bound admitted schema
|
||||
and integrity facts before a read transaction begins. Reopening a reader validates
|
||||
pending session keys without repeating a catalog scan, schema metadata lookup,
|
||||
or complete session inventory. Durable quarantine-row guards remain current.
|
||||
Copied or replaced files require their own admission; canonical session-row
|
||||
receipts alone never grant integrity
|
||||
proof. Writer FIFO ordering and current-authority checks remain unchanged.
|
||||
|
||||
When a fully checked opener registers the same live database, it promotes existing
|
||||
canonical proof without revoking a pending native admission. Ordinary registration,
|
||||
|
|
@ -585,17 +647,17 @@ their native owner. Canonical comparison contracts return through the existing
|
|||
schema-contract cache; version facts stay within their unchanged snapshot. Each
|
||||
request rereads stored facts. Schemas, stored bytes, and update behavior are unchanged.
|
||||
|
||||
Shared-state readers reuse the content-version marker at their admitted connection
|
||||
revision. Existing foreign-commit probes, local mutations, rollback, and schema
|
||||
changes invalidate that fact. Transactions, pinned snapshots, unadmitted reads,
|
||||
and authorizer-controlled reads still query SQLite. Each caller applies its own
|
||||
published-version floor without retaining that floor in the marker cache.
|
||||
Shared-state readers reuse physical-database version admission across connections
|
||||
and workers, including within transactions. Ordinary commits and connection
|
||||
retirement do not invalidate these facts. Migration publishes replacement facts;
|
||||
an unseen historical snapshot may read its matching marker once without replacing
|
||||
a newer publication. Each caller still applies its own published-version floor.
|
||||
|
||||
Trajectory retention readers receive already cached canonical validation
|
||||
definitions under the exact source-schema key. Cold hosts do not build a
|
||||
comparison database to prepare that handoff. Each reader still validates the
|
||||
actual schema, database identity, quarantine state, and read freshness; retention
|
||||
selection and retry policy are unchanged.
|
||||
Trajectory retention readers receive already admitted schema facts under the
|
||||
physical database identity. Cold hosts do not build a comparison database to
|
||||
prepare that handoff. Each reader retains its physical identity and row-freshness
|
||||
checks without repeating schema SQL; durable quarantine-row guards remain current.
|
||||
Retention selection and retry policy are unchanged.
|
||||
|
||||
Session lifecycle result counts use the already admitted agent executor after
|
||||
maintenance settles. These metadata reads retain physical-store FIFO and source
|
||||
|
|
@ -623,29 +685,31 @@ Revocation, failed discovery, deletion, and native cleanup failure keep the exis
|
|||
retirement and settlement requirements. Retained readers continue to observe native
|
||||
mutations, foreign commits, and physical replacement through their original owner.
|
||||
|
||||
Workers may inherit completed native SQLite capability checks and canonical schema
|
||||
comparison definitions through the existing worker launcher. Runtime facts remain
|
||||
bound to the originating process, executable, runtime, and selected library;
|
||||
comparison definitions also bind their canonical source and format. Missing or
|
||||
mismatched facts use the original admission path. A SQLite writer returns its
|
||||
Workers inherit completed native SQLite capability checks, canonical schema
|
||||
comparison definitions, and physical-database admission facts through the existing
|
||||
worker launcher. Runtime facts remain bound to the originating process,
|
||||
executable, runtime, and selected library; comparison definitions also bind their
|
||||
canonical source and format. Physical-database facts belong to their admitted file
|
||||
identity. Missing facts request that identity's first admission. A SQLite writer returns its
|
||||
completed native capability fact with an existing successful reply, so later
|
||||
sibling workers can inherit it without another probe. The native runtime owner
|
||||
checks the same process and library identity and current WAL safety floor before
|
||||
forwarding it; invalid hints leave both write settlement and normal admission
|
||||
unchanged. Each worker still validates its
|
||||
actual database and current caller authority. Registry admission likewise reuses
|
||||
exact canonical audit definitions from admitted schema facts, retaining structural
|
||||
validation for other supported shapes. Schemas, stored bytes, retention, and update
|
||||
behavior are unchanged.
|
||||
unchanged. Each worker still checks physical identity and current caller authority;
|
||||
it consumes published schema and integrity facts without repeating validation.
|
||||
Registry admission likewise reuses admitted schema facts. Its first validation
|
||||
retains structural checks for other supported shapes. Schemas, stored bytes,
|
||||
retention, and update behavior are unchanged.
|
||||
|
||||
Completed full expected-schema descriptions are shared across bundled module
|
||||
copies and inherited by newly created workers through that same launcher. The
|
||||
Gateway prepares the canonical shared-state and agent descriptions during boot
|
||||
admission, after SQLite runtime selection and before starting database workers.
|
||||
The cache is keyed by complete canonical SQL, so same-version additive definitions
|
||||
remain distinct. Only expected definitions are transferred: every admission still
|
||||
inspects its actual database, and newer database versions remain refused. Retained
|
||||
supervisors forward the caller's current definitions when creating each worker.
|
||||
remain distinct. Actual admitted facts are shared separately by physical database
|
||||
identity; the first admission still refuses newer database versions. Retained
|
||||
supervisors forward both the caller's current definitions and its admitted facts
|
||||
when creating each worker.
|
||||
Runtime shared-state comparisons select their existing table and index view from
|
||||
the full description. They do not construct another comparison database for that
|
||||
subset. The full maintenance policy still validates present lazy objects, and
|
||||
|
|
@ -713,8 +777,9 @@ agent-store reader and use the existing exact entry and membership predicates at
|
|||
transaction and commit grants. Transport waits check lifetime and source custody
|
||||
without repeating those reads. Grants never rediscover shared-state registry or
|
||||
profile ownership while the placement writer holds its transaction.
|
||||
Retained readers reuse admitted schema facts, recheck ownership after foreign
|
||||
commits, and refuse changed schemas before re-admission.
|
||||
Retained readers reuse admitted schema facts and recheck ownership after foreign
|
||||
commits. Schema changes made by OpenClaw's migration or repair owner arrive through
|
||||
its fact publication.
|
||||
|
||||
The released `getMany` and `retireSessionPlacement` methods retain synchronous
|
||||
SDK adapters through the next Plugin SDK major. Native source/reset and final
|
||||
|
|
@ -1954,39 +2019,33 @@ hold. Preparation starts at worker dispatch or the preceding transaction's
|
|||
settlement; it includes command loading and opening the database, not broker
|
||||
queue residence.
|
||||
|
||||
Runtime connections borrow completed integrity and foreign-key proof from the
|
||||
shared-state file's process-local lifecycle generation. Native file identity,
|
||||
schema version, and the owner's schema revision must still match. Schema changes
|
||||
revoke the revision, including rolled-back DDL; new captures can establish fresh
|
||||
proof. Observed corruption revokes borrowed proof before native cleanup;
|
||||
canonical close and replacement revoke the generation. Proof is published
|
||||
only after successful transaction settlement. Simultaneous first opens can each
|
||||
validate while proof is pending; they never wait on a new admission lock.
|
||||
Per-connection schema contracts and mutable metadata remain checked. Explicit
|
||||
maintenance and copied-file verification always check integrity, with canonical
|
||||
read-only admission performing one scan instead of two.
|
||||
Runtime connections reuse completed schema, integrity, and foreign-key admission
|
||||
from the shared-state file's process-wide record. The first opener validates the
|
||||
physical database and publishes the result for sibling handles and workers;
|
||||
concurrent or later opens reuse that result. Closing a native connection does not
|
||||
discard it. A new physical file needs fresh admission, while observed corruption
|
||||
revokes existing admission before native cleanup. A migration or repair publishes
|
||||
its new schema facts only after successful transaction settlement. Mutable
|
||||
metadata retains its row-freshness checks. Explicit maintenance and copied-file
|
||||
verification keep their independent integrity checks.
|
||||
|
||||
Cold writable agent admission uses the existing agent executor before a bundled
|
||||
runtime caller receives its native handle. Concurrent acquisitions share that executor's
|
||||
physical generation. Its validation receipt carries the admitted schema facts;
|
||||
later native handles compare committed schema markers and reuse those facts
|
||||
instead of repeating canonical table, index, trigger, and integrity scans.
|
||||
later native handles check physical file identity and reuse those facts without
|
||||
schema-marker queries or canonical table, index, trigger, and integrity scans.
|
||||
Session generation and transcript-index trackers declare their fixed connection-local
|
||||
tables, indexes, and triggers to the schema owner, which checks existing TEMP names
|
||||
and shapes once at installation before preserving the receipt. Mismatched objects,
|
||||
other TEMP objects, and ordinary local DDL still revoke the shared schema
|
||||
proof, including rolled-back DDL; a partial tracker installation also revokes it. Revoked
|
||||
facts on live handles and host-handle eviction return to the retained worker for admission and
|
||||
publication; stale proof never falls back to schema scans on the Gateway thread.
|
||||
The worker validates changed schemas before publishing replacement facts while
|
||||
retaining its native generation. Ordinary execution reuses completed preparation;
|
||||
explicit host readmission and startup recovery still request the worker's current
|
||||
validation. Re-adopting an unchanged schema preserves its facts identity, so
|
||||
standing-intent bindings skip already-covered additive DDL. A foreign schema change
|
||||
observed by the next unpinned freshness check also revokes the shared receipt;
|
||||
data-only commits preserve schema facts. File replacement and revoked integrity
|
||||
still require fresh admission. Mutable agent ownership is checked again on
|
||||
acquisition and when each connection opens.
|
||||
and shapes once at installation. Installing those declared TEMP tracking objects
|
||||
does not cause another validation of the physical database. The migration or repair owner publishes
|
||||
replacement facts for committed local DDL; rollback cannot publish uncommitted
|
||||
facts. Host-handle eviction preserves process admission and never falls back to
|
||||
schema scans on the Gateway thread. Ordinary execution reuses completed
|
||||
preparation, including the facts used to skip already-covered additive DDL.
|
||||
Foreign data commits refresh row facts without schema-marker queries. File
|
||||
replacement requires first admission for the replacement; revoked integrity
|
||||
requires recovery through its existing owner. Mutable agent ownership remains
|
||||
subject to its own current-row checks.
|
||||
Cloud turns retain the admitted handle through execution and finalization, so
|
||||
their existing synchronous transcript-authority checks cannot become cold openers
|
||||
after an idle eviction.
|
||||
|
|
@ -2336,12 +2395,11 @@ later successful receipt does not clear that uncertainty. Stored formats,
|
|||
schemas, and path-redaction rules are unchanged.
|
||||
|
||||
The canonical worker retains the actual existing-schema ledger connection for
|
||||
step and phase mutations. Its first transaction admits integrity and the stable
|
||||
table contract; later writes reuse the schema owner's facts and re-admit that
|
||||
contract after schema changes, including another feature's lazy table creation.
|
||||
Every write retains transaction-held source, ownership metadata, content-version,
|
||||
and write-authority checks. Content markers remain current SQLite snapshot reads;
|
||||
only their prepared statements are cached.
|
||||
step and phase mutations. The first admission validates integrity and the stable
|
||||
table contract; later writes and reopened connections reuse that physical store's
|
||||
admitted subset. Another feature's lazy table creation does not repeat its
|
||||
validation. Every write retains transaction-held physical-source and live
|
||||
write-authority checks; schema ownership and content versions come from admission.
|
||||
Candidate validation warms that writer before snapshot selection and holds the
|
||||
existing actor operation through validation and copied-state cleanup, including
|
||||
silent copy intervals. Idle inspection and actor drainage include the retained
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { copyFileSync, renameSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import type { SqliteWorkerBackend } from "openclaw/plugin-sdk/sqlite-worker-runtime";
|
||||
|
|
@ -15,6 +16,7 @@ const reads = vi.hoisted(() => ({
|
|||
}));
|
||||
const preparations = vi.hoisted(() => new Map<string, number>());
|
||||
const batchReads = vi.hoisted(() => [] as string[][]);
|
||||
const schemaStatements = vi.hoisted(() => [] as string[]);
|
||||
vi.mock("openclaw/plugin-sdk/sqlite-worker-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/sqlite-worker-runtime")>();
|
||||
return {
|
||||
|
|
@ -22,7 +24,22 @@ vi.mock("openclaw/plugin-sdk/sqlite-worker-runtime", async (importOriginal) => {
|
|||
openNodeSqliteDatabase: (...args: Parameters<typeof actual.openNodeSqliteDatabase>) => {
|
||||
const db = actual.openNodeSqliteDatabase(...args);
|
||||
const prepare = db.prepare.bind(db);
|
||||
const recordSchema = (sql: string) => {
|
||||
if (
|
||||
/user_version|schema_version|sqlite_schema|sqlite_master|pragma_table|CREATE\s+(?:TABLE|INDEX)/i.test(
|
||||
sql,
|
||||
)
|
||||
) {
|
||||
schemaStatements.push(sql);
|
||||
}
|
||||
};
|
||||
const exec = db.exec.bind(db);
|
||||
vi.spyOn(db, "exec").mockImplementation((sql) => {
|
||||
recordSchema(sql);
|
||||
exec(sql);
|
||||
});
|
||||
vi.spyOn(db, "prepare").mockImplementation((sql) => {
|
||||
recordSchema(sql);
|
||||
const statement = prepare(sql);
|
||||
if (/^\s*(?:insert into "(?:frames|standups)"|update "batches")/i.test(sql)) {
|
||||
preparations.set(sql, (preparations.get(sql) ?? 0) + 1);
|
||||
|
|
@ -97,12 +114,41 @@ const tempDirs = useAutoCleanupTempDirTracker((cleanup) =>
|
|||
vi.restoreAllMocks();
|
||||
preparations.clear();
|
||||
batchReads.length = 0;
|
||||
schemaStatements.length = 0;
|
||||
cleanup();
|
||||
}
|
||||
}),
|
||||
);
|
||||
const day = "2026-07-03";
|
||||
|
||||
it("reuses schema admission on reopen and validates a replacement physical database", async () => {
|
||||
const dataDir = tempDirs.make("logbook-schema-admission-");
|
||||
const databasePath = path.join(dataDir, "logbook.sqlite");
|
||||
for (let opened = 0; opened < 3; opened += 1) {
|
||||
schemaStatements.length = 0;
|
||||
const backend = createSqliteWorkerBackend({ dataDir }, { databasePath });
|
||||
try {
|
||||
if (opened === 1) {
|
||||
expect(schemaStatements).toEqual([]);
|
||||
} else {
|
||||
expect(schemaStatements.length).toBeGreaterThan(0);
|
||||
}
|
||||
if (opened === 0) {
|
||||
backend.execute({ type: "saveStandup", input: { day, text: "Persisted standup" } });
|
||||
}
|
||||
expect(backend.execute({ type: "getStandup", input: { day } })).toMatchObject({
|
||||
text: "Persisted standup",
|
||||
});
|
||||
} finally {
|
||||
await backend.close();
|
||||
}
|
||||
if (opened === 1) {
|
||||
renameSync(databasePath, `${databasePath}.original`);
|
||||
copyFileSync(`${databasePath}.original`, databasePath);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function openBackend() {
|
||||
const dataDir = tempDirs.make("logbook-read-budget-");
|
||||
const databasePath = path.join(dataDir, "logbook.sqlite");
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
linkSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
|
|
@ -219,6 +221,9 @@ describe("LogbookStore", () => {
|
|||
const batch = await store.latestBatch();
|
||||
await store.close();
|
||||
const databasePath = path.join(dir, "logbook.sqlite");
|
||||
// Build the old format on a physical file this process has not admitted.
|
||||
renameSync(databasePath, `${databasePath}.seed`);
|
||||
copyFileSync(`${databasePath}.seed`, databasePath);
|
||||
const database = new DatabaseSync(databasePath);
|
||||
database.exec(`
|
||||
DROP INDEX IF EXISTS idx_logbook_frames_captured_at;
|
||||
|
|
@ -579,6 +584,9 @@ describe("LogbookStore", () => {
|
|||
await store.saveStandup(DAY, "Preserved future-version fixture");
|
||||
await store.close();
|
||||
const databasePath = path.join(dir, "logbook.sqlite");
|
||||
// The future-version file must reach its first managed admission here.
|
||||
renameSync(databasePath, `${databasePath}.seed`);
|
||||
copyFileSync(`${databasePath}.seed`, databasePath);
|
||||
const future = new DatabaseSync(databasePath);
|
||||
future.exec("PRAGMA user_version = 2");
|
||||
future.close();
|
||||
|
|
|
|||
|
|
@ -10,9 +10,12 @@ import {
|
|||
executeSqliteQuerySync,
|
||||
executeSqliteQueryTakeFirstSync,
|
||||
getNodeSqliteKysely,
|
||||
getSqliteDatabaseAdmission,
|
||||
openNodeSqliteDatabase,
|
||||
prepareSqliteQuerySync,
|
||||
publishSqliteDatabaseAdmission,
|
||||
runSqliteImmediateTransactionSync,
|
||||
type SqliteDatabaseAdmissionKey,
|
||||
type SqliteWorkerBackend,
|
||||
} from "openclaw/plugin-sdk/sqlite-worker-runtime";
|
||||
import { pickKeyframeId } from "./analyze.js";
|
||||
|
|
@ -47,6 +50,11 @@ type Database = import("node:sqlite").DatabaseSync;
|
|||
|
||||
const LOGBOOK_SQLITE_BUSY_TIMEOUT_MS = 5_000;
|
||||
const FRAME_PRUNE_BATCH_SIZE = 64;
|
||||
const schemaAdmission: SqliteDatabaseAdmissionKey<true> = {
|
||||
name: "logbook.schema",
|
||||
schemaDependent: true,
|
||||
read: (value) => (value === true ? true : undefined),
|
||||
};
|
||||
class LogbookDatabaseStore {
|
||||
private readonly db: Database;
|
||||
private readonly query;
|
||||
|
|
@ -78,17 +86,20 @@ class LogbookDatabaseStore {
|
|||
foreignKeys: true,
|
||||
synchronous: "NORMAL",
|
||||
});
|
||||
const versionRow = db.prepare("PRAGMA user_version").get();
|
||||
const schemaVersion = Number(versionRow?.user_version ?? 0);
|
||||
if (schemaVersion > LOGBOOK_SCHEMA_VERSION) {
|
||||
throw new Error(
|
||||
`Logbook database uses newer schema version ${schemaVersion}; this build supports ${LOGBOOK_SCHEMA_VERSION}`,
|
||||
);
|
||||
}
|
||||
db.exec(SCHEMA);
|
||||
if (schemaVersion < LOGBOOK_SCHEMA_VERSION) {
|
||||
migrateSqliteSchemaToStrict(db, SCHEMA, { databaseLabel: dbPath });
|
||||
db.exec(`PRAGMA user_version = ${LOGBOOK_SCHEMA_VERSION};`);
|
||||
if (!getSqliteDatabaseAdmission(db, schemaAdmission)) {
|
||||
const versionRow = db.prepare("PRAGMA user_version").get();
|
||||
const schemaVersion = Number(versionRow?.user_version ?? 0);
|
||||
if (schemaVersion > LOGBOOK_SCHEMA_VERSION) {
|
||||
throw new Error(
|
||||
`Logbook database uses newer schema version ${schemaVersion}; this build supports ${LOGBOOK_SCHEMA_VERSION}`,
|
||||
);
|
||||
}
|
||||
db.exec(SCHEMA);
|
||||
if (schemaVersion < LOGBOOK_SCHEMA_VERSION) {
|
||||
migrateSqliteSchemaToStrict(db, SCHEMA, { databaseLabel: dbPath });
|
||||
db.exec(`PRAGMA user_version = ${LOGBOOK_SCHEMA_VERSION};`);
|
||||
}
|
||||
publishSqliteDatabaseAdmission(db, schemaAdmission, true);
|
||||
}
|
||||
this.db = db;
|
||||
this.walMaintenance = walMaintenance;
|
||||
|
|
|
|||
|
|
@ -11,13 +11,19 @@ import {
|
|||
MEMORY_INDEX_FTS_TABLE,
|
||||
rebuildMemoryChunkFts,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-engine-schema";
|
||||
import { runSqliteImmediateTransactionSync } from "openclaw/plugin-sdk/sqlite-worker-runtime";
|
||||
import {
|
||||
runSqliteImmediateTransactionSync,
|
||||
tableExists as admittedTableExists,
|
||||
} from "openclaw/plugin-sdk/sqlite-worker-runtime";
|
||||
import { markMemoryVectorIndexClean } from "./manager-vector-rebuild-state.js";
|
||||
|
||||
const MEMORY_REINDEX_SCHEMA = "memory_reindex";
|
||||
export const MEMORY_INDEX_STATE_ID = 1;
|
||||
|
||||
function tableExists(db: DatabaseSync, schema: string, tableName: string): boolean {
|
||||
if (schema === "main") {
|
||||
return admittedTableExists(db, tableName);
|
||||
}
|
||||
const row = db
|
||||
.prepare(`SELECT 1 AS ok FROM ${schema}.sqlite_master WHERE type = 'table' AND name = ?`)
|
||||
.get(tableName);
|
||||
|
|
|
|||
|
|
@ -5,12 +5,10 @@ import {
|
|||
MEMORY_INDEX_FTS_TABLE,
|
||||
MEMORY_INDEX_VECTOR_TABLE,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-engine-storage";
|
||||
import { tableExists } from "openclaw/plugin-sdk/sqlite-worker-runtime";
|
||||
import type { MemoryIndexMeta } from "./manager-reindex-state.js";
|
||||
import { loadMemorySourceFileState } from "./manager-source-state.js";
|
||||
import {
|
||||
memoryTableExists,
|
||||
resolvePersistedMemoryVectorIndexState,
|
||||
} from "./manager-vector-rebuild-state.js";
|
||||
import { resolvePersistedMemoryVectorIndexState } from "./manager-vector-rebuild-state.js";
|
||||
|
||||
export const MEMORY_INDEX_META_KEY = "memory_index_meta_v1";
|
||||
|
||||
|
|
@ -37,7 +35,7 @@ export function readMemoryRetrievalIndexState(db: DatabaseSync) {
|
|||
db.prepare("SELECT 1 FROM memory_index_chunks LIMIT 1").get() !== undefined;
|
||||
const hasFtsContent =
|
||||
!hasIndexedChunks &&
|
||||
memoryTableExists(db, MEMORY_INDEX_FTS_TABLE) &&
|
||||
tableExists(db, MEMORY_INDEX_FTS_TABLE) &&
|
||||
db.prepare(`SELECT 1 FROM ${MEMORY_INDEX_FTS_TABLE} LIMIT 1`).get() !== undefined;
|
||||
const vectorState =
|
||||
meta && meta.provider !== "none"
|
||||
|
|
|
|||
|
|
@ -6,12 +6,10 @@ import {
|
|||
executeSqliteQueryTakeFirstSync,
|
||||
getNodeSqliteKysely,
|
||||
runSqliteImmediateTransactionSync,
|
||||
tableExists,
|
||||
} from "openclaw/plugin-sdk/sqlite-worker-runtime";
|
||||
import { createMemoryChunkWriter, type IndexedMemoryChunk } from "./manager-chunk-writer.js";
|
||||
import {
|
||||
markMemoryVectorRebuildRequired,
|
||||
memoryTableExists,
|
||||
} from "./manager-vector-rebuild-state.js";
|
||||
import { markMemoryVectorRebuildRequired } from "./manager-vector-rebuild-state.js";
|
||||
import { createMemoryVectorWriter } from "./manager-vector-write.js";
|
||||
|
||||
const MAX_VECTOR_POINT_DELETES = 32;
|
||||
|
|
@ -136,7 +134,7 @@ export class MemorySourceIndexKernel {
|
|||
}
|
||||
|
||||
private clear(pathname: string, source: MemorySource): void {
|
||||
if (memoryTableExists(this.database, MEMORY_INDEX_VECTOR_TABLE)) {
|
||||
if (tableExists(this.database, MEMORY_INDEX_VECTOR_TABLE)) {
|
||||
if (!this.state.vector.enabled || this.state.vector.available !== true) {
|
||||
markMemoryVectorRebuildRequired(this.database);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { observeHostDataSql } from "openclaw/plugin-sdk/sqlite-runtime-testing";
|
||||
import { useAutoCleanupTempDirTracker } from "openclaw/plugin-sdk/test-env";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { inspectMemoryIndexPresenceInWorker } from "./manager-status-presence.js";
|
||||
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
|
||||
it.each(["memory_index_chunks", "chunks"])(
|
||||
"reuses the admitted catalog but reads fresh %s presence on every open",
|
||||
(table) => {
|
||||
const filename = path.join(tempDirs.make("memory-presence-"), "memory.sqlite");
|
||||
{
|
||||
using db = new DatabaseSync(filename);
|
||||
db.exec(`CREATE TABLE ${table} (id TEXT PRIMARY KEY)`);
|
||||
}
|
||||
const observed = observeHostDataSql();
|
||||
const schemaQueries = () =>
|
||||
observed.queries.filter((sql) =>
|
||||
/sqlite_(?:schema|master)|\bPRAGMA\s+(?:user_version|schema_version)\b/iu.test(sql),
|
||||
);
|
||||
try {
|
||||
expect(inspectMemoryIndexPresenceInWorker(filename)).toBe(false);
|
||||
expect(schemaQueries().length).toBeGreaterThan(0);
|
||||
{
|
||||
using db = new DatabaseSync(filename);
|
||||
db.exec(`INSERT INTO ${table} VALUES ('new-memory')`);
|
||||
}
|
||||
observed.queries.length = 0;
|
||||
expect(inspectMemoryIndexPresenceInWorker(filename)).toBe(true);
|
||||
expect(schemaQueries()).toEqual([]);
|
||||
{
|
||||
using db = new DatabaseSync(filename);
|
||||
db.exec(`DELETE FROM ${table}`);
|
||||
}
|
||||
observed.queries.length = 0;
|
||||
expect(inspectMemoryIndexPresenceInWorker(filename)).toBe(false);
|
||||
expect(schemaQueries()).toEqual([]);
|
||||
} finally {
|
||||
observed.restore();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
@ -5,6 +5,7 @@ import {
|
|||
MEMORY_INDEX_META_TABLE,
|
||||
MEMORY_INDEX_SOURCES_TABLE,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-engine-schema";
|
||||
import { admitSqliteSchema, tableExists } from "openclaw/plugin-sdk/sqlite-worker-runtime";
|
||||
|
||||
const MEMORY_INDEX_META_KEY = "memory_index_meta_v1";
|
||||
|
||||
|
|
@ -13,6 +14,7 @@ export function inspectMemoryIndexPresenceInWorker(databasePath: string): boolea
|
|||
let db: DatabaseSync | undefined;
|
||||
try {
|
||||
db = openNodeSqliteDatabase(databasePath, { readOnly: true });
|
||||
admitSqliteSchema(db);
|
||||
const builtInMemoryTableSets = [
|
||||
{
|
||||
meta: MEMORY_INDEX_META_TABLE,
|
||||
|
|
@ -21,23 +23,9 @@ export function inspectMemoryIndexPresenceInWorker(databasePath: string): boolea
|
|||
},
|
||||
{ meta: "meta", sources: "files", chunks: "chunks" },
|
||||
] as const;
|
||||
const builtInMemoryTables = builtInMemoryTableSets.flatMap(({ meta, sources, chunks }) => [
|
||||
meta,
|
||||
sources,
|
||||
chunks,
|
||||
]);
|
||||
const tableNames = new Set(
|
||||
db
|
||||
.prepare(
|
||||
`SELECT name FROM sqlite_master WHERE type = 'table' AND name IN (${builtInMemoryTables.map(() => "?").join(", ")})`,
|
||||
)
|
||||
.all(...builtInMemoryTables)
|
||||
.map((row) => row.name)
|
||||
.filter((name): name is string => typeof name === "string"),
|
||||
);
|
||||
for (const tables of builtInMemoryTableSets) {
|
||||
if (
|
||||
tableNames.has(tables.meta) &&
|
||||
tableExists(db, tables.meta) &&
|
||||
db
|
||||
.prepare(`SELECT 1 AS ok FROM ${tables.meta} WHERE key = ? LIMIT 1`)
|
||||
.get(MEMORY_INDEX_META_KEY)
|
||||
|
|
@ -46,7 +34,7 @@ export function inspectMemoryIndexPresenceInWorker(databasePath: string): boolea
|
|||
}
|
||||
for (const tableName of [tables.sources, tables.chunks]) {
|
||||
if (
|
||||
tableNames.has(tableName) &&
|
||||
tableExists(db, tableName) &&
|
||||
db.prepare(`SELECT 1 AS ok FROM ${tableName} LIMIT 1`).get()
|
||||
) {
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
type MemorySyncParams,
|
||||
type MemorySyncProgressUpdate,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-engine-storage";
|
||||
import { tableExists } from "openclaw/plugin-sdk/sqlite-worker-runtime";
|
||||
import type { MemoryCoreAcquireLocalService } from "./embedding-local-service.js";
|
||||
import {
|
||||
resolveEmbeddingProviderIndexIdentity,
|
||||
|
|
@ -41,7 +42,7 @@ import {
|
|||
readMemoryIndexMetadata,
|
||||
} from "./manager-retrieval-read.js";
|
||||
import { MemorySyncOutcomeLedger } from "./manager-sync-outcome.js";
|
||||
import { memoryTableExists, requiresMemoryVectorRebuild } from "./manager-vector-rebuild-state.js";
|
||||
import { requiresMemoryVectorRebuild } from "./manager-vector-rebuild-state.js";
|
||||
import type { MemoryCoreRuntimeHost } from "./runtime-host.js";
|
||||
import { buildMemorySourceFilter } from "./source-filter.js";
|
||||
|
||||
|
|
@ -547,7 +548,7 @@ export abstract class MemoryManagerSyncBase extends MemoryManagerDatabaseContext
|
|||
}
|
||||
|
||||
private ensureVectorTable(dimensions: number): void {
|
||||
if (this.vector.dims === dimensions && memoryTableExists(this.db, VECTOR_TABLE)) {
|
||||
if (this.vector.dims === dimensions && tableExists(this.db, VECTOR_TABLE)) {
|
||||
return;
|
||||
}
|
||||
if (!this.dropVectorTable()) {
|
||||
|
|
@ -566,7 +567,7 @@ export abstract class MemoryManagerSyncBase extends MemoryManagerDatabaseContext
|
|||
tableName: typeof VECTOR_TABLE | typeof LEGACY_VECTOR_TABLE = VECTOR_TABLE,
|
||||
): boolean {
|
||||
const legacy = tableName === LEGACY_VECTOR_TABLE;
|
||||
if (legacy && !memoryTableExists(this.db, tableName)) {
|
||||
if (legacy && !tableExists(this.db, tableName)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import { DatabaseSync } from "node:sqlite";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { MEMORY_INDEX_META_TABLE } from "openclaw/plugin-sdk/memory-core-host-engine-storage";
|
||||
import { observeHostDataSql } from "openclaw/plugin-sdk/sqlite-runtime-testing";
|
||||
import {
|
||||
admitSqliteSchema,
|
||||
openNodeSqliteDatabase,
|
||||
} from "openclaw/plugin-sdk/sqlite-worker-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { resolvePersistedMemoryVectorIndexState } from "./manager-vector-rebuild-state.js";
|
||||
|
||||
|
|
@ -7,11 +12,12 @@ describe("persisted memory vector index state", () => {
|
|||
let db: DatabaseSync;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new DatabaseSync(":memory:");
|
||||
db = openNodeSqliteDatabase(":memory:");
|
||||
db.exec(`
|
||||
CREATE TABLE ${MEMORY_INDEX_META_TABLE} (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
||||
CREATE TABLE memory_index_chunks_vec (id TEXT PRIMARY KEY);
|
||||
`);
|
||||
admitSqliteSchema(db);
|
||||
});
|
||||
|
||||
afterEach(() => db.close());
|
||||
|
|
@ -21,13 +27,23 @@ describe("persisted memory vector index state", () => {
|
|||
"memory_vector_rebuild_v1",
|
||||
);
|
||||
|
||||
expect(
|
||||
resolvePersistedMemoryVectorIndexState({
|
||||
db,
|
||||
vectorTable: "memory_index_chunks_vec",
|
||||
hasSemanticChunks: true,
|
||||
}),
|
||||
).toEqual({ state: "complete" });
|
||||
const observed = observeHostDataSql();
|
||||
try {
|
||||
const read = () =>
|
||||
resolvePersistedMemoryVectorIndexState({
|
||||
db,
|
||||
vectorTable: "memory_index_chunks_vec",
|
||||
hasSemanticChunks: true,
|
||||
});
|
||||
expect(read()).toEqual({ state: "complete" });
|
||||
db.prepare(`DELETE FROM ${MEMORY_INDEX_META_TABLE} WHERE key = ?`).run(
|
||||
"memory_vector_rebuild_v1",
|
||||
);
|
||||
expect(read()).toEqual({ state: "incomplete" });
|
||||
expect(observed.queries.filter((sql) => /sqlite_(?:schema|master)/iu.test(sql))).toEqual([]);
|
||||
} finally {
|
||||
observed.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a pre-marker vector index unverified", () => {
|
||||
|
|
|
|||
|
|
@ -2,15 +2,10 @@
|
|||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { MEMORY_INDEX_META_TABLE } from "openclaw/plugin-sdk/memory-core-host-engine-schema";
|
||||
import type { MemoryVectorIndexState } from "openclaw/plugin-sdk/memory-core-host-engine-storage";
|
||||
import { tableExists } from "openclaw/plugin-sdk/sqlite-worker-runtime";
|
||||
|
||||
const VECTOR_REBUILD_META_KEY = "memory_vector_rebuild_v1";
|
||||
|
||||
export function memoryTableExists(db: DatabaseSync, tableName: string): boolean {
|
||||
return Boolean(
|
||||
db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(tableName),
|
||||
);
|
||||
}
|
||||
|
||||
export function markMemoryVectorIndexClean(db: DatabaseSync): void {
|
||||
db.prepare(
|
||||
`INSERT INTO ${MEMORY_INDEX_META_TABLE} (key, value) VALUES (?, 'clean')
|
||||
|
|
@ -47,7 +42,7 @@ export function resolvePersistedMemoryVectorIndexState(params: {
|
|||
if (row?.value === "1") {
|
||||
return { state: "incomplete" };
|
||||
}
|
||||
if (!memoryTableExists(params.db, params.vectorTable)) {
|
||||
if (!tableExists(params.db, params.vectorTable)) {
|
||||
return params.metaVectorDims && params.hasSemanticChunks
|
||||
? { state: "incomplete" }
|
||||
: { state: "empty" };
|
||||
|
|
|
|||
|
|
@ -9,6 +9,51 @@ import { createSqliteWorkerBackend } from "./store.worker.js";
|
|||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("reopens with admitted persistent schema while creating fresh connection scratch", () => {
|
||||
const directory = tempDirs.make("team-reports-schema-admission-");
|
||||
const databasePath = path.join(directory, "reports.sqlite");
|
||||
const open = sqliteRuntime.openNodeSqliteDatabase;
|
||||
const schemaStatements: string[] = [];
|
||||
vi.spyOn(sqliteRuntime, "openNodeSqliteDatabase").mockImplementation((...args) => {
|
||||
const db = open(...args);
|
||||
const recordSchema = (sql: string) => {
|
||||
if (
|
||||
/user_version|schema_version|sqlite_schema|sqlite_master|pragma_table|team_reports_schema_migrations|CREATE\s+(?:TABLE|INDEX)/i.test(
|
||||
sql,
|
||||
)
|
||||
) {
|
||||
schemaStatements.push(sql);
|
||||
}
|
||||
};
|
||||
const prepare = db.prepare.bind(db);
|
||||
vi.spyOn(db, "prepare").mockImplementation((sql) => {
|
||||
recordSchema(sql);
|
||||
return prepare(sql);
|
||||
});
|
||||
const exec = db.exec.bind(db);
|
||||
vi.spyOn(db, "exec").mockImplementation((sql) => {
|
||||
recordSchema(sql);
|
||||
exec(sql);
|
||||
});
|
||||
return db;
|
||||
});
|
||||
for (let opened = 0; opened < 2; opened += 1) {
|
||||
schemaStatements.length = 0;
|
||||
const backend = createSqliteWorkerBackend(undefined, { databasePath });
|
||||
try {
|
||||
if (opened === 0) {
|
||||
expect(schemaStatements.length).toBeGreaterThan(0);
|
||||
} else {
|
||||
expect(schemaStatements).toEqual([]);
|
||||
}
|
||||
expect(backend.execute({ type: "listPeriods", input: {} })).toEqual([]);
|
||||
backend.execute({ type: "resetActivity", input: undefined });
|
||||
} finally {
|
||||
backend.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("aggregates across payload pages without paging metadata already retained for sorting", () => {
|
||||
const directory = tempDirs.make("team-reports-read-budget-");
|
||||
const backend = createSqliteWorkerBackend(undefined, {
|
||||
|
|
|
|||
|
|
@ -10,10 +10,13 @@ import {
|
|||
executeSqliteQuerySync,
|
||||
executeSqliteQueryTakeFirstSync,
|
||||
getNodeSqliteKysely,
|
||||
getSqliteDatabaseAdmission,
|
||||
iterateSqliteQuerySync,
|
||||
openNodeSqliteDatabase,
|
||||
publishSqliteDatabaseAdmission,
|
||||
runSqliteDeferredTransactionSync,
|
||||
runSqliteImmediateTransactionSync,
|
||||
type SqliteDatabaseAdmissionKey,
|
||||
type SqliteWorkerCommand,
|
||||
} from "openclaw/plugin-sdk/sqlite-worker-runtime";
|
||||
import { aggregateDay, aggregateDays, itemKey } from "./aggregate.js";
|
||||
|
|
@ -37,6 +40,11 @@ import type { DiscordMessage, GithubItem, Period, ReportDocument } from "./types
|
|||
|
||||
// Bound each 12-column person-day insert to 768 parameters.
|
||||
const PERSON_DAY_INSERT_BATCH_SIZE = 64;
|
||||
const schemaAdmission: SqliteDatabaseAdmissionKey<true> = {
|
||||
name: "teamReports.schema",
|
||||
schemaDependent: true,
|
||||
read: (value) => (value === true ? true : undefined),
|
||||
};
|
||||
|
||||
type PeriodRow = {
|
||||
period: Period;
|
||||
|
|
@ -606,28 +614,36 @@ function openTeamReportsDatabase(dbPath: string): TeamReportsDatabase {
|
|||
foreignKeys: true,
|
||||
synchronous: "NORMAL",
|
||||
});
|
||||
db.exec(TEAM_REPORTS_SCHEMA_SQL);
|
||||
const query = getNodeSqliteKysely<ReportsDatabase>(db);
|
||||
const migration = executeSqliteQueryTakeFirstSync(
|
||||
db,
|
||||
query.selectFrom("team_reports_schema_migrations").select("id").where("id", "=", "schema-1"),
|
||||
);
|
||||
if (!migration) {
|
||||
migrateSqliteSchemaToStrict(db, TEAM_REPORTS_SCHEMA_SQL, {
|
||||
databaseLabel: "team-reports database",
|
||||
});
|
||||
executeSqliteQuerySync(
|
||||
if (!getSqliteDatabaseAdmission(db, schemaAdmission)) {
|
||||
db.exec(TEAM_REPORTS_SCHEMA_SQL);
|
||||
const query = getNodeSqliteKysely<ReportsDatabase>(db);
|
||||
const migration = executeSqliteQueryTakeFirstSync(
|
||||
db,
|
||||
query
|
||||
.insertInto("team_reports_schema_migrations")
|
||||
.values({ id: "schema-1", applied_at: Date.now() })
|
||||
.onConflict((conflict) => conflict.column("id").doNothing()),
|
||||
.selectFrom("team_reports_schema_migrations")
|
||||
.select("id")
|
||||
.where("id", "=", "schema-1"),
|
||||
);
|
||||
if (!migration) {
|
||||
migrateSqliteSchemaToStrict(db, TEAM_REPORTS_SCHEMA_SQL, {
|
||||
databaseLabel: "team-reports database",
|
||||
});
|
||||
executeSqliteQuerySync(
|
||||
db,
|
||||
query
|
||||
.insertInto("team_reports_schema_migrations")
|
||||
.values({ id: "schema-1", applied_at: Date.now() })
|
||||
.onConflict((conflict) => conflict.column("id").doNothing()),
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const file of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`, `${dbPath}-journal`]) {
|
||||
chmodIfExists(file);
|
||||
}
|
||||
return new TeamReportsDatabase(db, maintenance);
|
||||
const database = new TeamReportsDatabase(db, maintenance);
|
||||
// This connection's TEMP activity table leaves the admitted persistent schema unchanged.
|
||||
publishSqliteDatabaseAdmission(db, schemaAdmission, true);
|
||||
return database;
|
||||
} catch (error) {
|
||||
try {
|
||||
maintenance?.close();
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ const sqliteStatements = vi.hoisted(() => ({
|
|||
count: 0,
|
||||
prepared: [] as string[],
|
||||
executed: [] as string[],
|
||||
schema: [] as string[],
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/sqlite-worker-runtime", async (importOriginal) => {
|
||||
|
|
@ -32,7 +33,22 @@ vi.mock("openclaw/plugin-sdk/sqlite-worker-runtime", async (importOriginal) => {
|
|||
openNodeSqliteDatabase: (...args: Parameters<typeof actual.openNodeSqliteDatabase>) => {
|
||||
const db = actual.openNodeSqliteDatabase(...args);
|
||||
const prepare = db.prepare.bind(db);
|
||||
const recordSchema = (sql: string) => {
|
||||
if (
|
||||
/user_version|schema_version|sqlite_schema|sqlite_master|pragma_table|PRAGMA\s+table_info|workboard_schema_migrations|CREATE\s+(?:TABLE|INDEX)/i.test(
|
||||
sql,
|
||||
)
|
||||
) {
|
||||
sqliteStatements.schema.push(sql);
|
||||
}
|
||||
};
|
||||
const exec = db.exec.bind(db);
|
||||
vi.spyOn(db, "exec").mockImplementation((sql) => {
|
||||
recordSchema(sql);
|
||||
exec(sql);
|
||||
});
|
||||
vi.spyOn(db, "prepare").mockImplementation((sql) => {
|
||||
recordSchema(sql);
|
||||
sqliteStatements.prepared.push(sql);
|
||||
const statement = prepare(sql);
|
||||
for (const method of ["all", "get", "iterate", "run"] as const) {
|
||||
|
|
@ -126,6 +142,24 @@ function withStores<T>(run: (dbPath: string) => Promise<T>): Promise<T> {
|
|||
}
|
||||
|
||||
describe("workboard sqlite batch card read", () => {
|
||||
it("reopens without repeating admitted schema installation or column checks", async () => {
|
||||
await withStores(async (dbPath) => {
|
||||
for (let opened = 0; opened < 2; opened += 1) {
|
||||
sqliteStatements.schema.length = 0;
|
||||
const stores = createKernelStores(dbPath);
|
||||
try {
|
||||
if (opened === 0) {
|
||||
expect(sqliteStatements.schema.length).toBeGreaterThan(0);
|
||||
} else {
|
||||
expect(sqliteStatements.schema).toEqual([]);
|
||||
}
|
||||
expect(await stores.cards.entries()).toEqual([]);
|
||||
} finally {
|
||||
await stores.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
it.each(["worker log", "proof"])("hydrates once when adding a %s", async (operation) => {
|
||||
await withStores(async (dbPath) => {
|
||||
const stores = createKernelStores(dbPath);
|
||||
|
|
|
|||
|
|
@ -5,11 +5,21 @@ import {
|
|||
configureSqliteConnectionPragmas,
|
||||
migrateSqliteSchemaToStrict,
|
||||
} from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import { openNodeSqliteDatabase } from "openclaw/plugin-sdk/sqlite-worker-runtime";
|
||||
import {
|
||||
getSqliteDatabaseAdmission,
|
||||
openNodeSqliteDatabase,
|
||||
publishSqliteDatabaseAdmission,
|
||||
type SqliteDatabaseAdmissionKey,
|
||||
} from "openclaw/plugin-sdk/sqlite-worker-runtime";
|
||||
const SCHEMA_VERSION = 3;
|
||||
const WORKBOARD_SQLITE_BUSY_TIMEOUT_MS = 5000;
|
||||
const WORKBOARD_SQLITE_DIR_MODE = 0o700;
|
||||
const WORKBOARD_SQLITE_FILE_MODE = 0o600;
|
||||
const schemaAdmission: SqliteDatabaseAdmissionKey<true> = {
|
||||
name: "workboard.schema",
|
||||
schemaDependent: true,
|
||||
read: (value) => (value === true ? true : undefined),
|
||||
};
|
||||
|
||||
function tableColumns(db: DatabaseSync, tableName: string): Set<string> {
|
||||
return new Set(
|
||||
|
|
@ -273,6 +283,9 @@ const WORKBOARD_SCHEMA_SQL = `
|
|||
`;
|
||||
|
||||
function ensureWorkboardSchema(db: DatabaseSync): void {
|
||||
if (getSqliteDatabaseAdmission(db, schemaAdmission)) {
|
||||
return;
|
||||
}
|
||||
db.exec(WORKBOARD_SCHEMA_SQL);
|
||||
ensureColumn(db, "workboard_boards", "automation_job_id", "automation_job_id TEXT");
|
||||
ensureColumn(db, "workboard_boards", "kind", "kind TEXT");
|
||||
|
|
@ -289,6 +302,7 @@ function ensureWorkboardSchema(db: DatabaseSync): void {
|
|||
"INSERT OR IGNORE INTO workboard_schema_migrations (id, applied_at) VALUES (?, ?)",
|
||||
).run(migrationId, Date.now());
|
||||
}
|
||||
publishSqliteDatabaseAdmission(db, schemaAdmission, true);
|
||||
}
|
||||
|
||||
function chmodIfExists(targetPath: string, mode: number): void {
|
||||
|
|
|
|||
164
extensions/workboard/src/sqlite-store.schema.test.ts
Normal file
164
extensions/workboard/src/sqlite-store.schema.test.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { resolveRuntimeWorkerUrl } from "openclaw/plugin-sdk/process-runtime";
|
||||
import { useAutoCleanupTempDirTracker } from "openclaw/plugin-sdk/test-env";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { workboardSqliteBackendEntrypoint } from "./sqlite-backend-entrypoint.test-support.js";
|
||||
import { createWorkboardSqliteStores } from "./sqlite-store.js";
|
||||
|
||||
const workerModuleUrl = resolveRuntimeWorkerUrl(workboardSqliteBackendEntrypoint);
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
|
||||
const WORKBOARD_CARD_CHILD_INDEXES = [
|
||||
["workboard_card_events", "workboard_card_events_card_idx"],
|
||||
["workboard_card_attempts", "workboard_card_attempts_card_idx"],
|
||||
["workboard_card_comments", "workboard_card_comments_card_idx"],
|
||||
["workboard_card_links", "workboard_card_links_card_idx"],
|
||||
["workboard_card_proof", "workboard_card_proof_card_idx"],
|
||||
["workboard_card_artifacts", "workboard_card_artifacts_card_idx"],
|
||||
["workboard_card_notifications", "workboard_card_notifications_card_idx"],
|
||||
["workboard_worker_logs", "workboard_worker_logs_card_idx"],
|
||||
] as const;
|
||||
|
||||
function explainWorkboardQueryPlan(
|
||||
db: DatabaseSync,
|
||||
sql: string,
|
||||
params: readonly (number | string | null)[] = [],
|
||||
): string {
|
||||
const rows = db.prepare(`EXPLAIN QUERY PLAN ${sql}`).all(...params) as Array<{
|
||||
detail?: unknown;
|
||||
}>;
|
||||
return rows
|
||||
.map((row) => (typeof row.detail === "string" ? row.detail : JSON.stringify(row.detail ?? "")))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
describe("Workboard SQLite schema admission", () => {
|
||||
it("restores dropped card child indexes without changing the schema version", async () => {
|
||||
const dir = tempDirs.make("openclaw-workboard-index-reopen-");
|
||||
const dbPath = path.join(dir, "workboard.sqlite");
|
||||
const initialized = createWorkboardSqliteStores({ dbPath, workerModuleUrl });
|
||||
await initialized.ready;
|
||||
await initialized.close();
|
||||
// Build the old format on a physical file this process has not admitted.
|
||||
fs.renameSync(dbPath, `${dbPath}.seed`);
|
||||
fs.copyFileSync(`${dbPath}.seed`, dbPath);
|
||||
const db = new DatabaseSync(dbPath);
|
||||
let initialMigrationIds: Array<{ id: string }>;
|
||||
try {
|
||||
initialMigrationIds = db
|
||||
.prepare("SELECT id FROM workboard_schema_migrations ORDER BY id")
|
||||
.all() as Array<{ id: string }>;
|
||||
for (const [, index] of WORKBOARD_CARD_CHILD_INDEXES) {
|
||||
db.exec(`DROP INDEX ${index}`);
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
|
||||
const reopened = createWorkboardSqliteStores({ dbPath, workerModuleUrl });
|
||||
await reopened.ready;
|
||||
await reopened.close();
|
||||
const verified = new DatabaseSync(dbPath, { readOnly: true });
|
||||
try {
|
||||
const indexes = new Set(
|
||||
(
|
||||
verified.prepare("SELECT name FROM sqlite_schema WHERE type = 'index'").all() as Array<{
|
||||
name: string;
|
||||
}>
|
||||
).map((row) => row.name),
|
||||
);
|
||||
for (const [table, index] of WORKBOARD_CARD_CHILD_INDEXES) {
|
||||
expect(indexes).toContain(index);
|
||||
const plan = explainWorkboardQueryPlan(
|
||||
verified,
|
||||
`SELECT * FROM ${table} WHERE card_id = ? ORDER BY ordinal ASC`,
|
||||
["card-1"],
|
||||
);
|
||||
expect(plan).toContain(index);
|
||||
expect(plan).not.toContain("USE TEMP B-TREE FOR ORDER BY");
|
||||
}
|
||||
expect(
|
||||
verified.prepare("SELECT id FROM workboard_schema_migrations ORDER BY id").all(),
|
||||
).toEqual(initialMigrationIds);
|
||||
} finally {
|
||||
verified.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("migrates a version 2 workboard table to STRICT without losing rows", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-workboard-strict-migration-"));
|
||||
const dbPath = path.join(dir, "workboard.sqlite");
|
||||
const initialized = createWorkboardSqliteStores({ dbPath, workerModuleUrl });
|
||||
await initialized.ready;
|
||||
await initialized.close();
|
||||
// Build the old format on a physical file this process has not admitted.
|
||||
fs.renameSync(dbPath, `${dbPath}.seed`);
|
||||
fs.copyFileSync(`${dbPath}.seed`, dbPath);
|
||||
const legacy = new DatabaseSync(dbPath);
|
||||
try {
|
||||
legacy.exec(`
|
||||
INSERT INTO workboard_boards (
|
||||
id, name, description, icon, color, default_workspace_json, orchestration_json,
|
||||
created_at, updated_at, archived_at
|
||||
) VALUES ('legacy', 'Legacy board', NULL, NULL, NULL, NULL, NULL, 1, 2, NULL);
|
||||
ALTER TABLE workboard_boards RENAME TO workboard_boards_strict;
|
||||
CREATE TABLE workboard_boards (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
description TEXT,
|
||||
icon TEXT,
|
||||
color TEXT,
|
||||
default_workspace_json TEXT,
|
||||
orchestration_json TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
archived_at INTEGER
|
||||
);
|
||||
INSERT INTO workboard_boards (
|
||||
id, name, description, icon, color, default_workspace_json, orchestration_json,
|
||||
created_at, updated_at, archived_at
|
||||
) SELECT
|
||||
id, name, description, icon, color, default_workspace_json, orchestration_json,
|
||||
created_at, updated_at, archived_at
|
||||
FROM workboard_boards_strict;
|
||||
DROP TABLE workboard_boards_strict;
|
||||
DELETE FROM workboard_schema_migrations WHERE id = 'schema-3';
|
||||
INSERT OR IGNORE INTO workboard_schema_migrations (id, applied_at)
|
||||
VALUES ('schema-2', 1);
|
||||
`);
|
||||
} finally {
|
||||
legacy.close();
|
||||
}
|
||||
|
||||
try {
|
||||
const migratedStores = createWorkboardSqliteStores({ dbPath, workerModuleUrl });
|
||||
try {
|
||||
await expect(migratedStores.boards.lookup("legacy")).resolves.toMatchObject({
|
||||
board: { id: "legacy", name: "Legacy board" },
|
||||
});
|
||||
} finally {
|
||||
await migratedStores.close();
|
||||
}
|
||||
const migrated = new DatabaseSync(dbPath, { readOnly: true });
|
||||
try {
|
||||
expect(
|
||||
migrated
|
||||
.prepare("SELECT strict FROM pragma_table_list WHERE name = 'workboard_boards'")
|
||||
.get(),
|
||||
).toEqual({ strict: 1 });
|
||||
expect(
|
||||
migrated
|
||||
.prepare("SELECT 1 AS found FROM workboard_schema_migrations WHERE id = 'schema-3'")
|
||||
.get(),
|
||||
).toEqual({ found: 1 });
|
||||
} finally {
|
||||
migrated.close();
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { copyFileSync, renameSync } from "node:fs";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
|
@ -258,6 +259,9 @@ describe("Sessions board schema reopening", () => {
|
|||
});
|
||||
await store.close();
|
||||
|
||||
// Model the prior schema's first admission, rather than altering admitted format facts.
|
||||
renameSync(dbPath, `${dbPath}.seed`);
|
||||
copyFileSync(`${dbPath}.seed`, dbPath);
|
||||
{
|
||||
using prior = new DatabaseSync(dbPath);
|
||||
// These are the only schema additions for Sessions boards; preserve the existing migration receipt.
|
||||
|
|
|
|||
|
|
@ -198,32 +198,8 @@ function statfsFixture(type: number): ReturnType<typeof fs.statfsSync> {
|
|||
};
|
||||
}
|
||||
|
||||
const WORKBOARD_CARD_CHILD_INDEXES = [
|
||||
["workboard_card_events", "workboard_card_events_card_idx"],
|
||||
["workboard_card_attempts", "workboard_card_attempts_card_idx"],
|
||||
["workboard_card_comments", "workboard_card_comments_card_idx"],
|
||||
["workboard_card_links", "workboard_card_links_card_idx"],
|
||||
["workboard_card_proof", "workboard_card_proof_card_idx"],
|
||||
["workboard_card_artifacts", "workboard_card_artifacts_card_idx"],
|
||||
["workboard_card_notifications", "workboard_card_notifications_card_idx"],
|
||||
["workboard_worker_logs", "workboard_worker_logs_card_idx"],
|
||||
] as const;
|
||||
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
|
||||
function explainWorkboardQueryPlan(
|
||||
db: DatabaseSync,
|
||||
sql: string,
|
||||
params: readonly (number | string | null)[] = [],
|
||||
): string {
|
||||
const rows = db.prepare(`EXPLAIN QUERY PLAN ${sql}`).all(...params) as Array<{
|
||||
detail?: unknown;
|
||||
}>;
|
||||
return rows
|
||||
.map((row) => (typeof row.detail === "string" ? row.detail : JSON.stringify(row.detail ?? "")))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
describe("WorkboardStore", () => {
|
||||
it("emits monotonic committed changes, ignores no-ops, and isolates listener failures", async () => {
|
||||
const store = createWorkboardSqliteTestStore({ createStores: createKernelStores });
|
||||
|
|
@ -609,55 +585,6 @@ describe("WorkboardStore", () => {
|
|||
expect((await first.list()).filter((card) => card.status === "running")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("restores dropped card child indexes without changing the schema version", async () => {
|
||||
const dir = tempDirs.make("openclaw-workboard-index-reopen-");
|
||||
const dbPath = path.join(dir, "workboard.sqlite");
|
||||
const initialized = createWorkboardSqliteStores({ dbPath, workerModuleUrl });
|
||||
await initialized.ready;
|
||||
await initialized.close();
|
||||
const db = new DatabaseSync(dbPath);
|
||||
let initialMigrationIds: Array<{ id: string }>;
|
||||
try {
|
||||
initialMigrationIds = db
|
||||
.prepare("SELECT id FROM workboard_schema_migrations ORDER BY id")
|
||||
.all() as Array<{ id: string }>;
|
||||
for (const [, index] of WORKBOARD_CARD_CHILD_INDEXES) {
|
||||
db.exec(`DROP INDEX ${index}`);
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
|
||||
const reopened = createWorkboardSqliteStores({ dbPath, workerModuleUrl });
|
||||
await reopened.ready;
|
||||
await reopened.close();
|
||||
const verified = new DatabaseSync(dbPath, { readOnly: true });
|
||||
try {
|
||||
const indexes = new Set(
|
||||
(
|
||||
verified.prepare("SELECT name FROM sqlite_schema WHERE type = 'index'").all() as Array<{
|
||||
name: string;
|
||||
}>
|
||||
).map((row) => row.name),
|
||||
);
|
||||
for (const [table, index] of WORKBOARD_CARD_CHILD_INDEXES) {
|
||||
expect(indexes).toContain(index);
|
||||
const plan = explainWorkboardQueryPlan(
|
||||
verified,
|
||||
`SELECT * FROM ${table} WHERE card_id = ? ORDER BY ordinal ASC`,
|
||||
["card-1"],
|
||||
);
|
||||
expect(plan).toContain(index);
|
||||
expect(plan).not.toContain("USE TEMP B-TREE FOR ORDER BY");
|
||||
}
|
||||
expect(
|
||||
verified.prepare("SELECT id FROM workboard_schema_migrations ORDER BY id").all(),
|
||||
).toEqual(initialMigrationIds);
|
||||
} finally {
|
||||
verified.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("persists boards, cards, subscriptions, and attachment blobs in sqlite", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-workboard-sqlite-"));
|
||||
const dbPath = path.join(dir, "workboard.sqlite");
|
||||
|
|
@ -864,77 +791,6 @@ describe("WorkboardStore", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("migrates a version 2 workboard table to STRICT without losing rows", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-workboard-strict-migration-"));
|
||||
const dbPath = path.join(dir, "workboard.sqlite");
|
||||
const initialized = createWorkboardSqliteStores({ dbPath, workerModuleUrl });
|
||||
await initialized.ready;
|
||||
await initialized.close();
|
||||
const legacy = new DatabaseSync(dbPath);
|
||||
try {
|
||||
legacy.exec(`
|
||||
INSERT INTO workboard_boards (
|
||||
id, name, description, icon, color, default_workspace_json, orchestration_json,
|
||||
created_at, updated_at, archived_at
|
||||
) VALUES ('legacy', 'Legacy board', NULL, NULL, NULL, NULL, NULL, 1, 2, NULL);
|
||||
ALTER TABLE workboard_boards RENAME TO workboard_boards_strict;
|
||||
CREATE TABLE workboard_boards (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
description TEXT,
|
||||
icon TEXT,
|
||||
color TEXT,
|
||||
default_workspace_json TEXT,
|
||||
orchestration_json TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
archived_at INTEGER
|
||||
);
|
||||
INSERT INTO workboard_boards (
|
||||
id, name, description, icon, color, default_workspace_json, orchestration_json,
|
||||
created_at, updated_at, archived_at
|
||||
) SELECT
|
||||
id, name, description, icon, color, default_workspace_json, orchestration_json,
|
||||
created_at, updated_at, archived_at
|
||||
FROM workboard_boards_strict;
|
||||
DROP TABLE workboard_boards_strict;
|
||||
DELETE FROM workboard_schema_migrations WHERE id = 'schema-3';
|
||||
INSERT OR IGNORE INTO workboard_schema_migrations (id, applied_at)
|
||||
VALUES ('schema-2', 1);
|
||||
`);
|
||||
} finally {
|
||||
legacy.close();
|
||||
}
|
||||
|
||||
try {
|
||||
const migratedStores = createWorkboardSqliteStores({ dbPath, workerModuleUrl });
|
||||
try {
|
||||
await expect(migratedStores.boards.lookup("legacy")).resolves.toMatchObject({
|
||||
board: { id: "legacy", name: "Legacy board" },
|
||||
});
|
||||
} finally {
|
||||
await migratedStores.close();
|
||||
}
|
||||
const migrated = new DatabaseSync(dbPath, { readOnly: true });
|
||||
try {
|
||||
expect(
|
||||
migrated
|
||||
.prepare("SELECT strict FROM pragma_table_list WHERE name = 'workboard_boards'")
|
||||
.get(),
|
||||
).toEqual({ strict: 1 });
|
||||
expect(
|
||||
migrated
|
||||
.prepare("SELECT 1 AS found FROM workboard_schema_migrations WHERE id = 'schema-3'")
|
||||
.get(),
|
||||
).toEqual({ found: 1 });
|
||||
} finally {
|
||||
migrated.close();
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("uses rollback journaling on network-backed volumes", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-workboard-sqlite-network-"));
|
||||
const dbPath = path.join(dir, "workboard.sqlite");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,195 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { openNodeSqliteDatabase } from "../../../../src/infra/node-sqlite.js";
|
||||
import { revokeSqliteDatabaseAdmissions } from "../../../../src/infra/sqlite-database-admission.js";
|
||||
import { admitSqliteSchema } from "../../../../src/infra/sqlite-schema-facts.js";
|
||||
import { runSqliteImmediateTransactionSync } from "../../../../src/infra/sqlite-transaction.js";
|
||||
import { observeHostDataSql } from "../../../../test/helpers/sqlite-statement-execution-counter.js";
|
||||
import { useAutoCleanupTempDirTracker } from "../../../../test/helpers/temp-dir.js";
|
||||
import { ensureMemoryIndexSchema } from "./memory-schema.js";
|
||||
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
|
||||
function schemaStatements(queries: string[]): string[] {
|
||||
return queries.filter((sql) =>
|
||||
/sqlite_(?:schema|master)|pragma_(?:table|index|foreign_key)|\bPRAGMA\s+(?:main\.)?(?:user_version|schema_version|integrity_check|quick_check|foreign_key_check|table_info|table_xinfo|index_list|index_info|index_xinfo)\b|\b(?:CREATE|ALTER|DROP)\s+(?:TABLE|INDEX|TRIGGER|VIRTUAL)\b/iu.test(
|
||||
sql,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
describe("memory physical schema admission", () => {
|
||||
it.each(["commit", "rollback", "later DDL", "revocation"])(
|
||||
"publishes only final committed initialization after %s",
|
||||
(outcome) => {
|
||||
const filename = path.join(tempDirs.make("memory-admission-transaction-"), "memory.sqlite");
|
||||
using db = openNodeSqliteDatabase(filename);
|
||||
admitSqliteSchema(db);
|
||||
using peer = openNodeSqliteDatabase(filename);
|
||||
const initialize = () =>
|
||||
ensureMemoryIndexSchema({ db, cacheEnabled: true, ftsEnabled: true });
|
||||
const transact = () =>
|
||||
runSqliteImmediateTransactionSync(db, () => {
|
||||
initialize();
|
||||
if (outcome === "rollback") {
|
||||
throw new Error("synthetic initialization rollback");
|
||||
}
|
||||
if (outcome === "later DDL") {
|
||||
db.exec("DROP TABLE memory_index_sources");
|
||||
}
|
||||
if (outcome === "revocation") {
|
||||
revokeSqliteDatabaseAdmissions(peer);
|
||||
}
|
||||
});
|
||||
if (outcome === "rollback") {
|
||||
expect(transact).toThrow("synthetic initialization rollback");
|
||||
} else {
|
||||
transact();
|
||||
}
|
||||
const observed = observeHostDataSql();
|
||||
try {
|
||||
expect(initialize()).toEqual({ ftsAvailable: true });
|
||||
if (outcome === "commit") {
|
||||
expect(schemaStatements(observed.queries)).toEqual([]);
|
||||
} else {
|
||||
expect(schemaStatements(observed.queries).length).toBeGreaterThan(0);
|
||||
}
|
||||
expect(db.prepare("SELECT path FROM memory_index_sources").all()).toEqual([]);
|
||||
} finally {
|
||||
observed.restore();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("reuses admission across handles while backfilling newly imported rows", () => {
|
||||
const filename = path.join(tempDirs.make("memory-admission-"), "memory.sqlite");
|
||||
const observed = observeHostDataSql();
|
||||
try {
|
||||
for (let opening = 0; opening < 2; opening++) {
|
||||
using db = openNodeSqliteDatabase(filename);
|
||||
observed.queries.length = 0;
|
||||
expect(ensureMemoryIndexSchema({ db, cacheEnabled: true, ftsEnabled: true })).toEqual({
|
||||
ftsAvailable: true,
|
||||
});
|
||||
if (opening === 0) {
|
||||
expect(schemaStatements(observed.queries).length).toBeGreaterThan(0);
|
||||
} else {
|
||||
expect(schemaStatements(observed.queries)).toEqual([]);
|
||||
}
|
||||
db.prepare(`INSERT INTO memory_index_chunks
|
||||
(id, path, source, start_line, end_line, hash, model, text, embedding, updated_at)
|
||||
VALUES (?, 'notes.md', 'memory', 1, 1, 'hash', 'model', 'body', X'', 17)
|
||||
`).run(`import-${opening}`);
|
||||
db.exec(`
|
||||
INSERT OR IGNORE INTO memory_index_sources (path, source, hash, mtime, size)
|
||||
VALUES ('notes.md', 'memory', 'hash', 1, 1);
|
||||
DELETE FROM memory_index_chunks_fts;
|
||||
DELETE FROM memory_index_paths_fts;
|
||||
`);
|
||||
observed.queries.length = 0;
|
||||
ensureMemoryIndexSchema({ db, cacheEnabled: true, ftsEnabled: true });
|
||||
expect(schemaStatements(observed.queries)).toEqual([]);
|
||||
expect(
|
||||
db
|
||||
.prepare("SELECT origin_class FROM memory_index_chunk_provenance WHERE chunk_id = ?")
|
||||
.get(`import-${opening}`),
|
||||
).toEqual({ origin_class: "untrusted" });
|
||||
expect(
|
||||
db
|
||||
.prepare(
|
||||
"SELECT id FROM memory_index_chunks_fts WHERE memory_index_chunks_fts MATCH 'body'",
|
||||
)
|
||||
.all(),
|
||||
).toHaveLength(opening + 1);
|
||||
expect(db.prepare("SELECT path FROM memory_index_paths_fts").all()).toEqual([
|
||||
{ path: "notes.md" },
|
||||
]);
|
||||
}
|
||||
} finally {
|
||||
observed.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("retries transient FTS initialization failure on a later handle", () => {
|
||||
const filename = path.join(tempDirs.make("memory-admission-retry-"), "memory.sqlite");
|
||||
let failFts = true;
|
||||
const observed = observeHostDataSql((sql) => {
|
||||
if (failFts && /\bCREATE\s+VIRTUAL\s+TABLE\b/iu.test(sql)) {
|
||||
failFts = false;
|
||||
throw Object.assign(new Error("database is locked"), { errcode: 5 });
|
||||
}
|
||||
});
|
||||
try {
|
||||
{
|
||||
using db = openNodeSqliteDatabase(filename);
|
||||
expect(ensureMemoryIndexSchema({ db, cacheEnabled: true, ftsEnabled: true })).toEqual({
|
||||
ftsAvailable: false,
|
||||
ftsError: expect.stringContaining("database is locked"),
|
||||
});
|
||||
db.exec(`INSERT INTO memory_index_chunks
|
||||
(id, path, source, start_line, end_line, hash, model, text, embedding, updated_at)
|
||||
VALUES ('retry', 'notes.md', 'memory', 1, 1, 'hash', 'model', 'recovered body', X'', 17)
|
||||
`);
|
||||
}
|
||||
using db = openNodeSqliteDatabase(filename);
|
||||
expect(ensureMemoryIndexSchema({ db, cacheEnabled: true, ftsEnabled: true })).toEqual({
|
||||
ftsAvailable: true,
|
||||
});
|
||||
expect(
|
||||
db
|
||||
.prepare(
|
||||
"SELECT id FROM memory_index_chunks_fts WHERE memory_index_chunks_fts MATCH 'recovered'",
|
||||
)
|
||||
.all(),
|
||||
).toEqual([{ id: "retry" }]);
|
||||
observed.queries.length = 0;
|
||||
expect(ensureMemoryIndexSchema({ db, cacheEnabled: true, ftsEnabled: true })).toEqual({
|
||||
ftsAvailable: true,
|
||||
});
|
||||
expect(schemaStatements(observed.queries)).toEqual([]);
|
||||
} finally {
|
||||
observed.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("admits changed options and rejects schema drift in a replacement file", () => {
|
||||
const filename = path.join(tempDirs.make("memory-admission-replace-"), "memory.sqlite");
|
||||
{
|
||||
using db = openNodeSqliteDatabase(filename);
|
||||
expect(ensureMemoryIndexSchema({ db, cacheEnabled: false, ftsEnabled: false })).toEqual({
|
||||
ftsAvailable: false,
|
||||
});
|
||||
expect(ensureMemoryIndexSchema({ db, cacheEnabled: true, ftsEnabled: true })).toEqual({
|
||||
ftsAvailable: true,
|
||||
});
|
||||
expect(ensureMemoryIndexSchema({ db, cacheEnabled: false, ftsEnabled: false })).toEqual({
|
||||
ftsAvailable: false,
|
||||
});
|
||||
const observed = observeHostDataSql();
|
||||
try {
|
||||
expect(ensureMemoryIndexSchema({ db, cacheEnabled: false, ftsEnabled: false })).toEqual({
|
||||
ftsAvailable: false,
|
||||
});
|
||||
expect(schemaStatements(observed.queries)).toEqual([]);
|
||||
} finally {
|
||||
observed.restore();
|
||||
}
|
||||
expect(
|
||||
db.prepare("SELECT name FROM sqlite_schema WHERE name = 'memory_index_chunks_fts'").get(),
|
||||
).toBeUndefined();
|
||||
}
|
||||
const replacement = `${filename}.replacement`;
|
||||
fs.copyFileSync(filename, replacement);
|
||||
{
|
||||
using db = new DatabaseSync(replacement);
|
||||
db.exec("ALTER TABLE memory_index_sources ADD COLUMN unexpected TEXT");
|
||||
}
|
||||
fs.renameSync(replacement, filename);
|
||||
using db = openNodeSqliteDatabase(filename);
|
||||
expect(() => ensureMemoryIndexSchema({ db, cacheEnabled: false, ftsEnabled: false })).toThrow(
|
||||
"canonical memory source identity schema is invalid",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -180,6 +180,33 @@ export function rebuildMemoryChunkFts(db: DatabaseSync, ftsTable: string): void
|
|||
`);
|
||||
}
|
||||
|
||||
export function reconcileMemoryChunkFtsRows(
|
||||
db: DatabaseSync,
|
||||
ftsTable: string,
|
||||
force = false,
|
||||
): void {
|
||||
const rowCounts = db
|
||||
.prepare(
|
||||
`SELECT
|
||||
(SELECT COUNT(*) FROM ${MEMORY_INDEX_CHUNKS_TABLE}) AS canonical_count,
|
||||
(SELECT COUNT(*) FROM ${ftsTable}) AS derived_count`,
|
||||
)
|
||||
// SAFETY: both scalar aggregate subqueries always return their named numeric columns.
|
||||
.get() as { canonical_count: number; derived_count: number };
|
||||
if (force || rowCounts.canonical_count !== rowCounts.derived_count) {
|
||||
rebuildMemoryChunkFts(db, ftsTable);
|
||||
}
|
||||
}
|
||||
|
||||
export function backfillMemoryPathFtsRows(db: DatabaseSync): void {
|
||||
db.exec(`
|
||||
INSERT INTO ${MEMORY_INDEX_PATHS_FTS_TABLE} (rowid, path, source)
|
||||
SELECT id, path, source
|
||||
FROM ${MEMORY_INDEX_SOURCES_TABLE}
|
||||
WHERE NOT EXISTS (SELECT 1 FROM ${MEMORY_INDEX_PATHS_FTS_TABLE} LIMIT 1);
|
||||
`);
|
||||
}
|
||||
|
||||
/** Reconcile and backfill the derived body index in one atomic savepoint. */
|
||||
export function ensureMemoryChunkFtsSchema(params: {
|
||||
db: DatabaseSync;
|
||||
|
|
@ -211,14 +238,6 @@ export function ensureMemoryChunkFtsSchema(params: {
|
|||
` end_line UNINDEXED\n` +
|
||||
`${params.tokenizeClause});`,
|
||||
);
|
||||
const rowCounts = params.db
|
||||
.prepare(
|
||||
`SELECT
|
||||
(SELECT COUNT(*) FROM ${MEMORY_INDEX_CHUNKS_TABLE}) AS canonical_count,
|
||||
(SELECT COUNT(*) FROM ${params.ftsTable}) AS derived_count`,
|
||||
)
|
||||
// SAFETY: both scalar aggregate subqueries always return their named numeric columns.
|
||||
.get() as { canonical_count: number; derived_count: number };
|
||||
// FTS is fully derived. A cardinality mismatch proves that an ordinary
|
||||
// schema ensure cannot leave the populated index untouched.
|
||||
const canonical = params.ftsTable === MEMORY_INDEX_FTS_TABLE;
|
||||
|
|
@ -231,9 +250,7 @@ export function ensureMemoryChunkFtsSchema(params: {
|
|||
);
|
||||
// Pre-rowid indexes can have matching counts but unrelated FTS identities.
|
||||
// Installing their maintenance contract owns the one-time identity rebuild.
|
||||
if (!hasRowidMaintenance || rowCounts.canonical_count !== rowCounts.derived_count) {
|
||||
rebuildMemoryChunkFts(params.db, params.ftsTable);
|
||||
}
|
||||
reconcileMemoryChunkFtsRows(params.db, params.ftsTable, !hasRowidMaintenance);
|
||||
if (canonical) {
|
||||
ensureMemoryChunkFtsTriggers(params.db);
|
||||
}
|
||||
|
|
@ -295,13 +312,10 @@ export function ensureMemoryPathFtsSchema(params: {
|
|||
source UNINDEXED
|
||||
${params.tokenizeClause}
|
||||
);
|
||||
-- The initial copy and trigger installation share this savepoint. Once
|
||||
-- populated, the triggers own completeness; per-row FTS probes are too costly.
|
||||
INSERT INTO ${MEMORY_INDEX_PATHS_FTS_TABLE} (rowid, path, source)
|
||||
SELECT id, path, source
|
||||
FROM ${MEMORY_INDEX_SOURCES_TABLE}
|
||||
WHERE NOT EXISTS (SELECT 1 FROM ${MEMORY_INDEX_PATHS_FTS_TABLE} LIMIT 1);
|
||||
`);
|
||||
// The initial copy and trigger installation share this savepoint. Once
|
||||
// populated, the triggers own completeness; per-row FTS probes are too costly.
|
||||
backfillMemoryPathFtsRows(params.db);
|
||||
ensureMemoryPathFtsTriggers(params.db);
|
||||
params.db.exec("RELEASE ensure_memory_index_paths_fts");
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,18 @@ export function ensureMemoryChunkProvenance(db: DatabaseSync): void {
|
|||
// legacy/imported rows without changing the canonical chunk table.
|
||||
db.exec("DROP TRIGGER IF EXISTS memory_index_chunk_provenance_after_insert");
|
||||
db.exec(MEMORY_INDEX_CHUNK_PROVENANCE_SCHEMA_SQL);
|
||||
backfillMemoryChunkProvenance(db);
|
||||
};
|
||||
if (db.isTransaction) {
|
||||
ensure();
|
||||
return;
|
||||
}
|
||||
runSqliteImmediateTransactionSync(db, ensure);
|
||||
}
|
||||
|
||||
/** Imported rows still need provenance after their physical schema has been admitted. */
|
||||
export function backfillMemoryChunkProvenance(db: DatabaseSync): void {
|
||||
const backfill = () => {
|
||||
// Materialize indexed missing IDs before touching wide chunk payloads.
|
||||
// CROSS JOIN keeps that empty-set check ahead of the chunk-table lookup.
|
||||
db.exec(`
|
||||
|
|
@ -58,8 +70,8 @@ export function ensureMemoryChunkProvenance(db: DatabaseSync): void {
|
|||
`);
|
||||
};
|
||||
if (db.isTransaction) {
|
||||
ensure();
|
||||
backfill();
|
||||
return;
|
||||
}
|
||||
runSqliteImmediateTransactionSync(db, ensure);
|
||||
runSqliteImmediateTransactionSync(db, backfill);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import os from "node:os";
|
|||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { openNodeSqliteDatabase } from "../../../../src/infra/node-sqlite.js";
|
||||
import { useAutoCleanupTempDirTracker } from "../../../../test/helpers/temp-dir.js";
|
||||
import { encodeMemoryEmbedding } from "./embedding-vector.js";
|
||||
import { ensureMemoryRecallMetadataSchema } from "./memory-schema-recall.js";
|
||||
|
|
@ -17,7 +18,7 @@ describe("memory index schema", () => {
|
|||
(kind) => {
|
||||
const databasePath = path.join(tempDirs.make("memory-schema-index-"), "memory.sqlite");
|
||||
if (kind === "existing") {
|
||||
using seed = new DatabaseSync(databasePath);
|
||||
using seed = openNodeSqliteDatabase(databasePath);
|
||||
ensureMemoryIndexSchema({ db: seed, cacheEnabled: false, ftsEnabled: false });
|
||||
seed.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_memory_index_chunks_path ON memory_index_chunks(path);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
MEMORY_INDEX_STATE_TABLE,
|
||||
} from "./memory-schema-base.js";
|
||||
import {
|
||||
backfillMemoryPathFtsRows,
|
||||
dropDisabledMemoryFts,
|
||||
dropMemoryChunkFtsTriggers,
|
||||
dropMemoryPathFtsTriggers,
|
||||
|
|
@ -16,14 +17,19 @@ import {
|
|||
MEMORY_INDEX_FTS_TABLE,
|
||||
MEMORY_INDEX_PATHS_FTS_TABLE,
|
||||
MEMORY_INDEX_SOURCES_TABLE,
|
||||
reconcileMemoryChunkFtsRows,
|
||||
} from "./memory-schema-fts.js";
|
||||
import * as provenanceSchema from "./memory-schema-provenance.js";
|
||||
import { ensureMemoryRecallMetadataSchema } from "./memory-schema-recall.js";
|
||||
import { migrateMemoryIndexStorage } from "./memory-schema-storage-migration.js";
|
||||
import {
|
||||
canReuseSqliteSchemaInTransaction,
|
||||
getSqliteDatabaseAdmission,
|
||||
migrateSqliteSchemaToStrict,
|
||||
migrateSqliteSchemaToStrictInTransaction,
|
||||
publishSqliteDatabaseAdmission,
|
||||
runSqliteImmediateTransactionSync,
|
||||
type SqliteDatabaseAdmissionKey,
|
||||
} from "./openclaw-runtime-sqlite.js";
|
||||
export {
|
||||
markInvalidImportedMemoryEmbeddings,
|
||||
|
|
@ -83,6 +89,20 @@ type TableColumnInfo = {
|
|||
hidden: number;
|
||||
};
|
||||
|
||||
type MemoryIndexSchemaAdmission = { ftsAvailable: boolean };
|
||||
|
||||
function readMemoryIndexSchemaAdmission(value: unknown): MemoryIndexSchemaAdmission | undefined {
|
||||
if (
|
||||
typeof value !== "object" ||
|
||||
value === null ||
|
||||
!("ftsAvailable" in value) ||
|
||||
typeof value.ftsAvailable !== "boolean"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return { ftsAvailable: value.ftsAvailable };
|
||||
}
|
||||
|
||||
function tableColumnInfo(db: DatabaseSync, tableName: string): TableColumnInfo[] {
|
||||
const rows = db.prepare(`PRAGMA main.table_xinfo(${tableName})`).all() as Array<{
|
||||
name?: unknown;
|
||||
|
|
@ -321,6 +341,46 @@ export function ensureMemoryIndexSchema(params: {
|
|||
ftsEnabled: boolean;
|
||||
ftsTokenizer?: "unicode61" | "trigram";
|
||||
}): { ftsAvailable: boolean; ftsError?: string } {
|
||||
const embeddingCacheTable = params.embeddingCacheTable ?? MEMORY_EMBEDDING_CACHE_TABLE;
|
||||
const ftsTable = params.ftsTable ?? MEMORY_INDEX_FTS_TABLE;
|
||||
const tokenizer = params.ftsTokenizer ?? "unicode61";
|
||||
const admissionKey: SqliteDatabaseAdmissionKey<MemoryIndexSchemaAdmission> = {
|
||||
name: `memory.index-schema:${JSON.stringify([
|
||||
embeddingCacheTable,
|
||||
params.cacheEnabled,
|
||||
ftsTable,
|
||||
params.ftsEnabled,
|
||||
tokenizer,
|
||||
])}`,
|
||||
schemaDependent: true,
|
||||
read: readMemoryIndexSchemaAdmission,
|
||||
};
|
||||
const admitted = getSqliteDatabaseAdmission(params.db, admissionKey);
|
||||
if (admitted) {
|
||||
provenanceSchema.backfillMemoryChunkProvenance(params.db);
|
||||
if (admitted.ftsAvailable) {
|
||||
try {
|
||||
const reconcile = () => {
|
||||
reconcileMemoryChunkFtsRows(params.db, ftsTable);
|
||||
if (ftsTable === MEMORY_INDEX_FTS_TABLE) {
|
||||
backfillMemoryPathFtsRows(params.db);
|
||||
}
|
||||
};
|
||||
if (params.db.isTransaction) {
|
||||
reconcile();
|
||||
} else {
|
||||
runSqliteImmediateTransactionSync(params.db, reconcile);
|
||||
}
|
||||
} catch (err) {
|
||||
if (ftsTable === MEMORY_INDEX_FTS_TABLE) {
|
||||
dropMemoryChunkFtsTriggers(params.db);
|
||||
dropMemoryPathFtsTriggers(params.db);
|
||||
}
|
||||
return { ftsAvailable: false, ftsError: formatErrorMessage(err) };
|
||||
}
|
||||
}
|
||||
return admitted;
|
||||
}
|
||||
if (
|
||||
tableHasExactColumns(params.db, "meta", ["key", "value"]) &&
|
||||
tableHasExactColumns(params.db, "files", ["path", "source", "hash", "mtime", "size"]) &&
|
||||
|
|
@ -341,8 +401,6 @@ export function ensureMemoryIndexSchema(params: {
|
|||
"Retired memory index format detected. Preserve a complete copy of your state and configuration, then use OpenClaw 2026.9.7 to migrate a compatible copy of this index before retrying the upgrade.",
|
||||
);
|
||||
}
|
||||
const embeddingCacheTable = params.embeddingCacheTable ?? MEMORY_EMBEDDING_CACHE_TABLE;
|
||||
const ftsTable = params.ftsTable ?? MEMORY_INDEX_FTS_TABLE;
|
||||
params.db.exec(
|
||||
buildMemoryIndexStrictSchema({
|
||||
embeddingCacheTable,
|
||||
|
|
@ -405,7 +463,6 @@ export function ensureMemoryIndexSchema(params: {
|
|||
let ftsError: string | undefined;
|
||||
if (params.ftsEnabled) {
|
||||
try {
|
||||
const tokenizer = params.ftsTokenizer ?? "unicode61";
|
||||
const tokenizeClause = tokenizer === "trigram" ? `, tokenize='trigram case_sensitive 0'` : "";
|
||||
ensureMemoryChunkFtsSchema({ db: params.db, ftsTable, tokenizeClause });
|
||||
// Deprecated custom FTS tables preserve their body-only contract. The
|
||||
|
|
@ -423,5 +480,8 @@ export function ensureMemoryIndexSchema(params: {
|
|||
}
|
||||
}
|
||||
|
||||
if (!params.ftsEnabled || ftsAvailable) {
|
||||
publishSqliteDatabaseAdmission(params.db, admissionKey, { ftsAvailable });
|
||||
}
|
||||
return { ftsAvailable, ...(ftsError ? { ftsError } : {}) };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,4 +12,9 @@ export {
|
|||
type SqliteTableContractReader,
|
||||
} from "../../../../src/infra/sqlite-schema-contract.js";
|
||||
export { canReuseSqliteSchemaInTransaction } from "../../../../src/infra/sqlite-schema-facts.js";
|
||||
export {
|
||||
getSqliteDatabaseAdmission,
|
||||
publishSqliteDatabaseAdmission,
|
||||
type SqliteDatabaseAdmissionKey,
|
||||
} from "../../../../src/infra/sqlite-database-admission.js";
|
||||
export { runSqliteImmediateTransactionSync } from "../../../../src/infra/sqlite-transaction.js";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { WorkerOptions } from "node:worker_threads";
|
||||
import type { Transferable, WorkerOptions } from "node:worker_threads";
|
||||
import type { RetainedOperation } from "./retained-operation.js";
|
||||
import type { WorkerLifecycle, RetainedNativeWorker } from "./worker-lifecycle.js";
|
||||
import type { WorkerComputeCapacity } from "./worker-task-capacity.js";
|
||||
|
|
@ -32,7 +32,8 @@ export type WorkerTaskHost = {
|
|||
serviceNativeWorkers(workers: readonly RetainedNativeWorker[]): void;
|
||||
prepareResources(): Promise<unknown>;
|
||||
releaseTemporaryDirectory(directory: string): Promise<void>;
|
||||
captureTaskContext(): unknown;
|
||||
captureTaskContext(worker: WorkerLifecycle): unknown;
|
||||
taskContextTransferList?(context: unknown): readonly Transferable[];
|
||||
createTaskObserver?(url: URL): (operation?: string) => WorkerTaskObservation;
|
||||
receiveMessage(worker: WorkerLifecycle, message: unknown): boolean;
|
||||
workerStarted(worker: WorkerLifecycle, pool: object): void;
|
||||
|
|
|
|||
|
|
@ -66,13 +66,14 @@ export function sendWorkerTaskInput<Input, Output>(params: {
|
|||
}
|
||||
const transferList = task.options.transferList?.(input);
|
||||
if (!task.done) {
|
||||
const context = params.host.captureTaskContext(worker);
|
||||
postWorkerTaskInput(
|
||||
worker,
|
||||
slot,
|
||||
task,
|
||||
input,
|
||||
transferList,
|
||||
params.host.captureTaskContext(),
|
||||
[...(transferList ?? []), ...(params.host.taskContextTransferList?.(context) ?? [])],
|
||||
context,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -997,6 +997,7 @@ src/infra/secure-random.ts
|
|||
src/infra/semver.ts
|
||||
src/infra/session-cost-usage-cache-migration.ts
|
||||
src/infra/session-cost-usage-rollup-codec.ts
|
||||
src/infra/session-cost-usage-worker.types.ts
|
||||
src/infra/shell-env.ts
|
||||
src/infra/shell-inline-command.ts
|
||||
src/infra/shell-wrapper-resolution.ts
|
||||
|
|
@ -1007,6 +1008,10 @@ src/infra/sqlite-audit-record.kernel.ts
|
|||
src/infra/sqlite-backup.ts
|
||||
src/infra/sqlite-busy-timeout.ts
|
||||
src/infra/sqlite-commit-receipt.ts
|
||||
src/infra/sqlite-database-admission-key.ts
|
||||
src/infra/sqlite-database-admission-record.ts
|
||||
src/infra/sqlite-database-admission-turn.ts
|
||||
src/infra/sqlite-database-admission.ts
|
||||
src/infra/sqlite-error-diagnostics.ts
|
||||
src/infra/sqlite-existing-database.ts
|
||||
src/infra/sqlite-file-copy.ts
|
||||
|
|
@ -1022,6 +1027,7 @@ src/infra/sqlite-jsonl-budget.ts
|
|||
src/infra/sqlite-live-snapshot.ts
|
||||
src/infra/sqlite-lifecycle-errors.ts
|
||||
src/infra/sqlite-maintenance-cache.ts
|
||||
src/infra/sqlite-native-admission.ts
|
||||
src/infra/sqlite-native-observer.ts
|
||||
src/infra/sqlite-number.ts
|
||||
src/infra/sqlite-page-cache-residency.ts
|
||||
|
|
@ -1047,6 +1053,7 @@ src/infra/sqlite-recovery-files.ts
|
|||
src/infra/sqlite-recovery-snapshot.ts
|
||||
src/infra/sqlite-rollback-recovery.ts
|
||||
src/infra/sqlite-runtime-version.ts
|
||||
src/infra/sqlite-schema-admission.ts
|
||||
src/infra/sqlite-schema-contract-assembly.ts
|
||||
src/infra/sqlite-schema-contract.ts
|
||||
src/infra/sqlite-schema-ensure.ts
|
||||
|
|
@ -1054,6 +1061,7 @@ src/infra/sqlite-schema-facts.ts
|
|||
src/infra/sqlite-schema-header.ts
|
||||
src/infra/sqlite-schema-issues.ts
|
||||
src/infra/sqlite-schema-mutation.ts
|
||||
src/infra/sqlite-schema-scope.ts
|
||||
src/infra/sqlite-schema-sql.ts
|
||||
src/infra/sqlite-snapshot-file.ts
|
||||
src/infra/sqlite-snapshot-policy.ts
|
||||
|
|
@ -1093,6 +1101,7 @@ src/infra/sqlite-worker-client.ts
|
|||
src/infra/sqlite-worker-contract.ts
|
||||
src/infra/sqlite-worker-identity.ts
|
||||
src/infra/sqlite-worker-operation-admission.ts
|
||||
src/infra/sqlite-worker-operation-settlement.ts
|
||||
src/infra/sqlite-worker-request-class.ts
|
||||
src/infra/sqlite-worker-state-context.ts
|
||||
src/infra/sqlite-worker-store.ts
|
||||
|
|
@ -1159,6 +1168,7 @@ src/infra/windows-port-pids.ts
|
|||
src/infra/windows-private-directory.ts
|
||||
src/infra/windows-process-census.ts
|
||||
src/infra/windows-process-start.ts
|
||||
src/infra/worker-ancestry.ts
|
||||
src/infra/worker-cpu.ts
|
||||
src/infra/worker-diagnostic-script.ts
|
||||
src/infra/worker-idle-gc.ts
|
||||
|
|
@ -1677,6 +1687,7 @@ src/state/openclaw-agent-db-session-provenance.ts
|
|||
src/state/openclaw-agent-db-terminal.ts
|
||||
src/state/openclaw-agent-db-transaction.ts
|
||||
src/state/openclaw-agent-db-validation-cache.ts
|
||||
src/state/openclaw-agent-db-validation-facts.ts
|
||||
src/state/openclaw-agent-db.paths.ts
|
||||
src/state/openclaw-agent-db.ts
|
||||
src/state/openclaw-agent-db.wal.ts
|
||||
|
|
@ -1720,6 +1731,7 @@ src/state/openclaw-database-verify.ts
|
|||
src/state/openclaw-quarantine-error.ts
|
||||
src/state/openclaw-quarantine-store.ts
|
||||
src/state/openclaw-state-db-additive-columns.ts
|
||||
src/state/openclaw-state-db-admission.ts
|
||||
src/state/openclaw-state-db-async-lifecycle.ts
|
||||
src/state/openclaw-state-db-audit-migration.ts
|
||||
src/state/openclaw-state-db-borrow.ts
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import { copyFileSync, renameSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { describe, expect, it, vi, type MockInstance } from "vitest";
|
||||
import { observeSqliteReadSql } from "../../test/helpers/sqlite-statement-execution-counter.js";
|
||||
import * as nodeSqlite from "../infra/node-sqlite.js";
|
||||
import { OPENCLAW_AGENT_SCHEMA_VERSION } from "../state/openclaw-agent-db-contract.js";
|
||||
import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js";
|
||||
import { loadPersistedAuthProfileStore } from "./auth-profiles/persisted.js";
|
||||
import {
|
||||
clearRuntimeAuthProfileStoreSnapshots,
|
||||
replaceRuntimeAuthProfileStoreSnapshots,
|
||||
} from "./auth-profiles/runtime-snapshots.js";
|
||||
import { acquireAuthProfileReadDatabase } from "./auth-profiles/sqlite-read-pool.js";
|
||||
import {
|
||||
closeAuthProfileReadPool,
|
||||
resolveAuthProfileDatabasePath,
|
||||
|
|
@ -51,6 +55,39 @@ function withReaders(
|
|||
}
|
||||
|
||||
describe("auth profile sqlite reader lifecycle", () => {
|
||||
it("reuses format admission across pool reopen and validates a replacement file", async () => {
|
||||
await withReaders((agentDir) => {
|
||||
const pathname = resolveAuthProfileDatabasePath(agentDir);
|
||||
const observed = observeSqliteReadSql(nodeSqlite.requireNodeSqlite().StatementSync.prototype);
|
||||
const formatQueries = () =>
|
||||
observed.queries.filter((sql) =>
|
||||
/sqlite_(?:schema|master)|\bPRAGMA\s+(?:user_version|schema_version|table_info|table_xinfo|index_list|index_info|index_xinfo)\b/iu.test(
|
||||
sql,
|
||||
),
|
||||
);
|
||||
try {
|
||||
expect(acquireAuthProfileReadDatabase(pathname).status).toBe("readable");
|
||||
closeAuthProfileReadPool({ kind: "database", databasePath: pathname });
|
||||
observed.queries.length = 0;
|
||||
expect(acquireAuthProfileReadDatabase(pathname).status).toBe("readable");
|
||||
expect(formatQueries()).toEqual([]);
|
||||
closeAuthProfileReadPool({ kind: "database", databasePath: pathname });
|
||||
const replacement = `${pathname}.replacement`;
|
||||
copyFileSync(pathname, replacement);
|
||||
{
|
||||
using database = new DatabaseSync(replacement);
|
||||
database.exec(`PRAGMA user_version=${OPENCLAW_AGENT_SCHEMA_VERSION + 1}`);
|
||||
}
|
||||
renameSync(replacement, pathname);
|
||||
observed.queries.length = 0;
|
||||
expect(acquireAuthProfileReadDatabase(pathname).status).toBe("unreadable");
|
||||
expect(formatQueries().length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
observed.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("observes foreign commits through cached readers and closes them on snapshot replacement", async () => {
|
||||
await withReaders((agentDir, open) => {
|
||||
expect(loadPersistedAuthProfileStore(agentDir)).toMatchObject(apiKeyStore("qa-main"));
|
||||
|
|
|
|||
|
|
@ -306,7 +306,7 @@ describe("auth profile sqlite store", () => {
|
|||
const inspection = vi
|
||||
.spyOn(databaseIdentity, "inspectDatabasePathIdentitySync")
|
||||
.mockImplementation((pathname) => {
|
||||
if (path.resolve(pathname) === path.resolve(sourcePath)) {
|
||||
if (!sourceInspectionFailed && path.resolve(pathname) === path.resolve(sourcePath)) {
|
||||
sourceInspectionFailed = true;
|
||||
throw Object.assign(new Error("permission denied"), { code: "EACCES" });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
enableNodeSqliteKyselyStatementCache,
|
||||
} from "../../infra/kysely-sync.js";
|
||||
import { isPathInside } from "../../infra/path-guards.js";
|
||||
import { admitSqliteSchema } from "../../infra/sqlite-schema-facts.js";
|
||||
import { openSqliteReadOnlyDatabase } from "../../infra/sqlite-snapshot-source.js";
|
||||
import { readSqliteUserVersion } from "../../infra/sqlite-user-version.js";
|
||||
import {
|
||||
|
|
@ -159,8 +160,16 @@ export function acquireAuthProfileReadDatabase(
|
|||
let readable = false;
|
||||
try {
|
||||
enableNodeSqliteKyselyStatementCache(db);
|
||||
// The pooled reader bypasses canonical bootstrap and validates its schema on open.
|
||||
readable = readSqliteUserVersion(db) <= OPENCLAW_AGENT_SCHEMA_VERSION;
|
||||
if (inspection) {
|
||||
readable = readSqliteUserVersion(db) <= OPENCLAW_AGENT_SCHEMA_VERSION;
|
||||
} else {
|
||||
admitSqliteSchema(db, (version) => {
|
||||
if (version > OPENCLAW_AGENT_SCHEMA_VERSION) {
|
||||
throw new Error("Auth profile database uses a newer agent schema");
|
||||
}
|
||||
});
|
||||
readable = true;
|
||||
}
|
||||
} catch {
|
||||
// Invalid readers are disposed below, where native close failures propagate.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -167,10 +167,16 @@ it.each(["schema", "owner"] as const)(
|
|||
new DatabaseSync(target.storePath).close();
|
||||
} else {
|
||||
await upsertSessionEntryCore(target, { sessionId: target.sessionId, updatedAt: 1 });
|
||||
openOpenClawAgentDatabase({ agentId: "main", path: target.storePath }).db.exec(
|
||||
"UPDATE schema_meta SET agent_id = 'different' WHERE meta_key = 'primary'",
|
||||
);
|
||||
await closeOpenClawAgentDatabaseByPathAsync(target.storePath);
|
||||
fs.renameSync(target.storePath, `${target.storePath}.template`);
|
||||
fs.copyFileSync(
|
||||
`${target.storePath}.template`,
|
||||
target.storePath,
|
||||
fs.constants.COPYFILE_EXCL,
|
||||
);
|
||||
const database = new DatabaseSync(target.storePath);
|
||||
database.exec("UPDATE schema_meta SET agent_id = 'different' WHERE meta_key = 'primary'");
|
||||
database.close();
|
||||
}
|
||||
await expect(
|
||||
loadCliSessionContextEngineMessages({ sessionTarget: target }),
|
||||
|
|
|
|||
|
|
@ -49,7 +49,10 @@ describe("Gateway catalog worker agent database readers", () => {
|
|||
try {
|
||||
expect(() => leaveWalMode(deleted!)).toThrow(/locked/);
|
||||
expect(() => leaveWalMode(survivor!)).toThrow(/locked/);
|
||||
await closeDeletedAgentDatabases(fixture.agentIds[0]!, [deleted!]);
|
||||
await closeDeletedAgentDatabases(fixture.agentIds[0]!, [deleted!], {
|
||||
assertCurrentFinal() {},
|
||||
async assertCurrentAsync() {},
|
||||
});
|
||||
|
||||
expect(leaveWalMode(deleted!)).toBe("delete");
|
||||
const survivorProfileId = `fleet:${fixture.agentIds[1]!}`;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { getRuntimeConfig } from "../../config/config.js";
|
|||
import { setRuntimeConfigSnapshot } from "../../config/runtime-snapshot.js";
|
||||
import { snapshotFiles } from "../../infra/state-migrations.caller-mode.test-helpers.js";
|
||||
import { autoMigrateLegacyState } from "../../infra/state-migrations.doctor.js";
|
||||
import { inspectLegacyAgentDir } from "../../infra/state-migrations.legacy-sessions.js";
|
||||
import type { Model } from "../../llm/types.js";
|
||||
import { EMPTY_LEGACY_SESSION_SURFACES } from "../../plugins/legacy-session-surfaces.types.js";
|
||||
import {
|
||||
|
|
@ -76,6 +77,8 @@ it("keeps a legacy SDK store when Doctor's configured owner differs", async () =
|
|||
status: "owned",
|
||||
agentId: "main",
|
||||
});
|
||||
// Ownership may use admitted facts; payload inspection materializes native WAL sidecars.
|
||||
expect(inspectLegacyAgentDir(legacyDir)).toEqual({ status: "payload" });
|
||||
const before = snapshotFiles(legacyDir);
|
||||
const cfg = { agents: { entries: { worker: {} } }, plugins: { enabled: false } };
|
||||
await state.writeConfig(cfg);
|
||||
|
|
|
|||
|
|
@ -115,6 +115,8 @@ describe("subagent registry sqlite store", () => {
|
|||
saveSubagentRegistryToSqlite(new Map([[legacy.runId, legacy]]));
|
||||
const original = openOpenClawStateDatabase();
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
await fs.rename(original.path, `${original.path}.template`);
|
||||
await fs.copyFile(`${original.path}.template`, original.path, fs.constants.COPYFILE_EXCL);
|
||||
const old = new DatabaseSync(original.path);
|
||||
try {
|
||||
for (const column of ["requester_store_path", "controller_store_path"]) {
|
||||
|
|
|
|||
|
|
@ -296,7 +296,10 @@ describe("managed worktree registry", () => {
|
|||
|
||||
it("adds the provisioned-path ledger to an existing worktree registry", async () => {
|
||||
const databasePath = openOpenClawStateDatabase({ env }).path;
|
||||
await closeOpenClawStateDatabaseAsync();
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
await fs.rename(databasePath, `${databasePath}.template`);
|
||||
await fs.copyFile(`${databasePath}.template`, databasePath, fs.constants.COPYFILE_EXCL);
|
||||
const { DatabaseSync } = requireNodeSqlite();
|
||||
const legacy = new DatabaseSync(databasePath);
|
||||
legacy.exec(`
|
||||
|
|
|
|||
|
|
@ -224,7 +224,7 @@ describe("ManagedWorktreeService capacity", () => {
|
|||
const stat = fsSync.statSync;
|
||||
vi.spyOn(fsSync, "statSync").mockImplementation((...args) => {
|
||||
const result = stat(...args);
|
||||
if (result) {
|
||||
if (result?.isDirectory()) {
|
||||
result.dev = isData(args[0]) ? 2 : 1;
|
||||
}
|
||||
return result;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
|
||||
|
|
@ -51,6 +52,8 @@ describe("worktree template registry", () => {
|
|||
const database = openOpenClawStateDatabase({ env });
|
||||
const databasePath = database.path;
|
||||
await closeStateDatabaseForTest();
|
||||
await fs.rename(databasePath, `${databasePath}.template`);
|
||||
await fs.copyFile(`${databasePath}.template`, databasePath, fs.constants.COPYFILE_EXCL);
|
||||
const { DatabaseSync } = requireNodeSqlite();
|
||||
const older = new DatabaseSync(databasePath);
|
||||
older.exec("DROP TABLE IF EXISTS worktree_templates");
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { existsSync } from "node:fs";
|
||||
import { constants, copyFileSync, existsSync, renameSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { BoardWidgetMaterializedPutParams } from "../../packages/gateway-protocol/src/index.js";
|
||||
|
|
@ -805,6 +805,8 @@ describe("SqliteBoardStore persistence", () => {
|
|||
await closeOpenClawStateDatabaseAsync();
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
|
||||
renameSync(databasePath, `${databasePath}.template`);
|
||||
copyFileSync(`${databasePath}.template`, databasePath, constants.COPYFILE_EXCL);
|
||||
const { DatabaseSync } = requireNodeSqlite();
|
||||
const existingV14 = new DatabaseSync(databasePath);
|
||||
restoreEmptyV21StorageForHistoricalFixture(existingV14);
|
||||
|
|
|
|||
|
|
@ -4,12 +4,12 @@ import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
|
|||
import { replaceSessionEntrySync } from "../config/sessions/session-accessor.entry.js";
|
||||
import { composeSessionSourceAssertion } from "../config/sessions/session-source-authority.js";
|
||||
import {
|
||||
failSqliteWorkerSlot,
|
||||
receiveSqliteWorkerReply,
|
||||
settleFailedSqliteWorkerJobs,
|
||||
settleSqliteWorkerJob,
|
||||
withSqliteWorkerCleanupFailure,
|
||||
} from "../infra/sqlite-worker-broker-reply.js";
|
||||
import type { Job } from "../infra/sqlite-worker-broker.types.js";
|
||||
import type { Actor, Job } from "../infra/sqlite-worker-broker.types.js";
|
||||
import {
|
||||
isSqliteWorkerError,
|
||||
SqliteWorkerError,
|
||||
|
|
@ -112,9 +112,11 @@ async function receiveExecutedFailure(retire: boolean) {
|
|||
},
|
||||
detach() {},
|
||||
};
|
||||
const slot: Parameters<typeof receiveSqliteWorkerReply>[0] = {
|
||||
actors: new Set(),
|
||||
const slot: Parameters<typeof receiveSqliteWorkerReply>[0] &
|
||||
Parameters<typeof failSqliteWorkerSlot>[0] = {
|
||||
actors: new Set<Actor>(),
|
||||
current: job,
|
||||
queue: [],
|
||||
worker: {
|
||||
postMessage() {
|
||||
throw new Error("A failure reply must not request another result frame");
|
||||
|
|
@ -136,20 +138,11 @@ async function receiveExecutedFailure(retire: boolean) {
|
|||
},
|
||||
},
|
||||
{
|
||||
fail(reason, currentError, openOutcome) {
|
||||
if (!(reason instanceof Error)) {
|
||||
throw new Error("Expected a decoded worker error");
|
||||
}
|
||||
const current = slot.current;
|
||||
slot.current = undefined;
|
||||
slot.failed = new SqliteWorkerError(reason.message, "unavailable");
|
||||
settleFailedSqliteWorkerJobs({
|
||||
queuedError: slot.failed,
|
||||
current,
|
||||
queued: [],
|
||||
error: reason,
|
||||
fail(reason, currentError, openOutcome, completed) {
|
||||
failSqliteWorkerSlot(slot, reason, {
|
||||
currentError,
|
||||
openOutcome,
|
||||
completed,
|
||||
retire: async () => {
|
||||
events.push("retired");
|
||||
},
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { createConfigIO } from "../../config/io.js";
|
|||
import { asResolvedSourceConfig, asRuntimeConfig } from "../../config/materialize.js";
|
||||
import { appendTranscriptEventsInTransaction } from "../../config/sessions/session-accessor.sqlite-transcript-store.js";
|
||||
import { readDaemonRuntimePin } from "../../daemon/runtime-pin-state.js";
|
||||
import { openNodeSqliteDatabase } from "../../infra/node-sqlite.js";
|
||||
import {
|
||||
createPackageIntegrityReader,
|
||||
type PackageLauncherFingerprint,
|
||||
|
|
@ -551,7 +552,8 @@ it.each([
|
|||
const originalRuntimePin = original
|
||||
? readDaemonRuntimePin({ kind: "gateway", env }, { programArguments: [] })
|
||||
: undefined;
|
||||
const migrated = new DatabaseSync(database.path);
|
||||
// The migration owner publishes its committed schema to the already-running updater.
|
||||
const migrated = openNodeSqliteDatabase(database.path);
|
||||
try {
|
||||
migrated.exec(`
|
||||
BEGIN IMMEDIATE;
|
||||
|
|
|
|||
|
|
@ -254,7 +254,7 @@ it("drains the local cache and excludes reopening throughout awaited removal", a
|
|||
]),
|
||||
).toBe("removing");
|
||||
expect(database.db.isOpen).toBe(false);
|
||||
expect(() => retainedStatement.get()).toThrow(/finalized/);
|
||||
expect(() => retainedStatement.get()).toThrow(/finalized|database is not open/);
|
||||
const before = fs.statSync(database.path, { bigint: true });
|
||||
expect(() => openOpenClawStateDatabase(options)).toThrow("offline maintenance");
|
||||
expect(fs.statSync(database.path, { bigint: true })).toEqual(before);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { writeTextAtomic } from "../infra/json-files.js";
|
|||
import { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-sync.js";
|
||||
import { isSqliteCorruptionError } from "../infra/sqlite-error-diagnostics.js";
|
||||
import { SQLITE_SIDECAR_SUFFIXES } from "../infra/sqlite-files.js";
|
||||
import { invalidateSqliteSchemaFacts } from "../infra/sqlite-schema-facts.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import { hasSqliteFileFamily } from "../state/agent-deletion-discovery.js";
|
||||
import {
|
||||
|
|
@ -33,6 +34,8 @@ import { isReservedSystemAgentId } from "../system-agent/agent-id.js";
|
|||
type JournalDatabase = Pick<OpenClawStateDatabase, "db" | "path">;
|
||||
|
||||
function readRecoverySource(database: JournalDatabase) {
|
||||
// Recovery must inspect the damaged catalog instead of the runtime's admitted shape.
|
||||
invalidateSqliteSchemaFacts(database.db);
|
||||
const held = tableExists(database.db, "migration_sources")
|
||||
? readAgentDeletionRecoveryHolds(database)
|
||||
: [];
|
||||
|
|
|
|||
|
|
@ -115,15 +115,18 @@ describe("container image replacement Doctor repair and startup readiness", () =
|
|||
await withContainerState(async (stateDir) => {
|
||||
const databasePath = seedSchema19Agent(stateDir);
|
||||
const original = fs.readFileSync(databasePath);
|
||||
await withAgentDatabaseStartupAdmission(async () => {
|
||||
await expect(runStartupConfigPreflight({ gateway: true })).rejects.toMatchObject({
|
||||
code: 78,
|
||||
});
|
||||
expect(fs.readFileSync(databasePath)).toEqual(original);
|
||||
await repairContainerState();
|
||||
await runStartupConfigPreflight({ gateway: true });
|
||||
expect(listAgentDatabaseAdmissionRefusals()).toEqual([]);
|
||||
});
|
||||
await withAgentDatabaseStartupAdmission(
|
||||
async () => {
|
||||
await expect(runStartupConfigPreflight({ gateway: true })).rejects.toMatchObject({
|
||||
code: 78,
|
||||
});
|
||||
expect(fs.readFileSync(databasePath)).toEqual(original);
|
||||
await repairContainerState();
|
||||
await runStartupConfigPreflight({ gateway: true });
|
||||
expect(listAgentDatabaseAdmissionRefusals()).toEqual([]);
|
||||
},
|
||||
{ deferInspections: false },
|
||||
);
|
||||
const backups = fs
|
||||
.readdirSync(path.dirname(databasePath))
|
||||
.filter((name) => name.startsWith(`${path.basename(databasePath)}.pre-startup-migration-`));
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@ import path from "node:path";
|
|||
import { DatabaseSync } from "node:sqlite";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { createSystemdCommandQuery } from "../daemon/systemd-command-query.js";
|
||||
import * as integrity from "../infra/sqlite-integrity.js";
|
||||
import * as snapshots from "../infra/sqlite-snapshot-source.js";
|
||||
import { readUpdateRunDriver } from "../infra/update-run-driver.js";
|
||||
import { createUpdateRun, recordUpdateRunRepairContinuation } from "../infra/update-run-ledger.js";
|
||||
import * as ledger from "../infra/update-run-ledger.js";
|
||||
import { recordOpenClawDatabaseQuarantine } from "../state/openclaw-quarantine-store.js";
|
||||
import { createOpenClawDatabaseMaintenanceScope } from "../state/openclaw-state-db-async-lifecycle.js";
|
||||
import {
|
||||
|
|
@ -28,7 +28,7 @@ function maintenanceScope(admission: () => void) {
|
|||
});
|
||||
}
|
||||
|
||||
it("admits fast systemd inspection after slow continuation integrity validation", async () => {
|
||||
it("admits fast systemd inspection after a slow continuation write", async () => {
|
||||
const { env, assertIsolation } = fixture();
|
||||
const run = createUpdateRun(
|
||||
{ trigger: "cli", origin: { driver: readUpdateRunDriver() } },
|
||||
|
|
@ -36,9 +36,9 @@ it("admits fast systemd inspection after slow continuation integrity validation"
|
|||
);
|
||||
let elapsed = 0;
|
||||
vi.spyOn(performance, "now").mockImplementation(() => elapsed);
|
||||
const validate = integrity.assertSqliteIntegrity;
|
||||
vi.spyOn(integrity, "assertSqliteIntegrity").mockImplementation((...args) => {
|
||||
const result = validate(...args);
|
||||
const recordContinuation = ledger.recordUpdateRunRepairContinuation;
|
||||
vi.spyOn(ledger, "recordUpdateRunRepairContinuation").mockImplementation((...args) => {
|
||||
const result = recordContinuation(...args);
|
||||
elapsed += 6_000;
|
||||
return result;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
|||
import fs from "node:fs";
|
||||
import { hostname } from "node:os";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, expect, it, vi } from "vitest";
|
||||
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
|
||||
import * as restartHealthProbe from "../cli/daemon-cli/restart-health-probe.js";
|
||||
|
|
@ -232,7 +233,8 @@ async function runDoctorFinishForStoppedUnit(
|
|||
}
|
||||
const pathname = openOpenClawStateDatabase().path;
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
const db = openNodeSqliteDatabase(pathname);
|
||||
// Damaged legacy bytes are input to Doctor, not a managed schema publication.
|
||||
const db = new DatabaseSync(pathname);
|
||||
try {
|
||||
db.exec(
|
||||
"CREATE TABLE IF NOT EXISTS skill_workshop_collection_reviews (review_id TEXT NOT NULL PRIMARY KEY, owner_agent_id TEXT NOT NULL, backup_id TEXT NOT NULL, create_time INTEGER NOT NULL, kept_names_json TEXT NOT NULL, written_names_json TEXT NOT NULL, dropped_json TEXT NOT NULL) STRICT; CREATE INDEX idx_skill_workshop_collection_reviews_workspace_time ON skill_workshop_collection_reviews(review_id, create_time DESC);",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import * as replaceFile from "@openclaw/fs-safe/atomic";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { SessionManager } from "../agents/sessions/session-manager.js";
|
||||
|
|
@ -547,6 +548,9 @@ describe("runDoctorSessionSqlite", () => {
|
|||
const databaseBefore = fs.readFileSync(sqlitePath);
|
||||
expect(fs.existsSync(`${sqlitePath}-wal`)).toBe(false);
|
||||
expect(fs.existsSync(`${sqlitePath}-shm`)).toBe(false);
|
||||
// Keep real source sidecars present independently of cached owner inspection.
|
||||
using sourceReader = new DatabaseSync(sqlitePath, { readOnly: true });
|
||||
sourceReader.prepare("SELECT count(*) FROM sqlite_schema").get();
|
||||
const preview = inspectSessionSqliteRecovery({ cfg: {}, env: store.env });
|
||||
expect(preview.artifacts.find((item) => item.path === originalMove.archivePath)?.outcome).toBe(
|
||||
"candidate",
|
||||
|
|
@ -556,7 +560,6 @@ describe("runDoctorSessionSqlite", () => {
|
|||
preview,
|
||||
readConfig: async () => ({}),
|
||||
confirm: async () => {
|
||||
// The actual read-only owner inspection creates these sidecars before confirmation.
|
||||
expect(fs.existsSync(`${sqlitePath}-wal`)).toBe(true);
|
||||
expect(fs.existsSync(`${sqlitePath}-shm`)).toBe(true);
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -2,11 +2,13 @@ import { spawnSync } from "node:child_process";
|
|||
import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
|
||||
import { loadPersistedAuthProfileStore } from "../agents/auth-profiles/persisted.js";
|
||||
import { openNodeSqliteDatabase } from "../infra/node-sqlite.js";
|
||||
import { readSqliteReaderDiagnosticsForPath } from "../infra/sqlite-reader-lifecycle.js";
|
||||
import { admitSqliteSchema } from "../infra/sqlite-schema-facts.js";
|
||||
import { isSqlitePathOnBtrfs, setSqliteDirectoryNoCow } from "../infra/sqlite-wal-filesystem.js";
|
||||
import { openOpenClawAgentDatabase } from "../state/openclaw-agent-db.js";
|
||||
import { openOpenClawStateDatabase } from "../state/openclaw-state-db.js";
|
||||
|
|
@ -79,7 +81,7 @@ async function repair(assertCurrent = () => {}) {
|
|||
|
||||
describe("Doctor btrfs NOCOW", () => {
|
||||
it.skipIf(process.platform !== "linux")(
|
||||
"rewrites shared and agent stores after the real Doctor maintenance scope drains",
|
||||
"rewrites shared and agent stores after Doctor drains native and admission descriptors",
|
||||
async () => {
|
||||
await withOpenClawTestState(
|
||||
{ scenario: "external-service", label: "doctor-nocow-owner" },
|
||||
|
|
@ -167,6 +169,55 @@ describe("Doctor btrfs NOCOW", () => {
|
|||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform !== "linux")(
|
||||
"keeps admission descriptors when an unknown native handle blocks NOCOW repair",
|
||||
async () => {
|
||||
seedDatabase();
|
||||
const admitted = openNodeSqliteDatabase(sqlitePath);
|
||||
try {
|
||||
admitSqliteSchema(admitted);
|
||||
} finally {
|
||||
admitted.close();
|
||||
}
|
||||
const unknown = new DatabaseSync(sqlitePath);
|
||||
unknown.exec("BEGIN IMMEDIATE");
|
||||
const identity = fs.statSync(sqlitePath);
|
||||
const sourceDescriptors = () =>
|
||||
fs
|
||||
.readdirSync("/proc/self/fd")
|
||||
.flatMap((entry) => {
|
||||
try {
|
||||
const descriptor = Number(entry);
|
||||
const file = fs.fstatSync(descriptor);
|
||||
return file.dev === identity.dev && file.ino === identity.ino ? [descriptor] : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})
|
||||
.toSorted((left, right) => left - right);
|
||||
const descriptors = sourceDescriptors();
|
||||
expect(descriptors.length).toBeGreaterThanOrEqual(2);
|
||||
const native =
|
||||
await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
||||
const fakeTools = vi.mocked(spawnSync).getMockImplementation()!;
|
||||
vi.mocked(spawnSync).mockImplementation((command, args, options) =>
|
||||
command === "fuser"
|
||||
? native.spawnSync(command, args, options)
|
||||
: fakeTools(command, args, options),
|
||||
);
|
||||
try {
|
||||
expect((await repair()).join("\n")).toContain("store files are open");
|
||||
expect(sourceDescriptors()).toEqual(descriptors);
|
||||
expect(unknown.isTransaction).toBe(true);
|
||||
expect(fs.statSync(sqlitePath).ino).toBe(identity.ino);
|
||||
expect(fixture.exchanges).toBe(0);
|
||||
} finally {
|
||||
unknown.exec("ROLLBACK");
|
||||
unknown.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("reports existing CoW stores, skips other filesystems and explains missing tooling", () => {
|
||||
fs.writeFileSync(sqlitePath, "fixture");
|
||||
expect(inspectDoctorSqliteNoCow([sqlitePath]).paths).toEqual([sqlitePath]);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { loadSqliteVecExtension } from "../../packages/memory-host-sdk/src/host/
|
|||
import { requireDirectorySync, syncDirectory } from "../infra/directory-durability.js";
|
||||
import { copyFileHandle, sameFileMutationFingerprint } from "../infra/file-descriptor.js";
|
||||
import { openNodeSqliteDatabase } from "../infra/node-sqlite.js";
|
||||
import { retireSqliteDatabaseAdmissionForPath } from "../infra/sqlite-database-admission.js";
|
||||
import { assertSqliteIntegrity } from "../infra/sqlite-integrity.js";
|
||||
import { createVerifiedSqliteSnapshot } from "../infra/sqlite-snapshot.js";
|
||||
import { isSqlitePathOnBtrfs, setSqliteDirectoryNoCow } from "../infra/sqlite-wal-filesystem.js";
|
||||
|
|
@ -307,6 +308,10 @@ export async function repairDoctorSqliteNoCow(params: {
|
|||
.filter((pathname) => !sourceLinks.has(pathname))
|
||||
.map((pathname) => [pathname, readAcl(pathname)]),
|
||||
);
|
||||
for (const pathname of sqlitePaths) {
|
||||
params.assertCurrent();
|
||||
retireSqliteDatabaseAdmissionForPath(pathname, { requireSoleDescriptor: true });
|
||||
}
|
||||
// fuser follows links, so inspect only regular files owned by this tree.
|
||||
assertNoOpenFiles(regularPaths);
|
||||
const size = [...inventory.values()].reduce((total, stat) => total + stat.size, 0n);
|
||||
|
|
@ -401,6 +406,10 @@ export async function repairDoctorSqliteNoCow(params: {
|
|||
const observedFiles = readStoreEntries(directory).map((entry) =>
|
||||
path.join(entry.parentPath, entry.name),
|
||||
);
|
||||
for (const pathname of sqlitePaths) {
|
||||
params.assertCurrent();
|
||||
retireSqliteDatabaseAdmissionForPath(pathname, { requireSoleDescriptor: true });
|
||||
}
|
||||
assertNoOpenFiles(observedFiles.filter((pathname) => fs.lstatSync(pathname).isFile()));
|
||||
// A file can appear while fuser checks the previously observed inventory.
|
||||
const currentIdentity = fs.statSync(directory, { bigint: true });
|
||||
|
|
|
|||
|
|
@ -200,7 +200,7 @@ function loadConfiguredAgentAuthStores(
|
|||
const expectedAgentIds = expectedAgentIdsByDir.get(mainAgentDir);
|
||||
const owner =
|
||||
availability === "present"
|
||||
? inspectOpenClawAgentDatabaseOwner(sharedDatabasePath)
|
||||
? inspectOpenClawAgentDatabaseOwner(sharedDatabasePath, { revalidateSchema: true })
|
||||
: undefined;
|
||||
if (
|
||||
availability === "unreadable" ||
|
||||
|
|
@ -237,7 +237,9 @@ function loadConfiguredAgentAuthStores(
|
|||
return { status: "blocked", warnings: [INVALID_SQLITE_STORE_WARNING] };
|
||||
}
|
||||
const owner =
|
||||
availability === "present" ? inspectOpenClawAgentDatabaseOwner(databasePath) : undefined;
|
||||
availability === "present"
|
||||
? inspectOpenClawAgentDatabaseOwner(databasePath, { revalidateSchema: true })
|
||||
: undefined;
|
||||
if (
|
||||
owner?.status === "unreadable" ||
|
||||
(expectedAgentIds && owner?.status === "owned" && !expectedAgentIds.has(owner.agentId))
|
||||
|
|
@ -297,7 +299,7 @@ function loadConfiguredAgentAuthStores(
|
|||
if (availability === "unreadable") {
|
||||
return { status: "blocked", warnings: [INVALID_SQLITE_STORE_WARNING] };
|
||||
}
|
||||
const owner = inspectOpenClawAgentDatabaseOwner(databasePath);
|
||||
const owner = inspectOpenClawAgentDatabaseOwner(databasePath, { revalidateSchema: true });
|
||||
if (owner.status !== "owned" || !owners.has(owner.agentId)) {
|
||||
return { status: "blocked", warnings: [INVALID_SQLITE_STORE_WARNING] };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@ import { registerConfigCli } from "../cli/config-cli.js";
|
|||
import { createPluginMetadataSnapshotFixture } from "../plugins/plugin-metadata.test-support.js";
|
||||
import * as schemas from "../plugins/schema-validator.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import {
|
||||
closeOpenClawStateDatabaseAsync,
|
||||
openOpenClawStateDatabase,
|
||||
} from "../state/openclaw-state-db.js";
|
||||
import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js";
|
||||
import * as metadata from "./io.plugin-metadata.js";
|
||||
import type { OpenClawConfig } from "./types.js";
|
||||
|
|
@ -55,6 +59,30 @@ async function validateThroughCli(
|
|||
return { output: output.at(-1), schemaValidation };
|
||||
}
|
||||
|
||||
it("validates existing state through a private read-only snapshot", async () => {
|
||||
const output: unknown[] = [];
|
||||
vi.spyOn(defaultRuntime, "writeJson").mockImplementation((value) => {
|
||||
output.push(value);
|
||||
});
|
||||
vi.spyOn(defaultRuntime, "error").mockImplementation(() => {});
|
||||
await withOpenClawTestState({ prefix: "config-validation-existing-state-" }, async (state) => {
|
||||
await state.writeConfig({ gateway: { mode: "local" }, plugins: { enabled: false } });
|
||||
const before = fs.readFileSync(state.configPath, "utf8");
|
||||
openOpenClawStateDatabase({ env: state.env });
|
||||
await closeOpenClawStateDatabaseAsync();
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerConfigCli(program);
|
||||
try {
|
||||
await program.parseAsync(["config", "validate", "--json"], { from: "user" });
|
||||
} catch (error) {
|
||||
expect(error).toMatchObject({ name: "ExitError", code: 1 });
|
||||
}
|
||||
expect(output.at(-1)).toMatchObject({ valid: true });
|
||||
expect(fs.readFileSync(state.configPath, "utf8")).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ rootPath: "/synthetic", valid: true, calls: 1 },
|
||||
{ rootPath: "~/synthetic", valid: false, calls: 2 },
|
||||
|
|
|
|||
|
|
@ -252,7 +252,10 @@ describe("config health-state warnings", () => {
|
|||
it("propagates audit migration required from health writes and config snapshots", async () => {
|
||||
const deps = createHealthDeps();
|
||||
const { path: databasePath } = openOpenClawStateDatabase(deps);
|
||||
await closeOpenClawStateDatabaseAsync();
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
fs.renameSync(databasePath, `${databasePath}.seed`);
|
||||
fs.copyFileSync(`${databasePath}.seed`, databasePath);
|
||||
const db = new DatabaseSync(databasePath);
|
||||
db.exec(`
|
||||
DROP TABLE audit_events;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import type {
|
|||
} from "../../gateway/session-transcript-read.types.js";
|
||||
import { executeSqliteQuerySync } from "../../infra/kysely-sync.js";
|
||||
import { pruneMapToMaxSize } from "../../infra/map-size.js";
|
||||
import { hasSqlitePostCommitScope } from "../../infra/sqlite-post-commit.js";
|
||||
import { hasUncommittedSqliteWrites } from "../../infra/sqlite-schema-facts.js";
|
||||
import {
|
||||
resolveHistoryAnchorPageRange,
|
||||
resolveTranscriptPageEnd,
|
||||
|
|
@ -359,7 +359,7 @@ export function readRecentSessionTranscriptHistoryEventsFromProjection(
|
|||
);
|
||||
if (
|
||||
!projection.generation ||
|
||||
hasSqlitePostCommitScope(projection.database.db) ||
|
||||
hasUncommittedSqliteWrites(projection.database.db) ||
|
||||
projection.database.db.location() === null ||
|
||||
options.expectedReadWindow ||
|
||||
resolveSessionTranscriptReadFence(projection.resolved)
|
||||
|
|
|
|||
|
|
@ -229,6 +229,8 @@ export function prepareSessionEntryMaintenanceInDatabase(
|
|||
},
|
||||
};
|
||||
}
|
||||
// Selected victims have not changed yet; their future age hint is not committed.
|
||||
invalidateSessionEntryMaintenanceAgeFact(reader.db);
|
||||
const readInputs = (database: Pick<OpenClawAgentDatabase, "db">) => {
|
||||
const db = getSessionKysely(database.db);
|
||||
const rows = executeSqliteQuerySync(
|
||||
|
|
|
|||
|
|
@ -228,8 +228,6 @@ export function readSessionMaintenanceInWorker(
|
|||
readPreservation(input),
|
||||
);
|
||||
if (prepared.kind === "write") {
|
||||
// Selected victims have not changed yet; their future age hint is not committed.
|
||||
invalidateSessionEntryMaintenanceAgeFact(database.db);
|
||||
return {
|
||||
kind:
|
||||
plan.kind === "maintenance-plan"
|
||||
|
|
|
|||
|
|
@ -412,7 +412,10 @@ export class SqliteReclamationWorker {
|
|||
validationOwner: params.validationOwner,
|
||||
readOpeningValidation: params.readOpeningValidation,
|
||||
dispatch: () =>
|
||||
worker.postMessage(params.request(operationId, coordination), [...params.transferList]),
|
||||
worker.postMessage(params.request(operationId, coordination), [
|
||||
...params.transferList,
|
||||
...(coordination.databaseAdmission ? [coordination.databaseAdmission] : []),
|
||||
]),
|
||||
}).then(
|
||||
(value) => ({ value }),
|
||||
(error: unknown) => {
|
||||
|
|
@ -422,6 +425,7 @@ export class SqliteReclamationWorker {
|
|||
throw error;
|
||||
},
|
||||
),
|
||||
params.assertCurrent,
|
||||
)
|
||||
.catch((error: unknown) => {
|
||||
this.failure ??= toStringifiedError(error);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
iterateSqliteQuerySync,
|
||||
} from "../../infra/kysely-sync.js";
|
||||
import { pruneMapToMaxSize } from "../../infra/map-size.js";
|
||||
import { hasSqlitePostCommitScope } from "../../infra/sqlite-post-commit.js";
|
||||
import { hasUncommittedSqliteWrites } from "../../infra/sqlite-schema-facts.js";
|
||||
import {
|
||||
iterateUnindexedActiveTranscriptNavigation,
|
||||
iterateUnindexedTranscriptNavigation,
|
||||
|
|
@ -100,7 +100,7 @@ export function readUnindexedHistoryControls(
|
|||
: snapshot.rows.filter((row) => row.event_seq <= coveredThrough);
|
||||
}
|
||||
const key = `${projection.database.path}\0${projection.resolved.sessionId}\0unindexed-controls`;
|
||||
const cacheable = !hasSqlitePostCommitScope(projection.database.db);
|
||||
const cacheable = !hasUncommittedSqliteWrites(projection.database.db);
|
||||
const cached = cacheable ? resetMessageWindowCache.get(key) : undefined;
|
||||
const reusable =
|
||||
cached?.database === projection.database.db &&
|
||||
|
|
@ -398,7 +398,7 @@ export function resolveTranscriptBoundaryWindow(
|
|||
beforeRawSeq?: number,
|
||||
): ResetMessageWindow | null {
|
||||
// Current-turn bounds and uncommitted writes need their own window.
|
||||
if (beforeRawSeq !== undefined || hasSqlitePostCommitScope(projection.database.db)) {
|
||||
if (beforeRawSeq !== undefined || hasUncommittedSqliteWrites(projection.database.db)) {
|
||||
return findLatestResetMessageWindow(projection, scope, beforeRawSeq);
|
||||
}
|
||||
const key = `${projection.database.path}\0${projection.resolved.sessionId}\0${scope}`;
|
||||
|
|
|
|||
|
|
@ -1,16 +1,22 @@
|
|||
import { threadId, type MessagePort } from "node:worker_threads";
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { resolveIdentityPathViaExistingAncestorSync } from "../../infra/boundary-path.js";
|
||||
import { assertStateDatabaseAccessAllowed } from "../../infra/gateway-state-owner.js";
|
||||
import { withSqliteDatabaseAdmissionExchange } from "../../infra/sqlite-database-admission.js";
|
||||
import { retainSqliteWriteAdmissionService } from "../../infra/sqlite-transaction.js";
|
||||
import { assertExistingDatabaseIdentity } from "../../infra/sqlite-worker-identity.js";
|
||||
import {
|
||||
createSqliteWorkerOperationAdmission,
|
||||
exchangeSqliteDatabaseAdmissions,
|
||||
requestSqliteWorkerOperationAdmission,
|
||||
withSqliteWorkerOperationAdmission,
|
||||
} from "../../infra/sqlite-worker-operation-admission.js";
|
||||
import type { SqliteWorkerStateContext } from "../../infra/sqlite-worker-state-context.js";
|
||||
import type { OpenClawAgentDatabaseOptions } from "../../state/openclaw-agent-db.js";
|
||||
import { resolveOpenClawStateSqlitePath } from "../../state/openclaw-state-db.paths.js";
|
||||
import {
|
||||
resolveOpenClawStateSqlitePath,
|
||||
resolveQuarantineStorePath,
|
||||
} from "../../state/openclaw-state-db.paths.js";
|
||||
import type { OpenClawStateWorkerContext } from "../../state/openclaw-state-worker-context.types.js";
|
||||
import {
|
||||
sqliteMutationWorkerThreadId,
|
||||
|
|
@ -22,6 +28,7 @@ export type SqliteMutationWorkerCoordination = {
|
|||
actorId: string;
|
||||
databasePath: string;
|
||||
stateContext: SqliteWorkerStateContext;
|
||||
databaseAdmission?: MessagePort;
|
||||
reconciliation?: { identity: string; admission: MessagePort };
|
||||
};
|
||||
|
||||
|
|
@ -31,15 +38,23 @@ export async function withSqliteMutationWorkerCoordination<T>(
|
|||
transport: SqliteMutationWorkerTransport,
|
||||
operationId: number,
|
||||
run: (coordination: SqliteMutationWorkerCoordination) => Promise<T>,
|
||||
assertRequestCurrent?: () => void,
|
||||
): Promise<T> {
|
||||
const worker = transport.channel;
|
||||
const actorId = `${sqliteMutationWorkerThreadId(transport)}:${operationId}`;
|
||||
const preparingError = () => {};
|
||||
worker.on("error", preparingError);
|
||||
try {
|
||||
return await withSqliteWorkerLifecycleCoordination(context, actorId, run, async () => {
|
||||
await terminateSqliteMutationWorker(transport);
|
||||
});
|
||||
return await withSqliteWorkerLifecycleCoordination(
|
||||
context,
|
||||
actorId,
|
||||
run,
|
||||
async () => {
|
||||
await terminateSqliteMutationWorker(transport);
|
||||
},
|
||||
"retained",
|
||||
assertRequestCurrent,
|
||||
);
|
||||
} finally {
|
||||
worker.off("error", preparingError);
|
||||
}
|
||||
|
|
@ -52,6 +67,7 @@ export async function withSqliteWorkerLifecycleCoordination<T>(
|
|||
run: (coordination: SqliteMutationWorkerCoordination) => Promise<T>,
|
||||
settleFailure: () => Promise<void>,
|
||||
mode: "retained" | "reconciliation" = "retained",
|
||||
assertRequestCurrent?: () => void,
|
||||
): Promise<T> {
|
||||
const identity = context.admission.identity.key;
|
||||
let opened = false;
|
||||
|
|
@ -79,7 +95,45 @@ export async function withSqliteWorkerLifecycleCoordination<T>(
|
|||
}
|
||||
opened ||= facts.phase === "open";
|
||||
})
|
||||
: undefined;
|
||||
: assertRequestCurrent
|
||||
? createSqliteWorkerOperationAdmission(() => {
|
||||
throw new Error("SQLite mutation file admission does not grant transaction authority");
|
||||
})
|
||||
: undefined;
|
||||
if (admission && assertRequestCurrent) {
|
||||
admission.bindDatabaseAuthority({
|
||||
databasePath: context.admission.databasePath,
|
||||
assertRequest: assertRequestCurrent,
|
||||
assertAccess() {
|
||||
context.admission.assertCurrent();
|
||||
// First creation stays path-bound until the native lease publishes its file identity.
|
||||
const current = context.admission.identity;
|
||||
if (current.key.startsWith("file:")) {
|
||||
assertExistingDatabaseIdentity(
|
||||
context.admission.databasePath,
|
||||
current.key,
|
||||
current.birthtime,
|
||||
);
|
||||
}
|
||||
context.maintenanceScope?.assertAdmission();
|
||||
},
|
||||
assertCreate(location) {
|
||||
// Raw agent admission may create shared state and its first integrity receipt.
|
||||
if (
|
||||
location !== resolveIdentityPathViaExistingAncestorSync(context.admission.databasePath) &&
|
||||
location !==
|
||||
resolveIdentityPathViaExistingAncestorSync(
|
||||
resolveQuarantineStorePath(context.environment),
|
||||
)
|
||||
) {
|
||||
throw new Error("SQLite mutation creation target differs from its captured companions");
|
||||
}
|
||||
},
|
||||
acquireSchema() {
|
||||
throw new Error("SQLite mutation file admission does not grant schema maintenance");
|
||||
},
|
||||
});
|
||||
}
|
||||
const releaseService = admission
|
||||
? retainSqliteWriteAdmissionService([context.admission.databasePath], () => admission.service())
|
||||
: undefined;
|
||||
|
|
@ -88,7 +142,11 @@ export async function withSqliteWorkerLifecycleCoordination<T>(
|
|||
actorId,
|
||||
databasePath: context.admission.databasePath,
|
||||
stateContext: { environment: context.environment },
|
||||
...(admission ? { reconciliation: { identity, admission: admission.port } } : {}),
|
||||
...(admission
|
||||
? mode === "reconciliation"
|
||||
? { reconciliation: { identity, admission: admission.port } }
|
||||
: { databaseAdmission: admission.port }
|
||||
: {}),
|
||||
});
|
||||
} catch (error) {
|
||||
try {
|
||||
|
|
@ -124,10 +182,31 @@ export async function runWithSqliteMutationWorkerCoordination<
|
|||
) {
|
||||
throw new Error("SQLite mutation Worker shared-state owner changed");
|
||||
}
|
||||
return await run({
|
||||
...options,
|
||||
env: { ...options.env, ...coordination.stateContext.environment },
|
||||
});
|
||||
const execute = () =>
|
||||
run({
|
||||
...options,
|
||||
env: { ...options.env, ...coordination.stateContext.environment },
|
||||
});
|
||||
const admission = coordination.databaseAdmission;
|
||||
if (!admission) {
|
||||
return await execute();
|
||||
}
|
||||
let active = true;
|
||||
try {
|
||||
return await withSqliteDatabaseAdmissionExchange((facts, location, create) => {
|
||||
if (!active) {
|
||||
throw new Error("SQLite mutation file admission outlived its request");
|
||||
}
|
||||
if (create) {
|
||||
// First-open creation can run inside this worker's live schema lease.
|
||||
assertStateDatabaseAccessAllowed(coordination.databasePath);
|
||||
}
|
||||
return exchangeSqliteDatabaseAdmissions(admission, facts, location, create);
|
||||
}, execute);
|
||||
} finally {
|
||||
active = false;
|
||||
admission.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** Reconciliation retains its native handles and durable agent lease between grants. */
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { spawnSync } from "node:child_process";
|
||||
import { renameSync } from "node:fs";
|
||||
import { DatabaseSync, StatementSync } from "node:sqlite";
|
||||
import { backup, DatabaseSync, StatementSync } from "node:sqlite";
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import {
|
||||
|
|
@ -8,7 +8,10 @@ import {
|
|||
resolveRuntimeWorkerUrl,
|
||||
} from "../../infra/runtime-worker-url.js";
|
||||
import { createDeferredCore } from "../../shared/deferred.js";
|
||||
import { closeOpenClawAgentDatabases } from "../../state/openclaw-agent-db-lifecycle.js";
|
||||
import {
|
||||
closeOpenClawAgentDatabaseByPathAsync,
|
||||
closeOpenClawAgentDatabases,
|
||||
} from "../../state/openclaw-agent-db-lifecycle.js";
|
||||
import {
|
||||
hasOpenClawAgentCanonicalValidation,
|
||||
invalidateOpenClawAgentDatabaseValidation,
|
||||
|
|
@ -107,13 +110,16 @@ it.each(["pending rows", "revoked receipt"] as const)(
|
|||
},
|
||||
);
|
||||
|
||||
it("refuses drifted triggers at a new canonical admission", async () => {
|
||||
it("refuses replacement schema drift before reusing a warm canonical readiness receipt", async () => {
|
||||
await withOpenClawTestState({ scenario: "minimal" }, async () => {
|
||||
const { options, database } = seedPendingRows(0);
|
||||
expect(hasOpenClawAgentCanonicalValidation(database)).toBe(true);
|
||||
expect(hasPendingCanonicalSessionValidation(database)).toBe(false);
|
||||
await certifySessionCanonicalValidationPending(options);
|
||||
const peer = new DatabaseSync(database.path);
|
||||
const replacement = `${database.path}.replacement`;
|
||||
await backup(database.db, replacement);
|
||||
await closeOpenClawAgentDatabaseByPathAsync(database.path);
|
||||
const peer = new DatabaseSync(replacement);
|
||||
try {
|
||||
peer.exec(
|
||||
"CREATE TRIGGER unexpected_session_trigger AFTER INSERT ON session_nodes BEGIN SELECT 1; END",
|
||||
|
|
@ -121,9 +127,9 @@ it("refuses drifted triggers at a new canonical admission", async () => {
|
|||
} finally {
|
||||
peer.close();
|
||||
}
|
||||
invalidateOpenClawAgentDatabaseValidation(database.path);
|
||||
renameSync(replacement, database.path);
|
||||
await expect(certifySessionCanonicalValidationPending(options)).rejects.toThrow(
|
||||
/unexpected trigger unexpected_session_trigger.*openclaw doctor --fix/u,
|
||||
/unexpected_session_trigger.*openclaw doctor --fix/u,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -97,8 +97,7 @@ export async function createSessionColdStorageFixture(
|
|||
}, options);
|
||||
|
||||
const database = () => openOpenClawAgentDatabase(options).db;
|
||||
const snapshot = () => {
|
||||
const db = database();
|
||||
const snapshot = (db = database()) => {
|
||||
return {
|
||||
events: executeSqliteQuerySync(
|
||||
db,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { observeHostDataSql } from "../../../test/helpers/sqlite-statement-execu
|
|||
import { createTempDirTracker } from "../../../test/helpers/temp-dir.js";
|
||||
import { clearAgentRunContext, registerAgentRunContext } from "../../infra/agent-run-registry.js";
|
||||
import { executeSqliteQuerySync, getNodeSqliteKysely } from "../../infra/kysely-sync.js";
|
||||
import { openNodeSqliteDatabase } from "../../infra/node-sqlite.js";
|
||||
import { flushLogger, setLoggerOverride } from "../../logging/logger.js";
|
||||
import { beginSessionWorkAdmission } from "../../sessions/session-lifecycle-admission.js";
|
||||
import { sessionChanges } from "../../sessions/session-row-changes.js";
|
||||
|
|
@ -694,28 +695,29 @@ describe("cold transcript storage workers", () => {
|
|||
}
|
||||
await closeOpenClawAgentDatabaseByPathAsync(fixture.options.path);
|
||||
closeOpenClawAgentDatabasesForTest();
|
||||
const changes = vi.fn(() =>
|
||||
Boolean(readSessionColdTranscript(fixture.database(), historicalId)),
|
||||
);
|
||||
let inspection: ReturnType<typeof openNodeSqliteDatabase> | undefined;
|
||||
// The committed notification must not re-register a writable database while observing it.
|
||||
const readOnly = () =>
|
||||
(inspection ??= openNodeSqliteDatabase(fixture.options.path, { readOnly: true }));
|
||||
const changes = vi.fn(() => Boolean(readSessionColdTranscript(readOnly(), historicalId)));
|
||||
const unsubscribe = sessionChanges.subscribe(changes);
|
||||
try {
|
||||
await restoreSessionColdTranscript(fixture.scope);
|
||||
expect(fixture.snapshot()).toEqual(fixture.original);
|
||||
expect(fixture.snapshot(readOnly())).toEqual(fixture.original);
|
||||
expect(
|
||||
fixture
|
||||
.database()
|
||||
readOnly()
|
||||
.prepare(`SELECT f.message_id FROM session_transcript_fts_rows m
|
||||
JOIN session_transcript_fts f ON f.rowid=m.id AND f.session_id=m.session_id
|
||||
WHERE m.session_id=? ORDER BY f.message_id`)
|
||||
.all(historicalId),
|
||||
).toEqual([{ message_id: "history-assistant" }, { message_id: "history-user" }]);
|
||||
expect(readSessionColdTranscript(fixture.database(), historicalId)).toBeUndefined();
|
||||
expect(fixture.database().prepare("PRAGMA quick_check").get()).toEqual({
|
||||
expect(readSessionColdTranscript(readOnly(), historicalId)).toBeUndefined();
|
||||
expect(readOnly().prepare("PRAGMA quick_check").get()).toEqual({
|
||||
quick_check: "ok",
|
||||
});
|
||||
expect(fixture.database().prepare("PRAGMA foreign_key_check").all()).toEqual([]);
|
||||
expect(readOnly().prepare("PRAGMA foreign_key_check").all()).toEqual([]);
|
||||
await restoreSessionColdTranscript(fixture.scope);
|
||||
expect(fixture.snapshot()).toEqual(fixture.original);
|
||||
expect(fixture.snapshot(readOnly())).toEqual(fixture.original);
|
||||
expect(changes).toHaveBeenCalledExactlyOnceWith({
|
||||
storePath: fixture.scope.storePath,
|
||||
sessionKey: fixture.scope.sessionKey,
|
||||
|
|
@ -723,6 +725,7 @@ describe("cold transcript storage workers", () => {
|
|||
expect(changes.mock.results[0]?.value).toBe(false);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
inspection?.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import {
|
|||
trackSqliteStatementExecutions,
|
||||
} from "../../../test/helpers/sqlite-statement-execution-counter.js";
|
||||
import * as boardStore from "../../boards/sqlite-board-store.kernel.js";
|
||||
import { requireNodeSqlite } from "../../infra/node-sqlite.js";
|
||||
import { openNodeSqliteDatabase, requireNodeSqlite } from "../../infra/node-sqlite.js";
|
||||
import { runExclusiveSessionLifecycleMutation } from "../../sessions/session-lifecycle-admission.js";
|
||||
import { sessionChanges } from "../../sessions/session-row-changes.js";
|
||||
import { runInDetachedAsyncContext } from "../../shared/detached-async-context.js";
|
||||
|
|
@ -458,7 +458,7 @@ it.each([false, true])("reads row metadata (continuation: %s)", async (useContin
|
|||
});
|
||||
});
|
||||
|
||||
it("consumes admitted board absence for a cohort and observes first use and foreign DDL", async () => {
|
||||
it("consumes admitted board absence for a cohort and observes first use and published DDL", async () => {
|
||||
await withOpenClawTestState({ scenario: "minimal" }, async ({ env }) => {
|
||||
const database = openOpenClawAgentDatabase({ agentId: "main", env });
|
||||
const sessionKeys = Array.from(
|
||||
|
|
@ -471,7 +471,7 @@ it("consumes admitted board absence for a cohort and observes first use and fore
|
|||
database.db.exec("DROP TABLE board_widgets; DROP TABLE board_tabs");
|
||||
const target = { agentId: database.agentId, path: database.path };
|
||||
await closeOpenClawAgentDatabaseByPathAsync(database.path, database.agentId);
|
||||
const peer = new (requireNodeSqlite().DatabaseSync)(target.path);
|
||||
const peer = openNodeSqliteDatabase(target.path);
|
||||
const retained = new OpenClawAgentDatabaseReadOnlyScope();
|
||||
try {
|
||||
retained.run(target, () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
import { mkdirSync, realpathSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import {
|
||||
mkdirSync,
|
||||
realpathSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { backup, type DatabaseSync } from "node:sqlite";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import {
|
||||
isSessionNodePayloadSelect,
|
||||
|
|
@ -186,14 +194,17 @@ it("resolves runtime targets through one fresh admitted reader", async () => {
|
|||
queries.restore();
|
||||
}
|
||||
|
||||
const peer = new (nodeSqlite.requireNodeSqlite().DatabaseSync)(database.path);
|
||||
const replacement = `${database.path}.replacement`;
|
||||
await backup(database.db, replacement);
|
||||
await closeOpenClawAgentDatabaseByPathAsync(database.path);
|
||||
const peer = new (nodeSqlite.requireNodeSqlite().DatabaseSync)(replacement);
|
||||
try {
|
||||
peer.exec(`PRAGMA user_version = ${OPENCLAW_AGENT_SCHEMA_VERSION + 1}`);
|
||||
await expect(read()).rejects.toThrow("newer schema version");
|
||||
} finally {
|
||||
peer.exec(`PRAGMA user_version = ${OPENCLAW_AGENT_SCHEMA_VERSION}`);
|
||||
peer.close();
|
||||
}
|
||||
renameSync(replacement, database.path);
|
||||
await expect(read()).rejects.toThrow("newer schema version");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type {
|
|||
WorkerTaskOptions,
|
||||
WorkerTaskPoolOptions,
|
||||
} from "../../infra/worker-task-pool.types.js";
|
||||
import type { WorkerTaskContext } from "../../infra/worker-task-transport.js";
|
||||
import type { SessionTranscriptDisplayDeltaResult } from "./session-accessor.sqlite-history-query.js";
|
||||
import { SessionTranscriptColdError } from "./session-cold-storage-state.js";
|
||||
import { SessionTranscriptProjectionUnavailableError } from "./session-transcript-projection-error.js";
|
||||
|
|
@ -13,15 +14,9 @@ type Request = {
|
|||
taskId: number;
|
||||
interactive?: boolean;
|
||||
nativeSections: SharedArrayBuffer;
|
||||
taskContext: [string, string][];
|
||||
taskContext: WorkerTaskContext;
|
||||
};
|
||||
type Resource = { close: () => Promise<void>; agentId?: string; revoke: () => void };
|
||||
type QuarantineDatabase = {
|
||||
isOpen: boolean;
|
||||
exec: () => void;
|
||||
prepare: () => { get: () => unknown };
|
||||
close: () => void;
|
||||
};
|
||||
const observed = vi.hoisted(() => ({
|
||||
receive: undefined as ((message: Request) => void) | undefined,
|
||||
post: vi.fn<(message: unknown) => void>(),
|
||||
|
|
@ -35,11 +30,6 @@ const observed = vi.hoisted(() => ({
|
|||
deferredRun: undefined as
|
||||
| ((prepare: () => unknown, options: { inputBytes?: number }) => Promise<unknown>)
|
||||
| undefined,
|
||||
quarantineRead: vi.fn<() => unknown>(),
|
||||
quarantineClose: vi.fn<() => void>(),
|
||||
quarantineOpen: vi.fn<() => QuarantineDatabase>(),
|
||||
quarantinePaths: new Set<string>(),
|
||||
hydrate: vi.fn<() => unknown>(),
|
||||
rotate: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
|
||||
unregister: vi.fn<() => void>(),
|
||||
resources: [] as Resource[],
|
||||
|
|
@ -142,16 +132,6 @@ vi.mock("../../state/openclaw-agent-db-readonly-scope.js", () => ({
|
|||
}
|
||||
},
|
||||
}));
|
||||
vi.mock("../../infra/node-sqlite.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../infra/node-sqlite.js")>();
|
||||
return {
|
||||
...actual,
|
||||
openNodeSqliteDatabase: (...args: Parameters<typeof actual.openNodeSqliteDatabase>) =>
|
||||
observed.quarantinePaths.has(args[0])
|
||||
? observed.quarantineOpen()
|
||||
: actual.openNodeSqliteDatabase(...args),
|
||||
};
|
||||
});
|
||||
// Keep the history fixture's fake pool out of the process-wide disk-scan singleton.
|
||||
vi.mock("./disk-budget-runtime.js", () => ({
|
||||
measureSessionPhysicalDiskUsage: () => {
|
||||
|
|
@ -159,9 +139,6 @@ vi.mock("./disk-budget-runtime.js", () => ({
|
|||
},
|
||||
drainSessionDiskBudgetWorkers: async () => {},
|
||||
}));
|
||||
vi.mock("./session-transcript-hydration.worker.js", () => ({
|
||||
streamSessionTranscriptHydration: observed.hydrate,
|
||||
}));
|
||||
vi.mock("./session-accessor.sqlite-exact-read.js", () => ({
|
||||
loadSessionEntryReadOnlyInScope: () => observed.read(),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -2,10 +2,8 @@
|
|||
import "./session-history-worker-errors.test-support.js";
|
||||
import assert from "node:assert/strict";
|
||||
import { channel } from "node:diagnostics_channel";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
|
||||
import { readChatHistoryDelta } from "../../gateway/server-methods/chat-history-delta.js";
|
||||
import { decodeAgentDatabaseReaderRequest } from "../../infra/agent-database-readers.js";
|
||||
import { WorkerTaskError } from "../../infra/worker-task-pool.js";
|
||||
|
|
@ -18,7 +16,6 @@ import {
|
|||
SessionCanonicalKeyMigrationRequiredError,
|
||||
} from "./session-canonical-row.js";
|
||||
import { readSessionHistoryPageInWorker } from "./session-history-worker-runtime.js";
|
||||
import { prepareSessionTranscriptHydration } from "./session-transcript-hydration.js";
|
||||
import {
|
||||
historyLane,
|
||||
rotateDatabaseWorkers,
|
||||
|
|
@ -91,7 +88,7 @@ function installWorkerTransport() {
|
|||
taskId: 7,
|
||||
interactive: Boolean(options.onRequest),
|
||||
nativeSections: new SharedArrayBuffer(4),
|
||||
taskContext: [],
|
||||
taskContext: { deletedAgentDatabaseFences: [], databaseAdmissions: [] },
|
||||
});
|
||||
const reply = await posted.promise;
|
||||
assert(reply && typeof reply === "object" && "status" in reply);
|
||||
|
|
@ -112,7 +109,6 @@ async function readThroughWorker() {
|
|||
);
|
||||
}
|
||||
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
beforeEach(() => {
|
||||
closeCapabilities.explicitSqliteCloseReleasesNativeResources = true;
|
||||
// Synthetic targets keep discovery outside the error-transfer worker controls.
|
||||
|
|
@ -129,26 +125,6 @@ beforeEach(() => {
|
|||
observed.rotate.mockReset().mockResolvedValue(undefined);
|
||||
observed.closeResources.mockReset().mockResolvedValue(undefined);
|
||||
observed.unregister.mockReset();
|
||||
observed.quarantinePaths.clear();
|
||||
observed.quarantineRead.mockReset().mockReturnValue({ user_version: 0 });
|
||||
observed.quarantineClose.mockReset();
|
||||
observed.quarantineOpen.mockReset().mockImplementation(() => {
|
||||
const database: ReturnType<typeof observed.quarantineOpen> = {
|
||||
isOpen: true,
|
||||
exec() {},
|
||||
prepare: () => ({ get: observed.quarantineRead }),
|
||||
close() {
|
||||
observed.quarantineClose();
|
||||
database.isOpen = false;
|
||||
},
|
||||
};
|
||||
return database;
|
||||
});
|
||||
observed.hydrate.mockReset().mockReturnValue({
|
||||
kind: "full",
|
||||
eventCount: 0,
|
||||
version: { generation: null, rawSeq: null, updatedAt: null },
|
||||
});
|
||||
});
|
||||
afterEach(async () => {
|
||||
observed.rotate.mockResolvedValue(undefined);
|
||||
|
|
@ -875,106 +851,3 @@ it.each([
|
|||
}
|
||||
},
|
||||
);
|
||||
|
||||
function hydrateThroughWorker() {
|
||||
const root = tempDirs.make("openclaw-hydration-quarantine-cleanup-");
|
||||
fs.mkdirSync(path.join(root, "state"));
|
||||
const quarantinePath = path.join(root, "state", "openclaw-quarantine.sqlite");
|
||||
observed.quarantinePaths.add(quarantinePath);
|
||||
fs.writeFileSync(quarantinePath, "mock quarantine");
|
||||
installWorkerTransport();
|
||||
return prepareSessionTranscriptHydration({
|
||||
agentId: "main",
|
||||
sessionId: "quarantine-hydration",
|
||||
sessionKey: "agent:main:quarantine-hydration",
|
||||
storePath: path.join(root, "agents", "main", "agent", "openclaw-agent.sqlite"),
|
||||
env: { OPENCLAW_STATE_DIR: root },
|
||||
}).read();
|
||||
}
|
||||
|
||||
it.each([
|
||||
{ readFails: true, closeFails: false, retirementFails: false },
|
||||
{ readFails: false, closeFails: true, retirementFails: false },
|
||||
{ readFails: true, closeFails: true, retirementFails: false },
|
||||
{ readFails: true, closeFails: true, retirementFails: true },
|
||||
])(
|
||||
"settles hydration quarantine (read=$readFails, close=$closeFails, retirement=$retirementFails)",
|
||||
async ({ readFails, closeFails, retirementFails }) => {
|
||||
const readFailure = new Error("quarantine metadata read failed");
|
||||
const closeFailure = Object.assign(new Error("quarantine native close failed"), {
|
||||
code: "ERR_SQLITE_ERROR",
|
||||
errcode: 5,
|
||||
});
|
||||
const stopFailure = new Error("quarantine worker retirement failed");
|
||||
if (readFails) {
|
||||
observed.quarantineRead.mockImplementation(() => {
|
||||
throw readFailure;
|
||||
});
|
||||
}
|
||||
if (!closeFails) {
|
||||
await expect(hydrateThroughWorker()).resolves.toMatchObject({
|
||||
kind: "full",
|
||||
snapshot: { events: [] },
|
||||
});
|
||||
expect(observed.quarantineClose).toHaveBeenCalledOnce();
|
||||
expect(observed.hydrate).toHaveBeenCalledOnce();
|
||||
expect(observed.rotate).not.toHaveBeenCalled();
|
||||
return;
|
||||
}
|
||||
observed.quarantineClose.mockImplementation(() => {
|
||||
throw closeFailure;
|
||||
});
|
||||
const entered = createDeferredCore();
|
||||
const retirement = createDeferredCore();
|
||||
observed.rotate.mockImplementation(() => {
|
||||
entered.resolve();
|
||||
return retirement.promise;
|
||||
});
|
||||
let settled = false;
|
||||
const pending = hydrateThroughWorker()
|
||||
.then(
|
||||
(value) => ({ value }),
|
||||
(error: unknown) => ({ error }),
|
||||
)
|
||||
.finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
try {
|
||||
expect(
|
||||
await Promise.race([entered.promise.then(() => "retiring"), pending.then(() => "settled")]),
|
||||
).toBe("retiring");
|
||||
expect(settled).toBe(false);
|
||||
expect(observed.quarantineOpen.mock.results[0]?.value.isOpen).toBe(true);
|
||||
expect(observed.hydrate).not.toHaveBeenCalled();
|
||||
expect(observed.unregister).not.toHaveBeenCalled();
|
||||
if (retirementFails) {
|
||||
retirement.reject(stopFailure);
|
||||
} else {
|
||||
retirement.resolve();
|
||||
}
|
||||
const result = await pending;
|
||||
assert("error" in result);
|
||||
const failure = result.error;
|
||||
assert(failure instanceof AggregateError);
|
||||
const quarantine = retirementFails ? failure.errors[0] : failure;
|
||||
assert(quarantine instanceof AggregateError);
|
||||
expect(quarantine.name).toBe("OpenClawQuarantineReadCleanupError");
|
||||
expect(quarantine.errors).toMatchObject([
|
||||
...(readFails ? [{ message: readFailure.message }] : []),
|
||||
{ message: closeFailure.message, code: "ERR_SQLITE_ERROR", errcode: 5 },
|
||||
]);
|
||||
expect(quarantine.cause).toBe(quarantine.errors[0]);
|
||||
expect(observed.rotate).toHaveBeenCalledOnce();
|
||||
if (retirementFails) {
|
||||
expect(failure.errors[1]).toBe(stopFailure);
|
||||
expect(failure.cause).toBe(stopFailure);
|
||||
expect(observed.unregister).not.toHaveBeenCalled();
|
||||
} else {
|
||||
expect(observed.unregister).toHaveBeenCalledOnce();
|
||||
}
|
||||
} finally {
|
||||
retirement.resolve();
|
||||
await pending;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -26,14 +26,6 @@ export async function readSessionTranscriptHydrationRequest(
|
|||
}
|
||||
return result.value;
|
||||
}
|
||||
const { readOpenClawDatabaseQuarantineFailure } =
|
||||
await import("../../state/openclaw-quarantine-store.js");
|
||||
const quarantine = readOpenClawDatabaseQuarantineFailure("agent", request.database.path, {
|
||||
env: request.target.env,
|
||||
});
|
||||
if (quarantine) {
|
||||
throw quarantine;
|
||||
}
|
||||
if (request.kind === "latest-active-message") {
|
||||
const { readLatestSessionTranscriptMessageEvent } =
|
||||
await import("./session-accessor.sqlite-active-events.js");
|
||||
|
|
|
|||
|
|
@ -4,8 +4,17 @@ import {
|
|||
} from "../../infra/bun-sqlite-library.js";
|
||||
import { runtimeProcessEntrypoints } from "../../infra/runtime-process-entrypoints.js";
|
||||
import { resolveRuntimeWorkerUrl } from "../../infra/runtime-worker-url.js";
|
||||
import { resolveWorkerPoolSize } from "../../infra/worker-pool-sizing.js";
|
||||
import {
|
||||
UsageCostWorkerReplyError,
|
||||
type UsageCostWorkerInput,
|
||||
type UsageCostWorkerReply,
|
||||
} from "../../infra/session-cost-usage-worker.types.js";
|
||||
import {
|
||||
resolveWorkerPoolSize,
|
||||
SESSION_TRANSCRIPT_FOREGROUND_WORKERS,
|
||||
} from "../../infra/worker-pool-sizing.js";
|
||||
import { createOwnedWorkerTaskPool, WorkerTaskPool } from "../../infra/worker-task-pool.js";
|
||||
import { assertOpenClawAgentWriterReleased } from "../../state/openclaw-agent-write-admission-state.js";
|
||||
import type {
|
||||
SessionHistoryWorkerInput,
|
||||
SessionTranscriptWorkerInput,
|
||||
|
|
@ -67,3 +76,110 @@ export function createSessionTranscriptHistoryPool(
|
|||
generations.size > 0 && [...generations].every((entry) => entry.canCloseNativeResources),
|
||||
};
|
||||
}
|
||||
|
||||
function createUsageCostPool(kind: "read" | "refresh") {
|
||||
return new WorkerTaskPool<UsageCostWorkerInput, UsageCostWorkerReply>({
|
||||
workerUrl,
|
||||
workerOptions: { resourceLimits: { maxOldGenerationSizeMb: 512 } },
|
||||
// A retired task releases lane-wide database custody, which requires one native worker.
|
||||
workerClass: kind === "refresh" ? "writer" : "singleton",
|
||||
// Host writes can wait for a writer-held shared-compute reader; neither usage lane may hold its permit.
|
||||
idleTimeoutMs: 0,
|
||||
prepareWorker: () => {
|
||||
ensureSqliteLibrarySelected();
|
||||
return { options: {} };
|
||||
},
|
||||
validateResult(reply) {
|
||||
if (!reply.ok) {
|
||||
throw new UsageCostWorkerReplyError(reply.error);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type SessionDatabaseWorkerLane = {
|
||||
name: string;
|
||||
pool: { rotate: () => Promise<void> };
|
||||
nativeSequence: number;
|
||||
retiredSequence: number;
|
||||
pending: number;
|
||||
idleTimer?: NodeJS.Timeout;
|
||||
rotation?: Promise<void>;
|
||||
};
|
||||
|
||||
function createDatabaseWorkerLane<Pool extends SessionDatabaseWorkerLane["pool"]>(
|
||||
name: string,
|
||||
pool: Pool,
|
||||
): SessionDatabaseWorkerLane & { pool: Pool } {
|
||||
return { name, pool, nativeSequence: 0, retiredSequence: 0, pending: 0 };
|
||||
}
|
||||
|
||||
export function createSessionTranscriptWorkerLanes() {
|
||||
const historyLane = createDatabaseWorkerLane(
|
||||
"Session history",
|
||||
createSessionTranscriptHistoryPool(SESSION_TRANSCRIPT_FOREGROUND_WORKERS),
|
||||
);
|
||||
// Search retains its reader while the host checks writable index readiness.
|
||||
// It must never occupy the workers serving committed history and metadata.
|
||||
const transcriptSearchLane = createDatabaseWorkerLane(
|
||||
"Session transcript search",
|
||||
createSessionTranscriptHistoryPool(SESSION_TRANSCRIPT_FOREGROUND_WORKERS),
|
||||
);
|
||||
// Keep list materialization independent of large history pages, with one extra reader per store.
|
||||
const projectionLane = createDatabaseWorkerLane(
|
||||
"Session projection",
|
||||
createSessionTranscriptHistoryPool(),
|
||||
);
|
||||
// Full-store validation cannot yield its snapshot to a foreground history read.
|
||||
const maintenanceLane = createDatabaseWorkerLane(
|
||||
"Session maintenance",
|
||||
createSessionTranscriptHistoryPool(),
|
||||
);
|
||||
// Writers retain FIFO admission through target discovery and cleanup. These reads
|
||||
// cannot share a worker with history tasks that await a host-side database write.
|
||||
const targetDiscoveryLane = createDatabaseWorkerLane(
|
||||
"Session target discovery",
|
||||
createSessionTranscriptHistoryPool(),
|
||||
);
|
||||
const costReadLane = createDatabaseWorkerLane("Session usage read", createUsageCostPool("read"));
|
||||
const costRefreshLane = createDatabaseWorkerLane(
|
||||
"Session usage refresh",
|
||||
createUsageCostPool("refresh"),
|
||||
);
|
||||
|
||||
const independentHistoryLanes = [
|
||||
historyLane,
|
||||
transcriptSearchLane,
|
||||
projectionLane,
|
||||
maintenanceLane,
|
||||
];
|
||||
|
||||
// Install only in development: production dispatch/cleanup has no context check.
|
||||
// Target discovery is reserved with the writer; its cold-read host callbacks
|
||||
// reenter that reservation. Other pools can wait on an independent writer.
|
||||
if (process.env.NODE_ENV === "test" || process.env.NODE_ENV === "development") {
|
||||
for (const lane of [...independentHistoryLanes, costReadLane, costRefreshLane]) {
|
||||
const rotate = lane.pool.rotate.bind(lane.pool);
|
||||
lane.pool.rotate = () => {
|
||||
assertOpenClawAgentWriterReleased(`drain the ${lane.name} reader pool`);
|
||||
return rotate();
|
||||
};
|
||||
}
|
||||
for (const lane of independentHistoryLanes) {
|
||||
const close = lane.pool.closeResources;
|
||||
lane.pool.closeResources = (key) => {
|
||||
assertOpenClawAgentWriterReleased(`close the ${lane.name} reader pool`);
|
||||
return close(key);
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
historyLane,
|
||||
transcriptSearchLane,
|
||||
projectionLane,
|
||||
maintenanceLane,
|
||||
targetDiscoveryLane,
|
||||
costReadLane,
|
||||
costRefreshLane,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import path from "node:path";
|
||||
import { runWithSqliteDatabaseAdmissionTurn } from "../../infra/sqlite-database-admission-turn.js";
|
||||
import {
|
||||
resolveWorkerPoolSize,
|
||||
SESSION_TRANSCRIPT_FOREGROUND_WORKERS,
|
||||
|
|
@ -7,6 +9,10 @@ import type { readSessionTranscriptModelContext } from "./session-accessor.sqlit
|
|||
import type { SessionTranscriptRuntimeTarget } from "./session-accessor.types.js";
|
||||
import type { SessionContextMessagesWorkerInput } from "./session-history-read.types.js";
|
||||
import { unwrapSessionTranscriptWorkerReply } from "./session-history-worker-errors.js";
|
||||
import {
|
||||
listSqliteTargetCandidatePathsForSessionStorePath,
|
||||
resolveUnsuffixedSqliteTargetFromSessionStorePath,
|
||||
} from "./session-sqlite-target-paths.js";
|
||||
import { resolveSessionTranscriptReadFence } from "./session-transcript-read-fence.js";
|
||||
import { createSessionTranscriptReadPool } from "./session-transcript-read-pools.js";
|
||||
import type {
|
||||
|
|
@ -38,9 +44,13 @@ export async function readSessionTranscriptModelContextInWorker(
|
|||
): Promise<ReturnType<typeof readSessionTranscriptModelContext>> {
|
||||
signal?.throwIfAborted();
|
||||
const value = unwrapSessionTranscriptWorkerReply(
|
||||
await modelContextReads.run(
|
||||
{ kind: "model-context", target, admission, through, limits, expectedIdentity },
|
||||
{ timeoutMs: 60_000, signal },
|
||||
await runWithSqliteDatabaseAdmissionTurn(
|
||||
[resolveUnsuffixedSqliteTargetFromSessionStorePath(target.storePath).path],
|
||||
() =>
|
||||
modelContextReads.run(
|
||||
{ kind: "model-context", target, admission, through, limits, expectedIdentity },
|
||||
{ timeoutMs: 60_000, signal },
|
||||
),
|
||||
),
|
||||
);
|
||||
if (!("events" in value)) {
|
||||
|
|
@ -54,10 +64,16 @@ export async function resolveSessionSqliteTargetInWorker(
|
|||
signal?: AbortSignal,
|
||||
) {
|
||||
signal?.throwIfAborted();
|
||||
const target = resolveUnsuffixedSqliteTargetFromSessionStorePath(input.storePath);
|
||||
const value = unwrapSessionTranscriptWorkerReply(
|
||||
await modelContextReads.run(
|
||||
{ kind: "sqlite-target", ...input },
|
||||
{ inputBytes: JSON.stringify(input).length * 2, timeoutMs: 60_000, signal },
|
||||
await runWithSqliteDatabaseAdmissionTurn(
|
||||
listSqliteTargetCandidatePathsForSessionStorePath(input.storePath),
|
||||
() =>
|
||||
modelContextReads.run(
|
||||
{ kind: "sqlite-target", ...input },
|
||||
{ inputBytes: JSON.stringify(input).length * 2, timeoutMs: 60_000, signal },
|
||||
),
|
||||
target.agentId || target.shared ? [] : [path.dirname(target.path)],
|
||||
),
|
||||
);
|
||||
if (!("target" in value)) {
|
||||
|
|
@ -74,9 +90,13 @@ export async function readSessionTranscriptContextMessagesInWorker(
|
|||
) {
|
||||
signal?.throwIfAborted();
|
||||
const value = unwrapSessionTranscriptWorkerReply(
|
||||
await modelContextReads.run(
|
||||
{ kind: "context-messages", target, admission, expectedIdentity },
|
||||
{ timeoutMs: 60_000, signal },
|
||||
await runWithSqliteDatabaseAdmissionTurn(
|
||||
[resolveUnsuffixedSqliteTargetFromSessionStorePath(target.storePath).path],
|
||||
() =>
|
||||
modelContextReads.run(
|
||||
{ kind: "context-messages", target, admission, expectedIdentity },
|
||||
{ timeoutMs: 60_000, signal },
|
||||
),
|
||||
),
|
||||
);
|
||||
if (!("messages" in value)) {
|
||||
|
|
@ -92,24 +112,28 @@ export async function prepareSessionEntryInWorker(
|
|||
) {
|
||||
const receipt = resolveSessionTranscriptReadFence(options);
|
||||
const result = unwrapSessionTranscriptWorkerReply<"session-entry" | "session-reset-recall">(
|
||||
await sessionEntries.run(
|
||||
{
|
||||
kind: "session-entry",
|
||||
absPath,
|
||||
options,
|
||||
redaction,
|
||||
...(receipt ? { admission: { ...receipt } } : {}),
|
||||
},
|
||||
{
|
||||
inputBytes:
|
||||
2 *
|
||||
(absPath.length +
|
||||
options.agentId.length +
|
||||
options.sessionId.length +
|
||||
options.storePath.length +
|
||||
(options.sessionKey?.length ?? 0) +
|
||||
redaction.registeredSecretValues.reduce((bytes, value) => bytes + value.length, 0)),
|
||||
},
|
||||
await runWithSqliteDatabaseAdmissionTurn(
|
||||
[resolveUnsuffixedSqliteTargetFromSessionStorePath(options.storePath).path],
|
||||
() =>
|
||||
sessionEntries.run(
|
||||
{
|
||||
kind: "session-entry",
|
||||
absPath,
|
||||
options,
|
||||
redaction,
|
||||
...(receipt ? { admission: { ...receipt } } : {}),
|
||||
},
|
||||
{
|
||||
inputBytes:
|
||||
2 *
|
||||
(absPath.length +
|
||||
options.agentId.length +
|
||||
options.sessionId.length +
|
||||
options.storePath.length +
|
||||
(options.sessionKey?.length ?? 0) +
|
||||
redaction.registeredSecretValues.reduce((bytes, value) => bytes + value.length, 0)),
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
if (!("entry" in result)) {
|
||||
|
|
@ -123,13 +147,17 @@ export async function readSessionResetRecallCutoffInWorker(
|
|||
) {
|
||||
const receipt = resolveSessionTranscriptReadFence(scope);
|
||||
const result = unwrapSessionTranscriptWorkerReply<"session-entry" | "session-reset-recall">(
|
||||
await sessionEntries.run(
|
||||
{
|
||||
kind: "session-reset-recall",
|
||||
scope,
|
||||
...(receipt ? { admission: { ...receipt } } : {}),
|
||||
},
|
||||
{ inputBytes: JSON.stringify(scope).length * 2 },
|
||||
await runWithSqliteDatabaseAdmissionTurn(
|
||||
[resolveUnsuffixedSqliteTargetFromSessionStorePath(scope.storePath).path],
|
||||
() =>
|
||||
sessionEntries.run(
|
||||
{
|
||||
kind: "session-reset-recall",
|
||||
scope,
|
||||
...(receipt ? { admission: { ...receipt } } : {}),
|
||||
},
|
||||
{ inputBytes: JSON.stringify(scope).length * 2 },
|
||||
),
|
||||
),
|
||||
);
|
||||
if (!("cutoff" in result)) {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { toStringifiedError } from "@openclaw/normalization-core/error-coercion"
|
|||
import type { Result } from "@openclaw/normalization-core/result";
|
||||
import { runtimeProcessEntrypoints } from "../../infra/runtime-process-entrypoints.js";
|
||||
import { resolveRuntimeWorkerUrl } from "../../infra/runtime-worker-url.js";
|
||||
import { runWithSqliteDatabaseAdmissionTurn } from "../../infra/sqlite-database-admission-turn.js";
|
||||
import { readDatabasePathIdentitySync } from "../../infra/sqlite-worker-identity.js";
|
||||
import { resolveWorkerPoolSize } from "../../infra/worker-pool-sizing.js";
|
||||
import { WorkerTaskPool } from "../../infra/worker-task-pool.js";
|
||||
|
|
@ -453,23 +454,27 @@ async function startReconcileWorkerTask(
|
|||
(input.mode === "disk" ? 2 * input.agentId.length : 0));
|
||||
let poolCompletion: Promise<void> | undefined;
|
||||
const execute = async (coordination?: SqliteMutationWorkerCoordination) => {
|
||||
poolCompletion = pool.run(
|
||||
{
|
||||
input,
|
||||
port: port2,
|
||||
coordination,
|
||||
sourceIdentity,
|
||||
},
|
||||
{
|
||||
inputBytes,
|
||||
transferList: (task) => [
|
||||
task.port,
|
||||
...(task.coordination?.reconciliation
|
||||
? [task.coordination.reconciliation.admission]
|
||||
: []),
|
||||
],
|
||||
signal: controller.signal,
|
||||
},
|
||||
poolCompletion = runWithSqliteDatabaseAdmissionTurn(
|
||||
input.mode === "disk" ? [input.path] : [],
|
||||
() =>
|
||||
pool.run(
|
||||
{
|
||||
input,
|
||||
port: port2,
|
||||
coordination,
|
||||
sourceIdentity,
|
||||
},
|
||||
{
|
||||
inputBytes,
|
||||
transferList: (task) => [
|
||||
task.port,
|
||||
...(task.coordination?.reconciliation
|
||||
? [task.coordination.reconciliation.admission]
|
||||
: []),
|
||||
],
|
||||
signal: controller.signal,
|
||||
},
|
||||
),
|
||||
);
|
||||
try {
|
||||
await poolCompletion;
|
||||
|
|
|
|||
|
|
@ -1,17 +1,41 @@
|
|||
import { appendFileSync } from "node:fs";
|
||||
import { workerData } from "node:worker_threads";
|
||||
const { register } = await import(workerData.sourceLoaderUrl);
|
||||
register();
|
||||
const { requireNodeSqlite, resolveNodeSqliteLocation } = await import("../../infra/node-sqlite.ts");
|
||||
const sqlite = requireNodeSqlite();
|
||||
const target = resolveNodeSqliteLocation(workerData.databasePath);
|
||||
const target = workerData.databasePath && resolveNodeSqliteLocation(workerData.databasePath);
|
||||
// oxlint-disable-next-line typescript/unbound-method -- The fault wrapper calls the captured method with its database receiver.
|
||||
const close = sqlite.DatabaseSync.prototype.close;
|
||||
let failed = false;
|
||||
sqlite.DatabaseSync.prototype.close = function () {
|
||||
if (!failed && this.location() === target) {
|
||||
if (target && !failed && this.location() === target) {
|
||||
failed = true;
|
||||
throw new Error("Synthetic native shared-state close failure");
|
||||
}
|
||||
return close.call(this);
|
||||
};
|
||||
if (workerData.schemaTrace) {
|
||||
const paths = new Set(workerData.schemaTrace.databasePaths.map(resolveNodeSqliteLocation));
|
||||
const statements = new WeakMap();
|
||||
// oxlint-disable-next-line typescript/unbound-method -- The trace wrapper supplies the intercepted database receiver.
|
||||
const prepare = sqlite.DatabaseSync.prototype.prepare;
|
||||
sqlite.DatabaseSync.prototype.prepare = function (sql) {
|
||||
const statement = prepare.call(this, sql);
|
||||
if (paths.has(this.location())) {
|
||||
statements.set(statement, sql);
|
||||
}
|
||||
return statement;
|
||||
};
|
||||
for (const method of ["get", "all", "iterate"]) {
|
||||
const execute = sqlite.StatementSync.prototype[method];
|
||||
sqlite.StatementSync.prototype[method] = function (...args) {
|
||||
const sql = statements.get(this);
|
||||
if (sql && /PRAGMA\s+(?:schema_version|user_version)\b/i.test(sql)) {
|
||||
appendFileSync(workerData.schemaTrace.path, `${JSON.stringify(sql)}\n`);
|
||||
}
|
||||
return Reflect.apply(execute, this, args);
|
||||
};
|
||||
}
|
||||
}
|
||||
await import("./session-transcript-reconcile.worker.ts");
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { MessageChannel, type MessagePort } from "node:worker_threads";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createDeferred } from "../../../test/helpers/promise.js";
|
||||
import { acquireGatewayStateOwner } from "../../infra/gateway-state-owner.js";
|
||||
import { requireNodeSqlite } from "../../infra/node-sqlite.js";
|
||||
import { openNodeSqliteDatabase, requireNodeSqlite } from "../../infra/node-sqlite.js";
|
||||
import { runtimeProcessEntrypoints } from "../../infra/runtime-process-entrypoints.js";
|
||||
import { resolveRuntimeWorkerUrl } from "../../infra/runtime-worker-url.js";
|
||||
import { getAdmittedSqliteSchemaFacts } from "../../infra/sqlite-schema-facts.js";
|
||||
import { readDatabasePathIdentitySync } from "../../infra/sqlite-worker-identity.js";
|
||||
import * as admissionOwner from "../../infra/sqlite-worker-operation-admission.js";
|
||||
import { sqliteWorkerOwnerProbe as probe } from "../../infra/sqlite-worker-owner-probe.test-support.js";
|
||||
|
|
@ -38,17 +39,22 @@ import type {
|
|||
} from "./session-transcript-reconcile.worker.js";
|
||||
|
||||
type Pool = WorkerTaskPool<SessionTranscriptReconcileWorkerTask, void>;
|
||||
function createPool(failNativeCloseAt?: string): Pool {
|
||||
function createPool(
|
||||
failNativeCloseAt?: string,
|
||||
schemaTrace?: { path: string; databasePaths: string[] },
|
||||
): Pool {
|
||||
return new WorkerTaskPool<SessionTranscriptReconcileWorkerTask, void>({
|
||||
workerUrl: failNativeCloseAt
|
||||
? new URL("./session-transcript-reconcile.close-failure.test-support.mjs", import.meta.url)
|
||||
: resolveRuntimeWorkerUrl(runtimeProcessEntrypoints.sessionTranscriptReconcile),
|
||||
...(failNativeCloseAt
|
||||
workerUrl:
|
||||
failNativeCloseAt || schemaTrace
|
||||
? new URL("./session-transcript-reconcile.close-failure.test-support.mjs", import.meta.url)
|
||||
: resolveRuntimeWorkerUrl(runtimeProcessEntrypoints.sessionTranscriptReconcile),
|
||||
...(failNativeCloseAt || schemaTrace
|
||||
? {
|
||||
workerOptions: {
|
||||
workerData: {
|
||||
sourceLoaderUrl: import.meta.resolve("tsx/esm/api"),
|
||||
databasePath: failNativeCloseAt,
|
||||
schemaTrace,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -276,8 +282,8 @@ describe("reconciliation cleanup transport native custody", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it.each(["exclude", "schema", "version"] as const)(
|
||||
"preserves deletion and schema checks across the phase yield against foreign %s",
|
||||
it.each(["exclude", "schema", "version", "own-schema"] as const)(
|
||||
"keeps phase authority and admitted format across %s changes without version polling",
|
||||
async (operation) => {
|
||||
await withOpenClawTestState(
|
||||
{ scenario: "external-service", label: "reconcile-phase-yield" },
|
||||
|
|
@ -287,10 +293,44 @@ it.each(["exclude", "schema", "version"] as const)(
|
|||
closeOpenClawAgentDatabaseByPath(options.path);
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
const context = captureOpenClawStateWorkerContext();
|
||||
const pool = createPool();
|
||||
const tracePath = state.path("reconcile-schema-sql.jsonl");
|
||||
const pool = createPool(undefined, {
|
||||
path: tracePath,
|
||||
databasePaths: [context.admission.databasePath, options.path],
|
||||
});
|
||||
const ready = createDeferred<MessagePort>();
|
||||
const leaseId = `phase-yield-${operation}`;
|
||||
const observation = observe(context);
|
||||
let closeGranted = false;
|
||||
const createAdmission = admissionOwner.createSqliteWorkerOperationAdmission;
|
||||
const admissions =
|
||||
operation === "own-schema"
|
||||
? vi
|
||||
.spyOn(admissionOwner, "createSqliteWorkerOperationAdmission")
|
||||
.mockImplementation((admit, attachment) =>
|
||||
createAdmission(
|
||||
(request, grant) =>
|
||||
admit(request, (beforeRelease) => {
|
||||
const granted = grant(beforeRelease);
|
||||
const facts = request.facts;
|
||||
if (
|
||||
granted &&
|
||||
request.stage === "prepare" &&
|
||||
typeof facts === "object" &&
|
||||
facts !== null &&
|
||||
"kind" in facts &&
|
||||
facts.kind === "transcript-reconciliation" &&
|
||||
"phase" in facts &&
|
||||
facts.phase === "close"
|
||||
) {
|
||||
closeGranted = true;
|
||||
}
|
||||
return granted;
|
||||
}),
|
||||
attachment,
|
||||
),
|
||||
)
|
||||
: undefined;
|
||||
let parent: MessagePort | undefined;
|
||||
let completed = false;
|
||||
const task = releaseInRealWorker(pool, context, leaseId, options.path, {
|
||||
|
|
@ -313,38 +353,59 @@ it.each(["exclude", "schema", "version"] as const)(
|
|||
throw new Error("Reconciliation settled before the phase-yield witness");
|
||||
}),
|
||||
]);
|
||||
const child = await promisify(execFile)(process.execPath, [
|
||||
fileURLToPath(
|
||||
new URL(
|
||||
"./session-transcript-reconcile.foreign-owner.test-support.mjs",
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
JSON.stringify({
|
||||
operation,
|
||||
statePath: context.admission.databasePath,
|
||||
agentPath: options.path,
|
||||
environment: context.environment,
|
||||
sourceLoaderUrl: import.meta.resolve("tsx/esm/api"),
|
||||
}),
|
||||
]);
|
||||
const verdict: unknown = JSON.parse(child.stdout);
|
||||
if (operation === "exclude") {
|
||||
expect(verdict).toEqual({ agentCleanupRefused: true });
|
||||
} else if (operation === "version") {
|
||||
expect(verdict).toEqual({ changed: true, unchangedSchemaCookie: true });
|
||||
if (operation === "own-schema") {
|
||||
expect(observation.calls).toEqual([]);
|
||||
const sibling = openNodeSqliteDatabase(options.path);
|
||||
try {
|
||||
const previousAdmission = getAdmittedSqliteSchemaFacts(sibling)?.admissionId;
|
||||
expect(typeof previousAdmission).toBe("string");
|
||||
sibling.exec("CREATE TABLE synthetic_schema_reentry_guard (value INTEGER)");
|
||||
const publishedAdmission = getAdmittedSqliteSchemaFacts(sibling)?.admissionId;
|
||||
expect(typeof publishedAdmission).toBe("string");
|
||||
expect(publishedAdmission).not.toBe(previousAdmission);
|
||||
} finally {
|
||||
sibling.close();
|
||||
}
|
||||
// The synchronous fixture mutation is separate from both worker lifecycle phases.
|
||||
observation.calls.length = 0;
|
||||
} else {
|
||||
expect(verdict).toEqual({ changed: true });
|
||||
const child = await promisify(execFile)(process.execPath, [
|
||||
fileURLToPath(
|
||||
new URL(
|
||||
"./session-transcript-reconcile.foreign-owner.test-support.mjs",
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
JSON.stringify({
|
||||
operation,
|
||||
statePath: context.admission.databasePath,
|
||||
agentPath: options.path,
|
||||
environment: context.environment,
|
||||
sourceLoaderUrl: import.meta.resolve("tsx/esm/api"),
|
||||
}),
|
||||
]);
|
||||
const verdict: unknown = JSON.parse(child.stdout);
|
||||
if (operation === "exclude") {
|
||||
expect(verdict).toEqual({ agentCleanupRefused: true });
|
||||
} else if (operation === "version") {
|
||||
expect(verdict).toEqual({ changed: true, unchangedSchemaCookie: true });
|
||||
} else {
|
||||
expect(verdict).toEqual({ changed: true });
|
||||
}
|
||||
}
|
||||
parent.postMessage({ type: "release" }, []);
|
||||
if (operation === "exclude") {
|
||||
await expect(task).resolves.toContainEqual({ type: "lease-released" });
|
||||
if (operation === "own-schema") {
|
||||
// Native-phase failures retire the isolate instead of transporting its internal error.
|
||||
await expect(task).rejects.toThrow("worker exited with code 1");
|
||||
expect(closeGranted).toBe(true);
|
||||
} else {
|
||||
await expect(task).rejects.toThrow();
|
||||
await expect(task).resolves.toContainEqual({ type: "lease-released" });
|
||||
}
|
||||
completed = true;
|
||||
await pool.close();
|
||||
expect(pool.getSnapshot().workers).toBe(0);
|
||||
expect(observation.calls).toEqual([]);
|
||||
expect(existsSync(tracePath) ? readFileSync(tracePath, "utf8") : "").toBe("");
|
||||
} finally {
|
||||
if (!completed) {
|
||||
parent?.postMessage({ type: "release" }, []);
|
||||
|
|
@ -352,11 +413,12 @@ it.each(["exclude", "schema", "version"] as const)(
|
|||
await task.catch(() => {});
|
||||
await pool.close();
|
||||
observation.restore();
|
||||
admissions?.mockRestore();
|
||||
}
|
||||
const lease = openOpenClawStateDatabase()
|
||||
.db.prepare("SELECT lease_id FROM agent_database_leases WHERE lease_id = ?")
|
||||
.get(leaseId);
|
||||
if (operation !== "exclude") {
|
||||
if (operation === "own-schema") {
|
||||
expect(lease).toEqual({ lease_id: leaseId });
|
||||
releaseOpenClawAgentDatabaseLease(leaseId);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/** Worker entrypoint for transcript parsing and active-branch resolution only. */
|
||||
import { MessagePort } from "node:worker_threads";
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { readSqliteSchemaCookie } from "../../infra/sqlite-schema-contract.js";
|
||||
import { getAdmittedSqliteSchemaFacts } from "../../infra/sqlite-schema-facts.js";
|
||||
import { assertExistingDatabaseIdentity } from "../../infra/sqlite-worker-identity.js";
|
||||
import { serveWorkerTasks } from "../../infra/worker-task-server.js";
|
||||
import {
|
||||
|
|
@ -365,9 +365,9 @@ async function run(
|
|||
throw new Error("Transcript worker lost its admitted shared-state handle");
|
||||
}
|
||||
const shared = sharedBorrow.database;
|
||||
const sharedSchema = readSqliteSchemaCookie(shared.db);
|
||||
const agentSchema = readSqliteSchemaCookie(opened.database.db);
|
||||
if (typeof sharedSchema !== "number" || typeof agentSchema !== "number") {
|
||||
const sharedSchema = getAdmittedSqliteSchemaFacts(shared.db)?.admissionId;
|
||||
const agentSchema = getAdmittedSqliteSchemaFacts(opened.database.db)?.admissionId;
|
||||
if (!sharedSchema || !agentSchema) {
|
||||
throw new Error("Transcript worker cannot retain its admitted schema identities");
|
||||
}
|
||||
// Reentry is a new native phase, never adoption of a schema changed while yielding.
|
||||
|
|
@ -375,8 +375,8 @@ async function run(
|
|||
sharedBorrow?.assertCurrent();
|
||||
if (
|
||||
!shared.db.isOpen ||
|
||||
readSqliteSchemaCookie(shared.db) !== sharedSchema ||
|
||||
readSqliteSchemaCookie(opened.database.db) !== agentSchema
|
||||
getAdmittedSqliteSchemaFacts(shared.db)?.admissionId !== sharedSchema ||
|
||||
getAdmittedSqliteSchemaFacts(opened.database.db)?.admissionId !== agentSchema
|
||||
) {
|
||||
throw new Error("Transcript worker schema changed between lifecycle phases");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,8 +65,13 @@ vi.mock("node:worker_threads", async (importOriginal) => {
|
|||
};
|
||||
});
|
||||
|
||||
// Transport controls normally represent an admitted store; the cold-admission
|
||||
// regression below exercises the real writer queue before such facts exist.
|
||||
// Transport controls model settled native preparation and its published format facts;
|
||||
// cold-admission regressions exercise the real writer queue before either exists.
|
||||
vi.mock("../../infra/sqlite-database-admission.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../infra/sqlite-database-admission.js")>()),
|
||||
hasSqliteDatabaseSchemaAdmissionForPath: () => observed.preparedDatabase,
|
||||
}));
|
||||
|
||||
vi.mock("../../state/openclaw-agent-execution.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../state/openclaw-agent-execution.js")>()),
|
||||
captureExistingOpenClawAgentDatabaseExecution: (options: { path: string }) => {
|
||||
|
|
|
|||
|
|
@ -3,18 +3,14 @@ import path from "node:path";
|
|||
import { err, ok, type Result } from "@openclaw/normalization-core/result";
|
||||
import { joinOwnedWorkerTasks } from "@openclaw/worker-runtime";
|
||||
import { encodeAgentDatabaseReaderRequest } from "../../infra/agent-database-readers.js";
|
||||
import { ensureSqliteLibrarySelected } from "../../infra/bun-sqlite-library.js";
|
||||
import { runtimeProcessEntrypoints } from "../../infra/runtime-process-entrypoints.js";
|
||||
import { resolveRuntimeWorkerUrl } from "../../infra/runtime-worker-url.js";
|
||||
import {
|
||||
UsageCostWorkerReplyError,
|
||||
type UsageCostWorkerDatabase,
|
||||
type UsageCostWorkerInput,
|
||||
type UsageCostWorkerReply,
|
||||
import type {
|
||||
UsageCostWorkerDatabase,
|
||||
UsageCostWorkerInput,
|
||||
UsageCostWorkerReply,
|
||||
} from "../../infra/session-cost-usage-worker.types.js";
|
||||
import { runWithSqliteDatabaseAdmissionTurn } from "../../infra/sqlite-database-admission-turn.js";
|
||||
import { SQLITE_IDLE_HANDLE_TTL_MS } from "../../infra/sqlite-handle-lifecycle.js";
|
||||
import { SESSION_TRANSCRIPT_FOREGROUND_WORKERS } from "../../infra/worker-pool-sizing.js";
|
||||
import { WorkerTaskError, WorkerTaskPool } from "../../infra/worker-task-pool.js";
|
||||
import { WorkerTaskError, type WorkerTaskPool } from "../../infra/worker-task-pool.js";
|
||||
import { normalizeAgentId } from "../../routing/session-key.js";
|
||||
import { runInDetachedAsyncContext } from "../../shared/detached-async-context.js";
|
||||
import type { OpenClawAgentDatabaseOptions } from "../../state/openclaw-agent-db-contract.js";
|
||||
|
|
@ -24,7 +20,6 @@ import {
|
|||
registerOpenClawAgentDatabaseReadCandidateResource,
|
||||
} from "../../state/openclaw-agent-db-resources.js";
|
||||
import { resolveOpenClawAgentSqlitePath } from "../../state/openclaw-agent-db.paths.js";
|
||||
import { assertOpenClawAgentWriterReleased } from "../../state/openclaw-agent-write-admission-state.js";
|
||||
import { runOutsideOpenClawDatabaseMaintenanceScope } from "../../state/openclaw-state-db-async-lifecycle.js";
|
||||
import { registerOpenClawStateDatabaseAsyncResource } from "../../state/openclaw-state-db-cache.js";
|
||||
import {
|
||||
|
|
@ -43,42 +38,14 @@ import type {
|
|||
SessionStoreTargetReadResult,
|
||||
SessionStoreTargetInventoryResult,
|
||||
} from "./session-store-target-inventory.js";
|
||||
import { createSessionTranscriptHistoryPool } from "./session-transcript-read-pools.js";
|
||||
import {
|
||||
createSessionTranscriptWorkerLanes,
|
||||
type createSessionTranscriptHistoryPool,
|
||||
type SessionDatabaseWorkerLane,
|
||||
} from "./session-transcript-read-pools.js";
|
||||
import type { SessionHistoryWorkerInput } from "./session-transcript-worker.types.js";
|
||||
import { captureSessionTranscriptStorageEnvironment } from "./transcript-target-binding.js";
|
||||
|
||||
const workerUrl = resolveRuntimeWorkerUrl(runtimeProcessEntrypoints.sessionTranscript);
|
||||
|
||||
function createUsageCostPool(kind: "read" | "refresh") {
|
||||
return new WorkerTaskPool<UsageCostWorkerInput, UsageCostWorkerReply>({
|
||||
workerUrl,
|
||||
workerOptions: { resourceLimits: { maxOldGenerationSizeMb: 512 } },
|
||||
// A retired task releases lane-wide database custody, which requires one native worker.
|
||||
workerClass: kind === "refresh" ? "writer" : "singleton",
|
||||
// Host writes can wait for a writer-held shared-compute reader; neither usage lane may hold its permit.
|
||||
idleTimeoutMs: 0,
|
||||
prepareWorker: () => {
|
||||
ensureSqliteLibrarySelected();
|
||||
return { options: {} };
|
||||
},
|
||||
validateResult(reply) {
|
||||
if (!reply.ok) {
|
||||
throw new UsageCostWorkerReplyError(reply.error);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type SessionDatabaseWorkerLane = {
|
||||
name: string;
|
||||
pool: { rotate: () => Promise<void> };
|
||||
nativeSequence: number;
|
||||
retiredSequence: number;
|
||||
pending: number;
|
||||
idleTimer?: NodeJS.Timeout;
|
||||
rotation?: Promise<void>;
|
||||
};
|
||||
|
||||
export type SessionCostWorkerLane = SessionDatabaseWorkerLane & {
|
||||
pool: WorkerTaskPool<UsageCostWorkerInput, UsageCostWorkerReply>;
|
||||
};
|
||||
|
|
@ -112,76 +79,25 @@ const historyDatabases = new Map<string, HistoryDatabaseResource>();
|
|||
const historySetTimeout = setTimeout;
|
||||
export const historyClearTimeout = clearTimeout;
|
||||
let historyGeneration = 0;
|
||||
function createDatabaseWorkerLane<Pool extends SessionDatabaseWorkerLane["pool"]>(
|
||||
name: string,
|
||||
pool: Pool,
|
||||
): SessionDatabaseWorkerLane & { pool: Pool } {
|
||||
return { name, pool, nativeSequence: 0, retiredSequence: 0, pending: 0 };
|
||||
}
|
||||
|
||||
export const historyLane = createDatabaseWorkerLane(
|
||||
"Session history",
|
||||
createSessionTranscriptHistoryPool(SESSION_TRANSCRIPT_FOREGROUND_WORKERS),
|
||||
);
|
||||
// Search retains its reader while the host checks writable index readiness.
|
||||
// It must never occupy the workers serving committed history and metadata.
|
||||
export const transcriptSearchLane = createDatabaseWorkerLane(
|
||||
"Session transcript search",
|
||||
createSessionTranscriptHistoryPool(SESSION_TRANSCRIPT_FOREGROUND_WORKERS),
|
||||
);
|
||||
// Keep list materialization independent of large history pages, with one extra reader per store.
|
||||
export const projectionLane = createDatabaseWorkerLane(
|
||||
"Session projection",
|
||||
createSessionTranscriptHistoryPool(),
|
||||
);
|
||||
// Full-store validation cannot yield its snapshot to a foreground history read.
|
||||
export const maintenanceLane = createDatabaseWorkerLane(
|
||||
"Session maintenance",
|
||||
createSessionTranscriptHistoryPool(),
|
||||
);
|
||||
// Writers retain FIFO admission through target discovery and cleanup. These reads
|
||||
// cannot share a worker with history tasks that await a host-side database write.
|
||||
export const targetDiscoveryLane = createDatabaseWorkerLane(
|
||||
"Session target discovery",
|
||||
createSessionTranscriptHistoryPool(),
|
||||
);
|
||||
export const costReadLane = createDatabaseWorkerLane(
|
||||
"Session usage read",
|
||||
createUsageCostPool("read"),
|
||||
);
|
||||
export const costRefreshLane = createDatabaseWorkerLane(
|
||||
"Session usage refresh",
|
||||
createUsageCostPool("refresh"),
|
||||
);
|
||||
|
||||
const independentHistoryLanes = [
|
||||
export const {
|
||||
historyLane,
|
||||
transcriptSearchLane,
|
||||
projectionLane,
|
||||
maintenanceLane,
|
||||
targetDiscoveryLane,
|
||||
costReadLane,
|
||||
costRefreshLane,
|
||||
} = createSessionTranscriptWorkerLanes();
|
||||
|
||||
const historyWorkerLanes = [
|
||||
historyLane,
|
||||
transcriptSearchLane,
|
||||
projectionLane,
|
||||
maintenanceLane,
|
||||
targetDiscoveryLane,
|
||||
];
|
||||
const historyWorkerLanes = [...independentHistoryLanes, targetDiscoveryLane];
|
||||
const databaseWorkerLanes = [...historyWorkerLanes, costReadLane, costRefreshLane];
|
||||
|
||||
// Install only in development: production dispatch/cleanup has no context check.
|
||||
// Target discovery is reserved with the writer; its cold-read host callbacks
|
||||
// reenter that reservation. Other pools can wait on an independent writer.
|
||||
if (process.env.NODE_ENV === "test" || process.env.NODE_ENV === "development") {
|
||||
for (const lane of [...independentHistoryLanes, costReadLane, costRefreshLane]) {
|
||||
const rotate = lane.pool.rotate.bind(lane.pool);
|
||||
lane.pool.rotate = () => {
|
||||
assertOpenClawAgentWriterReleased(`drain the ${lane.name} reader pool`);
|
||||
return rotate();
|
||||
};
|
||||
}
|
||||
for (const lane of independentHistoryLanes) {
|
||||
const close = lane.pool.closeResources;
|
||||
lane.pool.closeResources = (key) => {
|
||||
assertOpenClawAgentWriterReleased(`close the ${lane.name} reader pool`);
|
||||
return close(key);
|
||||
};
|
||||
}
|
||||
}
|
||||
const memoryPressure = channel("openclaw.memory.critical");
|
||||
let pressureSubscribed = false;
|
||||
|
||||
|
|
@ -482,6 +398,12 @@ export async function withSessionHistoryWorkerReadCandidates<T>(
|
|||
path: physicalPath,
|
||||
...(scope ? { scope } : {}),
|
||||
}));
|
||||
const admissionPaths = capturedCandidates
|
||||
.filter((candidate) => !candidate.scope)
|
||||
.map((candidate) => candidate.physicalPath);
|
||||
const admissionFamilies = capturedCandidates
|
||||
.filter((candidate) => candidate.scope === "sibling-family")
|
||||
.map((candidate) => path.dirname(candidate.physicalPath));
|
||||
historyClearTimeout(lane.idleTimer);
|
||||
lane.pending++;
|
||||
refreshDatabaseWorkerPressureSubscription();
|
||||
|
|
@ -633,14 +555,19 @@ export async function withSessionHistoryWorkerReadCandidates<T>(
|
|||
env: captureSessionTranscriptStorageEnvironment(request.env),
|
||||
candidates: capturedCandidates,
|
||||
};
|
||||
const reply = await lane.pool.run(
|
||||
() => {
|
||||
assertCurrent();
|
||||
dispatched = true;
|
||||
lane.nativeSequence++;
|
||||
return { kind: "session-store-target", request: preparedRequest };
|
||||
},
|
||||
{ inputBytes: JSON.stringify(preparedRequest).length * 2, timeoutMs: 60_000 },
|
||||
const reply = await runWithSqliteDatabaseAdmissionTurn(
|
||||
admissionPaths,
|
||||
() =>
|
||||
lane.pool.run(
|
||||
() => {
|
||||
assertCurrent();
|
||||
dispatched = true;
|
||||
lane.nativeSequence++;
|
||||
return { kind: "session-store-target", request: preparedRequest };
|
||||
},
|
||||
{ inputBytes: JSON.stringify(preparedRequest).length * 2, timeoutMs: 60_000 },
|
||||
),
|
||||
admissionFamilies,
|
||||
);
|
||||
const result = unwrapSessionTranscriptWorkerReply<SessionHistoryWorkerInput["kind"]>(reply);
|
||||
if (
|
||||
|
|
@ -673,17 +600,22 @@ export async function withSessionHistoryWorkerReadCandidates<T>(
|
|||
env: captureSessionTranscriptStorageEnvironment(request.env),
|
||||
candidates: capturedCandidates,
|
||||
};
|
||||
const reply = await lane.pool.run(
|
||||
() => {
|
||||
assertCurrent();
|
||||
dispatched = true;
|
||||
lane.nativeSequence++;
|
||||
return { kind: "session-target-inventory", request: preparedRequest };
|
||||
},
|
||||
{
|
||||
inputBytes: measureSessionStoreTargetInventoryInputBytes(preparedRequest),
|
||||
timeoutMs: 60_000,
|
||||
},
|
||||
const reply = await runWithSqliteDatabaseAdmissionTurn(
|
||||
admissionPaths,
|
||||
() =>
|
||||
lane.pool.run(
|
||||
() => {
|
||||
assertCurrent();
|
||||
dispatched = true;
|
||||
lane.nativeSequence++;
|
||||
return { kind: "session-target-inventory", request: preparedRequest };
|
||||
},
|
||||
{
|
||||
inputBytes: measureSessionStoreTargetInventoryInputBytes(preparedRequest),
|
||||
timeoutMs: 60_000,
|
||||
},
|
||||
),
|
||||
admissionFamilies,
|
||||
);
|
||||
const result =
|
||||
unwrapSessionTranscriptWorkerReply<SessionHistoryWorkerInput["kind"]>(reply);
|
||||
|
|
|
|||
|
|
@ -1,14 +1,5 @@
|
|||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { toErrorObject } from "@openclaw/normalization-core/error-coercion";
|
||||
import {
|
||||
UsageCostWorkerReplyError,
|
||||
type UsageCostWorkerInput,
|
||||
type UsageCostWorkerResult,
|
||||
type SessionCostUsageWorkerOptions,
|
||||
type SessionCostUsageWorkerScope,
|
||||
} from "../../infra/session-cost-usage-worker.types.js";
|
||||
import { withSqliteWorkerCleanupFailure } from "../../infra/sqlite-worker-broker-reply.js";
|
||||
import { runWithSqliteDatabaseAdmissionTurn } from "../../infra/sqlite-database-admission-turn.js";
|
||||
import { assertExistingDatabaseIdentity } from "../../infra/sqlite-worker-identity.js";
|
||||
import { WorkerTaskError } from "../../infra/worker-task-pool.js";
|
||||
import type { WorkerTaskOptions, WorkerTaskResponse } from "../../infra/worker-task-pool.types.js";
|
||||
|
|
@ -45,19 +36,14 @@ import {
|
|||
import {
|
||||
acquireHistoryDatabaseResource,
|
||||
armDatabaseWorkerIdleRetirement,
|
||||
clearClosedDatabaseCustody,
|
||||
costReadLane,
|
||||
costRefreshLane,
|
||||
historyClearTimeout,
|
||||
historyLane,
|
||||
maintenanceLane,
|
||||
pruneHistoryDatabases,
|
||||
refreshDatabaseWorkerPressureSubscription,
|
||||
releaseRetiredDatabaseCustody,
|
||||
rotateDatabaseWorkers,
|
||||
settleSessionHistoryWorkerEviction,
|
||||
type HistoryDatabaseResource,
|
||||
type SessionCostWorkerLane,
|
||||
type SessionDatabaseCleanup,
|
||||
type SessionHistoryDatabaseTarget,
|
||||
type SessionHistoryWorkerLane,
|
||||
|
|
@ -279,49 +265,51 @@ export function retainSessionHistoryWorkerDatabase(
|
|||
async (admit, requestLane) => {
|
||||
try {
|
||||
const reply = await admit((requestSignal, remaining) =>
|
||||
requestLane.pool.run(
|
||||
() => {
|
||||
assertRequestCurrent();
|
||||
const input = prepare();
|
||||
assertRequestCurrent();
|
||||
sequence = ++requestLane.nativeSequence;
|
||||
owned.nativeSequences.set(requestLane, sequence);
|
||||
return {
|
||||
...input,
|
||||
database,
|
||||
validation: validation?.validation,
|
||||
} satisfies SessionTranscriptWorkerRequest;
|
||||
},
|
||||
{
|
||||
inputBytes: inputBytes + (validation?.inputBytes ?? 0),
|
||||
timeoutMs: remaining,
|
||||
signal: requestSignal,
|
||||
onRequest: onRequest
|
||||
? (value, context) => {
|
||||
const effect = (async () => {
|
||||
context.signal.throwIfAborted();
|
||||
assertRequestCurrent();
|
||||
const response = await onRequest(value, context.signal);
|
||||
context.signal.throwIfAborted();
|
||||
assertRequestCurrent();
|
||||
return response ?? { input: null, timeoutMs };
|
||||
})();
|
||||
hostEffects.add(effect);
|
||||
owned.hostEffects.add(effect);
|
||||
const releaseEffect = () => {
|
||||
hostEffects.delete(effect);
|
||||
owned.hostEffects.delete(effect);
|
||||
};
|
||||
void effect.then(releaseEffect, releaseEffect);
|
||||
return effect;
|
||||
}
|
||||
: undefined,
|
||||
onExecutionSettled: ({ retired }) => {
|
||||
if (retired) {
|
||||
retirement = rotateDatabaseWorkers(requestLane);
|
||||
}
|
||||
runWithSqliteDatabaseAdmissionTurn([database.path], () =>
|
||||
requestLane.pool.run(
|
||||
() => {
|
||||
assertRequestCurrent();
|
||||
const input = prepare();
|
||||
assertRequestCurrent();
|
||||
sequence = ++requestLane.nativeSequence;
|
||||
owned.nativeSequences.set(requestLane, sequence);
|
||||
return {
|
||||
...input,
|
||||
database,
|
||||
validation: validation?.validation,
|
||||
} satisfies SessionTranscriptWorkerRequest;
|
||||
},
|
||||
},
|
||||
{
|
||||
inputBytes: inputBytes + (validation?.inputBytes ?? 0),
|
||||
timeoutMs: remaining,
|
||||
signal: requestSignal,
|
||||
onRequest: onRequest
|
||||
? (value, context) => {
|
||||
const effect = (async () => {
|
||||
context.signal.throwIfAborted();
|
||||
assertRequestCurrent();
|
||||
const response = await onRequest(value, context.signal);
|
||||
context.signal.throwIfAborted();
|
||||
assertRequestCurrent();
|
||||
return response ?? { input: null, timeoutMs };
|
||||
})();
|
||||
hostEffects.add(effect);
|
||||
owned.hostEffects.add(effect);
|
||||
const releaseEffect = () => {
|
||||
hostEffects.delete(effect);
|
||||
owned.hostEffects.delete(effect);
|
||||
};
|
||||
void effect.then(releaseEffect, releaseEffect);
|
||||
return effect;
|
||||
}
|
||||
: undefined,
|
||||
onExecutionSettled: ({ retired }) => {
|
||||
if (retired) {
|
||||
retirement = rotateDatabaseWorkers(requestLane);
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
await retirement;
|
||||
|
|
@ -449,246 +437,6 @@ export function withSessionHistoryWorkerDatabase<T>(
|
|||
);
|
||||
}
|
||||
|
||||
/** Usage reads retain every physical store while compute and its admitted host effects settle. */
|
||||
export async function withSessionCostUsageWorkerDatabases<T>(
|
||||
options: readonly OpenClawAgentDatabaseOptions[],
|
||||
operation: (owner: SessionCostUsageWorkerScope) => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (options.length === 0) {
|
||||
throw new Error("Usage cost work requires its database owners");
|
||||
}
|
||||
const resources = new Set<HistoryDatabaseResource>();
|
||||
try {
|
||||
for (const databaseOptions of options) {
|
||||
const resource = acquireHistoryDatabaseResource(databaseOptions);
|
||||
if (!resources.has(resource)) {
|
||||
resources.add(resource);
|
||||
resource.pending++;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
for (const resource of resources) {
|
||||
resource.pending--;
|
||||
}
|
||||
pruneHistoryDatabases();
|
||||
throw error;
|
||||
}
|
||||
const pending = new Set<Promise<UsageCostWorkerResult>>();
|
||||
const cleanups = new Set<SessionDatabaseCleanup>();
|
||||
const lanes = new Map<SessionCostWorkerLane, { nativeThrough: number; failedThrough: number }>();
|
||||
let phase: "open" | "closing" | "closed" = "open";
|
||||
const assertCurrent = () => {
|
||||
if (phase === "closed" || [...resources].some((resource) => resource.revoked)) {
|
||||
throw new WorkerTaskError("Session usage database work was revoked", "unavailable");
|
||||
}
|
||||
};
|
||||
const settle = async () => {
|
||||
while (pending.size > 0) {
|
||||
await Promise.allSettled(pending);
|
||||
}
|
||||
for (const [lane, custody] of lanes) {
|
||||
if (custody.nativeThrough > lane.retiredSequence) {
|
||||
await lane.rotation;
|
||||
}
|
||||
if (custody.failedThrough > lane.retiredSequence) {
|
||||
await rotateDatabaseWorkers(lane);
|
||||
}
|
||||
}
|
||||
};
|
||||
const retainCleanup = (close: () => Promise<void>): (() => void) => {
|
||||
if (phase === "closed") {
|
||||
throw new WorkerTaskError("Session usage database scope is closed", "unavailable");
|
||||
}
|
||||
const runInContext = AsyncLocalStorage.snapshot();
|
||||
let released = false;
|
||||
let closing: Promise<void> | undefined;
|
||||
const release = () => {
|
||||
released = true;
|
||||
cleanups.delete(cleanup);
|
||||
for (const resource of resources) {
|
||||
resource.cleanups.delete(cleanup);
|
||||
}
|
||||
pruneHistoryDatabases();
|
||||
};
|
||||
const cleanup: SessionDatabaseCleanup = {
|
||||
run: () => {
|
||||
if (released) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
closing ??= (async () => {
|
||||
await settle();
|
||||
await runInContext(close);
|
||||
release();
|
||||
})().catch((error: unknown) => {
|
||||
closing = undefined;
|
||||
throw error;
|
||||
});
|
||||
return closing;
|
||||
},
|
||||
};
|
||||
cleanups.add(cleanup);
|
||||
for (const resource of resources) {
|
||||
resource.cleanups.add(cleanup);
|
||||
}
|
||||
return release;
|
||||
};
|
||||
const run = (
|
||||
input: UsageCostWorkerInput,
|
||||
runOptions: SessionCostUsageWorkerOptions,
|
||||
): Promise<UsageCostWorkerResult> => {
|
||||
assertCurrent();
|
||||
if (phase !== "open") {
|
||||
throw new WorkerTaskError("Session usage database scope is closing", "unavailable");
|
||||
}
|
||||
const lane = input.operation.kind === "refresh" ? costRefreshLane : costReadLane;
|
||||
const custody = lanes.get(lane) ?? { nativeThrough: 0, failedThrough: 0 };
|
||||
lanes.set(lane, custody);
|
||||
const controller = new AbortController();
|
||||
const signal = runOptions.signal
|
||||
? AbortSignal.any([controller.signal, runOptions.signal])
|
||||
: controller.signal;
|
||||
const abort = () =>
|
||||
controller.abort(
|
||||
new WorkerTaskError("Session usage database work was revoked", "unavailable"),
|
||||
);
|
||||
for (const resource of resources) {
|
||||
resource.aborters.add(abort);
|
||||
}
|
||||
historyClearTimeout(lane.idleTimer);
|
||||
lane.pending++;
|
||||
refreshDatabaseWorkerPressureSubscription();
|
||||
const hostEffects = new Set<Promise<WorkerTaskResponse>>();
|
||||
const onRequest = runOptions.onRequest;
|
||||
let sequence = 0;
|
||||
let executionSettled = false;
|
||||
const task = (async (): Promise<UsageCostWorkerResult> => {
|
||||
try {
|
||||
const reply = await lane.pool.run(
|
||||
() => {
|
||||
assertCurrent();
|
||||
signal.throwIfAborted();
|
||||
runOptions.beforeDispatch?.();
|
||||
sequence = ++lane.nativeSequence;
|
||||
custody.nativeThrough = sequence;
|
||||
for (const resource of resources) {
|
||||
resource.nativeSequences.set(lane, sequence);
|
||||
}
|
||||
return { ...input, databases: [...resources].map((resource) => resource.database) };
|
||||
},
|
||||
{
|
||||
...runOptions,
|
||||
signal,
|
||||
onExecutionSettled: ({ retired }) => {
|
||||
executionSettled = true;
|
||||
if (retired && sequence > 0) {
|
||||
releaseRetiredDatabaseCustody(lane, sequence);
|
||||
}
|
||||
},
|
||||
onRequest: onRequest
|
||||
? (value, context) => {
|
||||
const effect = createDeferredCore<WorkerTaskResponse>();
|
||||
hostEffects.add(effect.promise);
|
||||
for (const resource of resources) {
|
||||
resource.hostEffects.add(effect.promise);
|
||||
}
|
||||
const releaseEffect = () => {
|
||||
hostEffects.delete(effect.promise);
|
||||
for (const resource of resources) {
|
||||
resource.hostEffects.delete(effect.promise);
|
||||
}
|
||||
};
|
||||
void effect.promise.then(releaseEffect, releaseEffect);
|
||||
try {
|
||||
assertCurrent();
|
||||
context.signal.throwIfAborted();
|
||||
effect.resolve(onRequest(value, context));
|
||||
} catch (error) {
|
||||
effect.reject(error);
|
||||
}
|
||||
return effect.promise;
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
if (!reply.ok) {
|
||||
throw new UsageCostWorkerReplyError(reply.error);
|
||||
}
|
||||
clearClosedDatabaseCustody(lane, sequence, reply.closedDatabases);
|
||||
signal.throwIfAborted();
|
||||
assertCurrent();
|
||||
return reply.value;
|
||||
} catch (error) {
|
||||
if (sequence > 0 && !executionSettled) {
|
||||
custody.failedThrough = Math.max(custody.failedThrough, sequence);
|
||||
try {
|
||||
await rotateDatabaseWorkers(lane);
|
||||
} catch (cleanupError) {
|
||||
throw withSqliteWorkerCleanupFailure(
|
||||
toErrorObject(error, "Usage cost worker failed"),
|
||||
cleanupError,
|
||||
);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
// Native worker exit does not settle an already admitted host write.
|
||||
await Promise.allSettled(hostEffects);
|
||||
for (const resource of resources) {
|
||||
resource.aborters.delete(abort);
|
||||
}
|
||||
lane.pending--;
|
||||
pruneHistoryDatabases();
|
||||
armDatabaseWorkerIdleRetirement(lane);
|
||||
}
|
||||
})();
|
||||
pending.add(task);
|
||||
void task.then(
|
||||
() => pending.delete(task),
|
||||
() => pending.delete(task),
|
||||
);
|
||||
return task;
|
||||
};
|
||||
let result: { ok: true; value: T } | { ok: false; error: unknown };
|
||||
try {
|
||||
assertCurrent();
|
||||
const value = await operation({ assertCurrent, run, retainCleanup });
|
||||
assertCurrent();
|
||||
result = { ok: true, value };
|
||||
} catch (error) {
|
||||
result = { ok: false, error };
|
||||
}
|
||||
phase = "closing";
|
||||
try {
|
||||
await settle();
|
||||
for (const cleanup of cleanups) {
|
||||
await cleanup.run();
|
||||
}
|
||||
if (result.ok) {
|
||||
assertCurrent();
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
throw result.ok
|
||||
? cleanupError
|
||||
: withSqliteWorkerCleanupFailure(
|
||||
toErrorObject(result.error, "Usage cost operation failed"),
|
||||
cleanupError,
|
||||
);
|
||||
} finally {
|
||||
phase = "closed";
|
||||
for (const resource of resources) {
|
||||
resource.pending--;
|
||||
}
|
||||
pruneHistoryDatabases();
|
||||
for (const lane of lanes.keys()) {
|
||||
armDatabaseWorkerIdleRetirement(lane);
|
||||
}
|
||||
}
|
||||
if (!result.ok) {
|
||||
throw result.error;
|
||||
}
|
||||
return result.value;
|
||||
}
|
||||
|
||||
/** Process-held sources exchange bounded pages without reopening their memory database. */
|
||||
export async function runProcessHeldHistoryTask(
|
||||
request: import("./session-history-types.js").ChatHistoryDisplayRequest,
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ it("borrows canonical writer proof without a native reader and still fences fore
|
|||
const validation = {
|
||||
agentId: retained.agentId,
|
||||
identity: retained.identity,
|
||||
birthtime: retained.birthtime,
|
||||
receiptId: retained.receiptId,
|
||||
valid: retained.valid,
|
||||
canonicalReady: retained.canonicalReady,
|
||||
|
|
@ -210,7 +211,7 @@ it("transfers bounded history JSON without retaining the worker buffer", async (
|
|||
});
|
||||
|
||||
it.for([
|
||||
{ change: "newer schema", sql: "PRAGMA user_version = 999", error: /newer schema version/ },
|
||||
{ change: "newer schema", sql: "PRAGMA user_version = 999", error: undefined },
|
||||
{
|
||||
change: "missing table",
|
||||
sql: "DROP TABLE session_nodes",
|
||||
|
|
@ -222,7 +223,7 @@ it.for([
|
|||
error: undefined,
|
||||
},
|
||||
])(
|
||||
"revalidates a warm worker listing after a foreign $change",
|
||||
"keeps admitted format and observes row availability after a foreign $change",
|
||||
async ({ sql, error }, { signal }) => {
|
||||
await withOpenClawTestState({ scenario: "minimal" }, async (state) => {
|
||||
const options = { agentId: "main", env: state.env };
|
||||
|
|
@ -280,7 +281,7 @@ it.for([
|
|||
} else {
|
||||
expect(result).toMatchObject(expected);
|
||||
}
|
||||
// Refresh the retained admission, rather than hiding the bug with a cold reader.
|
||||
// The same retained reader handles the changed file.
|
||||
expect(countOpens()).toBe(1);
|
||||
if (!error) {
|
||||
const receipt = getOpenClawAgentDatabaseValidationForTransfer(database);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import fs from "node:fs";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { Worker } from "node:worker_threads";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
|
@ -45,6 +46,8 @@ describe("Gateway shared-state integrity readiness", () => {
|
|||
expect(await read()).toEqual([]);
|
||||
await closeOpenClawStateDatabaseAsync();
|
||||
|
||||
fs.renameSync(pathname, `${pathname}.template`);
|
||||
fs.copyFileSync(`${pathname}.template`, pathname, fs.constants.COPYFILE_EXCL);
|
||||
const database = new DatabaseSync(pathname);
|
||||
try {
|
||||
database.exec(`INSERT INTO audit_events
|
||||
|
|
|
|||
|
|
@ -1,10 +1,86 @@
|
|||
import path from "node:path";
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { expect, it, type Mock, type vi } from "vitest";
|
||||
import type {
|
||||
AgentDeletionJournalCleanupPath,
|
||||
AgentDeletionJournalEntry,
|
||||
} from "../../state/agent-deletion-journal.js";
|
||||
|
||||
type MockIdentity = {
|
||||
name?: string;
|
||||
theme?: string;
|
||||
emoji?: string;
|
||||
avatar?: string;
|
||||
};
|
||||
|
||||
export type MockAgentEntry = {
|
||||
id: string;
|
||||
name?: string;
|
||||
workspace?: string;
|
||||
agentDir?: string;
|
||||
model?: string;
|
||||
identity?: MockIdentity;
|
||||
};
|
||||
|
||||
export type MockConfig = {
|
||||
agents?: {
|
||||
entries?: Record<string, Omit<MockAgentEntry, "id">>;
|
||||
};
|
||||
};
|
||||
|
||||
export function getAgentList(cfg: unknown): MockAgentEntry[] {
|
||||
return Object.entries((cfg as MockConfig | undefined)?.agents?.entries ?? {}).map(([id, entry]) =>
|
||||
Object.assign({}, entry, { id }),
|
||||
);
|
||||
}
|
||||
|
||||
export function mergeAgentConfig(cfg: unknown, opts: unknown): MockConfig {
|
||||
const config = (cfg as MockConfig | undefined) ?? {};
|
||||
const params = (opts as {
|
||||
agentId?: string;
|
||||
name?: string;
|
||||
workspace?: string;
|
||||
agentDir?: string;
|
||||
model?: string | null;
|
||||
identity?: MockIdentity;
|
||||
}) ?? { agentId: "" };
|
||||
const list = getAgentList(config);
|
||||
const agentId = params.agentId ?? "";
|
||||
const index = list.findIndex((entry) => entry.id === agentId);
|
||||
const base = index >= 0 ? expectDefined(list[index], "existing agent entry") : { id: agentId };
|
||||
const nextEntry: MockAgentEntry = {
|
||||
...base,
|
||||
...(params.name ? { name: params.name } : {}),
|
||||
...(params.workspace ? { workspace: params.workspace } : {}),
|
||||
...(params.agentDir ? { agentDir: params.agentDir } : {}),
|
||||
...(params.model ? { model: params.model } : {}),
|
||||
...(params.identity ? { identity: { ...base.identity, ...params.identity } } : {}),
|
||||
};
|
||||
if (params.model === null) {
|
||||
delete nextEntry.model;
|
||||
}
|
||||
if (index >= 0) {
|
||||
list[index] = nextEntry;
|
||||
} else {
|
||||
list.push(nextEntry);
|
||||
}
|
||||
return {
|
||||
...config,
|
||||
agents: {
|
||||
...config.agents,
|
||||
entries: Object.fromEntries(list.map(({ id, ...entry }) => [id, entry])),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveMockWorkspaceDir(cfg: unknown, agentId?: string): string {
|
||||
const resolvedAgentId = agentId ?? "";
|
||||
return (
|
||||
getAgentList(cfg).find((entry) => entry.id === resolvedAgentId)?.workspace ??
|
||||
`/workspace/${resolvedAgentId}`
|
||||
);
|
||||
}
|
||||
|
||||
export function cleanupPath(
|
||||
pathname: string,
|
||||
overrides: Partial<AgentDeletionJournalCleanupPath> = {},
|
||||
|
|
|
|||
|
|
@ -19,9 +19,14 @@ import {
|
|||
expectRespondErrorContaining,
|
||||
expectRespondOk,
|
||||
firstRespondResult,
|
||||
getAgentList,
|
||||
makeFileStat,
|
||||
mergeAgentConfig,
|
||||
mockCallArg,
|
||||
registerAgentCreationCommitTests,
|
||||
resolveMockWorkspaceDir,
|
||||
type MockAgentEntry,
|
||||
type MockConfig,
|
||||
} from "./agents-mutate.test-support.js";
|
||||
const mocks = vi.hoisted(() => ({
|
||||
sharedAuthStoreOwnership: { location: "legacy-main" } as {
|
||||
|
|
@ -560,81 +565,6 @@ async function call(method: keyof typeof agentsHandlers, params: Record<string,
|
|||
return respond;
|
||||
}
|
||||
|
||||
type MockIdentity = {
|
||||
name?: string;
|
||||
theme?: string;
|
||||
emoji?: string;
|
||||
avatar?: string;
|
||||
};
|
||||
|
||||
type MockAgentEntry = {
|
||||
id: string;
|
||||
name?: string;
|
||||
workspace?: string;
|
||||
agentDir?: string;
|
||||
model?: string;
|
||||
identity?: MockIdentity;
|
||||
};
|
||||
|
||||
type MockConfig = {
|
||||
agents?: {
|
||||
entries?: Record<string, Omit<MockAgentEntry, "id">>;
|
||||
};
|
||||
};
|
||||
|
||||
function getAgentList(cfg: unknown): MockAgentEntry[] {
|
||||
return Object.entries((cfg as MockConfig | undefined)?.agents?.entries ?? {}).map(([id, entry]) =>
|
||||
Object.assign({}, entry, { id }),
|
||||
);
|
||||
}
|
||||
|
||||
function mergeAgentConfig(cfg: unknown, opts: unknown): MockConfig {
|
||||
const config = (cfg as MockConfig | undefined) ?? {};
|
||||
const params = (opts as {
|
||||
agentId?: string;
|
||||
name?: string;
|
||||
workspace?: string;
|
||||
agentDir?: string;
|
||||
model?: string | null;
|
||||
identity?: MockIdentity;
|
||||
}) ?? { agentId: "" };
|
||||
const list = getAgentList(config);
|
||||
const agentId = params.agentId ?? "";
|
||||
const index = list.findIndex((entry) => entry.id === agentId);
|
||||
const base = index >= 0 ? expectDefined(list[index], "existing agent entry") : { id: agentId };
|
||||
const nextEntry: MockAgentEntry = {
|
||||
...base,
|
||||
...(params.name ? { name: params.name } : {}),
|
||||
...(params.workspace ? { workspace: params.workspace } : {}),
|
||||
...(params.agentDir ? { agentDir: params.agentDir } : {}),
|
||||
...(params.model ? { model: params.model } : {}),
|
||||
...(params.identity ? { identity: { ...base.identity, ...params.identity } } : {}),
|
||||
};
|
||||
if (params.model === null) {
|
||||
delete nextEntry.model;
|
||||
}
|
||||
if (index >= 0) {
|
||||
list[index] = nextEntry;
|
||||
} else {
|
||||
list.push(nextEntry);
|
||||
}
|
||||
return {
|
||||
...config,
|
||||
agents: {
|
||||
...config.agents,
|
||||
entries: Object.fromEntries(list.map(({ id, ...entry }) => [id, entry])),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function resolveMockWorkspaceDir(cfg: unknown, agentId?: string): string {
|
||||
const resolvedAgentId = agentId ?? "";
|
||||
return (
|
||||
getAgentList(cfg).find((entry) => entry.id === resolvedAgentId)?.workspace ??
|
||||
`/workspace/${resolvedAgentId}`
|
||||
);
|
||||
}
|
||||
|
||||
async function listAgentFileNames(agentId = "main") {
|
||||
const respond = await call("agents.files.list", { agentId });
|
||||
|
||||
|
|
@ -1578,7 +1508,14 @@ describe("agents.delete", () => {
|
|||
"test-agent",
|
||||
);
|
||||
expect(mocks.assertNoOpenClawAgentDatabaseLeases).toHaveBeenCalledWith("test-agent", {});
|
||||
expect(mocks.closeDeletedAgentDatabases).toHaveBeenCalledWith("test-agent", []);
|
||||
expect(mocks.closeDeletedAgentDatabases).toHaveBeenCalledWith(
|
||||
"test-agent",
|
||||
[],
|
||||
expect.objectContaining({
|
||||
assertCurrentFinal: mocks.assertAgentDeletionCurrentFinal,
|
||||
assertCurrentAsync: mocks.assertAgentDeletionCurrentAsync,
|
||||
}),
|
||||
);
|
||||
expectNotTrashed("/journal/agent");
|
||||
expectNotTrashed("/journal");
|
||||
expectTrashedWithinParent("/deleted/sessions");
|
||||
|
|
@ -1659,9 +1596,14 @@ describe("agents.delete", () => {
|
|||
);
|
||||
expectNotTrashed("/linked/shared/agent.sqlite");
|
||||
expectNotTrashed("/linked/shared/agent.sqlite-wal");
|
||||
expect(mocks.closeDeletedAgentDatabases).toHaveBeenCalledWith("test-agent", [
|
||||
"/agents/test-agent/openclaw-agent.sqlite",
|
||||
]);
|
||||
expect(mocks.closeDeletedAgentDatabases).toHaveBeenCalledWith(
|
||||
"test-agent",
|
||||
["/agents/test-agent/openclaw-agent.sqlite"],
|
||||
expect.objectContaining({
|
||||
assertCurrentFinal: mocks.assertAgentDeletionCurrentFinal,
|
||||
assertCurrentAsync: mocks.assertAgentDeletionCurrentAsync,
|
||||
}),
|
||||
);
|
||||
expect(mocks.beginAgentDeletionFinish).toHaveBeenCalledWith({ unregisterDatabases: true });
|
||||
expect(mocks.beginAgentDeletionFinish).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -613,9 +613,8 @@ export const agentsHandlers: GatewayRequestHandlers = {
|
|||
await deletion.assertCurrentAsync();
|
||||
const { closeDeletedAgentDatabases } =
|
||||
await import("../../state/openclaw-agent-db-readers.js");
|
||||
await deletion.assertCurrentAsync();
|
||||
await closeDeletedAgentDatabases(agentId, databasePlan?.readerPaths ?? []);
|
||||
await deletion.assertCurrentAsync();
|
||||
const readerPaths = databasePlan?.readerPaths ?? [];
|
||||
await closeDeletedAgentDatabases(agentId, readerPaths, deletion);
|
||||
|
||||
const removed: AgentDeleteRemovedPath[] = [];
|
||||
const failed: AgentDeleteFailedPath[] = [];
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import {
|
|||
import { SessionTranscriptProjectionUnavailableError } from "../config/sessions/session-transcript-projection-error.js";
|
||||
import { resolveSessionTranscriptReadFence } from "../config/sessions/session-transcript-read-fence.js";
|
||||
import { pruneMapToMaxSize } from "../infra/map-size.js";
|
||||
import { hasSqlitePostCommitScope } from "../infra/sqlite-post-commit.js";
|
||||
import { hasUncommittedSqliteWrites } from "../infra/sqlite-schema-facts.js";
|
||||
import { isImageMediaFact, readPersistedMediaFacts } from "../media/media-facts.js";
|
||||
import type { TranscriptReadWindow } from "../sessions/transcript-read-window.js";
|
||||
import {
|
||||
|
|
@ -345,7 +345,7 @@ export function readArtifactSummariesFromProjection(
|
|||
query: Extract<SessionArtifactReadQuery, { kind: "list" }>,
|
||||
): ArtifactRecord[] {
|
||||
const cacheable =
|
||||
!hasSqlitePostCommitScope(projection.database.db) &&
|
||||
!hasUncommittedSqliteWrites(projection.database.db) &&
|
||||
!resolveSessionTranscriptReadFence(projection.resolved);
|
||||
let cache = summaryLists.get(projection.database.db);
|
||||
if (!cache && cacheable) {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import {
|
|||
addSessionMember,
|
||||
removeSessionMember,
|
||||
} from "../config/sessions/session-sharing-store.native.js";
|
||||
import { OPENCLAW_AGENT_SCHEMA_VERSION } from "../state/openclaw-agent-db-contract.js";
|
||||
import { registerOpenClawAgentDatabase } from "../state/openclaw-agent-db-registry.js";
|
||||
import { openOpenClawAgentDatabase } from "../state/openclaw-agent-db.js";
|
||||
import { openOpenClawStateDatabase } from "../state/openclaw-state-db.js";
|
||||
|
|
@ -43,14 +42,9 @@ function inWriterTransaction(db: DatabaseSync, check: () => void) {
|
|||
}
|
||||
|
||||
describe("committed session mutation authorization", () => {
|
||||
it.each([
|
||||
{ mode: "resident", revocation: "membership" },
|
||||
{ mode: "fixed", revocation: "membership" },
|
||||
{ mode: "fixed", revocation: "schema-owner" },
|
||||
{ mode: "fixed", revocation: "schema-version" },
|
||||
] as const)(
|
||||
"keeps $mode worker grants current after $revocation changes without shared-state reads",
|
||||
async ({ mode, revocation }) => {
|
||||
it.each(["resident", "fixed"] as const)(
|
||||
"keeps %s worker grants current after membership changes without shared-state reads",
|
||||
async (mode) => {
|
||||
await withOpenClawTestState({ scenario: "minimal" }, async (state) => {
|
||||
const cfg = rolePolicyConfig();
|
||||
if (mode === "fixed") {
|
||||
|
|
@ -159,17 +153,9 @@ describe("committed session mutation authorization", () => {
|
|||
} else {
|
||||
const foreign = new DatabaseSync(source.path);
|
||||
try {
|
||||
if (revocation === "schema-owner") {
|
||||
foreign
|
||||
.prepare("UPDATE schema_meta SET agent_id = ? WHERE meta_key = 'primary'")
|
||||
.run("another-owner");
|
||||
} else if (revocation === "schema-version") {
|
||||
foreign.exec(`PRAGMA user_version = ${OPENCLAW_AGENT_SCHEMA_VERSION + 1}`);
|
||||
} else {
|
||||
foreign
|
||||
.prepare("DELETE FROM session_members WHERE session_key = ? AND identity_id = ?")
|
||||
.run(sessionKey, identityId);
|
||||
}
|
||||
foreign
|
||||
.prepare("DELETE FROM session_members WHERE session_key = ? AND identity_id = ?")
|
||||
.run(sessionKey, identityId);
|
||||
} finally {
|
||||
foreign.close();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -125,26 +125,32 @@ it.each([false, true])(
|
|||
async (reclaimed) => {
|
||||
const root = dirs.make("backup-scratch-create-open-");
|
||||
const open = nodeSqlite.openNodeSqliteDatabase;
|
||||
const createDirectory = privateDirectory.createPrivateSqliteTempDirectory;
|
||||
let failedDirectory: string | undefined;
|
||||
let nativeFailure: unknown;
|
||||
vi.spyOn(nodeSqlite, "openNodeSqliteDatabase").mockImplementation((location, options) => {
|
||||
if (failedDirectory) {
|
||||
return open(location, options);
|
||||
}
|
||||
failedDirectory = path.dirname(location);
|
||||
if (reclaimed) {
|
||||
fsSync.rmSync(failedDirectory, { recursive: true });
|
||||
}
|
||||
try {
|
||||
return open(
|
||||
reclaimed ? location : path.join(failedDirectory, "missing", "owner.sqlite"),
|
||||
options,
|
||||
vi.spyOn(privateDirectory, "createPrivateSqliteTempDirectory").mockImplementationOnce(
|
||||
async (...args) => {
|
||||
const directory = await createDirectory(...args);
|
||||
vi.spyOn(nodeSqlite, "openNodeSqliteDatabase").mockImplementationOnce(
|
||||
(location, options) => {
|
||||
failedDirectory = directory;
|
||||
if (reclaimed) {
|
||||
fsSync.rmSync(directory, { recursive: true });
|
||||
}
|
||||
try {
|
||||
return open(
|
||||
reclaimed ? location : path.join(directory, "missing", "owner.sqlite"),
|
||||
options,
|
||||
);
|
||||
} catch (error) {
|
||||
nativeFailure = error;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
nativeFailure = error;
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
return directory;
|
||||
},
|
||||
);
|
||||
let scratch: Awaited<ReturnType<typeof createBackupScratchDirectory>> | undefined;
|
||||
try {
|
||||
const outcome = await createBackupScratchDirectory(root).then(
|
||||
|
|
|
|||
|
|
@ -48,8 +48,8 @@ export async function createBackupScratchDirectory(root: string): Promise<Backup
|
|||
if (
|
||||
isSqliteLockError(error) ||
|
||||
error instanceof SqliteStagingRetiredError ||
|
||||
isMissingPathError(error) ||
|
||||
((isSqliteNativeOpenFailure(error) ||
|
||||
((isMissingPathError(error) ||
|
||||
isSqliteNativeOpenFailure(error) ||
|
||||
(error instanceof FsSafeError &&
|
||||
error.code === "path-mismatch" &&
|
||||
isMissingPathError(error.cause))) &&
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { getEnvironmentData, isMainThread, setEnvironmentData } from "node:worke
|
|||
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
|
||||
import type { probeSqliteNativeClose } from "./bun-sqlite-close-probe.js";
|
||||
import { parseDiagnosticEnvFlags } from "./diagnostic-flags-env.js";
|
||||
import { SQLITE_DATABASE_ADMISSIONS_KEY } from "./sqlite-database-admission-key.js";
|
||||
import { isSqliteWalResetSafeVersion } from "./sqlite-runtime-version.js";
|
||||
|
||||
export type SqliteLibrarySelection =
|
||||
|
|
@ -329,6 +330,7 @@ export function captureSqliteWorkerEnvironmentData(): ReadonlyArray<
|
|||
SQLITE_EXPECTED_SCHEMA_CONTRACTS_KEY,
|
||||
getEnvironmentData(SQLITE_EXPECTED_SCHEMA_CONTRACTS_KEY),
|
||||
],
|
||||
[SQLITE_DATABASE_ADMISSIONS_KEY, getEnvironmentData(SQLITE_DATABASE_ADMISSIONS_KEY)],
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import "../test-utils/prepare-compiled-subprocesses.js";
|
|||
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
|
||||
import { createDeferredCore } from "../shared/deferred.js";
|
||||
import type { RuntimeWorkerGeneration } from "./runtime-worker-generation.js";
|
||||
import { SQLITE_DATABASE_ADMISSIONS_KEY } from "./sqlite-database-admission-key.js";
|
||||
import type { SqliteWorkerBroker } from "./sqlite-worker-broker.js";
|
||||
import type { SqliteWorkerStore } from "./sqlite-worker-contract.js";
|
||||
import type { FixtureOperations } from "./sqlite-worker-store.test-support.js";
|
||||
|
|
@ -424,15 +425,23 @@ describe("Bun SQLite process selection and worker inheritance", () => {
|
|||
{ bun: false, platform: "darwin" },
|
||||
{ bun: true, platform: "linux" },
|
||||
])(
|
||||
"leaves worker environment facts and native selection untouched on $platform (Bun: $bun)",
|
||||
"leaves library-selection facts and native selection untouched on $platform (Bun: $bun)",
|
||||
async ({ bun, platform }) => {
|
||||
setRuntime(bun, platform);
|
||||
runtime.getEnvironmentData.mockImplementation(() => {
|
||||
runtime.getEnvironmentData.mockImplementation((key) => {
|
||||
if (key === SQLITE_DATABASE_ADMISSIONS_KEY) {
|
||||
return undefined;
|
||||
}
|
||||
throw new Error("Unrelated worker data must not be read");
|
||||
});
|
||||
const worker = await enterWorkerHeap();
|
||||
expect(worker.ensureSqliteLibrarySelected()).toMatchObject({ source: "runtime" });
|
||||
expect(runtime.getEnvironmentData).not.toHaveBeenCalled();
|
||||
expect(runtime.getEnvironmentData).not.toHaveBeenCalledWith(
|
||||
"openclaw.bunSqliteLibrarySelection",
|
||||
);
|
||||
expect(runtime.getEnvironmentData).not.toHaveBeenCalledWith(
|
||||
"openclaw.sqliteRuntimeCapabilities",
|
||||
);
|
||||
expect(runtime.setEnvironmentData).not.toHaveBeenCalled();
|
||||
expect(runtime.dlopen).not.toHaveBeenCalled();
|
||||
expect(runtime.select).not.toHaveBeenCalled();
|
||||
|
|
@ -563,7 +572,13 @@ describe("Bun SQLite process selection and worker inheritance", () => {
|
|||
await Promise.allSettled([closing, ...(reopening ? [reopening] : [])]);
|
||||
}
|
||||
expect(runtime.select).toHaveBeenCalledExactlyOnceWith("/fixture/sqlite.dylib");
|
||||
expect(runtime.setEnvironmentData).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
runtime.setEnvironmentData.mock.calls.filter(([key]) =>
|
||||
["openclaw.bunSqliteLibrarySelection", "openclaw.sqliteRuntimeCapabilities"].includes(
|
||||
String(key),
|
||||
),
|
||||
),
|
||||
).toHaveLength(2);
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -78,22 +78,33 @@ function expectedUnsafeSqliteError(version: string, shared: boolean): string {
|
|||
|
||||
describe("node SQLite locations", () => {
|
||||
const dirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
it("writes existing URI-escaped paths but never creates a missing database", () => {
|
||||
const pathname = path.join(dirs.make("sqlite-existing-uri-"), "state ?#%.sqlite");
|
||||
const uri = resolveExistingSqliteFileUri(pathname);
|
||||
expect(() => openNodeSqliteDatabase(uri)).toThrow();
|
||||
expect(fs.existsSync(pathname)).toBe(false);
|
||||
const initial = openNodeSqliteDatabase(pathname);
|
||||
initial.exec("CREATE TABLE retained(value TEXT) STRICT");
|
||||
initial.close();
|
||||
const existing = openNodeSqliteDatabase(uri);
|
||||
try {
|
||||
existing.prepare("INSERT INTO retained(value) VALUES(?)").run("written");
|
||||
expect(existing.prepare("SELECT value FROM retained").all()).toEqual([{ value: "written" }]);
|
||||
} finally {
|
||||
existing.close();
|
||||
}
|
||||
});
|
||||
it.each(["absolute", "relative"])(
|
||||
"writes existing %s URI-escaped paths without creating missing databases",
|
||||
(kind) => {
|
||||
const pathname = path.join(
|
||||
dirs.make(".sqlite-existing-uri-", kind === "relative" ? process.cwd() : undefined),
|
||||
"state ?#%.sqlite",
|
||||
);
|
||||
const uri =
|
||||
kind === "relative"
|
||||
? `file:${path.relative(process.cwd(), pathname).split(path.sep).map(encodeURIComponent).join("/")}?mode=rw`
|
||||
: resolveExistingSqliteFileUri(pathname);
|
||||
expect(() => openNodeSqliteDatabase(uri)).toThrow();
|
||||
expect(fs.existsSync(pathname)).toBe(false);
|
||||
const initial = openNodeSqliteDatabase(uri.replace(/\?mode=rw$/u, ""));
|
||||
initial.exec("CREATE TABLE retained(value TEXT) STRICT");
|
||||
initial.close();
|
||||
const existing = openNodeSqliteDatabase(uri);
|
||||
try {
|
||||
existing.prepare("INSERT INTO retained(value) VALUES(?)").run("written");
|
||||
expect(existing.prepare("SELECT value FROM retained").all()).toEqual([
|
||||
{ value: "written" },
|
||||
]);
|
||||
} finally {
|
||||
existing.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
it("preserves Windows long paths in non-creating writable URIs", () => {
|
||||
const pathname = String.raw`C:\deep state\openclaw.sqlite`;
|
||||
expect(resolveExistingSqliteFileUri(pathname, "win32")).toBe(
|
||||
|
|
@ -117,8 +128,21 @@ describe("node SQLite locations", () => {
|
|||
expect(resolveNodeSqliteLocation("relative/openclaw.sqlite")).toBe("relative/openclaw.sqlite");
|
||||
});
|
||||
|
||||
it("opens special locations through the shared connection boundary", () => {
|
||||
const database = openNodeSqliteDatabase(":memory:", { timeout: 5000 });
|
||||
it.each([
|
||||
":memory:",
|
||||
"file::memory:",
|
||||
"file::memory:?cache=shared",
|
||||
"file:checkonce-memory?mode=memory&cache=shared",
|
||||
])("opens in-memory location %s without creating a disk file", (location) => {
|
||||
const open = fs.openSync;
|
||||
vi.spyOn(fs, "openSync").mockImplementation((filename, flags, mode) => {
|
||||
// Keep the regression from leaving a literal :memory: file in the checkout.
|
||||
if (filename === ":memory:") {
|
||||
throw new Error("In-memory SQLite attempted to create a disk file");
|
||||
}
|
||||
return open(filename, flags, mode);
|
||||
});
|
||||
const database = openNodeSqliteDatabase(location, { timeout: 5000 });
|
||||
try {
|
||||
expect(database.prepare("PRAGMA busy_timeout").get()).toEqual({ timeout: 5000 });
|
||||
const identity = " a\0🦞 ";
|
||||
|
|
@ -214,6 +238,10 @@ describe("node SQLite safety", () => {
|
|||
expect(captureSqliteNativeRuntimeAdmission()).toMatchObject({
|
||||
version: "3.51.3",
|
||||
extensionLoadingSupported: omitted === 0,
|
||||
iteratorBehavior: {
|
||||
nextAfterDoneIsTerminal: expect.any(Boolean),
|
||||
returnAfterDoneIsInert: expect.any(Boolean),
|
||||
},
|
||||
});
|
||||
expect(prepare).toHaveBeenCalledOnce();
|
||||
},
|
||||
|
|
@ -297,6 +325,7 @@ describe("node SQLite safety", () => {
|
|||
const native = mockBunSqliteNativeBoundary({ isBun: false });
|
||||
const parent = await loadNodeSqliteWithVersion("3.44.6", 1);
|
||||
parent.requireNodeSqlite();
|
||||
const iteratorBehavior = parent.captureSqliteNativeRuntimeAdmission()?.iteratorBehavior;
|
||||
parent.prepare.mockRestore();
|
||||
const receiptKey = "openclaw.sqliteNativeRuntimeAdmission";
|
||||
const receipt = native.environment.get(receiptKey);
|
||||
|
|
@ -343,6 +372,9 @@ describe("node SQLite safety", () => {
|
|||
expect(worker.supportsNodeSqliteWalCheckpointNoop()).toBe(changed !== "unchanged");
|
||||
if (changed === "unchanged") {
|
||||
expect(worker.prepare).not.toHaveBeenCalled();
|
||||
expect(worker.captureSqliteNativeRuntimeAdmission()?.iteratorBehavior).toEqual(
|
||||
iteratorBehavior,
|
||||
);
|
||||
} else {
|
||||
expect(worker.prepare).toHaveBeenCalled();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,13 +13,23 @@ import {
|
|||
import { formatErrorMessage } from "./errors.js";
|
||||
import { registerNodeSqliteDisposeCallback } from "./kysely-sync-cache-state.js";
|
||||
import { compareValidSemver } from "./semver.js";
|
||||
import {
|
||||
bindSqliteDatabaseAdmission,
|
||||
prepareSqliteDatabaseAdmission,
|
||||
} from "./sqlite-database-admission.js";
|
||||
import {
|
||||
probeSqliteIteratorBehavior,
|
||||
type SqliteIteratorBehavior,
|
||||
} from "./sqlite-native-observer.js";
|
||||
import { registerSqliteReaderConnection } from "./sqlite-reader-lifecycle.js";
|
||||
import { isSqliteWalResetSafeVersion } from "./sqlite-runtime-version.js";
|
||||
import { trackSqliteSchema } from "./sqlite-schema-facts.js";
|
||||
import { installProcessWarningFilter } from "./warning-filter.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
let validatedSqliteModule: typeof import("node:sqlite") | undefined;
|
||||
let validatedSqliteModule:
|
||||
| { sqlite: typeof import("node:sqlite"); iteratorBehavior: SqliteIteratorBehavior }
|
||||
| undefined;
|
||||
let extensionLoadingSupported = false;
|
||||
let jsonbSupported = false;
|
||||
let walCheckpointNoopSupported = false;
|
||||
|
|
@ -106,6 +116,7 @@ type SqliteNativeRuntimeAdmission = {
|
|||
runtime: ReturnType<typeof sqliteNativeRuntimeIdentity>;
|
||||
version: string;
|
||||
extensionLoadingSupported: boolean;
|
||||
iteratorBehavior: SqliteIteratorBehavior;
|
||||
};
|
||||
|
||||
function parseSqliteNativeRuntimeAdmission(
|
||||
|
|
@ -115,7 +126,10 @@ function parseSqliteNativeRuntimeAdmission(
|
|||
!isRecord(value) ||
|
||||
value.format !== 1 ||
|
||||
typeof value.version !== "string" ||
|
||||
typeof value.extensionLoadingSupported !== "boolean"
|
||||
typeof value.extensionLoadingSupported !== "boolean" ||
|
||||
!isRecord(value.iteratorBehavior) ||
|
||||
typeof value.iteratorBehavior.nextAfterDoneIsTerminal !== "boolean" ||
|
||||
typeof value.iteratorBehavior.returnAfterDoneIsInert !== "boolean"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -128,6 +142,10 @@ function parseSqliteNativeRuntimeAdmission(
|
|||
runtime,
|
||||
version: value.version,
|
||||
extensionLoadingSupported: value.extensionLoadingSupported,
|
||||
iteratorBehavior: {
|
||||
nextAfterDoneIsTerminal: value.iteratorBehavior.nextAfterDoneIsTerminal,
|
||||
returnAfterDoneIsInert: value.iteratorBehavior.returnAfterDoneIsInert,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -154,9 +172,9 @@ export function installSqliteNativeRuntimeAdmission(value: unknown): void {
|
|||
}
|
||||
}
|
||||
|
||||
function assertSafeSqliteRuntime(sqlite: typeof import("node:sqlite")): void {
|
||||
if (validatedSqliteModule === sqlite) {
|
||||
return;
|
||||
function assertSafeSqliteRuntime(sqlite: typeof import("node:sqlite")): SqliteIteratorBehavior {
|
||||
if (validatedSqliteModule?.sqlite === sqlite) {
|
||||
return validatedSqliteModule.iteratorBehavior;
|
||||
}
|
||||
const inherited = isMainThread
|
||||
? undefined
|
||||
|
|
@ -167,36 +185,39 @@ function assertSafeSqliteRuntime(sqlite: typeof import("node:sqlite")): void {
|
|||
jsonbSupported = (compareValidSemver(inherited.version, "3.45.0") ?? -1) >= 0;
|
||||
walCheckpointNoopSupported = (compareValidSemver(inherited.version, "3.53.0") ?? -1) >= 0;
|
||||
extensionLoadingSupported = inherited.extensionLoadingSupported;
|
||||
validatedSqliteModule = sqlite;
|
||||
return;
|
||||
validatedSqliteModule = { sqlite, iteratorBehavior: inherited.iteratorBehavior };
|
||||
return inherited.iteratorBehavior;
|
||||
}
|
||||
// Shared-SQLite Node builds can load a different library than process.versions
|
||||
// reports, so query the loaded library before callers open real state databases.
|
||||
const database = new sqlite.DatabaseSync(":memory:");
|
||||
let version: string;
|
||||
let extensions: boolean;
|
||||
let iteratorBehavior: SqliteIteratorBehavior;
|
||||
try {
|
||||
const row = database
|
||||
.prepare(
|
||||
"SELECT sqlite_version() AS version, sqlite_compileoption_used('OMIT_LOAD_EXTENSION') AS omitted",
|
||||
)
|
||||
.get() as { version?: unknown; omitted?: unknown } | undefined;
|
||||
const statement = database.prepare(
|
||||
"SELECT sqlite_version() AS version, sqlite_compileoption_used('OMIT_LOAD_EXTENSION') AS omitted",
|
||||
);
|
||||
const row = statement.get() as { version?: unknown; omitted?: unknown } | undefined;
|
||||
version = typeof row?.version === "string" ? row.version : "unknown";
|
||||
assertSqliteWalResetSafeVersion(version, process.versions.node);
|
||||
extensions = row?.omitted === 0;
|
||||
iteratorBehavior = probeSqliteIteratorBehavior(statement);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
jsonbSupported = (compareValidSemver(version, "3.45.0") ?? -1) >= 0;
|
||||
walCheckpointNoopSupported = (compareValidSemver(version, "3.53.0") ?? -1) >= 0;
|
||||
extensionLoadingSupported = extensions;
|
||||
validatedSqliteModule = sqlite;
|
||||
validatedSqliteModule = { sqlite, iteratorBehavior };
|
||||
setEnvironmentData(SQLITE_NATIVE_RUNTIME_ADMISSION_KEY, {
|
||||
format: 1,
|
||||
runtime: sqliteNativeRuntimeIdentity(),
|
||||
version,
|
||||
extensionLoadingSupported: extensions,
|
||||
iteratorBehavior,
|
||||
});
|
||||
return iteratorBehavior;
|
||||
}
|
||||
|
||||
// node:sqlite is optional across Node versions, so callers get a clear runtime
|
||||
|
|
@ -244,9 +265,37 @@ export function openNodeSqliteDatabase(
|
|||
// Callers may pass file: URIs or already-namespaced paths from specialized
|
||||
// resolvers; location normalization must remain idempotent for those forms.
|
||||
const resolvedLocation = resolveNodeSqliteLocation(location);
|
||||
const identity =
|
||||
options?.open === false
|
||||
? undefined
|
||||
: prepareSqliteDatabaseAdmission(resolvedLocation, { create: options?.readOnly !== true });
|
||||
const database = new sqlite.DatabaseSync(resolvedLocation, options ?? {});
|
||||
if (database.isOpen) {
|
||||
try {
|
||||
bindSqliteDatabaseAdmission(database, identity);
|
||||
} catch (error) {
|
||||
database.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
database.open = () => {
|
||||
const reopenedIdentity = prepareSqliteDatabaseAdmission(resolvedLocation, {
|
||||
create: options?.readOnly !== true,
|
||||
});
|
||||
sqlite.DatabaseSync.prototype.open.call(database);
|
||||
try {
|
||||
bindSqliteDatabaseAdmission(database, reopenedIdentity);
|
||||
} catch (error) {
|
||||
database.close();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
// Schema tracking must precede the statement-cache authorizer wrapper.
|
||||
trackSqliteSchema(database, sqlite);
|
||||
trackSqliteSchema(database, {
|
||||
DatabaseSync: sqlite.DatabaseSync,
|
||||
StatementSync: sqlite.StatementSync,
|
||||
iteratorBehavior: assertSafeSqliteRuntime(sqlite),
|
||||
});
|
||||
if (!getSqliteRuntimeCapabilities().explicitSqliteCloseReleasesNativeResources) {
|
||||
registerNodeSqliteDisposeCallback(database, () => {
|
||||
bunSqliteNativeCleanupPending = true;
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ import { restoreSessionColdTranscript } from "../config/sessions/session-cold-st
|
|||
import { listDurableSqliteTargetPathsForSessionStorePath } from "../config/sessions/session-sqlite-target.js";
|
||||
import { resolveSessionStorePathForScope } from "../config/sessions/session-store-path.js";
|
||||
import { createMemoryTranscriptProjectionSource } from "../config/sessions/session-transcript-reconcile-memory.js";
|
||||
import { withSessionCostUsageWorkerDatabases } from "../config/sessions/session-transcript-worker-runtime.js";
|
||||
import { resolveStateDir } from "../config/state-dir.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
|
|
@ -60,6 +59,7 @@ import {
|
|||
} from "./session-cost-usage-pricing-context.js";
|
||||
import type { UsageCostResolver } from "./session-cost-usage-pricing.js";
|
||||
import { openUsageCostRefreshFailures } from "./session-cost-usage-refresh-health.js";
|
||||
import { withSessionCostUsageWorkerDatabases } from "./session-cost-usage-worker-scope.js";
|
||||
import {
|
||||
UsageCostWorkerReplyError,
|
||||
type UsageCostWorkerHostEffects,
|
||||
|
|
|
|||
274
src/infra/session-cost-usage-worker-scope.ts
Normal file
274
src/infra/session-cost-usage-worker-scope.ts
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import { toErrorObject } from "@openclaw/normalization-core/error-coercion";
|
||||
import {
|
||||
acquireHistoryDatabaseResource,
|
||||
armDatabaseWorkerIdleRetirement,
|
||||
clearClosedDatabaseCustody,
|
||||
costReadLane,
|
||||
costRefreshLane,
|
||||
historyClearTimeout,
|
||||
pruneHistoryDatabases,
|
||||
refreshDatabaseWorkerPressureSubscription,
|
||||
releaseRetiredDatabaseCustody,
|
||||
rotateDatabaseWorkers,
|
||||
type HistoryDatabaseResource,
|
||||
type SessionCostWorkerLane,
|
||||
type SessionDatabaseCleanup,
|
||||
} from "../config/sessions/session-transcript-worker-resources.js";
|
||||
import { createDeferredCore } from "../shared/deferred.js";
|
||||
import type { OpenClawAgentDatabaseOptions } from "../state/openclaw-agent-db-contract.js";
|
||||
import {
|
||||
UsageCostWorkerReplyError,
|
||||
type UsageCostWorkerInput,
|
||||
type UsageCostWorkerResult,
|
||||
type SessionCostUsageWorkerOptions,
|
||||
type SessionCostUsageWorkerScope,
|
||||
} from "./session-cost-usage-worker.types.js";
|
||||
import { runWithSqliteDatabaseAdmissionTurn } from "./sqlite-database-admission-turn.js";
|
||||
import { withSqliteWorkerCleanupFailure } from "./sqlite-worker-broker-reply.js";
|
||||
import { WorkerTaskError } from "./worker-task-pool.js";
|
||||
import type { WorkerTaskResponse } from "./worker-task-pool.types.js";
|
||||
|
||||
/** Usage reads retain every physical store while compute and its admitted host effects settle. */
|
||||
export async function withSessionCostUsageWorkerDatabases<T>(
|
||||
options: readonly OpenClawAgentDatabaseOptions[],
|
||||
operation: (owner: SessionCostUsageWorkerScope) => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (options.length === 0) {
|
||||
throw new Error("Usage cost work requires its database owners");
|
||||
}
|
||||
const resources = new Set<HistoryDatabaseResource>();
|
||||
try {
|
||||
for (const databaseOptions of options) {
|
||||
const resource = acquireHistoryDatabaseResource(databaseOptions);
|
||||
if (!resources.has(resource)) {
|
||||
resources.add(resource);
|
||||
resource.pending++;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
for (const resource of resources) {
|
||||
resource.pending--;
|
||||
}
|
||||
pruneHistoryDatabases();
|
||||
throw error;
|
||||
}
|
||||
const pending = new Set<Promise<UsageCostWorkerResult>>();
|
||||
const cleanups = new Set<SessionDatabaseCleanup>();
|
||||
const lanes = new Map<SessionCostWorkerLane, { nativeThrough: number; failedThrough: number }>();
|
||||
let phase: "open" | "closing" | "closed" = "open";
|
||||
const assertCurrent = () => {
|
||||
if (phase === "closed" || [...resources].some((resource) => resource.revoked)) {
|
||||
throw new WorkerTaskError("Session usage database work was revoked", "unavailable");
|
||||
}
|
||||
};
|
||||
const settle = async () => {
|
||||
while (pending.size > 0) {
|
||||
await Promise.allSettled(pending);
|
||||
}
|
||||
for (const [lane, custody] of lanes) {
|
||||
if (custody.nativeThrough > lane.retiredSequence) {
|
||||
await lane.rotation;
|
||||
}
|
||||
if (custody.failedThrough > lane.retiredSequence) {
|
||||
await rotateDatabaseWorkers(lane);
|
||||
}
|
||||
}
|
||||
};
|
||||
const retainCleanup = (close: () => Promise<void>): (() => void) => {
|
||||
if (phase === "closed") {
|
||||
throw new WorkerTaskError("Session usage database scope is closed", "unavailable");
|
||||
}
|
||||
const runInContext = AsyncLocalStorage.snapshot();
|
||||
let released = false;
|
||||
let closing: Promise<void> | undefined;
|
||||
const release = () => {
|
||||
released = true;
|
||||
cleanups.delete(cleanup);
|
||||
for (const resource of resources) {
|
||||
resource.cleanups.delete(cleanup);
|
||||
}
|
||||
pruneHistoryDatabases();
|
||||
};
|
||||
const cleanup: SessionDatabaseCleanup = {
|
||||
run: () => {
|
||||
if (released) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
closing ??= (async () => {
|
||||
await settle();
|
||||
await runInContext(close);
|
||||
release();
|
||||
})().catch((error: unknown) => {
|
||||
closing = undefined;
|
||||
throw error;
|
||||
});
|
||||
return closing;
|
||||
},
|
||||
};
|
||||
cleanups.add(cleanup);
|
||||
for (const resource of resources) {
|
||||
resource.cleanups.add(cleanup);
|
||||
}
|
||||
return release;
|
||||
};
|
||||
const run = (
|
||||
input: UsageCostWorkerInput,
|
||||
runOptions: SessionCostUsageWorkerOptions,
|
||||
): Promise<UsageCostWorkerResult> => {
|
||||
assertCurrent();
|
||||
if (phase !== "open") {
|
||||
throw new WorkerTaskError("Session usage database scope is closing", "unavailable");
|
||||
}
|
||||
const lane = input.operation.kind === "refresh" ? costRefreshLane : costReadLane;
|
||||
const custody = lanes.get(lane) ?? { nativeThrough: 0, failedThrough: 0 };
|
||||
lanes.set(lane, custody);
|
||||
const controller = new AbortController();
|
||||
const signal = runOptions.signal
|
||||
? AbortSignal.any([controller.signal, runOptions.signal])
|
||||
: controller.signal;
|
||||
const abort = () =>
|
||||
controller.abort(
|
||||
new WorkerTaskError("Session usage database work was revoked", "unavailable"),
|
||||
);
|
||||
for (const resource of resources) {
|
||||
resource.aborters.add(abort);
|
||||
}
|
||||
historyClearTimeout(lane.idleTimer);
|
||||
lane.pending++;
|
||||
refreshDatabaseWorkerPressureSubscription();
|
||||
const hostEffects = new Set<Promise<WorkerTaskResponse>>();
|
||||
const onRequest = runOptions.onRequest;
|
||||
let sequence = 0;
|
||||
let executionSettled = false;
|
||||
const task = (async (): Promise<UsageCostWorkerResult> => {
|
||||
try {
|
||||
const reply = await runWithSqliteDatabaseAdmissionTurn(
|
||||
[...resources].map((resource) => resource.database.path),
|
||||
() =>
|
||||
lane.pool.run(
|
||||
() => {
|
||||
assertCurrent();
|
||||
signal.throwIfAborted();
|
||||
runOptions.beforeDispatch?.();
|
||||
sequence = ++lane.nativeSequence;
|
||||
custody.nativeThrough = sequence;
|
||||
for (const resource of resources) {
|
||||
resource.nativeSequences.set(lane, sequence);
|
||||
}
|
||||
return { ...input, databases: [...resources].map((resource) => resource.database) };
|
||||
},
|
||||
{
|
||||
...runOptions,
|
||||
signal,
|
||||
onExecutionSettled: ({ retired }) => {
|
||||
executionSettled = true;
|
||||
if (retired && sequence > 0) {
|
||||
releaseRetiredDatabaseCustody(lane, sequence);
|
||||
}
|
||||
},
|
||||
onRequest: onRequest
|
||||
? (value, context) => {
|
||||
const effect = createDeferredCore<WorkerTaskResponse>();
|
||||
hostEffects.add(effect.promise);
|
||||
for (const resource of resources) {
|
||||
resource.hostEffects.add(effect.promise);
|
||||
}
|
||||
const releaseEffect = () => {
|
||||
hostEffects.delete(effect.promise);
|
||||
for (const resource of resources) {
|
||||
resource.hostEffects.delete(effect.promise);
|
||||
}
|
||||
};
|
||||
void effect.promise.then(releaseEffect, releaseEffect);
|
||||
try {
|
||||
assertCurrent();
|
||||
context.signal.throwIfAborted();
|
||||
effect.resolve(onRequest(value, context));
|
||||
} catch (error) {
|
||||
effect.reject(error);
|
||||
}
|
||||
return effect.promise;
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
),
|
||||
);
|
||||
if (!reply.ok) {
|
||||
throw new UsageCostWorkerReplyError(reply.error);
|
||||
}
|
||||
clearClosedDatabaseCustody(lane, sequence, reply.closedDatabases);
|
||||
signal.throwIfAborted();
|
||||
assertCurrent();
|
||||
return reply.value;
|
||||
} catch (error) {
|
||||
if (sequence > 0 && !executionSettled) {
|
||||
custody.failedThrough = Math.max(custody.failedThrough, sequence);
|
||||
try {
|
||||
await rotateDatabaseWorkers(lane);
|
||||
} catch (cleanupError) {
|
||||
throw withSqliteWorkerCleanupFailure(
|
||||
toErrorObject(error, "Usage cost worker failed"),
|
||||
cleanupError,
|
||||
);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
// Native worker exit does not settle an already admitted host write.
|
||||
await Promise.allSettled(hostEffects);
|
||||
for (const resource of resources) {
|
||||
resource.aborters.delete(abort);
|
||||
}
|
||||
lane.pending--;
|
||||
pruneHistoryDatabases();
|
||||
armDatabaseWorkerIdleRetirement(lane);
|
||||
}
|
||||
})();
|
||||
pending.add(task);
|
||||
void task.then(
|
||||
() => pending.delete(task),
|
||||
() => pending.delete(task),
|
||||
);
|
||||
return task;
|
||||
};
|
||||
let result: { ok: true; value: T } | { ok: false; error: unknown };
|
||||
try {
|
||||
assertCurrent();
|
||||
const value = await operation({ assertCurrent, run, retainCleanup });
|
||||
assertCurrent();
|
||||
result = { ok: true, value };
|
||||
} catch (error) {
|
||||
result = { ok: false, error };
|
||||
}
|
||||
phase = "closing";
|
||||
try {
|
||||
await settle();
|
||||
for (const cleanup of cleanups) {
|
||||
await cleanup.run();
|
||||
}
|
||||
if (result.ok) {
|
||||
assertCurrent();
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
throw result.ok
|
||||
? cleanupError
|
||||
: withSqliteWorkerCleanupFailure(
|
||||
toErrorObject(result.error, "Usage cost operation failed"),
|
||||
cleanupError,
|
||||
);
|
||||
} finally {
|
||||
phase = "closed";
|
||||
for (const resource of resources) {
|
||||
resource.pending--;
|
||||
}
|
||||
pruneHistoryDatabases();
|
||||
for (const lane of lanes.keys()) {
|
||||
armDatabaseWorkerIdleRetirement(lane);
|
||||
}
|
||||
}
|
||||
if (!result.ok) {
|
||||
throw result.error;
|
||||
}
|
||||
return result.value;
|
||||
}
|
||||
1
src/infra/sqlite-database-admission-key.ts
Normal file
1
src/infra/sqlite-database-admission-key.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const SQLITE_DATABASE_ADMISSIONS_KEY = "openclaw.sqliteDatabaseAdmissions.v1";
|
||||
145
src/infra/sqlite-database-admission-record.ts
Normal file
145
src/infra/sqlite-database-admission-record.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { threadId } from "node:worker_threads";
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { readWorkerAncestors } from "./worker-ancestry.js";
|
||||
|
||||
export type AdmissionFact = {
|
||||
value: unknown;
|
||||
revision: number;
|
||||
schemaDependent: boolean;
|
||||
publication: string;
|
||||
current: SharedArrayBuffer;
|
||||
};
|
||||
export type StagedAdmissionFact = Pick<AdmissionFact, "value" | "revision" | "schemaDependent"> & {
|
||||
ddlRevision: number;
|
||||
};
|
||||
type Writer = { cell: SharedArrayBuffer; ancestors: readonly number[] };
|
||||
export type Admission = {
|
||||
identity: string;
|
||||
location: string;
|
||||
descriptor: number;
|
||||
descriptorOwner: number;
|
||||
generationId: string;
|
||||
generation: SharedArrayBuffer;
|
||||
writers: Map<number, Writer>;
|
||||
facts: Map<string, AdmissionFact>;
|
||||
};
|
||||
export type SqliteDatabaseAdmissions = Admission[];
|
||||
function readAdmissionFact(value: unknown): AdmissionFact | undefined {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
typeof value.revision !== "number" ||
|
||||
typeof value.schemaDependent !== "boolean" ||
|
||||
typeof value.publication !== "string" ||
|
||||
!(value.current instanceof SharedArrayBuffer) ||
|
||||
value.current.byteLength !== Int32Array.BYTES_PER_ELEMENT
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
value: value.value,
|
||||
revision: value.revision,
|
||||
schemaDependent: value.schemaDependent,
|
||||
publication: value.publication,
|
||||
current: value.current,
|
||||
};
|
||||
}
|
||||
|
||||
function readInheritedAdmission(value: unknown): Admission | undefined {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
typeof value.identity !== "string" ||
|
||||
typeof value.location !== "string" ||
|
||||
typeof value.descriptor !== "number" ||
|
||||
typeof value.descriptorOwner !== "number" ||
|
||||
typeof value.generationId !== "string" ||
|
||||
!(value.generation instanceof SharedArrayBuffer) ||
|
||||
value.generation.byteLength !== 5 * Int32Array.BYTES_PER_ELEMENT ||
|
||||
!(value.facts instanceof Map)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
if (!(value.writers instanceof Map)) {
|
||||
return undefined;
|
||||
}
|
||||
const writers = new Map<number, Writer>();
|
||||
for (const [writer, custody] of value.writers) {
|
||||
if (
|
||||
typeof writer !== "number" ||
|
||||
!Number.isInteger(writer) ||
|
||||
writer < 0 ||
|
||||
!isRecord(custody) ||
|
||||
!(custody.cell instanceof SharedArrayBuffer) ||
|
||||
custody.cell.byteLength !== 2 * Int32Array.BYTES_PER_ELEMENT
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const ancestors = readWorkerAncestors(custody.ancestors);
|
||||
if (!ancestors || ancestors.includes(writer)) {
|
||||
return undefined;
|
||||
}
|
||||
writers.set(writer, { cell: custody.cell, ancestors });
|
||||
}
|
||||
const facts = new Map<string, AdmissionFact>();
|
||||
for (const [key, entry] of value.facts) {
|
||||
const fact = readAdmissionFact(entry);
|
||||
if (typeof key !== "string" || !fact) {
|
||||
return undefined;
|
||||
}
|
||||
facts.set(key, fact);
|
||||
}
|
||||
return {
|
||||
identity: value.identity,
|
||||
location: value.location,
|
||||
descriptor: value.descriptor,
|
||||
descriptorOwner: value.descriptorOwner,
|
||||
generationId: value.generationId,
|
||||
generation: value.generation,
|
||||
writers,
|
||||
facts,
|
||||
};
|
||||
}
|
||||
|
||||
export function readSqliteDatabaseAdmissions(value: unknown): SqliteDatabaseAdmissions | undefined {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const admissions: SqliteDatabaseAdmissions = [];
|
||||
for (const entry of value) {
|
||||
const record = readInheritedAdmission(entry);
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
admissions.push(record);
|
||||
}
|
||||
return admissions;
|
||||
}
|
||||
|
||||
export function isSqliteDatabaseAdmissionRetired(record: Admission): boolean {
|
||||
return Atomics.load(new Int32Array(record.generation), 3) !== 0;
|
||||
}
|
||||
|
||||
export function registerWriterCustody(record: Admission): void {
|
||||
if (threadId !== 0) {
|
||||
return;
|
||||
}
|
||||
for (const { cell } of record.writers.values()) {
|
||||
const writer = new Int32Array(cell);
|
||||
if (Atomics.load(writer, 1) === 0) {
|
||||
// Only thread 0 allocates registrations, after installing their metadata.
|
||||
Atomics.add(new Int32Array(record.generation), 4, 1);
|
||||
Atomics.store(writer, 1, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isSqliteDatabaseAdmissionFactCurrent(
|
||||
record: Admission,
|
||||
fact: AdmissionFact,
|
||||
): boolean {
|
||||
const cell = new Int32Array(record.generation);
|
||||
return (
|
||||
!isSqliteDatabaseAdmissionRetired(record) &&
|
||||
Atomics.load(new Int32Array(fact.current), 0) === 1 &&
|
||||
fact.revision === Atomics.load(cell, fact.schemaDependent ? 0 : 1)
|
||||
);
|
||||
}
|
||||
177
src/infra/sqlite-database-admission-turn.ts
Normal file
177
src/infra/sqlite-database-admission-turn.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import path from "node:path";
|
||||
import { createDeferredCore } from "../shared/deferred.js";
|
||||
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
|
||||
import { resolveIdentityPathViaExistingAncestorSync } from "./boundary-path.js";
|
||||
import { isMissingPathError } from "./errno.js";
|
||||
import {
|
||||
canShareSqliteDatabaseAdmissions,
|
||||
hasSqliteDatabaseSchemaAdmissionForPath,
|
||||
} from "./sqlite-database-admission.js";
|
||||
import { inspectDatabasePathIdentitySync } from "./sqlite-worker-identity.js";
|
||||
|
||||
type AdmissionTurn = {
|
||||
keys: ReadonlySet<string>;
|
||||
predecessors: Set<AdmissionTurn>;
|
||||
completion: Promise<void>;
|
||||
holding: boolean;
|
||||
references: number;
|
||||
releaseReference(): void;
|
||||
};
|
||||
type AdmissionTurnScope = { turns: readonly AdmissionTurn[]; active: boolean };
|
||||
|
||||
const pending = resolveGlobalSingleton(
|
||||
Symbol.for("openclaw.sqliteDatabaseAdmissionTurns"),
|
||||
() => new Map<string, AdmissionTurn>(),
|
||||
);
|
||||
const current = resolveGlobalSingleton(
|
||||
Symbol.for("openclaw.sqliteDatabaseAdmissionTurnContext"),
|
||||
() => new AsyncLocalStorage<AdmissionTurnScope>(),
|
||||
);
|
||||
|
||||
function reserveAdmissionTurn(locations: string | readonly string[], families: readonly string[]) {
|
||||
if (!canShareSqliteDatabaseAdmissions()) {
|
||||
return undefined;
|
||||
}
|
||||
const keys = new Set(
|
||||
families.map((directory) => `family:${resolveIdentityPathViaExistingAncestorSync(directory)}`),
|
||||
);
|
||||
for (const location of typeof locations === "string" ? [locations] : locations) {
|
||||
let identity: ReturnType<typeof inspectDatabasePathIdentitySync>;
|
||||
try {
|
||||
// A native task can request a host writer after publishing its completed schema.
|
||||
if (hasSqliteDatabaseSchemaAdmissionForPath(location)) {
|
||||
continue;
|
||||
}
|
||||
identity = inspectDatabasePathIdentitySync(location);
|
||||
} catch (error) {
|
||||
if (!isMissingPathError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
// Discovery owns unavailable targets; they still share pathname and family ordering.
|
||||
const canonicalPath =
|
||||
identity?.canonicalPath ?? resolveIdentityPathViaExistingAncestorSync(location);
|
||||
keys.add(`path:${canonicalPath}`);
|
||||
if (identity) {
|
||||
keys.add(`${identity.key}:${identity.birthtime ?? ""}`);
|
||||
}
|
||||
keys.add(`family:${path.dirname(canonicalPath)}`);
|
||||
}
|
||||
if (keys.size === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const scope = current.getStore();
|
||||
const parents = scope?.active ? scope.turns.filter((turn) => turn.holding) : [];
|
||||
const inherited = new Set(parents);
|
||||
const ownedKeys = new Set(parents.flatMap((turn) => [...turn.keys]));
|
||||
const ordered = [...keys].filter((key) => !ownedKeys.has(key)).toSorted();
|
||||
const predecessors = new Set<AdmissionTurn>();
|
||||
const waitsForParent = (turn: AdmissionTurn): boolean => {
|
||||
const search = [turn];
|
||||
const visited = new Set<AdmissionTurn>();
|
||||
for (const candidate of search) {
|
||||
if (inherited.has(candidate)) {
|
||||
return true;
|
||||
}
|
||||
if (!visited.has(candidate)) {
|
||||
visited.add(candidate);
|
||||
search.push(...candidate.predecessors);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const search = ordered.flatMap((key) => pending.get(key) ?? []);
|
||||
const visited = new Set<AdmissionTurn>();
|
||||
for (const turn of search) {
|
||||
if (visited.has(turn)) {
|
||||
continue;
|
||||
}
|
||||
visited.add(turn);
|
||||
if (inherited.has(turn)) {
|
||||
continue;
|
||||
}
|
||||
if (waitsForParent(turn)) {
|
||||
// A queued caller cannot stand between an active turn and its nested callback.
|
||||
// Keep its other blockers, so parallel nested opens still validate each file once.
|
||||
search.push(...turn.predecessors);
|
||||
} else {
|
||||
predecessors.add(turn);
|
||||
}
|
||||
}
|
||||
const completion = createDeferredCore();
|
||||
const turn: AdmissionTurn = {
|
||||
keys: new Set(ordered),
|
||||
predecessors,
|
||||
completion: completion.promise,
|
||||
holding: false,
|
||||
references: 1,
|
||||
releaseReference() {
|
||||
if (--turn.references !== 0) {
|
||||
return;
|
||||
}
|
||||
turn.holding = false;
|
||||
for (const key of ordered) {
|
||||
if (pending.get(key) === turn) {
|
||||
pending.delete(key);
|
||||
}
|
||||
}
|
||||
completion.resolve();
|
||||
for (const parent of parents) {
|
||||
parent.releaseReference();
|
||||
}
|
||||
},
|
||||
};
|
||||
for (const parent of parents) {
|
||||
parent.references += 1;
|
||||
}
|
||||
for (const key of ordered) {
|
||||
pending.set(key, turn);
|
||||
}
|
||||
let released = false;
|
||||
const release = () => {
|
||||
if (!released) {
|
||||
released = true;
|
||||
turn.releaseReference();
|
||||
}
|
||||
};
|
||||
const ready = Promise.all([...predecessors].map((predecessor) => predecessor.completion)).then(
|
||||
() => {
|
||||
turn.holding = true;
|
||||
predecessors.clear();
|
||||
},
|
||||
);
|
||||
return { ready, release, turns: [...parents, turn] };
|
||||
}
|
||||
|
||||
/** Only cold dispatch waits here; native getters never block the host on a worker's publication. */
|
||||
export function acquireSqliteDatabaseAdmissionTurn(
|
||||
locations: string | readonly string[],
|
||||
families: readonly string[] = [],
|
||||
): Promise<() => void> | undefined {
|
||||
const turn = reserveAdmissionTurn(locations, families);
|
||||
return turn?.ready.then(() => turn.release);
|
||||
}
|
||||
|
||||
/** Known database locators are captured by their task owner before dispatch, never inferred from IPC. */
|
||||
export function runWithSqliteDatabaseAdmissionTurn<T>(
|
||||
locations: readonly string[],
|
||||
operation: () => Promise<T>,
|
||||
families: readonly string[] = [],
|
||||
): Promise<T> {
|
||||
const turn = reserveAdmissionTurn(locations, families);
|
||||
if (!turn) {
|
||||
return operation();
|
||||
}
|
||||
return turn.ready.then(() => {
|
||||
const scope: AdmissionTurnScope = { turns: turn.turns, active: true };
|
||||
return current.run(scope, async () => {
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
scope.active = false;
|
||||
turn.release();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue