@dotdrelle/wiki-manager 0.15.73 → 0.15.76
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 -6
- package/package.json +1 -1
- package/src/cli/wiki-manager.js +2 -2
- package/src/cli/wiki-manager.test.js +2 -2
- package/src/commands/slash.js +54 -9
- package/src/commands/slash.test.js +28 -0
- package/src/core/buildInfo.json +2 -2
- package/src/core/env.js +17 -2
- package/src/core/env.test.js +31 -0
- package/src/core/mcp.js +2 -3
- package/src/core/skillCompiler.test.js +3 -3
- package/src/core/startupCheck.js +12 -3
- package/src/core/startupCheck.test.js +28 -0
- package/src/core/workflow.js +4 -6
- package/src/orchestrator/objectiveResolver.test.js +1 -1
- package/src/orchestrator/providers/deepAgentsProvider.js +30 -3
- package/src/orchestrator/providers/deepAgentsProvider.test.js +32 -4
- package/src/orchestrator/providers/runtimeProviders.js +28 -2
- package/src/orchestrator/providers/runtimeProviders.test.js +48 -0
- package/src/runtime/server.js +2 -3
- package/src/runtime/server.test.js +5 -5
- package/src/runtime/skillChain.e2e.test.js +13 -8
package/README.md
CHANGED
|
@@ -23,11 +23,10 @@ deterministic dispatcher executes bounded tasks with idempotency, bounded
|
|
|
23
23
|
approvals, per-run budgets and automatic recovery after restart. The chat
|
|
24
24
|
stays available while runs execute; additional requests are queued.
|
|
25
25
|
|
|
26
|
-
Scope note: this is a single-user deployment baseline.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
choice with bearer-token and network protection.
|
|
26
|
+
Scope note: this is a single-user deployment baseline. Do not expose the
|
|
27
|
+
runtime as a shared write surface; it binds to `127.0.0.1` by default, and
|
|
28
|
+
`--host 0.0.0.0` must be an explicit deployment choice with bearer-token and
|
|
29
|
+
network protection.
|
|
31
30
|
|
|
32
31
|
---
|
|
33
32
|
|
|
@@ -170,7 +169,7 @@ Projects compared: [OpenWiki](https://github.com/langchain-ai/openwiki),
|
|
|
170
169
|
territory.
|
|
171
170
|
- Serve a true multi-user instance with per-user identity and an attributed
|
|
172
171
|
audit trail. This is a single-user deployment baseline (see the scope note
|
|
173
|
-
above)
|
|
172
|
+
above).
|
|
174
173
|
- Expose a graph-query API over the corpus the way GraphRAG does; retrieval is
|
|
175
174
|
BM25 plus a vector index feeding generation.
|
|
176
175
|
- Ship or host the multi-provider AI gateway — routing to several providers is
|
package/package.json
CHANGED
package/src/cli/wiki-manager.js
CHANGED
|
@@ -702,8 +702,8 @@ async function runHeadless(argv, agent) {
|
|
|
702
702
|
session._onStep = step;
|
|
703
703
|
|
|
704
704
|
let input = prompt;
|
|
705
|
-
//
|
|
706
|
-
//
|
|
705
|
+
// Headless must resolve executable skills through the same runtime resolver
|
|
706
|
+
// as the Shell and serve. Injecting the skill body into a
|
|
707
707
|
// local prompt bypasses the compiler, so a multi-capability skill such as
|
|
708
708
|
// wiki-sync would collapse into a single run here while producing two
|
|
709
709
|
// everywhere else — and the rewritten bodies are business intentions, not
|
|
@@ -396,8 +396,8 @@ test('argument extraction keeps a value the vocabulary allows', async () => {
|
|
|
396
396
|
});
|
|
397
397
|
|
|
398
398
|
test('headless waits for every run of a skill chain, not just the first', async () => {
|
|
399
|
-
//
|
|
400
|
-
//
|
|
399
|
+
// A multi-step user skill compiles into sequential runs: returning as soon as
|
|
400
|
+
// the first finishes would report success before the rest had started.
|
|
401
401
|
const client = chainClient([
|
|
402
402
|
{ controlQueue: [chainItem(0, 'running', { runId: 'run-a' }), chainItem(1, 'queued')] },
|
|
403
403
|
{ controlQueue: [chainItem(0, 'done'), chainItem(1, 'running', { runId: 'run-b' })] },
|
package/src/commands/slash.js
CHANGED
|
@@ -682,6 +682,14 @@ function runtimeProvidersSection(session) {
|
|
|
682
682
|
// never truncates and stays scannable.
|
|
683
683
|
lines.push('capabilities:');
|
|
684
684
|
for (const capability of capabilities) lines.push(` ${capability}`);
|
|
685
|
+
// Configured in agent-runtimes.json but not served by the gateway (the
|
|
686
|
+
// list above is the intersection): say so, one per line, or the operator
|
|
687
|
+
// reads a shorter list without knowing the gateway lost its /config.
|
|
688
|
+
const drift = (session.runtimeProviderDrift ?? []).find((item) => item.runtimeId === runtimeId);
|
|
689
|
+
if (drift && drift.missing.length > 0) {
|
|
690
|
+
lines.push('not served by the gateway:');
|
|
691
|
+
for (const capability of drift.missing) lines.push(` ${capability}`);
|
|
692
|
+
}
|
|
685
693
|
}
|
|
686
694
|
return sectionBlock('Agentic runtime', lines);
|
|
687
695
|
}
|
|
@@ -886,7 +894,7 @@ function componentInstallAction(missingImages) {
|
|
|
886
894
|
return missingImages.length > 0 ? 'downloaded-and-installed-missing-components' : null;
|
|
887
895
|
}
|
|
888
896
|
|
|
889
|
-
export function localizedOperationResult({ operation, target, status = 'succeeded', componentAction = null, images = [], detail = null }) {
|
|
897
|
+
export function localizedOperationResult({ operation, target, status = 'succeeded', componentAction = null, images = [], detail = null, style = 'compact' }) {
|
|
890
898
|
const facts = JSON.stringify({
|
|
891
899
|
operation,
|
|
892
900
|
target,
|
|
@@ -894,15 +902,21 @@ export function localizedOperationResult({ operation, target, status = 'succeede
|
|
|
894
902
|
...(componentAction ? { componentAction, images } : {}),
|
|
895
903
|
...(detail ? { detail } : {}),
|
|
896
904
|
});
|
|
905
|
+
const instructions = style === 'report'
|
|
906
|
+
? [
|
|
907
|
+
'Formule le résultat structuré suivant dans la langue et le ton demandés par le profil du workspace.',
|
|
908
|
+
"Décris ce qui a été fait : ce qui a été démarré, et l'état actuel de chaque service (en marche, en échec ou inconnu).",
|
|
909
|
+
'Réponds en deux ou trois phrases. Ne cite aucune commande, syntaxe shell, chemin de fichier, sortie docker ou identifiant.',
|
|
910
|
+
]
|
|
911
|
+
: [
|
|
912
|
+
'Formule le résultat structuré suivant dans la langue et le ton demandés par le profil du workspace.',
|
|
913
|
+
'Réponds par une seule phrase humaine et naturelle.',
|
|
914
|
+
'Ne mentionne aucune commande, syntaxe shell, étape suivante ou détail technique.',
|
|
915
|
+
];
|
|
897
916
|
return {
|
|
898
917
|
output: facts,
|
|
899
918
|
rawOutput: true,
|
|
900
|
-
agentTrigger: [
|
|
901
|
-
'Formule le résultat structuré suivant dans la langue et le ton demandés par le profil du workspace.',
|
|
902
|
-
'Réponds par une seule phrase humaine et naturelle.',
|
|
903
|
-
'Ne mentionne aucune commande, syntaxe shell, étape suivante ou détail technique.',
|
|
904
|
-
`Résultat: ${facts}`,
|
|
905
|
-
].join('\n'),
|
|
919
|
+
agentTrigger: [...instructions, `Résultat: ${facts}`].join('\n'),
|
|
906
920
|
};
|
|
907
921
|
}
|
|
908
922
|
|
|
@@ -1189,6 +1203,7 @@ export async function handleSlashCommand(line, context) {
|
|
|
1189
1203
|
const startsAgents = service === 'all';
|
|
1190
1204
|
const target = service === 'services' ? undefined : service;
|
|
1191
1205
|
try {
|
|
1206
|
+
let agentsResult = null;
|
|
1192
1207
|
if (startsAgents) {
|
|
1193
1208
|
// Validate the workspace half before mutating the global agents
|
|
1194
1209
|
// stack. Otherwise `/start all` with no active workspace starts the
|
|
@@ -1196,18 +1211,48 @@ export async function handleSlashCommand(line, context) {
|
|
|
1196
1211
|
if (!context.session.workspace || !context.session.workspacePath || !context.session.workspaceEnv?.WORKSPACE_NAME) {
|
|
1197
1212
|
throw new Error('No workspace loaded. Use /use <workspace>.');
|
|
1198
1213
|
}
|
|
1199
|
-
|
|
1214
|
+
agentsResult = await runAgentCommand(startAgents, 'start');
|
|
1200
1215
|
if (agentsResult?.failed) return agentsResult;
|
|
1201
1216
|
}
|
|
1202
1217
|
step(`Services: starting ${target ?? 'workspace services'}…`);
|
|
1203
|
-
const
|
|
1218
|
+
const started = [];
|
|
1219
|
+
const missingImages = await collectMissingImages(step, async (opts) => {
|
|
1220
|
+
const result = await startService(context.session, target, opts);
|
|
1221
|
+
if (Array.isArray(result?.targets)) started.push(...result.targets);
|
|
1222
|
+
});
|
|
1204
1223
|
step('Services: refreshing MCP runtime…');
|
|
1205
1224
|
await refreshMcpRuntimeStatus(context.session);
|
|
1225
|
+
// The report is about what was done, so it names what started and how
|
|
1226
|
+
// each service ended up. A failed state check says so instead of
|
|
1227
|
+
// leaving every state "unknown" without a word.
|
|
1228
|
+
let states = null;
|
|
1229
|
+
try {
|
|
1230
|
+
states = await serviceStates(context.session);
|
|
1231
|
+
} catch (err) {
|
|
1232
|
+
step(`Services: state check after start unavailable — ${rawFailureText(err)}`);
|
|
1233
|
+
}
|
|
1234
|
+
const agentsFacts = agentsResult ? parseJsonText(agentsResult.output) : null;
|
|
1235
|
+
const detail = {
|
|
1236
|
+
...(agentsFacts && typeof agentsFacts === 'object' && !Array.isArray(agentsFacts) ? { agents: agentsFacts } : {}),
|
|
1237
|
+
...(started.length > 0
|
|
1238
|
+
? {
|
|
1239
|
+
services: {
|
|
1240
|
+
started,
|
|
1241
|
+
states: started.map((name) => ({
|
|
1242
|
+
service: name,
|
|
1243
|
+
running: states?.[name] ? states[name].running : null,
|
|
1244
|
+
})),
|
|
1245
|
+
},
|
|
1246
|
+
}
|
|
1247
|
+
: {}),
|
|
1248
|
+
};
|
|
1206
1249
|
return localizedOperationResult({
|
|
1207
1250
|
operation: 'start',
|
|
1208
1251
|
target: startsAgents ? 'all-services-and-agents' : (target || 'workspace-services'),
|
|
1209
1252
|
componentAction: componentInstallAction(missingImages),
|
|
1210
1253
|
images: missingImages,
|
|
1254
|
+
detail: Object.keys(detail).length > 0 ? detail : null,
|
|
1255
|
+
style: 'report',
|
|
1211
1256
|
});
|
|
1212
1257
|
} catch (err) {
|
|
1213
1258
|
step(formatActivityError('services', 'start', err));
|
|
@@ -128,6 +128,34 @@ test('a started agent stack reloads the env and the MCP endpoints the script rew
|
|
|
128
128
|
assert.match(runAgent, /await refreshMcpRuntimeStatus\(context\.session\)/);
|
|
129
129
|
});
|
|
130
130
|
|
|
131
|
+
test('start report style asks Donna to describe what was done, not a bare sentence', () => {
|
|
132
|
+
const result = localizedOperationResult({
|
|
133
|
+
operation: 'start',
|
|
134
|
+
target: 'wiki',
|
|
135
|
+
style: 'report',
|
|
136
|
+
detail: { services: { started: ['wiki'], states: [{ service: 'wiki', running: true }] } },
|
|
137
|
+
});
|
|
138
|
+
assert.deepEqual(JSON.parse(result.output).detail, {
|
|
139
|
+
services: { started: ['wiki'], states: [{ service: 'wiki', running: true }] },
|
|
140
|
+
});
|
|
141
|
+
assert.match(result.agentTrigger, /Décris ce qui a été fait/);
|
|
142
|
+
assert.match(result.agentTrigger, /en marche, en échec ou inconnu/);
|
|
143
|
+
assert.doesNotMatch(result.agentTrigger, /une seule phrase/);
|
|
144
|
+
assert.doesNotMatch(result.agentTrigger, /\/start|Docker|compose/);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('/start captures what actually started and their resulting states', async () => {
|
|
148
|
+
const { readFile } = await import('node:fs/promises');
|
|
149
|
+
const source = await readFile(new URL('./slash.js', import.meta.url), 'utf8');
|
|
150
|
+
const start = source.slice(source.indexOf("case 'start': {"), source.indexOf("case 'stop': {"));
|
|
151
|
+
|
|
152
|
+
assert.match(start, /const started = \[\];/);
|
|
153
|
+
assert.match(start, /result\?\.targets/);
|
|
154
|
+
assert.match(start, /states = await serviceStates\(context\.session\)/);
|
|
155
|
+
assert.match(start, /running: states\?\.\[name\] \? states\[name\]\.running : null/);
|
|
156
|
+
assert.match(start, /style: 'report'/);
|
|
157
|
+
});
|
|
158
|
+
|
|
131
159
|
test('/start completes to the three documented targets', async () => {
|
|
132
160
|
const { completionDescription } = await import('../shell/repl.js');
|
|
133
161
|
const { matches } = completionContext('/start ', {});
|
package/src/core/buildInfo.json
CHANGED
package/src/core/env.js
CHANGED
|
@@ -11,6 +11,13 @@ const TEMPLATE_AUTHORING_CHAT_TOOLS = [
|
|
|
11
11
|
'wiki_outline', 'template_read', 'template_write', 'build_context_write',
|
|
12
12
|
'wiki_read_deliverable',
|
|
13
13
|
];
|
|
14
|
+
// Same additive rule for the packaged cme allow-list: an install scaffolded
|
|
15
|
+
// before the live search tools existed keeps the three legacy reads forever,
|
|
16
|
+
// and /chat can never call cme_confluence_search — the LLM is offered a
|
|
17
|
+
// server with no search and loops. A hand-edited list (not a superset of the
|
|
18
|
+
// legacy default) is the operator's and stays untouched.
|
|
19
|
+
const LEGACY_DEFAULT_CME_CHAT_TOOLS = ['cme_status', 'cme_sources_list', 'cme_export_status'];
|
|
20
|
+
const CME_SEARCH_CHAT_TOOLS = ['cme_confluence_search', 'cme_wiki_search'];
|
|
14
21
|
|
|
15
22
|
export function userManagerDir() {
|
|
16
23
|
return process.cwd();
|
|
@@ -148,17 +155,25 @@ export function ensureManagerScaffold({ log = () => {} } = {}) {
|
|
|
148
155
|
const missingWikiChatTools = migrateWikiChatTools
|
|
149
156
|
? TEMPLATE_AUTHORING_CHAT_TOOLS.filter((tool) => !wikiAllow.includes(tool))
|
|
150
157
|
: [];
|
|
158
|
+
const cmeAllow = current.chatAccess?.servers?.cme?.allow;
|
|
159
|
+
const migrateCmeChatTools = Array.isArray(cmeAllow)
|
|
160
|
+
&& LEGACY_DEFAULT_CME_CHAT_TOOLS.every((tool) => cmeAllow.includes(tool));
|
|
161
|
+
const missingCmeChatTools = migrateCmeChatTools
|
|
162
|
+
? CME_SEARCH_CHAT_TOOLS.filter((tool) => !cmeAllow.includes(tool))
|
|
163
|
+
: [];
|
|
151
164
|
if (missing.length > 0) {
|
|
152
165
|
for (const key of missing) current[key] = example[key];
|
|
153
166
|
}
|
|
154
167
|
for (const key of missingServers) currentServers[key] = exampleServers[key];
|
|
155
168
|
wikiAllow?.push(...missingWikiChatTools);
|
|
156
|
-
|
|
169
|
+
cmeAllow?.push(...missingCmeChatTools);
|
|
170
|
+
const missingChatTools = [...missingWikiChatTools, ...missingCmeChatTools];
|
|
171
|
+
if (missing.length > 0 || missingServers.length > 0 || missingChatTools.length > 0) {
|
|
157
172
|
writeFileSync(endpointsFile, `${JSON.stringify(current, null, 2)}\n`);
|
|
158
173
|
const changes = [
|
|
159
174
|
missing.length > 0 ? `keys: ${missing.join(', ')}` : '',
|
|
160
175
|
missingServers.length > 0 ? `servers: ${missingServers.join(', ')}` : '',
|
|
161
|
-
|
|
176
|
+
missingChatTools.length > 0 ? `chat tools: ${missingChatTools.join(', ')}` : '',
|
|
162
177
|
].filter(Boolean).join('; ');
|
|
163
178
|
created.push(`mcp.endpoints.json ${changes}`);
|
|
164
179
|
}
|
package/src/core/env.test.js
CHANGED
|
@@ -141,6 +141,37 @@ test('scaffold upgrades the packaged wiki chat allow-list with template authorin
|
|
|
141
141
|
});
|
|
142
142
|
});
|
|
143
143
|
|
|
144
|
+
test('scaffold upgrades the packaged cme chat allow-list with the live search tools', () => {
|
|
145
|
+
withTempManagerDir((dir) => {
|
|
146
|
+
const endpointsFile = join(dir, 'mcp.endpoints.json');
|
|
147
|
+
const example = JSON.parse(readFileSync('mcp.endpoints.example.json', 'utf8'));
|
|
148
|
+
example.chatAccess.servers.cme.allow = ['cme_status', 'cme_sources_list', 'cme_export_status'];
|
|
149
|
+
writeFileSync(endpointsFile, JSON.stringify(example, null, 2));
|
|
150
|
+
|
|
151
|
+
const changes = ensureManagerScaffold();
|
|
152
|
+
const after = JSON.parse(readFileSync(endpointsFile, 'utf8'));
|
|
153
|
+
|
|
154
|
+
assert.ok(changes.some((item) => item.includes('cme_confluence_search')));
|
|
155
|
+
assert.deepEqual(after.chatAccess.servers.cme.allow, [
|
|
156
|
+
'cme_status', 'cme_sources_list', 'cme_export_status', 'cme_confluence_search', 'cme_wiki_search',
|
|
157
|
+
]);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('scaffold leaves a custom cme chat allow-list untouched', () => {
|
|
162
|
+
withTempManagerDir((dir) => {
|
|
163
|
+
const endpointsFile = join(dir, 'mcp.endpoints.json');
|
|
164
|
+
writeFileSync(endpointsFile, JSON.stringify({
|
|
165
|
+
mcpServers: {},
|
|
166
|
+
chatAccess: { servers: { cme: { allow: ['cme_status'] } } },
|
|
167
|
+
}, null, 2));
|
|
168
|
+
|
|
169
|
+
ensureManagerScaffold();
|
|
170
|
+
const after = JSON.parse(readFileSync(endpointsFile, 'utf8'));
|
|
171
|
+
assert.deepEqual(after.chatAccess.servers.cme.allow, ['cme_status']);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
144
175
|
test('scaffold leaves a custom wiki chat allow-list untouched', () => {
|
|
145
176
|
withTempManagerDir((dir) => {
|
|
146
177
|
const endpointsFile = join(dir, 'mcp.endpoints.json');
|
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.76';
|
|
5
5
|
|
|
6
6
|
function envValue(key) {
|
|
7
7
|
const filePath = managerEnvFile();
|
|
@@ -756,8 +756,7 @@ export function parseToolCallName(name) {
|
|
|
756
756
|
// tool name, route to it and report `normalized: true`; otherwise return
|
|
757
757
|
// `server: null` with the list of candidate servers so the caller can raise
|
|
758
758
|
// an explicit error. This is name normalization, never fuzzy matching — do
|
|
759
|
-
// not extend it to description/similarity-based selection
|
|
760
|
-
// §20 forbids that).
|
|
759
|
+
// not extend it to description/similarity-based selection.
|
|
761
760
|
export function resolveToolCallName(mcpStatus, name, extraServers = {}) {
|
|
762
761
|
const parsed = parseToolCallName(name);
|
|
763
762
|
if (parsed.server) return { ...parsed, normalized: false, candidates: [] };
|
|
@@ -30,8 +30,8 @@ test('validation rejects technical routing details', () => {
|
|
|
30
30
|
assert.throws(() => validateCompiledObjectives([{ text: 'agent: cme' }]), { code: 'skill_compile_failed' });
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
-
test('
|
|
34
|
-
const expected = { pipeline: 1, 'wiki-ingest': 1, 'wiki-build': 1, deliver: 1, diagnose: 1, status: 1, 'new-template': 1
|
|
33
|
+
test('every shipped scaffold skill compiles to a single intention', async () => {
|
|
34
|
+
const expected = { pipeline: 1, 'wiki-sync': 1, 'wiki-ingest': 1, 'wiki-build': 1, deliver: 1, diagnose: 1, status: 1, 'new-template': 1 };
|
|
35
35
|
for (const [name, count] of Object.entries(expected)) {
|
|
36
36
|
const raw = readFileSync(resolve('../llm-wiki/scaffold/workspace/.wiki/skills', `${name}.md`), 'utf8');
|
|
37
37
|
const { meta, body } = parseFrontmatter(raw);
|
|
@@ -53,7 +53,7 @@ test('every objective of a chain carries the user parameters, not just the last'
|
|
|
53
53
|
// Appending the parameters before splitting attached `source` to the ingest
|
|
54
54
|
// step and left the export step — the one that consumes it — without it.
|
|
55
55
|
const skill = {
|
|
56
|
-
name: '
|
|
56
|
+
name: 'collect-then-ingest',
|
|
57
57
|
params: ['source'],
|
|
58
58
|
body: 'Export the requested source.\n\nThen ingest what was exported.',
|
|
59
59
|
};
|
package/src/core/startupCheck.js
CHANGED
|
@@ -357,13 +357,22 @@ export async function checkAgenticRuntimes({
|
|
|
357
357
|
for (const { id, provider } of providers) {
|
|
358
358
|
try {
|
|
359
359
|
const description = await provider.describe();
|
|
360
|
+
const down = description.health === 'unavailable';
|
|
361
|
+
// Ask the runtime what it serves — describe() alone reflects /health,
|
|
362
|
+
// not the capability set, and a gateway that lost its config answers
|
|
363
|
+
// healthy with one default capability.
|
|
364
|
+
const capabilities = down ? [] : await provider.discoverCapabilities();
|
|
365
|
+
const missing = provider?.lastDiscovery?.missing ?? [];
|
|
360
366
|
runtimes.push({
|
|
361
367
|
name: id,
|
|
362
|
-
status:
|
|
363
|
-
capabilities: (
|
|
368
|
+
status: down ? 'failed' : 'connected',
|
|
369
|
+
capabilities: (capabilities ?? [])
|
|
364
370
|
.map((capability) => capability?.name ?? capability?.id ?? '')
|
|
365
371
|
.filter(Boolean),
|
|
366
|
-
|
|
372
|
+
...(missing.length > 0 ? { missingCapabilities: [...missing] } : {}),
|
|
373
|
+
reason: down
|
|
374
|
+
? (description.error ?? 'unavailable')
|
|
375
|
+
: (missing.length > 0 ? `does not serve configured: ${missing.join(', ')}` : null),
|
|
367
376
|
});
|
|
368
377
|
} catch (err) {
|
|
369
378
|
runtimes.push({ name: id, status: 'failed', reason: commandError(err) });
|
|
@@ -327,3 +327,31 @@ test('checkAgenticRuntimes reports a down runtime as failed', async () => {
|
|
|
327
327
|
assert.equal(result.context.runtimes[0].status, 'failed');
|
|
328
328
|
assert.match(result.detail, /pending/);
|
|
329
329
|
});
|
|
330
|
+
|
|
331
|
+
test('checkAgenticRuntimes asks the runtime what it serves and reports configured capabilities it does not', async () => {
|
|
332
|
+
const provider = {
|
|
333
|
+
runtime: 'deepagents',
|
|
334
|
+
lastDiscovery: null,
|
|
335
|
+
async describe() { return { runtime: 'deepagents', health: 'available', version: 't', protocolVersion: '1' }; },
|
|
336
|
+
async discoverCapabilities() {
|
|
337
|
+
this.lastDiscovery = { served: ['agent.review'], configured: ['agent.review', 'agent.notify'], missing: ['agent.notify'], extra: [] };
|
|
338
|
+
return [{ name: 'agent.review', operations: ['run'] }];
|
|
339
|
+
},
|
|
340
|
+
async execute() { throw new Error('unused'); },
|
|
341
|
+
async status() { throw new Error('unused'); },
|
|
342
|
+
async cancel() {},
|
|
343
|
+
async approve() {},
|
|
344
|
+
subscribe() { return () => {}; },
|
|
345
|
+
};
|
|
346
|
+
const result = await checkAgenticRuntimes({
|
|
347
|
+
loadConfig: () => [{ id: 'deepagents', type: 'deepagents', enabled: true }],
|
|
348
|
+
resolve: () => ({ providers: [{ id: 'deepagents', type: 'deepagents', provider }], skipped: [] }),
|
|
349
|
+
env: {},
|
|
350
|
+
});
|
|
351
|
+
assert.equal(result.ok, true);
|
|
352
|
+
const runtime = result.context.runtimes[0];
|
|
353
|
+
assert.equal(runtime.status, 'connected');
|
|
354
|
+
assert.deepEqual(runtime.capabilities, ['agent.review'], 'the served set, not the configured one');
|
|
355
|
+
assert.deepEqual(runtime.missingCapabilities, ['agent.notify']);
|
|
356
|
+
assert.match(runtime.reason, /does not serve configured: agent\.notify/);
|
|
357
|
+
});
|
package/src/core/workflow.js
CHANGED
|
@@ -14,13 +14,11 @@ import { isTerminal } from '../orchestrator/taskStatuses.js';
|
|
|
14
14
|
|
|
15
15
|
const RUNNING_STATUSES = new Set(['running', 'starting', 'queued', 'waiting', 'pending_approval']);
|
|
16
16
|
|
|
17
|
-
// Canonical workflow projection
|
|
17
|
+
// Canonical workflow projection.
|
|
18
18
|
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
// for Serve and ShellTUI. Future releases can move the reducer internals behind
|
|
23
|
-
// this module without changing UI contracts.
|
|
19
|
+
// projectWorkflow consumes the existing event-sourced agentProjection instead
|
|
20
|
+
// of replacing it: agentProjection remains the compatibility reducer/hydration
|
|
21
|
+
// format; workflow is the canonical read model for Serve and ShellTUI.
|
|
24
22
|
export function projectWorkflow(state = {}, events = []) {
|
|
25
23
|
const run = currentRun(state, events);
|
|
26
24
|
const plan = Array.isArray(state.plan) ? state.plan : [];
|
|
@@ -92,7 +92,7 @@ test('resolveObjective disambiguates "export" of a Confluence source via alias,
|
|
|
92
92
|
assert.equal(result.provider.agentInstanceId, 'cme-1');
|
|
93
93
|
});
|
|
94
94
|
|
|
95
|
-
test('resolveObjective resolves
|
|
95
|
+
test('resolveObjective resolves an ingest objective deterministically despite notification and guardrails', async () => {
|
|
96
96
|
const session = sessionWith([
|
|
97
97
|
provider('production-1', knowledge),
|
|
98
98
|
provider('production-2', publish),
|
|
@@ -56,7 +56,10 @@ export function createDeepAgentsProvider({
|
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
const provider = {
|
|
60
|
+
// Filled by discoverCapabilities: what the gateway served vs what the
|
|
61
|
+
// config declares. `null` until the first discovery.
|
|
62
|
+
lastDiscovery: null,
|
|
60
63
|
async describe() {
|
|
61
64
|
let health = 'available';
|
|
62
65
|
let runtimeVersion = version ?? null;
|
|
@@ -77,10 +80,33 @@ export function createDeepAgentsProvider({
|
|
|
77
80
|
...(lastError ? { error: lastError } : {}),
|
|
78
81
|
};
|
|
79
82
|
},
|
|
83
|
+
// The gateway is ALWAYS asked what it serves. The static config (the
|
|
84
|
+
// manager's agent-runtimes.json entry) carries the richer metadata the
|
|
85
|
+
// resolver routes on — mutationClass, aliases, aliasOperations — but it is
|
|
86
|
+
// a declaration, not an observation: a gateway that lost its /config
|
|
87
|
+
// mount degrades to its built-in default and the manager, trusting the
|
|
88
|
+
// config alone, kept listing eight capabilities it could not govern.
|
|
89
|
+
// Only the capabilities BOTH declare are offered; the difference is
|
|
90
|
+
// exposed as `lastDiscovery` so /status and the runtime log can say why a
|
|
91
|
+
// configured capability is missing.
|
|
80
92
|
async discoverCapabilities() {
|
|
81
|
-
if (Array.isArray(capabilities)) return capabilities;
|
|
82
93
|
const list = await httpJson('/capabilities');
|
|
83
|
-
|
|
94
|
+
const served = (Array.isArray(list) ? list : [])
|
|
95
|
+
.filter((item) => item && typeof item === 'object' && typeof item.name === 'string' && item.name.trim());
|
|
96
|
+
const servedNames = served.map((item) => item.name);
|
|
97
|
+
if (!Array.isArray(capabilities)) {
|
|
98
|
+
provider.lastDiscovery = { served: servedNames, configured: null, missing: [], extra: [] };
|
|
99
|
+
return served;
|
|
100
|
+
}
|
|
101
|
+
const configuredNames = capabilities.map((item) => String(item?.name ?? ''));
|
|
102
|
+
const offered = capabilities.filter((item) => servedNames.includes(String(item?.name ?? '')));
|
|
103
|
+
provider.lastDiscovery = {
|
|
104
|
+
served: servedNames,
|
|
105
|
+
configured: configuredNames,
|
|
106
|
+
missing: configuredNames.filter((name) => !servedNames.includes(name)),
|
|
107
|
+
extra: servedNames.filter((name) => !configuredNames.includes(name)),
|
|
108
|
+
};
|
|
109
|
+
return offered;
|
|
84
110
|
},
|
|
85
111
|
async execute(request = {}) {
|
|
86
112
|
const accepted = await httpJson('/runs', {
|
|
@@ -165,4 +191,5 @@ export function createDeepAgentsProvider({
|
|
|
165
191
|
return () => controller.abort();
|
|
166
192
|
},
|
|
167
193
|
};
|
|
194
|
+
return provider;
|
|
168
195
|
}
|
|
@@ -74,16 +74,44 @@ test('discoverCapabilities fetches /capabilities', async () => {
|
|
|
74
74
|
assert.deepEqual(await provider.discoverCapabilities(), [{ name: 'agent.review', operations: ['run'] }]);
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
-
test('discoverCapabilities
|
|
78
|
-
const fetchImpl = mockFetch({
|
|
77
|
+
test('discoverCapabilities keeps the configured metadata but only for capabilities the gateway serves', async () => {
|
|
78
|
+
const fetchImpl = mockFetch({
|
|
79
|
+
'GET /capabilities': () => jsonResponse(200, [
|
|
80
|
+
{ name: 'agent.review', operations: ['run'] },
|
|
81
|
+
{ name: 'agent.notify', operations: ['run'] },
|
|
82
|
+
]),
|
|
83
|
+
});
|
|
79
84
|
const provider = createDeepAgentsProvider({
|
|
80
85
|
endpoint: 'http://agent-runtime:8080',
|
|
81
|
-
capabilities: [
|
|
86
|
+
capabilities: [
|
|
87
|
+
{ name: 'agent.review', operations: ['run'], aliases: ['audit'] },
|
|
88
|
+
{ name: 'agent.research', operations: ['run'], mutationClass: 'ingest' },
|
|
89
|
+
{ name: 'agent.notify', operations: ['run'], defaultRequiresApproval: true },
|
|
90
|
+
],
|
|
82
91
|
fetchImpl,
|
|
83
92
|
});
|
|
84
93
|
|
|
94
|
+
const offered = await provider.discoverCapabilities();
|
|
95
|
+
assert.equal(fetchImpl.calls.length, 1, 'the gateway is always asked what it serves');
|
|
96
|
+
assert.deepEqual(offered.map((item) => item.name), ['agent.review', 'agent.notify']);
|
|
97
|
+
assert.equal(offered[0].aliases[0], 'audit', 'the configured metadata is what the resolver routes on');
|
|
98
|
+
assert.deepEqual(provider.lastDiscovery, {
|
|
99
|
+
served: ['agent.review', 'agent.notify'],
|
|
100
|
+
configured: ['agent.review', 'agent.research', 'agent.notify'],
|
|
101
|
+
missing: ['agent.research'],
|
|
102
|
+
extra: [],
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('discoverCapabilities with no static config serves the gateway list as-is', async () => {
|
|
107
|
+
const fetchImpl = mockFetch({
|
|
108
|
+
'GET /capabilities': () => jsonResponse(200, [{ name: 'agent.review', operations: ['run'] }]),
|
|
109
|
+
});
|
|
110
|
+
const provider = createDeepAgentsProvider({ endpoint: 'http://agent-runtime:8080', fetchImpl });
|
|
111
|
+
|
|
85
112
|
assert.deepEqual(await provider.discoverCapabilities(), [{ name: 'agent.review', operations: ['run'] }]);
|
|
86
|
-
assert.
|
|
113
|
+
assert.deepEqual(provider.lastDiscovery.missing, []);
|
|
114
|
+
assert.equal(provider.lastDiscovery.configured, null);
|
|
87
115
|
});
|
|
88
116
|
|
|
89
117
|
test('execute POSTs /runs and returns the runId', async () => {
|
|
@@ -24,6 +24,11 @@ export async function discoverRuntimeProviderAgents(runtimeProviders) {
|
|
|
24
24
|
: (runtimeProviders?.list?.() ?? []);
|
|
25
25
|
const agents = [];
|
|
26
26
|
const unavailable = [];
|
|
27
|
+
// Configured-but-not-served capabilities, per runtime (deepagents providers
|
|
28
|
+
// fill `lastDiscovery`). A drift is not an outage: the runtime stays
|
|
29
|
+
// available with the capabilities it really serves, and the difference is
|
|
30
|
+
// reported so an operator can see WHY agent.research is not routable.
|
|
31
|
+
const drift = [];
|
|
27
32
|
|
|
28
33
|
for (const entry of providers) {
|
|
29
34
|
const provider = entry?.provider ?? entry;
|
|
@@ -53,9 +58,13 @@ export async function discoverRuntimeProviderAgents(runtimeProviders) {
|
|
|
53
58
|
for (const capability of capabilities ?? []) {
|
|
54
59
|
agents.push(runtimeProviderAgent(runtimeId, provider, description, capability, health));
|
|
55
60
|
}
|
|
61
|
+
const discovery = provider?.lastDiscovery;
|
|
62
|
+
if (discovery && Array.isArray(discovery.missing) && discovery.missing.length > 0) {
|
|
63
|
+
drift.push({ runtimeId, missing: [...discovery.missing], served: [...(discovery.served ?? [])] });
|
|
64
|
+
}
|
|
56
65
|
}
|
|
57
66
|
|
|
58
|
-
return { agents, unavailable };
|
|
67
|
+
return { agents, unavailable, drift };
|
|
59
68
|
}
|
|
60
69
|
|
|
61
70
|
/**
|
|
@@ -254,7 +263,10 @@ export async function discoverRuntimeProvidersOnce(session, {
|
|
|
254
263
|
},
|
|
255
264
|
});
|
|
256
265
|
const resolved = resolveRuntimeProviders(entries);
|
|
257
|
-
const { agents, unavailable } = await discoverRuntimeProviderAgents(resolved.providers);
|
|
266
|
+
const { agents, unavailable, drift } = await discoverRuntimeProviderAgents(resolved.providers);
|
|
267
|
+
// Exposed for /status: which configured capabilities the runtime does not
|
|
268
|
+
// serve right now. Replaced on every scan — a recovered gateway clears it.
|
|
269
|
+
session.runtimeProviderDrift = drift;
|
|
258
270
|
|
|
259
271
|
// A failed probe is not a lost agent (same rule as agentRegistry): a runtime
|
|
260
272
|
// that reported unavailable this round but for which we hold a last-known-good
|
|
@@ -330,6 +342,20 @@ export async function discoverRuntimeProvidersOnce(session, {
|
|
|
330
342
|
: `agent-runtimes: ${item.runtimeId} unavailable (${item.error})`);
|
|
331
343
|
}
|
|
332
344
|
}
|
|
345
|
+
// Same edge-triggered rule for a capability drift: announced when it
|
|
346
|
+
// appears (or changes), silent while it persists, announced again after a
|
|
347
|
+
// recovery.
|
|
348
|
+
session._runtimeProviderDriftAnnounced ??= new Map();
|
|
349
|
+
const announced = session._runtimeProviderDriftAnnounced;
|
|
350
|
+
const currentDrift = new Map(drift.map((item) => [item.runtimeId, item.missing.join(',')]));
|
|
351
|
+
for (const item of drift) {
|
|
352
|
+
if (announced.get(item.runtimeId) === currentDrift.get(item.runtimeId)) continue;
|
|
353
|
+
dispatchRuntimeLog(session, `agent-runtimes: ${item.runtimeId} does not serve ${item.missing.length} configured capabilit${item.missing.length === 1 ? 'y' : 'ies'} (${item.missing.join(', ')}) — it serves ${item.served.join(', ') || 'nothing'}; check the gateway's /config mount (agent-runtimes.json)`);
|
|
354
|
+
}
|
|
355
|
+
for (const id of [...announced.keys()]) {
|
|
356
|
+
if (!currentDrift.has(id)) announced.delete(id);
|
|
357
|
+
}
|
|
358
|
+
for (const [id, key] of currentDrift) announced.set(id, key);
|
|
333
359
|
session._runtimeProviderDown = currentDown;
|
|
334
360
|
return effectiveAgents;
|
|
335
361
|
}
|
|
@@ -265,6 +265,54 @@ test('discoverRuntimeProvidersOnce announces a down runtime only once across re-
|
|
|
265
265
|
assert.equal(logs.length, 1, 'the degradation is announced once, not on every re-scan');
|
|
266
266
|
});
|
|
267
267
|
|
|
268
|
+
test('a gateway serving fewer capabilities than configured is a drift: offered = intersection, announced once, cleared on recovery', async () => {
|
|
269
|
+
const session = sessionWithEvents();
|
|
270
|
+
const config = [{
|
|
271
|
+
id: 'deepagents', type: 'deepagents', endpoint: 'http://127.0.0.1:7789', enabled: true,
|
|
272
|
+
capabilities: [
|
|
273
|
+
{ name: 'agent.review', operations: ['run'] },
|
|
274
|
+
{ name: 'agent.research', operations: ['run'], mutationClass: 'ingest' },
|
|
275
|
+
{ name: 'agent.notify', operations: ['run'], defaultRequiresApproval: true },
|
|
276
|
+
],
|
|
277
|
+
}];
|
|
278
|
+
const originalFetch = globalThis.fetch;
|
|
279
|
+
let served = [{ name: 'agent.review', operations: ['run'] }];
|
|
280
|
+
globalThis.fetch = async (url) => {
|
|
281
|
+
if (String(url).includes('/health')) return new Response(JSON.stringify({ ok: true, version: 't' }), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
282
|
+
if (String(url).includes('/capabilities')) return new Response(JSON.stringify(served), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
283
|
+
throw new Error('unexpected probe ' + String(url));
|
|
284
|
+
};
|
|
285
|
+
try {
|
|
286
|
+
await discoverRuntimeProvidersOnce(session, { config });
|
|
287
|
+
await discoverRuntimeProvidersOnce(session, { config });
|
|
288
|
+
const driftLogs = () => (session.agentEvents ?? []).filter((event) => event.type === 'runtime_log'
|
|
289
|
+
&& String(event.payload?.message ?? '').includes('does not serve'));
|
|
290
|
+
|
|
291
|
+
// Only what the gateway serves is routable — a governed capability the
|
|
292
|
+
// gateway would run ungoverned is not offered at all.
|
|
293
|
+
assert.deepEqual(session.runtimeProviderAgents.map((agent) => agent.agentInstanceId), ['deepagents::agent.review']);
|
|
294
|
+
assert.deepEqual(session.runtimeProviderDrift, [{
|
|
295
|
+
runtimeId: 'deepagents', missing: ['agent.research', 'agent.notify'], served: ['agent.review'],
|
|
296
|
+
}]);
|
|
297
|
+
assert.equal(driftLogs().length, 1, 'announced once across re-scans');
|
|
298
|
+
assert.match(String(driftLogs()[0].payload.message), /does not serve 2 configured capabilities \(agent\.research, agent\.notify\)/);
|
|
299
|
+
assert.match(String(driftLogs()[0].payload.message), /\/config mount/);
|
|
300
|
+
|
|
301
|
+
// The gateway recovers its config: no drift, all three routable.
|
|
302
|
+
served = config[0].capabilities.map(({ name, operations }) => ({ name, operations }));
|
|
303
|
+
await discoverRuntimeProvidersOnce(session, { config });
|
|
304
|
+
assert.equal(session.runtimeProviderAgents.length, 3);
|
|
305
|
+
assert.deepEqual(session.runtimeProviderDrift, []);
|
|
306
|
+
|
|
307
|
+
// …and a later drift is announced again.
|
|
308
|
+
served = [{ name: 'agent.review', operations: ['run'] }];
|
|
309
|
+
await discoverRuntimeProvidersOnce(session, { config });
|
|
310
|
+
assert.equal(driftLogs().length, 2);
|
|
311
|
+
} finally {
|
|
312
|
+
globalThis.fetch = originalFetch;
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
|
|
268
316
|
test('an HTTP 401 gateway probe is announced as not-ready with the ⚠ glyph, not as a failure', async () => {
|
|
269
317
|
const session = sessionWithEvents();
|
|
270
318
|
const originalFetch = globalThis.fetch;
|
package/src/runtime/server.js
CHANGED
|
@@ -1422,9 +1422,8 @@ function rejectPlanPatch(context, store, patchId, reason) {
|
|
|
1422
1422
|
};
|
|
1423
1423
|
}
|
|
1424
1424
|
|
|
1425
|
-
// Classifier for control
|
|
1426
|
-
// LLM-backed
|
|
1427
|
-
// is that, now: the only deterministic matches left are the runtime's own
|
|
1425
|
+
// Classifier for the control lane's free-text messages. The classification is
|
|
1426
|
+
// LLM-backed: the only deterministic matches left are the runtime's own
|
|
1428
1427
|
// control verbs (cancel, an explicit "later/queue", status and plan-change
|
|
1429
1428
|
// wording). Deciding "is this a NEW task to queue vs plain conversation" is a
|
|
1430
1429
|
// semantic judgement about the workspace's domain, so it is never a keyword
|
|
@@ -1606,7 +1606,7 @@ test('runtime server handle drains a pre-existing hydrated control request', asy
|
|
|
1606
1606
|
test('POST /run compiles a workspace skill into a sequential runtime chain', async (t) => {
|
|
1607
1607
|
const root = mkdtempSync(join(tmpdir(), 'runtime-skill-'));
|
|
1608
1608
|
mkdirSync(join(root, '.wiki', 'skills'), { recursive: true });
|
|
1609
|
-
writeFileSync(join(root, '.wiki', 'skills', '
|
|
1609
|
+
writeFileSync(join(root, '.wiki', 'skills', 'sync-ingest.md'), '---\nname: sync-ingest\nparams:\n - source\n---\nExport the source.\n\nThen ingest the files.');
|
|
1610
1610
|
const session = { workspace: 'acme', workspacePath: root, controlQueue: [] };
|
|
1611
1611
|
const context = { workspace: 'acme', session, running: false, currentAbortController: null };
|
|
1612
1612
|
let startedBody = null;
|
|
@@ -1623,7 +1623,7 @@ test('POST /run compiles a workspace skill into a sequential runtime chain', asy
|
|
|
1623
1623
|
throw err;
|
|
1624
1624
|
}
|
|
1625
1625
|
try {
|
|
1626
|
-
const response = await fetch(`http://127.0.0.1:${handle.port}/run?workspace=acme`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ input: '/
|
|
1626
|
+
const response = await fetch(`http://127.0.0.1:${handle.port}/run?workspace=acme`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ input: '/sync-ingest docs' }) });
|
|
1627
1627
|
const body = await response.json();
|
|
1628
1628
|
assert.equal(response.status, 202);
|
|
1629
1629
|
assert.equal(body.kind, 'skill_chain');
|
|
@@ -1633,15 +1633,15 @@ test('POST /run compiles a workspace skill into a sequential runtime chain', asy
|
|
|
1633
1633
|
assert.equal(session.controlQueue[1].status, 'queued');
|
|
1634
1634
|
assert.equal(session.controlQueue[0].chainId, session.controlQueue[1].chainId);
|
|
1635
1635
|
assert.equal('capabilityPlan' in session.controlQueue[0], false);
|
|
1636
|
-
assert.equal(session.controlQueue[0].input, '/
|
|
1636
|
+
assert.equal(session.controlQueue[0].input, '/sync-ingest source="docs"');
|
|
1637
1637
|
assert.equal(session.controlQueue[0].skillExecution, 'orchestrated');
|
|
1638
1638
|
assert.match(startedBody.input, /Export the source/);
|
|
1639
|
-
assert.equal(startedBody.publicInput, '/
|
|
1639
|
+
assert.equal(startedBody.publicInput, '/sync-ingest source="docs"');
|
|
1640
1640
|
assert.equal(startedBody.skillChain.execution, 'orchestrated');
|
|
1641
1641
|
assert.equal(startedBody.requireApproval, true);
|
|
1642
1642
|
assert.notEqual(startedBody.autoApprove, true);
|
|
1643
1643
|
assert.deepEqual(session.agentProjection.conversation, [
|
|
1644
|
-
{ role: 'user', content: '/
|
|
1644
|
+
{ role: 'user', content: '/sync-ingest docs' },
|
|
1645
1645
|
]);
|
|
1646
1646
|
assert.doesNotMatch(JSON.stringify(session.agentEvents), /Export the source|Then ingest the files/);
|
|
1647
1647
|
} finally {
|
|
@@ -132,26 +132,31 @@ test('E2E-001 pipeline: one objective, one run, internal plan left untouched', a
|
|
|
132
132
|
assert.equal(env.chain().length, 1);
|
|
133
133
|
});
|
|
134
134
|
|
|
135
|
-
test('E2E-002
|
|
136
|
-
|
|
135
|
+
test('E2E-002 multi-step skill: two objectives, two ordered runs, one chainId', async (t) => {
|
|
136
|
+
// No shipped skill splits any more (each is one intention); a user-authored
|
|
137
|
+
// body with a strong connector still must.
|
|
138
|
+
const env = await harness(t, {
|
|
139
|
+
skills: {
|
|
140
|
+
'sync-ingest': '---\nname: sync-ingest\nparams: []\n---\nExport every configured Confluence source.\n\nThen ingest the exported Markdown into the wiki.',
|
|
141
|
+
},
|
|
142
|
+
});
|
|
137
143
|
if (!env) return;
|
|
138
144
|
|
|
139
|
-
const { body } = await env.post('/run?workspace=acme', { input: '/
|
|
145
|
+
const { body } = await env.post('/run?workspace=acme', { input: '/sync-ingest' });
|
|
140
146
|
await env.settle();
|
|
141
147
|
|
|
142
148
|
assert.equal(body.objectives, 2);
|
|
143
149
|
assert.equal(env.runs.length, 2, 'the second objective must run after the first');
|
|
144
150
|
assert.match(env.runs[0].input, /^Export every configured Confluence source/);
|
|
145
|
-
assert.match(env.runs[1].input, /^
|
|
146
|
-
//
|
|
147
|
-
// no run may receive a source selector (the export step must stay "all").
|
|
151
|
+
assert.match(env.runs[1].input, /^Ingest the exported Markdown/);
|
|
152
|
+
// The skill carries no parameter, so no run may receive a selector.
|
|
148
153
|
for (const run of env.runs) assert.doesNotMatch(run.input, /User parameters:/);
|
|
149
154
|
const items = env.chain();
|
|
150
155
|
assert.equal(items.length, 2);
|
|
151
156
|
assert.equal(items[0].chainId, items[1].chainId);
|
|
152
157
|
assert.equal(items[0].chainId, body.chainId);
|
|
153
158
|
assert.deepEqual(items.map((item) => item.status), ['done', 'done']);
|
|
154
|
-
assert.deepEqual(items.map((item) => item.skillName), ['
|
|
159
|
+
assert.deepEqual(items.map((item) => item.skillName), ['sync-ingest', 'sync-ingest']);
|
|
155
160
|
});
|
|
156
161
|
|
|
157
162
|
test('E2E-002b parameters reach every objective of a multi-step skill', async (t) => {
|
|
@@ -220,13 +225,13 @@ test('E2E-003 cancel: the running step and its chain stop, unrelated queue survi
|
|
|
220
225
|
// that silently fragments would show up as extra runs, not as extra objectives.
|
|
221
226
|
const PERFORMANCE_TABLE = {
|
|
222
227
|
pipeline: 1,
|
|
228
|
+
'wiki-sync': 1,
|
|
223
229
|
'wiki-ingest': 1,
|
|
224
230
|
'wiki-build': 1,
|
|
225
231
|
deliver: 1,
|
|
226
232
|
diagnose: 1,
|
|
227
233
|
status: 1,
|
|
228
234
|
'new-template': 1,
|
|
229
|
-
'wiki-sync': 2,
|
|
230
235
|
};
|
|
231
236
|
|
|
232
237
|
for (const [name, expected] of Object.entries(PERFORMANCE_TABLE)) {
|