@dotdrelle/wiki-manager 0.9.3 → 0.11.0

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,8 @@
1
1
  import { createServer } from 'node:http';
2
- import { randomUUID } from 'node:crypto';
2
+ import { randomUUID, timingSafeEqual } from 'node:crypto';
3
3
  import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
4
+ import { normalizePlanPatch, rebasePlanPatch } from '../core/planPatch.js';
5
+ import { validateContractInDev } from '../contracts/schemas.js';
4
6
  import { runtimeTokenFromEnv } from './auth.js';
5
7
 
6
8
  export function startRuntimeServer({
@@ -18,7 +20,7 @@ export function startRuntimeServer({
18
20
  useConfigProfile,
19
21
  } = {}) {
20
22
  const clients = new Set();
21
- const defaultContext = { workspace: null, session, running: false, currentAbortController: null };
23
+ const defaultContext = { workspace: null, session, running: false, currentAbortController: null, currentRunId: null };
22
24
  const resolvedGetContext = getContext ?? (() => defaultContext);
23
25
 
24
26
  function publish(event) {
@@ -51,7 +53,7 @@ export function startRuntimeServer({
51
53
  if (request.method === 'GET' && url.pathname === '/state') {
52
54
  const workspace = workspaceFromUrl(url);
53
55
  const context = workspace ? await resolveContext({ workspace }) : null;
54
- sendJson(response, 200, store.getState(context?.session ?? session, { workspace }));
56
+ sendJson(response, 200, runtimeState(context, store, { workspace, session }));
55
57
  return;
56
58
  }
57
59
  if (request.method === 'GET' && url.pathname === '/events') {
@@ -59,6 +61,19 @@ export function startRuntimeServer({
59
61
  sendJson(response, 200, { events: store.listEvents({ workspace }) });
60
62
  return;
61
63
  }
64
+ if (request.method === 'GET' && url.pathname === '/audit') {
65
+ const workspace = workspaceFromUrl(url);
66
+ const runId = url.searchParams.get('runId') ?? null;
67
+ sendJson(response, 200, {
68
+ ok: true,
69
+ workspace,
70
+ runId,
71
+ audit: typeof store.listAuditTrail === 'function'
72
+ ? store.listAuditTrail({ workspace, runId })
73
+ : [],
74
+ });
75
+ return;
76
+ }
62
77
  if (request.method === 'GET' && url.pathname === '/control') {
63
78
  const workspace = workspaceFromUrl(url);
64
79
  const context = await resolveContext({ workspace });
@@ -69,20 +84,54 @@ export function startRuntimeServer({
69
84
  const { body, context } = await resolveBodyContext(request, url);
70
85
  const action = String(body.action ?? 'status').trim().toLowerCase();
71
86
  if (action === 'status') {
87
+ validateContractInDev('controlMessage', { ...body, action });
72
88
  sendJson(response, 200, controlStatus(context, store));
73
89
  return;
74
90
  }
75
91
  if (action === 'explain') {
92
+ validateContractInDev('controlMessage', { ...body, action });
76
93
  const status = controlStatus(context, store);
77
94
  sendJson(response, 200, { ...status, explanation: explainControlState(status) });
78
95
  return;
79
96
  }
97
+ if (action === 'message') {
98
+ const input = String(body.input ?? body.message ?? body.prompt ?? body.request ?? '').trim();
99
+ if (!input) {
100
+ sendJson(response, 400, { error: 'Missing input.' });
101
+ return;
102
+ }
103
+ validateContractInDev('controlMessage', { ...body, action, input });
104
+ const result = handleControlMessage(context, store, input, {
105
+ intent: body.intent,
106
+ startNextControlRequest,
107
+ });
108
+ sendJson(response, result.statusCode, result.body);
109
+ return;
110
+ }
111
+ if (action === 'approve_patch') {
112
+ const patchId = readRequiredPatchId(body, response);
113
+ if (!patchId) return;
114
+ validateContractInDev('controlMessage', { ...body, action, patchId });
115
+ const result = approvePlanPatch(context, store, patchId);
116
+ sendJson(response, result.statusCode, result.body);
117
+ return;
118
+ }
119
+ if (action === 'reject_patch') {
120
+ const patchId = readRequiredPatchId(body, response);
121
+ if (!patchId) return;
122
+ const reason = String(body.reason ?? 'rejected_by_user');
123
+ validateContractInDev('controlMessage', { ...body, action, patchId, reason });
124
+ const result = rejectPlanPatch(context, store, patchId, reason);
125
+ sendJson(response, result.statusCode, result.body);
126
+ return;
127
+ }
80
128
  if (action === 'enqueue') {
81
129
  const input = String(body.input ?? body.prompt ?? body.request ?? '').trim();
82
130
  if (!input) {
83
131
  sendJson(response, 400, { error: 'Missing input.' });
84
132
  return;
85
133
  }
134
+ validateContractInDev('controlMessage', { ...body, action, input });
86
135
  const item = enqueueControlRequest(context, input);
87
136
  void startNextControlRequest(context);
88
137
  sendJson(response, 202, {
@@ -134,7 +183,7 @@ export function startRuntimeServer({
134
183
  Connection: 'keep-alive',
135
184
  'X-Accel-Buffering': 'no',
136
185
  });
137
- response.write(`event: state\ndata: ${JSON.stringify(store.getState(context?.session ?? session, { workspace }))}\n\n`);
186
+ response.write(`event: state\ndata: ${JSON.stringify(runtimeState(context, store, { workspace, session }))}\n\n`);
138
187
  const client = { response, workspace };
139
188
  clients.add(client);
140
189
  request.on('close', () => clients.delete(client));
@@ -152,6 +201,7 @@ export function startRuntimeServer({
152
201
  sendJson(response, 400, { error: 'Missing input.' });
153
202
  return;
154
203
  }
204
+ validateContractInDev('runRequest', { ...body, input });
155
205
  const accepted = startRuntimeRun(context, body);
156
206
  sendJson(response, 202, accepted);
157
207
  } catch (err) {
@@ -236,6 +286,8 @@ export function startRuntimeServer({
236
286
  const runWorkspace = context.workspace ?? body.workspace ?? null;
237
287
  context.running = true;
238
288
  context.currentAbortController = new AbortController();
289
+ context.currentRunId = runId;
290
+ context.currentRunWorkspace = runWorkspace;
239
291
  const runBody = { ...body, workspace: runWorkspace, runId };
240
292
  if (controlItemId) {
241
293
  dispatchAgentEvent(context.session, createAgentEvent('control_started', {
@@ -253,6 +305,8 @@ export function startRuntimeServer({
253
305
  .finally(() => {
254
306
  context.running = false;
255
307
  context.currentAbortController = null;
308
+ context.currentRunId = null;
309
+ context.currentRunWorkspace = null;
256
310
  void startNextControlRequest(context);
257
311
  });
258
312
  return { accepted: true, runId, workspace: runWorkspace };
@@ -282,23 +336,31 @@ function workspaceFromBody(body) {
282
336
 
283
337
  function controlStatus(context, store) {
284
338
  const workspace = context?.workspace ?? context?.session?.workspace ?? null;
285
- const state = store.getState(context?.session ?? null, { workspace });
339
+ const state = runtimeState(context, store, { workspace });
286
340
  return {
287
341
  ok: true,
288
- workspace,
289
- status: context?.running ? 'running' : state.status ?? 'idle',
342
+ ...state,
343
+ workspace: state.workspace ?? workspace,
290
344
  running: Boolean(context?.running),
291
- plan: Array.isArray(state.plan) ? state.plan : [],
292
- queue: Array.isArray(state.queue) ? state.queue : [],
293
345
  controlQueue: controlQueueFor(context?.session),
294
- approvals: Array.isArray(state.approvals) ? state.approvals : [],
295
- summary: state.summary ?? null,
346
+ };
347
+ }
348
+
349
+ function runtimeState(context, store, { workspace = null, session = null } = {}) {
350
+ const state = store.getState(context?.session ?? session ?? null, { workspace });
351
+ return {
352
+ ...state,
353
+ status: context?.running ? 'running' : state.status ?? 'idle',
354
+ running: Boolean(context?.running),
355
+ runId: context?.currentRunId ?? state.runId ?? null,
356
+ workspace: context?.currentRunWorkspace ?? context?.workspace ?? state.workspace ?? workspace ?? null,
296
357
  };
297
358
  }
298
359
 
299
360
  function explainControlState(status) {
361
+ const plan = Array.isArray(status.plan) ? status.plan : [];
300
362
  if (status.running) {
301
- const runningStep = status.plan.find((step) => step.status === 'running');
363
+ const runningStep = plan.find((step) => step.status === 'running');
302
364
  return runningStep
303
365
  ? `Runtime run is active. Current step: ${runningStep.description ?? runningStep.label ?? runningStep.step}.`
304
366
  : 'Runtime run is active. No current plan step is available yet.';
@@ -311,7 +373,7 @@ function explainControlState(status) {
311
373
  if (queued.length > 0) {
312
374
  return `${queued.length} control request${queued.length === 1 ? '' : 's'} queued. They are not applied to the active plan automatically.`;
313
375
  }
314
- if (status.plan.some((step) => step.status === 'pending')) {
376
+ if (plan.some((step) => step.status === 'pending')) {
315
377
  return 'Runtime is idle with pending plan steps visible from the last run.';
316
378
  }
317
379
  return 'Runtime is idle.';
@@ -321,6 +383,67 @@ function controlQueueFor(session) {
321
383
  return Array.isArray(session?.controlQueue) ? session.controlQueue : [];
322
384
  }
323
385
 
386
+ function readOnlyControlResponse(kind, classification, status, explanation, { accepted = true, extra = {} } = {}) {
387
+ return {
388
+ statusCode: 200,
389
+ body: { accepted, kind, classification, ...status, explanation, ...extra },
390
+ };
391
+ }
392
+
393
+ function handleControlMessage(context, store, input, { intent = null, startNextControlRequest = () => false } = {}) {
394
+ const status = controlStatus(context, store);
395
+ const classification = classifyControlMessage(input, status, intent);
396
+ if (classification.kind === 'observe') {
397
+ return readOnlyControlResponse('observe', classification, status, explainControlState(status));
398
+ }
399
+ if (classification.kind === 'mutate') {
400
+ const proposal = storeControlProposal(context, input, classification, status);
401
+ return {
402
+ statusCode: 202,
403
+ body: {
404
+ accepted: true,
405
+ kind: 'mutate',
406
+ classification,
407
+ proposal,
408
+ ...controlStatus(context, store),
409
+ explanation: 'Plan patch proposed. Approve it explicitly to apply it to the active plan.',
410
+ },
411
+ };
412
+ }
413
+ if (classification.kind === 'enqueue') {
414
+ const item = enqueueControlRequest(context, input);
415
+ // Unlike `mutate`, this may synchronously start a queued run (see
416
+ // startNextControlRequest), which can change running/plan/status — a full
417
+ // controlStatus() recompute is required here, not just controlQueue.
418
+ void startNextControlRequest(context);
419
+ return {
420
+ statusCode: 202,
421
+ body: {
422
+ accepted: true,
423
+ kind: 'enqueue',
424
+ classification,
425
+ item,
426
+ ...controlStatus(context, store),
427
+ },
428
+ };
429
+ }
430
+ if (classification.kind === 'ambiguous') {
431
+ return readOnlyControlResponse('ambiguous', classification, status, 'The runtime cannot safely classify this message.', {
432
+ accepted: false,
433
+ extra: {
434
+ choices: [
435
+ { action: 'message', intent: 'observe', label: 'Ask about this run' },
436
+ { action: 'message', intent: 'mutate', label: 'Propose a change to this run' },
437
+ { action: 'enqueue', intent: 'enqueue', label: 'Queue as a future run' },
438
+ ],
439
+ },
440
+ });
441
+ }
442
+ return readOnlyControlResponse('converse', classification, status, status.running
443
+ ? 'Runtime run is still active. This message was treated as conversation and did not create a queued run.'
444
+ : 'Runtime is idle. This message was treated as conversation and did not create a run.');
445
+ }
446
+
324
447
  function enqueueControlRequest(context, input) {
325
448
  const now = new Date().toISOString();
326
449
  const item = {
@@ -340,11 +463,186 @@ function enqueueControlRequest(context, input) {
340
463
  return item;
341
464
  }
342
465
 
466
+ function storeControlProposal(context, input, classification, status) {
467
+ const now = new Date().toISOString();
468
+ const patch = buildPlanPatchFromInput(input, status);
469
+ const proposal = {
470
+ id: `proposal-${randomUUID()}`,
471
+ workspace: context?.workspace ?? context?.session?.workspace ?? null,
472
+ type: 'active_plan_mutation',
473
+ input,
474
+ status: 'proposed',
475
+ reason: classification.reason,
476
+ patch,
477
+ createdAt: now,
478
+ updatedAt: now,
479
+ };
480
+ dispatchAgentEvent(context.session, createAgentEvent('control_message_received', {
481
+ origin: 'runtime',
482
+ runId: context.currentRunId ?? status.runId ?? null,
483
+ workspace: proposal.workspace,
484
+ payload: { input, intent: 'mutate', classification },
485
+ }));
486
+ dispatchAgentEvent(context.session, createAgentEvent('plan_patch_proposed', {
487
+ origin: 'runtime',
488
+ runId: context.currentRunId ?? status.runId ?? null,
489
+ workspace: proposal.workspace,
490
+ payload: {
491
+ id: proposal.id,
492
+ input,
493
+ patch,
494
+ },
495
+ }));
496
+ return proposal;
497
+ }
498
+
499
+ function buildPlanPatchFromInput(input, status) {
500
+ const plan = Array.isArray(status.plan) ? status.plan : [];
501
+ const doneIds = plan.filter((step) => step.status === 'done').map((step) => String(step.id ?? step.step));
502
+ const active = plan.find((step) => step.status === 'running')
503
+ ?? plan.find((step) => step.status === 'pending')
504
+ ?? plan.at(-1);
505
+ const dependsOn = active ? [String(active.id ?? active.step)] : doneIds.slice(-1);
506
+ const description = String(input).replace(/\s+/g, ' ').trim();
507
+ return normalizePlanPatch({
508
+ targetRunId: status.runId ?? null,
509
+ basePlanRevision: status.planRevision ?? 0,
510
+ reason: 'control_mutate',
511
+ operations: [{
512
+ op: 'add_task',
513
+ task: {
514
+ id: `task-${randomUUID().slice(0, 8)}`,
515
+ description,
516
+ dependsOn: dependsOn.filter(Boolean),
517
+ executorQuery: { capability: description },
518
+ },
519
+ }],
520
+ });
521
+ }
522
+
523
+ function approvePlanPatch(context, store, patchId) {
524
+ const status = controlStatus(context, store);
525
+ const proposal = status.planPatches.find((patch) => patch.id === patchId);
526
+ if (!proposal) {
527
+ return { statusCode: 404, body: { accepted: false, error: 'Plan patch proposal not found.' } };
528
+ }
529
+ if (proposal.status === 'applied' || proposal.status === 'rejected') {
530
+ // Idempotency guard: re-running applyPlanPatch here would hit
531
+ // duplicate_task_id for an already-applied add_task patch, and the
532
+ // plan_patch_applied reducer would then overwrite status back to
533
+ // 'rejected' even though the original application is still in effect.
534
+ return {
535
+ statusCode: 409,
536
+ body: { accepted: false, error: `Plan patch already ${proposal.status}.`, patchId, status: proposal.status },
537
+ };
538
+ }
539
+ const currentRevision = status.planRevision ?? 0;
540
+ let patch = proposal.patch;
541
+ if (!patch) {
542
+ return { statusCode: 400, body: { accepted: false, error: 'Plan patch proposal has no patch.' } };
543
+ }
544
+ if (patch.basePlanRevision !== currentRevision) {
545
+ patch = rebasePlanPatch(patch, { currentRevision });
546
+ dispatchAgentEvent(context.session, createAgentEvent('plan_patch_rebased', {
547
+ origin: 'runtime',
548
+ runId: context.currentRunId ?? status.runId ?? null,
549
+ workspace: status.workspace ?? context.workspace ?? null,
550
+ payload: { patchId, patch },
551
+ }));
552
+ }
553
+ dispatchAgentEvent(context.session, createAgentEvent('plan_patch_approved', {
554
+ origin: 'runtime',
555
+ runId: context.currentRunId ?? status.runId ?? null,
556
+ workspace: status.workspace ?? context.workspace ?? null,
557
+ payload: { patchId },
558
+ }));
559
+ dispatchAgentEvent(context.session, createAgentEvent('plan_patch_applied', {
560
+ origin: 'runtime',
561
+ runId: context.currentRunId ?? status.runId ?? null,
562
+ workspace: status.workspace ?? context.workspace ?? null,
563
+ payload: { patchId, patch },
564
+ }));
565
+ return {
566
+ statusCode: 202,
567
+ body: {
568
+ accepted: true,
569
+ kind: 'approve_patch',
570
+ patchId,
571
+ ...controlStatus(context, store),
572
+ },
573
+ };
574
+ }
575
+
576
+ function rejectPlanPatch(context, store, patchId, reason) {
577
+ const status = controlStatus(context, store);
578
+ const proposal = status.planPatches.find((patch) => patch.id === patchId);
579
+ if (!proposal) {
580
+ return { statusCode: 404, body: { accepted: false, error: 'Plan patch proposal not found.' } };
581
+ }
582
+ if (proposal.status === 'applied' || proposal.status === 'rejected') {
583
+ return {
584
+ statusCode: 409,
585
+ body: { accepted: false, error: `Plan patch already ${proposal.status}.`, patchId, status: proposal.status },
586
+ };
587
+ }
588
+ dispatchAgentEvent(context.session, createAgentEvent('plan_patch_rejected', {
589
+ origin: 'runtime',
590
+ runId: context.currentRunId ?? status.runId ?? null,
591
+ workspace: status.workspace ?? context.workspace ?? null,
592
+ payload: { patchId, reason },
593
+ }));
594
+ return {
595
+ statusCode: 200,
596
+ body: { accepted: true, kind: 'reject_patch', patchId, ...controlStatus(context, store) },
597
+ };
598
+ }
599
+
600
+ // Interim classifier for control §4.2 of the plan directeur: the plan expects
601
+ // an LLM-backed classification eventually ("la classification LLM se
602
+ // trompera" — the plan's own fallback-UX rule presupposes an LLM). This is a
603
+ // synchronous keyword/regex stand-in with the same {kind, confidence, reason}
604
+ // contract, so swapping in an LLM call later shouldn't require touching
605
+ // handleControlMessage.
606
+ function classifyControlMessage(input, status, forcedIntent = null) {
607
+ // Caller (the /control message route) already trims and rejects empty input.
608
+ const lower = String(input ?? '').toLowerCase();
609
+ const intent = forcedIntent ? String(forcedIntent).toLowerCase() : null;
610
+ if (['observe', 'converse', 'mutate', 'enqueue'].includes(intent)) {
611
+ return { kind: intent, confidence: 1, reason: 'explicit_intent' };
612
+ }
613
+ if (/\b(o[uù] en est|status|statut|progress|progression|build|run|job|queue|file|logs?|explique|explain|inspect|show|montre|quoi de neuf)\b/i.test(lower)) {
614
+ return { kind: 'observe', confidence: 0.86, reason: 'status_or_explanation_request' };
615
+ }
616
+ if (status.running && /\b(ajoute|add|change|modifie|modify|remplace|replace|retire|remove|skip|ignore|apr[eè]s|before|after|chaque|each|plan|step|t[aâ]che)\b/i.test(lower)) {
617
+ return { kind: 'mutate', confidence: 0.78, reason: 'active_run_change_request' };
618
+ }
619
+ if (/\b(plus tard|later|ensuite|apr[eè]s ce run|apr[eè]s|enqueue|queue|mets en file|met en file|futur|next run|future run)\b/i.test(lower)) {
620
+ return { kind: 'enqueue', confidence: 0.8, reason: 'future_run_request' };
621
+ }
622
+ if (status.running && /\b(lance|run|g[eé]n[eè]re|build|export|cr[eé]e|create|send|envoie|ingest|convert|importe|import)\b/i.test(lower)) {
623
+ return { kind: 'ambiguous', confidence: 0.45, reason: 'active_run_action_is_ambiguous' };
624
+ }
625
+ return { kind: 'converse', confidence: 0.62, reason: 'plain_conversation' };
626
+ }
627
+
343
628
  function isAuthorized(request, token) {
344
629
  if (!token) return true;
345
630
  const authorization = request.headers.authorization ?? '';
346
- if (authorization === `Bearer ${token}`) return true;
347
- return request.headers['x-runtime-token'] === token;
631
+ const bearer = authorization.startsWith('Bearer ') ? authorization.slice(7) : '';
632
+ if (constantTimeEqual(bearer, token)) return true;
633
+ return constantTimeEqual(headerValue(request.headers['x-runtime-token']), token);
634
+ }
635
+
636
+ function headerValue(value) {
637
+ if (Array.isArray(value)) return value[0] ?? '';
638
+ return typeof value === 'string' ? value : '';
639
+ }
640
+
641
+ function constantTimeEqual(left, right) {
642
+ const leftBuffer = Buffer.from(String(left), 'utf8');
643
+ const rightBuffer = Buffer.from(String(right), 'utf8');
644
+ if (leftBuffer.length !== rightBuffer.length) return false;
645
+ return timingSafeEqual(leftBuffer, rightBuffer);
348
646
  }
349
647
 
350
648
  function sendJson(response, statusCode, value) {
@@ -352,6 +650,15 @@ function sendJson(response, statusCode, value) {
352
650
  response.end(`${JSON.stringify(value)}\n`);
353
651
  }
354
652
 
653
+ function readRequiredPatchId(body, response) {
654
+ const patchId = String(body.patchId ?? body.id ?? '').trim();
655
+ if (!patchId) {
656
+ sendJson(response, 400, { error: 'Missing patchId.' });
657
+ return null;
658
+ }
659
+ return patchId;
660
+ }
661
+
355
662
  function readJson(request) {
356
663
  return new Promise((resolve, reject) => {
357
664
  let raw = '';