session-steward 0.10.1 → 0.10.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/lib/providers/claude-code/process-state.mjs +73 -0
- package/lib/providers/claude-code/store.mjs +195 -33
- package/lib/providers/codex/database-families.mjs +24 -3
- package/lib/providers/codex/desktop-catalog.mjs +179 -0
- package/lib/providers/codex/store.mjs +341 -33
- package/lib/storage/jsonl.mjs +2 -1
- package/package.json +2 -1
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { lstatSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { DatabaseSync } from "node:sqlite";
|
|
4
|
+
import { batches, placeholders } from "../../storage/sqlite.mjs";
|
|
5
|
+
|
|
6
|
+
const FILENAMES = ["codex.db", "codex-dev.db"];
|
|
7
|
+
const TABLE = "local_thread_catalog";
|
|
8
|
+
const SCAN_TABLE = "local_thread_catalog_scan_entries";
|
|
9
|
+
const REQUIREMENTS = {
|
|
10
|
+
[TABLE]: ["host_id", "thread_id", "observation_sequence", "missing_candidate"],
|
|
11
|
+
[SCAN_TABLE]: ["host_id", "thread_id", "removed"],
|
|
12
|
+
local_thread_catalog_hosts: ["host_id", "host_kind"],
|
|
13
|
+
local_thread_catalog_metadata: ["id", "catalog_revision"],
|
|
14
|
+
local_thread_catalog_scan_checkpoints: ["host_id", "checkpoint"],
|
|
15
|
+
local_thread_catalog_sync_state: ["host_id", "observation_sequence"],
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function regularPath(filePath, directory = false) {
|
|
19
|
+
try {
|
|
20
|
+
const stats = lstatSync(filePath);
|
|
21
|
+
if (directory ? !stats.isDirectory() : !stats.isFile()) {
|
|
22
|
+
throw new Error("The Codex desktop catalogue path is not a regular file or directory. It was left unchanged.");
|
|
23
|
+
}
|
|
24
|
+
return true;
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if (error.code === "ENOENT") return false;
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function catalogPath(codexHome, filename) {
|
|
32
|
+
if (!FILENAMES.includes(filename)) throw new Error("Unrecognized Codex desktop catalogue backup.");
|
|
33
|
+
const directory = path.join(codexHome, "sqlite");
|
|
34
|
+
return regularPath(directory, true) ? path.join(directory, filename) : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function schema(db) {
|
|
38
|
+
if (!db.prepare("select 1 from sqlite_master where type = 'table' and name = ?").get(TABLE)) return null;
|
|
39
|
+
const columns = {};
|
|
40
|
+
for (const [table, required] of Object.entries(REQUIREMENTS)) {
|
|
41
|
+
const info = db.prepare(`pragma table_info(${table})`).all();
|
|
42
|
+
columns[table] = info.map((column) => column.name);
|
|
43
|
+
if (!required.every((name) => columns[table].includes(name))
|
|
44
|
+
|| ([TABLE, SCAN_TABLE].includes(table)
|
|
45
|
+
&& info.filter((column) => column.pk).sort((a, b) => a.pk - b.pk).map((column) => column.name).join() !== "host_id,thread_id")) {
|
|
46
|
+
throw new Error("The Codex desktop catalogue layout is not recognized. Cleanup stopped without changing it.");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const host = db.prepare("select host_kind from local_thread_catalog_hosts where host_id = 'local'").get();
|
|
50
|
+
const hasLocalRows = db.prepare(`select 1 from ${TABLE} where host_id = 'local' limit 1`).get();
|
|
51
|
+
if ((host && host.host_kind !== "local") || (hasLocalRows && !host)) {
|
|
52
|
+
throw new Error("The Codex desktop catalogue local host could not be verified.");
|
|
53
|
+
}
|
|
54
|
+
if (!db.prepare("select 1 from local_thread_catalog_metadata where id = 1").get()) {
|
|
55
|
+
throw new Error("The Codex desktop catalogue revision is missing.");
|
|
56
|
+
}
|
|
57
|
+
return columns;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function selectedRows(db, table, ids) {
|
|
61
|
+
return [...batches([...new Set(ids)].sort())].flatMap((batch) => db.prepare(
|
|
62
|
+
`select * from ${table} where host_id = 'local' and thread_id in (${placeholders(batch)}) order by thread_id`,
|
|
63
|
+
).all(...batch));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function snapshot(db, filename, ids) {
|
|
67
|
+
const columns = schema(db);
|
|
68
|
+
return columns ? {
|
|
69
|
+
filename,
|
|
70
|
+
columns: columns[TABLE],
|
|
71
|
+
rows: selectedRows(db, TABLE, ids),
|
|
72
|
+
scanRows: selectedRows(db, SCAN_TABLE, ids),
|
|
73
|
+
} : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Only known app-owned databases beneath this run's Codex home, never a
|
|
77
|
+
// process-global/default home or arbitrary database supplied by a backup.
|
|
78
|
+
export function readDesktopCatalogs(codexHome, ids) {
|
|
79
|
+
const result = [];
|
|
80
|
+
for (const filename of FILENAMES) {
|
|
81
|
+
const filePath = catalogPath(codexHome, filename);
|
|
82
|
+
if (!filePath || !regularPath(filePath)) continue;
|
|
83
|
+
const db = new DatabaseSync(filePath, { readOnly: true, timeout: 5_000 });
|
|
84
|
+
try {
|
|
85
|
+
db.exec("begin");
|
|
86
|
+
const entry = snapshot(db, filename, ids);
|
|
87
|
+
if (entry) result.push(entry);
|
|
88
|
+
} finally { db.close(); }
|
|
89
|
+
}
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function transaction(codexHome, entry, callback) {
|
|
94
|
+
const filePath = catalogPath(codexHome, entry.filename);
|
|
95
|
+
if (!filePath || !regularPath(filePath)) throw new Error("The Codex desktop catalogue disappeared. Restart cleanup after refreshing.");
|
|
96
|
+
const db = new DatabaseSync(filePath, { timeout: 5_000 });
|
|
97
|
+
try {
|
|
98
|
+
db.exec("begin immediate");
|
|
99
|
+
const columns = schema(db);
|
|
100
|
+
if (!columns) throw new Error("The Codex desktop catalogue layout changed.");
|
|
101
|
+
callback(db, columns);
|
|
102
|
+
db.exec("commit");
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (db.isTransaction) db.exec("rollback");
|
|
105
|
+
throw error;
|
|
106
|
+
} finally { db.close(); }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function deleteDesktopCatalogs(codexHome, ids, backup) {
|
|
110
|
+
if (JSON.stringify(readDesktopCatalogs(codexHome, ids)) !== JSON.stringify(backup)) {
|
|
111
|
+
throw new Error("The selected Codex desktop catalogue entries changed after backup. Refresh and try again.");
|
|
112
|
+
}
|
|
113
|
+
for (const entry of backup) {
|
|
114
|
+
transaction(codexHome, entry, (db) => {
|
|
115
|
+
if (JSON.stringify(snapshot(db, entry.filename, ids)) !== JSON.stringify(entry)) {
|
|
116
|
+
throw new Error("The selected Codex desktop catalogue entries changed after backup. Refresh and try again.");
|
|
117
|
+
}
|
|
118
|
+
if (entry.rows.length === 0) return;
|
|
119
|
+
// Match the app's authoritative-removal behavior: an in-flight scan
|
|
120
|
+
// must not reinsert an entry from a page fetched before deletion.
|
|
121
|
+
db.prepare("update local_thread_catalog_sync_state set observation_sequence = observation_sequence + 1 where host_id = 'local'").run();
|
|
122
|
+
for (const batch of batches(entry.rows.map((row) => row.thread_id))) {
|
|
123
|
+
// Also protect a scan whose first page is still in flight (it has no
|
|
124
|
+
// persisted checkpoint yet). The app clears these at the next scan.
|
|
125
|
+
const tombstone = db.prepare(`insert into ${SCAN_TABLE} (host_id, thread_id, removed) values ('local', ?, 1)
|
|
126
|
+
on conflict(host_id, thread_id) do update set removed = 1`);
|
|
127
|
+
for (const id of batch) tombstone.run(id);
|
|
128
|
+
db.prepare(`delete from ${TABLE} where host_id = 'local' and thread_id in (${placeholders(batch)})`).run(...batch);
|
|
129
|
+
}
|
|
130
|
+
db.prepare("update local_thread_catalog_metadata set catalog_revision = catalog_revision + 1 where id = 1").run();
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function validateDesktopCatalogBackup(codexHome, ids, backup) {
|
|
136
|
+
if (!Array.isArray(backup)) throw new Error("Invalid Codex desktop catalogue backup.");
|
|
137
|
+
const allowed = new Set(ids);
|
|
138
|
+
const names = new Set();
|
|
139
|
+
for (const entry of backup) {
|
|
140
|
+
if (!FILENAMES.includes(entry?.filename) || names.has(entry.filename)
|
|
141
|
+
|| !Array.isArray(entry.columns) || !Array.isArray(entry.rows) || !Array.isArray(entry.scanRows)
|
|
142
|
+
|| ![...entry.rows, ...entry.scanRows].every((row) => row.host_id === "local" && allowed.has(row.thread_id))) {
|
|
143
|
+
throw new Error("Invalid Codex desktop catalogue backup.");
|
|
144
|
+
}
|
|
145
|
+
names.add(entry.filename);
|
|
146
|
+
if (entry.rows.length === 0) continue;
|
|
147
|
+
transaction(codexHome, entry, (_db, columns) => {
|
|
148
|
+
if (JSON.stringify(columns[TABLE]) !== JSON.stringify(entry.columns)
|
|
149
|
+
|| entry.rows.some((row) => Object.keys(row).join() !== entry.columns.join())
|
|
150
|
+
|| entry.scanRows.some((row) => Object.keys(row).join() !== columns[SCAN_TABLE].join())) {
|
|
151
|
+
throw new Error("The Codex desktop catalogue schema changed since backup. Its rows were left unchanged.");
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function restoreDesktopCatalogs(codexHome, ids, backup) {
|
|
158
|
+
validateDesktopCatalogBackup(codexHome, ids, backup);
|
|
159
|
+
for (const entry of backup) {
|
|
160
|
+
if (entry.rows.length === 0) continue;
|
|
161
|
+
transaction(codexHome, entry, (db, columns) => {
|
|
162
|
+
if (JSON.stringify(columns[TABLE]) !== JSON.stringify(entry.columns)) throw new Error("The Codex desktop catalogue schema changed during restore.");
|
|
163
|
+
const quoted = entry.columns.map((column) => `"${column.replaceAll('"', '""')}"`).join(", ");
|
|
164
|
+
const insert = db.prepare(`insert into ${TABLE} (${quoted}) values (${placeholders(entry.columns)}) on conflict(host_id, thread_id) do nothing`);
|
|
165
|
+
const restored = [];
|
|
166
|
+
for (const row of entry.rows) {
|
|
167
|
+
if (insert.run(...entry.columns.map((column) => row[column])).changes) restored.push(row.thread_id);
|
|
168
|
+
}
|
|
169
|
+
// Merge selected rows only: never restore the entire app database or
|
|
170
|
+
// overwrite a newer entry, other hosts, settings, or scan checkpoints.
|
|
171
|
+
for (const id of restored) {
|
|
172
|
+
const prior = entry.scanRows.find((row) => row.thread_id === id);
|
|
173
|
+
db.prepare(`delete from ${SCAN_TABLE} where host_id = 'local' and thread_id = ?`).run(id);
|
|
174
|
+
if (prior) db.prepare(`insert into ${SCAN_TABLE} (host_id, thread_id, removed) values ('local', ?, ?)`).run(id, prior.removed);
|
|
175
|
+
}
|
|
176
|
+
if (restored.length) db.prepare("update local_thread_catalog_metadata set catalog_revision = catalog_revision + 1 where id = 1").run();
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|