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

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

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

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

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

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

* refactor(state): fold installed_plugin_index into config_machine_state

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

* refactor(state): merge workspace_attestations into workspace_setup_state

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

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

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

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

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

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

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

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

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

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

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

* style: format the synced downgrade example

* style: drop unused import and duplicate union constituent

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

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

* perf(state): retire unused cron indexes

* fix(state): preserve v13 migration recovery

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

* docs(state): document v13 auth relocation

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
Peter Steinberger
2026-08-27 00:26:14 -07:00
committed by GitHub
parent dff2d26820
commit 1ea2640f54
113 changed files with 3505 additions and 3481 deletions
@@ -25,19 +25,19 @@ function writeSharedDatabase(
try {
if (options.asView) {
db.exec(`
CREATE VIEW auth_profile_stores AS
SELECT 'shared' AS store_key, '{}' AS store_json, 1 AS updated_at;
CREATE VIEW config_machine_state AS
SELECT 'authProfiles.store' AS state_key, '{}' AS value_json, 1 AS updated_at_ms;
`);
} else {
db.exec(`
CREATE TABLE auth_profile_stores (
store_key TEXT NOT NULL PRIMARY KEY,
store_json TEXT NOT NULL,
updated_at INTEGER NOT NULL
CREATE TABLE config_machine_state (
state_key TEXT NOT NULL PRIMARY KEY,
value_json TEXT NOT NULL,
updated_at_ms INTEGER NOT NULL
) STRICT;
`);
db.prepare("INSERT INTO auth_profile_stores VALUES (?, ?, ?)").run(
"shared",
db.prepare("INSERT INTO config_machine_state VALUES (?, ?, ?)").run(
"authProfiles.store",
options.storeJson ?? "{}",
Date.now(),
);
@@ -127,7 +127,7 @@ describe("auth profile store E2E assertions", () => {
writeSharedDatabase(stateDir, { asView: true });
expect(() => readSharedAuthProfileStoreText(stateDir)).toThrow(
"auth_profile_stores is view, not a table",
"config_machine_state is view, not a table",
);
});
@@ -7,7 +7,7 @@ import { OPENCLAW_STATE_SCHEMA_VERSION } from "../../src/state/openclaw-state-db
describe("native state schema version guard", () => {
it("keeps the checked-in Swift and TypeScript contracts aligned", () => {
expect(OPENCLAW_STATE_SCHEMA_VERSION).toBe(12);
expect(OPENCLAW_STATE_SCHEMA_VERSION).toBe(13);
expect(checkNativeStateSchemaVersion()).toBe(OPENCLAW_STATE_SCHEMA_VERSION);
});
@@ -57,19 +57,19 @@ function writeAuthProfileStoreSqlite(stateDir: string) {
const db = new DatabaseSync(databasePath);
try {
db.exec(`
CREATE TABLE IF NOT EXISTS auth_profile_stores (
store_key TEXT NOT NULL PRIMARY KEY,
store_json TEXT NOT NULL,
updated_at INTEGER NOT NULL
CREATE TABLE IF NOT EXISTS config_machine_state (
state_key TEXT NOT NULL PRIMARY KEY,
value_json TEXT NOT NULL,
updated_at_ms INTEGER NOT NULL
);
`);
db.prepare(
`
INSERT INTO auth_profile_stores (store_key, store_json, updated_at)
INSERT INTO config_machine_state (state_key, value_json, updated_at_ms)
VALUES (?, ?, ?)
`,
).run(
"shared",
"authProfiles.store",
JSON.stringify({
version: 1,
profiles: {
+3 -1
View File
@@ -51,7 +51,9 @@ describe("live Docker state staging", () => {
expect(script).toContain("--exclude=sandboxes");
expect(script).toContain("--exclude=plugins/installs.json");
expect(script).toContain("--exclude=plugins/installs.json.migrated");
expect(script).toContain("DELETE FROM installed_plugin_index");
expect(script).toContain(
`db.prepare("DELETE FROM config_machine_state WHERE state_key = ?").run("plugins.installedIndex");`,
);
expect(script).toContain("PRAGMA secure_delete = ON");
expect(script).toContain("VACUUM");
expect(script).toContain("host-absolute paths");
@@ -43,18 +43,18 @@ function writeSharedAuthProfileStoreSqlite(home: string, store: unknown): void {
const db = new DatabaseSync(path.join(stateDir, "openclaw.sqlite"));
try {
db.exec(`
CREATE TABLE IF NOT EXISTS auth_profile_stores (
store_key TEXT NOT NULL PRIMARY KEY,
store_json TEXT NOT NULL,
updated_at INTEGER NOT NULL
CREATE TABLE IF NOT EXISTS config_machine_state (
state_key TEXT NOT NULL PRIMARY KEY,
value_json TEXT NOT NULL,
updated_at_ms INTEGER NOT NULL
);
`);
db.prepare(
`
INSERT INTO auth_profile_stores (store_key, store_json, updated_at)
INSERT INTO config_machine_state (state_key, value_json, updated_at_ms)
VALUES (?, ?, ?)
`,
).run("shared", JSON.stringify(store), Date.now());
).run("authProfiles.store", JSON.stringify(store), Date.now());
} finally {
db.close();
}
+20 -34
View File
@@ -32,44 +32,30 @@ function writeSqliteIndex(root: string, installRecordsJson: string) {
const db = new DatabaseSync(dbPath);
try {
db.exec(`
CREATE TABLE installed_plugin_index (
index_key TEXT NOT NULL PRIMARY KEY,
version INTEGER NOT NULL,
host_contract_version TEXT NOT NULL,
compat_registry_version TEXT NOT NULL,
migration_version INTEGER NOT NULL,
policy_hash TEXT NOT NULL,
generated_at_ms INTEGER NOT NULL,
refresh_reason TEXT,
install_records_json TEXT NOT NULL,
plugins_json TEXT NOT NULL,
diagnostics_json TEXT NOT NULL,
warning TEXT,
CREATE TABLE config_machine_state (
state_key TEXT NOT NULL PRIMARY KEY,
value_json TEXT NOT NULL,
updated_at_ms INTEGER NOT NULL
);
`);
db.prepare(
`
INSERT INTO installed_plugin_index (
index_key, version, host_contract_version, compat_registry_version,
migration_version, policy_hash, generated_at_ms, refresh_reason,
install_records_json, plugins_json, diagnostics_json, warning, updated_at_ms
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
"INSERT INTO config_machine_state (state_key, value_json, updated_at_ms) VALUES (?, ?, ?)",
).run(
"installed-plugin-index",
1,
"1",
"1",
1,
"hash",
Date.now(),
null,
installRecordsJson,
"{}",
"{}",
null,
"plugins.installedIndex",
JSON.stringify({
revision: Date.now(),
index: {
version: 1,
hostContractVersion: "1",
compatRegistryVersion: "1",
migrationVersion: 1,
policyHash: "hash",
generatedAtMs: Date.now(),
installRecords: JSON.parse(installRecordsJson) as unknown,
plugins: [],
diagnostics: [],
},
}),
Date.now(),
);
} finally {
@@ -139,7 +125,7 @@ describe("plugin index SQLite E2E helpers", () => {
expect(() =>
readPluginInstallIndex({ stateDir: root, configPath: configPath(root) }),
).toThrow("plugin index install_records_json exceeded 64 bytes");
).toThrow("plugin index value_json exceeded 64 bytes");
} finally {
rmSync(root, { force: true, recursive: true });
}
@@ -23,46 +23,37 @@ function writeIndex(
const db = new DatabaseSync(databasePath);
try {
db.exec(`
CREATE TABLE IF NOT EXISTS installed_plugin_index (
index_key TEXT NOT NULL PRIMARY KEY,
version INTEGER NOT NULL,
host_contract_version TEXT NOT NULL,
compat_registry_version TEXT NOT NULL,
migration_version INTEGER NOT NULL,
policy_hash TEXT NOT NULL,
generated_at_ms INTEGER NOT NULL,
refresh_reason TEXT,
install_records_json TEXT NOT NULL,
plugins_json TEXT NOT NULL,
diagnostics_json TEXT NOT NULL,
warning TEXT,
CREATE TABLE IF NOT EXISTS config_machine_state (
state_key TEXT NOT NULL PRIMARY KEY,
value_json TEXT NOT NULL,
updated_at_ms INTEGER NOT NULL
);
`);
const now = Date.now();
const valueJson = JSON.stringify({
revision: now,
index: {
version: 1,
hostContractVersion: "test",
compatRegistryVersion: "test",
migrationVersion: 1,
policyHash: "test",
generatedAtMs: now,
refreshReason: "source-changed",
installRecords: { [pluginId]: record },
plugins: [{ pluginId, packageVersion }],
diagnostics: [],
},
});
db.prepare(
`
INSERT OR REPLACE INTO installed_plugin_index (
index_key, version, host_contract_version, compat_registry_version,
migration_version, policy_hash, generated_at_ms, refresh_reason,
install_records_json, plugins_json, diagnostics_json, warning, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO config_machine_state (state_key, value_json, updated_at_ms)
VALUES ('plugins.installedIndex', ?, ?)
ON CONFLICT(state_key) DO UPDATE SET
value_json = excluded.value_json,
updated_at_ms = excluded.updated_at_ms
`,
).run(
"installed-plugin-index",
1,
"test",
"test",
1,
"test",
now,
"source-changed",
JSON.stringify({ [pluginId]: record }),
JSON.stringify([{ pluginId, packageVersion }]),
"[]",
null,
now,
);
).run(valueJson, now);
} finally {
db.close();
}
@@ -120,13 +111,16 @@ function clearMarketplaceIndex(home: string) {
try {
db.prepare(
`
UPDATE installed_plugin_index
SET install_records_json = ?,
plugins_json = ?,
UPDATE config_machine_state
SET value_json = json_set(
value_json,
'$.index.installRecords', json('{}'),
'$.index.plugins', json('[]')
),
updated_at_ms = ?
WHERE index_key = ?
WHERE state_key = 'plugins.installedIndex'
`,
).run("{}", "[]", Date.now(), "installed-plugin-index");
).run(Date.now());
} finally {
db.close();
}
@@ -38,18 +38,18 @@ function writeAuthProfileStoreSqlite(stateDir: string, store: unknown) {
const db = new DatabaseSync(databasePath);
try {
db.exec(`
CREATE TABLE IF NOT EXISTS auth_profile_stores (
store_key TEXT NOT NULL PRIMARY KEY,
store_json TEXT NOT NULL,
updated_at INTEGER NOT NULL
CREATE TABLE IF NOT EXISTS config_machine_state (
state_key TEXT NOT NULL PRIMARY KEY,
value_json TEXT NOT NULL,
updated_at_ms INTEGER NOT NULL
);
`);
db.prepare(
`
INSERT INTO auth_profile_stores (store_key, store_json, updated_at)
INSERT INTO config_machine_state (state_key, value_json, updated_at_ms)
VALUES (?, ?, ?)
`,
).run("shared", JSON.stringify(store), Date.now());
).run("authProfiles.store", JSON.stringify(store), Date.now());
} finally {
db.close();
}