Skip to content

Commit 9fc5650

Browse files
committed
Fix SQLite session status migration
1 parent c7f4d3d commit 9fc5650

2 files changed

Lines changed: 228 additions & 4 deletions

File tree

src/store/sqlite.test.ts

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,22 @@ function columns(db: Database.Database, table: string): Set<string> {
4141

4242
describe("SqliteStore — fresh-DB schema", () => {
4343
it("creates every table and every current column on a brand new file", () => {
44-
const store = new SqliteStore(newDbPath());
44+
const path = newDbPath();
45+
const store = new SqliteStore(path);
46+
const agent = store.agents.create({
47+
model: "m",
48+
tools: [],
49+
instructions: "",
50+
permissionPolicy: { type: "always_allow" },
51+
callableAgents: [],
52+
maxSubagentDepth: 0,
53+
});
54+
const session = store.sessions.create({ agentId: agent.agentId });
55+
store.sessions.beginRun(session.sessionId);
56+
expect(store.sessions.get(session.sessionId)?.status).toBe("starting");
4557
// Read via a second handle so we can snapshot the schema without
4658
// going through the ORM-shaped methods.
47-
const probe = new Database(newDbPath(), { readonly: true });
59+
const probe = new Database(path, { readonly: true });
4860
const agents = columns(probe, "agents");
4961
const sessions = columns(probe, "sessions");
5062
const envs = columns(probe, "environments");
@@ -150,6 +162,94 @@ describe("SqliteStore — additive migrations on pre-existing DBs", () => {
150162
expect(row.environment_id).toBeNull(); // nullable column
151163
});
152164

165+
it("upgrades the sessions status constraint in place and preserves session_container foreign keys", () => {
166+
const path = newDbPath();
167+
const seed = new Database(path);
168+
seed.pragma("foreign_keys = ON");
169+
seed.exec(`
170+
CREATE TABLE sessions (
171+
session_id TEXT PRIMARY KEY,
172+
agent_id TEXT NOT NULL,
173+
environment_id TEXT,
174+
status TEXT NOT NULL CHECK (status IN ('idle','running','failed')),
175+
ephemeral INTEGER NOT NULL DEFAULT 0,
176+
remaining_subagent_depth INTEGER NOT NULL DEFAULT 0,
177+
turns INTEGER NOT NULL DEFAULT 0,
178+
tokens_in INTEGER NOT NULL DEFAULT 0,
179+
tokens_out INTEGER NOT NULL DEFAULT 0,
180+
cost_usd REAL NOT NULL DEFAULT 0,
181+
error TEXT,
182+
created_at INTEGER NOT NULL,
183+
last_event_at INTEGER,
184+
vault_id TEXT,
185+
parent_session_id TEXT,
186+
user_id TEXT
187+
);
188+
CREATE TABLE session_containers (
189+
session_id TEXT PRIMARY KEY REFERENCES sessions(session_id) ON DELETE CASCADE,
190+
agent_id TEXT NOT NULL,
191+
container_id TEXT NOT NULL,
192+
container_name TEXT NOT NULL,
193+
container_port INTEGER NOT NULL,
194+
gateway_token TEXT NOT NULL,
195+
claimed_at INTEGER NOT NULL,
196+
boot_ms INTEGER,
197+
pool_source TEXT
198+
);
199+
CREATE INDEX idx_session_containers_container ON session_containers(container_id);
200+
`);
201+
seed.prepare(
202+
`INSERT INTO sessions (
203+
session_id, agent_id, environment_id, status, ephemeral,
204+
remaining_subagent_depth, turns, tokens_in, tokens_out, cost_usd,
205+
error, created_at, last_event_at, vault_id, parent_session_id, user_id
206+
) VALUES (
207+
?, ?, NULL, 'idle', 0,
208+
0, 0, 0, 0, 0,
209+
NULL, ?, NULL, NULL, NULL, NULL
210+
)`,
211+
).run("ses_old", "agt_old", 1000);
212+
seed.prepare(
213+
`INSERT INTO session_containers (
214+
session_id, agent_id, container_id, container_name, container_port,
215+
gateway_token, claimed_at, boot_ms, pool_source
216+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
217+
).run(
218+
"ses_old",
219+
"agt_old",
220+
"cid_old",
221+
"openclaw-agt-old",
222+
18789,
223+
"tok_old",
224+
1001,
225+
250,
226+
"warm",
227+
);
228+
seed.close();
229+
230+
const store = new SqliteStore(path);
231+
store.sessions.beginRun("ses_old");
232+
expect(store.sessions.get("ses_old")?.status).toBe("starting");
233+
expect(store.sessions.delete("ses_old")).toBe(true);
234+
store.close();
235+
236+
const probe = new Database(path, { readonly: true });
237+
const fk = probe.pragma("foreign_key_list(session_containers)") as Array<{
238+
table: string;
239+
}>;
240+
const remainingContainers = probe
241+
.prepare(`SELECT COUNT(*) as count FROM session_containers`)
242+
.get() as { count: number };
243+
const sessionsSql = probe
244+
.prepare(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'sessions'`)
245+
.get() as { sql: string };
246+
probe.close();
247+
248+
expect(fk[0]?.table).toBe("sessions");
249+
expect(remainingContainers.count).toBe(0);
250+
expect(sessionsSql.sql).toContain("'starting'");
251+
});
252+
153253
it("adds every agents migration column when opening a v1-era DB", () => {
154254
// Pre-migration agents table: only the fields that existed before
155255
// Items 12-14, 17, 19 landed. Minimum viable shape.

src/store/sqlite.ts

Lines changed: 126 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import type {
4040
} from "./types.js";
4141

4242
const nanoid = customAlphabet("abcdefghijklmnopqrstuvwxyz0123456789", 12);
43+
const SESSION_STATUS_VALUES_SQL = `'idle', 'starting', 'running', 'failed'`;
4344

4445
// ---------- Row shapes ----------
4546

@@ -158,6 +159,46 @@ function rowToSession(r: SessionRow): Session {
158159
};
159160
}
160161

162+
function createSessionsTableSql(tableName: string): string {
163+
return `
164+
CREATE TABLE ${tableName} (
165+
session_id TEXT PRIMARY KEY,
166+
agent_id TEXT NOT NULL,
167+
environment_id TEXT,
168+
status TEXT NOT NULL CHECK (status IN (${SESSION_STATUS_VALUES_SQL})),
169+
ephemeral INTEGER NOT NULL DEFAULT 0,
170+
remaining_subagent_depth INTEGER NOT NULL DEFAULT 0,
171+
turns INTEGER NOT NULL DEFAULT 0,
172+
tokens_in INTEGER NOT NULL DEFAULT 0,
173+
tokens_out INTEGER NOT NULL DEFAULT 0,
174+
cost_usd REAL NOT NULL DEFAULT 0,
175+
error TEXT,
176+
created_at INTEGER NOT NULL,
177+
last_event_at INTEGER,
178+
vault_id TEXT,
179+
parent_session_id TEXT,
180+
user_id TEXT
181+
)`;
182+
}
183+
184+
function createSessionContainersTableSql(
185+
tableName: string,
186+
sessionsTableName: string,
187+
): string {
188+
return `
189+
CREATE TABLE ${tableName} (
190+
session_id TEXT PRIMARY KEY REFERENCES ${sessionsTableName}(session_id) ON DELETE CASCADE,
191+
agent_id TEXT NOT NULL,
192+
container_id TEXT NOT NULL,
193+
container_name TEXT NOT NULL,
194+
container_port INTEGER NOT NULL,
195+
gateway_token TEXT NOT NULL,
196+
claimed_at INTEGER NOT NULL,
197+
boot_ms INTEGER,
198+
pool_source TEXT
199+
)`;
200+
}
201+
161202
// ---------- Schema bootstrap ----------
162203

163204
// Applied once per database. Idempotent — every CREATE uses IF NOT EXISTS.
@@ -226,7 +267,8 @@ CREATE TABLE IF NOT EXISTS environments (
226267
CREATE TABLE IF NOT EXISTS sessions (
227268
session_id TEXT PRIMARY KEY,
228269
agent_id TEXT NOT NULL,
229-
status TEXT NOT NULL CHECK (status IN ('idle', 'running', 'failed')),
270+
environment_id TEXT,
271+
status TEXT NOT NULL CHECK (status IN (${SESSION_STATUS_VALUES_SQL})),
230272
ephemeral INTEGER NOT NULL DEFAULT 0,
231273
remaining_subagent_depth INTEGER NOT NULL DEFAULT 0,
232274
turns INTEGER NOT NULL DEFAULT 0,
@@ -237,7 +279,8 @@ CREATE TABLE IF NOT EXISTS sessions (
237279
created_at INTEGER NOT NULL,
238280
last_event_at INTEGER,
239281
vault_id TEXT,
240-
parent_session_id TEXT
282+
parent_session_id TEXT,
283+
user_id TEXT
241284
);
242285
243286
CREATE INDEX IF NOT EXISTS idx_sessions_agent_id ON sessions(agent_id);
@@ -1794,6 +1837,7 @@ export class SqliteStore implements Store {
17941837
if (scCols.length > 0 && !scCols.some((c) => c.name === "pool_source")) {
17951838
this.db.exec("ALTER TABLE session_containers ADD COLUMN pool_source TEXT");
17961839
}
1840+
this.migrateSessionStatusConstraint();
17971841

17981842
const envCols = this.db.pragma("table_info(environments)") as Array<{
17991843
name: string;
@@ -1845,6 +1889,86 @@ export class SqliteStore implements Store {
18451889
this.migratedVaultCredentials = vaultsStore.migratePlaintextCredentials();
18461890
}
18471891

1892+
private migrateSessionStatusConstraint(): void {
1893+
const row = this.db
1894+
.prepare(
1895+
`SELECT sql
1896+
FROM sqlite_master
1897+
WHERE type = 'table' AND name = 'sessions'`,
1898+
)
1899+
.get() as { sql: string | null } | undefined;
1900+
const sessionsSql = row?.sql ?? "";
1901+
if (sessionsSql.includes("'starting'")) return;
1902+
1903+
const fkWasEnabled =
1904+
(this.db.pragma("foreign_keys", { simple: true }) as number) === 1;
1905+
if (fkWasEnabled) {
1906+
this.db.pragma("foreign_keys = OFF");
1907+
}
1908+
try {
1909+
const migrate = this.db.transaction(() => {
1910+
this.db.exec(createSessionsTableSql("sessions_new"));
1911+
this.db.exec(`
1912+
INSERT INTO sessions_new (
1913+
session_id, agent_id, environment_id, status, ephemeral,
1914+
remaining_subagent_depth, turns, tokens_in, tokens_out, cost_usd,
1915+
error, created_at, last_event_at, vault_id, parent_session_id, user_id
1916+
)
1917+
SELECT
1918+
session_id, agent_id, environment_id, status, ephemeral,
1919+
remaining_subagent_depth, turns, tokens_in, tokens_out, cost_usd,
1920+
error, created_at, last_event_at, vault_id, parent_session_id, user_id
1921+
FROM sessions
1922+
`);
1923+
this.db.exec(
1924+
createSessionContainersTableSql(
1925+
"session_containers_new",
1926+
"sessions_new",
1927+
),
1928+
);
1929+
this.db.exec(`
1930+
INSERT INTO session_containers_new (
1931+
session_id, agent_id, container_id, container_name, container_port,
1932+
gateway_token, claimed_at, boot_ms, pool_source
1933+
)
1934+
SELECT
1935+
session_id, agent_id, container_id, container_name, container_port,
1936+
gateway_token, claimed_at, boot_ms, pool_source
1937+
FROM session_containers
1938+
`);
1939+
this.db.exec("DROP TABLE session_containers");
1940+
this.db.exec("DROP TABLE sessions");
1941+
this.db.exec("ALTER TABLE sessions_new RENAME TO sessions");
1942+
this.db.exec(
1943+
"ALTER TABLE session_containers_new RENAME TO session_containers",
1944+
);
1945+
this.db.exec(
1946+
"CREATE INDEX IF NOT EXISTS idx_sessions_agent_id ON sessions(agent_id)",
1947+
);
1948+
this.db.exec(
1949+
"CREATE INDEX IF NOT EXISTS idx_sessions_parent ON sessions(parent_session_id)",
1950+
);
1951+
this.db.exec(
1952+
"CREATE INDEX IF NOT EXISTS idx_session_containers_container ON session_containers(container_id)",
1953+
);
1954+
});
1955+
migrate();
1956+
} finally {
1957+
if (fkWasEnabled) {
1958+
this.db.pragma("foreign_keys = ON");
1959+
}
1960+
}
1961+
1962+
const fkViolations = this.db.pragma(
1963+
"foreign_key_check",
1964+
) as Array<Record<string, unknown>>;
1965+
if (fkViolations.length > 0) {
1966+
throw new Error(
1967+
`session status migration left foreign key violations: ${JSON.stringify(fkViolations)}`,
1968+
);
1969+
}
1970+
}
1971+
18481972
close(): void {
18491973
if (this.closed) return;
18501974
this.db.close();

0 commit comments

Comments
 (0)