@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.
- package/package.json +2 -2
- package/src/activity/activityAggregator.js +3 -3
- package/src/activity/progressCalculator.js +3 -4
- package/src/agent/graph.js +40 -1
- package/src/cli/wiki-manager.js +64 -38
- package/src/commands/slash.js +47 -9
- package/src/core/activity.js +3 -2
- package/src/core/agentEvents.js +68 -11
- package/src/core/agentEvents.test.js +76 -0
- package/src/core/agentLoop.js +32 -6
- package/src/core/buildInfo.json +2 -2
- package/src/core/compose.js +32 -0
- package/src/core/dockerCompose.test.js +32 -0
- package/src/core/jobQueue.js +9 -0
- package/src/core/mcp.js +1 -1
- package/src/core/otherWorkspacesRunning.test.js +51 -0
- package/src/core/plan.js +3 -2
- package/src/core/planPatch.js +2 -1
- package/src/core/profileServiceStatus.test.js +26 -2
- package/src/core/toolLoop.js +32 -7
- package/src/core/toolLoop.test.js +95 -0
- package/src/core/wikiSetup.js +50 -7
- package/src/core/wikiWorkspace.test.js +30 -0
- package/src/core/wikirc.test.js +121 -2
- package/src/core/workflow.js +11 -2
- package/src/core/workspaceInherit.js +149 -0
- package/src/core/workspaceInherit.test.js +181 -0
- package/src/graph/graphVisibilityPolicy.js +2 -2
- package/src/orchestrator/agentRegistry.js +50 -0
- package/src/orchestrator/agentRegistry.test.js +76 -1
- package/src/orchestrator/approvalPolicy.js +2 -2
- package/src/orchestrator/dependencyResolver.js +52 -12
- package/src/orchestrator/dispatcher.js +2 -5
- package/src/orchestrator/planIntegrator.js +5 -5
- package/src/orchestrator/resultAggregator.js +2 -1
- package/src/orchestrator/scheduler.test.js +62 -1
- package/src/orchestrator/taskStatuses.js +99 -0
- package/src/orchestrator/taskStatuses.test.js +112 -0
- package/src/runtime/delegation.js +158 -0
- package/src/runtime/delegation.test.js +281 -0
- package/src/runtime/recoveryManager.js +2 -5
- package/src/runtime/recoveryManager.test.js +5 -1
- package/src/runtime/runner.js +129 -15
- package/src/runtime/runner.test.js +153 -6
- package/src/runtime/server.test.js +28 -0
- package/src/runtime/store.js +16 -1
- package/src/runtime/store.test.js +50 -0
- package/src/shell/repl.js +21 -11
- package/src/shell/setupWizardModality.test.js +55 -0
- package/src/shell/tui.tsx +29 -9
- package/wiki-workspace +10 -4
package/src/core/wikirc.test.js
CHANGED
|
@@ -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
|
+
});
|
package/src/core/workflow.js
CHANGED
|
@@ -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
|
|
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
|
-
||
|
|
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
|
+
});
|