@dotdrelle/wiki-manager 0.14.12 → 0.14.16
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 +11 -0
- package/docker-compose.yml +1 -1
- package/package.json +1 -1
- package/src/activity/activityAggregator.js +43 -13
- package/src/activity/activityAggregator.test.js +51 -2
- package/src/agent/graph.js +79 -11
- package/src/agent/graph.test.js +43 -3
- package/src/cli/wiki-manager.js +44 -3
- package/src/commands/slash.js +10 -3
- package/src/commands/slash.test.js +24 -0
- package/src/core/activity.js +4 -0
- package/src/core/activity.test.js +31 -0
- package/src/core/agentEvents.js +11 -2
- package/src/core/buildInfo.json +2 -2
- package/src/core/dockerCompose.test.js +4 -0
- package/src/core/env.js +16 -4
- package/src/core/env.test.js +3 -0
- package/src/core/mcp.js +1 -1
- package/src/core/wikiSetup.js +43 -1
- package/src/core/wikiWorkspace.test.js +20 -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.test.js +22 -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 +130 -17
- package/src/runtime/runner.test.js +62 -1
- package/src/runtime/server.js +13 -2
- package/src/runtime/server.test.js +30 -1
- package/src/shell/FileEditorDialog.tsx +2 -2
- package/src/shell/LeftPane.tsx +60 -15
- package/src/shell/RightPane.tsx +147 -54
- 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 +128 -38
- package/src/shell/tui.tsx +59 -64
- package/src/shell/useSession.ts +45 -6
- package/wiki-workspace +36 -3
package/src/core/agentEvents.js
CHANGED
|
@@ -122,13 +122,22 @@ export function resetSessionProjection(session) {
|
|
|
122
122
|
|
|
123
123
|
function withSessionRunIdentity(event, session) {
|
|
124
124
|
const identity = session?._currentRunIdentity;
|
|
125
|
-
|
|
125
|
+
// The workspace must be attached even OUTSIDE a run identity: the SSE
|
|
126
|
+
// publisher drops events whose workspace does not match the (always
|
|
127
|
+
// workspace-scoped) serve UI client. Events dispatched between chained
|
|
128
|
+
// executions — e.g. the plan_set of the next action, run evaluation,
|
|
129
|
+
// assistant messages — used to leave with workspace=null and never reached
|
|
130
|
+
// the UI, which then displayed a stale plan from the previous action.
|
|
131
|
+
const workspace = event.workspace ?? identity?.workspace ?? session?.workspace ?? null;
|
|
132
|
+
if (!identity) {
|
|
133
|
+
return workspace === (event.workspace ?? null) ? event : { ...event, workspace };
|
|
134
|
+
}
|
|
126
135
|
return {
|
|
127
136
|
...event,
|
|
128
137
|
runId: event.runId ?? identity.runId ?? null,
|
|
129
138
|
turnId: event.turnId ?? identity.turnId ?? null,
|
|
130
139
|
taskId: event.taskId ?? identity.taskId ?? null,
|
|
131
|
-
workspace
|
|
140
|
+
workspace,
|
|
132
141
|
};
|
|
133
142
|
}
|
|
134
143
|
|
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.js
CHANGED
|
@@ -98,14 +98,26 @@ export function ensureManagerScaffold({ log = () => {} } = {}) {
|
|
|
98
98
|
return created;
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
// Mirrors workspacesDir() in core/workspaces.js (which imports this module,
|
|
102
|
+
// so it cannot be imported here without a cycle).
|
|
103
|
+
function workspacesRootDir() {
|
|
104
|
+
return process.env.WIKI_WORKSPACES_DIR
|
|
105
|
+
? resolve(process.env.WIKI_WORKSPACES_DIR)
|
|
106
|
+
: join(userManagerDir(), 'workspaces');
|
|
107
|
+
}
|
|
108
|
+
|
|
101
109
|
// Single source of truth for where `.agents-data` lives, shared by the
|
|
102
|
-
// manager's own document intake
|
|
103
|
-
//
|
|
110
|
+
// manager's own document intake, the per-workspace serve stack, and the
|
|
111
|
+
// GLOBAL agents stack (documents/cme containers). All three must resolve the
|
|
112
|
+
// same host directory: serve writes an upload into its mount and the
|
|
113
|
+
// documents container must find it under the identical path — a divergence
|
|
114
|
+
// here is exactly the clean-install "Input file does not exist" bug.
|
|
115
|
+
// Canonical location: `<workspaces root>/.agents-data`, session or not.
|
|
104
116
|
export function resolveAgentsDataDir(session = null) {
|
|
105
117
|
const configured = process.env.AGENTS_DATA_DIR;
|
|
106
|
-
if (configured) return isAbsolute(configured) ? configured : resolve(
|
|
118
|
+
if (configured) return isAbsolute(configured) ? configured : resolve(managerStateDir(), configured);
|
|
107
119
|
if (session?.workspacePath) return resolve(dirname(session.workspacePath), '.agents-data');
|
|
108
|
-
return resolve(
|
|
120
|
+
return resolve(workspacesRootDir(), '.agents-data');
|
|
109
121
|
}
|
|
110
122
|
|
|
111
123
|
function parseEnvValue(value) {
|
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.16';
|
|
5
5
|
|
|
6
6
|
function envValue(key) {
|
|
7
7
|
const filePath = managerEnvFile();
|
package/src/core/wikiSetup.js
CHANGED
|
@@ -6,7 +6,7 @@ import { promisify } from 'node:util';
|
|
|
6
6
|
import YAML from 'yaml';
|
|
7
7
|
import { checkMissingDockerImages } from './dockerImages.js';
|
|
8
8
|
import { patchWikircProfile } from './wikirc.js';
|
|
9
|
-
import { managerEnvFile, managerMcpEndpointsFile } from './env.js';
|
|
9
|
+
import { managerEnvFile, managerMcpEndpointsFile, resolveAgentsDataDir } from './env.js';
|
|
10
10
|
import { createWorkspace, findWorkspace, isValidWorkspaceName, listWorkspaces, managerRoot, workspacesDir } from './workspaces.js';
|
|
11
11
|
|
|
12
12
|
const execFileAsync = promisify(execFile);
|
|
@@ -68,6 +68,12 @@ export async function startAgents(options = {}) {
|
|
|
68
68
|
// user's .env stayed empty on a fresh install.
|
|
69
69
|
WIKI_MANAGER_ENV_FILE: managerEnvFile(),
|
|
70
70
|
WIKI_MANAGER_ENDPOINTS_FILE: managerMcpEndpointsFile(),
|
|
71
|
+
// Same pinning for .agents-data: without it the script defaults to
|
|
72
|
+
// $PWD/.agents-data (the PACKAGE directory), while the serve stack
|
|
73
|
+
// mounts the workspace-root .agents-data — the documents container
|
|
74
|
+
// then reads an empty input dir and every conversion fails with
|
|
75
|
+
// "Input file does not exist" on a clean install.
|
|
76
|
+
AGENTS_DATA_DIR: resolveAgentsDataDir(),
|
|
71
77
|
},
|
|
72
78
|
timeout: options.timeout ?? 180_000,
|
|
73
79
|
maxBuffer: options.maxBuffer ?? 1024 * 1024 * 8,
|
|
@@ -88,6 +94,7 @@ export async function stopAgents(options = {}) {
|
|
|
88
94
|
env: {
|
|
89
95
|
...process.env,
|
|
90
96
|
WIKI_WORKSPACES_DIR: workspacesDir(),
|
|
97
|
+
AGENTS_DATA_DIR: resolveAgentsDataDir(),
|
|
91
98
|
},
|
|
92
99
|
timeout: options.timeout ?? 120_000,
|
|
93
100
|
maxBuffer: options.maxBuffer ?? 1024 * 1024 * 8,
|
|
@@ -98,6 +105,41 @@ export async function stopAgents(options = {}) {
|
|
|
98
105
|
}
|
|
99
106
|
}
|
|
100
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
|
+
|
|
101
143
|
export async function createNewWorkspace(name, targetPath) {
|
|
102
144
|
try {
|
|
103
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/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)));
|
|
@@ -42,9 +42,31 @@ test('resolveObjective selects and validates one real provider', async () => {
|
|
|
42
42
|
assert.equal(result.provider.agentInstanceId, 'production-1');
|
|
43
43
|
});
|
|
44
44
|
|
|
45
|
+
test('resolveObjective uses an unambiguously mentioned registry operation without asking the LLM', async () => {
|
|
46
|
+
const session = sessionWithSelection({ capability: 'external-source.export', operation: 'export' });
|
|
47
|
+
session.capabilityRegistry.snapshot = () => ({
|
|
48
|
+
'knowledge.update@1': [sessionWithSelection({}).capabilityRegistry.snapshot()['knowledge.update@1'][0]],
|
|
49
|
+
'external-source.export@1': [{
|
|
50
|
+
agentInstanceId: 'cme-1',
|
|
51
|
+
serverName: 'cme',
|
|
52
|
+
capability: { id: 'external-source.export', version: '1', supportedOperations: ['export'] },
|
|
53
|
+
}],
|
|
54
|
+
});
|
|
55
|
+
session.capabilityRegistry.providersFor = (capability) =>
|
|
56
|
+
session.capabilityRegistry.snapshot()[`${capability}@1`] ?? [];
|
|
57
|
+
session.llm.completeWithTools = async () => {
|
|
58
|
+
throw new Error('the explicit operation must not depend on LLM selection');
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const result = await resolveObjective("lance l'ingestion", session);
|
|
62
|
+
assert.equal(result.capability, 'knowledge.update');
|
|
63
|
+
assert.equal(result.operation, 'ingest');
|
|
64
|
+
assert.equal(result.provider.agentInstanceId, 'production-1');
|
|
65
|
+
});
|
|
66
|
+
|
|
45
67
|
test('resolveObjective rejects invented capability and operation', async () => {
|
|
46
68
|
await assert.rejects(
|
|
47
|
-
resolveObjective('
|
|
69
|
+
resolveObjective('Traite tout', sessionWithSelection({ capability: 'ingest', operation: 'ingest_all_pending' })),
|
|
48
70
|
/unknown capability "ingest"/,
|
|
49
71
|
);
|
|
50
72
|
});
|
|
@@ -4,7 +4,7 @@ import { join } from 'node:path';
|
|
|
4
4
|
import test from 'node:test';
|
|
5
5
|
|
|
6
6
|
import { createBudgetManager } from './budgetManager.js';
|
|
7
|
-
import { readyTasks } from './dependencyResolver.js';
|
|
7
|
+
import { readyTasks, tasksAwaitingApproval } from './dependencyResolver.js';
|
|
8
8
|
import { createLockManager } from './lockManager.js';
|
|
9
9
|
import {
|
|
10
10
|
effectiveConcurrency,
|
|
@@ -60,6 +60,27 @@ test('dependencyResolver releases a waiting task when a run grant covers it', ()
|
|
|
60
60
|
}).map((item) => item.id), ['ingest']);
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
test('dependencyResolver does not request approval for a task blocked by a failed dependency', () => {
|
|
64
|
+
const plan = {
|
|
65
|
+
runId: 'run-1',
|
|
66
|
+
workspace: 'test4',
|
|
67
|
+
planRevision: 1,
|
|
68
|
+
tasks: [
|
|
69
|
+
task('plan', { status: 'failed' }),
|
|
70
|
+
task('apply', {
|
|
71
|
+
status: 'waiting_approval',
|
|
72
|
+
dependsOn: ['plan'],
|
|
73
|
+
requiresApproval: true,
|
|
74
|
+
approvalClass: 'mutation',
|
|
75
|
+
}),
|
|
76
|
+
],
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
assert.deepEqual(tasksAwaitingApproval(plan), []);
|
|
80
|
+
plan.tasks[0].status = 'done';
|
|
81
|
+
assert.deepEqual(tasksAwaitingApproval(plan).map((item) => item.id), ['apply']);
|
|
82
|
+
});
|
|
83
|
+
|
|
63
84
|
test('dependencyResolver skips tasks whose locks are not free and keeps other ready work moving', () => {
|
|
64
85
|
const lockManager = createLockManager();
|
|
65
86
|
const held = lockManager.acquire(['deliverable:a.md']);
|
package/src/runtime/auth.test.js
CHANGED
|
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import test from 'node:test';
|
|
6
6
|
import { resolveRuntimeAuthToken } from './auth.js';
|
|
7
|
-
import { assertRuntimeNode, runtimeNodeExecutable } from './lifecycle.js';
|
|
7
|
+
import { assertRuntimeNode, runtimeNodeExecutable, shutdownOwnedRuntime } from './lifecycle.js';
|
|
8
8
|
|
|
9
9
|
test('resolveRuntimeAuthToken: loopback host does not require token', () => {
|
|
10
10
|
const result = resolveRuntimeAuthToken({ host: '127.0.0.1', explicitToken: null });
|
|
@@ -27,3 +27,67 @@ test('runtime lifecycle uses a Node executable with node:sqlite support', async
|
|
|
27
27
|
assert.ok(runtimeNode.executable);
|
|
28
28
|
assert.ok(Number(runtimeNode.version.split('.')[0]) >= 22);
|
|
29
29
|
});
|
|
30
|
+
|
|
31
|
+
test('shutdownOwnedRuntime reports progress and stops an idle owned runtime', async () => {
|
|
32
|
+
const originalFetch = globalThis.fetch;
|
|
33
|
+
const calls = [];
|
|
34
|
+
const logs = [];
|
|
35
|
+
globalThis.fetch = async (url, options = {}) => {
|
|
36
|
+
calls.push({ url: String(url), method: options.method ?? 'GET' });
|
|
37
|
+
return new Response(JSON.stringify(calls.length === 1 ? { activeRuns: [] } : { shutdown: true }), {
|
|
38
|
+
status: calls.length === 1 ? 200 : 202,
|
|
39
|
+
headers: { 'content-type': 'application/json' },
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
try {
|
|
43
|
+
const result = await shutdownOwnedRuntime(
|
|
44
|
+
{ url: 'http://127.0.0.1:7788', started: true, token: null },
|
|
45
|
+
{ log: (message) => logs.push(message), timeoutMs: 100 },
|
|
46
|
+
);
|
|
47
|
+
assert.equal(result.action, 'shutdown');
|
|
48
|
+
assert.deepEqual(calls.map((call) => call.method), ['GET', 'POST']);
|
|
49
|
+
assert.match(logs.join('\n'), /runtime arrêté/);
|
|
50
|
+
} finally {
|
|
51
|
+
globalThis.fetch = originalFetch;
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('shutdownOwnedRuntime also stops an idle reused runtime', async () => {
|
|
56
|
+
const originalFetch = globalThis.fetch;
|
|
57
|
+
const calls = [];
|
|
58
|
+
globalThis.fetch = async (_url, options = {}) => {
|
|
59
|
+
calls.push(options.method ?? 'GET');
|
|
60
|
+
return new Response(JSON.stringify(calls.length === 1 ? { activeRuns: [] } : { shutdown: true }), {
|
|
61
|
+
status: calls.length === 1 ? 200 : 202,
|
|
62
|
+
headers: { 'content-type': 'application/json' },
|
|
63
|
+
});
|
|
64
|
+
};
|
|
65
|
+
try {
|
|
66
|
+
const result = await shutdownOwnedRuntime(
|
|
67
|
+
{ url: 'http://127.0.0.1:7788', started: false, token: null },
|
|
68
|
+
{ timeoutMs: 100 },
|
|
69
|
+
);
|
|
70
|
+
assert.equal(result.action, 'shutdown');
|
|
71
|
+
assert.deepEqual(calls, ['GET', 'POST']);
|
|
72
|
+
} finally {
|
|
73
|
+
globalThis.fetch = originalFetch;
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('shutdownOwnedRuntime bounds an unresponsive shutdown', async () => {
|
|
78
|
+
const originalFetch = globalThis.fetch;
|
|
79
|
+
const logs = [];
|
|
80
|
+
globalThis.fetch = async (_url, { signal } = {}) => new Promise((_resolve, reject) => {
|
|
81
|
+
signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')), { once: true });
|
|
82
|
+
});
|
|
83
|
+
try {
|
|
84
|
+
const result = await shutdownOwnedRuntime(
|
|
85
|
+
{ url: 'http://127.0.0.1:7788', started: true, token: null },
|
|
86
|
+
{ log: (message) => logs.push(message), timeoutMs: 5 },
|
|
87
|
+
);
|
|
88
|
+
assert.equal(result.action, 'timeout');
|
|
89
|
+
assert.match(logs.join('\n'), /délai de fermeture/);
|
|
90
|
+
} finally {
|
|
91
|
+
globalThis.fetch = originalFetch;
|
|
92
|
+
}
|
|
93
|
+
});
|
package/src/runtime/client.js
CHANGED
|
@@ -38,9 +38,11 @@ export async function checkRuntimeHealth({
|
|
|
38
38
|
url = runtimeUrlFromEnv(),
|
|
39
39
|
token = runtimeToken(),
|
|
40
40
|
workspace = null,
|
|
41
|
+
signal = null,
|
|
41
42
|
} = {}) {
|
|
42
43
|
const response = await fetch(runtimeEndpoint(url, '/health', workspace), {
|
|
43
44
|
headers: runtimeHeaders(token),
|
|
45
|
+
signal,
|
|
44
46
|
});
|
|
45
47
|
if (!response.ok) return null;
|
|
46
48
|
return response.json();
|
|
@@ -143,10 +145,12 @@ export async function postRuntimeKill({
|
|
|
143
145
|
export async function postRuntimeShutdown({
|
|
144
146
|
url = runtimeUrlFromEnv(),
|
|
145
147
|
token = runtimeToken(),
|
|
148
|
+
signal = null,
|
|
146
149
|
} = {}) {
|
|
147
150
|
const response = await fetch(runtimeEndpoint(url, '/shutdown'), {
|
|
148
151
|
method: 'POST',
|
|
149
152
|
headers: runtimeHeaders(token),
|
|
153
|
+
signal,
|
|
150
154
|
});
|
|
151
155
|
if (!response.ok) throw new Error(`Runtime shutdown failed: HTTP ${response.status}`);
|
|
152
156
|
return response.json();
|
|
@@ -259,6 +259,8 @@ test('CME export is dispatched only from an approved DAG task', async () => {
|
|
|
259
259
|
assert.equal(result.reason, 'awaiting_approval');
|
|
260
260
|
assert.equal(executeCalls, 0);
|
|
261
261
|
assert.equal(session.headlessPlan[0].status, 'waiting_approval');
|
|
262
|
+
assert.ok(session.agentEvents.some((event) => event.type === 'approval.requested'
|
|
263
|
+
&& event.taskId === session.headlessPlan[0].id));
|
|
262
264
|
});
|
|
263
265
|
|
|
264
266
|
function buildSingleTaskAgent({ taskId, description, finalResponse }) {
|
package/src/runtime/lifecycle.js
CHANGED
|
@@ -127,22 +127,25 @@ async function waitForRuntimeShutdown(url, token, timeoutMs) {
|
|
|
127
127
|
}
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
-
export async function runtimeHealthOrNull(url = runtimeUrlFromEnv(), token = runtimeTokenFromEnv()) {
|
|
130
|
+
export async function runtimeHealthOrNull(url = runtimeUrlFromEnv(), token = runtimeTokenFromEnv(), signal = null) {
|
|
131
131
|
try {
|
|
132
|
-
return await checkRuntimeHealth({ url, token });
|
|
133
|
-
} catch {
|
|
132
|
+
return await checkRuntimeHealth({ url, token, signal });
|
|
133
|
+
} catch (err) {
|
|
134
|
+
if (signal?.aborted) throw err;
|
|
134
135
|
return null;
|
|
135
136
|
}
|
|
136
137
|
}
|
|
137
138
|
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
export async function shutdownOwnedRuntime(runtime, { log = (_message) => {} } = {}) {
|
|
143
|
-
if (!runtime?.url
|
|
139
|
+
// Close the runtime attached to the shell when it is idle, including a process
|
|
140
|
+
// reused at startup. Restricting cleanup to `started: true` left ownerless
|
|
141
|
+
// runtimes occupying port 7788 with another manager directory/token.
|
|
142
|
+
// An active run still survives the shell.
|
|
143
|
+
export async function shutdownOwnedRuntime(runtime, { log = (_message) => {}, timeoutMs = 3000 } = {}) {
|
|
144
|
+
if (!runtime?.url) return { action: 'kept', reason: 'unavailable' };
|
|
145
|
+
const controller = new AbortController();
|
|
146
|
+
const timeout = setTimeout(() => controller.abort(), Math.max(1, Number(timeoutMs) || 3000));
|
|
144
147
|
try {
|
|
145
|
-
const health = await runtimeHealthOrNull(runtime.url, runtime.token);
|
|
148
|
+
const health = await runtimeHealthOrNull(runtime.url, runtime.token, controller.signal);
|
|
146
149
|
if (!health) return { action: 'kept', reason: 'unreachable' };
|
|
147
150
|
const activeRuns = Array.isArray(health.activeRuns) ? health.activeRuns : [];
|
|
148
151
|
if (activeRuns.length > 0) {
|
|
@@ -152,10 +155,16 @@ export async function shutdownOwnedRuntime(runtime, { log = (_message) => {} } =
|
|
|
152
155
|
log(`runtime laissé actif : run en cours (${labels}) — il survivra à ce shell ; relance wiki-manager pour le retrouver.`);
|
|
153
156
|
return { action: 'kept', reason: 'run_active', activeRuns };
|
|
154
157
|
}
|
|
155
|
-
await postRuntimeShutdown({ url: runtime.url, token: runtime.token });
|
|
156
|
-
log('runtime arrêté (
|
|
158
|
+
await postRuntimeShutdown({ url: runtime.url, token: runtime.token, signal: controller.signal });
|
|
159
|
+
log('runtime arrêté (aucun run en cours).');
|
|
157
160
|
return { action: 'shutdown' };
|
|
158
161
|
} catch (err) {
|
|
162
|
+
if (controller.signal.aborted) {
|
|
163
|
+
log('délai de fermeture du runtime dépassé — le shell termine sans attendre davantage.');
|
|
164
|
+
return { action: 'timeout', reason: 'shutdown_timeout' };
|
|
165
|
+
}
|
|
159
166
|
return { action: 'error', reason: err instanceof Error ? err.message : String(err) };
|
|
167
|
+
} finally {
|
|
168
|
+
clearTimeout(timeout);
|
|
160
169
|
}
|
|
161
170
|
}
|