@dotdrelle/wiki-manager 0.14.13 → 0.14.20
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/.env.example +29 -4
- package/README.md +19 -0
- package/docker-compose.yml +6 -1
- package/package.json +1 -1
- package/src/activity/activityAggregator.js +50 -16
- package/src/activity/activityAggregator.test.js +67 -4
- package/src/agent/graph.js +79 -11
- package/src/agent/graph.test.js +43 -3
- package/src/cli/wiki-manager.js +74 -11
- package/src/cli/wiki-manager.test.js +40 -1
- package/src/commands/slash.js +10 -3
- package/src/commands/slash.test.js +24 -0
- package/src/core/buildInfo.json +2 -2
- package/src/core/dockerCompose.test.js +4 -0
- package/src/core/env.test.js +3 -0
- package/src/core/mcp.js +1 -1
- package/src/core/wikiSetup.js +35 -0
- package/src/core/wikiWorkspace.test.js +20 -0
- package/src/core/workflow.js +72 -0
- package/src/core/workflow.test.js +57 -0
- package/src/core/workspaces.js +10 -2
- package/src/orchestrator/dependencyResolver.js +19 -1
- package/src/orchestrator/objectiveResolver.js +24 -0
- package/src/orchestrator/objectiveResolver.test.js +23 -1
- package/src/orchestrator/scheduler.js +27 -7
- package/src/orchestrator/scheduler.test.js +45 -1
- package/src/runtime/auth.test.js +65 -1
- package/src/runtime/client.js +4 -0
- package/src/runtime/donna-contract.test.js +2 -0
- package/src/runtime/lifecycle.js +21 -12
- package/src/runtime/runner.js +141 -18
- package/src/runtime/runner.test.js +30 -0
- package/src/runtime/server.js +13 -2
- package/src/runtime/server.test.js +30 -1
- package/src/runtime/store.js +54 -0
- package/src/runtime/store.test.js +42 -0
- package/src/shell/FileEditorDialog.tsx +2 -2
- package/src/shell/LeftPane.tsx +60 -15
- package/src/shell/RightPane.tsx +168 -56
- package/src/shell/StartupScreen.tsx +3 -7
- package/src/shell/renderer.ts +1 -0
- package/src/shell/repl.js +7 -81
- package/src/shell/repl.test.js +141 -38
- package/src/shell/tui.tsx +65 -65
- package/src/shell/useSession.ts +92 -6
- package/wiki-workspace +28 -0
package/src/cli/wiki-manager.js
CHANGED
|
@@ -8,6 +8,7 @@ import { createAgentGraph } from '../agent/graph.js';
|
|
|
8
8
|
import { handleSlashCommand, printHelp, printVersion, refreshMcpRuntimeStatus } from '../commands/slash.js';
|
|
9
9
|
import { runShell, runHeadlessChatTurn } from '../shell/repl.js';
|
|
10
10
|
import { runPreflightChecks, withRuntimePreflight } from '../core/startupCheck.js';
|
|
11
|
+
import { refreshRunningContainers } from '../core/wikiSetup.js';
|
|
11
12
|
import { applySessionWikircProfile } from '../core/sessionConfig.js';
|
|
12
13
|
import { listWikircProfiles } from '../core/wikirc.js';
|
|
13
14
|
import { callMcpTool, formatMcpToolResult, readChatAccessConfig } from '../core/mcp.js';
|
|
@@ -17,6 +18,7 @@ import { createAgentEvent, dispatchAgentEvent, reduceAgentEvents } from '../core
|
|
|
17
18
|
import { runAgentTurn, runAgenticLoop } from '../core/agentLoop.js';
|
|
18
19
|
import { resolveCapabilityConcurrency } from '../orchestrator/scheduler.js';
|
|
19
20
|
import { capabilityRegistryForSession } from '../orchestrator/capabilityRegistry.js';
|
|
21
|
+
import { listWorkspaces } from '../core/workspaces.js';
|
|
20
22
|
// Runtime modules use node:sqlite (Node.js built-in unavailable in Bun).
|
|
21
23
|
// They are imported dynamically so the shell / TUI path never loads them.
|
|
22
24
|
|
|
@@ -112,6 +114,18 @@ export async function forwardRuntimeApproval(getWorkspaceContext, request = {})
|
|
|
112
114
|
return context.approvalManager?.approve(request) ?? { approved: false };
|
|
113
115
|
}
|
|
114
116
|
|
|
117
|
+
export function resolvePreparedDelegationApproval({
|
|
118
|
+
autoApprove = false,
|
|
119
|
+
approvalManager = null,
|
|
120
|
+
runId,
|
|
121
|
+
} = {}) {
|
|
122
|
+
if (autoApprove !== true || typeof approvalManager?.approve !== 'function') {
|
|
123
|
+
return { approved: false, awaitingApproval: true };
|
|
124
|
+
}
|
|
125
|
+
const result = approvalManager.approve({ scope: 'run', runId });
|
|
126
|
+
return { approved: true, awaitingApproval: false, result };
|
|
127
|
+
}
|
|
128
|
+
|
|
115
129
|
function timestampForFile() {
|
|
116
130
|
return new Date().toISOString().replace(/[:.]/g, '-');
|
|
117
131
|
}
|
|
@@ -422,7 +436,7 @@ async function runHeadless(argv, agent) {
|
|
|
422
436
|
let input = prompt;
|
|
423
437
|
if (skillName) {
|
|
424
438
|
const skillResult = await handleSlashCommand(`/skills run ${skillName}`, { packageJson, session, onStep: step });
|
|
425
|
-
if (skillResult.output) log.push(skillResult.output);
|
|
439
|
+
if (skillResult.output && !skillResult.rawOutput) log.push(skillResult.output);
|
|
426
440
|
if (String(skillResult.output ?? '').startsWith('Skill not found')) throw new Error(`Skill not found: ${skillName}`);
|
|
427
441
|
input = skillResult.agentTrigger
|
|
428
442
|
? [
|
|
@@ -501,7 +515,7 @@ async function runRuntime(argv, agent) {
|
|
|
501
515
|
const { defaultRuntimeStateDir, openRuntimeStore, RECOVERABLE_QUEUE_STATUSES } = await import('../runtime/store.js');
|
|
502
516
|
const { startRuntimeServer } = await import('../runtime/server.js');
|
|
503
517
|
const { recoverActiveRuns } = await import('../runtime/recoveryManager.js');
|
|
504
|
-
const { emitRuntimeLog, startActivitySupervisor, cancelActiveActivityJobs } = await import('../runtime/supervisor.js');
|
|
518
|
+
const { emitRuntimeLog, startActivitySupervisor, cancelActiveActivityJobs, discoverAgentsOnce } = await import('../runtime/supervisor.js');
|
|
505
519
|
const { resolveRuntimeAuthToken } = await import('../runtime/auth.js');
|
|
506
520
|
const { createSqliteQueueStore } = await import('../runtime/queueStore.js');
|
|
507
521
|
const { createApprovalManager } = await import('../runtime/approvals.js');
|
|
@@ -803,6 +817,12 @@ async function runRuntime(argv, agent) {
|
|
|
803
817
|
const { resolveObjective } = await import('../orchestrator/objectiveResolver.js');
|
|
804
818
|
const { validateFragment } = await import('../orchestrator/planValidator.js');
|
|
805
819
|
const session = context.session;
|
|
820
|
+
// The supervisor starts discovery asynchronously. A delegation submitted
|
|
821
|
+
// immediately after opening ShellUI must not observe the transient empty
|
|
822
|
+
// registry and fail while the provider is already healthy. Refresh the
|
|
823
|
+
// live endpoints and await one discovery pass before resolving.
|
|
824
|
+
await refreshMcpRuntimeStatus(session);
|
|
825
|
+
await discoverAgentsOnce(session, { registry: session.agentRegistry });
|
|
806
826
|
let selection;
|
|
807
827
|
try {
|
|
808
828
|
selection = await resolveObjective(objective, session);
|
|
@@ -915,14 +935,24 @@ async function runRuntime(argv, agent) {
|
|
|
915
935
|
throw new Error(`Delegated plan integration failed: ${(integrated.errors ?? []).map((error) => error.message ?? error.code ?? String(error)).join('; ')}`);
|
|
916
936
|
}
|
|
917
937
|
emitRuntimeLog(session, `delegation: ${prepared.fragment.tasks.length} validated task(s) integrated from ${prepared.provider.serverName}.agent_plan (${prepared.capability}/${prepared.operation})`);
|
|
918
|
-
//
|
|
919
|
-
//
|
|
920
|
-
//
|
|
921
|
-
//
|
|
922
|
-
//
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
938
|
+
// Real approval gate (opt-out): a directly-delegated run only skips the
|
|
939
|
+
// human approval step when the caller explicitly opts in via
|
|
940
|
+
// `autoApprove` (e.g. headless/CI, or a future "trust this run" toggle).
|
|
941
|
+
// By default the run WAITS: integrate() above created the per-task
|
|
942
|
+
// approval requests, and the scheduler's approvalCovered() filter blocks
|
|
943
|
+
// the mutating tasks until a run-scope grant arrives (/approve or
|
|
944
|
+
// "valide tout"). This keeps a visible pending_approval window instead of
|
|
945
|
+
// resolving it programmatically ~30ms after launch, which no polled UI
|
|
946
|
+
// could ever render.
|
|
947
|
+
const approval = resolvePreparedDelegationApproval({
|
|
948
|
+
autoApprove: body.autoApprove,
|
|
949
|
+
approvalManager: context.approvalManager,
|
|
950
|
+
runId,
|
|
951
|
+
});
|
|
952
|
+
if (approval.approved) {
|
|
953
|
+
emitRuntimeLog(session, `approval: run ${runId} auto-approved (autoApprove opt-in)`);
|
|
954
|
+
} else {
|
|
955
|
+
emitRuntimeLog(session, `approval: run ${runId} awaiting explicit approval before mutations (/approve or « valide tout »)`);
|
|
926
956
|
}
|
|
927
957
|
body._planReady?.resolve?.({ runId, planRevision: session.agentProjection?.planRevision ?? 0 });
|
|
928
958
|
}
|
|
@@ -1162,10 +1192,22 @@ async function runRuntime(argv, agent) {
|
|
|
1162
1192
|
await new Promise(() => {});
|
|
1163
1193
|
}
|
|
1164
1194
|
|
|
1195
|
+
// One place for the skipped-image-update warnings so the runtime and TUI
|
|
1196
|
+
// startup paths report refresh failures identically.
|
|
1197
|
+
function logImageRefreshErrors(imageRefresh) {
|
|
1198
|
+
for (const error of imageRefresh?.errors ?? []) {
|
|
1199
|
+
console.warn(`[wiki-manager] image update skipped: ${error}`);
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1165
1203
|
export async function runCli(argv) {
|
|
1166
1204
|
if (argv[0] === 'runtime') {
|
|
1167
1205
|
const scaffolded = ensureManagerScaffold({ log: (message) => console.log(`[wiki-manager] ${message}`) });
|
|
1168
1206
|
if (scaffolded.length > 0) loadManagerEnv();
|
|
1207
|
+
const imageRefresh = await refreshRunningContainers({
|
|
1208
|
+
onStep: (message) => console.log(`[wiki-manager] ${message}`),
|
|
1209
|
+
});
|
|
1210
|
+
logImageRefreshErrors(imageRefresh);
|
|
1169
1211
|
const agent = createAgentGraph();
|
|
1170
1212
|
await runRuntime(argv.slice(1), agent);
|
|
1171
1213
|
return;
|
|
@@ -1218,6 +1260,10 @@ export async function runCli(argv) {
|
|
|
1218
1260
|
if (!process.versions.bun) {
|
|
1219
1261
|
throw new Error('Interactive TUI requires Bun. Run: bun ./bin/wiki-manager.js');
|
|
1220
1262
|
}
|
|
1263
|
+
const initialWorkspaceName = valueAfter(argv, '--workspace');
|
|
1264
|
+
if (initialWorkspaceName && !listWorkspaces().some((workspace) => workspace.name === initialWorkspaceName)) {
|
|
1265
|
+
throw new Error(`Workspace not found: ${initialWorkspaceName}`);
|
|
1266
|
+
}
|
|
1221
1267
|
const { runOpenTuiShell, runStartupWizard } = await import('../shell/tui.tsx');
|
|
1222
1268
|
// Fresh directory → copy mcp.endpoints.json/.env from the packaged
|
|
1223
1269
|
// examples so external agents (cme, mailer, documents) connect out of
|
|
@@ -1242,6 +1288,17 @@ export async function runCli(argv) {
|
|
|
1242
1288
|
// configuration. Re-read everything before drawing the home screen.
|
|
1243
1289
|
preflight = await runPreflightChecks();
|
|
1244
1290
|
}
|
|
1291
|
+
const dockerReady = preflight.checks.some((check) => check.kind === 'docker' && check.ok);
|
|
1292
|
+
const internetReady = preflight.checks.some((check) => check.kind === 'internet' && check.ok);
|
|
1293
|
+
if (dockerReady && internetReady) {
|
|
1294
|
+
void refreshRunningContainers({
|
|
1295
|
+
onStep: (message) => console.log(`[wiki-manager] ${message}`),
|
|
1296
|
+
}).then((imageRefresh) => {
|
|
1297
|
+
logImageRefreshErrors(imageRefresh);
|
|
1298
|
+
}).catch((error) => {
|
|
1299
|
+
console.warn(`[wiki-manager] image update skipped: ${error instanceof Error ? error.message : String(error)}`);
|
|
1300
|
+
});
|
|
1301
|
+
}
|
|
1245
1302
|
let runtime = null;
|
|
1246
1303
|
try {
|
|
1247
1304
|
const { ensureRuntime } = await import('../runtime/lifecycle.js');
|
|
@@ -1257,7 +1314,13 @@ export async function runCli(argv) {
|
|
|
1257
1314
|
// (see tui.tsx onShellExit): render() resolves at MOUNT, so anything
|
|
1258
1315
|
// after this await would run while the shell is still on screen —
|
|
1259
1316
|
// 0.12.9 shipped exactly that bug and killed the runtime under the user.
|
|
1260
|
-
await runOpenTuiShell({
|
|
1317
|
+
await runOpenTuiShell({
|
|
1318
|
+
agent,
|
|
1319
|
+
packageJson,
|
|
1320
|
+
runtime,
|
|
1321
|
+
preflight,
|
|
1322
|
+
initialWorkspaceName,
|
|
1323
|
+
});
|
|
1261
1324
|
return;
|
|
1262
1325
|
}
|
|
1263
1326
|
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import test from 'node:test';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
forwardRuntimeApproval,
|
|
5
|
+
resolvePreparedDelegationApproval,
|
|
6
|
+
} from './wiki-manager.js';
|
|
4
7
|
|
|
5
8
|
test('runtime approval bridge preserves the complete run-scoped grant', async () => {
|
|
6
9
|
let forwarded = null;
|
|
@@ -26,3 +29,39 @@ test('runtime approval bridge preserves the complete run-scoped grant', async ()
|
|
|
26
29
|
assert.deepEqual(forwarded, request);
|
|
27
30
|
assert.deepEqual(result, { approved: true });
|
|
28
31
|
});
|
|
32
|
+
|
|
33
|
+
test('prepared delegation waits for explicit approval by default', () => {
|
|
34
|
+
let calls = 0;
|
|
35
|
+
const result = resolvePreparedDelegationApproval({
|
|
36
|
+
runId: 'run-gated',
|
|
37
|
+
approvalManager: {
|
|
38
|
+
approve() {
|
|
39
|
+
calls += 1;
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
assert.equal(calls, 0);
|
|
45
|
+
assert.deepEqual(result, { approved: false, awaitingApproval: true });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('prepared delegation only approves when autoApprove is explicitly true', () => {
|
|
49
|
+
let forwarded = null;
|
|
50
|
+
const result = resolvePreparedDelegationApproval({
|
|
51
|
+
autoApprove: true,
|
|
52
|
+
runId: 'run-headless',
|
|
53
|
+
approvalManager: {
|
|
54
|
+
approve(request) {
|
|
55
|
+
forwarded = request;
|
|
56
|
+
return { approved: true };
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
assert.deepEqual(forwarded, { scope: 'run', runId: 'run-headless' });
|
|
62
|
+
assert.deepEqual(result, {
|
|
63
|
+
approved: true,
|
|
64
|
+
awaitingApproval: false,
|
|
65
|
+
result: { approved: true },
|
|
66
|
+
});
|
|
67
|
+
});
|
package/src/commands/slash.js
CHANGED
|
@@ -391,7 +391,10 @@ function skillDetailText(skill) {
|
|
|
391
391
|
|
|
392
392
|
function buildSkillRunPrompt(skill) {
|
|
393
393
|
return [
|
|
394
|
-
`
|
|
394
|
+
`The user asked to run the "${skill.name}" skill for the current workspace.`,
|
|
395
|
+
'First explain concisely, in the user language, what will be launched and its intended outcome.',
|
|
396
|
+
'Do not quote, reproduce, or display the raw skill content.',
|
|
397
|
+
'Then execute the workflow, using the available tools when required.',
|
|
395
398
|
'Follow the workflow steps below. Call MCP tools and shell commands as needed for each step.',
|
|
396
399
|
'Report progress as you go. Ask for confirmation before irreversible or costly actions not already defined in the skill.',
|
|
397
400
|
'',
|
|
@@ -413,7 +416,11 @@ function skillActionCommand(session, action, name) {
|
|
|
413
416
|
return { output: `Skill not found: ${name}.${hint}` };
|
|
414
417
|
}
|
|
415
418
|
if (action === 'run') {
|
|
416
|
-
return {
|
|
419
|
+
return {
|
|
420
|
+
output: JSON.stringify({ operation: 'run-skill', skill: skill.name }),
|
|
421
|
+
rawOutput: true,
|
|
422
|
+
agentTrigger: buildSkillRunPrompt(skill),
|
|
423
|
+
};
|
|
417
424
|
}
|
|
418
425
|
return { output: skillDetailText(skill) };
|
|
419
426
|
}
|
|
@@ -603,7 +610,7 @@ Options:
|
|
|
603
610
|
--cacert <path> Trust a local CA; Docker must be able to read this host path
|
|
604
611
|
--once <prompt> Run one agent turn and exit
|
|
605
612
|
--headless Run a workspace task non-interactively
|
|
606
|
-
--workspace <name>
|
|
613
|
+
--workspace <name> Initial workspace (interactive or --headless)
|
|
607
614
|
--skill <name> Skill to run in --headless (implies --wait)
|
|
608
615
|
--prompt <text> Task or extra instruction for --headless
|
|
609
616
|
--log-file <path> Optional headless log path
|
|
@@ -93,6 +93,30 @@ test('/new without a name shows usage', async () => {
|
|
|
93
93
|
assert.match(result.output ?? '', /Usage/i);
|
|
94
94
|
});
|
|
95
95
|
|
|
96
|
+
test('/skills run sends the private skill body to Donna without rendering it as command output', async () => {
|
|
97
|
+
const root = await mkdtemp(join(tmpdir(), 'wiki-manager-skill-run-'));
|
|
98
|
+
const skillDir = join(root, '.wiki', 'skills');
|
|
99
|
+
mkdirSync(skillDir, { recursive: true });
|
|
100
|
+
writeFileSync(join(skillDir, 'pipeline.md'), [
|
|
101
|
+
'---',
|
|
102
|
+
'name: pipeline',
|
|
103
|
+
'description: Build the deliverables',
|
|
104
|
+
'---',
|
|
105
|
+
'SECRET WORKFLOW BODY',
|
|
106
|
+
'',
|
|
107
|
+
].join('\n'), 'utf8');
|
|
108
|
+
|
|
109
|
+
const result = await handleSlashCommand('/skills run pipeline', {
|
|
110
|
+
packageJson: { version: 'test' },
|
|
111
|
+
session: { workspacePath: root },
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
assert.equal(result.rawOutput, true);
|
|
115
|
+
assert.doesNotMatch(result.output, /SECRET WORKFLOW BODY/);
|
|
116
|
+
assert.match(result.agentTrigger, /SECRET WORKFLOW BODY/);
|
|
117
|
+
assert.match(result.agentTrigger, /Do not quote, reproduce, or display the raw skill content/);
|
|
118
|
+
});
|
|
119
|
+
|
|
96
120
|
test('/use loads only workspaces and /config use switches wikirc profiles', async () => {
|
|
97
121
|
const root = await mkdtemp(join(tmpdir(), 'wiki-manager-use-profile-'));
|
|
98
122
|
const registryRoot = join(root, 'registry');
|
package/src/core/buildInfo.json
CHANGED
|
@@ -11,6 +11,10 @@ test('workspace compose does not start a per-workspace agent runtime', async ()
|
|
|
11
11
|
assert.equal(compose.services['agent-runtime'], undefined);
|
|
12
12
|
assert.deepEqual(aliases.all.targets, ['serve', 'mcp-http', 'production-mcp']);
|
|
13
13
|
assert.equal(aliases.runtime, undefined);
|
|
14
|
+
assert.equal(
|
|
15
|
+
compose.services.serve.environment.includes('WIKI_MANAGER_RUNTIME_URL=http://host.docker.internal:${WIKI_MANAGER_RUNTIME_PORT:-7788}'),
|
|
16
|
+
true,
|
|
17
|
+
);
|
|
14
18
|
});
|
|
15
19
|
|
|
16
20
|
test('agent compose services run as the host uid and gid', async () => {
|
package/src/core/env.test.js
CHANGED
|
@@ -29,6 +29,9 @@ test('scaffold copies the packaged examples into a fresh directory', () => {
|
|
|
29
29
|
const endpoints = JSON.parse(readFileSync(join(dir, 'mcp.endpoints.json'), 'utf8'));
|
|
30
30
|
assert.ok(endpoints.mcpServers);
|
|
31
31
|
assert.ok(endpoints.chatAccess);
|
|
32
|
+
const env = readFileSync(join(dir, '.env'), 'utf8');
|
|
33
|
+
assert.match(env, /^# WIKI_MANAGER_RUNTIME_HOST=0\.0\.0\.0$/m);
|
|
34
|
+
assert.match(env, /^# WIKI_MANAGER_RUNTIME_PORT=7788$/m);
|
|
32
35
|
});
|
|
33
36
|
});
|
|
34
37
|
|
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.14.
|
|
4
|
+
const WIKI_MANAGER_VERSION = '0.14.20';
|
|
5
5
|
|
|
6
6
|
function envValue(key) {
|
|
7
7
|
const filePath = managerEnvFile();
|
package/src/core/wikiSetup.js
CHANGED
|
@@ -105,6 +105,41 @@ export async function stopAgents(options = {}) {
|
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
export async function refreshRunningContainers(options = {}) {
|
|
109
|
+
if (process.env.WIKI_MANAGER_AUTO_UPDATE === '0') {
|
|
110
|
+
return { skipped: true, refreshed: [] };
|
|
111
|
+
}
|
|
112
|
+
const script = join(managerRoot(), 'wiki-workspace');
|
|
113
|
+
const common = {
|
|
114
|
+
cwd: dirname(managerEnvFile()),
|
|
115
|
+
env: {
|
|
116
|
+
...process.env,
|
|
117
|
+
WIKI_WORKSPACES_DIR: workspacesDir(),
|
|
118
|
+
WIKI_MANAGER_ENV_FILE: managerEnvFile(),
|
|
119
|
+
WIKI_MANAGER_ENDPOINTS_FILE: managerMcpEndpointsFile(),
|
|
120
|
+
AGENTS_DATA_DIR: resolveAgentsDataDir(),
|
|
121
|
+
},
|
|
122
|
+
timeout: options.timeout ?? 600_000,
|
|
123
|
+
maxBuffer: options.maxBuffer ?? 1024 * 1024 * 8,
|
|
124
|
+
};
|
|
125
|
+
const targets = [['agents', 'refresh'], ...listWorkspaces().map((workspace) => ['wiki', workspace.name, 'refresh'])];
|
|
126
|
+
// Each target is an independent Compose project (its own agents/workspace
|
|
127
|
+
// stack) — refresh them concurrently instead of summing every target's
|
|
128
|
+
// pull/restart time into one sequential wait.
|
|
129
|
+
const results = await Promise.all(targets.map(async (args) => {
|
|
130
|
+
options.onStep?.(`Images: checking ${args[0] === 'agents' ? 'running agents' : `workspace ${args[1]}`}…`);
|
|
131
|
+
try {
|
|
132
|
+
const { stdout, stderr } = await execFileAsync(script, args, common);
|
|
133
|
+
return { ok: true, output: [stdout, stderr].filter(Boolean).join('\n').trim() };
|
|
134
|
+
} catch (err) {
|
|
135
|
+
return { ok: false, error: wrapDockerError(err).message };
|
|
136
|
+
}
|
|
137
|
+
}));
|
|
138
|
+
const refreshed = results.filter((result) => result.ok).map((result) => result.output).filter(Boolean);
|
|
139
|
+
const errors = results.filter((result) => !result.ok).map((result) => result.error);
|
|
140
|
+
return { skipped: false, refreshed, errors };
|
|
141
|
+
}
|
|
142
|
+
|
|
108
143
|
export async function createNewWorkspace(name, targetPath) {
|
|
109
144
|
try {
|
|
110
145
|
const output = await createWorkspace(name, targetPath, { timeout: 600_000 });
|
|
@@ -31,3 +31,23 @@ test('wiki-workspace regenerates CA compose overrides instead of retaining remov
|
|
|
31
31
|
assert.match(script, /mv "\$tmp_override" "\$override_path"/);
|
|
32
32
|
assert.match(script, /Changes are overwritten on the next compose command/);
|
|
33
33
|
});
|
|
34
|
+
|
|
35
|
+
test('workspace creation keeps mutable manager files outside the installed package', async () => {
|
|
36
|
+
const source = await readFile(new URL('./workspaces.js', import.meta.url), 'utf8');
|
|
37
|
+
|
|
38
|
+
assert.match(source, /const stateDir = dirname\(managerEnvFile\(\)\)/);
|
|
39
|
+
assert.match(source, /cwd: stateDir/);
|
|
40
|
+
assert.match(source, /WIKI_MANAGER_ENV_FILE: managerEnvFile\(\)/);
|
|
41
|
+
assert.match(source, /WIKI_MANAGER_ENDPOINTS_FILE: managerMcpEndpointsFile\(\)/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('container refresh pulls and renews only services that are already running', async () => {
|
|
45
|
+
const script = await readFile(new URL('../../wiki-workspace', import.meta.url), 'utf8');
|
|
46
|
+
|
|
47
|
+
assert.match(script, /refresh_running_services\(\) \{/);
|
|
48
|
+
assert.match(script, /\[\[ -n "\$\{line\/\/\[\[:space:\]\]\/\}" \]\] \|\| continue/);
|
|
49
|
+
assert.match(script, /ps --status running --services/);
|
|
50
|
+
assert.match(script, /"\$@" pull "\$\{running_services\[@\]\}"/);
|
|
51
|
+
assert.match(script, /refresh_running_services 'No running external agent containers to refresh' 'Refreshed running agents: %s' _agents_dc/);
|
|
52
|
+
assert.match(script, /refresh_running_services "No running workspace containers to refresh: \$workspace" "Refreshed running workspace containers: \$workspace \(%s\)" compose_for_workspace "\$workspace"/);
|
|
53
|
+
});
|
package/src/core/workflow.js
CHANGED
|
@@ -107,11 +107,83 @@ export function projectWorkflow(state = {}, events = []) {
|
|
|
107
107
|
activity,
|
|
108
108
|
waitingReasons,
|
|
109
109
|
warnings,
|
|
110
|
+
usage: summarizeTokenUsage(events),
|
|
111
|
+
timingByTask: summarizeTaskTiming(events),
|
|
110
112
|
};
|
|
111
113
|
projected.graph = aggregateGraph(projected, events);
|
|
112
114
|
return projected;
|
|
113
115
|
}
|
|
114
116
|
|
|
117
|
+
// Per-task wall-clock timing, derived from the lifecycle events. Start = the
|
|
118
|
+
// first assigned/started event, finish = the last terminal event. Display-only
|
|
119
|
+
// (the inspector shows duration + orders tasks into a temporal flow); never used
|
|
120
|
+
// for scheduling.
|
|
121
|
+
function summarizeTaskTiming(events = []) {
|
|
122
|
+
const byTask = {};
|
|
123
|
+
const START_EVENTS = new Set(['task.assigned', 'task.started']);
|
|
124
|
+
const END_EVENTS = new Set(['task.completed', 'task.failed', 'task.result_returned']);
|
|
125
|
+
for (const event of events) {
|
|
126
|
+
if (!START_EVENTS.has(event.type) && !END_EVENTS.has(event.type)) continue;
|
|
127
|
+
const taskId = String(event.taskId ?? event.payload?.taskId ?? '').replace(/^task:/, '');
|
|
128
|
+
if (!taskId) continue;
|
|
129
|
+
const ts = Number(new Date(event.ts).getTime());
|
|
130
|
+
if (!Number.isFinite(ts)) continue;
|
|
131
|
+
const entry = byTask[taskId] ?? (byTask[taskId] = { startedAt: null, finishedAt: null, durationMs: null });
|
|
132
|
+
if (START_EVENTS.has(event.type)) {
|
|
133
|
+
entry.startedAt = entry.startedAt == null ? ts : Math.min(entry.startedAt, ts);
|
|
134
|
+
} else {
|
|
135
|
+
entry.finishedAt = entry.finishedAt == null ? ts : Math.max(entry.finishedAt, ts);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
for (const entry of Object.values(byTask)) {
|
|
139
|
+
if (entry.startedAt != null && entry.finishedAt != null && entry.finishedAt >= entry.startedAt) {
|
|
140
|
+
entry.durationMs = entry.finishedAt - entry.startedAt;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return byTask;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function summarizeTokenUsage(events = []) {
|
|
147
|
+
let inputTokens = 0;
|
|
148
|
+
let outputTokens = 0;
|
|
149
|
+
let totalTokens = 0;
|
|
150
|
+
let inputKnown = false;
|
|
151
|
+
let outputKnown = false;
|
|
152
|
+
let totalKnown = false;
|
|
153
|
+
const byTask = {};
|
|
154
|
+
const seen = new Set();
|
|
155
|
+
for (const event of events) {
|
|
156
|
+
if (!['task.result_returned', 'task.completed', 'task.failed'].includes(event.type)) continue;
|
|
157
|
+
const result = event.payload?.result ?? {};
|
|
158
|
+
const metrics = result.metrics ?? event.payload?.metrics ?? {};
|
|
159
|
+
const attemptId = result.attemptId ?? event.payload?.attemptId ?? event.id;
|
|
160
|
+
if (attemptId && seen.has(attemptId)) continue;
|
|
161
|
+
if (attemptId) seen.add(attemptId);
|
|
162
|
+
const input = metricNumber(metrics.inputTokens ?? metrics.promptTokens ?? metrics.prompt_tokens);
|
|
163
|
+
const output = metricNumber(metrics.outputTokens ?? metrics.completionTokens ?? metrics.completion_tokens);
|
|
164
|
+
const total = metricNumber(metrics.totalTokens ?? metrics.total_tokens ?? metrics.tokens);
|
|
165
|
+
if (input != null) { inputTokens += input; inputKnown = true; }
|
|
166
|
+
if (output != null) { outputTokens += output; outputKnown = true; }
|
|
167
|
+
if (total != null) { totalTokens += total; totalKnown = true; }
|
|
168
|
+
else if (input != null || output != null) { totalTokens += (input ?? 0) + (output ?? 0); totalKnown = true; }
|
|
169
|
+
const taskId = String(event.taskId ?? event.payload?.taskId ?? '');
|
|
170
|
+
if (taskId && (input != null || output != null || total != null)) {
|
|
171
|
+
const current = byTask[taskId] ?? { inputTokens: 0, outputTokens: 0, totalTokens: 0, inputKnown: false, outputKnown: false, totalKnown: false };
|
|
172
|
+
if (input != null) { current.inputTokens += input; current.inputKnown = true; }
|
|
173
|
+
if (output != null) { current.outputTokens += output; current.outputKnown = true; }
|
|
174
|
+
current.totalTokens += total ?? ((input ?? 0) + (output ?? 0));
|
|
175
|
+
current.totalKnown = true;
|
|
176
|
+
byTask[taskId] = current;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return { inputTokens, outputTokens, totalTokens, inputKnown, outputKnown, totalKnown, byTask };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function metricNumber(value) {
|
|
183
|
+
const number = Number(value);
|
|
184
|
+
return Number.isFinite(number) && number >= 0 ? number : null;
|
|
185
|
+
}
|
|
186
|
+
|
|
115
187
|
function currentRun(state, events) {
|
|
116
188
|
const runId = state.runId ?? state.runs?.find((run) => isActiveStatus(run.status))?.id ?? events.findLast?.((event) => event.runId)?.runId ?? null;
|
|
117
189
|
if (!runId && !state.status) return null;
|
|
@@ -64,3 +64,60 @@ test('projectWorkflow reports approval and queue waiting reasons', () => {
|
|
|
64
64
|
assert.ok(workflow.waitingReasons.includes('approval:approval-1'));
|
|
65
65
|
assert.ok(workflow.waitingReasons.includes('queue:queued-1'));
|
|
66
66
|
});
|
|
67
|
+
|
|
68
|
+
test('projectWorkflow aggregates input and output tokens once per attempt', () => {
|
|
69
|
+
const state = {
|
|
70
|
+
status: 'done',
|
|
71
|
+
runId: 'run-usage',
|
|
72
|
+
plan: [{ id: 'build', description: 'Build', status: 'done' }],
|
|
73
|
+
activities: [],
|
|
74
|
+
queue: [],
|
|
75
|
+
approvals: [],
|
|
76
|
+
};
|
|
77
|
+
const result = {
|
|
78
|
+
attemptId: 'attempt-1',
|
|
79
|
+
metrics: { inputTokens: 1200, outputTokens: 300, totalTokens: 1500 },
|
|
80
|
+
};
|
|
81
|
+
const workflow = projectWorkflow(state, [
|
|
82
|
+
{ id: 'event-1', type: 'task.result_returned', runId: 'run-usage', taskId: 'build', payload: { result } },
|
|
83
|
+
{ id: 'event-2', type: 'task.completed', runId: 'run-usage', taskId: 'build', payload: { result } },
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
assert.deepEqual(workflow.usage, {
|
|
87
|
+
inputTokens: 1200,
|
|
88
|
+
outputTokens: 300,
|
|
89
|
+
totalTokens: 1500,
|
|
90
|
+
inputKnown: true,
|
|
91
|
+
outputKnown: true,
|
|
92
|
+
totalKnown: true,
|
|
93
|
+
byTask: {
|
|
94
|
+
build: {
|
|
95
|
+
inputTokens: 1200,
|
|
96
|
+
outputTokens: 300,
|
|
97
|
+
totalTokens: 1500,
|
|
98
|
+
inputKnown: true,
|
|
99
|
+
outputKnown: true,
|
|
100
|
+
totalKnown: true,
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('projectWorkflow derives per-task timing (start, finish, duration) from lifecycle events', () => {
|
|
107
|
+
const state = {
|
|
108
|
+
status: 'done',
|
|
109
|
+
runId: 'run-timing',
|
|
110
|
+
plan: [{ id: 'ingest', description: 'Ingest', status: 'done' }],
|
|
111
|
+
activities: [],
|
|
112
|
+
queue: [],
|
|
113
|
+
approvals: [],
|
|
114
|
+
};
|
|
115
|
+
const workflow = projectWorkflow(state, [
|
|
116
|
+
{ id: 'e1', type: 'task.started', runId: 'run-timing', taskId: 'ingest', ts: '2026-07-23T10:00:00.000Z', payload: {} },
|
|
117
|
+
{ id: 'e2', type: 'task.completed', runId: 'run-timing', taskId: 'ingest', ts: '2026-07-23T10:00:12.500Z', payload: {} },
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
assert.equal(workflow.timingByTask.ingest.startedAt, Date.parse('2026-07-23T10:00:00.000Z'));
|
|
121
|
+
assert.equal(workflow.timingByTask.ingest.finishedAt, Date.parse('2026-07-23T10:00:12.500Z'));
|
|
122
|
+
assert.equal(workflow.timingByTask.ingest.durationMs, 12500);
|
|
123
|
+
});
|
package/src/core/workspaces.js
CHANGED
|
@@ -3,7 +3,7 @@ import { existsSync, readdirSync, realpathSync } from 'node:fs';
|
|
|
3
3
|
import { dirname, join, resolve } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
|
-
import { managerEnvFile, readEnvFile, userManagerDir } from './env.js';
|
|
6
|
+
import { managerEnvFile, managerMcpEndpointsFile, readEnvFile, userManagerDir } from './env.js';
|
|
7
7
|
|
|
8
8
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
9
9
|
const packageRoot = resolve(__dirname, '../..');
|
|
@@ -70,15 +70,23 @@ export async function createWorkspace(name, targetPath = null, options = {}) {
|
|
|
70
70
|
}
|
|
71
71
|
const args = ['config', name];
|
|
72
72
|
if (targetPath) args.push(targetPath);
|
|
73
|
+
const stateDir = dirname(managerEnvFile());
|
|
73
74
|
const { stdout, stderr } = await execFileAsync(
|
|
74
75
|
join(managerRoot(), 'wiki-workspace'),
|
|
75
76
|
args,
|
|
76
77
|
{
|
|
77
|
-
|
|
78
|
+
// Relative Compose mounts and default scaffold paths must resolve from
|
|
79
|
+
// the user's manager state, never from the globally installed package.
|
|
80
|
+
cwd: stateDir,
|
|
78
81
|
env: {
|
|
79
82
|
...process.env,
|
|
80
83
|
WIKI_WORKSPACES_DIR: workspacesDir(),
|
|
81
84
|
WIKI_MANAGER_ENV_FILE: managerEnvFile(),
|
|
85
|
+
// The script runs from the installed package directory so it can find
|
|
86
|
+
// its Compose templates. Pin mutable manager state to the user's
|
|
87
|
+
// launch directory; a global npm package is commonly owned by root
|
|
88
|
+
// and must never become the destination for this scaffold.
|
|
89
|
+
WIKI_MANAGER_ENDPOINTS_FILE: managerMcpEndpointsFile(),
|
|
82
90
|
},
|
|
83
91
|
maxBuffer: options.maxBuffer ?? 1024 * 1024 * 8,
|
|
84
92
|
timeout: options.timeout ?? 600_000,
|
|
@@ -3,6 +3,9 @@ import { approvalCovered } from './approvalPolicy.js';
|
|
|
3
3
|
|
|
4
4
|
const DONE_STATUSES = new Set(['done', 'completed', 'complete', 'success', 'succeeded']);
|
|
5
5
|
const TERMINAL_STATUSES = new Set([...DONE_STATUSES, 'failed', 'cancelled', 'canceled', 'skipped']);
|
|
6
|
+
// A task in one of these statuses hasn't run yet but could become ready —
|
|
7
|
+
// shared with runner.js's scheduler-stall check so the two can't drift apart.
|
|
8
|
+
export const PENDING_STATUSES = new Set(['pending', 'pending_approval', 'waiting_approval']);
|
|
6
9
|
|
|
7
10
|
export function readyTasks(dag, {
|
|
8
11
|
registry = null,
|
|
@@ -18,7 +21,7 @@ export function readyTasks(dag, {
|
|
|
18
21
|
.filter((task) => {
|
|
19
22
|
const status = statusOf(task);
|
|
20
23
|
return status === 'pending'
|
|
21
|
-
|| ((status
|
|
24
|
+
|| (PENDING_STATUSES.has(status)
|
|
22
25
|
&& approvalCovered(task, approvals, {
|
|
23
26
|
runId: task?.runId ?? dag?.runId ?? null,
|
|
24
27
|
workspaceId: dag?.workspace ?? null,
|
|
@@ -35,6 +38,21 @@ export function readyTasks(dag, {
|
|
|
35
38
|
.sort(compareTaskPriority);
|
|
36
39
|
}
|
|
37
40
|
|
|
41
|
+
export function tasksAwaitingApproval(dag, { approvals = [] } = {}) {
|
|
42
|
+
const tasks = normalizeTasks(dag);
|
|
43
|
+
const done = new Set(tasks.filter((task) => DONE_STATUSES.has(statusOf(task))).map(taskId));
|
|
44
|
+
return tasks
|
|
45
|
+
.filter((task) => PENDING_STATUSES.has(statusOf(task)))
|
|
46
|
+
.filter((task) => task?.requiresApproval === true)
|
|
47
|
+
.filter((task) => !approvalCovered(task, approvals, {
|
|
48
|
+
runId: task?.runId ?? dag?.runId ?? null,
|
|
49
|
+
workspaceId: dag?.workspace ?? null,
|
|
50
|
+
planRevision: dag?.planRevision ?? null,
|
|
51
|
+
}))
|
|
52
|
+
.filter((task) => dependenciesDone(task, done))
|
|
53
|
+
.filter((task) => groupBarrierSatisfied(task, tasks));
|
|
54
|
+
}
|
|
55
|
+
|
|
38
56
|
function normalizeTasks(dag) {
|
|
39
57
|
if (Array.isArray(dag)) return dag;
|
|
40
58
|
if (Array.isArray(dag?.tasks)) return dag.tasks;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export async function resolveObjective(objective, session) {
|
|
2
2
|
const candidates = capabilityCandidates(session);
|
|
3
3
|
if (candidates.length === 0) throw new Error('No orchestrable capability is currently available.');
|
|
4
|
+
const deterministic = resolveMentionedRegistryOperation(objective, candidates);
|
|
5
|
+
if (deterministic) return selectionWithProvider(session, deterministic, candidates);
|
|
4
6
|
const llm = session?.llm;
|
|
5
7
|
if (!llm?.completeWithTools) throw new Error('Objective resolution requires the configured workspace LLM.');
|
|
6
8
|
|
|
@@ -25,6 +27,28 @@ export async function resolveObjective(objective, session) {
|
|
|
25
27
|
if (!candidate.operations.includes(operation)) {
|
|
26
28
|
throw new Error(`Objective resolver selected unsupported operation "${operation}" for ${capability}.`);
|
|
27
29
|
}
|
|
30
|
+
return selectionWithProvider(session, { capability, operation }, candidates);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Prefer an operation explicitly named by the user when that name resolves to
|
|
34
|
+
// exactly one entry in the live registry. This is deliberately generic: the
|
|
35
|
+
// resolver knows neither capability ids nor business verbs. Prefix matching
|
|
36
|
+
// covers natural inflections such as an operation name followed by a suffix.
|
|
37
|
+
function resolveMentionedRegistryOperation(objective, candidates) {
|
|
38
|
+
const words = String(objective ?? '')
|
|
39
|
+
.normalize('NFKD')
|
|
40
|
+
.replace(/\p{Diacritic}/gu, '')
|
|
41
|
+
.toLowerCase()
|
|
42
|
+
.match(/[a-z0-9]+/g) ?? [];
|
|
43
|
+
const matches = candidates.flatMap((candidate) => candidate.operations
|
|
44
|
+
.filter((operation) => String(operation).split(/[._-]+/).some((token) =>
|
|
45
|
+
token.length >= 4 && words.some((word) => word.startsWith(token))))
|
|
46
|
+
.map((operation) => ({ capability: candidate.id, operation })));
|
|
47
|
+
return matches.length === 1 ? matches[0] : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function selectionWithProvider(session, selection, candidates) {
|
|
51
|
+
const { capability, operation } = selection;
|
|
28
52
|
const providers = providersFor(session, capability)
|
|
29
53
|
.filter((provider) => !operation || (provider.capability?.supportedOperations ?? []).includes(operation))
|
|
30
54
|
.sort((a, b) => String(a.agentInstanceId).localeCompare(String(b.agentInstanceId)));
|