byterover-cli 3.5.1 → 3.6.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/.env.production +4 -6
- package/dist/agent/core/interfaces/i-cipher-agent.d.ts +1 -0
- package/dist/agent/infra/agent/cipher-agent.d.ts +1 -0
- package/dist/agent/infra/agent/cipher-agent.js +1 -0
- package/dist/oclif/commands/curate/view.js +5 -25
- package/dist/oclif/commands/dream.d.ts +18 -0
- package/dist/oclif/commands/dream.js +230 -0
- package/dist/oclif/commands/query-log/summary.d.ts +18 -0
- package/dist/oclif/commands/query-log/summary.js +75 -0
- package/dist/oclif/commands/query-log/view.d.ts +23 -0
- package/dist/oclif/commands/query-log/view.js +95 -0
- package/dist/oclif/lib/time-filter.d.ts +10 -0
- package/dist/oclif/lib/time-filter.js +21 -0
- package/dist/server/config/environment.d.ts +10 -3
- package/dist/server/config/environment.js +34 -15
- package/dist/server/constants.d.ts +5 -0
- package/dist/server/constants.js +7 -0
- package/dist/server/core/domain/entities/query-log-entry.d.ts +61 -0
- package/dist/server/core/domain/entities/query-log-entry.js +40 -0
- package/dist/server/core/domain/transport/schemas.d.ts +108 -7
- package/dist/server/core/domain/transport/schemas.js +34 -2
- package/dist/server/core/interfaces/executor/i-query-executor.d.ts +23 -2
- package/dist/server/core/interfaces/i-terminal.d.ts +3 -0
- package/dist/server/core/interfaces/i-terminal.js +1 -0
- package/dist/server/core/interfaces/storage/i-query-log-store.d.ts +23 -0
- package/dist/server/core/interfaces/storage/i-query-log-store.js +2 -0
- package/dist/server/core/interfaces/usecase/i-query-log-summary-use-case.d.ts +44 -0
- package/dist/server/core/interfaces/usecase/i-query-log-summary-use-case.js +1 -0
- package/dist/server/core/interfaces/usecase/i-query-log-use-case.d.ts +13 -0
- package/dist/server/core/interfaces/usecase/i-query-log-use-case.js +3 -0
- package/dist/server/infra/daemon/agent-process.js +79 -9
- package/dist/server/infra/daemon/brv-server.js +74 -5
- package/dist/server/infra/dream/dream-lock-service.d.ts +37 -0
- package/dist/server/infra/dream/dream-lock-service.js +88 -0
- package/dist/server/infra/dream/dream-log-schema.d.ts +966 -0
- package/dist/server/infra/dream/dream-log-schema.js +57 -0
- package/dist/server/infra/dream/dream-log-store.d.ts +55 -0
- package/dist/server/infra/dream/dream-log-store.js +141 -0
- package/dist/server/infra/dream/dream-response-schemas.d.ts +219 -0
- package/dist/server/infra/dream/dream-response-schemas.js +38 -0
- package/dist/server/infra/dream/dream-state-schema.d.ts +67 -0
- package/dist/server/infra/dream/dream-state-schema.js +23 -0
- package/dist/server/infra/dream/dream-state-service.d.ts +38 -0
- package/dist/server/infra/dream/dream-state-service.js +91 -0
- package/dist/server/infra/dream/dream-trigger.d.ts +46 -0
- package/dist/server/infra/dream/dream-trigger.js +65 -0
- package/dist/server/infra/dream/dream-undo.d.ts +38 -0
- package/dist/server/infra/dream/dream-undo.js +293 -0
- package/dist/server/infra/dream/operations/consolidate.d.ts +52 -0
- package/dist/server/infra/dream/operations/consolidate.js +514 -0
- package/dist/server/infra/dream/operations/prune.d.ts +45 -0
- package/dist/server/infra/dream/operations/prune.js +362 -0
- package/dist/server/infra/dream/operations/synthesize.d.ts +37 -0
- package/dist/server/infra/dream/operations/synthesize.js +278 -0
- package/dist/server/infra/dream/parse-dream-response.d.ts +11 -0
- package/dist/server/infra/dream/parse-dream-response.js +35 -0
- package/dist/server/infra/executor/curate-executor.js +10 -0
- package/dist/server/infra/executor/dream-executor.d.ts +97 -0
- package/dist/server/infra/executor/dream-executor.js +431 -0
- package/dist/server/infra/executor/query-executor.d.ts +2 -2
- package/dist/server/infra/executor/query-executor.js +92 -22
- package/dist/server/infra/process/feature-handlers.js +10 -6
- package/dist/server/infra/process/query-log-handler.d.ts +42 -0
- package/dist/server/infra/process/query-log-handler.js +150 -0
- package/dist/server/infra/process/task-router.d.ts +40 -0
- package/dist/server/infra/process/task-router.js +67 -9
- package/dist/server/infra/process/transport-handlers.d.ts +4 -0
- package/dist/server/infra/process/transport-handlers.js +1 -0
- package/dist/server/infra/storage/file-curate-log-store.js +1 -1
- package/dist/server/infra/storage/file-query-log-store.d.ts +81 -0
- package/dist/server/infra/storage/file-query-log-store.js +249 -0
- package/dist/server/infra/transport/handlers/config-handler.js +1 -1
- package/dist/server/infra/usecase/curate-log-use-case.js +7 -3
- package/dist/server/infra/usecase/query-log-summary-narrative-formatter.d.ts +15 -0
- package/dist/server/infra/usecase/query-log-summary-narrative-formatter.js +79 -0
- package/dist/server/infra/usecase/query-log-summary-use-case.d.ts +13 -0
- package/dist/server/infra/usecase/query-log-summary-use-case.js +217 -0
- package/dist/server/infra/usecase/query-log-use-case.d.ts +31 -0
- package/dist/server/infra/usecase/query-log-use-case.js +128 -0
- package/dist/server/utils/log-format-utils.d.ts +5 -0
- package/dist/server/utils/log-format-utils.js +23 -0
- package/dist/shared/transport/events/config-events.d.ts +1 -1
- package/oclif.manifest.json +258 -3
- package/package.json +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { join, relative } from 'node:path';
|
|
2
2
|
import { ABSTRACT_EXTENSION, BRV_DIR, CONTEXT_FILE_EXTENSION, CONTEXT_TREE_DIR } from '../../constants.js';
|
|
3
|
+
import { TIER_DIRECT_SEARCH, TIER_EXACT_CACHE, TIER_FULL_AGENTIC, TIER_FUZZY_CACHE, TIER_OPTIMIZED_LLM, } from '../../core/domain/entities/query-log-entry.js';
|
|
3
4
|
import { loadSources } from '../../core/domain/source/source-schema.js';
|
|
4
5
|
import { isDerivedArtifact } from '../context-tree/derived-artifact.js';
|
|
5
6
|
import { FileContextTreeManifestService } from '../context-tree/file-context-tree-manifest-service.js';
|
|
@@ -7,6 +8,10 @@ import { canRespondDirectly, formatDirectResponse, formatNotFoundResponse, } fro
|
|
|
7
8
|
import { QueryResultCache } from './query-result-cache.js';
|
|
8
9
|
/** Attribution footer appended to all query responses */
|
|
9
10
|
const ATTRIBUTION_FOOTER = '\n\n---\nSource: ByteRover Knowledge Base';
|
|
11
|
+
/** Map search results to the matchedDocs shape for QueryExecutorResult. */
|
|
12
|
+
function buildMatchedDocs(sr) {
|
|
13
|
+
return (sr?.results ?? []).map((r) => ({ path: r.path, score: r.score, title: r.title }));
|
|
14
|
+
}
|
|
10
15
|
/** Minimum normalized score to consider a result high-confidence for pre-fetching */
|
|
11
16
|
const SMART_ROUTING_SCORE_THRESHOLD = 0.7;
|
|
12
17
|
/** Maximum number of documents to pre-fetch and inject into the prompt */
|
|
@@ -46,6 +51,7 @@ export class QueryExecutor {
|
|
|
46
51
|
}
|
|
47
52
|
}
|
|
48
53
|
async executeWithAgent(agent, options) {
|
|
54
|
+
const startTime = Date.now();
|
|
49
55
|
const { query, taskId, worktreeRoot } = options;
|
|
50
56
|
const workspaceScope = this.deriveWorkspaceScope(worktreeRoot);
|
|
51
57
|
// Start search early — runs in parallel with fingerprint computation (independent operations)
|
|
@@ -58,14 +64,24 @@ export class QueryExecutor {
|
|
|
58
64
|
fingerprint = await this.computeContextTreeFingerprint(worktreeRoot);
|
|
59
65
|
const cached = this.cache.get(query, fingerprint);
|
|
60
66
|
if (cached) {
|
|
61
|
-
return
|
|
67
|
+
return {
|
|
68
|
+
matchedDocs: [],
|
|
69
|
+
response: cached + ATTRIBUTION_FOOTER,
|
|
70
|
+
tier: TIER_EXACT_CACHE,
|
|
71
|
+
timing: { durationMs: Date.now() - startTime },
|
|
72
|
+
};
|
|
62
73
|
}
|
|
63
74
|
}
|
|
64
75
|
// === Tier 1: Fuzzy cache match (~50ms) ===
|
|
65
76
|
if (this.cache && fingerprint) {
|
|
66
77
|
const fuzzyHit = this.cache.findSimilar(query, fingerprint);
|
|
67
78
|
if (fuzzyHit) {
|
|
68
|
-
return
|
|
79
|
+
return {
|
|
80
|
+
matchedDocs: [],
|
|
81
|
+
response: fuzzyHit + ATTRIBUTION_FOOTER,
|
|
82
|
+
tier: TIER_FUZZY_CACHE,
|
|
83
|
+
timing: { durationMs: Date.now() - startTime },
|
|
84
|
+
};
|
|
69
85
|
}
|
|
70
86
|
}
|
|
71
87
|
// Await search result (already started in parallel with fingerprint computation)
|
|
@@ -86,7 +102,13 @@ export class QueryExecutor {
|
|
|
86
102
|
if (this.cache && fingerprint) {
|
|
87
103
|
this.cache.set(query, response, fingerprint);
|
|
88
104
|
}
|
|
89
|
-
return
|
|
105
|
+
return {
|
|
106
|
+
matchedDocs: [],
|
|
107
|
+
response: response + ATTRIBUTION_FOOTER,
|
|
108
|
+
searchMetadata: { resultCount: 0, topScore: 0, totalFound: 0 },
|
|
109
|
+
tier: TIER_DIRECT_SEARCH,
|
|
110
|
+
timing: { durationMs: Date.now() - startTime },
|
|
111
|
+
};
|
|
90
112
|
}
|
|
91
113
|
// === Tier 2: Direct search response (~100-200ms) ===
|
|
92
114
|
if (searchResult && this.fileSystem) {
|
|
@@ -95,7 +117,18 @@ export class QueryExecutor {
|
|
|
95
117
|
if (this.cache && fingerprint) {
|
|
96
118
|
this.cache.set(query, directResult, fingerprint);
|
|
97
119
|
}
|
|
98
|
-
return
|
|
120
|
+
return {
|
|
121
|
+
matchedDocs: buildMatchedDocs(searchResult),
|
|
122
|
+
response: directResult + ATTRIBUTION_FOOTER,
|
|
123
|
+
searchMetadata: {
|
|
124
|
+
cacheFingerprint: fingerprint,
|
|
125
|
+
resultCount: searchResult.results.length,
|
|
126
|
+
topScore: searchResult.results[0]?.score ?? 0,
|
|
127
|
+
totalFound: searchResult.totalFound,
|
|
128
|
+
},
|
|
129
|
+
tier: TIER_DIRECT_SEARCH,
|
|
130
|
+
timing: { durationMs: Date.now() - startTime },
|
|
131
|
+
};
|
|
99
132
|
}
|
|
100
133
|
}
|
|
101
134
|
// === Tier 3/4: LLM call with RLM pattern (variable-based search results) ===
|
|
@@ -115,9 +148,7 @@ export class QueryExecutor {
|
|
|
115
148
|
if (manifest) {
|
|
116
149
|
const resolved = await manifestService.resolveForInjection(manifest, query, this.baseDirectory);
|
|
117
150
|
if (resolved.length > 0) {
|
|
118
|
-
manifestContext = resolved
|
|
119
|
-
.map((e) => `[${e.type} ${e.path}]\n${e.content}`)
|
|
120
|
-
.join('\n\n---\n\n');
|
|
151
|
+
manifestContext = resolved.map((e) => `[${e.type} ${e.path}]\n${e.content}`).join('\n\n---\n\n');
|
|
121
152
|
}
|
|
122
153
|
}
|
|
123
154
|
}
|
|
@@ -169,7 +200,19 @@ export class QueryExecutor {
|
|
|
169
200
|
if (this.cache && fingerprint) {
|
|
170
201
|
this.cache.set(query, response, fingerprint);
|
|
171
202
|
}
|
|
172
|
-
|
|
203
|
+
const tier = prefetchedContext ? TIER_OPTIMIZED_LLM : TIER_FULL_AGENTIC;
|
|
204
|
+
return {
|
|
205
|
+
matchedDocs: buildMatchedDocs(searchResult),
|
|
206
|
+
response: response + ATTRIBUTION_FOOTER,
|
|
207
|
+
searchMetadata: {
|
|
208
|
+
cacheFingerprint: fingerprint,
|
|
209
|
+
resultCount: searchResult?.results.length ?? 0,
|
|
210
|
+
topScore: searchResult?.results[0]?.score ?? 0,
|
|
211
|
+
totalFound: searchResult?.totalFound ?? 0,
|
|
212
|
+
},
|
|
213
|
+
tier,
|
|
214
|
+
timing: { durationMs: Date.now() - startTime },
|
|
215
|
+
};
|
|
173
216
|
}
|
|
174
217
|
finally {
|
|
175
218
|
// Clean up entire task session (sandbox + history) in one call
|
|
@@ -188,9 +231,7 @@ export class QueryExecutor {
|
|
|
188
231
|
if (highConfidenceResults.length === 0)
|
|
189
232
|
return undefined;
|
|
190
233
|
const sections = highConfidenceResults.map((r) => {
|
|
191
|
-
const source = r.origin === 'shared' && r.originAlias
|
|
192
|
-
? `[${r.originAlias}]:${r.path}`
|
|
193
|
-
: `.brv/context-tree/${r.path}`;
|
|
234
|
+
const source = r.origin === 'shared' && r.originAlias ? `[${r.originAlias}]:${r.path}` : `.brv/context-tree/${r.path}`;
|
|
194
235
|
return `### ${r.title}\n**Source**: ${source}\n\n${r.excerpt}`;
|
|
195
236
|
});
|
|
196
237
|
return sections.join('\n\n---\n\n');
|
|
@@ -274,9 +315,10 @@ ${responseFormat}`;
|
|
|
274
315
|
async computeContextTreeFingerprint(worktreeRoot) {
|
|
275
316
|
// Fast path: return cached fingerprint if still valid (avoids globFiles I/O)
|
|
276
317
|
// Invalidate if worktreeRoot changed or knowledge source validity changed
|
|
277
|
-
if (this.cachedFingerprint &&
|
|
278
|
-
|
|
279
|
-
|
|
318
|
+
if (this.cachedFingerprint &&
|
|
319
|
+
Date.now() < this.cachedFingerprint.expiresAt &&
|
|
320
|
+
this.cachedFingerprint.worktreeRoot === worktreeRoot &&
|
|
321
|
+
this.cachedFingerprint.sourceValidityHash === this.computeSourceValidityHash()) {
|
|
280
322
|
return this.cachedFingerprint.value;
|
|
281
323
|
}
|
|
282
324
|
try {
|
|
@@ -298,7 +340,7 @@ ${responseFormat}`;
|
|
|
298
340
|
}));
|
|
299
341
|
// Include shared source state in fingerprint so edits in shared
|
|
300
342
|
// projects invalidate cached query answers.
|
|
301
|
-
const loaded = this.baseDirectory ? loadSources(this.baseDirectory) :
|
|
343
|
+
const loaded = this.baseDirectory ? loadSources(this.baseDirectory) : undefined;
|
|
302
344
|
if (loaded) {
|
|
303
345
|
// sources-file mtime detects source additions/removals
|
|
304
346
|
if (loaded.mtime) {
|
|
@@ -357,7 +399,10 @@ ${responseFormat}`;
|
|
|
357
399
|
const loaded = loadSources(this.baseDirectory);
|
|
358
400
|
if (!loaded)
|
|
359
401
|
return 'no-sources';
|
|
360
|
-
return loaded.origins
|
|
402
|
+
return loaded.origins
|
|
403
|
+
.map((o) => o.originKey)
|
|
404
|
+
.sort()
|
|
405
|
+
.join(',');
|
|
361
406
|
}
|
|
362
407
|
/**
|
|
363
408
|
* Derive a workspace scope for search from the worktreeRoot.
|
|
@@ -385,9 +430,36 @@ ${responseFormat}`;
|
|
|
385
430
|
*/
|
|
386
431
|
extractQueryEntities(query) {
|
|
387
432
|
const stopwords = new Set([
|
|
388
|
-
'a',
|
|
389
|
-
'
|
|
390
|
-
'
|
|
433
|
+
'a',
|
|
434
|
+
'about',
|
|
435
|
+
'an',
|
|
436
|
+
'and',
|
|
437
|
+
'by',
|
|
438
|
+
'did',
|
|
439
|
+
'do',
|
|
440
|
+
'does',
|
|
441
|
+
'for',
|
|
442
|
+
'from',
|
|
443
|
+
'how',
|
|
444
|
+
'in',
|
|
445
|
+
'is',
|
|
446
|
+
'my',
|
|
447
|
+
'of',
|
|
448
|
+
'or',
|
|
449
|
+
'our',
|
|
450
|
+
'that',
|
|
451
|
+
'the',
|
|
452
|
+
'their',
|
|
453
|
+
'this',
|
|
454
|
+
'to',
|
|
455
|
+
'was',
|
|
456
|
+
'were',
|
|
457
|
+
'what',
|
|
458
|
+
'when',
|
|
459
|
+
'where',
|
|
460
|
+
'which',
|
|
461
|
+
'who',
|
|
462
|
+
'with',
|
|
391
463
|
]);
|
|
392
464
|
const words = query.toLowerCase().split(/\s+/);
|
|
393
465
|
return words.filter((w) => w.length >= 3 && !stopwords.has(w));
|
|
@@ -461,9 +533,7 @@ ${responseFormat}`;
|
|
|
461
533
|
// Use excerpt if full read fails
|
|
462
534
|
}
|
|
463
535
|
// Include source attribution in path for shared results
|
|
464
|
-
const displayPath = result.origin === 'shared' && result.originAlias
|
|
465
|
-
? `[${result.originAlias}]:${result.path}`
|
|
466
|
-
: result.path;
|
|
536
|
+
const displayPath = result.origin === 'shared' && result.originAlias ? `[${result.originAlias}]:${result.path}` : result.path;
|
|
467
537
|
return { content, path: displayPath, score: result.score, title: result.title };
|
|
468
538
|
}));
|
|
469
539
|
if (!canRespondDirectly(fullResults))
|
|
@@ -9,7 +9,7 @@ import { join } from 'node:path';
|
|
|
9
9
|
import { ReviewEvents } from '../../../shared/transport/events/review-events.js';
|
|
10
10
|
import { getAuthConfig } from '../../config/auth.config.js';
|
|
11
11
|
import { getCurrentConfig } from '../../config/environment.js';
|
|
12
|
-
import { BRV_DIR } from '../../constants.js';
|
|
12
|
+
import { API_V1_PATH, BRV_DIR } from '../../constants.js';
|
|
13
13
|
import { getProjectDataDir } from '../../utils/path-utils.js';
|
|
14
14
|
import { OAuthService } from '../auth/oauth-service.js';
|
|
15
15
|
import { OidcDiscoveryService } from '../auth/oidc-discovery-service.js';
|
|
@@ -48,9 +48,12 @@ export async function setupFeatureHandlers({ authStateStore, broadcastToProject,
|
|
|
48
48
|
const envConfig = getCurrentConfig();
|
|
49
49
|
const tokenStore = createTokenStore();
|
|
50
50
|
const projectConfigStore = new ProjectConfigStore();
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
51
|
+
// API version paths appended at point of use.
|
|
52
|
+
// Note: IAM and Cogit currently share this version path, but may version independently in the future.
|
|
53
|
+
const iamApiV1 = `${envConfig.iamBaseUrl}${API_V1_PATH}`;
|
|
54
|
+
const userService = new HttpUserService({ apiBaseUrl: iamApiV1 });
|
|
55
|
+
const teamService = new HttpTeamService({ apiBaseUrl: iamApiV1 });
|
|
56
|
+
const spaceService = new HttpSpaceService({ apiBaseUrl: iamApiV1 });
|
|
54
57
|
// Auth handler requires async OIDC discovery
|
|
55
58
|
const discoveryService = new OidcDiscoveryService();
|
|
56
59
|
const authConfig = await getAuthConfig(discoveryService);
|
|
@@ -86,8 +89,9 @@ export async function setupFeatureHandlers({ authStateStore, broadcastToProject,
|
|
|
86
89
|
const contextTreeWriterService = new FileContextTreeWriterService({ snapshotService: contextTreeSnapshotService });
|
|
87
90
|
const contextTreeMerger = new FileContextTreeMerger({ snapshotService: contextTreeSnapshotService });
|
|
88
91
|
const contextFileReader = new FileContextFileReader();
|
|
89
|
-
const
|
|
90
|
-
const
|
|
92
|
+
const cogitApiV1 = `${envConfig.cogitBaseUrl}${API_V1_PATH}`;
|
|
93
|
+
const cogitPushService = new HttpCogitPushService({ apiBaseUrl: cogitApiV1 });
|
|
94
|
+
const cogitPullService = new HttpCogitPullService({ apiBaseUrl: cogitApiV1 });
|
|
91
95
|
// ConnectorManager factory — creates per-project instances since constructor binds to projectRoot
|
|
92
96
|
const fileService = new FsFileService();
|
|
93
97
|
const templateLoader = new FsTemplateLoader(fileService);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { TaskInfo } from '../../core/domain/transport/task-info.js';
|
|
2
|
+
import type { QueryExecutorResult } from '../../core/interfaces/executor/i-query-executor.js';
|
|
3
|
+
import type { ITaskLifecycleHook } from '../../core/interfaces/process/i-task-lifecycle-hook.js';
|
|
4
|
+
import type { IQueryLogStore } from '../../core/interfaces/storage/i-query-log-store.js';
|
|
5
|
+
/** Query metadata without the response string (response arrives via task:completed). */
|
|
6
|
+
type QueryResultMetadata = Omit<QueryExecutorResult, 'response'>;
|
|
7
|
+
/**
|
|
8
|
+
* Lifecycle hook that transparently logs query task execution.
|
|
9
|
+
*
|
|
10
|
+
* Wired into TaskRouter via lifecycleHooks[]. Writes log entries to
|
|
11
|
+
* per-project FileQueryLogStore. All I/O errors are swallowed — logging
|
|
12
|
+
* must never block or affect query task execution.
|
|
13
|
+
*
|
|
14
|
+
* Key difference from CurateLogHandler: no onToolResult accumulation.
|
|
15
|
+
* Query metadata (tier, timing, matchedDocs, searchMetadata) arrives via
|
|
16
|
+
* setQueryResult() called after QueryExecutor.executeWithAgent() returns.
|
|
17
|
+
*/
|
|
18
|
+
export declare class QueryLogHandler implements ITaskLifecycleHook {
|
|
19
|
+
private readonly createStore?;
|
|
20
|
+
/** Active task count per projectPath — used to evict idle stores. */
|
|
21
|
+
private readonly activeTaskCount;
|
|
22
|
+
/** Per-project store cache (one store per projectPath). Evicted when no active tasks remain. */
|
|
23
|
+
private readonly stores;
|
|
24
|
+
/** In-memory state per active task. Cleared on cleanup(). */
|
|
25
|
+
private readonly tasks;
|
|
26
|
+
constructor(createStore?: ((projectPath: string) => IQueryLogStore) | undefined);
|
|
27
|
+
cleanup(taskId: string): void;
|
|
28
|
+
onTaskCancelled(taskId: string, _task: TaskInfo): Promise<void>;
|
|
29
|
+
onTaskCompleted(taskId: string, result: string, _task: TaskInfo): Promise<void>;
|
|
30
|
+
onTaskCreate(task: TaskInfo): Promise<void | {
|
|
31
|
+
logId?: string;
|
|
32
|
+
}>;
|
|
33
|
+
onTaskError(taskId: string, errorMessage: string, _task: TaskInfo): Promise<void>;
|
|
34
|
+
/**
|
|
35
|
+
* Store query execution metadata for later finalization.
|
|
36
|
+
* Called by agent-process after QueryExecutor.executeWithAgent() returns.
|
|
37
|
+
* Synchronous — no I/O. Metadata is merged into the final entry on completion.
|
|
38
|
+
*/
|
|
39
|
+
setQueryResult(taskId: string, result: QueryResultMetadata): void;
|
|
40
|
+
private getOrCreateStore;
|
|
41
|
+
}
|
|
42
|
+
export {};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { getProjectDataDir } from '../../utils/path-utils.js';
|
|
2
|
+
import { transportLog } from '../../utils/process-logger.js';
|
|
3
|
+
import { FileQueryLogStore } from '../storage/file-query-log-store.js';
|
|
4
|
+
const QUERY_TASK_TYPES = new Set(['query']);
|
|
5
|
+
// ── QueryLogHandler ──────────────────────────────────────────────────────────
|
|
6
|
+
/**
|
|
7
|
+
* Lifecycle hook that transparently logs query task execution.
|
|
8
|
+
*
|
|
9
|
+
* Wired into TaskRouter via lifecycleHooks[]. Writes log entries to
|
|
10
|
+
* per-project FileQueryLogStore. All I/O errors are swallowed — logging
|
|
11
|
+
* must never block or affect query task execution.
|
|
12
|
+
*
|
|
13
|
+
* Key difference from CurateLogHandler: no onToolResult accumulation.
|
|
14
|
+
* Query metadata (tier, timing, matchedDocs, searchMetadata) arrives via
|
|
15
|
+
* setQueryResult() called after QueryExecutor.executeWithAgent() returns.
|
|
16
|
+
*/
|
|
17
|
+
export class QueryLogHandler {
|
|
18
|
+
createStore;
|
|
19
|
+
/** Active task count per projectPath — used to evict idle stores. */
|
|
20
|
+
activeTaskCount = new Map();
|
|
21
|
+
/** Per-project store cache (one store per projectPath). Evicted when no active tasks remain. */
|
|
22
|
+
stores = new Map();
|
|
23
|
+
/** In-memory state per active task. Cleared on cleanup(). */
|
|
24
|
+
tasks = new Map();
|
|
25
|
+
constructor(createStore) {
|
|
26
|
+
this.createStore = createStore;
|
|
27
|
+
}
|
|
28
|
+
cleanup(taskId) {
|
|
29
|
+
const state = this.tasks.get(taskId);
|
|
30
|
+
this.tasks.delete(taskId);
|
|
31
|
+
if (state) {
|
|
32
|
+
const remaining = (this.activeTaskCount.get(state.projectPath) ?? 1) - 1;
|
|
33
|
+
if (remaining <= 0) {
|
|
34
|
+
this.activeTaskCount.delete(state.projectPath);
|
|
35
|
+
this.stores.delete(state.projectPath);
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
this.activeTaskCount.set(state.projectPath, remaining);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
async onTaskCancelled(taskId, _task) {
|
|
43
|
+
const state = this.tasks.get(taskId);
|
|
44
|
+
if (!state)
|
|
45
|
+
return;
|
|
46
|
+
const store = this.getOrCreateStore(state.projectPath);
|
|
47
|
+
const updated = {
|
|
48
|
+
...state.entry,
|
|
49
|
+
completedAt: Date.now(),
|
|
50
|
+
matchedDocs: state.queryResult?.matchedDocs ?? state.entry.matchedDocs,
|
|
51
|
+
searchMetadata: state.queryResult?.searchMetadata,
|
|
52
|
+
status: 'cancelled',
|
|
53
|
+
tier: state.queryResult?.tier,
|
|
54
|
+
timing: state.queryResult?.timing,
|
|
55
|
+
};
|
|
56
|
+
await store.save(updated).catch((error) => {
|
|
57
|
+
transportLog(`QueryLogHandler: failed to save cancelled entry for ${taskId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
async onTaskCompleted(taskId, result, _task) {
|
|
61
|
+
const state = this.tasks.get(taskId);
|
|
62
|
+
if (!state)
|
|
63
|
+
return;
|
|
64
|
+
const store = this.getOrCreateStore(state.projectPath);
|
|
65
|
+
const updated = {
|
|
66
|
+
...state.entry,
|
|
67
|
+
completedAt: Date.now(),
|
|
68
|
+
matchedDocs: state.queryResult?.matchedDocs ?? state.entry.matchedDocs,
|
|
69
|
+
response: result.length > 0 ? result : undefined,
|
|
70
|
+
searchMetadata: state.queryResult?.searchMetadata,
|
|
71
|
+
status: 'completed',
|
|
72
|
+
tier: state.queryResult?.tier,
|
|
73
|
+
timing: state.queryResult?.timing,
|
|
74
|
+
};
|
|
75
|
+
await store.save(updated).catch((error) => {
|
|
76
|
+
transportLog(`QueryLogHandler: failed to save completed entry for ${taskId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
async onTaskCreate(task) {
|
|
80
|
+
if (!QUERY_TASK_TYPES.has(task.type))
|
|
81
|
+
return;
|
|
82
|
+
if (!task.projectPath)
|
|
83
|
+
return;
|
|
84
|
+
const store = this.getOrCreateStore(task.projectPath);
|
|
85
|
+
const logId = await store.getNextId().catch((error) => {
|
|
86
|
+
transportLog(`QueryLogHandler: getNextId failed for ${task.taskId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
87
|
+
});
|
|
88
|
+
if (!logId)
|
|
89
|
+
return;
|
|
90
|
+
const entry = {
|
|
91
|
+
id: logId,
|
|
92
|
+
matchedDocs: [],
|
|
93
|
+
query: task.content,
|
|
94
|
+
startedAt: task.createdAt,
|
|
95
|
+
status: 'processing',
|
|
96
|
+
taskId: task.taskId,
|
|
97
|
+
};
|
|
98
|
+
// MEMORY-FIRST: Set in-memory state BEFORE disk write so setQueryResult can access it immediately.
|
|
99
|
+
// Caching `entry` here lets onTaskCompleted/onTaskError rebuild the final entry
|
|
100
|
+
// without a getById round-trip — so completion is never lost even if this initial
|
|
101
|
+
// save fails.
|
|
102
|
+
this.tasks.set(task.taskId, { entry, projectPath: task.projectPath });
|
|
103
|
+
this.activeTaskCount.set(task.projectPath, (this.activeTaskCount.get(task.projectPath) ?? 0) + 1);
|
|
104
|
+
// Fire-and-forget disk I/O — logId is already known and returned.
|
|
105
|
+
store.save(entry).catch((error) => {
|
|
106
|
+
transportLog(`QueryLogHandler: failed to save processing entry for ${task.taskId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
107
|
+
});
|
|
108
|
+
return { logId };
|
|
109
|
+
}
|
|
110
|
+
async onTaskError(taskId, errorMessage, _task) {
|
|
111
|
+
const state = this.tasks.get(taskId);
|
|
112
|
+
if (!state)
|
|
113
|
+
return;
|
|
114
|
+
const store = this.getOrCreateStore(state.projectPath);
|
|
115
|
+
const updated = {
|
|
116
|
+
...state.entry,
|
|
117
|
+
completedAt: Date.now(),
|
|
118
|
+
error: errorMessage,
|
|
119
|
+
matchedDocs: state.queryResult?.matchedDocs ?? state.entry.matchedDocs,
|
|
120
|
+
searchMetadata: state.queryResult?.searchMetadata,
|
|
121
|
+
status: 'error',
|
|
122
|
+
tier: state.queryResult?.tier,
|
|
123
|
+
timing: state.queryResult?.timing,
|
|
124
|
+
};
|
|
125
|
+
await store.save(updated).catch((error) => {
|
|
126
|
+
transportLog(`QueryLogHandler: failed to save error entry for ${taskId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Store query execution metadata for later finalization.
|
|
131
|
+
* Called by agent-process after QueryExecutor.executeWithAgent() returns.
|
|
132
|
+
* Synchronous — no I/O. Metadata is merged into the final entry on completion.
|
|
133
|
+
*/
|
|
134
|
+
setQueryResult(taskId, result) {
|
|
135
|
+
const state = this.tasks.get(taskId);
|
|
136
|
+
if (!state)
|
|
137
|
+
return;
|
|
138
|
+
state.queryResult = result;
|
|
139
|
+
}
|
|
140
|
+
getOrCreateStore(projectPath) {
|
|
141
|
+
const existing = this.stores.get(projectPath);
|
|
142
|
+
if (existing)
|
|
143
|
+
return existing;
|
|
144
|
+
const store = this.createStore
|
|
145
|
+
? this.createStore(projectPath)
|
|
146
|
+
: new FileQueryLogStore({ baseDir: getProjectDataDir(projectPath) });
|
|
147
|
+
this.stores.set(projectPath, store);
|
|
148
|
+
return store;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -14,18 +14,41 @@
|
|
|
14
14
|
*
|
|
15
15
|
* Consumed by TransportHandlers (orchestrator).
|
|
16
16
|
*/
|
|
17
|
+
import type { TaskCreateRequest } from '../../core/domain/transport/schemas.js';
|
|
17
18
|
import type { IAgentPool } from '../../core/interfaces/agent/i-agent-pool.js';
|
|
18
19
|
import type { ITaskLifecycleHook } from '../../core/interfaces/process/i-task-lifecycle-hook.js';
|
|
19
20
|
import type { IProjectRegistry } from '../../core/interfaces/project/i-project-registry.js';
|
|
20
21
|
import type { IProjectRouter } from '../../core/interfaces/routing/i-project-router.js';
|
|
21
22
|
import type { ITransportServer } from '../../core/interfaces/transport/i-transport-server.js';
|
|
22
23
|
import type { TaskInfo } from './types.js';
|
|
24
|
+
/**
|
|
25
|
+
* Outcome of the daemon-side pre-dispatch check.
|
|
26
|
+
*
|
|
27
|
+
* `skipResult` is the full string sent to the client as the task:completed `result`.
|
|
28
|
+
* The callback owns the message format so task-router stays task-type-agnostic
|
|
29
|
+
* (e.g. dream uses "Dream skipped: <reason>"; future task types can use their own).
|
|
30
|
+
*/
|
|
31
|
+
export type PreDispatchCheckResult = {
|
|
32
|
+
eligible: false;
|
|
33
|
+
skipResult: string;
|
|
34
|
+
} | {
|
|
35
|
+
eligible: true;
|
|
36
|
+
};
|
|
37
|
+
export type PreDispatchCheck = (task: TaskCreateRequest, projectPath?: string) => Promise<PreDispatchCheckResult>;
|
|
23
38
|
type TaskRouterOptions = {
|
|
24
39
|
agentPool?: IAgentPool;
|
|
25
40
|
/** Function to resolve agent clientId for a given project */
|
|
26
41
|
getAgentForProject: (projectPath?: string) => string | undefined;
|
|
27
42
|
/** Lifecycle hooks for task events (e.g. CurateLogHandler). */
|
|
28
43
|
lifecycleHooks?: ITaskLifecycleHook[];
|
|
44
|
+
/**
|
|
45
|
+
* Optional daemon-side gate run before dispatching to the agent pool. If it
|
|
46
|
+
* resolves ineligible, task-router short-circuits with task:completed carrying
|
|
47
|
+
* the skip reason and never submits the task to an agent.
|
|
48
|
+
* Used for dream task type to enforce gates 1-3 (time, activity, queue) even
|
|
49
|
+
* on the CLI dispatch path — mirrors the idle-trigger pre-check pattern.
|
|
50
|
+
*/
|
|
51
|
+
preDispatchCheck?: PreDispatchCheck;
|
|
29
52
|
projectRegistry?: IProjectRegistry;
|
|
30
53
|
projectRouter?: IProjectRouter;
|
|
31
54
|
/** Resolves the projectPath a client registered with (from client:register). */
|
|
@@ -41,6 +64,7 @@ export declare class TaskRouter {
|
|
|
41
64
|
private completedTasks;
|
|
42
65
|
private readonly getAgentForProject;
|
|
43
66
|
private readonly lifecycleHooks;
|
|
67
|
+
private readonly preDispatchCheck;
|
|
44
68
|
private readonly projectRegistry;
|
|
45
69
|
private readonly projectRouter;
|
|
46
70
|
private readonly resolveClientProjectPath;
|
|
@@ -100,6 +124,22 @@ export declare class TaskRouter {
|
|
|
100
124
|
*/
|
|
101
125
|
private handleTaskCreate;
|
|
102
126
|
private handleTaskError;
|
|
127
|
+
/**
|
|
128
|
+
* Emit `task:completed` for a task that the daemon's pre-dispatch gate skipped
|
|
129
|
+
* before it ever reached `AgentPool.submitTask`.
|
|
130
|
+
*
|
|
131
|
+
* Distinct from {@link handleTaskCompleted}:
|
|
132
|
+
* - does NOT call `agentPool.notifyTaskCompleted` (the pool's `activeTasks`
|
|
133
|
+
* counter was never incremented, so decrementing here would undercount real
|
|
134
|
+
* load and let `drainQueue` dispatch an extra queued task)
|
|
135
|
+
* - does NOT fire `onTaskCompleted` lifecycle hooks (counters/metrics that
|
|
136
|
+
* act on completed tasks should not see pre-check skips as completions)
|
|
137
|
+
*
|
|
138
|
+
* Still emits the event to the client and the project room so REPL/TUI
|
|
139
|
+
* receive the skip result, and still calls `moveToCompleted` so the task is
|
|
140
|
+
* removed from the active set.
|
|
141
|
+
*/
|
|
142
|
+
private handleTaskSkippedByPreCheck;
|
|
103
143
|
private handleTaskStarted;
|
|
104
144
|
/**
|
|
105
145
|
* Move a task to the completed tasks map with grace period cleanup.
|
|
@@ -38,6 +38,7 @@ export class TaskRouter {
|
|
|
38
38
|
completedTasks = new Map();
|
|
39
39
|
getAgentForProject;
|
|
40
40
|
lifecycleHooks;
|
|
41
|
+
preDispatchCheck;
|
|
41
42
|
projectRegistry;
|
|
42
43
|
projectRouter;
|
|
43
44
|
resolveClientProjectPath;
|
|
@@ -49,6 +50,7 @@ export class TaskRouter {
|
|
|
49
50
|
this.agentPool = options.agentPool;
|
|
50
51
|
this.getAgentForProject = options.getAgentForProject;
|
|
51
52
|
this.lifecycleHooks = options.lifecycleHooks ?? [];
|
|
53
|
+
this.preDispatchCheck = options.preDispatchCheck;
|
|
52
54
|
this.projectRegistry = options.projectRegistry;
|
|
53
55
|
this.projectRouter = options.projectRouter;
|
|
54
56
|
this.resolveClientProjectPath = options.resolveClientProjectPath;
|
|
@@ -170,7 +172,7 @@ export class TaskRouter {
|
|
|
170
172
|
}
|
|
171
173
|
}
|
|
172
174
|
handleTaskCompleted(data) {
|
|
173
|
-
const { result, taskId } = data;
|
|
175
|
+
const { logId: eventLogId, result, taskId } = data;
|
|
174
176
|
const task = this.tasks.get(taskId);
|
|
175
177
|
transportLog(`Task completed: ${taskId}`);
|
|
176
178
|
// Collect synchronous completion data from hooks (e.g. pendingReviewCount from CurateLogHandler).
|
|
@@ -187,24 +189,29 @@ export class TaskRouter {
|
|
|
187
189
|
}
|
|
188
190
|
}
|
|
189
191
|
}
|
|
192
|
+
// Prefer logId from lifecycle hooks (curate), fall back to executor-provided logId (dream)
|
|
193
|
+
const resolvedLogId = task?.logId ?? eventLogId;
|
|
190
194
|
if (task) {
|
|
191
195
|
this.transport.sendTo(task.clientId, TransportTaskEventNames.COMPLETED, {
|
|
192
|
-
...(
|
|
196
|
+
...(resolvedLogId ? { logId: resolvedLogId } : {}),
|
|
193
197
|
...hookData,
|
|
194
198
|
result,
|
|
195
199
|
taskId,
|
|
196
200
|
});
|
|
197
201
|
}
|
|
198
202
|
broadcastToProjectRoom(this.projectRegistry, this.projectRouter, task?.projectPath, TransportTaskEventNames.COMPLETED, {
|
|
199
|
-
...(
|
|
203
|
+
...(resolvedLogId ? { logId: resolvedLogId } : {}),
|
|
200
204
|
...hookData,
|
|
201
205
|
result,
|
|
202
206
|
taskId,
|
|
203
207
|
}, task?.clientId);
|
|
204
208
|
this.moveToCompleted(taskId);
|
|
205
|
-
// Notify pool so it can clear busy flag and drain queued tasks
|
|
206
|
-
|
|
207
|
-
|
|
209
|
+
// Notify pool so it can clear busy flag and drain queued tasks.
|
|
210
|
+
// Fallback to data.projectPath for daemon-submitted tasks (e.g. idle dream)
|
|
211
|
+
// that bypass handleTaskCreate and are not registered in this.tasks.
|
|
212
|
+
const projectPath = task?.projectPath ?? data.projectPath;
|
|
213
|
+
if (projectPath) {
|
|
214
|
+
this.agentPool?.notifyTaskCompleted(projectPath);
|
|
208
215
|
}
|
|
209
216
|
// Notify hooks (fire-and-forget)
|
|
210
217
|
if (task) {
|
|
@@ -303,6 +310,27 @@ export class TaskRouter {
|
|
|
303
310
|
...(logId ? { logId } : {}),
|
|
304
311
|
taskId,
|
|
305
312
|
});
|
|
313
|
+
// ── Daemon-side pre-dispatch gate (dream uses this for gates 1-3) ────────
|
|
314
|
+
// Runs after ack so the client has a logId to correlate; short-circuits with
|
|
315
|
+
// task:completed + skip-reason when ineligible. Mirrors the idle-trigger
|
|
316
|
+
// pattern in brv-server.ts:260 for the CLI dispatch path.
|
|
317
|
+
if (this.preDispatchCheck) {
|
|
318
|
+
let check = { eligible: true };
|
|
319
|
+
try {
|
|
320
|
+
check = await this.preDispatchCheck(data, projectPath);
|
|
321
|
+
}
|
|
322
|
+
catch (error_) {
|
|
323
|
+
transportLog(`preDispatchCheck threw for task ${taskId}, proceeding with dispatch: ${error_ instanceof Error ? error_.message : String(error_)}`);
|
|
324
|
+
}
|
|
325
|
+
if (!check.eligible) {
|
|
326
|
+
transportLog(`Task ${taskId} (type=${data.type}) skipped by daemon pre-check: ${check.skipResult}`);
|
|
327
|
+
// Use the skip-specific handler so the pool's activeTasks counter and
|
|
328
|
+
// onTaskCompleted hooks aren't notified for a task that never reached
|
|
329
|
+
// submitTask. See handleTaskSkippedByPreCheck for rationale.
|
|
330
|
+
this.handleTaskSkippedByPreCheck(taskId, check.skipResult);
|
|
331
|
+
return { taskId };
|
|
332
|
+
}
|
|
333
|
+
}
|
|
306
334
|
// ── Submit to AgentPool (fire-and-forget) ─────────────────────────────────
|
|
307
335
|
const executeMsg = {
|
|
308
336
|
clientId,
|
|
@@ -310,6 +338,7 @@ export class TaskRouter {
|
|
|
310
338
|
...(data.clientCwd ? { clientCwd: data.clientCwd } : {}),
|
|
311
339
|
...(data.files?.length ? { files: data.files } : {}),
|
|
312
340
|
...(data.folderPath ? { folderPath: data.folderPath } : {}),
|
|
341
|
+
...(data.force === undefined ? {} : { force: data.force }),
|
|
313
342
|
...(projectPath ? { projectPath } : {}),
|
|
314
343
|
taskId,
|
|
315
344
|
type: data.type,
|
|
@@ -386,15 +415,44 @@ export class TaskRouter {
|
|
|
386
415
|
taskId,
|
|
387
416
|
}, task?.clientId);
|
|
388
417
|
this.moveToCompleted(taskId);
|
|
389
|
-
// Notify pool so it can clear busy flag and drain queued tasks
|
|
390
|
-
|
|
391
|
-
|
|
418
|
+
// Notify pool so it can clear busy flag and drain queued tasks.
|
|
419
|
+
// Fallback to data.projectPath for daemon-submitted tasks (e.g. idle dream).
|
|
420
|
+
const errorProjectPath = task?.projectPath ?? data.projectPath;
|
|
421
|
+
if (errorProjectPath) {
|
|
422
|
+
this.agentPool?.notifyTaskCompleted(errorProjectPath);
|
|
392
423
|
}
|
|
393
424
|
// Notify hooks (fire-and-forget)
|
|
394
425
|
if (task) {
|
|
395
426
|
this.notifyHooksError(taskId, error.message, task).catch(() => { });
|
|
396
427
|
}
|
|
397
428
|
}
|
|
429
|
+
/**
|
|
430
|
+
* Emit `task:completed` for a task that the daemon's pre-dispatch gate skipped
|
|
431
|
+
* before it ever reached `AgentPool.submitTask`.
|
|
432
|
+
*
|
|
433
|
+
* Distinct from {@link handleTaskCompleted}:
|
|
434
|
+
* - does NOT call `agentPool.notifyTaskCompleted` (the pool's `activeTasks`
|
|
435
|
+
* counter was never incremented, so decrementing here would undercount real
|
|
436
|
+
* load and let `drainQueue` dispatch an extra queued task)
|
|
437
|
+
* - does NOT fire `onTaskCompleted` lifecycle hooks (counters/metrics that
|
|
438
|
+
* act on completed tasks should not see pre-check skips as completions)
|
|
439
|
+
*
|
|
440
|
+
* Still emits the event to the client and the project room so REPL/TUI
|
|
441
|
+
* receive the skip result, and still calls `moveToCompleted` so the task is
|
|
442
|
+
* removed from the active set.
|
|
443
|
+
*/
|
|
444
|
+
handleTaskSkippedByPreCheck(taskId, result) {
|
|
445
|
+
const task = this.tasks.get(taskId);
|
|
446
|
+
transportLog(`Task skipped by pre-dispatch gate: ${taskId}`);
|
|
447
|
+
if (task) {
|
|
448
|
+
this.transport.sendTo(task.clientId, TransportTaskEventNames.COMPLETED, {
|
|
449
|
+
result,
|
|
450
|
+
taskId,
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
broadcastToProjectRoom(this.projectRegistry, this.projectRouter, task?.projectPath, TransportTaskEventNames.COMPLETED, { result, taskId }, task?.clientId);
|
|
454
|
+
this.moveToCompleted(taskId);
|
|
455
|
+
}
|
|
398
456
|
handleTaskStarted(data) {
|
|
399
457
|
const { taskId } = data;
|
|
400
458
|
const task = this.tasks.get(taskId);
|