dsh-plugin-sessiongraph-sessions 0.1.8 → 0.1.10
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 +20 -6
- package/lib/index.js +37 -8
- package/lib/lock.d.ts +41 -0
- package/lib/lock.js +107 -0
- package/package.json +3 -3
package/lib/index.d.ts
CHANGED
|
@@ -14,10 +14,11 @@
|
|
|
14
14
|
* rebuilds every log into a temporary file that replaces the old index atomically. A single bad log
|
|
15
15
|
* is reported in `failed` and never aborts the run.
|
|
16
16
|
*
|
|
17
|
-
* Every session creation also
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* the
|
|
17
|
+
* Every session creation also does indexing work in the background (`agent/created`, `autoIndex`):
|
|
18
|
+
* an existing index is refreshed incrementally, and a missing one is built from scratch (`autoBuild`,
|
|
19
|
+
* default true) so a fresh install becomes searchable without the user running anything. Both runs
|
|
20
|
+
* go through the host's job registry and never block session creation. `autoBuild: false` restores
|
|
21
|
+
* the stricter behaviour where nothing is indexed until the caller asks for it.
|
|
21
22
|
*
|
|
22
23
|
* Sessions the host still holds in memory (`ctx.sessionQuery`) are indexed as well, in a second pass
|
|
23
24
|
* after the file-path transaction commits: a conversation that is still running has no log on disk
|
|
@@ -98,10 +99,23 @@ export interface Config {
|
|
|
98
99
|
/** Logs larger than this are skipped and reported in `failed` (default 67108864). */
|
|
99
100
|
maxFileBytes?: number;
|
|
100
101
|
/**
|
|
101
|
-
*
|
|
102
|
-
*
|
|
102
|
+
* Do indexing work in the background whenever a session is created (default true). With no index
|
|
103
|
+
* yet this builds the first one; with one already present it refreshes incrementally.
|
|
103
104
|
*/
|
|
104
105
|
autoIndex?: boolean;
|
|
106
|
+
/**
|
|
107
|
+
* Build the very first index automatically in the background when a session starts (default
|
|
108
|
+
* true). The first build reads every log on the machine and can take seconds to minutes, so it
|
|
109
|
+
* runs as a host background job and never blocks session creation. Set false to require an
|
|
110
|
+
* explicit `sessiongraph_index` / `/sessiongraph index` before anything is searchable.
|
|
111
|
+
*/
|
|
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;
|
|
105
119
|
/**
|
|
106
120
|
* Hand an explicit index run to the host's background-job registry once the session store holds at
|
|
107
121
|
* least this many logs (default 1000). Below it the run is synchronous, because a small index is
|
package/lib/index.js
CHANGED
|
@@ -14,10 +14,11 @@
|
|
|
14
14
|
* rebuilds every log into a temporary file that replaces the old index atomically. A single bad log
|
|
15
15
|
* is reported in `failed` and never aborts the run.
|
|
16
16
|
*
|
|
17
|
-
* Every session creation also
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* the
|
|
17
|
+
* Every session creation also does indexing work in the background (`agent/created`, `autoIndex`):
|
|
18
|
+
* an existing index is refreshed incrementally, and a missing one is built from scratch (`autoBuild`,
|
|
19
|
+
* default true) so a fresh install becomes searchable without the user running anything. Both runs
|
|
20
|
+
* go through the host's job registry and never block session creation. `autoBuild: false` restores
|
|
21
|
+
* the stricter behaviour where nothing is indexed until the caller asks for it.
|
|
21
22
|
*
|
|
22
23
|
* Sessions the host still holds in memory (`ctx.sessionQuery`) are indexed as well, in a second pass
|
|
23
24
|
* after the file-path transaction commits: a conversation that is still running has no log on disk
|
|
@@ -39,6 +40,7 @@ import { basename, join } from 'node:path';
|
|
|
39
40
|
import z from '@deepseek-ai/schemastery';
|
|
40
41
|
import { loadNative } from 'dsh-plugin-sessiongraph-native';
|
|
41
42
|
import { INDEX_RELATIVE_PATH, SessiongraphIndexerId } from 'dsh-plugin-sessiongraph-service';
|
|
43
|
+
import { acquireIndexLock } from "./lock.js";
|
|
42
44
|
import { collectLiveSessions, LIVE_SOURCE_PREFIX } from "./live.js";
|
|
43
45
|
import { parseSessionLog } from "./parse.js";
|
|
44
46
|
import { scanSessions } from "./scan.js";
|
|
@@ -88,6 +90,8 @@ export const Config = z.object({
|
|
|
88
90
|
toolCallChars: z.number().default(DEFAULT_TOOL_CALL_CHARS),
|
|
89
91
|
maxFileBytes: z.number().default(DEFAULT_MAX_FILE_BYTES),
|
|
90
92
|
autoIndex: z.boolean().default(true),
|
|
93
|
+
autoBuild: z.boolean().default(true),
|
|
94
|
+
indexLockStaleMs: z.number().default(300_000),
|
|
91
95
|
backgroundIndexThreshold: z.number().default(DEFAULT_BACKGROUND_INDEX_THRESHOLD),
|
|
92
96
|
maxLiveSessions: z.number().default(DEFAULT_MAX_LIVE_SESSIONS),
|
|
93
97
|
engine: z.union([z.const('auto'), z.const('native'), z.const('typescript')]).default(DEFAULT_ENGINE),
|
|
@@ -182,9 +186,11 @@ function scheduleAutoIndex(ctx, config, autoIndexed, payload) {
|
|
|
182
186
|
// Once per root per process: a burst of session creations must not queue a burst of index runs.
|
|
183
187
|
if (autoIndexed.has(indexRoot))
|
|
184
188
|
return;
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
|
|
189
|
+
// A first build reads every log on the machine and can take a while, so it stays a background
|
|
190
|
+
// job like the incremental refresh. `autoBuild: false` restores the stricter behaviour where
|
|
191
|
+
// nothing becomes searchable until someone asks for it.
|
|
192
|
+
const existing = hasExistingIndex(indexRoot);
|
|
193
|
+
if (!existing && !config.autoBuild)
|
|
188
194
|
return;
|
|
189
195
|
// `ctx.get` rather than an `inject` entry: background jobs are a host composition choice, and
|
|
190
196
|
// requiring the service would hide this whole indexer from a profile that runs none.
|
|
@@ -195,7 +201,9 @@ function scheduleAutoIndex(ctx, config, autoIndexed, payload) {
|
|
|
195
201
|
// The registry keys a job by its owning session id, not the agent object.
|
|
196
202
|
owner: agent.id,
|
|
197
203
|
kind: AUTO_INDEX_JOB_KIND,
|
|
198
|
-
label:
|
|
204
|
+
label: existing
|
|
205
|
+
? `sessiongraph refresh for ${workspace}`
|
|
206
|
+
: `sessiongraph first build for ${workspace}`,
|
|
199
207
|
run: handle => runIndexJob(indexRoot, {}, config, handle),
|
|
200
208
|
});
|
|
201
209
|
// Marked only after the registry accepted the job, so a refusal leaves the root eligible for
|
|
@@ -535,6 +543,27 @@ async function runLivePass(ctx, indexPath, config, inScope, provenance) {
|
|
|
535
543
|
* @returns what the run did.
|
|
536
544
|
*/
|
|
537
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) {
|
|
538
567
|
const startedAt = Date.now();
|
|
539
568
|
const indexPath = join(indexRoot, INDEX_RELATIVE_PATH);
|
|
540
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.10",
|
|
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.10",
|
|
53
|
+
"dsh-plugin-sessiongraph-native": "^0.1.10"
|
|
54
54
|
},
|
|
55
55
|
"peerDependencies": {
|
|
56
56
|
"@deepseek-ai/cordis": ">=4.0.1",
|