@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.
Files changed (43) hide show
  1. package/.env.example +11 -0
  2. package/docker-compose.yml +1 -1
  3. package/package.json +1 -1
  4. package/src/activity/activityAggregator.js +43 -13
  5. package/src/activity/activityAggregator.test.js +51 -2
  6. package/src/agent/graph.js +79 -11
  7. package/src/agent/graph.test.js +43 -3
  8. package/src/cli/wiki-manager.js +44 -3
  9. package/src/commands/slash.js +10 -3
  10. package/src/commands/slash.test.js +24 -0
  11. package/src/core/activity.js +4 -0
  12. package/src/core/activity.test.js +31 -0
  13. package/src/core/agentEvents.js +11 -2
  14. package/src/core/buildInfo.json +2 -2
  15. package/src/core/dockerCompose.test.js +4 -0
  16. package/src/core/env.js +16 -4
  17. package/src/core/env.test.js +3 -0
  18. package/src/core/mcp.js +1 -1
  19. package/src/core/wikiSetup.js +43 -1
  20. package/src/core/wikiWorkspace.test.js +20 -0
  21. package/src/core/workspaces.js +10 -2
  22. package/src/orchestrator/dependencyResolver.js +19 -1
  23. package/src/orchestrator/objectiveResolver.js +24 -0
  24. package/src/orchestrator/objectiveResolver.test.js +23 -1
  25. package/src/orchestrator/scheduler.test.js +22 -1
  26. package/src/runtime/auth.test.js +65 -1
  27. package/src/runtime/client.js +4 -0
  28. package/src/runtime/donna-contract.test.js +2 -0
  29. package/src/runtime/lifecycle.js +21 -12
  30. package/src/runtime/runner.js +130 -17
  31. package/src/runtime/runner.test.js +62 -1
  32. package/src/runtime/server.js +13 -2
  33. package/src/runtime/server.test.js +30 -1
  34. package/src/shell/FileEditorDialog.tsx +2 -2
  35. package/src/shell/LeftPane.tsx +60 -15
  36. package/src/shell/RightPane.tsx +147 -54
  37. package/src/shell/StartupScreen.tsx +3 -7
  38. package/src/shell/renderer.ts +1 -0
  39. package/src/shell/repl.js +7 -81
  40. package/src/shell/repl.test.js +128 -38
  41. package/src/shell/tui.tsx +59 -64
  42. package/src/shell/useSession.ts +45 -6
  43. package/wiki-workspace +36 -3
@@ -122,13 +122,22 @@ export function resetSessionProjection(session) {
122
122
 
123
123
  function withSessionRunIdentity(event, session) {
124
124
  const identity = session?._currentRunIdentity;
125
- if (!identity) return event;
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: event.workspace ?? identity.workspace ?? null,
140
+ workspace,
132
141
  };
133
142
  }
134
143
 
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.14.12",
3
- "commit": "621830d"
2
+ "version": "0.14.16",
3
+ "commit": "a603298"
4
4
  }
@@ -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 and by the host path it mounts into agent
103
- // containers — keeping them in sync avoids the two silently drifting apart.
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(defaultRuntimeStateDir(), configured);
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(defaultRuntimeStateDir(), 'agents-data');
120
+ return resolve(workspacesRootDir(), '.agents-data');
109
121
  }
110
122
 
111
123
  function parseEnvValue(value) {
@@ -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.12';
4
+ const WIKI_MANAGER_VERSION = '0.14.16';
5
5
 
6
6
  function envValue(key) {
7
7
  const filePath = managerEnvFile();
@@ -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
+ });
@@ -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
- cwd: managerRoot(),
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 === 'waiting_approval' || status === 'pending_approval')
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('Ingère tout', sessionWithSelection({ capability: 'ingest', operation: 'ingest_all_pending' })),
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']);
@@ -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
+ });
@@ -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 }) {
@@ -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
- // Contract with the user: the shell OWNS the runtime it started — leaving it
139
- // alive after exit produced zombie runtimes running yesterday's code and
140
- // yesterday's endpoints. Nuance preserved: if a run is active anywhere, the
141
- // runtime is left alive so the run survives the shell (that promise stays).
142
- export async function shutdownOwnedRuntime(runtime, { log = (_message) => {} } = {}) {
143
- if (!runtime?.url || !runtime?.started) return { action: 'kept', reason: 'not_owned' };
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é (démarré par ce shell, aucun run en cours).');
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
  }