om-memory-system 3.2.0-next.11 → 3.2.0-next.12
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/dist/adapters/opencode/import-command.d.ts +3 -7
- package/dist/adapters/opencode/import-command.js +3 -54
- package/dist/adapters/pi/profile.js +2 -1
- package/dist/config.d.ts +11 -1
- package/dist/config.js +82 -9
- package/dist/core/capture.js +2 -1
- package/dist/core/profile-analysis.js +9 -1
- package/dist/importer/discovery.d.ts +30 -5
- package/dist/importer/discovery.js +88 -36
- package/dist/importer/import-args.d.ts +23 -1
- package/dist/importer/import-args.js +29 -1
- package/dist/importer/import-project.d.ts +16 -0
- package/dist/importer/import-project.js +35 -0
- package/dist/importer/import-readiness.d.ts +47 -0
- package/dist/importer/import-readiness.js +54 -0
- package/dist/importer/import-sessions.d.ts +98 -0
- package/dist/importer/import-sessions.js +222 -0
- package/dist/importer/import-sources.d.ts +47 -0
- package/dist/importer/import-sources.js +147 -0
- package/dist/importer/importer.d.ts +31 -1
- package/dist/importer/importer.js +82 -24
- package/dist/importer/opencode-import.d.ts +11 -1
- package/dist/importer/opencode-import.js +133 -69
- package/dist/importer/opencode-project.d.ts +1 -4
- package/dist/importer/opencode-project.js +3 -36
- package/dist/importer/opencode-reader.d.ts +25 -5
- package/dist/importer/opencode-reader.js +92 -106
- package/dist/importer/opencode-snapshot.d.ts +82 -0
- package/dist/importer/opencode-snapshot.js +276 -0
- package/dist/importer/profile-import.d.ts +1 -0
- package/dist/importer/profile-import.js +7 -1
- package/dist/importer/run-import.d.ts +10 -0
- package/dist/importer/run-import.js +21 -0
- package/dist/importer/settings-health.d.ts +25 -0
- package/dist/importer/settings-health.js +136 -0
- package/dist/importer/web-import-api.d.ts +8 -0
- package/dist/importer/web-import-api.js +8 -0
- package/dist/importer/web-import-jobs.d.ts +53 -0
- package/dist/importer/web-import-jobs.js +227 -0
- package/dist/index.js +1 -0
- package/dist/services/ai/live-model-choice.js +6 -3
- package/dist/services/ai/opencode-import-models.d.ts +11 -0
- package/dist/services/ai/opencode-import-models.js +55 -0
- package/dist/services/capture-attempt-store.d.ts +21 -0
- package/dist/services/capture-attempt-store.js +108 -0
- package/dist/services/capture-diagnostics.js +3 -0
- package/dist/services/cleanup-service.js +9 -0
- package/dist/services/global-config-writer.d.ts +10 -0
- package/dist/services/global-config-writer.js +106 -0
- package/dist/services/settings-log.d.ts +5 -0
- package/dist/services/settings-log.js +36 -0
- package/dist/services/settings-models.d.ts +35 -0
- package/dist/services/settings-models.js +39 -0
- package/dist/services/settings-snapshot.d.ts +24 -0
- package/dist/services/settings-snapshot.js +71 -0
- package/dist/services/settings-traces.d.ts +7 -0
- package/dist/services/settings-traces.js +39 -0
- package/dist/services/user-memory-learning.js +2 -1
- package/dist/services/web-server.d.ts +5 -0
- package/dist/services/web-server.js +176 -3
- package/dist/web/assets/index-BO6gokx9.js +124 -0
- package/dist/web/assets/index-D5ak5KsI.css +2 -0
- package/dist/web/index.html +2 -2
- package/package.json +2 -1
- package/dist/web/assets/index-DPjEpD1h.css +0 -2
- package/dist/web/assets/index-do26c4JJ.js +0 -122
|
@@ -1,38 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
if (!path)
|
|
4
|
-
return false;
|
|
5
|
-
try {
|
|
6
|
-
return statSync(path).isDirectory();
|
|
7
|
-
}
|
|
8
|
-
catch {
|
|
9
|
-
return false;
|
|
10
|
-
}
|
|
11
|
-
}
|
|
12
|
-
/** Prefer the recorded directory, then an explicit map, then the project root. */
|
|
1
|
+
import { resolveImportProject } from "./import-project.js";
|
|
2
|
+
/** A directory map first, then the recorded directory, then the project root. */
|
|
13
3
|
export function resolveOpencodeProject(recordedDirectory, projectWorktree, maps = []) {
|
|
14
|
-
|
|
15
|
-
return recordedDirectory;
|
|
16
|
-
const mapped = maps.find((item) => item.from === recordedDirectory)?.to;
|
|
17
|
-
if (isDirectory(mapped))
|
|
18
|
-
return mapped;
|
|
19
|
-
return projectWorktree !== "/" && isDirectory(projectWorktree) ? projectWorktree : null;
|
|
20
|
-
}
|
|
21
|
-
/** Resolve each session without buffering its conversation windows. */
|
|
22
|
-
export async function* resolveOpencodeSessions(sessions, maps, unresolved) {
|
|
23
|
-
for await (const session of sessions) {
|
|
24
|
-
const directory = resolveOpencodeProject(session.recordedDirectory, session.projectWorktree, maps);
|
|
25
|
-
if (directory) {
|
|
26
|
-
yield { ...session, directory };
|
|
27
|
-
continue;
|
|
28
|
-
}
|
|
29
|
-
const entry = unresolved.get(session.recordedDirectory) ?? {
|
|
30
|
-
directory: session.recordedDirectory,
|
|
31
|
-
sessions: 0,
|
|
32
|
-
units: 0,
|
|
33
|
-
};
|
|
34
|
-
entry.sessions++;
|
|
35
|
-
entry.units += session.units.length;
|
|
36
|
-
unresolved.set(entry.directory, entry);
|
|
37
|
-
}
|
|
4
|
+
return resolveImportProject(recordedDirectory, maps, projectWorktree).directory;
|
|
38
5
|
}
|
|
@@ -1,19 +1,39 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
export
|
|
1
|
+
import type { ImportWindow } from "./importer.js";
|
|
2
|
+
export declare const DEFAULT_OPENCODE_DB: string;
|
|
3
|
+
/** One top-level session's metadata; its turns are built on demand. */
|
|
4
|
+
export interface OpencodeSessionMeta {
|
|
5
|
+
sessionId: string;
|
|
3
6
|
recordedDirectory: string;
|
|
4
7
|
projectWorktree: string | null;
|
|
5
8
|
timeCreated: number;
|
|
6
9
|
}
|
|
7
|
-
export interface
|
|
10
|
+
export interface OpencodeHistory {
|
|
8
11
|
childSessions: number;
|
|
9
12
|
topLevelSessions: number;
|
|
10
|
-
sessions
|
|
13
|
+
/** Top-level sessions matching the filters, oldest first. */
|
|
14
|
+
sessions: OpencodeSessionMeta[];
|
|
15
|
+
/** Build one session's work units; call only before `close`. */
|
|
16
|
+
loadUnits: (sessionId: string) => ImportWindow[];
|
|
17
|
+
close: () => Promise<void>;
|
|
11
18
|
}
|
|
12
19
|
export interface OpencodeReaderFilters {
|
|
13
20
|
session?: string;
|
|
21
|
+
/** Web selection; never combined with `session` or `maxSessions`. */
|
|
22
|
+
sessionIds?: string[];
|
|
14
23
|
maxSessions?: number;
|
|
15
24
|
since?: number;
|
|
16
25
|
until?: number;
|
|
17
26
|
}
|
|
27
|
+
export interface OpencodeOpenOptions {
|
|
28
|
+
/**
|
|
29
|
+
* Share the snapshot with the web listing and jobs under this source key.
|
|
30
|
+
* `fresh` copies again, `reuse` fails when no copy exists, `any` does either.
|
|
31
|
+
*/
|
|
32
|
+
shared?: {
|
|
33
|
+
key: string;
|
|
34
|
+
mode: "fresh" | "reuse" | "any";
|
|
35
|
+
};
|
|
36
|
+
signal?: AbortSignal;
|
|
37
|
+
}
|
|
18
38
|
/** Read V1 OpenCode history without changing its database or WAL sidecars. */
|
|
19
|
-
export declare function
|
|
39
|
+
export declare function openOpencodeHistory(dbPath: string, filters?: OpencodeReaderFilters, options?: OpencodeOpenOptions): Promise<OpencodeHistory>;
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { existsSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { pathToFileURL } from "node:url";
|
|
5
5
|
import { DatabaseSync } from "node:sqlite";
|
|
6
6
|
import { INTERNAL_CAPTURE_SESSION_TITLES } from "../services/ai/internal-capture-sessions.js";
|
|
7
|
-
import {
|
|
8
|
-
|
|
7
|
+
import { acquirePrivateSnapshot, opencodeSnapshots, OpencodeSnapshotError, } from "./opencode-snapshot.js";
|
|
8
|
+
export const DEFAULT_OPENCODE_DB = join(homedir(), ".local/share/opencode/opencode.db");
|
|
9
9
|
function validateSchema(db) {
|
|
10
10
|
const required = {
|
|
11
11
|
session: ["id", "directory", "project_id", "parent_id", "time_created"],
|
|
@@ -113,121 +113,107 @@ function fileStamp(path) {
|
|
|
113
113
|
const info = statSync(path, { bigint: true });
|
|
114
114
|
return `${info.size}:${info.mtimeNs}`;
|
|
115
115
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
* A copy that raced a write or checkpoint is discarded and retried.
|
|
121
|
-
*/
|
|
122
|
-
/**
|
|
123
|
-
* Remove a snapshot folder without ever failing the import. Windows can hold
|
|
124
|
-
* SQLite files briefly after close, so retry lock errors; if the lock
|
|
125
|
-
* outlasts the retries, log the leftover path instead of throwing.
|
|
126
|
-
*/
|
|
127
|
-
async function removeSnapshotDir(dir) {
|
|
128
|
-
try {
|
|
129
|
-
await withSqliteFileLockRetry(() => rmSync(dir, { recursive: true, force: true }));
|
|
130
|
-
}
|
|
131
|
-
catch (error) {
|
|
132
|
-
log("OpenCode import: could not remove the temporary database copy", {
|
|
133
|
-
dir,
|
|
134
|
-
error: String(error),
|
|
135
|
-
});
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
function snapshotWalDatabase(dbPath) {
|
|
139
|
-
const dir = mkdtempSync(join(tmpdir(), "omms-opencode-"));
|
|
140
|
-
const cleanup = () => removeSnapshotDir(dir);
|
|
141
|
-
const copy = join(dir, "opencode.db");
|
|
116
|
+
async function openSnapshot(dbPath, options) {
|
|
117
|
+
const lease = options.shared
|
|
118
|
+
? await opencodeSnapshots.acquire(options.shared.key, dbPath, options.shared.mode, options.signal)
|
|
119
|
+
: await acquirePrivateSnapshot(dbPath, options.signal);
|
|
142
120
|
try {
|
|
143
|
-
|
|
144
|
-
const before = [fileStamp(dbPath), fileStamp(`${dbPath}-wal`)];
|
|
145
|
-
// Clone where the file system supports it (APFS, Btrfs): large databases copy instantly.
|
|
146
|
-
copyFileSync(dbPath, copy, constants.COPYFILE_FICLONE);
|
|
147
|
-
copyFileSync(`${dbPath}-wal`, `${copy}-wal`, constants.COPYFILE_FICLONE);
|
|
148
|
-
const after = [fileStamp(dbPath), fileStamp(`${dbPath}-wal`)];
|
|
149
|
-
if (before[0] === after[0] && before[1] === after[1])
|
|
150
|
-
return { path: copy, cleanup };
|
|
151
|
-
}
|
|
121
|
+
return { db: new DatabaseSync(lease.path), lease };
|
|
152
122
|
}
|
|
153
123
|
catch (error) {
|
|
154
|
-
|
|
124
|
+
await lease.release();
|
|
155
125
|
throw error;
|
|
156
126
|
}
|
|
157
|
-
void cleanup();
|
|
158
|
-
throw new Error("OpenCode database kept changing while it was copied; retry the import");
|
|
159
127
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
catch (error) {
|
|
171
|
-
void snapshot.cleanup();
|
|
172
|
-
throw error;
|
|
128
|
+
/**
|
|
129
|
+
* Without a WAL every committed page is in the main file, so read it in place
|
|
130
|
+
* with `immutable=1`. A rollback journal means a writer is mid-transaction,
|
|
131
|
+
* which an immutable read could see half-done.
|
|
132
|
+
*/
|
|
133
|
+
async function openSource(dbPath, options) {
|
|
134
|
+
if (existsSync(`${dbPath}-wal`))
|
|
135
|
+
return openSnapshot(dbPath, options);
|
|
136
|
+
if (existsSync(`${dbPath}-journal`)) {
|
|
137
|
+
throw new OpencodeSnapshotError("busy", "The OpenCode database is being written. Retry in a moment, or choose a backup.");
|
|
173
138
|
}
|
|
139
|
+
const stamp = fileStamp(dbPath);
|
|
140
|
+
const uri = `${pathToFileURL(dbPath).href}?immutable=1`;
|
|
141
|
+
return { db: new DatabaseSync(uri, { readOnly: true }), stamp };
|
|
142
|
+
}
|
|
143
|
+
/** True when an in-place read may have raced a writer that started meanwhile. */
|
|
144
|
+
function changedSinceOpen(dbPath, source) {
|
|
145
|
+
if (source.stamp === undefined)
|
|
146
|
+
return false;
|
|
147
|
+
return existsSync(`${dbPath}-wal`) || fileStamp(dbPath) !== source.stamp;
|
|
148
|
+
}
|
|
149
|
+
function readSessions(db, filters) {
|
|
150
|
+
validateSchema(db);
|
|
151
|
+
// omms's own capture/profile calls run in OpenCode sessions; never import them as history.
|
|
152
|
+
const hasTitle = db.prepare("PRAGMA table_info(session)").all().some((column) => column.name === "title");
|
|
153
|
+
const notInternal = hasTitle
|
|
154
|
+
? `AND (s.title IS NULL OR s.title NOT IN (${INTERNAL_CAPTURE_SESSION_TITLES.map(() => "?").join(", ")}))`
|
|
155
|
+
: "";
|
|
156
|
+
const internalArgs = hasTitle ? [...INTERNAL_CAPTURE_SESSION_TITLES] : [];
|
|
157
|
+
const count = (where) => Number(db
|
|
158
|
+
.prepare(`SELECT COUNT(*) AS count FROM session s WHERE ${where} ${notInternal}`)
|
|
159
|
+
.get(...internalArgs).count);
|
|
160
|
+
const query = `SELECT s.id, s.directory, p.worktree AS project_worktree, s.time_created
|
|
161
|
+
FROM session s LEFT JOIN project p ON p.id = s.project_id
|
|
162
|
+
WHERE s.parent_id IS NULL ${notInternal} ${filters.session ? "AND s.id = ?" : ""}
|
|
163
|
+
${filters.sessionIds ? "AND s.id IN (SELECT value FROM json_each(?))" : ""}
|
|
164
|
+
ORDER BY s.time_created, s.id ${filters.maxSessions ? "LIMIT ?" : ""}`;
|
|
165
|
+
const args = [...internalArgs];
|
|
166
|
+
if (filters.session)
|
|
167
|
+
args.push(filters.session);
|
|
168
|
+
if (filters.sessionIds)
|
|
169
|
+
args.push(JSON.stringify(filters.sessionIds));
|
|
170
|
+
if (filters.maxSessions)
|
|
171
|
+
args.push(filters.maxSessions);
|
|
172
|
+
const sessions = db.prepare(query).all(...args).map((row) => ({
|
|
173
|
+
sessionId: row.id,
|
|
174
|
+
recordedDirectory: row.directory,
|
|
175
|
+
projectWorktree: row.project_worktree,
|
|
176
|
+
timeCreated: Number(row.time_created),
|
|
177
|
+
}));
|
|
178
|
+
return {
|
|
179
|
+
childSessions: count("s.parent_id IS NOT NULL"),
|
|
180
|
+
topLevelSessions: count("s.parent_id IS NULL"),
|
|
181
|
+
sessions,
|
|
182
|
+
};
|
|
174
183
|
}
|
|
175
184
|
/** Read V1 OpenCode history without changing its database or WAL sidecars. */
|
|
176
|
-
export function
|
|
185
|
+
export async function openOpencodeHistory(dbPath, filters = {}, options = {}) {
|
|
177
186
|
if (!existsSync(dbPath))
|
|
178
|
-
throw new Error(
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
db.close();
|
|
182
|
-
await cleanup();
|
|
183
|
-
};
|
|
187
|
+
throw new Error("OpenCode database not found");
|
|
188
|
+
let source = await openSource(dbPath, options);
|
|
189
|
+
let listed;
|
|
184
190
|
try {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
.prepare(`SELECT COUNT(*) AS count FROM session s WHERE ${where} ${notInternal}`)
|
|
194
|
-
.get(...internalArgs).count);
|
|
195
|
-
const childSessions = count("s.parent_id IS NOT NULL");
|
|
196
|
-
const topLevelSessions = count("s.parent_id IS NULL");
|
|
197
|
-
const sessions = {
|
|
198
|
-
async *[Symbol.asyncIterator]() {
|
|
199
|
-
try {
|
|
200
|
-
const query = `SELECT s.id, s.directory, p.worktree AS project_worktree, s.time_created
|
|
201
|
-
FROM session s LEFT JOIN project p ON p.id = s.project_id
|
|
202
|
-
WHERE s.parent_id IS NULL ${notInternal} ${filters.session ? "AND s.id = ?" : ""}
|
|
203
|
-
ORDER BY s.time_created, s.id ${filters.maxSessions ? "LIMIT ?" : ""}`;
|
|
204
|
-
const args = [...internalArgs];
|
|
205
|
-
if (filters.session)
|
|
206
|
-
args.push(filters.session);
|
|
207
|
-
if (filters.maxSessions)
|
|
208
|
-
args.push(filters.maxSessions);
|
|
209
|
-
for (const row of db.prepare(query).all(...args)) {
|
|
210
|
-
yield {
|
|
211
|
-
sessionId: row.id,
|
|
212
|
-
recordedDirectory: row.directory,
|
|
213
|
-
directory: row.directory,
|
|
214
|
-
sourceFile: dbPath,
|
|
215
|
-
projectWorktree: row.project_worktree,
|
|
216
|
-
timeCreated: row.time_created,
|
|
217
|
-
units: buildWindows(db, row.id, filters),
|
|
218
|
-
};
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
finally {
|
|
222
|
-
await close();
|
|
223
|
-
}
|
|
224
|
-
},
|
|
225
|
-
};
|
|
226
|
-
return { childSessions, topLevelSessions, sessions };
|
|
191
|
+
listed = readSessions(source.db, filters);
|
|
192
|
+
if (changedSinceOpen(dbPath, source)) {
|
|
193
|
+
// OpenCode started writing during the in-place read: read a private copy instead.
|
|
194
|
+
source.db.close();
|
|
195
|
+
source = undefined;
|
|
196
|
+
source = await openSnapshot(dbPath, options);
|
|
197
|
+
listed = readSessions(source.db, filters);
|
|
198
|
+
}
|
|
227
199
|
}
|
|
228
200
|
catch (error) {
|
|
229
|
-
//
|
|
230
|
-
|
|
201
|
+
// Close only what is still open, so the original error is what the caller sees.
|
|
202
|
+
source?.db.close();
|
|
203
|
+
await source?.lease?.release();
|
|
231
204
|
throw error;
|
|
232
205
|
}
|
|
206
|
+
const opened = source;
|
|
207
|
+
let closed = false;
|
|
208
|
+
return {
|
|
209
|
+
...listed,
|
|
210
|
+
loadUnits: (sessionId) => buildWindows(opened.db, sessionId, filters),
|
|
211
|
+
close: async () => {
|
|
212
|
+
if (closed)
|
|
213
|
+
return;
|
|
214
|
+
closed = true;
|
|
215
|
+
opened.db.close();
|
|
216
|
+
await opened.lease?.release();
|
|
217
|
+
},
|
|
218
|
+
};
|
|
233
219
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Private copies of a live OpenCode database. A normal SQLite open writes to
|
|
3
|
+
* OpenCode's `-shm`, and `immutable=1` ignores the WAL, so a copy of the
|
|
4
|
+
* database and its WAL is the only way to read uncheckpointed turns while
|
|
5
|
+
* leaving every source file untouched.
|
|
6
|
+
*
|
|
7
|
+
* Copies run asynchronously so the OpenCode process that hosts the web UI and
|
|
8
|
+
* live capture keeps responding, and they can be cancelled. One copy per
|
|
9
|
+
* source is shared by the web listing, preview, and import.
|
|
10
|
+
*/
|
|
11
|
+
export declare const SNAPSHOT_PREFIX = "omms-opencode-";
|
|
12
|
+
export declare const SNAPSHOT_IDLE_MS: number;
|
|
13
|
+
/** Sources above this size, or on another device, get one copy attempt instead of five. */
|
|
14
|
+
export declare const SINGLE_ATTEMPT_BYTES: number;
|
|
15
|
+
export type SnapshotErrorCode = "no-space" | "changing" | "busy" | "expired";
|
|
16
|
+
export declare class OpencodeSnapshotError extends Error {
|
|
17
|
+
readonly code: SnapshotErrorCode;
|
|
18
|
+
constructor(code: SnapshotErrorCode, message: string);
|
|
19
|
+
}
|
|
20
|
+
export interface SnapshotLease {
|
|
21
|
+
/** Path of the private database copy. */
|
|
22
|
+
path: string;
|
|
23
|
+
release: () => Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
/** Bytes a snapshot of `dbPath` needs in the temporary folder, margin included. */
|
|
26
|
+
export declare function snapshotSpaceNeeded(dbPath: string): number;
|
|
27
|
+
export declare function checkSnapshotSpace(dbPath: string, folder?: string, available?: number): void;
|
|
28
|
+
/**
|
|
29
|
+
* Remove a snapshot folder without ever failing the import. Windows can hold
|
|
30
|
+
* SQLite files briefly after close, so retry lock errors; if the lock
|
|
31
|
+
* outlasts the retries, log the leftover path instead of throwing.
|
|
32
|
+
*/
|
|
33
|
+
export declare function removeSnapshotDir(dir: string): Promise<void>;
|
|
34
|
+
/**
|
|
35
|
+
* Delete snapshot folders left by processes that no longer run, such as an
|
|
36
|
+
* OpenCode window that quit mid-import. A folder whose owner is alive, or
|
|
37
|
+
* whose owner file is unreadable because it is still being created, is kept.
|
|
38
|
+
*/
|
|
39
|
+
export declare function sweepOrphanSnapshots(folder?: string): Promise<number>;
|
|
40
|
+
/**
|
|
41
|
+
* Copy `dbPath` and its WAL into a private folder. A copy that raced a write
|
|
42
|
+
* or checkpoint is discarded and retried, but only once for large or
|
|
43
|
+
* cross-device sources, where each attempt can take minutes.
|
|
44
|
+
*/
|
|
45
|
+
export declare function createOpencodeSnapshot(dbPath: string, options?: {
|
|
46
|
+
signal?: AbortSignal;
|
|
47
|
+
folder?: string;
|
|
48
|
+
clone?: boolean;
|
|
49
|
+
}): Promise<{
|
|
50
|
+
dir: string;
|
|
51
|
+
path: string;
|
|
52
|
+
}>;
|
|
53
|
+
/**
|
|
54
|
+
* Snapshots shared by source key (real path, device, inode). A fresh request
|
|
55
|
+
* replaces the current copy for later callers; a job still reading the old
|
|
56
|
+
* copy keeps it until it releases it.
|
|
57
|
+
*/
|
|
58
|
+
export declare class OpencodeSnapshotRegistry {
|
|
59
|
+
private readonly idleMs;
|
|
60
|
+
private readonly create;
|
|
61
|
+
private readonly current;
|
|
62
|
+
private readonly pending;
|
|
63
|
+
private readonly all;
|
|
64
|
+
constructor(idleMs?: number, create?: typeof createOpencodeSnapshot);
|
|
65
|
+
/**
|
|
66
|
+
* `fresh` makes a new copy (a listing refresh); `reuse` only returns an
|
|
67
|
+
* existing copy and fails as expired when there is none (a job).
|
|
68
|
+
*/
|
|
69
|
+
acquire(key: string, dbPath: string, mode: "fresh" | "reuse" | "any", signal?: AbortSignal): Promise<SnapshotLease>;
|
|
70
|
+
private retire;
|
|
71
|
+
private remove;
|
|
72
|
+
/**
|
|
73
|
+
* Drop the current copy for a source after a real import: a later listing
|
|
74
|
+
* copies afresh. A job still reading it keeps it until it releases it.
|
|
75
|
+
*/
|
|
76
|
+
discard(key: string): void;
|
|
77
|
+
/** Remove every copy, for server shutdown. */
|
|
78
|
+
closeAll(): Promise<void>;
|
|
79
|
+
}
|
|
80
|
+
export declare const opencodeSnapshots: OpencodeSnapshotRegistry;
|
|
81
|
+
/** An unshared snapshot for one CLI or slash-command run. */
|
|
82
|
+
export declare function acquirePrivateSnapshot(dbPath: string, signal?: AbortSignal): Promise<SnapshotLease>;
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { constants, createReadStream, createWriteStream, mkdtempSync, readdirSync, readFileSync, rmSync, statfsSync, statSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { copyFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { pipeline } from "node:stream/promises";
|
|
6
|
+
import { log } from "../services/logger.js";
|
|
7
|
+
import { withSqliteFileLockRetry } from "../services/turso/sqlite-handle-release.js";
|
|
8
|
+
/**
|
|
9
|
+
* Private copies of a live OpenCode database. A normal SQLite open writes to
|
|
10
|
+
* OpenCode's `-shm`, and `immutable=1` ignores the WAL, so a copy of the
|
|
11
|
+
* database and its WAL is the only way to read uncheckpointed turns while
|
|
12
|
+
* leaving every source file untouched.
|
|
13
|
+
*
|
|
14
|
+
* Copies run asynchronously so the OpenCode process that hosts the web UI and
|
|
15
|
+
* live capture keeps responding, and they can be cancelled. One copy per
|
|
16
|
+
* source is shared by the web listing, preview, and import.
|
|
17
|
+
*/
|
|
18
|
+
export const SNAPSHOT_PREFIX = "omms-opencode-";
|
|
19
|
+
export const SNAPSHOT_IDLE_MS = 30 * 60 * 1000;
|
|
20
|
+
/** Sources above this size, or on another device, get one copy attempt instead of five. */
|
|
21
|
+
export const SINGLE_ATTEMPT_BYTES = 1024 ** 3;
|
|
22
|
+
const MIN_MARGIN_BYTES = 256 * 1024 ** 2;
|
|
23
|
+
const OWNER_FILE = "owner.json";
|
|
24
|
+
export class OpencodeSnapshotError extends Error {
|
|
25
|
+
code;
|
|
26
|
+
constructor(code, message) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.code = code;
|
|
29
|
+
this.name = "OpencodeSnapshotError";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function formatBytes(bytes) {
|
|
33
|
+
if (bytes >= 1024 ** 3)
|
|
34
|
+
return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
|
|
35
|
+
return `${Math.ceil(bytes / 1024 ** 2)} MB`;
|
|
36
|
+
}
|
|
37
|
+
function fileStamp(path) {
|
|
38
|
+
const info = statSync(path, { bigint: true });
|
|
39
|
+
return `${info.size}:${info.mtimeNs}`;
|
|
40
|
+
}
|
|
41
|
+
function fileSize(path) {
|
|
42
|
+
try {
|
|
43
|
+
return statSync(path).size;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** Bytes a snapshot of `dbPath` needs in the temporary folder, margin included. */
|
|
50
|
+
export function snapshotSpaceNeeded(dbPath) {
|
|
51
|
+
const total = fileSize(dbPath) + fileSize(`${dbPath}-wal`);
|
|
52
|
+
return total + Math.max(MIN_MARGIN_BYTES, Math.ceil(total * 0.1));
|
|
53
|
+
}
|
|
54
|
+
export function checkSnapshotSpace(dbPath, folder = tmpdir(), available = (() => {
|
|
55
|
+
const info = statfsSync(folder);
|
|
56
|
+
return Number(info.bavail) * Number(info.bsize);
|
|
57
|
+
})()) {
|
|
58
|
+
const needed = snapshotSpaceNeeded(dbPath);
|
|
59
|
+
if (available < needed) {
|
|
60
|
+
throw new OpencodeSnapshotError("no-space", `Not enough temporary space to copy the OpenCode database: needs ${formatBytes(needed)}, ` +
|
|
61
|
+
`${formatBytes(available)} free in ${folder}. Free space there or choose a checkpointed backup.`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Remove a snapshot folder without ever failing the import. Windows can hold
|
|
66
|
+
* SQLite files briefly after close, so retry lock errors; if the lock
|
|
67
|
+
* outlasts the retries, log the leftover path instead of throwing.
|
|
68
|
+
*/
|
|
69
|
+
export async function removeSnapshotDir(dir) {
|
|
70
|
+
try {
|
|
71
|
+
await withSqliteFileLockRetry(() => rmSync(dir, { recursive: true, force: true }));
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
log("OpenCode import: could not remove the temporary database copy", {
|
|
75
|
+
dir,
|
|
76
|
+
code: error.code ?? "error",
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function processAlive(pid) {
|
|
81
|
+
try {
|
|
82
|
+
process.kill(pid, 0);
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
// EPERM: the process exists but belongs to someone else.
|
|
87
|
+
return error.code === "EPERM";
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Delete snapshot folders left by processes that no longer run, such as an
|
|
92
|
+
* OpenCode window that quit mid-import. A folder whose owner is alive, or
|
|
93
|
+
* whose owner file is unreadable because it is still being created, is kept.
|
|
94
|
+
*/
|
|
95
|
+
export async function sweepOrphanSnapshots(folder = tmpdir()) {
|
|
96
|
+
let removed = 0;
|
|
97
|
+
let entries;
|
|
98
|
+
try {
|
|
99
|
+
entries = readdirSync(folder).filter((name) => name.startsWith(SNAPSHOT_PREFIX));
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
|
104
|
+
for (const name of entries) {
|
|
105
|
+
const dir = join(folder, name);
|
|
106
|
+
let owner;
|
|
107
|
+
try {
|
|
108
|
+
owner = JSON.parse(readFileSync(join(dir, OWNER_FILE), "utf8"));
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (typeof owner.pid !== "number" || processAlive(owner.pid))
|
|
114
|
+
continue;
|
|
115
|
+
await removeSnapshotDir(dir);
|
|
116
|
+
removed++;
|
|
117
|
+
}
|
|
118
|
+
return removed;
|
|
119
|
+
}
|
|
120
|
+
async function copyOne(source, target, signal, clone = true) {
|
|
121
|
+
signal?.throwIfAborted();
|
|
122
|
+
if (clone) {
|
|
123
|
+
try {
|
|
124
|
+
// A copy-on-write clone (APFS, Btrfs) is instant and runs off the event loop.
|
|
125
|
+
await copyFile(source, target, constants.COPYFILE_FICLONE_FORCE);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// No clone support here, or another device: stream the bytes instead.
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
await pipeline(createReadStream(source), createWriteStream(target), { signal });
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Copy `dbPath` and its WAL into a private folder. A copy that raced a write
|
|
136
|
+
* or checkpoint is discarded and retried, but only once for large or
|
|
137
|
+
* cross-device sources, where each attempt can take minutes.
|
|
138
|
+
*/
|
|
139
|
+
export async function createOpencodeSnapshot(dbPath, options = {}) {
|
|
140
|
+
const folder = options.folder ?? tmpdir();
|
|
141
|
+
await sweepOrphanSnapshots(folder);
|
|
142
|
+
checkSnapshotSpace(dbPath, folder);
|
|
143
|
+
const wal = `${dbPath}-wal`;
|
|
144
|
+
const total = fileSize(dbPath) + fileSize(wal);
|
|
145
|
+
let sameDevice = false;
|
|
146
|
+
try {
|
|
147
|
+
sameDevice = statSync(folder).dev === statSync(dbPath).dev;
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
// Unknown device: treat it as another one.
|
|
151
|
+
}
|
|
152
|
+
const attempts = sameDevice && total <= SINGLE_ATTEMPT_BYTES ? 5 : 1;
|
|
153
|
+
const dir = mkdtempSync(join(folder, SNAPSHOT_PREFIX));
|
|
154
|
+
writeFileSync(join(dir, OWNER_FILE), JSON.stringify({ pid: process.pid, createdAt: Date.now() }));
|
|
155
|
+
const copy = join(dir, "opencode.db");
|
|
156
|
+
try {
|
|
157
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
158
|
+
const before = [fileStamp(dbPath), fileStamp(wal)];
|
|
159
|
+
await copyOne(dbPath, copy, options.signal, options.clone);
|
|
160
|
+
await copyOne(wal, `${copy}-wal`, options.signal, options.clone);
|
|
161
|
+
const after = [fileStamp(dbPath), fileStamp(wal)];
|
|
162
|
+
if (before[0] === after[0] && before[1] === after[1])
|
|
163
|
+
return { dir, path: copy };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
await removeSnapshotDir(dir);
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
await removeSnapshotDir(dir);
|
|
171
|
+
throw new OpencodeSnapshotError("changing", attempts === 1
|
|
172
|
+
? "The OpenCode database changed while it was copied. Quit OpenCode, or choose a checkpointed backup, and retry."
|
|
173
|
+
: "OpenCode database kept changing while it was copied; retry the import");
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Snapshots shared by source key (real path, device, inode). A fresh request
|
|
177
|
+
* replaces the current copy for later callers; a job still reading the old
|
|
178
|
+
* copy keeps it until it releases it.
|
|
179
|
+
*/
|
|
180
|
+
export class OpencodeSnapshotRegistry {
|
|
181
|
+
idleMs;
|
|
182
|
+
create;
|
|
183
|
+
current = new Map();
|
|
184
|
+
pending = new Map();
|
|
185
|
+
all = new Set();
|
|
186
|
+
constructor(idleMs = SNAPSHOT_IDLE_MS, create = createOpencodeSnapshot) {
|
|
187
|
+
this.idleMs = idleMs;
|
|
188
|
+
this.create = create;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* `fresh` makes a new copy (a listing refresh); `reuse` only returns an
|
|
192
|
+
* existing copy and fails as expired when there is none (a job).
|
|
193
|
+
*/
|
|
194
|
+
async acquire(key, dbPath, mode, signal) {
|
|
195
|
+
let shared = mode === "fresh" ? undefined : this.current.get(key);
|
|
196
|
+
if (!shared) {
|
|
197
|
+
if (mode === "reuse") {
|
|
198
|
+
throw new OpencodeSnapshotError("expired", "The session list is out of date. Refresh the list and try again.");
|
|
199
|
+
}
|
|
200
|
+
let creating = mode === "fresh" ? undefined : this.pending.get(key);
|
|
201
|
+
if (!creating) {
|
|
202
|
+
creating = this.create(dbPath, { signal }).then((copy) => {
|
|
203
|
+
const next = { ...copy, refs: 0, retired: false };
|
|
204
|
+
const previous = this.current.get(key);
|
|
205
|
+
if (previous)
|
|
206
|
+
this.retire(previous);
|
|
207
|
+
this.current.set(key, next);
|
|
208
|
+
this.all.add(next);
|
|
209
|
+
return next;
|
|
210
|
+
});
|
|
211
|
+
this.pending.set(key, creating);
|
|
212
|
+
creating.then(() => this.pending.delete(key), () => this.pending.delete(key));
|
|
213
|
+
}
|
|
214
|
+
shared = await creating;
|
|
215
|
+
}
|
|
216
|
+
const lease = shared;
|
|
217
|
+
lease.refs++;
|
|
218
|
+
if (lease.timer)
|
|
219
|
+
clearTimeout(lease.timer);
|
|
220
|
+
lease.timer = undefined;
|
|
221
|
+
let released = false;
|
|
222
|
+
return {
|
|
223
|
+
path: lease.path,
|
|
224
|
+
release: async () => {
|
|
225
|
+
if (released)
|
|
226
|
+
return;
|
|
227
|
+
released = true;
|
|
228
|
+
lease.refs--;
|
|
229
|
+
if (lease.refs > 0)
|
|
230
|
+
return;
|
|
231
|
+
if (lease.retired)
|
|
232
|
+
return this.remove(lease);
|
|
233
|
+
lease.timer = setTimeout(() => {
|
|
234
|
+
if (this.current.get(key) === lease)
|
|
235
|
+
this.current.delete(key);
|
|
236
|
+
void this.remove(lease);
|
|
237
|
+
}, this.idleMs);
|
|
238
|
+
lease.timer.unref?.();
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
retire(shared) {
|
|
243
|
+
shared.retired = true;
|
|
244
|
+
if (shared.refs === 0)
|
|
245
|
+
void this.remove(shared);
|
|
246
|
+
}
|
|
247
|
+
async remove(shared) {
|
|
248
|
+
if (shared.timer)
|
|
249
|
+
clearTimeout(shared.timer);
|
|
250
|
+
if (!this.all.delete(shared))
|
|
251
|
+
return;
|
|
252
|
+
await removeSnapshotDir(shared.dir);
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Drop the current copy for a source after a real import: a later listing
|
|
256
|
+
* copies afresh. A job still reading it keeps it until it releases it.
|
|
257
|
+
*/
|
|
258
|
+
discard(key) {
|
|
259
|
+
const shared = this.current.get(key);
|
|
260
|
+
if (!shared)
|
|
261
|
+
return;
|
|
262
|
+
this.current.delete(key);
|
|
263
|
+
this.retire(shared);
|
|
264
|
+
}
|
|
265
|
+
/** Remove every copy, for server shutdown. */
|
|
266
|
+
async closeAll() {
|
|
267
|
+
this.current.clear();
|
|
268
|
+
await Promise.all([...this.all].map((shared) => this.remove(shared)));
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
export const opencodeSnapshots = new OpencodeSnapshotRegistry();
|
|
272
|
+
/** An unshared snapshot for one CLI or slash-command run. */
|
|
273
|
+
export async function acquirePrivateSnapshot(dbPath, signal) {
|
|
274
|
+
const copy = await createOpencodeSnapshot(dbPath, { signal });
|
|
275
|
+
return { path: copy.path, release: () => removeSnapshotDir(copy.dir) };
|
|
276
|
+
}
|