trekoon 0.4.0 → 0.4.2
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/.agents/skills/trekoon/SKILL.md +20 -577
- package/.agents/skills/trekoon/reference/execution-with-team.md +21 -9
- package/.agents/skills/trekoon/reference/execution.md +246 -7
- package/.agents/skills/trekoon/reference/planning.md +138 -1
- package/.agents/skills/trekoon/reference/status-machine.md +21 -0
- package/.agents/skills/trekoon/reference/sync.md +129 -0
- package/README.md +8 -7
- package/docs/ai-agents.md +17 -2
- package/docs/commands.md +147 -3
- package/docs/machine-contracts.md +123 -0
- package/docs/quickstart.md +52 -0
- package/package.json +1 -1
- package/src/board/assets/app.js +49 -16
- package/src/board/assets/components/Component.js +22 -8
- package/src/board/assets/components/Workspace.js +9 -3
- package/src/board/assets/components/helpers.js +5 -1
- package/src/board/assets/runtime/delegation.js +8 -0
- package/src/board/assets/runtime/focus-trap.js +48 -0
- package/src/board/assets/state/actions.js +47 -4
- package/src/board/assets/state/api.js +284 -11
- package/src/board/assets/state/store.js +87 -11
- package/src/board/assets/state/url.js +10 -0
- package/src/board/assets/state/utils.js +2 -1
- package/src/board/event-bus.ts +72 -0
- package/src/board/routes.ts +412 -33
- package/src/board/server.ts +77 -8
- package/src/board/wal-watcher.ts +302 -0
- package/src/commands/board.ts +52 -17
- package/src/commands/epic.ts +7 -9
- package/src/commands/error-utils.ts +54 -1
- package/src/commands/help.ts +69 -4
- package/src/commands/migrate.ts +153 -24
- package/src/commands/quickstart.ts +7 -0
- package/src/commands/subtask.ts +71 -10
- package/src/commands/suggest.ts +6 -13
- package/src/commands/task.ts +137 -88
- package/src/domain/batch-validation.ts +329 -0
- package/src/domain/cascade-planner.ts +412 -0
- package/src/domain/dependency-rules.ts +15 -0
- package/src/domain/mutation-service.ts +828 -192
- package/src/domain/search.ts +113 -0
- package/src/domain/tracker-domain.ts +150 -680
- package/src/domain/types.ts +53 -2
- package/src/index.ts +37 -0
- package/src/runtime/cli-shell.ts +44 -0
- package/src/runtime/daemon.ts +639 -0
- package/src/storage/backup.ts +166 -0
- package/src/storage/database.ts +261 -4
- package/src/storage/migrations.ts +422 -20
- package/src/storage/path.ts +8 -0
- package/src/storage/schema.ts +5 -1
- package/src/sync/event-writes.ts +38 -11
- package/src/sync/git-context.ts +226 -8
- package/src/sync/service.ts +650 -147
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { Database } from "bun:sqlite";
|
|
5
|
+
|
|
6
|
+
import { DomainError } from "../domain/types";
|
|
7
|
+
import { LATEST_MIGRATION_VERSION, readCurrentMigrationVersionReadOnly } from "./migrations";
|
|
8
|
+
import { resolveStoragePaths } from "./path";
|
|
9
|
+
|
|
10
|
+
export interface MigrateBackupResult {
|
|
11
|
+
readonly backupPath: string;
|
|
12
|
+
readonly bytes: number;
|
|
13
|
+
readonly migrationVersion: number;
|
|
14
|
+
readonly latestVersion: number;
|
|
15
|
+
readonly timestamp: string;
|
|
16
|
+
readonly retainedCount: number;
|
|
17
|
+
readonly prunedPaths: readonly string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Default retention count when --retain is not provided. */
|
|
21
|
+
export const DEFAULT_BACKUP_RETENTION = 10;
|
|
22
|
+
|
|
23
|
+
/** Filename prefix shared by every backup snapshot. */
|
|
24
|
+
const BACKUP_FILENAME_PREFIX = "trekoon.db.backup-";
|
|
25
|
+
|
|
26
|
+
function isoTimestampForFilename(now: Date): string {
|
|
27
|
+
// ISO 8601 with colons and dots replaced for filesystem-safe use across
|
|
28
|
+
// macOS/Linux/Windows. Resolution is millisecond-precise so two backups in
|
|
29
|
+
// the same second still produce distinct filenames.
|
|
30
|
+
// Example input: 2026-05-02T13:45:30.123Z -> 2026-05-02T13-45-30-123Z
|
|
31
|
+
return now.toISOString().replace(/[:.]/gu, "-");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function quoteForVacuumInto(filePath: string): string {
|
|
35
|
+
// SQLite path literal: wrap in single quotes and escape embedded single quotes by doubling.
|
|
36
|
+
return `'${filePath.replace(/'/gu, "''")}'`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface CreateMigrationBackupOptions {
|
|
40
|
+
readonly cwd: string;
|
|
41
|
+
readonly now?: Date;
|
|
42
|
+
/**
|
|
43
|
+
* Maximum number of timestamped backup siblings to retain (including the
|
|
44
|
+
* one being created). Older snapshots are pruned. Defaults to
|
|
45
|
+
* {@link DEFAULT_BACKUP_RETENTION}. Pass `Infinity` or any non-positive
|
|
46
|
+
* number to disable pruning.
|
|
47
|
+
*/
|
|
48
|
+
readonly retain?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface ListedBackup {
|
|
52
|
+
readonly filename: string;
|
|
53
|
+
readonly fullPath: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function listExistingBackups(storageDir: string): ListedBackup[] {
|
|
57
|
+
if (!existsSync(storageDir)) {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return readdirSync(storageDir)
|
|
62
|
+
.filter((entry) => entry.startsWith(BACKUP_FILENAME_PREFIX))
|
|
63
|
+
.map((entry) => ({ filename: entry, fullPath: resolve(storageDir, entry) }));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function pruneOlderBackups(storageDir: string, keepCount: number): string[] {
|
|
67
|
+
if (!Number.isFinite(keepCount) || keepCount <= 0) {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const existing = listExistingBackups(storageDir);
|
|
72
|
+
if (existing.length <= keepCount) {
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Filename suffix is a millisecond-precise ISO timestamp, so a lexicographic
|
|
77
|
+
// sort is equivalent to chronological order. Most-recent first.
|
|
78
|
+
const sorted = [...existing].sort((left, right) => {
|
|
79
|
+
if (left.filename < right.filename) {
|
|
80
|
+
return 1;
|
|
81
|
+
}
|
|
82
|
+
if (left.filename > right.filename) {
|
|
83
|
+
return -1;
|
|
84
|
+
}
|
|
85
|
+
return 0;
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const toPrune = sorted.slice(keepCount);
|
|
89
|
+
const pruned: string[] = [];
|
|
90
|
+
|
|
91
|
+
for (const candidate of toPrune) {
|
|
92
|
+
try {
|
|
93
|
+
unlinkSync(candidate.fullPath);
|
|
94
|
+
pruned.push(candidate.fullPath);
|
|
95
|
+
} catch {
|
|
96
|
+
// Best-effort prune: a transient unlink failure (concurrent backup,
|
|
97
|
+
// permissions, etc.) must not abort the surrounding backup operation.
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return pruned;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function createMigrationBackup(options: CreateMigrationBackupOptions): MigrateBackupResult {
|
|
105
|
+
const cwd: string = options.cwd;
|
|
106
|
+
const now: Date = options.now ?? new Date();
|
|
107
|
+
const retainRaw: number = options.retain ?? DEFAULT_BACKUP_RETENTION;
|
|
108
|
+
const retainCount: number =
|
|
109
|
+
Number.isFinite(retainRaw) && retainRaw > 0 ? Math.floor(retainRaw) : Number.POSITIVE_INFINITY;
|
|
110
|
+
const storagePaths = resolveStoragePaths(cwd);
|
|
111
|
+
const databaseFile: string = storagePaths.databaseFile;
|
|
112
|
+
|
|
113
|
+
if (!existsSync(databaseFile)) {
|
|
114
|
+
throw new DomainError({
|
|
115
|
+
code: "backup_database_missing",
|
|
116
|
+
message: `Cannot back up Trekoon database: ${databaseFile} does not exist. Run 'trekoon init' first.`,
|
|
117
|
+
details: { databaseFile },
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const timestamp: string = isoTimestampForFilename(now);
|
|
122
|
+
const storageDir: string = dirname(databaseFile);
|
|
123
|
+
const backupFilename = `${BACKUP_FILENAME_PREFIX}${timestamp}`;
|
|
124
|
+
const backupPath: string = resolve(storageDir, backupFilename);
|
|
125
|
+
|
|
126
|
+
if (existsSync(backupPath)) {
|
|
127
|
+
throw new DomainError({
|
|
128
|
+
code: "backup_already_exists",
|
|
129
|
+
message:
|
|
130
|
+
`Backup already exists at ${backupPath}. ` +
|
|
131
|
+
`Backup filenames are millisecond-precise; two backups can only collide ` +
|
|
132
|
+
`when the same explicit timestamp is reused. Wait at least one millisecond ` +
|
|
133
|
+
`between backups or pass a distinct now/timestamp.`,
|
|
134
|
+
details: { backupPath },
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// VACUUM INTO writes a fully-consistent snapshot at a single transaction
|
|
139
|
+
// boundary, including any uncommitted WAL state once the read transaction
|
|
140
|
+
// is taken. This is the SQLite-recommended way to atomically clone a DB.
|
|
141
|
+
// Open read-only so we never mutate the live DB while snapshotting.
|
|
142
|
+
const sourceDb = new Database(databaseFile, { readonly: true });
|
|
143
|
+
let migrationVersion = 0;
|
|
144
|
+
const latestVersion: number = LATEST_MIGRATION_VERSION;
|
|
145
|
+
try {
|
|
146
|
+
migrationVersion = readCurrentMigrationVersionReadOnly(sourceDb);
|
|
147
|
+
sourceDb.exec(`VACUUM INTO ${quoteForVacuumInto(backupPath)};`);
|
|
148
|
+
} finally {
|
|
149
|
+
sourceDb.close(false);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const stats = statSync(backupPath);
|
|
153
|
+
|
|
154
|
+
const prunedPaths: readonly string[] = pruneOlderBackups(storageDir, retainCount);
|
|
155
|
+
const retainedCount: number = listExistingBackups(storageDir).length;
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
backupPath,
|
|
159
|
+
bytes: stats.size,
|
|
160
|
+
migrationVersion,
|
|
161
|
+
latestVersion,
|
|
162
|
+
timestamp,
|
|
163
|
+
retainedCount,
|
|
164
|
+
prunedPaths,
|
|
165
|
+
};
|
|
166
|
+
}
|
package/src/storage/database.ts
CHANGED
|
@@ -1,16 +1,55 @@
|
|
|
1
|
-
import { mkdirSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
2
2
|
|
|
3
3
|
import { Database } from "bun:sqlite";
|
|
4
4
|
|
|
5
5
|
import { DomainError } from "../domain/types";
|
|
6
|
-
import { migrateDatabase } from "./migrations";
|
|
7
|
-
import { resolveStoragePaths, type StoragePaths } from "./path";
|
|
6
|
+
import { LATEST_MIGRATION_VERSION, migrateDatabase, readCurrentMigrationVersionReadOnly } from "./migrations";
|
|
7
|
+
import { resolveLegacyWorktreeDatabaseFile, resolveStoragePaths, type StoragePaths } from "./path";
|
|
8
8
|
import {
|
|
9
9
|
inspectWorktreeDatabaseState,
|
|
10
10
|
recoverWorktreeDatabaseState,
|
|
11
11
|
type WorktreeRecoveryDiagnostics,
|
|
12
12
|
} from "./worktree-recovery";
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Re-inspect worktree state on a daemon-mode cache hit so out-of-band changes
|
|
16
|
+
* to the worktree (e.g. an operator restoring a legacy `.trekoon/trekoon.db`
|
|
17
|
+
* after the daemon started, or a new tracked-vs-ignored conflict) surface to
|
|
18
|
+
* the caller through `diagnostics.recoveryRequired` instead of being masked
|
|
19
|
+
* by the cached handle's stale snapshot. Returns NO_LEGACY_RECOVERY when the
|
|
20
|
+
* fast-path no-legacy-DB shortcut applies and falls back to the
|
|
21
|
+
* DomainError-aware capture used by `resolveStorageResolutionDiagnostics`
|
|
22
|
+
* when inspection itself raises (e.g. tracked_ignored_mismatch).
|
|
23
|
+
*/
|
|
24
|
+
function reinspectRecoveryForCacheHit(paths: StoragePaths): WorktreeRecoveryDiagnostics {
|
|
25
|
+
const legacyDbFile: string = resolveLegacyWorktreeDatabaseFile(paths.worktreeRoot);
|
|
26
|
+
if (legacyDbFile !== paths.databaseFile && !existsSync(legacyDbFile)) {
|
|
27
|
+
return NO_LEGACY_RECOVERY;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
return inspectWorktreeDatabaseState(paths);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (!(error instanceof DomainError)) {
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
const details: Record<string, unknown> = error.details ?? {};
|
|
37
|
+
return {
|
|
38
|
+
status: (details.status as WorktreeRecoveryDiagnostics["status"] | undefined) ?? "no_legacy_state",
|
|
39
|
+
legacyDatabaseFiles: Array.isArray(details.legacyDatabaseFiles)
|
|
40
|
+
? (details.legacyDatabaseFiles as string[])
|
|
41
|
+
: [],
|
|
42
|
+
backupFiles: Array.isArray(details.backupFiles) ? (details.backupFiles as string[]) : [],
|
|
43
|
+
trackedStorageFiles: Array.isArray(details.trackedStorageFiles)
|
|
44
|
+
? (details.trackedStorageFiles as string[])
|
|
45
|
+
: [],
|
|
46
|
+
autoMigrated: details.autoMigrated === true,
|
|
47
|
+
importedFrom: typeof details.importedFrom === "string" ? details.importedFrom : null,
|
|
48
|
+
operatorAction: typeof details.operatorAction === "string" ? details.operatorAction : error.message,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
14
53
|
export interface StorageResolutionDiagnostics {
|
|
15
54
|
readonly invocationCwd: string;
|
|
16
55
|
readonly storageMode: StoragePaths["storageMode"];
|
|
@@ -140,12 +179,208 @@ export function writeTransaction<T>(db: Database, fn: (db: Database) => T): T {
|
|
|
140
179
|
}
|
|
141
180
|
}
|
|
142
181
|
|
|
182
|
+
/** Sentinel recovery result for the common case: no legacy worktree DB present. */
|
|
183
|
+
const NO_LEGACY_RECOVERY: WorktreeRecoveryDiagnostics = {
|
|
184
|
+
status: "no_legacy_state",
|
|
185
|
+
legacyDatabaseFiles: [],
|
|
186
|
+
backupFiles: [],
|
|
187
|
+
trackedStorageFiles: [],
|
|
188
|
+
autoMigrated: false,
|
|
189
|
+
importedFrom: null,
|
|
190
|
+
operatorAction: "No legacy worktree-local database detected.",
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Process-level cache of opened TrekoonDatabase handles. Enabled only when
|
|
195
|
+
* `TREKOON_DAEMON_INPROCESS=1` is set (the daemon spike sets this on startup).
|
|
196
|
+
* In normal one-shot CLI invocations this map stays empty and the cache layer
|
|
197
|
+
* is bypassed entirely, so default behavior is unchanged.
|
|
198
|
+
*
|
|
199
|
+
* Cached handles override `close()` so callers that follow the
|
|
200
|
+
* `try { ... } finally { db?.close(); }` pattern do not actually tear down
|
|
201
|
+
* the shared connection — instead, `close()` decrements an in-use refcount.
|
|
202
|
+
* The daemon shutdown path calls `closeCachedDatabases()`.
|
|
203
|
+
*
|
|
204
|
+
* The cache is bounded LRU (insertion-order on Map; touched on access) so a
|
|
205
|
+
* long-running daemon serving many distinct cwds does not accumulate open
|
|
206
|
+
* SQLite connections / FDs without bound. Eviction closes the underlying
|
|
207
|
+
* database after a passive WAL checkpoint.
|
|
208
|
+
*
|
|
209
|
+
* In-use protection: each `openTrekoonDatabase` call increments the entry's
|
|
210
|
+
* refcount; the matching `close()` decrements it. Eviction skips entries
|
|
211
|
+
* whose refcount is > 0 — closing them mid-query would surface as
|
|
212
|
+
* SQLITE_MISUSE on the in-flight handler. If every cached entry is in use
|
|
213
|
+
* when a new cwd is opened, we permit temporary growth past the cap and
|
|
214
|
+
* emit a single warning; the next eviction-check after a `close()` will
|
|
215
|
+
* reduce the cache back under the cap.
|
|
216
|
+
*/
|
|
217
|
+
const CACHED_DATABASES_CAPACITY = 16;
|
|
218
|
+
|
|
219
|
+
interface CachedEntry {
|
|
220
|
+
readonly handle: TrekoonDatabase;
|
|
221
|
+
refcount: number;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const cachedDatabases: Map<string, CachedEntry> = new Map();
|
|
225
|
+
let warnedOnTransientOvergrowth = false;
|
|
226
|
+
|
|
227
|
+
function isDaemonInProcessCacheEnabled(): boolean {
|
|
228
|
+
return process.env.TREKOON_DAEMON_INPROCESS === "1";
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function closeCachedHandle(handle: TrekoonDatabase): void {
|
|
232
|
+
try {
|
|
233
|
+
handle.db.exec("PRAGMA wal_checkpoint(PASSIVE);");
|
|
234
|
+
} catch {
|
|
235
|
+
/* best effort */
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
handle.db.close(false);
|
|
239
|
+
} catch {
|
|
240
|
+
/* best effort */
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Evict the least-recently-used handle when the cache is at capacity. Map
|
|
246
|
+
* iteration order in JS is insertion order; we re-insert on access (`touch`)
|
|
247
|
+
* to model LRU semantics. Entries whose refcount is > 0 are SKIPPED — closing
|
|
248
|
+
* an in-use handle would surface as SQLITE_MISUSE for the caller currently
|
|
249
|
+
* borrowing it. A subsequent `releaseCachedDatabase` after the caller's
|
|
250
|
+
* `close()` will re-run eviction so transient over-cap growth is bounded by
|
|
251
|
+
* the number of concurrently-borrowed entries, which is in practice tiny.
|
|
252
|
+
*/
|
|
253
|
+
function evictLruIfNeeded(): void {
|
|
254
|
+
while (cachedDatabases.size >= CACHED_DATABASES_CAPACITY) {
|
|
255
|
+
let evicted = false;
|
|
256
|
+
for (const [key, entry] of cachedDatabases) {
|
|
257
|
+
if (entry.refcount > 0) {
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
cachedDatabases.delete(key);
|
|
261
|
+
closeCachedHandle(entry.handle);
|
|
262
|
+
evicted = true;
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (!evicted) {
|
|
267
|
+
// Every cached entry is currently borrowed. Allow temporary growth
|
|
268
|
+
// past the cap rather than closing an in-use handle. Surface a single
|
|
269
|
+
// warning so an operator can spot pathological concurrency.
|
|
270
|
+
if (!warnedOnTransientOvergrowth) {
|
|
271
|
+
warnedOnTransientOvergrowth = true;
|
|
272
|
+
// eslint-disable-next-line no-console
|
|
273
|
+
console.warn(
|
|
274
|
+
`[trekoon daemon] all ${CACHED_DATABASES_CAPACITY} cached database handles are in use; temporarily growing cache past the cap`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function touchCachedDatabase(key: string, entry: CachedEntry): void {
|
|
283
|
+
// Re-insert to move to the most-recently-used (tail) end of insertion order.
|
|
284
|
+
cachedDatabases.delete(key);
|
|
285
|
+
cachedDatabases.set(key, entry);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Decrement the refcount for a cached entry. Called from the cached handle's
|
|
290
|
+
* `close()` method. After a release, re-run eviction in case the cache had
|
|
291
|
+
* grown past the cap because every entry was borrowed.
|
|
292
|
+
*/
|
|
293
|
+
function releaseCachedDatabase(key: string): void {
|
|
294
|
+
const entry = cachedDatabases.get(key);
|
|
295
|
+
if (!entry) {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
if (entry.refcount > 0) {
|
|
299
|
+
entry.refcount -= 1;
|
|
300
|
+
}
|
|
301
|
+
if (cachedDatabases.size > CACHED_DATABASES_CAPACITY) {
|
|
302
|
+
evictLruIfNeeded();
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function closeCachedDatabases(): void {
|
|
307
|
+
for (const entry of cachedDatabases.values()) {
|
|
308
|
+
closeCachedHandle(entry.handle);
|
|
309
|
+
}
|
|
310
|
+
cachedDatabases.clear();
|
|
311
|
+
warnedOnTransientOvergrowth = false;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Test-only: report the current size of the daemon-mode database cache.
|
|
316
|
+
* Production code never inspects this — used by `tests/runtime/cache-bound`.
|
|
317
|
+
*/
|
|
318
|
+
export function cachedDatabasesSize(): number {
|
|
319
|
+
return cachedDatabases.size;
|
|
320
|
+
}
|
|
321
|
+
|
|
143
322
|
export function openTrekoonDatabase(
|
|
144
323
|
workingDirectory: string = process.cwd(),
|
|
145
324
|
options: OpenTrekoonDatabaseOptions = {},
|
|
146
325
|
): TrekoonDatabase {
|
|
147
326
|
const paths: StoragePaths = resolveStoragePaths(workingDirectory);
|
|
148
|
-
|
|
327
|
+
|
|
328
|
+
// Daemon-mode reuse: when running inside `trekoon serve`, return a cached
|
|
329
|
+
// connection so each request avoids the migration probe and database open.
|
|
330
|
+
if (isDaemonInProcessCacheEnabled()) {
|
|
331
|
+
const cachedEntry = cachedDatabases.get(paths.databaseFile);
|
|
332
|
+
if (cachedEntry) {
|
|
333
|
+
// Honor autoMigrate on the cached-handle path: a previous request that
|
|
334
|
+
// opened this DB with `{autoMigrate: false}` (e.g. migrate-status) may
|
|
335
|
+
// have left the schema below LATEST_MIGRATION_VERSION. The next request
|
|
336
|
+
// that asks for autoMigrate (the default) must still get a migrated DB.
|
|
337
|
+
if (
|
|
338
|
+
(options.autoMigrate ?? true)
|
|
339
|
+
&& readCurrentMigrationVersionReadOnly(cachedEntry.handle.db) < LATEST_MIGRATION_VERSION
|
|
340
|
+
) {
|
|
341
|
+
migrateDatabase(cachedEntry.handle.db);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Re-inspect worktree state on every cache hit so out-of-band changes
|
|
345
|
+
// (legacy DB restored after daemon start, fresh tracked/ignored
|
|
346
|
+
// mismatch, etc.) surface to callers through fresh diagnostics. The
|
|
347
|
+
// cached `handle.diagnostics` is a frozen snapshot from the original
|
|
348
|
+
// open and would otherwise mask a freshly-required recovery — leading
|
|
349
|
+
// `session` and friends to silently proceed against a stale state.
|
|
350
|
+
const refreshedRecovery: WorktreeRecoveryDiagnostics = reinspectRecoveryForCacheHit(paths);
|
|
351
|
+
const refreshedDiagnostics: StorageResolutionDiagnostics =
|
|
352
|
+
buildStorageResolutionDiagnostics(paths, refreshedRecovery);
|
|
353
|
+
|
|
354
|
+
// Refresh LRU position on access and increment the in-use refcount so
|
|
355
|
+
// `evictLruIfNeeded` cannot close this entry under the borrower.
|
|
356
|
+
cachedEntry.refcount += 1;
|
|
357
|
+
touchCachedDatabase(paths.databaseFile, cachedEntry);
|
|
358
|
+
|
|
359
|
+
const cacheKey: string = paths.databaseFile;
|
|
360
|
+
// Return a per-request handle wrapper that exposes the refreshed
|
|
361
|
+
// diagnostics while sharing the underlying connection. The wrapper's
|
|
362
|
+
// close() releases the same cache key the cached entry's close()
|
|
363
|
+
// does, so refcount accounting stays consistent regardless of which
|
|
364
|
+
// handle the caller holds.
|
|
365
|
+
return {
|
|
366
|
+
db: cachedEntry.handle.db,
|
|
367
|
+
paths: cachedEntry.handle.paths,
|
|
368
|
+
diagnostics: refreshedDiagnostics,
|
|
369
|
+
close(): void {
|
|
370
|
+
releaseCachedDatabase(cacheKey);
|
|
371
|
+
},
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Fast path: if no legacy .trekoon/trekoon.db exists in the current worktree,
|
|
377
|
+
// skip the git-heavy recoverWorktreeDatabaseState entirely.
|
|
378
|
+
const legacyDbFile: string = resolveLegacyWorktreeDatabaseFile(paths.worktreeRoot);
|
|
379
|
+
const recovery: WorktreeRecoveryDiagnostics =
|
|
380
|
+
legacyDbFile !== paths.databaseFile && !existsSync(legacyDbFile)
|
|
381
|
+
? NO_LEGACY_RECOVERY
|
|
382
|
+
: recoverWorktreeDatabaseState(paths);
|
|
383
|
+
|
|
149
384
|
const diagnostics: StorageResolutionDiagnostics = buildStorageResolutionDiagnostics(paths, recovery);
|
|
150
385
|
|
|
151
386
|
mkdirSync(paths.storageDir, { recursive: true });
|
|
@@ -160,6 +395,28 @@ export function openTrekoonDatabase(
|
|
|
160
395
|
migrateDatabase(db);
|
|
161
396
|
}
|
|
162
397
|
|
|
398
|
+
if (isDaemonInProcessCacheEnabled()) {
|
|
399
|
+
const cacheKey: string = paths.databaseFile;
|
|
400
|
+
const cachedHandle: TrekoonDatabase = {
|
|
401
|
+
db,
|
|
402
|
+
paths,
|
|
403
|
+
diagnostics,
|
|
404
|
+
close(): void {
|
|
405
|
+
// The daemon owns the lifetime (freed via closeCachedDatabases()), but
|
|
406
|
+
// we still need to track in-use refcount so eviction does not close a
|
|
407
|
+
// handle that an in-flight request is still using.
|
|
408
|
+
releaseCachedDatabase(cacheKey);
|
|
409
|
+
},
|
|
410
|
+
};
|
|
411
|
+
// Bound the cache before insertion so a daemon serving many distinct
|
|
412
|
+
// cwds doesn't accumulate open FDs without limit. Eviction will skip
|
|
413
|
+
// entries that other requests are currently borrowing.
|
|
414
|
+
evictLruIfNeeded();
|
|
415
|
+
// Initial refcount = 1 reflects the borrow taken by THIS caller.
|
|
416
|
+
cachedDatabases.set(cacheKey, { handle: cachedHandle, refcount: 1 });
|
|
417
|
+
return cachedHandle;
|
|
418
|
+
}
|
|
419
|
+
|
|
163
420
|
return {
|
|
164
421
|
db,
|
|
165
422
|
paths,
|