@dotdrelle/wiki-manager 0.15.38 → 0.15.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -771,6 +771,11 @@ capabilityRouting:
771
771
  allowedAgents: [connectors]
772
772
  ```
773
773
 
774
+ The production agent also advertises `workspace.restore` for Git-backed
775
+ rollback. It is workspace-scoped and remains subject to the normal runtime
776
+ approval and lock checks; it can be pinned in the same way when several agents
777
+ provide that capability.
778
+
774
779
  #### Compose overrides — optional agents, proxies, local fixes
775
780
 
776
781
  Two override files sit under **`.wiki/compose/`**, one per stack:
@@ -124,7 +124,7 @@ services:
124
124
  - WORKSPACE_NAME=${WORKSPACE_NAME:-workspace}
125
125
  - WIKI_WORKSPACE_PATH=/workspace
126
126
  - WIKI_CONFIG_PATH=${WIKI_CONFIG_PATH:-}
127
- - PRODUCTION_ALLOWED_STEPS=${PRODUCTION_ALLOWED_STEPS:-doctor,ingest,ingest_plan,ingest_apply,build,export,polish,pipeline}
127
+ - PRODUCTION_ALLOWED_STEPS=${PRODUCTION_ALLOWED_STEPS:-doctor,ingest,ingest_plan,ingest_apply,build,export,polish,restore,pipeline}
128
128
  - PRODUCTION_REQUIRE_CONFIRMATION=${PRODUCTION_REQUIRE_CONFIRMATION:-false}
129
129
  # Parallelism levers — effective concurrency ≈ recommendedConcurrency.
130
130
  # Intermediate defaults (4/8). Low profile 2/4, high profile 8/16.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdrelle/wiki-manager",
3
- "version": "0.15.38",
3
+ "version": "0.15.40",
4
4
  "description": "Agentic shell and orchestration cockpit for llm-wiki workspaces.",
5
5
  "license": "PolyForm-Noncommercial-1.0.0",
6
6
  "author": "dotrelle",
@@ -1231,9 +1231,11 @@ async function runRuntime(argv, agent) {
1231
1231
  ),
1232
1232
  requireApprovalForMutations: body.capabilityPlan.requireApproval !== false,
1233
1233
  },
1234
- ...(Array.isArray(body.capabilityPlan.inputs) && body.capabilityPlan.inputs.length > 0
1235
- ? { arguments: { inputs: body.capabilityPlan.inputs } }
1236
- : {}),
1234
+ ...(body.capabilityPlan.arguments && typeof body.capabilityPlan.arguments === 'object'
1235
+ ? { arguments: body.capabilityPlan.arguments }
1236
+ : Array.isArray(body.capabilityPlan.inputs) && body.capabilityPlan.inputs.length > 0
1237
+ ? { arguments: { inputs: body.capabilityPlan.inputs } }
1238
+ : {}),
1237
1239
  })));
1238
1240
  if (!Array.isArray(fragment?.tasks) || fragment.tasks.length === 0) {
1239
1241
  dispatchAgentEvent(session, createAgentEvent('assistant_message', {
@@ -556,6 +556,7 @@ function applyEvent(state, event) {
556
556
  status: 'queued',
557
557
  createdAt: event.payload?.createdAt ?? event.ts,
558
558
  updatedAt: event.ts,
559
+ ...(event.payload?.capabilityPlan !== undefined ? { capabilityPlan: event.payload.capabilityPlan } : {}),
559
560
  });
560
561
  return;
561
562
  case 'control_started':
@@ -364,6 +364,38 @@ test('reduceAgentEvents: control queue is event sourced and follows run status',
364
364
  assert.equal(projection.controlQueue[1].status, 'cancelled');
365
365
  });
366
366
 
367
+ test('reduceAgentEvents: control_enqueued preserves a structured capabilityPlan across replay', () => {
368
+ const capabilityPlan = {
369
+ capability: 'workspace.restore',
370
+ operation: 'restore',
371
+ arguments: { run: 'abc123' },
372
+ requireApproval: true,
373
+ };
374
+ const projection = reduceAgentEvents([
375
+ createAgentEvent('control_enqueued', {
376
+ origin: 'runtime',
377
+ workspace: 'docs',
378
+ payload: {
379
+ id: 'control-restore',
380
+ workspace: 'docs',
381
+ input: 'restore',
382
+ createdAt: '2026-01-01T00:00:00.000Z',
383
+ capabilityPlan,
384
+ },
385
+ }),
386
+ createAgentEvent('control_started', {
387
+ origin: 'runtime',
388
+ runId: 'run-control-restore',
389
+ workspace: 'docs',
390
+ payload: { id: 'control-restore', runId: 'run-control-restore' },
391
+ }),
392
+ ]);
393
+
394
+ const item = projection.controlQueue.find((entry) => entry.id === 'control-restore');
395
+ assert.deepEqual(item.capabilityPlan, capabilityPlan);
396
+ assert.equal(item.status, 'running');
397
+ });
398
+
367
399
  test('reduceAgentEvents: activity-owned plan is used when no orchestrator plan exists', () => {
368
400
  const projection = reduceAgentEvents([
369
401
  createAgentEvent('activity_upserted', {
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.15.38",
3
- "commit": "abf8356"
2
+ "version": "0.15.40",
3
+ "commit": "49ea8ff"
4
4
  }
@@ -19,6 +19,14 @@ test('workspace compose does not start a per-workspace agent runtime', async ()
19
19
  );
20
20
  });
21
21
 
22
+ test('workspace production agent enables restore by default', async () => {
23
+ const raw = await readFile(new URL('../../docker-compose.yml', import.meta.url), 'utf8');
24
+ const compose = YAML.parse(raw);
25
+ const allowed = compose.services['production-mcp'].environment
26
+ .find((entry) => String(entry).startsWith('PRODUCTION_ALLOWED_STEPS='));
27
+ assert.match(String(allowed), /(?:^|,)restore(?:,|})/);
28
+ });
29
+
22
30
  test('shipped compose files never carry a build context', async () => {
23
31
  // Ces deux fichiers partent dans le paquet npm, où les dépôts frères
24
32
  // (`../agent-external/…`) n'existent pas : un `build:` y rend toute commande
package/src/core/mcp.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { managerEnvFile, managerMcpEndpointsFile, readEnvFile } from './env.js';
3
3
 
4
- const WIKI_MANAGER_VERSION = '0.15.38';
4
+ const WIKI_MANAGER_VERSION = '0.15.40';
5
5
 
6
6
  function envValue(key) {
7
7
  const filePath = managerEnvFile();
@@ -53,7 +53,7 @@ export async function execute(task, assignment, {
53
53
  session.mcp,
54
54
  serverName,
55
55
  executeTool,
56
- executeRequest(task, session),
56
+ executeRequest(task, session, runId),
57
57
  signal,
58
58
  ));
59
59
  if (accepted?.accepted === false || accepted?.ok === false) {
@@ -157,9 +157,11 @@ export async function execute(task, assignment, {
157
157
  }
158
158
  }
159
159
 
160
- function executeRequest(task, session) {
160
+ function executeRequest(task, session, runId) {
161
161
  return {
162
162
  taskId: String(task.id ?? task.step),
163
+ ...(runId ? { runId: String(runId) } : {}),
164
+ ...(task.requiredCapability ? { capability: String(task.requiredCapability) } : {}),
163
165
  idempotencyKey: task.idempotencyKey ?? undefined,
164
166
  operation: task.operation,
165
167
  workspace: workspaceRequest(session),
@@ -33,6 +33,39 @@ test('dispatcher returns a retryable logical failure when agent_execute reports
33
33
  assert.equal(result.error.retryable, true);
34
34
  });
35
35
 
36
+ test('dispatcher forwards the orchestration run and required capability to agent_execute', async () => {
37
+ let executeArgs;
38
+ const session = {
39
+ workspace: 'test',
40
+ mcp: {
41
+ production: {
42
+ tools: [{ name: 'agent_execute' }, { name: 'agent_status' }, { name: 'agent_cancel' }],
43
+ },
44
+ },
45
+ activities: {},
46
+ };
47
+ const dispatcher = createDispatcher({
48
+ session,
49
+ pollIntervalMs: 1,
50
+ callTool: async (_mcp, _server, tool, args) => {
51
+ if (tool === 'agent_execute') {
52
+ executeArgs = args;
53
+ return { accepted: true, jobId: 'job-build', status: 'queued' };
54
+ }
55
+ return { jobId: 'job-build', status: 'succeeded', terminal: true, result: { status: 'succeeded' } };
56
+ },
57
+ });
58
+
59
+ await dispatcher.execute(
60
+ { id: 'build-a', requiredCapability: 'document.build', operation: 'build', arguments: {} },
61
+ { serverName: 'production', agentInstanceId: 'production-main' },
62
+ { runId: 'run-donna-1', attempt: { attemptId: 'build-a:attempt-1', locks: [], release() {} } },
63
+ );
64
+
65
+ assert.equal(executeArgs.runId, 'run-donna-1');
66
+ assert.equal(executeArgs.capability, 'document.build');
67
+ });
68
+
36
69
  test('dispatcher completes when an executor-only agent reports succeeded', async () => {
37
70
  const session = {
38
71
  workspace: 'test',
@@ -283,6 +283,27 @@ export function startRuntimeServer({
283
283
  }
284
284
  validateContractInDev('runRequest', { ...body, input });
285
285
  if (context.running) {
286
+ // A structured capability plan must never degrade into a free-text
287
+ // control message: its exact arguments and approval policy would
288
+ // otherwise be discarded by the control lane. It can still be
289
+ // queued as a future run when the caller explicitly opts in via
290
+ // intent: 'enqueue' — the control queue carries capabilityPlan
291
+ // through untouched (see enqueueControlRequest/
292
+ // startNextControlRequest) instead of collapsing it to text.
293
+ if (body.capabilityPlan) {
294
+ if (body.intent === 'enqueue') {
295
+ const item = enqueueControlRequest(context, input, { capabilityPlan: body.capabilityPlan });
296
+ void startNextControlRequest(context);
297
+ sendJson(response, 202, {
298
+ accepted: true,
299
+ item,
300
+ ...controlStatus(context, store),
301
+ });
302
+ return;
303
+ }
304
+ sendJson(response, 409, { error: 'run_active' });
305
+ return;
306
+ }
286
307
  // Blind enqueueing made every message typed during a run
287
308
  // invisible until the run ended (serve UI symptom: "no result
288
309
  // until the job finished or was stopped"). Classify instead:
@@ -630,6 +651,7 @@ export function startRuntimeServer({
630
651
  startRuntimeRun(context, {
631
652
  input: item.input,
632
653
  workspace: item.workspace ?? context.workspace ?? null,
654
+ ...(item.capabilityPlan !== undefined ? { capabilityPlan: item.capabilityPlan } : {}),
633
655
  }, { controlItemId: item.id });
634
656
  return true;
635
657
  }
@@ -843,7 +865,7 @@ async function handleControlMessage(context, store, input, { intent = null, star
843
865
  : controlMessage(context?.session, 'converse_while_idle'));
844
866
  }
845
867
 
846
- function enqueueControlRequest(context, input) {
868
+ function enqueueControlRequest(context, input, { capabilityPlan } = {}) {
847
869
  const now = new Date().toISOString();
848
870
  const item = {
849
871
  id: `control-${randomUUID()}`,
@@ -853,6 +875,7 @@ function enqueueControlRequest(context, input) {
853
875
  status: 'queued',
854
876
  createdAt: now,
855
877
  updatedAt: now,
878
+ ...(capabilityPlan !== undefined ? { capabilityPlan } : {}),
856
879
  };
857
880
  dispatchAgentEvent(context.session, createAgentEvent('control_enqueued', {
858
881
  origin: 'runtime',
@@ -347,6 +347,119 @@ test('runtime server accepts only one active run', async (t) => {
347
347
  }
348
348
  });
349
349
 
350
+ test('runtime server rejects a structured capability plan while a run is active', async (t) => {
351
+ let releaseRun;
352
+ let handle;
353
+ try {
354
+ handle = await startRuntimeServer({
355
+ host: '127.0.0.1',
356
+ port: 0,
357
+ store: {
358
+ dbPath: ':memory:',
359
+ getState: () => ({ status: 'idle' }),
360
+ listEvents: () => [],
361
+ },
362
+ session: {},
363
+ run: async () => new Promise((resolve) => { releaseRun = resolve; }),
364
+ });
365
+ } catch (err) {
366
+ if (err?.code === 'EPERM') {
367
+ t.skip('network listen is not permitted in this sandbox');
368
+ return;
369
+ }
370
+ throw err;
371
+ }
372
+
373
+ try {
374
+ const url = `http://127.0.0.1:${handle.port}/run`;
375
+ const first = await fetch(url, {
376
+ method: 'POST',
377
+ headers: { 'Content-Type': 'application/json' },
378
+ body: JSON.stringify({ input: 'active build' }),
379
+ });
380
+ assert.equal(first.status, 202);
381
+
382
+ const restore = await fetch(url, {
383
+ method: 'POST',
384
+ headers: { 'Content-Type': 'application/json' },
385
+ body: JSON.stringify({
386
+ input: 'restore',
387
+ capabilityPlan: {
388
+ capability: 'workspace.restore',
389
+ operation: 'restore',
390
+ arguments: { run: 'abc123' },
391
+ requireApproval: true,
392
+ },
393
+ }),
394
+ });
395
+ assert.equal(restore.status, 409);
396
+ assert.deepEqual(await restore.json(), { error: 'run_active' });
397
+ } finally {
398
+ releaseRun?.();
399
+ await handle.close();
400
+ }
401
+ });
402
+
403
+ test('runtime server queues a structured capability plan when intent is enqueue', async (t) => {
404
+ let releaseRun;
405
+ let handle;
406
+ try {
407
+ handle = await startRuntimeServer({
408
+ host: '127.0.0.1',
409
+ port: 0,
410
+ store: {
411
+ dbPath: ':memory:',
412
+ getState: () => ({ status: 'idle' }),
413
+ listEvents: () => [],
414
+ },
415
+ session: {},
416
+ run: async () => new Promise((resolve) => { releaseRun = resolve; }),
417
+ });
418
+ } catch (err) {
419
+ if (err?.code === 'EPERM') {
420
+ t.skip('network listen is not permitted in this sandbox');
421
+ return;
422
+ }
423
+ throw err;
424
+ }
425
+
426
+ try {
427
+ const url = `http://127.0.0.1:${handle.port}/run`;
428
+ const first = await fetch(url, {
429
+ method: 'POST',
430
+ headers: { 'Content-Type': 'application/json' },
431
+ body: JSON.stringify({ input: 'active build' }),
432
+ });
433
+ assert.equal(first.status, 202);
434
+
435
+ const capabilityPlan = {
436
+ capability: 'workspace.restore',
437
+ operation: 'restore',
438
+ arguments: { run: 'abc123' },
439
+ requireApproval: true,
440
+ };
441
+ const restore = await fetch(url, {
442
+ method: 'POST',
443
+ headers: { 'Content-Type': 'application/json' },
444
+ body: JSON.stringify({ input: 'restore', intent: 'enqueue', capabilityPlan }),
445
+ });
446
+ assert.equal(restore.status, 202);
447
+ const restoreBody = await restore.json();
448
+ assert.equal(restoreBody.accepted, true);
449
+ assert.deepEqual(restoreBody.item.capabilityPlan, capabilityPlan);
450
+ assert.equal(restoreBody.item.status, 'queued');
451
+
452
+ const status = await fetch(`http://127.0.0.1:${handle.port}/control`);
453
+ const statusBody = await status.json();
454
+ const queued = statusBody.controlQueue.find((item) => item.id === restoreBody.item.id);
455
+ assert.ok(queued, 'queued item should be visible in control status');
456
+ assert.deepEqual(queued.capabilityPlan, capabilityPlan);
457
+ } finally {
458
+ releaseRun?.();
459
+ await handle.close();
460
+ }
461
+ });
462
+
350
463
  test('runtime server returns the accepted run id and passes it to the runner', async (t) => {
351
464
  let receivedBody = null;
352
465
  let handle;