@dotdrelle/wiki-manager 0.12.0 → 0.12.7
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/package.json +2 -2
- package/src/activity/activityAggregator.js +34 -3
- package/src/activity/activityAggregator.test.js +32 -0
- package/src/agent/graph.js +307 -29
- package/src/agent/graph.test.js +404 -1
- package/src/cli/wiki-manager.js +24 -1
- package/src/commands/slash.js +74 -1
- package/src/commands/slash.test.js +36 -0
- package/src/contracts/schemas.test.js +2 -2
- package/src/core/activity.js +4 -0
- package/src/core/activity.test.js +9 -1
- package/src/core/agentEvents.js +30 -0
- package/src/core/agentEvents.test.js +42 -1
- package/src/core/buildInfo.js +58 -0
- package/src/core/buildInfo.json +4 -0
- package/src/core/buildInfo.test.js +14 -0
- package/src/core/mcp.js +50 -3
- package/src/core/mcp.test.js +94 -1
- package/src/core/runtimeLog.js +6 -1
- package/src/core/runtimeLog.test.js +3 -1
- package/src/runtime/client.js +22 -0
- package/src/runtime/controlMessages.js +43 -0
- package/src/runtime/controlMessages.test.js +21 -0
- package/src/runtime/donna-contract.test.js +3 -3
- package/src/runtime/recoveryManager.js +54 -0
- package/src/runtime/recoveryManager.test.js +73 -0
- package/src/runtime/runner.js +49 -13
- package/src/runtime/runner.test.js +196 -0
- package/src/runtime/server.js +73 -13
- package/src/runtime/server.test.js +284 -67
- package/src/runtime/store.js +48 -3
- package/src/runtime/store.test.js +56 -24
- package/src/runtime/supervisor.js +77 -1
- package/src/shell/RightPane.tsx +124 -31
- package/src/shell/SetupWizard.tsx +13 -1
- package/src/shell/repl.js +81 -11
- package/src/shell/repl.test.js +115 -2
- package/src/shell/tui.tsx +4 -1
- package/src/shell/useAgent.ts +37 -4
- package/src/shell/useSession.ts +51 -10
package/src/runtime/store.js
CHANGED
|
@@ -279,6 +279,11 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
|
|
|
279
279
|
SET status = 'interrupted', updated_at = ?
|
|
280
280
|
WHERE workspace = ? AND status IN (${RECOVERABLE_RUN_STATUSES.map(() => '?').join(', ')})
|
|
281
281
|
`);
|
|
282
|
+
const interruptRunByIdStatement = db.prepare(`
|
|
283
|
+
UPDATE runs
|
|
284
|
+
SET status = 'interrupted', updated_at = ?
|
|
285
|
+
WHERE id = ? AND (? IS NULL OR workspace = ?) AND status IN (${RECOVERABLE_RUN_STATUSES.map(() => '?').join(', ')})
|
|
286
|
+
`);
|
|
282
287
|
const upsertQueueItem = db.prepare(`
|
|
283
288
|
INSERT INTO queue_items (
|
|
284
289
|
id, workspace, server, tool, args, lock_key, status, reason, job_id, activity_key,
|
|
@@ -413,6 +418,25 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
|
|
|
413
418
|
`);
|
|
414
419
|
const deleteTaskDependenciesStatement = db.prepare('DELETE FROM task_dependencies WHERE run_id = ? AND task_id = ?');
|
|
415
420
|
const updateTaskStatusStatement = db.prepare('UPDATE tasks SET status = ?, declaration = ?, updated_at = ? WHERE run_id = ? AND id = ?');
|
|
421
|
+
const listActiveTasksForInterruptedRunsStatement = db.prepare(`
|
|
422
|
+
SELECT tasks.*
|
|
423
|
+
FROM tasks
|
|
424
|
+
JOIN runs ON runs.id = tasks.run_id
|
|
425
|
+
WHERE runs.workspace = ?
|
|
426
|
+
AND runs.status = 'interrupted'
|
|
427
|
+
AND tasks.status IN ('pending', 'pending_approval', 'waiting_approval', 'running', 'starting', 'blocked')
|
|
428
|
+
ORDER BY tasks.run_id ASC, tasks.id ASC
|
|
429
|
+
`);
|
|
430
|
+
const listActiveTasksForInterruptedRunByIdStatement = db.prepare(`
|
|
431
|
+
SELECT tasks.*
|
|
432
|
+
FROM tasks
|
|
433
|
+
JOIN runs ON runs.id = tasks.run_id
|
|
434
|
+
WHERE runs.id = ?
|
|
435
|
+
AND (? IS NULL OR runs.workspace = ?)
|
|
436
|
+
AND runs.status = 'interrupted'
|
|
437
|
+
AND tasks.status IN ('pending', 'pending_approval', 'waiting_approval', 'running', 'starting', 'blocked')
|
|
438
|
+
ORDER BY tasks.run_id ASC, tasks.id ASC
|
|
439
|
+
`);
|
|
416
440
|
const insertTaskDependencyStatement = db.prepare(`
|
|
417
441
|
INSERT OR IGNORE INTO task_dependencies (run_id, task_id, depends_on_task_id, created_at)
|
|
418
442
|
VALUES (?, ?, ?, ?)
|
|
@@ -619,13 +643,33 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
|
|
|
619
643
|
.map((row) => row.workspace);
|
|
620
644
|
}
|
|
621
645
|
|
|
622
|
-
function interruptRuns({ workspace, reason = 'Runtime restart recovery failed.' } = {}) {
|
|
623
|
-
if (!workspace) return 0;
|
|
646
|
+
function interruptRuns({ workspace, runId = null, reason = 'Runtime restart recovery failed.' } = {}) {
|
|
647
|
+
if (!workspace && !runId) return 0;
|
|
624
648
|
const now = new Date().toISOString();
|
|
625
|
-
const result =
|
|
649
|
+
const result = runId
|
|
650
|
+
? interruptRunByIdStatement.run(now, String(runId), workspace ?? null, workspace ?? null, ...RECOVERABLE_RUN_STATUSES)
|
|
651
|
+
: interruptRunsStatement.run(now, workspace, ...RECOVERABLE_RUN_STATUSES);
|
|
626
652
|
return Number(result.changes ?? 0);
|
|
627
653
|
}
|
|
628
654
|
|
|
655
|
+
function cancelActiveTasksForInterruptedRuns({ workspace = null, runId = null } = {}) {
|
|
656
|
+
if (!workspace && !runId) return 0;
|
|
657
|
+
const now = new Date().toISOString();
|
|
658
|
+
const tasks = runId
|
|
659
|
+
? listActiveTasksForInterruptedRunByIdStatement.all(String(runId), workspace ?? null, workspace ?? null).map(rowToTask)
|
|
660
|
+
: listActiveTasksForInterruptedRunsStatement.all(workspace).map(rowToTask);
|
|
661
|
+
for (const task of tasks) {
|
|
662
|
+
updateTaskStatusStatement.run(
|
|
663
|
+
'cancelled',
|
|
664
|
+
JSON.stringify({ ...task.declaration, status: 'cancelled' }),
|
|
665
|
+
now,
|
|
666
|
+
task.runId,
|
|
667
|
+
task.id,
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
return tasks.length;
|
|
671
|
+
}
|
|
672
|
+
|
|
629
673
|
function saveQueue(queue = [], { workspace = null } = {}) {
|
|
630
674
|
const items = Array.isArray(queue) ? queue : [];
|
|
631
675
|
const now = new Date().toISOString();
|
|
@@ -1121,6 +1165,7 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
|
|
|
1121
1165
|
listRecoverableRuns,
|
|
1122
1166
|
listRecoverableWorkspaces,
|
|
1123
1167
|
interruptRuns,
|
|
1168
|
+
cancelActiveTasksForInterruptedRuns,
|
|
1124
1169
|
saveQueue,
|
|
1125
1170
|
listQueue,
|
|
1126
1171
|
listTasks,
|
|
@@ -281,8 +281,8 @@ test('runtime store persists duplicate events idempotently', () => {
|
|
|
281
281
|
origin: 'test',
|
|
282
282
|
runId: 'run-1',
|
|
283
283
|
turnId: 'run-1:turn-0',
|
|
284
|
-
workspace: '
|
|
285
|
-
payload: { input: 'build wiki', workspace: '
|
|
284
|
+
workspace: 'acme',
|
|
285
|
+
payload: { input: 'build wiki', workspace: 'acme' },
|
|
286
286
|
});
|
|
287
287
|
|
|
288
288
|
store.persistEvent(event);
|
|
@@ -292,7 +292,7 @@ test('runtime store persists duplicate events idempotently', () => {
|
|
|
292
292
|
assert.equal(store.listEvents()[0].sequence, 1);
|
|
293
293
|
assert.equal(store.getState().eventsCursor, 1);
|
|
294
294
|
assert.equal(store.listRuns()[0].id, 'run-1');
|
|
295
|
-
assert.equal(store.listRuns()[0].workspace, '
|
|
295
|
+
assert.equal(store.listRuns()[0].workspace, 'acme');
|
|
296
296
|
assert.equal(store.listRuns()[0].status, 'running');
|
|
297
297
|
store.close();
|
|
298
298
|
});
|
|
@@ -590,22 +590,54 @@ test('runtime store persists cancelled run status', () => {
|
|
|
590
590
|
origin: 'test',
|
|
591
591
|
runId: 'run-1',
|
|
592
592
|
turnId: 'run-1:turn-0',
|
|
593
|
-
workspace: '
|
|
594
|
-
payload: { input: 'build wiki', workspace: '
|
|
593
|
+
workspace: 'acme',
|
|
594
|
+
payload: { input: 'build wiki', workspace: 'acme' },
|
|
595
595
|
}));
|
|
596
596
|
store.persistEvent(createAgentEvent('run_cancelled', {
|
|
597
597
|
origin: 'test',
|
|
598
598
|
runId: 'run-1',
|
|
599
599
|
turnId: 'run-1:turn-1',
|
|
600
|
-
workspace: '
|
|
601
|
-
payload: { runId: 'run-1', workspace: '
|
|
600
|
+
workspace: 'acme',
|
|
601
|
+
payload: { runId: 'run-1', workspace: 'acme' },
|
|
602
602
|
}));
|
|
603
603
|
|
|
604
604
|
assert.equal(store.listRuns()[0].status, 'cancelled');
|
|
605
|
-
assert.equal(store.listRuns()[0].workspace, '
|
|
605
|
+
assert.equal(store.listRuns()[0].workspace, 'acme');
|
|
606
606
|
store.close();
|
|
607
607
|
});
|
|
608
608
|
|
|
609
|
+
test('runtime store interrupts runs by runId and does not recover killed runs after reopen', () => {
|
|
610
|
+
const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
|
|
611
|
+
const store = openRuntimeStore({ stateDir });
|
|
612
|
+
for (const runId of ['run-kill', 'run-keep']) {
|
|
613
|
+
store.persistEvent(createAgentEvent('run_started', {
|
|
614
|
+
origin: 'test',
|
|
615
|
+
runId,
|
|
616
|
+
workspace: 'docs',
|
|
617
|
+
payload: { input: runId, workspace: 'docs' },
|
|
618
|
+
}));
|
|
619
|
+
store.persistEvent(createAgentEvent('task.created', {
|
|
620
|
+
origin: 'test',
|
|
621
|
+
runId,
|
|
622
|
+
taskId: `${runId}:task`,
|
|
623
|
+
workspace: 'docs',
|
|
624
|
+
payload: { runId, task: { ...plannedTask(`${runId}:task`), status: 'running' } },
|
|
625
|
+
}));
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
assert.equal(store.interruptRuns({ workspace: 'docs', runId: 'run-kill' }), 1);
|
|
629
|
+
assert.equal(store.cancelActiveTasksForInterruptedRuns({ workspace: 'docs', runId: 'run-kill' }), 1);
|
|
630
|
+
assert.equal(store.listRuns().find((run) => run.id === 'run-kill').status, 'interrupted');
|
|
631
|
+
assert.equal(store.listRuns().find((run) => run.id === 'run-keep').status, 'running');
|
|
632
|
+
assert.equal(store.listTasks({ runId: 'run-kill' })[0].status, 'cancelled');
|
|
633
|
+
assert.equal(store.listTasks({ runId: 'run-keep' })[0].status, 'running');
|
|
634
|
+
store.close();
|
|
635
|
+
|
|
636
|
+
const reopened = openRuntimeStore({ stateDir });
|
|
637
|
+
assert.deepEqual(reopened.listRecoverableRuns({ workspace: 'docs' }).map((run) => run.id), ['run-keep']);
|
|
638
|
+
reopened.close();
|
|
639
|
+
});
|
|
640
|
+
|
|
609
641
|
test('runtime store migrates legacy databases without workspace columns', () => {
|
|
610
642
|
const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
|
|
611
643
|
const db = new DatabaseSync(join(stateDir, 'runtime.db'));
|
|
@@ -724,10 +756,10 @@ test('runtime store filters events runs and queue by workspace', () => {
|
|
|
724
756
|
const store = openRuntimeStore({ stateDir });
|
|
725
757
|
store.persistEvent(createAgentEvent('run_started', {
|
|
726
758
|
origin: 'test',
|
|
727
|
-
runId: 'run-
|
|
728
|
-
turnId: 'run-
|
|
729
|
-
workspace: '
|
|
730
|
-
payload: { input: '
|
|
759
|
+
runId: 'run-acme',
|
|
760
|
+
turnId: 'run-acme:turn-0',
|
|
761
|
+
workspace: 'acme',
|
|
762
|
+
payload: { input: 'acme', workspace: 'acme' },
|
|
731
763
|
}));
|
|
732
764
|
store.persistEvent(createAgentEvent('run_started', {
|
|
733
765
|
origin: 'test',
|
|
@@ -736,12 +768,12 @@ test('runtime store filters events runs and queue by workspace', () => {
|
|
|
736
768
|
workspace: 'docs',
|
|
737
769
|
payload: { input: 'docs', workspace: 'docs' },
|
|
738
770
|
}));
|
|
739
|
-
store.saveQueue([{ id: 'q-
|
|
771
|
+
store.saveQueue([{ id: 'q-acme', workspace: 'acme', status: 'waiting' }], { workspace: 'acme' });
|
|
740
772
|
store.saveQueue([{ id: 'q-docs', workspace: 'docs', status: 'waiting' }], { workspace: 'docs' });
|
|
741
773
|
|
|
742
|
-
assert.deepEqual(store.listEvents({ workspace: '
|
|
774
|
+
assert.deepEqual(store.listEvents({ workspace: 'acme' }).map((event) => event.runId), ['run-acme']);
|
|
743
775
|
assert.deepEqual(store.listRuns({ workspace: 'docs' }).map((run) => run.id), ['run-docs']);
|
|
744
|
-
assert.deepEqual(store.listQueue({ workspace: '
|
|
776
|
+
assert.deepEqual(store.listQueue({ workspace: 'acme' }).map((item) => item.id), ['q-acme']);
|
|
745
777
|
|
|
746
778
|
const session = { activities: {}, headlessPlan: null };
|
|
747
779
|
store.hydrateSession(session, { workspace: 'docs' });
|
|
@@ -794,8 +826,8 @@ test('runtime store persists and replays control queue events without mixing MCP
|
|
|
794
826
|
}));
|
|
795
827
|
store.persistEvent(createAgentEvent('control_enqueued', {
|
|
796
828
|
origin: 'runtime',
|
|
797
|
-
workspace: '
|
|
798
|
-
payload: { id: 'control-
|
|
829
|
+
workspace: 'acme',
|
|
830
|
+
payload: { id: 'control-acme', workspace: 'acme', input: 'other' },
|
|
799
831
|
}));
|
|
800
832
|
store.close();
|
|
801
833
|
|
|
@@ -925,10 +957,10 @@ test('runtime store identifies recoverable workspaces and interrupts stale runs'
|
|
|
925
957
|
const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
|
|
926
958
|
const store = openRuntimeStore({ stateDir });
|
|
927
959
|
store.persistRun({
|
|
928
|
-
id: 'run-
|
|
929
|
-
workspace: '
|
|
960
|
+
id: 'run-acme',
|
|
961
|
+
workspace: 'acme',
|
|
930
962
|
status: 'running',
|
|
931
|
-
input: 'build
|
|
963
|
+
input: 'build acme',
|
|
932
964
|
});
|
|
933
965
|
store.persistRun({
|
|
934
966
|
id: 'run-done',
|
|
@@ -938,9 +970,9 @@ test('runtime store identifies recoverable workspaces and interrupts stale runs'
|
|
|
938
970
|
});
|
|
939
971
|
store.saveQueue([{ id: 'q-docs', workspace: 'docs', status: 'waiting' }], { workspace: 'docs' });
|
|
940
972
|
|
|
941
|
-
assert.deepEqual(store.listRecoverableWorkspaces(), ['
|
|
942
|
-
assert.deepEqual(store.listRecoverableRuns({ workspace: '
|
|
943
|
-
assert.equal(store.interruptRuns({ workspace: '
|
|
944
|
-
assert.equal(store.listRuns({ workspace: '
|
|
973
|
+
assert.deepEqual(store.listRecoverableWorkspaces(), ['acme', 'docs']);
|
|
974
|
+
assert.deepEqual(store.listRecoverableRuns({ workspace: 'acme' }).map((run) => run.id), ['run-acme']);
|
|
975
|
+
assert.equal(store.interruptRuns({ workspace: 'acme' }), 1);
|
|
976
|
+
assert.equal(store.listRuns({ workspace: 'acme' })[0].status, 'interrupted');
|
|
945
977
|
store.close();
|
|
946
978
|
});
|
|
@@ -98,8 +98,55 @@ export async function pollActivitiesOnce(session, {
|
|
|
98
98
|
payload: { activity: polledActivity },
|
|
99
99
|
}));
|
|
100
100
|
syncQueueWithActivity(session, polledActivity);
|
|
101
|
-
|
|
101
|
+
// Only log when something actually changed: the poll fires every
|
|
102
|
+
// second and an unchanged "activity: X -> running" line repeated
|
|
103
|
+
// dozens of times drowned Logs/Trace and looked like a frozen system.
|
|
104
|
+
// The line itself carries the operational signal (current file,
|
|
105
|
+
// percent, batch, last trace event, retry/backoff) so the panel
|
|
106
|
+
// reads as a narrative of the job, not a heartbeat.
|
|
107
|
+
const progress = payload?.progress ?? polledActivity.progress ?? {};
|
|
108
|
+
const percent = Number(progress.percent ?? polledActivity.progress?.percent);
|
|
109
|
+
const detail = progress.detail ?? progress.step ?? '';
|
|
110
|
+
// The trace's batchIndex is 0-based and the human detail already says
|
|
111
|
+
// "Batch 1/2" — printing both gave "Batch 1/2 · batch 0/2". Display
|
|
112
|
+
// 1-based, and only when the detail doesn't already carry it.
|
|
113
|
+
const batch = progress.batchIndex != null && progress.batchCount != null && !/batch/i.test(String(detail))
|
|
114
|
+
? `batch ${Number(progress.batchIndex) + 1}/${progress.batchCount}`
|
|
115
|
+
: null;
|
|
116
|
+
const lastEvent = progress.lastEvent ? String(progress.lastEvent) : null;
|
|
117
|
+
const retry = progress.retryAt
|
|
118
|
+
? `retry ${progress.retryAt}`
|
|
119
|
+
: (progress.waitMs ? `wait ${progress.waitMs}ms` : null);
|
|
120
|
+
const progressKey = `${polledActivity.status}:${Number.isFinite(percent) ? Math.round(percent) : ''}:${detail}:${batch ?? ''}:${lastEvent ?? ''}:${retry ?? ''}`;
|
|
121
|
+
session._activityLogKeys ??= {};
|
|
122
|
+
if (session._activityLogKeys[key] !== progressKey) {
|
|
123
|
+
session._activityLogKeys[key] = progressKey;
|
|
124
|
+
const progressLabel = [
|
|
125
|
+
Number.isFinite(percent) ? `${Math.round(percent)}%` : null,
|
|
126
|
+
detail ? String(detail) : null,
|
|
127
|
+
batch,
|
|
128
|
+
lastEvent,
|
|
129
|
+
retry,
|
|
130
|
+
].filter(Boolean).join(' · ');
|
|
131
|
+
emitRuntimeLog(session, `activity: ${polledActivity.label} -> ${polledActivity.status}${progressLabel ? ` (${progressLabel})` : ''}`);
|
|
132
|
+
}
|
|
133
|
+
// Stream NEW job-log lines into Logs/Trace: document names, chunking,
|
|
134
|
+
// LLM call/token summaries and errors live in the job log, not in the
|
|
135
|
+
// status fields. Diff against the last line already shown.
|
|
136
|
+
const logTail = Array.isArray(payload?.logTail) ? payload.logTail.map(String) : [];
|
|
137
|
+
if (logTail.length > 0) {
|
|
138
|
+
session._activityLogCursors ??= {};
|
|
139
|
+
const lastSeen = session._activityLogCursors[key];
|
|
140
|
+
const lastIndex = lastSeen ? logTail.lastIndexOf(lastSeen) : -1;
|
|
141
|
+
for (const line of logTail.slice(lastIndex + 1)) {
|
|
142
|
+
const clean = line.trim();
|
|
143
|
+
if (clean) emitRuntimeLog(session, `job ${polledActivity.id ?? key}: ${clean}`);
|
|
144
|
+
}
|
|
145
|
+
session._activityLogCursors[key] = logTail.at(-1);
|
|
146
|
+
}
|
|
102
147
|
if (polledActivity.terminal) {
|
|
148
|
+
delete session._activityLogKeys[key];
|
|
149
|
+
delete session._activityLogCursors?.[key];
|
|
103
150
|
await startNextQueuedJob(session, {
|
|
104
151
|
addLog: (message) => emitRuntimeLog(session, message),
|
|
105
152
|
});
|
|
@@ -134,3 +181,32 @@ function registryIntervalFromEnv() {
|
|
|
134
181
|
const value = Number(process.env.WIKI_MANAGER_AGENT_REGISTRY_INTERVAL_MS);
|
|
135
182
|
return Number.isFinite(value) && value >= 0 ? Math.floor(value) : 60000;
|
|
136
183
|
}
|
|
184
|
+
|
|
185
|
+
// Cancelling a runtime run must also cancel the asynchronous jobs it started:
|
|
186
|
+
// the run's abort only stopped the manager-side loop while the agent kept
|
|
187
|
+
// executing (an ingest subprocess ran on for minutes after "cancel requested",
|
|
188
|
+
// with the panels frozen on its last known state). Best effort: prefer the
|
|
189
|
+
// orchestration contract's agent_cancel, fall back to legacy *_cancel_job.
|
|
190
|
+
export async function cancelActiveActivityJobs(session, { callTool = callMcpTool } = {}) {
|
|
191
|
+
const cancelled = [];
|
|
192
|
+
for (const activity of sessionActivities(session)) {
|
|
193
|
+
if (activity.terminal || !activity.poll) continue;
|
|
194
|
+
const jobId = activity.poll.args?.jobId ?? activity.id ?? null;
|
|
195
|
+
const server = activity.poll.server;
|
|
196
|
+
const endpoint = session.mcp?.[server];
|
|
197
|
+
if (!jobId || !endpoint || endpoint.status !== 'connected') continue;
|
|
198
|
+
const names = (endpoint.tools ?? []).map((tool) => String(tool.name ?? ''));
|
|
199
|
+
const cancelTool = names.find((name) => /(^|__)agent_cancel$/.test(name))
|
|
200
|
+
?? names.find((name) => /_cancel_job$/.test(name))
|
|
201
|
+
?? names.find((name) => /(^|__)[a-z]*_?cancel$/.test(name));
|
|
202
|
+
if (!cancelTool) continue;
|
|
203
|
+
try {
|
|
204
|
+
await callTool(session.mcp, server, cancelTool, { jobId });
|
|
205
|
+
emitRuntimeLog(session, `cancel: requested ${server}.${cancelTool} for job ${jobId}`);
|
|
206
|
+
cancelled.push({ server, jobId });
|
|
207
|
+
} catch (err) {
|
|
208
|
+
emitRuntimeLog(session, `cancel: ${server}.${cancelTool} failed for ${jobId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return cancelled;
|
|
212
|
+
}
|
package/src/shell/RightPane.tsx
CHANGED
|
@@ -16,7 +16,9 @@ type QueueItem = {
|
|
|
16
16
|
type QueueInfo = { active: number; current: number; frozen: number };
|
|
17
17
|
type LogLineParts = { time: string | null; message: string };
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
// 4 slots (was 6): items can now span up to 5 lines each (wrapped label +
|
|
20
|
+
// wrapped status/error), so fewer, readable entries beat more, truncated ones.
|
|
21
|
+
const ACTIVITY_SLOTS = Array.from({ length: 4 }, (_, index) => index);
|
|
20
22
|
const LOG_SLOTS = Array.from({ length: 24 }, (_, index) => index);
|
|
21
23
|
|
|
22
24
|
function wrapLine(value: string, width: number) {
|
|
@@ -70,7 +72,18 @@ function queueColor(status: string) {
|
|
|
70
72
|
function activityLine(activity: any) {
|
|
71
73
|
const detail = activity?.progress?.detail ?? null;
|
|
72
74
|
const phase = activity?.progress?.step ?? activity?.progress?.phase ?? activity?.progress?.currentStep ?? null;
|
|
73
|
-
|
|
75
|
+
// On failure the error message is the information that matters — surface
|
|
76
|
+
// it instead of repeating the bare status the label color already conveys.
|
|
77
|
+
const error = activityErrorText(activity);
|
|
78
|
+
return [activity?.status ?? 'unknown', error ?? detail ?? phase].filter(Boolean).join(' · ');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function activityErrorText(activity: any): string | null {
|
|
82
|
+
const status = String(activity?.status ?? '').toLowerCase();
|
|
83
|
+
if (!['failed', 'error', 'cancelled', 'canceled'].includes(status)) return null;
|
|
84
|
+
const error = activity?.error ?? activity?.result?.error ?? activity?.progress?.detail ?? null;
|
|
85
|
+
const text = error == null ? '' : String(error).replace(/\s+/g, ' ').trim();
|
|
86
|
+
return text || null;
|
|
74
87
|
}
|
|
75
88
|
|
|
76
89
|
function activityPercentBadge(activity: any): { text: string; bg: string; fg: string } | null {
|
|
@@ -127,13 +140,19 @@ export function PlanPanel(props: { plan: PlanStep[]; width: number; jobName?: st
|
|
|
127
140
|
<box flexShrink={0} flexDirection="column" padding={1}>
|
|
128
141
|
<text width={lineWidth()} fg="#D6DEE8" content={fit(title(), lineWidth())} />
|
|
129
142
|
<Index each={props.plan}>
|
|
130
|
-
{(step) =>
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
143
|
+
{(step) => {
|
|
144
|
+
// Wrap step descriptions over up to 2 lines instead of truncating —
|
|
145
|
+
// "Ingest des 39 documents raw/untrac…" hid the actual target.
|
|
146
|
+
const lines = () => wrapLine(`${icon(step().status)} ${step().step}. ${step().description}`, lineWidth()).slice(0, 2);
|
|
147
|
+
return (
|
|
148
|
+
<box flexDirection="column">
|
|
149
|
+
<text width={lineWidth()} fg={planStepColor(step(), firstPending())} content={lines()[0]} />
|
|
150
|
+
<Show when={lines()[1]}>
|
|
151
|
+
<text width={lineWidth()} fg={planStepColor(step(), firstPending())} content={` ${fit(lines()[1], Math.max(8, lineWidth() - 4))}`} />
|
|
152
|
+
</Show>
|
|
153
|
+
</box>
|
|
154
|
+
);
|
|
155
|
+
}}
|
|
137
156
|
</Index>
|
|
138
157
|
</box>
|
|
139
158
|
);
|
|
@@ -141,7 +160,7 @@ export function PlanPanel(props: { plan: PlanStep[]; width: number; jobName?: st
|
|
|
141
160
|
|
|
142
161
|
export function ActivityPanel(props: { activities: any[]; width: number }) {
|
|
143
162
|
const lineWidth = () => Math.max(8, props.width - 2);
|
|
144
|
-
const visible = () => props.activities.slice(-
|
|
163
|
+
const visible = () => props.activities.slice(-ACTIVITY_SLOTS.length).reverse();
|
|
145
164
|
const activityAt = (index: number) => visible()[index] ?? null;
|
|
146
165
|
return (
|
|
147
166
|
<box flexShrink={0} flexDirection="column" padding={1}>
|
|
@@ -150,21 +169,39 @@ export function ActivityPanel(props: { activities: any[]; width: number }) {
|
|
|
150
169
|
<Index each={ACTIVITY_SLOTS}>
|
|
151
170
|
{(slot) => {
|
|
152
171
|
const activity = () => activityAt(slot());
|
|
153
|
-
|
|
172
|
+
// Wrap instead of hard-truncating: a 40-column pane cut labels to
|
|
173
|
+
// "Appliquer la config recommandée (doct…" and hid the one thing
|
|
174
|
+
// that mattered. Labels get up to 2 lines, the status/error line
|
|
175
|
+
// up to 2 lines; empty continuation lines are not rendered.
|
|
176
|
+
const labelLines = () => {
|
|
154
177
|
const item = activity();
|
|
155
|
-
if (!item) return '';
|
|
156
|
-
|
|
178
|
+
if (!item) return [''];
|
|
179
|
+
const label = String(item.progress?.label ?? item.label ?? `${item.source ?? 'mcp'} ${item.kind ?? 'job'}`);
|
|
180
|
+
const lines = wrapLine(label, lineWidth()).slice(0, 2);
|
|
181
|
+
if (lines.length === 2 && String(label).length > lines[0].length + lines[1].length) {
|
|
182
|
+
lines[1] = fit(lines[1], lineWidth());
|
|
183
|
+
}
|
|
184
|
+
return lines;
|
|
157
185
|
};
|
|
158
186
|
const badge = () => activityPercentBadge(activity());
|
|
159
187
|
const badgeLen = () => badge()?.text.length ?? 0;
|
|
188
|
+
const statusLines = () => {
|
|
189
|
+
const item = activity();
|
|
190
|
+
if (!item) return [''];
|
|
191
|
+
return wrapLine(activityLine(item), Math.max(8, lineWidth() - badgeLen())).slice(0, 2);
|
|
192
|
+
};
|
|
193
|
+
const statusColor = () => (activityErrorText(activity()) ? '#F38BA8' : '#AAB7C4');
|
|
160
194
|
return (
|
|
161
195
|
<box flexDirection="column" marginTop={slot() === 0 ? 1 : 0}>
|
|
162
|
-
<text width={lineWidth()} fg={activityColor(activity()?.status)} content={
|
|
196
|
+
<text width={lineWidth()} fg={activityColor(activity()?.status)} content={labelLines()[0]} />
|
|
197
|
+
<Show when={labelLines()[1]}>
|
|
198
|
+
<text width={lineWidth()} fg={activityColor(activity()?.status)} content={labelLines()[1]} />
|
|
199
|
+
</Show>
|
|
163
200
|
<box height={1} flexDirection="row">
|
|
164
201
|
<text
|
|
165
202
|
width={lineWidth() - badgeLen()}
|
|
166
|
-
fg=
|
|
167
|
-
content={activity() ? fit(
|
|
203
|
+
fg={statusColor()}
|
|
204
|
+
content={activity() ? fit(statusLines()[0], lineWidth() - badgeLen()) : ''}
|
|
168
205
|
/>
|
|
169
206
|
<text
|
|
170
207
|
width={badgeLen()}
|
|
@@ -173,6 +210,9 @@ export function ActivityPanel(props: { activities: any[]; width: number }) {
|
|
|
173
210
|
content={badge()?.text ?? ''}
|
|
174
211
|
/>
|
|
175
212
|
</box>
|
|
213
|
+
<Show when={statusLines()[1]}>
|
|
214
|
+
<text width={lineWidth()} fg={statusColor()} content={fit(statusLines()[1], lineWidth())} />
|
|
215
|
+
</Show>
|
|
176
216
|
<text width={lineWidth()} fg="#7F8C8D" content={activity() ? fit(updatedLine(activity()), lineWidth()) : ''} />
|
|
177
217
|
</box>
|
|
178
218
|
);
|
|
@@ -183,31 +223,84 @@ export function ActivityPanel(props: { activities: any[]; width: number }) {
|
|
|
183
223
|
);
|
|
184
224
|
}
|
|
185
225
|
|
|
226
|
+
type LogSegment = { text: string; fg: string };
|
|
227
|
+
|
|
228
|
+
// Per-line coloring: 'runtime' source tag in violet, HH:MM:SS in blue,
|
|
229
|
+
// message tinted by nature (errors red, warnings amber, activity teal).
|
|
230
|
+
// Continuation lines of a wrapped entry are indented and dimmed so each
|
|
231
|
+
// entry reads as one visual block instead of an undifferentiated wall.
|
|
232
|
+
function logMessageColor(message: string): string {
|
|
233
|
+
if (/\b(error|failed|exception|unavailable|introuvable|HTTP 4\d\d|HTTP 5\d\d)\b/i.test(message)) return '#F38BA8';
|
|
234
|
+
if (/\b(warn|warning|avertissement|fallback|retry|expired|stale)\b/i.test(message)) return '#FBBF24';
|
|
235
|
+
if (/^(activity|job)\b/i.test(message)) return '#8BD5CA';
|
|
236
|
+
return '#AAB7C4';
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function logRenderLines(logs: string[], width: number): LogSegment[][] {
|
|
240
|
+
// Newest entry FIRST (descending): the freshest information belongs at the
|
|
241
|
+
// top of the pane. Wrapped continuation lines stay attached below their
|
|
242
|
+
// entry's first line.
|
|
243
|
+
const blocks: LogSegment[][][] = [];
|
|
244
|
+
for (const raw of logs) {
|
|
245
|
+
blocks.push(logEntryLines(raw, width));
|
|
246
|
+
}
|
|
247
|
+
return blocks.reverse().flat();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function logEntryLines(raw: string, width: number): LogSegment[][] {
|
|
251
|
+
const out: LogSegment[][] = [];
|
|
252
|
+
for (const item of [raw]) {
|
|
253
|
+
const rawLine = item;
|
|
254
|
+
const sourceMatch = String(rawLine).match(/^(runtime)\s+(.*)$/);
|
|
255
|
+
const source = sourceMatch ? sourceMatch[1] : null;
|
|
256
|
+
const rest = sourceMatch ? sourceMatch[2] : String(rawLine);
|
|
257
|
+
const parts = logLineParts(rest);
|
|
258
|
+
const prefix: LogSegment[] = [];
|
|
259
|
+
if (source) prefix.push({ text: `${source} `, fg: '#C6A0F6' });
|
|
260
|
+
if (parts.time) prefix.push({ text: `${parts.time} `, fg: '#89B4FA' });
|
|
261
|
+
const prefixLength = prefix.reduce((total, segment) => total + segment.text.length, 0);
|
|
262
|
+
const messageColor = logMessageColor(parts.message);
|
|
263
|
+
const wrapped = wrapLine(parts.message, Math.max(8, width - prefixLength));
|
|
264
|
+
wrapped.forEach((text, index) => {
|
|
265
|
+
if (index === 0) {
|
|
266
|
+
out.push([...prefix, { text, fg: messageColor }]);
|
|
267
|
+
} else {
|
|
268
|
+
const indent = ' '.repeat(Math.min(prefixLength, 4));
|
|
269
|
+
out.push([{ text: `${indent}${text}`, fg: messageColor === '#F38BA8' ? messageColor : '#7F8C8D' }]);
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
return out;
|
|
274
|
+
}
|
|
275
|
+
|
|
186
276
|
export function LogPanel(props: { logs: string[]; width: number; filter?: string }) {
|
|
187
277
|
const lineWidth = () => Math.max(8, props.width - 2);
|
|
188
278
|
const filteredLogs = () => filterRuntimeLogs(props.logs, props.filter ?? '');
|
|
189
|
-
const visibleLines = () => filteredLogs()
|
|
190
|
-
const
|
|
279
|
+
const visibleLines = createMemo(() => logRenderLines(filteredLogs(), lineWidth()).slice(0, 24));
|
|
280
|
+
const segmentsAt = (index: number): LogSegment[] => visibleLines()[index] ?? [];
|
|
191
281
|
return (
|
|
192
282
|
<box flexGrow={1} flexDirection="column" padding={1}>
|
|
193
283
|
<text width={lineWidth()} fg="#D6DEE8" content="Logs / Trace" />
|
|
194
284
|
<box flexGrow={1} flexDirection="column" overflow="hidden">
|
|
195
285
|
<Index each={LOG_SLOTS}>
|
|
196
286
|
{(slot) => {
|
|
197
|
-
const
|
|
198
|
-
const
|
|
199
|
-
const
|
|
200
|
-
const
|
|
287
|
+
const segments = () => segmentsAt(slot());
|
|
288
|
+
const segmentAt = (position: number): LogSegment => segments()[position] ?? { text: '', fg: '#AAB7C4' };
|
|
289
|
+
const usedWidth = (upTo: number) => segments().slice(0, upTo).reduce((total, segment) => total + segment.text.length, 0);
|
|
290
|
+
const widthFor = (position: number) => {
|
|
291
|
+
const segment = segmentAt(position);
|
|
292
|
+
if (!segment.text) return 0;
|
|
293
|
+
// Last visible segment absorbs the remaining width.
|
|
294
|
+
return position === segments().length - 1
|
|
295
|
+
? Math.max(1, lineWidth() - usedWidth(position))
|
|
296
|
+
: Math.min(segment.text.length, lineWidth() - usedWidth(position));
|
|
297
|
+
};
|
|
201
298
|
return (
|
|
202
|
-
<
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
<text width={timeWidth()} fg="#89B4FA" content={`${parts().time} `} />
|
|
208
|
-
<text width={messageWidth()} fg="#AAB7C4" content={parts().message} />
|
|
209
|
-
</box>
|
|
210
|
-
</Show>
|
|
299
|
+
<box height={1} flexDirection="row" overflow="hidden">
|
|
300
|
+
<text width={widthFor(0)} fg={segmentAt(0).fg} content={segmentAt(0).text} />
|
|
301
|
+
<text width={widthFor(1)} fg={segmentAt(1).fg} content={segmentAt(1).text} />
|
|
302
|
+
<text width={widthFor(2)} fg={segmentAt(2).fg} content={segmentAt(2).text} />
|
|
303
|
+
</box>
|
|
211
304
|
);
|
|
212
305
|
}}
|
|
213
306
|
</Index>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/solid */
|
|
2
2
|
import { execFileSync } from 'node:child_process';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
import { useKeyboard } from '@opentui/solid';
|
|
4
|
+
import { useKeyboard, usePaste } from '@opentui/solid';
|
|
5
5
|
import { createEffect, createMemo, createSignal, For, Show } from 'solid-js';
|
|
6
6
|
import { fallbackModels, normalizeProvider } from '../core/modelFetch.js';
|
|
7
7
|
import {
|
|
@@ -606,6 +606,18 @@ export function SetupWizard(props: {
|
|
|
606
606
|
} catch { return ''; }
|
|
607
607
|
}
|
|
608
608
|
|
|
609
|
+
// Terminal paste (Cmd+V/middle-click) arrives as a bracketed-paste block.
|
|
610
|
+
// openTUI's key parser swallows the \x1b[200~/\x1b[201~ markers and emits a
|
|
611
|
+
// dedicated `paste` event instead of key events — the text NEVER reaches
|
|
612
|
+
// useKeyboard, so the legacy bracketed-paste branch below can't fire.
|
|
613
|
+
usePaste((event: any) => {
|
|
614
|
+
if (busy() || step().kind !== 'text') return;
|
|
615
|
+
const raw = event?.text
|
|
616
|
+
?? (event?.bytes != null ? Buffer.from(event.bytes).toString('utf8') : '');
|
|
617
|
+
const pasted = String(raw).replace(/\r\n?/g, '\n').replace(/\n+$/, '').split('\n').join(' ');
|
|
618
|
+
if (pasted) setInput((value) => value + pasted);
|
|
619
|
+
});
|
|
620
|
+
|
|
609
621
|
useKeyboard((key: any) => {
|
|
610
622
|
if (busy()) return;
|
|
611
623
|
const s = step();
|