dsh-plugin-dbgraph-tool 0.0.0-stage → 0.1.0

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.js ADDED
@@ -0,0 +1,586 @@
1
+ /**
2
+ * Model-facing `dbgraph` tool family over `ctx.dbgraph`.
3
+ *
4
+ * Two tools, deliberately separate. `dbgraph` is the read side, with the eight operations the seam
5
+ * answers directly; `dbgraph_index` builds or refreshes the on-disk index on explicit request. They
6
+ * are separate because `defineTool`'s `timeoutMs` is fixed per registration, not per call: a query
7
+ * is milliseconds and an index build is minutes, so one budget cannot fit both.
8
+ *
9
+ * The plugin also owns the system-prompt section that carries the schema digest. That section's
10
+ * `text` is a function, not a string: the host re-evaluates it on every assembly, which is what lets
11
+ * the model learn which tables exist for free instead of spending a discovery round-trip. The
12
+ * function reads a small JSON sidecar synchronously — opening SQLite, or reaching a server, from an
13
+ * assembly would tax every request.
14
+ *
15
+ * The tools own every default the seam refuses to guess — result limits, prompt budgets, the
16
+ * background-index threshold — so the seam's requests stay fully specified and a deployment can
17
+ * retune the model's answer size without touching a store. The `/dbgraph` command attaches through
18
+ * conditional injection instead, so a host without a command registry still gets the tool family.
19
+ *
20
+ * Namespace plugin (named exports, no default export).
21
+ * @module dsh-plugin-dbgraph-tool
22
+ */
23
+ import { defineTool } from '@deepseek-ai/dsh-tools';
24
+ import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout';
25
+ import { DEFAULT_INDEX_STALE_HOURS, DbgraphError } from 'dsh-plugin-dbgraph-service';
26
+ import { registerCommands } from "./command.js";
27
+ import { DBGRAPH_PROMPT_TEXT, dbgraphPromptHint, DEFAULT_MAX_PROMPT_CHARS, DEFAULT_MAX_PROMPT_TABLES, readStatusFile } from "./prompt.js";
28
+ import { renderDbgraph, renderDbgraphIndex } from "./render.js";
29
+ import { DBGRAPH_INDEX_OUTPUT_SCHEMA, DBGRAPH_INDEX_PARAMETERS, DBGRAPH_OUTPUT_SCHEMA, DBGRAPH_PARAMETERS, } from "./schema.js";
30
+ export { renderDbgraph, renderDbgraphIndex } from "./render.js";
31
+ export { DBGRAPH_INDEX_MODES, DBGRAPH_INDEX_OUTPUT_SCHEMA, DBGRAPH_INDEX_PARAMETERS, DBGRAPH_INDEX_WRITERS, DBGRAPH_OPERATIONS, DBGRAPH_OUTPUT_SCHEMA, DBGRAPH_PARAMETERS, } from "./schema.js";
32
+ export { COMMAND_DEFINITION_ID, COMMAND_DESCRIPTION, COMMAND_INPUT_HINT, COMMAND_NAME, COMMAND_USAGE, registerCommands, } from "./command.js";
33
+ export { DBGRAPH_PROMPT_TEXT, DEFAULT_MAX_PROMPT_CHARS, DEFAULT_MAX_PROMPT_TABLES, dbgraphPromptHint, dbgraphRoot, formatAge, readIndexDirectories, readStatusFile, renderStatusDigest, } from "./prompt.js";
34
+ /** Cordis plugin name for loader diagnostics. */
35
+ export const name = 'tool-dbgraph';
36
+ /** Services required by this plugin. */
37
+ export const inject = ['tools', 'dbgraph', 'systemPrompt'];
38
+ /** Default tool-call timeout budget (ms) for the query-side `dbgraph` tool. */
39
+ export const DEFAULT_DBGRAPH_TOOL_TIMEOUT_MS = 30_000;
40
+ /** Default timeout budget (ms) for `dbgraph_index`. Building an index is a different order of work than a query. */
41
+ export const DEFAULT_DBGRAPH_INDEX_TIMEOUT_MS = 300_000;
42
+ /** Objects per connection past which an index run moves to a background job under `backgroundIndex: 'auto'`. */
43
+ export const DEFAULT_BACKGROUND_INDEX_THRESHOLD = 500;
44
+ /** Rows a `find` or `sql` call returns when the model names no `limit`. */
45
+ export const DEFAULT_TOOL_LIMIT = 50;
46
+ /** Largest `limit` honored, whatever the model asks for. */
47
+ export const DEFAULT_MAX_TOOL_LIMIT = 200;
48
+ /**
49
+ * Apply every default and reject a value that would fail at call time instead.
50
+ * @param config - the deployment's configuration.
51
+ * @returns the resolved configuration.
52
+ */
53
+ export function resolveConfig(config = {}) {
54
+ const resolved = {
55
+ defaultLimit: config.defaultLimit ?? DEFAULT_TOOL_LIMIT,
56
+ maxLimit: config.maxLimit ?? DEFAULT_MAX_TOOL_LIMIT,
57
+ maxPromptTables: config.maxPromptTables ?? DEFAULT_MAX_PROMPT_TABLES,
58
+ maxPromptChars: config.maxPromptChars ?? DEFAULT_MAX_PROMPT_CHARS,
59
+ indexStaleHours: config.indexStaleHours ?? DEFAULT_INDEX_STALE_HOURS,
60
+ prompt: config.prompt ?? true,
61
+ backgroundIndex: config.backgroundIndex ?? 'auto',
62
+ backgroundIndexThreshold: config.backgroundIndexThreshold ?? DEFAULT_BACKGROUND_INDEX_THRESHOLD,
63
+ timeoutMs: config.timeoutMs ?? DEFAULT_DBGRAPH_TOOL_TIMEOUT_MS,
64
+ indexTimeoutMs: config.indexTimeoutMs ?? DEFAULT_DBGRAPH_INDEX_TIMEOUT_MS,
65
+ };
66
+ for (const field of [
67
+ 'defaultLimit', 'maxLimit', 'maxPromptTables', 'maxPromptChars', 'indexStaleHours', 'backgroundIndexThreshold',
68
+ ]) {
69
+ assertPositiveInteger(field, resolved[field]);
70
+ }
71
+ assertTimer('timeoutMs', resolved.timeoutMs);
72
+ assertTimer('indexTimeoutMs', resolved.indexTimeoutMs);
73
+ return resolved;
74
+ }
75
+ /**
76
+ * Register the two model-facing tools, their system-prompt section, and the `/dbgraph` command.
77
+ * @param ctx - the plugin context (must inject `tools`, `dbgraph`, `systemPrompt`).
78
+ * @param config - the plugin configuration, or a partial one.
79
+ */
80
+ export function apply(ctx, config = {}) {
81
+ const resolved = resolveConfig(config);
82
+ // A function, not a string: the host re-evaluates it on every assembly, which is what lets the
83
+ // model learn the configured tables for free. It is total by construction — `prompt.ts` turns
84
+ // every filesystem and parse failure into a weaker sentence — so an assembly never fails here.
85
+ if (resolved.prompt) {
86
+ ctx.systemPrompt.section({
87
+ name: 'tool:dbgraph',
88
+ order: 112,
89
+ text: () => [DBGRAPH_PROMPT_TEXT, dbgraphPromptHint({
90
+ maxPromptTables: resolved.maxPromptTables,
91
+ maxPromptChars: resolved.maxPromptChars,
92
+ staleHours: resolved.indexStaleHours,
93
+ // The store owns the index root; the environment variable is only the fallback for a
94
+ // composition that mounts the tool without a store. Deriving it here independently would let
95
+ // the digest describe a directory the index is not in.
96
+ ...ctx.dbgraph.storeRoot === undefined ? {} : { root: ctx.dbgraph.storeRoot },
97
+ })].filter(part => part !== '').join('\n\n'),
98
+ });
99
+ }
100
+ ctx.tools.register(defineTool({
101
+ name: 'dbgraph',
102
+ description: 'Query a pre-built index of the configured databases\' structure, and fetch data in one call. '
103
+ + 'The system prompt already lists the indexed tables; use dbgraph find with a business word, '
104
+ + 'column names, and a filter to resolve offline and execute ONE parameterized read. Use schema, '
105
+ + 'objects, columns, impact, and refs for structure, and sql only when no resolution fits. '
106
+ + 'Sensitive columns are masked in results.',
107
+ parameters: DBGRAPH_PARAMETERS,
108
+ output: {
109
+ schema: DBGRAPH_OUTPUT_SCHEMA,
110
+ render: (_args, value) => [{ type: 'text', text: renderDbgraph(value) }],
111
+ },
112
+ timeoutMs: resolved.timeoutMs,
113
+ async execute(args, exec) {
114
+ return run(ctx, resolved, args, exec);
115
+ },
116
+ presentCall: args => ({
117
+ card: 'generic',
118
+ title: callTitle(args),
119
+ kind: 'search',
120
+ rawInput: args,
121
+ }),
122
+ }));
123
+ ctx.tools.register(defineTool({
124
+ name: 'dbgraph_index',
125
+ description: 'Build or refresh the dbgraph index for one connection, or every configured connection. '
126
+ + 'Without a connection every configured connection is indexed and every report is returned. '
127
+ + 'Indexing reads the database catalog and can take minutes, so this runs on its own timeout '
128
+ + 'budget, separate from dbgraph\'s query operations. A database large enough to outlive the call '
129
+ + 'is handed to a background job and answered immediately with that job\'s id; read its report with job_output.',
130
+ parameters: DBGRAPH_INDEX_PARAMETERS,
131
+ output: {
132
+ schema: DBGRAPH_INDEX_OUTPUT_SCHEMA,
133
+ render: (_args, value) => [{ type: 'text', text: renderDbgraphIndex(value) }],
134
+ },
135
+ timeoutMs: resolved.indexTimeoutMs,
136
+ async execute(args, exec) {
137
+ return runIndex(ctx, resolved, args, exec);
138
+ },
139
+ presentCall: args => ({
140
+ card: 'generic',
141
+ title: args.connection === undefined ? 'dbgraph_index' : `dbgraph_index ${args.connection}`,
142
+ kind: 'execute',
143
+ rawInput: args,
144
+ }),
145
+ }));
146
+ registerCommands(ctx);
147
+ }
148
+ /**
149
+ * The one-line label a pending call shows.
150
+ * @param args - the validated tool arguments.
151
+ * @returns the card title naming the operation and whichever subject the operation takes.
152
+ */
153
+ export function callTitle(args) {
154
+ const subject = args.entity ?? args.object ?? args.query ?? args.connection;
155
+ return subject === undefined ? `dbgraph ${args.operation}` : `dbgraph ${args.operation} ${subject}`;
156
+ }
157
+ /**
158
+ * The seam guarantees JSON-safe row values (its dialects serialize Dates and Buffers before
159
+ * returning them); this one cast records that guarantee instead of re-checking every cell.
160
+ * @param rows - the seam's rows.
161
+ * @returns the same rows, typed as the lossless JSON the output schema declares.
162
+ */
163
+ function jsonRows(rows) {
164
+ return rows;
165
+ }
166
+ /** Project one index state onto the model-facing field names. */
167
+ function indexStateValue(state) {
168
+ return {
169
+ ...state.l1IndexedAt === undefined ? {} : { l1_indexed_at: state.l1IndexedAt },
170
+ ...state.l2IndexedAt === undefined ? {} : { l2_indexed_at: state.l2IndexedAt },
171
+ objects: state.objects,
172
+ columns: state.columns,
173
+ stale: state.stale,
174
+ ...state.writer === undefined ? {} : { writer: state.writer },
175
+ };
176
+ }
177
+ /** Project one connection onto the model-facing field names. */
178
+ function connectionValue(connection) {
179
+ return {
180
+ id: connection.id,
181
+ engine: connection.engine,
182
+ ...connection.label === undefined ? {} : { label: connection.label },
183
+ ...connection.host === undefined ? {} : { host: connection.host },
184
+ ...connection.database === undefined ? {} : { database: connection.database },
185
+ credential_configured: connection.credentialConfigured,
186
+ index: indexStateValue(connection.index),
187
+ };
188
+ }
189
+ /** Project one object onto the model-facing field names. */
190
+ function objectValue(object) {
191
+ return {
192
+ object: object.object,
193
+ kind: object.kind,
194
+ ...object.comment === undefined ? {} : { comment: object.comment },
195
+ column_count: object.columnCount,
196
+ ...object.rowEstimate === undefined ? {} : { row_estimate: object.rowEstimate },
197
+ };
198
+ }
199
+ /** Project one column onto the model-facing field names. */
200
+ function columnValue(column) {
201
+ return {
202
+ name: column.name,
203
+ type: column.type,
204
+ nullable: column.nullable,
205
+ ...column.defaultValue === undefined ? {} : { default_value: column.defaultValue },
206
+ ...column.comment === undefined ? {} : { comment: column.comment },
207
+ is_primary_key: column.isPrimaryKey,
208
+ is_unique: column.isUnique,
209
+ ...column.enumValues === undefined ? {} : { enum_values: [...column.enumValues] },
210
+ };
211
+ }
212
+ /** Project one edge onto the model-facing field names. */
213
+ function relationValue(entry) {
214
+ return {
215
+ kind: entry.kind,
216
+ object: entry.object,
217
+ ...entry.column === undefined ? {} : { column: entry.column },
218
+ ...entry.refColumn === undefined ? {} : { ref_column: entry.refColumn },
219
+ ...entry.name === undefined ? {} : { name: entry.name },
220
+ };
221
+ }
222
+ /** Project one matched column onto the model-facing field names. */
223
+ function matchedColumnValue(column) {
224
+ return {
225
+ column: column.column,
226
+ ...column.comment === undefined ? {} : { comment: column.comment },
227
+ matched_by: column.matchedBy,
228
+ ...column.term === undefined ? {} : { term: column.term },
229
+ score: column.score,
230
+ };
231
+ }
232
+ /** Project one resolver candidate onto the model-facing field names. */
233
+ function candidateValue(candidate) {
234
+ return {
235
+ object: candidate.object,
236
+ kind: candidate.kind,
237
+ ...candidate.comment === undefined ? {} : { comment: candidate.comment },
238
+ score: candidate.score,
239
+ columns: candidate.columns.map(matchedColumnValue),
240
+ };
241
+ }
242
+ /** Project every finished index run onto the model-facing field names. */
243
+ export function completedIndexValue(reports) {
244
+ return {
245
+ status: 'completed',
246
+ reports: reports.map(report => ({
247
+ connection: report.connection,
248
+ level: report.level,
249
+ objects: report.objects,
250
+ columns: report.columns,
251
+ edges: report.edges,
252
+ terms: report.terms,
253
+ writer: report.writer,
254
+ elapsed_ms: report.elapsedMs,
255
+ ...report.warnings.length === 0 ? {} : { warnings: [...report.warnings] },
256
+ })),
257
+ };
258
+ }
259
+ /** Read a required string argument, rejecting an absent or blank value. */
260
+ function required(args, field) {
261
+ const value = args[field];
262
+ if (value === undefined || value.trim() === '') {
263
+ throw new DbgraphError(`the dbgraph "${args.operation}" operation requires a non-empty "${field}"`, 'DBGRAPH_INVALID_REQUEST');
264
+ }
265
+ return value;
266
+ }
267
+ /** Clamp a model-supplied bound into the configured range. */
268
+ function bounded(value, fallback, max) {
269
+ if (value === undefined || !Number.isFinite(value))
270
+ return fallback;
271
+ return Math.min(max, Math.max(1, Math.trunc(value)));
272
+ }
273
+ /**
274
+ * The validated `where` object of a `find` request, or `undefined` when none was supplied.
275
+ * @param value - the model's JSON value.
276
+ * @returns the object, typed for the seam's request.
277
+ */
278
+ function predicateValues(value) {
279
+ if (value === undefined)
280
+ return undefined;
281
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
282
+ throw new DbgraphError('the dbgraph "find" operation requires "where" to be a JSON object mapping a column to a value', 'DBGRAPH_INVALID_REQUEST');
283
+ }
284
+ return value;
285
+ }
286
+ /**
287
+ * Answer one query-side tool call.
288
+ * @param ctx - the plugin context.
289
+ * @param config - the resolved plugin configuration.
290
+ * @param args - the validated tool arguments.
291
+ * @param exec - the tool-execution context.
292
+ * @returns the canonical value for the requested operation.
293
+ */
294
+ async function run(ctx, config, args, exec) {
295
+ const signal = exec.signal;
296
+ const limit = bounded(args.limit, config.defaultLimit, config.maxLimit);
297
+ switch (args.operation) {
298
+ case 'connections': {
299
+ const connections = await ctx.dbgraph.listConnections();
300
+ return { operation: 'connections', connections: connections.map(connectionValue) };
301
+ }
302
+ case 'schema': {
303
+ const connection = required(args, 'connection');
304
+ const result = await ctx.dbgraph.schema({ connection }, signal);
305
+ return {
306
+ operation: 'schema',
307
+ connection: result.connection,
308
+ engine: result.engine,
309
+ database: result.database,
310
+ objects: result.objects.map(objectValue),
311
+ truncated: result.truncated,
312
+ comment_coverage: result.commentCoverage,
313
+ index: indexStateValue(result.index),
314
+ };
315
+ }
316
+ case 'objects': {
317
+ const connection = required(args, 'connection');
318
+ const query = args.query?.trim();
319
+ const result = await ctx.dbgraph.objects({
320
+ connection,
321
+ ...query === undefined || query === '' ? {} : { query },
322
+ limit,
323
+ }, signal);
324
+ return {
325
+ operation: 'objects',
326
+ connection,
327
+ objects: result.objects.map(objectValue),
328
+ truncated: result.truncated,
329
+ };
330
+ }
331
+ case 'columns': {
332
+ const connection = required(args, 'connection');
333
+ const result = await ctx.dbgraph.columns({ connection, object: required(args, 'object') }, signal);
334
+ return {
335
+ operation: 'columns',
336
+ connection,
337
+ object: result.object,
338
+ kind: result.kind,
339
+ ...result.comment === undefined ? {} : { comment: result.comment },
340
+ columns: result.columns.map(columnValue),
341
+ truncated: result.truncated,
342
+ };
343
+ }
344
+ case 'find': {
345
+ const connection = required(args, 'connection');
346
+ const where = predicateValues(args.where);
347
+ const entity = args.entity?.trim();
348
+ const object = args.object?.trim();
349
+ const request = {
350
+ connection,
351
+ ...entity === undefined || entity === '' ? {} : { entity },
352
+ ...args.fields === undefined || args.fields.length === 0 ? {} : { fields: args.fields },
353
+ ...where === undefined ? {} : { where },
354
+ ...object === undefined || object === '' ? {} : { object },
355
+ limit,
356
+ };
357
+ const result = await ctx.dbgraph.find(request, signal);
358
+ return {
359
+ operation: 'find',
360
+ connection,
361
+ status: result.status,
362
+ reason: result.reason,
363
+ confidence: result.confidence,
364
+ ...result.resolved === undefined ? {} : { resolved: candidateValue(result.resolved) },
365
+ ...result.candidates === undefined ? {} : { candidates: result.candidates.map(candidateValue) },
366
+ ...result.sql === undefined ? {} : { sql: result.sql },
367
+ ...result.rows === undefined ? {} : { rows: jsonRows(result.rows) },
368
+ ...result.rowCount === undefined ? {} : { row_count: result.rowCount },
369
+ ...result.truncated === undefined ? {} : { truncated: result.truncated },
370
+ ...result.redacted === undefined ? {} : { redacted: [...result.redacted] },
371
+ elapsed_ms: result.elapsedMs,
372
+ };
373
+ }
374
+ case 'sql': {
375
+ const connection = required(args, 'connection');
376
+ const request = { connection, query: required(args, 'query'), limit };
377
+ const result = await ctx.dbgraph.sql(request, signal);
378
+ return {
379
+ operation: 'sql',
380
+ connection,
381
+ columns: [...result.columns],
382
+ rows: jsonRows(result.rows),
383
+ row_count: result.rowCount,
384
+ truncated: result.truncated,
385
+ redacted: [...result.redacted],
386
+ ...result.affectedRows === undefined ? {} : { affected_rows: result.affectedRows },
387
+ elapsed_ms: result.elapsedMs,
388
+ ...result.warnings === undefined ? {} : { warnings: [...result.warnings] },
389
+ };
390
+ }
391
+ case 'impact':
392
+ case 'refs': {
393
+ const connection = required(args, 'connection');
394
+ const request = { connection, object: required(args, 'object') };
395
+ if (args.operation === 'refs') {
396
+ const result = await ctx.dbgraph.refs(request, signal);
397
+ return {
398
+ operation: 'refs',
399
+ connection,
400
+ object: result.object,
401
+ entries: result.entries.map(relationValue),
402
+ truncated: result.truncated,
403
+ };
404
+ }
405
+ const result = await ctx.dbgraph.impact(request, signal);
406
+ return {
407
+ operation: 'impact',
408
+ connection,
409
+ object: result.object,
410
+ entries: result.entries.map(relationValue),
411
+ truncated: result.truncated,
412
+ };
413
+ }
414
+ /* v8 ignore next -- exhaustive over the parameter schema's closed operation enum; unreachable. */
415
+ default:
416
+ throw new DbgraphError(`unsupported operation "${String(args.operation)}"`, 'DBGRAPH_INVALID_REQUEST');
417
+ }
418
+ }
419
+ /**
420
+ * Build or refresh one index. A run large enough to outlive the call moves to the host's
421
+ * background-job registry, where it keeps its own timeout-free lifetime and reports through that job
422
+ * instead of this value. Everything that cannot start a job — no registry in the composition, no
423
+ * owning agent, a database still small, a refusal from the registry — falls through to the
424
+ * synchronous run, so the answer never depends on a host feature being present.
425
+ * @param ctx - the plugin context.
426
+ * @param config - the resolved plugin configuration.
427
+ * @param args - the index tool's validated arguments.
428
+ * @param exec - the tool-execution context.
429
+ * @returns the index tool's canonical value: a started job, or every finished run's report.
430
+ */
431
+ async function runIndex(ctx, config, args, exec) {
432
+ const connection = args.connection?.trim();
433
+ const mode = args.mode ?? 'full';
434
+ const request = {
435
+ mode,
436
+ ...connection === undefined || connection === '' ? {} : { connection },
437
+ ...args.writer === undefined ? {} : { writer: args.writer },
438
+ };
439
+ const target = request.connection;
440
+ const jobId = startBackgroundIndex(ctx, config, request, exec);
441
+ if (jobId !== undefined) {
442
+ return { status: 'started', job_id: jobId, ...target === undefined ? {} : { connection: target }, mode };
443
+ }
444
+ // A named connection answers with its own report; an unnamed run must name every connection it
445
+ // indexed, or the model reads the unnamed ones as never indexed and asks again.
446
+ const reports = target === undefined
447
+ ? await ctx.dbgraph.indexMany(request, exec.signal)
448
+ : [await ctx.dbgraph.index(request, exec.signal)];
449
+ return completedIndexValue(reports);
450
+ }
451
+ /**
452
+ * Hand one index run to the host's background-job registry, when the host offers one and the run
453
+ * looks large enough to outlive the call.
454
+ *
455
+ * Every reason not to start a job falls back to the synchronous run rather than failing the call: a
456
+ * composition without a job controller is a normal deployment, not an error, and the tool's contract
457
+ * has always been "index it now".
458
+ * @param ctx - the plugin context.
459
+ * @param config - the resolved plugin configuration.
460
+ * @param request - the seam request to run.
461
+ * @param exec - the tool-execution context; its agent owns the job.
462
+ * @returns the started job's id, or `undefined` when this call must index synchronously.
463
+ */
464
+ function startBackgroundIndex(ctx, config, request, exec) {
465
+ const owner = exec.agent;
466
+ if (owner === undefined)
467
+ return undefined;
468
+ // `ctx.get` rather than an `inject` entry: `jobs` is a host composition choice, and requiring it in
469
+ // `inject` would hide this entire tool family from a profile that runs no background jobs.
470
+ const jobs = ctx.get('jobs');
471
+ if (jobs === undefined)
472
+ return undefined;
473
+ if (!backgroundRunWanted(config, request, ctx.dbgraph.storeRoot))
474
+ return undefined;
475
+ try {
476
+ return jobs.start({
477
+ // The registry keys a job by its owning *session id*, not the agent object: it resolves the
478
+ // live agent from that id and hangs the job's teardown (and therefore its cancellation) off it.
479
+ owner: owner.id,
480
+ kind: 'dbgraph-index',
481
+ label: `dbgraph index ${request.connection ?? 'all connections'}`,
482
+ run: handle => runIndexJob(ctx, request, handle),
483
+ });
484
+ }
485
+ catch {
486
+ // No controller serves this agent, or the owner already holds its maximum number of jobs: both
487
+ // mean "do the work here instead", never "fail the model's call".
488
+ return undefined;
489
+ }
490
+ }
491
+ /**
492
+ * Whether this run is large enough — or configured — to belong in a background job.
493
+ *
494
+ * `'auto'` reads the previous run's object count from the digest sidecar, which is the only size
495
+ * signal available before a run starts and exactly the case a job exists for. A connection with no
496
+ * summary has never been indexed by this plugin, so there is no evidence it is large; it runs
497
+ * synchronously and writes the summary the next call decides from.
498
+ * @param config - the resolved plugin configuration.
499
+ * @param request - the seam request about to run.
500
+ * @returns whether the caller should try to start a job.
501
+ */
502
+ function backgroundRunWanted(config, request, root) {
503
+ if (config.backgroundIndex === false)
504
+ return false;
505
+ if (config.backgroundIndex === true)
506
+ return true;
507
+ const status = root === undefined ? readStatusFile() : readStatusFile(root);
508
+ if (status === undefined)
509
+ return false;
510
+ const target = request.connection;
511
+ const entries = target === undefined
512
+ ? Object.values(status.connections)
513
+ : (() => {
514
+ const entry = status.connections[target];
515
+ return entry === undefined ? [] : [entry];
516
+ })();
517
+ return entries.some(entry => entry.objects >= config.backgroundIndexThreshold);
518
+ }
519
+ /**
520
+ * The producer body of one background indexing job.
521
+ *
522
+ * Both outcomes are reported twice on purpose: the appended line is the live log while the job is
523
+ * recent, and the returned outcome carries the terminal status plus the report a later `job_output`
524
+ * delivers even after the log ring is trimmed. Neither may throw — a rejected `done` breaks the
525
+ * registry's producer contract, which it can only log, leaving the job to look stuck.
526
+ *
527
+ * The run goes through `ctx.dbgraph.index`, the same in-process path a synchronous call uses, whose
528
+ * store writes the digest sidecar itself after renaming the index into place; there is nothing extra
529
+ * for `recordIndex` to fold in.
530
+ * @param ctx - the plugin context.
531
+ * @param request - the seam request to run.
532
+ * @param handle - the job's producer face.
533
+ * @returns the job's cancellation hook and settlement promise.
534
+ */
535
+ function runIndexJob(ctx, request, handle) {
536
+ const controller = new AbortController();
537
+ const target = request.connection ?? 'all connections';
538
+ // Called while the registry is still building the job record, so it seeds the registered
539
+ // projection instead of racing an update against a job that does not exist yet.
540
+ handle.updateProgress(`indexing ${target}`);
541
+ const done = (async () => {
542
+ try {
543
+ // `indexMany` with one connection answers exactly one report, so the producer has a single
544
+ // path and the job's final result always carries the complete set.
545
+ const reports = await ctx.dbgraph.indexMany(request, controller.signal);
546
+ for (const report of reports)
547
+ handle.append(`${indexSummaryLine(report)}\n`);
548
+ return { status: 'completed', result: renderDbgraphIndex(completedIndexValue(reports)) };
549
+ }
550
+ catch (error) {
551
+ const message = failureMessage(error);
552
+ handle.append(`dbgraph index failed for ${target}: ${message}\n`);
553
+ return { status: 'failed', detail: message };
554
+ }
555
+ })();
556
+ return { cancel: () => controller.abort(), done };
557
+ }
558
+ /**
559
+ * One line of the counts a finished run wrote, for a job's live log.
560
+ * @param report - the store's report.
561
+ * @returns the summary line.
562
+ */
563
+ function indexSummaryLine(report) {
564
+ return `Indexed ${report.connection}: ${report.objects} 对象 / ${report.columns} 列 / ${report.edges} 关系.`;
565
+ }
566
+ /**
567
+ * The text a failed job reports.
568
+ * @param error - whatever the store threw.
569
+ * @returns the error's own message when it has one, else its string form.
570
+ */
571
+ function failureMessage(error) {
572
+ return error instanceof Error ? error.message : String(error);
573
+ }
574
+ /** Reject a non-positive-integer config value at load, so misconfiguration fails loud. */
575
+ function assertPositiveInteger(field, value) {
576
+ if (!Number.isInteger(value) || value < 1) {
577
+ throw new Error(`tool-dbgraph: ${field} must be a positive integer`);
578
+ }
579
+ }
580
+ /** Reject a timer value Node would clamp instead of scheduling as configured. */
581
+ function assertTimer(field, value) {
582
+ if (!Number.isInteger(value) || value < 1 || value > MAX_TIMER_DELAY_MS) {
583
+ throw new Error(`tool-dbgraph: ${field} must be a positive integer no greater than ${MAX_TIMER_DELAY_MS}`);
584
+ }
585
+ }
586
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Package-owned invariant companion for `dsh-plugin-dbgraph-tool`.
3
+ * @module dsh-plugin-dbgraph-tool/invariant
4
+ */
5
+ import type { Context } from '@deepseek-ai/cordis';
6
+ /** Cordis companion plugin name. */
7
+ export declare const name = "tool-dbgraph-invariant";
8
+ /** Service required before the companion can reserve package ownership. */
9
+ export declare const inject: string[];
10
+ /**
11
+ * Register this package's invariant companion.
12
+ * @param ctx - Cordis context carrying the invariant service.
13
+ * @returns the installed registration's disposer after setup succeeds.
14
+ */
15
+ export declare const apply: (ctx: Context) => Promise<() => void>;
16
+ //# sourceMappingURL=invariant.d.ts.map
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Package-owned invariant companion for `dsh-plugin-dbgraph-tool`.
3
+ * @module dsh-plugin-dbgraph-tool/invariant
4
+ */
5
+ const PACKAGE_NAME = 'dsh-plugin-dbgraph-tool';
6
+ /** Cordis companion plugin name. */
7
+ export const name = 'tool-dbgraph-invariant';
8
+ /** Service required before the companion can reserve package ownership. */
9
+ export const inject = ['invariants'];
10
+ /**
11
+ * No runtime invariant: this stateless adapter contributes two tools, one prompt section, and one
12
+ * command, while index contents remain owned by the store seam it reads and query results remain
13
+ * owned by the dialect seam it composes.
14
+ */
15
+ const install = () => { };
16
+ /**
17
+ * Register this package's invariant companion.
18
+ * @param ctx - Cordis context carrying the invariant service.
19
+ * @returns the installed registration's disposer after setup succeeds.
20
+ */
21
+ export const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
22
+ /* jscpd:ignore-end */
23
+ //# sourceMappingURL=invariant.js.map