@dotdrelle/wiki-manager 0.15.28 → 0.15.29
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 +5 -4
- package/agents.docker-compose.override.example.yml +1 -1
- package/docker-compose.override.example.yml +2 -2
- package/mcp.endpoints.example.json +1 -1
- package/package.json +2 -2
- package/src/agent/graph.js +77 -13
- package/src/agent/graph.test.js +148 -4
- package/src/cli/wiki-manager.js +39 -6
- package/src/cli/wiki-manager.test.js +35 -0
- package/src/commands/slash.js +159 -39
- package/src/commands/slash.test.js +100 -1
- package/src/core/agentsCompose.js +79 -0
- package/src/core/agentsCompose.test.js +99 -0
- package/src/core/buildInfo.json +2 -2
- package/src/core/composeOverrides.test.js +17 -3
- package/src/core/env.js +27 -10
- package/src/core/mcp.js +1 -1
- package/src/core/startupCheck.js +64 -27
- package/src/core/startupCheck.test.js +49 -1
- package/src/core/wikiSetup.js +57 -22
- package/src/core/wikiSetup.test.js +87 -0
- package/src/orchestrator/objectiveResolver.js +24 -0
- package/src/orchestrator/objectiveResolver.test.js +27 -0
- package/src/shell/LeftPane.tsx +49 -24
- package/src/shell/repl.js +114 -21
- package/src/shell/repl.test.js +88 -17
- package/src/shell/tui.tsx +0 -1
- package/wiki-workspace +29 -12
package/src/core/env.js
CHANGED
|
@@ -39,18 +39,24 @@ export function managerMcpEndpointsFile() {
|
|
|
39
39
|
return join(managerStateDir(), 'mcp.endpoints.json');
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
// User-owned compose overrides, one per stack
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
// deployment — proxy passthrough, extra mounts, optional agents — instead of
|
|
47
|
-
// editing a generated file and losing the change on the next command.
|
|
42
|
+
// User-owned compose overrides, one per stack. `.wiki/compose` is persistent
|
|
43
|
+
// operator configuration; `.wiki/runtime` is generated state rewritten by
|
|
44
|
+
// compose commands. Keeping those directories separate makes the lifecycle
|
|
45
|
+
// explicit while grouping manager-local files under one `.wiki` tree.
|
|
48
46
|
export const COMPOSE_OVERRIDES = [
|
|
49
47
|
{ example: 'docker-compose.override.example.yml', target: 'docker-compose.override.yml' },
|
|
50
48
|
{ example: 'agents.docker-compose.override.example.yml', target: 'agents.docker-compose.override.yml' },
|
|
51
49
|
];
|
|
52
50
|
|
|
51
|
+
export function managerComposeDir() {
|
|
52
|
+
return join(managerStateDir(), '.wiki', 'compose');
|
|
53
|
+
}
|
|
54
|
+
|
|
53
55
|
export function managerComposeOverrideFile(target = 'docker-compose.override.yml') {
|
|
56
|
+
return join(managerComposeDir(), target);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function legacyManagerComposeOverrideFile(target) {
|
|
54
60
|
return join(managerStateDir(), target);
|
|
55
61
|
}
|
|
56
62
|
|
|
@@ -160,12 +166,23 @@ export function ensureManagerScaffold({ log = () => {} } = {}) {
|
|
|
160
166
|
for (const { example, target } of COMPOSE_OVERRIDES) {
|
|
161
167
|
const examplePath = join(packageRoot, example);
|
|
162
168
|
const targetPath = managerComposeOverrideFile(target);
|
|
169
|
+
const legacyPath = legacyManagerComposeOverrideFile(target);
|
|
170
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
171
|
+
if (!existsSync(targetPath) && existsSync(legacyPath)) {
|
|
172
|
+
renameSync(legacyPath, targetPath);
|
|
173
|
+
created.push(`.wiki/compose/${target} migrated`);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
163
176
|
if (!existsSync(examplePath) || existsSync(targetPath)) continue;
|
|
164
177
|
copyFileSync(examplePath, targetPath);
|
|
165
|
-
created.push(target);
|
|
178
|
+
created.push(`.wiki/compose/${target}`);
|
|
166
179
|
}
|
|
167
180
|
if (created.length > 0) {
|
|
168
|
-
|
|
181
|
+
// The list of seeded files and the state directory are implementation
|
|
182
|
+
// detail the operator has no decision to make about — the return value
|
|
183
|
+
// still carries them for callers that need to react (a fresh scaffold
|
|
184
|
+
// forces a runtime restart).
|
|
185
|
+
log('configuration initialized successfully');
|
|
169
186
|
}
|
|
170
187
|
return created;
|
|
171
188
|
}
|
|
@@ -209,12 +226,12 @@ function parseEnvValue(value) {
|
|
|
209
226
|
return value;
|
|
210
227
|
}
|
|
211
228
|
|
|
212
|
-
export function loadManagerEnv() {
|
|
229
|
+
export function loadManagerEnv({ override = false } = {}) {
|
|
213
230
|
const filePath = managerEnvFile();
|
|
214
231
|
if (!existsSync(filePath)) return;
|
|
215
232
|
const values = readEnvFile(filePath);
|
|
216
233
|
for (const [key, value] of Object.entries(values)) {
|
|
217
|
-
if (!(key in process.env)) process.env[key] = value;
|
|
234
|
+
if (override || !(key in process.env)) process.env[key] = value;
|
|
218
235
|
}
|
|
219
236
|
}
|
|
220
237
|
|
package/src/core/mcp.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { managerEnvFile, managerMcpEndpointsFile, readEnvFile } from './env.js';
|
|
3
3
|
|
|
4
|
-
const WIKI_MANAGER_VERSION = '0.15.
|
|
4
|
+
const WIKI_MANAGER_VERSION = '0.15.29';
|
|
5
5
|
|
|
6
6
|
function envValue(key) {
|
|
7
7
|
const filePath = managerEnvFile();
|
package/src/core/startupCheck.js
CHANGED
|
@@ -4,6 +4,7 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { promisify } from 'node:util';
|
|
5
5
|
import { activeCacertPath, cacertEnv } from './cacert.js';
|
|
6
6
|
import { COMPOSE_SERVICES, parseComposePsJson, serviceStates } from './compose.js';
|
|
7
|
+
import { resolveAgentsComposeContext } from './agentsCompose.js';
|
|
7
8
|
import { buildMcpStatus, discoverMcpTools } from './mcp.js';
|
|
8
9
|
import { listWikircProfiles, loadWikircProfile, summarizeWikircConfig } from './wikirc.js';
|
|
9
10
|
import { listWorkspaces, managerRoot, workspacesDir } from './workspaces.js';
|
|
@@ -79,22 +80,27 @@ export async function checkInternetConnectivity({
|
|
|
79
80
|
}
|
|
80
81
|
}
|
|
81
82
|
|
|
82
|
-
async function checkAgents({ exec = execFileAsync } = {}) {
|
|
83
|
-
|
|
84
|
-
if (!existsSync(composeFile)) return null;
|
|
83
|
+
export async function checkAgents({ exec = execFileAsync, context = resolveAgentsComposeContext() } = {}) {
|
|
84
|
+
if (!context.exists) return null;
|
|
85
85
|
try {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
'
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
86
|
+
// `config --services` is Compose's authoritative view after profiles,
|
|
87
|
+
// overrides and interpolation. Unlike `ps`, it also includes services that
|
|
88
|
+
// have never created a container, including ordinary (non-profiled)
|
|
89
|
+
// services and services contributed by the user override.
|
|
90
|
+
const { stdout: configuredStdout } = await exec(
|
|
91
|
+
'docker',
|
|
92
|
+
[...context.args, 'config', '--services'],
|
|
93
|
+
{
|
|
94
|
+
cwd: managerRoot(),
|
|
95
|
+
env: {
|
|
96
|
+
...process.env,
|
|
97
|
+
WIKI_WORKSPACES_DIR: workspacesDir(),
|
|
98
|
+
},
|
|
99
|
+
timeout: 30_000,
|
|
100
|
+
maxBuffer: 1024 * 1024,
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
const { stdout } = await exec('docker', [...context.args, 'ps', '--all', '--format', 'json'], {
|
|
98
104
|
cwd: managerRoot(),
|
|
99
105
|
env: {
|
|
100
106
|
...process.env,
|
|
@@ -104,16 +110,38 @@ async function checkAgents({ exec = execFileAsync } = {}) {
|
|
|
104
110
|
timeout: 5000,
|
|
105
111
|
maxBuffer: 1024 * 1024,
|
|
106
112
|
});
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
.filter((entry) => {
|
|
111
|
-
const state = String(entry.State ?? entry.state ?? entry.Status ?? entry.status ?? '').toLowerCase();
|
|
112
|
-
return !(state.includes('running') || state.includes('up'));
|
|
113
|
-
})
|
|
114
|
-
.map((entry) => entry.Service ?? entry.service ?? entry.Name ?? entry.name)
|
|
113
|
+
const expectedServices = String(configuredStdout ?? '')
|
|
114
|
+
.split(/\r?\n/)
|
|
115
|
+
.map((service) => service.trim())
|
|
115
116
|
.filter(Boolean);
|
|
116
|
-
|
|
117
|
+
const entries = parseComposePsJson(stdout);
|
|
118
|
+
const running = new Set();
|
|
119
|
+
const downServices = [];
|
|
120
|
+
for (const entry of entries) {
|
|
121
|
+
const name = entry.Service ?? entry.service ?? entry.Name ?? entry.name;
|
|
122
|
+
if (!name) continue;
|
|
123
|
+
const state = String(entry.State ?? entry.state ?? entry.Status ?? entry.status ?? '').toLowerCase();
|
|
124
|
+
if (state.includes('running') || state.includes('up')) running.add(name);
|
|
125
|
+
else downServices.push(name);
|
|
126
|
+
}
|
|
127
|
+
// A service whose profile the operator enabled but which `ps` does not list
|
|
128
|
+
// at all is missing, not opted out — reporting only the listed-but-stopped
|
|
129
|
+
// ones turned "connectors never started" into a silent success.
|
|
130
|
+
const missingServices = expectedServices.filter(
|
|
131
|
+
(service) => !running.has(service) && !downServices.includes(service),
|
|
132
|
+
);
|
|
133
|
+
if (entries.length === 0 && missingServices.length === 0) return null;
|
|
134
|
+
if (downServices.length === 0 && missingServices.length === 0) return null;
|
|
135
|
+
return {
|
|
136
|
+
kind: 'agents',
|
|
137
|
+
context: {
|
|
138
|
+
...(downServices.length > 0 ? { downServices } : {}),
|
|
139
|
+
...(missingServices.length > 0 ? { missingServices } : {}),
|
|
140
|
+
expectedServices,
|
|
141
|
+
requestedProfileServices: context.expectedProfileServices,
|
|
142
|
+
profiles: context.profiles,
|
|
143
|
+
},
|
|
144
|
+
};
|
|
117
145
|
} catch (err) {
|
|
118
146
|
if (err?.code === 'ENOENT') {
|
|
119
147
|
return { kind: 'agents', context: { dockerMissing: true } };
|
|
@@ -349,16 +377,25 @@ export async function runChecks({
|
|
|
349
377
|
if (!docker.ok) gaps.push({ kind: 'agents', context: docker.context ?? { dockerUnavailable: true } });
|
|
350
378
|
if (!internet.ok) gaps.push({ kind: 'network', context: internet.context ?? {} });
|
|
351
379
|
if (agents) gaps.push(agents);
|
|
380
|
+
const missingRequested = (agents?.context?.missingServices ?? []).filter(
|
|
381
|
+
(service) => (agents?.context?.requestedProfileServices ?? []).includes(service),
|
|
382
|
+
);
|
|
352
383
|
onCheck({
|
|
353
384
|
kind: 'agents',
|
|
354
385
|
ok: docker.ok && !agents,
|
|
355
386
|
skipped: !docker.ok,
|
|
356
387
|
pending: !docker.ok || Boolean(agents),
|
|
388
|
+
// A service behind a profile the operator explicitly enabled is not merely
|
|
389
|
+
// "not started yet": they asked for it. Reporters use this to stay silent
|
|
390
|
+
// about ordinary boot latency while still surfacing this one.
|
|
391
|
+
requested: missingRequested.length > 0,
|
|
357
392
|
detail: !docker.ok
|
|
358
393
|
? 'Waiting for Docker'
|
|
359
|
-
:
|
|
360
|
-
? `
|
|
361
|
-
:
|
|
394
|
+
: missingRequested.length > 0
|
|
395
|
+
? `Enabled but not running: ${missingRequested.join(', ')}`
|
|
396
|
+
: agents?.context?.downServices?.length
|
|
397
|
+
? `To start: ${agents.context.downServices.join(', ')}`
|
|
398
|
+
: 'Running',
|
|
362
399
|
context: { ...(agents?.context ?? {}), command: 'wiki-workspace agents up' },
|
|
363
400
|
});
|
|
364
401
|
const workspaceGap = checkWorkspace(workspaces);
|
|
@@ -4,7 +4,7 @@ import { mkdtemp } from 'node:fs/promises';
|
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import test from 'node:test';
|
|
7
|
-
import { checkInternetConnectivity, checkMcpConnections, runChecks, runPreflightChecks, withRuntimePreflight } from './startupCheck.js';
|
|
7
|
+
import { checkAgents, checkInternetConnectivity, checkMcpConnections, runChecks, runPreflightChecks, withRuntimePreflight } from './startupCheck.js';
|
|
8
8
|
|
|
9
9
|
async function withWorkspace(wikircLines, fn) {
|
|
10
10
|
const root = await mkdtemp(join(tmpdir(), 'wiki-manager-startup-check-'));
|
|
@@ -34,6 +34,54 @@ function hasGap(gaps, kind) {
|
|
|
34
34
|
return gaps.some((gap) => gap.kind === kind);
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
test('checkAgents reports an enabled profile whose container never appeared', async () => {
|
|
38
|
+
// `docker compose ps` lists nothing for a service that was never created, so
|
|
39
|
+
// filtering only on "listed but stopped" reported success while connectors
|
|
40
|
+
// was absent. An enabled profile is a promise, not an opt-out.
|
|
41
|
+
const context = {
|
|
42
|
+
exists: true,
|
|
43
|
+
args: ['compose', '-p', 'wiki-agents', '--profile', 'connectors'],
|
|
44
|
+
profiles: ['connectors'],
|
|
45
|
+
expectedProfileServices: ['connectors'],
|
|
46
|
+
};
|
|
47
|
+
const running = JSON.stringify([{ Service: 'cme', State: 'running' }]);
|
|
48
|
+
const execWithPs = (ps) => async (_command, args) => ({
|
|
49
|
+
stdout: args.includes('config') ? 'cme\nconnectors\n' : ps,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const gap = await checkAgents({ context, exec: execWithPs(running) });
|
|
53
|
+
assert.equal(gap.kind, 'agents');
|
|
54
|
+
assert.deepEqual(gap.context.missingServices, ['connectors']);
|
|
55
|
+
assert.deepEqual(gap.context.profiles, ['connectors']);
|
|
56
|
+
|
|
57
|
+
const started = JSON.stringify([
|
|
58
|
+
{ Service: 'cme', State: 'running' },
|
|
59
|
+
{ Service: 'connectors', State: 'running' },
|
|
60
|
+
]);
|
|
61
|
+
assert.equal(await checkAgents({ context, exec: execWithPs(started) }), null);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('checkAgents ignores a profile the operator did not enable', async () => {
|
|
65
|
+
const context = { exists: true, args: [], profiles: [], expectedProfileServices: [] };
|
|
66
|
+
const stdout = JSON.stringify([{ Service: 'cme', State: 'running' }]);
|
|
67
|
+
const exec = async (_command, args) => ({
|
|
68
|
+
stdout: args.includes('config') ? 'cme\n' : stdout,
|
|
69
|
+
});
|
|
70
|
+
assert.equal(await checkAgents({ context, exec }), null);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('checkAgents reports an ordinary agent that never created a container', async () => {
|
|
74
|
+
const context = { exists: true, args: [], profiles: [], expectedProfileServices: [] };
|
|
75
|
+
const exec = async (_command, args) => ({
|
|
76
|
+
stdout: args.includes('config')
|
|
77
|
+
? 'cme\ndocuments\n'
|
|
78
|
+
: JSON.stringify([{ Service: 'cme', State: 'running' }]),
|
|
79
|
+
});
|
|
80
|
+
const gap = await checkAgents({ context, exec });
|
|
81
|
+
assert.deepEqual(gap.context.missingServices, ['documents']);
|
|
82
|
+
assert.deepEqual(gap.context.expectedServices, ['cme', 'documents']);
|
|
83
|
+
});
|
|
84
|
+
|
|
37
85
|
test('runChecks treats default LLM config without baseUrl as incomplete', async () => {
|
|
38
86
|
await withWorkspace([
|
|
39
87
|
'llm:',
|
package/src/core/wikiSetup.js
CHANGED
|
@@ -6,15 +6,12 @@ import { promisify } from 'node:util';
|
|
|
6
6
|
import YAML from 'yaml';
|
|
7
7
|
import { checkMissingDockerImages } from './dockerImages.js';
|
|
8
8
|
import { patchWikircProfile } from './wikirc.js';
|
|
9
|
-
import {
|
|
9
|
+
import { resolveAgentsComposeContext } from './agentsCompose.js';
|
|
10
|
+
import { managerEnvFile, managerMcpEndpointsFile, resolveAgentsDataDir } from './env.js';
|
|
10
11
|
import { createWorkspace, findWorkspace, isValidWorkspaceName, listWorkspaces, managerRoot, workspacesDir } from './workspaces.js';
|
|
11
12
|
|
|
12
13
|
const execFileAsync = promisify(execFile);
|
|
13
14
|
|
|
14
|
-
function enabled(value) {
|
|
15
|
-
return /^(?:1|true|yes|on)$/i.test(String(value ?? '').trim());
|
|
16
|
-
}
|
|
17
|
-
|
|
18
15
|
export function configuredAgentImages(config, activeProfiles = new Set()) {
|
|
19
16
|
return Object.values(config.services ?? {})
|
|
20
17
|
.filter((service) => {
|
|
@@ -25,21 +22,12 @@ export function configuredAgentImages(config, activeProfiles = new Set()) {
|
|
|
25
22
|
.filter(Boolean);
|
|
26
23
|
}
|
|
27
24
|
|
|
28
|
-
async function missingAgentImages() {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
-
const activeProfiles = new Set();
|
|
36
|
-
if (enabled(managerEnv.CONNECTORS_ENABLED ?? process.env.CONNECTORS_ENABLED)) {
|
|
37
|
-
activeProfiles.add('connectors');
|
|
38
|
-
}
|
|
39
|
-
const composeFiles = [
|
|
40
|
-
join(managerRoot(), 'agents.docker-compose.yml'),
|
|
41
|
-
join(dirname(managerEnvFile()), 'agents.docker-compose.override.yml'),
|
|
42
|
-
].filter(existsSync);
|
|
25
|
+
async function missingAgentImages(context = resolveAgentsComposeContext()) {
|
|
26
|
+
// Same resolution as the preflight and as `wiki-workspace agents up`: an
|
|
27
|
+
// image behind a profile the operator did not enable must not be reported
|
|
28
|
+
// missing, and one behind a profile they DID enable must be.
|
|
29
|
+
const activeProfiles = new Set(context.profiles);
|
|
30
|
+
const composeFiles = context.composeFiles.filter(existsSync);
|
|
43
31
|
const images = [...new Set(composeFiles.flatMap((filePath) => {
|
|
44
32
|
try {
|
|
45
33
|
const config = YAML.parse(readFileSync(filePath, 'utf8')) ?? {};
|
|
@@ -77,13 +65,22 @@ function wrapDockerError(err) {
|
|
|
77
65
|
}
|
|
78
66
|
|
|
79
67
|
export async function startAgents(options = {}) {
|
|
68
|
+
const composeContext = options.composeContext ?? resolveAgentsComposeContext();
|
|
80
69
|
try {
|
|
81
|
-
const absentImages =
|
|
70
|
+
const absentImages = options.imagesCheck
|
|
71
|
+
? await options.imagesCheck(composeContext)
|
|
72
|
+
: await missingAgentImages(composeContext);
|
|
82
73
|
if (absentImages.length > 0) options.onImagesMissing?.(absentImages);
|
|
83
|
-
const
|
|
74
|
+
const exec = options.exec ?? execFileAsync;
|
|
75
|
+
const { stdout, stderr } = await exec(join(managerRoot(), 'wiki-workspace'), ['agents', 'up'], {
|
|
84
76
|
cwd: managerRoot(),
|
|
85
77
|
env: {
|
|
86
78
|
...process.env,
|
|
79
|
+
// Compose gives variables already exported by its parent process
|
|
80
|
+
// precedence over --env-file, including an empty value loaded at boot.
|
|
81
|
+
// Re-apply the manager's resolved policy here so a token/secret written
|
|
82
|
+
// later to .env is not shadowed by stale process.env state.
|
|
83
|
+
...composeContext.env,
|
|
87
84
|
WIKI_WORKSPACES_DIR: workspacesDir(),
|
|
88
85
|
// cwd is the npm package root (the script and compose files live
|
|
89
86
|
// there), so the manager files MUST be pinned explicitly: without
|
|
@@ -102,15 +99,53 @@ export async function startAgents(options = {}) {
|
|
|
102
99
|
timeout: options.timeout ?? 180_000,
|
|
103
100
|
maxBuffer: options.maxBuffer ?? 1024 * 1024 * 8,
|
|
104
101
|
});
|
|
102
|
+
// `wiki-workspace agents up` also (re)writes mcp.endpoints.json, so the
|
|
103
|
+
// endpoints must be re-read here — the caller's in-memory session still
|
|
104
|
+
// holds the file as it was BEFORE the connectors entry was added, and the
|
|
105
|
+
// freshly started agent would stay unreachable until the next restart.
|
|
106
|
+
const verification = await verifyAgentsStarted({
|
|
107
|
+
context: composeContext,
|
|
108
|
+
agentsCheck: options.agentsCheck,
|
|
109
|
+
});
|
|
110
|
+
if (!verification.ok) throw agentsStartFailure(verification);
|
|
105
111
|
return {
|
|
106
112
|
output: [stdout, stderr].filter(Boolean).join('\n').trim(),
|
|
107
113
|
missingImages: absentImages,
|
|
114
|
+
profiles: composeContext.profiles,
|
|
108
115
|
};
|
|
109
116
|
} catch (err) {
|
|
110
117
|
throw wrapDockerError(err);
|
|
111
118
|
}
|
|
112
119
|
}
|
|
113
120
|
|
|
121
|
+
// A start that leaves an enabled service down is a failure. Reporting success
|
|
122
|
+
// and letting the operator discover it through a silent preflight line is what
|
|
123
|
+
// made CONNECTORS_ENABLED=true look like it had been ignored.
|
|
124
|
+
async function verifyAgentsStarted({ context, agentsCheck }) {
|
|
125
|
+
const check = agentsCheck ?? (await import('./startupCheck.js')).checkAgents;
|
|
126
|
+
const gap = await check({ context });
|
|
127
|
+
if (!gap) return { ok: true, profiles: context.profiles };
|
|
128
|
+
const down = gap.context?.downServices ?? [];
|
|
129
|
+
const missing = gap.context?.missingServices ?? [];
|
|
130
|
+
if (down.length === 0 && missing.length === 0) return { ok: true, profiles: context.profiles };
|
|
131
|
+
return { ok: false, downServices: down, missingServices: missing, profiles: context.profiles };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function agentsStartFailure({ downServices = [], missingServices = [], profiles = [] }) {
|
|
135
|
+
const parts = [
|
|
136
|
+
missingServices.length > 0 ? `never started: ${missingServices.join(', ')}` : '',
|
|
137
|
+
downServices.length > 0 ? `not running: ${downServices.join(', ')}` : '',
|
|
138
|
+
].filter(Boolean);
|
|
139
|
+
const error = new Error(
|
|
140
|
+
`Agents started, but the expected services are not up (${parts.join('; ')}).`
|
|
141
|
+
+ (profiles.length > 0 ? ` Active profiles: ${profiles.join(', ')}.` : ''),
|
|
142
|
+
);
|
|
143
|
+
error.name = 'AgentsNotRunningError';
|
|
144
|
+
error.downServices = downServices;
|
|
145
|
+
error.missingServices = missingServices;
|
|
146
|
+
return error;
|
|
147
|
+
}
|
|
148
|
+
|
|
114
149
|
export async function stopAgents(options = {}) {
|
|
115
150
|
try {
|
|
116
151
|
const { stdout, stderr } = await execFileAsync(join(managerRoot(), 'wiki-workspace'), ['agents', 'down'], {
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
|
|
4
|
+
import { startAgents } from './wikiSetup.js';
|
|
5
|
+
|
|
6
|
+
const CONTEXT = {
|
|
7
|
+
exists: true,
|
|
8
|
+
args: [],
|
|
9
|
+
profiles: ['connectors'],
|
|
10
|
+
expectedProfileServices: ['connectors'],
|
|
11
|
+
composeFiles: [],
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function startOptions(overrides = {}) {
|
|
15
|
+
return {
|
|
16
|
+
composeContext: CONTEXT,
|
|
17
|
+
imagesCheck: async () => [],
|
|
18
|
+
exec: async () => ({ stdout: 'started', stderr: '' }),
|
|
19
|
+
...overrides,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test('starting the agents succeeds when every enabled service is running', async () => {
|
|
24
|
+
const result = await startAgents(startOptions({ agentsCheck: async () => null }));
|
|
25
|
+
assert.equal(result.output, 'started');
|
|
26
|
+
assert.deepEqual(result.profiles, ['connectors']);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('an enabled connector that never started makes the start fail, not succeed', async () => {
|
|
30
|
+
// The script exits 0 whether or not the profiled container came up, so
|
|
31
|
+
// trusting its exit code alone reported success while connectors was absent
|
|
32
|
+
// — the operator only found out much later, through a silent preflight line.
|
|
33
|
+
await assert.rejects(
|
|
34
|
+
startAgents(startOptions({
|
|
35
|
+
agentsCheck: async () => ({ kind: 'agents', context: { missingServices: ['connectors'], profiles: ['connectors'] } }),
|
|
36
|
+
})),
|
|
37
|
+
(err) => {
|
|
38
|
+
assert.equal(err.name, 'AgentsNotRunningError');
|
|
39
|
+
assert.deepEqual(err.missingServices, ['connectors']);
|
|
40
|
+
assert.match(err.message, /never started: connectors/);
|
|
41
|
+
assert.match(err.message, /Active profiles: connectors\./);
|
|
42
|
+
return true;
|
|
43
|
+
},
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('a listed but stopped service also fails the start', async () => {
|
|
48
|
+
await assert.rejects(
|
|
49
|
+
startAgents(startOptions({
|
|
50
|
+
agentsCheck: async () => ({ kind: 'agents', context: { downServices: ['cme'] } }),
|
|
51
|
+
})),
|
|
52
|
+
(err) => {
|
|
53
|
+
assert.equal(err.name, 'AgentsNotRunningError');
|
|
54
|
+
assert.deepEqual(err.downServices, ['cme']);
|
|
55
|
+
assert.match(err.message, /not running: cme/);
|
|
56
|
+
return true;
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('the verification runs against the same compose context as the start', async () => {
|
|
62
|
+
let seen = null;
|
|
63
|
+
await startAgents(startOptions({
|
|
64
|
+
agentsCheck: async ({ context }) => { seen = context; return null; },
|
|
65
|
+
}));
|
|
66
|
+
assert.equal(seen, CONTEXT);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('the manager .env values override stale blank process values for agents up', async () => {
|
|
70
|
+
let childEnv = null;
|
|
71
|
+
const context = {
|
|
72
|
+
...CONTEXT,
|
|
73
|
+
env: {
|
|
74
|
+
...process.env,
|
|
75
|
+
GOOGLE_OAUTH_CLIENT_SECRET: 'fresh-secret',
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
await startAgents(startOptions({
|
|
79
|
+
composeContext: context,
|
|
80
|
+
agentsCheck: async () => null,
|
|
81
|
+
exec: async (_file, _args, options) => {
|
|
82
|
+
childEnv = options.env;
|
|
83
|
+
return { stdout: 'started', stderr: '' };
|
|
84
|
+
},
|
|
85
|
+
}));
|
|
86
|
+
assert.equal(childEnv.GOOGLE_OAUTH_CLIENT_SECRET, 'fresh-secret');
|
|
87
|
+
});
|
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
// Distinguishes "nothing here can do that" from a genuine resolution failure:
|
|
2
|
+
// the first is a normal answer Donna gives the user, the second is a defect.
|
|
3
|
+
export class ObjectiveNotOrchestrableError extends Error {
|
|
4
|
+
constructor(objective, candidates = [], reason = '') {
|
|
5
|
+
const available = candidates.map((item) => item.id).join(', ') || 'none';
|
|
6
|
+
super(
|
|
7
|
+
`No connected agent can do that.${reason ? ` ${String(reason).trim()}` : ''}`
|
|
8
|
+
+ ` Available capabilities: ${available}.`,
|
|
9
|
+
);
|
|
10
|
+
this.name = 'ObjectiveNotOrchestrableError';
|
|
11
|
+
this.objective = String(objective ?? '');
|
|
12
|
+
this.candidates = candidates.map((item) => item.id);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
1
16
|
export async function resolveObjective(objective, session) {
|
|
2
17
|
const candidates = capabilityCandidates(session);
|
|
3
18
|
if (candidates.length === 0) throw new Error('No orchestrable capability is currently available.');
|
|
@@ -10,6 +25,12 @@ export async function resolveObjective(objective, session) {
|
|
|
10
25
|
system: [
|
|
11
26
|
'You resolve one user objective against a closed capability registry.',
|
|
12
27
|
'Select exactly one listed capability and one of its supported operations.',
|
|
28
|
+
// Without an explicit way out, the model has to pick SOMETHING: an
|
|
29
|
+
// objective no listed capability covers ("authorize Gmail") came back as
|
|
30
|
+
// workspace.diagnose/doctor and launched an unrelated job. Declining is
|
|
31
|
+
// a valid answer and the caller turns it into a plain reply.
|
|
32
|
+
'If no listed capability can achieve the objective, do not pick the closest one:',
|
|
33
|
+
'return {"capability":null,"reason":"<short reason>"}.',
|
|
13
34
|
'Never invent identifiers. Return JSON only: {"capability":"...","operation":"..."}.',
|
|
14
35
|
].join('\n'),
|
|
15
36
|
tools: [],
|
|
@@ -20,6 +41,9 @@ export async function resolveObjective(objective, session) {
|
|
|
20
41
|
signal: session?._abortSignal,
|
|
21
42
|
});
|
|
22
43
|
const selection = parseJson(result?.content);
|
|
44
|
+
if (selection?.capability === null) {
|
|
45
|
+
throw new ObjectiveNotOrchestrableError(objective, candidates, selection?.reason);
|
|
46
|
+
}
|
|
23
47
|
const capability = String(selection?.capability ?? '');
|
|
24
48
|
const operation = String(selection?.operation ?? '');
|
|
25
49
|
const candidate = candidates.find((item) => item.id === capability);
|
|
@@ -64,6 +64,33 @@ test('resolveObjective uses an unambiguously mentioned registry operation withou
|
|
|
64
64
|
assert.equal(result.provider.agentInstanceId, 'production-1');
|
|
65
65
|
});
|
|
66
66
|
|
|
67
|
+
test('resolveObjective declines an objective no listed capability covers', async () => {
|
|
68
|
+
// Forcing a pick is how "cree l'auth pour le gmail" became a doctor run on
|
|
69
|
+
// the production agent. Declining must be reported as such, not as a defect.
|
|
70
|
+
const session = sessionWithSelection({ capability: null, reason: 'Gmail authorization is not an orchestrable capability.' });
|
|
71
|
+
await assert.rejects(
|
|
72
|
+
resolveObjective("cree l'auth pour le gmail", session),
|
|
73
|
+
(err) => {
|
|
74
|
+
assert.equal(err.name, 'ObjectiveNotOrchestrableError');
|
|
75
|
+
assert.match(err.message, /No connected agent can do that\./);
|
|
76
|
+
assert.match(err.message, /Available capabilities: knowledge\.update\./);
|
|
77
|
+
assert.deepEqual(err.candidates, ['knowledge.update']);
|
|
78
|
+
return true;
|
|
79
|
+
},
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('resolveObjective treats a malformed selection as a resolution defect, not a decline', async () => {
|
|
84
|
+
await assert.rejects(
|
|
85
|
+
resolveObjective('Traite tout', sessionWithSelection({ reason: 'missing capability' })),
|
|
86
|
+
(err) => {
|
|
87
|
+
assert.notEqual(err.name, 'ObjectiveNotOrchestrableError');
|
|
88
|
+
assert.match(err.message, /unknown capability/);
|
|
89
|
+
return true;
|
|
90
|
+
},
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
67
94
|
test('resolveObjective rejects invented capability and operation', async () => {
|
|
68
95
|
await assert.rejects(
|
|
69
96
|
resolveObjective('Traite tout', sessionWithSelection({ capability: 'ingest', operation: 'ingest_all_pending' })),
|