@myagentroam/node 0.9.141 → 0.9.143
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/dist/connector.js +1 -1
- package/dist/runner/mar-agent/conversation-normalizer.js +252 -2
- package/dist/runner/mar-agent/history-projection.js +1 -0
- package/dist/service/continuous-run-coordinator.js +5 -8
- package/dist/service/workspace-code-navigation-service.js +9 -0
- package/dist/service/workspace-file-service.js +94 -10
- package/dist/service/workspace-watch-controller.js +8 -3
- package/dist/service/workspace-watch-service.js +18 -8
- package/dist/service/workspace-watch-snapshot-service.js +26 -0
- package/dist/service/workspace-workbench-service.js +4 -0
- package/dist/workspace.js +147 -42
- package/package.json +4 -4
package/dist/connector.js
CHANGED
|
@@ -304,7 +304,7 @@ export class NodeConnector {
|
|
|
304
304
|
terminalManager;
|
|
305
305
|
upgradeCoordinator;
|
|
306
306
|
workspaceState = new WorkspaceDomainState();
|
|
307
|
-
workspaceWatchService = new WorkspaceWatchService(this.workspaceState, (workspaceId) => this.workspaceSessionService.ensureWatch(workspaceId), (workspaceId) => this.workspaceSessionService.stopWatch(workspaceId), (workspaceId) => this.workspaceWorkbenchService?.releaseWorkspace(workspaceId));
|
|
307
|
+
workspaceWatchService = new WorkspaceWatchService(this.workspaceState, (workspaceId) => this.workspaceSessionService.ensureWatch(workspaceId), (workspaceId) => this.workspaceSessionService.stopWatch(workspaceId), (workspaceId) => this.workspaceWorkbenchService?.releaseWorkspace(workspaceId), (watch) => this.workspaceWorkbenchService?.cancelFilePrewarm(watch));
|
|
308
308
|
workspaceSessionService = new WorkspaceSessionService(this.workspaceState, {
|
|
309
309
|
discover: (workspaceId, onRunnerDiscovered) => this.discoverWorkspaceSessions(workspaceId, onRunnerDiscovered),
|
|
310
310
|
listPage: (workspaceId) => this.executeNodeOperation('session.list', { workspaceId, limit: 20 }),
|
|
@@ -8,6 +8,8 @@ export class MarAgentConversationNormalizer {
|
|
|
8
8
|
#subagentDetails = new Map();
|
|
9
9
|
#subagentStarts = new Map();
|
|
10
10
|
#subagentLifecycles = new Map();
|
|
11
|
+
#codeModeObservationIds = new Set();
|
|
12
|
+
#codeModeSourceCallIds = new Set();
|
|
11
13
|
messageItemId(nativeItemId) {
|
|
12
14
|
return itemId('message', nativeItemId);
|
|
13
15
|
}
|
|
@@ -71,8 +73,8 @@ export class MarAgentConversationNormalizer {
|
|
|
71
73
|
if (event.type === 'tool.started')
|
|
72
74
|
return this.#toolStarted(event);
|
|
73
75
|
if (event.type === 'tool.completed')
|
|
74
|
-
return this.#toolCompleted(event);
|
|
75
|
-
return this.#toolFailed(event);
|
|
76
|
+
return [...this.#toolCompleted(event), ...this.#codeModeToolResults(event)];
|
|
77
|
+
return [...this.#toolFailed(event), ...this.#codeModeToolResults(event)];
|
|
76
78
|
}
|
|
77
79
|
if (event.type === 'hosted_tool.updated') {
|
|
78
80
|
const sourceSummary = event.summary.sources
|
|
@@ -318,6 +320,72 @@ export class MarAgentConversationNormalizer {
|
|
|
318
320
|
genericToolItem(state?.itemId ?? itemId('tool', event.callId), event.toolName, 'FAILED', state?.input, event.message ?? event.code, event.code, false, undefined, state?.subagentTitle, state?.subagentDetails)
|
|
319
321
|
];
|
|
320
322
|
}
|
|
323
|
+
#codeModeToolResults(event) {
|
|
324
|
+
const items = [];
|
|
325
|
+
for (const observation of codeModeToolResults(event)) {
|
|
326
|
+
if ((observation.id !== undefined && this.#codeModeObservationIds.has(observation.id)) ||
|
|
327
|
+
this.#codeModeSourceCallIds.has(observation.source.callId))
|
|
328
|
+
continue;
|
|
329
|
+
const { toolName, input } = observation.source;
|
|
330
|
+
let projected = [];
|
|
331
|
+
if (toolName === 'apply_patch') {
|
|
332
|
+
if ((typeof input !== 'string' && record(input) === undefined) ||
|
|
333
|
+
!isApplyPatchObservationData(observation.result.data))
|
|
334
|
+
continue;
|
|
335
|
+
projected = this.#normalizeObservedToolResult(event, observation, observation.result.data);
|
|
336
|
+
if (observation.outcome === 'failed')
|
|
337
|
+
projected = projected.map((item) => item.kind === 'file_change' ? { ...item, status: 'FAILED' } : item);
|
|
338
|
+
}
|
|
339
|
+
else if (toolName === 'todo_read') {
|
|
340
|
+
if (observation.outcome !== 'completed' ||
|
|
341
|
+
record(input) === undefined ||
|
|
342
|
+
!isTodoObservationData(observation.result.data))
|
|
343
|
+
continue;
|
|
344
|
+
const todo = observation.result.data;
|
|
345
|
+
projected = this.normalize({
|
|
346
|
+
...eventMetadata(event, observation.source.callId),
|
|
347
|
+
type: 'todo.updated',
|
|
348
|
+
revision: todo.revision,
|
|
349
|
+
items: todo.items
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
else if (marAgentSubagentOperation(toolName) !== undefined) {
|
|
353
|
+
if (!isSubagentToolInput(toolName, input) ||
|
|
354
|
+
!isSubagentObservationData(observation.result.data, input))
|
|
355
|
+
continue;
|
|
356
|
+
projected = this.#normalizeObservedToolResult(event, observation, observation.result.data);
|
|
357
|
+
}
|
|
358
|
+
if (projected.length === 0)
|
|
359
|
+
continue;
|
|
360
|
+
if (observation.id !== undefined)
|
|
361
|
+
this.#codeModeObservationIds.add(observation.id);
|
|
362
|
+
this.#codeModeSourceCallIds.add(observation.source.callId);
|
|
363
|
+
items.push(...projected);
|
|
364
|
+
}
|
|
365
|
+
return items;
|
|
366
|
+
}
|
|
367
|
+
#normalizeObservedToolResult(event, observation, data) {
|
|
368
|
+
const { callId, toolName, input } = observation.source;
|
|
369
|
+
const resultId = observation.id ?? 'call-' + callId;
|
|
370
|
+
this.normalize({
|
|
371
|
+
...eventMetadata(event, resultId + ':started'),
|
|
372
|
+
type: 'tool.started',
|
|
373
|
+
toolName,
|
|
374
|
+
callId,
|
|
375
|
+
input
|
|
376
|
+
});
|
|
377
|
+
return this.normalize({
|
|
378
|
+
...eventMetadata(event, resultId + ':completed'),
|
|
379
|
+
type: 'tool.completed',
|
|
380
|
+
toolName,
|
|
381
|
+
callId,
|
|
382
|
+
summary: observation.result.content,
|
|
383
|
+
...(observation.result.truncated === undefined
|
|
384
|
+
? {}
|
|
385
|
+
: { truncated: observation.result.truncated }),
|
|
386
|
+
data
|
|
387
|
+
});
|
|
388
|
+
}
|
|
321
389
|
#subagentLifecycle(event) {
|
|
322
390
|
const eventDescription = record(event)?.description;
|
|
323
391
|
const eventTitle = compactRunnerText(typeof eventDescription === 'string' ? eventDescription : '', 500);
|
|
@@ -358,6 +426,188 @@ export class MarAgentConversationNormalizer {
|
|
|
358
426
|
return [subagentStartItem(start, event.agentId, title, details, lifecycle)];
|
|
359
427
|
}
|
|
360
428
|
}
|
|
429
|
+
function codeModeToolResults(event) {
|
|
430
|
+
if ((event.type !== 'tool.completed' && event.type !== 'tool.failed') ||
|
|
431
|
+
(event.toolName !== 'code_exec' && event.toolName !== 'code_exec_wait'))
|
|
432
|
+
return [];
|
|
433
|
+
const data = record(record(event)?.data);
|
|
434
|
+
if (data === undefined ||
|
|
435
|
+
typeof data.cellId !== 'string' ||
|
|
436
|
+
data.cellId.trim().length === 0 ||
|
|
437
|
+
(data.status !== 'running' &&
|
|
438
|
+
data.status !== 'completed' &&
|
|
439
|
+
data.status !== 'failed' &&
|
|
440
|
+
data.status !== 'cancelled') ||
|
|
441
|
+
('cached' in data && typeof data.cached !== 'boolean'))
|
|
442
|
+
return [];
|
|
443
|
+
if (Object.hasOwn(data, 'calls'))
|
|
444
|
+
return codeModeCallResults(data.calls);
|
|
445
|
+
if (!Array.isArray(data.observations))
|
|
446
|
+
return [];
|
|
447
|
+
return codeModeObservationResults(data.observations);
|
|
448
|
+
}
|
|
449
|
+
function codeModeCallResults(value) {
|
|
450
|
+
if (!Array.isArray(value))
|
|
451
|
+
return [];
|
|
452
|
+
return value.flatMap((entry) => {
|
|
453
|
+
const call = record(entry);
|
|
454
|
+
const result = record(call?.result);
|
|
455
|
+
if (call === undefined ||
|
|
456
|
+
typeof call.callId !== 'string' ||
|
|
457
|
+
call.callId.trim().length === 0 ||
|
|
458
|
+
typeof call.toolName !== 'string' ||
|
|
459
|
+
!isPersonalizedToolName(call.toolName) ||
|
|
460
|
+
!('input' in call) ||
|
|
461
|
+
(call.status !== 'completed' && call.status !== 'failed') ||
|
|
462
|
+
result === undefined ||
|
|
463
|
+
typeof result.content !== 'string' ||
|
|
464
|
+
('truncated' in result && typeof result.truncated !== 'boolean'))
|
|
465
|
+
return [];
|
|
466
|
+
return [
|
|
467
|
+
{
|
|
468
|
+
source: { callId: call.callId, toolName: call.toolName, input: call.input },
|
|
469
|
+
outcome: call.status,
|
|
470
|
+
result: {
|
|
471
|
+
content: result.content,
|
|
472
|
+
...('data' in result ? { data: result.data } : {}),
|
|
473
|
+
...(typeof result.truncated === 'boolean' ? { truncated: result.truncated } : {})
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
];
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
function codeModeObservationResults(value) {
|
|
480
|
+
if (!Array.isArray(value))
|
|
481
|
+
return [];
|
|
482
|
+
return value.flatMap((value) => {
|
|
483
|
+
const observation = record(value);
|
|
484
|
+
const source = record(observation?.source);
|
|
485
|
+
const result = record(observation?.result);
|
|
486
|
+
if (observation?.type !== 'tool_result' ||
|
|
487
|
+
typeof observation.id !== 'string' ||
|
|
488
|
+
observation.id.trim().length === 0 ||
|
|
489
|
+
source === undefined ||
|
|
490
|
+
typeof source.callId !== 'string' ||
|
|
491
|
+
source.callId.trim().length === 0 ||
|
|
492
|
+
typeof source.toolName !== 'string' ||
|
|
493
|
+
!isPersonalizedToolName(source.toolName) ||
|
|
494
|
+
!('input' in source) ||
|
|
495
|
+
(observation.outcome !== 'completed' && observation.outcome !== 'failed') ||
|
|
496
|
+
result === undefined ||
|
|
497
|
+
typeof result.content !== 'string' ||
|
|
498
|
+
('truncated' in result && typeof result.truncated !== 'boolean'))
|
|
499
|
+
return [];
|
|
500
|
+
return [
|
|
501
|
+
{
|
|
502
|
+
id: observation.id,
|
|
503
|
+
source: { callId: source.callId, toolName: source.toolName, input: source.input },
|
|
504
|
+
outcome: observation.outcome,
|
|
505
|
+
result: {
|
|
506
|
+
content: result.content,
|
|
507
|
+
...('data' in result ? { data: result.data } : {}),
|
|
508
|
+
...(typeof result.truncated === 'boolean' ? { truncated: result.truncated } : {})
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
];
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
function isPersonalizedToolName(toolName) {
|
|
515
|
+
// todo_write already emits the native todo.updated event; avoid a duplicate card here.
|
|
516
|
+
return (toolName === 'apply_patch' ||
|
|
517
|
+
toolName === 'todo_read' ||
|
|
518
|
+
marAgentSubagentOperation(toolName) !== undefined);
|
|
519
|
+
}
|
|
520
|
+
function isApplyPatchObservationData(value) {
|
|
521
|
+
const data = record(value);
|
|
522
|
+
const totals = record(data?.totals);
|
|
523
|
+
return (data !== undefined &&
|
|
524
|
+
Array.isArray(data.files) &&
|
|
525
|
+
data.files.every((entry) => {
|
|
526
|
+
const file = record(entry);
|
|
527
|
+
return (file !== undefined &&
|
|
528
|
+
typeof file.path === 'string' &&
|
|
529
|
+
file.path.trim().length > 0 &&
|
|
530
|
+
(file.targetPath === undefined ||
|
|
531
|
+
(typeof file.targetPath === 'string' && file.targetPath.trim().length > 0)) &&
|
|
532
|
+
(file.operation === 'added' ||
|
|
533
|
+
file.operation === 'modified' ||
|
|
534
|
+
file.operation === 'deleted' ||
|
|
535
|
+
file.operation === 'moved') &&
|
|
536
|
+
(file.operation !== 'moved' ||
|
|
537
|
+
(typeof file.targetPath === 'string' && file.targetPath.trim().length > 0)) &&
|
|
538
|
+
Number.isSafeInteger(file.addedLines) &&
|
|
539
|
+
file.addedLines >= 0 &&
|
|
540
|
+
Number.isSafeInteger(file.deletedLines) &&
|
|
541
|
+
file.deletedLines >= 0);
|
|
542
|
+
}) &&
|
|
543
|
+
totals !== undefined &&
|
|
544
|
+
Number.isSafeInteger(totals.files) &&
|
|
545
|
+
totals.files >= 0 &&
|
|
546
|
+
Number.isSafeInteger(totals.addedLines) &&
|
|
547
|
+
totals.addedLines >= 0 &&
|
|
548
|
+
Number.isSafeInteger(totals.deletedLines) &&
|
|
549
|
+
totals.deletedLines >= 0);
|
|
550
|
+
}
|
|
551
|
+
function isTodoObservationData(value) {
|
|
552
|
+
const data = record(value);
|
|
553
|
+
if (data === undefined ||
|
|
554
|
+
!Number.isSafeInteger(data.revision) ||
|
|
555
|
+
data.revision < 0 ||
|
|
556
|
+
!Array.isArray(data.items))
|
|
557
|
+
return false;
|
|
558
|
+
return data.items.every((entry) => {
|
|
559
|
+
const item = record(entry);
|
|
560
|
+
if (item === undefined ||
|
|
561
|
+
typeof item.id !== 'string' ||
|
|
562
|
+
item.id.trim().length === 0 ||
|
|
563
|
+
typeof item.step !== 'string' ||
|
|
564
|
+
item.step.trim().length === 0 ||
|
|
565
|
+
(item.status !== 'pending' && item.status !== 'in_progress' && item.status !== 'completed') ||
|
|
566
|
+
!Number.isSafeInteger(data.revision))
|
|
567
|
+
return false;
|
|
568
|
+
return true;
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
function isSubagentToolInput(toolName, value) {
|
|
572
|
+
const input = record(value);
|
|
573
|
+
if (input === undefined)
|
|
574
|
+
return false;
|
|
575
|
+
if (toolName === 'agent_start')
|
|
576
|
+
return typeof input.description === 'string' && input.description.trim().length > 0;
|
|
577
|
+
if (toolName === 'agent_wait')
|
|
578
|
+
return (input.agentId === undefined || input.agentId === null || marAgentAgentId(input) !== undefined);
|
|
579
|
+
if (toolName === 'agent_message')
|
|
580
|
+
return (marAgentAgentId(input) !== undefined &&
|
|
581
|
+
typeof input.message === 'string' &&
|
|
582
|
+
input.message.trim().length > 0);
|
|
583
|
+
return toolName === 'agent_cancel' && marAgentAgentId(input) !== undefined;
|
|
584
|
+
}
|
|
585
|
+
function isSubagentObservationData(value, input) {
|
|
586
|
+
const data = record(value);
|
|
587
|
+
const inputAgentId = marAgentAgentId(input);
|
|
588
|
+
return (data !== undefined &&
|
|
589
|
+
typeof data.agentId === 'string' &&
|
|
590
|
+
data.agentId.trim().length > 0 &&
|
|
591
|
+
typeof data.description === 'string' &&
|
|
592
|
+
data.description.trim().length > 0 &&
|
|
593
|
+
(data.status === 'queued' ||
|
|
594
|
+
data.status === 'running' ||
|
|
595
|
+
data.status === 'completed' ||
|
|
596
|
+
data.status === 'failed' ||
|
|
597
|
+
data.status === 'cancelled' ||
|
|
598
|
+
data.status === 'interrupted') &&
|
|
599
|
+
(inputAgentId === undefined || data.agentId === inputAgentId));
|
|
600
|
+
}
|
|
601
|
+
function eventMetadata(event, eventId) {
|
|
602
|
+
return {
|
|
603
|
+
eventId,
|
|
604
|
+
sequence: event.sequence,
|
|
605
|
+
timestamp: event.timestamp,
|
|
606
|
+
sessionId: event.sessionId,
|
|
607
|
+
executionId: event.executionId,
|
|
608
|
+
...(event.turnId === undefined ? {} : { turnId: event.turnId })
|
|
609
|
+
};
|
|
610
|
+
}
|
|
361
611
|
function itemId(kind, value) {
|
|
362
612
|
return boundedConversationItemId(`mar-agent-${kind}`, value);
|
|
363
613
|
}
|
|
@@ -26,6 +26,7 @@ export function projectMarAgentHistory(session, entries, options = {}) {
|
|
|
26
26
|
return created;
|
|
27
27
|
};
|
|
28
28
|
const replace = (turn, item) => {
|
|
29
|
+
turn = turns.get(turn.id) ?? turn;
|
|
29
30
|
const index = turn.items.findIndex((candidate) => candidate.id === item.id);
|
|
30
31
|
const items = [...turn.items];
|
|
31
32
|
if (index < 0)
|
|
@@ -283,14 +283,11 @@ export class ContinuousRunCoordinator {
|
|
|
283
283
|
}
|
|
284
284
|
this.dispatchedExecutions.add(executionId);
|
|
285
285
|
try {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
const scheduledMcp = await this.options.scheduledMcp?.(
|
|
290
|
-
const mailMcp = await this.options.mailMcp?.(
|
|
291
|
-
...pending,
|
|
292
|
-
runId: executionId
|
|
293
|
-
});
|
|
286
|
+
// Capabilities belong to this Execution, never the Run or a claimed QueueItem.
|
|
287
|
+
const executionInput = { ...pending, runId: executionId };
|
|
288
|
+
const goalMcp = pending.goal === undefined ? undefined : await this.options.goalMcp?.(executionInput);
|
|
289
|
+
const scheduledMcp = await this.options.scheduledMcp?.(executionInput);
|
|
290
|
+
const mailMcp = await this.options.mailMcp?.(executionInput);
|
|
294
291
|
if (!this.goalCurrent(pending)) {
|
|
295
292
|
this.dispatchedExecutions.delete(executionId);
|
|
296
293
|
this.rejectStaleGoal(executionId);
|
|
@@ -147,6 +147,15 @@ export class WorkspaceCodeNavigationService {
|
|
|
147
147
|
truncated: targets.length > MAX_RESULTS || manifest.truncated
|
|
148
148
|
};
|
|
149
149
|
}
|
|
150
|
+
if (!current && cachedCandidates === undefined) {
|
|
151
|
+
const failure = this.failures.get(location.directory);
|
|
152
|
+
if (failure !== undefined) {
|
|
153
|
+
this.failures.delete(location.directory);
|
|
154
|
+
return emptyDefinitionResult({ state: 'FAILED', failureCode: failure });
|
|
155
|
+
}
|
|
156
|
+
this.startInitialBuild(workspace, location);
|
|
157
|
+
return emptyDefinitionResult({ state: 'BUILDING' });
|
|
158
|
+
}
|
|
150
159
|
const requireFresh = !current;
|
|
151
160
|
const prepared = await this.indexInput(workspace, requireFresh);
|
|
152
161
|
if (!current) {
|
|
@@ -2,13 +2,20 @@ import { isAbsolute, relative, resolve, win32 } from 'node:path';
|
|
|
2
2
|
import { realpath } from 'node:fs/promises';
|
|
3
3
|
import { listWorkspaceFiles, mutateWorkspaceFile, normalizeFilePreviewPath, readAbsoluteFileContentRange, readWorkspaceFileContentRange, readAbsoluteTextFile, readWorkspaceTextFile, writeWorkspaceTextFile, WorkspaceFileIndex } from '../workspace.js';
|
|
4
4
|
const INDEX_CACHE_LIMIT = 32;
|
|
5
|
+
const INDEX_CACHE_BYTE_LIMIT = 128 * 1024 * 1024;
|
|
6
|
+
const INDEX_QUEUE_LIMIT = 16;
|
|
7
|
+
const INDEX_QUEUE_TIMEOUT_MS = 120_000;
|
|
5
8
|
export class WorkspaceFileService {
|
|
6
9
|
state;
|
|
7
10
|
onInvalidated;
|
|
11
|
+
indexLimits;
|
|
8
12
|
mutationTails = new Map();
|
|
9
|
-
|
|
13
|
+
indexQueue = [];
|
|
14
|
+
indexRunning = false;
|
|
15
|
+
constructor(state, onInvalidated, indexLimits = {}) {
|
|
10
16
|
this.state = state;
|
|
11
17
|
this.onInvalidated = onInvalidated;
|
|
18
|
+
this.indexLimits = indexLimits;
|
|
12
19
|
}
|
|
13
20
|
async list(workspace, input) {
|
|
14
21
|
if ((input.path !== undefined && typeof input.path !== 'string') ||
|
|
@@ -151,12 +158,13 @@ export class WorkspaceFileService {
|
|
|
151
158
|
if (typeof input.query !== 'string' ||
|
|
152
159
|
(input.cursor !== undefined && typeof input.cursor !== 'string') ||
|
|
153
160
|
(input.includeDirectories !== undefined && typeof input.includeDirectories !== 'boolean') ||
|
|
161
|
+
(input.waitForIndex !== undefined && typeof input.waitForIndex !== 'boolean') ||
|
|
154
162
|
(input.limit !== undefined &&
|
|
155
163
|
(!Number.isInteger(input.limit) ||
|
|
156
164
|
input.limit < 1 ||
|
|
157
165
|
input.limit > 200)))
|
|
158
166
|
throw new Error('FILE_SEARCH_LIMIT_EXCEEDED');
|
|
159
|
-
return this.index(workspace).search(input.query, typeof input.cursor === 'string' ? input.cursor : undefined, typeof input.limit === 'number' ? input.limit : undefined, input.includeDirectories === true);
|
|
167
|
+
return this.index(workspace).search(input.query, typeof input.cursor === 'string' ? input.cursor : undefined, typeof input.limit === 'number' ? input.limit : undefined, input.includeDirectories === true, input.waitForIndex !== false);
|
|
160
168
|
}
|
|
161
169
|
async mutate(workspace, input) {
|
|
162
170
|
const action = input.action;
|
|
@@ -192,28 +200,104 @@ export class WorkspaceFileService {
|
|
|
192
200
|
index(workspace) {
|
|
193
201
|
let index = this.state.fileIndexes.get(workspace.id);
|
|
194
202
|
if (index === undefined) {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
}
|
|
202
|
-
index = new WorkspaceFileIndex(workspace.path);
|
|
203
|
+
index = new WorkspaceFileIndex(workspace.path, {
|
|
204
|
+
...(this.indexLimits.indexByteLimit === undefined
|
|
205
|
+
? {}
|
|
206
|
+
: { byteLimit: this.indexLimits.indexByteLimit }),
|
|
207
|
+
schedule: (run, background, needed) => this.scheduleIndex(run, background, needed),
|
|
208
|
+
onRebuilt: () => this.pruneIndexes(workspace.id)
|
|
209
|
+
});
|
|
203
210
|
}
|
|
204
211
|
this.state.fileIndexes.delete(workspace.id);
|
|
205
212
|
this.state.fileIndexes.set(workspace.id, index);
|
|
213
|
+
this.pruneIndexes(workspace.id);
|
|
206
214
|
return index;
|
|
207
215
|
}
|
|
216
|
+
prepare(workspace, needed) {
|
|
217
|
+
if (!needed())
|
|
218
|
+
return Promise.resolve();
|
|
219
|
+
return this.index(workspace).prepare(needed);
|
|
220
|
+
}
|
|
221
|
+
/** Removes queued preparation whose Watch consumers have all gone away. */
|
|
222
|
+
prunePreparations() {
|
|
223
|
+
for (let index = this.indexQueue.length - 1; index >= 0; index -= 1) {
|
|
224
|
+
const job = this.indexQueue[index];
|
|
225
|
+
if (job.needed())
|
|
226
|
+
continue;
|
|
227
|
+
this.indexQueue.splice(index, 1);
|
|
228
|
+
clearTimeout(job.timer);
|
|
229
|
+
job.resolve();
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
pruneIndexes(protectedId) {
|
|
233
|
+
let bytes = [...this.state.fileIndexes.values()].reduce((total, index) => total + index.accountedBytes, 0);
|
|
234
|
+
const byteLimit = this.indexLimits.cacheByteLimit ?? INDEX_CACHE_BYTE_LIMIT;
|
|
235
|
+
for (const [id, index] of this.state.fileIndexes) {
|
|
236
|
+
if (this.state.fileIndexes.size <= INDEX_CACHE_LIMIT && bytes <= byteLimit)
|
|
237
|
+
break;
|
|
238
|
+
if (id === protectedId)
|
|
239
|
+
continue;
|
|
240
|
+
bytes -= index.accountedBytes;
|
|
241
|
+
index.dispose();
|
|
242
|
+
this.state.fileIndexes.delete(id);
|
|
243
|
+
}
|
|
244
|
+
this.prunePreparations();
|
|
245
|
+
}
|
|
246
|
+
scheduleIndex(run, background, needed) {
|
|
247
|
+
this.prunePreparations();
|
|
248
|
+
if (!needed())
|
|
249
|
+
return Promise.resolve();
|
|
250
|
+
if (this.indexQueue.length >= (this.indexLimits.queueLimit ?? INDEX_QUEUE_LIMIT))
|
|
251
|
+
return Promise.reject(new Error('FILE_SEARCH_LIMIT_EXCEEDED'));
|
|
252
|
+
return new Promise((resolve, reject) => {
|
|
253
|
+
const job = {
|
|
254
|
+
run,
|
|
255
|
+
background,
|
|
256
|
+
needed,
|
|
257
|
+
resolve: () => resolve(),
|
|
258
|
+
reject,
|
|
259
|
+
timer: setTimeout(() => {
|
|
260
|
+
const index = this.indexQueue.indexOf(job);
|
|
261
|
+
if (index < 0)
|
|
262
|
+
return;
|
|
263
|
+
this.indexQueue.splice(index, 1);
|
|
264
|
+
reject(new Error('FILE_SEARCH_LIMIT_EXCEEDED'));
|
|
265
|
+
}, this.indexLimits.queueTimeoutMs ?? INDEX_QUEUE_TIMEOUT_MS)
|
|
266
|
+
};
|
|
267
|
+
job.timer.unref();
|
|
268
|
+
this.indexQueue.push(job);
|
|
269
|
+
this.runNextIndex();
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
runNextIndex() {
|
|
273
|
+
if (this.indexRunning)
|
|
274
|
+
return;
|
|
275
|
+
this.prunePreparations();
|
|
276
|
+
const foreground = this.indexQueue.findIndex((job) => !job.background());
|
|
277
|
+
const job = this.indexQueue.splice(foreground < 0 ? 0 : foreground, 1)[0];
|
|
278
|
+
if (job === undefined)
|
|
279
|
+
return;
|
|
280
|
+
clearTimeout(job.timer);
|
|
281
|
+
this.indexRunning = true;
|
|
282
|
+
void Promise.resolve()
|
|
283
|
+
.then(job.run)
|
|
284
|
+
.then(job.resolve, job.reject)
|
|
285
|
+
.finally(() => {
|
|
286
|
+
this.indexRunning = false;
|
|
287
|
+
this.runNextIndex();
|
|
288
|
+
});
|
|
289
|
+
}
|
|
208
290
|
dispose(workspaceId) {
|
|
209
291
|
if (workspaceId !== undefined) {
|
|
210
292
|
this.state.fileIndexes.get(workspaceId)?.dispose();
|
|
211
293
|
this.state.fileIndexes.delete(workspaceId);
|
|
294
|
+
this.prunePreparations();
|
|
212
295
|
return;
|
|
213
296
|
}
|
|
214
297
|
for (const index of this.state.fileIndexes.values())
|
|
215
298
|
index.dispose();
|
|
216
299
|
this.state.fileIndexes.clear();
|
|
300
|
+
this.prunePreparations();
|
|
217
301
|
}
|
|
218
302
|
}
|
|
219
303
|
function isFilePreviewAbsolutePath(path) {
|
|
@@ -24,12 +24,15 @@ export class WorkspaceWatchController {
|
|
|
24
24
|
const existing = this.options.watch.existing(workspace.id, key);
|
|
25
25
|
if (existing !== undefined &&
|
|
26
26
|
existing.watchId === input.watchId &&
|
|
27
|
-
JSON.stringify(existing.topics) === JSON.stringify(topics)
|
|
27
|
+
JSON.stringify(existing.topics) === JSON.stringify(topics) &&
|
|
28
|
+
(existing.prepareFiles === true) === (input.prepareFiles === true)) {
|
|
28
29
|
this.options.watch.renew(workspace.id, key, input.watchId);
|
|
29
30
|
return this.accepted(existing);
|
|
30
31
|
}
|
|
31
|
-
const watch = this.options.watch.create(workspace.id, input.watchId, this.options.watch.sessionHash(input), input.workbenchConnectionId, this.options.nodeGeneration(), topics);
|
|
32
|
+
const watch = this.options.watch.create(workspace.id, input.watchId, this.options.watch.sessionHash(input), input.workbenchConnectionId, this.options.nodeGeneration(), topics, input.prepareFiles === true);
|
|
32
33
|
void this.options.snapshots.initial(workspace, watch);
|
|
34
|
+
if (watch.prepareFiles)
|
|
35
|
+
this.options.snapshots.prewarmFiles(workspace, watch);
|
|
33
36
|
return this.accepted(watch);
|
|
34
37
|
}
|
|
35
38
|
unwatch(input) {
|
|
@@ -49,7 +52,9 @@ export class WorkspaceWatchController {
|
|
|
49
52
|
typeof input.watchId !== 'string' ||
|
|
50
53
|
typeof input.workbenchConnectionId !== 'string' ||
|
|
51
54
|
!/^[A-Za-z0-9_-]{8,128}$/.test(input.watchId) ||
|
|
52
|
-
(input.mode !== 'initial' && input.mode !== 'renew')
|
|
55
|
+
(input.mode !== 'initial' && input.mode !== 'renew') ||
|
|
56
|
+
(input.prepareFiles !== undefined &&
|
|
57
|
+
(input.mode !== 'initial' || input.prepareFiles !== true)))
|
|
53
58
|
throw new Error('WORKSPACE_WATCH_INVALID');
|
|
54
59
|
}
|
|
55
60
|
accepted(watch) {
|
|
@@ -5,11 +5,13 @@ export class WorkspaceWatchService {
|
|
|
5
5
|
onSessionWatchRequired;
|
|
6
6
|
onSessionWatchUnused;
|
|
7
7
|
onWorkspaceWatchUnused;
|
|
8
|
-
|
|
8
|
+
onWatchRemoved;
|
|
9
|
+
constructor(state, onSessionWatchRequired, onSessionWatchUnused, onWorkspaceWatchUnused, onWatchRemoved = () => undefined) {
|
|
9
10
|
this.state = state;
|
|
10
11
|
this.onSessionWatchRequired = onSessionWatchRequired;
|
|
11
12
|
this.onSessionWatchUnused = onSessionWatchUnused;
|
|
12
13
|
this.onWorkspaceWatchUnused = onWorkspaceWatchUnused;
|
|
14
|
+
this.onWatchRemoved = onWatchRemoved;
|
|
13
15
|
}
|
|
14
16
|
parseTopics(input) {
|
|
15
17
|
if (!plain(input.topics))
|
|
@@ -51,7 +53,7 @@ export class WorkspaceWatchService {
|
|
|
51
53
|
throw new Error('WORKSPACE_WATCH_INVALID');
|
|
52
54
|
return input.workbenchConnectionId;
|
|
53
55
|
}
|
|
54
|
-
create(workspaceId, watchId, sessionHash, workbenchConnectionId, nodeGeneration, topics) {
|
|
56
|
+
create(workspaceId, watchId, sessionHash, workbenchConnectionId, nodeGeneration, topics, prepareFiles = false) {
|
|
55
57
|
const key = `${sessionHash}:${workbenchConnectionId}`;
|
|
56
58
|
for (const [activeWorkspaceId, watches] of this.state.watches)
|
|
57
59
|
if (watches.has(key))
|
|
@@ -64,6 +66,7 @@ export class WorkspaceWatchService {
|
|
|
64
66
|
nodeGeneration,
|
|
65
67
|
expiresAt: Date.now() + WATCH_TTL_MS,
|
|
66
68
|
topics,
|
|
69
|
+
prepareFiles,
|
|
67
70
|
timer: undefined
|
|
68
71
|
};
|
|
69
72
|
watch.timer = this.expiryTimer(workspaceId, key);
|
|
@@ -102,8 +105,10 @@ export class WorkspaceWatchService {
|
|
|
102
105
|
return;
|
|
103
106
|
clearTimeout(watch.timer);
|
|
104
107
|
watches?.delete(key);
|
|
105
|
-
if (watches?.size === 0)
|
|
108
|
+
if (watches?.size === 0)
|
|
106
109
|
this.state.watches.delete(workspaceId);
|
|
110
|
+
this.onWatchRemoved(watch);
|
|
111
|
+
if (watches?.size === 0) {
|
|
107
112
|
this.clearRevisions(workspaceId);
|
|
108
113
|
this.onWorkspaceWatchUnused(workspaceId);
|
|
109
114
|
}
|
|
@@ -112,20 +117,25 @@ export class WorkspaceWatchService {
|
|
|
112
117
|
}
|
|
113
118
|
clear(workspaceId) {
|
|
114
119
|
const watches = this.state.watches.get(workspaceId);
|
|
120
|
+
this.state.watches.delete(workspaceId);
|
|
115
121
|
if (watches !== undefined)
|
|
116
|
-
for (const watch of watches.values())
|
|
122
|
+
for (const watch of watches.values()) {
|
|
117
123
|
clearTimeout(watch.timer);
|
|
118
|
-
|
|
124
|
+
this.onWatchRemoved(watch);
|
|
125
|
+
}
|
|
119
126
|
this.clearRevisions(workspaceId);
|
|
120
127
|
this.onSessionWatchUnused(workspaceId);
|
|
121
128
|
this.onWorkspaceWatchUnused(workspaceId);
|
|
122
129
|
}
|
|
123
130
|
clearAll() {
|
|
124
131
|
const workspaceIds = [...this.state.watches.keys()];
|
|
125
|
-
|
|
126
|
-
for (const watch of watches.values())
|
|
127
|
-
clearTimeout(watch.timer);
|
|
132
|
+
const watchesByWorkspace = [...this.state.watches.values()];
|
|
128
133
|
this.state.watches.clear();
|
|
134
|
+
for (const watches of watchesByWorkspace)
|
|
135
|
+
for (const watch of watches.values()) {
|
|
136
|
+
clearTimeout(watch.timer);
|
|
137
|
+
this.onWatchRemoved(watch);
|
|
138
|
+
}
|
|
129
139
|
this.state.watchRevisions.clear();
|
|
130
140
|
for (const workspaceId of workspaceIds) {
|
|
131
141
|
this.onSessionWatchUnused(workspaceId);
|
|
@@ -2,6 +2,7 @@ const FILES_REFRESH_INTERVAL_MS = 30_000;
|
|
|
2
2
|
/** Owns Workspace topic reads, single-flight invalidation and directed snapshot delivery. */
|
|
3
3
|
export class WorkspaceWatchSnapshotService {
|
|
4
4
|
options;
|
|
5
|
+
filePrewarmTimers = new Map();
|
|
5
6
|
refreshes = new Map();
|
|
6
7
|
dirty = new Set();
|
|
7
8
|
reads = new Map();
|
|
@@ -26,6 +27,23 @@ export class WorkspaceWatchSnapshotService {
|
|
|
26
27
|
}
|
|
27
28
|
}));
|
|
28
29
|
}
|
|
30
|
+
prewarmFiles(workspace, watch) {
|
|
31
|
+
this.cancelFilePrewarm(watch);
|
|
32
|
+
const timer = setTimeout(() => {
|
|
33
|
+
this.filePrewarmTimers.delete(watch);
|
|
34
|
+
const needed = () => this.matches(workspace.id, watch) && watch.prepareFiles === true;
|
|
35
|
+
if (needed())
|
|
36
|
+
void this.options.files.prepare(workspace, needed).catch(() => undefined);
|
|
37
|
+
}, 750);
|
|
38
|
+
timer.unref();
|
|
39
|
+
this.filePrewarmTimers.set(watch, timer);
|
|
40
|
+
}
|
|
41
|
+
cancelFilePrewarm(watch) {
|
|
42
|
+
const timer = this.filePrewarmTimers.get(watch);
|
|
43
|
+
if (timer !== undefined)
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
this.filePrewarmTimers.delete(watch);
|
|
46
|
+
}
|
|
29
47
|
async invalidate(workspaceId, topic) {
|
|
30
48
|
const key = `${workspaceId}:${topic}`;
|
|
31
49
|
const active = this.refreshes.get(key);
|
|
@@ -54,6 +72,11 @@ export class WorkspaceWatchSnapshotService {
|
|
|
54
72
|
return this.refreshes.get(`${workspaceId}:${topic}`) ?? this.invalidate(workspaceId, topic);
|
|
55
73
|
}
|
|
56
74
|
clear(workspaceId) {
|
|
75
|
+
for (const [watch, timer] of this.filePrewarmTimers)
|
|
76
|
+
if (watch.workspaceId === workspaceId) {
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
this.filePrewarmTimers.delete(watch);
|
|
79
|
+
}
|
|
57
80
|
for (const key of [...this.dirty])
|
|
58
81
|
if (key.startsWith(`${workspaceId}:`))
|
|
59
82
|
this.dirty.delete(key);
|
|
@@ -62,6 +85,9 @@ export class WorkspaceWatchSnapshotService {
|
|
|
62
85
|
this.signatures.delete(key);
|
|
63
86
|
}
|
|
64
87
|
clearAll() {
|
|
88
|
+
for (const timer of this.filePrewarmTimers.values())
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
this.filePrewarmTimers.clear();
|
|
65
91
|
if (this.filesRefreshTimer !== undefined)
|
|
66
92
|
clearInterval(this.filesRefreshTimer);
|
|
67
93
|
this.filesRefreshTimer = undefined;
|
|
@@ -26,6 +26,10 @@ export class WorkspaceWorkbenchService {
|
|
|
26
26
|
nodeGeneration: options.nodeGeneration
|
|
27
27
|
});
|
|
28
28
|
}
|
|
29
|
+
cancelFilePrewarm(watch) {
|
|
30
|
+
this.snapshots.cancelFilePrewarm(watch);
|
|
31
|
+
this.options.files.prunePreparations();
|
|
32
|
+
}
|
|
29
33
|
operations() {
|
|
30
34
|
return {
|
|
31
35
|
...this.watchController.operations(),
|
package/dist/workspace.js
CHANGED
|
@@ -3,7 +3,7 @@ import { lstat, mkdir, opendir, open, readdir, readFile, realpath, rename, rm, s
|
|
|
3
3
|
import { homedir, platform as hostPlatform } from 'node:os';
|
|
4
4
|
import { basename, dirname, isAbsolute, posix, relative, resolve, sep, win32 } from 'node:path';
|
|
5
5
|
import { spawn } from 'node:child_process';
|
|
6
|
-
import {
|
|
6
|
+
import { Minimatch } from 'minimatch';
|
|
7
7
|
import { logNodeCommandFailure } from './operational.js';
|
|
8
8
|
import { terminateProcessTree } from './process-tree.js';
|
|
9
9
|
import { gitOperationEnvironment } from './service/git-operation-service.js';
|
|
@@ -21,6 +21,7 @@ const FILE_INDEX_IDLE_TTL_MS = 60 * 60 * 1_000;
|
|
|
21
21
|
const FILE_INDEX_FRESHNESS_TTL_MS = 30_000;
|
|
22
22
|
const FILE_INDEX_ENTRY_LIMIT = 100_000;
|
|
23
23
|
const FILE_INDEX_METADATA_CONCURRENCY = 32;
|
|
24
|
+
const FILE_INDEX_BYTE_LIMIT = 64 * 1024 * 1024;
|
|
24
25
|
const DEFAULT_QUICK_OPEN_EXCLUDES = [
|
|
25
26
|
'**/.git/**',
|
|
26
27
|
'**/node_modules/**',
|
|
@@ -84,6 +85,11 @@ export class WorkspaceFileIndex {
|
|
|
84
85
|
workspaceRoot;
|
|
85
86
|
limits;
|
|
86
87
|
entries = [];
|
|
88
|
+
fileEntries = [];
|
|
89
|
+
retainedBytes = 0;
|
|
90
|
+
failure;
|
|
91
|
+
foregroundRequested = false;
|
|
92
|
+
preparationConsumers = new Set();
|
|
87
93
|
truncated = false;
|
|
88
94
|
root;
|
|
89
95
|
loading;
|
|
@@ -96,6 +102,28 @@ export class WorkspaceFileIndex {
|
|
|
96
102
|
this.workspaceRoot = workspaceRoot;
|
|
97
103
|
this.limits = limits;
|
|
98
104
|
}
|
|
105
|
+
get accountedBytes() {
|
|
106
|
+
return this.retainedBytes;
|
|
107
|
+
}
|
|
108
|
+
/** Prepares only metadata; a queued background scan may lose its consumer. */
|
|
109
|
+
prepare(needed = () => true) {
|
|
110
|
+
if (!this.stale && this.root !== undefined) {
|
|
111
|
+
this.touch();
|
|
112
|
+
return Promise.resolve();
|
|
113
|
+
}
|
|
114
|
+
if (!needed())
|
|
115
|
+
return Promise.resolve();
|
|
116
|
+
this.preparationConsumers.add(needed);
|
|
117
|
+
this.startLoading(true, () => {
|
|
118
|
+
for (const consumer of this.preparationConsumers) {
|
|
119
|
+
if (consumer())
|
|
120
|
+
return true;
|
|
121
|
+
this.preparationConsumers.delete(consumer);
|
|
122
|
+
}
|
|
123
|
+
return false;
|
|
124
|
+
});
|
|
125
|
+
return this.loading;
|
|
126
|
+
}
|
|
99
127
|
async contentCandidates(options = {}) {
|
|
100
128
|
await this.ensureLoaded(options.requireFresh === true);
|
|
101
129
|
return this.currentContentCandidates();
|
|
@@ -118,16 +146,12 @@ export class WorkspaceFileIndex {
|
|
|
118
146
|
if (this.loading !== undefined)
|
|
119
147
|
return this.loading;
|
|
120
148
|
if (this.stale || this.root === undefined) {
|
|
121
|
-
this.
|
|
122
|
-
this.loading = undefined;
|
|
123
|
-
});
|
|
149
|
+
this.startLoading();
|
|
124
150
|
return this.loading;
|
|
125
151
|
}
|
|
126
152
|
if (requireFresh) {
|
|
127
153
|
this.refreshAttemptedAt = Date.now();
|
|
128
|
-
this.
|
|
129
|
-
this.loading = undefined;
|
|
130
|
-
});
|
|
154
|
+
this.startLoading();
|
|
131
155
|
return this.loading;
|
|
132
156
|
}
|
|
133
157
|
if (Date.now() - this.refreshAttemptedAt >=
|
|
@@ -138,7 +162,7 @@ export class WorkspaceFileIndex {
|
|
|
138
162
|
currentContentCandidates() {
|
|
139
163
|
this.touch();
|
|
140
164
|
return {
|
|
141
|
-
entries: this.
|
|
165
|
+
entries: this.fileEntries,
|
|
142
166
|
truncated: this.truncated
|
|
143
167
|
};
|
|
144
168
|
}
|
|
@@ -148,25 +172,45 @@ export class WorkspaceFileIndex {
|
|
|
148
172
|
clearTimeout(this.expiryTimer);
|
|
149
173
|
this.expiryTimer = undefined;
|
|
150
174
|
this.entries = [];
|
|
175
|
+
this.fileEntries = [];
|
|
176
|
+
this.root = undefined;
|
|
177
|
+
this.retainedBytes = 0;
|
|
178
|
+
this.failure = undefined;
|
|
179
|
+
this.foregroundRequested = false;
|
|
180
|
+
this.preparationConsumers.clear();
|
|
151
181
|
this.truncated = false;
|
|
152
182
|
this.stale = true;
|
|
153
183
|
this.refreshedAt = 0;
|
|
154
184
|
this.refreshAttemptedAt = 0;
|
|
155
185
|
}
|
|
156
|
-
async search(query, cursor, limit = SEARCH_RESULT_LIMIT, includeDirectories = false) {
|
|
186
|
+
async search(query, cursor, limit = SEARCH_RESULT_LIMIT, includeDirectories = false, waitForIndex = true) {
|
|
187
|
+
const generation = this.lifecycleGeneration;
|
|
157
188
|
if (query.trim().length === 0 ||
|
|
158
189
|
query.length > 256 ||
|
|
159
190
|
!Number.isSafeInteger(limit) ||
|
|
160
191
|
limit < 1 ||
|
|
161
192
|
limit > SEARCH_RESULT_LIMIT)
|
|
162
193
|
throw new Error('FILE_SEARCH_LIMIT_EXCEEDED');
|
|
194
|
+
const after = decodeSearchCursor(cursor, query);
|
|
195
|
+
if (!waitForIndex && (this.stale || this.root === undefined)) {
|
|
196
|
+
if (this.failure !== undefined) {
|
|
197
|
+
const failure = this.failure;
|
|
198
|
+
this.failure = undefined;
|
|
199
|
+
throw failure;
|
|
200
|
+
}
|
|
201
|
+
this.foregroundRequested = true;
|
|
202
|
+
this.startLoading();
|
|
203
|
+
return { entries: [], nextCursor: null, truncated: false, indexState: 'BUILDING' };
|
|
204
|
+
}
|
|
163
205
|
await this.ensureLoaded();
|
|
206
|
+
if (generation !== this.lifecycleGeneration || this.root === undefined)
|
|
207
|
+
throw new Error('FILE_INDEX_INVALIDATED');
|
|
164
208
|
this.touch();
|
|
165
|
-
const
|
|
209
|
+
const terms = query.trim().toLocaleLowerCase().split(/\s+/u);
|
|
166
210
|
const afterEntry = after === undefined
|
|
167
211
|
? undefined
|
|
168
212
|
: this.entries.find((entry) => entry.path === after && (includeDirectories || entry.kind === 'FILE'));
|
|
169
|
-
const afterScore = afterEntry === undefined ? -1 : fuzzyPathScore(afterEntry.path,
|
|
213
|
+
const afterScore = afterEntry === undefined ? -1 : fuzzyPathScore(afterEntry.path, terms);
|
|
170
214
|
const afterCandidate = afterEntry === undefined || afterScore < 0
|
|
171
215
|
? undefined
|
|
172
216
|
: { entry: afterEntry, score: afterScore };
|
|
@@ -174,7 +218,7 @@ export class WorkspaceFileIndex {
|
|
|
174
218
|
for (const entry of this.entries) {
|
|
175
219
|
if (!includeDirectories && entry.kind !== 'FILE')
|
|
176
220
|
continue;
|
|
177
|
-
const score = fuzzyPathScore(entry.path,
|
|
221
|
+
const score = fuzzyPathScore(entry.path, terms);
|
|
178
222
|
if (score < 0)
|
|
179
223
|
continue;
|
|
180
224
|
const candidate = { entry, score };
|
|
@@ -183,38 +227,75 @@ export class WorkspaceFileIndex {
|
|
|
183
227
|
ranked.add(candidate);
|
|
184
228
|
}
|
|
185
229
|
const candidates = ranked.sorted();
|
|
186
|
-
const page = candidates.slice(0, limit).map((candidate) =>
|
|
230
|
+
const page = candidates.slice(0, limit).map((candidate) => ({
|
|
231
|
+
...candidate.entry,
|
|
232
|
+
score: candidate.score
|
|
233
|
+
}));
|
|
187
234
|
const next = candidates[limit]?.entry.path;
|
|
188
235
|
return {
|
|
189
236
|
entries: page,
|
|
190
237
|
nextCursor: next === undefined ? null : encodeSearchCursor(query, page.at(-1)?.path ?? after ?? ''),
|
|
191
|
-
truncated: next !== undefined || this.truncated
|
|
238
|
+
truncated: next !== undefined || this.truncated,
|
|
239
|
+
...(!waitForIndex ? { indexState: 'READY' } : {})
|
|
192
240
|
};
|
|
193
241
|
}
|
|
194
242
|
async ensureLoaded(requireFresh = false) {
|
|
243
|
+
const generation = this.lifecycleGeneration;
|
|
195
244
|
if (!this.stale && this.root !== undefined) {
|
|
196
245
|
if (Date.now() - this.refreshAttemptedAt >=
|
|
197
246
|
(this.limits.freshnessTtlMs ?? FILE_INDEX_FRESHNESS_TTL_MS)) {
|
|
198
247
|
if (requireFresh) {
|
|
248
|
+
this.foregroundRequested = true;
|
|
199
249
|
if (this.loading === undefined) {
|
|
200
250
|
this.refreshAttemptedAt = Date.now();
|
|
201
|
-
this.
|
|
202
|
-
this.loading = undefined;
|
|
203
|
-
});
|
|
251
|
+
this.startLoading();
|
|
204
252
|
}
|
|
205
253
|
await this.loading;
|
|
254
|
+
if (generation !== this.lifecycleGeneration)
|
|
255
|
+
throw new Error('FILE_INDEX_INVALIDATED');
|
|
206
256
|
}
|
|
207
257
|
else
|
|
208
258
|
this.refreshInBackground();
|
|
209
259
|
}
|
|
210
260
|
return;
|
|
211
261
|
}
|
|
212
|
-
|
|
213
|
-
this.loading = this.rebuild().finally(() => {
|
|
214
|
-
this.loading = undefined;
|
|
215
|
-
});
|
|
216
|
-
}
|
|
262
|
+
this.startLoading();
|
|
217
263
|
await this.loading;
|
|
264
|
+
if (generation !== this.lifecycleGeneration)
|
|
265
|
+
throw new Error('FILE_INDEX_INVALIDATED');
|
|
266
|
+
}
|
|
267
|
+
startLoading(background = false, needed = () => true) {
|
|
268
|
+
if (!background)
|
|
269
|
+
this.foregroundRequested = true;
|
|
270
|
+
if (this.loading !== undefined)
|
|
271
|
+
return;
|
|
272
|
+
const generation = this.lifecycleGeneration;
|
|
273
|
+
const current = () => generation === this.lifecycleGeneration;
|
|
274
|
+
const required = () => current() && (this.foregroundRequested || needed());
|
|
275
|
+
const run = async () => {
|
|
276
|
+
if (!current())
|
|
277
|
+
throw new Error('FILE_INDEX_INVALIDATED');
|
|
278
|
+
if (required())
|
|
279
|
+
await this.rebuild();
|
|
280
|
+
if (!current())
|
|
281
|
+
throw new Error('FILE_INDEX_INVALIDATED');
|
|
282
|
+
};
|
|
283
|
+
const pending = (this.limits.schedule?.(run, () => !this.foregroundRequested, required) ?? run())
|
|
284
|
+
.catch((error) => {
|
|
285
|
+
if (current())
|
|
286
|
+
this.failure = error;
|
|
287
|
+
throw error;
|
|
288
|
+
})
|
|
289
|
+
.finally(() => {
|
|
290
|
+
if (this.loading === pending) {
|
|
291
|
+
this.loading = undefined;
|
|
292
|
+
this.foregroundRequested = false;
|
|
293
|
+
this.preparationConsumers.clear();
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
this.loading = pending;
|
|
297
|
+
// A fast acknowledgement has no awaiting consumer; retain failure for its next query.
|
|
298
|
+
void this.loading.catch(() => undefined);
|
|
218
299
|
}
|
|
219
300
|
touch() {
|
|
220
301
|
if (this.expiryTimer !== undefined)
|
|
@@ -227,11 +308,23 @@ export class WorkspaceFileIndex {
|
|
|
227
308
|
const root = await resolveRegisteredWorkspaceRoot(this.workspaceRoot);
|
|
228
309
|
if (generation !== this.lifecycleGeneration)
|
|
229
310
|
return;
|
|
230
|
-
const
|
|
311
|
+
const excludeOptions = { dot: true, nocase: hostPlatform() !== 'linux' };
|
|
312
|
+
const excludes = [
|
|
313
|
+
// Fixed directory globs can use their compiled regex without splitting every path.
|
|
314
|
+
...DEFAULT_QUICK_OPEN_EXCLUDES.map((pattern) => {
|
|
315
|
+
const matcher = new Minimatch(pattern, excludeOptions);
|
|
316
|
+
const regex = matcher.makeRe();
|
|
317
|
+
// Globstar also matches line terminators in valid filesystem names.
|
|
318
|
+
return regex ? new RegExp(regex.source, `${regex.flags}s`) : matcher;
|
|
319
|
+
}),
|
|
320
|
+
...(await workspaceExcludePatterns(root)).map((pattern) => new Minimatch(pattern, excludeOptions))
|
|
321
|
+
];
|
|
231
322
|
if (generation !== this.lifecycleGeneration)
|
|
232
323
|
return;
|
|
233
324
|
const indexed = [];
|
|
234
325
|
const entryLimit = this.limits.entryLimit ?? FILE_INDEX_ENTRY_LIMIT;
|
|
326
|
+
const byteLimit = this.limits.byteLimit ?? FILE_INDEX_BYTE_LIMIT;
|
|
327
|
+
let bytes = 0;
|
|
235
328
|
let truncated = false;
|
|
236
329
|
const directories = ['.'];
|
|
237
330
|
let directoryCursor = 0;
|
|
@@ -255,10 +348,14 @@ export class WorkspaceFileIndex {
|
|
|
255
348
|
for (const entry of resolvedEntries) {
|
|
256
349
|
if (entry === undefined)
|
|
257
350
|
continue;
|
|
258
|
-
if (entry.path === '.git' ||
|
|
259
|
-
entry.path.startsWith('.git/') ||
|
|
260
|
-
isExcluded(entry.path, excludes))
|
|
351
|
+
if (entry.path === '.git' || entry.path.startsWith('.git/'))
|
|
261
352
|
continue;
|
|
353
|
+
const entryBytes = 160 + 2 * (entry.path.length + entry.name.length);
|
|
354
|
+
if (bytes + entryBytes > byteLimit) {
|
|
355
|
+
truncated = true;
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
bytes += entryBytes;
|
|
262
359
|
if (entry.kind === 'DIRECTORY')
|
|
263
360
|
directories.push(entry.path);
|
|
264
361
|
indexed.push(entry);
|
|
@@ -278,7 +375,7 @@ export class WorkspaceFileIndex {
|
|
|
278
375
|
candidatePath.startsWith('.git/') ||
|
|
279
376
|
isExcluded(candidatePath, excludes))
|
|
280
377
|
continue;
|
|
281
|
-
pending.push(inspectWorkspaceIndexEntry(root, candidatePath, child.name));
|
|
378
|
+
pending.push(inspectWorkspaceIndexEntry(root, candidatePath, child.name, excludes));
|
|
282
379
|
if (pending.length >= FILE_INDEX_METADATA_CONCURRENCY)
|
|
283
380
|
await appendPending();
|
|
284
381
|
if (truncated)
|
|
@@ -291,20 +388,21 @@ export class WorkspaceFileIndex {
|
|
|
291
388
|
return;
|
|
292
389
|
this.root = root;
|
|
293
390
|
this.entries = indexed;
|
|
391
|
+
this.fileEntries = indexed.filter((entry) => entry.kind === 'FILE');
|
|
392
|
+
this.retainedBytes = bytes;
|
|
393
|
+
this.failure = undefined;
|
|
294
394
|
this.truncated = truncated;
|
|
295
395
|
this.stale = false;
|
|
296
396
|
this.refreshedAt = Date.now();
|
|
297
397
|
this.refreshAttemptedAt = this.refreshedAt;
|
|
398
|
+
this.touch();
|
|
399
|
+
this.limits.onRebuilt?.();
|
|
298
400
|
}
|
|
299
401
|
refreshInBackground() {
|
|
300
402
|
if (this.loading !== undefined)
|
|
301
403
|
return;
|
|
302
404
|
this.refreshAttemptedAt = Date.now();
|
|
303
|
-
this.
|
|
304
|
-
.catch(() => undefined)
|
|
305
|
-
.finally(() => {
|
|
306
|
-
this.loading = undefined;
|
|
307
|
-
});
|
|
405
|
+
this.startLoading(true);
|
|
308
406
|
}
|
|
309
407
|
}
|
|
310
408
|
async function workspaceExcludePatterns(root) {
|
|
@@ -322,8 +420,9 @@ async function workspaceExcludePatterns(root) {
|
|
|
322
420
|
});
|
|
323
421
|
}
|
|
324
422
|
function isExcluded(path, patterns) {
|
|
325
|
-
return patterns.some((pattern) =>
|
|
326
|
-
|
|
423
|
+
return patterns.some((pattern) => pattern instanceof RegExp
|
|
424
|
+
? pattern.test(path) || pattern.test(`${path}/`)
|
|
425
|
+
: pattern.match(path) || pattern.match(`${path}/`));
|
|
327
426
|
}
|
|
328
427
|
function compareRankedCandidate(left, right) {
|
|
329
428
|
return right.score - left.score || left.entry.path.localeCompare(right.entry.path);
|
|
@@ -383,9 +482,8 @@ class RankedCandidateHeap {
|
|
|
383
482
|
}
|
|
384
483
|
}
|
|
385
484
|
}
|
|
386
|
-
function fuzzyPathScore(path,
|
|
485
|
+
function fuzzyPathScore(path, terms) {
|
|
387
486
|
const source = path.toLocaleLowerCase();
|
|
388
|
-
const terms = query.trim().toLocaleLowerCase().split(/\s+/u);
|
|
389
487
|
const name = source.slice(source.lastIndexOf('/') + 1);
|
|
390
488
|
let total = 0;
|
|
391
489
|
for (const needle of terms) {
|
|
@@ -397,6 +495,14 @@ function fuzzyPathScore(path, query) {
|
|
|
397
495
|
return total - path.length / 1_000;
|
|
398
496
|
}
|
|
399
497
|
function fuzzyTermScore(source, name, needle) {
|
|
498
|
+
if (name === needle || source === needle)
|
|
499
|
+
return 1_000;
|
|
500
|
+
if (name.startsWith(needle) || source.startsWith(needle))
|
|
501
|
+
return 700;
|
|
502
|
+
if (source.endsWith(`/${needle}`) || source.includes(`/${needle}/`))
|
|
503
|
+
return 600;
|
|
504
|
+
if (name.includes(needle) || source.includes(needle))
|
|
505
|
+
return 500;
|
|
400
506
|
let cursor = 0;
|
|
401
507
|
let score = 0;
|
|
402
508
|
let previous = -2;
|
|
@@ -410,11 +516,7 @@ function fuzzyTermScore(source, name, needle) {
|
|
|
410
516
|
previous = index;
|
|
411
517
|
cursor = index + 1;
|
|
412
518
|
}
|
|
413
|
-
|
|
414
|
-
score += 40;
|
|
415
|
-
else if (source.includes(needle))
|
|
416
|
-
score += 16;
|
|
417
|
-
return score;
|
|
519
|
+
return 100 + score / 1_000;
|
|
418
520
|
}
|
|
419
521
|
/**
|
|
420
522
|
* Lists directory names only for the Workspace picker. Browsing begins at the
|
|
@@ -939,12 +1041,15 @@ async function resolveWorkspaceEntryMetadata(root, requestedPath) {
|
|
|
939
1041
|
const metadata = await lstat(candidate);
|
|
940
1042
|
return { path: candidate, metadata };
|
|
941
1043
|
}
|
|
942
|
-
async function inspectWorkspaceIndexEntry(root, requestedPath, name) {
|
|
1044
|
+
async function inspectWorkspaceIndexEntry(root, requestedPath, name, excludes) {
|
|
943
1045
|
try {
|
|
944
1046
|
const entry = await resolveWorkspaceEntryMetadata(root, requestedPath);
|
|
945
1047
|
if (!entry.metadata.isFile() && !entry.metadata.isDirectory())
|
|
946
1048
|
return undefined;
|
|
947
1049
|
const safePath = relative(root, entry.path).split(sep).join('/');
|
|
1050
|
+
// The requested path was already checked; aliases need the canonical-path check too.
|
|
1051
|
+
if (safePath !== requestedPath && isExcluded(safePath, excludes))
|
|
1052
|
+
return undefined;
|
|
948
1053
|
return entry.metadata.isDirectory()
|
|
949
1054
|
? {
|
|
950
1055
|
path: safePath,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@myagentroam/node",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.143",
|
|
4
4
|
"description": "MyAgentRoam Node runtime CLI.",
|
|
5
5
|
"license": "Apache-2.0 AND GPL-2.0-or-later",
|
|
6
6
|
"type": "module",
|
|
@@ -35,9 +35,9 @@
|
|
|
35
35
|
"ws": "^8.21.3",
|
|
36
36
|
"zod": "4.4.3",
|
|
37
37
|
"@myagentroam/marctl": "0.9.120",
|
|
38
|
-
"@myagentroam/
|
|
39
|
-
"@myagentroam/
|
|
40
|
-
"@myagentroam/
|
|
38
|
+
"@myagentroam/code-navigation": "0.9.129",
|
|
39
|
+
"@myagentroam/agent": "0.9.143",
|
|
40
|
+
"@myagentroam/protocol": "0.9.143"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@types/ws": "^8.18.1"
|