@dotdrelle/wiki-manager 0.15.41 → 0.15.43
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 +48 -4
- package/package.json +2 -2
- package/src/agent/graph.js +3 -8
- package/src/cli/wiki-manager.js +35 -2
- package/src/commands/slash.js +36 -8
- package/src/core/agentEvents.js +23 -0
- package/src/core/agentEvents.test.js +32 -0
- 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/mcp.js +1 -1
- package/src/core/otherWorkspacesRunning.test.js +51 -0
- package/src/core/profile.js +19 -0
- 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 +64 -0
- package/src/core/wikirc.test.js +121 -2
- package/src/core/workspaceInherit.js +149 -0
- package/src/core/workspaceInherit.test.js +181 -0
- package/src/core/workspaceProfile.test.js +55 -0
- package/src/runtime/server.js +15 -5
- package/src/runtime/server.test.js +31 -2
- package/src/runtime/workspaceIsolation.test.js +178 -0
- package/src/shell/repl.js +28 -7
- package/src/shell/setupWizardModality.test.js +55 -0
- package/src/shell/tui.tsx +29 -9
- package/wiki-workspace +147 -5
|
@@ -86,3 +86,98 @@ test('propagates an abort thrown by executeCall', async () => {
|
|
|
86
86
|
/aborted/,
|
|
87
87
|
);
|
|
88
88
|
});
|
|
89
|
+
|
|
90
|
+
// Le tour paraissait figé pendant toute sa durée : la réponse n'apparaissait
|
|
91
|
+
// qu'une fois complète, parce que la boucle n'appelait que completeWithTools.
|
|
92
|
+
test('streams the answer when the caller asks for deltas', async () => {
|
|
93
|
+
const deltas = [];
|
|
94
|
+
const llm = {
|
|
95
|
+
async completeWithTools() { throw new Error('streamWithTools devait être préféré'); },
|
|
96
|
+
async streamWithTools({ onTextDelta }) {
|
|
97
|
+
onTextDelta('Le wiki ');
|
|
98
|
+
onTextDelta('contient 12 pages.');
|
|
99
|
+
return { content: 'Le wiki contient 12 pages.', tool_calls: [] };
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const out = await runBoundedToolLoop({
|
|
104
|
+
llm,
|
|
105
|
+
tools: [],
|
|
106
|
+
executeCall: async () => 'unused',
|
|
107
|
+
onTextDelta: (delta) => deltas.push(delta),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
assert.deepEqual(deltas, ['Le wiki ', 'contient 12 pages.']);
|
|
111
|
+
assert.equal(out.content, 'Le wiki contient 12 pages.');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('keeps the non-streaming path when no delta callback is given', async () => {
|
|
115
|
+
let streamed = false;
|
|
116
|
+
const llm = {
|
|
117
|
+
async completeWithTools() { return { content: 'plain', tool_calls: [] }; },
|
|
118
|
+
async streamWithTools() { streamed = true; return { content: 'streamed', tool_calls: [] }; },
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const out = await runBoundedToolLoop({ llm, tools: [], executeCall: async () => 'unused' });
|
|
122
|
+
|
|
123
|
+
assert.equal(streamed, false, 'sans onTextDelta, rien ne doit changer');
|
|
124
|
+
assert.equal(out.content, 'plain');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('discards text emitted by an iteration that ends in tool calls', async () => {
|
|
128
|
+
// Un modèle peut écrire un raisonnement puis décider d'appeler un outil. Ce
|
|
129
|
+
// texte est remplacé par le tour suivant : le laisser afficherait des
|
|
130
|
+
// paragraphes qui disparaissent, pire que pas de streaming du tout.
|
|
131
|
+
let round = 0;
|
|
132
|
+
const deltas = [];
|
|
133
|
+
let resets = 0;
|
|
134
|
+
const llm = {
|
|
135
|
+
async streamWithTools({ onTextDelta }) {
|
|
136
|
+
round += 1;
|
|
137
|
+
if (round === 1) {
|
|
138
|
+
onTextDelta('Je vais regarder…');
|
|
139
|
+
const calls = [toolCall('c1', 'wiki__wiki_list_pages')];
|
|
140
|
+
return { message: { role: 'assistant', content: '', tool_calls: calls }, tool_calls: calls };
|
|
141
|
+
}
|
|
142
|
+
onTextDelta('12 pages.');
|
|
143
|
+
return { content: '12 pages.', tool_calls: [] };
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const out = await runBoundedToolLoop({
|
|
148
|
+
llm,
|
|
149
|
+
tools: [],
|
|
150
|
+
executeCall: async () => 'ok',
|
|
151
|
+
onTextDelta: (delta) => deltas.push(delta),
|
|
152
|
+
onTextReset: () => { resets += 1; },
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
assert.deepEqual(deltas, ['Je vais regarder…', '12 pages.']);
|
|
156
|
+
assert.equal(resets, 1, 'le texte intermédiaire doit être annulé, une fois');
|
|
157
|
+
assert.equal(out.content, '12 pages.');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('never asks to discard text that was never emitted', async () => {
|
|
161
|
+
let resets = 0;
|
|
162
|
+
let round = 0;
|
|
163
|
+
const llm = {
|
|
164
|
+
async streamWithTools() {
|
|
165
|
+
round += 1;
|
|
166
|
+
if (round === 1) {
|
|
167
|
+
const calls = [toolCall('c1', 'wiki__wiki_list_pages')];
|
|
168
|
+
return { message: { role: 'assistant', content: '', tool_calls: calls }, tool_calls: calls };
|
|
169
|
+
}
|
|
170
|
+
return { content: 'fini', tool_calls: [] };
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
await runBoundedToolLoop({
|
|
175
|
+
llm,
|
|
176
|
+
tools: [],
|
|
177
|
+
executeCall: async () => 'ok',
|
|
178
|
+
onTextDelta: () => {},
|
|
179
|
+
onTextReset: () => { resets += 1; },
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
assert.equal(resets, 0);
|
|
183
|
+
});
|
package/src/core/wikiSetup.js
CHANGED
|
@@ -5,7 +5,8 @@ import { dirname, join } from 'node:path';
|
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
6
|
import YAML from 'yaml';
|
|
7
7
|
import { checkMissingDockerImages } from './dockerImages.js';
|
|
8
|
-
import { patchWikircProfile } from './wikirc.js';
|
|
8
|
+
import { loadWikircProfile, patchWikircProfile } from './wikirc.js';
|
|
9
|
+
import { buildInheritedWikircPatch, copyCmeCredentials } from './workspaceInherit.js';
|
|
9
10
|
import { resolveAgentsComposeContext } from './agentsCompose.js';
|
|
10
11
|
import { managerEnvFile, managerMcpEndpointsFile, resolveAgentsDataDir } from './env.js';
|
|
11
12
|
import { createWorkspace, findWorkspace, isValidWorkspaceName, listWorkspaces, managerRoot, workspacesDir } from './workspaces.js';
|
|
@@ -208,20 +209,62 @@ export async function refreshRunningContainers(options = {}) {
|
|
|
208
209
|
return { skipped: false, refreshed, errors };
|
|
209
210
|
}
|
|
210
211
|
|
|
211
|
-
export async function createNewWorkspace(name, targetPath) {
|
|
212
|
+
export async function createNewWorkspace(name, targetPath, options = {}) {
|
|
212
213
|
try {
|
|
213
214
|
const output = await createWorkspace(name, targetPath, { timeout: 600_000 });
|
|
214
|
-
const workspace = finalizeCreatedWorkspace(name);
|
|
215
|
-
return { output, workspace };
|
|
215
|
+
const { workspace, inherited } = await finalizeCreatedWorkspace(name, options);
|
|
216
|
+
return { output, workspace, inherited };
|
|
216
217
|
} catch (err) {
|
|
217
218
|
throw wrapDockerError(err);
|
|
218
219
|
}
|
|
219
220
|
}
|
|
220
221
|
|
|
221
|
-
|
|
222
|
+
/**
|
|
223
|
+
* @param options.inheritFrom name of the workspace to copy LLM config and CME
|
|
224
|
+
* credentials from — normally the session's current workspace. Omitted, or
|
|
225
|
+
* unknown, means "scaffold defaults only", the previous behaviour.
|
|
226
|
+
*/
|
|
227
|
+
export async function finalizeCreatedWorkspace(name, options = {}) {
|
|
222
228
|
const workspace = findWorkspace(name);
|
|
223
|
-
if (workspace)
|
|
224
|
-
|
|
229
|
+
if (!workspace) return { workspace: null, inherited: [] };
|
|
230
|
+
// Order matters: the access key is this workspace's own credential and must
|
|
231
|
+
// land before anything else touches the file.
|
|
232
|
+
initializeWorkspaceWikirc(workspace);
|
|
233
|
+
const inherited = await inheritWorkspaceSetup(workspace, options.inheritFrom ?? null);
|
|
234
|
+
return { workspace, inherited };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Carry an existing workspace's proven setup over to a new one. Best effort by
|
|
239
|
+
* design: a workspace that exists and works must never fail to be created
|
|
240
|
+
* because the workspace next door has an unreadable config.
|
|
241
|
+
*/
|
|
242
|
+
export async function inheritWorkspaceSetup(workspace, sourceName) {
|
|
243
|
+
if (!workspace?.workspacePath || !sourceName || sourceName === workspace.name) return [];
|
|
244
|
+
const source = findWorkspace(sourceName);
|
|
245
|
+
if (!source?.workspacePath) return [];
|
|
246
|
+
|
|
247
|
+
const inherited = [];
|
|
248
|
+
try {
|
|
249
|
+
const { config: sourceConfig } = loadWikircProfile(source.workspacePath, 'default');
|
|
250
|
+
const { config: targetConfig } = loadWikircProfile(workspace.workspacePath, 'default');
|
|
251
|
+
const { patch, inherited: keys } = buildInheritedWikircPatch(sourceConfig, targetConfig);
|
|
252
|
+
if (keys.length > 0) {
|
|
253
|
+
patchWikircProfile(workspace.workspacePath, 'default', patch);
|
|
254
|
+
inherited.push(...keys);
|
|
255
|
+
}
|
|
256
|
+
} catch {
|
|
257
|
+
// Unreadable or absent profile on either side — nothing to inherit.
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
const copied = await copyCmeCredentials(resolveAgentsDataDir(), source.name, workspace.name);
|
|
262
|
+
if (copied) inherited.push('cme.app_data.json');
|
|
263
|
+
} catch {
|
|
264
|
+
// Credentials are a convenience, not a prerequisite.
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return inherited;
|
|
225
268
|
}
|
|
226
269
|
|
|
227
270
|
export function initializeWorkspaceWikirc(workspace) {
|
|
@@ -105,3 +105,67 @@ test('container refresh pulls and renews only services that are already running'
|
|
|
105
105
|
assert.match(script, /refresh_running_services 'No running external agent containers to refresh' 'Refreshed running agents: %s' _agents_dc/);
|
|
106
106
|
assert.match(script, /refresh_running_services "No running workspace containers to refresh: \$workspace" "Refreshed running workspace containers: \$workspace \(%s\)" compose_for_workspace "\$workspace"/);
|
|
107
107
|
});
|
|
108
|
+
|
|
109
|
+
// `${arr[@]}` on an empty array is an unbound-variable error under `set -u` in
|
|
110
|
+
// bash <= 4.3, and macOS still ships /bin/bash 3.2. `parse_global_options`
|
|
111
|
+
// tripped on it, so `wiki-workspace` with no global option died before reaching
|
|
112
|
+
// any subcommand.
|
|
113
|
+
//
|
|
114
|
+
// The execution check below cannot catch a regression on a CI running bash 5,
|
|
115
|
+
// where an empty array is legal — hence the source assertion that every
|
|
116
|
+
// optional array keeps the `${arr[@]+"${arr[@]}"}` guard. Both are needed.
|
|
117
|
+
test('optional arrays keep the empty-array guard required by bash 3.2', async () => {
|
|
118
|
+
const script = await readFile(new URL('../../wiki-workspace', import.meta.url), 'utf8');
|
|
119
|
+
|
|
120
|
+
for (const name of ['parsed', 'PARSED_ARGS', 'log_args']) {
|
|
121
|
+
const unguarded = new RegExp(String.raw`(?<!\+)"\$\{${name}\[@\]\}"`);
|
|
122
|
+
assert.doesNotMatch(script, unguarded, `${name}[@] must use the \${name[@]+"..."} guard`);
|
|
123
|
+
}
|
|
124
|
+
assert.match(script, /set -- \$\{parsed\[@\]\+"\$\{parsed\[@\]\}"\}/);
|
|
125
|
+
assert.match(script, /set -- \$\{PARSED_ARGS\[@\]\+"\$\{PARSED_ARGS\[@\]\}"\}/);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('wiki-workspace reaches its usage text with no arguments', async () => {
|
|
129
|
+
const { spawnSync } = await import('node:child_process');
|
|
130
|
+
const scriptPath = new URL('../../wiki-workspace', import.meta.url).pathname;
|
|
131
|
+
|
|
132
|
+
const result = spawnSync('bash', [scriptPath], { encoding: 'utf8' });
|
|
133
|
+
|
|
134
|
+
assert.doesNotMatch(`${result.stdout}${result.stderr}`, /unbound variable/);
|
|
135
|
+
assert.match(result.stdout, /^Usage:/);
|
|
136
|
+
assert.equal(result.status, 2);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// `reset` deletes everything workspace_reset_keep rejects, and its very next
|
|
140
|
+
// statement is `run_wiki … init`, which opens with need_workspace_env. If
|
|
141
|
+
// `.env` ever falls out of the keep list, reset erases the workspace and then
|
|
142
|
+
// dies before re-scaffolding it — and recovering with `config` mints new ports
|
|
143
|
+
// and MCP tokens, invalidating every reference to the old ones. Exercise the
|
|
144
|
+
// real function rather than asserting on the source text.
|
|
145
|
+
test('workspace_reset_keep protects the workspace registration and method', async () => {
|
|
146
|
+
const { spawnSync } = await import('node:child_process');
|
|
147
|
+
const scriptPath = new URL('../../wiki-workspace', import.meta.url).pathname;
|
|
148
|
+
const script = await readFile(new URL('../../wiki-workspace', import.meta.url), 'utf8');
|
|
149
|
+
const start = script.indexOf('workspace_reset_keep() {');
|
|
150
|
+
assert.notEqual(start, -1, 'workspace_reset_keep must exist');
|
|
151
|
+
const body = script.slice(start, script.indexOf('\n}', start) + 2);
|
|
152
|
+
|
|
153
|
+
const ask = (name) =>
|
|
154
|
+
spawnSync('bash', ['-c', `${body}\nworkspace_reset_keep "$1"`, '_', name]).status === 0;
|
|
155
|
+
|
|
156
|
+
for (const kept of ['.env', '.env.local', '.wikirc.yaml', '.wikirc.yaml.prod', 'templates', 'build-context', '.git']) {
|
|
157
|
+
assert.equal(ask(kept), true, `${kept} must survive a reset`);
|
|
158
|
+
}
|
|
159
|
+
for (const removed of ['wiki', 'deliverables', 'raw', '.wiki', 'CLAUDE.md', '.gitignore', '.environment']) {
|
|
160
|
+
assert.equal(ask(removed), false, `${removed} must be reset`);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('reset re-scaffolds through run_wiki, which requires the workspace env', async () => {
|
|
165
|
+
const script = await readFile(new URL('../../wiki-workspace', import.meta.url), 'utf8');
|
|
166
|
+
|
|
167
|
+
// The coupling the keep list depends on: if either half moves, revisit it.
|
|
168
|
+
assert.match(script, /run_wiki\(\) \{\n local workspace="\$1"\n shift\n need_workspace_env "\$workspace"/);
|
|
169
|
+
assert.match(script, /run_wiki "\$workspace" init/);
|
|
170
|
+
assert.match(script, /workspace_env_file\(\) \{\n local workspace="\$1"\n printf '%s\/%s\/\.env\\n'/);
|
|
171
|
+
});
|
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
|
+
});
|
|
@@ -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
|
+
}
|