@dotdrelle/wiki-manager 0.15.70 → 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/README.md +24 -21
- package/agents.docker-compose.yml +5 -1
- package/package.json +1 -1
- package/src/activity/activityAggregator.test.js +2 -2
- package/src/agent/graph.js +1 -0
- package/src/cli/wiki-manager.js +1 -1
- package/src/cli/wiki-manager.test.js +16 -16
- package/src/commands/slash.js +24 -5
- package/src/core/agentEvents.js +122 -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/mcp.js +1 -1
- package/src/core/mcp.test.js +1 -1
- package/src/core/otherWorkspacesRunning.test.js +6 -6
- package/src/core/runtimeLog.js +35 -1
- package/src/core/runtimeLog.test.js +27 -2
- package/src/core/skillInvocation.test.js +1 -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.test.js +6 -6
- package/src/orchestrator/dispatcher.js +70 -26
- package/src/orchestrator/dispatcher.test.js +46 -3
- package/src/orchestrator/providers/deepAgentsProvider.test.js +2 -2
- package/src/orchestrator/providers/runtimeProviders.js +58 -5
- package/src/orchestrator/providers/runtimeProviders.test.js +23 -0
- package/src/orchestrator/scheduler.test.js +4 -4
- package/src/runtime/delegation.test.js +11 -11
- package/src/runtime/runner.test.js +1 -1
- package/src/runtime/server.test.js +2 -2
- package/src/runtime/store.test.js +8 -5
- package/src/runtime/workspaceIsolation.test.js +26 -26
- package/src/shell/RightPane.tsx +14 -2
- package/src/shell/repl.js +24 -2
- package/wiki-workspace +34 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { dispatchRuntimeLog } from '../../core/agentEvents.js';
|
|
4
|
-
import { managerStateDir } from '../../core/env.js';
|
|
4
|
+
import { managerEnvFile, managerStateDir, readEnvFile } from '../../core/env.js';
|
|
5
5
|
import { createDeepAgentsProvider } from './deepAgentsProvider.js';
|
|
6
6
|
import { createFakeRuntimeProvider } from './fakeRuntimeProvider.js';
|
|
7
7
|
import { assertRuntimeProvider } from './runtimeProvider.js';
|
|
@@ -65,7 +65,23 @@ export async function discoverRuntimeProviderAgents(runtimeProviders) {
|
|
|
65
65
|
*
|
|
66
66
|
* Entrée : `{ id, type, endpoint?, enabled?, capabilities?, limits? }`.
|
|
67
67
|
*/
|
|
68
|
-
export function loadAgentRuntimesConfig({
|
|
68
|
+
export function loadAgentRuntimesConfig({
|
|
69
|
+
stateDir = managerStateDir(),
|
|
70
|
+
log = () => {},
|
|
71
|
+
env = null,
|
|
72
|
+
} = {}) {
|
|
73
|
+
// GATEWAY_ENABLED / GATEWAY_AUTH_TOKEN live in the manager .env FILE, not in
|
|
74
|
+
// the process environment (the runtime child is spawned without them). The
|
|
75
|
+
// manager's canonical policy is the same as resolvedManagerEnv: the file
|
|
76
|
+
// wins over stale process values, so a token generated later by `agents up`
|
|
77
|
+
// is honoured without a restart.
|
|
78
|
+
const resolvedEnv = env ?? (() => {
|
|
79
|
+
try {
|
|
80
|
+
return { ...process.env, ...readEnvFile(managerEnvFile()) };
|
|
81
|
+
} catch {
|
|
82
|
+
return process.env;
|
|
83
|
+
}
|
|
84
|
+
})();
|
|
69
85
|
const file = join(stateDir, 'agent-runtimes.json');
|
|
70
86
|
let entries = [];
|
|
71
87
|
if (existsSync(file)) {
|
|
@@ -78,7 +94,7 @@ export function loadAgentRuntimesConfig({ stateDir = managerStateDir(), log = ()
|
|
|
78
94
|
log(`agent-runtimes.json unreadable: ${error instanceof Error ? error.message : String(error)}`);
|
|
79
95
|
}
|
|
80
96
|
}
|
|
81
|
-
return withImpliedGateway(entries,
|
|
97
|
+
return withImpliedGateway(entries, resolvedEnv, log);
|
|
82
98
|
}
|
|
83
99
|
|
|
84
100
|
// One switch: when the operator starts the gateway container
|
|
@@ -90,6 +106,25 @@ export function loadAgentRuntimesConfig({ stateDir = managerStateDir(), log = ()
|
|
|
90
106
|
// case worth stating, not assuming: an operator who wrote `enabled: false`
|
|
91
107
|
// deliberately would otherwise have no way to learn why deepagents ran anyway.
|
|
92
108
|
function withImpliedGateway(entries, env = process.env, log = () => {}) {
|
|
109
|
+
const token = String(env.GATEWAY_AUTH_TOKEN ?? '').trim();
|
|
110
|
+
const gatewayPort = String(env.GATEWAY_PORT ?? '7789');
|
|
111
|
+
if (isTruthy(env.GATEWAY_ENABLED) && token) {
|
|
112
|
+
// The manager owns THIS gateway (host-local, GATEWAY_PORT): its bearer
|
|
113
|
+
// token must reach the EXPLICIT entry too. The scaffolded
|
|
114
|
+
// agent-runtimes.json declares the capabilities but no headers, so
|
|
115
|
+
// discovery probed /health without the token and the gateway answered 401
|
|
116
|
+
// forever — "enabled by default" plus "explicit entry" must not combine
|
|
117
|
+
// into a permanently unavailable runtime. An operator who pinned explicit
|
|
118
|
+
// headers keeps them; a `deepagents` entry pointing at a foreign or shared
|
|
119
|
+
// host gets NOTHING — the local gateway secret must never leave the box.
|
|
120
|
+
for (const entry of entries) {
|
|
121
|
+
if (entry?.type !== 'deepagents' || entry?.enabled === false) continue;
|
|
122
|
+
if (!isManagerOwnedGatewayEndpoint(entry.endpoint, gatewayPort)) continue;
|
|
123
|
+
const headers = entry.headers && typeof entry.headers === 'object' ? entry.headers : {};
|
|
124
|
+
if ('Authorization' in headers || 'authorization' in headers) continue;
|
|
125
|
+
entry.headers = { ...headers, Authorization: `Bearer ${token}` };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
93
128
|
if (!isTruthy(env.GATEWAY_ENABLED)) return entries;
|
|
94
129
|
const explicitEnabled = entries.some((entry) =>
|
|
95
130
|
entry?.type === 'deepagents' && entry?.enabled !== false);
|
|
@@ -98,8 +133,7 @@ function withImpliedGateway(entries, env = process.env, log = () => {}) {
|
|
|
98
133
|
if (disabledEntry) {
|
|
99
134
|
log('agent-runtimes: GATEWAY_ENABLED=true implies a "deepagents" runtime despite an explicit enabled:false entry in agent-runtimes.json');
|
|
100
135
|
}
|
|
101
|
-
const port =
|
|
102
|
-
const token = String(env.GATEWAY_AUTH_TOKEN ?? '').trim();
|
|
136
|
+
const port = gatewayPort;
|
|
103
137
|
// The implied entry inherits the disabled entry's declared capability shape
|
|
104
138
|
// (approval classes, alias operations, descriptions): the scaffolded
|
|
105
139
|
// agent-runtimes.json ships the full list with enabled:false, and dropping it
|
|
@@ -133,6 +167,25 @@ function isTruthy(value) {
|
|
|
133
167
|
return /^(1|true|yes|on)$/i.test(String(value ?? '').trim());
|
|
134
168
|
}
|
|
135
169
|
|
|
170
|
+
// The manager-owned gateway is the one the manager itself starts: host-local,
|
|
171
|
+
// on GATEWAY_PORT. An entry with no endpoint relies on the implied host-local
|
|
172
|
+
// one, so it counts too. Anything else — a shared or third-party gateway host
|
|
173
|
+
// an operator pointed a second `deepagents` entry at — must not be handed the
|
|
174
|
+
// manager's local GATEWAY_AUTH_TOKEN.
|
|
175
|
+
function isManagerOwnedGatewayEndpoint(endpoint, gatewayPort) {
|
|
176
|
+
if (!endpoint) return true;
|
|
177
|
+
let url;
|
|
178
|
+
try {
|
|
179
|
+
url = new URL(String(endpoint));
|
|
180
|
+
} catch {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
const localHosts = new Set(['localhost', '127.0.0.1', '::1', '[::1]', 'host.docker.internal']);
|
|
184
|
+
if (!localHosts.has(url.hostname)) return false;
|
|
185
|
+
const entryPort = url.port || (url.protocol === 'https:' ? '443' : '80');
|
|
186
|
+
return entryPort === String(gatewayPort);
|
|
187
|
+
}
|
|
188
|
+
|
|
136
189
|
/**
|
|
137
190
|
* Usines de providers par `type`. `fake` sert les tests/plomberie ; `deepagents`
|
|
138
191
|
* parle HTTP à un runtime externe (Phase 5). Un type inconnu est ignoré et
|
|
@@ -194,6 +194,29 @@ test('GATEWAY_ENABLED implies the deepagents runtime with the bearer token (one
|
|
|
194
194
|
}
|
|
195
195
|
});
|
|
196
196
|
|
|
197
|
+
test('the local gateway bearer is injected only into the manager-owned host-local entry', () => {
|
|
198
|
+
const dir = mkdtempSync(join(tmpdir(), 'agent-runtimes-'));
|
|
199
|
+
try {
|
|
200
|
+
writeFileSync(join(dir, 'agent-runtimes.json'), JSON.stringify({
|
|
201
|
+
runtimes: [
|
|
202
|
+
{ id: 'local', type: 'deepagents', endpoint: 'http://127.0.0.1:7789', enabled: true },
|
|
203
|
+
{ id: 'shared', type: 'deepagents', endpoint: 'https://gateway.partner.example', enabled: true },
|
|
204
|
+
{ id: 'otherport', type: 'deepagents', endpoint: 'http://localhost:9999', enabled: true },
|
|
205
|
+
],
|
|
206
|
+
}));
|
|
207
|
+
const config = loadAgentRuntimesConfig({
|
|
208
|
+
stateDir: dir,
|
|
209
|
+
env: { GATEWAY_ENABLED: 'true', GATEWAY_PORT: '7789', GATEWAY_AUTH_TOKEN: 'tok-local' },
|
|
210
|
+
});
|
|
211
|
+
const byId = Object.fromEntries(config.map((entry) => [entry.id, entry]));
|
|
212
|
+
assert.deepEqual(byId.local.headers, { Authorization: 'Bearer tok-local' });
|
|
213
|
+
assert.equal(byId.shared.headers, undefined, 'a foreign gateway host must never receive the local token');
|
|
214
|
+
assert.equal(byId.otherport.headers, undefined, 'a non-gateway port is not the manager-owned gateway');
|
|
215
|
+
} finally {
|
|
216
|
+
rmSync(dir, { recursive: true, force: true });
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
197
220
|
test('an explicit enabled deepagents entry wins over the implied one', () => {
|
|
198
221
|
const dir = mkdtempSync(join(tmpdir(), 'agent-runtimes-'));
|
|
199
222
|
try {
|
|
@@ -246,7 +246,7 @@ function task(id, overrides = {}) {
|
|
|
246
246
|
}
|
|
247
247
|
|
|
248
248
|
/*
|
|
249
|
-
Cas observé le 2026-08-22 (workspace
|
|
249
|
+
Cas observé le 2026-08-22 (workspace acme) : `/wiki-ingest` planifie 13
|
|
250
250
|
ingest_plan (groupe `ingest`) + 13 ingest_apply (groupe `apply`, sérialisés
|
|
251
251
|
sur le lock `workspace-write`, derrière la barrière `ingest`) + 1 taxonomy
|
|
252
252
|
(barrière `apply`). Le grant run-scope émis par le bouton Approve est « nu » :
|
|
@@ -260,7 +260,7 @@ function task(id, overrides = {}) {
|
|
|
260
260
|
test('un grant run-scope « nu » (sans classes ni révision) débloque les apply derrière une barrière', () => {
|
|
261
261
|
const plan = {
|
|
262
262
|
runId: 'run-1',
|
|
263
|
-
workspace: '
|
|
263
|
+
workspace: 'acme',
|
|
264
264
|
planRevision: 1,
|
|
265
265
|
tasks: [
|
|
266
266
|
// 13 ingest_plan du groupe ingest, tous done.
|
|
@@ -307,7 +307,7 @@ test('un grant run-scope « nu » (sans classes ni révision) débloque les appl
|
|
|
307
307
|
status: 'approved',
|
|
308
308
|
scope: 'run',
|
|
309
309
|
runId: 'run-1',
|
|
310
|
-
workspaceId: '
|
|
310
|
+
workspaceId: 'acme',
|
|
311
311
|
planRevision: null,
|
|
312
312
|
approvalClasses: [],
|
|
313
313
|
}],
|
|
@@ -322,7 +322,7 @@ test('un grant run-scope « nu » (sans classes ni révision) débloque les appl
|
|
|
322
322
|
});
|
|
323
323
|
|
|
324
324
|
/*
|
|
325
|
-
Cas observé le 2026-08-04 (workspace
|
|
325
|
+
Cas observé le 2026-08-04 (workspace demo) : une ingestion de dix fichiers,
|
|
326
326
|
neuf réussis, le dixième en échec sur du JSON malformé. La barrière de groupe
|
|
327
327
|
exigeait que TOUS les membres soient `done` : elle ne s'est jamais ouverte, le
|
|
328
328
|
planificateur n'a plus trouvé de tâche prête, et le run est resté `running`
|
|
@@ -47,11 +47,11 @@ function preparedDelegation(taskId) {
|
|
|
47
47
|
|
|
48
48
|
function runningSession(runId = 'run-conversational') {
|
|
49
49
|
return {
|
|
50
|
-
workspace: '
|
|
50
|
+
workspace: 'demo',
|
|
51
51
|
activities: {},
|
|
52
52
|
agentEvents: [],
|
|
53
53
|
headlessPlan: null,
|
|
54
|
-
_currentRunIdentity: { runId, workspace: '
|
|
54
|
+
_currentRunIdentity: { runId, workspace: 'demo' },
|
|
55
55
|
};
|
|
56
56
|
}
|
|
57
57
|
|
|
@@ -98,9 +98,9 @@ test('une demande de build délègue dans le run courant, sans en démarrer un s
|
|
|
98
98
|
const session = runningSession('run-fr-build');
|
|
99
99
|
const started = [];
|
|
100
100
|
|
|
101
|
-
const result = await delegateWithinRun(session, '
|
|
101
|
+
const result = await delegateWithinRun(session, 'build the deliverables of the demo workspace', {
|
|
102
102
|
prepare: async ({ objective }) => {
|
|
103
|
-
assert.match(objective, /
|
|
103
|
+
assert.match(objective, /build the deliverables/);
|
|
104
104
|
return preparedDelegation('build-fr');
|
|
105
105
|
},
|
|
106
106
|
registry: registryDouble(),
|
|
@@ -118,10 +118,10 @@ test('une demande de build délègue dans le run courant, sans en démarrer un s
|
|
|
118
118
|
test('la délégation interne exige un run actif', async () => {
|
|
119
119
|
// Hors run, il n'y a pas d'exécution à transformer : c'est un vrai démarrage
|
|
120
120
|
// de run, et il doit passer par le chemin normal plutôt que par ici.
|
|
121
|
-
const session = { workspace: '
|
|
121
|
+
const session = { workspace: 'demo', agentEvents: [] };
|
|
122
122
|
|
|
123
123
|
await assert.rejects(
|
|
124
|
-
() => delegateWithinRun(session, '
|
|
124
|
+
() => delegateWithinRun(session, 'build the deliverables', {
|
|
125
125
|
prepare: async () => preparedDelegation(),
|
|
126
126
|
registry: registryDouble(),
|
|
127
127
|
}),
|
|
@@ -184,12 +184,12 @@ test('une demande française délègue une fois et bascule, sans jamais duplique
|
|
|
184
184
|
const { runRuntimeAgenticWorkflow } = await import('./runner.js');
|
|
185
185
|
const runId = 'run-fr-integration';
|
|
186
186
|
const session = {
|
|
187
|
-
workspace: '
|
|
187
|
+
workspace: 'demo',
|
|
188
188
|
activities: {},
|
|
189
189
|
agentEvents: [],
|
|
190
190
|
headlessPlan: null,
|
|
191
191
|
// Posée par executeRun avant l'appel au workflow, comme en production.
|
|
192
|
-
_currentRunIdentity: { runId, workspace: '
|
|
192
|
+
_currentRunIdentity: { runId, workspace: 'demo' },
|
|
193
193
|
llm: { async completeWithTools() { assert.fail('aucune évaluation ne doit avoir lieu ici'); } },
|
|
194
194
|
};
|
|
195
195
|
const fiveTasks = {
|
|
@@ -205,7 +205,7 @@ test('une demande française délègue une fois et bascule, sans jamais duplique
|
|
|
205
205
|
async invoke({ session: turnSession }) {
|
|
206
206
|
conversationalTurns += 1;
|
|
207
207
|
// Le tour conversationnel appelle l'outil de délégation, comme Donna.
|
|
208
|
-
const result = await delegateWithinRun(turnSession, '
|
|
208
|
+
const result = await delegateWithinRun(turnSession, 'build the deliverables of the demo workspace', {
|
|
209
209
|
prepare: async () => {
|
|
210
210
|
delegations += 1;
|
|
211
211
|
return { ...preparedDelegation(), fragment: fiveTasks };
|
|
@@ -242,7 +242,7 @@ test('une demande française délègue une fois et bascule, sans jamais duplique
|
|
|
242
242
|
// Borne l'attente d'approbation comme en headless : un test ne doit jamais
|
|
243
243
|
// pouvoir se figer sur une décision humaine qui ne viendra pas.
|
|
244
244
|
session._approvalTimeoutMs = 200;
|
|
245
|
-
await runRuntimeAgenticWorkflow(agent, session, '
|
|
245
|
+
await runRuntimeAgenticWorkflow(agent, session, 'build the deliverables of the demo workspace', {
|
|
246
246
|
runId,
|
|
247
247
|
timeoutMs: 2000,
|
|
248
248
|
maxTurns: 4,
|
|
@@ -272,7 +272,7 @@ test('un run qui porte déjà un plan validé refuse une seconde délégation',
|
|
|
272
272
|
session.headlessPlan = [task('build-a')];
|
|
273
273
|
|
|
274
274
|
await assert.rejects(
|
|
275
|
-
() => delegateWithinRun(session, '
|
|
275
|
+
() => delegateWithinRun(session, 'build the deliverables', {
|
|
276
276
|
prepare: async () => preparedDelegation(),
|
|
277
277
|
registry: registryDouble(),
|
|
278
278
|
}),
|
|
@@ -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
|
}
|
|
@@ -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');
|
|
@@ -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 };
|
|
@@ -344,6 +344,11 @@ type LogSegment = { text: string; fg: string };
|
|
|
344
344
|
// Continuation lines of a wrapped entry are indented and dimmed so each
|
|
345
345
|
// entry reads as one visual block instead of an undifferentiated wall.
|
|
346
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';
|
|
347
352
|
// A doctor summary with ZERO errors is a warning by construction
|
|
348
353
|
// ("⚠ 0 error(s), 2 warning(s)") — amber, never red, even though it
|
|
349
354
|
// mentions the word "error".
|
|
@@ -396,7 +401,14 @@ function logEntryLines(raw: string, width: number): LogSegment[][] {
|
|
|
396
401
|
export function LogPanel(props: { logs: string[]; width: number; filter?: string }) {
|
|
397
402
|
const [activeLogTab, setActiveLogTab] = createSignal<'flow' | 'agent-status'>('flow');
|
|
398
403
|
const lineWidth = () => Math.max(8, props.width - 2);
|
|
399
|
-
|
|
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);
|
|
400
412
|
// Filtering preserves the runtime history order. logRenderLines performs
|
|
401
413
|
// the single block-level reversal shared by both tabs.
|
|
402
414
|
const filteredLogs = () => filterRuntimeLogs(props.logs, props.filter ?? '')
|
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/wiki-workspace
CHANGED
|
@@ -343,6 +343,24 @@ agents_compose() {
|
|
|
343
343
|
if [[ -n "$CACERT_PATH" ]]; then
|
|
344
344
|
up_args+=(--force-recreate)
|
|
345
345
|
fi
|
|
346
|
+
# Same lifecycle as the 7788 runtime: an agentic gateway already serving
|
|
347
|
+
# on the port is REUSED — a container started over it cannot bind, and
|
|
348
|
+
# the whole `up` would fail. Reuse applies only when the serving process
|
|
349
|
+
# is EXTERNAL to this compose project (a native runner): if the compose
|
|
350
|
+
# gateway container is the one running, the normal `up` leaves it alone.
|
|
351
|
+
# Only an EXPLICIT `agents up gateway` asks for the container; then a
|
|
352
|
+
# bind conflict is the honest answer.
|
|
353
|
+
local gateway_scale=()
|
|
354
|
+
local requested_list="${only_services[*]:-}"
|
|
355
|
+
local gateway_container_running=false
|
|
356
|
+
if _agents_dc ps --format '{{.Service}}' 2>/dev/null | grep -qx 'gateway'; then
|
|
357
|
+
gateway_container_running=true
|
|
358
|
+
fi
|
|
359
|
+
if gateway_enabled && ! $gateway_container_running && ! [[ " $requested_list " == *" gateway "* ]] && gateway_serving; then
|
|
360
|
+
printf 'Agentic gateway: already serving on :%s — reusing the existing runner (no container started).\n' "${GATEWAY_PORT:-7789}"
|
|
361
|
+
gateway_scale=(--scale gateway=0)
|
|
362
|
+
fi
|
|
363
|
+
up_args+=(${gateway_scale[@]+"${gateway_scale[@]}"})
|
|
346
364
|
up_args+=(${only_services[@]+"${only_services[@]}"})
|
|
347
365
|
_agents_dc "${up_args[@]}"
|
|
348
366
|
printf 'Workspaces root: %s\n' "$workspaces_root"
|
|
@@ -574,6 +592,22 @@ gateway_enabled() {
|
|
|
574
592
|
esac
|
|
575
593
|
}
|
|
576
594
|
|
|
595
|
+
# Same lifecycle discipline as the 7788 runtime: an agentic gateway ALREADY
|
|
596
|
+
# serving on the port is REUSED, never shadowed by a container that cannot
|
|
597
|
+
# bind. A native `node bin/wiki-agentic-gateway.js` answers without auth
|
|
598
|
+
# (dev); a token-configured gateway (the container, or a native one with
|
|
599
|
+
# GATEWAY_AUTH_TOKEN) answers only to the Bearer — probe both.
|
|
600
|
+
gateway_serving() {
|
|
601
|
+
local port="${GATEWAY_PORT:-7789}"
|
|
602
|
+
local token url
|
|
603
|
+
url="http://localhost:${port}/health"
|
|
604
|
+
token="$(env_value "$MANAGER_ENV_FILE" GATEWAY_AUTH_TOKEN "")"
|
|
605
|
+
if [[ -n "$token" ]]; then
|
|
606
|
+
curl -fsS -m 3 -H "Authorization: Bearer $token" "$url" >/dev/null 2>&1 && return 0
|
|
607
|
+
fi
|
|
608
|
+
curl -fsS -m 3 "$url" >/dev/null 2>&1
|
|
609
|
+
}
|
|
610
|
+
|
|
577
611
|
ensure_endpoints_file() {
|
|
578
612
|
# Without mcp.endpoints.json the shell never connects to the agents it
|
|
579
613
|
# just started (and `agents status` refuses to run). Seed it from the
|