From 1ea2640f5428eacb70e182137e9501fbdfd8cbca Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 27 Aug 2026 00:26:14 -0700 Subject: [PATCH] refactor(state): consolidate wide rows, plugin index, workspace attestations, and shared auth singletons at schema v13 (#130466) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 9a93a52a8a2 and 473962b7def, 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 --- .../PortGuardianRecordStoreTests.swift | 4 +- .../OpenClawNativeStateSQLite.swift | 2 +- config/assertion-safety-baseline.txt | 11 +- docs/cli/backup.md | 13 +- docs/cli/plugins.md | 4 +- docs/install/backups.md | 19 +- docs/plugins/architecture-internals.md | 9 +- docs/refactor/database-first.md | 54 +- docs/reference/database-schemas.md | 515 ++++++++- extensions/voice-call/doctor-contract-api.ts | 2 + package.json | 2 +- scripts/bench-agent-concurrency-worker.ts | 10 +- scripts/bench-sqlite-state.ts | 103 +- scripts/check-kysely-guardrails.mts | 1 + .../e2e/lib/auth-profile-store-assertions.mjs | 10 +- scripts/e2e/lib/plugin-index-sqlite.mjs | 120 +- .../lib/upgrade-survivor/sqlite-volume.mjs | 26 +- scripts/lib/live-docker-stage.sh | 4 +- src/agents/auth-profiles.sqlite-store.test.ts | 22 +- src/agents/auth-profiles/sqlite.ts | 146 +-- ...ubagent-completion-admission.store.test.ts | 4 +- .../subagent-registry.store.sqlite.test.ts | 53 +- .../subagent-registry.store.sqlite.ts | 111 +- src/agents/workspace-sqlite-safety.test.ts | 16 +- src/agents/workspace-state-store.test.ts | 12 +- src/agents/workspace-state-store.ts | 48 +- .../message-delivery-progress-store.test.ts | 8 +- src/claws/lifecycle-state.test.ts | 82 +- .../doctor-auth-migration-receipts.ts | 47 +- src/commands/doctor-plugin-registry.test.ts | 33 +- src/commands/doctor/cron/index.test.ts | 107 -- src/commands/doctor/cron/index.ts | 17 - src/commands/doctor/cron/legacy-repair.ts | 52 +- src/commands/doctor/cron/repair-plan.ts | 42 - src/commands/doctor/repair-sequencing.test.ts | 38 +- src/commands/doctor/repair-sequencing.ts | 7 - ...t-agent-role-materialization.write.test.ts | 6 +- .../doctor/shared/deprecation-compat.ts | 2 +- .../shared/plugin-registry-migration.test.ts | 55 +- .../shared/plugin-registry-migration.ts | 2 +- src/cron/delivery-plan.ts | 8 +- src/cron/delivery.test.ts | 16 + ...gacy-default-agent-owner-migration.test.ts | 4 +- src/cron/service.cross-tick-admission.test.ts | 10 +- .../service/ops.run-admission-cleanup.test.ts | 8 +- src/cron/service/ops.run-admission.test.ts | 7 +- src/cron/service/ops.test.ts | 26 +- src/cron/service/owner-hardening.test.ts | 11 +- src/cron/service/store.test.ts | 81 +- src/cron/service/store.ts | 3 + src/cron/service/timer.test.ts | 2 +- src/cron/store.test.ts | 170 ++- src/cron/store/delivery-codec.ts | 172 +-- src/cron/store/failure-alert-codec.test.ts | 25 +- src/cron/store/failure-alert-codec.ts | 77 -- src/cron/store/payload-codec.ts | 238 ---- src/cron/store/row-codec.schedule.test.ts | 21 +- src/cron/store/row-codec.ts | 349 +----- src/cron/store/scalar-codec.ts | 27 - src/cron/store/state-codec.ts | 78 -- src/cron/store/trigger-codec.test.ts | 12 - src/cron/store/trigger-codec.ts | 25 - src/cron/types.ts | 2 +- .../placement-store.move.test.ts | 2 +- src/infra/device-auth-store.test.ts | 41 - src/infra/device-auth-store.ts | 36 +- src/infra/state-migrations.audit-backup.ts | 2 + src/infra/state-migrations.doctor.ts | 2 + ...rations.onboarding-recommendations.test.ts | 122 -- ...e-migrations.onboarding-recommendations.ts | 113 -- ...state-migrations.shared-auth-store.test.ts | 38 +- .../state-migrations.shared-auth-store.ts | 65 +- src/infra/state-migrations.state-dir.test.ts | 29 +- ...state-migrations.subagent-registry.test.ts | 3 - ...grations.workspace-setup-recreated.test.ts | 4 +- .../state-migrations.workspace-setup-store.ts | 97 +- .../state-migrations.workspace-setup.test.ts | 10 +- .../node-worker-launch-store.test.ts | 10 +- .../installed-plugin-index-record-state.ts | 26 +- .../installed-plugin-index-records.test.ts | 13 +- ...gin-index-store.install-record-map.test.ts | 17 +- .../installed-plugin-index-store.test.ts | 56 +- src/plugins/installed-plugin-index-store.ts | 170 ++- src/secrets/apply.test.ts | 4 +- src/secrets/audit.test.ts | 4 +- src/snapshot/git-backup.test.ts | 14 +- .../openclaw-database-maintenance.test.ts | 9 +- src/state/openclaw-schema-retirements.json | 83 +- .../openclaw-state-db-additive-columns.ts | 1 - src/state/openclaw-state-db-contract.ts | 5 +- ...openclaw-state-db-legacy-backfills.test.ts | 146 +-- .../openclaw-state-db-legacy-backfills.ts | 228 +--- src/state/openclaw-state-db-maintenance.ts | 37 +- .../openclaw-state-db-schema-additive.ts | 79 -- src/state/openclaw-state-db-schema-repair.ts | 10 + .../openclaw-state-db-schema-v13-widerow.ts | 259 +++++ src/state/openclaw-state-db.generated.d.ts | 160 +-- src/state/openclaw-state-db.test.ts | 1016 +++++++++++++++-- src/state/openclaw-state-db.ts | 27 +- .../openclaw-state-schema-compatibility.ts | 4 - ...w-state-schema-v13-widerow.test-support.ts | 469 ++++++++ src/state/openclaw-state-schema.sql | 178 +-- src/state/secret-state-tables.ts | 8 +- src/state/sqlite-query-plan.test.ts | 12 - src/state/user-profiles.test.ts | 2 +- .../auth-profile-store-assertions.test.ts | 18 +- .../check-native-state-schema-version.test.ts | 2 +- test/scripts/codex-install-assertions.test.ts | 12 +- test/scripts/live-docker-stage.test.ts | 4 +- ...m-onboard-channel-agent-assertions.test.ts | 12 +- test/scripts/plugin-index-sqlite.test.ts | 54 +- ...lease-plugin-marketplace-lifecycle.test.ts | 70 +- .../release-scenarios-assertions.test.ts | 12 +- 113 files changed, 3505 insertions(+), 3481 deletions(-) delete mode 100644 src/cron/store/failure-alert-codec.ts delete mode 100644 src/cron/store/payload-codec.ts delete mode 100644 src/cron/store/state-codec.ts delete mode 100644 src/cron/store/trigger-codec.ts delete mode 100644 src/infra/state-migrations.onboarding-recommendations.test.ts delete mode 100644 src/infra/state-migrations.onboarding-recommendations.ts create mode 100644 src/state/openclaw-state-db-schema-v13-widerow.ts create mode 100644 src/state/openclaw-state-schema-v13-widerow.test-support.ts diff --git a/apps/macos/Tests/OpenClawIPCTests/PortGuardianRecordStoreTests.swift b/apps/macos/Tests/OpenClawIPCTests/PortGuardianRecordStoreTests.swift index 229211ab3b91..7a8d6868fdc9 100644 --- a/apps/macos/Tests/OpenClawIPCTests/PortGuardianRecordStoreTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/PortGuardianRecordStoreTests.swift @@ -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) { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawNativeState/OpenClawNativeStateSQLite.swift b/apps/shared/OpenClawKit/Sources/OpenClawNativeState/OpenClawNativeStateSQLite.swift index 89bcc3a0c70e..1d9480b85c2a 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawNativeState/OpenClawNativeStateSQLite.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawNativeState/OpenClawNativeStateSQLite.swift @@ -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 { diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index c212b72949c4..28e5848125e9 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -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 diff --git a/docs/cli/backup.md b/docs/cli/backup.md index 440d536ebaa1..322ea8636287 100644 --- a/docs/cli/backup.md +++ b/docs/cli/backup.md @@ -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. 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 ` 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 ` 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 diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 8178c105b417..572e8bbb5e79 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -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 diff --git a/docs/install/backups.md b/docs/install/backups.md index 1f62d4d2d987..1ddf0073f275 100644 --- a/docs/install/backups.md +++ b/docs/install/backups.md @@ -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 ` 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 ` 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: diff --git a/docs/plugins/architecture-internals.md b/docs/plugins/architecture-internals.md index 7e86b927e3a0..554f0b08a6f0 100644 --- a/docs/plugins/architecture-internals.md +++ b/docs/plugins/architecture-internals.md @@ -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 diff --git a/docs/refactor/database-first.md b/docs/refactor/database-first.md index a79f58f64795..1baa1bd5ed54 100644 --- a/docs/refactor/database-first.md +++ b/docs/refactor/database-first.md @@ -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/` 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.`, `tui.lastSession.`, `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. diff --git a/docs/reference/database-schemas.md b/docs/reference/database-schemas.md index 32c384d1b63f..5da5b570a0e3 100644 --- a/docs/reference/database-schemas.md +++ b/docs/reference/database-schemas.md @@ -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. diff --git a/extensions/voice-call/doctor-contract-api.ts b/extensions/voice-call/doctor-contract-api.ts index 3bce91ab9a2d..b2bcc6f0ace2 100644 --- a/extensions/voice-call/doctor-contract-api.ts +++ b/extensions/voice-call/doctor-contract-api.ts @@ -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": diff --git a/package.json b/package.json index e885f2c00196..4edabf59dfd6 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "2026.8.1", "openclaw": { "schemaVersions": { - "state": 12, + "state": 13, "agent": 17 } }, diff --git a/scripts/bench-agent-concurrency-worker.ts b/scripts/bench-agent-concurrency-worker.ts index 0c8d90554b97..3893eaf83810 100644 --- a/scripts/bench-agent-concurrency-worker.ts +++ b/scripts/bench-agent-concurrency-worker.ts @@ -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("json_extract", ["payload_json", eb.val("$.execution.endedAt")]) + .as("ended_at"), + ]) + .orderBy("run_id"), ).rows, taskRows: executeSqliteQuerySync( database.db, diff --git a/scripts/bench-sqlite-state.ts b/scripts/bench-sqlite-state.ts index fc5373c15e5b..8383117de87a 100644 --- a/scripts/bench-sqlite-state.ts +++ b/scripts/bench-sqlite-state.ts @@ -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, ), diff --git a/scripts/check-kysely-guardrails.mts b/scripts/check-kysely-guardrails.mts index 0691a0fc3000..8d329feeeb95 100644 --- a/scripts/check-kysely-guardrails.mts +++ b/scripts/check-kysely-guardrails.mts @@ -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", diff --git a/scripts/e2e/lib/auth-profile-store-assertions.mjs b/scripts/e2e/lib/auth-profile-store-assertions.mjs index 070d0a7d445e..1cef42818607 100644 --- a/scripts/e2e/lib/auth-profile-store-assertions.mjs +++ b/scripts/e2e/lib/auth-profile-store-assertions.mjs @@ -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}`, { diff --git a/scripts/e2e/lib/plugin-index-sqlite.mjs b/scripts/e2e/lib/plugin-index-sqlite.mjs index 76be8acccea8..0fe3d3f749d6 100644 --- a/scripts/e2e/lib/plugin-index-sqlite.mjs +++ b/scripts/e2e/lib/plugin-index-sqlite.mjs @@ -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(); } diff --git a/scripts/e2e/lib/upgrade-survivor/sqlite-volume.mjs b/scripts/e2e/lib/upgrade-survivor/sqlite-volume.mjs index 1c4f48ef32f3..40d9733a496e 100644 --- a/scripts/e2e/lib/upgrade-survivor/sqlite-volume.mjs +++ b/scripts/e2e/lib/upgrade-survivor/sqlite-volume.mjs @@ -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}`, ); } diff --git a/scripts/lib/live-docker-stage.sh b/scripts/lib/live-docker-stage.sh index 2dceb828458f..29e20040e19d 100644 --- a/scripts/lib/live-docker-stage.sh +++ b/scripts/lib/live-docker-stage.sh @@ -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 \ diff --git a/src/agents/auth-profiles.sqlite-store.test.ts b/src/agents/auth-profiles.sqlite-store.test.ts index 05a2086e2f3c..9fc21d85560d 100644 --- a/src/agents/auth-profiles.sqlite-store.test.ts +++ b/src/agents/auth-profiles.sqlite-store.test.ts @@ -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"); diff --git a/src/agents/auth-profiles/sqlite.ts b/src/agents/auth-profiles/sqlite.ts index 709e3720a077..d57f360c03b4 100644 --- a/src/agents/auth-profiles/sqlite.ts +++ b/src/agents/auth-profiles/sqlite.ts @@ -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; 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(); const sharedAuthPostCommitPublications = new WeakMap 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); diff --git a/src/agents/subagents/completion/subagent-completion-admission.store.test.ts b/src/agents/subagents/completion/subagent-completion-admission.store.test.ts index dea61343cea8..d5a2ad15e704 100644 --- a/src/agents/subagents/completion/subagent-completion-admission.store.test.ts +++ b/src/agents/subagents/completion/subagent-completion-admission.store.test.ts @@ -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'") diff --git a/src/agents/subagents/registry/subagent-registry.store.sqlite.test.ts b/src/agents/subagents/registry/subagent-registry.store.sqlite.test.ts index 10049391cf89..ac707ae3c9f5 100644 --- a/src/agents/subagents/registry/subagent-registry.store.sqlite.test.ts +++ b/src/agents/subagents/registry/subagent-registry.store.sqlite.test.ts @@ -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(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 }, diff --git a/src/agents/subagents/registry/subagent-registry.store.sqlite.ts b/src/agents/subagents/registry/subagent-registry.store.sqlite.ts index 85a739456059..7db2113fd128 100644 --- a/src/agents/subagents/registry/subagent-registry.store.sqlite.ts +++ b/src/agents/subagents/registry/subagent-registry.store.sqlite.ts @@ -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; 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("$.model").as("model"), + subagentPayloadJsonValue("$.runTimeoutSeconds").as("run_timeout_seconds"), + subagentPayloadJsonValue("$.execution.startedAt").as("started_at"), + subagentPayloadJsonValue("$.sessionStartedAt").as("session_started_at"), + subagentPayloadJsonValue("$.accumulatedRuntimeMs").as( + "accumulated_runtime_ms", + ), + subagentPayloadJsonValue("$.execution.endedAt").as("ended_at"), + subagentPayloadJsonValue("$.endedReason").as("ended_reason"), + subagentPayloadJsonValue("$.cleanupCompletedAt").as("cleanup_completed_at"), subagentPayloadJsonValue("$.generation").as("generation"), subagentPayloadJsonValue("$.execution.outcome.status").as("outcome_status"), subagentPayloadJsonValue("$.delivery.status").as("delivery_status"), diff --git a/src/agents/workspace-sqlite-safety.test.ts b/src/agents/workspace-sqlite-safety.test.ts index 40a594e1d42c..31e4cfbc5b49 100644 --- a/src/agents/workspace-sqlite-safety.test.ts +++ b/src/agents/workspace-sqlite-safety.test.ts @@ -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, diff --git a/src/agents/workspace-state-store.test.ts b/src/agents/workspace-state-store.test.ts index e740c4441e77..d18fc5cc2a23 100644 --- a/src/agents/workspace-state-store.test.ts +++ b/src/agents/workspace-state-store.test.ts @@ -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)], diff --git a/src/agents/workspace-state-store.ts b/src/agents/workspace-state-store.ts index 02340398097c..c62b4dba009c 100644 --- a/src/agents/workspace-state-store.ts +++ b/src/agents/workspace-state-store.ts @@ -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(); - 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), diff --git a/src/audit/message-delivery-progress-store.test.ts b/src/audit/message-delivery-progress-store.test.ts index be4d4b1cf154..86ac33907e6b 100644 --- a/src/audit/message-delivery-progress-store.test.ts +++ b/src/audit/message-delivery-progress-store.test.ts @@ -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); diff --git a/src/claws/lifecycle-state.test.ts b/src/claws/lifecycle-state.test.ts index 6c33e88c356e..10eda861f920 100644 --- a/src/claws/lifecycle-state.test.ts +++ b/src/claws/lifecycle-state.test.ts @@ -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[ }; } +function seedAttachedCronJob( + env: NodeJS.ProcessEnv, + job: Pick, +): 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, diff --git a/src/commands/doctor-auth-migration-receipts.ts b/src/commands/doctor-auth-migration-receipts.ts index ea99166fff17..dc650911cef2 100644 --- a/src/commands/doctor-auth-migration-receipts.ts +++ b/src/commands/doctor-auth-migration-receipts.ts @@ -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; 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(db) - .selectFrom("auth_profile_stores") - .select("store_json") - .where("store_key", "=", "shared"), + ? projectSharedStoreCell( + executeSqliteQueryTakeFirstSync( + db, + getNodeSqliteKysely(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(db) - .selectFrom("auth_profile_state") - .select("state_json") - .where("store_key", "=", "shared"), + ? projectSharedStateCell( + executeSqliteQueryTakeFirstSync( + db, + getNodeSqliteKysely(db) + .selectFrom("config_machine_state") + .select("value_json") + .where("state_key", "=", "authProfiles.state"), + ), ) : executeSqliteQueryTakeFirstSync( db, diff --git a/src/commands/doctor-plugin-registry.test.ts b/src/commands/doctor-plugin-registry.test.ts index ef3e73e6708a..106e1977fb87 100644 --- a/src/commands/doctor-plugin-registry.test.ts +++ b/src/commands/doctor-plugin-registry.test.ts @@ -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 () => { diff --git a/src/commands/doctor/cron/index.test.ts b/src/commands/doctor/cron/index.test.ts index f5a995b978aa..b47fec86c2a3 100644 --- a/src/commands/doctor/cron/index.test.ts +++ b/src/commands/doctor/cron/index.test.ts @@ -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, - 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>) { 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()]); diff --git a/src/commands/doctor/cron/index.ts b/src/commands/doctor/cron/index.ts index 0716418e834d..b8b1170839cc 100644 --- a/src/commands/doctor/cron/index.ts +++ b/src/commands/doctor/cron/index.ts @@ -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`, diff --git a/src/commands/doctor/cron/legacy-repair.ts b/src/commands/doctor/cron/legacy-repair.ts index 0331892bb337..781c50db3b3b 100644 --- a/src/commands/doctor/cron/legacy-repair.ts +++ b/src/commands/doctor/cron/legacy-repair.ts @@ -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; rawJobs: Array>; @@ -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>); - 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 || diff --git a/src/commands/doctor/cron/repair-plan.ts b/src/commands/doctor/cron/repair-plan.ts index 4e8ad587ac59..b53852e26d6a 100644 --- a/src/commands/doctor/cron/repair-plan.ts +++ b/src/commands/doctor/cron/repair-plan.ts @@ -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; - 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; -} diff --git a/src/commands/doctor/repair-sequencing.test.ts b/src/commands/doctor/repair-sequencing.test.ts index b0a92e7f4ef2..3d8643a55efb 100644 --- a/src/commands/doctor/repair-sequencing.test.ts +++ b/src/commands/doctor/repair-sequencing.test.ts @@ -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."], }); diff --git a/src/commands/doctor/repair-sequencing.ts b/src/commands/doctor/repair-sequencing.ts index 1ce43a8ba874..4a747628629b 100644 --- a/src/commands/doctor/repair-sequencing.ts +++ b/src/commands/doctor/repair-sequencing.ts @@ -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 }, diff --git a/src/commands/doctor/shared/default-agent-role-materialization.write.test.ts b/src/commands/doctor/shared/default-agent-role-materialization.write.test.ts index 34ebb2665bc4..b4846e73bb1d 100644 --- a/src/commands/doctor/shared/default-agent-role-materialization.write.test.ts +++ b/src/commands/doctor/shared/default-agent-role-materialization.write.test.ts @@ -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, diff --git a/src/commands/doctor/shared/deprecation-compat.ts b/src/commands/doctor/shared/deprecation-compat.ts index 3bca4c3b721a..23ed7d612634 100644 --- a/src/commands/doctor/shared/deprecation-compat.ts +++ b/src/commands/doctor/shared/deprecation-compat.ts @@ -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", diff --git a/src/commands/doctor/shared/plugin-registry-migration.test.ts b/src/commands/doctor/shared/plugin-registry-migration.test.ts index e004dcbeadcc..7c858e30ca2a 100644 --- a/src/commands/doctor/shared/plugin-registry-migration.test.ts +++ b/src/commands/doctor/shared/plugin-registry-migration.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 () => { diff --git a/src/commands/doctor/shared/plugin-registry-migration.ts b/src/commands/doctor/shared/plugin-registry-migration.ts index 88e09136fbae..e12c2e7c5534 100644 --- a/src/commands/doctor/shared/plugin-registry-migration.ts +++ b/src/commands/doctor/shared/plugin-registry-migration.ts @@ -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(" "); } diff --git a/src/cron/delivery-plan.ts b/src/cron/delivery-plan.ts index d1b53af40870..82728d6dadd6 100644 --- a/src/cron/delivery-plan.ts +++ b/src/cron/delivery-plan.ts @@ -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 }); diff --git a/src/cron/delivery.test.ts b/src/cron/delivery.test.ts index 6cc5097e33cf..50398285a43d 100644 --- a/src/cron/delivery.test.ts +++ b/src/cron/delivery.test.ts @@ -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( diff --git a/src/cron/legacy-default-agent-owner-migration.test.ts b/src/cron/legacy-default-agent-owner-migration.test.ts index bbc8ccb31094..90d4a45f10a1 100644 --- a/src/cron/legacy-default-agent-owner-migration.test.ts +++ b/src/cron/legacy-default-agent-owner-migration.test.ts @@ -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); diff --git a/src/cron/service.cross-tick-admission.test.ts b/src/cron/service.cross-tick-admission.test.ts index 837a3746b047..e47f733cc01f 100644 --- a/src/cron/service.cross-tick-admission.test.ts +++ b/src/cron/service.cross-tick-admission.test.ts @@ -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; }); } diff --git a/src/cron/service/ops.run-admission-cleanup.test.ts b/src/cron/service/ops.run-admission-cleanup.test.ts index 1c1eb31e47b0..b692e30f62e4 100644 --- a/src/cron/service/ops.run-admission-cleanup.test.ts +++ b/src/cron/service/ops.run-admission-cleanup.test.ts @@ -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}`); diff --git a/src/cron/service/ops.run-admission.test.ts b/src/cron/service/ops.run-admission.test.ts index 1827c193b747..50b85c075b82 100644 --- a/src/cron/service/ops.run-admission.test.ts +++ b/src/cron/service/ops.run-admission.test.ts @@ -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(); }); diff --git a/src/cron/service/ops.test.ts b/src/cron/service/ops.test.ts index e596ef1e7826..e8aec902726f 100644 --- a/src/cron/service/ops.test.ts +++ b/src/cron/service/ops.test.ts @@ -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, }); }); diff --git a/src/cron/service/owner-hardening.test.ts b/src/cron/service/owner-hardening.test.ts index cb1e707056a7..223c7de3a3b5 100644 --- a/src/cron/service/owner-hardening.test.ts +++ b/src/cron/service/owner-hardening.test.ts @@ -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 { diff --git a/src/cron/service/store.test.ts b/src/cron/service/store.test.ts index 1a43e35cb5c8..114b1912f0e9 100644 --- a/src/cron/service/store.test.ts +++ b/src/cron/service/store.test.ts @@ -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); diff --git a/src/cron/service/store.ts b/src/cron/service/store.ts index d4c5bbd5c6ea..15cf963c0bbd 100644 --- a/src/cron/service/store.ts +++ b/src/cron/service/store.ts @@ -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)) { diff --git a/src/cron/service/timer.test.ts b/src/cron/service/timer.test.ts index fc74e2fa810a..20d961748656 100644 --- a/src/cron/service/timer.test.ts +++ b/src/cron/service/timer.test.ts @@ -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; diff --git a/src/cron/store.test.ts b/src/cron/store.test.ts index 97a405a5f833..56bce41a6931 100644 --- a/src/cron/store.test.ts +++ b/src/cron/store.test.ts @@ -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; + failureAlert: NonNullable; + }>)( + "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; + 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, diff --git a/src/cron/store/delivery-codec.ts b/src/cron/store/delivery-codec.ts index 215047fe99d8..45b0fc63f7c7 100644 --- a/src/cron/store/delivery-codec.ts +++ b/src/cron/store/delivery-codec.ts @@ -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 { + 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; } diff --git a/src/cron/store/failure-alert-codec.test.ts b/src/cron/store/failure-alert-codec.test.ts index 3bce6d027674..f2687bea9ae4 100644 --- a/src/cron/store/failure-alert-codec.test.ts +++ b/src/cron/store/failure-alert-codec.test.ts @@ -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[0], -): ReturnType { - 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({}); - }); }); diff --git a/src/cron/store/failure-alert-codec.ts b/src/cron/store/failure-alert-codec.ts deleted file mode 100644 index b84c16f2cac6..000000000000 --- a/src/cron/store/failure-alert-codec.ts +++ /dev/null @@ -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 } : {}), - }; -} diff --git a/src/cron/store/payload-codec.ts b/src/cron/store/payload-codec.ts deleted file mode 100644 index 3882e6c740f2..000000000000 --- a/src/cron/store/payload-codec.ts +++ /dev/null @@ -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; -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, -): 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, "kind" | "timeoutSeconds"> | null { - const parsed = raw ? safeParseJson(raw) : undefined; - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return null; - } - const record = parsed as Record; - 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).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, "kind" | "timeoutSeconds"> | null { - const parsed = raw ? safeParseJson(raw) : undefined; - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return null; - } - const record = parsed as Record; - 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; -} diff --git a/src/cron/store/row-codec.schedule.test.ts b/src/cron/store/row-codec.schedule.test.ts index c6d7af0638ab..0762952995f4 100644 --- a/src/cron/store/row-codec.schedule.test.ts +++ b/src/cron/store/row-codec.schedule.test.ts @@ -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"); - }); }); diff --git a/src/cron/store/row-codec.ts b/src/cron/store/row-codec.ts index 98a67e88578b..90f66868f997 100644 --- a/src/cron/store/row-codec.ts +++ b/src/cron/store/row-codec.ts @@ -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 { const { runtimeAuthority: _runtimeAuthority, @@ -112,62 +23,21 @@ function stripJobRuntimeFields(job: CronStoreFile["jobs"][number]): Record, - projectedJob: CronStoredJob | null, -): Record { - 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 = 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): 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): 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): Record { + const delivery = deliveryFromJson(jobJson.delivery); + return delivery ? { ...jobJson, delivery } : jobJson; } function rowToCronJob(row: CronJobRow, jobJson: Record): 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, + 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 diff --git a/src/cron/store/scalar-codec.ts b/src/cron/store/scalar-codec.ts index 613f17c3c132..0483413e16ab 100644 --- a/src/cron/store/scalar-codec.ts +++ b/src/cron/store/scalar-codec.ts @@ -9,30 +9,3 @@ export function tryParseJsonObject(raw: string): Record | 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; -} diff --git a/src/cron/store/state-codec.ts b/src/cron/store/state-codec.ts deleted file mode 100644 index 6297ef6b2652..000000000000 --- a/src/cron/store/state-codec.ts +++ /dev/null @@ -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) } - : {}), - }; -} diff --git a/src/cron/store/trigger-codec.test.ts b/src/cron/store/trigger-codec.test.ts index d3c9635bdc2c..02b29bf7c4a6 100644 --- a/src/cron/store/trigger-codec.test.ts +++ b/src/cron/store/trigger-codec.test.ts @@ -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", diff --git a/src/cron/store/trigger-codec.ts b/src/cron/store/trigger-codec.ts deleted file mode 100644 index 06b80cab66ad..000000000000 --- a/src/cron/store/trigger-codec.ts +++ /dev/null @@ -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 { - 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) } : {}), - }; -} diff --git a/src/cron/types.ts b/src/cron/types.ts index 5e20d4b14eef..c0cd8e8401ea 100644 --- a/src/cron/types.ts +++ b/src/cron/types.ts @@ -474,7 +474,7 @@ export type CronJobState = { lastFailureNotificationDeliveryError?: string; }; -export type CronTrigger = { +type CronTrigger = { script: string; once?: boolean; }; diff --git a/src/gateway/worker-environments/placement-store.move.test.ts b/src/gateway/worker-environments/placement-store.move.test.ts index 6fd689d854ce..318abb435794 100644 --- a/src/gateway/worker-environments/placement-store.move.test.ts +++ b/src/gateway/worker-environments/placement-store.move.test.ts @@ -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]]), diff --git a/src/infra/device-auth-store.test.ts b/src/infra/device-auth-store.test.ts index 5ba02aa600bb..448b37c97631 100644 --- a/src/infra/device-auth-store.test.ts +++ b/src/infra/device-auth-store.test.ts @@ -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); diff --git a/src/infra/device-auth-store.ts b/src/infra/device-auth-store.ts index 9f30b38f3677..1b7fae1e1545 100644 --- a/src/infra/device-auth-store.ts +++ b/src/infra/device-auth-store.ts @@ -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(); -const ensuredOriginDatabases = new WeakSet(); -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 }) => { diff --git a/src/infra/state-migrations.audit-backup.ts b/src/infra/state-migrations.audit-backup.ts index deadf5f7b2a2..31b7ff9f0396 100644 --- a/src/infra/state-migrations.audit-backup.ts +++ b/src/infra/state-migrations.audit-backup.ts @@ -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; diff --git a/src/infra/state-migrations.doctor.ts b/src/infra/state-migrations.doctor.ts index 342088c25c98..1dc910f41a5d 100644 --- a/src/infra/state-migrations.doctor.ts +++ b/src/infra/state-migrations.doctor.ts @@ -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": diff --git a/src/infra/state-migrations.onboarding-recommendations.test.ts b/src/infra/state-migrations.onboarding-recommendations.test.ts deleted file mode 100644 index 1b77d705fdaf..000000000000 --- a/src/infra/state-migrations.onboarding-recommendations.test.ts +++ /dev/null @@ -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(); - }, - ); - }); -}); diff --git a/src/infra/state-migrations.onboarding-recommendations.ts b/src/infra/state-migrations.onboarding-recommendations.ts deleted file mode 100644 index 8046427a99a1..000000000000 --- a/src/infra/state-migrations.onboarding-recommendations.ts +++ /dev/null @@ -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(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)}`], - }; - } -} diff --git a/src/infra/state-migrations.shared-auth-store.test.ts b/src/infra/state-migrations.shared-auth-store.test.ts index fff1f4e630f1..3824332bda53 100644 --- a/src/infra/state-migrations.shared-auth-store.test.ts +++ b/src/infra/state-migrations.shared-auth-store.test.ts @@ -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 diff --git a/src/infra/state-migrations.shared-auth-store.ts b/src/infra/state-migrations.shared-auth-store.ts index 67f9d29b78e7..30784c2fe879 100644 --- a/src/infra/state-migrations.shared-auth-store.ts +++ b/src/infra/state-migrations.shared-auth-store.ts @@ -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(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, }), ); } diff --git a/src/infra/state-migrations.state-dir.test.ts b/src/infra/state-migrations.state-dir.test.ts index 61d22336b963..e530dde43c0c 100644 --- a/src/infra/state-migrations.state-dir.test.ts +++ b/src/infra/state-migrations.state-dir.test.ts @@ -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 }); }); }); diff --git a/src/infra/state-migrations.subagent-registry.test.ts b/src/infra/state-migrations.subagent-registry.test.ts index 842a45b42fab..c72058b8cc4b 100644 --- a/src/infra/state-migrations.subagent-registry.test.ts +++ b/src/infra/state-migrations.subagent-registry.test.ts @@ -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), }), diff --git a/src/infra/state-migrations.workspace-setup-recreated.test.ts b/src/infra/state-migrations.workspace-setup-recreated.test.ts index 7086e373bfe3..2e2b5cf15ad6 100644 --- a/src/infra/state-migrations.workspace-setup-recreated.test.ts +++ b/src/infra/state-migrations.workspace-setup-recreated.test.ts @@ -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( diff --git a/src/infra/state-migrations.workspace-setup-store.ts b/src/infra/state-migrations.workspace-setup-store.ts index 039f2f152ded..38f36fd367d0 100644 --- a/src/infra/state-migrations.workspace-setup-store.ts +++ b/src/infra/state-migrations.workspace-setup-store.ts @@ -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, diff --git a/src/infra/state-migrations.workspace-setup.test.ts b/src/infra/state-migrations.workspace-setup.test.ts index e5925f1c284b..7be6bb26085c 100644 --- a/src/infra/state-migrations.workspace-setup.test.ts +++ b/src/infra/state-migrations.workspace-setup.test.ts @@ -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() }); }); diff --git a/src/node-host/node-worker-launch-store.test.ts b/src/node-host/node-worker-launch-store.test.ts index 1ea4f78f1606..10d3c8c175a1 100644 --- a/src/node-host/node-worker-launch-store.test.ts +++ b/src/node-host/node-worker-launch-store.test.ts @@ -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, ), diff --git a/src/plugins/installed-plugin-index-record-state.ts b/src/plugins/installed-plugin-index-record-state.ts index 1b47ac25c22b..6b692fb11440 100644 --- a/src/plugins/installed-plugin-index-record-state.ts +++ b/src/plugins/installed-plugin-index-record-state.ts @@ -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) { diff --git a/src/plugins/installed-plugin-index-records.test.ts b/src/plugins/installed-plugin-index-records.test.ts index 72ed493b9774..60e2eda11185 100644 --- a/src/plugins/installed-plugin-index-records.test.ts +++ b/src/plugins/installed-plugin-index-records.test.ts @@ -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 } }, ); diff --git a/src/plugins/installed-plugin-index-store.install-record-map.test.ts b/src/plugins/installed-plugin-index-store.install-record-map.test.ts index a3c7cee0037c..fd5bd94f0919 100644 --- a/src/plugins/installed-plugin-index-store.install-record-map.test.ts +++ b/src/plugins/installed-plugin-index-store.install-record-map.test.ts @@ -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"}}', ); }); }); diff --git a/src/plugins/installed-plugin-index-store.test.ts b/src/plugins/installed-plugin-index-store.test.ts index 9ab0dd514cfa..43c49eccc713 100644 --- a/src/plugins/installed-plugin-index-store.test.ts +++ b/src/plugins/installed-plugin-index-store.test.ts @@ -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 () => { diff --git a/src/plugins/installed-plugin-index-store.ts b/src/plugins/installed-plugin-index-store.ts index a5e3978d972d..2928bad94d8c 100644 --- a/src/plugins/installed-plugin-index-store.ts +++ b/src/plugins/installed-plugin-index-store.ts @@ -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)); diff --git a/src/secrets/apply.test.ts b/src/secrets/apply.test.ts index a1b84762e804..4dab9447e193 100644 --- a/src/secrets/apply.test.ts +++ b/src/secrets/apply.test.ts @@ -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: { diff --git a/src/secrets/audit.test.ts b/src/secrets/audit.test.ts index 51f20be1735e..79d6f93bdaa6 100644 --- a/src/secrets/audit.test.ts +++ b/src/secrets/audit.test.ts @@ -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: { diff --git a/src/snapshot/git-backup.test.ts b/src/snapshot/git-backup.test.ts index 069d106e0dcb..d2868e75a389 100644 --- a/src/snapshot/git-backup.test.ts +++ b/src/snapshot/git-backup.test.ts @@ -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 () => { diff --git a/src/state/openclaw-database-maintenance.test.ts b/src/state/openclaw-database-maintenance.test.ts index 27662b6b8969..56d10cc02fab 100644 --- a/src/state/openclaw-database-maintenance.test.ts +++ b/src/state/openclaw-database-maintenance.test.ts @@ -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 { diff --git a/src/state/openclaw-schema-retirements.json b/src/state/openclaw-schema-retirements.json index 3ea8f7ed57a7..bb0f806498fc 100644 --- a/src/state/openclaw-schema-retirements.json +++ b/src/state/openclaw-schema-retirements.json @@ -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.." }, { @@ -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." } ] } diff --git a/src/state/openclaw-state-db-additive-columns.ts b/src/state/openclaw-state-db-additive-columns.ts index 3e04b63e0564..cd50a41cff85 100644 --- a/src/state/openclaw-state-db-additive-columns.ts +++ b/src/state/openclaw-state-db-additive-columns.ts @@ -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", diff --git a/src/state/openclaw-state-db-contract.ts b/src/state/openclaw-state-db-contract.ts index 32cf14ad2ad3..f12d770701d9 100644 --- a/src/state/openclaw-state-db-contract.ts +++ b/src/state/openclaw-state-db-contract.ts @@ -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"; diff --git a/src/state/openclaw-state-db-legacy-backfills.test.ts b/src/state/openclaw-state-db-legacy-backfills.test.ts index 361ad78fd184..8b81c2997e3b 100644 --- a/src/state/openclaw-state-db-legacy-backfills.test.ts +++ b/src/state/openclaw-state-db-legacy-backfills.test.ts @@ -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 (?, ?, ?, ?)", diff --git a/src/state/openclaw-state-db-legacy-backfills.ts b/src/state/openclaw-state-db-legacy-backfills.ts index 9250c734b9b0..aa9de0908f4b 100644 --- a/src/state/openclaw-state-db-legacy-backfills.ts +++ b/src/state/openclaw-state-db-legacy-backfills.ts @@ -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 | 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, key: string): Record): 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 | null { - const value = textField(record, "wakeMode"); - return value === "now" || value === "next-heartbeat" ? value : null; -} - -function booleanField(record: Record, key: string): number | null { - const value = record[key]; - return typeof value === "boolean" ? (value ? 1 : 0) : null; -} - -function failureDestinationField( - record: Record | 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) - : 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, ); diff --git a/src/state/openclaw-state-db-maintenance.ts b/src/state/openclaw-state-db-maintenance.ts index c40f04b693a4..0559af6379ed 100644 --- a/src/state/openclaw-state-db-maintenance.ts +++ b/src/state/openclaw-state-db-maintenance.ts @@ -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; 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 } = {}, diff --git a/src/state/openclaw-state-db-schema-additive.ts b/src/state/openclaw-state-db-schema-additive.ts index c9be677bb228..bf47e77519b2 100644 --- a/src/state/openclaw-state-db-schema-additive.ts +++ b/src/state/openclaw-state-db-schema-additive.ts @@ -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); diff --git a/src/state/openclaw-state-db-schema-repair.ts b/src/state/openclaw-state-db-schema-repair.ts index 2d3fd9405c4e..a838c23cf600 100644 --- a/src/state/openclaw-state-db-schema-repair.ts +++ b/src/state/openclaw-state-db-schema-repair.ts @@ -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 }); } diff --git a/src/state/openclaw-state-db-schema-v13-widerow.ts b/src/state/openclaw-state-db-schema-v13-widerow.ts new file mode 100644 index 000000000000..88fd5acb5de4 --- /dev/null +++ b/src/state/openclaw-state-db-schema-v13-widerow.ts @@ -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; +} diff --git a/src/state/openclaw-state-db.generated.d.ts b/src/state/openclaw-state-db.generated.d.ts index f7919655d6fe..9181238a63bc 100644 --- a/src/state/openclaw-state-db.generated.d.ts +++ b/src/state/openclaw-state-db.generated.d.ts @@ -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; - stagger_ms: number | null; state_json: Generated; 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; - 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; diff --git a/src/state/openclaw-state-db.test.ts b/src/state/openclaw-state-db.test.ts index 818a67d04e50..5a0a24e541c4 100644 --- a/src/state/openclaw-state-db.test.ts +++ b/src/state/openclaw-state-db.test.ts @@ -8,6 +8,9 @@ import type { DatabaseSync } from "node:sqlite"; import { gunzipSync } from "node:zlib"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { cleanupTempDirs, makeTempDir } from "../../test/helpers/temp-dir.js"; +import { saveCronStore } from "../cron/store.js"; +import { loadedCronStoreFromRows, loadCronRows } from "../cron/store/row-codec.js"; +import type { CronStoredJob } from "../cron/types.js"; import { buildApprovalResolutionRef } from "../infra/approval-resolution-ref.js"; import { countFailedDeliveryQueueEntries, @@ -60,6 +63,7 @@ import { getOpenClawStateRuntimeSchema } from "./openclaw-state-schema-compatibi import { STATE_SCHEMA_10_TO_9_DOWNGRADE_SQL } from "./openclaw-state-schema-v10-retirement.test-support.js"; import { STATE_SCHEMA_11_TO_10_TABLES_SQL } from "./openclaw-state-schema-v11-retirement.test-support.js"; import { STATE_SCHEMA_12_TO_11_DOWNGRADE_SQL } from "./openclaw-state-schema-v12-foldin.test-support.js"; +import { STATE_SCHEMA_13_TO_12_DOWNGRADE_SQL } from "./openclaw-state-schema-v13-widerow.test-support.js"; import { OPENCLAW_STATE_SCHEMA_SQL } from "./openclaw-state-schema.js"; import { collectSqliteSchemaShape, @@ -1862,12 +1866,14 @@ describe("openclaw state database", () => { }); } const migrated = openOpenClawStateDatabase(options); - expect(readSqliteNumberPragma(migrated.db, "user_version")).toBe(12); + expect(readSqliteNumberPragma(migrated.db, "user_version")).toBe( + OPENCLAW_STATE_SCHEMA_VERSION, + ); expect( migrated.db .prepare("SELECT schema_version FROM schema_meta WHERE meta_key = 'primary'") .get(), - ).toEqual({ schema_version: 12 }); + ).toEqual({ schema_version: OPENCLAW_STATE_SCHEMA_VERSION }); for (const name of [ "skill_lifecycle", "idx_skill_lifecycle_key", @@ -1976,12 +1982,14 @@ describe("openclaw state database", () => { } const migrated = openOpenClawStateDatabase(options); - expect(readSqliteNumberPragma(migrated.db, "user_version")).toBe(12); + expect(readSqliteNumberPragma(migrated.db, "user_version")).toBe( + OPENCLAW_STATE_SCHEMA_VERSION, + ); expect( migrated.db .prepare("SELECT schema_version FROM schema_meta WHERE meta_key = 'primary'") .get(), - ).toEqual({ schema_version: 12 }); + ).toEqual({ schema_version: OPENCLAW_STATE_SCHEMA_VERSION }); for (const tableName of FOLDED_STATE_TABLES_V12) { expect( migrated.db @@ -2071,6 +2079,862 @@ describe("openclaw state database", () => { }, ); + it.each(["runtime open", "doctor repair"] as const)( + "migrates v12 wide rows to canonical JSON through %s without changing hydrated jobs", + (migrationPath) => { + const stateDir = createTempStateDir(); + const options = { env: { OPENCLAW_STATE_DIR: stateDir } }; + const databasePath = materializeCurrentStateDatabase(stateDir); + const { DatabaseSync } = requireNodeSqlite(); + const legacy = new DatabaseSync(databasePath); + legacy.exec(STATE_SCHEMA_13_TO_12_DOWNGRADE_SQL); + legacy.exec("DROP TABLE gateway_origin_device_tokens;"); + + const job = { + id: "legacy-wide-job", + name: "Legacy wide job", + description: "preserved cron configuration", + enabled: true, + declarationKey: "legacy-declaration", + owner: { agentId: "legacy-owner" }, + createdAtMs: 100, + updatedAtMs: 250, + agentId: "legacy-agent", + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "now", + payload: { kind: "agentTurn", message: "hello" }, + delivery: { + mode: "announce", + channel: "telegram", + failureDestination: { channel: "slack", to: null }, + }, + }; + const storeKey = path.join(stateDir, "cron", "jobs.json"); + legacy + .prepare( + `INSERT INTO cron_jobs ( + store_key, job_id, declaration_key, owner_agent_id, name, description, + enabled, created_at_ms, agent_id, payload_kind, job_json, state_json, + runtime_updated_at_ms, schedule_identity, sort_order, updated_at, + schedule_kind, every_ms, session_target, wake_mode, payload_message, + delivery_mode, delivery_channel, failure_delivery_mode, + failure_delivery_channel, failure_delivery_to, failure_delivery_account_id, + last_run_status + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + storeKey, + job.id, + "legacy-declaration", + "legacy-owner", + job.name, + job.description, + 1, + job.createdAtMs, + job.agentId, + job.payload.kind, + JSON.stringify(job), + JSON.stringify({ lastStatus: "error" }), + job.updatedAtMs, + "every:60000", + 4, + job.updatedAtMs, + job.schedule.kind, + job.schedule.everyMs, + job.sessionTarget, + job.wakeMode, + job.payload.message, + job.delivery.mode, + job.delivery.channel, + "announce", + "discord", + "https://example.invalid/failure", + "", + "ok", + ); + const authoritySchemaStart = OPENCLAW_STATE_SCHEMA_SQL.indexOf( + "CREATE TABLE IF NOT EXISTS cron_job_runtime_authorities (", + ); + const authoritySchemaEnd = OPENCLAW_STATE_SCHEMA_SQL.indexOf( + "\n) STRICT;", + authoritySchemaStart, + ); + legacy.exec(OPENCLAW_STATE_SCHEMA_SQL.slice(authoritySchemaStart, authoritySchemaEnd + 10)); + legacy + .prepare( + `INSERT INTO cron_job_runtime_authorities ( + store_key, job_id, authority_json, authority_input_fingerprint, recovery_required + ) VALUES (?, ?, ?, ?, ?)`, + ) + .run(storeKey, job.id, '{"owner":"preserved"}', "preserved-fingerprint", 0); + const runPayload = { + runId: "legacy-run", + childSessionKey: "agent:child:legacy", + requesterSessionKey: "agent:main:legacy", + task: "preserved subagent task", + }; + legacy + .prepare( + `INSERT INTO subagent_runs ( + run_id, child_session_key, controller_session_key, requester_session_key, + created_at, payload_json, task, requester_display_key, cleanup + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + runPayload.runId, + runPayload.childSessionKey, + "agent:controller:legacy", + runPayload.requesterSessionKey, + 200, + JSON.stringify(runPayload), + runPayload.task, + "legacy-requester", + "keep", + ); + legacy + .prepare( + `INSERT INTO workspace_setup_state ( + workspace_key, workspace_path, version, bootstrap_seeded_at, setup_completed_at, + updated_at + ) VALUES (?, ?, 1, ?, ?, ?)`, + ) + .run( + "wk-setup", + "/tmp/wk-setup", + "2026-07-15T10:00:00.000Z", + "2026-07-15T10:01:00.000Z", + 500, + ); + const insertLegacyAttestation = legacy.prepare( + "INSERT INTO workspace_attestations (workspace_key, attested_at_ms, updated_at_ms) VALUES (?, ?, ?)", + ); + insertLegacyAttestation.run("wk-setup", 1_000, 1_100); + insertLegacyAttestation.run("wk-alias", 2_000, 2_100); + insertLegacyAttestation.run("wk-orphan", 3_000, 3_100); + legacy + .prepare( + `INSERT INTO workspace_path_aliases ( + alias_key, alias_path, workspace_key, workspace_path, updated_at_ms + ) VALUES (?, ?, ?, ?, ?)`, + ) + .run("wk-alias-link", "/tmp/wk-alias-link", "wk-alias", "/tmp/wk-alias", 2_200); + const insertLegacyHash = legacy.prepare( + "INSERT INTO workspace_generated_bootstrap_hashes (workspace_key, filename, sha256) VALUES (?, ?, ?)", + ); + insertLegacyHash.run("wk-setup", "AGENTS.md", "a".repeat(64)); + insertLegacyHash.run("wk-alias", "TOOLS.md", "b".repeat(64)); + insertLegacyHash.run("wk-orphan", "USER.md", "c".repeat(64)); + const sharedStoreJson = JSON.stringify({ + version: 1, + profiles: { "openai:default": { type: "api_key", provider: "openai", key: "sk-shared" } }, + }); + const sharedStateJson = JSON.stringify({ + version: 1, + order: { openai: ["openai:default"] }, + }); + const insertLegacyAuthStore = legacy.prepare( + "INSERT INTO auth_profile_stores (store_key, store_json, updated_at) VALUES (?, ?, ?)", + ); + insertLegacyAuthStore.run("shared", sharedStoreJson, 91); + insertLegacyAuthStore.run("stray", '{"version":1,"profiles":{}}', 93); + legacy + .prepare( + "INSERT INTO auth_profile_state (store_key, state_json, updated_at) VALUES (?, ?, ?)", + ) + .run("shared", sharedStateJson, 92); + legacy.close(); + + expect(detectOpenClawStateDatabaseSchemaMigrations(options)).toContainEqual({ + kind: "state-consolidation-v13", + path: databasePath, + }); + if (migrationPath === "doctor repair") { + expect(repairOpenClawStateDatabaseSchema(options)).toEqual({ + changes: ["Consolidated shared state tables (v13)"], + warnings: [], + }); + } + + const migrated = openOpenClawStateDatabase(options); + expect(readSqliteNumberPragma(migrated.db, "user_version")).toBe(13); + expect(collectSqliteSchemaShape(migrated.db).gateway_origin_device_tokens).toEqual( + createInitialStateSchemaShape().gateway_origin_device_tokens, + ); + const cronColumns = migrated.db.prepare("PRAGMA table_info(cron_jobs)").all() as Array<{ + name: string; + }>; + expect(cronColumns.map((column) => column.name)).toEqual([ + "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", + ]); + expect( + migrated.db + .prepare( + `SELECT name FROM sqlite_master + WHERE type = 'index' + AND name IN ( + 'idx_cron_jobs_store_updated', + 'idx_cron_jobs_enabled_next_run', + 'idx_cron_jobs_store_order' + ) + ORDER BY name`, + ) + .all(), + ).toEqual([{ name: "idx_cron_jobs_store_order" }]); + const runColumns = migrated.db.prepare("PRAGMA table_info(subagent_runs)").all() as Array<{ + name: string; + }>; + expect(runColumns.map((column) => column.name)).toEqual([ + "run_id", + "child_session_key", + "controller_session_key", + "requester_session_key", + "created_at", + "payload_json", + ]); + const row = migrated.db + .prepare( + `SELECT declaration_key, owner_agent_id, agent_id, payload_kind, + runtime_updated_at_ms, schedule_identity, sort_order, job_json, state_json + FROM cron_jobs WHERE job_id = ?`, + ) + .get(job.id) as { + declaration_key: string; + owner_agent_id: string; + agent_id: string; + payload_kind: string; + runtime_updated_at_ms: number; + schedule_identity: string; + sort_order: number; + job_json: string; + state_json: string; + }; + expect(row).toMatchObject({ + declaration_key: "legacy-declaration", + owner_agent_id: "legacy-owner", + agent_id: "legacy-agent", + payload_kind: "agentTurn", + runtime_updated_at_ms: 250, + schedule_identity: "every:60000", + sort_order: 4, + }); + expect(JSON.parse(row.job_json).delivery.failureDestination).toEqual({ + mode: "announce", + channel: "slack", + to: null, + accountId: null, + }); + expect(JSON.parse(row.state_json)).toEqual({ + lastStatus: "error", + lastRunStatus: "error", + }); + expect(loadedCronStoreFromRows(loadCronRows(migrated.db, storeKey)).store.jobs).toEqual([ + { + ...job, + declarationKey: "legacy-declaration", + owner: { agentId: "legacy-owner" }, + delivery: { + ...job.delivery, + failureDestination: { + mode: "announce", + channel: "slack", + to: undefined, + accountId: undefined, + }, + }, + state: { lastStatus: "error", lastRunStatus: "error" }, + }, + ]); + expect( + migrated.db + .prepare( + `SELECT store_key, job_id, authority_json, authority_input_fingerprint, + recovery_required + FROM cron_job_runtime_authorities WHERE job_id = ?`, + ) + .get(job.id), + ).toEqual({ + store_key: storeKey, + job_id: job.id, + authority_json: '{"owner":"preserved"}', + authority_input_fingerprint: "preserved-fingerprint", + recovery_required: 0, + }); + expect( + migrated.db.prepare("SELECT * FROM subagent_runs WHERE run_id = ?").get(runPayload.runId), + ).toEqual({ + run_id: runPayload.runId, + child_session_key: runPayload.childSessionKey, + controller_session_key: "agent:controller:legacy", + requester_session_key: runPayload.requesterSessionKey, + created_at: 200, + payload_json: JSON.stringify(runPayload), + }); + expect( + migrated.db + .prepare( + `SELECT workspace_key, workspace_path, version, bootstrap_seeded_at, + setup_completed_at, updated_at, attested_at_ms, attestation_updated_at_ms + FROM workspace_setup_state ORDER BY workspace_key`, + ) + .all(), + ).toEqual([ + { + workspace_key: "wk-alias", + workspace_path: "/tmp/wk-alias", + version: null, + bootstrap_seeded_at: null, + setup_completed_at: null, + updated_at: null, + attested_at_ms: 2_000, + attestation_updated_at_ms: 2_100, + }, + { + workspace_key: "wk-orphan", + workspace_path: null, + version: null, + bootstrap_seeded_at: null, + setup_completed_at: null, + updated_at: null, + attested_at_ms: 3_000, + attestation_updated_at_ms: 3_100, + }, + { + workspace_key: "wk-setup", + workspace_path: "/tmp/wk-setup", + version: 1, + bootstrap_seeded_at: "2026-07-15T10:00:00.000Z", + setup_completed_at: "2026-07-15T10:01:00.000Z", + updated_at: 500, + attested_at_ms: 1_000, + attestation_updated_at_ms: 1_100, + }, + ]); + expect( + migrated.db + .prepare( + `SELECT workspace_key, filename, sha256 FROM workspace_generated_bootstrap_hashes + ORDER BY workspace_key`, + ) + .all(), + ).toEqual([ + { workspace_key: "wk-alias", filename: "TOOLS.md", sha256: "b".repeat(64) }, + { workspace_key: "wk-orphan", filename: "USER.md", sha256: "c".repeat(64) }, + { workspace_key: "wk-setup", filename: "AGENTS.md", sha256: "a".repeat(64) }, + ]); + expect( + migrated.db + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'workspace_attestations'", + ) + .all(), + ).toEqual([]); + expect( + migrated.db + .prepare( + `SELECT value_json, updated_at_ms FROM config_machine_state + WHERE state_key = 'authProfiles.store'`, + ) + .get(), + ).toEqual({ value_json: sharedStoreJson, updated_at_ms: 91 }); + expect( + migrated.db + .prepare( + `SELECT value_json, updated_at_ms FROM config_machine_state + WHERE state_key = 'authProfiles.state'`, + ) + .get(), + ).toEqual({ value_json: sharedStateJson, updated_at_ms: 92 }); + expect( + migrated.db + .prepare( + `SELECT name FROM sqlite_master + WHERE type = 'table' AND name IN ('auth_profile_stores', 'auth_profile_state')`, + ) + .all(), + ).toEqual([]); + expect(migrated.db.prepare("PRAGMA foreign_key_check").all()).toEqual([]); + expect(detectOpenClawStateDatabaseSchemaMigrations(options)).toEqual([]); + }, + ); + + it.each(["runtime open", "doctor repair"] as const)( + "preserves malformed cron JSON for quarantine through the v13 %s", + (migrationPath) => { + const stateDir = createTempStateDir(); + const options = { env: { OPENCLAW_STATE_DIR: stateDir } }; + const databasePath = materializeCurrentStateDatabase(stateDir); + const { DatabaseSync } = requireNodeSqlite(); + const legacy = new DatabaseSync(databasePath); + legacy.exec(STATE_SCHEMA_13_TO_12_DOWNGRADE_SQL); + const insert = legacy.prepare( + `INSERT INTO cron_jobs ( + store_key, job_id, name, enabled, created_at_ms, schedule_kind, schedule_expr, + session_target, wake_mode, payload_kind, payload_message, job_json, state_json, + sort_order, updated_at + ) VALUES (?, ?, ?, 1, 1, 'cron', '0 6 * * *', 'main', 'now', + 'systemEvent', 'tick', ?, ?, ?, 1)`, + ); + const storeKey = path.join(stateDir, "cron", "jobs.json"); + insert.run(storeKey, "malformed-job", "Malformed job", "{", "{}", 0); + insert.run( + storeKey, + "malformed-state", + "Malformed state", + '{"id":"malformed-state"}', + "[]", + 1, + ); + legacy.close(); + + if (migrationPath === "doctor repair") { + expect(repairOpenClawStateDatabaseSchema(options).changes).toContain( + "Consolidated shared state tables (v13)", + ); + } + const migrated = openOpenClawStateDatabase(options); + expect( + migrated.db + .prepare("SELECT job_id, job_json, state_json FROM cron_jobs ORDER BY sort_order, job_id") + .all(), + ).toEqual([ + { job_id: "malformed-job", job_json: "{", state_json: "{}" }, + { job_id: "malformed-state", job_json: '{"id":"malformed-state"}', state_json: "[]" }, + ]); + expect(migrated.db.prepare("PRAGMA integrity_check").get()).toEqual({ + integrity_check: "ok", + }); + expect(migrated.db.prepare("PRAGMA foreign_key_check").all()).toEqual([]); + closeOpenClawStateDatabaseForTest(); + expect( + openOpenClawStateDatabase(options) + .db.prepare("SELECT COUNT(*) AS count FROM cron_jobs") + .get(), + ).toEqual({ count: 2 }); + expect(detectOpenClawStateDatabaseSchemaMigrations(options)).toEqual([]); + }, + ); + + it.each( + (["runtime open", "doctor repair"] as const).flatMap((migrationPath) => [ + [migrationPath, "install_records_json", "[]"], + [migrationPath, "plugins_json", "{}"], + [migrationPath, "diagnostics_json", "{"], + ]) as Array< + readonly [ + "runtime open" | "doctor repair", + "install_records_json" | "plugins_json" | "diagnostics_json", + string, + ] + >, + )( + "drops an invalid plugin-index cache during v13 %s when %s is invalid", + (migrationPath, column, value) => { + const stateDir = createTempStateDir(); + const options = { env: { OPENCLAW_STATE_DIR: stateDir } }; + const databasePath = materializeCurrentStateDatabase(stateDir); + const { DatabaseSync } = requireNodeSqlite(); + const legacy = new DatabaseSync(databasePath); + legacy.exec(STATE_SCHEMA_13_TO_12_DOWNGRADE_SQL); + const values = { + install_records_json: "{}", + plugins_json: "[]", + diagnostics_json: "[]", + [column]: value, + }; + legacy + .prepare( + `INSERT INTO installed_plugin_index ( + index_key, version, host_contract_version, compat_registry_version, + migration_version, policy_hash, generated_at_ms, install_records_json, + plugins_json, diagnostics_json, updated_at_ms + ) VALUES ('installed-plugin-index', 1, 'host', 'compat', 1, 'policy', 10, ?, ?, ?, 11)`, + ) + .run(values.install_records_json, values.plugins_json, values.diagnostics_json); + legacy.close(); + + if (migrationPath === "doctor repair") { + repairOpenClawStateDatabaseSchema(options); + } + const migrated = openOpenClawStateDatabase(options); + expect( + migrated.db + .prepare("SELECT name FROM sqlite_schema WHERE name = 'installed_plugin_index'") + .get(), + ).toBeUndefined(); + expect( + migrated.db + .prepare( + "SELECT value_json FROM config_machine_state WHERE state_key = 'plugins.installedIndex'", + ) + .get(), + ).toBeUndefined(); + expect(migrated.db.prepare("PRAGMA integrity_check").get()).toEqual({ + integrity_check: "ok", + }); + closeOpenClawStateDatabaseForTest(); + expect(readSqliteNumberPragma(openOpenClawStateDatabase(options).db, "user_version")).toBe( + 13, + ); + }, + ); + + it("reprojects canonical cron JSON into the complete v12 downgrade contract", async () => { + await withOpenClawTestState( + { layout: "state-only", applyEnv: true, prefix: "openclaw-v13-downgrade-" }, + async ({ stateDir }) => { + materializeCurrentStateDatabase(stateDir); + const storePath = path.join(stateDir, "cron", "jobs.json"); + const base = { + enabled: true, + createdAtMs: 100, + updatedAtMs: 200, + sessionTarget: "main" as const, + wakeMode: "now" as const, + state: {}, + }; + const jobs = [ + { + ...base, + id: "at-system-event", + name: "At system event", + deleteAfterRun: false, + schedule: { kind: "at", at: "2026-08-27T12:00:00.000Z" }, + payload: { kind: "systemEvent", text: "tick", toolsAllow: [] }, + failureAlert: false, + }, + { + ...base, + id: "every-agent-turn", + name: "Every agent turn", + displayName: "Every display", + owner: { agentId: "owner-agent", sessionKey: "agent:owner:main" }, + agentId: "worker", + sessionKey: "agent:worker:cron", + schedule: { kind: "every", everyMs: 60_000, anchorMs: 1_000 }, + sessionTarget: "isolated", + payload: { + kind: "agentTurn", + message: "hello", + model: "openai/gpt-5.6-luna", + fallbacks: ["anthropic/claude-sonnet-4-6"], + thinking: "medium", + timeoutSeconds: 30, + allowUnsafeExternalContent: false, + externalContentSource: "webhook", + lightContext: false, + toolsAllow: ["read"], + toolsAllowIsDefault: false, + }, + delivery: { + mode: "announce", + channel: "telegram", + to: "chat", + threadId: 42, + accountId: "account", + bestEffort: false, + completionDestination: { mode: "webhook", to: "https://example.invalid/done" }, + failureDestination: { + mode: undefined, + channel: "discord", + to: undefined, + accountId: "ops", + }, + }, + failureAlert: {}, + state: { + nextRunAtMs: 300, + runningAtMs: 301, + lastRunAtMs: 302, + lastRunStatus: "ok", + lastError: "old error", + lastDurationMs: 303, + consecutiveErrors: 0, + consecutiveSkipped: 2, + scheduleErrorCount: 1, + lastDeliveryStatus: "delivered", + lastDeliveryError: "old delivery error", + lastDelivered: false, + lastFailureAlertAtMs: 304, + }, + }, + { + ...base, + id: "cron-command", + name: "Cron command", + schedule: { kind: "cron", expr: "0 6 * * *", tz: "UTC", staggerMs: 500 }, + trigger: { script: "return true", once: false }, + payload: { + kind: "command", + argv: ["echo", "hello"], + cwd: "/tmp", + env: { LANG: "C" }, + input: "stdin", + timeoutSeconds: 10, + noOutputTimeoutSeconds: 5, + outputMaxBytes: 1024, + }, + }, + { + ...base, + id: "exit-script", + name: "Exit script", + schedule: { kind: "on-exit", command: "sleep 1", cwd: "/tmp" }, + payload: { kind: "script", script: "return 1", timeoutSeconds: 11, toolBudget: 3 }, + }, + { + ...base, + id: "stream-heartbeat", + name: "Stream heartbeat", + schedule: { kind: "stream", command: ["tail", "-f", "events.log"] }, + payload: { kind: "heartbeat" }, + }, + ] satisfies CronStoredJob[]; + await saveCronStore(storePath, { version: 1, jobs }); + closeOpenClawStateDatabaseForTest(); + + const { DatabaseSync } = requireNodeSqlite(); + const databasePath = resolveOpenClawStateSqlitePath({ OPENCLAW_STATE_DIR: stateDir }); + const db = new DatabaseSync(databasePath); + const canonicalRows = db + .prepare("SELECT job_id, job_json, state_json FROM cron_jobs ORDER BY sort_order") + .all(); + const authoritySchemaStart = OPENCLAW_STATE_SCHEMA_SQL.indexOf( + "CREATE TABLE IF NOT EXISTS cron_job_runtime_authorities (", + ); + const authoritySchemaEnd = OPENCLAW_STATE_SCHEMA_SQL.indexOf( + "\n) STRICT;", + authoritySchemaStart, + ); + db.exec(OPENCLAW_STATE_SCHEMA_SQL.slice(authoritySchemaStart, authoritySchemaEnd + 10)); + db.prepare( + `INSERT INTO cron_job_runtime_authorities ( + store_key, job_id, authority_json, authority_input_fingerprint, recovery_required + ) VALUES (?, ?, '{}', 'fingerprint', 0)`, + ).run(path.resolve(storePath), "every-agent-turn"); + db.exec(STATE_SCHEMA_13_TO_12_DOWNGRADE_SQL); + + expect( + db + .prepare( + `SELECT schedule_kind, schedule_expr, schedule_tz, every_ms, anchor_ms, at, + stagger_ms, payload_kind, payload_message, failure_alert_disabled + FROM cron_jobs ORDER BY sort_order`, + ) + .all(), + ).toMatchObject([ + { + schedule_kind: "at", + at: "2026-08-27T12:00:00.000Z", + payload_kind: "systemEvent", + payload_message: "tick", + failure_alert_disabled: 1, + }, + { + schedule_kind: "every", + every_ms: 60_000, + anchor_ms: 1_000, + payload_kind: "agentTurn", + payload_message: "hello", + failure_alert_disabled: 0, + }, + { + schedule_kind: "cron", + schedule_expr: "0 6 * * *", + schedule_tz: "UTC", + stagger_ms: 500, + payload_kind: "command", + }, + { + schedule_kind: "on-exit", + schedule_expr: "sleep 1", + schedule_tz: "/tmp", + payload_kind: "script", + }, + { schedule_kind: "stream", payload_kind: "heartbeat" }, + ]); + const every = db.prepare("SELECT * FROM cron_jobs WHERE job_id = 'every-agent-turn'").get(); + expect(every).toMatchObject({ + display_name: "Every display", + owner_agent_id: "owner-agent", + owner_session_key: "agent:owner:main", + agent_id: "worker", + session_key: "agent:worker:cron", + payload_model: "openai/gpt-5.6-luna", + payload_fallbacks_json: '["anthropic/claude-sonnet-4-6"]', + payload_timeout_seconds: 30, + payload_allow_unsafe_external_content: 0, + payload_external_content_source_json: '"webhook"', + payload_light_context: 0, + payload_tools_allow_json: '["read"]', + payload_tools_allow_is_default: 0, + delivery_thread_id: "42", + delivery_thread_id_type: "number", + delivery_best_effort: 0, + failure_delivery_mode: "", + failure_delivery_channel: "discord", + failure_delivery_to: "", + failure_delivery_account_id: "ops", + next_run_at_ms: 300, + running_at_ms: 301, + last_run_at_ms: 302, + last_run_status: "ok", + last_delivered: 0, + }); + expect( + JSON.parse( + ( + db + .prepare("SELECT payload_message FROM cron_jobs WHERE job_id = 'cron-command'") + .get() as { payload_message: string } + ).payload_message, + ), + ).toEqual({ + argv: ["echo", "hello"], + cwd: "/tmp", + env: { LANG: "C" }, + input: "stdin", + noOutputTimeoutSeconds: 5, + outputMaxBytes: 1024, + }); + expect( + db + .prepare("SELECT job_id, job_json, state_json FROM cron_jobs ORDER BY sort_order") + .all(), + ).toEqual(canonicalRows); + expect( + db + .prepare( + `SELECT name FROM sqlite_schema + WHERE type = 'index' AND name LIKE 'idx_cron_jobs_%' ORDER BY name`, + ) + .all(), + ).toEqual([ + { name: "idx_cron_jobs_agent_session" }, + { name: "idx_cron_jobs_enabled_next_run" }, + { name: "idx_cron_jobs_store_order" }, + { name: "idx_cron_jobs_store_updated" }, + ]); + expect( + db + .prepare( + "SELECT authority_input_fingerprint FROM cron_job_runtime_authorities WHERE job_id = 'every-agent-turn'", + ) + .get(), + ).toEqual({ authority_input_fingerprint: "fingerprint" }); + expect(db.prepare("PRAGMA integrity_check").get()).toEqual({ integrity_check: "ok" }); + expect(db.prepare("PRAGMA foreign_key_check").all()).toEqual([]); + db.close(); + + const migrated = openOpenClawStateDatabase({ + env: { OPENCLAW_STATE_DIR: stateDir }, + }); + expect( + loadedCronStoreFromRows( + loadCronRows(migrated.db, path.resolve(storePath)), + ).store.jobs.map((job) => job.id), + ).toEqual(jobs.map((job) => job.id)); + closeOpenClawStateDatabaseForTest(); + expect( + readSqliteNumberPragma( + openOpenClawStateDatabase({ + env: { OPENCLAW_STATE_DIR: stateDir }, + }).db, + "user_version", + ), + ).toBe(13); + }, + ); + }); + + it("refuses to downgrade malformed canonical cron JSON", async () => { + await withOpenClawTestState( + { layout: "state-only", applyEnv: true, prefix: "openclaw-v13-downgrade-invalid-" }, + async ({ stateDir }) => { + materializeCurrentStateDatabase(stateDir); + const storePath = path.join(stateDir, "cron", "jobs.json"); + await saveCronStore(storePath, { + version: 1, + jobs: [ + { + id: "malformed-downgrade", + name: "Malformed downgrade", + enabled: true, + createdAtMs: 1, + updatedAtMs: 1, + schedule: { kind: "cron", expr: "0 6 * * *" }, + sessionTarget: "main", + wakeMode: "now", + payload: { kind: "systemEvent", text: "tick" }, + state: {}, + }, + ], + }); + closeOpenClawStateDatabaseForTest(); + const { DatabaseSync } = requireNodeSqlite(); + const db = new DatabaseSync( + resolveOpenClawStateSqlitePath({ OPENCLAW_STATE_DIR: stateDir }), + ); + db.prepare("UPDATE cron_jobs SET state_json = '[]'").run(); + expect(() => db.exec(STATE_SCHEMA_13_TO_12_DOWNGRADE_SQL)).toThrow(/CHECK constraint/); + db.exec("ROLLBACK"); + expect(readSqliteNumberPragma(db, "user_version")).toBe(13); + expect(db.prepare("SELECT state_json FROM cron_jobs").get()).toEqual({ state_json: "[]" }); + db.close(); + }, + ); + }); + + it("keeps a pre-existing authProfiles.store KV value over the v13 auth import", () => { + const stateDir = createTempStateDir(); + const options = { env: { OPENCLAW_STATE_DIR: stateDir } }; + const databasePath = materializeCurrentStateDatabase(stateDir); + const { DatabaseSync } = requireNodeSqlite(); + const legacy = new DatabaseSync(databasePath); + legacy.exec(STATE_SCHEMA_13_TO_12_DOWNGRADE_SQL); + legacy + .prepare( + "INSERT INTO auth_profile_stores (store_key, store_json, updated_at) VALUES (?, ?, ?)", + ) + .run("shared", '{"imported":true}', 10); + legacy + .prepare( + `INSERT INTO config_machine_state (state_key, value_json, updated_at_ms) + VALUES ('authProfiles.store', ?, 20)`, + ) + .run('{"kept":true}'); + legacy.close(); + + const migrated = openOpenClawStateDatabase(options); + expect(readSqliteNumberPragma(migrated.db, "user_version")).toBe(13); + expect( + migrated.db + .prepare( + "SELECT value_json, updated_at_ms FROM config_machine_state WHERE state_key = 'authProfiles.store'", + ) + .get(), + ).toEqual({ value_json: '{"kept":true}', updated_at_ms: 20 }); + expect( + migrated.db + .prepare("SELECT name FROM sqlite_master WHERE name = 'auth_profile_stores'") + .all(), + ).toEqual([]); + }); + it.each(["runtime open", "doctor repair"] as const)( "retires v6 commitments through %s while preserving shared leases", (migrationPath) => { @@ -2385,6 +3249,7 @@ describe("openclaw state database", () => { { kind: "state-table-retirement-v10", path: fixture.databasePath }, { kind: "state-table-retirement-v11", path: fixture.databasePath }, { kind: "singleton-state-foldin-v12", path: fixture.databasePath }, + { kind: "state-consolidation-v13", path: fixture.databasePath }, { kind: "audit-events-v2", path: fixture.databasePath }, { kind: "strict-tables-v3", path: fixture.databasePath }, ]); @@ -2400,7 +3265,8 @@ describe("openclaw state database", () => { "Retired legacy skill curator lifecycle and proposal origin-run tables", "Folded singleton state tables into config_machine_state (v12)", "Migrated shared state audit event ledger → versioned message lifecycle schema", - "Migrated shared state tables to SQLite STRICT typing (54)", + "Consolidated shared state tables (v13)", + "Migrated shared state tables to SQLite STRICT typing (48)", ], warnings: [], }); @@ -2427,13 +3293,16 @@ describe("openclaw state database", () => { expect(normalizeSqliteSchemaShapeSql(collectSqliteSchemaShape(migrated.db))).toEqual( normalizeSqliteSchemaShapeSql(createInitialStateSchemaShape()), ); + // The fixture's auth_profile_stores row is keyed 'fixture-store', not the + // production 'shared' key, so the v13 fold drops the table without + // importing it into the KV. expect( migrated.db .prepare( - "SELECT store_key, store_json, updated_at FROM auth_profile_stores WHERE store_key = ?", + "SELECT value_json FROM config_machine_state WHERE state_key = 'authProfiles.store'", ) - .get("fixture-store"), - ).toEqual({ store_key: "fixture-store", store_json: '{"fixture":true}', updated_at: 1000 }); + .get(), + ).toBeUndefined(); expect( migrated.db .prepare( @@ -3035,18 +3904,22 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're const legacy = new DatabaseSync(databasePath); legacy .prepare( - "INSERT INTO auth_profile_stores (store_key, store_json, updated_at) VALUES (?, ?, ?)", + `INSERT INTO workspace_path_aliases ( + alias_key, alias_path, workspace_key, workspace_path, updated_at_ms + ) VALUES (?, ?, ?, ?, ?)`, ) - .run("legacy-store", "{}", 20); + .run("legacy-alias", "/tmp/legacy-alias", "legacy-workspace", "/tmp/legacy-workspace", 20); legacy.exec(` - ALTER TABLE auth_profile_stores RENAME TO auth_profile_stores_strict; - CREATE TABLE auth_profile_stores ( - store_key TEXT NOT NULL PRIMARY KEY, - store_json TEXT NOT NULL, - updated_at INTEGER NOT NULL + ALTER TABLE workspace_path_aliases RENAME TO workspace_path_aliases_strict; + CREATE TABLE workspace_path_aliases ( + alias_key TEXT NOT NULL PRIMARY KEY, + alias_path TEXT NOT NULL, + workspace_key TEXT NOT NULL, + workspace_path TEXT NOT NULL, + updated_at_ms INTEGER NOT NULL ); - INSERT INTO auth_profile_stores SELECT * FROM auth_profile_stores_strict; - DROP TABLE auth_profile_stores_strict; + INSERT INTO workspace_path_aliases SELECT * FROM workspace_path_aliases_strict; + DROP TABLE workspace_path_aliases_strict; PRAGMA user_version = 2; UPDATE schema_meta SET schema_version = 2 WHERE meta_key = 'primary'; `); @@ -3069,13 +3942,15 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're const migrated = openOpenClawStateDatabase(options); expect( migrated.db - .prepare("SELECT strict FROM pragma_table_list WHERE name = 'auth_profile_stores'") + .prepare("SELECT strict FROM pragma_table_list WHERE name = 'workspace_path_aliases'") .get(), ).toEqual({ strict: 1 }); - expect(migrated.db.prepare("SELECT * FROM auth_profile_stores").get()).toEqual({ - store_key: "legacy-store", - store_json: "{}", - updated_at: 20, + expect(migrated.db.prepare("SELECT * FROM workspace_path_aliases").get()).toEqual({ + alias_key: "legacy-alias", + alias_path: "/tmp/legacy-alias", + workspace_key: "legacy-workspace", + workspace_path: "/tmp/legacy-workspace", + updated_at_ms: 20, }); }); @@ -3711,36 +4586,6 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're }, ); - it("appends the same-version plugin workspace column before schema validation", () => { - const stateDir = createTempStateDir(); - const env = { OPENCLAW_STATE_DIR: stateDir }; - const databasePath = materializeCurrentStateDatabase(stateDir); - - const { DatabaseSync } = requireNodeSqlite(); - const shippedSchema = new DatabaseSync(databasePath); - try { - shippedSchema.exec(` - ALTER TABLE installed_plugin_index DROP COLUMN workspace_dir; - `); - expect(readSqliteNumberPragma(shippedSchema, "user_version")).toBe( - OPENCLAW_STATE_SCHEMA_VERSION, - ); - } finally { - shippedSchema.close(); - } - - const reopened = openOpenClawStateDatabase({ env }); - const columns = reopened.db - .prepare("PRAGMA table_info(installed_plugin_index)") - .all() as Array<{ - name: string; - }>; - expect(columns.map((column) => column.name)).toContain("workspace_dir"); - expect(() => - assertOpenClawStateDatabaseForMaintenance(reopened.db, { pathname: reopened.path }), - ).not.toThrow(); - }); - it("installs same-version worker session tool tables before runtime schema validation", () => { const stateDir = createTempStateDir(); const env = { OPENCLAW_STATE_DIR: stateDir }; @@ -3894,13 +4739,15 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're const { DatabaseSync } = requireNodeSqlite(); const drifted = new DatabaseSync(databasePath); - drifted.exec("DROP TABLE auth_profile_stores;"); + drifted.exec("DROP TABLE apns_registration_tombstones;"); drifted.close(); - expect(() => openOpenClawStateDatabase(options)).toThrow(/missing table auth_profile_stores/iu); + expect(() => openOpenClawStateDatabase(options)).toThrow( + /missing table apns_registration_tombstones/iu, + ); expect(repairOpenClawStateDatabaseSchema(options)).toEqual({ changes: [], - warnings: [expect.stringContaining("missing table auth_profile_stores")], + warnings: [expect.stringContaining("missing table apns_registration_tombstones")], }); const after = new DatabaseSync(databasePath, { readOnly: true }); @@ -3908,7 +4755,7 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're expect( after .prepare( - "SELECT name FROM sqlite_schema WHERE type = 'table' AND name = 'auth_profile_stores'", + "SELECT name FROM sqlite_schema WHERE type = 'table' AND name = 'apns_registration_tombstones'", ) .get(), ).toBeUndefined(); @@ -3933,18 +4780,18 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're const { DatabaseSync } = requireNodeSqlite(); const damaged = new DatabaseSync(databasePath); - damaged.exec("DROP TABLE auth_profile_stores;"); + damaged.exec("DROP TABLE apns_registration_tombstones;"); markStateDatabaseVersion(damaged, version); damaged.close(); if (migrationPath === "runtime open") { expect(() => openOpenClawStateDatabase(options)).toThrow( - /missing table auth_profile_stores/iu, + /missing table apns_registration_tombstones/iu, ); } else { expect(repairOpenClawStateDatabaseSchema(options)).toEqual({ changes: [], - warnings: [expect.stringContaining("missing table auth_profile_stores")], + warnings: [expect.stringContaining("missing table apns_registration_tombstones")], }); } @@ -3953,7 +4800,7 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're expect( after .prepare( - "SELECT name FROM sqlite_schema WHERE type = 'table' AND name = 'auth_profile_stores'", + "SELECT name FROM sqlite_schema WHERE type = 'table' AND name = 'apns_registration_tombstones'", ) .get(), ).toBeUndefined(); @@ -4046,17 +4893,16 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're const { DatabaseSync } = requireNodeSqlite(); const drifted = new DatabaseSync(databasePath); drifted.exec(` - DROP TABLE auth_profile_stores; - CREATE TABLE auth_profile_stores ( - store_key TEXT COLLATE NOCASE NOT NULL PRIMARY KEY, - store_json TEXT NOT NULL, - updated_at INTEGER NOT NULL + DROP TABLE apns_registration_tombstones; + CREATE TABLE apns_registration_tombstones ( + node_id TEXT COLLATE NOCASE NOT NULL PRIMARY KEY, + deleted_at_ms INTEGER NOT NULL ) STRICT; `); drifted.close(); expect(() => openOpenClawStateDatabase(options)).toThrow( - /column definitions differ for auth_profile_stores/iu, + /column definitions differ for apns_registration_tombstones/iu, ); }); @@ -4350,7 +5196,7 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're expect( preserved .prepare( - "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'auth_profile_stores'", + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'apns_registration_tombstones'", ) .get(), ).toBeUndefined(); @@ -5959,58 +6805,30 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're env: { OPENCLAW_STATE_DIR: stateDir }, }); - expect(() => - database.db.prepare("SELECT enabled, session_key FROM cron_jobs LIMIT 1").all(), - ).not.toThrow(); expect( database.db .prepare( - `SELECT name, enabled, delete_after_run, schedule_kind, every_ms, payload_kind, payload_message, - payload_model, agent_id, session_key, session_target, wake_mode, delivery_mode, delivery_channel, - delivery_to, delivery_account_id, delivery_best_effort, failure_delivery_mode, - failure_delivery_channel, failure_delivery_to, failure_delivery_account_id, - failure_alert_mode, failure_alert_channel, failure_alert_to, - failure_alert_after + `SELECT name, enabled, payload_kind, agent_id, job_json FROM cron_jobs WHERE job_id = ?`, ) .get("legacy-job"), ).toEqual({ enabled: 1, - delete_after_run: 1, - every_ms: 3_600_000, agent_id: "agent-a", name: "Legacy job", payload_kind: "agentTurn", - payload_message: "hello", - payload_model: "anthropic/claude-sonnet-4-6", - schedule_kind: "every", - session_key: "agent:agent-a:main", - session_target: "isolated", - wake_mode: "now", - delivery_account_id: "acct-1", - delivery_best_effort: 1, - delivery_channel: "telegram", - delivery_mode: "announce", - delivery_to: "chat-1", - failure_alert_after: 2, - failure_alert_channel: "discord", - failure_alert_mode: "announce", - failure_alert_to: "ops", - failure_delivery_account_id: null, - failure_delivery_channel: null, - failure_delivery_mode: null, - failure_delivery_to: "https://example.invalid/hook", + job_json: jobJson, }); expect( database.db .prepare( - `SELECT delivery_thread_id, delivery_thread_id_type + `SELECT json_extract(job_json, '$.delivery.threadId') AS delivery_thread_id FROM cron_jobs WHERE job_id = ?`, ) .get("already-projected-job"), - ).toEqual({ delivery_thread_id: "1008013", delivery_thread_id_type: "number" }); + ).toEqual({ delivery_thread_id: 1008013 }); }); it("imports early cron run-log tables before dropping them", () => { diff --git a/src/state/openclaw-state-db.ts b/src/state/openclaw-state-db.ts index 30b6a7b9df51..9c0a91b8cb8b 100644 --- a/src/state/openclaw-state-db.ts +++ b/src/state/openclaw-state-db.ts @@ -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, { diff --git a/src/state/openclaw-state-schema-compatibility.ts b/src/state/openclaw-state-schema-compatibility.ts index d05ea97f9d88..d139998bd35b 100644 --- a/src/state/openclaw-state-schema-compatibility.ts +++ b/src/state/openclaw-state-schema-compatibility.ts @@ -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'", ], diff --git a/src/state/openclaw-state-schema-v13-widerow.test-support.ts b/src/state/openclaw-state-schema-v13-widerow.test-support.ts new file mode 100644 index 000000000000..73fe538e8392 --- /dev/null +++ b/src/state/openclaw-state-schema-v13-widerow.test-support.ts @@ -0,0 +1,469 @@ +// Historical readers compare complete column definitions, so ALTER ADD defaults +// cannot restore v12's NOT NULL columns; rebuild both exact original contracts. +export const STATE_SCHEMA_13_TO_12_DOWNGRADE_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; +`; diff --git a/src/state/openclaw-state-schema.sql b/src/state/openclaw-state-schema.sql index 47b1cc1062c5..9232c1d6029d 100644 --- a/src/state/openclaw-state-schema.sql +++ b/src/state/openclaw-state-schema.sql @@ -1,14 +1,4 @@ -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; -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; CREATE TABLE IF NOT EXISTS mcp_oauth_stores ( store_key TEXT NOT NULL PRIMARY KEY, @@ -693,11 +683,18 @@ CREATE INDEX IF NOT EXISTS idx_macos_port_guardian_records_port CREATE TABLE IF NOT EXISTS workspace_setup_state ( workspace_key TEXT NOT NULL PRIMARY KEY, - workspace_path TEXT NOT NULL, - version INTEGER NOT NULL, + -- NULL only for attestation-only rows whose legacy source never recorded a + -- path (orphan hashed-key attestations); setup rows always carry one. + workspace_path TEXT, + -- NULL setup columns mean an attestation-only row: replaceWorkspaceAttestation + -- may record hashes before any setup milestone exists for the workspace. + version INTEGER, bootstrap_seeded_at TEXT, setup_completed_at TEXT, - updated_at INTEGER NOT NULL + updated_at INTEGER, + attested_at_ms INTEGER, + attestation_updated_at_ms INTEGER, + CHECK (version IS NULL OR workspace_path IS NOT NULL) ) STRICT; CREATE INDEX IF NOT EXISTS idx_workspace_setup_state_path @@ -714,21 +711,14 @@ CREATE TABLE IF NOT EXISTS workspace_path_aliases ( CREATE INDEX IF NOT EXISTS idx_workspace_path_aliases_workspace ON workspace_path_aliases(workspace_key); -CREATE TABLE IF NOT EXISTS workspace_attestations ( - workspace_key TEXT NOT NULL PRIMARY KEY, - attested_at_ms INTEGER NOT NULL, - updated_at_ms INTEGER NOT NULL -) STRICT; -CREATE INDEX IF NOT EXISTS idx_workspace_attestations_attested - ON workspace_attestations(attested_at_ms DESC, workspace_key); CREATE TABLE IF NOT EXISTS workspace_generated_bootstrap_hashes ( 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 + FOREIGN KEY (workspace_key) REFERENCES workspace_setup_state(workspace_key) ON DELETE CASCADE ) STRICT; CREATE TABLE IF NOT EXISTS native_hook_relay_bridges ( @@ -944,25 +934,7 @@ CREATE TABLE IF NOT EXISTS clawhub_promotion_claims ( claimed_at_ms INTEGER NOT NULL ) STRICT; -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); CREATE TABLE IF NOT EXISTS official_external_plugin_catalog_snapshots ( feed_url TEXT NOT NULL PRIMARY KEY, @@ -1332,72 +1304,12 @@ CREATE TABLE IF NOT EXISTS cron_jobs ( 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, @@ -1407,20 +1319,9 @@ CREATE TABLE IF NOT EXISTS cron_jobs ( PRIMARY KEY (store_key, job_id) ) STRICT; -CREATE INDEX IF NOT EXISTS idx_cron_jobs_store_updated - ON cron_jobs(store_key, sort_order ASC, updated_at DESC, job_id); - CREATE INDEX IF NOT EXISTS idx_cron_jobs_store_order ON cron_jobs(store_key, sort_order ASC, updated_at ASC, job_id); -CREATE INDEX IF NOT EXISTS 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 IF NOT EXISTS 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; - -- One owner-native receipt is also the durable execution fence. Receipts -- survive job deletion so operators can distinguish a run from log inference. CREATE TABLE IF NOT EXISTS cron_run_receipts ( @@ -1575,60 +1476,7 @@ CREATE TABLE IF NOT EXISTS subagent_runs ( 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; @@ -1638,10 +1486,6 @@ CREATE INDEX IF NOT EXISTS idx_subagent_runs_requester_session_key ON subagent_runs(requester_session_key, created_at DESC, run_id); CREATE INDEX IF NOT EXISTS idx_subagent_runs_controller_session_key ON subagent_runs(controller_session_key, created_at DESC, run_id); -CREATE INDEX IF NOT EXISTS idx_subagent_runs_archive_at - ON subagent_runs(archive_at_ms, cleanup_handled, run_id); -CREATE INDEX IF NOT EXISTS idx_subagent_runs_ended_cleanup - ON subagent_runs(ended_at, cleanup_handled, run_id); CREATE TABLE IF NOT EXISTS current_conversation_bindings ( binding_key TEXT NOT NULL PRIMARY KEY, diff --git a/src/state/secret-state-tables.ts b/src/state/secret-state-tables.ts index 76758ddd618c..6092493931dc 100644 --- a/src/state/secret-state-tables.ts +++ b/src/state/secret-state-tables.ts @@ -1,8 +1,6 @@ /** Redaction policy surface: Git snapshots may omit these credential-bearing tables. */ export const STATE_SECRET_TABLE_NAMES = [ "audit_identity_keys", - "auth_profile_state", - "auth_profile_stores", "apns_registrations", "channel_ingress_events", "channel_pairing_requests", @@ -23,7 +21,11 @@ export const STATE_SECRET_TABLE_NAMES = [ ] as const; /** Secret-redacted Git backups must never carry machine-state values under these prefixes. */ -export const STATE_SECRET_CONFIG_STATE_KEY_PREFIXES = ["nodeHost.", "webPush.vapidKeys"] as const; +export const STATE_SECRET_CONFIG_STATE_KEY_PREFIXES = [ + "authProfiles.", + "nodeHost.", + "webPush.vapidKeys", +] as const; /** Redaction policy surface for credential-bearing per-agent database tables. */ export const AGENT_SECRET_TABLE_NAMES = [ diff --git a/src/state/sqlite-query-plan.test.ts b/src/state/sqlite-query-plan.test.ts index e5117e43650c..a946d46403c1 100644 --- a/src/state/sqlite-query-plan.test.ts +++ b/src/state/sqlite-query-plan.test.ts @@ -76,18 +76,6 @@ describe("sqlite hot query plans", () => { LIMIT 25 `, }); - expectPlanUsesIndex({ - db: database.db, - indexName: "idx_cron_jobs_enabled_next_run", - params: ["/state/cron/jobs.json"], - sql: ` - 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 25 - `, - }); expectPlanUsesIndex({ db: database.db, indexName: "idx_delivery_queue_pending", diff --git a/src/state/user-profiles.test.ts b/src/state/user-profiles.test.ts index eae97fb431e0..645b5cd99f86 100644 --- a/src/state/user-profiles.test.ts +++ b/src/state/user-profiles.test.ts @@ -114,7 +114,7 @@ describe("user profiles", () => { openOpenClawStateDatabase(options).db.prepare("PRAGMA user_version").get()?.user_version, ).toBe(versionBefore); expect(versionBefore).toBe(OPENCLAW_STATE_SCHEMA_VERSION); - expect(OPENCLAW_STATE_SCHEMA_VERSION).toBe(12); + expect(OPENCLAW_STATE_SCHEMA_VERSION).toBe(13); expect(second).toEqual(first); expect(ensureProfileForEmail("ADA@example.com", options)).toEqual(first); expect(listProfiles(options)).toEqual([ diff --git a/test/scripts/auth-profile-store-assertions.test.ts b/test/scripts/auth-profile-store-assertions.test.ts index 4f6115457b15..b6315845b3ee 100644 --- a/test/scripts/auth-profile-store-assertions.test.ts +++ b/test/scripts/auth-profile-store-assertions.test.ts @@ -25,19 +25,19 @@ function writeSharedDatabase( try { if (options.asView) { db.exec(` - CREATE VIEW auth_profile_stores AS - SELECT 'shared' AS store_key, '{}' AS store_json, 1 AS updated_at; + CREATE VIEW config_machine_state AS + SELECT 'authProfiles.store' AS state_key, '{}' AS value_json, 1 AS updated_at_ms; `); } else { db.exec(` - CREATE TABLE auth_profile_stores ( - store_key TEXT NOT NULL PRIMARY KEY, - store_json TEXT NOT NULL, - updated_at INTEGER NOT NULL + CREATE TABLE config_machine_state ( + state_key TEXT NOT NULL PRIMARY KEY, + value_json TEXT NOT NULL, + updated_at_ms INTEGER NOT NULL ) STRICT; `); - db.prepare("INSERT INTO auth_profile_stores VALUES (?, ?, ?)").run( - "shared", + db.prepare("INSERT INTO config_machine_state VALUES (?, ?, ?)").run( + "authProfiles.store", options.storeJson ?? "{}", Date.now(), ); @@ -127,7 +127,7 @@ describe("auth profile store E2E assertions", () => { writeSharedDatabase(stateDir, { asView: true }); expect(() => readSharedAuthProfileStoreText(stateDir)).toThrow( - "auth_profile_stores is view, not a table", + "config_machine_state is view, not a table", ); }); diff --git a/test/scripts/check-native-state-schema-version.test.ts b/test/scripts/check-native-state-schema-version.test.ts index bbc1f7063617..e82e3aa21aa2 100644 --- a/test/scripts/check-native-state-schema-version.test.ts +++ b/test/scripts/check-native-state-schema-version.test.ts @@ -7,7 +7,7 @@ import { OPENCLAW_STATE_SCHEMA_VERSION } from "../../src/state/openclaw-state-db describe("native state schema version guard", () => { it("keeps the checked-in Swift and TypeScript contracts aligned", () => { - expect(OPENCLAW_STATE_SCHEMA_VERSION).toBe(12); + expect(OPENCLAW_STATE_SCHEMA_VERSION).toBe(13); expect(checkNativeStateSchemaVersion()).toBe(OPENCLAW_STATE_SCHEMA_VERSION); }); diff --git a/test/scripts/codex-install-assertions.test.ts b/test/scripts/codex-install-assertions.test.ts index 10010070e250..19d79b68caa5 100644 --- a/test/scripts/codex-install-assertions.test.ts +++ b/test/scripts/codex-install-assertions.test.ts @@ -57,19 +57,19 @@ function writeAuthProfileStoreSqlite(stateDir: string) { const db = new DatabaseSync(databasePath); try { db.exec(` - 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 + 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 ); `); db.prepare( ` - INSERT INTO auth_profile_stores (store_key, store_json, updated_at) + INSERT INTO config_machine_state (state_key, value_json, updated_at_ms) VALUES (?, ?, ?) `, ).run( - "shared", + "authProfiles.store", JSON.stringify({ version: 1, profiles: { diff --git a/test/scripts/live-docker-stage.test.ts b/test/scripts/live-docker-stage.test.ts index 0beadc5f413e..e06accdd8a72 100644 --- a/test/scripts/live-docker-stage.test.ts +++ b/test/scripts/live-docker-stage.test.ts @@ -51,7 +51,9 @@ describe("live Docker state staging", () => { expect(script).toContain("--exclude=sandboxes"); expect(script).toContain("--exclude=plugins/installs.json"); expect(script).toContain("--exclude=plugins/installs.json.migrated"); - expect(script).toContain("DELETE FROM installed_plugin_index"); + expect(script).toContain( + `db.prepare("DELETE FROM config_machine_state WHERE state_key = ?").run("plugins.installedIndex");`, + ); expect(script).toContain("PRAGMA secure_delete = ON"); expect(script).toContain("VACUUM"); expect(script).toContain("host-absolute paths"); diff --git a/test/scripts/npm-onboard-channel-agent-assertions.test.ts b/test/scripts/npm-onboard-channel-agent-assertions.test.ts index a75b69003baa..6d2b70374b3e 100644 --- a/test/scripts/npm-onboard-channel-agent-assertions.test.ts +++ b/test/scripts/npm-onboard-channel-agent-assertions.test.ts @@ -43,18 +43,18 @@ function writeSharedAuthProfileStoreSqlite(home: string, store: unknown): void { const db = new DatabaseSync(path.join(stateDir, "openclaw.sqlite")); try { db.exec(` - 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 + 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 ); `); db.prepare( ` - INSERT INTO auth_profile_stores (store_key, store_json, updated_at) + INSERT INTO config_machine_state (state_key, value_json, updated_at_ms) VALUES (?, ?, ?) `, - ).run("shared", JSON.stringify(store), Date.now()); + ).run("authProfiles.store", JSON.stringify(store), Date.now()); } finally { db.close(); } diff --git a/test/scripts/plugin-index-sqlite.test.ts b/test/scripts/plugin-index-sqlite.test.ts index 60bedf3596fe..a95aba959f0b 100644 --- a/test/scripts/plugin-index-sqlite.test.ts +++ b/test/scripts/plugin-index-sqlite.test.ts @@ -32,44 +32,30 @@ function writeSqliteIndex(root: string, installRecordsJson: string) { const db = new DatabaseSync(dbPath); try { db.exec(` - CREATE TABLE 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 config_machine_state ( + state_key TEXT NOT NULL PRIMARY KEY, + value_json TEXT NOT NULL, updated_at_ms INTEGER NOT NULL ); `); 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, + "INSERT INTO config_machine_state (state_key, value_json, updated_at_ms) VALUES (?, ?, ?)", ).run( - "installed-plugin-index", - 1, - "1", - "1", - 1, - "hash", - Date.now(), - null, - installRecordsJson, - "{}", - "{}", - null, + "plugins.installedIndex", + JSON.stringify({ + revision: Date.now(), + index: { + version: 1, + hostContractVersion: "1", + compatRegistryVersion: "1", + migrationVersion: 1, + policyHash: "hash", + generatedAtMs: Date.now(), + installRecords: JSON.parse(installRecordsJson) as unknown, + plugins: [], + diagnostics: [], + }, + }), Date.now(), ); } finally { @@ -139,7 +125,7 @@ describe("plugin index SQLite E2E helpers", () => { expect(() => readPluginInstallIndex({ stateDir: root, configPath: configPath(root) }), - ).toThrow("plugin index install_records_json exceeded 64 bytes"); + ).toThrow("plugin index value_json exceeded 64 bytes"); } finally { rmSync(root, { force: true, recursive: true }); } diff --git a/test/scripts/release-plugin-marketplace-lifecycle.test.ts b/test/scripts/release-plugin-marketplace-lifecycle.test.ts index a87d339aa1de..2bbac39632f8 100644 --- a/test/scripts/release-plugin-marketplace-lifecycle.test.ts +++ b/test/scripts/release-plugin-marketplace-lifecycle.test.ts @@ -23,46 +23,37 @@ function writeIndex( const db = new DatabaseSync(databasePath); 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 valueJson = JSON.stringify({ + revision: now, + index: { + version: 1, + hostContractVersion: "test", + compatRegistryVersion: "test", + migrationVersion: 1, + policyHash: "test", + generatedAtMs: now, + refreshReason: "source-changed", + installRecords: { [pluginId]: record }, + plugins: [{ pluginId, packageVersion }], + 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO config_machine_state (state_key, value_json, updated_at_ms) + VALUES ('plugins.installedIndex', ?, ?) + ON CONFLICT(state_key) DO UPDATE SET + value_json = excluded.value_json, + updated_at_ms = excluded.updated_at_ms `, - ).run( - "installed-plugin-index", - 1, - "test", - "test", - 1, - "test", - now, - "source-changed", - JSON.stringify({ [pluginId]: record }), - JSON.stringify([{ pluginId, packageVersion }]), - "[]", - null, - now, - ); + ).run(valueJson, now); } finally { db.close(); } @@ -120,13 +111,16 @@ function clearMarketplaceIndex(home: string) { try { db.prepare( ` - UPDATE installed_plugin_index - SET install_records_json = ?, - plugins_json = ?, + UPDATE config_machine_state + SET value_json = json_set( + value_json, + '$.index.installRecords', json('{}'), + '$.index.plugins', json('[]') + ), updated_at_ms = ? - WHERE index_key = ? + WHERE state_key = 'plugins.installedIndex' `, - ).run("{}", "[]", Date.now(), "installed-plugin-index"); + ).run(Date.now()); } finally { db.close(); } diff --git a/test/scripts/release-scenarios-assertions.test.ts b/test/scripts/release-scenarios-assertions.test.ts index 7f3eb614acdd..d44f5f64c8ae 100644 --- a/test/scripts/release-scenarios-assertions.test.ts +++ b/test/scripts/release-scenarios-assertions.test.ts @@ -38,18 +38,18 @@ function writeAuthProfileStoreSqlite(stateDir: string, store: unknown) { const db = new DatabaseSync(databasePath); try { db.exec(` - 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 + 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 ); `); db.prepare( ` - INSERT INTO auth_profile_stores (store_key, store_json, updated_at) + INSERT INTO config_machine_state (state_key, value_json, updated_at_ms) VALUES (?, ?, ?) `, - ).run("shared", JSON.stringify(store), Date.now()); + ).run("authProfiles.store", JSON.stringify(store), Date.now()); } finally { db.close(); }