@dotdrelle/wiki-manager 0.15.29 → 0.15.32

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.
@@ -1,6 +1,6 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
- import { createDispatcher } from './dispatcher.js';
3
+ import { createDispatcher, normalizeTaskError } from './dispatcher.js';
4
4
 
5
5
  test('dispatcher returns a retryable logical failure when agent_execute reports workspace_busy', async () => {
6
6
  const session = {
@@ -159,3 +159,26 @@ test('dispatcher keeps a null error when a terminal status reports none', async
159
159
  assert.equal(result.ok, true);
160
160
  assert.equal(result.error, null);
161
161
  });
162
+
163
+ test('normalizeTaskError keeps the agent reason as the message, never the fallback', () => {
164
+ // Regression: the fallback describes only WHERE the failure was seen
165
+ // ("agent_execute rejected task"). Letting it win discarded the one
166
+ // actionable sentence — and left Donna to invent a cause.
167
+ const error = normalizeTaskError("Error: source 'acpi' not found", {
168
+ fallbackCode: 'execution_rejected',
169
+ fallbackMessage: 'agent_execute rejected task',
170
+ });
171
+
172
+ assert.equal(error.code, "Error: source 'acpi' not found");
173
+ assert.equal(error.message, "Error: source 'acpi' not found");
174
+ });
175
+
176
+ test('normalizeTaskError falls back only when the agent reports no reason at all', () => {
177
+ const error = normalizeTaskError('', {
178
+ fallbackCode: 'execution_rejected',
179
+ fallbackMessage: 'agent_execute rejected task',
180
+ });
181
+
182
+ assert.equal(error.code, 'execution_rejected');
183
+ assert.equal(error.message, 'agent_execute rejected task');
184
+ });
@@ -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, shutdownOwnedRuntime } from './lifecycle.js';
7
+ import { assertRuntimeNode, runtimeNodeExecutable } 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,67 +27,3 @@ 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
- });
@@ -146,7 +146,8 @@ test('CME setup and source configuration stay outside export orchestration', asy
146
146
  const calledTools = [];
147
147
  globalThis.fetch = async (_url, options = {}) => {
148
148
  const body = JSON.parse(String(options.body ?? '{}'));
149
- calledTools.push(body.params?.name);
149
+ // Record tool calls only — the MCP session handshake is not a tool call.
150
+ if (body.method === 'tools/call') calledTools.push(body.params?.name);
150
151
  return {
151
152
  ok: true,
152
153
  status: 200,
@@ -139,36 +139,3 @@ export async function runtimeHealthOrNull(url = runtimeUrlFromEnv(), token = run
139
139
  return null;
140
140
  }
141
141
  }
142
-
143
- // Close the runtime attached to the shell when it is idle, including a process
144
- // reused at startup. Restricting cleanup to `started: true` left ownerless
145
- // runtimes occupying port 7788 with another manager directory/token.
146
- // An active run still survives the shell.
147
- export async function shutdownOwnedRuntime(runtime, { log = (_message) => {}, timeoutMs = 3000 } = {}) {
148
- if (!runtime?.url) return { action: 'kept', reason: 'unavailable' };
149
- const controller = new AbortController();
150
- const timeout = setTimeout(() => controller.abort(), Math.max(1, Number(timeoutMs) || 3000));
151
- try {
152
- const health = await runtimeHealthOrNull(runtime.url, runtime.token, controller.signal);
153
- if (!health) return { action: 'kept', reason: 'unreachable' };
154
- const activeRuns = Array.isArray(health.activeRuns) ? health.activeRuns : [];
155
- if (activeRuns.length > 0) {
156
- const labels = activeRuns
157
- .map((run) => [run.workspace, run.runId ? String(run.runId).slice(0, 8) : null].filter(Boolean).join('/'))
158
- .join(', ');
159
- log(`runtime laissé actif : run en cours (${labels}) — il survivra à ce shell ; relance wiki-manager pour le retrouver.`);
160
- return { action: 'kept', reason: 'run_active', activeRuns };
161
- }
162
- await postRuntimeShutdown({ url: runtime.url, token: runtime.token, signal: controller.signal });
163
- log('runtime arrêté (aucun run en cours).');
164
- return { action: 'shutdown' };
165
- } catch (err) {
166
- if (controller.signal.aborted) {
167
- log('délai de fermeture du runtime dépassé — le shell termine sans attendre davantage.');
168
- return { action: 'timeout', reason: 'shutdown_timeout' };
169
- }
170
- return { action: 'error', reason: err instanceof Error ? err.message : String(err) };
171
- } finally {
172
- clearTimeout(timeout);
173
- }
174
- }
@@ -401,7 +401,16 @@ test('replanRuntimeRun requires runtime approval for mutating replanned work', a
401
401
  test('runRuntimeAgenticWorkflow replans after terminal activity error', async () => {
402
402
  const originalFetch = globalThis.fetch;
403
403
  let pollAttempts = 0;
404
- globalThis.fetch = async () => {
404
+ globalThis.fetch = async (_url, init) => {
405
+ // Count polls, not the one-off MCP session handshake.
406
+ if (JSON.parse(init.body).method === 'initialize') {
407
+ return {
408
+ ok: true,
409
+ status: 200,
410
+ headers: { get: () => null },
411
+ text: async () => '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2025-06-18"}}',
412
+ };
413
+ }
405
414
  pollAttempts += 1;
406
415
  return {
407
416
  ok: true,
@@ -93,7 +93,17 @@ test('pollActivitiesOnce does not repeat an unchanged retry state when retryAt m
93
93
  test('pollActivitiesOnce retries transient MCP poll failures', async () => {
94
94
  const originalFetch = globalThis.fetch;
95
95
  let attempts = 0;
96
- globalThis.fetch = async () => {
96
+ globalThis.fetch = async (_url, init) => {
97
+ // The session handshake is not the thing under test: answer it, and make
98
+ // the first *poll* the transient failure.
99
+ if (JSON.parse(init.body).method === 'initialize') {
100
+ return {
101
+ ok: true,
102
+ status: 200,
103
+ headers: { get: () => null },
104
+ text: async () => '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2025-06-18"}}',
105
+ };
106
+ }
97
107
  attempts += 1;
98
108
  if (attempts === 1) {
99
109
  return {
package/src/shell/tui.tsx CHANGED
@@ -150,30 +150,22 @@ function App(props: {
150
150
  const [preflightBusy, setPreflightBusy] = createSignal(false);
151
151
  let ctrlCTimer: ReturnType<typeof setTimeout> | null = null;
152
152
  let exiting = false;
153
- // Single exit path: the owned-runtime shutdown MUST happen here, on the
154
- // user's actual exit gesture. render() resolves at MOUNT, so code placed
155
- // after `await runOpenTuiShell(...)` runs while the shell is still on
156
- // screen — 0.12.9 shipped that and killed the runtime mid-session.
153
+ // The runtime is shared infrastructure: `llm-wiki serve` can still be using
154
+ // it after this shell closes. Exiting the UI only destroys the renderer;
155
+ // explicit lifecycle commands own runtime shutdown.
157
156
  const exitShell = () => {
158
157
  if (exiting) return;
159
158
  exiting = true;
160
159
  setExitStatus('Fermeture enclenchée…');
161
- // Register the complete shutdown before destroying the renderer: onDestroy
162
- // resolves runOpenTuiShell, which must still await this cleanup task.
163
160
  const task = Promise.resolve().then(async () => {
164
161
  renderer.destroy();
165
- console.log('[wiki-manager] fermeture enclenchée — nettoyage en cours…');
166
- const messages: string[] = [];
167
- try {
168
- if (props.runtime?.url) {
169
- const { shutdownOwnedRuntime } = await import('../runtime/lifecycle.js');
170
- await shutdownOwnedRuntime(props.runtime, { log: (message: string) => { messages.push(message); } });
171
- }
172
- } catch {
173
- // Best effort: never block the exit on runtime cleanup.
174
- }
175
- for (const message of messages) console.log(`[wiki-manager] ${message}`);
176
- console.log('[wiki-manager] fermeture terminée — shell clos proprement.');
162
+ console.log('[wiki-manager] shell closed; shared runtime left running.');
163
+ // MCP clients, image-refresh probes, and reconnect timers may still own
164
+ // event-loop handles. They belong to this shell process, not to the
165
+ // detached shared runtime. Once the renderer is gone there is no useful
166
+ // foreground work left, so terminate explicitly instead of leaving the
167
+ // terminal pending indefinitely.
168
+ process.exit(0);
177
169
  });
178
170
  if (props.exitRef) props.exitRef.current = task;
179
171
  };
package/wiki-workspace CHANGED
@@ -84,6 +84,7 @@ Usage:
84
84
  wiki-workspace [--cacert <path>] <command> ...
85
85
  wiki-workspace config <workspace> [path]
86
86
  wiki-workspace up <workspace>
87
+ wiki-workspace refresh
87
88
  wiki-workspace wiki <workspace> <command> [args...]
88
89
  wiki-workspace list
89
90
 
@@ -91,6 +92,7 @@ Commands:
91
92
  config <workspace> [path] Create workspace folder, auto-select ports, run wiki init
92
93
  up <workspace> [--open] Start workspace stack: wiki + production
93
94
  list List configured workspaces
95
+ refresh Stop runtime/stacks and remove their Compose images
94
96
 
95
97
  agents up [--pull] Start all external agents (cme, documents)
96
98
  agents down Stop all external agents
@@ -260,6 +262,31 @@ agents_compose() {
260
262
  local compose_env_args=()
261
263
  [[ -f "$MANAGER_ENV_FILE" ]] && compose_env_args+=(--env-file "$MANAGER_ENV_FILE")
262
264
 
265
+ # Local-iteration build only: the packaged connectors service carries a
266
+ # `build:` block pointing at the sibling agent-connectors repo, whose OAuth
267
+ # credentials live in a gitignored .env.build.local. Compose resolves the
268
+ # value-less build args from *this* process environment, not from
269
+ # --env-file, so the variables must be exported here or the image ships with
270
+ # empty WIKILLM_GOOGLE_OAUTH_* and every Google connector stays unusable.
271
+ local connectors_build_env="$ROOT_DIR/../agent-external/agent-connectors/.env.build.local"
272
+ if [[ -f "$connectors_build_env" ]]; then
273
+ local build_key build_value
274
+ while IFS='=' read -r build_key build_value; do
275
+ case "$build_key" in
276
+ WIKILLM_GOOGLE_OAUTH_CLIENT_ID|WIKILLM_GOOGLE_OAUTH_CLIENT_SECRET) ;;
277
+ *) continue ;;
278
+ esac
279
+ [[ -n "${!build_key:-}" ]] || export "$build_key=$build_value"
280
+ done < <(grep -v '^[[:space:]]*#' "$connectors_build_env" || true)
281
+ fi
282
+
283
+ require_connectors_build_credentials() {
284
+ local build_key
285
+ for build_key in WIKILLM_GOOGLE_OAUTH_CLIENT_ID WIKILLM_GOOGLE_OAUTH_CLIENT_SECRET; do
286
+ [[ -n "${!build_key:-}" ]] || die "cannot build connectors: $build_key is missing from $connectors_build_env"
287
+ done
288
+ }
289
+
263
290
  _agents_dc() {
264
291
  local cacert_args=()
265
292
  local profile_args=()
@@ -288,6 +315,9 @@ agents_compose() {
288
315
  [[ $# -eq 0 ]] || die "agents up accepts only --pull"
289
316
  ensure_agent_tokens
290
317
  ensure_endpoints_file
318
+ if connectors_enabled && [[ -f "$ROOT_DIR/../agent-external/agent-connectors/Dockerfile" ]]; then
319
+ require_connectors_build_credentials
320
+ fi
291
321
  # The .env may have just been created above — recompute the env-file
292
322
  # flag so freshly generated tokens reach the containers on first run.
293
323
  compose_env_args=()
@@ -314,6 +344,10 @@ agents_compose() {
314
344
  [[ $# -eq 0 ]] || die "agents down does not take arguments"
315
345
  _agents_dc down
316
346
  ;;
347
+ purge)
348
+ [[ $# -eq 0 ]] || die "agents purge does not take arguments"
349
+ _agents_dc down --rmi all --remove-orphans
350
+ ;;
317
351
  logs)
318
352
  local log_args=()
319
353
  read_lines_into_array log_args logs_args "$@"
@@ -687,6 +721,19 @@ runtime_command() {
687
721
  ;;
688
722
  down|stop)
689
723
  [[ $# -eq 0 ]] || die "runtime down does not take arguments"
724
+ token="$(ensure_manager_runtime_token)"
725
+ if runtime_health_check; then
726
+ RUNTIME_URL="$(runtime_health_url)/shutdown" RUNTIME_TOKEN="$token" "$node_bin" --input-type=module -e \
727
+ "const h=process.env.RUNTIME_TOKEN?{Authorization:'Bearer '+process.env.RUNTIME_TOKEN}:{}; const r=await fetch(process.env.RUNTIME_URL,{method:'POST',headers:h}); process.exit(r.ok?0:1);"
728
+ local wait_count=0
729
+ while runtime_health_check && (( wait_count < 40 )); do
730
+ sleep 0.1
731
+ (( wait_count++ )) || true
732
+ done
733
+ rm -f "$pid_file"
734
+ printf 'agent-runtime stopped\n'
735
+ return
736
+ fi
690
737
  if ! runtime_pid_running; then
691
738
  printf 'agent-runtime is not running from %s\n' "$pid_file"
692
739
  rm -f "$pid_file"
@@ -731,6 +778,23 @@ runtime_command() {
731
778
  esac
732
779
  }
733
780
 
781
+ refresh_project() {
782
+ printf 'Stopping shared runtime...\n'
783
+ runtime_command down
784
+
785
+ local entry workspace
786
+ for entry in "$WORKSPACES_DIR"/*; do
787
+ [[ -f "$entry/.env" ]] || continue
788
+ workspace="$(basename "$entry")"
789
+ printf 'Removing workspace stack and images: %s\n' "$workspace"
790
+ compose_for_workspace "$workspace" down --rmi all --remove-orphans
791
+ done
792
+
793
+ printf 'Removing external-agent stack and images...\n'
794
+ agents_compose purge
795
+ printf 'Refresh complete. Persistent workspace and agent data were preserved.\n'
796
+ }
797
+
734
798
  ensure_runtime_up() {
735
799
  if [[ "${WIKI_MANAGER_RUNTIME_AUTOSTART:-1}" == "0" ]]; then
736
800
  return
@@ -1214,6 +1278,11 @@ main() {
1214
1278
  exit 0
1215
1279
  fi
1216
1280
 
1281
+ if [[ $# -eq 1 && "$1" == "refresh" ]]; then
1282
+ refresh_project
1283
+ exit 0
1284
+ fi
1285
+
1217
1286
  # config <workspace> [path]
1218
1287
  if [[ $# -ge 2 && "$1" == "config" ]]; then
1219
1288
  local cfg_workspace="$2"