refactor(state): consolidate wide rows, plugin index, workspace attestations, and shared auth singletons at schema v13 (#130466)

* refactor(state): make cron and subagent rows JSON-canonical

* refactor(state): make gateway origin device tokens canonical at v13

The lazy ensure predates the table joining the canonical schema; at the
v13 bump the schema owns creation, so the feature-local DDL, WeakSet
dedupe, and lazy-list entry retire. The legacy-file guard the ensure
carried stays at each call site.

* test: drop obsolete lazy-ensure coverage for origin device tokens

The table is canonical at v13; same-version lazy creation no longer
exists to protect. Origin CRUD, isolation, and rotation coverage remains
in the surviving cases.

* refactor(state): fold installed_plugin_index into config_machine_state

The singleton index row becomes one JSON value under
plugins.installedIndex with its rollback-fencing revision inside the
value; reads, CAS restore, and the lease-held write transactions use
direct Kysely on config_machine_state so the state_leases assertion
stays in-transaction. The v13 migration imports the row and drops the
table; the additive workspace_dir entry folds with it. Doctor guidance,
docker staging, and the e2e probes name the machine-state row.

* refactor(state): merge workspace_attestations into workspace_setup_state

One row per workspace now carries both setup milestones and the
attestation clock: nullable setup columns represent attestation-only
workspaces (replaceWorkspaceAttestation can precede any setup write) and
setupExists derives from a non-null version. The bootstrap-hash FK
repoints to the merged table; migration receipts keep the historical
workspace_attestations discriminator string. The v13 migration grows and
rebuilds the table, merges attestation rows (orphans without a path
alias drop — their hashes re-derive at the next bootstrap attestation),
and the consolidation kind is renamed state-consolidation-v13 to cover
the batch.

* test(state): cover the workspace merge and consolidation fallout

The v12-to-v13 regression seeds merged, attestation-only, and orphan
attestation workspaces; the 13-to-12 downgrade fixture recreates
workspace_attestations and installed_plugin_index from the folded data;
the fold-in migration gates the additive workspace_dir column for
pre-additive rows; the workspace merge now triggers on the setup table's
own shape so stable-era databases without an attestations table still
reshape; the consolidation applied-message covers the batch.

* refactor(state): fold shared auth profile singletons into config_machine_state

The shared-state auth_profile_stores/auth_profile_state rows (fixed key
'shared') become authProfiles.store/authProfiles.state machine-state
values; the agent-DB tables of the same names are untouched. Git-backup
redaction moves from table-drop to the authProfiles. secret prefix with
seeded-secret absence proof; migration receipts keep the historical
table-name discriminators; the shared-auth relocation and receipt
verification project the KV cells back to the receipt-era row shapes so
persisted digests stay byte-compatible. mcp_oauth_stores stays a table —
its multi-key fold is a named follow-up.

* test(state): finish shared-auth fold coverage and annotate boundary casts

Auth seeders and assertions across the e2e/scripts/secrets suites target
the authProfiles machine-state cells; the v12-to-v13 regression proves
payload-byte fidelity, non-shared-row drop, and insert-if-absent
precedence; the downgrade fixture recreates and repopulates both v12
tables. Boundary type assertions in the plugin-index store carry SAFETY
invariants per the ratchet.

* chore: shrink assertion-safety baseline for plugin-index store

* refactor(doctor): delete the dead onboarding-recommendations migration

Its input — the unscoped 'primary' onboarding row — existed only between
9a93a52a8a and 473962b7de, a two-day beta window; no shipped stable
can produce it and the runtime table folded away at v12. The audit
backup list keeps recognizing system-agent.jsonl artifacts because beta
installs that ran that import may still carry its backups.

* docs: sync the 13-to-12 downgrade example with the executable fixture

* style: format the synced downgrade example

* style: drop unused import and duplicate union constituent

* fix(state): keep orphan attestations across the v13 workspace merge

The merged workspace_setup_state required a workspace path, but legacy
orphan hashed-key attestations never recorded one. workspace_path is now
nullable (setup rows still enforce it via CHECK), the v13 migration and
the doctor file import keep orphans with a NULL path that heals on the
next live access, and the 13-to-12 downgrade keeps attestation-owned
hashes. Doctor test seeds move to the folded KV row.

* perf(state): retire unused cron indexes

* fix(state): preserve v13 migration recovery

* fix(state): preserve v12 lazy-table upgrade

* docs(state): document v13 auth relocation

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
Peter Steinberger
2026-08-27 00:26:14 -07:00
committed by GitHub
parent dff2d26820
commit 1ea2640f54
113 changed files with 3505 additions and 3481 deletions
@@ -568,7 +568,7 @@ struct PortGuardianRecordStoreTests {
let fixture = try Self.fixture()
defer { fixture.cleanup() }
for version in [4, 5, 6, 7, 8, 9, 10, 11, 12] {
for version in [4, 5, 6, 7, 8, 9, 10, 11, 12, 13] {
let databaseURL = fixture.root.appendingPathComponent("supported-v\(version).sqlite")
try Self.seedVersionedPortGuardianDatabase(databaseURL, schemaVersion: version)
let store = try PortGuardianRecordStore(databaseURL: databaseURL)
@@ -580,7 +580,7 @@ struct PortGuardianRecordStoreTests {
#expect(try store.records() == [record])
}
for version in [13, 99] {
for version in [14, 99] {
let databaseURL = fixture.root.appendingPathComponent("newer-v\(version).sqlite")
try Self.seedVersionedPortGuardianDatabase(databaseURL, schemaVersion: version)
#expect(throws: PortGuardianStoreError.self) {
@@ -37,7 +37,7 @@ public enum OpenClawNativeStateSQLiteValueType: Equatable, Sendable {
/// One recursive connection lock serializes transactions and statement access.
public final class OpenClawNativeStateSQLite: @unchecked Sendable {
// Keep aligned with OPENCLAW_STATE_SCHEMA_VERSION. Native clients never upgrade this database.
private static let maximumSupportedSchemaVersion: Int64 = 12
private static let maximumSupportedSchemaVersion: Int64 = 13
private static let defaultBusyTimeoutMilliseconds: Int32 = 5000
private struct SchemaObject: Hashable {
+4 -7
View File
@@ -2802,12 +2802,9 @@ src/cron/service/timer.ts 1
src/cron/session-reaper.ts 1
src/cron/stagger.ts 1
src/cron/store.ts 1
src/cron/store/delivery-codec.ts 3
src/cron/store/failure-alert-codec.ts 2
src/cron/store/payload-codec.ts 3
src/cron/store/delivery-codec.ts 2
src/cron/store/quarantine.ts 1
src/cron/store/row-codec.ts 7
src/cron/store/state-codec.ts 3
src/cron/store/row-codec.ts 4
src/cron/store/transaction-hooks.ts 1
src/cron/task-run-detail.ts 2
src/cron/task-run-history.ts 1
@@ -3552,7 +3549,7 @@ src/plugins/installed-plugin-index-install-owner.ts 2
src/plugins/installed-plugin-index-policy.ts 1
src/plugins/installed-plugin-index-record-builder.ts 1
src/plugins/installed-plugin-index-record-state.ts 1
src/plugins/installed-plugin-index-store.ts 3
src/plugins/installed-plugin-index-store.ts 2
src/plugins/interactive-state.ts 3
src/plugins/interactive.ts 4
src/plugins/lazy-service-module.ts 1
@@ -3789,7 +3786,7 @@ src/state/openclaw-quarantine-store.ts 2
src/state/openclaw-schema-versions.ts 7
src/state/openclaw-state-db-audit-migration.ts 5
src/state/openclaw-state-db-delivery-queue-backfill.ts 1
src/state/openclaw-state-db-legacy-backfills.ts 7
src/state/openclaw-state-db-legacy-backfills.ts 5
src/state/openclaw-state-db-maintenance.ts 3
src/state/openclaw-state-db-operator-approval-migration.ts 1
src/state/openclaw-state-db-readonly.ts 1
+6 -7
View File
@@ -173,12 +173,11 @@ unrelated files elsewhere in an adopted repository are never staged.
`src/state/secret-state-tables.ts` is the source of truth for redaction. At this revision, `--exclude-secrets` omits these shared-state tables:
- `audit_identity_keys`
- `auth_profile_state`
- `auth_profile_stores`
- `apns_registrations`
- `channel_ingress_events`
- `channel_pairing_requests`
- `clawhub_promotion_claims`
- `config_revision_keys`
- `device_auth_tokens`
- `device_bootstrap_tokens`
- `device_identities`
@@ -192,8 +191,8 @@ unrelated files elsewhere in an adopted repository are never staged.
- `web_push_subscriptions`
- `worker_environment_credentials`
It also omits `config_machine_state` rows whose keys begin with `nodeHost.` or
`webPush.vapidKeys`, while retaining other machine-state rows.
It also omits `config_machine_state` rows whose keys begin with `authProfiles.`,
`nodeHost.`, or `webPush.vapidKeys`, while retaining other machine-state rows.
It omits these per-agent tables:
@@ -203,8 +202,8 @@ It omits these per-agent tables:
The backup manifest records omitted tables in `excludedTables` and omitted
machine-state prefixes in `excludedConfigStateKeyPrefixes`. Restore reports
omitted tables so a redacted snapshot cannot be mistaken for a complete
credential backup.
omitted tables and machine-state prefixes so a redacted snapshot cannot be
mistaken for a complete credential backup.
</Warning>
Inspect or verify history without changing the live databases:
@@ -231,7 +230,7 @@ Provision one Gateway-owned automation with a fixed name:
openclaw backup enable --repository ~/Backups/openclaw-git --every 24h --push
```
The default scope is every database. Use `--global-only` or `--agent <id>` to narrow it, and add `--exclude-secrets` for a redacted history. Pushed schedules (`--push`) redact credential-bearing tables by default because an unattended recurring push retains them durably in remote history; pass `--include-secrets` for explicit full-fidelity remote backups (restores from redacted history need device re-pairing and provider re-authentication). `--push` also requires the repository to already have an `origin` remote. Re-running `backup enable` updates the existing automation instead of creating a duplicate. `openclaw backup disable` removes it; disabling an already-missing job is a successful no-op. Backup scheduling currently requires a local Gateway because the command job runs on the Gateway host; for a remote Gateway, create the cron job manually with `openclaw cron add`.
The default scope is every database. Use `--global-only` or `--agent <id>` to narrow it, and add `--exclude-secrets` for a redacted history. Pushed schedules (`--push`) redact credential-bearing tables and secret-prefixed machine-state rows by default because an unattended recurring push retains them durably in remote history; pass `--include-secrets` for explicit full-fidelity remote backups (restores from redacted history need device re-pairing and provider re-authentication). `--push` also requires the repository to already have an `origin` remote. Re-running `backup enable` updates the existing automation instead of creating a duplicate. `openclaw backup disable` removes it; disabling an already-missing job is a successful no-op. Backup scheduling currently requires a local Gateway because the command job runs on the Gateway host; for a remote Gateway, create the cron job manually with `openclaw cron add`.
## Recorded runs and freshness
+2 -2
View File
@@ -408,9 +408,9 @@ For runtime hook debugging:
### Plugin index
Plugin install metadata is machine-managed state, not user config. Installs and updates write it to the shared SQLite state database under the active OpenClaw state directory. The `installed_plugin_index` row stores durable `installRecords` metadata, including records for broken or missing plugin manifests, plus a manifest-derived cold registry cache used by `openclaw plugins update`, uninstall, diagnostics, and the cold plugin registry.
Plugin install metadata is machine-managed state, not user config. Installs and updates write it to the shared SQLite state database under the active OpenClaw state directory. The `config_machine_state` value keyed by `plugins.installedIndex` stores durable `installRecords` metadata, including records for broken or missing plugin manifests, plus a manifest-derived cold registry cache used by `openclaw plugins update`, uninstall, diagnostics, and the cold plugin registry.
`plugins.installs` is a retired authored-config surface. Runtime and update commands read only the SQLite installed-plugin index. Run `openclaw doctor --fix` to import legacy config records into the index and remove the retired key before normal runtime use.
`plugins.installs` is a retired authored-config surface. Runtime and update commands read only the SQLite machine-state plugin index. Run `openclaw doctor --fix` to import legacy config records into the index and remove the retired key before normal runtime use.
## Uninstall
+10 -9
View File
@@ -114,13 +114,13 @@ openclaw backup enable --repository ~/Backups/openclaw-git --every 24h --push
configured, so a fresh install cannot silently create a schedule whose pushes
always fail.
Pushed schedules redact credential-bearing tables by default: an unattended
recurring push would otherwise retain credentials durably in remote Git
history. Pass `--include-secrets` to schedule full-fidelity remote backups
when you accept that tradeoff and the remote is private; restores from
redacted history require re-pairing devices and re-authenticating providers
afterward. Local (non-push) schedules keep full fidelity so restores are
complete.
Pushed schedules redact credential-bearing tables and secret-prefixed
machine-state rows by default: an unattended recurring push would otherwise
retain credentials durably in remote Git history. Pass `--include-secrets` to
schedule full-fidelity remote backups when you accept that tradeoff and the
remote is private; restores from redacted history require re-pairing devices
and re-authenticating providers afterward. Local (non-push) schedules keep full
fidelity so restores are complete.
Use `--global-only` or `--agent <id>` to narrow the scope. Add
`--exclude-secrets` for a redacted Git history. Re-running the command updates
@@ -195,8 +195,9 @@ confirm ownership and run `chmod 700 <repository>` to repair unsafe permissions.
The repository is ordinary Git and can use any remote, including GitHub. Keep
the remote private: the default dump includes auth profiles, tokens, and other
credential-bearing state. `--exclude-secrets` omits the documented secret
tables when a redacted history is more useful than a credential-complete
backup; see [Backup CLI](/cli/backup#versioned-git-backups) for the exact list.
tables and machine-state key prefixes when a redacted history is more useful
than a credential-complete backup; see
[Backup CLI](/cli/backup#versioned-git-backups) for the exact list.
Verify or restore one database at any commit without overwriting a live file:
+5 -4
View File
@@ -1017,10 +1017,11 @@ plugin index entry with `source: "path"` and a workspace-relative
`plugins.load.paths`; the install record avoids duplicating local workstation
paths into long-lived config. This keeps local development installs visible to
source-plane diagnostics without adding a second raw filesystem-path disclosure
surface. The persisted `installed_plugin_index` SQLite table is the install
source of truth and can be refreshed without loading plugin runtime modules.
Its `installRecords` map is durable even when a plugin manifest is missing or
invalid; its `plugins` payload is a rebuildable manifest view.
surface. The persisted `config_machine_state` value under
`plugins.installedIndex` is the install source of truth and can be refreshed
without loading plugin runtime modules. Its `installRecords` map is durable
even when a plugin manifest is missing or invalid; its `plugins` payload is a
rebuildable manifest view.
## Context engine plugins
+27 -27
View File
@@ -203,7 +203,7 @@ without exceptions outside doctor/import/export/debug boundaries.
- No active session files.
- No fake JSONL test fixtures except doctor legacy migration tests.
- No raw SQLite access where Kysely is expected.
- No new file-era runtime stores. The current global schema is version `12`, and
- No new file-era runtime stores. The current global schema is version `13`, and
the current per-agent schema is version `17`; older supported databases move
through the bounded forward migrations listed in
[Database schemas](/reference/database-schemas).
@@ -310,20 +310,19 @@ The branch already has a real shared SQLite base:
- Runtime stores derive selected and inserted row types from those generated
Kysely `DB` interfaces instead of shadowing SQLite row shapes by hand. Raw SQL
remains limited to schema application, pragmas, and migration-only DDL.
- The global SQLite schema is at `user_version = 12`. The per-agent schema is at
- The global SQLite schema is at `user_version = 13`. The per-agent schema is at
version `17`; their openers apply bounded forward migrations from supported
older schemas. File-to-database import remains in Doctor code.
- Relational ownership is enforced where the ownership boundary is canonical:
source migration rows cascade from `migration_runs`, task delivery state
cascades from `task_runs`, and transcript identity rows cascade from
transcript events.
- Current shared tables include `agent_databases`,
`auth_profile_stores`, `auth_profile_state`,
- Current shared tables include `config_machine_state`, `agent_databases`,
`plugin_state_entries`, `plugin_blob_entries`,
`skill_uploads`, `capture_sessions`, `capture_events`, `capture_blobs`,
`sandbox_registry_entries`, `cron_jobs`,
`delivery_queue_entries`,
`workspace_setup_state`, `workspace_path_aliases`, `workspace_attestations`,
`workspace_setup_state`, `workspace_path_aliases`,
`workspace_generated_bootstrap_hashes`, `native_hook_relay_bridges`,
`current_conversation_bindings`, `plugin_binding_approvals`,
`acp_sessions`, `acp_replay_sessions`,
@@ -349,7 +348,7 @@ The branch already has a real shared SQLite base:
site.
- Global and per-agent databases record a `schema_meta` row with database role,
schema version, timestamps, and agent id for agent databases. The global DB
currently uses `user_version = 9`; per-agent DBs use version `17`.
currently uses `user_version = 13`; per-agent DBs use version `17`.
- Per-agent session identity now has a canonical `sessions` root table keyed by
`session_id`, with `session_key`, `session_scope`, `account_id`,
`primary_conversation_id`, timestamps, display fields, model metadata,
@@ -443,8 +442,8 @@ The branch already has a real shared SQLite base:
legacy path resolver lives in the doctor migration module.
- Secret target metadata now talks about stores instead of pretending every
credential target is a config file. `openclaw.json` remains the config store;
auth-profile targets use typed SQLite `auth_profile_stores` rows with
provider-shaped credentials kept as JSON payloads.
shared auth-profile snapshots use the `authProfiles.store` and
`authProfiles.state` keys in `config_machine_state`.
- Secret audit no longer scans retired per-agent `auth.json` files. Doctor owns
warning about, importing, and removing that legacy file.
- Legacy auth profile path helpers now live in doctor legacy code. Core auth
@@ -465,9 +464,9 @@ The branch already has a real shared SQLite base:
define VFS, tool-artifact, or run-artifact tables.
- Workspace bootstrap completion, attestation recency, and generated bootstrap
hashes now live in typed shared `workspace_setup_state`,
`workspace_path_aliases`, `workspace_attestations`, and
`workspace_generated_bootstrap_hashes` rows keyed by canonical workspace
identity. Persisted lexical and real-path aliases keep vanished-workspace
`workspace_path_aliases`, and `workspace_generated_bootstrap_hashes` rows
keyed by canonical workspace identity. Attestation timestamps are owned by
`workspace_setup_state`. Persisted lexical and real-path aliases keep vanished-workspace
protection stable after a configured symlink disappears; repointed aliases
fail closed. Runtime no longer reads or writes
`openclaw-workspace-state.json`, `.openclaw/workspace-state.json`, state-dir
@@ -482,7 +481,7 @@ The branch already has a real shared SQLite base:
`device_identities` and `device_auth_tokens` rows. Gateway startup may import
a valid retired primary identity under the startup migration lease; invalid
canonical identity repair remains Doctor-only. Gateway-origin-scoped tokens
use the lazy additive `gateway_origin_device_tokens` table.
use the canonical `gateway_origin_device_tokens` table.
- GitHub Copilot token exchange cache uses the shared SQLite plugin-state table
under `github-copilot/token-cache/default`. It is provider-owned cache state,
so it intentionally does not add a host schema table.
@@ -1299,10 +1298,10 @@ sessionId})`; create, branch, continue, list, and fork flows live in their
- Hermes secret migration plans and applies imported API-key profiles directly
into the SQLite auth-profile store. It no longer writes or verifies
`auth-profiles.json` as an intermediate target.
- User-facing auth docs now describe
`state/openclaw.sqlite#table/auth_profile_stores/<agentDir>` instead of
telling users to inspect or copy `auth-profiles.json`; legacy OAuth/auth JSON
names remain documented only as doctor-import inputs.
- User-facing auth docs distinguish shared auth snapshots in
`config_machine_state` under `authProfiles.store` and `authProfiles.state`
from the per-agent `auth_profile_store` and `auth_profile_state` tables.
Legacy OAuth/auth JSON names remain documented only as doctor-import inputs.
- MCP OAuth sessions now use versioned `mcp_oauth_stores` rows in shared
`state/openclaw.sqlite`. SDK-owned token, client-registration, and discovery
objects remain one validated JSON payload so dependency extension fields
@@ -1353,9 +1352,9 @@ sessionId})`; create, branch, continue, list, and fork flows live in their
state objects rather than file-shaped lockfile/origin abstractions. Doctor
imports the legacy sidecars from configured agent workspaces and removes them
after a clean import.
- The installed plugin index now reads and writes the typed shared SQLite
`installed_plugin_index` singleton row instead of `plugins/installs.json`; the
legacy JSON file is only a doctor migration input and is removed after import.
- The installed plugin index now reads and writes `config_machine_state` under
`plugins.installedIndex` instead of `plugins/installs.json`; the legacy JSON
file is only a doctor migration input and is removed after import.
- The legacy `plugins/installs.json` path helper now lives in doctor legacy
code. Runtime plugin-index modules expose only SQLite-backed persistence
options, not a JSON file path.
@@ -1504,7 +1503,7 @@ agent_databases(agent_id, path, schema_version, last_seen_at, size_bytes)
task_runs(...)
task_delivery_state(...)
flow_runs(...)
subagent_runs(run_id, child_session_key, requester_session_key, controller_session_key, created_at, ended_at, cleanup_handled, payload_json)
subagent_runs(run_id, child_session_key, controller_session_key, requester_session_key, created_at, payload_json)
current_conversation_bindings(binding_key, binding_id, target_agent_id, target_session_id, target_session_key, channel, account_id, conversation_kind, parent_conversation_id, conversation_id, target_kind, status, bound_at, expires_at, metadata_json, updated_at)
plugin_binding_approvals(plugin_root, channel, account_id, plugin_id, plugin_name, approved_at)
plugin_state_entries(plugin_id, namespace, entry_key, value_json, created_at, expires_at)
@@ -1517,9 +1516,8 @@ apns_registration_tombstones(node_id, deleted_at_ms)
device_identities(identity_key, device_id, public_key_pem, private_key_pem, created_at_ms, updated_at_ms)
device_auth_tokens(device_id, role, token, scopes_json, updated_at_ms)
macos_port_guardian_records(pid, port, command, mode, timestamp)
workspace_setup_state(workspace_key, workspace_path, version, bootstrap_seeded_at, setup_completed_at, updated_at)
workspace_setup_state(workspace_key, workspace_path, version, bootstrap_seeded_at, setup_completed_at, updated_at, attested_at_ms, attestation_updated_at_ms)
workspace_path_aliases(alias_key, alias_path, workspace_key, workspace_path, updated_at_ms)
workspace_attestations(workspace_key, attested_at_ms, updated_at_ms)
workspace_generated_bootstrap_hashes(workspace_key, filename, sha256)
native_hook_relay_bridges(relay_id, pid, hostname, port, token, expires_at_ms, updated_at_ms)
managed_outgoing_image_records(attachment_id, session_key, agent_id, message_id, created_at, updated_at, retention_class, alt, original_media_id, original_media_subdir, original_content_type, original_width, original_height, original_size_bytes, original_filename, record_json, cleanup_pending)
@@ -1528,19 +1526,21 @@ channel_pairing_requests(channel_key, account_id, request_id, code, created_at,
channel_pairing_allow_entries(channel_key, account_id, entry, sort_order, updated_at)
config_health_entries(config_path, last_known_good_json, last_promoted_good_json, last_observed_suspicious_signature, updated_at_ms)
sandbox_registry_entries(registry_kind, container_name, session_key, backend_id, runtime_label, image, created_at_ms, last_used_at_ms, config_label_kind, config_hash, cdp_port, no_vnc_port, entry_json, updated_at)
cron_jobs(store_key, job_id, name, description, enabled, delete_after_run, created_at_ms, agent_id, session_key, schedule_kind, schedule_expr, schedule_tz, every_ms, anchor_ms, at, stagger_ms, session_target, wake_mode, payload_kind, payload_message, payload_model, payload_fallbacks_json, payload_thinking, payload_timeout_seconds, payload_allow_unsafe_external_content, payload_external_content_source_json, payload_light_context, payload_tools_allow_json, delivery_mode, delivery_channel, delivery_to, delivery_thread_id, delivery_account_id, delivery_best_effort, failure_delivery_mode, failure_delivery_channel, failure_delivery_to, failure_delivery_account_id, failure_alert_disabled, failure_alert_after, failure_alert_channel, failure_alert_to, failure_alert_cooldown_ms, failure_alert_include_skipped, failure_alert_mode, failure_alert_account_id, next_run_at_ms, running_at_ms, last_run_at_ms, last_run_status, last_error, last_duration_ms, consecutive_errors, consecutive_skipped, schedule_error_count, last_delivery_status, last_delivery_error, last_delivered, last_failure_alert_at_ms, job_json, state_json, runtime_updated_at_ms, schedule_identity, sort_order, updated_at)
cron_jobs(store_key, job_id, declaration_key, owner_agent_id, name, description, enabled, agent_id, payload_kind, job_json, state_json, runtime_updated_at_ms, schedule_identity, sort_order, updated_at)
delivery_queue_entries(queue_name, id, status, entry_kind, session_key, channel, target, account_id, retry_count, last_attempt_at, last_error, recovery_state, platform_send_started_at, entry_json, enqueued_at, updated_at, failed_at)
migration_runs(id, started_at, finished_at, status, report_json)
migration_sources(source_key, migration_kind, source_path, target_table, source_sha256, source_size_bytes, source_record_count, last_run_id, status, imported_at, removed_source, report_json)
backup_runs(id, created_at, archive_path, status, manifest_json)
```
`config_machine_state` owns the `skills.curatorState`, `update.checkState`,
`config_machine_state` owns the `authProfiles.store`, `authProfiles.state`,
`plugins.installedIndex`, `skills.curatorState`, `update.checkState`,
`clawhub.promotionsFeed`, `modelCatalog.remote`, `voicewake.triggers`,
`voicewake.routing`, `onboarding.recommendations.<workspaceKey>`,
`tui.lastSession.<scopeKey>`, `sidebar.sectionOrder`, `nodeHost.config`, and
`webPush.vapidKeys` snapshots. Secret-excluding Git backups omit the `nodeHost.`
and `webPush.vapidKeys` key prefixes without dropping other machine state.
`webPush.vapidKeys` snapshots. Secret-excluding Git backups omit the
`authProfiles.`, `nodeHost.`, and `webPush.vapidKeys` key prefixes without
dropping other machine state.
Agent database:
@@ -1956,7 +1956,7 @@ manifest asset from the verified extracted payload.
1. Add database registry APIs.
- Resolve global DB and per-agent DB paths.
- The global schema now uses `user_version = 9`; per-agent DBs use version
- The global schema now uses `user_version = 13`; per-agent DBs use version
`17`, with bounded forward migrations from supported older versions.
- Add close/checkpoint/integrity helpers used by tests, backup, and doctor.
+500 -15
View File
@@ -115,20 +115,27 @@ Version 3 was an unshipped development step folded into version 4.
## State schema history
| Version | Change | First release |
| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------- |
| 1 | Initial shared state database | `v2026.5.30-beta.1` |
| 2 | Metadata-only message audit events ([#103903](https://github.com/openclaw/openclaw/pull/103903)) | `v2026.7.2-beta.1` |
| 3 | `STRICT` tables and schema-drift hardening ([#108663](https://github.com/openclaw/openclaw/pull/108663)) | `v2026.7.2-beta.2` |
| 4 | Session watch provenance replaces encoded sentinel rows | Unreleased |
| 5 | Durable cloud-worker result references on pending workspace fences ([`7a7d6bb`](https://github.com/openclaw/openclaw/commit/7a7d6bb51f42bd896de2b8a4df2ee66f3dce0a21), [#110952](https://github.com/openclaw/openclaw/pull/110952)) | `v2026.7.2-beta.4` |
| 6 | Every committed shared-state table becomes part of the canonical runtime schema ([`509a5f0`](https://github.com/openclaw/openclaw/commit/509a5f03737642fec4a940e6d605887f7957ddc8), [#113473](https://github.com/openclaw/openclaw/pull/113473)) | `v2026.7.2-beta.5` |
| 7 | Retired inferred-commitment storage removed | Unreleased |
| 8 | Cloud-worker placement execution modes and mode-aware turn claims | Unreleased |
| 9 | In-root agent database registry paths stored relative to the state directory | Unreleased |
| 10 | Six dead tables retired (agent_model_catalogs, android_notification_recent_packages, command_log_entries, diagnostic_stability_bundles, media_blobs, model_capability_cache) | Unreleased |
| 11 | Legacy skill curator lifecycle table and never-read proposal origin-run projection retired | Unreleased |
| 12 | Thirteen singleton/cache tables retired; durable state folded into config_machine_state | Unreleased |
| Version | Change | First release |
| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- |
| 1 | Initial shared state database | `v2026.5.30-beta.1` |
| 2 | Metadata-only message audit events ([#103903](https://github.com/openclaw/openclaw/pull/103903)) | `v2026.7.2-beta.1` |
| 3 | `STRICT` tables and schema-drift hardening ([#108663](https://github.com/openclaw/openclaw/pull/108663)) | `v2026.7.2-beta.2` |
| 4 | Session watch provenance replaces encoded sentinel rows | Unreleased |
| 5 | Durable cloud-worker result references on pending workspace fences ([`7a7d6bb`](https://github.com/openclaw/openclaw/commit/7a7d6bb51f42bd896de2b8a4df2ee66f3dce0a21), [#110952](https://github.com/openclaw/openclaw/pull/110952)) | `v2026.7.2-beta.4` |
| 6 | Every committed shared-state table becomes part of the canonical runtime schema ([`509a5f0`](https://github.com/openclaw/openclaw/commit/509a5f03737642fec4a940e6d605887f7957ddc8), [#113473](https://github.com/openclaw/openclaw/pull/113473)) | `v2026.7.2-beta.5` |
| 7 | Retired inferred-commitment storage removed | Unreleased |
| 8 | Cloud-worker placement execution modes and mode-aware turn claims | Unreleased |
| 9 | In-root agent database registry paths stored relative to the state directory | Unreleased |
| 10 | Six dead tables retired (agent_model_catalogs, android_notification_recent_packages, command_log_entries, diagnostic_stability_bundles, media_blobs, model_capability_cache) | Unreleased |
| 11 | Legacy skill curator lifecycle table and never-read proposal origin-run projection retired | Unreleased |
| 12 | Thirteen singleton/cache tables retired; durable state folded into config_machine_state | Unreleased |
| 13 | State consolidation: cron jobs and subagent runs become JSON-canonical (113 projection columns, five unused indexes removed); installed_plugin_index and shared auth-profile singletons fold into config_machine_state; workspace_attestations merges into workspace_setup_state; gateway origin device tokens become canonical | Unreleased |
### State schema 13
Schema 13 makes `cron_jobs.job_json`, `cron_jobs.state_json`, and `subagent_runs.payload_json` the canonical records. Physical columns remain only where production queries, ordering, or runtime-only updates require them. Cron jobs shrink from 75 columns to 15, and subagent runs shrink from 59 columns to six. Migration preserves failure-destination fields explicitly configured as undefined by encoding them as JSON `null`; it also normalizes legacy run-status aliases into `state_json` before removing the redundant projections.
The shared-state `auth_profile_stores` and `auth_profile_state` singletons move into `config_machine_state` under `authProfiles.store` and `authProfiles.state`; per-agent auth tables remain unchanged. Because these rows contain credentials, secret-redacted Git backups omit the `authProfiles.` machine-state prefix.
### State schema 11
@@ -185,10 +192,488 @@ Manual schema downgrades are for agents and operators who accept the risk. [Crea
The general procedure is:
1. Read the target release's schema and migrations.
2. In one transaction, drop every table, index, trigger, and column introduced after the target version.
2. In one transaction, restore the target release's exact table, column, index, and trigger definitions; remove newer objects and recreate objects retired by subsequent upgrades.
3. Set `PRAGMA user_version` and `schema_meta.schema_version` to the target version.
4. Run the target release's full database verification before starting the Gateway.
### Example: state schema 13 to 12
Schema 13 removed 60 cron-job projection columns, 53 subagent-run projection columns, and five unused indexes. A schema 12 build still expects the exact original column definitions, ordering, and indexes. Adding the removed required columns with defaults produces a different schema that older builds reject, so rebuild both tables instead. Reproject every v12 cron field from canonical `job_json` and `state_json`; abort before rebuilding when either record is malformed.
Disable foreign-key enforcement before starting the transaction. The cron-runtime authority table references `cron_jobs` with `ON DELETE CASCADE`, so dropping the original table while enforcement is active would silently delete its authority rows. Re-enable enforcement after the rebuild commits, and verify that `PRAGMA foreign_key_check;` returns no rows before starting the older build.
Run equivalent SQL against the global state database after inspecting the exact schema that wrote it:
```sql
PRAGMA foreign_keys = OFF;
BEGIN;
CREATE TEMP TABLE openclaw_v13_cron_downgrade_preflight (
valid INTEGER NOT NULL CHECK (valid = 1)
) STRICT;
INSERT INTO openclaw_v13_cron_downgrade_preflight (valid)
SELECT json_valid(job_json)
AND json_type(job_json) = 'object'
AND json_valid(state_json)
AND json_type(state_json) = 'object'
FROM cron_jobs;
DROP TABLE openclaw_v13_cron_downgrade_preflight;
CREATE TABLE cron_jobs_migration_v12 (
store_key TEXT NOT NULL,
job_id TEXT NOT NULL,
declaration_key TEXT,
display_name TEXT,
owner_agent_id TEXT,
owner_session_key TEXT,
name TEXT NOT NULL,
description TEXT,
enabled INTEGER NOT NULL,
delete_after_run INTEGER,
created_at_ms INTEGER NOT NULL,
agent_id TEXT,
session_key TEXT,
schedule_kind TEXT NOT NULL,
schedule_expr TEXT,
schedule_tz TEXT,
every_ms INTEGER,
anchor_ms INTEGER,
at TEXT,
stagger_ms INTEGER,
session_target TEXT NOT NULL,
wake_mode TEXT NOT NULL,
trigger_script TEXT,
trigger_once INTEGER,
payload_kind TEXT NOT NULL,
payload_message TEXT,
payload_model TEXT,
payload_fallbacks_json TEXT,
payload_thinking TEXT,
payload_timeout_seconds INTEGER,
payload_allow_unsafe_external_content INTEGER,
payload_external_content_source_json TEXT,
payload_light_context INTEGER,
payload_tools_allow_json TEXT,
payload_tools_allow_is_default INTEGER,
delivery_mode TEXT,
delivery_channel TEXT,
delivery_to TEXT,
delivery_thread_id TEXT,
delivery_thread_id_type TEXT,
delivery_account_id TEXT,
delivery_best_effort INTEGER,
delivery_completion_mode TEXT,
delivery_completion_to TEXT,
failure_delivery_mode TEXT,
failure_delivery_channel TEXT,
failure_delivery_to TEXT,
failure_delivery_account_id TEXT,
failure_alert_disabled INTEGER,
failure_alert_after INTEGER,
failure_alert_channel TEXT,
failure_alert_to TEXT,
failure_alert_cooldown_ms INTEGER,
failure_alert_include_skipped INTEGER,
failure_alert_mode TEXT,
failure_alert_account_id TEXT,
next_run_at_ms INTEGER,
running_at_ms INTEGER,
last_run_at_ms INTEGER,
last_run_status TEXT,
last_error TEXT,
last_duration_ms INTEGER,
consecutive_errors INTEGER,
consecutive_skipped INTEGER,
schedule_error_count INTEGER,
last_delivery_status TEXT,
last_delivery_error TEXT,
last_delivered INTEGER,
last_failure_alert_at_ms INTEGER,
job_json TEXT NOT NULL,
state_json TEXT NOT NULL DEFAULT '{}',
runtime_updated_at_ms INTEGER,
schedule_identity TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL,
PRIMARY KEY (store_key, job_id)
) STRICT;
INSERT INTO cron_jobs_migration_v12 (
store_key, job_id, declaration_key, display_name, owner_agent_id,
owner_session_key, name, description, enabled, delete_after_run, created_at_ms,
agent_id, session_key, schedule_kind, schedule_expr, schedule_tz, every_ms,
anchor_ms, at, stagger_ms, session_target, wake_mode, trigger_script, trigger_once,
payload_kind, payload_message, payload_model, payload_fallbacks_json,
payload_thinking, payload_timeout_seconds, payload_allow_unsafe_external_content,
payload_external_content_source_json, payload_light_context, payload_tools_allow_json,
payload_tools_allow_is_default, delivery_mode, delivery_channel, delivery_to,
delivery_thread_id, delivery_thread_id_type, delivery_account_id, delivery_best_effort,
delivery_completion_mode, delivery_completion_to, failure_delivery_mode,
failure_delivery_channel, failure_delivery_to, failure_delivery_account_id,
failure_alert_disabled, failure_alert_after, failure_alert_channel, failure_alert_to,
failure_alert_cooldown_ms, failure_alert_include_skipped, failure_alert_mode,
failure_alert_account_id, next_run_at_ms, running_at_ms, last_run_at_ms,
last_run_status, last_error, last_duration_ms, consecutive_errors,
consecutive_skipped, schedule_error_count, last_delivery_status, last_delivery_error,
last_delivered, last_failure_alert_at_ms, job_json, state_json, runtime_updated_at_ms,
schedule_identity, sort_order, updated_at
)
SELECT
store_key,
job_id,
json_extract(job_json, '$.declarationKey'),
json_extract(job_json, '$.displayName'),
json_extract(job_json, '$.owner.agentId'),
json_extract(job_json, '$.owner.sessionKey'),
json_extract(job_json, '$.name'),
json_extract(job_json, '$.description'),
json_extract(job_json, '$.enabled'),
json_extract(job_json, '$.deleteAfterRun'),
json_extract(job_json, '$.createdAtMs'),
json_extract(job_json, '$.agentId'),
json_extract(job_json, '$.sessionKey'),
json_extract(job_json, '$.schedule.kind'),
CASE json_extract(job_json, '$.schedule.kind')
WHEN 'cron' THEN json_extract(job_json, '$.schedule.expr')
WHEN 'on-exit' THEN json_extract(job_json, '$.schedule.command')
END,
CASE json_extract(job_json, '$.schedule.kind')
WHEN 'cron' THEN json_extract(job_json, '$.schedule.tz')
WHEN 'on-exit' THEN json_extract(job_json, '$.schedule.cwd')
END,
json_extract(job_json, '$.schedule.everyMs'),
json_extract(job_json, '$.schedule.anchorMs'),
json_extract(job_json, '$.schedule.at'),
json_extract(job_json, '$.schedule.staggerMs'),
json_extract(job_json, '$.sessionTarget'),
json_extract(job_json, '$.wakeMode'),
json_extract(job_json, '$.trigger.script'),
json_extract(job_json, '$.trigger.once'),
json_extract(job_json, '$.payload.kind'),
CASE json_extract(job_json, '$.payload.kind')
WHEN 'systemEvent' THEN json_extract(job_json, '$.payload.text')
WHEN 'agentTurn' THEN json_extract(job_json, '$.payload.message')
WHEN 'command' THEN json_remove(
json_extract(job_json, '$.payload'),
'$.kind', '$.timeoutSeconds', '$.toolsAllow', '$.toolsAllowIsDefault'
)
WHEN 'script' THEN json_remove(
json_extract(job_json, '$.payload'),
'$.kind', '$.timeoutSeconds', '$.toolsAllow', '$.toolsAllowIsDefault'
)
END,
json_extract(job_json, '$.payload.model'),
CASE WHEN json_type(job_json, '$.payload.fallbacks') = 'array'
THEN json_extract(job_json, '$.payload.fallbacks')
END,
json_extract(job_json, '$.payload.thinking'),
json_extract(job_json, '$.payload.timeoutSeconds'),
json_extract(job_json, '$.payload.allowUnsafeExternalContent'),
CASE WHEN json_type(job_json, '$.payload.externalContentSource') IS NOT NULL
THEN json_quote(json_extract(job_json, '$.payload.externalContentSource'))
END,
json_extract(job_json, '$.payload.lightContext'),
CASE WHEN json_type(job_json, '$.payload.toolsAllow') = 'array'
THEN json_extract(job_json, '$.payload.toolsAllow')
END,
CASE WHEN json_type(job_json, '$.payload.toolsAllow') = 'array'
THEN json_extract(job_json, '$.payload.toolsAllowIsDefault')
END,
json_extract(job_json, '$.delivery.mode'),
json_extract(job_json, '$.delivery.channel'),
json_extract(job_json, '$.delivery.to'),
CASE WHEN json_type(job_json, '$.delivery.threadId') IN ('integer', 'real', 'text')
THEN CAST(json_extract(job_json, '$.delivery.threadId') AS TEXT)
END,
CASE json_type(job_json, '$.delivery.threadId')
WHEN 'integer' THEN 'number'
WHEN 'real' THEN 'number'
WHEN 'text' THEN 'string'
END,
json_extract(job_json, '$.delivery.accountId'),
json_extract(job_json, '$.delivery.bestEffort'),
json_extract(job_json, '$.delivery.completionDestination.mode'),
json_extract(job_json, '$.delivery.completionDestination.to'),
CASE json_type(job_json, '$.delivery.failureDestination.mode')
WHEN 'null' THEN ''
WHEN 'text' THEN json_extract(job_json, '$.delivery.failureDestination.mode')
END,
CASE json_type(job_json, '$.delivery.failureDestination.channel')
WHEN 'null' THEN ''
WHEN 'text' THEN json_extract(job_json, '$.delivery.failureDestination.channel')
END,
CASE json_type(job_json, '$.delivery.failureDestination.to')
WHEN 'null' THEN ''
WHEN 'text' THEN json_extract(job_json, '$.delivery.failureDestination.to')
END,
CASE json_type(job_json, '$.delivery.failureDestination.accountId')
WHEN 'null' THEN ''
WHEN 'text' THEN json_extract(job_json, '$.delivery.failureDestination.accountId')
END,
CASE json_type(job_json, '$.failureAlert')
WHEN 'false' THEN 1
WHEN 'object' THEN 0
END,
json_extract(job_json, '$.failureAlert.after'),
json_extract(job_json, '$.failureAlert.channel'),
json_extract(job_json, '$.failureAlert.to'),
json_extract(job_json, '$.failureAlert.cooldownMs'),
json_extract(job_json, '$.failureAlert.includeSkipped'),
json_extract(job_json, '$.failureAlert.mode'),
json_extract(job_json, '$.failureAlert.accountId'),
json_extract(state_json, '$.nextRunAtMs'),
json_extract(state_json, '$.runningAtMs'),
json_extract(state_json, '$.lastRunAtMs'),
COALESCE(
json_extract(state_json, '$.lastRunStatus'),
json_extract(state_json, '$.lastStatus')
),
json_extract(state_json, '$.lastError'),
json_extract(state_json, '$.lastDurationMs'),
json_extract(state_json, '$.consecutiveErrors'),
json_extract(state_json, '$.consecutiveSkipped'),
json_extract(state_json, '$.scheduleErrorCount'),
json_extract(state_json, '$.lastDeliveryStatus'),
json_extract(state_json, '$.lastDeliveryError'),
json_extract(state_json, '$.lastDelivered'),
json_extract(state_json, '$.lastFailureAlertAtMs'),
job_json,
state_json,
runtime_updated_at_ms,
schedule_identity,
sort_order,
updated_at
FROM cron_jobs;
DROP TABLE cron_jobs;
ALTER TABLE cron_jobs_migration_v12 RENAME TO cron_jobs;
CREATE INDEX idx_cron_jobs_store_updated
ON cron_jobs(store_key, sort_order ASC, updated_at DESC, job_id);
CREATE INDEX idx_cron_jobs_store_order
ON cron_jobs(store_key, sort_order ASC, updated_at ASC, job_id);
CREATE INDEX idx_cron_jobs_enabled_next_run
ON cron_jobs(store_key, enabled, next_run_at_ms, job_id)
WHERE next_run_at_ms IS NOT NULL;
CREATE INDEX idx_cron_jobs_agent_session
ON cron_jobs(agent_id, session_key, updated_at DESC, job_id)
WHERE agent_id IS NOT NULL OR session_key IS NOT NULL;
CREATE TABLE subagent_runs_migration_v12 (
run_id TEXT NOT NULL PRIMARY KEY,
child_session_key TEXT NOT NULL,
controller_session_key TEXT,
requester_session_key TEXT NOT NULL,
requester_display_key TEXT NOT NULL,
requester_origin_json TEXT,
task TEXT NOT NULL,
task_name TEXT,
cleanup TEXT NOT NULL,
label TEXT,
model TEXT,
agent_dir TEXT,
workspace_dir TEXT,
run_timeout_seconds INTEGER,
spawn_mode TEXT,
created_at INTEGER NOT NULL,
started_at INTEGER,
session_started_at INTEGER,
accumulated_runtime_ms INTEGER,
ended_at INTEGER,
outcome_json TEXT,
archive_at_ms INTEGER,
cleanup_completed_at INTEGER,
cleanup_handled INTEGER,
suppress_announce_reason TEXT,
expects_completion_message INTEGER,
announce_retry_count INTEGER,
last_announce_retry_at INTEGER,
last_announce_delivery_error TEXT,
ended_reason TEXT,
pause_reason TEXT,
wake_on_descendant_settle INTEGER,
requester_settle_wake_status TEXT,
requester_settle_wake_attempt_count INTEGER,
requester_settle_wake_replay_count INTEGER,
requester_settle_wake_next_attempt_at INTEGER,
requester_settle_wake_batch_run_ids_json TEXT,
requester_settle_wake_last_error TEXT,
requester_settle_wake_retire_after INTEGER,
frozen_result_text TEXT,
frozen_result_captured_at INTEGER,
fallback_frozen_result_text TEXT,
fallback_frozen_result_captured_at INTEGER,
ended_hook_emitted_at INTEGER,
pending_final_delivery INTEGER,
pending_final_delivery_created_at INTEGER,
pending_final_delivery_last_attempt_at INTEGER,
pending_final_delivery_attempt_count INTEGER,
pending_final_delivery_last_error TEXT,
pending_final_delivery_payload_json TEXT,
completion_announced_at INTEGER,
swarm_group_id TEXT,
swarm_collector INTEGER,
swarm_output_schema_json TEXT,
swarm_completion_status TEXT,
swarm_structured_json TEXT,
swarm_schema_error TEXT,
swarm_usage_json TEXT,
payload_json TEXT NOT NULL DEFAULT '{}'
) STRICT;
INSERT INTO subagent_runs_migration_v12 (
run_id, child_session_key, controller_session_key, requester_session_key,
requester_display_key, task, cleanup, created_at, payload_json
)
SELECT run_id, child_session_key, controller_session_key, requester_session_key,
'', '', '', created_at, payload_json
FROM subagent_runs;
DROP TABLE subagent_runs;
ALTER TABLE subagent_runs_migration_v12 RENAME TO subagent_runs;
CREATE INDEX idx_subagent_runs_child_session_key
ON subagent_runs(child_session_key, created_at DESC, run_id);
CREATE INDEX idx_subagent_runs_requester_session_key
ON subagent_runs(requester_session_key, created_at DESC, run_id);
CREATE INDEX idx_subagent_runs_controller_session_key
ON subagent_runs(controller_session_key, created_at DESC, run_id);
CREATE INDEX idx_subagent_runs_archive_at
ON subagent_runs(archive_at_ms, cleanup_handled, run_id);
CREATE INDEX idx_subagent_runs_ended_cleanup
ON subagent_runs(ended_at, cleanup_handled, run_id);
CREATE TABLE workspace_attestations (
workspace_key TEXT NOT NULL PRIMARY KEY,
attested_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL
) STRICT;
INSERT INTO workspace_attestations (workspace_key, attested_at_ms, updated_at_ms)
SELECT workspace_key, attested_at_ms, attestation_updated_at_ms
FROM workspace_setup_state
WHERE attested_at_ms IS NOT NULL;
CREATE INDEX idx_workspace_attestations_attested
ON workspace_attestations(attested_at_ms DESC, workspace_key);
-- Data note: v12 requires version/updated_at NOT NULL in the setup table, so
-- merged attestation-only rows (NULL version) survive the downgrade only as
-- workspace_attestations rows, which also own the generated hashes in v12.
DELETE FROM workspace_generated_bootstrap_hashes
WHERE workspace_key NOT IN (SELECT workspace_key FROM workspace_attestations);
DELETE FROM workspace_setup_state WHERE version IS NULL;
CREATE TABLE workspace_setup_state_migration_v12 (
workspace_key TEXT NOT NULL PRIMARY KEY,
workspace_path TEXT NOT NULL,
version INTEGER NOT NULL,
bootstrap_seeded_at TEXT,
setup_completed_at TEXT,
updated_at INTEGER NOT NULL
) STRICT;
INSERT INTO workspace_setup_state_migration_v12 (
workspace_key, workspace_path, version, bootstrap_seeded_at, setup_completed_at, updated_at
)
SELECT workspace_key, workspace_path, version, bootstrap_seeded_at, setup_completed_at, updated_at
FROM workspace_setup_state;
DROP TABLE workspace_setup_state;
ALTER TABLE workspace_setup_state_migration_v12 RENAME TO workspace_setup_state;
CREATE INDEX idx_workspace_setup_state_path
ON workspace_setup_state(workspace_path);
CREATE TABLE workspace_generated_bootstrap_hashes_migration_v12 (
workspace_key TEXT NOT NULL,
filename TEXT NOT NULL,
sha256 TEXT NOT NULL,
PRIMARY KEY (workspace_key, filename),
FOREIGN KEY (workspace_key) REFERENCES workspace_attestations(workspace_key) ON DELETE CASCADE
) STRICT;
INSERT INTO workspace_generated_bootstrap_hashes_migration_v12 (workspace_key, filename, sha256)
SELECT workspace_key, filename, sha256 FROM workspace_generated_bootstrap_hashes;
DROP TABLE workspace_generated_bootstrap_hashes;
ALTER TABLE workspace_generated_bootstrap_hashes_migration_v12
RENAME TO workspace_generated_bootstrap_hashes;
-- v12 carried installed_plugin_index; repopulate it from the folded KV row.
CREATE TABLE IF NOT EXISTS installed_plugin_index (
index_key TEXT NOT NULL PRIMARY KEY,
version INTEGER NOT NULL,
host_contract_version TEXT NOT NULL,
compat_registry_version TEXT NOT NULL,
migration_version INTEGER NOT NULL,
policy_hash TEXT NOT NULL,
generated_at_ms INTEGER NOT NULL,
workspace_dir TEXT,
refresh_reason TEXT,
install_records_json TEXT NOT NULL,
plugins_json TEXT NOT NULL,
diagnostics_json TEXT NOT NULL,
warning TEXT,
updated_at_ms INTEGER NOT NULL
) STRICT;
CREATE INDEX IF NOT EXISTS idx_installed_plugin_index_generated
ON installed_plugin_index(generated_at_ms DESC, index_key);
INSERT INTO installed_plugin_index (
index_key, version, host_contract_version, compat_registry_version,
migration_version, policy_hash, generated_at_ms, workspace_dir, refresh_reason,
install_records_json, plugins_json, diagnostics_json, warning, updated_at_ms
)
SELECT 'installed-plugin-index',
json_extract(value_json, '$.index.version'),
json_extract(value_json, '$.index.hostContractVersion'),
json_extract(value_json, '$.index.compatRegistryVersion'),
json_extract(value_json, '$.index.migrationVersion'),
json_extract(value_json, '$.index.policyHash'),
json_extract(value_json, '$.index.generatedAtMs'),
json_extract(value_json, '$.index.workspaceDir'),
json_extract(value_json, '$.index.refreshReason'),
json_extract(value_json, '$.index.installRecords'),
json_extract(value_json, '$.index.plugins'),
json_extract(value_json, '$.index.diagnostics'),
json_extract(value_json, '$.index.warning'),
json_extract(value_json, '$.revision')
FROM config_machine_state
WHERE state_key = 'plugins.installedIndex';
DELETE FROM config_machine_state WHERE state_key = 'plugins.installedIndex';
-- v12 carried the shared auth singleton tables; repopulate the 'shared' rows
-- from the folded KV cells (value_json is the payload verbatim).
CREATE TABLE IF NOT EXISTS auth_profile_stores (
store_key TEXT NOT NULL PRIMARY KEY,
store_json TEXT NOT NULL,
updated_at INTEGER NOT NULL
) STRICT;
INSERT INTO auth_profile_stores (store_key, store_json, updated_at)
SELECT 'shared', value_json, updated_at_ms
FROM config_machine_state
WHERE state_key = 'authProfiles.store';
CREATE TABLE IF NOT EXISTS auth_profile_state (
store_key TEXT NOT NULL PRIMARY KEY,
state_json TEXT NOT NULL,
updated_at INTEGER NOT NULL
) STRICT;
INSERT INTO auth_profile_state (store_key, state_json, updated_at)
SELECT 'shared', value_json, updated_at_ms
FROM config_machine_state
WHERE state_key = 'authProfiles.state';
DELETE FROM config_machine_state
WHERE state_key IN ('authProfiles.store', 'authProfiles.state');
PRAGMA user_version = 12;
UPDATE schema_meta SET schema_version = 12 WHERE meta_key = 'primary';
COMMIT;
PRAGMA foreign_keys = ON;
PRAGMA foreign_key_check;
```
The recreated cron columns are recovered from canonical JSON, including schedule and payload variants, explicit failure-destination clears, boolean `false`, numeric thread IDs, and runtime state. Canonical JSON bytes remain unchanged. Subagent-run state remains in `payload_json`; its retired projections are not runtime scheduling inputs. A botched downgrade means restore from the verified backup.
### Example: state schema 12 to 11
Schema 12 folded durable state snapshots into `config_machine_state` and retired rebuildable caches plus the write-only cron store epoch table. A schema 11 build still expects the thirteen former tables, so a manual downgrade must recreate their exact schemas and indexes before lowering the version.
@@ -150,6 +150,8 @@ function describeVoiceCallSchemaMigration(migration: OpenClawStateDatabaseSchema
return "retired skill curator tables -> removed tables and indexes";
case "singleton-state-foldin-v12":
return "singleton state tables -> shared configuration state";
case "state-consolidation-v13":
return "cron jobs and subagent runs -> canonical JSON storage";
case "worker-placement-execution-mode-v8":
return "cloud worker placements -> execution-mode claims";
case "operator-approvals-system-agent":
+1 -1
View File
@@ -3,7 +3,7 @@
"version": "2026.8.1",
"openclaw": {
"schemaVersions": {
"state": 12,
"state": 13,
"agent": 17
}
},
+9 -1
View File
@@ -265,7 +265,15 @@ async function readDurableRows() {
path: database.path,
subagentRows: executeSqliteQuerySync(
database.db,
db.selectFrom("subagent_runs").select(["run_id", "ended_at"]).orderBy("run_id"),
db
.selectFrom("subagent_runs")
.select((eb) => [
"run_id",
eb
.fn<number | null>("json_extract", ["payload_json", eb.val("$.execution.endedAt")])
.as("ended_at"),
])
.orderBy("run_id"),
).rows,
taskRows: executeSqliteQuerySync(
database.db,
+54 -49
View File
@@ -195,51 +195,67 @@ function seedStateDatabase(db: DatabaseSync, config: ProfileConfig): void {
function seedCronJobs(db: DatabaseSync, count: number): void {
const insert = db.prepare(`
INSERT INTO cron_jobs (
store_key, job_id, name, description, enabled, delete_after_run, created_at_ms,
agent_id, session_key, schedule_kind, schedule_expr, schedule_tz, every_ms,
anchor_ms, at, stagger_ms, session_target, wake_mode, payload_kind,
payload_message, payload_model, payload_fallbacks_json, payload_thinking,
payload_timeout_seconds, payload_allow_unsafe_external_content,
payload_external_content_source_json, payload_light_context, payload_tools_allow_json,
delivery_mode, delivery_channel, delivery_to, delivery_thread_id, delivery_account_id,
delivery_best_effort, delivery_completion_mode, delivery_completion_to,
failure_delivery_mode, failure_delivery_channel, failure_delivery_to,
failure_delivery_account_id, failure_alert_disabled, failure_alert_after,
failure_alert_channel, failure_alert_to, failure_alert_cooldown_ms,
failure_alert_include_skipped, failure_alert_mode, failure_alert_account_id,
next_run_at_ms, running_at_ms, last_run_at_ms, last_run_status, last_error,
last_duration_ms, consecutive_errors, consecutive_skipped, schedule_error_count,
last_delivery_status, last_delivery_error, last_delivered, last_failure_alert_at_ms,
store_key, job_id, name, enabled, agent_id, payload_kind,
job_json, state_json, runtime_updated_at_ms, schedule_identity, sort_order, updated_at
) VALUES (
?, ?, ?, NULL, ?, NULL, ?, ?, ?, 'every', NULL, NULL, ?, ?, NULL, NULL,
'isolated', 'now', 'agentTurn', ?, 'openai/gpt-5.6-luna', NULL, NULL, 60,
0, NULL, 1, NULL, 'announce', 'telegram', ?, NULL, 'bench-account',
1, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
NULL, NULL, NULL, ?, NULL, ?, 'completed', NULL, ?, 0, 0, 0, 'sent',
NULL, 1, NULL, ?, '{}', ?, ?, ?, ?
)
) VALUES (?, ?, ?, ?, ?, 'agentTurn', ?, ?, ?, ?, ?, ?)
`);
for (let i = 0; i < count; i += 1) {
const jobId = `job-${String(i).padStart(8, "0")}`;
const storeKey = `/state/cron/jobs-${i % 8}.json`;
const updatedAt = 1_700_000_000_000 + i;
const name = `Benchmark job ${i}`;
const enabled = i % 5 !== 0;
const agentId = `agent-${i % 16}`;
const job = {
id: jobId,
name,
enabled,
createdAtMs: updatedAt - 100_000,
agentId,
sessionKey: `agent:${agentId}:main`,
schedule: {
kind: "every",
everyMs: 60_000 + (i % 120) * 1_000,
anchorMs: updatedAt - 60_000,
},
sessionTarget: "isolated",
wakeMode: "now",
payload: {
kind: "agentTurn",
message: `Benchmark payload ${i}`,
model: "openai/gpt-5.6-luna",
timeoutSeconds: 60,
allowUnsafeExternalContent: false,
lightContext: true,
},
delivery: {
mode: "announce",
channel: "telegram",
to: `chat-${i % 32}`,
accountId: "bench-account",
bestEffort: true,
},
state: {},
};
const state = {
nextRunAtMs: updatedAt + (i % 2_000) * 1_000,
lastRunAtMs: updatedAt - 1_000,
lastRunStatus: "completed",
lastDurationMs: 50 + (i % 500),
consecutiveErrors: 0,
consecutiveSkipped: 0,
scheduleErrorCount: 0,
lastDeliveryStatus: "sent",
lastDelivered: true,
};
insert.run(
storeKey,
jobId,
`Benchmark job ${i}`,
i % 5 === 0 ? 0 : 1,
updatedAt - 100_000,
`agent-${i % 16}`,
`agent:agent-${i % 16}:main`,
60_000 + (i % 120) * 1_000,
updatedAt - 60_000,
`Benchmark payload ${i}`,
`chat-${i % 32}`,
updatedAt + (i % 2_000) * 1_000,
updatedAt - 1_000,
50 + (i % 500),
JSON.stringify({ id: jobId, seed: i }),
name,
enabled ? 1 : 0,
agentId,
JSON.stringify(job),
JSON.stringify(state),
updatedAt,
`schedule-${i % 512}`,
i,
@@ -491,21 +507,10 @@ function runHotQueries(params: {
return [
runTimedQuery(
params.stateDb,
`SELECT job_id, name, updated_at
`SELECT *
FROM cron_jobs
WHERE store_key = ?
ORDER BY sort_order ASC, updated_at ASC, job_id
LIMIT 50`,
["/state/cron/jobs-0.json"],
params.config.queryRuns,
),
runTimedQuery(
params.stateDb,
`SELECT job_id, next_run_at_ms
FROM cron_jobs
WHERE store_key = ? AND enabled = 1 AND next_run_at_ms IS NOT NULL
ORDER BY next_run_at_ms ASC, job_id
LIMIT 50`,
ORDER BY sort_order ASC, updated_at ASC, job_id ASC`,
["/state/cron/jobs-0.json"],
params.config.queryRuns,
),
+1
View File
@@ -67,6 +67,7 @@ const rawSqliteAllowPathGroups = {
"src/state/openclaw-state-db-schema-helpers.ts",
"src/state/openclaw-state-db-schema-repair.ts",
"src/state/openclaw-state-db-schema-v12-foldin.ts",
"src/state/openclaw-state-db-schema-v13-widerow.ts",
"src/state/openclaw-state-db-startup-checkpoint.ts",
"src/state/openclaw-state-db-table-retirements.ts",
"src/state/openclaw-state-db-fast-path.ts",
@@ -14,17 +14,17 @@ export function readSharedAuthProfileStoreText(stateDir) {
db = new DatabaseSync(dbPath, { readOnly: true });
const schema = db
.prepare("SELECT type FROM sqlite_schema WHERE name = ? LIMIT 1")
.get("auth_profile_stores");
.get("config_machine_state");
if (!schema) {
return "";
}
if (schema.type !== "table") {
throw new Error(`auth_profile_stores is ${String(schema.type)}, not a table`);
throw new Error(`config_machine_state is ${String(schema.type)}, not a table`);
}
const row = db
.prepare("SELECT store_json FROM auth_profile_stores WHERE store_key = ?")
.get("shared");
return typeof row?.store_json === "string" ? row.store_json : "";
.prepare("SELECT value_json FROM config_machine_state WHERE state_key = ?")
.get("authProfiles.store");
return typeof row?.value_json === "string" ? row.value_json : "";
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(`could not read the shared auth profile store: ${detail}`, {
+36 -84
View File
@@ -5,7 +5,7 @@ import { DatabaseSync } from "node:sqlite";
import { readPositiveIntEnv } from "./env-limits.mjs";
import { readTextFileBounded } from "./text-file-utils.mjs";
const INDEX_KEY = "installed-plugin-index";
const STATE_KEY = "plugins.installedIndex";
const ERROR_DETAIL_TAIL_BYTES = 16 * 1024;
const JSON_ARTIFACT_MAX_BYTES = readPositiveIntEnv(
"OPENCLAW_PLUGIN_INDEX_JSON_MAX_BYTES",
@@ -80,53 +80,24 @@ function readSqlitePluginIndex(root = stateDir()) {
const lengths = db
.prepare(
`
SELECT octet_length(install_records_json) AS install_records_json_bytes,
octet_length(plugins_json) AS plugins_json_bytes,
octet_length(diagnostics_json) AS diagnostics_json_bytes
FROM installed_plugin_index
WHERE index_key = ?
SELECT octet_length(value_json) AS value_json_bytes
FROM config_machine_state
WHERE state_key = ?
`,
)
.get(INDEX_KEY);
.get(STATE_KEY);
if (!lengths) {
return {};
}
assertIndexJsonByteLength(
lengths.install_records_json_bytes,
"plugin index install_records_json",
);
assertIndexJsonByteLength(lengths.plugins_json_bytes, "plugin index plugins_json");
assertIndexJsonByteLength(lengths.diagnostics_json_bytes, "plugin index diagnostics_json");
assertIndexJsonByteLength(lengths.value_json_bytes, "plugin index value_json");
const row = db
.prepare(
`
SELECT version, warning, host_contract_version, compat_registry_version,
migration_version, policy_hash, generated_at_ms, refresh_reason,
install_records_json, plugins_json, diagnostics_json
FROM installed_plugin_index
WHERE index_key = ?
`,
)
.get(INDEX_KEY);
.prepare("SELECT value_json FROM config_machine_state WHERE state_key = ?")
.get(STATE_KEY);
if (!row) {
return {};
}
return {
version: Number(row.version),
...(row.warning ? { warning: row.warning } : {}),
hostContractVersion: row.host_contract_version,
compatRegistryVersion: row.compat_registry_version,
migrationVersion: Number(row.migration_version),
policyHash: row.policy_hash,
generatedAtMs: Number(row.generated_at_ms),
...(row.refresh_reason ? { refreshReason: row.refresh_reason } : {}),
installRecords: parseIndexJsonText(
row.install_records_json,
"plugin index install_records_json",
),
plugins: parseIndexJsonText(row.plugins_json, "plugin index plugins_json"),
diagnostics: parseIndexJsonText(row.diagnostics_json, "plugin index diagnostics_json"),
};
const value = parseIndexJsonText(row.value_json, "plugin index value_json");
return value?.index && typeof value.index === "object" ? value.index : {};
} catch (error) {
if (error?.code === "ETOOBIG") {
throw error;
@@ -168,59 +139,40 @@ export function writePluginInstallIndexForE2E(index, options = {}) {
const db = new DatabaseSync(dbPath);
try {
db.exec(`
CREATE TABLE IF NOT EXISTS installed_plugin_index (
index_key TEXT NOT NULL PRIMARY KEY,
version INTEGER NOT NULL,
host_contract_version TEXT NOT NULL,
compat_registry_version TEXT NOT NULL,
migration_version INTEGER NOT NULL,
policy_hash TEXT NOT NULL,
generated_at_ms INTEGER NOT NULL,
refresh_reason TEXT,
install_records_json TEXT NOT NULL,
plugins_json TEXT NOT NULL,
diagnostics_json TEXT NOT NULL,
warning TEXT,
CREATE TABLE IF NOT EXISTS config_machine_state (
state_key TEXT NOT NULL PRIMARY KEY,
value_json TEXT NOT NULL,
updated_at_ms INTEGER NOT NULL
);
`);
const now = Date.now();
const persisted = {
revision: now,
index: {
version: index.version ?? 1,
warning:
index.warning ??
"DO NOT EDIT. This row is generated by OpenClaw plugin registry commands.",
hostContractVersion: index.hostContractVersion ?? "docker-e2e",
compatRegistryVersion: index.compatRegistryVersion ?? "docker-e2e",
migrationVersion: index.migrationVersion ?? 1,
policyHash: index.policyHash ?? "docker-e2e",
generatedAtMs: index.generatedAtMs ?? now,
...(index.refreshReason ? { refreshReason: index.refreshReason } : {}),
installRecords: index.installRecords ?? {},
plugins: index.plugins ?? [],
diagnostics: index.diagnostics ?? [],
},
};
db.prepare(
`
INSERT INTO installed_plugin_index (
index_key, version, host_contract_version, compat_registry_version,
migration_version, policy_hash, generated_at_ms, refresh_reason,
install_records_json, plugins_json, diagnostics_json, warning, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(index_key) DO UPDATE SET
version = excluded.version,
host_contract_version = excluded.host_contract_version,
compat_registry_version = excluded.compat_registry_version,
migration_version = excluded.migration_version,
policy_hash = excluded.policy_hash,
generated_at_ms = excluded.generated_at_ms,
refresh_reason = excluded.refresh_reason,
install_records_json = excluded.install_records_json,
plugins_json = excluded.plugins_json,
diagnostics_json = excluded.diagnostics_json,
warning = excluded.warning,
INSERT INTO config_machine_state (state_key, value_json, updated_at_ms)
VALUES (?, ?, ?)
ON CONFLICT(state_key) DO UPDATE SET
value_json = excluded.value_json,
updated_at_ms = excluded.updated_at_ms
`,
).run(
INDEX_KEY,
index.version ?? 1,
index.hostContractVersion ?? "docker-e2e",
index.compatRegistryVersion ?? "docker-e2e",
index.migrationVersion ?? 1,
index.policyHash ?? "docker-e2e",
index.generatedAtMs ?? now,
index.refreshReason ?? null,
JSON.stringify(index.installRecords ?? {}),
JSON.stringify(index.plugins ?? []),
JSON.stringify(index.diagnostics ?? []),
index.warning ?? "DO NOT EDIT. This row is generated by OpenClaw plugin registry commands.",
now,
);
).run(STATE_KEY, JSON.stringify(persisted), now);
} finally {
db.close();
}
@@ -427,9 +427,8 @@ export function assertUpgradeVolumeMigrated(stateDir, stage) {
assertHealthySqlite(stateDatabasePath, (db) => {
const rows = db
.prepare(
`SELECT job_id, job_json, state_json, enabled, schedule_kind, every_ms, anchor_ms,
payload_kind, payload_message, delivery_mode, next_run_at_ms, running_at_ms,
last_run_status, last_error, updated_at, runtime_updated_at_ms
`SELECT job_id, job_json, state_json, enabled, payload_kind, updated_at,
runtime_updated_at_ms
FROM cron_jobs
WHERE job_id LIKE 'volume-cron-%'`,
)
@@ -468,26 +467,23 @@ export function assertUpgradeVolumeMigrated(stateDir, stage) {
row?.enabled === (expected.enabled ? 1 : 0),
`volume cron enabled column changed: ${index}`,
);
assert(row?.schedule_kind === "every", `volume cron schedule column changed: ${index}`);
assert(row?.every_ms === expected.schedule.everyMs, `volume cron interval changed: ${index}`);
assert(row?.anchor_ms === expected.schedule.anchorMs, `volume cron anchor changed: ${index}`);
assert(row?.payload_kind === "agentTurn", `volume cron payload kind changed: ${index}`);
assert(
row?.payload_message === expected.payload.message,
`volume cron payload changed: ${index}`,
);
assert(row?.delivery_mode === "none", `volume cron delivery mode changed: ${index}`);
assert(
row?.next_run_at_ms === (expected.enabled ? expected.state.nextRunAtMs : null),
(actualState?.nextRunAtMs ?? null) ===
(expected.enabled ? expected.state.nextRunAtMs : null),
`volume cron next-run state changed: ${index}`,
);
assert(row?.running_at_ms === null, `volume cron running state changed: ${index}`);
assert(
row?.last_run_status === (expected.state.lastStatus ?? null),
(actualState?.runningAtMs ?? null) === null,
`volume cron running state changed: ${index}`,
);
assert(
(actualState?.lastRunStatus ?? actualState?.lastStatus ?? null) ===
(expected.state.lastStatus ?? null),
`volume cron status state changed: ${index}`,
);
assert(
row?.last_error === (expected.state.lastError ?? null),
(actualState?.lastError ?? null) === (expected.state.lastError ?? null),
`volume cron error state changed: ${index}`,
);
}
+2 -2
View File
@@ -135,7 +135,7 @@ try {
db = new DatabaseSync(dbPath);
try {
db.exec("PRAGMA secure_delete = ON;");
db.prepare("DELETE FROM installed_plugin_index WHERE index_key = ?").run("installed-plugin-index");
db.prepare("DELETE FROM config_machine_state WHERE state_key = ?").run("plugins.installedIndex");
db.exec("PRAGMA wal_checkpoint(TRUNCATE);");
db.exec("VACUUM;");
} catch (err) {
@@ -158,7 +158,7 @@ openclaw_live_stage_state_dir() {
# Sandbox workspaces can accumulate root-owned artifacts from prior Docker
# runs. Persisted plugin registry state contains host-absolute paths that
# are not portable into Linux containers. Live-test auth/config staging does
# not need the old JSON source or the SQLite installed_plugin_index row.
# not need the old JSON source or the SQLite plugins.installedIndex machine-state row.
set +e
tar -C "$source_dir" \
--warning=no-file-changed \
+16 -6
View File
@@ -166,14 +166,18 @@ describe("auth profile sqlite store", () => {
const database = new DatabaseSync(resolveOpenClawStateSqlitePath());
expect(
database
.prepare("SELECT store_key FROM auth_profile_stores WHERE store_key = 'shared'")
.prepare(
"SELECT state_key FROM config_machine_state WHERE state_key = 'authProfiles.store'",
)
.get(),
).toEqual({ store_key: "shared" });
).toEqual({ state_key: "authProfiles.store" });
expect(
database
.prepare("SELECT store_key FROM auth_profile_state WHERE store_key = 'shared'")
.prepare(
"SELECT state_key FROM config_machine_state WHERE state_key = 'authProfiles.state'",
)
.get(),
).toEqual({ store_key: "shared" });
).toEqual({ state_key: "authProfiles.state" });
expect(
database
.prepare(
@@ -220,7 +224,9 @@ describe("auth profile sqlite store", () => {
).toBeUndefined();
expect(
sharedDatabase
.prepare("SELECT store_key FROM auth_profile_stores WHERE store_key = 'shared'")
.prepare(
"SELECT state_key FROM config_machine_state WHERE state_key = 'authProfiles.store'",
)
.get(),
).toBeUndefined();
sharedDatabase.close();
@@ -314,7 +320,11 @@ describe("auth profile sqlite store", () => {
.get(),
).toBeUndefined();
expect(
after.prepare("SELECT store_key FROM auth_profile_stores WHERE store_key = 'shared'").get(),
after
.prepare(
"SELECT state_key FROM config_machine_state WHERE state_key = 'authProfiles.store'",
)
.get(),
).toBeUndefined();
after.close();
expect(inspectPersistedAuthProfileStoreRaw(agentDir).status).toBe("readable");
+59 -87
View File
@@ -44,10 +44,7 @@ type AgentAuthProfileDatabase = Pick<
OpenClawAgentKyselyDatabase,
"auth_profile_store" | "auth_profile_state"
>;
type SharedAuthProfileDatabase = Pick<
OpenClawStateKyselyDatabase,
"auth_profile_stores" | "auth_profile_state"
>;
type SharedAuthProfileDatabase = Pick<OpenClawStateKyselyDatabase, "config_machine_state">;
export type AuthProfileDatabase = OpenClawAgentDatabase | OpenClawStateDatabase;
type AuthProfileDatabaseTarget =
@@ -57,7 +54,45 @@ type AuthProfileDatabaseTarget =
// Auth profiles store one JSON blob for secrets and one JSON blob for runtime
// state. SQLite owns durability/transactions; JSON shape owns compatibility.
const PRIMARY_ROW_KEY = "primary";
const SHARED_ROW_KEY = "shared";
// Shared-state auth payloads live in config_machine_state; the keys are listed
// in STATE_SECRET_CONFIG_STATE_KEY_PREFIXES so git backups never carry them.
const SHARED_STORE_STATE_KEY = "authProfiles.store";
const SHARED_STATE_STATE_KEY = "authProfiles.state";
// These run inside the module's own transactions; opening another would nest.
function readSharedAuthKvCell(db: DatabaseSync, stateKey: string): string | undefined {
const row = executeSqliteQueryTakeFirstSync(
db,
getSharedAuthProfileKysely(db)
.selectFrom("config_machine_state")
.select("value_json")
.where("state_key", "=", stateKey),
);
return row?.value_json;
}
function writeSharedAuthKvCell(db: DatabaseSync, stateKey: string, valueJson: string): void {
executeSqliteQuerySync(
db,
getSharedAuthProfileKysely(db)
.insertInto("config_machine_state")
.values({ state_key: stateKey, value_json: valueJson, updated_at_ms: Date.now() })
.onConflict((conflict) =>
conflict
.column("state_key")
.doUpdateSet({ value_json: valueJson, updated_at_ms: Date.now() }),
),
);
}
function deleteSharedAuthKvCell(db: DatabaseSync, stateKey: string): void {
executeSqliteQuerySync(
db,
getSharedAuthProfileKysely(db)
.deleteFrom("config_machine_state")
.where("state_key", "=", stateKey),
);
}
const AUTH_PROFILE_READ_HANDLE_CAP = 8;
const authProfileReadDatabases = new Map<string, DatabaseSync>();
const sharedAuthPostCommitPublications = new WeakMap<OpenClawStateDatabase, Array<() => void>>();
@@ -182,8 +217,8 @@ function inspectAuthProfileTable(
databaseKind: AuthProfileDatabaseTarget["kind"],
): PersistedAuthProfileStoreInspection | null {
const tableName =
target === "store" && databaseKind === "shared-state"
? "auth_profile_stores"
databaseKind === "shared-state"
? "config_machine_state"
: target === "store"
? "auth_profile_store"
: "auth_profile_state";
@@ -208,30 +243,15 @@ function inspectAuthProfileJsonCell(
return tableInspection;
}
let raw: string;
if (databaseKind === "shared-state" && target === "store") {
const row = executeSqliteQueryTakeFirstSync(
if (databaseKind === "shared-state") {
const cell = readSharedAuthKvCell(
db,
getSharedAuthProfileKysely(db)
.selectFrom("auth_profile_stores")
.select("store_json")
.where("store_key", "=", SHARED_ROW_KEY),
target === "store" ? SHARED_STORE_STATE_KEY : SHARED_STATE_STATE_KEY,
);
if (!row) {
if (cell === undefined) {
return { status: "missing", reason: "row" };
}
raw = row.store_json;
} else if (databaseKind === "shared-state") {
const row = executeSqliteQueryTakeFirstSync(
db,
getSharedAuthProfileKysely(db)
.selectFrom("auth_profile_state")
.select("state_json")
.where("store_key", "=", SHARED_ROW_KEY),
);
if (!row) {
return { status: "missing", reason: "row" };
}
raw = row.state_json;
raw = cell;
} else if (target === "store") {
const row = executeSqliteQueryTakeFirstSync(
db,
@@ -450,14 +470,7 @@ export function readPersistedAuthProfileStoreRaw(
const databaseTarget = resolveAuthProfileDatabaseOptions(agentDir);
if (database) {
if (resolveAuthProfileDatabaseKind(agentDir, database) === "shared-state") {
const row = executeSqliteQueryTakeFirstSync(
database.db,
getSharedAuthProfileKysely(database.db)
.selectFrom("auth_profile_stores")
.select("store_json")
.where("store_key", "=", SHARED_ROW_KEY),
);
return parseJsonCell(row?.store_json);
return parseJsonCell(readSharedAuthKvCell(database.db, SHARED_STORE_STATE_KEY));
}
const row = executeSqliteQueryTakeFirstSync(
database.db,
@@ -480,14 +493,7 @@ export function readPersistedAuthProfileStateRaw(
const databaseTarget = resolveAuthProfileDatabaseOptions(agentDir);
if (database) {
if (resolveAuthProfileDatabaseKind(agentDir, database) === "shared-state") {
const row = executeSqliteQueryTakeFirstSync(
database.db,
getSharedAuthProfileKysely(database.db)
.selectFrom("auth_profile_state")
.select("state_json")
.where("store_key", "=", SHARED_ROW_KEY),
);
return parseJsonCell(row?.state_json);
return parseJsonCell(readSharedAuthKvCell(database.db, SHARED_STATE_STATE_KEY));
}
const row = executeSqliteQueryTakeFirstSync(
database.db,
@@ -523,22 +529,7 @@ export function writePersistedAuthProfileStoreRaw(
const databaseKind = resolveAuthProfileDatabaseKind(agentDir, database);
const write = (target: AuthProfileDatabase) => {
if (databaseKind === "shared-state") {
executeSqliteQuerySync(
target.db,
getSharedAuthProfileKysely(target.db)
.insertInto("auth_profile_stores")
.values({
store_key: SHARED_ROW_KEY,
store_json: JSON.stringify(payload),
updated_at: Date.now(),
})
.onConflict((conflict) =>
conflict.column("store_key").doUpdateSet({
store_json: JSON.stringify(payload),
updated_at: Date.now(),
}),
),
);
writeSharedAuthKvCell(target.db, SHARED_STORE_STATE_KEY, JSON.stringify(payload));
return;
}
executeSqliteQuerySync(
@@ -572,15 +563,15 @@ export function deletePersistedAuthProfileStoreRaw(
): void {
const databaseKind = resolveAuthProfileDatabaseKind(agentDir, database);
const remove = (target: AuthProfileDatabase) => {
if (databaseKind === "shared-state") {
deleteSharedAuthKvCell(target.db, SHARED_STORE_STATE_KEY);
return;
}
executeSqliteQuerySync(
target.db,
databaseKind === "shared-state"
? getSharedAuthProfileKysely(target.db)
.deleteFrom("auth_profile_stores")
.where("store_key", "=", SHARED_ROW_KEY)
: getAgentAuthProfileKysely(target.db)
.deleteFrom("auth_profile_store")
.where("store_key", "=", PRIMARY_ROW_KEY),
getAgentAuthProfileKysely(target.db)
.deleteFrom("auth_profile_store")
.where("store_key", "=", PRIMARY_ROW_KEY),
);
};
if (database) {
@@ -599,30 +590,11 @@ export function writePersistedAuthProfileStateRaw(
const databaseKind = resolveAuthProfileDatabaseKind(agentDir, database);
const write = (target: AuthProfileDatabase) => {
if (databaseKind === "shared-state") {
const db = getSharedAuthProfileKysely(target.db);
if (!payload) {
executeSqliteQuerySync(
target.db,
db.deleteFrom("auth_profile_state").where("store_key", "=", SHARED_ROW_KEY),
);
deleteSharedAuthKvCell(target.db, SHARED_STATE_STATE_KEY);
return;
}
executeSqliteQuerySync(
target.db,
db
.insertInto("auth_profile_state")
.values({
store_key: SHARED_ROW_KEY,
state_json: JSON.stringify(payload),
updated_at: Date.now(),
})
.onConflict((conflict) =>
conflict.column("store_key").doUpdateSet({
state_json: JSON.stringify(payload),
updated_at: Date.now(),
}),
),
);
writeSharedAuthKvCell(target.db, SHARED_STATE_STATE_KEY, JSON.stringify(payload));
return;
}
const db = getAgentAuthProfileKysely(target.db);
@@ -376,9 +376,7 @@ describe("atomic subagent completion admission store", () => {
};
delete legacyPayload.completion!.fallbackResultText;
database.db
.prepare(
"UPDATE subagent_runs SET payload_json = ?, fallback_frozen_result_text = NULL WHERE run_id = ?",
)
.prepare("UPDATE subagent_runs SET payload_json = ? WHERE run_id = ?")
.run(JSON.stringify(legacyPayload), input.subagent.runId);
database.db
.prepare("UPDATE schema_meta SET app_version = ? WHERE meta_key = 'primary'")
@@ -236,46 +236,6 @@ describe("subagent registry sqlite store", () => {
});
});
it("keeps the complete payload authoritative over stale derived state columns", async () => {
await withTempStateEnv(async () => {
const run = createRun({
requesterSettleWake: {
status: "dispatching",
attemptCount: 2,
batchRunIds: ["run-one"],
},
});
saveSubagentRegistryToSqlite(new Map([[run.runId, run]]));
const { db } = openOpenClawStateDatabase();
executeSqliteQuerySync(
db,
getNodeSqliteKysely<SubagentRegistryDatabase>(db)
.updateTable("subagent_runs")
.set({
expects_completion_message: 0,
frozen_result_text: "stale typed completion",
pending_final_delivery_last_error: "stale typed delivery",
requester_settle_wake_status: "pending",
requester_settle_wake_attempt_count: 99,
outcome_json: JSON.stringify({ status: "timeout" }),
})
.where("run_id", "=", run.runId),
);
closeOpenClawStateDatabaseForTest();
const restored = loadSubagentRegistryFromSqlite().get(run.runId);
expect(restored?.expectsCompletionMessage).toBe(true);
expect(restored?.completion?.resultText).toBe("done");
expect(restored?.delivery).toMatchObject({ status: "pending", lastError: "retry later" });
expect(restored?.requesterSettleWake).toEqual(run.requesterSettleWake);
expect(restored?.execution.outcome?.status).toBe("ok");
const sessionListRun = loadSubagentSessionListRunsFromSqlite().get(run.runId);
expect(sessionListRun?.execution.outcome?.status).toBe("ok");
expect(sessionListRun?.delivery?.status).toBe("pending");
});
});
it("promotes legacy retained results into canonical completion state once", async () => {
await withTempStateEnv(async () => {
const run = createRun({
@@ -315,16 +275,8 @@ describe("subagent registry sqlite store", () => {
expect(restored?.delivery?.payload).not.toHaveProperty("fallbackFrozenResultText");
const stored = openOpenClawStateDatabase()
.db.prepare(
"SELECT payload_json, frozen_result_text, fallback_frozen_result_text FROM subagent_runs WHERE run_id = ?",
)
.get(run.runId) as {
payload_json: string;
frozen_result_text: string | null;
fallback_frozen_result_text: string | null;
};
expect(stored.frozen_result_text).toBe("NO_REPLY");
expect(stored.fallback_frozen_result_text).toBe("legacy retained result");
.db.prepare("SELECT payload_json FROM subagent_runs WHERE run_id = ?")
.get(run.runId) as { payload_json: string };
const storedPayload = JSON.parse(stored.payload_json) as SubagentRunRecord;
expect(storedPayload.completion).toMatchObject({
required: true,
@@ -491,7 +443,6 @@ describe("subagent registry sqlite store", () => {
stateDb
.updateTable("subagent_runs")
.set({
expects_completion_message: 1,
payload_json: JSON.stringify({
...run,
delivery: { status: "delivered", announcedAt: 300, deliveredAt: 300 },
@@ -1,7 +1,6 @@
/**
* Persists subagent run records in the shared sqlite state database. The
* store preserves typed columns for hot delivery state while retaining the
* normalized payload JSON for forward-compatible record hydration.
* Persists subagent run records in the shared sqlite state database, with
* query-bearing identity columns indexing canonical normalized payload JSON.
*/
import { safeParseJson } from "@openclaw/normalization-core";
import { asFiniteNumber as normalizeFiniteNumber } from "@openclaw/normalization-core/number-coercion";
@@ -26,20 +25,16 @@ type SubagentRunSqliteInsert = BoundSubagentRunRecord;
type SubagentRunSqliteUpdate = Updateable<SubagentRunsTable>;
type SubagentRunReadSqliteRow = Pick<
SubagentRunSqliteRow,
| "run_id"
| "child_session_key"
| "controller_session_key"
| "requester_session_key"
| "model"
| "run_timeout_seconds"
| "created_at"
| "started_at"
| "session_started_at"
| "accumulated_runtime_ms"
| "ended_at"
| "ended_reason"
| "cleanup_completed_at"
"run_id" | "child_session_key" | "controller_session_key" | "requester_session_key" | "created_at"
> & {
model: string | null;
run_timeout_seconds: number | null;
started_at: number | null;
session_started_at: number | null;
accumulated_runtime_ms: number | null;
ended_at: number | null;
ended_reason: string | null;
cleanup_completed_at: number | null;
generation: number | null;
outcome_status: string | null;
delivery_status: string | null;
@@ -75,18 +70,10 @@ function isCanonicalSubagentRunRecord(value: unknown): value is CanonicalSubagen
);
}
function jsonStringify(value: unknown): string | null {
return value === undefined ? null : JSON.stringify(value);
}
function parseJson(raw: string | null): unknown {
return raw ? safeParseJson(raw) : undefined;
}
function boolToSqlite(value: boolean | undefined): number | null {
return value === undefined ? null : value ? 1 : 0;
}
/** Rehydrates one sqlite row into the normalized subagent run record shape. */
function rowToSubagentRunRecord(row: SubagentRunSqliteRow): SubagentRunRecord | null {
const payload = parseJson(row.payload_json);
@@ -120,70 +107,12 @@ export function bindSubagentRunRecord(entry: SubagentRunRecord): BoundSubagentRu
if (!isCanonicalSubagentRunRecord(normalized)) {
throw new Error("subagent run is missing canonical nested state");
}
const delivery = normalized.delivery;
const completion = normalized.completion;
const requesterSettleWake = normalized.requesterSettleWake;
return {
run_id: normalized.runId,
child_session_key: normalized.childSessionKey,
controller_session_key: normalized.controllerSessionKey?.trim() || null,
requester_session_key: normalized.requesterSessionKey,
requester_display_key: normalized.requesterDisplayKey,
requester_origin_json: jsonStringify(normalized.requesterOrigin),
task: normalized.task,
task_name: normalized.taskName ?? null,
cleanup: normalized.cleanup,
label: normalized.label ?? null,
model: normalized.model ?? null,
agent_dir: normalized.agentDir ?? null,
workspace_dir: normalized.workspaceDir ?? null,
run_timeout_seconds: normalized.runTimeoutSeconds ?? null,
spawn_mode: normalized.spawnMode ?? null,
created_at: normalized.createdAt,
started_at: normalized.execution.startedAt ?? null,
session_started_at: normalized.sessionStartedAt ?? null,
accumulated_runtime_ms: normalized.accumulatedRuntimeMs ?? null,
ended_at: normalized.execution.endedAt ?? null,
outcome_json: jsonStringify(normalized.execution.outcome),
archive_at_ms: normalized.archiveAtMs ?? null,
cleanup_completed_at: normalized.cleanupCompletedAt ?? null,
cleanup_handled: boolToSqlite(normalized.cleanupHandled),
suppress_announce_reason: normalized.suppressAnnounceReason ?? null,
expects_completion_message: boolToSqlite(normalized.expectsCompletionMessage),
announce_retry_count: delivery?.attemptCount ?? null,
last_announce_retry_at: delivery?.lastAttemptAt ?? null,
last_announce_delivery_error: delivery?.lastError ?? null,
ended_reason: normalized.endedReason ?? null,
pause_reason: normalized.pauseReason ?? null,
wake_on_descendant_settle: boolToSqlite(normalized.wakeOnDescendantSettle),
requester_settle_wake_status: requesterSettleWake?.status ?? null,
requester_settle_wake_attempt_count: requesterSettleWake?.attemptCount ?? null,
requester_settle_wake_replay_count: requesterSettleWake?.replayCount ?? null,
requester_settle_wake_next_attempt_at: requesterSettleWake?.nextAttemptAt ?? null,
requester_settle_wake_batch_run_ids_json: jsonStringify(requesterSettleWake?.batchRunIds),
requester_settle_wake_last_error: requesterSettleWake?.lastError ?? null,
requester_settle_wake_retire_after: boolToSqlite(requesterSettleWake?.retireAfterSettle),
frozen_result_text: completion?.resultText ?? null,
frozen_result_captured_at: completion?.capturedAt ?? null,
fallback_frozen_result_text: completion?.fallbackResultText ?? null,
fallback_frozen_result_captured_at: completion?.fallbackCapturedAt ?? null,
ended_hook_emitted_at: normalized.endedHookEmittedAt ?? null,
pending_final_delivery: boolToSqlite(
delivery?.status === "pending" || Boolean(delivery?.payload),
),
pending_final_delivery_created_at: delivery?.createdAt ?? null,
pending_final_delivery_last_attempt_at: delivery?.lastAttemptAt ?? null,
pending_final_delivery_attempt_count: delivery?.attemptCount ?? null,
pending_final_delivery_last_error: delivery?.lastError ?? null,
pending_final_delivery_payload_json: jsonStringify(delivery?.payload),
completion_announced_at: delivery?.announcedAt ?? null,
swarm_group_id: normalized.groupId ?? null,
swarm_collector: boolToSqlite(normalized.collect),
swarm_output_schema_json: jsonStringify(normalized.outputSchema),
swarm_completion_status: normalized.collectorCompletion?.status ?? null,
swarm_structured_json: jsonStringify(normalized.collectorCompletion?.structured),
swarm_schema_error: normalized.collectorCompletion?.schemaError ?? null,
swarm_usage_json: jsonStringify(normalized.collectorCompletion?.usage),
payload_json: JSON.stringify(normalized),
};
}
@@ -306,15 +235,17 @@ function readSubagentSessionListRows(): SubagentRunReadSqliteRow[] {
"child_session_key",
"controller_session_key",
"requester_session_key",
"model",
"run_timeout_seconds",
"created_at",
"started_at",
"session_started_at",
"accumulated_runtime_ms",
"ended_at",
"ended_reason",
"cleanup_completed_at",
subagentPayloadJsonValue<string | null>("$.model").as("model"),
subagentPayloadJsonValue<number | null>("$.runTimeoutSeconds").as("run_timeout_seconds"),
subagentPayloadJsonValue<number | null>("$.execution.startedAt").as("started_at"),
subagentPayloadJsonValue<number | null>("$.sessionStartedAt").as("session_started_at"),
subagentPayloadJsonValue<number | null>("$.accumulatedRuntimeMs").as(
"accumulated_runtime_ms",
),
subagentPayloadJsonValue<number | null>("$.execution.endedAt").as("ended_at"),
subagentPayloadJsonValue<string | null>("$.endedReason").as("ended_reason"),
subagentPayloadJsonValue<number | null>("$.cleanupCompletedAt").as("cleanup_completed_at"),
subagentPayloadJsonValue<number | null>("$.generation").as("generation"),
subagentPayloadJsonValue<string | null>("$.execution.outcome.status").as("outcome_status"),
subagentPayloadJsonValue<string | null>("$.delivery.status").as("delivery_status"),
+12 -4
View File
@@ -42,9 +42,17 @@ afterEach(async () => {
function deleteWorkspaceAttestation(workspaceDir: string): void {
const identity = resolveWorkspaceStateIdentity(workspaceDir);
openOpenClawStateDatabase()
.db.prepare("DELETE FROM workspace_attestations WHERE workspace_key = ?")
.run(identity.workspaceKey);
const db = openOpenClawStateDatabase().db;
// Mirrors the pre-v13 attestation-row delete: clearing the merged columns
// must also drop the generated hashes the old FK cascade removed.
db.prepare(
`UPDATE workspace_setup_state
SET attested_at_ms = NULL, attestation_updated_at_ms = NULL
WHERE workspace_key = ?`,
).run(identity.workspaceKey);
db.prepare("DELETE FROM workspace_generated_bootstrap_hashes WHERE workspace_key = ?").run(
identity.workspaceKey,
);
}
describe("workspace setup-only SQLite safety", () => {
@@ -80,7 +88,7 @@ describe("workspace setup-only SQLite safety", () => {
const expiredAtMs = Date.now() - 25 * 60 * 60 * 1000;
const db = openOpenClawStateDatabase().db;
db.prepare(
"UPDATE workspace_attestations SET attested_at_ms = ?, updated_at_ms = ? WHERE workspace_key = ?",
"UPDATE workspace_setup_state SET attested_at_ms = ?, attestation_updated_at_ms = ? WHERE workspace_key = ?",
).run(expiredAtMs, expiredAtMs, identity.workspaceKey);
db.prepare("UPDATE workspace_setup_state SET updated_at = ? WHERE workspace_key = ?").run(
expiredAtMs,
+9 -3
View File
@@ -54,8 +54,10 @@ function insertPersistedAttestationHash(filename: string, sha256: string): void
const identity = resolveWorkspaceStateIdentity(workspaceDir());
const db = openOpenClawStateDatabase().db;
db.prepare(
"INSERT INTO workspace_attestations (workspace_key, attested_at_ms, updated_at_ms) VALUES (?, 1, 1)",
).run(identity.workspaceKey);
`INSERT INTO workspace_setup_state (
workspace_key, workspace_path, attested_at_ms, attestation_updated_at_ms
) VALUES (?, ?, 1, 1)`,
).run(identity.workspaceKey, identity.workspacePath);
db.prepare(
"INSERT INTO workspace_generated_bootstrap_hashes (workspace_key, filename, sha256) VALUES (?, ?, ?)",
).run(identity.workspaceKey, filename, sha256);
@@ -189,7 +191,11 @@ describe("workspace state store", () => {
nowMs: 4_000,
});
const attestation = readWorkspaceStateSnapshot(dir).attestation;
const snapshot = readWorkspaceStateSnapshot(dir);
// Attestation-only rows carry NULL setup columns: recording hashes before
// any setup write must not fabricate setup state.
expect(snapshot.setupExists).toBe(false);
const attestation = snapshot.attestation;
expect(attestation?.attestedAtMs).toBe(3_000);
expect([...attestation!.generatedHashes.entries()]).toStrictEqual([
["SOUL.md", "c".repeat(64)],
+24 -24
View File
@@ -91,7 +91,6 @@ type WorkspaceStateDatabase = Pick<
OpenClawStateKyselyDatabase,
| "workspace_setup_state"
| "workspace_path_aliases"
| "workspace_attestations"
| "workspace_generated_bootstrap_hashes"
| "migration_runs"
| "migration_sources"
@@ -237,27 +236,26 @@ function readSnapshotFromDatabase(params: {
.selectAll()
.where("workspace_key", "=", identity.workspaceKey),
);
if (setupRow && setupRow.workspace_path !== identity.workspacePath) {
// A NULL path marks a legacy orphan attestation; the first live access to a
// matching workspace adopts it, so only a differing recorded path collides.
if (setupRow?.workspace_path != null && setupRow.workspace_path !== identity.workspacePath) {
throw new Error("workspace state key collision");
}
if (setupRow && setupRow.version !== WORKSPACE_SETUP_STATE_VERSION) {
if (setupRow?.version != null && setupRow.version !== WORKSPACE_SETUP_STATE_VERSION) {
throw new Error("workspace setup state version requires openclaw doctor --fix");
}
if (setupRow) {
if (setupRow?.version != null) {
assertCanonicalTimestamp(setupRow.bootstrap_seeded_at, "bootstrap seeded");
assertCanonicalTimestamp(setupRow.setup_completed_at, "setup completed");
if (setupRow.updated_at == null) {
throw new Error("workspace setup update timestamp is invalid");
}
assertCanonicalIntegerTimestamp(setupRow.updated_at, "setup update");
}
const attestationRow = executeSqliteQueryTakeFirstSync(
params.database.db,
kysely
.selectFrom("workspace_attestations")
.selectAll()
.where("workspace_key", "=", identity.workspaceKey),
);
const attestationPresent = setupRow?.attested_at_ms != null;
const generatedHashes = new Map<string, string>();
if (attestationRow) {
assertCanonicalIntegerTimestamp(attestationRow.attested_at_ms, "attestation");
if (setupRow && attestationPresent) {
assertCanonicalIntegerTimestamp(setupRow.attested_at_ms!, "attestation");
const hashRows = executeSqliteQuerySync(
params.database.db,
kysely
@@ -278,19 +276,22 @@ function readSnapshotFromDatabase(params: {
generatedHashes.set(row.filename, row.sha256);
}
}
const setupExists = setupRow?.version != null;
return {
identity,
setupExists: Boolean(setupRow),
...(setupRow ? { setupUpdatedAtMs: setupRow.updated_at } : {}),
setupExists,
...(setupExists && setupRow?.updated_at != null
? { setupUpdatedAtMs: setupRow.updated_at }
: {}),
setup: {
version: WORKSPACE_SETUP_STATE_VERSION,
...(setupRow?.bootstrap_seeded_at ? { bootstrapSeededAt: setupRow.bootstrap_seeded_at } : {}),
...(setupRow?.setup_completed_at ? { setupCompletedAt: setupRow.setup_completed_at } : {}),
},
...(attestationRow
...(attestationPresent
? {
attestation: {
attestedAtMs: attestationRow.attested_at_ms,
attestedAtMs: setupRow!.attested_at_ms!,
generatedHashes,
},
}
@@ -470,16 +471,19 @@ export function replaceWorkspaceAttestation(params: {
executeSqliteQuerySync(
database.db,
kysely
.insertInto("workspace_attestations")
.insertInto("workspace_setup_state")
.values({
workspace_key: identity.workspaceKey,
workspace_path: identity.workspacePath,
attested_at_ms: params.attestedAtMs,
updated_at_ms: updatedAtMs,
attestation_updated_at_ms: updatedAtMs,
})
.onConflict((conflict) =>
conflict.column("workspace_key").doUpdateSet({
// Heals the NULL path on adopted legacy orphan attestation rows.
workspace_path: identity.workspacePath,
attested_at_ms: params.attestedAtMs,
updated_at_ms: updatedAtMs,
attestation_updated_at_ms: updatedAtMs,
}),
),
);
@@ -563,10 +567,6 @@ function deleteWorkspaceRows(
.deleteFrom("workspace_generated_bootstrap_hashes")
.where("workspace_key", "=", workspaceKey),
);
executeSqliteQuerySync(
database.db,
kysely.deleteFrom("workspace_attestations").where("workspace_key", "=", workspaceKey),
);
executeSqliteQuerySync(
database.db,
kysely.deleteFrom("workspace_setup_state").where("workspace_key", "=", workspaceKey),
@@ -12,6 +12,7 @@ import {
import { STATE_SCHEMA_10_TO_9_DOWNGRADE_SQL } from "../state/openclaw-state-schema-v10-retirement.test-support.js";
import { STATE_SCHEMA_11_TO_10_TABLES_SQL } from "../state/openclaw-state-schema-v11-retirement.test-support.js";
import { STATE_SCHEMA_12_TO_11_DOWNGRADE_SQL } from "../state/openclaw-state-schema-v12-foldin.test-support.js";
import { STATE_SCHEMA_13_TO_12_DOWNGRADE_SQL } from "../state/openclaw-state-schema-v13-widerow.test-support.js";
import { recordAuditEvent } from "./audit-event-store.js";
import type { OutboundMessageProgressInput } from "./audit-event-types.js";
import {
@@ -146,7 +147,7 @@ describe("outbound message progress companion", () => {
expect(opened.db.prepare("PRAGMA user_version").get()).toEqual({
user_version: OPENCLAW_STATE_SCHEMA_VERSION,
});
expect(OPENCLAW_STATE_SCHEMA_VERSION).toBe(12);
expect(OPENCLAW_STATE_SCHEMA_VERSION).toBe(13);
expect(tableExists(opened.db, "outbound_message_progress")).toBe(false);
expect(tableExists(opened.db, "outbound_message_execution_bindings")).toBe(false);
@@ -277,10 +278,11 @@ describe("outbound message progress companion", () => {
).toBe(true);
// This pinned reader predates the Workshop's first-use column and requires present lazy tables
// to retain its exact shape; project that unrelated table to the reader's historical contract.
// The v9-era reader needs the v12 singleton fold-in, v11 curator retirement,
// and v10 dead-table retirement projected backward in migration order.
// The v9-era reader needs the v13 projection removal, v12 singleton fold-in,
// v11 curator retirement, and v10 dead-table retirement reversed in order.
const projectedDatabase = openOpenClawStateDatabase(database).db;
projectedDatabase.exec("ALTER TABLE skill_workshop_proposals DROP COLUMN claim_released_time;");
projectedDatabase.exec(STATE_SCHEMA_13_TO_12_DOWNGRADE_SQL);
projectedDatabase.exec(STATE_SCHEMA_12_TO_11_DOWNGRADE_SQL);
projectedDatabase.exec(STATE_SCHEMA_11_TO_10_TABLES_SQL);
projectedDatabase.exec(STATE_SCHEMA_10_TO_9_DOWNGRADE_SQL);
+36 -46
View File
@@ -4,6 +4,8 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { normalizeCronJobCreate } from "../cron/normalize.js";
import { upsertCronJobRow } from "../cron/store/row-codec.js";
import type { CronStoredJob } from "../cron/types.js";
import {
listOpenClawRegisteredAgentDatabases,
registerOpenClawAgentDatabase,
@@ -66,6 +68,30 @@ function cronReadView(agentId: string, ref: ReturnType<typeof readClawCronRefs>[
};
}
function seedAttachedCronJob(
env: NodeJS.ProcessEnv,
job: Pick<CronStoredJob, "id" | "name" | "schedule">,
): void {
const database = openOpenClawStateDatabase({ env });
upsertCronJobRow(
database.db,
"default",
{
...job,
agentId: "worker",
owner: { agentId: "worker" },
enabled: true,
createdAtMs: 1,
updatedAtMs: 1,
sessionTarget: "isolated",
wakeMode: "now",
payload: { kind: "agentTurn", message: "Run scheduled job" },
state: {},
},
0,
);
}
async function fixture(
params: {
id?: string;
@@ -404,29 +430,11 @@ describe("Claw status and remove", () => {
it("previews and blocks operator-owned cron jobs attached to the agent", async () => {
const current = await addFixture();
const database = openOpenClawStateDatabase({ env: current.env });
database.db
.prepare(
`INSERT INTO cron_jobs (
store_key, job_id, name, enabled, created_at_ms, agent_id, owner_agent_id,
schedule_kind, session_target, wake_mode, payload_kind, job_json, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
"default",
"operator-job",
"Operator job",
1,
1,
"worker",
"worker",
"every",
"isolated",
"now",
"agentTurn",
"{}",
1,
);
seedAttachedCronJob(current.env, {
id: "operator-job",
name: "Operator job",
schedule: { kind: "every", everyMs: 60_000 },
});
const plan = await buildClawRemovePlan("worker", {
env: current.env,
@@ -446,29 +454,11 @@ describe("Claw status and remove", () => {
it("does not treat Claw-owned cron jobs as external agent blockers", async () => {
const current = await addFixture({ withCron: true });
const database = openOpenClawStateDatabase({ env: current.env });
database.db
.prepare(
`INSERT INTO cron_jobs (
store_key, job_id, name, enabled, created_at_ms, agent_id, owner_agent_id,
schedule_kind, session_target, wake_mode, payload_kind, job_json, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
"default",
"scheduler-daily",
"Claw job",
1,
1,
"worker",
"worker",
"cron",
"isolated",
"now",
"agentTurn",
"{}",
1,
);
seedAttachedCronJob(current.env, {
id: "scheduler-daily",
name: "Claw job",
schedule: { kind: "cron", expr: "0 9 * * *", tz: "UTC" },
});
const plan = await buildClawRemovePlan("worker", {
env: current.env,
+31 -16
View File
@@ -26,10 +26,7 @@ type AuthProfileTargetDatabase = Pick<
OpenClawAgentKyselyDatabase,
"auth_profile_store" | "auth_profile_state"
>;
type SharedAuthProfileTargetDatabase = Pick<
OpenClawStateDatabase,
"auth_profile_stores" | "auth_profile_state"
>;
type SharedAuthProfileTargetDatabase = Pick<OpenClawStateDatabase, "config_machine_state">;
export type AuthProfileMigrationSourceReceipt = {
sourceKey: string;
@@ -263,6 +260,20 @@ export function acquireAuthProfileMigrationSourceLocks(sourcePaths: readonly str
};
}
// Shared auth payloads moved to config_machine_state at v13; project the KV
// cell back to the receipt-era row shape for sha comparison.
function projectSharedStoreCell(
row: { value_json: string } | undefined,
): { store_json: string } | undefined {
return row ? { store_json: row.value_json } : undefined;
}
function projectSharedStateCell(
row: { value_json: string } | undefined,
): { state_json: string } | undefined {
return row ? { state_json: row.value_json } : undefined;
}
function verifyAuthProfileMigrationTarget(receipt: AuthProfileMigrationSourceReceipt): void {
const hasExpectedProfiles = Object.keys(receipt.expectedProfileSha256 ?? {}).length > 0;
if (!hasExpectedProfiles && !receipt.expectedStateSha256) {
@@ -274,12 +285,14 @@ function verifyAuthProfileMigrationTarget(receipt: AuthProfileMigrationSourceRec
if (hasExpectedProfiles && receipt.expectedProfileSha256) {
const row =
targetStoreKey === "shared"
? executeSqliteQueryTakeFirstSync(
db,
getNodeSqliteKysely<SharedAuthProfileTargetDatabase>(db)
.selectFrom("auth_profile_stores")
.select("store_json")
.where("store_key", "=", "shared"),
? projectSharedStoreCell(
executeSqliteQueryTakeFirstSync(
db,
getNodeSqliteKysely<SharedAuthProfileTargetDatabase>(db)
.selectFrom("config_machine_state")
.select("value_json")
.where("state_key", "=", "authProfiles.store"),
),
)
: executeSqliteQueryTakeFirstSync(
db,
@@ -298,12 +311,14 @@ function verifyAuthProfileMigrationTarget(receipt: AuthProfileMigrationSourceRec
if (receipt.expectedStateSha256) {
const row =
targetStoreKey === "shared"
? executeSqliteQueryTakeFirstSync(
db,
getNodeSqliteKysely<SharedAuthProfileTargetDatabase>(db)
.selectFrom("auth_profile_state")
.select("state_json")
.where("store_key", "=", "shared"),
? projectSharedStateCell(
executeSqliteQueryTakeFirstSync(
db,
getNodeSqliteKysely<SharedAuthProfileTargetDatabase>(db)
.selectFrom("config_machine_state")
.select("value_json")
.where("state_key", "=", "authProfiles.state"),
),
)
: executeSqliteQueryTakeFirstSync(
db,
+17 -16
View File
@@ -573,19 +573,19 @@ describe("maybeRepairPluginRegistryState", () => {
const installRecordsJson = '{"__proto__":{"source":"bogus"}}';
runOpenClawStateWriteTransaction(
({ db }) => {
// Build the JSON text manually so the __proto__ key stays an own property.
const valueJson =
'{"revision":123,"index":{"version":1,"hostContractVersion":"test",' +
'"compatRegistryVersion":"test","migrationVersion":1,"policyHash":"test",' +
'"generatedAtMs":1,"installRecords":' +
installRecordsJson +
',"plugins":[],"diagnostics":[]}}';
db.prepare(
`
INSERT OR REPLACE INTO installed_plugin_index (
index_key, version, host_contract_version, compat_registry_version,
migration_version, policy_hash, generated_at_ms, refresh_reason,
install_records_json, plugins_json, diagnostics_json, warning, updated_at_ms
) VALUES (
'installed-plugin-index', 1, 'test', 'test',
1, 'test', 1, NULL,
?, '[]', '[]', NULL, 123
)
INSERT OR REPLACE INTO config_machine_state (state_key, value_json, updated_at_ms)
VALUES ('plugins.installedIndex', ?, 123)
`,
).run(installRecordsJson);
).run(valueJson);
},
{ env: { ...process.env, OPENCLAW_STATE_DIR: stateDir } },
);
@@ -602,21 +602,22 @@ describe("maybeRepairPluginRegistryState", () => {
const notes = vi.mocked(note).mock.calls.join("\n");
expect(notes).toContain("Stop the Gateway");
expect(notes).toContain(
"delete only the installed_plugin_index row with index_key='installed-plugin-index'",
"delete only the config_machine_state row with state_key='plugins.installedIndex'",
);
expect(notes).toContain("rerun `openclaw doctor --fix`");
const row = runOpenClawStateWriteTransaction(
({ db }) =>
db
.prepare(
`SELECT install_records_json, updated_at_ms
FROM installed_plugin_index
WHERE index_key = 'installed-plugin-index'`,
`SELECT value_json, updated_at_ms
FROM config_machine_state
WHERE state_key = 'plugins.installedIndex'`,
)
.get() as { install_records_json: string; updated_at_ms: number | bigint },
.get() as { value_json: string; updated_at_ms: number | bigint },
{ env: { ...process.env, OPENCLAW_STATE_DIR: stateDir } },
);
expect(row).toEqual({ install_records_json: installRecordsJson, updated_at_ms: 123 });
expect(row.updated_at_ms).toBe(123);
expect(row.value_json).toContain(installRecordsJson);
});
it("removes stale managed npm packages that shadow bundled plugins during repair", async () => {
-107
View File
@@ -16,7 +16,6 @@ import {
} from "../../../cron/store.js";
import { cronStoreKey } from "../../../cron/store/key.js";
import { readCronTaskRunHistoryPage } from "../../../cron/task-run-history.js";
import { runOpenClawStateWriteTransaction } from "../../../state/openclaw-state-db.js";
import { resolveOpenClawStateSqlitePath } from "../../../state/openclaw-state-db.paths.js";
import { withRestoredMocks } from "../../../test-utils/vitest-spies.js";
import {
@@ -131,43 +130,6 @@ async function writeCurrentCronStore(storePath: string, jobs: Array<Record<strin
});
}
function insertEarlySQLiteCronRow(
storePath: string,
job: Record<string, unknown>,
options: { payloadMessage?: string | null } = {},
) {
const schedule = requireRecord(job.schedule, "cron schedule");
const payload = requireRecord(job.payload, "cron payload");
runOpenClawStateWriteTransaction(({ db }) => {
db.prepare(
`INSERT INTO cron_jobs (
store_key, job_id, name, enabled, created_at_ms, updated_at,
schedule_kind, every_ms, session_target, wake_mode, payload_kind, payload_message,
job_json, state_json
) VALUES (
$storeKey, $jobId, $name, $enabled, $createdAtMs, $updatedAt,
$scheduleKind, $everyMs, $sessionTarget, $wakeMode, $payloadKind, $payloadMessage,
$jobJson, $stateJson
)`,
).run({
$storeKey: path.resolve(storePath),
$jobId: String(job.id),
$name: String(job.name),
$enabled: job.enabled === false ? 0 : 1,
$createdAtMs: Number(job.createdAtMs),
$updatedAt: Number(job.updatedAtMs),
$scheduleKind: String(schedule.kind),
$everyMs: Number(schedule.everyMs),
$sessionTarget: String(job.sessionTarget),
$wakeMode: String(job.wakeMode),
$payloadKind: String(payload.kind),
$payloadMessage: options.payloadMessage ?? null,
$jobJson: JSON.stringify(job),
$stateJson: JSON.stringify(job.state ?? {}),
});
});
}
async function writeLegacyCronArrayStore(storePath: string, jobs: Array<Record<string, unknown>>) {
await fs.mkdir(path.dirname(storePath), { recursive: true });
await fs.writeFile(storePath, JSON.stringify(jobs, null, 2), "utf-8");
@@ -1740,75 +1702,6 @@ describe("maybeRepairLegacyCronStore", () => {
expectNoteContaining("Cron store migrated to SQLite", "Doctor changes");
});
it("backfills early SQLite rows from job_json before runtime relies on split columns", async () => {
const storePath = await makeTempStorePath();
insertEarlySQLiteCronRow(storePath, {
id: "early-sqlite-agent-turn",
name: "Early SQLite agent turn",
enabled: true,
createdAtMs: Date.parse("2026-02-03T00:00:00.000Z"),
updatedAtMs: Date.parse("2026-02-03T00:00:00.000Z"),
schedule: { kind: "every", everyMs: 3_600_000, anchorMs: 0 },
sessionTarget: "isolated",
wakeMode: "now",
payload: { kind: "agentTurn", message: "use config json" },
state: {},
});
expect(await readPersistedJobs(storePath)).toEqual([]);
await maybeRepairLegacyCronStore({
cfg: createCronConfig(storePath),
options: {},
prompter: makePrompter(true),
});
const jobs = await readPersistedJobs(storePath);
const job = requirePersistedJob(jobs, 0);
expect(job.id).toBe("early-sqlite-agent-turn");
expect(job.payload).toEqual({ kind: "agentTurn", message: "use config json" });
expectNoteContaining("1 SQLite cron row will be backfilled", "Cron");
});
it("backfills parseable SQLite rows when optional config fields only exist in job_json", async () => {
const storePath = await makeTempStorePath();
insertEarlySQLiteCronRow(
storePath,
{
id: "early-sqlite-model",
name: "Early SQLite model",
enabled: true,
createdAtMs: Date.parse("2026-02-03T00:00:00.000Z"),
updatedAtMs: Date.parse("2026-02-03T00:00:00.000Z"),
schedule: { kind: "every", everyMs: 3_600_000, anchorMs: 0 },
sessionTarget: "isolated",
wakeMode: "now",
payload: { kind: "agentTurn", message: "use split text", model: "openai/gpt-5.5" },
state: {},
},
{ payloadMessage: "use split text" },
);
expect(requirePersistedJob(await readPersistedJobs(storePath), 0).payload).toEqual({
kind: "agentTurn",
message: "use split text",
});
await maybeRepairLegacyCronStore({
cfg: createCronConfig(storePath),
options: {},
prompter: makePrompter(true),
});
const job = requirePersistedJob(await readPersistedJobs(storePath), 0);
expect(job.payload).toEqual({
kind: "agentTurn",
message: "use split text",
model: "openai/gpt-5.5",
});
expectNoteContaining("1 SQLite cron row will be backfilled", "Cron");
});
it("migrates legacy run logs even when the legacy job store was already archived", async () => {
const storePath = await makeTempStorePath();
await writeCurrentCronStore(storePath, [createCurrentCronJob()]);
-17
View File
@@ -185,7 +185,6 @@ export async function collectLegacyCronStoreHealthFindings(params: {
legacyRunLogDetected,
legacyQuarantine,
legacyImportCount,
sqliteProjectionBackfillCount,
rawJobs,
} = state;
const sqliteStorePath = resolveOpenClawStateSqlitePath();
@@ -305,16 +304,6 @@ export async function collectLegacyCronStoreHealthFindings(params: {
}
}
if (sqliteProjectionBackfillCount > 0) {
findings.push(
legacyCronStoreFinding({
message: `${pluralize(sqliteProjectionBackfillCount, "SQLite cron row")} will be backfilled from stored config JSON into split columns.`,
path: sqliteStorePath,
requirement: "sqlite-projection-backfill",
}),
);
}
const notifyCount = rawJobs.filter((job) => job.notify === true).length;
if (notifyCount > 0) {
findings.push(
@@ -381,7 +370,6 @@ export async function maybeRepairLegacyCronStore(params: {
legacyRunLogDetected,
legacyQuarantine,
legacyImportCount,
sqliteProjectionBackfillCount,
invalidConfigRows,
rawJobs,
} = state;
@@ -600,11 +588,6 @@ export async function maybeRepairLegacyCronStore(params: {
`- ${pluralize(invalidConfigRows.length, "malformed cron row")} will be quarantined in SQLite`,
);
}
if (sqliteProjectionBackfillCount > 0) {
previewLines.push(
`- ${pluralize(sqliteProjectionBackfillCount, "SQLite cron row")} will be backfilled from stored config JSON into split columns`,
);
}
if (notifyCount > 0) {
previewLines.push(
`- ${pluralize(notifyCount, "job")} still uses legacy \`notify: true\` webhook fallback`,
+9 -43
View File
@@ -3,7 +3,6 @@ import { normalizeOptionalString } from "../../../../packages/normalization-core
import { tryResolveLegacyCompatibilityAgentId } from "../../../agents/agent-scope-config.js";
import { formatCliCommand } from "../../../cli/command-format.js";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import { getInvalidPersistedCronJobReason } from "../../../cron/persisted-shape.js";
import {
loadCronJobsStoreWithConfigJobs,
loadCronJobsStoreWithConfigJobsReadOnly,
@@ -43,11 +42,7 @@ import {
hasLegacyCronMigrationReceiptReadOnly,
markLegacyCronMigrationSourceRemoved,
} from "./migration-ledger.js";
import {
mergeLegacyCronJobs,
mergeRuntimeEntryIntoConfigJob,
needsSqliteProjectionBackfill,
} from "./repair-plan.js";
import { mergeLegacyCronJobs, mergeRuntimeEntryIntoConfigJob } from "./repair-plan.js";
import { planCronCodexRefRewriteAgainstPersistedConfig } from "./runtime-policy-migration.js";
import {
assertCronStateSchemaSupported,
@@ -73,7 +68,6 @@ export type LegacyCronRepairState = {
legacyMigrationSource?: LegacyCronMigrationSource;
legacyMigrationAlreadyImported: boolean;
legacyImportCount: number;
sqliteProjectionBackfillCount: number;
invalidConfigRows: QuarantinedCronConfigJob[];
projectedOwnersByJobId: ReadonlyMap<string, CronOwnerProjection>;
rawJobs: Array<Record<string, unknown>>;
@@ -144,42 +138,16 @@ export async function loadLegacyCronRepairState(params: {
const projectedOwnersByJobId = new Map(
loaded.store.jobs.map((job) => [job.id, projectCronOwner(job, runtimeDefaultAgentId)]),
);
const currentEntries = loaded.configJobs.map((job, index) => ({
sourceIndex: loaded.configJobIndexes[index] ?? index,
job: mergeRuntimeEntryIntoConfigJob({
job,
runtimeEntry: loaded.configJobRuntimeEntries[index],
}),
projectedJob: loaded.store.jobs[index],
}));
const invalidConfigRows: QuarantinedCronConfigJob[] = [];
for (const row of loaded.invalidConfigRows) {
if (row.job && !getInvalidPersistedCronJobReason(row.job)) {
// Early SQLite builds omitted projection columns but retained a complete
// job_json definition; doctor must backfill that job instead of deleting it.
currentEntries.push({
sourceIndex: row.sourceIndex,
job: mergeRuntimeEntryIntoConfigJob({
job: row.job,
runtimeEntry: { state: row.state, updatedAtMs: row.updatedAtMs },
}),
projectedJob: undefined,
});
continue;
}
invalidConfigRows.push(row);
}
currentEntries.sort((left, right) => left.sourceIndex - right.sourceIndex);
const invalidConfigRows: QuarantinedCronConfigJob[] = [...loaded.invalidConfigRows];
const currentJobs =
currentEntries.length > 0
? currentEntries.map((entry) => entry.job)
loaded.configJobs.length > 0
? loaded.configJobs.map((job, index) =>
mergeRuntimeEntryIntoConfigJob({
job,
runtimeEntry: loaded.configJobRuntimeEntries[index],
}),
)
: (loaded.store.jobs as unknown as Array<Record<string, unknown>>);
const sqliteProjectionBackfillCount = currentEntries.filter((entry) =>
needsSqliteProjectionBackfill({
configJob: entry.job,
projectedJob: entry.projectedJob,
}),
).length;
let rawJobs = currentJobs;
let legacyImportCount = 0;
let legacyMigrationSource: LegacyCronMigrationSource | undefined;
@@ -211,7 +179,6 @@ export async function loadLegacyCronRepairState(params: {
legacyMigrationSource,
legacyMigrationAlreadyImported,
legacyImportCount,
sqliteProjectionBackfillCount,
invalidConfigRows,
projectedOwnersByJobId,
rawJobs,
@@ -266,7 +233,6 @@ export async function applyLegacyCronStoreRepair(params: {
const storeChanged =
(state.legacyStoreDetected && !state.legacyMigrationAlreadyImported) ||
state.sqliteProjectionBackfillCount > 0 ||
state.invalidConfigRows.length > 0 ||
normalized.mutated ||
notifyMigration.changed ||
-42
View File
@@ -1,9 +1,5 @@
// Cron doctor repair planning helpers for previewing and merging legacy rows.
import { isDeepStrictEqual } from "node:util";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalStringifiedId } from "../../../../packages/normalization-core/src/string-coerce.js";
import { normalizeCronJobInput } from "../../../cron/normalize.js";
import type { CronJob } from "../../../cron/types.js";
import {
IMAGE_INSPECTION_TOOL_NAME_MIGRATION,
TASK_SUGGESTION_TOOL_NAME_MIGRATION,
@@ -224,41 +220,3 @@ export function mergeRuntimeEntryIntoConfigJob(params: {
...(params.runtimeEntry?.state ? { state: structuredClone(params.runtimeEntry.state) } : {}),
};
}
/** Return true when a SQLite cron projection row no longer matches config JSON. */
export function needsSqliteProjectionBackfill(params: {
configJob: Record<string, unknown>;
projectedJob?: CronJob;
}): boolean {
if (!params.projectedJob) {
return true;
}
const normalizedConfig = normalizeCronJobInput(params.configJob, { applyDefaults: true });
if (!normalizedConfig) {
return true;
}
if (!isRecord(params.projectedJob)) {
return true;
}
const projected = params.projectedJob;
for (const field of [
"agentId",
"deleteAfterRun",
"delivery",
"description",
"enabled",
"failureAlert",
"name",
"payload",
"schedule",
"scheduledToolPolicy",
"sessionKey",
"sessionTarget",
"wakeMode",
] as const) {
if (!isDeepStrictEqual(normalizedConfig[field], projected[field])) {
return true;
}
}
return false;
}
+1 -37
View File
@@ -18,7 +18,6 @@ const mocks = vi.hoisted(() => ({
maybeRepairGroupAllowFromFallback: vi.fn(),
maybeRepairPluginOpenClawHostLinks: vi.fn(),
maybeRepairLegacyOAuthSidecarProfiles: vi.fn(),
migrateLegacyOnboardingRecommendationsScope: vi.fn(),
migrateLegacyTailscaleProfileIdentities: vi.fn(),
maybeMigrateAuthProfileJsonStoresToSqlite: vi.fn(),
maybeRepairOpenAICodexAuthConfig: vi.fn(),
@@ -56,10 +55,6 @@ vi.mock("../doctor-auth-oauth-sidecar.js", () => ({
maybeRepairLegacyOAuthSidecarProfiles: mocks.maybeRepairLegacyOAuthSidecarProfiles,
}));
vi.mock("../../infra/state-migrations.onboarding-recommendations.js", () => ({
migrateLegacyOnboardingRecommendationsScope: mocks.migrateLegacyOnboardingRecommendationsScope,
}));
vi.mock("../../state/user-profiles-tailscale-migration.js", () => ({
migrateLegacyTailscaleProfileIdentities: mocks.migrateLegacyTailscaleProfileIdentities,
}));
@@ -280,10 +275,6 @@ describe("doctor repair sequencing", () => {
changes: [],
warnings: [],
});
mocks.migrateLegacyOnboardingRecommendationsScope.mockReturnValue({
changes: [],
warnings: [],
});
mocks.migrateLegacyTailscaleProfileIdentities.mockReturnValue({ changes: [], warnings: [] });
mocks.collectOpenAICodexAuthProfileStoreIdMap.mockReturnValue(new Map());
mocks.maybeMigrateAuthProfileJsonStoresToSqlite.mockResolvedValue({
@@ -331,33 +322,6 @@ describe("doctor repair sequencing", () => {
}));
});
it("runs the doctor-only onboarding recommendation scope migration", async () => {
const env = { OPENCLAW_STATE_DIR: "/tmp/openclaw-doctor-test" };
const candidate = {} as OpenClawConfig;
mocks.migrateLegacyOnboardingRecommendationsScope.mockReturnValue({
changes: ["Migrated onboarding recommendation state."],
warnings: ["Migration warning."],
});
const result = await runDoctorRepairSequence({
state: {
cfg: candidate,
candidate,
pendingChanges: false,
fixHints: [],
},
doctorFixCommand: "openclaw doctor --fix",
env,
});
expect(mocks.migrateLegacyOnboardingRecommendationsScope).toHaveBeenCalledWith({
cfg: candidate,
env,
});
expect(result.changeNotes).toContain("Migrated onboarding recommendation state.");
expect(result.warningNotes).toContain("Migration warning.");
});
it("runs the doctor-only Tailscale profile identity migration", async () => {
const env = { OPENCLAW_STATE_DIR: "/tmp/openclaw-doctor-test" };
const candidate = {} as OpenClawConfig;
@@ -414,7 +378,7 @@ describe("doctor repair sequencing", () => {
warnings: ["Plugin \u001B[31mwarning\u001B[0m\r\nnext."],
notices: ["Plugin \u001B[31mnotice\u001B[0m\r\nnext."],
});
mocks.migrateLegacyOnboardingRecommendationsScope.mockReturnValueOnce({
mocks.migrateLegacyTailscaleProfileIdentities.mockReturnValueOnce({
changes: ["Migrated \u001B[31mrecommendations\u001B[0m\r\nnext."],
warnings: ["Migration \u001B[31mwarning\u001B[0m\r\nnext."],
});
-7
View File
@@ -6,7 +6,6 @@ import {
materializePluginAutoEnableCandidates,
} from "../../config/plugin-auto-enable.js";
import { repairObsoleteGeneratedExecApprovals } from "../../infra/exec-approvals-generated-migration.js";
import { migrateLegacyOnboardingRecommendationsScope } from "../../infra/state-migrations.onboarding-recommendations.js";
import type { PluginMetadataSnapshotScopeRunner } from "../../plugins/current-plugin-metadata-snapshot.js";
import {
loadPluginMetadataSnapshot,
@@ -328,12 +327,6 @@ export async function runDoctorRepairSequence(params: {
appendRepairNotes(await migrateLegacySkillWorkshopProposals({ config: state.candidate, env }));
appendRepairNotes(migrateLegacyTailscaleProfileIdentities({ env }));
appendRepairNotes(await cleanupLegacyPluginDependencyState({ env }));
appendRepairNotes(
migrateLegacyOnboardingRecommendationsScope({
cfg: state.candidate,
env,
}),
);
const legacyOAuthSidecarRepair = await maybeRepairLegacyOAuthSidecarProfiles({
cfg: state.candidate,
prompter: { confirmAutoFix: async () => true },
@@ -547,10 +547,8 @@ describe("default role materialization authored writes", () => {
jobs: [makeCronJob({ id: "corrupt" })],
});
database
.prepare(
"UPDATE cron_jobs SET job_json = ?, schedule_kind = ? WHERE store_key = ? AND job_id = ?",
)
.run("not json", "broken", cronStoreKey(storePath), "corrupt");
.prepare("UPDATE cron_jobs SET job_json = ? WHERE store_key = ? AND job_id = ?")
.run("not json", cronStoreKey(storePath), "corrupt");
const io = createConfigIO({
configPath,
env,
@@ -483,7 +483,7 @@ const DOCTOR_DEPRECATION_COMPAT_RECORDS = [
removeAfter: "2026-07-26",
source: "plugins.installs in authored config",
migration: "src/config/plugin-install-config-migration.ts",
replacement: "shared SQLite installed_plugin_index install ledger",
replacement: "shared SQLite config_machine_state plugins.installedIndex install ledger",
docsPath: "/cli/plugins#registry",
tests: [
"src/config/io.write-config.test.ts",
@@ -121,19 +121,27 @@ function requirePlugin(index: InstalledPluginIndex | null | undefined, pluginId:
function insertStalePersistedIndexRow(stateDir: string, installRecordsJson = "{}") {
runOpenClawStateWriteTransaction(
({ db }) => {
const valueJson = JSON.stringify({
revision: 123,
index: {
version: 1,
warning: null,
hostContractVersion: "2026.4.25",
compatRegistryVersion: "compat-v1",
migrationVersion: 0,
policyHash: "stale-policy",
generatedAtMs: 123,
installRecords: JSON.parse(installRecordsJson) as unknown,
plugins: [],
diagnostics: [],
},
});
db.prepare(
`
INSERT OR REPLACE INTO installed_plugin_index (
index_key, version, host_contract_version, compat_registry_version,
migration_version, policy_hash, generated_at_ms, refresh_reason,
install_records_json, plugins_json, diagnostics_json, warning, updated_at_ms
) VALUES (
'installed-plugin-index', 1, '2026.4.25', 'compat-v1',
0, 'stale-policy', 123, NULL,
@install_records_json, '[]', '[]', NULL, 123
)
INSERT OR REPLACE INTO config_machine_state (state_key, value_json, updated_at_ms)
VALUES ('plugins.installedIndex', ?, 123)
`,
).run({ install_records_json: installRecordsJson });
).run(valueJson);
},
{ env: { ...process.env, OPENCLAW_STATE_DIR: stateDir } },
);
@@ -198,29 +206,28 @@ describe("plugin registry install migration", () => {
env: hermeticEnv(),
}),
).rejects.toThrow(
"delete only the installed_plugin_index row with index_key='installed-plugin-index'",
"delete only the config_machine_state row with state_key='plugins.installedIndex'",
);
const row = runOpenClawStateWriteTransaction(
({ db }) =>
db
.prepare(
`SELECT migration_version, install_records_json, updated_at_ms
FROM installed_plugin_index
WHERE index_key = 'installed-plugin-index'`,
`SELECT value_json, updated_at_ms
FROM config_machine_state
WHERE state_key = 'plugins.installedIndex'`,
)
.get() as {
migration_version: number | bigint;
install_records_json: string;
updated_at_ms: number | bigint;
},
.get() as { value_json: string; updated_at_ms: number | bigint },
{ env: { ...process.env, OPENCLAW_STATE_DIR: stateDir } },
);
expect(row).toEqual({
migration_version: 0,
install_records_json: installRecordsJson,
updated_at_ms: 123,
});
expect(row.updated_at_ms).toBe(123);
const persistedValue = JSON.parse(row.value_json) as {
index: { migrationVersion: number; installRecords: unknown };
};
expect(persistedValue.index.migrationVersion).toBe(0);
expect(JSON.stringify(persistedValue.index.installRecords)).toBe(
JSON.stringify(JSON.parse(installRecordsJson)),
);
});
it("rejects invalid config install records before recovery or persistence", async () => {
@@ -64,7 +64,7 @@ export class InvalidPluginInstallRecordStateError extends Error {}
function invalidPersistedInstallRecordMessage(filePath: string): string {
return [
`Persisted plugin install records are invalid at ${filePath}.`,
"Stop the Gateway, back up this database, delete only the installed_plugin_index row with index_key='installed-plugin-index' using SQLite tooling, then rerun `openclaw doctor --fix` to rebuild it.",
"Stop the Gateway, back up this database, delete only the config_machine_state row with state_key='plugins.installedIndex' using SQLite tooling, then rerun `openclaw doctor --fix` to rebuild it.",
].join(" ");
}
+4 -4
View File
@@ -190,10 +190,10 @@ export function resolveFailureDestination(
: undefined);
const overrideAccountId = normalizeOptionalString(routeOverride.accountId);
const overrideMode = normalizeFailureMode(routeOverride.mode);
const hasChannelField = "channel" in routeOverride;
const hasToField = "to" in routeOverride;
const hasAccountIdField = "accountId" in routeOverride;
const hasModeField = "mode" in routeOverride;
const hasChannelField = Object.hasOwn(routeOverride, "channel");
const hasToField = Object.hasOwn(routeOverride, "to");
const hasAccountIdField = Object.hasOwn(routeOverride, "accountId");
const hasModeField = Object.hasOwn(routeOverride, "mode");
const hasExplicitTo = hasToField && overrideTo !== undefined;
const globalChannel = resolveAnnounceChannel({ channel, to });
+16
View File
@@ -579,6 +579,22 @@ describe("resolveFailureDestination", () => {
},
expected: null,
},
{
name: "JSON-null clear-only override",
failureDestination: {
channel: null as never,
to: null as never,
accountId: null as never,
mode: null as never,
},
globalConfig: {
channel: "telegram",
to: "group-abc",
accountId: "global-account",
mode: "announce" as const,
},
expected: null,
},
])("resolves $name", ({ failureDestination, globalConfig, expected }) => {
expect(
resolveFailureDestination(
@@ -52,9 +52,7 @@ it("preserves a session-scoped owner stored only in job JSON", async () => {
delete jobJson.agentId;
jobJson.sessionKey = "agent:research:main";
database
.prepare(
"UPDATE cron_jobs SET agent_id = NULL, session_key = NULL, job_json = ? WHERE store_key = ?",
)
.prepare("UPDATE cron_jobs SET agent_id = NULL, job_json = ? WHERE store_key = ?")
.run(JSON.stringify(jobJson), storeKey);
expect(await migrate(storePath, env)).toBe(0);
@@ -367,16 +367,10 @@ describe("cron service cross-tick bounded admission", () => {
});
db.prepare(
`UPDATE cron_jobs
SET running_at_ms = ?,
state_json = json_set(state_json, '$.runningAtMs', ?),
SET state_json = json_set(state_json, '$.runningAtMs', ?),
updated_at = updated_at + 1
WHERE store_key = ? AND job_id = ?`,
).run(
foreignStartedAtMs,
foreignStartedAtMs,
cronStoreKey(store.storePath),
conflicted.id,
);
).run(foreignStartedAtMs, cronStoreKey(store.storePath), conflicted.id);
return receipt;
});
}
@@ -39,14 +39,14 @@ function observeCronJobWrites(
const suffix = ++cronJobWriteObserverId;
const functionName = `observe_cron_job_write_${suffix}`;
const triggerName = `observe_cron_job_write_${suffix}`;
database.function(functionName, (writtenJobId, stateJson, runningAtMs) => {
database.function(functionName, (writtenJobId, stateJson) => {
if (writtenJobId !== jobId || typeof stateJson !== "string") {
return 0;
}
const state = JSON.parse(stateJson) as { queuedAtMs?: number };
const state = JSON.parse(stateJson) as { queuedAtMs?: number; runningAtMs?: number };
observer({
...(typeof state.queuedAtMs === "number" ? { queuedAtMs: state.queuedAtMs } : {}),
...(typeof runningAtMs === "number" ? { runningAtMs } : {}),
...(typeof state.runningAtMs === "number" ? { runningAtMs: state.runningAtMs } : {}),
});
return 0;
});
@@ -54,7 +54,7 @@ function observeCronJobWrites(
CREATE TEMP TRIGGER ${triggerName}
AFTER UPDATE ON cron_jobs
BEGIN
SELECT ${functionName}(NEW.job_id, NEW.state_json, NEW.running_at_ms);
SELECT ${functionName}(NEW.job_id, NEW.state_json);
END;
`);
return () => database.exec(`DROP TRIGGER IF EXISTS ${triggerName}`);
+4 -3
View File
@@ -672,6 +672,7 @@ describe("cron service run admission", () => {
job.failureAlert = { after: 1, cooldownMs: 60_000, includeSkipped: true };
await saveCronStore(store.storePath, { version: 1, jobs: [job] });
const sendCronFailureAlert = vi.fn(async () => {});
const editedName = "edited before invalid-run commit";
let edited = false;
const state = createAdmissionTestState({
cronEnabled: true,
@@ -689,9 +690,9 @@ describe("cron service run admission", () => {
edited = true;
openOpenClawStateDatabase()
.db.prepare(
"UPDATE cron_jobs SET name = ?, updated_at = updated_at + 1 WHERE store_key = ? AND job_id = ?",
"UPDATE cron_jobs SET name = ?, job_json = json_set(job_json, '$.name', ?), updated_at = updated_at + 1 WHERE store_key = ? AND job_id = ?",
)
.run("edited before invalid-run commit", cronStoreKey(store.storePath), job.id);
.run(editedName, editedName, cronStoreKey(store.storePath), job.id);
},
});
@@ -702,7 +703,7 @@ describe("cron service run admission", () => {
});
const persisted = (await loadCronStore(store.storePath)).jobs[0];
expect(persisted?.name).toBe("edited before invalid-run commit");
expect(persisted?.name).toBe(editedName);
expect(persisted?.state.lastRunStatus).toBeUndefined();
expect(sendCronFailureAlert).not.toHaveBeenCalled();
});
+7 -19
View File
@@ -601,16 +601,15 @@ async function writeLegacyCronArraySnapshot(storePath: string, jobs: CronJob[])
}
function insertCronJobRow(storePath: string, job: CronJob) {
const { state, ...jobConfig } = job;
runOpenClawStateWriteTransaction(({ db }) => {
db.prepare(
`INSERT INTO cron_jobs (
store_key, job_id, declaration_key, name, description, enabled, created_at_ms, schedule_kind,
at, every_ms, anchor_ms, schedule_expr, session_target, wake_mode, payload_kind,
payload_message, delivery_mode, delivery_to, job_json, state_json, updated_at
store_key, job_id, declaration_key, name, description, enabled, payload_kind,
job_json, state_json, updated_at
) VALUES (
$storeKey, $jobId, $declarationKey, $name, $description, $enabled, $createdAtMs, $scheduleKind,
$at, $everyMs, $anchorMs, $scheduleExpr, $sessionTarget, $wakeMode, $payloadKind,
$payloadMessage, $deliveryMode, $deliveryTo, $jobJson, $stateJson, $updatedAt
$storeKey, $jobId, $declarationKey, $name, $description, $enabled, $payloadKind,
$jobJson, $stateJson, $updatedAt
)`,
).run({
$storeKey: path.resolve(storePath),
@@ -619,20 +618,9 @@ function insertCronJobRow(storePath: string, job: CronJob) {
$name: job.name,
$description: job.description ?? null,
$enabled: job.enabled ? 1 : 0,
$createdAtMs: job.createdAtMs,
$scheduleKind: job.schedule.kind,
$at: job.schedule.kind === "at" ? job.schedule.at : null,
$everyMs: job.schedule.kind === "every" ? job.schedule.everyMs : null,
$anchorMs: job.schedule.kind === "every" ? (job.schedule.anchorMs ?? null) : null,
$scheduleExpr: job.schedule.kind === "cron" ? job.schedule.expr : null,
$sessionTarget: job.sessionTarget,
$wakeMode: job.wakeMode,
$payloadKind: job.payload.kind,
$payloadMessage: "message" in job.payload ? job.payload.message : null,
$deliveryMode: job.delivery ? (job.delivery.mode ?? "announce") : null,
$deliveryTo: job.delivery?.to ?? null,
$jobJson: JSON.stringify(job),
$stateJson: JSON.stringify(job.state),
$jobJson: JSON.stringify(jobConfig),
$stateJson: JSON.stringify(state),
$updatedAt: job.updatedAtMs,
});
});
+8 -3
View File
@@ -107,8 +107,9 @@ beforeEach(async () => {
});
database.exec(\`
CREATE TEMP TRIGGER crash_cron_activation
BEFORE UPDATE OF running_at_ms ON cron_jobs
WHEN OLD.running_at_ms IS NULL AND NEW.running_at_ms IS NOT NULL
BEFORE UPDATE OF state_json ON cron_jobs
WHEN json_extract(OLD.state_json, '$.runningAtMs') IS NULL
AND json_extract(NEW.state_json, '$.runningAtMs') IS NOT NULL
BEGIN
SELECT crash_activation();
END;
@@ -520,7 +521,11 @@ describe("cron durable run ownership", () => {
const unrelated = makeCommandJob("imported-during-foreign-run", now + 60_000);
unrelated.state = {};
upsertCronJobRow(database, cronStoreKey(storePath), unrelated, 1);
database.prepare("UPDATE cron_jobs SET next_run_at_ms = NULL WHERE job_id = ?").run(job.id);
database
.prepare(
"UPDATE cron_jobs SET state_json = json_remove(state_json, '$.nextRunAtMs') WHERE job_id = ?",
)
.run(job.id);
const replacement = makeParentService(storePath);
try {
+64 -17
View File
@@ -146,7 +146,9 @@ describe("cron service store seam coverage", () => {
});
await saveCronStore(storePath, { version: 1, jobs: [malformed, surviving] });
openOpenClawStateDatabase()
.db.prepare("UPDATE cron_jobs SET schedule_kind = ? WHERE job_id = ?")
.db.prepare(
"UPDATE cron_jobs SET job_json = json_set(job_json, '$.schedule.kind', ?) WHERE job_id = ?",
)
.run("unsupported", malformed.id);
const state = createStoreTestState(storePath);
@@ -164,6 +166,42 @@ describe("cron service store seam coverage", () => {
await expectPathMissing(storePath.replace(/\.json$/, "-quarantine.json"));
});
it("quarantines malformed job and state JSON with exact recovery bytes", async () => {
const { storePath } = await makeStorePath();
const malformedJob = createReloadCronJob({ id: "malformed-job-json" });
const malformedState = createReloadCronJob({ id: "malformed-state-json" });
const surviving = createReloadCronJob({ id: "surviving-json-row" });
await saveCronStore(storePath, {
version: 1,
jobs: [malformedJob, malformedState, surviving],
});
const db = openOpenClawStateDatabase().db;
const stateRow = db
.prepare("SELECT job_json FROM cron_jobs WHERE job_id = ?")
.get(malformedState.id) as { job_json: string };
db.prepare("UPDATE cron_jobs SET job_json = ? WHERE job_id = ?").run("{", malformedJob.id);
db.prepare("UPDATE cron_jobs SET state_json = ? WHERE job_id = ?").run("[]", malformedState.id);
const state = createStoreTestState(storePath);
await ensureLoaded(state, { skipRecompute: true });
expect(state.store?.jobs.map((job) => job.id)).toEqual([surviving.id]);
expect((await loadCronStore(storePath)).jobs.map((job) => job.id)).toEqual([surviving.id]);
expect(cronStoreModule.loadCronQuarantinedJobs(storePath)).toEqual([
expect.objectContaining({
sourceIndex: 0,
reason: "invalid-payload",
raw: { jobId: malformedJob.id, jobJson: "{", stateJson: "{}" },
}),
expect.objectContaining({
sourceIndex: 1,
reason: "invalid-state",
job: expect.objectContaining({ id: malformedState.id }),
raw: { jobId: malformedState.id, jobJson: stateRow.job_json, stateJson: "[]" },
}),
]);
});
it("quarantines persisted every schedules that cannot produce valid Date timestamps", async () => {
const { storePath } = await makeStorePath();
const invalidInterval = createReloadCronJob({
@@ -202,18 +240,15 @@ describe("cron service store seam coverage", () => {
],
});
const db = openOpenClawStateDatabase().db;
db.prepare("UPDATE cron_jobs SET every_ms = ? WHERE job_id = ?").run(
MAX_DATE_TIMESTAMP_MS + 1,
invalidInterval.id,
);
db.prepare("UPDATE cron_jobs SET anchor_ms = ? WHERE job_id = ?").run(
MAX_DATE_TIMESTAMP_MS + 1,
invalidAnchor.id,
);
db.prepare("UPDATE cron_jobs SET stagger_ms = ? WHERE job_id = ?").run(
MAX_DATE_TIMESTAMP_MS + 1,
invalidStagger.id,
);
db.prepare(
"UPDATE cron_jobs SET job_json = json_set(job_json, '$.schedule.everyMs', ?) WHERE job_id = ?",
).run(MAX_DATE_TIMESTAMP_MS + 1, invalidInterval.id);
db.prepare(
"UPDATE cron_jobs SET job_json = json_set(job_json, '$.schedule.anchorMs', ?) WHERE job_id = ?",
).run(MAX_DATE_TIMESTAMP_MS + 1, invalidAnchor.id);
db.prepare(
"UPDATE cron_jobs SET job_json = json_set(job_json, '$.schedule.staggerMs', ?) WHERE job_id = ?",
).run(MAX_DATE_TIMESTAMP_MS + 1, invalidStagger.id);
const state = createStoreTestState(storePath);
await ensureLoaded(state, { skipRecompute: true });
@@ -224,13 +259,23 @@ describe("cron service store seam coverage", () => {
surviving.id,
]);
expect(cronStoreModule.loadCronQuarantinedJobs(storePath)).toEqual([
expect.objectContaining({ job: expect.objectContaining({ id: invalidInterval.id }) }),
expect.objectContaining({ job: expect.objectContaining({ id: invalidAnchor.id }) }),
expect.objectContaining({
sourceIndex: 0,
job: expect.objectContaining({ id: invalidInterval.id }),
}),
expect.objectContaining({
sourceIndex: 1,
job: expect.objectContaining({ id: invalidAnchor.id }),
}),
expect.objectContaining({
sourceIndex: 2,
reason: "unsatisfiable-schedule",
job: expect.objectContaining({ id: unsatisfiableInterval.id }),
}),
expect.objectContaining({ job: expect.objectContaining({ id: invalidStagger.id }) }),
expect.objectContaining({
sourceIndex: 4,
job: expect.objectContaining({ id: invalidStagger.id }),
}),
]);
});
@@ -240,7 +285,9 @@ describe("cron service store seam coverage", () => {
const surviving = createReloadCronJob({ id: "valid-runtime-state" });
await saveCronStore(storePath, { version: 1, jobs: [invalidState, surviving] });
openOpenClawStateDatabase()
.db.prepare("UPDATE cron_jobs SET last_run_at_ms = ? WHERE job_id = ?")
.db.prepare(
"UPDATE cron_jobs SET state_json = json_set(state_json, '$.lastRunAtMs', ?) WHERE job_id = ?",
)
.run(MAX_DATE_TIMESTAMP_MS + 1, invalidState.id);
const state = createStoreTestState(storePath);
+3
View File
@@ -257,6 +257,9 @@ export async function ensureLoaded(
loadedCronStoreRevisions.set(state, getCronJobsStoreRevision(state.deps.storePath));
if (quarantinedConfigJobs.length > 0) {
// Config decoding and runtime validation reject rows in separate passes;
// restore their original durable order before writing operator-visible quarantine.
quarantinedConfigJobs.sort((left, right) => left.sourceIndex - right.sourceIndex);
state.pendingQuarantineConfigJobs = quarantinedConfigJobs;
try {
if (await persist(state)) {
+1 -1
View File
@@ -331,7 +331,7 @@ describe("cron service timer seam coverage", () => {
.mockImplementation((params) => {
const persistedJob = openOpenClawStateDatabase()
.db.prepare(
"SELECT running_at_ms AS runningAtMs, next_run_at_ms AS nextRunAtMs FROM cron_jobs WHERE store_key = ? AND job_id = ?",
"SELECT json_extract(state_json, '$.runningAtMs') AS runningAtMs, json_extract(state_json, '$.nextRunAtMs') AS nextRunAtMs FROM cron_jobs WHERE store_key = ? AND job_id = ?",
)
.get(cronStoreKey(storePath), job.id) as {
runningAtMs: number | null;
+114 -56
View File
@@ -138,6 +138,75 @@ describe("cron store", () => {
expect(loaded).toEqual({ version: 1, jobs: [] });
});
it.each([
{
name: "one-shot schedule without delivery",
schedule: { kind: "at", at: "2030-01-01T00:00:00.000Z" },
delivery: { mode: "none" },
failureAlert: false,
},
{
name: "interval schedule with an explicitly empty failure alert",
schedule: { kind: "every", everyMs: 60_000, anchorMs: 1_000 },
delivery: { mode: "announce", channel: "telegram", threadId: 42 },
failureAlert: {},
},
{
name: "cron schedule with webhook delivery and populated failure alert",
schedule: { kind: "cron", expr: "0 9 * * *", tz: "UTC", staggerMs: 0 },
delivery: { mode: "webhook", to: "https://example.invalid/cron" },
failureAlert: { after: 3, cooldownMs: 60_000, includeSkipped: true },
},
{
name: "process-exit schedule with explicit failure destination clears",
schedule: { kind: "on-exit", command: "./watch.sh", cwd: "/repo" },
delivery: {
mode: "announce",
channel: "telegram",
failureDestination: { channel: undefined, to: "slack:C123", accountId: undefined },
},
failureAlert: { channel: "slack", to: "slack:C123", mode: "announce" },
},
{
name: "stream schedule with completion webhook",
schedule: {
kind: "stream",
command: ["node", "events.mjs"],
mode: "match",
match: "^ready:",
batchMs: 100,
},
delivery: {
mode: "announce",
to: "telegram:chat",
completionDestination: { mode: "webhook", to: "https://example.invalid/complete" },
},
failureAlert: { accountId: "bot-1", mode: "webhook" },
},
] satisfies Array<{
name: string;
schedule: CronStoreFile["jobs"][number]["schedule"];
delivery: NonNullable<CronStoreFile["jobs"][number]["delivery"]>;
failureAlert: NonNullable<CronStoreFile["jobs"][number]["failureAlert"]>;
}>)(
"preserves the complete job for $name",
async ({ name, schedule, delivery, failureAlert }) => {
const { storePath } = await makeStorePath();
const job = expectDefined(makeStore(name, true).jobs[0], "cron round-trip fixture");
Object.assign(job, {
schedule,
delivery,
failureAlert,
sessionTarget: "isolated",
payload: { kind: "agentTurn", message: "run" },
});
await saveCronStore(storePath, { version: 1, jobs: [job] });
expect((await loadCronStore(storePath)).jobs[0]).toStrictEqual(job);
},
);
it("throws when doctor migration reads invalid legacy JSON", async () => {
const store = await makeStorePath();
await fs.mkdir(path.dirname(store.storePath), { recursive: true });
@@ -416,7 +485,9 @@ describe("cron store", () => {
surviving.state = { nextRunAtMs: 987_654 };
await saveCronStore(storePath, { version: 1, jobs: [malformed, surviving] });
openOpenClawStateDatabase()
.db.prepare("UPDATE cron_jobs SET schedule_kind = ? WHERE store_key = ? AND job_id = ?")
.db.prepare(
"UPDATE cron_jobs SET job_json = json_set(job_json, '$.schedule.kind', ?) WHERE store_key = ? AND job_id = ?",
)
.run("unsupported", path.resolve(storePath), malformed.id);
const loaded = await loadCronJobsStoreWithConfigJobs(storePath);
@@ -621,6 +692,26 @@ describe("cron store", () => {
expect((await loadCronStore(store.storePath)).jobs[0]?.state).toMatchObject(job.state);
});
it("normalizes legacy run-status aliases into canonical runtime state JSON", async () => {
const store = await makeStorePath();
const payload = makeStore("legacy-run-status", true);
const job = expectDefined(payload.jobs[0], "legacy run-status fixture");
job.state = { lastStatus: "ok" };
await saveCronStore(store.storePath, payload);
expect((await loadCronStore(store.storePath)).jobs[0]?.state).toEqual({
lastStatus: "ok",
lastRunStatus: "ok",
});
job.state = { lastStatus: "error" };
await saveCronStore(store.storePath, payload, { stateOnly: true });
expect((await loadCronStore(store.storePath)).jobs[0]?.state).toEqual({
lastStatus: "error",
lastRunStatus: "error",
});
});
it("stores queued reservations separately from active run markers", async () => {
const store = await makeStorePath();
const payload = makeStore("job-queued-phase", true);
@@ -635,10 +726,11 @@ describe("cron store", () => {
await saveCronStore(store.storePath, payload);
const queuedRow = openOpenClawStateDatabase()
.db.prepare("SELECT running_at_ms, state_json FROM cron_jobs WHERE job_id = ?")
.get(job.id) as { running_at_ms: number | null; state_json: string };
expect(queuedRow.running_at_ms).toBeNull();
expect(JSON.parse(queuedRow.state_json)).toMatchObject({
.db.prepare("SELECT state_json FROM cron_jobs WHERE job_id = ?")
.get(job.id) as { state_json: string };
const queuedState = JSON.parse(queuedRow.state_json) as Record<string, unknown>;
expect(queuedState.runningAtMs).toBeUndefined();
expect(queuedState).toMatchObject({
queuedAtMs: job.createdAtMs + 1,
startupCatchupAtMs: job.createdAtMs,
pacedNextRunAtMs: job.createdAtMs,
@@ -805,7 +897,7 @@ describe("cron store", () => {
const database = openOpenClawStateDatabase().db;
database
.prepare(
"UPDATE cron_jobs SET payload_tools_allow_json = ?, payload_tools_allow_is_default = 0 WHERE job_id = ?",
"UPDATE cron_jobs SET job_json = json_set(job_json, '$.payload.toolsAllow', json(?), '$.payload.toolsAllowIsDefault', json('false')) WHERE job_id = ?",
)
.run(JSON.stringify(["read"]), job.id);
@@ -821,7 +913,7 @@ describe("cron store", () => {
// Reverting the visible cap cannot revive the retired envelope.
database
.prepare(
"UPDATE cron_jobs SET payload_tools_allow_json = ?, payload_tools_allow_is_default = 1 WHERE job_id = ?",
"UPDATE cron_jobs SET job_json = json_set(job_json, '$.payload.toolsAllow', json(?), '$.payload.toolsAllowIsDefault', json('true')) WHERE job_id = ?",
)
.run(JSON.stringify(["read", "cron"]), job.id);
const reverted = (await loadCronStore(storePath)).jobs[0];
@@ -1017,7 +1109,7 @@ describe("cron store", () => {
});
});
it("round-trips completion destinations through SQLite delivery columns", async () => {
it("round-trips completion destinations through canonical cron job JSON", async () => {
const { storePath } = await makeStorePath();
const job = expectDefined(
makeStore("sqlite-webhook-delivery-job", true).jobs[0],
@@ -1052,7 +1144,7 @@ describe("cron store", () => {
});
});
it("round-trips a numeric delivery thread id through SQLite delivery columns", async () => {
it("round-trips a numeric delivery thread id through canonical cron job JSON", async () => {
const { storePath } = await makeStorePath();
const job = expectDefined(
makeStore("sqlite-numeric-thread-id-job", true).jobs[0],
@@ -1073,7 +1165,7 @@ describe("cron store", () => {
});
it.each(["42", "1737500000.123456", "007"])(
"keeps a numeric-looking delivery thread id %s as a string through SQLite delivery columns",
"keeps a numeric-looking delivery thread id %s as a string through canonical cron job JSON",
async (threadId) => {
const { storePath } = await makeStorePath();
const job = expectDefined(
@@ -1095,51 +1187,7 @@ describe("cron store", () => {
},
);
it("does not resurrect a cleared thread id from the stored config copy", async () => {
const { storePath } = await makeStorePath();
const job = expectDefined(
makeStore("sqlite-early-row-thread-id-job", true).jobs[0],
'makeStore("sqlite-early-row-thread-id-job", true).jobs[0] test invariant',
);
job.delivery = {
mode: "announce",
channel: "telegram",
to: "telegram:chat-1",
threadId: 1008013,
};
await saveCronStore(storePath, { version: 1, jobs: [job] });
openOpenClawStateDatabase()
.db.prepare("UPDATE cron_jobs SET delivery_thread_id = NULL WHERE job_id = ?")
.run(job.id);
const loadedThreadId = (await loadCronStore(storePath)).jobs[0]?.delivery?.threadId;
expect(loadedThreadId).toBeUndefined();
});
it("uses the normalized thread id when the stored config copy is stale", async () => {
const { storePath } = await makeStorePath();
const job = expectDefined(
makeStore("sqlite-stale-thread-id-job", true).jobs[0],
'makeStore("sqlite-stale-thread-id-job", true).jobs[0] test invariant',
);
job.delivery = {
mode: "announce",
channel: "telegram",
to: "telegram:chat-1",
threadId: 1008013,
};
await saveCronStore(storePath, { version: 1, jobs: [job] });
openOpenClawStateDatabase()
.db.prepare("UPDATE cron_jobs SET delivery_thread_id = ? WHERE job_id = ?")
.run("replacement", job.id);
const loadedThreadId = (await loadCronStore(storePath)).jobs[0]?.delivery?.threadId;
expect(loadedThreadId).toBe("replacement");
});
it("disambiguates identical thread id text using the normalized type marker", async () => {
it("preserves distinct numeric and string thread identities in canonical cron job JSON", async () => {
const { storePath } = await makeStorePath();
const numberJob = expectDefined(
makeStore("sqlite-thread-id-number", true).jobs[0],
@@ -1166,7 +1214,7 @@ describe("cron store", () => {
expect(typeof jobs[1]?.delivery?.threadId).toBe("string");
});
it("round-trips explicit failure destination field clears through SQLite delivery columns", async () => {
it("round-trips explicit failure destination field clears through canonical cron job JSON", async () => {
const { storePath } = await makeStorePath();
const job = expectDefined(
makeStore("sqlite-failure-destination-clear-job", true).jobs[0],
@@ -1188,6 +1236,16 @@ describe("cron store", () => {
await saveCronStore(storePath, { version: 1, jobs: [job] });
const row = openOpenClawStateDatabase()
.db.prepare("SELECT job_json FROM cron_jobs WHERE job_id = ?")
.get(job.id) as { job_json: string };
expect(JSON.parse(row.job_json).delivery.failureDestination).toEqual({
channel: null,
to: "slack:C123",
accountId: null,
mode: null,
});
const delivery = (await loadCronStore(storePath)).jobs[0]?.delivery;
expect(delivery?.failureDestination).toEqual({
channel: undefined,
+32 -140
View File
@@ -1,151 +1,43 @@
/** SQLite column codec for cron delivery configuration. */
/** JSON codec for cron delivery configuration and explicit destination clears. */
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import type { CronDelivery } from "../types.js";
import { booleanToInteger, integerToBoolean } from "./scalar-codec.js";
import type { CronJobInsert, CronJobRow } from "./schema.js";
/** Maps cron delivery config into normalized SQLite columns. */
export function bindDeliveryColumns(
delivery: CronDelivery | undefined,
): Pick<
CronJobInsert,
| "delivery_account_id"
| "delivery_best_effort"
| "delivery_channel"
| "delivery_completion_mode"
| "delivery_completion_to"
| "delivery_mode"
| "delivery_thread_id"
| "delivery_thread_id_type"
| "delivery_to"
| "failure_delivery_account_id"
| "failure_delivery_channel"
| "failure_delivery_mode"
| "failure_delivery_to"
> {
const failureDestination = delivery?.failureDestination;
const FAILURE_DESTINATION_FIELDS = ["channel", "to", "accountId", "mode"] as const;
/** Encodes explicitly undefined failure overrides as durable JSON null values. */
export function deliveryToJson(delivery: CronDelivery): Record<string, unknown> {
const failureDestination = delivery.failureDestination;
if (!failureDestination) {
return { ...delivery };
}
return {
delivery_mode: delivery?.mode ?? null,
delivery_channel: delivery?.channel ?? null,
delivery_to: delivery?.to ?? null,
delivery_thread_id:
delivery?.threadId === undefined || delivery.threadId === null
? null
: String(delivery.threadId),
delivery_thread_id_type:
delivery?.threadId === undefined || delivery.threadId === null
? null
: typeof delivery.threadId,
delivery_account_id: delivery?.accountId ?? null,
delivery_best_effort: booleanToInteger(delivery?.bestEffort),
delivery_completion_mode: delivery?.completionDestination?.mode ?? null,
delivery_completion_to: delivery?.completionDestination?.to ?? null,
// Empty string is an internal SQLite sentinel for an explicit undefined field.
// `resolveFailureDestination` uses own-property presence to clear inherited
// global failure-destination fields, so persistence must preserve presence.
failure_delivery_mode: bindFailureDestinationField(failureDestination, "mode"),
failure_delivery_channel: bindFailureDestinationField(failureDestination, "channel"),
failure_delivery_to: bindFailureDestinationField(failureDestination, "to"),
failure_delivery_account_id: bindFailureDestinationField(failureDestination, "accountId"),
...delivery,
failureDestination: Object.fromEntries(
FAILURE_DESTINATION_FIELDS.filter((field) => Object.hasOwn(failureDestination, field)).map(
(field) => [field, failureDestination[field] ?? null],
),
),
};
}
function bindFailureDestinationField(
failureDestination: CronDelivery["failureDestination"],
key: "accountId" | "channel" | "mode" | "to",
): string | null {
if (!failureDestination || !Object.hasOwn(failureDestination, key)) {
return null;
}
return failureDestination[key] ?? "";
}
function readFailureDestinationField(value: string | null): string | undefined {
return value === "" || value == null ? undefined : value;
}
function cronDeliveryModeFromValue(value: unknown): CronDelivery["mode"] | undefined {
return value === "none" || value === "announce" || value === "webhook" ? value : undefined;
}
function threadIdFromRow(row: CronJobRow): string | number | undefined {
const value = row.delivery_thread_id;
if (!value) {
/** Restores JSON null overrides as present-but-undefined runtime properties. */
export function deliveryFromJson(value: unknown): CronDelivery | undefined {
if (
!isRecord(value) ||
(value.mode !== "none" && value.mode !== "announce" && value.mode !== "webhook")
) {
return undefined;
}
if (row.delivery_thread_id_type === "number") {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : value;
const failureDestination = value.failureDestination;
if (!isRecord(failureDestination)) {
return value as CronDelivery;
}
return value;
}
/** Reconstructs delivery config from split SQLite columns, preserving legacy partial rows. */
export function deliveryFromRow(row: CronJobRow): CronDelivery | undefined {
const rowMode = cronDeliveryModeFromValue(row.delivery_mode);
const threadId = threadIdFromRow(row);
const hasDeliveryColumns =
Boolean(
row.delivery_channel ||
row.delivery_to ||
threadId !== undefined ||
row.delivery_account_id ||
row.delivery_completion_mode ||
row.delivery_completion_to ||
row.failure_delivery_channel != null ||
row.failure_delivery_to != null ||
row.failure_delivery_mode != null ||
row.failure_delivery_account_id != null,
) || row.delivery_best_effort != null;
const completionDestination =
rowMode === "announce" && row.delivery_completion_mode === "webhook"
? {
mode: "webhook" as const,
...(row.delivery_completion_to ? { to: row.delivery_completion_to } : {}),
}
: undefined;
const failureDestination =
row.failure_delivery_channel != null ||
row.failure_delivery_to != null ||
row.failure_delivery_mode != null ||
row.failure_delivery_account_id != null
? {
...(row.failure_delivery_channel != null
? {
channel: readFailureDestinationField(
row.failure_delivery_channel,
) as CronDelivery["channel"],
}
: {}),
...(row.failure_delivery_to != null
? { to: readFailureDestinationField(row.failure_delivery_to) }
: {}),
...(row.failure_delivery_mode != null
? {
mode: readFailureDestinationField(row.failure_delivery_mode) as
| "announce"
| "webhook",
}
: {}),
...(row.failure_delivery_account_id != null
? { accountId: readFailureDestinationField(row.failure_delivery_account_id) }
: {}),
}
: undefined;
if (!rowMode && !hasDeliveryColumns) {
return undefined;
}
// Old rows may have destination columns without a mode; announce matches the
// historical default for configured channel delivery.
return {
mode: rowMode ?? "announce",
...(row.delivery_channel ? { channel: row.delivery_channel as CronDelivery["channel"] } : {}),
...(row.delivery_to ? { to: row.delivery_to } : {}),
...(threadId !== undefined ? { threadId } : {}),
...(row.delivery_account_id ? { accountId: row.delivery_account_id } : {}),
...(row.delivery_best_effort != null
? { bestEffort: integerToBoolean(row.delivery_best_effort) }
: {}),
...(completionDestination ? { completionDestination } : {}),
...(failureDestination ? { failureDestination } : {}),
};
...value,
failureDestination: Object.fromEntries(
FAILURE_DESTINATION_FIELDS.filter((field) => Object.hasOwn(failureDestination, field)).map(
(field) => [field, failureDestination[field] ?? undefined],
),
),
} as CronDelivery;
}
+7 -18
View File
@@ -1,16 +1,14 @@
// Unit tests for failure-alert SQLite column codec roundtrip.
// Failure-alert settings retain their public shape through canonical cron job JSON.
import { describe, expect, it } from "vitest";
import { bindFailureAlertColumns, failureAlertFromRow } from "./failure-alert-codec.js";
import type { CronJobRow } from "./schema.js";
import { makeCronJob } from "../delivery.test-helpers.js";
import type { CronFailureAlert } from "../types.js";
import { projectCronJobThroughStorageCodec } from "./row-codec.js";
function roundtrip(
input: Parameters<typeof bindFailureAlertColumns>[0],
): ReturnType<typeof failureAlertFromRow> {
const columns = bindFailureAlertColumns(input);
return failureAlertFromRow(columns as CronJobRow);
function roundtrip(input: CronFailureAlert | false | undefined) {
return projectCronJobThroughStorageCodec(makeCronJob({ failureAlert: input })).failureAlert;
}
describe("failureAlertFromRow", () => {
describe("failure-alert cron JSON round-trip", () => {
it("round-trips disabled config (false)", () => {
expect(roundtrip(false)).toBe(false);
});
@@ -40,13 +38,4 @@ describe("failureAlertFromRow", () => {
it("round-trips partial config (only after)", () => {
expect(roundtrip({ after: 5 })).toEqual({ after: 5 });
});
it("enabled-with-defaults does not collapse to undefined on read", () => {
const columns = bindFailureAlertColumns({});
const row = columns as CronJobRow;
expect(row.failure_alert_disabled).toBe(0);
expect(row.failure_alert_after).toBeNull();
const decoded = failureAlertFromRow(row);
expect(decoded).toEqual({});
});
});
-77
View File
@@ -1,77 +0,0 @@
/** SQLite column codec for cron failure-alert configuration. */
import type { CronFailureAlert } from "../types.js";
import { booleanToInteger, integerToBoolean, normalizeNumber } from "./scalar-codec.js";
import type { CronJobInsert, CronJobRow } from "./schema.js";
/** Maps cron failure-alert config into normalized SQLite columns. */
export function bindFailureAlertColumns(
failureAlert: CronFailureAlert | false | undefined,
): Pick<
CronJobInsert,
| "failure_alert_account_id"
| "failure_alert_after"
| "failure_alert_channel"
| "failure_alert_cooldown_ms"
| "failure_alert_disabled"
| "failure_alert_include_skipped"
| "failure_alert_mode"
| "failure_alert_to"
> {
if (failureAlert === false) {
return {
failure_alert_disabled: 1,
failure_alert_after: null,
failure_alert_channel: null,
failure_alert_to: null,
failure_alert_cooldown_ms: null,
failure_alert_include_skipped: null,
failure_alert_mode: null,
failure_alert_account_id: null,
};
}
return {
failure_alert_disabled: failureAlert ? 0 : null,
failure_alert_after: failureAlert?.after ?? null,
failure_alert_channel: failureAlert?.channel ?? null,
failure_alert_to: failureAlert?.to ?? null,
failure_alert_cooldown_ms: failureAlert?.cooldownMs ?? null,
failure_alert_include_skipped: booleanToInteger(failureAlert?.includeSkipped),
failure_alert_mode: failureAlert?.mode ?? null,
failure_alert_account_id: failureAlert?.accountId ?? null,
};
}
/** Reconstructs failure-alert config, distinguishing disabled from omitted config. */
export function failureAlertFromRow(row: CronJobRow): CronFailureAlert | false | undefined {
if (row.failure_alert_disabled === 1) {
return false;
}
const failureAlertExplicitlyEnabled = row.failure_alert_disabled === 0;
if (
row.failure_alert_after == null &&
!row.failure_alert_channel &&
!row.failure_alert_to &&
row.failure_alert_cooldown_ms == null &&
row.failure_alert_include_skipped == null &&
!row.failure_alert_mode &&
!row.failure_alert_account_id &&
!failureAlertExplicitlyEnabled
) {
return undefined;
}
return {
...(row.failure_alert_after != null ? { after: normalizeNumber(row.failure_alert_after) } : {}),
...(row.failure_alert_channel
? { channel: row.failure_alert_channel as CronFailureAlert["channel"] }
: {}),
...(row.failure_alert_to ? { to: row.failure_alert_to } : {}),
...(row.failure_alert_cooldown_ms != null
? { cooldownMs: normalizeNumber(row.failure_alert_cooldown_ms) }
: {}),
...(row.failure_alert_include_skipped != null
? { includeSkipped: integerToBoolean(row.failure_alert_include_skipped) }
: {}),
...(row.failure_alert_mode ? { mode: row.failure_alert_mode as "announce" | "webhook" } : {}),
...(row.failure_alert_account_id ? { accountId: row.failure_alert_account_id } : {}),
};
}
-238
View File
@@ -1,238 +0,0 @@
/** SQLite column codec for cron payload variants. */
import { safeParseJson } from "@openclaw/normalization-core";
import { isSystemOwnedCronPayloadKind, type CronPayload } from "../types.js";
import {
booleanToInteger,
integerToBoolean,
normalizeNumber,
parseJsonArray,
serializeJson,
} from "./scalar-codec.js";
import type { CronJobInsert, CronJobRow } from "./schema.js";
type CronPayloadToolAllow = Pick<CronPayload, "toolsAllow" | "toolsAllowIsDefault">;
type CronPayloadToolAllowColumns = Pick<
CronJobInsert,
"payload_tools_allow_json" | "payload_tools_allow_is_default"
>;
function bindPayloadToolAllowColumns(payload: CronPayloadToolAllow): CronPayloadToolAllowColumns {
return {
payload_tools_allow_json: serializeJson(payload.toolsAllow),
payload_tools_allow_is_default: payload.toolsAllow
? booleanToInteger(payload.toolsAllowIsDefault)
: null,
};
}
function payloadToolAllowFromRow(
row: Pick<CronJobRow, "payload_tools_allow_json" | "payload_tools_allow_is_default">,
): CronPayloadToolAllow {
const toolsAllow = parseJsonArray(row.payload_tools_allow_json);
if (!toolsAllow) {
return {};
}
const toolsAllowIsDefault = integerToBoolean(row.payload_tools_allow_is_default);
return {
toolsAllow,
...(toolsAllowIsDefault ? { toolsAllowIsDefault: true } : {}),
};
}
function parseExternalContentSource(raw: string | null): "email" | "gmail" | "webhook" | undefined {
const parsed = raw ? safeParseJson(raw) : undefined;
return parsed === "email" || parsed === "gmail" || parsed === "webhook" ? parsed : undefined;
}
function parseCommandPayloadMessage(
raw: string | null,
): Omit<Extract<CronPayload, { kind: "command" }>, "kind" | "timeoutSeconds"> | null {
const parsed = raw ? safeParseJson(raw) : undefined;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return null;
}
const record = parsed as Record<string, unknown>;
if (
!Array.isArray(record.argv) ||
record.argv.length === 0 ||
record.argv.some((value) => typeof value !== "string" || value.length === 0)
) {
return null;
}
const argv = record.argv.map((value) => String(value));
const env =
record.env && typeof record.env === "object" && !Array.isArray(record.env)
? Object.fromEntries(
Object.entries(record.env as Record<string, unknown>).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
)
: undefined;
const rawNoOutputTimeoutSeconds =
typeof record.noOutputTimeoutSeconds === "number" ||
typeof record.noOutputTimeoutSeconds === "bigint"
? record.noOutputTimeoutSeconds
: null;
const rawOutputMaxBytes =
typeof record.outputMaxBytes === "number" || typeof record.outputMaxBytes === "bigint"
? record.outputMaxBytes
: null;
const noOutputTimeoutSeconds = normalizeNumber(rawNoOutputTimeoutSeconds);
const outputMaxBytes = normalizeNumber(rawOutputMaxBytes);
return {
argv,
...(typeof record.cwd === "string" && record.cwd.trim() ? { cwd: record.cwd } : {}),
...(env && Object.keys(env).length > 0 ? { env } : {}),
...(typeof record.input === "string" ? { input: record.input } : {}),
...(noOutputTimeoutSeconds != null ? { noOutputTimeoutSeconds } : {}),
...(outputMaxBytes != null && outputMaxBytes > 0 ? { outputMaxBytes } : {}),
};
}
function parseScriptPayloadMessage(
raw: string | null,
): Omit<Extract<CronPayload, { kind: "script" }>, "kind" | "timeoutSeconds"> | null {
const parsed = raw ? safeParseJson(raw) : undefined;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return null;
}
const record = parsed as Record<string, unknown>;
if (typeof record.script !== "string" || !record.script.trim()) {
return null;
}
const toolBudget = normalizeNumber(
typeof record.toolBudget === "number" || typeof record.toolBudget === "bigint"
? record.toolBudget
: null,
);
return {
script: record.script,
...(toolBudget != null ? { toolBudget } : {}),
};
}
/** Maps cron payload variants into normalized SQLite columns. */
export function bindPayloadColumns(
payload: CronPayload,
): Pick<
CronJobInsert,
| "payload_allow_unsafe_external_content"
| "payload_external_content_source_json"
| "payload_fallbacks_json"
| "payload_kind"
| "payload_light_context"
| "payload_message"
| "payload_model"
| "payload_thinking"
| "payload_timeout_seconds"
| "payload_tools_allow_json"
| "payload_tools_allow_is_default"
> {
const agentTurn = payload.kind === "agentTurn" ? payload : undefined;
let payloadMessage: string | null;
if (payload.kind === "systemEvent") {
payloadMessage = payload.text;
} else if (payload.kind === "agentTurn") {
payloadMessage = payload.message;
} else if (payload.kind === "command" || payload.kind === "script") {
const {
timeoutSeconds: _timeoutSeconds,
toolsAllow: _toolsAllow,
toolsAllowIsDefault: _toolsAllowIsDefault,
...serializedPayload
} = payload;
payloadMessage = serializeJson(serializedPayload);
} else {
payloadMessage = null;
}
return {
payload_kind: payload.kind,
payload_message: payloadMessage,
payload_model: agentTurn?.model ?? null,
payload_fallbacks_json: serializeJson(agentTurn?.fallbacks),
payload_thinking: agentTurn?.thinking ?? null,
payload_timeout_seconds:
payload.kind === "agentTurn" || payload.kind === "command" || payload.kind === "script"
? (payload.timeoutSeconds ?? null)
: null,
payload_allow_unsafe_external_content: booleanToInteger(agentTurn?.allowUnsafeExternalContent),
payload_external_content_source_json: serializeJson(agentTurn?.externalContentSource),
payload_light_context: booleanToInteger(agentTurn?.lightContext),
...bindPayloadToolAllowColumns(payload),
};
}
/** Reconstructs cron payload variants from SQLite columns, returning null for invalid rows. */
export function payloadFromRow(row: CronJobRow): CronPayload | null {
if (row.payload_kind === "systemEvent") {
if (row.payload_message == null) {
return null;
}
return {
kind: "systemEvent",
text: row.payload_message,
...payloadToolAllowFromRow(row),
};
}
if (row.payload_kind === "agentTurn") {
if (row.payload_message == null) {
return null;
}
const fallbacks = row.payload_fallbacks_json
? parseJsonArray(row.payload_fallbacks_json)
: undefined;
const timeoutSeconds = normalizeNumber(row.payload_timeout_seconds);
const allowUnsafeExternalContent =
row.payload_allow_unsafe_external_content != null
? integerToBoolean(row.payload_allow_unsafe_external_content)
: undefined;
const externalContentSource = parseExternalContentSource(
row.payload_external_content_source_json,
);
const lightContext =
row.payload_light_context != null ? integerToBoolean(row.payload_light_context) : undefined;
return {
kind: "agentTurn",
message: row.payload_message,
...(row.payload_model ? { model: row.payload_model } : {}),
...(fallbacks ? { fallbacks } : {}),
...(row.payload_thinking ? { thinking: row.payload_thinking } : {}),
...(timeoutSeconds != null ? { timeoutSeconds } : {}),
...(allowUnsafeExternalContent != null ? { allowUnsafeExternalContent } : {}),
...(externalContentSource ? { externalContentSource } : {}),
...(lightContext != null ? { lightContext } : {}),
...payloadToolAllowFromRow(row),
};
}
if (row.payload_kind === "command") {
const command = parseCommandPayloadMessage(row.payload_message);
if (!command) {
return null;
}
const timeoutSeconds = normalizeNumber(row.payload_timeout_seconds);
return {
kind: "command",
...command,
...(timeoutSeconds != null ? { timeoutSeconds } : {}),
...payloadToolAllowFromRow(row),
};
}
if (isSystemOwnedCronPayloadKind(row.payload_kind)) {
return { kind: row.payload_kind };
}
if (row.payload_kind === "script") {
const script = parseScriptPayloadMessage(row.payload_message);
if (!script) {
return null;
}
const timeoutSeconds = normalizeNumber(row.payload_timeout_seconds);
return {
kind: "script",
...script,
...(timeoutSeconds != null ? { timeoutSeconds } : {}),
...payloadToolAllowFromRow(row),
};
}
return null;
}
+7 -14
View File
@@ -1,6 +1,4 @@
// Round-trips each CronSchedule kind through the SQLite column codec so the
// on-exit command/cwd persistence (v1 reuses schedule_expr/schedule_tz) is
// covered alongside the existing kinds.
// Round-trips each CronSchedule kind through canonical SQLite job JSON.
import { describe, expect, it } from "vitest";
import { makeCronJob } from "../delivery.test-helpers.js";
import type { CronSchedule } from "../types.js";
@@ -10,8 +8,8 @@ function roundTrip(schedule: CronSchedule): CronSchedule | null {
return projectCronJobThroughStorageCodec(makeCronJob({ schedule })).schedule;
}
describe("schedule column codec round-trip", () => {
it("round-trips the creator account through the additive job_json envelope", () => {
describe("canonical cron schedule JSON round-trip", () => {
it("round-trips the creator account through canonical job JSON", () => {
const job = projectCronJobThroughStorageCodec(
makeCronJob({
owner: {
@@ -29,7 +27,7 @@ describe("schedule column codec round-trip", () => {
});
});
it("round-trips scheduled authority through the additive job_json envelope", () => {
it("round-trips scheduled authority through canonical job JSON", () => {
const job = projectCronJobThroughStorageCodec(
makeCronJob({
owner: {
@@ -90,7 +88,7 @@ describe("schedule column codec round-trip", () => {
expect(malformed.runtimeAuthority).toBeUndefined();
});
it("round-trips pacing through the additive job_json envelope", () => {
it("round-trips pacing through canonical job JSON", () => {
const job = projectCronJobThroughStorageCodec(
makeCronJob({ pacing: { min: "15m", max: "4h" } }),
);
@@ -113,7 +111,7 @@ describe("schedule column codec round-trip", () => {
});
});
it("round-trips a stream schedule through job_json without new columns", () => {
it("round-trips a stream schedule through canonical job JSON", () => {
expect(
roundTrip({
kind: "stream",
@@ -135,7 +133,7 @@ describe("schedule column codec round-trip", () => {
});
});
it("keeps existing kinds intact (no cross-talk from on-exit column reuse)", () => {
it("keeps existing schedule kinds intact", () => {
expect(roundTrip({ kind: "every", everyMs: 60_000 })).toEqual({
kind: "every",
everyMs: 60_000,
@@ -150,9 +148,4 @@ describe("schedule column codec round-trip", () => {
at: "2026-01-01T00:00:00.000Z",
});
});
it("an on-exit row is decoded as on-exit, not cron (schedule_kind disambiguates)", () => {
const decoded = roundTrip({ kind: "on-exit", command: "sleep 5" });
expect(decoded?.kind).toBe("on-exit");
});
});
+54 -295
View File
@@ -1,109 +1,20 @@
/** Converts cron jobs between public store shape and normalized SQLite rows. */
import type { DatabaseSync } from "node:sqlite";
import { safeParseJson } from "@openclaw/normalization-core";
import { asOptionalObjectRecord, isRecord } from "@openclaw/normalization-core/record-coerce";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import type { SessionCreatedActor } from "../../config/sessions/session-entry-provenance.js";
import { executeSqliteQuerySync } from "../../infra/kysely-sync.js";
import { normalizeOptionalAccountId } from "../../routing/account-id.js";
import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js";
import { normalizeCronJobIdentityFields } from "../normalize-job-identity.js";
import { normalizeCronJobInput } from "../normalize.js";
import { getInvalidPersistedCronJobReason } from "../persisted-shape.js";
import { tryCronScheduleIdentity } from "../schedule-identity.js";
import {
normalizeCronScheduledToolCallerOrigin,
normalizeCronScheduledToolPolicy,
} from "../scheduled-tool-policy.js";
import type {
CronJobState,
CronPacing,
CronSchedule,
CronStoredJob,
CronStoreFile,
} from "../types.js";
import { bindDeliveryColumns, deliveryFromRow } from "./delivery-codec.js";
import { bindFailureAlertColumns, failureAlertFromRow } from "./failure-alert-codec.js";
import { bindPayloadColumns, payloadFromRow } from "./payload-codec.js";
import {
booleanToInteger,
integerToBoolean,
normalizeNumber,
tryParseJsonObject,
} from "./scalar-codec.js";
import type { CronJobState, CronStoredJob, CronStoreFile } from "../types.js";
import { deliveryFromJson, deliveryToJson } from "./delivery-codec.js";
import { normalizeNumber, tryParseJsonObject } from "./scalar-codec.js";
import type { CronJobInsert, CronJobRow } from "./schema.js";
import { getCronStoreKysely } from "./schema.js";
import { bindStateColumns, stateFromRow } from "./state-codec.js";
import { bindTriggerColumns, triggerFromRow } from "./trigger-codec.js";
import type { LoadedCronStore } from "./types.js";
function bindScheduleColumns(
schedule: CronSchedule,
): Pick<
CronJobInsert,
"anchor_ms" | "at" | "every_ms" | "schedule_expr" | "schedule_kind" | "schedule_tz" | "stagger_ms"
> {
if (schedule.kind === "at") {
return {
schedule_kind: "at",
at: schedule.at,
every_ms: null,
anchor_ms: null,
schedule_expr: null,
schedule_tz: null,
stagger_ms: null,
};
}
if (schedule.kind === "every") {
return {
schedule_kind: "every",
at: null,
every_ms: schedule.everyMs,
anchor_ms: schedule.anchorMs ?? null,
schedule_expr: null,
schedule_tz: null,
stagger_ms: null,
};
}
if (schedule.kind === "on-exit") {
// v1: reuse existing nullable TEXT columns to round-trip the watcher's
// command (schedule_expr) and cwd (schedule_tz) without a schema migration.
// schedule_kind disambiguates from cron. (Dedicated columns are a possible
// follow-up if reviewers prefer.)
return {
schedule_kind: "on-exit",
at: null,
every_ms: null,
anchor_ms: null,
schedule_expr: schedule.command,
schedule_tz: schedule.cwd ?? null,
stagger_ms: null,
};
}
if (schedule.kind === "stream") {
// argv-shaped stream schedules live in the existing additive job_json
// envelope; normalized columns retain only the discriminant (no DDL).
return {
schedule_kind: "stream",
at: null,
every_ms: null,
anchor_ms: null,
schedule_expr: null,
schedule_tz: null,
stagger_ms: null,
};
}
return {
schedule_kind: "cron",
at: null,
every_ms: null,
anchor_ms: null,
schedule_expr: schedule.expr,
schedule_tz: schedule.tz ?? null,
stagger_ms: schedule.staggerMs ?? null,
};
}
function stripJobRuntimeFields(job: CronStoreFile["jobs"][number]): Record<string, unknown> {
const {
runtimeAuthority: _runtimeAuthority,
@@ -112,62 +23,21 @@ function stripJobRuntimeFields(job: CronStoreFile["jobs"][number]): Record<strin
updatedAtMs: _updatedAtMs,
...rest
} = job;
// job_json stores config shape only; runtime state lives in split columns and
// state_json. Runtime authority has its own downgrade-stable companion row.
return { ...rest, state: {} };
// Runtime state and authority have separate owners; JSON is canonical for config.
return {
...rest,
...(rest.delivery ? { delivery: deliveryToJson(rest.delivery) } : {}),
state: {},
};
}
function mergeFailureDestinationProjection(
configJob: Record<string, unknown>,
projectedJob: CronStoredJob | null,
): Record<string, unknown> {
const failureDestination = projectedJob?.delivery?.failureDestination;
if (!failureDestination) {
return configJob;
}
// Empty SQLite sentinels preserve explicit undefined fields for failure
// destination overrides; project them back into the config sidecar shape.
const delivery: Record<string, unknown> = isRecord(configJob.delivery)
? { ...configJob.delivery }
: projectedJob?.delivery
? {
mode: projectedJob.delivery.mode,
...(projectedJob.delivery.channel ? { channel: projectedJob.delivery.channel } : {}),
...(projectedJob.delivery.to ? { to: projectedJob.delivery.to } : {}),
...(projectedJob.delivery.threadId !== undefined
? { threadId: projectedJob.delivery.threadId }
: {}),
...(projectedJob.delivery.accountId
? { accountId: projectedJob.delivery.accountId }
: {}),
...(projectedJob.delivery.bestEffort !== undefined
? { bestEffort: projectedJob.delivery.bestEffort }
: {}),
...(projectedJob.delivery.completionDestination
? { completionDestination: projectedJob.delivery.completionDestination }
: {}),
}
: {};
const nextFailureDestination = isRecord(delivery.failureDestination)
? { ...delivery.failureDestination }
: {};
if (Object.hasOwn(failureDestination, "channel")) {
nextFailureDestination.channel = failureDestination.channel;
}
if (Object.hasOwn(failureDestination, "to")) {
nextFailureDestination.to = failureDestination.to;
}
if (Object.hasOwn(failureDestination, "accountId")) {
nextFailureDestination.accountId = failureDestination.accountId;
}
if (Object.hasOwn(failureDestination, "mode")) {
nextFailureDestination.mode = failureDestination.mode;
}
delivery.failureDestination = nextFailureDestination;
return {
...configJob,
delivery,
};
function serializeCronJobState(state: CronJobState): string {
return JSON.stringify({
...state,
...(state.lastRunStatus === undefined && state.lastStatus !== undefined
? { lastRunStatus: state.lastStatus }
: {}),
});
}
function bindCronJobRow(storeKey: string, job: CronStoredJob, sortOrder: number): CronJobInsert {
@@ -175,27 +45,15 @@ function bindCronJobRow(storeKey: string, job: CronStoredJob, sortOrder: number)
store_key: storeKey,
job_id: job.id,
declaration_key: job.declarationKey ?? null,
display_name: job.displayName ?? null,
owner_agent_id: job.owner?.agentId ?? null,
owner_session_key: job.owner?.sessionKey ?? null,
name: job.name,
description: job.description ?? null,
enabled: job.enabled ? 1 : 0,
delete_after_run: booleanToInteger(job.deleteAfterRun),
created_at_ms: job.createdAtMs,
updated_at: job.updatedAtMs,
agent_id: job.agentId ?? null,
session_key: job.sessionKey ?? null,
session_target: job.sessionTarget,
wake_mode: job.wakeMode,
...bindTriggerColumns(job.trigger),
...bindScheduleColumns(job.schedule),
...bindPayloadColumns(job.payload),
...bindDeliveryColumns(job.delivery),
...bindFailureAlertColumns(job.failureAlert),
...bindStateColumns(job.state ?? {}),
payload_kind: job.payload.kind,
job_json: JSON.stringify(stripJobRuntimeFields(job)),
state_json: JSON.stringify(job.state ?? {}),
state_json: serializeCronJobState(job.state ?? {}),
runtime_updated_at_ms: job.updatedAtMs,
schedule_identity: tryCronScheduleIdentity({ ...job }) ?? null,
sort_order: sortOrder,
@@ -243,135 +101,34 @@ export function assertCronStoreCanPersist(store: CronStoreFile): void {
}
}
function scheduleFromRow(row: CronJobRow, jobJson: Record<string, unknown>): CronSchedule | null {
if (row.schedule_kind === "at" && row.at) {
return { kind: "at", at: row.at };
}
if (row.schedule_kind === "every" && row.every_ms != null) {
return {
kind: "every",
everyMs: normalizeNumber(row.every_ms) ?? 0,
...(row.anchor_ms != null ? { anchorMs: normalizeNumber(row.anchor_ms) } : {}),
};
}
if (row.schedule_kind === "cron" && row.schedule_expr) {
return {
kind: "cron",
expr: row.schedule_expr,
...(row.schedule_tz ? { tz: row.schedule_tz } : {}),
...(row.stagger_ms != null ? { staggerMs: normalizeNumber(row.stagger_ms) } : {}),
};
}
if (row.schedule_kind === "on-exit" && row.schedule_expr) {
return {
kind: "on-exit",
command: row.schedule_expr,
...(row.schedule_tz ? { cwd: row.schedule_tz } : {}),
};
}
if (row.schedule_kind === "stream") {
const schedule = jobJson.schedule;
if (!isRecord(schedule) || schedule.kind !== "stream" || !Array.isArray(schedule.command)) {
return null;
}
return structuredClone(schedule) as CronSchedule;
}
return null;
}
function pacingFromJobJson(jobJson: Record<string, unknown>): CronPacing | undefined {
const pacing = jobJson.pacing;
if (!isRecord(pacing)) {
return undefined;
}
return {
...(typeof pacing.min === "string" ? { min: pacing.min } : {}),
...(typeof pacing.max === "string" ? { max: pacing.max } : {}),
};
}
function createdActorFromJobJson(value: unknown): SessionCreatedActor | undefined {
if (
!isRecord(value) ||
(value.type !== "human" && value.type !== "agent" && value.type !== "system")
) {
return undefined;
}
const id = normalizeOptionalString(typeof value.id === "string" ? value.id : undefined);
const label = normalizeOptionalString(typeof value.label === "string" ? value.label : undefined);
return {
type: value.type,
...(id ? { id } : {}),
...(label ? { label } : {}),
};
function decodeCronJobConfig(jobJson: Record<string, unknown>): Record<string, unknown> {
const delivery = deliveryFromJson(jobJson.delivery);
return delivery ? { ...jobJson, delivery } : jobJson;
}
function rowToCronJob(row: CronJobRow, jobJson: Record<string, unknown>): CronStoredJob | null {
const jsonOwner = isRecord(jobJson.owner) ? jobJson.owner : undefined;
const ownerAccountId = normalizeOptionalAccountId(
typeof jsonOwner?.accountId === "string" ? jsonOwner.accountId : undefined,
);
const schedule = scheduleFromRow(row, jobJson);
const payload = payloadFromRow(row);
const delivery = deliveryFromRow(row);
const failureAlert = failureAlertFromRow(row);
const trigger = triggerFromRow(row);
const pacing = pacingFromJobJson(jobJson);
const createdActor = createdActorFromJobJson(jobJson.createdActor);
const scheduledToolPolicy = normalizeCronScheduledToolPolicy(jobJson.scheduledToolPolicy);
const toolsAllowProvenance =
isRecord(jobJson.toolsAllowProvenance) &&
jobJson.toolsAllowProvenance.version === 1 &&
jobJson.toolsAllowProvenance.source === "final-executable-surface"
? ({
version: 1,
source: "final-executable-surface",
callerOrigin: normalizeCronScheduledToolCallerOrigin(
jobJson.toolsAllowProvenance.callerOrigin,
),
} as const)
: undefined;
if (!schedule || !payload) {
const state = tryParseJsonObject(row.state_json);
if (!state || getInvalidPersistedCronJobReason(jobJson)) {
return null;
}
const createdAtMs = normalizeNumber(row.created_at_ms) ?? Date.now();
const createdAtMs =
typeof jobJson.createdAtMs === "number" && Number.isFinite(jobJson.createdAtMs)
? jobJson.createdAtMs
: Date.now();
// Doctor retains unresolved legacy markers in config JSON; runtime never consumes them.
const { notify: _legacyNotify, ...runtimeConfig } = decodeCronJobConfig(jobJson);
if (isRecord(runtimeConfig.delivery) && runtimeConfig.delivery.mode === undefined) {
// Legacy destination-only config remains untouched for doctor; runtime defaults to announce.
runtimeConfig.delivery = deliveryFromJson({ ...runtimeConfig.delivery, mode: "announce" });
}
return {
...runtimeConfig,
id: row.job_id,
...(createdActor ? { createdActor } : {}),
...(row.declaration_key ? { declarationKey: row.declaration_key } : {}),
...(row.display_name ? { displayName: row.display_name } : {}),
...(row.owner_agent_id || row.owner_session_key || ownerAccountId
? {
owner: {
...(row.owner_agent_id ? { agentId: row.owner_agent_id } : {}),
...(row.owner_session_key ? { sessionKey: row.owner_session_key } : {}),
...(ownerAccountId ? { accountId: ownerAccountId } : {}),
},
}
: {}),
...(scheduledToolPolicy ? { scheduledToolPolicy } : {}),
...(toolsAllowProvenance ? { toolsAllowProvenance } : {}),
name: row.name,
...(row.description ? { description: row.description } : {}),
enabled: row.enabled !== 0,
...(row.delete_after_run != null
? { deleteAfterRun: integerToBoolean(row.delete_after_run) }
: {}),
createdAtMs,
updatedAtMs:
normalizeNumber(row.runtime_updated_at_ms) ?? normalizeNumber(row.updated_at) ?? createdAtMs,
...(row.agent_id ? { agentId: row.agent_id } : {}),
...(row.session_key ? { sessionKey: row.session_key } : {}),
schedule,
...(pacing !== undefined ? { pacing } : {}),
sessionTarget: row.session_target as CronStoredJob["sessionTarget"],
wakeMode: row.wake_mode as CronStoredJob["wakeMode"],
...(trigger ? { trigger } : {}),
payload,
...(delivery ? { delivery } : {}),
...(failureAlert !== undefined ? { failureAlert } : {}),
state: stateFromRow(row),
};
state,
} as CronStoredJob;
}
/** Projects a live job through the same normalization/codecs used by SQLite persistence. */
@@ -381,7 +138,7 @@ export function projectCronJobThroughStorageCodec(job: CronStoredJob): CronStore
throw new Error(`cannot project invalid cron job ${job.id}`);
}
const row = bindCronJobRow("config-revision", normalized, 0) as CronJobRow;
const projected = rowToCronJob(row, asOptionalObjectRecord(safeParseJson(row.job_json)) ?? {});
const projected = rowToCronJob(row, tryParseJsonObject(row.job_json) ?? {});
if (!projected) {
throw new Error(`cannot project cron job ${job.id} through storage codecs`);
}
@@ -418,7 +175,6 @@ export function materializeCronRowAgentOwners(
if (
normalizeOptionalString(row.agent_id) ||
normalizeOptionalString(jobJson?.agentId) ||
parseAgentSessionKey(row.session_key)?.agentId ||
jsonSessionAgentId
) {
continue;
@@ -574,8 +330,7 @@ export function updateCronRuntimeRows(
getCronStoreKysely(db)
.updateTable("cron_jobs")
.set({
...bindStateColumns(job.state ?? {}),
state_json: JSON.stringify(job.state ?? {}),
state_json: serializeCronJobState(job.state ?? {}),
runtime_updated_at_ms: job.updatedAtMs,
schedule_identity: tryCronScheduleIdentity({ ...job }),
})
@@ -594,25 +349,29 @@ export function loadedCronStoreFromRows(rows: CronJobRow[]): LoadedCronStore {
const invalidConfigRows: LoadedCronStore["invalidConfigRows"] = [];
for (const [index, row] of rows.entries()) {
const parsedJobJson = asOptionalObjectRecord(safeParseJson(row.job_json));
const jobJson = parsedJobJson ?? {};
const job = rowToCronJob(row, jobJson);
const configJob = mergeFailureDestinationProjection(
parsedJobJson ?? (job ? stripJobRuntimeFields(job) : {}),
job,
);
const parsedJobJson = tryParseJsonObject(row.job_json);
const parsedStateJson = tryParseJsonObject(row.state_json);
if (!parsedJobJson || !parsedStateJson) {
invalidConfigRows.push({
sourceIndex: index,
reason: parsedJobJson ? "invalid-state" : "invalid-payload",
...(parsedJobJson ? { job: decodeCronJobConfig(parsedJobJson) } : {}),
raw: { jobId: row.job_id, jobJson: row.job_json, stateJson: row.state_json },
});
continue;
}
const job = rowToCronJob(row, parsedJobJson);
const configJob = decodeCronJobConfig(parsedJobJson);
const runtimeEntry = {
updatedAtMs: normalizeNumber(row.runtime_updated_at_ms) ?? normalizeNumber(row.updated_at),
scheduleIdentity: row.schedule_identity ?? undefined,
state: stateFromRow(row) as Record<string, unknown>,
state: parsedStateJson,
};
if (!job) {
invalidConfigRows.push({
sourceIndex: index,
reason:
getInvalidPersistedCronJobReason(configJob) ??
(scheduleFromRow(row, jobJson) ? "invalid-payload" : "invalid-schedule"),
reason: getInvalidPersistedCronJobReason(configJob) ?? "invalid-payload",
job: configJob,
...(runtimeEntry.state ? { state: runtimeEntry.state } : {}),
...(runtimeEntry.updatedAtMs !== undefined
-27
View File
@@ -9,30 +9,3 @@ export function tryParseJsonObject(raw: string): Record<string, unknown> | undef
/** Normalizes SQLite number/bigint columns into JavaScript numbers. */
export { normalizeSqliteNumber as normalizeNumber };
/** Converts optional booleans into nullable SQLite integer flags. */
export function booleanToInteger(value: boolean | undefined): number | null {
return typeof value === "boolean" ? (value ? 1 : 0) : null;
}
/** Converts SQLite integer flags into booleans while preserving missing columns as undefined. */
export function integerToBoolean(value: number | bigint | null): boolean | undefined {
const normalized = normalizeSqliteNumber(value);
return normalized == null ? undefined : normalized !== 0;
}
/** Serializes optional structured values for JSON columns. */
export function serializeJson(value: unknown): string | null {
return value == null ? null : JSON.stringify(value);
}
/** Parses a JSON string-array column and drops non-string entries from legacy data. */
export function parseJsonArray(raw: string | null): string[] | undefined {
if (!raw) {
return undefined;
}
const parsed = safeParseJson(raw);
return Array.isArray(parsed)
? parsed.filter((item): item is string => typeof item === "string")
: undefined;
}
-78
View File
@@ -1,78 +0,0 @@
/** SQLite column codec for mutable cron runtime state. */
import { safeParseJson } from "@openclaw/normalization-core";
import { asRecord } from "@openclaw/normalization-core/record-coerce";
import type { CronJobState } from "../types.js";
import { booleanToInteger, integerToBoolean, normalizeNumber } from "./scalar-codec.js";
import type { CronJobInsert, CronJobRow } from "./schema.js";
/** Maps mutable cron runtime state into normalized SQLite columns. */
export function bindStateColumns(
state: CronJobState,
): Pick<
CronJobInsert,
| "consecutive_errors"
| "consecutive_skipped"
| "last_delivered"
| "last_delivery_error"
| "last_delivery_status"
| "last_duration_ms"
| "last_error"
| "last_failure_alert_at_ms"
| "last_run_at_ms"
| "last_run_status"
| "next_run_at_ms"
| "running_at_ms"
| "schedule_error_count"
> {
return {
next_run_at_ms: state.nextRunAtMs ?? null,
running_at_ms: state.runningAtMs ?? null,
last_run_at_ms: state.lastRunAtMs ?? null,
last_run_status: state.lastRunStatus ?? state.lastStatus ?? null,
last_error: state.lastError ?? null,
last_duration_ms: state.lastDurationMs ?? null,
consecutive_errors: state.consecutiveErrors ?? null,
consecutive_skipped: state.consecutiveSkipped ?? null,
schedule_error_count: state.scheduleErrorCount ?? null,
last_delivery_status: state.lastDeliveryStatus ?? null,
last_delivery_error: state.lastDeliveryError ?? null,
last_delivered: booleanToInteger(state.lastDelivered),
last_failure_alert_at_ms: state.lastFailureAlertAtMs ?? null,
};
}
/** Reconstructs cron runtime state from JSON plus split indexed columns. */
export function stateFromRow(row: CronJobRow): CronJobState {
return {
// Keep unknown runtime fields from state_json while letting indexed columns
// win for fields that SQLite updates independently during hot-path writes.
...(asRecord(safeParseJson(row.state_json)) as CronJobState),
...(row.next_run_at_ms != null ? { nextRunAtMs: normalizeNumber(row.next_run_at_ms) } : {}),
...(row.running_at_ms != null ? { runningAtMs: normalizeNumber(row.running_at_ms) } : {}),
...(row.last_run_at_ms != null ? { lastRunAtMs: normalizeNumber(row.last_run_at_ms) } : {}),
...(row.last_run_status
? { lastRunStatus: row.last_run_status as CronJobState["lastRunStatus"] }
: {}),
...(row.last_error ? { lastError: row.last_error } : {}),
...(row.last_duration_ms != null
? { lastDurationMs: normalizeNumber(row.last_duration_ms) }
: {}),
...(row.consecutive_errors != null
? { consecutiveErrors: normalizeNumber(row.consecutive_errors) }
: {}),
...(row.consecutive_skipped != null
? { consecutiveSkipped: normalizeNumber(row.consecutive_skipped) }
: {}),
...(row.schedule_error_count != null
? { scheduleErrorCount: normalizeNumber(row.schedule_error_count) }
: {}),
...(row.last_delivery_status
? { lastDeliveryStatus: row.last_delivery_status as CronJobState["lastDeliveryStatus"] }
: {}),
...(row.last_delivery_error ? { lastDeliveryError: row.last_delivery_error } : {}),
...(row.last_delivered != null ? { lastDelivered: integerToBoolean(row.last_delivered) } : {}),
...(row.last_failure_alert_at_ms != null
? { lastFailureAlertAtMs: normalizeNumber(row.last_failure_alert_at_ms) }
: {}),
};
}
-12
View File
@@ -10,20 +10,8 @@ import {
replaceCronRows,
updateCronRuntimeRows,
} from "./row-codec.js";
import type { CronJobRow } from "./schema.js";
import { bindTriggerColumns, triggerFromRow } from "./trigger-codec.js";
describe("cron trigger SQLite codec", () => {
it("round-trips trigger columns", () => {
const columns = bindTriggerColumns({ script: "json({ fire: true })", once: true });
expect(columns).toEqual({ trigger_script: "json({ fire: true })", trigger_once: 1 });
expect(triggerFromRow(columns as CronJobRow)).toEqual({
script: "json({ fire: true })",
once: true,
});
expect(triggerFromRow(bindTriggerColumns(undefined) as CronJobRow)).toBeUndefined();
});
it("round-trips trigger state through updateCronRuntimeRows", async () => {
const job = {
id: "job-1",
-25
View File
@@ -1,25 +0,0 @@
/** SQLite column codec for cron trigger configuration. */
import type { CronTrigger } from "../types.js";
import { booleanToInteger, integerToBoolean } from "./scalar-codec.js";
import type { CronJobInsert, CronJobRow } from "./schema.js";
/** Maps cron trigger config into normalized SQLite columns. */
export function bindTriggerColumns(
trigger: CronTrigger | undefined,
): Pick<CronJobInsert, "trigger_script" | "trigger_once"> {
return {
trigger_script: trigger?.script ?? null,
trigger_once: booleanToInteger(trigger?.once),
};
}
/** Reconstructs trigger config from normalized SQLite columns. */
export function triggerFromRow(row: CronJobRow): CronTrigger | undefined {
if (!row.trigger_script) {
return undefined;
}
return {
script: row.trigger_script,
...(row.trigger_once != null ? { once: integerToBoolean(row.trigger_once) } : {}),
};
}
+1 -1
View File
@@ -474,7 +474,7 @@ export type CronJobState = {
lastFailureNotificationDeliveryError?: string;
};
export type CronTrigger = {
type CronTrigger = {
script: string;
once?: boolean;
};
@@ -156,7 +156,7 @@ describe("worker session placement moves", () => {
},
});
expect(begun.intent.operationId).toMatch(/^move:v1:[A-Za-z0-9_-]{43}$/u);
expect(database.db.prepare("PRAGMA user_version").get()).toEqual({ user_version: 12 });
expect(database.db.prepare("PRAGMA user_version").get()).toEqual({ user_version: 13 });
expect(store.getPlacementMove(SESSION.sessionId)).toEqual(begun.intent);
expect(store.getPlacementMoves([SESSION.sessionId, "missing"])).toEqual(
new Map([[SESSION.sessionId, begun.intent]]),
-41
View File
@@ -19,7 +19,6 @@ import {
storeOriginDeviceToken,
} from "./device-auth-store.js";
import { executeSqliteQuerySync, getNodeSqliteKysely } from "./kysely-sync.js";
import { requireNodeSqlite } from "./node-sqlite.js";
function createEnv(stateDir: string): NodeJS.ProcessEnv {
return {
@@ -89,46 +88,6 @@ describe("infra/device-auth-store", () => {
});
});
it("lazily adds origin-scoped tokens without changing the schema version", async () => {
await withTempDir("openclaw-device-auth-origin-", async (stateDir) => {
const env = createEnv(stateDir);
const databasePath = path.join(stateDir, "state", "openclaw.sqlite");
const opened = openOpenClawStateDatabase({ env });
const version = opened.db.prepare("PRAGMA user_version").get();
closeOpenClawStateDatabaseForTest();
const { DatabaseSync } = requireNodeSqlite();
const beforeEnsure = new DatabaseSync(databasePath);
beforeEnsure.exec("DROP TABLE gateway_origin_device_tokens;");
beforeEnsure.close();
const reopened = openOpenClawStateDatabase({ env });
expect(
reopened.db
.prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name = ?")
.get("gateway_origin_device_tokens"),
).toBeUndefined();
closeOpenClawStateDatabaseForTest();
expect(
loadOriginDeviceToken({
gatewayScope: "wss://one.example",
deviceId: "device-1",
role: "operator",
env,
}),
).toBeNull();
const afterEnsure = new DatabaseSync(databasePath, { readOnly: true });
expect(
afterEnsure
.prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name = ?")
.get("gateway_origin_device_tokens"),
).toEqual({ name: "gateway_origin_device_tokens" });
expect(afterEnsure.prepare("PRAGMA user_version").get()).toEqual(version);
afterEnsure.close();
});
});
it("never exposes a device token to a different gateway origin", async () => {
await withTempDir("openclaw-device-auth-origin-", async (stateDir) => {
const env = createEnv(stateDir);
+3 -33
View File
@@ -34,36 +34,6 @@ type DeviceAuthRow = {
// outcomes to keep reconnects free of freshness polling; Doctor invalidates
// the entry after its exclusive legacy import removes the retired file.
const legacyPresenceCache = new Map<string, boolean>();
const ensuredOriginDatabases = new WeakSet<DatabaseSync>();
const ORIGIN_DEVICE_AUTH_SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS gateway_origin_device_tokens (
gateway_scope TEXT NOT NULL,
device_id TEXT NOT NULL,
role TEXT NOT NULL,
token TEXT NOT NULL,
scopes_json TEXT NOT NULL,
updated_at_ms INTEGER NOT NULL,
PRIMARY KEY (gateway_scope, device_id, role)
) STRICT;
`;
function ensureOriginDeviceAuthSchema(env?: NodeJS.ProcessEnv): void {
assertNoLegacyDeviceAuth(env);
const options = env ? { env } : {};
const database = openOpenClawStateDatabase(options);
if (ensuredOriginDatabases.has(database.db)) {
return;
}
runOpenClawStateWriteTransaction(
({ db }) => {
// sqlite-allow-raw -- Feature-local additive schema DDL; token rows use Kysely.
db.exec(ORIGIN_DEVICE_AUTH_SCHEMA_SQL);
},
options,
{ operationLabel: "device-auth.origin.schema.ensure" },
);
ensuredOriginDatabases.add(database.db);
}
function assertNoLegacyDeviceAuth(env: NodeJS.ProcessEnv | undefined): void {
const stateDir = resolveStateDir(env);
@@ -284,7 +254,7 @@ export function loadOriginDeviceToken(params: {
role: string;
env?: NodeJS.ProcessEnv;
}): DeviceAuthEntry | null {
ensureOriginDeviceAuthSchema(params.env);
assertNoLegacyDeviceAuth(params.env);
const { db } = openOpenClawStateDatabase({ env: params.env });
return readOriginDeviceTokenFromDatabase(db, params);
}
@@ -317,7 +287,7 @@ export function storeOriginDeviceToken(params: {
env?: NodeJS.ProcessEnv;
expectedToken?: string;
}): DeviceAuthEntry | null {
ensureOriginDeviceAuthSchema(params.env);
assertNoLegacyDeviceAuth(params.env);
const entry = createDeviceAuthEntry(params);
let stored = false;
runOpenClawStateWriteTransaction(
@@ -374,7 +344,7 @@ export function clearOriginDeviceToken(params: {
env?: NodeJS.ProcessEnv;
expectedToken?: string;
}): boolean {
ensureOriginDeviceAuthSchema(params.env);
assertNoLegacyDeviceAuth(params.env);
let cleared = false;
runOpenClawStateWriteTransaction(
({ db }) => {
@@ -22,6 +22,8 @@ import {
const LEGACY_AUDIT_LOGICAL_PATHS = [
{ directory: "logs", basename: "config-audit.jsonl" },
// system-agent.jsonl never shipped in a stable, but beta installs that ran
// its import left backup artifacts this list must keep recognizing.
{ directory: "audit", basename: "system-agent.jsonl" },
{ directory: "audit", basename: "crestodian.jsonl" },
] as const;
+2
View File
@@ -201,6 +201,8 @@ function describeStateSchemaMigration(migration: OpenClawStateDatabaseSchemaMigr
return "retired skill curator tables → removed tables and indexes";
case "singleton-state-foldin-v12":
return "singleton state tables → shared configuration state";
case "state-consolidation-v13":
return "cron jobs and subagent runs → canonical JSON storage";
case "operator-approvals-system-agent":
return "operator approvals → OpenClaw system changes";
case "session-watch-cursor-provenance-v4":
@@ -1,122 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { readConfigMachineState, writeConfigMachineState } from "../state/config-machine-state.js";
import { createOnboardingRecommendationsStore } from "../state/onboarding-recommendations.js";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js";
import { migrateLegacyOnboardingRecommendationsScope } from "./state-migrations.onboarding-recommendations.js";
function insertRecommendationRow(params: {
database: { env: NodeJS.ProcessEnv };
configKey: string;
inventoryHash: string;
}): void {
writeConfigMachineState(
`onboarding.recommendations.${params.configKey}`,
{
inventoryHash: params.inventoryHash,
matches: [],
offeredAt: 1_000,
acceptedAt: 2_000,
updatedAt: 2_000,
},
params.database,
);
}
afterEach(() => {
closeOpenClawStateDatabaseForTest();
});
describe("onboarding recommendations scope migration", () => {
it("moves the legacy singleton row to the default workspace", async () => {
await withOpenClawTestState(
{ label: "onboarding-recommendations-migration" },
async (state) => {
const database = { env: state.env };
insertRecommendationRow({
database,
configKey: "primary",
inventoryHash: "legacy-inventory",
});
const result = migrateLegacyOnboardingRecommendationsScope({
cfg: {
agents: {
defaults: { workspace: state.workspaceDir },
entries: { main: { default: true } },
},
} as OpenClawConfig,
env: state.env,
});
expect(result).toEqual({
changes: [
"Migrated onboarding recommendation state to the legacy owner workspace scope.",
],
warnings: [],
});
expect(
createOnboardingRecommendationsStore({
workspaceDir: state.workspaceDir,
database,
}).read(),
).toEqual({
inventoryHash: "legacy-inventory",
matches: [],
offeredAt: 1_000,
acceptedAt: 2_000,
updatedAt: 2_000,
});
expect(
readConfigMachineState("onboarding.recommendations.primary", database),
).toBeUndefined();
},
);
});
it("keeps an existing scoped row when legacy state is also present", async () => {
await withOpenClawTestState(
{ label: "onboarding-recommendations-migration-conflict" },
async (state) => {
const database = { env: state.env };
const store = createOnboardingRecommendationsStore({
workspaceDir: state.workspaceDir,
database,
});
const scoped = store.writeOffer({
inventory: [{ label: "Scoped" }],
matches: [],
answered: false,
nowMs: 3_000,
});
insertRecommendationRow({
database,
configKey: "primary",
inventoryHash: "legacy-inventory",
});
const result = migrateLegacyOnboardingRecommendationsScope({
cfg: {
agents: {
defaults: { workspace: state.workspaceDir },
entries: { main: { default: true } },
},
} as OpenClawConfig,
env: state.env,
});
expect(result).toEqual({
changes: [
"Removed ambiguous legacy onboarding recommendation state; kept the legacy owner workspace record.",
],
warnings: [],
});
expect(store.read()).toEqual(scoped);
expect(
readConfigMachineState("onboarding.recommendations.primary", database),
).toBeUndefined();
},
);
});
});
@@ -1,113 +0,0 @@
import { existsSync } from "node:fs";
import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
import { resolveWorkspaceStateIdentity } from "../agents/workspace-state-identity.js";
import type { OpenClawConfig } from "../config/config.js";
import { tryResolveLegacyCompatibilityAgentId } from "../config/legacy.default-agent-owner.js";
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
import { runOpenClawStateWriteTransaction } from "../state/openclaw-state-db.js";
import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js";
import {
executeSqliteQuerySync,
executeSqliteQueryTakeFirstSync,
getNodeSqliteKysely,
} from "./kysely-sync.js";
import type { MigrationMessages } from "./state-migrations.types.js";
const LEGACY_ONBOARDING_RECOMMENDATIONS_KEY = "onboarding.recommendations.primary";
type OnboardingRecommendationsMigrationDatabase = Pick<
OpenClawStateKyselyDatabase,
"config_machine_state"
>;
/** Move the shipped singleton row into the default workspace during doctor repair. */
export function migrateLegacyOnboardingRecommendationsScope(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): MigrationMessages {
const env = params.env ?? process.env;
if (!existsSync(resolveOpenClawStateSqlitePath(env))) {
return { changes: [], warnings: [] };
}
try {
const migrationAgentId = tryResolveLegacyCompatibilityAgentId(params.cfg);
const workspaceKey = migrationAgentId
? resolveWorkspaceStateIdentity(resolveAgentWorkspaceDir(params.cfg, migrationAgentId, env))
.workspaceKey
: undefined;
const scopedKey = workspaceKey ? `onboarding.recommendations.${workspaceKey}` : undefined;
const outcome = runOpenClawStateWriteTransaction(
({ db: writeDatabase }) => {
const writeDb =
getNodeSqliteKysely<OnboardingRecommendationsMigrationDatabase>(writeDatabase);
const legacyAtCommit = executeSqliteQueryTakeFirstSync(
writeDatabase,
writeDb
.selectFrom("config_machine_state")
.select("state_key")
.where("state_key", "=", LEGACY_ONBOARDING_RECOMMENDATIONS_KEY),
);
if (!legacyAtCommit) {
return "unchanged" as const;
}
if (!scopedKey) {
return "deferred" as const;
}
const scoped = executeSqliteQueryTakeFirstSync(
writeDatabase,
writeDb
.selectFrom("config_machine_state")
.select("state_key")
.where("state_key", "=", scopedKey),
);
if (scoped) {
executeSqliteQuerySync(
writeDatabase,
writeDb
.deleteFrom("config_machine_state")
.where("state_key", "=", LEGACY_ONBOARDING_RECOMMENDATIONS_KEY),
);
return "removed-legacy" as const;
}
executeSqliteQuerySync(
writeDatabase,
writeDb
.updateTable("config_machine_state")
.set({ state_key: scopedKey })
.where("state_key", "=", LEGACY_ONBOARDING_RECOMMENDATIONS_KEY),
);
return "migrated" as const;
},
{ env },
{ operationLabel: "onboarding.recommendations.migrate-scope" },
);
if (outcome === "migrated") {
return {
changes: ["Migrated onboarding recommendation state to the legacy owner workspace scope."],
warnings: [],
};
}
if (outcome === "removed-legacy") {
return {
changes: [
"Removed ambiguous legacy onboarding recommendation state; kept the legacy owner workspace record.",
],
warnings: [],
};
}
if (outcome === "deferred") {
return {
changes: [],
warnings: ["Deferred legacy onboarding recommendation migration: no owner is selected"],
};
}
return { changes: [], warnings: [] };
} catch (err) {
return {
changes: [],
warnings: [`Failed migrating onboarding recommendation workspace scope: ${String(err)}`],
};
}
}
@@ -154,14 +154,18 @@ describe("shared auth store relocation", () => {
const database = fixture.stateDb.openOpenClawStateDatabase({ env: fixture.env }).db;
expect(
database
.prepare("SELECT store_key, store_json FROM auth_profile_stores WHERE store_key = 'shared'")
.prepare(
"SELECT value_json FROM config_machine_state WHERE state_key = 'authProfiles.store'",
)
.get(),
).toEqual({ store_key: "shared", store_json: JSON.stringify(fixture.sharedStore) });
).toEqual({ value_json: JSON.stringify(fixture.sharedStore) });
expect(
database
.prepare("SELECT store_key, state_json FROM auth_profile_state WHERE store_key = 'shared'")
.prepare(
"SELECT value_json FROM config_machine_state WHERE state_key = 'authProfiles.state'",
)
.get(),
).toEqual({ store_key: "shared", state_json: JSON.stringify(fixture.sharedState) });
).toEqual({ value_json: JSON.stringify(fixture.sharedState) });
expect(
database
.prepare("SELECT COUNT(*) AS count FROM migration_sources WHERE migration_kind = ?")
@@ -196,10 +200,10 @@ describe("shared auth store relocation", () => {
.get() as { state_json: string; updated_at: number };
const target = fixture.stateDb.openOpenClawStateDatabase({ env: fixture.env }).db;
target
.prepare("INSERT INTO auth_profile_stores VALUES ('shared', ?, ?)")
.prepare("INSERT INTO config_machine_state VALUES ('authProfiles.store', ?, ?)")
.run(sourceStore.store_json, sourceStore.updated_at);
target
.prepare("INSERT INTO auth_profile_state VALUES ('shared', ?, ?)")
.prepare("INSERT INTO config_machine_state VALUES ('authProfiles.state', ?, ?)")
.run(sourceState.state_json, sourceState.updated_at);
if (
crashState === "copied-source-empty-not-flipped" ||
@@ -267,12 +271,22 @@ describe("shared auth store relocation", () => {
location: "state-db",
});
expect(retry).toEqual({ changes: [], warnings: [] });
expect(target.prepare("SELECT COUNT(*) AS count FROM auth_profile_stores").get()).toEqual({
count: 1,
});
expect(target.prepare("SELECT COUNT(*) AS count FROM auth_profile_state").get()).toEqual({
count: 1,
});
expect(
target
.prepare(
`SELECT COUNT(*) AS count FROM config_machine_state
WHERE state_key = 'authProfiles.store'`,
)
.get(),
).toEqual({ count: 1 });
expect(
target
.prepare(
`SELECT COUNT(*) AS count FROM config_machine_state
WHERE state_key = 'authProfiles.state'`,
)
.get(),
).toEqual({ count: 1 });
const cleanedSource = new DatabaseSync(sourcePath, { readOnly: true });
expect(
cleanedSource
+40 -25
View File
@@ -50,11 +50,7 @@ type SourceAuthDatabase = Pick<
>;
type SharedAuthMigrationDatabase = Pick<
OpenClawStateKyselyDatabase,
| "auth_profile_stores"
| "auth_profile_state"
| "config_machine_state"
| "migration_runs"
| "migration_sources"
"config_machine_state" | "migration_runs" | "migration_sources"
>;
type MigrationStage = "copied" | "ownership-flipped" | "completed";
@@ -96,25 +92,44 @@ function readSourceSnapshot(params: { env: NodeJS.ProcessEnv; sourcePath: string
function readTargetRows(database: DatabaseSync): AuthRows {
const db = getNodeSqliteKysely<SharedAuthMigrationDatabase>(database);
return {
// Shared auth payloads live in config_machine_state; project the KV cell
// back to the historical row shape so digest/row-match verification and
// persisted receipts stay byte-compatible.
store:
executeSqliteQueryTakeFirstSync(
database,
db
.selectFrom("auth_profile_stores")
.select(["store_json", "updated_at"])
.where("store_key", "=", TARGET_STORE_KEY),
projectStoreRow(
executeSqliteQueryTakeFirstSync(
database,
db
.selectFrom("config_machine_state")
.select(["value_json", "updated_at_ms"])
.where("state_key", "=", "authProfiles.store"),
),
) ?? null,
state:
executeSqliteQueryTakeFirstSync(
database,
db
.selectFrom("auth_profile_state")
.select(["state_json", "updated_at"])
.where("store_key", "=", TARGET_STORE_KEY),
projectStateRow(
executeSqliteQueryTakeFirstSync(
database,
db
.selectFrom("config_machine_state")
.select(["value_json", "updated_at_ms"])
.where("state_key", "=", "authProfiles.state"),
),
) ?? null,
};
}
function projectStoreRow(
row: { value_json: string; updated_at_ms: number } | undefined,
): StoreRow | null {
return row ? { store_json: row.value_json, updated_at: row.updated_at_ms } : null;
}
function projectStateRow(
row: { value_json: string; updated_at_ms: number } | undefined,
): StateRow | null {
return row ? { state_json: row.value_json, updated_at: row.updated_at_ms } : null;
}
function rowDigest(row: StoreRow | StateRow | null): string {
return createHash("sha256").update(JSON.stringify(row)).digest("hex");
}
@@ -312,20 +327,20 @@ function copyRowsToState(params: {
if (params.sourceRows.store && !target.store) {
executeSqliteQuerySync(
database,
db.insertInto("auth_profile_stores").values({
store_key: TARGET_STORE_KEY,
store_json: params.sourceRows.store.store_json,
updated_at: params.sourceRows.store.updated_at,
db.insertInto("config_machine_state").values({
state_key: "authProfiles.store",
value_json: params.sourceRows.store.store_json,
updated_at_ms: params.sourceRows.store.updated_at,
}),
);
}
if (params.sourceRows.state && !target.state) {
executeSqliteQuerySync(
database,
db.insertInto("auth_profile_state").values({
store_key: TARGET_STORE_KEY,
state_json: params.sourceRows.state.state_json,
updated_at: params.sourceRows.state.updated_at,
db.insertInto("config_machine_state").values({
state_key: "authProfiles.state",
value_json: params.sourceRows.state.state_json,
updated_at_ms: params.sourceRows.state.updated_at,
}),
);
}
+14 -15
View File
@@ -179,21 +179,20 @@ describe("legacy state dir auto-migration", () => {
"utf8",
);
const installRecordsJson = '{"__proto__":{"source":"bogus"}}';
// Built by string concatenation so the "__proto__" key survives as JSON
// text instead of mutating a JS object prototype during serialization.
const persistedValueJson =
'{"revision":123,"index":{"version":1,"hostContractVersion":"test",' +
'"compatRegistryVersion":"test","migrationVersion":1,"policyHash":"test",' +
`"generatedAtMs":1,"installRecords":${installRecordsJson},"plugins":[],"diagnostics":[]}}`;
runOpenClawStateWriteTransaction(
({ db }) => {
db.prepare(
`
INSERT OR REPLACE INTO installed_plugin_index (
index_key, version, host_contract_version, compat_registry_version,
migration_version, policy_hash, generated_at_ms, refresh_reason,
install_records_json, plugins_json, diagnostics_json, warning, updated_at_ms
) VALUES (
'installed-plugin-index', 1, 'test', 'test',
1, 'test', 1, NULL,
?, '[]', '[]', NULL, 123
)
INSERT OR REPLACE INTO config_machine_state (state_key, value_json, updated_at_ms)
VALUES ('plugins.installedIndex', ?, 123)
`,
).run(installRecordsJson);
).run(persistedValueJson);
},
{ env: { ...process.env, OPENCLAW_STATE_DIR: stateDir } },
);
@@ -213,14 +212,14 @@ describe("legacy state dir auto-migration", () => {
({ db }) =>
db
.prepare(
`SELECT install_records_json, updated_at_ms
FROM installed_plugin_index
WHERE index_key = 'installed-plugin-index'`,
`SELECT value_json, updated_at_ms
FROM config_machine_state
WHERE state_key = 'plugins.installedIndex'`,
)
.get() as { install_records_json: string; updated_at_ms: number | bigint },
.get() as { value_json: string; updated_at_ms: number | bigint },
{ env: { ...process.env, OPENCLAW_STATE_DIR: stateDir } },
);
expect(row).toEqual({ install_records_json: installRecordsJson, updated_at_ms: 123 });
expect(row).toEqual({ value_json: persistedValueJson, updated_at_ms: 123 });
});
});
@@ -77,9 +77,6 @@ describe("legacy subagent registry Doctor migration", () => {
run_id: run.runId,
child_session_key: run.childSessionKey,
requester_session_key: run.requesterSessionKey,
requester_display_key: run.requesterDisplayKey,
task: run.task,
cleanup: run.cleanup,
created_at: run.createdAt,
payload_json: JSON.stringify(run),
}),
@@ -96,7 +96,7 @@ describe("recreated legacy workspace state migration", () => {
const db = openOpenClawStateDatabase({ env: context.env }).db;
expect(
db
.prepare("SELECT attested_at_ms FROM workspace_attestations WHERE workspace_key = ?")
.prepare("SELECT attested_at_ms FROM workspace_setup_state WHERE workspace_key = ?")
.get(identity.workspaceKey),
).toEqual({ attested_at_ms: recreatedMtime.getTime() });
expect(
@@ -144,7 +144,7 @@ describe("recreated legacy workspace state migration", () => {
const db = openOpenClawStateDatabase({ env: context.env }).db;
expect(
db
.prepare("SELECT attested_at_ms FROM workspace_attestations WHERE workspace_key = ?")
.prepare("SELECT attested_at_ms FROM workspace_setup_state WHERE workspace_key = ?")
.get(identity.workspaceKey),
).toEqual({ attested_at_ms: originalMtime.getTime() });
expect(
@@ -32,7 +32,6 @@ type WorkspaceMigrationDatabase = Pick<
OpenClawStateKyselyDatabase,
| "workspace_setup_state"
| "workspace_path_aliases"
| "workspace_attestations"
| "workspace_generated_bootstrap_hashes"
| "migration_sources"
>;
@@ -225,6 +224,9 @@ function findMigrationAuthority(params: {
.where(
"target_table",
"=",
// "workspace_attestations" is a receipt discriminator, not a live table:
// installs that migrated before the v13 merge persisted it, so both
// read and write sides keep the historical value.
params.source.kind === "setup" ? "workspace_setup_state" : "workspace_attestations",
),
).rows;
@@ -302,11 +304,11 @@ export function canonicalCoversParsedSource(params: {
const row = executeSqliteQueryTakeFirstSync(
db,
kysely
.selectFrom("workspace_attestations")
.selectFrom("workspace_setup_state")
.select("attested_at_ms")
.where("workspace_key", "=", params.source.workspaceKey),
);
if (!row) {
if (!row || row.attested_at_ms == null) {
return false;
}
if (row.attested_at_ms > params.parsed.value.attestedAtMs) {
@@ -375,7 +377,7 @@ export function importAndRecordReceipt(params: {
.selectAll()
.where("workspace_key", "=", params.source.workspaceKey),
);
if (existing) {
if (existing && existing.version != null) {
if (
existing.workspace_path !== params.source.workspaceDir ||
existing.version !== WORKSPACE_SETUP_STATE_VERSION
@@ -454,19 +456,30 @@ export function importAndRecordReceipt(params: {
verifiedFingerprint = existingFingerprint;
}
} else {
// Missing row, or an attestation-only merged row (NULL version) that
// adopts the legacy setup facts; a differing recorded path conflicts.
if (
existing?.workspace_path != null &&
existing.workspace_path !== params.source.workspaceDir
) {
throw new Error("legacy workspace setup conflicts with canonical SQLite state");
}
const setupColumns = {
workspace_path: params.source.workspaceDir,
version: WORKSPACE_SETUP_STATE_VERSION,
bootstrap_seeded_at: params.parsed.value.bootstrapSeededAt ?? null,
setup_completed_at: params.parsed.value.setupCompletedAt ?? null,
updated_at: now,
};
executeSqliteQuerySync(
db,
kysely.insertInto("workspace_setup_state").values({
workspace_key: params.source.workspaceKey,
workspace_path: params.source.workspaceDir,
version: WORKSPACE_SETUP_STATE_VERSION,
bootstrap_seeded_at: params.parsed.value.bootstrapSeededAt ?? null,
setup_completed_at: params.parsed.value.setupCompletedAt ?? null,
updated_at: now,
}),
kysely
.insertInto("workspace_setup_state")
.values({ workspace_key: params.source.workspaceKey, ...setupColumns })
.onConflict((conflict) => conflict.column("workspace_key").doUpdateSet(setupColumns)),
);
imported = true;
resolution = "inserted";
resolution = existing ? "merged" : "inserted";
verifiedFingerprint = incomingFingerprint;
}
const verified = executeSqliteQueryTakeFirstSync(
@@ -476,13 +489,16 @@ export function importAndRecordReceipt(params: {
.selectAll()
.where("workspace_key", "=", params.source.workspaceKey),
);
const actualFingerprint = verified
? setupFingerprint({
workspacePath: verified.workspace_path,
bootstrapSeededAt: verified.bootstrap_seeded_at,
setupCompletedAt: verified.setup_completed_at,
})
: null;
// Every setup import branch writes the source path, so a NULL path
// here is a verification failure, not an attestation-only row.
const actualFingerprint =
verified && verified.workspace_path != null
? setupFingerprint({
workspacePath: verified.workspace_path,
bootstrapSeededAt: verified.bootstrap_seeded_at,
setupCompletedAt: verified.setup_completed_at,
})
: null;
if (!verified || actualFingerprint !== verifiedFingerprint) {
throw new Error("SQLite verification failed for workspace setup state");
}
@@ -492,13 +508,17 @@ export function importAndRecordReceipt(params: {
attestedAtMs: parsedAttestation.attestedAtMs,
generatedHashes: parsedAttestation.generatedHashes,
});
const existing = executeSqliteQueryTakeFirstSync(
const existingRow = executeSqliteQueryTakeFirstSync(
db,
kysely
.selectFrom("workspace_attestations")
.selectFrom("workspace_setup_state")
.selectAll()
.where("workspace_key", "=", params.source.workspaceKey),
);
const existing =
existingRow && existingRow.attested_at_ms != null
? { attested_at_ms: existingRow.attested_at_ms }
: null;
if (existing) {
const rows = executeSqliteQuerySync(
db,
@@ -516,10 +536,10 @@ export function importAndRecordReceipt(params: {
executeSqliteQuerySync(
db,
kysely
.updateTable("workspace_attestations")
.updateTable("workspace_setup_state")
.set({
attested_at_ms: parsedAttestation.attestedAtMs,
updated_at_ms: now,
attestation_updated_at_ms: now,
})
.where("workspace_key", "=", params.source.workspaceKey),
);
@@ -584,11 +604,22 @@ export function importAndRecordReceipt(params: {
} else {
executeSqliteQuerySync(
db,
kysely.insertInto("workspace_attestations").values({
workspace_key: params.source.workspaceKey,
attested_at_ms: parsedAttestation.attestedAtMs,
updated_at_ms: now,
}),
kysely
.insertInto("workspace_setup_state")
.values({
workspace_key: params.source.workspaceKey,
// Orphan hashed-key attestation files carry no path; the row
// heals its NULL path when the workspace next appears live.
workspace_path: params.source.workspaceDir ?? null,
attested_at_ms: parsedAttestation.attestedAtMs,
attestation_updated_at_ms: now,
})
.onConflict((conflict) =>
conflict.column("workspace_key").doUpdateSet({
attested_at_ms: parsedAttestation.attestedAtMs,
attestation_updated_at_ms: now,
}),
),
);
const hashes = [...parsedAttestation.generatedHashes.entries()].toSorted(([a], [b]) =>
a.localeCompare(b),
@@ -609,13 +640,17 @@ export function importAndRecordReceipt(params: {
resolution = "inserted";
verifiedFingerprint = incomingFingerprint;
}
const verified = executeSqliteQueryTakeFirstSync(
const verifiedRow = executeSqliteQueryTakeFirstSync(
db,
kysely
.selectFrom("workspace_attestations")
.selectFrom("workspace_setup_state")
.select("attested_at_ms")
.where("workspace_key", "=", params.source.workspaceKey),
);
const verified =
verifiedRow && verifiedRow.attested_at_ms != null
? { attested_at_ms: verifiedRow.attested_at_ms }
: null;
const verifiedHashes = new Map(
executeSqliteQuerySync(
db,
@@ -105,7 +105,7 @@ describe("legacy workspace Doctor migration", () => {
});
expect(
db
.prepare("SELECT attested_at_ms FROM workspace_attestations WHERE workspace_key = ?")
.prepare("SELECT attested_at_ms FROM workspace_setup_state WHERE workspace_key = ?")
.get(identity.workspaceKey),
).toEqual({ attested_at_ms: mtime.getTime() });
expect(
@@ -265,7 +265,7 @@ describe("legacy workspace Doctor migration", () => {
expect(
db
.prepare(
"SELECT workspace_key, attested_at_ms FROM workspace_attestations ORDER BY workspace_key",
"SELECT workspace_key, attested_at_ms FROM workspace_setup_state ORDER BY workspace_key",
)
.all(),
).toEqual(
@@ -330,7 +330,7 @@ describe("legacy workspace Doctor migration", () => {
expect(fs.existsSync(attestationPath)).toBe(true);
expect(fs.existsSync(`${attestationPath}.doctor-importing`)).toBe(false);
const db = openOpenClawStateDatabase({ env: context.env }).db;
expect(db.prepare("SELECT COUNT(*) AS count FROM workspace_attestations").get()).toEqual({
expect(db.prepare("SELECT COUNT(*) AS count FROM workspace_setup_state").get()).toEqual({
count: 0,
});
expect(db.prepare("SELECT COUNT(*) AS count FROM migration_sources").get()).toEqual({
@@ -688,7 +688,7 @@ describe("legacy workspace Doctor migration", () => {
expect(fs.existsSync(`${externalSource}.doctor-importing`)).toBe(false);
expect(
openOpenClawStateDatabase({ env: context.env })
.db.prepare("SELECT workspace_key FROM workspace_attestations WHERE workspace_key = ?")
.db.prepare("SELECT workspace_key FROM workspace_setup_state WHERE workspace_key = ?")
.get(identity.workspaceKey),
).toBeUndefined();
});
@@ -907,7 +907,7 @@ describe("legacy workspace Doctor migration", () => {
expect(fs.existsSync(claimPath)).toBe(true);
expect(
openOpenClawStateDatabase({ env: context.env })
.db.prepare("SELECT attested_at_ms FROM workspace_attestations WHERE workspace_key = ?")
.db.prepare("SELECT attested_at_ms FROM workspace_setup_state WHERE workspace_key = ?")
.get(identity.workspaceKey),
).toEqual({ attested_at_ms: originalMtime.getTime() });
});
@@ -312,7 +312,7 @@ describe("node worker launch store container identity", () => {
expect(new NodeWorkerLaunchStore({ env }).get("container-launch")).toEqual(receipt);
});
it("lets the exact v12 predecessor read and write a populated candidate container journal before candidate reopen", () => {
it("lets the exact v13 predecessor read and write a populated candidate container journal before candidate reopen", () => {
const { database, env, store } = fixture();
const databasePath = openOpenClawStateDatabase({ env }).path;
const { planHash, supervisor } = claimLaunch(store, "candidate-container-launch");
@@ -330,8 +330,8 @@ describe("node worker launch store container identity", () => {
nowMs: NOW_MS,
});
expect(hasContainerIdentityTable(database)).toBe(true);
expect(OPENCLAW_STATE_SCHEMA_VERSION).toBe(12);
expect(database.prepare("PRAGMA user_version").get()).toEqual({ user_version: 12 });
expect(OPENCLAW_STATE_SCHEMA_VERSION).toBe(13);
expect(database.prepare("PRAGMA user_version").get()).toEqual({ user_version: 13 });
closeOpenClawStateDatabaseForTest();
const companionStart = OPENCLAW_STATE_SCHEMA_SQL.indexOf(
@@ -359,11 +359,11 @@ describe("node worker launch store container identity", () => {
const predecessor = new DatabaseSync(databasePath);
try {
expect(predecessor.prepare("PRAGMA user_version").get()).toEqual({ user_version: 12 });
expect(predecessor.prepare("PRAGMA user_version").get()).toEqual({ user_version: 13 });
expect(() =>
assertSqliteSchemaContains(
predecessor,
"predecessor v12 global schema",
"predecessor v13 global schema",
predecessorSchema,
predecessorCompatibility,
),
@@ -10,10 +10,6 @@ import {
type InstalledPluginIndexStoreOptions,
} from "./installed-plugin-index-store-path.js";
type InstalledPluginIndexRecordRow = {
install_records_json: string;
};
export function inspectPersistedInstalledPluginIndexInstallRecordsSync(
options: InstalledPluginIndexStoreOptions = {},
): PluginInstallRecordMapState {
@@ -27,26 +23,26 @@ export function inspectPersistedInstalledPluginIndexInstallRecordsSync(
.prepare(
`SELECT 1
FROM sqlite_master
WHERE type = 'table' AND name = 'installed_plugin_index'`,
WHERE type = 'table' AND name = 'config_machine_state'`,
)
.get();
if (!hasTable) {
return { status: "missing" };
}
const row = db
.prepare(
`
SELECT install_records_json
FROM installed_plugin_index
WHERE index_key = ?
`,
)
.get("installed-plugin-index") as InstalledPluginIndexRecordRow | undefined;
.prepare("SELECT value_json FROM config_machine_state WHERE state_key = ?")
// SAFETY: config_machine_state.value_json is TEXT NOT NULL under STRICT.
.get("plugins.installedIndex") as { value_json: string } | undefined;
if (!row) {
return { status: "missing" };
}
const parsed = safeParseJson(row.install_records_json);
return parsed === undefined ? { status: "invalid" } : inspectPluginInstallRecordMap(parsed);
const value = safeParseJson(row.value_json) as
| { index?: { installRecords?: unknown } }
| undefined;
const installRecords = value?.index?.installRecords;
return installRecords === undefined
? { status: "invalid" }
: inspectPluginInstallRecordMap(installRecords);
}, resolveInstalledPluginIndexStateDatabaseOptions(options)) ?? { status: "missing" }
);
} catch (error) {
@@ -81,14 +81,19 @@ function updatePersistedInstallRecordsWithoutClearingCache(
) {
runOpenClawStateWriteTransaction(
({ db }) => {
const now = Date.now();
db.prepare(
`
UPDATE installed_plugin_index
SET install_records_json = ?,
UPDATE config_machine_state
SET value_json = json_set(
value_json,
'$.index.installRecords', json(?),
'$.revision', ?
),
updated_at_ms = ?
WHERE index_key = 'installed-plugin-index'
WHERE state_key = 'plugins.installedIndex'
`,
).run(JSON.stringify(records), Date.now());
).run(JSON.stringify(records), now, now);
},
{ env: { ...process.env, OPENCLAW_STATE_DIR: stateDir } },
);
@@ -41,18 +41,18 @@ function createIndex(installRecords: InstalledPluginIndex["installRecords"]): In
}
function readInstallRecordRow(stateDir: string): {
install_records_json: string;
value_json: string;
updated_at_ms: number | bigint;
} {
return runOpenClawStateWriteTransaction(
({ db }) =>
db
.prepare(
`SELECT install_records_json, updated_at_ms
FROM installed_plugin_index
WHERE index_key = 'installed-plugin-index'`,
`SELECT value_json, updated_at_ms
FROM config_machine_state
WHERE state_key = 'plugins.installedIndex'`,
)
.get() as { install_records_json: string; updated_at_ms: number | bigint },
.get() as { value_json: string; updated_at_ms: number | bigint },
{ env: { ...process.env, OPENCLAW_STATE_DIR: stateDir } },
);
}
@@ -134,8 +134,11 @@ describe("installed plugin index install-record persistence", () => {
source: "npm",
futureMetadata: { retained: true },
});
expect(readInstallRecordRow(stateDir).install_records_json).toBe(
'{"1":{"source":"archive"},"10":{"source":"path"},"2":{"source":"npm","futureMetadata":{"retained":true}},"\uE000":{"source":"path"},"\u{10000}":{"source":"git"}}',
// The persisted value_json embeds the UTF-8 byte-order serialization as a
// JSON object, so JS object semantics hoist integer-like ids numerically
// while the remaining ids keep their byte-order position deterministically.
expect(readInstallRecordRow(stateDir).value_json).toContain(
'"installRecords":{"1":{"source":"archive"},"2":{"source":"npm","futureMetadata":{"retained":true}},"10":{"source":"path"},"\uE000":{"source":"path"},"\u{10000}":{"source":"git"}}',
);
});
});
@@ -195,31 +195,27 @@ function insertPersistedIndexRow(
pluginsJson?: string;
diagnosticsJson?: string;
},
) {
): string {
// Built by string concatenation so raw JSON fixtures (including "__proto__"
// keys) land in value_json verbatim instead of round-tripping JS objects.
const valueJson =
`{"revision":123,"index":{"version":${values.version ?? 1},` +
'"hostContractVersion":"2026.4.25","compatRegistryVersion":"compat-v1",' +
`"migrationVersion":${values.migrationVersion ?? 1},"policyHash":"policy-hash",` +
`"generatedAtMs":123,"installRecords":${values.installRecordsJson ?? "{}"},` +
`"plugins":${values.pluginsJson ?? "[]"},"diagnostics":${values.diagnosticsJson ?? "[]"}}}`;
runOpenClawStateWriteTransaction(
({ db }) => {
db.prepare(
`
INSERT OR REPLACE INTO installed_plugin_index (
index_key, version, host_contract_version, compat_registry_version,
migration_version, policy_hash, generated_at_ms, refresh_reason,
install_records_json, plugins_json, diagnostics_json, warning, updated_at_ms
) VALUES (
'installed-plugin-index', @version, '2026.4.25', 'compat-v1',
@migration_version, 'policy-hash', 123, NULL,
@install_records_json, @plugins_json, @diagnostics_json, NULL, 123
)
INSERT OR REPLACE INTO config_machine_state (state_key, value_json, updated_at_ms)
VALUES ('plugins.installedIndex', ?, 123)
`,
).run({
version: values.version ?? 1,
migration_version: values.migrationVersion ?? 1,
install_records_json: values.installRecordsJson ?? "{}",
plugins_json: values.pluginsJson ?? "[]",
diagnostics_json: values.diagnosticsJson ?? "[]",
});
).run(valueJson);
},
{ env: { ...process.env, OPENCLAW_STATE_DIR: stateDir } },
);
return valueJson;
}
function readPersistedIndexRevision(stateDir: string): number | null {
@@ -228,13 +224,17 @@ function readPersistedIndexRevision(stateDir: string): number | null {
const row = db
.prepare(
`
SELECT updated_at_ms
FROM installed_plugin_index
WHERE index_key = 'installed-plugin-index'
SELECT value_json
FROM config_machine_state
WHERE state_key = 'plugins.installedIndex'
`,
)
.get() as { updated_at_ms: number | bigint } | undefined;
return row ? Number(row.updated_at_ms) : null;
.get() as { value_json: string } | undefined;
if (!row) {
return null;
}
const revision = (JSON.parse(row.value_json) as { revision?: unknown }).revision;
return typeof revision === "number" ? revision : null;
},
{ env: { ...process.env, OPENCLAW_STATE_DIR: stateDir } },
);
@@ -691,7 +691,7 @@ describe("installed plugin index persistence", () => {
it("does not allocate a revision or rewrite an invalid predecessor", async () => {
const stateDir = makeTempDir();
const installRecordsJson = '{"__proto__":{"source":"bogus"}}';
insertPersistedIndexRow(stateDir, { installRecordsJson });
const persistedValueJson = insertPersistedIndexRow(stateDir, { installRecordsJson });
await expect(writePersistedInstalledPluginIndex(createIndex(), { stateDir })).rejects.toThrow(
"Persisted plugin install records are invalid",
@@ -700,14 +700,14 @@ describe("installed plugin index persistence", () => {
({ db }) =>
db
.prepare(
`SELECT install_records_json, updated_at_ms
FROM installed_plugin_index
WHERE index_key = 'installed-plugin-index'`,
`SELECT value_json, updated_at_ms
FROM config_machine_state
WHERE state_key = 'plugins.installedIndex'`,
)
.get() as { install_records_json: string; updated_at_ms: number | bigint },
.get() as { value_json: string; updated_at_ms: number | bigint },
{ env: { ...process.env, OPENCLAW_STATE_DIR: stateDir } },
);
expect(row).toEqual({ install_records_json: installRecordsJson, updated_at_ms: 123 });
expect(row).toEqual({ value_json: persistedValueJson, updated_at_ms: 123 });
});
it("returns null for missing or invalid persisted indexes", async () => {
+66 -104
View File
@@ -64,7 +64,7 @@ export type InstalledPluginIndexWriteReceipt = {
};
const StringArraySchema = z.array(z.string());
const INSTALLED_PLUGIN_INDEX_SQLITE_KEY = "installed-plugin-index";
const INSTALLED_PLUGIN_INDEX_STATE_KEY = "plugins.installedIndex";
const InstalledPluginIndexStartupSchema = z.object({
sidecar: z.boolean(),
@@ -195,20 +195,9 @@ export function parseInstalledPluginIndex(value: unknown): InstalledPluginIndex
};
}
type InstalledPluginIndexSqliteRow = {
version: number | bigint;
warning: string | null;
host_contract_version: string;
compat_registry_version: string;
migration_version: number | bigint;
policy_hash: string;
generated_at_ms: number | bigint;
workspace_dir: string | null;
refresh_reason: string | null;
install_records_json: string;
plugins_json: string;
diagnostics_json: string;
updated_at_ms: number | bigint;
type PersistedInstalledPluginIndexValue = {
revision: number;
index: unknown;
};
function assertWritableInstalledPluginIndexStoreOptions(
@@ -222,25 +211,9 @@ function assertWritableInstalledPluginIndexStoreOptions(
}
function parseInstalledPluginIndexSqliteRow(
row: InstalledPluginIndexSqliteRow | undefined,
value: PersistedInstalledPluginIndexValue | undefined,
): InstalledPluginIndex | null {
if (!row) {
return null;
}
return parseInstalledPluginIndex({
version: Number(row.version),
...(row.warning ? { warning: row.warning } : {}),
hostContractVersion: row.host_contract_version,
compatRegistryVersion: row.compat_registry_version,
migrationVersion: Number(row.migration_version),
policyHash: row.policy_hash,
generatedAtMs: Number(row.generated_at_ms),
...(row.workspace_dir !== null ? { workspaceDir: row.workspace_dir } : {}),
...(row.refresh_reason ? { refreshReason: row.refresh_reason } : {}),
installRecords: safeParseJson(row.install_records_json),
plugins: safeParseJson(row.plugins_json),
diagnostics: safeParseJson(row.diagnostics_json),
});
return value ? parseInstalledPluginIndex(value.index) : null;
}
function preparePersistedInstalledPluginIndex(index: InstalledPluginIndex): InstalledPluginIndex {
@@ -261,19 +234,25 @@ function preparePersistedInstalledPluginIndex(index: InstalledPluginIndex): Inst
function readInstalledPluginIndexRow(
database: DatabaseSync,
): InstalledPluginIndexSqliteRow | undefined {
return database
.prepare(
`
SELECT version, warning, host_contract_version, compat_registry_version,
migration_version, policy_hash, generated_at_ms, workspace_dir,
refresh_reason,
install_records_json, plugins_json, diagnostics_json, updated_at_ms
FROM installed_plugin_index
WHERE index_key = ?
`,
)
.get(INSTALLED_PLUGIN_INDEX_SQLITE_KEY) as InstalledPluginIndexSqliteRow | undefined;
): PersistedInstalledPluginIndexValue | undefined {
const row = database
.prepare("SELECT value_json FROM config_machine_state WHERE state_key = ?")
// SAFETY: config_machine_state.value_json is TEXT NOT NULL under STRICT.
.get(INSTALLED_PLUGIN_INDEX_STATE_KEY) as { value_json: string } | undefined;
if (!row) {
return undefined;
}
const value = safeParseJson(row.value_json);
if (
!value ||
typeof value !== "object" ||
// SAFETY: shape-checked field probe; the full value is validated below.
typeof (value as PersistedInstalledPluginIndexValue).revision !== "number"
) {
return undefined;
}
// SAFETY: revision checked above; index stays unknown until parseInstalledPluginIndex.
return value as PersistedInstalledPluginIndexValue;
}
function resolveNextInstalledPluginIndexRevision(current: number | null): number {
@@ -287,61 +266,45 @@ function writePersistedInstalledPluginIndexRow(
index: InstalledPluginIndex,
revision: number,
): void {
const persistedIndex = {
version: index.version,
warning: index.warning ?? INSTALLED_PLUGIN_INDEX_WARNING,
hostContractVersion: index.hostContractVersion,
compatRegistryVersion: index.compatRegistryVersion,
migrationVersion: index.migrationVersion,
policyHash: index.policyHash,
generatedAtMs: index.generatedAtMs,
...(index.workspaceDir !== undefined ? { workspaceDir: index.workspaceDir } : {}),
...(index.refreshReason ? { refreshReason: index.refreshReason } : {}),
// SAFETY: canonical serializer output re-parsed for byte-order-stable embedding.
installRecords: JSON.parse(serializePluginInstallRecordMap(index.installRecords)) as unknown,
plugins: index.plugins.map((plugin) => {
const installOwner = resolveInstalledPluginIndexInstallOwner(plugin);
return {
...plugin,
...(installOwner ? { installOwner } : {}),
...(isInstalledPluginIndexInstallOwnerAmbiguous(plugin)
? { installOwnerAmbiguous: true }
: {}),
};
}),
diagnostics: index.diagnostics,
};
const valueJson = JSON.stringify({
revision,
index: persistedIndex,
} satisfies PersistedInstalledPluginIndexValue);
database
.prepare(
`
INSERT INTO installed_plugin_index (
index_key, version, host_contract_version, compat_registry_version,
migration_version, policy_hash, generated_at_ms, workspace_dir, refresh_reason,
install_records_json, plugins_json, diagnostics_json, warning, updated_at_ms
) VALUES (
@index_key, @version, @host_contract_version, @compat_registry_version,
@migration_version, @policy_hash, @generated_at_ms, @workspace_dir, @refresh_reason,
@install_records_json, @plugins_json, @diagnostics_json, @warning, @updated_at_ms
)
ON CONFLICT(index_key) DO UPDATE SET
version = excluded.version,
host_contract_version = excluded.host_contract_version,
compat_registry_version = excluded.compat_registry_version,
migration_version = excluded.migration_version,
policy_hash = excluded.policy_hash,
generated_at_ms = excluded.generated_at_ms,
workspace_dir = excluded.workspace_dir,
refresh_reason = excluded.refresh_reason,
install_records_json = excluded.install_records_json,
plugins_json = excluded.plugins_json,
diagnostics_json = excluded.diagnostics_json,
warning = excluded.warning,
INSERT INTO config_machine_state (state_key, value_json, updated_at_ms)
VALUES (?, ?, ?)
ON CONFLICT(state_key) DO UPDATE SET
value_json = excluded.value_json,
updated_at_ms = excluded.updated_at_ms
`,
)
.run({
index_key: INSTALLED_PLUGIN_INDEX_SQLITE_KEY,
version: index.version,
host_contract_version: index.hostContractVersion,
compat_registry_version: index.compatRegistryVersion,
migration_version: index.migrationVersion,
policy_hash: index.policyHash,
generated_at_ms: index.generatedAtMs,
workspace_dir: index.workspaceDir ?? null,
refresh_reason: index.refreshReason ?? null,
install_records_json: serializePluginInstallRecordMap(index.installRecords),
plugins_json: JSON.stringify(
index.plugins.map((plugin) => {
const installOwner = resolveInstalledPluginIndexInstallOwner(plugin);
return {
...plugin,
...(installOwner ? { installOwner } : {}),
...(isInstalledPluginIndexInstallOwnerAmbiguous(plugin)
? { installOwnerAmbiguous: true }
: {}),
};
}),
),
diagnostics_json: JSON.stringify(index.diagnostics),
warning: index.warning ?? INSTALLED_PLUGIN_INDEX_WARNING,
updated_at_ms: revision,
});
.run(INSTALLED_PLUGIN_INDEX_STATE_KEY, valueJson, revision);
}
function readPersistedInstalledPluginIndexFromSqlite(
@@ -375,7 +338,9 @@ function writePersistedInstalledPluginIndexToSqlite(
return runOpenClawStateWriteTransaction(({ db }) => {
const previousRow = readInstalledPluginIndexRow(db);
if (previousRow) {
const previousInstallRecords = safeParseJson(previousRow.install_records_json);
// SAFETY: field probe on the stored value; inspectPluginInstallRecordMap validates it.
const previousInstallRecords = (previousRow.index as { installRecords?: unknown } | null)
?.installRecords;
if (
previousInstallRecords === undefined ||
inspectPluginInstallRecordMap(previousInstallRecords).status === "invalid"
@@ -387,7 +352,7 @@ function writePersistedInstalledPluginIndexToSqlite(
}
lease?.assertOwnedInTransaction(db);
const revision = resolveNextInstalledPluginIndexRevision(
previousRow ? Number(previousRow.updated_at_ms) : null,
previousRow ? previousRow.revision : null,
);
writePersistedInstalledPluginIndexRow(db, persisted, revision);
return {
@@ -437,7 +402,7 @@ export async function restorePersistedInstalledPluginIndexIfCurrent(
const restored = runOpenClawStateWriteTransaction(({ db }) => {
lease.assertOwnedInTransaction(db);
const currentRow = readInstalledPluginIndexRow(db);
const currentRevision = currentRow ? Number(currentRow.updated_at_ms) : null;
const currentRevision = currentRow ? currentRow.revision : null;
if (currentRevision !== expectedRevision) {
return false;
}
@@ -448,12 +413,9 @@ export async function restorePersistedInstalledPluginIndexIfCurrent(
resolveNextInstalledPluginIndexRevision(currentRevision),
);
} else {
db.prepare(
`
DELETE FROM installed_plugin_index
WHERE index_key = ?
`,
).run(INSTALLED_PLUGIN_INDEX_SQLITE_KEY);
db.prepare("DELETE FROM config_machine_state WHERE state_key = ?").run(
INSTALLED_PLUGIN_INDEX_STATE_KEY,
);
}
return true;
}, resolveInstalledPluginIndexStateDatabaseOptions(storeOptions));
+2 -2
View File
@@ -458,10 +458,10 @@ describe("secrets apply", () => {
.run(JSON.stringify({ location: "state-db" }));
stateDatabase
.prepare(
"INSERT INTO auth_profile_stores (store_key, store_json, updated_at) VALUES (?, ?, 1)",
"INSERT INTO config_machine_state (state_key, value_json, updated_at_ms) VALUES (?, ?, 1)",
)
.run(
"shared",
"authProfiles.store",
JSON.stringify({
version: 1,
profiles: {
+2 -2
View File
@@ -762,10 +762,10 @@ describe("secrets audit", () => {
.run(JSON.stringify({ location: "state-db" }));
stateDatabase
.prepare(
"INSERT INTO auth_profile_stores (store_key, store_json, updated_at) VALUES (?, ?, 1)",
"INSERT INTO config_machine_state (state_key, value_json, updated_at_ms) VALUES (?, ?, 1)",
)
.run(
"shared",
"authProfiles.store",
JSON.stringify({
version: 1,
profiles: {
+12 -2
View File
@@ -694,6 +694,9 @@ describe("Git-backed SQLite snapshots", () => {
writeConfigMachineState("nodeHost.config", { gateway: { token: nodeSecret } }, { env });
writeConfigMachineState("nodeHost.otherSecret", { token: nodeSecret }, { env });
writeConfigMachineState("webPush.vapidKeys", { privateKey: pushSecret }, { env });
const authSecret = "synthetic-shared-auth-profile-secret";
writeConfigMachineState("authProfiles.store", { profiles: { openai: authSecret } }, { env });
writeConfigMachineState("authProfiles.state", { active: authSecret }, { env });
writeConfigMachineState("sidebar.sectionOrder", ["first", "second"], { env });
closeOpenClawStateDatabaseForTest();
@@ -711,7 +714,7 @@ describe("Git-backed SQLite snapshots", () => {
const manifestJson = await fs.readFile(path.join(outputPath, "manifest.json"), "utf8");
expect(manifest).toMatchObject({
excludedConfigStateKeyPrefixes: ["nodeHost.", "webPush.vapidKeys"],
excludedConfigStateKeyPrefixes: ["authProfiles.", "nodeHost.", "webPush.vapidKeys"],
tables: { config_machine_state: { rows: 1 } },
});
expect(rows).toContain("sidebar.sectionOrder");
@@ -719,9 +722,12 @@ describe("Git-backed SQLite snapshots", () => {
expect(rows).not.toContain("nodeHost.");
expect(rows).not.toContain("webPush.vapidKeys");
expect(rows).not.toContain(nodeSecret);
expect(rows).not.toContain("authProfiles.");
expect(rows).not.toContain(authSecret);
expect(rows).not.toContain(pushSecret);
expect(manifestJson).not.toContain(nodeSecret);
expect(manifestJson).not.toContain(pushSecret);
expect(manifestJson).not.toContain(authSecret);
const restoredPath = path.join(root, "restored.sqlite");
const restored = await restoreGitBackupDirectory({
@@ -731,7 +737,11 @@ describe("Git-backed SQLite snapshots", () => {
});
// Restore must disclose the intentionally omitted machine-state prefixes so
// operators cannot mistake a redacted restore for a complete one.
expect(restored.excludedConfigStateKeyPrefixes).toEqual(["nodeHost.", "webPush.vapidKeys"]);
expect(restored.excludedConfigStateKeyPrefixes).toEqual([
"authProfiles.",
"nodeHost.",
"webPush.vapidKeys",
]);
});
it("rejects a restored global database without canonical ownership metadata", async () => {
@@ -46,12 +46,12 @@ describe("OpenClaw database maintenance schema validation", () => {
it("accepts a global schema produced by an additive column migration", () => {
const schemaWithoutMigratedColumn = OPENCLAW_STATE_SCHEMA_SQL.replace(
" delivery_thread_id_type TEXT,\n",
" schedule_identity TEXT,\n",
"",
);
const database = createGlobalDatabase(schemaWithoutMigratedColumn);
try {
database.exec("ALTER TABLE cron_jobs ADD COLUMN delivery_thread_id_type TEXT;");
database.exec("ALTER TABLE cron_jobs ADD COLUMN schedule_identity TEXT;");
expect(() =>
assertOpenClawStateDatabaseForMaintenance(database, {
@@ -222,7 +222,6 @@ describe("OpenClaw database maintenance schema validation", () => {
"device_bootstrap_tokens.setup_id TEXT",
"session_groups.cwd TEXT",
"session_groups.worktree INTEGER",
"installed_plugin_index.workspace_dir TEXT",
"secret_store_entries.allowed_hosts TEXT",
"skill_workshop_proposals.claim_released_time INTEGER",
]);
@@ -283,8 +282,8 @@ describe("OpenClaw database maintenance schema validation", () => {
it("accepts a migrated required column with its temporary default", () => {
const schemaWithoutMigratedColumn = OPENCLAW_STATE_SCHEMA_SQL.replace(
" owner_session_key TEXT,\n name TEXT NOT NULL,\n description TEXT,\n",
" owner_session_key TEXT,\n description TEXT,\n",
" name TEXT NOT NULL,\n description TEXT,\n enabled INTEGER NOT NULL,\n",
" description TEXT,\n enabled INTEGER NOT NULL,\n",
);
const database = createGlobalDatabase(schemaWithoutMigratedColumn);
try {
+72 -11
View File
@@ -5,7 +5,10 @@
"status": "completed",
"targetVersion": 17,
"table": "state_leases",
"indexes": ["idx_agent_state_leases_expiry", "idx_agent_state_leases_owner"],
"indexes": [
"idx_agent_state_leases_expiry",
"idx_agent_state_leases_owner"
],
"note": "Removed from the canonical per-agent schema in version 17."
},
{
@@ -13,7 +16,9 @@
"status": "completed",
"targetVersion": 10,
"table": "agent_model_catalogs",
"indexes": ["idx_agent_model_catalogs_agent_dir"],
"indexes": [
"idx_agent_model_catalogs_agent_dir"
],
"note": "Rebuildable model-catalog cache contents were dropped in state schema 10."
},
{
@@ -21,7 +26,9 @@
"status": "completed",
"targetVersion": 10,
"table": "android_notification_recent_packages",
"indexes": ["idx_android_notification_recent_packages_order"],
"indexes": [
"idx_android_notification_recent_packages_order"
],
"note": "Never had a shipped writer; retired in state schema 10."
},
{
@@ -29,7 +36,10 @@
"status": "completed",
"targetVersion": 10,
"table": "command_log_entries",
"indexes": ["idx_command_log_entries_timestamp", "idx_command_log_entries_session"],
"indexes": [
"idx_command_log_entries_timestamp",
"idx_command_log_entries_session"
],
"note": "Never had a shipped writer; retired in state schema 10."
},
{
@@ -37,7 +47,9 @@
"status": "completed",
"targetVersion": 10,
"table": "diagnostic_stability_bundles",
"indexes": ["idx_diagnostic_stability_bundles_created"],
"indexes": [
"idx_diagnostic_stability_bundles_created"
],
"note": "Never had a shipped writer; retired in state schema 10."
},
{
@@ -45,7 +57,9 @@
"status": "completed",
"targetVersion": 10,
"table": "media_blobs",
"indexes": ["idx_media_blobs_created"],
"indexes": [
"idx_media_blobs_created"
],
"note": "Never had a shipped writer; retired in state schema 10."
},
{
@@ -53,7 +67,9 @@
"status": "completed",
"targetVersion": 10,
"table": "model_capability_cache",
"indexes": ["idx_model_capability_cache_provider_updated"],
"indexes": [
"idx_model_capability_cache_provider_updated"
],
"note": "Never had a shipped writer; retired in state schema 10."
},
{
@@ -61,7 +77,10 @@
"status": "completed",
"targetVersion": 11,
"table": "skill_lifecycle",
"indexes": ["idx_skill_lifecycle_key", "idx_skill_lifecycle_state"],
"indexes": [
"idx_skill_lifecycle_key",
"idx_skill_lifecycle_state"
],
"note": "Legacy age-based skill curation was replaced by weekly collection review."
},
{
@@ -133,7 +152,9 @@
"status": "completed",
"targetVersion": 12,
"table": "tui_last_sessions",
"indexes": ["idx_tui_last_sessions_session_key"],
"indexes": [
"idx_tui_last_sessions_session_key"
],
"note": "Dropped rebuildable last-session pointers; new pointers use tui.lastSession.<scopeKey>."
},
{
@@ -157,7 +178,9 @@
"status": "completed",
"targetVersion": 12,
"table": "voicewake_routing_routes",
"indexes": ["idx_voicewake_routing_routes_trigger"],
"indexes": [
"idx_voicewake_routing_routes_trigger"
],
"note": "Folded into config_machine_state key voicewake.routing."
},
{
@@ -165,7 +188,9 @@
"status": "completed",
"targetVersion": 12,
"table": "voicewake_triggers",
"indexes": ["idx_voicewake_triggers_trigger"],
"indexes": [
"idx_voicewake_triggers_trigger"
],
"note": "Folded into config_machine_state key voicewake.triggers."
},
{
@@ -175,6 +200,42 @@
"table": "web_push_vapid_keys",
"indexes": [],
"note": "Folded into secret-excluded config_machine_state key webPush.vapidKeys."
},
{
"database": "state",
"status": "completed",
"targetVersion": 13,
"table": "installed_plugin_index",
"indexes": [
"idx_installed_plugin_index_generated"
],
"note": "Folded into config_machine_state key plugins.installedIndex (revision inside the value) in state schema 13."
},
{
"database": "state",
"status": "completed",
"targetVersion": 13,
"table": "workspace_attestations",
"indexes": [
"idx_workspace_attestations_attested"
],
"note": "Merged into workspace_setup_state (nullable attested_at_ms/attestation_updated_at_ms) in state schema 13."
},
{
"database": "state",
"status": "completed",
"targetVersion": 13,
"table": "auth_profile_stores",
"indexes": [],
"note": "Shared auth store folded into config_machine_state key authProfiles.store (secret prefix redaction) in state schema 13."
},
{
"database": "state",
"status": "completed",
"targetVersion": 13,
"table": "auth_profile_state",
"indexes": [],
"note": "Shared auth runtime state folded into config_machine_state key authProfiles.state in state schema 13; the agent-DB table of the same name is unaffected."
}
]
}
@@ -37,7 +37,6 @@ export const CLAW_LAZY_ADDITIVE_STATE_COLUMN_DEFINITIONS = [
{ columnName: "setup_id", dataType: "TEXT", tableName: "device_bootstrap_tokens" },
{ columnName: "cwd", dataType: "TEXT", tableName: "session_groups" },
{ columnName: "worktree", dataType: "INTEGER", tableName: "session_groups" },
{ columnName: "workspace_dir", dataType: "TEXT", tableName: "installed_plugin_index" },
{ columnName: "allowed_hosts", dataType: "TEXT", tableName: "secret_store_entries" },
{
columnName: "claim_released_time",
+3 -2
View File
@@ -1,6 +1,7 @@
import type { DatabaseSync } from "node:sqlite";
import type { SqliteWalMaintenance } from "../infra/sqlite-wal.js";
// v13 keeps cron jobs and subagent runs canonical in JSON, removing unused projections.
// v12 folds singleton state into config_machine_state and retires write-only cron epochs.
// v11 retires the legacy skill curator lifecycle and write-only proposal origin runs.
// v10 retires six dead tables that shipped without runtime owners.
@@ -9,7 +10,7 @@ import type { SqliteWalMaintenance } from "../infra/sqlite-wal.js";
// v7 retires the inert shared commitments table.
// v6 makes every committed shared-state table part of the canonical runtime schema.
// v5 records durable cloud-worker result refs on pending workspace fences.
export const OPENCLAW_STATE_SCHEMA_VERSION = 12;
export const OPENCLAW_STATE_SCHEMA_VERSION = 13;
export const OPENCLAW_STATE_STRICT_SCHEMA_VERSION = 3;
// Privacy-sensitive feature tables remain absent even in fresh databases until
// their feature-local first write. The canonical SQL still owns their shape.
@@ -47,7 +48,6 @@ export const LAZY_ADDITIVE_STATE_TABLES = [
"projects",
"user_preferences",
"device_pair_setup_completions",
"gateway_origin_device_tokens",
"github_publication_requests",
"device_pairing_join_codes",
"skill_workshop_proposal_events",
@@ -94,6 +94,7 @@ export type OpenClawStateDatabaseSchemaMigration = {
| "state-table-retirement-v10"
| "state-table-retirement-v11"
| "singleton-state-foldin-v12"
| "state-consolidation-v13"
| "operator-approvals-system-agent"
| "session-watch-cursor-provenance-v4"
| "strict-tables-v3";
@@ -19,9 +19,6 @@ const tempDirs = useAutoCleanupTempDirTracker((cleanup) => {
type StoredRun = {
run_id: string;
started_at: number | null;
ended_at: number | null;
outcome_json: string | null;
payload_json: string;
};
@@ -30,42 +27,21 @@ function createDatabase() {
db.exec(`
CREATE TABLE subagent_runs (
run_id TEXT PRIMARY KEY,
started_at INTEGER,
ended_at INTEGER,
outcome_json TEXT,
payload_json TEXT NOT NULL
) STRICT;
`);
const insert = db.prepare(`
INSERT INTO subagent_runs (run_id, started_at, ended_at, outcome_json, payload_json)
VALUES (?, ?, ?, ?, ?)
INSERT INTO subagent_runs (run_id, payload_json)
VALUES (?, ?)
`);
return {
db,
insert: (row: StoredRun) =>
insert.run(row.run_id, row.started_at, row.ended_at, row.outcome_json, row.payload_json),
insert: (row: StoredRun) => insert.run(row.run_id, row.payload_json),
read: (runId: string) =>
db.prepare("SELECT * FROM subagent_runs WHERE run_id = ?").get(runId) as StoredRun,
};
}
// Mirrors the timing/outcome overlay in v2026.7.2-beta.6's SQLite reader.
function readWithShippedBeta6Projection(row: StoredRun) {
const payload = JSON.parse(row.payload_json);
const outcome = row.outcome_json ? JSON.parse(row.outcome_json) : payload.outcome;
return {
...payload,
...(row.started_at !== null ? { startedAt: row.started_at } : {}),
...(row.ended_at !== null ? { endedAt: row.ended_at } : {}),
...(outcome ? { outcome } : {}),
execution: {
...payload.execution,
...(row.started_at !== null ? { startedAt: row.started_at } : {}),
...(row.ended_at !== null ? { status: "terminal", endedAt: row.ended_at, outcome } : {}),
},
};
}
describe("repairLegacySubagentSuspensionReasons", () => {
it("rewrites the shipped reason on open and stays canonical after a second open", () => {
const stateDir = tempDirs.make("openclaw-subagent-suspension-backfill-");
@@ -75,17 +51,13 @@ describe("repairLegacySubagentSuspensionReasons", () => {
initial.db
.prepare(
`INSERT INTO subagent_runs (
run_id, child_session_key, requester_session_key, requester_display_key,
task, cleanup, created_at, payload_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
run_id, child_session_key, requester_session_key, created_at, payload_json
) VALUES (?, ?, ?, ?, ?)`,
)
.run(
runId,
"agent:main:subagent:legacy",
"agent:main:main",
"main",
"legacy retry limit",
"keep",
100,
JSON.stringify({
runId,
@@ -126,9 +98,6 @@ describe("repairLegacySubagentExecutionPayloads", () => {
const killedOutcome = { status: "error", error: "manual kill" };
store.insert({
run_id: "paused",
started_at: 100,
ended_at: 200,
outcome_json: null,
payload_json: JSON.stringify({
startedAt: 100,
endedAt: 200,
@@ -138,9 +107,6 @@ describe("repairLegacySubagentExecutionPayloads", () => {
});
store.insert({
run_id: "killed",
started_at: 300,
ended_at: 400,
outcome_json: JSON.stringify(killedOutcome),
payload_json: JSON.stringify({
startedAt: 300,
endedAt: 400,
@@ -171,36 +137,12 @@ describe("repairLegacySubagentExecutionPayloads", () => {
expect(payload).not.toHaveProperty("endedAt");
expect(payload).not.toHaveProperty("outcome");
}
expect(
firstPass.map(({ started_at, ended_at, outcome_json }) => ({
started_at,
ended_at,
outcome_json,
})),
).toEqual([
{ started_at: 100, ended_at: 200, outcome_json: null },
{ started_at: 300, ended_at: 400, outcome_json: JSON.stringify(killedOutcome) },
]);
expect(readWithShippedBeta6Projection(firstPass[1]!)).toMatchObject({
startedAt: 300,
endedAt: 400,
outcome: killedOutcome,
execution: {
status: "terminal",
startedAt: 300,
endedAt: 400,
outcome: killedOutcome,
},
});
});
it("preserves newer canonical terminal state and optional start timing", () => {
const store = createDatabase();
store.insert({
run_id: "newer-terminal",
started_at: 100,
ended_at: 200,
outcome_json: JSON.stringify({ status: "error", error: "manual kill" }),
payload_json: JSON.stringify({
startedAt: 100,
endedAt: 200,
@@ -211,9 +153,6 @@ describe("repairLegacySubagentExecutionPayloads", () => {
});
store.insert({
run_id: "paused-without-start",
started_at: null,
ended_at: 500,
outcome_json: null,
payload_json: JSON.stringify({
endedAt: 500,
pauseReason: "sessions_yield",
@@ -239,9 +178,6 @@ describe("repairLegacySubagentExecutionPayloads", () => {
const store = createDatabase();
store.insert({
run_id: "malformed",
started_at: null,
ended_at: null,
outcome_json: null,
payload_json: "{not-json",
});
@@ -258,9 +194,7 @@ describe("repairLegacySubagentRetainedResults", () => {
CREATE TABLE subagent_runs (
run_id TEXT PRIMARY KEY,
payload_json TEXT NOT NULL,
pending_final_delivery_payload_json TEXT,
frozen_result_text TEXT,
fallback_frozen_result_text TEXT
pending_final_delivery_payload_json TEXT
) STRICT;
CREATE TABLE task_runs (
task_id TEXT PRIMARY KEY,
@@ -276,20 +210,23 @@ describe("repairLegacySubagentRetainedResults", () => {
};
db.prepare(
`INSERT INTO subagent_runs (
run_id, payload_json, pending_final_delivery_payload_json,
frozen_result_text, fallback_frozen_result_text
) VALUES (?, ?, ?, ?, ?)`,
run_id, payload_json, pending_final_delivery_payload_json
) VALUES (?, ?, ?)`,
).run(
"completion-run",
JSON.stringify({
runId: "completion-run",
taskRunId: "task-run",
completion: { required: true, resultText: "(no_reply)" },
delivery: { status: "suspended", payload: legacyPayload },
delivery: {
status: "suspended",
payload: {
frozenResultText: "(no_reply)",
requesterSessionKey: "agent:main:main",
},
},
}),
JSON.stringify(legacyPayload),
"(no_reply)",
null,
);
db.prepare(
"INSERT INTO task_runs (task_id, runtime, run_id, progress_summary) VALUES (?, ?, ?, ?)",
@@ -298,21 +235,17 @@ describe("repairLegacySubagentRetainedResults", () => {
repairLegacySubagentRetainedResults(db);
const firstPass = db
.prepare(
`SELECT payload_json, pending_final_delivery_payload_json,
frozen_result_text, fallback_frozen_result_text
`SELECT payload_json, pending_final_delivery_payload_json
FROM subagent_runs WHERE run_id = ?`,
)
.get("completion-run") as {
payload_json: string;
pending_final_delivery_payload_json: string;
frozen_result_text: string | null;
fallback_frozen_result_text: string | null;
};
repairLegacySubagentRetainedResults(db);
const secondPass = db
.prepare(
`SELECT payload_json, pending_final_delivery_payload_json,
frozen_result_text, fallback_frozen_result_text
`SELECT payload_json, pending_final_delivery_payload_json
FROM subagent_runs WHERE run_id = ?`,
)
.get("completion-run");
@@ -325,11 +258,7 @@ describe("repairLegacySubagentRetainedResults", () => {
fallbackResultText: "findings captured before wake",
});
expect(payload.delivery.payload).toEqual({ requesterSessionKey: "agent:main:main" });
expect(JSON.parse(firstPass.pending_final_delivery_payload_json)).toEqual({
requesterSessionKey: "agent:main:main",
});
expect(firstPass.frozen_result_text).toBe("(no_reply)");
expect(firstPass.fallback_frozen_result_text).toBe("findings captured before wake");
expect(JSON.parse(firstPass.pending_final_delivery_payload_json)).toEqual(legacyPayload);
expect(
db.prepare("SELECT progress_summary FROM task_runs WHERE task_id = ?").get("task-id"),
).toEqual({ progress_summary: "findings captured before wake" });
@@ -340,18 +269,10 @@ describe("repairLegacySubagentRetainedResults", () => {
db.exec(`
CREATE TABLE subagent_runs (
run_id TEXT PRIMARY KEY,
payload_json TEXT NOT NULL,
pending_final_delivery_payload_json TEXT,
frozen_result_text TEXT,
fallback_frozen_result_text TEXT
payload_json TEXT NOT NULL
) STRICT;
`);
db.prepare(
`INSERT INTO subagent_runs (
run_id, payload_json, pending_final_delivery_payload_json,
frozen_result_text, fallback_frozen_result_text
) VALUES (?, ?, ?, ?, ?)`,
).run(
db.prepare("INSERT INTO subagent_runs (run_id, payload_json) VALUES (?, ?)").run(
"canonical-run",
JSON.stringify({
completion: {
@@ -367,21 +288,12 @@ describe("repairLegacySubagentRetainedResults", () => {
},
},
}),
JSON.stringify({
frozenResultText: "legacy result",
fallbackFrozenResultText: "legacy fallback",
}),
"legacy result",
"legacy fallback",
);
repairLegacySubagentRetainedResults(db);
const row = db.prepare("SELECT * FROM subagent_runs WHERE run_id = ?").get("canonical-run") as {
payload_json: string;
pending_final_delivery_payload_json: string;
frozen_result_text: string | null;
fallback_frozen_result_text: string | null;
};
const payload = JSON.parse(row.payload_json);
expect(payload.completion).toEqual({
@@ -390,9 +302,6 @@ describe("repairLegacySubagentRetainedResults", () => {
fallbackResultText: "canonical fallback",
});
expect(payload.delivery.payload).toEqual({});
expect(JSON.parse(row.pending_final_delivery_payload_json)).toEqual({});
expect(row.frozen_result_text).toBe("canonical result");
expect(row.fallback_frozen_result_text).toBe("canonical fallback");
});
it("preserves authoritative terminal silence while promoting legacy results", () => {
@@ -400,10 +309,7 @@ describe("repairLegacySubagentRetainedResults", () => {
db.exec(`
CREATE TABLE subagent_runs (
run_id TEXT PRIMARY KEY,
payload_json TEXT NOT NULL,
pending_final_delivery_payload_json TEXT,
frozen_result_text TEXT,
fallback_frozen_result_text TEXT
payload_json TEXT NOT NULL
) STRICT;
CREATE TABLE task_runs (
task_id TEXT PRIMARY KEY,
@@ -416,12 +322,7 @@ describe("repairLegacySubagentRetainedResults", () => {
frozenResultText: "NO_REPLY",
fallbackFrozenResultText: "older visible fallback",
};
db.prepare(
`INSERT INTO subagent_runs (
run_id, payload_json, pending_final_delivery_payload_json,
frozen_result_text, fallback_frozen_result_text
) VALUES (?, ?, ?, ?, ?)`,
).run(
db.prepare("INSERT INTO subagent_runs (run_id, payload_json) VALUES (?, ?)").run(
"silent-run",
JSON.stringify({
taskRunId: "silent-task-run",
@@ -432,9 +333,6 @@ describe("repairLegacySubagentRetainedResults", () => {
},
delivery: { status: "suspended", payload: legacyPayload },
}),
JSON.stringify(legacyPayload),
"NO_REPLY",
"older visible fallback",
);
db.prepare(
"INSERT INTO task_runs (task_id, runtime, run_id, progress_summary) VALUES (?, ?, ?, ?)",
+15 -213
View File
@@ -129,9 +129,7 @@ export function repairLegacyTaskDeliveryStatuses(db: DatabaseSync): void {
type LegacyRetainedResultRow = {
run_id: string;
payload_json: string;
pending_final_delivery_payload_json: string | null;
frozen_result_text: string | null;
fallback_frozen_result_text: string | null;
pending_final_delivery_payload_json?: string | null;
};
function nullableTextValue(record: Record<string, unknown> | null, key: string) {
@@ -156,28 +154,25 @@ function selectLegacyRetainedTaskResult(
/** Promote shipped retained results before runtime hydrates canonical subagent/task state. */
export function repairLegacySubagentRetainedResults(db: DatabaseSync): void {
if (
!tableExists(db, "subagent_runs") ||
!tableHasColumn(db, "subagent_runs", "pending_final_delivery_payload_json") ||
!tableHasColumn(db, "subagent_runs", "frozen_result_text") ||
!tableHasColumn(db, "subagent_runs", "fallback_frozen_result_text")
) {
if (!tableExists(db, "subagent_runs")) {
return;
}
const repair = () => {
const hasLegacyPendingPayload = tableHasColumn(
db,
"subagent_runs",
"pending_final_delivery_payload_json",
);
const rows = db
.prepare(
`SELECT run_id, payload_json, pending_final_delivery_payload_json,
frozen_result_text, fallback_frozen_result_text
FROM subagent_runs`,
hasLegacyPendingPayload
? "SELECT run_id, payload_json, pending_final_delivery_payload_json FROM subagent_runs"
: "SELECT run_id, payload_json FROM subagent_runs",
)
.all() as LegacyRetainedResultRow[];
const updateRun = db.prepare(
`UPDATE subagent_runs
SET payload_json = ?,
pending_final_delivery_payload_json = ?,
frozen_result_text = ?,
fallback_frozen_result_text = ?
SET payload_json = ?
WHERE run_id = ?`,
);
const canProjectTasks =
@@ -233,17 +228,9 @@ export function repairLegacySubagentRetainedResults(db: DatabaseSync): void {
}
delete deliveryPayload?.frozenResultText;
delete deliveryPayload?.fallbackFrozenResultText;
delete pendingPayload?.frozenResultText;
delete pendingPayload?.fallbackFrozenResultText;
const primary = nullableTextValue(completion, "resultText");
const fallback = nullableTextValue(completion, "fallbackResultText");
updateRun.run(
JSON.stringify(payload),
pendingPayload ? JSON.stringify(pendingPayload) : row.pending_final_delivery_payload_json,
typeof primary === "string" ? primary : null,
typeof fallback === "string" ? fallback : null,
row.run_id,
);
updateRun.run(JSON.stringify(payload), row.run_id);
const taskRunId = textField(payload, "taskRunId") ?? row.run_id;
const terminalReply = normalizeAgentRunTerminalReplySnapshot(completion.terminalReply);
const taskResult = selectLegacyRetainedTaskResult(completion, primary, fallback);
@@ -393,89 +380,10 @@ function recordField(record: Record<string, unknown>, key: string): Record<strin
return asNullableRecord(record[key]);
}
function jsonField(value: unknown): string | null {
return value === undefined ? null : JSON.stringify(value);
}
function cronSessionTargetField(record: Record<string, unknown>): string | null {
const value = textField(record, "sessionTarget");
if (!value) {
return null;
}
return value === "main" ||
value === "isolated" ||
value === "current" ||
value.startsWith("session:")
? value
: null;
}
function cronWakeModeField(record: Record<string, unknown>): string | null {
const value = textField(record, "wakeMode");
return value === "now" || value === "next-heartbeat" ? value : null;
}
function booleanField(record: Record<string, unknown>, key: string): number | null {
const value = record[key];
return typeof value === "boolean" ? (value ? 1 : 0) : null;
}
function failureDestinationField(
record: Record<string, unknown> | null,
key: "accountId" | "channel" | "mode" | "to",
): string | null {
if (!record || !Object.hasOwn(record, key)) {
return null;
}
const value = record[key];
return typeof value === "string" && value.trim() ? value : "";
}
export function migrateLegacyCronDeliveryThreadIds(db: DatabaseSync): void {
const rows = db
.prepare(
`SELECT store_key, job_id, job_json, delivery_thread_id
FROM cron_jobs
WHERE delivery_thread_id_type IS NULL`,
)
.all() as Array<{
store_key: string;
job_id: string;
job_json: string;
delivery_thread_id: string | null;
}>;
const update = db.prepare(
`UPDATE cron_jobs
SET delivery_thread_id = ?, delivery_thread_id_type = ?
WHERE store_key = ? AND job_id = ? AND delivery_thread_id_type IS NULL`,
);
for (const row of rows) {
const job = parseJsonRecord(row.job_json);
const delivery = job ? recordField(job, "delivery") : null;
const typed = delivery?.threadId;
if (row.delivery_thread_id === null) {
// The first normalized cron migration could not project numeric thread IDs.
// Recover only that known lost shape while this type column is first added.
if (typeof typed === "number" && Number.isFinite(typed)) {
update.run(String(typed), "number", row.store_key, row.job_id);
}
continue;
}
const type =
typeof typed === "number" &&
Number.isFinite(typed) &&
String(typed) === row.delivery_thread_id
? "number"
: "string";
update.run(row.delivery_thread_id, type, row.store_key, row.job_id);
}
}
export function backfillCronJobsFromJobJson(db: DatabaseSync): void {
if (
!tableExists(db, "cron_jobs") ||
!tableHasColumn(db, "cron_jobs", "job_json") ||
!tableHasColumn(db, "cron_jobs", "schedule_kind") ||
!tableHasColumn(db, "cron_jobs", "payload_kind")
) {
return;
@@ -484,8 +392,7 @@ export function backfillCronJobsFromJobJson(db: DatabaseSync): void {
.prepare(
`SELECT store_key, job_id, job_json, updated_at
FROM cron_jobs
WHERE schedule_kind = 'manual'
OR payload_kind = 'message'
WHERE payload_kind = 'message'
OR name = ''`,
)
.all() as Array<{
@@ -501,49 +408,8 @@ export function backfillCronJobsFromJobJson(db: DatabaseSync): void {
`UPDATE cron_jobs
SET name = ?,
enabled = ?,
delete_after_run = ?,
created_at_ms = ?,
agent_id = ?,
session_key = ?,
schedule_kind = ?,
schedule_expr = ?,
schedule_tz = ?,
every_ms = ?,
anchor_ms = ?,
at = ?,
stagger_ms = ?,
session_target = ?,
wake_mode = ?,
payload_kind = ?,
payload_message = ?,
payload_model = ?,
payload_fallbacks_json = ?,
payload_thinking = ?,
payload_timeout_seconds = ?,
payload_allow_unsafe_external_content = ?,
payload_external_content_source_json = ?,
payload_light_context = ?,
payload_tools_allow_json = ?,
delivery_mode = ?,
delivery_channel = ?,
delivery_to = ?,
delivery_thread_id = ?,
delivery_account_id = ?,
delivery_best_effort = ?,
delivery_completion_mode = ?,
delivery_completion_to = ?,
failure_delivery_mode = ?,
failure_delivery_channel = ?,
failure_delivery_to = ?,
failure_delivery_account_id = ?,
failure_alert_disabled = ?,
failure_alert_after = ?,
failure_alert_channel = ?,
failure_alert_to = ?,
failure_alert_cooldown_ms = ?,
failure_alert_include_skipped = ?,
failure_alert_mode = ?,
failure_alert_account_id = ?,
runtime_updated_at_ms = ?
WHERE store_key = ?
AND job_id = ?`,
@@ -553,7 +419,7 @@ export function backfillCronJobsFromJobJson(db: DatabaseSync): void {
if (!job) {
continue;
}
// Legacy cron rows kept the contract in job_json; columns are a queryable projection of it.
// Legacy defaults are repaired only in the query-bearing projection; job_json owns config.
const schedule = recordField(job, "schedule");
const payload = recordField(job, "payload");
const scheduleKind = textField(schedule ?? {}, "kind");
@@ -571,76 +437,12 @@ export function backfillCronJobsFromJobJson(db: DatabaseSync): void {
) {
continue;
}
const fallbackTime = Number(row.updated_at) || 0;
const delivery = recordField(job, "delivery");
const completionDestination = delivery ? recordField(delivery, "completionDestination") : null;
const failureDestination = delivery ? recordField(delivery, "failureDestination") : null;
const failureAlertValue = job.failureAlert;
const failureAlert =
failureAlertValue &&
typeof failureAlertValue === "object" &&
!Array.isArray(failureAlertValue)
? (failureAlertValue as Record<string, unknown>)
: null;
update.run(
textField(job, "name") ?? row.job_id,
job.enabled === false ? 0 : 1,
booleanField(job, "deleteAfterRun"),
numberField(job, "createdAtMs") ?? fallbackTime,
textField(job, "agentId"),
textField(job, "sessionKey"),
scheduleKind,
isCron ? textField(schedule, "expr") : null,
isCron ? textField(schedule, "tz") : null,
isEvery ? numberField(schedule, "everyMs") : null,
isEvery ? numberField(schedule, "anchorMs") : null,
isAt ? textField(schedule, "at") : null,
isCron ? numberField(schedule, "staggerMs") : null,
cronSessionTargetField(job) ?? (payloadKind === "agentTurn" ? "isolated" : "main"),
cronWakeModeField(job) ?? "now",
payloadKind,
isSystemEvent ? textField(payload, "text") : textField(payload, "message"),
isAgentTurn ? textField(payload, "model") : null,
isAgentTurn ? jsonField(payload.fallbacks) : null,
isAgentTurn ? textField(payload, "thinking") : null,
isAgentTurn ? numberField(payload, "timeoutSeconds") : null,
isAgentTurn && typeof payload.allowUnsafeExternalContent === "boolean"
? payload.allowUnsafeExternalContent
? 1
: 0
: null,
isAgentTurn ? jsonField(payload.externalContentSource) : null,
isAgentTurn && typeof payload.lightContext === "boolean"
? payload.lightContext
? 1
: 0
: null,
isAgentTurn ? jsonField(payload.toolsAllow) : null,
delivery ? textField(delivery, "mode") : null,
delivery ? textField(delivery, "channel") : null,
delivery ? textField(delivery, "to") : null,
delivery ? textField(delivery, "threadId") : null,
delivery ? textField(delivery, "accountId") : null,
delivery && typeof delivery.bestEffort === "boolean" ? (delivery.bestEffort ? 1 : 0) : null,
completionDestination ? textField(completionDestination, "mode") : null,
completionDestination ? textField(completionDestination, "to") : null,
failureDestinationField(failureDestination, "mode"),
failureDestinationField(failureDestination, "channel"),
failureDestinationField(failureDestination, "to"),
failureDestinationField(failureDestination, "accountId"),
failureAlertValue === false ? 1 : failureAlert ? 0 : null,
failureAlert ? numberField(failureAlert, "after") : null,
failureAlert ? textField(failureAlert, "channel") : null,
failureAlert ? textField(failureAlert, "to") : null,
failureAlert ? numberField(failureAlert, "cooldownMs") : null,
failureAlert && typeof failureAlert.includeSkipped === "boolean"
? failureAlert.includeSkipped
? 1
: 0
: null,
failureAlert ? textField(failureAlert, "mode") : null,
failureAlert ? textField(failureAlert, "accountId") : null,
numberField(job, "updatedAtMs") ?? fallbackTime,
numberField(job, "updatedAtMs") ?? (Number(row.updated_at) || 0),
row.store_key,
row.job_id,
);
+30 -7
View File
@@ -20,6 +20,8 @@ import { OPENCLAW_STATE_MAINTENANCE_SCHEMA_COMPATIBILITY } from "./openclaw-stat
import { OPENCLAW_STATE_SCHEMA_SQL } from "./openclaw-state-schema.js";
const STATE_V6_ADDITIVE_TABLES = [
// v6-v12 databases may predate this former same-version lazy table.
"gateway_origin_device_tokens",
...LAZY_ADDITIVE_STATE_TABLES,
"worker_session_tool_operations",
"worker_turn_tool_authorities",
@@ -51,6 +53,7 @@ const STATE_MIGRATION_ALLOWED_MISSING_TABLES = {
9: STATE_V6_ADDITIVE_TABLES,
10: STATE_V6_ADDITIVE_TABLES,
11: STATE_V6_ADDITIVE_TABLES,
12: STATE_V6_ADDITIVE_TABLES,
} as const satisfies Record<number, readonly string[]>;
type OpenClawStateMigrationVersion = keyof typeof STATE_MIGRATION_ALLOWED_MISSING_TABLES;
@@ -169,7 +172,7 @@ function assertOpenClawStateDatabaseVersionForMigration(
}
/** Require every stable v5 table before the v6 additive migration can run. */
export function assertOpenClawStateDatabaseV5ForMigration(
function assertOpenClawStateDatabaseV5ForMigration(
database: DatabaseSync,
options: { pathname: string },
): void {
@@ -177,7 +180,7 @@ export function assertOpenClawStateDatabaseV5ForMigration(
}
/** Require every stable v6 table before the v7 retirement migration can run. */
export function assertOpenClawStateDatabaseV6ForMigration(
function assertOpenClawStateDatabaseV6ForMigration(
database: DatabaseSync,
options: { pathname: string },
): void {
@@ -185,7 +188,7 @@ export function assertOpenClawStateDatabaseV6ForMigration(
}
/** Require every stable v7 table before the v8 placement migration can run. */
export function assertOpenClawStateDatabaseV7ForMigration(
function assertOpenClawStateDatabaseV7ForMigration(
database: DatabaseSync,
options: { pathname: string },
): void {
@@ -193,7 +196,7 @@ export function assertOpenClawStateDatabaseV7ForMigration(
}
/** Require every stable v8 table before the v9 registry migration can run. */
export function assertOpenClawStateDatabaseV8ForMigration(
function assertOpenClawStateDatabaseV8ForMigration(
database: DatabaseSync,
options: { pathname: string },
): void {
@@ -201,7 +204,7 @@ export function assertOpenClawStateDatabaseV8ForMigration(
}
/** Require every stable v9 table before the v10 retirement migration can run. */
export function assertOpenClawStateDatabaseV9ForMigration(
function assertOpenClawStateDatabaseV9ForMigration(
database: DatabaseSync,
options: { pathname: string },
): void {
@@ -209,7 +212,7 @@ export function assertOpenClawStateDatabaseV9ForMigration(
}
/** Require every stable v10 table before the v11 curator retirement can run. */
export function assertOpenClawStateDatabaseV10ForMigration(
function assertOpenClawStateDatabaseV10ForMigration(
database: DatabaseSync,
options: { pathname: string },
): void {
@@ -217,13 +220,33 @@ export function assertOpenClawStateDatabaseV10ForMigration(
}
/** Require every stable v11 table before singleton state folds into the v12 store. */
export function assertOpenClawStateDatabaseV11ForMigration(
function assertOpenClawStateDatabaseV11ForMigration(
database: DatabaseSync,
options: { pathname: string },
): void {
assertOpenClawStateDatabaseVersionForMigration(database, { ...options, version: 11 });
}
/** Require every stable v12 table before wide rows become JSON-canonical. */
function assertOpenClawStateDatabaseV12ForMigration(
database: DatabaseSync,
options: { pathname: string },
): void {
assertOpenClawStateDatabaseVersionForMigration(database, { ...options, version: 12 });
}
/** Keep historical migration gates beside their version-specific ownership assertions. */
export const openClawStateMigrationAssertions = new Map([
[5, assertOpenClawStateDatabaseV5ForMigration],
[6, assertOpenClawStateDatabaseV6ForMigration],
[7, assertOpenClawStateDatabaseV7ForMigration],
[8, assertOpenClawStateDatabaseV8ForMigration],
[9, assertOpenClawStateDatabaseV9ForMigration],
[10, assertOpenClawStateDatabaseV10ForMigration],
[11, assertOpenClawStateDatabaseV11ForMigration],
[12, assertOpenClawStateDatabaseV12ForMigration],
]);
export function markCurrentStateSchemaVersion(
db: DatabaseSync,
options: { createMetadataIfMissing?: boolean } = {},
@@ -11,7 +11,6 @@ import {
backfillCronRunLogEntryJson,
backfillDeliveryQueueEntriesFromEntryJson,
ensureOperatorApprovalResolutionRefs,
migrateLegacyCronDeliveryThreadIds,
repairLegacyTaskAgentAttribution,
repairLegacyTaskDeliveryStatuses,
repairLegacySubagentExecutionPayloads,
@@ -412,79 +411,16 @@ export function ensureAdditiveStateColumns(db: DatabaseSync): void {
backfillAcpReplayEstimatedBytes(db);
ensureColumn(db, "cron_jobs", "description TEXT");
ensureColumn(db, "cron_jobs", "declaration_key TEXT");
ensureColumn(db, "cron_jobs", "display_name TEXT");
ensureColumn(db, "cron_jobs", "owner_agent_id TEXT");
ensureColumn(db, "cron_jobs", "owner_session_key TEXT");
ensureColumn(db, "cron_jobs", "name TEXT NOT NULL DEFAULT ''");
ensureColumn(db, "cron_jobs", "enabled INTEGER NOT NULL DEFAULT 1");
ensureColumn(db, "cron_jobs", "delete_after_run INTEGER");
ensureColumn(db, "cron_jobs", "created_at_ms INTEGER NOT NULL DEFAULT 0");
ensureColumn(db, "cron_jobs", "agent_id TEXT");
ensureColumn(db, "cron_jobs", "session_key TEXT");
ensureColumn(db, "cron_jobs", "schedule_kind TEXT NOT NULL DEFAULT 'manual'");
ensureColumn(db, "cron_jobs", "schedule_expr TEXT");
ensureColumn(db, "cron_jobs", "schedule_tz TEXT");
ensureColumn(db, "cron_jobs", "every_ms INTEGER");
ensureColumn(db, "cron_jobs", "anchor_ms INTEGER");
ensureColumn(db, "cron_jobs", "at TEXT");
ensureColumn(db, "cron_jobs", "stagger_ms INTEGER");
ensureColumn(db, "cron_jobs", "session_target TEXT NOT NULL DEFAULT 'main'");
ensureColumn(db, "cron_jobs", "wake_mode TEXT NOT NULL DEFAULT 'auto'");
ensureColumn(db, "cron_jobs", "trigger_script TEXT");
ensureColumn(db, "cron_jobs", "trigger_once INTEGER");
ensureColumn(db, "cron_jobs", "payload_kind TEXT NOT NULL DEFAULT 'message'");
ensureColumn(db, "cron_jobs", "payload_message TEXT");
ensureColumn(db, "cron_jobs", "payload_model TEXT");
ensureColumn(db, "cron_jobs", "payload_fallbacks_json TEXT");
ensureColumn(db, "cron_jobs", "payload_thinking TEXT");
ensureColumn(db, "cron_jobs", "payload_timeout_seconds INTEGER");
ensureColumn(db, "cron_jobs", "payload_allow_unsafe_external_content INTEGER");
ensureColumn(db, "cron_jobs", "payload_external_content_source_json TEXT");
ensureColumn(db, "cron_jobs", "payload_light_context INTEGER");
ensureColumn(db, "cron_jobs", "payload_tools_allow_json TEXT");
ensureColumn(db, "cron_jobs", "payload_tools_allow_is_default INTEGER");
ensureColumn(db, "cron_jobs", "delivery_mode TEXT");
ensureColumn(db, "cron_jobs", "delivery_channel TEXT");
ensureColumn(db, "cron_jobs", "delivery_to TEXT");
ensureColumn(db, "cron_jobs", "delivery_thread_id TEXT");
ensureColumn(db, "cron_jobs", "delivery_account_id TEXT");
ensureColumn(db, "cron_jobs", "delivery_best_effort INTEGER");
ensureColumn(db, "cron_jobs", "delivery_completion_mode TEXT");
ensureColumn(db, "cron_jobs", "delivery_completion_to TEXT");
ensureColumn(db, "cron_jobs", "failure_delivery_mode TEXT");
ensureColumn(db, "cron_jobs", "failure_delivery_channel TEXT");
ensureColumn(db, "cron_jobs", "failure_delivery_to TEXT");
ensureColumn(db, "cron_jobs", "failure_delivery_account_id TEXT");
ensureColumn(db, "cron_jobs", "failure_alert_disabled INTEGER");
ensureColumn(db, "cron_jobs", "failure_alert_after INTEGER");
ensureColumn(db, "cron_jobs", "failure_alert_channel TEXT");
ensureColumn(db, "cron_jobs", "failure_alert_to TEXT");
ensureColumn(db, "cron_jobs", "failure_alert_cooldown_ms INTEGER");
ensureColumn(db, "cron_jobs", "failure_alert_include_skipped INTEGER");
ensureColumn(db, "cron_jobs", "failure_alert_mode TEXT");
ensureColumn(db, "cron_jobs", "failure_alert_account_id TEXT");
ensureColumn(db, "cron_jobs", "next_run_at_ms INTEGER");
ensureColumn(db, "cron_jobs", "running_at_ms INTEGER");
ensureColumn(db, "cron_jobs", "last_run_at_ms INTEGER");
ensureColumn(db, "cron_jobs", "last_run_status TEXT");
ensureColumn(db, "cron_jobs", "last_error TEXT");
ensureColumn(db, "cron_jobs", "last_duration_ms INTEGER");
ensureColumn(db, "cron_jobs", "consecutive_errors INTEGER");
ensureColumn(db, "cron_jobs", "consecutive_skipped INTEGER");
ensureColumn(db, "cron_jobs", "schedule_error_count INTEGER");
ensureColumn(db, "cron_jobs", "last_delivery_status TEXT");
ensureColumn(db, "cron_jobs", "last_delivery_error TEXT");
ensureColumn(db, "cron_jobs", "last_delivered INTEGER");
ensureColumn(db, "cron_jobs", "last_failure_alert_at_ms INTEGER");
ensureColumn(db, "cron_jobs", "state_json TEXT NOT NULL DEFAULT '{}'");
ensureColumn(db, "cron_jobs", "runtime_updated_at_ms INTEGER");
ensureColumn(db, "cron_jobs", "schedule_identity TEXT");
ensureColumn(db, "cron_jobs", "sort_order INTEGER NOT NULL DEFAULT 0");
backfillCronJobsFromJobJson(db);
const addedDeliveryThreadIdType = ensureColumn(db, "cron_jobs", "delivery_thread_id_type TEXT");
if (addedDeliveryThreadIdType) {
migrateLegacyCronDeliveryThreadIds(db);
}
ensureColumn(db, "sandbox_registry_entries", "session_key TEXT");
ensureColumn(db, "sandbox_registry_entries", "backend_id TEXT");
ensureColumn(db, "sandbox_registry_entries", "runtime_label TEXT");
@@ -555,21 +491,6 @@ export function ensureAdditiveStateColumns(db: DatabaseSync): void {
ensureColumn(db, "task_runs", "tool_use_count INTEGER");
ensureColumn(db, "task_runs", "last_tool_name TEXT");
ensureColumn(db, "task_runs", "detail_json TEXT");
ensureColumn(db, "subagent_runs", "task_name TEXT");
ensureColumn(db, "subagent_runs", "requester_settle_wake_status TEXT");
ensureColumn(db, "subagent_runs", "requester_settle_wake_attempt_count INTEGER");
ensureColumn(db, "subagent_runs", "requester_settle_wake_replay_count INTEGER");
ensureColumn(db, "subagent_runs", "requester_settle_wake_next_attempt_at INTEGER");
ensureColumn(db, "subagent_runs", "requester_settle_wake_batch_run_ids_json TEXT");
ensureColumn(db, "subagent_runs", "requester_settle_wake_last_error TEXT");
ensureColumn(db, "subagent_runs", "requester_settle_wake_retire_after INTEGER");
ensureColumn(db, "subagent_runs", "swarm_group_id TEXT");
ensureColumn(db, "subagent_runs", "swarm_collector INTEGER");
ensureColumn(db, "subagent_runs", "swarm_output_schema_json TEXT");
ensureColumn(db, "subagent_runs", "swarm_completion_status TEXT");
ensureColumn(db, "subagent_runs", "swarm_structured_json TEXT");
ensureColumn(db, "subagent_runs", "swarm_schema_error TEXT");
ensureColumn(db, "subagent_runs", "swarm_usage_json TEXT");
repairLegacySubagentSuspensionReasons(db);
repairLegacySubagentExecutionPayloads(db);
repairLegacySubagentRetainedResults(db);
@@ -389,6 +389,16 @@ export function detectOpenClawStateDatabaseSchemaMigrationsFromDatabase(
) {
migrations.push({ kind: "singleton-state-foldin-v12", path: pathname });
}
if (
userVersion < 13 &&
(tableHasColumn(db, "cron_jobs", "schedule_kind") ||
tableHasColumn(db, "subagent_runs", "task") ||
tableExists(db, "workspace_attestations") ||
tableExists(db, "installed_plugin_index") ||
tableExists(db, "auth_profile_stores"))
) {
migrations.push({ kind: "state-consolidation-v13", path: pathname });
}
if (!hasCanonicalAgentDatabasesPrimaryKey(db)) {
migrations.push({ kind: "agent-databases-composite-primary-key", path: pathname });
}
@@ -0,0 +1,259 @@
import type { DatabaseSync } from "node:sqlite";
import { safeParseJson } from "@openclaw/normalization-core";
import { asNullableRecord } from "@openclaw/normalization-core/record-coerce";
import { quoteSqliteIdentifier } from "../infra/sqlite-schema-sql.js";
import { repairLegacySubagentRetainedResults } from "./openclaw-state-db-legacy-backfills.js";
import { tableExists, tableHasColumn } from "./openclaw-state-db-schema-helpers.js";
import { OPENCLAW_STATE_SCHEMA_SQL } from "./openclaw-state-schema.js";
const FAILURE_DESTINATION_COLUMNS = [
["failure_delivery_mode", "mode"],
["failure_delivery_channel", "channel"],
["failure_delivery_to", "to"],
["failure_delivery_account_id", "accountId"],
] as const;
function reprojectLegacyCronJson(db: DatabaseSync): void {
const projectionColumns = FAILURE_DESTINATION_COLUMNS.map(([columnName]) =>
tableHasColumn(db, "cron_jobs", columnName)
? quoteSqliteIdentifier(columnName)
: `NULL AS ${quoteSqliteIdentifier(columnName)}`,
);
const lastRunStatus = tableHasColumn(db, "cron_jobs", "last_run_status")
? "last_run_status"
: "NULL AS last_run_status";
const rows = db
.prepare(
`SELECT store_key, job_id, job_json, state_json, ${lastRunStatus}, ${projectionColumns.join(", ")}
FROM cron_jobs`,
)
.all();
const update = db.prepare(
"UPDATE cron_jobs SET job_json = ?, state_json = ? WHERE store_key = ? AND job_id = ?",
);
for (const row of rows) {
if (
typeof row.store_key !== "string" ||
typeof row.job_id !== "string" ||
typeof row.job_json !== "string" ||
typeof row.state_json !== "string"
) {
throw new Error("OpenClaw v12 cron job row is not canonical");
}
const job = asNullableRecord(safeParseJson(row.job_json));
const state = asNullableRecord(safeParseJson(row.state_json));
if (!job || !state) {
continue;
}
let changed = false;
const delivery = asNullableRecord(job.delivery);
const destination = asNullableRecord(delivery?.failureDestination);
if (
(!Object.hasOwn(job, "delivery") || delivery !== null) &&
(!delivery || !Object.hasOwn(delivery, "failureDestination") || destination !== null)
) {
const nextDelivery = delivery ?? {};
const nextDestination = destination ?? {};
for (const [columnName, fieldName] of FAILURE_DESTINATION_COLUMNS) {
const value = row[columnName];
if (typeof value !== "string" || Object.hasOwn(nextDestination, fieldName)) {
continue;
}
nextDestination[fieldName] = value === "" ? null : value;
changed = true;
}
if (changed) {
nextDelivery.failureDestination = nextDestination;
job.delivery = nextDelivery;
}
}
const hasLegacyStatus = Object.hasOwn(state, "lastStatus");
if (
!Object.hasOwn(state, "lastRunStatus") &&
(hasLegacyStatus || typeof row.last_run_status === "string")
) {
state.lastRunStatus = hasLegacyStatus ? state.lastStatus : row.last_run_status;
changed = true;
}
if (changed) {
update.run(JSON.stringify(job), JSON.stringify(state), row.store_key, row.job_id);
}
}
}
function rebuildJsonCanonicalTable(db: DatabaseSync, tableName: string): void {
const migrationTable = `${tableName}_migration_v13`;
if (tableExists(db, migrationTable)) {
throw new Error(`OpenClaw v13 migration table already exists: ${migrationTable}`);
}
const startMarker = `CREATE TABLE IF NOT EXISTS ${tableName} (`;
const start = OPENCLAW_STATE_SCHEMA_SQL.indexOf(startMarker);
const endMarker = "\n) STRICT;";
const end = start >= 0 ? OPENCLAW_STATE_SCHEMA_SQL.indexOf(endMarker, start) : -1;
if (start < 0 || end < 0) {
throw new Error(`Canonical ${tableName} schema block is missing`);
}
const migrationSchema = OPENCLAW_STATE_SCHEMA_SQL.slice(start, end + endMarker.length).replace(
startMarker,
`CREATE TABLE ${migrationTable} (`,
);
db.exec(migrationSchema);
const columns = db
.prepare(`PRAGMA table_xinfo(${migrationTable})`)
.all()
.flatMap((column) =>
column.hidden === 0 && typeof column.name === "string"
? [quoteSqliteIdentifier(column.name)]
: [],
)
.join(", ");
db.exec(`INSERT INTO ${migrationTable} (${columns}) SELECT ${columns} FROM ${tableName};`);
db.exec(`DROP TABLE ${tableName};`);
db.exec(`ALTER TABLE ${migrationTable} RENAME TO ${tableName};`);
}
/** Fold obsolete physical projections into canonical JSON before removing their columns. */
export function migrateJsonCanonicalWideRowsV13(
db: DatabaseSync,
previousVersion: number,
): boolean {
if (previousVersion >= 13) {
return false;
}
let migrated = false;
if (tableExists(db, "cron_jobs") && tableHasColumn(db, "cron_jobs", "schedule_kind")) {
reprojectLegacyCronJson(db);
rebuildJsonCanonicalTable(db, "cron_jobs");
migrated = true;
}
const hasSetupState = tableExists(db, "workspace_setup_state");
const hasAttestations = tableExists(db, "workspace_attestations");
if (hasSetupState && !tableHasColumn(db, "workspace_setup_state", "attested_at_ms")) {
// Grow the old table, then rebuild to the canonical merged shape (version
// and updated_at relax to nullable so attestation-only rows can exist).
db.exec("ALTER TABLE workspace_setup_state ADD COLUMN attested_at_ms INTEGER;");
db.exec("ALTER TABLE workspace_setup_state ADD COLUMN attestation_updated_at_ms INTEGER;");
rebuildJsonCanonicalTable(db, "workspace_setup_state");
migrated = true;
}
if (hasAttestations) {
db.exec(`
UPDATE workspace_setup_state
SET attested_at_ms = (
SELECT attested_at_ms FROM workspace_attestations
WHERE workspace_attestations.workspace_key = workspace_setup_state.workspace_key
),
attestation_updated_at_ms = (
SELECT updated_at_ms FROM workspace_attestations
WHERE workspace_attestations.workspace_key = workspace_setup_state.workspace_key
)
WHERE workspace_key IN (SELECT workspace_key FROM workspace_attestations);
`);
// Attestation-only workspaces borrow their path from an alias when one
// exists; the legacy attestation table never stored a path, so orphans
// keep a NULL path and heal it when the workspace next appears.
db.exec(`
INSERT INTO workspace_setup_state (
workspace_key, workspace_path, attested_at_ms, attestation_updated_at_ms
)
SELECT a.workspace_key,
(SELECT alias.workspace_path FROM workspace_path_aliases alias
WHERE alias.workspace_key = a.workspace_key LIMIT 1),
a.attested_at_ms,
a.updated_at_ms
FROM workspace_attestations a
WHERE a.workspace_key NOT IN (SELECT workspace_key FROM workspace_setup_state);
`);
db.exec("DROP TABLE workspace_attestations;");
migrated = true;
}
if (
(hasSetupState || hasAttestations) &&
tableExists(db, "workspace_generated_bootstrap_hashes")
) {
// Repoint the FK to the merged table and drop hashes whose owner row is gone.
rebuildJsonCanonicalTable(db, "workspace_generated_bootstrap_hashes");
db.exec(`
DELETE FROM workspace_generated_bootstrap_hashes
WHERE workspace_key NOT IN (SELECT workspace_key FROM workspace_setup_state);
`);
}
for (const [tableName, jsonColumn, stateKey] of [
["auth_profile_stores", "store_json", "authProfiles.store"],
["auth_profile_state", "state_json", "authProfiles.state"],
] as const) {
if (!tableExists(db, tableName)) {
continue;
}
// Shared-state singletons keyed 'shared'; the agent-DB twins are untouched.
db.prepare(
`INSERT INTO config_machine_state (state_key, value_json, updated_at_ms)
SELECT ?, ${jsonColumn}, updated_at FROM ${tableName} WHERE store_key = 'shared'
ON CONFLICT(state_key) DO NOTHING`,
).run(stateKey);
db.exec(`DROP TABLE ${tableName};`);
migrated = true;
}
if (tableExists(db, "installed_plugin_index")) {
// Fold the singleton index row (revision lived in updated_at_ms) into the KV.
// workspace_dir was a same-version additive column; pre-addition rows lack it.
const workspaceDirColumn = tableHasColumn(db, "installed_plugin_index", "workspace_dir")
? "workspace_dir"
: "NULL AS workspace_dir";
const rawRow = db
.prepare(
`SELECT version, warning, host_contract_version, compat_registry_version,
migration_version, policy_hash, generated_at_ms, ${workspaceDirColumn},
refresh_reason, install_records_json, plugins_json, diagnostics_json,
updated_at_ms
FROM installed_plugin_index
WHERE index_key = 'installed-plugin-index'`,
)
.get();
const installRecords = asNullableRecord(
safeParseJson(String(rawRow?.install_records_json ?? "")),
);
const plugins = safeParseJson(String(rawRow?.plugins_json ?? ""));
const diagnostics = safeParseJson(String(rawRow?.diagnostics_json ?? ""));
const row =
rawRow && installRecords && Array.isArray(plugins) && Array.isArray(diagnostics)
? rawRow
: undefined;
if (row) {
const index = {
version: Number(row.version),
...(typeof row.warning === "string" && row.warning ? { warning: row.warning } : {}),
hostContractVersion: row.host_contract_version,
compatRegistryVersion: row.compat_registry_version,
migrationVersion: Number(row.migration_version),
policyHash: row.policy_hash,
generatedAtMs: Number(row.generated_at_ms),
...(typeof row.workspace_dir === "string" ? { workspaceDir: row.workspace_dir } : {}),
...(typeof row.refresh_reason === "string" && row.refresh_reason
? { refreshReason: row.refresh_reason }
: {}),
installRecords,
plugins,
diagnostics,
};
db.prepare(
`INSERT INTO config_machine_state (state_key, value_json, updated_at_ms)
VALUES (?, ?, ?) ON CONFLICT(state_key) DO NOTHING`,
).run(
"plugins.installedIndex",
JSON.stringify({ revision: Number(row.updated_at_ms), index }),
Number(row.updated_at_ms),
);
}
db.exec("DROP TABLE installed_plugin_index;");
migrated = true;
}
if (tableExists(db, "subagent_runs") && tableHasColumn(db, "subagent_runs", "task")) {
// Shipped pending-delivery columns can hold the only surviving result text.
repairLegacySubagentRetainedResults(db);
rebuildJsonCanonicalTable(db, "subagent_runs");
migrated = true;
}
return migrated;
}
+5 -155
View File
@@ -145,18 +145,6 @@ export interface AuditIdentityKeys {
key_id: string;
}
export interface AuthProfileState {
state_json: string;
store_key: string;
updated_at: number;
}
export interface AuthProfileStores {
store_json: string;
store_key: string;
updated_at: number;
}
export interface BackupRuns {
archive_path: string;
created_at: number;
@@ -381,80 +369,20 @@ export interface CronJobScratch {
export interface CronJobs {
agent_id: string | null;
anchor_ms: number | null;
at: string | null;
consecutive_errors: number | null;
consecutive_skipped: number | null;
created_at_ms: number;
declaration_key: string | null;
delete_after_run: number | null;
delivery_account_id: string | null;
delivery_best_effort: number | null;
delivery_channel: string | null;
delivery_completion_mode: string | null;
delivery_completion_to: string | null;
delivery_mode: string | null;
delivery_thread_id: string | null;
delivery_thread_id_type: string | null;
delivery_to: string | null;
description: string | null;
display_name: string | null;
enabled: number;
every_ms: number | null;
failure_alert_account_id: string | null;
failure_alert_after: number | null;
failure_alert_channel: string | null;
failure_alert_cooldown_ms: number | null;
failure_alert_disabled: number | null;
failure_alert_include_skipped: number | null;
failure_alert_mode: string | null;
failure_alert_to: string | null;
failure_delivery_account_id: string | null;
failure_delivery_channel: string | null;
failure_delivery_mode: string | null;
failure_delivery_to: string | null;
job_id: string;
job_json: string;
last_delivered: number | null;
last_delivery_error: string | null;
last_delivery_status: string | null;
last_duration_ms: number | null;
last_error: string | null;
last_failure_alert_at_ms: number | null;
last_run_at_ms: number | null;
last_run_status: string | null;
name: string;
next_run_at_ms: number | null;
owner_agent_id: string | null;
owner_session_key: string | null;
payload_allow_unsafe_external_content: number | null;
payload_external_content_source_json: string | null;
payload_fallbacks_json: string | null;
payload_kind: string;
payload_light_context: number | null;
payload_message: string | null;
payload_model: string | null;
payload_thinking: string | null;
payload_timeout_seconds: number | null;
payload_tools_allow_is_default: number | null;
payload_tools_allow_json: string | null;
running_at_ms: number | null;
runtime_updated_at_ms: number | null;
schedule_error_count: number | null;
schedule_expr: string | null;
schedule_identity: string | null;
schedule_kind: string;
schedule_tz: string | null;
session_key: string | null;
session_target: string;
sort_order: Generated<number>;
stagger_ms: number | null;
state_json: Generated<string>;
store_key: string;
trigger_once: number | null;
trigger_script: string | null;
updated_at: number;
wake_mode: string;
}
export interface CronRunReceipts {
@@ -797,23 +725,6 @@ export interface GithubPublicationRequests {
worktree_id: string;
}
export interface InstalledPluginIndex {
compat_registry_version: string;
diagnostics_json: string;
generated_at_ms: number;
host_contract_version: string;
index_key: string;
install_records_json: string;
migration_version: number;
plugins_json: string;
policy_hash: string;
refresh_reason: string | null;
updated_at_ms: number;
version: number;
warning: string | null;
workspace_dir: string | null;
}
export interface MacosPortGuardianRecords {
command: string;
mode: string;
@@ -1297,65 +1208,12 @@ export interface StateLeases {
}
export interface SubagentRuns {
accumulated_runtime_ms: number | null;
agent_dir: string | null;
announce_retry_count: number | null;
archive_at_ms: number | null;
child_session_key: string;
cleanup: string;
cleanup_completed_at: number | null;
cleanup_handled: number | null;
completion_announced_at: number | null;
controller_session_key: string | null;
created_at: number;
ended_at: number | null;
ended_hook_emitted_at: number | null;
ended_reason: string | null;
expects_completion_message: number | null;
fallback_frozen_result_captured_at: number | null;
fallback_frozen_result_text: string | null;
frozen_result_captured_at: number | null;
frozen_result_text: string | null;
label: string | null;
last_announce_delivery_error: string | null;
last_announce_retry_at: number | null;
model: string | null;
outcome_json: string | null;
pause_reason: string | null;
payload_json: Generated<string>;
pending_final_delivery: number | null;
pending_final_delivery_attempt_count: number | null;
pending_final_delivery_created_at: number | null;
pending_final_delivery_last_attempt_at: number | null;
pending_final_delivery_last_error: string | null;
pending_final_delivery_payload_json: string | null;
requester_display_key: string;
requester_origin_json: string | null;
requester_session_key: string;
requester_settle_wake_attempt_count: number | null;
requester_settle_wake_batch_run_ids_json: string | null;
requester_settle_wake_last_error: string | null;
requester_settle_wake_next_attempt_at: number | null;
requester_settle_wake_replay_count: number | null;
requester_settle_wake_retire_after: number | null;
requester_settle_wake_status: string | null;
run_id: string;
run_timeout_seconds: number | null;
session_started_at: number | null;
spawn_mode: string | null;
started_at: number | null;
suppress_announce_reason: string | null;
swarm_collector: number | null;
swarm_completion_status: string | null;
swarm_group_id: string | null;
swarm_output_schema_json: string | null;
swarm_schema_error: string | null;
swarm_structured_json: string | null;
swarm_usage_json: string | null;
task: string;
task_name: string | null;
wake_on_descendant_settle: number | null;
workspace_dir: string | null;
}
export interface TaskDeliveryState {
@@ -1589,12 +1447,6 @@ export interface WorkerWorkspaceReconciliations {
session_id: string;
}
export interface WorkspaceAttestations {
attested_at_ms: number;
updated_at_ms: number;
workspace_key: string;
}
export interface WorkspaceGeneratedBootstrapHashes {
filename: string;
sha256: string;
@@ -1610,12 +1462,14 @@ export interface WorkspacePathAliases {
}
export interface WorkspaceSetupState {
attestation_updated_at_ms: number | null;
attested_at_ms: number | null;
bootstrap_seeded_at: string | null;
setup_completed_at: string | null;
updated_at: number;
version: number;
updated_at: number | null;
version: number | null;
workspace_key: string;
workspace_path: string;
workspace_path: string | null;
}
export interface WorktreeProvisionedFileChunks {
@@ -1654,8 +1508,6 @@ export interface DB {
apns_registrations: ApnsRegistrations;
audit_events: AuditEvents;
audit_identity_keys: AuditIdentityKeys;
auth_profile_state: AuthProfileState;
auth_profile_stores: AuthProfileStores;
backup_runs: BackupRuns;
capture_blobs: CaptureBlobs;
capture_events: CaptureEvents;
@@ -1698,7 +1550,6 @@ export interface DB {
gateway_restart_intent: GatewayRestartIntent;
gateway_restart_sentinel: GatewayRestartSentinel;
github_publication_requests: GithubPublicationRequests;
installed_plugin_index: InstalledPluginIndex;
macos_port_guardian_records: MacosPortGuardianRecords;
managed_outgoing_image_records: ManagedOutgoingImageRecords;
mcp_oauth_pending_authorizations: McpOauthPendingAuthorizations;
@@ -1755,7 +1606,6 @@ export interface DB {
worker_turn_tool_authorities: WorkerTurnToolAuthorities;
worker_workspace_pending_results: WorkerWorkspacePendingResults;
worker_workspace_reconciliations: WorkerWorkspaceReconciliations;
workspace_attestations: WorkspaceAttestations;
workspace_generated_bootstrap_hashes: WorkspaceGeneratedBootstrapHashes;
workspace_path_aliases: WorkspacePathAliases;
workspace_setup_state: WorkspaceSetupState;
File diff suppressed because it is too large Load Diff
+8 -19
View File
@@ -55,15 +55,9 @@ import {
} from "./openclaw-state-db-fast-path.js";
import {
assertOpenClawStateDatabaseForMaintenance,
assertOpenClawStateDatabaseV10ForMigration,
assertOpenClawStateDatabaseV11ForMigration,
assertOpenClawStateDatabaseV5ForMigration,
assertOpenClawStateDatabaseV6ForMigration,
assertOpenClawStateDatabaseV7ForMigration,
assertOpenClawStateDatabaseV8ForMigration,
assertOpenClawStateDatabaseV9ForMigration,
assertSupportedSchemaVersion,
markCurrentStateSchemaVersion,
openClawStateMigrationAssertions,
resolveDatabasePath,
} from "./openclaw-state-db-maintenance.js";
import { openUnpublishedStateDatabase } from "./openclaw-state-db-open.js";
@@ -85,6 +79,7 @@ import {
repairLegacyGatewayRestartHandoffsForStrictMigration,
} from "./openclaw-state-db-schema-repair.js";
import { migrateSingletonStateFoldInV12 } from "./openclaw-state-db-schema-v12-foldin.js";
import { migrateJsonCanonicalWideRowsV13 } from "./openclaw-state-db-schema-v13-widerow.js";
import * as sessionWatchMigration from "./openclaw-state-db-session-watch-migration.js";
import { withOpenClawStateStartupCheckpointConnection } from "./openclaw-state-db-startup-checkpoint.js";
import * as retirements from "./openclaw-state-db-table-retirements.js";
@@ -100,16 +95,6 @@ import { getOpenClawStateRuntimeSchema } from "./openclaw-state-schema-compatibi
import { OPENCLAW_STATE_SCHEMA_SQL } from "./openclaw-state-schema.js";
export { registerOpenClawStateDatabaseLifecycleListener } from "./openclaw-state-db-cache.js";
const STATE_MIGRATION_ASSERTIONS = new Map([
[5, assertOpenClawStateDatabaseV5ForMigration],
[6, assertOpenClawStateDatabaseV6ForMigration],
[7, assertOpenClawStateDatabaseV7ForMigration],
[8, assertOpenClawStateDatabaseV8ForMigration],
[9, assertOpenClawStateDatabaseV9ForMigration],
[10, assertOpenClawStateDatabaseV10ForMigration],
[11, assertOpenClawStateDatabaseV11ForMigration],
]);
export {
OPENCLAW_DATABASE_SCHEMA_DOCS_URL,
OPENCLAW_SQLITE_BUSY_TIMEOUT_MS,
@@ -201,7 +186,7 @@ function repairOpenClawStateDatabaseSchemaWithWriteAccess(
allowedMissingTables: LAZY_ADDITIVE_STATE_TABLES,
});
} else {
STATE_MIGRATION_ASSERTIONS.get(previousVersion)?.(db, { pathname });
openClawStateMigrationAssertions.get(previousVersion)?.(db, { pathname });
}
if (rebuiltIndexNames.size === 0) {
assertSqliteIntegrity(db, pathname);
@@ -237,6 +222,9 @@ function repairOpenClawStateDatabaseSchemaWithWriteAccess(
assertCanonicalStateSchemaShape(db, pathname);
if (tableExists(db, "audit_events")) {
ensureAdditiveStateColumns(db);
if (migrateJsonCanonicalWideRowsV13(db, previousVersion)) {
applied.push("Consolidated shared state tables (v13)");
}
executeCanonicalStateSchema(db, {
includeVersionLazyAdditiveTables: previousVersion !== OPENCLAW_STATE_SCHEMA_VERSION,
});
@@ -410,7 +398,7 @@ function ensureSchema(
ensureAdditiveStateColumns(db);
assertCurrentStateRuntimeSchema(db, pathname);
} else {
STATE_MIGRATION_ASSERTIONS.get(previousVersion)?.(db, { pathname });
openClawStateMigrationAssertions.get(previousVersion)?.(db, { pathname });
}
dropLegacyStateTables(db);
const retirementMessages = retirements.runRetiredStateTableMigrations(db, previousVersion);
@@ -418,6 +406,7 @@ function ensureSchema(
migrateWorkerPlacementExecutionModeSchema(db, previousVersion);
const pathMigration: AgentPathSummary = migrateAgentPaths(db, previousVersion, pathname);
ensureAdditiveStateColumns(db);
migrateJsonCanonicalWideRowsV13(db, previousVersion);
sessionWatchMigration.migrateSessionWatchCursorProvenance(db);
assertCanonicalStateSchemaShape(db, pathname);
executeCanonicalStateSchema(db, {
@@ -92,13 +92,9 @@ export const STATE_PERSISTENT_SCHEMA_COMPATIBILITY: SqliteSchemaCompatibility =
"package_integrity TEXT NOT NULL DEFAULT 'sha256:0000000000000000000000000000000000000000000000000000000000000000'",
],
"claw_package_refs.updated_at_ms": ["updated_at_ms INTEGER NOT NULL DEFAULT 0"],
"cron_jobs.created_at_ms": ["created_at_ms INTEGER NOT NULL DEFAULT 0"],
"cron_jobs.enabled": ["enabled INTEGER NOT NULL DEFAULT 1"],
"cron_jobs.name": ["name TEXT NOT NULL DEFAULT ''"],
"cron_jobs.payload_kind": ["payload_kind TEXT NOT NULL DEFAULT 'message'"],
"cron_jobs.schedule_kind": ["schedule_kind TEXT NOT NULL DEFAULT 'manual'"],
"cron_jobs.session_target": ["session_target TEXT NOT NULL DEFAULT 'main'"],
"cron_jobs.wake_mode": ["wake_mode TEXT NOT NULL DEFAULT 'auto'"],
"current_conversation_bindings.conversation_kind": [
"conversation_kind TEXT NOT NULL DEFAULT 'channel'",
],

Some files were not shown because too many files have changed in this diff Show More