@dotdrelle/wiki-manager 0.15.40 → 0.15.42

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.
Files changed (51) hide show
  1. package/package.json +2 -2
  2. package/src/activity/activityAggregator.js +3 -3
  3. package/src/activity/progressCalculator.js +3 -4
  4. package/src/agent/graph.js +40 -1
  5. package/src/cli/wiki-manager.js +64 -38
  6. package/src/commands/slash.js +47 -9
  7. package/src/core/activity.js +3 -2
  8. package/src/core/agentEvents.js +68 -11
  9. package/src/core/agentEvents.test.js +76 -0
  10. package/src/core/agentLoop.js +32 -6
  11. package/src/core/buildInfo.json +2 -2
  12. package/src/core/compose.js +32 -0
  13. package/src/core/dockerCompose.test.js +32 -0
  14. package/src/core/jobQueue.js +9 -0
  15. package/src/core/mcp.js +1 -1
  16. package/src/core/otherWorkspacesRunning.test.js +51 -0
  17. package/src/core/plan.js +3 -2
  18. package/src/core/planPatch.js +2 -1
  19. package/src/core/profileServiceStatus.test.js +26 -2
  20. package/src/core/toolLoop.js +32 -7
  21. package/src/core/toolLoop.test.js +95 -0
  22. package/src/core/wikiSetup.js +50 -7
  23. package/src/core/wikiWorkspace.test.js +30 -0
  24. package/src/core/wikirc.test.js +121 -2
  25. package/src/core/workflow.js +11 -2
  26. package/src/core/workspaceInherit.js +149 -0
  27. package/src/core/workspaceInherit.test.js +181 -0
  28. package/src/graph/graphVisibilityPolicy.js +2 -2
  29. package/src/orchestrator/agentRegistry.js +50 -0
  30. package/src/orchestrator/agentRegistry.test.js +76 -1
  31. package/src/orchestrator/approvalPolicy.js +2 -2
  32. package/src/orchestrator/dependencyResolver.js +52 -12
  33. package/src/orchestrator/dispatcher.js +2 -5
  34. package/src/orchestrator/planIntegrator.js +5 -5
  35. package/src/orchestrator/resultAggregator.js +2 -1
  36. package/src/orchestrator/scheduler.test.js +62 -1
  37. package/src/orchestrator/taskStatuses.js +99 -0
  38. package/src/orchestrator/taskStatuses.test.js +112 -0
  39. package/src/runtime/delegation.js +158 -0
  40. package/src/runtime/delegation.test.js +281 -0
  41. package/src/runtime/recoveryManager.js +2 -5
  42. package/src/runtime/recoveryManager.test.js +5 -1
  43. package/src/runtime/runner.js +129 -15
  44. package/src/runtime/runner.test.js +153 -6
  45. package/src/runtime/server.test.js +28 -0
  46. package/src/runtime/store.js +16 -1
  47. package/src/runtime/store.test.js +50 -0
  48. package/src/shell/repl.js +21 -11
  49. package/src/shell/setupWizardModality.test.js +55 -0
  50. package/src/shell/tui.tsx +29 -9
  51. package/wiki-workspace +10 -4
@@ -163,7 +163,7 @@ test('writeVectorConfig removes commented vector placeholders it replaces', () =
163
163
  assert.equal(parsed.retrieval.vector.apiKey, 'vector-key');
164
164
  });
165
165
 
166
- test('finalizeCreatedWorkspace copies generated wiki token into default wikirc', () => {
166
+ test('finalizeCreatedWorkspace copies generated wiki token into default wikirc', async () => {
167
167
  const root = mkdtempSync(join(tmpdir(), 'wikirc-workspace-token-'));
168
168
  const registryRoot = join(root, 'registry');
169
169
  const registryPath = join(registryRoot, 'demo');
@@ -190,7 +190,7 @@ test('finalizeCreatedWorkspace copies generated wiki token into default wikirc',
190
190
  const previousDir = process.env.WIKI_WORKSPACES_DIR;
191
191
  process.env.WIKI_WORKSPACES_DIR = registryRoot;
192
192
  try {
193
- const workspace = finalizeCreatedWorkspace('demo');
193
+ const { workspace } = await finalizeCreatedWorkspace('demo');
194
194
  const parsed = YAML.parse(readFileSync(join(workspacePath, '.wikirc.yaml'), 'utf8'));
195
195
 
196
196
  assert.equal(workspace.name, 'demo');
@@ -249,3 +249,122 @@ test('containerReachableUrl only touches loopback hosts', () => {
249
249
  assert.equal(containerReachableUrl('http://gateway:4000/v1').rewritten, false);
250
250
  assert.equal(containerReachableUrl(undefined).rewritten, false);
251
251
  });
252
+
253
+ // /new used to leave the operator re-entering the same endpoint, key and model
254
+ // they had just proven working in the workspace next door.
255
+ test('finalizeCreatedWorkspace seeds a new workspace from the one in use', async () => {
256
+ const root = mkdtempSync(join(tmpdir(), 'wikirc-inherit-'));
257
+ const registryRoot = join(root, 'registry');
258
+ const agentsData = join(root, '.agents-data');
259
+ const token = 'b'.repeat(64);
260
+
261
+ const makeWorkspace = (name, wikirc) => {
262
+ const registryPath = join(registryRoot, name);
263
+ const workspacePath = join(root, name);
264
+ mkdirSync(registryPath, { recursive: true });
265
+ mkdirSync(workspacePath, { recursive: true });
266
+ writeFileSync(join(registryPath, '.env'), [
267
+ `WORKSPACE_NAME=${name}`,
268
+ `WIKI_WORKSPACE_PATH=${workspacePath}`,
269
+ `WIKI_MCP_AUTH_TOKEN=${token}`,
270
+ '',
271
+ ].join('\n'), 'utf8');
272
+ writeFileSync(join(workspacePath, '.wikirc.yaml'), wikirc, 'utf8');
273
+ return workspacePath;
274
+ };
275
+
276
+ makeWorkspace('acpi', [
277
+ 'language: en',
278
+ 'llm:',
279
+ ' provider: ai-gateway',
280
+ ' engine: generic',
281
+ ' baseUrl: https://itsdonna.events/v1',
282
+ ' model: deepseek-v4-pro',
283
+ ' apiKey: sk-real-gateway-key',
284
+ 'mcp:',
285
+ ' accessKey: ' + 'c'.repeat(64),
286
+ '',
287
+ ].join('\n'));
288
+
289
+ // Exactly what the scaffold writes: placeholders everywhere.
290
+ const targetPath = makeWorkspace('nouveau', [
291
+ 'language: en',
292
+ 'llm:',
293
+ ' provider: openai-compatible',
294
+ ' engine: generic',
295
+ ' baseUrl: https://mon-provider.example.com/v1',
296
+ ' model: YOUR_MODEL_NAME',
297
+ ' apiKey: YOUR_LLM_API_KEY',
298
+ 'mcp:',
299
+ ' # accessKey: your-secret-key',
300
+ '',
301
+ ].join('\n'));
302
+
303
+ mkdirSync(join(agentsData, 'cme', 'acpi', 'cme'), { recursive: true });
304
+ writeFileSync(join(agentsData, 'cme', 'acpi', 'cme', 'app_data.json'), '{"pat":"x"}', 'utf8');
305
+
306
+ const previousDir = process.env.WIKI_WORKSPACES_DIR;
307
+ const previousData = process.env.AGENTS_DATA_DIR;
308
+ process.env.WIKI_WORKSPACES_DIR = registryRoot;
309
+ process.env.AGENTS_DATA_DIR = agentsData;
310
+ try {
311
+ const { inherited } = await finalizeCreatedWorkspace('nouveau', { inheritFrom: 'acpi' });
312
+ const parsed = YAML.parse(readFileSync(join(targetPath, '.wikirc.yaml'), 'utf8'));
313
+
314
+ assert.equal(parsed.llm.baseUrl, 'https://itsdonna.events/v1');
315
+ assert.equal(parsed.llm.model, 'deepseek-v4-pro');
316
+ assert.equal(parsed.llm.apiKey, 'sk-real-gateway-key');
317
+ assert.equal(parsed.llm.provider, 'ai-gateway');
318
+ // The workspace keeps ITS own MCP credential, written just before.
319
+ assert.equal(parsed.mcp.accessKey, token);
320
+ assert.ok(inherited.includes('llm.baseUrl'));
321
+ assert.ok(inherited.includes('cme.app_data.json'));
322
+ assert.equal(
323
+ readFileSync(join(agentsData, 'cme', 'nouveau', 'cme', 'app_data.json'), 'utf8'),
324
+ '{"pat":"x"}',
325
+ );
326
+ } finally {
327
+ if (previousDir === undefined) delete process.env.WIKI_WORKSPACES_DIR;
328
+ else process.env.WIKI_WORKSPACES_DIR = previousDir;
329
+ if (previousData === undefined) delete process.env.AGENTS_DATA_DIR;
330
+ else process.env.AGENTS_DATA_DIR = previousData;
331
+ }
332
+ });
333
+
334
+ test('finalizeCreatedWorkspace without a source keeps scaffold defaults', async () => {
335
+ const root = mkdtempSync(join(tmpdir(), 'wikirc-noinherit-'));
336
+ const registryRoot = join(root, 'registry');
337
+ const registryPath = join(registryRoot, 'solo');
338
+ const workspacePath = join(root, 'solo');
339
+ const token = 'd'.repeat(64);
340
+ mkdirSync(registryPath, { recursive: true });
341
+ mkdirSync(workspacePath, { recursive: true });
342
+ writeFileSync(join(registryPath, '.env'), [
343
+ 'WORKSPACE_NAME=solo',
344
+ `WIKI_WORKSPACE_PATH=${workspacePath}`,
345
+ `WIKI_MCP_AUTH_TOKEN=${token}`,
346
+ '',
347
+ ].join('\n'), 'utf8');
348
+ writeFileSync(join(workspacePath, '.wikirc.yaml'), [
349
+ 'language: en',
350
+ 'llm:',
351
+ ' baseUrl: https://mon-provider.example.com/v1',
352
+ 'mcp:',
353
+ ' # accessKey: your-secret-key',
354
+ '',
355
+ ].join('\n'), 'utf8');
356
+
357
+ const previousDir = process.env.WIKI_WORKSPACES_DIR;
358
+ process.env.WIKI_WORKSPACES_DIR = registryRoot;
359
+ try {
360
+ const { inherited } = await finalizeCreatedWorkspace('solo');
361
+ const parsed = YAML.parse(readFileSync(join(workspacePath, '.wikirc.yaml'), 'utf8'));
362
+
363
+ assert.deepEqual(inherited, []);
364
+ assert.equal(parsed.llm.baseUrl, 'https://mon-provider.example.com/v1');
365
+ assert.equal(parsed.mcp.accessKey, token);
366
+ } finally {
367
+ if (previousDir === undefined) delete process.env.WIKI_WORKSPACES_DIR;
368
+ else process.env.WIKI_WORKSPACES_DIR = previousDir;
369
+ }
370
+ });
@@ -1,8 +1,17 @@
1
+ /**
2
+ * @statuses-vocabulary
3
+ *
4
+ * DISPLAY normalization for the execution graph, with `added_during_run`,
5
+ * which exists only as a rendering hint.
6
+ *
7
+ * Declared here rather than in a central exception list so the waiver
8
+ * travels with the code it excuses (see orchestrator/taskStatuses.test.js).
9
+ */
1
10
  import { aggregateActivity } from '../activity/activityAggregator.js';
2
11
  import { calculateWeightedProgress } from '../activity/progressCalculator.js';
3
12
  import { aggregateGraph } from '../graph/graphAggregator.js';
13
+ import { isTerminal } from '../orchestrator/taskStatuses.js';
4
14
 
5
- const TERMINAL_STATUSES = new Set(['done', 'failed', 'cancelled', 'canceled', 'error', 'complete', 'completed', 'success']);
6
15
  const RUNNING_STATUSES = new Set(['running', 'starting', 'queued', 'waiting', 'pending_approval']);
7
16
 
8
17
  // Canonical workflow projection for 0.9.6.
@@ -278,7 +287,7 @@ function isActiveStatus(status) {
278
287
  }
279
288
 
280
289
  function isTerminalStatus(status) {
281
- return TERMINAL_STATUSES.has(normalizeStatus(status));
290
+ return isTerminal(status);
282
291
  }
283
292
 
284
293
  function findCurrentNode(nodes) {
@@ -0,0 +1,149 @@
1
+ import { copyFile, mkdir } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ /**
6
+ * Config a freshly created workspace inherits from an existing one.
7
+ *
8
+ * A new workspace was scaffolded with placeholder values only
9
+ * (`YOUR_MODEL_NAME`, `https://mon-provider.example.com/v1`…), so the operator
10
+ * had to redo the whole LLM setup by hand — reachable endpoint, gateway key,
11
+ * model name, vector settings — every single time, for values they had already
12
+ * proven working next door. `/new` now carries them over.
13
+ *
14
+ * Two rules make this safe rather than merely convenient:
15
+ *
16
+ * - Only ABSENT or PLACEHOLDER target values are filled. Anything the
17
+ * scaffold or the operator already set for real is left alone, so this can
18
+ * never overwrite a deliberate choice.
19
+ * - `mcp.accessKey` is never inherited. It is the workspace's own credential
20
+ * and `initializeWorkspaceWikirc` has already written the right one.
21
+ */
22
+
23
+ // Kept identical to SetupWizard.tsx on purpose — the wizard applies the same
24
+ // "a scaffolded value is not an answer" rule. `workspaceInherit.test.js`
25
+ // asserts the two literals stay in sync.
26
+ export const PLACEHOLDER_VALUE_RE = /YOUR_|<your|example\.com|infinity\.local/i;
27
+
28
+ const INHERITED_LLM_KEYS = ['baseUrl', 'model', 'apiKey', 'temperature', 'timeoutMs'];
29
+
30
+ // `provider` and `engine` describe HOW the endpoint in `baseUrl` behaves, so
31
+ // they are bound to it rather than inherited on their own merit.
32
+ //
33
+ // The scaffold ships `provider: openai-compatible` / `engine: generic` as
34
+ // defaults, and those are real values — not placeholders the fill rule would
35
+ // replace. Inheriting a gateway's `baseUrl` while leaving them behind produced
36
+ // a config claiming a direct openai-compatible server against an AI gateway:
37
+ // exactly the conflation the provider/engine split was introduced to end.
38
+ const ENDPOINT_BOUND_KEYS = ['provider', 'engine'];
39
+
40
+ // `retrieval.vector` stays independent from `llm` but inherits provider/engine/
41
+ // baseUrl/apiKey when absent, so only keys the source states EXPLICITLY are
42
+ // worth carrying: copying an inherited value would freeze it and break the
43
+ // link the source deliberately left open.
44
+ const INHERITED_VECTOR_KEYS = [
45
+ 'enabled',
46
+ 'provider',
47
+ 'engine',
48
+ 'baseUrl',
49
+ 'apiKey',
50
+ 'embeddingModel',
51
+ 'rerankerModel',
52
+ ];
53
+
54
+ /** A value the operator actually chose, as opposed to scaffold filler. */
55
+ export function isRealValue(value) {
56
+ if (value === null || value === undefined) return false;
57
+ if (typeof value === 'boolean' || typeof value === 'number') return true;
58
+ if (typeof value !== 'string') return false;
59
+ const text = value.trim();
60
+ return text.length > 0 && !PLACEHOLDER_VALUE_RE.test(text);
61
+ }
62
+
63
+ function inheritSection(sourceSection, targetSection, keys, prefix, patch, inherited) {
64
+ for (const key of keys) {
65
+ const from = sourceSection?.[key];
66
+ if (!isRealValue(from)) continue;
67
+ // Present AND real on the target: a deliberate value, never overwritten.
68
+ if (isRealValue(targetSection?.[key])) continue;
69
+ patch[key] = from;
70
+ inherited.push(`${prefix}.${key}`);
71
+ }
72
+ }
73
+
74
+ /**
75
+ * @param sourceConfig parsed `.wikirc.yaml` of the workspace to copy from
76
+ * @param targetConfig parsed `.wikirc.yaml` of the freshly created workspace
77
+ * @returns `{ patch, inherited }` — `patch` is shaped for `patchWikircProfile`
78
+ * and is `{}` when there is nothing to carry over.
79
+ */
80
+ export function buildInheritedWikircPatch(sourceConfig, targetConfig) {
81
+ const inherited = [];
82
+ const llm = {};
83
+ const vector = {};
84
+
85
+ inheritSection(sourceConfig?.llm, targetConfig?.llm, INHERITED_LLM_KEYS, 'llm', llm, inherited);
86
+ // Only once the endpoint itself has been taken over.
87
+ if (llm.baseUrl) {
88
+ for (const key of ENDPOINT_BOUND_KEYS) {
89
+ const from = sourceConfig?.llm?.[key];
90
+ if (!isRealValue(from) || from === targetConfig?.llm?.[key]) continue;
91
+ llm[key] = from;
92
+ inherited.push(`llm.${key}`);
93
+ }
94
+ }
95
+
96
+ inheritSection(
97
+ sourceConfig?.retrieval?.vector,
98
+ targetConfig?.retrieval?.vector,
99
+ INHERITED_VECTOR_KEYS,
100
+ 'retrieval.vector',
101
+ vector,
102
+ inherited,
103
+ );
104
+
105
+ // The gateway key unlocks every provider behind it. If the vector endpoint
106
+ // ends up on a different host than the LLM one, an inherited apiKey would be
107
+ // sent there — the exact leak the wizard already refuses to create. Drop the
108
+ // inherited baseUrl rather than the key: staying on the LLM endpoint is the
109
+ // conservative default, and the operator can still point it elsewhere.
110
+ const effectiveLlmBaseUrl = llm.baseUrl ?? targetConfig?.llm?.baseUrl ?? sourceConfig?.llm?.baseUrl;
111
+ const effectiveVectorBaseUrl = vector.baseUrl ?? targetConfig?.retrieval?.vector?.baseUrl;
112
+ const hasOwnVectorKey = isRealValue(vector.apiKey ?? targetConfig?.retrieval?.vector?.apiKey);
113
+ if (effectiveVectorBaseUrl && effectiveVectorBaseUrl !== effectiveLlmBaseUrl && !hasOwnVectorKey) {
114
+ delete vector.baseUrl;
115
+ const index = inherited.indexOf('retrieval.vector.baseUrl');
116
+ if (index >= 0) inherited.splice(index, 1);
117
+ }
118
+
119
+ const patch = {};
120
+ if (Object.keys(llm).length > 0) patch.llm = llm;
121
+ if (Object.keys(vector).length > 0) patch.retrieval = { vector };
122
+ return { patch, inherited };
123
+ }
124
+
125
+ export function cmeCredentialsPath(agentsDataDir, workspaceName) {
126
+ return join(agentsDataDir, 'cme', workspaceName, 'cme', 'app_data.json');
127
+ }
128
+
129
+ /**
130
+ * Carry the Confluence credentials of an existing workspace over to a new one.
131
+ *
132
+ * `app_data.json` only — NOT `sources-manifest.yaml`. The credentials are a
133
+ * property of the operator's Confluence account and are identical everywhere;
134
+ * which spaces and pages a workspace exports is precisely what makes it a
135
+ * different workspace, and copying that would silently re-export someone
136
+ * else's scope on the first run.
137
+ */
138
+ export async function copyCmeCredentials(agentsDataDir, sourceWorkspace, targetWorkspace) {
139
+ if (!agentsDataDir || !sourceWorkspace || !targetWorkspace) return null;
140
+ if (sourceWorkspace === targetWorkspace) return null;
141
+ const from = cmeCredentialsPath(agentsDataDir, sourceWorkspace);
142
+ const to = cmeCredentialsPath(agentsDataDir, targetWorkspace);
143
+ if (!existsSync(from)) return null;
144
+ // An existing target file is a real configuration; never clobber it.
145
+ if (existsSync(to)) return null;
146
+ await mkdir(join(agentsDataDir, 'cme', targetWorkspace, 'cme'), { recursive: true });
147
+ await copyFile(from, to);
148
+ return to;
149
+ }
@@ -0,0 +1,181 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import {
8
+ PLACEHOLDER_VALUE_RE,
9
+ buildInheritedWikircPatch,
10
+ cmeCredentialsPath,
11
+ copyCmeCredentials,
12
+ isRealValue,
13
+ } from './workspaceInherit.js';
14
+
15
+ const SCAFFOLD_LLM = {
16
+ provider: 'openai-compatible',
17
+ engine: 'generic',
18
+ baseUrl: 'https://mon-provider.example.com/v1',
19
+ model: 'YOUR_MODEL_NAME',
20
+ apiKey: 'YOUR_LLM_API_KEY',
21
+ };
22
+
23
+ const WORKING_LLM = {
24
+ provider: 'ai-gateway',
25
+ engine: 'generic',
26
+ baseUrl: 'https://itsdonna.events/v1',
27
+ model: 'deepseek-v4-pro',
28
+ apiKey: 'sk-real-gateway-key',
29
+ temperature: 0.1,
30
+ };
31
+
32
+ test('scaffold placeholders never count as configured values', () => {
33
+ for (const value of Object.values(SCAFFOLD_LLM).filter((v) => typeof v === 'string')) {
34
+ if (value === 'openai-compatible' || value === 'generic') continue;
35
+ assert.equal(isRealValue(value), false, `${value} must be treated as a placeholder`);
36
+ }
37
+ assert.equal(isRealValue('https://itsdonna.events/v1'), true);
38
+ assert.equal(isRealValue(''), false);
39
+ assert.equal(isRealValue(null), false);
40
+ // Booleans and numbers are legitimate answers; `false` and `0` must survive.
41
+ assert.equal(isRealValue(false), true);
42
+ assert.equal(isRealValue(0), true);
43
+ });
44
+
45
+ test('the placeholder rule stays identical to the wizard', () => {
46
+ // The wizard applies the same "a scaffolded value is not an answer" rule. If
47
+ // the two drift, /new starts inheriting values the wizard would refuse.
48
+ const wizard = readFileSync(
49
+ fileURLToPath(new URL('../shell/SetupWizard.tsx', import.meta.url)),
50
+ 'utf8',
51
+ );
52
+ const match = wizard.match(/^const PLACEHOLDER_VALUE_RE = (\/.+\/i);$/m);
53
+ assert.ok(match, 'SetupWizard.tsx must keep PLACEHOLDER_VALUE_RE on one line');
54
+ assert.equal(match[1], String(PLACEHOLDER_VALUE_RE));
55
+ });
56
+
57
+ test('a new workspace inherits the working LLM block over scaffold placeholders', () => {
58
+ const { patch, inherited } = buildInheritedWikircPatch(
59
+ { llm: WORKING_LLM },
60
+ { llm: SCAFFOLD_LLM },
61
+ );
62
+
63
+ assert.equal(patch.llm.baseUrl, 'https://itsdonna.events/v1');
64
+ assert.equal(patch.llm.model, 'deepseek-v4-pro');
65
+ assert.equal(patch.llm.apiKey, 'sk-real-gateway-key');
66
+ assert.equal(patch.llm.provider, 'ai-gateway');
67
+ assert.equal(patch.llm.temperature, 0.1);
68
+ assert.ok(inherited.includes('llm.baseUrl'));
69
+ });
70
+
71
+ test('provider and engine follow the endpoint they describe', () => {
72
+ // Keeping the scaffold's `openai-compatible` while adopting a gateway URL
73
+ // would describe the wrong kind of server — the conflation the
74
+ // provider/engine split exists to prevent.
75
+ const taken = buildInheritedWikircPatch({ llm: WORKING_LLM }, { llm: SCAFFOLD_LLM });
76
+ assert.equal(taken.patch.llm.provider, 'ai-gateway');
77
+ assert.ok(taken.inherited.includes('llm.provider'));
78
+
79
+ // Endpoint not taken over: provider and engine stay where they are, whatever
80
+ // the source says.
81
+ const kept = buildInheritedWikircPatch(
82
+ { llm: WORKING_LLM },
83
+ { llm: { ...SCAFFOLD_LLM, baseUrl: 'http://localhost:11434/v1' } },
84
+ );
85
+ assert.equal(kept.patch.llm?.provider, undefined);
86
+ assert.equal(kept.patch.llm?.engine, undefined);
87
+ });
88
+
89
+ test('a value already set on the target is never overwritten', () => {
90
+ const { patch } = buildInheritedWikircPatch(
91
+ { llm: WORKING_LLM },
92
+ { llm: { ...SCAFFOLD_LLM, model: 'mistral-large', baseUrl: 'http://localhost:11434/v1' } },
93
+ );
94
+
95
+ assert.equal(patch.llm.model, undefined, 'a deliberate model must survive');
96
+ assert.equal(patch.llm.baseUrl, undefined, 'a deliberate endpoint must survive');
97
+ assert.equal(patch.llm.apiKey, 'sk-real-gateway-key', 'the still-placeholder key is filled');
98
+ });
99
+
100
+ test('mcp.accessKey is never part of the inherited patch', () => {
101
+ const { patch, inherited } = buildInheritedWikircPatch(
102
+ { llm: WORKING_LLM, mcp: { accessKey: 'source-secret' } },
103
+ { llm: SCAFFOLD_LLM, mcp: { accessKey: 'target-secret' } },
104
+ );
105
+
106
+ assert.equal(patch.mcp, undefined);
107
+ assert.ok(!inherited.some((key) => key.startsWith('mcp')));
108
+ });
109
+
110
+ test('a divergent vector endpoint is not inherited without its own key', () => {
111
+ // Carrying baseUrl alone would send the gateway key — which unlocks every
112
+ // provider behind it — to a different host.
113
+ const { patch, inherited } = buildInheritedWikircPatch(
114
+ {
115
+ llm: WORKING_LLM,
116
+ retrieval: { vector: { enabled: true, baseUrl: 'http://infinity.internal:7997/v1' } },
117
+ },
118
+ { llm: SCAFFOLD_LLM, retrieval: { vector: {} } },
119
+ );
120
+
121
+ assert.equal(patch.retrieval?.vector?.baseUrl, undefined);
122
+ assert.ok(!inherited.includes('retrieval.vector.baseUrl'));
123
+ assert.equal(patch.retrieval.vector.enabled, true, 'the rest of the block still travels');
124
+ });
125
+
126
+ test('a divergent vector endpoint IS inherited when it carries its own key', () => {
127
+ const { patch } = buildInheritedWikircPatch(
128
+ {
129
+ llm: WORKING_LLM,
130
+ retrieval: {
131
+ vector: { enabled: true, baseUrl: 'http://infinity.internal:7997/v1', apiKey: 'vec-key' },
132
+ },
133
+ },
134
+ { llm: SCAFFOLD_LLM, retrieval: { vector: {} } },
135
+ );
136
+
137
+ assert.equal(patch.retrieval.vector.baseUrl, 'http://infinity.internal:7997/v1');
138
+ assert.equal(patch.retrieval.vector.apiKey, 'vec-key');
139
+ });
140
+
141
+ test('nothing to inherit yields an empty patch', () => {
142
+ const { patch, inherited } = buildInheritedWikircPatch({ llm: SCAFFOLD_LLM }, { llm: WORKING_LLM });
143
+ assert.deepEqual(patch, {});
144
+ assert.deepEqual(inherited, []);
145
+ });
146
+
147
+ test('CME credentials are copied, and the source manifest is not', async () => {
148
+ const root = mkdtempSync(join(tmpdir(), 'cme-inherit-'));
149
+ const sourceDir = join(root, 'cme', 'acpi', 'cme');
150
+ mkdirSync(sourceDir, { recursive: true });
151
+ writeFileSync(join(sourceDir, 'app_data.json'), '{"auth":{"pat":"secret"}}', 'utf8');
152
+ // Export scope is what makes a workspace different — it must NOT travel.
153
+ writeFileSync(join(root, 'cme', 'acpi', 'sources-manifest.yaml'), 'sources: []\n', 'utf8');
154
+
155
+ const copied = await copyCmeCredentials(root, 'acpi', 'nouveau');
156
+
157
+ assert.equal(copied, cmeCredentialsPath(root, 'nouveau'));
158
+ assert.equal(readFileSync(copied, 'utf8'), '{"auth":{"pat":"secret"}}');
159
+ assert.equal(existsSync(join(root, 'cme', 'nouveau', 'sources-manifest.yaml')), false);
160
+ });
161
+
162
+ test('existing CME credentials on the target are never clobbered', async () => {
163
+ const root = mkdtempSync(join(tmpdir(), 'cme-inherit-keep-'));
164
+ mkdirSync(join(root, 'cme', 'acpi', 'cme'), { recursive: true });
165
+ mkdirSync(join(root, 'cme', 'nouveau', 'cme'), { recursive: true });
166
+ writeFileSync(join(root, 'cme', 'acpi', 'cme', 'app_data.json'), '{"from":"source"}', 'utf8');
167
+ writeFileSync(join(root, 'cme', 'nouveau', 'cme', 'app_data.json'), '{"from":"target"}', 'utf8');
168
+
169
+ assert.equal(await copyCmeCredentials(root, 'acpi', 'nouveau'), null);
170
+ assert.equal(
171
+ readFileSync(cmeCredentialsPath(root, 'nouveau'), 'utf8'),
172
+ '{"from":"target"}',
173
+ );
174
+ });
175
+
176
+ test('copying is a no-op without a source, a target, or a source file', async () => {
177
+ const root = mkdtempSync(join(tmpdir(), 'cme-inherit-noop-'));
178
+ assert.equal(await copyCmeCredentials(root, 'absent', 'nouveau'), null);
179
+ assert.equal(await copyCmeCredentials(root, null, 'nouveau'), null);
180
+ assert.equal(await copyCmeCredentials(root, 'acpi', 'acpi'), null);
181
+ });
@@ -1,6 +1,6 @@
1
+ import { isFailed, isCancelled } from '../orchestrator/taskStatuses.js';
1
2
  const ALWAYS_VISIBLE = new Set(['run', 'task_group', 'barrier', 'approval']);
2
3
  const ACTIVE = new Set(['running', 'queued', 'pending_approval', 'waiting_approval']);
3
- const ERROR = new Set(['failed', 'error', 'cancelled']);
4
4
 
5
5
  export function applyGraphVisibility(graph, { maxNodes = 18 } = {}) {
6
6
  const nodes = graph.nodes ?? [];
@@ -35,6 +35,6 @@ function mustShow(node) {
35
35
  const status = String(node.status ?? '').toLowerCase();
36
36
  return ALWAYS_VISIBLE.has(node.type)
37
37
  || ACTIVE.has(status)
38
- || ERROR.has(status)
38
+ || isFailed(status) || isCancelled(status)
39
39
  || node.type === 'plan_expansion';
40
40
  }
@@ -4,6 +4,56 @@ import { assertContract } from '../contracts/schemas.js';
4
4
 
5
5
  const AVAILABLE = 'available';
6
6
  const UNAVAILABLE = 'unavailable';
7
+ /**
8
+ * Santé d'un agent restauré depuis le journal, tant qu'aucun `agent_describe`
9
+ * n'a réussi dans le processus courant.
10
+ *
11
+ * Ni `available` ni `unavailable` : on ne SAIT pas. Le distinguer de
12
+ * `unavailable` a une conséquence pratique — un agent inconnu redevient
13
+ * disponible en silence dès le premier scan réussi, là où un agent déclaré
14
+ * indisponible mériterait d'être signalé comme tel à l'utilisateur.
15
+ */
16
+ const UNKNOWN = 'unknown';
17
+
18
+ /**
19
+ * Un agent persisté n'est pas un agent joignable.
20
+ *
21
+ * Au redémarrage, `hydrateSession` rejoue le journal d'événements et
22
+ * reconstruit les agents avec la santé qu'ils avaient AU MOMENT où l'événement
23
+ * a été écrit. `cme-main` réapparaissait donc `available` alors que son
24
+ * endpoint n'existe plus, était retenu comme fournisseur, et la tâche partait
25
+ * vers un agent absent — la panne observée le 2026-08-04.
26
+ *
27
+ * La persistance dit ce qui a existé, pas ce qui répond maintenant. Seul un
28
+ * `agent_describe` réussi dans ce processus autorise à parler de disponibilité.
29
+ */
30
+ export function markPersistedAgentsStale(session) {
31
+ if (!session || typeof session !== 'object') return [];
32
+ const stale = (agent) => ({
33
+ ...agent,
34
+ health: UNKNOWN,
35
+ stale: true,
36
+ // La santé d'origine est conservée : elle raconte ce qu'on savait avant
37
+ // l'arrêt, ce qui aide à lire un journal, sans jamais servir au routage.
38
+ healthBeforeRestart: agent?.health ?? null,
39
+ });
40
+ /*
41
+ TOUTES les représentations restaurées, `agentRegistrySnapshot` compris.
42
+
43
+ J'avais d'abord épargné le snapshot, au motif qu'il pouvait porter un scan
44
+ vivant. C'était une inversion : à l'hydratation, aucun scan n'a encore eu
45
+ lieu — l'ordre est hydrate → invalidate → discover, et rien ne s'exécute
46
+ entre les deux premiers. Le snapshot vient donc de la même projection
47
+ persistée que `session.agents`. L'épargner laissait `cme-main` routable avec
48
+ son endpoint éteint, ce que la validation à chaud a montré.
49
+
50
+ Le seul scan qui compte est celui qui suivra : `discover()` réécrit le
51
+ snapshot en entier à partir des `agent_describe` réussis.
52
+ */
53
+ session.agents = (session.agents ?? []).map(stale);
54
+ session.agentRegistrySnapshot = (session.agentRegistrySnapshot ?? []).map(stale);
55
+ return session.agentRegistrySnapshot;
56
+ }
7
57
 
8
58
  export function createAgentRegistry({
9
59
  callTool = callMcpTool,
@@ -1,6 +1,7 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
- import { createAgentRegistry } from './agentRegistry.js';
3
+ import { createAgentRegistry, markPersistedAgentsStale } from './agentRegistry.js';
4
+ import { createCapabilityRegistry } from './capabilityRegistry.js';
4
5
 
5
6
  function description({ agentInstanceId = 'production-main', health = 'available', contractVersion = '1' } = {}) {
6
7
  return {
@@ -205,3 +206,77 @@ test('discovery sends no workspace argument when no workspace is active', async
205
206
 
206
207
  assert.deepEqual(seen, [{}]);
207
208
  });
209
+
210
+ /*
211
+ Cas observé le 2026-08-04 : après redémarrage du runtime, `cme-main` était
212
+ toujours présenté `available` alors que son endpoint n'existait plus. La
213
+ relecture du journal restitue la santé qu'un agent avait AU MOMENT où
214
+ l'événement a été écrit — la persistance dit ce qui a existé, pas ce qui
215
+ répond maintenant.
216
+ */
217
+ test('un agent restauré depuis le journal n’est pas déclaré disponible', () => {
218
+ const session = {
219
+ agents: [
220
+ { agentInstanceId: 'cme-main', serverName: 'cme', health: 'available', description: { contractVersion: '1', capabilities: [{ id: 'external-source.export', version: '1' }] } },
221
+ { agentInstanceId: 'production-main', serverName: 'production', health: 'available', description: { contractVersion: '1', capabilities: [{ id: 'knowledge.update', version: '1' }] } },
222
+ ],
223
+ };
224
+
225
+ markPersistedAgentsStale(session);
226
+
227
+ for (const agent of session.agents) {
228
+ assert.equal(agent.health, 'unknown');
229
+ assert.equal(agent.stale, true);
230
+ // Ce qu'on savait avant l'arrêt reste lisible, sans jamais servir au routage.
231
+ assert.equal(agent.healthBeforeRestart, 'available');
232
+ }
233
+ // Et surtout : plus aucun fournisseur sélectionnable tant que le scan n'a pas
234
+ // confirmé. C'est la seule chose qui empêche une tâche de partir vers un
235
+ // agent absent.
236
+ const registry = createCapabilityRegistry({ agents: session.agents });
237
+ assert.deepEqual(registry.providersFor('external-source.export'), []);
238
+ assert.deepEqual(registry.providersFor('knowledge.update'), []);
239
+ });
240
+
241
+ test('markPersistedAgentsStale invalide aussi l’instantané routable', () => {
242
+ /*
243
+ J'avais d'abord épargné `agentRegistrySnapshot`, au motif qu'il pouvait
244
+ porter un scan vivant. Inversion : à l'hydratation aucun scan n'a eu lieu,
245
+ l'ordre étant hydrate → invalidate → discover. L'épargner laissait
246
+ `cme-main` routable avec son endpoint éteint — constaté à chaud.
247
+ */
248
+ const session = {
249
+ agents: [{ agentInstanceId: 'cme-main', health: 'available' }],
250
+ agentRegistrySnapshot: [{ agentInstanceId: 'cme-main', health: 'available' }],
251
+ };
252
+
253
+ markPersistedAgentsStale(session);
254
+
255
+ assert.equal(session.agents[0].health, 'unknown');
256
+ assert.equal(session.agentRegistrySnapshot[0].health, 'unknown');
257
+ });
258
+
259
+ test('un agent redevient sélectionnable après un agent_describe réussi', async () => {
260
+ const session = {
261
+ workspace: 'juno',
262
+ agentEvents: [],
263
+ agents: [{ agentInstanceId: 'production-main', serverName: 'production', health: 'available', description: { contractVersion: '1', capabilities: [{ id: 'knowledge.update', version: '1' }] } }],
264
+ mcp: {
265
+ production: { status: 'connected', tools: [{ name: 'agent_describe' }] },
266
+ },
267
+ };
268
+ markPersistedAgentsStale(session);
269
+ assert.deepEqual(createCapabilityRegistry({ agents: session.agents }).providersFor('knowledge.update'), []);
270
+
271
+ const registry = createAgentRegistry({
272
+ callTool: async () => ({ content: [{ type: 'text', text: JSON.stringify(description()) }] }),
273
+ });
274
+ await registry.discover(session);
275
+
276
+ // La reconnexion suffit : aucune intervention, aucun redémarrage.
277
+ assert.equal(session.agentRegistrySnapshot[0].health, 'available');
278
+ assert.equal(
279
+ createCapabilityRegistry({ agents: session.agentRegistrySnapshot }).providersFor('knowledge.update').length,
280
+ 1,
281
+ );
282
+ });