fix(state): accept shipped commitments schema

This commit is contained in:
Vincent Koc
2026-08-13 10:48:40 +08:00
parent 812bbd8884
commit 68b03df870
2 changed files with 191 additions and 47 deletions
+25 -27
View File
@@ -3,7 +3,9 @@ import type { DatabaseSync } from "node:sqlite";
import { openNodeSqliteDatabase } from "../infra/node-sqlite.js";
import {
assertSqliteSchemaContains,
collectSqliteNamedIndexContract,
collectSqliteSchemaIssues,
getCanonicalSqliteNamedIndexContracts,
type SqliteSchemaCompatibility,
} from "../infra/sqlite-schema-contract.js";
import { quoteSqliteIdentifier } from "../infra/sqlite-schema-sql.js";
@@ -81,7 +83,7 @@ CREATE INDEX idx_commitments_agent_sent
ON commitments(agent_id, status, sent_at_ms, session_key);
`;
const ADDITIVE_RETIRED_COMMITMENTS_SCHEMA_SQL = `
const SHIPPED_RETIRED_COMMITMENTS_SCHEMA_SQL = `
CREATE TABLE commitments (
id TEXT NOT NULL PRIMARY KEY,
agent_id TEXT NOT NULL,
@@ -91,22 +93,22 @@ CREATE TABLE commitments (
recipient_id TEXT,
thread_id TEXT,
sender_id TEXT,
kind TEXT NOT NULL DEFAULT 'followup',
sensitivity TEXT NOT NULL DEFAULT 'normal',
source TEXT NOT NULL DEFAULT 'unknown',
kind TEXT NOT NULL,
sensitivity TEXT NOT NULL,
source TEXT NOT NULL,
status TEXT NOT NULL,
reason TEXT NOT NULL DEFAULT '',
suggested_text TEXT NOT NULL DEFAULT '',
dedupe_key TEXT NOT NULL DEFAULT '',
confidence REAL NOT NULL DEFAULT 0,
reason TEXT NOT NULL,
suggested_text TEXT NOT NULL,
dedupe_key TEXT NOT NULL,
confidence REAL NOT NULL,
due_earliest_ms INTEGER NOT NULL,
due_latest_ms INTEGER NOT NULL,
due_timezone TEXT NOT NULL DEFAULT 'UTC',
due_timezone TEXT NOT NULL,
source_message_id TEXT,
source_run_id TEXT,
created_at_ms INTEGER NOT NULL DEFAULT 0,
created_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
attempts INTEGER NOT NULL,
last_attempt_at_ms INTEGER,
sent_at_ms INTEGER,
dismissed_at_ms INTEGER,
@@ -118,12 +120,8 @@ CREATE INDEX idx_commitments_scope_due
ON commitments(agent_id, session_key, status, due_earliest_ms, due_latest_ms);
CREATE INDEX idx_commitments_status_due
ON commitments(status, due_earliest_ms, due_latest_ms);
CREATE INDEX idx_commitments_agent_due
ON commitments(agent_id, status, due_earliest_ms, due_latest_ms, session_key);
CREATE INDEX idx_commitments_scope_dedupe
ON commitments(agent_id, session_key, channel, dedupe_key, status);
CREATE INDEX idx_commitments_agent_sent
ON commitments(agent_id, status, sent_at_ms, session_key);
`;
const RETIRED_COMMITMENTS_INDEX_NAMES = [
@@ -133,6 +131,11 @@ const RETIRED_COMMITMENTS_INDEX_NAMES = [
"idx_commitments_scope_due",
"idx_commitments_status_due",
] as const;
const RETIRED_COMMITMENTS_INDEX_FINGERPRINTS = new Map(
getCanonicalSqliteNamedIndexContracts(RETIRED_COMMITMENTS_SCHEMA_SQL).map(
({ fingerprint, name }) => [name, JSON.stringify(fingerprint)],
),
);
const RETIRED_COMMITMENTS_ADDITIVE_COLUMNS = [
"commitments.account_id",
@@ -178,15 +181,9 @@ const RETIRED_COMMITMENTS_SCHEMA_COMPATIBILITY: SqliteSchemaCompatibility = {
allowedMissingIndexes: RETIRED_COMMITMENTS_INDEX_NAMES,
};
const ADDITIVE_RETIRED_COMMITMENTS_SCHEMA_COMPATIBILITY: SqliteSchemaCompatibility = {
allowedMissingColumns: RETIRED_COMMITMENTS_ADDITIVE_COLUMNS,
allowedMissingIndexes: RETIRED_COMMITMENTS_INDEX_NAMES,
};
function hasSupportedRetiredCommitmentsSchema(
db: DatabaseSync,
schemaSql: string,
expectedIndexNames: readonly string[],
compatibility: SqliteSchemaCompatibility = {},
): boolean {
if (collectSqliteSchemaIssues(db, schemaSql, compatibility).length > 0) {
@@ -202,9 +199,12 @@ function hasSupportedRetiredCommitmentsSchema(
ORDER BY type, name`,
)
.all() as Array<{ name: string; type: string }>;
const expectedIndexes = new Set(expectedIndexNames);
return attachedObjects.every(
(object) => object.type === "index" && expectedIndexes.has(object.name),
(object) =>
object.type === "index" &&
RETIRED_COMMITMENTS_INDEX_NAMES.includes(object.name) &&
JSON.stringify(collectSqliteNamedIndexContract(db, object.name)) ===
RETIRED_COMMITMENTS_INDEX_FINGERPRINTS.get(object.name),
);
}
@@ -228,14 +228,12 @@ function hasRecognizedRetiredCommitmentsSchema(db: DatabaseSync): boolean {
hasSupportedRetiredCommitmentsSchema(
db,
RETIRED_COMMITMENTS_SCHEMA_SQL,
RETIRED_COMMITMENTS_INDEX_NAMES,
RETIRED_COMMITMENTS_SCHEMA_COMPATIBILITY,
) ||
hasSupportedRetiredCommitmentsSchema(
db,
ADDITIVE_RETIRED_COMMITMENTS_SCHEMA_SQL,
RETIRED_COMMITMENTS_INDEX_NAMES,
ADDITIVE_RETIRED_COMMITMENTS_SCHEMA_COMPATIBILITY,
SHIPPED_RETIRED_COMMITMENTS_SCHEMA_SQL,
RETIRED_COMMITMENTS_SCHEMA_COMPATIBILITY,
)
);
}
+166 -20
View File
@@ -318,6 +318,63 @@ function seedAdditiveV6CommitmentSchema(database: DatabaseSync): void {
markStateDatabaseAsV6(database);
}
function seedV2026_7_1_2CommitmentSchema(database: DatabaseSync): void {
database.exec(`
CREATE TABLE commitments (
id TEXT NOT NULL PRIMARY KEY,
agent_id TEXT NOT NULL,
session_key TEXT NOT NULL,
channel TEXT NOT NULL,
account_id TEXT,
recipient_id TEXT,
thread_id TEXT,
sender_id TEXT,
kind TEXT NOT NULL,
sensitivity TEXT NOT NULL,
source TEXT NOT NULL,
status TEXT NOT NULL,
reason TEXT NOT NULL,
suggested_text TEXT NOT NULL,
dedupe_key TEXT NOT NULL,
confidence REAL NOT NULL,
due_earliest_ms INTEGER NOT NULL,
due_latest_ms INTEGER NOT NULL,
due_timezone TEXT NOT NULL,
source_message_id TEXT,
source_run_id TEXT,
created_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL,
attempts INTEGER NOT NULL,
last_attempt_at_ms INTEGER,
sent_at_ms INTEGER,
dismissed_at_ms INTEGER,
snoozed_until_ms INTEGER,
expired_at_ms INTEGER,
record_json TEXT NOT NULL
);
CREATE INDEX idx_commitments_scope_due
ON commitments(agent_id, session_key, status, due_earliest_ms, due_latest_ms);
CREATE INDEX idx_commitments_status_due
ON commitments(status, due_earliest_ms, due_latest_ms);
CREATE INDEX idx_commitments_scope_dedupe
ON commitments(agent_id, session_key, channel, dedupe_key, status);
INSERT INTO commitments (
id, agent_id, session_key, channel, kind, sensitivity, source, status,
reason, suggested_text, dedupe_key, confidence, due_earliest_ms,
due_latest_ms, due_timezone, created_at_ms, updated_at_ms, attempts, record_json
) VALUES (
'released-commitment', 'main', 'agent:main:main', 'telegram', 'followup',
'normal', 'message', 'pending', 'inert', 'follow up', 'released-dedupe',
1.0, 10, 20, 'UTC', 1, 1, 0, '{}'
);
INSERT INTO state_leases (
scope, lease_key, owner, expires_at, heartbeat_at, payload_json, created_at, updated_at
) VALUES ('test', 'released-preserved-lease', 'migration-test', 100, 50, '{}', 1, 2);
PRAGMA user_version = 1;
UPDATE schema_meta SET schema_version = 1 WHERE meta_key = 'primary';
`);
}
function seedPartiallyAdditiveV6CommitmentSchema(database: DatabaseSync): void {
database.exec(`
CREATE TABLE commitments (
@@ -1583,6 +1640,79 @@ describe("openclaw state database", () => {
},
);
it.each(["runtime open", "doctor repair"] as const)(
"retires the shipped v2026.7.1-2 commitments layout through %s",
(migrationPath) => {
const stateDir = createTempStateDir();
const options = { env: { OPENCLAW_STATE_DIR: stateDir } };
const databasePath = materializeCurrentStateDatabase(stateDir);
const { DatabaseSync } = requireNodeSqlite();
const released = new DatabaseSync(databasePath);
seedV2026_7_1_2CommitmentSchema(released);
expect(readSqliteNumberPragma(released, "user_version")).toBe(1);
expect(
released.prepare("SELECT schema_version FROM schema_meta WHERE meta_key = 'primary'").get(),
).toEqual({ schema_version: 1 });
expect(
released.prepare("SELECT strict FROM pragma_table_list WHERE name = 'commitments'").get(),
).toEqual({ strict: 0 });
expect(
released
.prepare(
`SELECT name
FROM sqlite_schema
WHERE type = 'index'
AND tbl_name = 'commitments'
AND sql IS NOT NULL
ORDER BY name`,
)
.all(),
).toEqual([
{ name: "idx_commitments_scope_dedupe" },
{ name: "idx_commitments_scope_due" },
{ name: "idx_commitments_status_due" },
]);
released.close();
if (migrationPath === "doctor repair") {
const result = repairOpenClawStateDatabaseSchema(options);
expect(result.warnings).toEqual([]);
expect(result.changes).toContain("Retired shared state commitments table and indexes");
}
const migrated = openOpenClawStateDatabase(options);
expect(readSqliteNumberPragma(migrated.db, "user_version")).toBe(7);
expect(
migrated.db
.prepare("SELECT schema_version FROM schema_meta WHERE meta_key = 'primary'")
.get(),
).toEqual({ schema_version: 7 });
for (const name of RETIRED_COMMITMENT_SCHEMA_OBJECTS) {
expect(
migrated.db.prepare("SELECT name FROM sqlite_schema WHERE name = ?").get(name),
).toBeUndefined();
}
expect(
migrated.db
.prepare(
`SELECT scope, lease_key, owner, expires_at, heartbeat_at, payload_json,
created_at, updated_at
FROM state_leases
WHERE scope = 'test' AND lease_key = 'released-preserved-lease'`,
)
.get(),
).toEqual({
scope: "test",
lease_key: "released-preserved-lease",
owner: "migration-test",
expires_at: 100,
heartbeat_at: 50,
payload_json: "{}",
created_at: 1,
updated_at: 2,
});
},
);
it.each(["runtime open", "doctor repair"] as const)(
"retires a partially additive v6 commitments layout through %s",
(migrationPath) => {
@@ -1687,29 +1817,45 @@ describe("openclaw state database", () => {
AFTER DELETE ON commitments BEGIN SELECT 1; END;`,
type: "trigger",
},
{
label: "drifted optional agent-due index",
name: "idx_commitments_agent_due",
sql: `DROP INDEX idx_commitments_agent_due;
CREATE INDEX idx_commitments_agent_due
ON commitments(agent_id, status, session_key);`,
type: "index",
},
])("preserves an $label on the final v6 commitments layout", ({ name, sql, type }) => {
const stateDir = createTempStateDir();
const options = { env: { OPENCLAW_STATE_DIR: stateDir } };
const databasePath = materializeCurrentStateDatabase(stateDir);
const { DatabaseSync } = requireNodeSqlite();
const customized = new DatabaseSync(databasePath);
seedV6CommitmentSchema(customized);
customized.exec(sql);
customized.close();
for (const migrationPath of ["runtime open", "doctor repair"] as const) {
const stateDir = createTempStateDir();
const options = { env: { OPENCLAW_STATE_DIR: stateDir } };
const databasePath = materializeCurrentStateDatabase(stateDir);
const { DatabaseSync } = requireNodeSqlite();
const customized = new DatabaseSync(databasePath);
seedV6CommitmentSchema(customized);
customized.exec(sql);
customized.close();
expect(() => openOpenClawStateDatabase(options)).toThrow(/commitments/u);
if (migrationPath === "doctor repair") {
const result = repairOpenClawStateDatabaseSchema(options);
expect(result.changes).toEqual([]);
expect(result.warnings.join("\n")).toMatch(/commitments/u);
} else {
expect(() => openOpenClawStateDatabase(options)).toThrow(/commitments/u);
}
const preserved = new DatabaseSync(databasePath, { readOnly: true });
try {
expect(readSqliteNumberPragma(preserved, "user_version")).toBe(6);
expect(
preserved.prepare("SELECT name FROM sqlite_schema WHERE name = 'commitments'").get(),
).toEqual({ name: "commitments" });
expect(
preserved.prepare("SELECT type, tbl_name FROM sqlite_schema WHERE name = ?").get(name),
).toEqual({ type, tbl_name: "commitments" });
} finally {
preserved.close();
const preserved = new DatabaseSync(databasePath, { readOnly: true });
try {
expect(readSqliteNumberPragma(preserved, "user_version")).toBe(6);
expect(
preserved.prepare("SELECT name FROM sqlite_schema WHERE name = 'commitments'").get(),
).toEqual({ name: "commitments" });
expect(
preserved.prepare("SELECT type, tbl_name FROM sqlite_schema WHERE name = ?").get(name),
).toEqual({ type, tbl_name: "commitments" });
} finally {
preserved.close();
}
}
});