mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(state): accept shipped commitments schema
This commit is contained in:
@@ -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,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user