dsh-plugin-sessiongraph-sessions 0.1.9 → 0.1.11
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/lib/index.d.ts +6 -0
- package/lib/index.js +23 -0
- package/lib/lock.d.ts +41 -0
- package/lib/lock.js +107 -0
- package/package.json +3 -3
package/lib/index.d.ts
CHANGED
|
@@ -110,6 +110,12 @@ export interface Config {
|
|
|
110
110
|
* explicit `sessiongraph_index` / `/sessiongraph index` before anything is searchable.
|
|
111
111
|
*/
|
|
112
112
|
autoBuild?: boolean;
|
|
113
|
+
/**
|
|
114
|
+
* Treat a write lock older than this many milliseconds as abandoned (default 300000). A crashed
|
|
115
|
+
* run leaves `index.lock` behind; without a staleness window the index would stay unwritable until
|
|
116
|
+
* someone deleted the file by hand.
|
|
117
|
+
*/
|
|
118
|
+
indexLockStaleMs?: number;
|
|
113
119
|
/**
|
|
114
120
|
* Hand an explicit index run to the host's background-job registry once the session store holds at
|
|
115
121
|
* least this many logs (default 1000). Below it the run is synchronous, because a small index is
|
package/lib/index.js
CHANGED
|
@@ -40,6 +40,7 @@ import { basename, join } from 'node:path';
|
|
|
40
40
|
import z from '@deepseek-ai/schemastery';
|
|
41
41
|
import { loadNative } from 'dsh-plugin-sessiongraph-native';
|
|
42
42
|
import { INDEX_RELATIVE_PATH, SessiongraphIndexerId } from 'dsh-plugin-sessiongraph-service';
|
|
43
|
+
import { acquireIndexLock } from "./lock.js";
|
|
43
44
|
import { collectLiveSessions, LIVE_SOURCE_PREFIX } from "./live.js";
|
|
44
45
|
import { parseSessionLog } from "./parse.js";
|
|
45
46
|
import { scanSessions } from "./scan.js";
|
|
@@ -90,6 +91,7 @@ export const Config = z.object({
|
|
|
90
91
|
maxFileBytes: z.number().default(DEFAULT_MAX_FILE_BYTES),
|
|
91
92
|
autoIndex: z.boolean().default(true),
|
|
92
93
|
autoBuild: z.boolean().default(true),
|
|
94
|
+
indexLockStaleMs: z.number().default(300_000),
|
|
93
95
|
backgroundIndexThreshold: z.number().default(DEFAULT_BACKGROUND_INDEX_THRESHOLD),
|
|
94
96
|
maxLiveSessions: z.number().default(DEFAULT_MAX_LIVE_SESSIONS),
|
|
95
97
|
engine: z.union([z.const('auto'), z.const('native'), z.const('typescript')]).default(DEFAULT_ENGINE),
|
|
@@ -541,6 +543,27 @@ async function runLivePass(ctx, indexPath, config, inScope, provenance) {
|
|
|
541
543
|
* @returns what the run did.
|
|
542
544
|
*/
|
|
543
545
|
export async function runIndex(indexRoot, options, signal, config, ctx) {
|
|
546
|
+
// Serialize every writer of this root — the automatic refresh, the lazy refresh and an explicit
|
|
547
|
+
// `/sessiongraph index` all land here, and two engines opening the same SQLite file is how the
|
|
548
|
+
// index got corrupted before. Waiting for another run to finish is always better than rebuilding.
|
|
549
|
+
const release = await acquireIndexLock(indexRoot, { staleMs: config.indexLockStaleMs });
|
|
550
|
+
try {
|
|
551
|
+
return await runIndexUnlocked(indexRoot, options, signal, config, ctx);
|
|
552
|
+
}
|
|
553
|
+
finally {
|
|
554
|
+
release();
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* The actual run, with the write lock already held.
|
|
559
|
+
* @param indexRoot - directory holding the index.
|
|
560
|
+
* @param options - caller switches.
|
|
561
|
+
* @param signal - aborts the run.
|
|
562
|
+
* @param config - resolved plugin configuration.
|
|
563
|
+
* @param ctx - plugin context, consulted for the optional live-session service.
|
|
564
|
+
* @returns the run's report.
|
|
565
|
+
*/
|
|
566
|
+
async function runIndexUnlocked(indexRoot, options, signal, config, ctx) {
|
|
544
567
|
const startedAt = Date.now();
|
|
545
568
|
const indexPath = join(indexRoot, INDEX_RELATIVE_PATH);
|
|
546
569
|
const workspaceFilter = options.workspace;
|
package/lib/lock.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One writer at a time per index root, across processes.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: the same `index.db` is written by two engines (the TypeScript one through
|
|
5
|
+
* `node:sqlite`, the Rust one through bundled rusqlite) and reached by three triggers (the
|
|
6
|
+
* `agent/created` background job, the lazy refresh before a query, and an explicit
|
|
7
|
+
* `sessiongraph_index`). Nothing in SQLite serializes two writers that open the file
|
|
8
|
+
* independently, and the failure mode is not a clean lock conflict — it is a corpus that later
|
|
9
|
+
* reports `database disk image is malformed` and has to be rebuilt from scratch. A lock file turns
|
|
10
|
+
* that into a short wait.
|
|
11
|
+
*
|
|
12
|
+
* The lock is a file created with `wx` (exclusive create): the winner writes its pid and proceeds,
|
|
13
|
+
* the losers poll until it disappears. A crash leaves the file behind, so a lock older than
|
|
14
|
+
* {@link STALE_LOCK_MS} is broken — the alternative is a permanently unusable index that only
|
|
15
|
+
* deleting a file by hand can fix.
|
|
16
|
+
* @module dsh-plugin-sessiongraph-sessions/lock
|
|
17
|
+
*/
|
|
18
|
+
/** File name of the lock inside an index root. */
|
|
19
|
+
export declare const LOCK_FILE_NAME = "index.lock";
|
|
20
|
+
/** How long a lock may sit untouched before another writer treats its owner as dead. */
|
|
21
|
+
export declare const STALE_LOCK_MS: number;
|
|
22
|
+
/** How long a writer waits for the lock before giving up. */
|
|
23
|
+
export declare const LOCK_TIMEOUT_MS: number;
|
|
24
|
+
/** Options for {@link acquireIndexLock}, all overridable so tests do not wait minutes. */
|
|
25
|
+
export interface IndexLockOptions {
|
|
26
|
+
/** Give up after this long. */
|
|
27
|
+
timeoutMs?: number;
|
|
28
|
+
/** Treat a lock older than this as abandoned. */
|
|
29
|
+
staleMs?: number;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Acquire the write lock for one index root.
|
|
33
|
+
*
|
|
34
|
+
* @param indexRoot - directory holding the index the caller is about to write.
|
|
35
|
+
* @param options - timeout and staleness overrides, for tests.
|
|
36
|
+
* @returns a release function; calling it more than once is safe.
|
|
37
|
+
* @throws when the lock cannot be taken before the timeout, so the caller can skip the run instead
|
|
38
|
+
* of writing concurrently.
|
|
39
|
+
*/
|
|
40
|
+
export declare function acquireIndexLock(indexRoot: string, options?: IndexLockOptions): Promise<() => void>;
|
|
41
|
+
//# sourceMappingURL=lock.d.ts.map
|
package/lib/lock.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One writer at a time per index root, across processes.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: the same `index.db` is written by two engines (the TypeScript one through
|
|
5
|
+
* `node:sqlite`, the Rust one through bundled rusqlite) and reached by three triggers (the
|
|
6
|
+
* `agent/created` background job, the lazy refresh before a query, and an explicit
|
|
7
|
+
* `sessiongraph_index`). Nothing in SQLite serializes two writers that open the file
|
|
8
|
+
* independently, and the failure mode is not a clean lock conflict — it is a corpus that later
|
|
9
|
+
* reports `database disk image is malformed` and has to be rebuilt from scratch. A lock file turns
|
|
10
|
+
* that into a short wait.
|
|
11
|
+
*
|
|
12
|
+
* The lock is a file created with `wx` (exclusive create): the winner writes its pid and proceeds,
|
|
13
|
+
* the losers poll until it disappears. A crash leaves the file behind, so a lock older than
|
|
14
|
+
* {@link STALE_LOCK_MS} is broken — the alternative is a permanently unusable index that only
|
|
15
|
+
* deleting a file by hand can fix.
|
|
16
|
+
* @module dsh-plugin-sessiongraph-sessions/lock
|
|
17
|
+
*/
|
|
18
|
+
import { rmSync } from 'node:fs';
|
|
19
|
+
import { mkdir, open, rm, stat } from 'node:fs/promises';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
/** File name of the lock inside an index root. */
|
|
22
|
+
export const LOCK_FILE_NAME = 'index.lock';
|
|
23
|
+
/** How long a lock may sit untouched before another writer treats its owner as dead. */
|
|
24
|
+
export const STALE_LOCK_MS = 5 * 60 * 1000;
|
|
25
|
+
/** How long a writer waits for the lock before giving up. */
|
|
26
|
+
export const LOCK_TIMEOUT_MS = 2 * 60 * 1000;
|
|
27
|
+
/** Polling interval while another writer holds the lock. */
|
|
28
|
+
const POLL_MS = 100;
|
|
29
|
+
/**
|
|
30
|
+
* Acquire the write lock for one index root.
|
|
31
|
+
*
|
|
32
|
+
* @param indexRoot - directory holding the index the caller is about to write.
|
|
33
|
+
* @param options - timeout and staleness overrides, for tests.
|
|
34
|
+
* @returns a release function; calling it more than once is safe.
|
|
35
|
+
* @throws when the lock cannot be taken before the timeout, so the caller can skip the run instead
|
|
36
|
+
* of writing concurrently.
|
|
37
|
+
*/
|
|
38
|
+
export async function acquireIndexLock(indexRoot, options = {}) {
|
|
39
|
+
const timeoutMs = options.timeoutMs ?? LOCK_TIMEOUT_MS;
|
|
40
|
+
const staleMs = options.staleMs ?? STALE_LOCK_MS;
|
|
41
|
+
const lockPath = join(indexRoot, LOCK_FILE_NAME);
|
|
42
|
+
// The index root may not exist yet — a first build creates it, and creating the lock is what has
|
|
43
|
+
// to happen first.
|
|
44
|
+
await mkdir(indexRoot, { recursive: true });
|
|
45
|
+
const deadline = Date.now() + timeoutMs;
|
|
46
|
+
for (;;) {
|
|
47
|
+
try {
|
|
48
|
+
const handle = await open(lockPath, 'wx');
|
|
49
|
+
try {
|
|
50
|
+
await handle.writeFile(`${process.pid}\n`, 'utf8');
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
await handle.close();
|
|
54
|
+
}
|
|
55
|
+
let released = false;
|
|
56
|
+
return () => {
|
|
57
|
+
if (released)
|
|
58
|
+
return;
|
|
59
|
+
released = true;
|
|
60
|
+
// Synchronous on purpose: a disposer returning a promise can be dropped by a `finally` on
|
|
61
|
+
// an async path that is already unwinding, and a leaked lock blocks every later writer
|
|
62
|
+
// until the staleness window reclaims it.
|
|
63
|
+
try {
|
|
64
|
+
rmSync(lockPath, { force: true });
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// A lock we cannot remove is bad, but throwing from a release would mask the run's own
|
|
68
|
+
// error. The staleness window is the backstop.
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
if (error.code !== 'EEXIST')
|
|
74
|
+
throw error;
|
|
75
|
+
await breakStaleLock(lockPath, staleMs);
|
|
76
|
+
if (Date.now() > deadline) {
|
|
77
|
+
throw new Error(`sessiongraph: timed out after ${timeoutMs}ms waiting for the index write lock at "${lockPath}"`);
|
|
78
|
+
}
|
|
79
|
+
await sleep(POLL_MS);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Remove the lock if its owner is presumed dead.
|
|
85
|
+
* @param lockPath - the lock file.
|
|
86
|
+
* @param staleMs - age past which the lock counts as abandoned.
|
|
87
|
+
*/
|
|
88
|
+
async function breakStaleLock(lockPath, staleMs) {
|
|
89
|
+
try {
|
|
90
|
+
const info = await stat(lockPath);
|
|
91
|
+
if (Date.now() - info.mtimeMs > staleMs)
|
|
92
|
+
await rm(lockPath, { force: true });
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// The holder released it between our failed create and this stat; the next iteration wins.
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Wait without keeping the process alive.
|
|
100
|
+
* @param ms - milliseconds to sleep.
|
|
101
|
+
*/
|
|
102
|
+
function sleep(ms) {
|
|
103
|
+
return new Promise(resolve => {
|
|
104
|
+
setTimeout(resolve, ms).unref?.();
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
//# sourceMappingURL=lock.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-sessiongraph-sessions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
4
4
|
"description": "Session-log indexer for DeepSeek Harness — scans ~/.dsh/sessions, decompresses the multi-frame zstd JSONL logs and writes the sessiongraph SQLite index the store reads",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
@@ -49,8 +49,8 @@
|
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
51
|
"@deepseek-ai/schemastery": "^3.18.1",
|
|
52
|
-
"dsh-plugin-sessiongraph-service": "^0.1.
|
|
53
|
-
"dsh-plugin-sessiongraph-native": "^0.1.
|
|
52
|
+
"dsh-plugin-sessiongraph-service": "^0.1.11",
|
|
53
|
+
"dsh-plugin-sessiongraph-native": "^0.1.11"
|
|
54
54
|
},
|
|
55
55
|
"peerDependencies": {
|
|
56
56
|
"@deepseek-ai/cordis": ">=4.0.1",
|