@dotdrelle/wiki-manager 0.15.66 → 0.15.71
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.example +10 -3
- package/README.md +57 -0
- package/agent-runtimes.example.json +68 -0
- package/agents.docker-compose.yml +39 -1
- package/docker-compose.yml +3 -3
- package/package.json +3 -2
- package/src/activity/activityAggregator.test.js +2 -2
- package/src/agent/graph.js +13 -11
- package/src/agent/skillRecursion.test.js +13 -12
- package/src/cli/wiki-manager.js +125 -37
- package/src/cli/wiki-manager.test.js +16 -16
- package/src/commands/slash.js +59 -5
- package/src/contracts/schemas.js +67 -0
- package/src/core/activity.js +5 -0
- package/src/core/agentEvents.js +139 -25
- package/src/core/agentEvents.test.js +26 -1
- package/src/core/buildInfo.json +2 -2
- package/src/core/commandFailure.test.js +2 -2
- package/src/core/currentArtifact.test.js +5 -5
- package/src/core/dockerCompose.test.js +8 -40
- package/src/core/env.js +14 -0
- package/src/core/env.test.js +19 -0
- package/src/core/googleGrants.test.js +1 -1
- package/src/core/mcp.js +1 -1
- package/src/core/mcp.test.js +1 -1
- package/src/core/otherWorkspacesRunning.test.js +6 -6
- package/src/core/runtimeEventAdapter.js +81 -0
- package/src/core/runtimeEventAdapter.test.js +61 -0
- package/src/core/runtimeLog.js +35 -1
- package/src/core/runtimeLog.test.js +27 -2
- package/src/core/skillChainView.test.js +2 -2
- package/src/core/skillCompiler.test.js +1 -1
- package/src/core/skillInvocation.js +13 -8
- package/src/core/skillInvocation.test.js +1 -1
- package/src/core/startupCheck.js +58 -0
- package/src/core/startupCheck.test.js +29 -1
- package/src/core/wikiSetup.js +25 -0
- package/src/core/wikiSetup.test.js +35 -0
- package/src/core/wikirc.test.js +6 -6
- package/src/core/workspaceInherit.test.js +14 -14
- package/src/orchestrator/agentRegistry.js +1 -22
- package/src/orchestrator/agentRegistry.test.js +6 -6
- package/src/orchestrator/assignmentManager.js +16 -4
- package/src/orchestrator/capabilityRegistry.js +8 -1
- package/src/orchestrator/dispatcher.js +405 -2
- package/src/orchestrator/dispatcher.test.js +158 -4
- package/src/orchestrator/objectiveResolver.js +10 -6
- package/src/orchestrator/objectiveResolver.test.js +26 -27
- package/src/orchestrator/providers/deepAgentsProvider.js +168 -0
- package/src/orchestrator/providers/deepAgentsProvider.test.js +178 -0
- package/src/orchestrator/providers/dispatcherExternalRuntime.test.js +409 -0
- package/src/orchestrator/providers/fakeRuntimeProvider.js +164 -0
- package/src/orchestrator/providers/fakeRuntimeProvider.test.js +201 -0
- package/src/orchestrator/providers/runtimeProvider.js +101 -0
- package/src/orchestrator/providers/runtimeProviders.js +378 -0
- package/src/orchestrator/providers/runtimeProviders.test.js +384 -0
- package/src/orchestrator/resultAggregator.js +35 -2
- package/src/orchestrator/resultAggregator.test.js +62 -0
- package/src/orchestrator/scheduler.test.js +4 -4
- package/src/runtime/delegation.test.js +11 -11
- package/src/runtime/recoveryManager.js +70 -5
- package/src/runtime/runner.test.js +1 -1
- package/src/runtime/server.test.js +2 -2
- package/src/runtime/skillChain.e2e.test.js +2 -2
- package/src/runtime/store.test.js +8 -5
- package/src/runtime/supervisor.js +5 -10
- package/src/runtime/workspaceIsolation.test.js +26 -26
- package/src/shell/RightPane.tsx +23 -3
- package/src/shell/StartupScreen.tsx +44 -7
- package/src/shell/repl.js +24 -2
- package/src/shell/repl.test.js +13 -0
- package/wiki-workspace +53 -3
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { parseJsonText } from '../core/activity.js';
|
|
2
2
|
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
3
3
|
import { formatMcpToolResult, callMcpTool as defaultCallMcpTool } from '../core/mcp.js';
|
|
4
|
-
import {
|
|
4
|
+
import { capabilityRegistryForSession } from '../orchestrator/capabilityRegistry.js';
|
|
5
5
|
import { accept as acceptResult } from '../orchestrator/resultAggregator.js';
|
|
6
6
|
import { isSuccessful, isTerminal } from '../orchestrator/taskStatuses.js';
|
|
7
7
|
|
|
@@ -100,6 +100,9 @@ async function recoverTask({ store, session, run, task, callTool, resultAggregat
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
const agent = agentFor(session, assignment.agentInstanceId);
|
|
103
|
+
if (agent?.providerKind === 'external-runtime' && typeof agent?.runtimeProvider?.status === 'function') {
|
|
104
|
+
return recoverExternalRuntimeTask({ store, session, run, task, attempt, assignment, agent, resultAggregator });
|
|
105
|
+
}
|
|
103
106
|
const serverName = agent?.serverName ?? assignment.agentId ?? assignment.agentInstanceId;
|
|
104
107
|
const statusTool = toolNameFor(session, serverName, 'agent_status');
|
|
105
108
|
const status = parseToolPayload(await callTool(session.mcp, serverName, statusTool, { jobId: attempt.jobId }));
|
|
@@ -146,6 +149,70 @@ async function recoverTask({ store, session, run, task, callTool, resultAggregat
|
|
|
146
149
|
return interruptTask({ store, session, run, task, reason: 'active job is non-terminal and task has no idempotencyKey' });
|
|
147
150
|
}
|
|
148
151
|
|
|
152
|
+
// An MCP job survives a manager restart on the agent's own side and reports
|
|
153
|
+
// its status through agent_status. An external-runtime job has no such
|
|
154
|
+
// side-channel here: the only way to check on it, or to give it up, is the
|
|
155
|
+
// same RuntimeProvider the dispatcher used to start it, re-resolved by
|
|
156
|
+
// agentInstanceId from the live registry rather than replayed from storage.
|
|
157
|
+
async function recoverExternalRuntimeTask({ store, session, run, task, attempt, assignment, agent, resultAggregator }) {
|
|
158
|
+
const runtimeProvider = agent.runtimeProvider;
|
|
159
|
+
let status;
|
|
160
|
+
try {
|
|
161
|
+
status = await runtimeProvider.status(attempt.jobId);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
return interruptTask({
|
|
164
|
+
store,
|
|
165
|
+
session,
|
|
166
|
+
run,
|
|
167
|
+
task,
|
|
168
|
+
reason: `external runtime status check failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (isTerminal(status?.status)) {
|
|
173
|
+
const result = {
|
|
174
|
+
ok: isSuccessful(String(status?.status ?? '').toLowerCase()),
|
|
175
|
+
taskId: task.id,
|
|
176
|
+
attemptId: attempt.attemptId ?? null,
|
|
177
|
+
jobId: attempt.jobId,
|
|
178
|
+
agentInstanceId: assignment.agentInstanceId,
|
|
179
|
+
status: status?.status,
|
|
180
|
+
outputRefs: Array.isArray(status?.result?.outputRefs) ? status.result.outputRefs : [],
|
|
181
|
+
metrics: status?.result?.metrics ?? {},
|
|
182
|
+
// The gateway reports its failure at the TOP level of the status
|
|
183
|
+
// payload ({ runId, status, error }), not inside `result` — same fix
|
|
184
|
+
// already applied in dispatcher.js's taskResultFromStatus and
|
|
185
|
+
// deepAgentsProvider.js's status().
|
|
186
|
+
error: status?.result?.error ?? status?.error ?? null,
|
|
187
|
+
rawStatus: status,
|
|
188
|
+
};
|
|
189
|
+
await resultAggregator(result, {
|
|
190
|
+
session,
|
|
191
|
+
runId: run.id,
|
|
192
|
+
task,
|
|
193
|
+
assignment: { agentInstanceId: assignment.agentInstanceId, serverName: null, agent },
|
|
194
|
+
store,
|
|
195
|
+
registry: capabilityRegistryForSession(session),
|
|
196
|
+
workspaceConfig: session.wikircConfig ?? session.wikirc?.config ?? {},
|
|
197
|
+
});
|
|
198
|
+
return { status: 'recovered', runId: run.id, taskId: task.id, jobId: attempt.jobId };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// The runtime declares supportsIdempotency: false (runtimeProviders.js), so
|
|
202
|
+
// a fresh invocation cannot be deduped against the one still running on the
|
|
203
|
+
// external side. Requeuing it to `pending` like the MCP path below would
|
|
204
|
+
// start a duplicate while the orphaned original keeps running/billing.
|
|
205
|
+
// Cancel it explicitly instead of leaving it to run unattended.
|
|
206
|
+
await runtimeProvider.cancel(attempt.jobId).catch(() => null);
|
|
207
|
+
return interruptTask({
|
|
208
|
+
store,
|
|
209
|
+
session,
|
|
210
|
+
run,
|
|
211
|
+
task,
|
|
212
|
+
reason: 'active external-runtime job cancelled on recovery (no idempotency support)',
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
149
216
|
function interruptTask({ store, session, run, task, reason }) {
|
|
150
217
|
dispatch(session, store, 'runtime_log', {
|
|
151
218
|
origin: 'recovery_manager',
|
|
@@ -171,10 +238,7 @@ function latestAssignment(assignments, attemptId) {
|
|
|
171
238
|
|
|
172
239
|
|
|
173
240
|
function capabilityResolvable(session, capability) {
|
|
174
|
-
const registry = session
|
|
175
|
-
?? ((session.agentRegistrySnapshot ?? []).length > 0
|
|
176
|
-
? createCapabilityRegistry({ agents: session.agentRegistrySnapshot })
|
|
177
|
-
: null);
|
|
241
|
+
const registry = capabilityRegistryForSession(session);
|
|
178
242
|
if (!registry || typeof registry.providersFor !== 'function') return true;
|
|
179
243
|
// Only trust a registry that actually knows about capabilities. An empty
|
|
180
244
|
// one (discovery not finished, or agents described without capability
|
|
@@ -211,6 +275,7 @@ function agentFor(session, agentInstanceId) {
|
|
|
211
275
|
return [
|
|
212
276
|
...(session.agentRegistrySnapshot ?? []),
|
|
213
277
|
...(session.agents ?? []),
|
|
278
|
+
...(session.runtimeProviderAgents ?? []),
|
|
214
279
|
].find((agent) => agent?.agentInstanceId === agentInstanceId) ?? null;
|
|
215
280
|
}
|
|
216
281
|
|
|
@@ -829,7 +829,7 @@ test('runRuntimeParallelPlan fails cleanly when scheduler budget is exceeded', a
|
|
|
829
829
|
laissait le run « stalled ». Ne pas attendre d'approbation était déjà acquis,
|
|
830
830
|
mais s'arrêter là déclenchait une replanification et laissait le run vivant.
|
|
831
831
|
|
|
832
|
-
Cas observé le 2026-08-04 (workspace
|
|
832
|
+
Cas observé le 2026-08-04 (workspace demo) : dix fichiers à ingérer, neuf
|
|
833
833
|
réussis, un en échec sur du JSON malformé — le run n'est jamais retombé.
|
|
834
834
|
Une tâche qui ne deviendra jamais exécutable est donc marquée `skipped` avec
|
|
835
835
|
le nom de la dépendance fautive, et le run se termine sur un résultat partiel.
|
|
@@ -2154,7 +2154,7 @@ test('runtime health reports active runs across workspaces', async (t) => {
|
|
|
2154
2154
|
session: {},
|
|
2155
2155
|
// The shell reads this at exit: shutting down its own runtime must not
|
|
2156
2156
|
// kill a run that is supposed to survive the shell.
|
|
2157
|
-
listActiveRuns: () => [{ workspace: '
|
|
2157
|
+
listActiveRuns: () => [{ workspace: 'demo', runId: 'run-1234abcd' }],
|
|
2158
2158
|
});
|
|
2159
2159
|
} catch (err) {
|
|
2160
2160
|
if (err?.code === 'EPERM') {
|
|
@@ -2166,7 +2166,7 @@ test('runtime health reports active runs across workspaces', async (t) => {
|
|
|
2166
2166
|
|
|
2167
2167
|
try {
|
|
2168
2168
|
const health = await (await fetch(`http://127.0.0.1:${handle.port}/health`)).json();
|
|
2169
|
-
assert.deepEqual(health.activeRuns, [{ workspace: '
|
|
2169
|
+
assert.deepEqual(health.activeRuns, [{ workspace: 'demo', runId: 'run-1234abcd' }]);
|
|
2170
2170
|
} finally {
|
|
2171
2171
|
await handle.close();
|
|
2172
2172
|
}
|
|
@@ -142,7 +142,7 @@ test('E2E-002 wiki-sync: two objectives, two ordered runs, one chainId', async (
|
|
|
142
142
|
assert.equal(body.objectives, 2);
|
|
143
143
|
assert.equal(env.runs.length, 2, 'the second objective must run after the first');
|
|
144
144
|
assert.match(env.runs[0].input, /^Export the requested Confluence source/);
|
|
145
|
-
assert.match(env.runs[1].input, /^Run the production pipeline over the newly exported Markdown/);
|
|
145
|
+
assert.match(env.runs[1].input, /^Run the production pipeline step ingest over the newly exported Markdown/);
|
|
146
146
|
// CME first, Production second — and the parameter reaches the step that
|
|
147
147
|
// consumes it, not only the last objective.
|
|
148
148
|
for (const run of env.runs) assert.match(run.input, /User parameters:\nsource: docs/);
|
|
@@ -202,7 +202,7 @@ test('E2E-003 cancel: the running step and its chain stop, unrelated queue survi
|
|
|
202
202
|
// that silently fragments would show up as extra runs, not as extra objectives.
|
|
203
203
|
const PERFORMANCE_TABLE = {
|
|
204
204
|
pipeline: 1,
|
|
205
|
-
'wiki-ingest':
|
|
205
|
+
'wiki-ingest': 1,
|
|
206
206
|
'wiki-build': 1,
|
|
207
207
|
deliver: 1,
|
|
208
208
|
diagnose: 1,
|
|
@@ -216,7 +216,10 @@ test('runtime store persists task assignments attempts and results from events',
|
|
|
216
216
|
const reopened = openRuntimeStore({ stateDir });
|
|
217
217
|
const session = { activities: {}, headlessPlan: null };
|
|
218
218
|
reopened.hydrateSession(session, { workspace: 'docs' });
|
|
219
|
-
|
|
219
|
+
const assignedLine = reopened.getState(session, { workspace: 'docs' }).logs.find((line) => /▸ Build A — started/.test(line));
|
|
220
|
+
assert.ok(assignedLine, 'expected a readable task-started line carrying the plan label');
|
|
221
|
+
assert.match(assignedLine, /document\.build/);
|
|
222
|
+
assert.match(assignedLine, /production-main/);
|
|
220
223
|
assert.equal(reopened.listTaskAttempts({ taskId })[0].jobId, 'job-1');
|
|
221
224
|
assert.equal(reopened.getTaskResult({ taskId }).status, 'succeeded');
|
|
222
225
|
reopened.close();
|
|
@@ -1131,10 +1134,10 @@ test('un agent restauré par hydrateSession n’est plus routable tant qu’aucu
|
|
|
1131
1134
|
premiers.
|
|
1132
1135
|
*/
|
|
1133
1136
|
const store = openRuntimeStore({ stateDir: mkdtempSync(join(tmpdir(), 'wiki-manager-agent-staleness-')) });
|
|
1134
|
-
const writer = { agentEvents: [], workspace: '
|
|
1137
|
+
const writer = { agentEvents: [], workspace: 'demo' };
|
|
1135
1138
|
dispatchAgentEvent(writer, createAgentEvent('agent.registered', {
|
|
1136
1139
|
origin: 'runtime',
|
|
1137
|
-
workspace: '
|
|
1140
|
+
workspace: 'demo',
|
|
1138
1141
|
payload: {
|
|
1139
1142
|
agent: {
|
|
1140
1143
|
agentInstanceId: 'cme-main',
|
|
@@ -1152,8 +1155,8 @@ test('un agent restauré par hydrateSession n’est plus routable tant qu’aucu
|
|
|
1152
1155
|
for (const event of writer.agentEvents) store.persistEvent(event);
|
|
1153
1156
|
|
|
1154
1157
|
// Redémarrage : une session neuve, aucun scan encore effectué.
|
|
1155
|
-
const rebooted = { agentEvents: [], workspace: '
|
|
1156
|
-
store.hydrateSession(rebooted, { workspace: '
|
|
1158
|
+
const rebooted = { agentEvents: [], workspace: 'demo' };
|
|
1159
|
+
store.hydrateSession(rebooted, { workspace: 'demo' });
|
|
1157
1160
|
|
|
1158
1161
|
const restored = [...(rebooted.agents ?? []), ...(rebooted.agentRegistrySnapshot ?? [])];
|
|
1159
1162
|
assert.ok(restored.length > 0, 'the agent must be restored, only not trusted');
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { openSync, readSync, closeSync, fstatSync } from 'node:fs';
|
|
2
2
|
import { isAbsolute, join, normalize, resolve } from 'node:path';
|
|
3
|
-
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
3
|
+
import { createAgentEvent, dispatchAgentEvent, dispatchRuntimeLog } from '../core/agentEvents.js';
|
|
4
4
|
import { extractActivity, mergePolledActivity, parseJsonText, sessionActivities } from '../core/activity.js';
|
|
5
5
|
import { callMcpTool, formatMcpToolResult } from '../core/mcp.js';
|
|
6
|
-
import { normalizeRuntimeLog } from '../core/runtimeLog.js';
|
|
7
6
|
import { startNextQueuedJob, syncQueueWithActivity } from '../core/jobQueue.js';
|
|
8
7
|
import { createAgentRegistry } from '../orchestrator/agentRegistry.js';
|
|
8
|
+
import { discoverRuntimeProvidersOnce } from '../orchestrator/providers/runtimeProviders.js';
|
|
9
9
|
|
|
10
10
|
export function startActivitySupervisor(session, {
|
|
11
11
|
intervalMs = 1000,
|
|
@@ -52,11 +52,13 @@ export function startActivitySupervisor(session, {
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
void discoverAgentsOnce(session, { registry, signal: runSignal });
|
|
55
|
+
void discoverRuntimeProvidersOnce(session, { signal: runSignal });
|
|
55
56
|
}, agentRegistryIntervalMs)
|
|
56
57
|
: null;
|
|
57
58
|
|
|
58
59
|
void pollActivitiesOnce(session, { pollBusy, callTool, signal: runSignal });
|
|
59
60
|
void discoverAgentsOnce(session, { registry, signal: runSignal });
|
|
61
|
+
void discoverRuntimeProvidersOnce(session, { signal: runSignal });
|
|
60
62
|
|
|
61
63
|
return {
|
|
62
64
|
pollBusy,
|
|
@@ -199,14 +201,7 @@ export async function pollActivitiesOnce(session, {
|
|
|
199
201
|
}
|
|
200
202
|
|
|
201
203
|
export function emitRuntimeLog(session, message) {
|
|
202
|
-
|
|
203
|
-
dispatchAgentEvent(session, createAgentEvent('runtime_log', {
|
|
204
|
-
origin: 'runtime',
|
|
205
|
-
runId: payload.runId ?? null,
|
|
206
|
-
taskId: payload.taskId ?? null,
|
|
207
|
-
workspace: payload.workspaceId ?? null,
|
|
208
|
-
payload,
|
|
209
|
-
}));
|
|
204
|
+
dispatchRuntimeLog(session, message);
|
|
210
205
|
}
|
|
211
206
|
|
|
212
207
|
function registryIntervalFromEnv() {
|
|
@@ -34,39 +34,39 @@ test('a session stamps its workspace on every event it dispatches', () => {
|
|
|
34
34
|
// ne le retrouverait jamais. La plan/activité d'un run serait perdue au
|
|
35
35
|
// redémarrage, pour tout le monde.
|
|
36
36
|
const store = freshStore();
|
|
37
|
-
const
|
|
37
|
+
const acmeSession = sessionFor(store, 'acme');
|
|
38
38
|
|
|
39
|
-
dispatchAgentEvent(
|
|
39
|
+
dispatchAgentEvent(acmeSession, createAgentEvent('user_message', {
|
|
40
40
|
origin: 'user',
|
|
41
41
|
payload: { content: 'ingest démarré' },
|
|
42
42
|
}));
|
|
43
43
|
|
|
44
|
-
const [event] = store.listEvents({ workspace: '
|
|
45
|
-
assert.equal(event.workspace, '
|
|
44
|
+
const [event] = store.listEvents({ workspace: 'acme' });
|
|
45
|
+
assert.equal(event.workspace, 'acme', "l'événement doit porter son workspace");
|
|
46
46
|
});
|
|
47
47
|
|
|
48
48
|
test('two workspaces writing at the same time never see each other', () => {
|
|
49
49
|
const store = freshStore();
|
|
50
|
-
const
|
|
50
|
+
const acmeSession = sessionFor(store, 'acme');
|
|
51
51
|
const demo = sessionFor(store, 'demo');
|
|
52
52
|
|
|
53
53
|
// Entrelacé volontairement : c'est la situation réelle de deux `serve`
|
|
54
54
|
// ouverts côte à côte, pas deux runs successifs.
|
|
55
55
|
for (let i = 0; i < 5; i += 1) {
|
|
56
|
-
dispatchAgentEvent(
|
|
57
|
-
origin: 'user', payload: { content: `
|
|
56
|
+
dispatchAgentEvent(acmeSession, createAgentEvent('user_message', {
|
|
57
|
+
origin: 'user', payload: { content: `acme-${i}` },
|
|
58
58
|
}));
|
|
59
59
|
dispatchAgentEvent(demo, createAgentEvent('user_message', {
|
|
60
60
|
origin: 'user', payload: { content: `demo-${i}` },
|
|
61
61
|
}));
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
const
|
|
64
|
+
const acmeEvents = store.listEvents({ workspace: 'acme' });
|
|
65
65
|
const demoEvents = store.listEvents({ workspace: 'demo' });
|
|
66
66
|
|
|
67
|
-
assert.equal(
|
|
67
|
+
assert.equal(acmeEvents.length, 5);
|
|
68
68
|
assert.equal(demoEvents.length, 5);
|
|
69
|
-
assert.ok(
|
|
69
|
+
assert.ok(acmeEvents.every((event) => event.payload.content.startsWith('acme-')));
|
|
70
70
|
assert.ok(demoEvents.every((event) => event.payload.content.startsWith('demo-')));
|
|
71
71
|
});
|
|
72
72
|
|
|
@@ -74,32 +74,32 @@ test('a conversation is rebuilt from its own workspace only', () => {
|
|
|
74
74
|
// C'est ce qui décide de ce qu'affiche un `serve` au chargement. Un mélange
|
|
75
75
|
// ici afficherait les échanges du voisin dans sa fenêtre de chat.
|
|
76
76
|
const store = freshStore();
|
|
77
|
-
const
|
|
77
|
+
const acmeSession = sessionFor(store, 'acme');
|
|
78
78
|
const demo = sessionFor(store, 'demo');
|
|
79
79
|
|
|
80
|
-
dispatchAgentEvent(
|
|
80
|
+
dispatchAgentEvent(acmeSession, createAgentEvent('user_message', { origin: 'user', payload: { content: 'acme question' } }));
|
|
81
81
|
dispatchAgentEvent(demo, createAgentEvent('user_message', { origin: 'user', payload: { content: 'question demo' } }));
|
|
82
|
-
dispatchAgentEvent(
|
|
82
|
+
dispatchAgentEvent(acmeSession, createAgentEvent('assistant_message', { origin: 'runtime', payload: { content: 'acme answer' } }));
|
|
83
83
|
|
|
84
|
-
const projection = reduceAgentEvents(store.listEvents({ workspace: '
|
|
84
|
+
const projection = reduceAgentEvents(store.listEvents({ workspace: 'acme' }));
|
|
85
85
|
|
|
86
86
|
assert.deepEqual(projection.conversation.map((entry) => entry.content), [
|
|
87
|
-
'question
|
|
88
|
-
'
|
|
87
|
+
'acme question',
|
|
88
|
+
'acme answer',
|
|
89
89
|
]);
|
|
90
90
|
});
|
|
91
91
|
|
|
92
92
|
test('purging one workspace leaves the others intact', () => {
|
|
93
93
|
// `/clear --all` depuis un `serve` ne doit pas vider le runtime du voisin.
|
|
94
94
|
const store = freshStore();
|
|
95
|
-
const
|
|
95
|
+
const acmeSession = sessionFor(store, 'acme');
|
|
96
96
|
const demo = sessionFor(store, 'demo');
|
|
97
|
-
dispatchAgentEvent(
|
|
97
|
+
dispatchAgentEvent(acmeSession, createAgentEvent('user_message', { origin: 'user', payload: { content: 'a' } }));
|
|
98
98
|
dispatchAgentEvent(demo, createAgentEvent('user_message', { origin: 'user', payload: { content: 'd' } }));
|
|
99
99
|
|
|
100
|
-
store.clearWorkspaceState({ workspace: '
|
|
100
|
+
store.clearWorkspaceState({ workspace: 'acme' });
|
|
101
101
|
|
|
102
|
-
assert.equal(store.listEvents({ workspace: '
|
|
102
|
+
assert.equal(store.listEvents({ workspace: 'acme' }).length, 0);
|
|
103
103
|
assert.equal(store.listEvents({ workspace: 'demo' }).length, 1);
|
|
104
104
|
});
|
|
105
105
|
|
|
@@ -119,12 +119,12 @@ test('a purge without a workspace wipes EVERY workspace', () => {
|
|
|
119
119
|
où on décide de refuser plutôt que d'élargir, ce soit un choix explicite.
|
|
120
120
|
*/
|
|
121
121
|
const store = freshStore();
|
|
122
|
-
dispatchAgentEvent(sessionFor(store, '
|
|
122
|
+
dispatchAgentEvent(sessionFor(store, 'acme'), createAgentEvent('user_message', { origin: 'user', payload: { content: 'a' } }));
|
|
123
123
|
dispatchAgentEvent(sessionFor(store, 'demo'), createAgentEvent('user_message', { origin: 'user', payload: { content: 'd' } }));
|
|
124
124
|
|
|
125
125
|
store.clearWorkspaceState({ workspace: null });
|
|
126
126
|
|
|
127
|
-
assert.equal(store.listEvents({ workspace: '
|
|
127
|
+
assert.equal(store.listEvents({ workspace: 'acme' }).length, 0);
|
|
128
128
|
assert.equal(store.listEvents({ workspace: 'demo' }).length, 0);
|
|
129
129
|
});
|
|
130
130
|
|
|
@@ -135,11 +135,11 @@ test('the SSE publisher delivers an event only to its own workspace', () => {
|
|
|
135
135
|
const deliver = (clientWorkspace, eventWorkspace) =>
|
|
136
136
|
!(clientWorkspace && eventWorkspace !== clientWorkspace);
|
|
137
137
|
|
|
138
|
-
assert.equal(deliver('
|
|
139
|
-
assert.equal(deliver('
|
|
140
|
-
assert.equal(deliver('
|
|
138
|
+
assert.equal(deliver('acme', 'acme'), true);
|
|
139
|
+
assert.equal(deliver('acme', 'demo'), false, 'un client scopé ne doit rien recevoir du voisin');
|
|
140
|
+
assert.equal(deliver('acme', null), false);
|
|
141
141
|
// Le cas qui fuit : un abonné SANS workspace reçoit tout.
|
|
142
|
-
assert.equal(deliver(null, '
|
|
142
|
+
assert.equal(deliver(null, 'acme'), true);
|
|
143
143
|
assert.equal(deliver(null, 'demo'), true);
|
|
144
144
|
});
|
|
145
145
|
|
package/src/shell/RightPane.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/solid */
|
|
2
2
|
import { createMemo, createSignal, Index, Show } from 'solid-js';
|
|
3
|
-
import { compactRuntimeLogForDisplay, filterRuntimeLogs } from '../core/runtimeLog.js';
|
|
3
|
+
import { compactRuntimeLogForDisplay, filterRuntimeLogs, isDispatchPlumbingLine } from '../core/runtimeLog.js';
|
|
4
4
|
import { fit } from './textFit';
|
|
5
5
|
|
|
6
6
|
type PlanStep = { step: number; description: string; status: string };
|
|
@@ -263,7 +263,11 @@ export function PlanPanel(props: { plan: PlanStep[]; width: number; jobName?: st
|
|
|
263
263
|
|
|
264
264
|
export function ActivityPanel(props: { activities: any[]; width: number }) {
|
|
265
265
|
const lineWidth = () => Math.max(8, props.width - 2);
|
|
266
|
-
|
|
266
|
+
// session.activities is never pruned within a run (see core/agentEvents.js),
|
|
267
|
+
// so a long batch job's activity count is unbounded — render only the most
|
|
268
|
+
// recent slice, memoized like LogPanel's allLines, instead of copying and
|
|
269
|
+
// re-wrapping the entire lifetime history on every reactive tick.
|
|
270
|
+
const visible = createMemo(() => props.activities.slice(-200).reverse());
|
|
267
271
|
return (
|
|
268
272
|
<box flexGrow={1} flexDirection="column" paddingX={1} backgroundColor="#111318">
|
|
269
273
|
<text width={lineWidth()} fg="#D6DEE8" content="Activity" />
|
|
@@ -340,6 +344,15 @@ type LogSegment = { text: string; fg: string };
|
|
|
340
344
|
// Continuation lines of a wrapped entry are indented and dimmed so each
|
|
341
345
|
// entry reads as one visual block instead of an undifferentiated wall.
|
|
342
346
|
function logMessageColor(message: string): string {
|
|
347
|
+
// Task-lifecycle glyphs win outright: ✓ done (green), ✗ failed (red),
|
|
348
|
+
// ▸ started / ↻ retry keep the neutral/amber tone the keyword rules below
|
|
349
|
+
// would give them anyway.
|
|
350
|
+
if (/^\s*✓/.test(message)) return '#A6E3A1';
|
|
351
|
+
if (/^\s*✗/.test(message)) return '#F38BA8';
|
|
352
|
+
// A doctor summary with ZERO errors is a warning by construction
|
|
353
|
+
// ("⚠ 0 error(s), 2 warning(s)") — amber, never red, even though it
|
|
354
|
+
// mentions the word "error".
|
|
355
|
+
if (/(?<!\d)0 error\(s\)/i.test(message)) return '#FBBF24';
|
|
343
356
|
if (/\b(?:trace:\s*)?WARN\b/i.test(message)) return '#FBBF24';
|
|
344
357
|
if (/\b(error|failed|exception|unavailable|introuvable|HTTP 4\d\d|HTTP 5\d\d)\b/i.test(message)) return '#F38BA8';
|
|
345
358
|
if (/\b(warn|warning|avertissement|fallback|retry|expired|stale)\b/i.test(message)) return '#FBBF24';
|
|
@@ -388,7 +401,14 @@ function logEntryLines(raw: string, width: number): LogSegment[][] {
|
|
|
388
401
|
export function LogPanel(props: { logs: string[]; width: number; filter?: string }) {
|
|
389
402
|
const [activeLogTab, setActiveLogTab] = createSignal<'flow' | 'agent-status'>('flow');
|
|
390
403
|
const lineWidth = () => Math.max(8, props.width - 2);
|
|
391
|
-
|
|
404
|
+
// "Agent status" collects the dispatch plumbing — capability resolution,
|
|
405
|
+
// agent selection, agent_execute/agent_status polling, job acceptance — so
|
|
406
|
+
// the "Runtime" tab is left with the readable business flow (plan + the
|
|
407
|
+
// ▸/✓/✗/↻ task lines). isDispatchPlumbingLine (shared with agentEvents'
|
|
408
|
+
// dedup) recognises the formatRuntimeLogPayload shape structurally; the old
|
|
409
|
+
// token enumeration only ever matched agent_status/agent_execute and missed
|
|
410
|
+
// every dotted event ('job.accepted' → 'ACCEPTED', …).
|
|
411
|
+
const isAgentStatus = (line: string) => isDispatchPlumbingLine(line);
|
|
392
412
|
// Filtering preserves the runtime history order. logRenderLines performs
|
|
393
413
|
// the single block-level reversal shared by both tabs.
|
|
394
414
|
const filteredLogs = () => filterRuntimeLogs(props.logs, props.filter ?? '')
|
|
@@ -238,9 +238,36 @@ export function StartupScreen(props: {
|
|
|
238
238
|
});
|
|
239
239
|
|
|
240
240
|
const preflightChecks = createMemo(() => props.preflight?.checks ?? []);
|
|
241
|
+
const checkByKind = createMemo(() => {
|
|
242
|
+
const map = new Map<string, any>();
|
|
243
|
+
for (const check of preflightChecks()) map.set(check.kind, check);
|
|
244
|
+
return map;
|
|
245
|
+
});
|
|
246
|
+
// Docker and Internet leave the check list: they are ambient state, shown in
|
|
247
|
+
// the header (top right), not among the startup rows.
|
|
248
|
+
const dockerCheck = createMemo(() => checkByKind().get('docker'));
|
|
249
|
+
const internetCheck = createMemo(() => checkByKind().get('internet'));
|
|
250
|
+
const listChecks = createMemo(() => {
|
|
251
|
+
const order = ['workspace', 'runtime', 'agentic', 'agents', 'containers', 'mcp'];
|
|
252
|
+
const seen = new Set<string>(['docker', 'internet']);
|
|
253
|
+
const ordered: Array<{ kind: string; ok?: boolean; skipped?: boolean; pending?: boolean; detail?: string }> = [];
|
|
254
|
+
for (const kind of order) {
|
|
255
|
+
const check = checkByKind().get(kind);
|
|
256
|
+
if (!check) continue;
|
|
257
|
+
// An optional engine is not part of the startup story: when nothing is
|
|
258
|
+
// enabled, its row disappears instead of showing a misleading green ✓.
|
|
259
|
+
if (kind === 'agentic' && check.skipped) continue;
|
|
260
|
+
ordered.push(check);
|
|
261
|
+
seen.add(kind);
|
|
262
|
+
}
|
|
263
|
+
for (const check of preflightChecks()) {
|
|
264
|
+
if (!seen.has(check.kind)) ordered.push(check);
|
|
265
|
+
}
|
|
266
|
+
return ordered;
|
|
267
|
+
});
|
|
241
268
|
const checkLabel = (kind: string) => ({
|
|
242
269
|
docker: 'Docker', internet: 'Internet', agents: 'Agents', workspace: 'Workspaces',
|
|
243
|
-
containers: 'Containers', mcp: 'MCP', runtime: 'Runtime',
|
|
270
|
+
containers: 'Containers', mcp: 'MCP', agentic: 'Agentic runtime', runtime: 'Runtime',
|
|
244
271
|
} as Record<string, string>)[kind] ?? kind;
|
|
245
272
|
|
|
246
273
|
const subtitle = createMemo(() => {
|
|
@@ -281,10 +308,20 @@ export function StartupScreen(props: {
|
|
|
281
308
|
overflow="hidden"
|
|
282
309
|
>
|
|
283
310
|
<box height={1} flexDirection="row">
|
|
284
|
-
<text fg="#8BD5CA" content={fit(`DONNA v${props.version}`, Math.floor(innerWidth() * 0.
|
|
285
|
-
<text fg={statusColor()} content={fit(` ${props.preflightBusy ? '◐' : statusDot(props.preflight?.status === 'ready')} ${status()}`, Math.floor(innerWidth() * 0.
|
|
311
|
+
<text fg="#8BD5CA" content={fit(`DONNA v${props.version}`, Math.floor(innerWidth() * 0.4))} />
|
|
312
|
+
<text fg={statusColor()} content={fit(` ${props.preflightBusy ? '◐' : statusDot(props.preflight?.status === 'ready')} ${status()}`, Math.floor(innerWidth() * 0.32))} />
|
|
313
|
+
<box flexGrow={1} />
|
|
314
|
+
<text fg="#7F8C8D" content={dockerCheck() ? fit(`Docker — ${dockerCheck()?.detail ?? '—'}`, Math.floor(innerWidth() * 0.28)) : ''} />
|
|
315
|
+
</box>
|
|
316
|
+
<box height={1} flexDirection="row">
|
|
317
|
+
<text height={1} fg="#7F8C8D" content={fit(subtitle(), Math.floor(innerWidth() * 0.6))} />
|
|
318
|
+
<box flexGrow={1} />
|
|
319
|
+
<text
|
|
320
|
+
height={1}
|
|
321
|
+
fg={internetCheck() ? (internetCheck()?.ok ? '#8BD5CA' : '#FBBF24') : '#7F8C8D'}
|
|
322
|
+
content={internetCheck() ? fit(`Internet — ${internetCheck()?.ok ? 'OK' : 'KO'}`, Math.floor(innerWidth() * 0.28)) : ''}
|
|
323
|
+
/>
|
|
286
324
|
</box>
|
|
287
|
-
<text height={1} fg="#7F8C8D" content={fit(subtitle(), innerWidth())} />
|
|
288
325
|
<text height={1}>{''}</text>
|
|
289
326
|
<box
|
|
290
327
|
height={8}
|
|
@@ -295,7 +332,7 @@ export function StartupScreen(props: {
|
|
|
295
332
|
overflow="hidden"
|
|
296
333
|
>
|
|
297
334
|
<text fg="#d6a85f">{DONNA_LOGO}</text>
|
|
298
|
-
<text fg="#888888">Intelligent workspace</text>
|
|
335
|
+
<text fg="#888888">Intelligent Agentic workspace(s)</text>
|
|
299
336
|
</box>
|
|
300
337
|
<text height={1}>{''}</text>
|
|
301
338
|
<text height={1} fg="#7F8C8D" content={menuTitle()} />
|
|
@@ -320,8 +357,8 @@ export function StartupScreen(props: {
|
|
|
320
357
|
</For>
|
|
321
358
|
</box>
|
|
322
359
|
<text height={1}>{''}</text>
|
|
323
|
-
<box height={Math.max(3,
|
|
324
|
-
<For each={
|
|
360
|
+
<box height={Math.max(3, listChecks().length)} flexDirection="column" border={['left']} borderStyle="heavy" borderColor="#5DADE2" paddingX={1} overflow="hidden">
|
|
361
|
+
<For each={listChecks().length ? listChecks() : [
|
|
325
362
|
{ kind: 'workspace', ok: props.wikiReady, detail: props.wikiReady ? 'default profile ready' : 'init required' },
|
|
326
363
|
{ kind: 'mcp', ok: props.connectedMcpServers > 0, detail: `${props.connectedMcpServers} server(s) configured` },
|
|
327
364
|
{ kind: 'llm', ok: Boolean(props.model), detail: props.model || 'not configured' },
|
package/src/shell/repl.js
CHANGED
|
@@ -3,19 +3,21 @@ import { createInterface } from 'node:readline';
|
|
|
3
3
|
import { emitKeypressEvents } from 'node:readline';
|
|
4
4
|
import { Transform } from 'node:stream';
|
|
5
5
|
import { execFileSync } from 'node:child_process';
|
|
6
|
+
import { statSync } from 'node:fs';
|
|
6
7
|
import { readFile } from 'node:fs/promises';
|
|
7
8
|
import path from 'node:path';
|
|
8
9
|
import { stdin as input, stdout as output } from 'node:process';
|
|
9
10
|
import { marked } from 'marked';
|
|
10
11
|
import { markedTerminal } from 'marked-terminal';
|
|
11
12
|
import { buildAgentSystemPrompt, formatLlmUnavailableMessage, isOrchestrationBypassTool } from '../agent/graph.js';
|
|
12
|
-
import { handleSlashCommand, rawCommandAgentPrompt } from '../commands/slash.js';
|
|
13
|
+
import { handleSlashCommand, rawCommandAgentPrompt, refreshMcpRuntimeStatus } from '../commands/slash.js';
|
|
13
14
|
import { serviceChoices as composeServiceChoices, serviceDescription } from '../core/compose.js';
|
|
14
15
|
import { extractActivity, mergePolledActivity, parseJsonText, sessionActivities } from '../core/activity.js';
|
|
15
16
|
import { syncActivitiesToPlan } from '../core/plan.js';
|
|
16
17
|
import { buildLlmTools, callMcpTool, formatMcpToolResult, parseToolCallName, resolveToolCallName } from '../core/mcp.js';
|
|
17
18
|
import { runBoundedToolLoop } from '../core/toolLoop.js';
|
|
18
|
-
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
19
|
+
import { createAgentEvent, dispatchAgentEvent, dispatchRuntimeLog } from '../core/agentEvents.js';
|
|
20
|
+
import { managerMcpEndpointsFile } from '../core/env.js';
|
|
19
21
|
import { togglableAgentNames } from '../core/agentsCompose.js';
|
|
20
22
|
import { loadWorkspaceProfile } from '../core/profile.js';
|
|
21
23
|
import { artifactFromToolCall, currentArtifactFor, currentArtifactPromptLine, rememberArtifact } from '../core/currentArtifact.js';
|
|
@@ -1922,6 +1924,25 @@ async function runTuiShell({ agent, packageJson, session, runtime = null }) {
|
|
|
1922
1924
|
void subscribeRuntimeEvents();
|
|
1923
1925
|
}
|
|
1924
1926
|
|
|
1927
|
+
// Connectors added from the serve panel land in mcp.endpoints.json while the
|
|
1928
|
+
// shell keeps running. Without a re-read, the new server stays invisible in
|
|
1929
|
+
// /chat and /agent until a restart — the operator had to delete and re-add
|
|
1930
|
+
// the connector and still saw nothing. Watch the file's mtime: a change
|
|
1931
|
+
// refreshes MCP status and tools IN PLACE, no restart.
|
|
1932
|
+
let endpointsMtimeMs = null;
|
|
1933
|
+
const endpointsWatchInterval = setInterval(async () => {
|
|
1934
|
+
if (runtimePollingActive) return;
|
|
1935
|
+
try {
|
|
1936
|
+
const mtime = statSync(managerMcpEndpointsFile()).mtimeMs;
|
|
1937
|
+
if (endpointsMtimeMs === null) { endpointsMtimeMs = mtime; return; }
|
|
1938
|
+
if (mtime === endpointsMtimeMs) return;
|
|
1939
|
+
endpointsMtimeMs = mtime;
|
|
1940
|
+
await refreshMcpRuntimeStatus(session);
|
|
1941
|
+
dispatchRuntimeLog(session, 'mcp: endpoints file changed — connectors refreshed in place');
|
|
1942
|
+
rerender();
|
|
1943
|
+
} catch { /* file absent or mid-write: try again next tick */ }
|
|
1944
|
+
}, 3000);
|
|
1945
|
+
|
|
1925
1946
|
const pollBusy = new Set();
|
|
1926
1947
|
const productionPollInterval = setInterval(async () => {
|
|
1927
1948
|
if (runtimePollingActive) return;
|
|
@@ -2259,6 +2280,7 @@ async function runTuiShell({ agent, packageJson, session, runtime = null }) {
|
|
|
2259
2280
|
clearTimeout(runtimeReconnectTimer);
|
|
2260
2281
|
clearTimeout(runtimeSyncTimer);
|
|
2261
2282
|
clearInterval(productionPollInterval);
|
|
2283
|
+
clearInterval(endpointsWatchInterval);
|
|
2262
2284
|
clearTimeout(ctrlCTimer);
|
|
2263
2285
|
clearTimeout(mouseSelectionTimer);
|
|
2264
2286
|
output.off('resize', onResize);
|
package/src/shell/repl.test.js
CHANGED
|
@@ -173,6 +173,19 @@ test('Flow/Trace does not repeat the runtime source prefix on every line', async
|
|
|
173
173
|
assert.match(entryRenderer, /prefix\.push\(\{ text: `\$\{parts\.time\} /);
|
|
174
174
|
});
|
|
175
175
|
|
|
176
|
+
test('a doctor summary with a nonzero error count is colored as an error, not a warning', async () => {
|
|
177
|
+
const source = await readFile(new URL('./RightPane.tsx', import.meta.url), 'utf8');
|
|
178
|
+
const colorFn = source.slice(
|
|
179
|
+
source.indexOf('function logMessageColor'),
|
|
180
|
+
source.indexOf('function logRenderLines'),
|
|
181
|
+
);
|
|
182
|
+
// The "0 error(s)" amber shortcut must be digit-anchored: "10 error(s)"
|
|
183
|
+
// ends in "0 error(s)" too, and an unanchored test painted a 10-error
|
|
184
|
+
// doctor failure the same colour as a clean run.
|
|
185
|
+
assert.match(colorFn, /\(\?<!\\d\)0 error\\\(s\\\)/);
|
|
186
|
+
assert.doesNotMatch(colorFn, /\/0 error\\\(s\\\)\/i\.test/);
|
|
187
|
+
});
|
|
188
|
+
|
|
176
189
|
test('runtime logs have a separator and a concise Runtime tab label', async () => {
|
|
177
190
|
const source = await readFile(new URL('./RightPane.tsx', import.meta.url), 'utf8');
|
|
178
191
|
const logPanel = source.slice(
|