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