@dotdrelle/wiki-manager 0.15.72 → 0.15.74
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 +8 -0
- package/mcp.endpoints.example.json +1 -1
- package/package.json +2 -2
- package/src/commands/slash.js +8 -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 +1 -1
- package/src/core/runtimeLog.js +4 -1
- package/src/core/runtimeLog.test.js +10 -0
- package/src/core/skillChainView.test.js +1 -1
- package/src/core/skillCompiler.js +5 -4
- package/src/core/startupCheck.js +12 -3
- package/src/core/startupCheck.test.js +28 -0
- package/src/core/wikiWorkspaceStart.test.js +36 -0
- 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 +42 -2
- package/src/orchestrator/providers/runtimeProviders.test.js +112 -0
- package/src/runtime/skillChain.e2e.test.js +24 -6
- package/src/shell/LeftPane.tsx +14 -7
- package/src/shell/RightPane.tsx +13 -1
- package/src/shell/repl.test.js +27 -0
- package/src/shell/tui.tsx +16 -11
- package/src/shell/useSession.ts +4 -1
- package/wiki-workspace +55 -0
package/README.md
CHANGED
|
@@ -310,6 +310,14 @@ wiki-workspace agents up # start cme, documents…
|
|
|
310
310
|
wiki-workspace agents status # check they respond
|
|
311
311
|
```
|
|
312
312
|
|
|
313
|
+
One command starts the whole deployment — agent-runtime, agents and every
|
|
314
|
+
configured workspace — and is safe to repeat while things are already up:
|
|
315
|
+
|
|
316
|
+
```bash
|
|
317
|
+
wiki-workspace start # runtime + agents + all workspaces
|
|
318
|
+
wiki-workspace start --open # …and open the first workspace chat
|
|
319
|
+
```
|
|
320
|
+
|
|
313
321
|
**Step 4 — Move to `wiki-manager` or `serve`.**
|
|
314
322
|
Two entry doors, your choice:
|
|
315
323
|
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"servers": {
|
|
32
32
|
"llm-wiki": { "allow": ["help_list", "help_read", "help_search", "wiki_workspace_status", "wiki_list_pages", "wiki_read_page", "wiki_read_pages", "wiki_search_context", "wiki_collect_context", "wiki_read_ingested_source", "wiki_outline", "template_read", "template_write", "build_context_write", "wiki_read_deliverable"] },
|
|
33
33
|
"wiki-production": { "allow": ["production_job_status", "production_jobs_list"] },
|
|
34
|
-
"cme": { "allow": ["cme_status", "cme_sources_list", "cme_export_status"] }
|
|
34
|
+
"cme": { "allow": ["cme_status", "cme_sources_list", "cme_export_status", "cme_confluence_search", "cme_wiki_search"] }
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotdrelle/wiki-manager",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.74",
|
|
4
4
|
"description": "Agentic shell and orchestration cockpit for llm-wiki workspaces.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
},
|
|
20
20
|
"scripts": {
|
|
21
21
|
"start": "bun ./bin/wiki-manager.js",
|
|
22
|
-
"test": "node --test src/core/skillInvocation.test.js src/core/skillCompiler.test.js src/runtime/skillRun.test.js src/runtime/controlDrain.test.js src/runtime/controlCancellation.test.js src/cli/runtimeStartup.test.js src/cli/wiki-manager.test.js src/agent/graph.test.js src/agent/skillRecursion.test.js src/contracts/schemas.test.js src/core/activity.test.js src/core/env.test.js src/core/agentsCompose.test.js src/core/profileServiceStatus.test.js src/core/workspaceProfile.test.js src/core/buildInfo.test.js src/core/agentEvents.test.js src/core/skillChainView.test.js src/core/runtimeLog.test.js src/core/runtimeEventAdapter.test.js src/activity/activityAggregator.test.js src/graph/runGraphProjector.test.js src/core/workflow.test.js src/core/planPatch.test.js src/core/agentLoop.test.js src/core/plan.test.js src/core/mcp.test.js src/core/toolLoop.test.js src/core/documentIntake.test.js src/core/dockerCompose.test.js src/core/otherWorkspacesRunning.test.js src/core/wikiSetup.test.js src/core/wikiWorkspace.test.js src/core/wikirc.test.js src/core/workspaceInherit.test.js src/core/cacert.test.js src/core/composeOverrides.test.js src/core/setEnvValue.test.js src/core/commandFailure.test.js src/core/currentArtifact.test.js src/core/googleGrants.test.js src/core/modelFetch.test.js src/core/startupCheck.test.js src/core/queueStore.test.js src/orchestrator/agentRegistry.test.js src/orchestrator/capabilityRegistry.test.js src/orchestrator/capabilityResolver.test.js src/orchestrator/planValidator.test.js src/orchestrator/planIntegrator.test.js src/orchestrator/taskStatuses.test.js src/orchestrator/scheduler.test.js src/orchestrator/attemptManager.test.js src/orchestrator/resultAggregator.test.js src/orchestrator/approvalPolicy.test.js src/orchestrator/dispatcher.test.js src/orchestrator/objectiveResolver.test.js src/orchestrator/providers/fakeRuntimeProvider.test.js src/orchestrator/providers/runtimeProviders.test.js src/orchestrator/providers/dispatcherExternalRuntime.test.js src/orchestrator/providers/deepAgentsProvider.test.js src/commands/slash.test.js src/shell/repl.test.js src/shell/setupWizardModality.test.js src/shell/setupWizardPlaceholders.test.js src/shell/setupWizardSuggestions.test.js src/shell/setupWizardDiscovery.test.js src/shell/wrapText.test.js src/runtime/lifecycle.test.js src/runtime/store.test.js src/runtime/workspaceIsolation.test.js src/runtime/controlMessages.test.js src/runtime/recoveryManager.test.js src/runtime/server.test.js src/runtime/supervisor.test.js src/runtime/delegation.test.js src/runtime/runner.test.js src/runtime/runner.e2e.test.js src/runtime/skillChain.e2e.test.js src/runtime/donna-contract.test.js src/runtime/approvals.test.js src/runtime/auth.test.js",
|
|
22
|
+
"test": "node --test src/core/skillInvocation.test.js src/core/skillCompiler.test.js src/runtime/skillRun.test.js src/runtime/controlDrain.test.js src/runtime/controlCancellation.test.js src/cli/runtimeStartup.test.js src/cli/wiki-manager.test.js src/agent/graph.test.js src/agent/skillRecursion.test.js src/contracts/schemas.test.js src/core/activity.test.js src/core/env.test.js src/core/agentsCompose.test.js src/core/profileServiceStatus.test.js src/core/workspaceProfile.test.js src/core/buildInfo.test.js src/core/agentEvents.test.js src/core/skillChainView.test.js src/core/runtimeLog.test.js src/core/runtimeEventAdapter.test.js src/activity/activityAggregator.test.js src/graph/runGraphProjector.test.js src/core/workflow.test.js src/core/planPatch.test.js src/core/agentLoop.test.js src/core/plan.test.js src/core/mcp.test.js src/core/toolLoop.test.js src/core/documentIntake.test.js src/core/dockerCompose.test.js src/core/otherWorkspacesRunning.test.js src/core/wikiSetup.test.js src/core/wikiWorkspace.test.js src/core/wikiWorkspaceStart.test.js src/core/wikirc.test.js src/core/workspaceInherit.test.js src/core/cacert.test.js src/core/composeOverrides.test.js src/core/setEnvValue.test.js src/core/commandFailure.test.js src/core/currentArtifact.test.js src/core/googleGrants.test.js src/core/modelFetch.test.js src/core/startupCheck.test.js src/core/queueStore.test.js src/orchestrator/agentRegistry.test.js src/orchestrator/capabilityRegistry.test.js src/orchestrator/capabilityResolver.test.js src/orchestrator/planValidator.test.js src/orchestrator/planIntegrator.test.js src/orchestrator/taskStatuses.test.js src/orchestrator/scheduler.test.js src/orchestrator/attemptManager.test.js src/orchestrator/resultAggregator.test.js src/orchestrator/approvalPolicy.test.js src/orchestrator/dispatcher.test.js src/orchestrator/objectiveResolver.test.js src/orchestrator/providers/fakeRuntimeProvider.test.js src/orchestrator/providers/runtimeProviders.test.js src/orchestrator/providers/dispatcherExternalRuntime.test.js src/orchestrator/providers/deepAgentsProvider.test.js src/commands/slash.test.js src/shell/repl.test.js src/shell/setupWizardModality.test.js src/shell/setupWizardPlaceholders.test.js src/shell/setupWizardSuggestions.test.js src/shell/setupWizardDiscovery.test.js src/shell/wrapText.test.js src/runtime/lifecycle.test.js src/runtime/store.test.js src/runtime/workspaceIsolation.test.js src/runtime/controlMessages.test.js src/runtime/recoveryManager.test.js src/runtime/server.test.js src/runtime/supervisor.test.js src/runtime/delegation.test.js src/runtime/runner.test.js src/runtime/runner.e2e.test.js src/runtime/skillChain.e2e.test.js src/runtime/donna-contract.test.js src/runtime/approvals.test.js src/runtime/auth.test.js",
|
|
23
23
|
"check-versions": "node scripts/check-versions.js",
|
|
24
24
|
"prepack": "node scripts/check-versions.js",
|
|
25
25
|
"prepublishOnly": "node scripts/check-versions.js",
|
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
|
}
|
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.74';
|
|
5
5
|
|
|
6
6
|
function envValue(key) {
|
|
7
7
|
const filePath = managerEnvFile();
|
package/src/core/runtimeLog.js
CHANGED
|
@@ -171,7 +171,10 @@ export function shortLogId(value, { maxLength = 40 } = {}) {
|
|
|
171
171
|
const DISPATCH_PLUMBING_LINE = /^(?:\d{1,2}:\d{2}(?::\d{2})?\s*(?:·\s*)?)?[A-Z][A-Z0-9_]{2,}(?:\s|$)/;
|
|
172
172
|
|
|
173
173
|
export function isDispatchPlumbingLine(line) {
|
|
174
|
-
|
|
174
|
+
// The Shell tags every runtime line with a "runtime " prefix before it
|
|
175
|
+
// reaches the Log panel; classification must look past it, or AGENT_STATUS
|
|
176
|
+
// rows land in the Runtime tab instead of Agent status.
|
|
177
|
+
return DISPATCH_PLUMBING_LINE.test(String(line ?? '').replace(/^runtime\s+/, ''));
|
|
175
178
|
}
|
|
176
179
|
|
|
177
180
|
export function runtimeLogMatchesFilter(line, filter = '') {
|
|
@@ -146,6 +146,16 @@ test('isDispatchPlumbingLine leaves the business flow lines for the Runtime tab'
|
|
|
146
146
|
}
|
|
147
147
|
});
|
|
148
148
|
|
|
149
|
+
test('isDispatchPlumbingLine recognises the shell-tagged "runtime " lines for the Agent status tab', () => {
|
|
150
|
+
// The Shell prepends "runtime " to every runtime line (useSession
|
|
151
|
+
// visibleLogs): AGENT_STATUS rows must still classify as plumbing, or they
|
|
152
|
+
// end up in the Runtime tab instead of Agent status.
|
|
153
|
+
const tagged = `runtime ${formatRuntimeLogPayload({ event: 'agent_status', runId: 'r1', taskId: 't1' }, '2026-07-08T14:42:18.000Z')}`;
|
|
154
|
+
assert.equal(isDispatchPlumbingLine(tagged), true, 'a tagged AGENT_STATUS line is dispatch plumbing');
|
|
155
|
+
assert.equal(isDispatchPlumbingLine('runtime 14:42:21 Run failed: No agent provides capability workspace.restore.'), false);
|
|
156
|
+
assert.equal(isDispatchPlumbingLine('runtime 14:42:22 Plan validated for run r1'), false);
|
|
157
|
+
});
|
|
158
|
+
|
|
149
159
|
test('shortLogId caps an over-long task slug while shortening embedded UUIDs', () => {
|
|
150
160
|
const long = `${'x'.repeat(48)}-deadbeef`;
|
|
151
161
|
assert.match(shortLogId(long), /…$/);
|
|
@@ -5,7 +5,7 @@ import { chainStepLabel, projectSkillChains, renderSkillChain, selectionKindLabe
|
|
|
5
5
|
const WIKI_SYNC = [
|
|
6
6
|
{
|
|
7
7
|
id: 'c0', chainId: 'chain-1', chainSequence: 0, skillName: 'wiki-sync', selectionKind: 'description_match', status: 'done',
|
|
8
|
-
input: 'Export
|
|
8
|
+
input: 'Export every configured Confluence source exactly as the connector is currently configured. Check configuration first.',
|
|
9
9
|
},
|
|
10
10
|
{
|
|
11
11
|
id: 'c1', chainId: 'chain-1', chainSequence: 1, skillName: 'wiki-sync', status: 'running', runId: 'run-b',
|
|
@@ -7,10 +7,11 @@ export async function compileSkillObjectives(skill, args = {}, { llmFallback = n
|
|
|
7
7
|
const body = String(skill?.body ?? '').trim();
|
|
8
8
|
if (!body) throw compileError('Skill body is empty.');
|
|
9
9
|
// Splitting must happen on the body alone. Appending the parameters first
|
|
10
|
-
// makes them part of the last objective only —
|
|
11
|
-
//
|
|
12
|
-
// actually
|
|
13
|
-
// step consumes which parameter, so every
|
|
10
|
+
// makes them part of the last objective only — a skill that compiles into
|
|
11
|
+
// several steps would hand its arguments to the final step and leave the
|
|
12
|
+
// earlier ones, the ones that actually consume them, without them. The
|
|
13
|
+
// compiler cannot know which step consumes which parameter, so every
|
|
14
|
+
// objective carries them.
|
|
14
15
|
const deterministic = deterministicObjectives(body);
|
|
15
16
|
if (!deterministic.ambiguous) {
|
|
16
17
|
return withNaturalArguments(validateCompiledObjectives(deterministic.objectives), args);
|
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
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import test from 'node:test';
|
|
5
|
+
|
|
6
|
+
const script = readFileSync(fileURLToPath(new URL('../../wiki-workspace', import.meta.url)), 'utf8');
|
|
7
|
+
|
|
8
|
+
test('wiki-workspace start advertises itself in the usage text', () => {
|
|
9
|
+
const usage = script.slice(script.indexOf('Commands:'), script.indexOf('Configuration:'));
|
|
10
|
+
assert.match(usage, /start \[--open\]\s+Start everything: agent-runtime, agents and every/);
|
|
11
|
+
assert.match(usage, /configured workspace/);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('wiki-workspace start routes to a start_all that composes the three idempotent steps', () => {
|
|
15
|
+
const fn = script.slice(script.indexOf("# `start` — the whole deployment"), script.indexOf('ensure_workspace_dirs()'));
|
|
16
|
+
// The whole deployment is the same three commands an operator runs by hand,
|
|
17
|
+
// in the boot order the runtime expects: runtime, agents, then workspaces.
|
|
18
|
+
assert.match(fn, /runtime_command up/);
|
|
19
|
+
assert.match(fn, /agents_compose up/);
|
|
20
|
+
assert.match(fn, /up_workspace "\$name"/);
|
|
21
|
+
// Every configured workspace joins, discovered exactly like list_workspaces:
|
|
22
|
+
// a directory under WORKSPACES_DIR carrying a .env.
|
|
23
|
+
assert.match(fn, /for entry in "\$WORKSPACES_DIR"\/\*/);
|
|
24
|
+
assert.match(fn, /env_value "\$file" WORKSPACE_NAME/);
|
|
25
|
+
// Without workspaces the command still completes, telling the operator how
|
|
26
|
+
// to create one.
|
|
27
|
+
assert.match(fn, /wiki-workspace config <name>/);
|
|
28
|
+
// Repeatable: each step is idempotent, so `start` is safe on a live stack.
|
|
29
|
+
assert.match(fn, /idempotent/);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('the start dispatch accepts only an optional --open', () => {
|
|
33
|
+
const dispatch = script.slice(script.indexOf('# start [--open]'), script.indexOf('# up <workspace> [--open]'));
|
|
34
|
+
assert.match(dispatch, /\[\[ "\$\{2:-\}" == "" \|\| "\$\{2:-\}" == "--open" \]\]/);
|
|
35
|
+
assert.match(dispatch, /start_all "\$start_open"/);
|
|
36
|
+
});
|
|
@@ -86,7 +86,7 @@ test('resolveObjective disambiguates "export" of a Confluence source via alias,
|
|
|
86
86
|
session.llm.completeWithTools = async () => {
|
|
87
87
|
throw new Error('the explicit alias must not depend on LLM selection');
|
|
88
88
|
};
|
|
89
|
-
const result = await resolveObjective('Export
|
|
89
|
+
const result = await resolveObjective('Export every configured Confluence source exactly as the connector is currently configured', session);
|
|
90
90
|
assert.equal(result.capability, 'external-source.export');
|
|
91
91
|
assert.equal(result.operation, 'export');
|
|
92
92
|
assert.equal(result.provider.agentInstanceId, 'cme-1');
|
|
@@ -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
|
|
@@ -311,11 +323,39 @@ export async function discoverRuntimeProvidersOnce(session, {
|
|
|
311
323
|
currentDown.add(key);
|
|
312
324
|
if (!session._runtimeProviderDown.has(key)) {
|
|
313
325
|
const kept = preservedByRuntime.get(item.runtimeId)?.length ?? 0;
|
|
326
|
+
// Transient states get the ⚠ glyph, which both UIs render in blue: the
|
|
327
|
+
// gateway answering HTTP 401 before its agents are loaded (workspace
|
|
328
|
+
// switch, boot order), or not listening yet (fetch failed, refused,
|
|
329
|
+
// timeout). An HTTP 4xx/5xx other than 401 — the server answers but is
|
|
330
|
+
// broken — stays a red "unavailable", like the non-HTTP agent contract
|
|
331
|
+
// errors.
|
|
332
|
+
const errorText = String(item.error ?? '');
|
|
333
|
+
const httpMatch = errorText.match(/\bHTTP (\d{3})\b/);
|
|
334
|
+
const connectionFailure = /\b(fetch failed|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|socket hang up|connection refused|network error|timeout|timed out)\b/i.test(errorText);
|
|
335
|
+
const notReady = (httpMatch != null && httpMatch[1] === '401') || (httpMatch == null && connectionFailure);
|
|
336
|
+
if (notReady) {
|
|
337
|
+
dispatchRuntimeLog(session, `⚠ agent-runtimes: ${item.runtimeId} not ready yet (${item.error})${kept > 0 ? ` — keeping ${kept} last-known capabilit${kept === 1 ? 'y' : 'ies'} until it recovers` : ''} — retrying on the next scan`);
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
314
340
|
dispatchRuntimeLog(session, kept > 0
|
|
315
341
|
? `agent-runtimes: ${item.runtimeId} unavailable (${item.error}) — keeping ${kept} last-known capabilit${kept === 1 ? 'y' : 'ies'} until it recovers`
|
|
316
342
|
: `agent-runtimes: ${item.runtimeId} unavailable (${item.error})`);
|
|
317
343
|
}
|
|
318
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);
|
|
319
359
|
session._runtimeProviderDown = currentDown;
|
|
320
360
|
return effectiveAgents;
|
|
321
361
|
}
|
|
@@ -265,6 +265,118 @@ 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
|
+
|
|
316
|
+
test('an HTTP 401 gateway probe is announced as not-ready with the ⚠ glyph, not as a failure', async () => {
|
|
317
|
+
const session = sessionWithEvents();
|
|
318
|
+
const originalFetch = globalThis.fetch;
|
|
319
|
+
globalThis.fetch = async (url) => {
|
|
320
|
+
if (String(url).includes('/health')) return new Response('{}', { status: 401 });
|
|
321
|
+
throw new Error('unexpected probe ' + String(url));
|
|
322
|
+
};
|
|
323
|
+
try {
|
|
324
|
+
await discoverRuntimeProvidersOnce(session, {
|
|
325
|
+
config: [{ id: 'deepagents', type: 'deepagents', endpoint: 'http://127.0.0.1:7789', enabled: true }],
|
|
326
|
+
});
|
|
327
|
+
} finally {
|
|
328
|
+
globalThis.fetch = originalFetch;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const logs = (session.agentEvents ?? []).filter((event) => event.type === 'runtime_log'
|
|
332
|
+
&& String(event.payload?.message ?? '').includes('deepagents'));
|
|
333
|
+
assert.equal(logs.length, 1, 'the transient auth state is announced once');
|
|
334
|
+
const message = String(logs[0].payload?.message ?? '');
|
|
335
|
+
assert.match(message, /^⚠ agent-runtimes: deepagents not ready yet \(HTTP 401/);
|
|
336
|
+
assert.match(message, /retrying on the next scan/);
|
|
337
|
+
assert.doesNotMatch(message, /unavailable/, 'a gateway answering before its agents load is not a failure');
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test('a gateway that is not listening yet (fetch failed) is also announced as not-ready', async () => {
|
|
341
|
+
const session = sessionWithEvents();
|
|
342
|
+
const originalFetch = globalThis.fetch;
|
|
343
|
+
globalThis.fetch = async () => {
|
|
344
|
+
throw new TypeError('fetch failed');
|
|
345
|
+
};
|
|
346
|
+
try {
|
|
347
|
+
await discoverRuntimeProvidersOnce(session, {
|
|
348
|
+
config: [{ id: 'deepagents', type: 'deepagents', endpoint: 'http://127.0.0.1:7789', enabled: true }],
|
|
349
|
+
});
|
|
350
|
+
} finally {
|
|
351
|
+
globalThis.fetch = originalFetch;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const logs = (session.agentEvents ?? []).filter((event) => event.type === 'runtime_log'
|
|
355
|
+
&& String(event.payload?.message ?? '').includes('deepagents'));
|
|
356
|
+
assert.equal(logs.length, 1);
|
|
357
|
+
const message = String(logs[0].payload?.message ?? '');
|
|
358
|
+
assert.match(message, /^⚠ agent-runtimes: deepagents not ready yet \(fetch failed\)/);
|
|
359
|
+
assert.doesNotMatch(message, /unavailable/);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test('an HTTP 500 gateway probe stays a red unavailable announcement', async () => {
|
|
363
|
+
const session = sessionWithEvents();
|
|
364
|
+
const originalFetch = globalThis.fetch;
|
|
365
|
+
globalThis.fetch = async () => new Response('{}', { status: 500 });
|
|
366
|
+
try {
|
|
367
|
+
await discoverRuntimeProvidersOnce(session, {
|
|
368
|
+
config: [{ id: 'deepagents', type: 'deepagents', endpoint: 'http://127.0.0.1:7789', enabled: true }],
|
|
369
|
+
});
|
|
370
|
+
} finally {
|
|
371
|
+
globalThis.fetch = originalFetch;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const logs = (session.agentEvents ?? []).filter((event) => event.type === 'runtime_log'
|
|
375
|
+
&& String(event.payload?.message ?? '').includes('deepagents'));
|
|
376
|
+
assert.equal(logs.length, 1);
|
|
377
|
+
assert.match(String(logs[0].payload?.message ?? ''), /^agent-runtimes: deepagents unavailable \(HTTP 500/);
|
|
378
|
+
});
|
|
379
|
+
|
|
268
380
|
test('a transient probe failure keeps the last-known capability set (a failed probe is not a lost agent)', async () => {
|
|
269
381
|
const session = sessionWithEvents();
|
|
270
382
|
const up = [{ id: 'gw', type: 'fake', capabilities: [{ name: 'agent.review', operations: ['run'] }] }];
|
|
@@ -136,16 +136,16 @@ test('E2E-002 wiki-sync: two objectives, two ordered runs, one chainId', async (
|
|
|
136
136
|
const env = await harness(t, { skills: { 'wiki-sync': null } });
|
|
137
137
|
if (!env) return;
|
|
138
138
|
|
|
139
|
-
const { body } = await env.post('/run?workspace=acme', { input: '/wiki-sync
|
|
139
|
+
const { body } = await env.post('/run?workspace=acme', { input: '/wiki-sync' });
|
|
140
140
|
await env.settle();
|
|
141
141
|
|
|
142
142
|
assert.equal(body.objectives, 2);
|
|
143
143
|
assert.equal(env.runs.length, 2, 'the second objective must run after the first');
|
|
144
|
-
assert.match(env.runs[0].input, /^Export
|
|
144
|
+
assert.match(env.runs[0].input, /^Export every configured Confluence source/);
|
|
145
145
|
assert.match(env.runs[1].input, /^Run the production pipeline step ingest over the newly exported Markdown/);
|
|
146
|
-
// CME first, Production second —
|
|
147
|
-
//
|
|
148
|
-
for (const run of env.runs) assert.
|
|
146
|
+
// CME first, Production second — the skill carries no source parameter, so
|
|
147
|
+
// no run may receive a source selector (the export step must stay "all").
|
|
148
|
+
for (const run of env.runs) assert.doesNotMatch(run.input, /User parameters:/);
|
|
149
149
|
const items = env.chain();
|
|
150
150
|
assert.equal(items.length, 2);
|
|
151
151
|
assert.equal(items[0].chainId, items[1].chainId);
|
|
@@ -154,6 +154,24 @@ test('E2E-002 wiki-sync: two objectives, two ordered runs, one chainId', async (
|
|
|
154
154
|
assert.deepEqual(items.map((item) => item.skillName), ['wiki-sync', 'wiki-sync']);
|
|
155
155
|
});
|
|
156
156
|
|
|
157
|
+
test('E2E-002b parameters reach every objective of a multi-step skill', async (t) => {
|
|
158
|
+
const env = await harness(t, {
|
|
159
|
+
skills: {
|
|
160
|
+
'two-step': '---\nname: two-step\nparams:\n - source\n---\nCollect the sources.\n\nThen ingest them.',
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
if (!env) return;
|
|
164
|
+
|
|
165
|
+
const { body } = await env.post('/run?workspace=acme', { input: '/two-step docs' });
|
|
166
|
+
await env.settle();
|
|
167
|
+
|
|
168
|
+
assert.equal(body.objectives, 2);
|
|
169
|
+
assert.equal(env.runs.length, 2);
|
|
170
|
+
// The parameter reaches every step that consumes it, not only the last
|
|
171
|
+
// objective of the chain.
|
|
172
|
+
for (const run of env.runs) assert.match(run.input, /User parameters:\nsource: docs/);
|
|
173
|
+
});
|
|
174
|
+
|
|
157
175
|
test('E2E-003 cancel: the running step and its chain stop, unrelated queue survives', async (t) => {
|
|
158
176
|
const env = await harness(t, {
|
|
159
177
|
autoFinish: false,
|
|
@@ -308,7 +326,7 @@ test('E2E-010 skill stack: every run receives the ancestors of its chain', async
|
|
|
308
326
|
const env = await harness(t, { skills: { 'wiki-sync': null } });
|
|
309
327
|
if (!env) return;
|
|
310
328
|
|
|
311
|
-
await env.post('/run?workspace=acme', { input: '/wiki-sync
|
|
329
|
+
await env.post('/run?workspace=acme', { input: '/wiki-sync' });
|
|
312
330
|
await env.settle();
|
|
313
331
|
|
|
314
332
|
assert.ok(env.runs.length >= 1);
|
package/src/shell/LeftPane.tsx
CHANGED
|
@@ -830,14 +830,21 @@ export function LeftPane(props: {
|
|
|
830
830
|
to the foot of the pane: they are an ambient state one glances at, not
|
|
831
831
|
something read before every message, and at the top they pushed the
|
|
832
832
|
conversation down by a line for standing information.
|
|
833
|
+
|
|
834
|
+
The hint row exists only while there is a hint. It is null almost
|
|
835
|
+
always, so reserving a blank line for it left a dead gap between the
|
|
836
|
+
mode bar and the thread once the standing info moved to the foot;
|
|
837
|
+
`conversationRows` in tui.tsx adds the row back only when it shows.
|
|
833
838
|
*/}
|
|
834
|
-
<box height={2} flexDirection="column">
|
|
839
|
+
<box height={props.hintLine ? 2 : 1} flexDirection="column">
|
|
835
840
|
<box height={1} flexDirection="row" backgroundColor={modeColor()} paddingX={1}>
|
|
836
841
|
<text fg="#0B1020">{modeLabel()}</text>
|
|
837
842
|
</box>
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
843
|
+
{props.hintLine ? (
|
|
844
|
+
<box height={1} flexDirection="row">
|
|
845
|
+
<text fg="#FBBF24">[ {props.hintLine} ]</text>
|
|
846
|
+
</box>
|
|
847
|
+
) : null}
|
|
841
848
|
</box>
|
|
842
849
|
{showWelcome() ? (
|
|
843
850
|
<WelcomeHelpPanels width={props.conversationColumns} />
|
|
@@ -875,9 +882,9 @@ export function LeftPane(props: {
|
|
|
875
882
|
onHeightChange={props.onInputHeightChange}
|
|
876
883
|
/>
|
|
877
884
|
{/*
|
|
878
|
-
Status foot. One line
|
|
879
|
-
`conversationRows` (
|
|
880
|
-
it
|
|
885
|
+
Status foot. One line: it replaced the standing header line rather than
|
|
886
|
+
adding one. `conversationRows` (tui.tsx) counts padding (2) + mode bar
|
|
887
|
+
(1) + this foot (1), plus the hint row only when it is present.
|
|
881
888
|
*/}
|
|
882
889
|
<box height={1} flexDirection="row">
|
|
883
890
|
<text fg="#D6DEE8">{props.title}</text>
|
package/src/shell/RightPane.tsx
CHANGED
|
@@ -349,6 +349,13 @@ function logMessageColor(message: string): string {
|
|
|
349
349
|
// would give them anyway.
|
|
350
350
|
if (/^\s*✓/.test(message)) return '#A6E3A1';
|
|
351
351
|
if (/^\s*✗/.test(message)) return '#F38BA8';
|
|
352
|
+
// ⚠ announces a state the reader should know about, not an error: the
|
|
353
|
+
// gateway answering 401 before its agents are loaded, a re-scan retry.
|
|
354
|
+
// Blue, with the glyph carrying the warning — the red rules below must not
|
|
355
|
+
// claim it (the text may still contain "HTTP 401"). Keyed on the glyph
|
|
356
|
+
// alone: every legitimate "not ready" line already carries it, whereas a
|
|
357
|
+
// genuine failure ("Run failed: agent X not ready") must stay red.
|
|
358
|
+
if (/^\s*⚠/.test(message)) return '#89B4FA';
|
|
352
359
|
// A doctor summary with ZERO errors is a warning by construction
|
|
353
360
|
// ("⚠ 0 error(s), 2 warning(s)") — amber, never red, even though it
|
|
354
361
|
// mentions the word "error".
|
|
@@ -419,7 +426,11 @@ export function LogPanel(props: { logs: string[]; width: number; filter?: string
|
|
|
419
426
|
// above the Runtime/Agent status tabs left a dead gap under a short Plan
|
|
420
427
|
// and made the two panels look detached. The Plan/Queue panel now grows
|
|
421
428
|
// into that space instead, so its bottom edge meets this panel's header.
|
|
422
|
-
|
|
429
|
+
// minHeight keeps this panel on screen while a long plan is running: with
|
|
430
|
+
// no floor the flex column shrank it to zero rows during a treatment — the
|
|
431
|
+
// plan grew, the Runtime/Agent status tabs vanished — while the Queue tab
|
|
432
|
+
// (fixed height, no shrink) kept them visible.
|
|
433
|
+
<box flexGrow={2} minHeight={10} flexDirection="column" paddingX={1} focusable={false}>
|
|
423
434
|
<text width={lineWidth()} fg="#4B5563" content={'─'.repeat(lineWidth())} />
|
|
424
435
|
<box height={1} flexDirection="row">
|
|
425
436
|
<text
|
|
@@ -438,6 +449,7 @@ export function LogPanel(props: { logs: string[]; width: number; filter?: string
|
|
|
438
449
|
</box>
|
|
439
450
|
<scrollbox
|
|
440
451
|
flexGrow={1}
|
|
452
|
+
minHeight={4}
|
|
441
453
|
focusable={false}
|
|
442
454
|
scrollY={true}
|
|
443
455
|
scrollX={false}
|
package/src/shell/repl.test.js
CHANGED
|
@@ -186,6 +186,28 @@ test('a doctor summary with a nonzero error count is colored as an error, not a
|
|
|
186
186
|
assert.doesNotMatch(colorFn, /\/0 error\\\(s\\\)\/i\.test/);
|
|
187
187
|
});
|
|
188
188
|
|
|
189
|
+
test('a ⚠ not-ready announcement is blue, even when its text mentions HTTP 401', async () => {
|
|
190
|
+
const source = await readFile(new URL('./RightPane.tsx', import.meta.url), 'utf8');
|
|
191
|
+
const colorFn = source.slice(
|
|
192
|
+
source.indexOf('function logMessageColor'),
|
|
193
|
+
source.indexOf('function logRenderLines'),
|
|
194
|
+
);
|
|
195
|
+
const blueRule = colorFn.indexOf("return '#89B4FA'");
|
|
196
|
+
const redRule = colorFn.indexOf('HTTP 4\\d\\d');
|
|
197
|
+
assert.ok(blueRule !== -1 && redRule !== -1, 'the blue and red rules both exist');
|
|
198
|
+
assert.ok(blueRule < redRule, 'the ⚠ rule must win over the HTTP 4xx red rule');
|
|
199
|
+
assert.match(colorFn, /\/\^\\s\*⚠\//);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('local log lines carry a fixed 24h timestamp like the runtime lines', async () => {
|
|
203
|
+
const source = await readFile(new URL('./useSession.ts', import.meta.url), 'utf8');
|
|
204
|
+
// A locale default rendered "9:50:37 PM" on some machines while runtime
|
|
205
|
+
// lines read "21:50:37" — two time dialects in one panel, and the
|
|
206
|
+
// Runtime/Agent-status classification reads the time prefix structurally.
|
|
207
|
+
assert.match(source, /toLocaleTimeString\('en-GB', \{ hour12: false \}\)/);
|
|
208
|
+
assert.doesNotMatch(source, /toLocaleTimeString\(\)/);
|
|
209
|
+
});
|
|
210
|
+
|
|
189
211
|
test('runtime logs have a separator and a concise Runtime tab label', async () => {
|
|
190
212
|
const source = await readFile(new URL('./RightPane.tsx', import.meta.url), 'utf8');
|
|
191
213
|
const logPanel = source.slice(
|
|
@@ -195,6 +217,11 @@ test('runtime logs have a separator and a concise Runtime tab label', async () =
|
|
|
195
217
|
assert.match(logPanel, /content=\{'─'\.repeat\(lineWidth\(\)\)\}/);
|
|
196
218
|
assert.match(logPanel, /content=" Runtime "/);
|
|
197
219
|
assert.doesNotMatch(logPanel, /Flow \/ Trace/);
|
|
220
|
+
// The panel keeps a floor so a long plan running above it cannot shrink it
|
|
221
|
+
// to zero rows — the Queue tab's fixed height is what used to keep it
|
|
222
|
+
// visible, and a treatment must not make the tabs vanish.
|
|
223
|
+
assert.match(logPanel, /flexGrow=\{2\} minHeight=\{10\}/);
|
|
224
|
+
assert.match(logPanel, /minHeight=\{4\}/);
|
|
198
225
|
});
|
|
199
226
|
|
|
200
227
|
test('ShellUI turns HTTP URLs into valid links without trailing punctuation', () => {
|
package/src/shell/tui.tsx
CHANGED
|
@@ -175,9 +175,22 @@ function App(props: {
|
|
|
175
175
|
let lastCopiedSelection = '';
|
|
176
176
|
const state = useSession(props);
|
|
177
177
|
const startup = createMemo(() => startupInfo(props.packageJson, props.initialWorkspaceName));
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
|
|
178
|
+
// Header line that changes the meaning of what is typed. Null most of the
|
|
179
|
+
// time — a transient exit/copy/runtime prompt only — so the header collapses
|
|
180
|
+
// to the single mode-bar row when it is absent (see LeftPane).
|
|
181
|
+
const hintLine = () => {
|
|
182
|
+
if (exitStatus()) return exitStatus();
|
|
183
|
+
if (copyHint()) return copyHint();
|
|
184
|
+
if (exitHint()) return 'Press Ctrl+C again to exit.';
|
|
185
|
+
if (state.runtimeHint()) return state.runtimeHint();
|
|
186
|
+
return null;
|
|
187
|
+
};
|
|
188
|
+
// Non-conversation rows: outer padding (2) + mode bar (1) + status foot (1),
|
|
189
|
+
// plus the hint row only when a hint is showing, plus the composer height and
|
|
190
|
+
// the Activity strip (4 rows, now above the composer).
|
|
191
|
+
const conversationRows = createMemo(() =>
|
|
192
|
+
Math.max(4, dimensions().height - 4 - (hintLine() ? 1 : 0) - chatInputHeight() - 4),
|
|
193
|
+
);
|
|
181
194
|
const rightColumns = createMemo(() => {
|
|
182
195
|
const width = dimensions().width;
|
|
183
196
|
// 38% + 2 columns: the Plan/Activity/Logs panes carry job labels, file
|
|
@@ -359,14 +372,6 @@ function App(props: {
|
|
|
359
372
|
}
|
|
360
373
|
});
|
|
361
374
|
|
|
362
|
-
const hintLine = () => {
|
|
363
|
-
if (exitStatus()) return exitStatus();
|
|
364
|
-
if (copyHint()) return copyHint();
|
|
365
|
-
if (exitHint()) return 'Press Ctrl+C again to exit.';
|
|
366
|
-
if (state.runtimeHint()) return state.runtimeHint();
|
|
367
|
-
return null;
|
|
368
|
-
};
|
|
369
|
-
|
|
370
375
|
return (
|
|
371
376
|
<Show
|
|
372
377
|
when={screen() === 'startup'}
|
package/src/shell/useSession.ts
CHANGED
|
@@ -82,7 +82,10 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
|
|
|
82
82
|
setActiveEditor(editor);
|
|
83
83
|
};
|
|
84
84
|
const addLog = (line: string) => {
|
|
85
|
-
|
|
85
|
+
// Fixed 24h HH:MM:SS, like the runtime's own log lines: a locale default
|
|
86
|
+
// rendered "9:50:37 PM" on some machines, and the Runtime/Agent-status
|
|
87
|
+
// split classifies lines structurally from that time prefix.
|
|
88
|
+
setLogs((items) => [...items, `${new Date().toLocaleTimeString('en-GB', { hour12: false })} ${line}`].slice(-200));
|
|
86
89
|
};
|
|
87
90
|
const runtimeUnavailableReason = createMemo(() => {
|
|
88
91
|
if (props.runtime?.url) return null;
|
package/wiki-workspace
CHANGED
|
@@ -97,6 +97,8 @@ Usage:
|
|
|
97
97
|
Commands:
|
|
98
98
|
config <workspace> [path] Create workspace folder, auto-select ports, run wiki init
|
|
99
99
|
up <workspace> [--open] Start workspace stack: wiki + production
|
|
100
|
+
start [--open] Start everything: agent-runtime, agents and every
|
|
101
|
+
configured workspace (--open opens the first chat)
|
|
100
102
|
list List configured workspaces
|
|
101
103
|
refresh Stop runtime/stacks and remove their Compose images
|
|
102
104
|
|
|
@@ -1438,6 +1440,49 @@ wait_and_open() {
|
|
|
1438
1440
|
open_app_mode "${url}"
|
|
1439
1441
|
}
|
|
1440
1442
|
|
|
1443
|
+
# `start` — the whole deployment in one command: the host agent-runtime, the
|
|
1444
|
+
# external agents, then every configured workspace stack. Each step is the
|
|
1445
|
+
# same idempotent command the operator would run by hand, so `start` is safe
|
|
1446
|
+
# to repeat while things are already up. A workspace failure stops the run —
|
|
1447
|
+
# the failing compose command already says why.
|
|
1448
|
+
start_all() {
|
|
1449
|
+
local open_browser="${1:-0}"
|
|
1450
|
+
|
|
1451
|
+
printf -- '── agent-runtime ──\n'
|
|
1452
|
+
runtime_command up
|
|
1453
|
+
printf '\n'
|
|
1454
|
+
|
|
1455
|
+
printf -- '── external agents ──\n'
|
|
1456
|
+
agents_compose up
|
|
1457
|
+
printf '\n'
|
|
1458
|
+
|
|
1459
|
+
mkdir -p "$WORKSPACES_DIR"
|
|
1460
|
+
local found=0 first_name="" entry file name
|
|
1461
|
+
for entry in "$WORKSPACES_DIR"/*; do
|
|
1462
|
+
[[ -d "$entry" ]] || continue
|
|
1463
|
+
file="$entry/.env"
|
|
1464
|
+
[[ -f "$file" ]] || continue
|
|
1465
|
+
name="$(env_value "$file" WORKSPACE_NAME "$(basename "$entry")")"
|
|
1466
|
+
found=1
|
|
1467
|
+
[[ -n "$first_name" ]] || first_name="$name"
|
|
1468
|
+
printf -- '── workspace: %s ──\n' "$name"
|
|
1469
|
+
up_workspace "$name"
|
|
1470
|
+
printf '\n'
|
|
1471
|
+
done
|
|
1472
|
+
|
|
1473
|
+
if [[ "$found" -eq 0 ]]; then
|
|
1474
|
+
printf 'No workspaces configured in %s\n' "$WORKSPACES_DIR"
|
|
1475
|
+
printf 'Create one with: wiki-workspace config <name>\n'
|
|
1476
|
+
return 0
|
|
1477
|
+
fi
|
|
1478
|
+
|
|
1479
|
+
if [[ "$open_browser" == "1" ]]; then
|
|
1480
|
+
local serve_port
|
|
1481
|
+
serve_port="$(workspace_value "$first_name" WIKI_SERVE_PORT 3100)"
|
|
1482
|
+
wait_and_open "http://localhost:${serve_port}/chat" &
|
|
1483
|
+
fi
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1441
1486
|
ensure_workspace_dirs() {
|
|
1442
1487
|
local ws_path="$1"
|
|
1443
1488
|
mkdir -p "$ws_path/.wiki/vector-index"
|
|
@@ -1575,6 +1620,16 @@ main() {
|
|
|
1575
1620
|
exit 0
|
|
1576
1621
|
fi
|
|
1577
1622
|
|
|
1623
|
+
# start [--open] — everything: runtime, agents, every configured workspace
|
|
1624
|
+
if [[ $# -ge 1 && "$1" == "start" ]]; then
|
|
1625
|
+
[[ $# -le 2 ]] || die "start takes at most --open"
|
|
1626
|
+
[[ "${2:-}" == "" || "${2:-}" == "--open" ]] || die "start takes at most --open"
|
|
1627
|
+
local start_open=0
|
|
1628
|
+
[[ "${2:-}" == "--open" ]] && start_open=1
|
|
1629
|
+
start_all "$start_open"
|
|
1630
|
+
exit 0
|
|
1631
|
+
fi
|
|
1632
|
+
|
|
1578
1633
|
# up <workspace> [--open]
|
|
1579
1634
|
if [[ $# -ge 2 && "$1" == "up" ]]; then
|
|
1580
1635
|
local up_open=0
|