@dotdrelle/wiki-manager 0.8.2 → 0.10.4

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,5 +1,8 @@
1
1
  import { createServer } from 'node:http';
2
- import { randomUUID } from 'node:crypto';
2
+ import { randomUUID, timingSafeEqual } from 'node:crypto';
3
+ import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
4
+ import { normalizePlanPatch, rebasePlanPatch } from '../core/planPatch.js';
5
+ import { validateContractInDev } from '../contracts/schemas.js';
3
6
  import { runtimeTokenFromEnv } from './auth.js';
4
7
 
5
8
  export function startRuntimeServer({
@@ -13,9 +16,11 @@ export function startRuntimeServer({
13
16
  cancel,
14
17
  resume,
15
18
  approve,
19
+ configProfiles,
20
+ useConfigProfile,
16
21
  } = {}) {
17
22
  const clients = new Set();
18
- const defaultContext = { workspace: null, session, running: false, currentAbortController: null };
23
+ const defaultContext = { workspace: null, session, running: false, currentAbortController: null, currentRunId: null };
19
24
  const resolvedGetContext = getContext ?? (() => defaultContext);
20
25
 
21
26
  function publish(event) {
@@ -48,7 +53,7 @@ export function startRuntimeServer({
48
53
  if (request.method === 'GET' && url.pathname === '/state') {
49
54
  const workspace = workspaceFromUrl(url);
50
55
  const context = workspace ? await resolveContext({ workspace }) : null;
51
- sendJson(response, 200, store.getState(context?.session ?? session, { workspace }));
56
+ sendJson(response, 200, runtimeState(context, store, { workspace, session }));
52
57
  return;
53
58
  }
54
59
  if (request.method === 'GET' && url.pathname === '/events') {
@@ -56,6 +61,119 @@ export function startRuntimeServer({
56
61
  sendJson(response, 200, { events: store.listEvents({ workspace }) });
57
62
  return;
58
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
+ }
77
+ if (request.method === 'GET' && url.pathname === '/control') {
78
+ const workspace = workspaceFromUrl(url);
79
+ const context = await resolveContext({ workspace });
80
+ sendJson(response, 200, controlStatus(context, store));
81
+ return;
82
+ }
83
+ if (request.method === 'POST' && url.pathname === '/control') {
84
+ const { body, context } = await resolveBodyContext(request, url);
85
+ const action = String(body.action ?? 'status').trim().toLowerCase();
86
+ if (action === 'status') {
87
+ validateContractInDev('controlMessage', { ...body, action });
88
+ sendJson(response, 200, controlStatus(context, store));
89
+ return;
90
+ }
91
+ if (action === 'explain') {
92
+ validateContractInDev('controlMessage', { ...body, action });
93
+ const status = controlStatus(context, store);
94
+ sendJson(response, 200, { ...status, explanation: explainControlState(status) });
95
+ return;
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
+ }
128
+ if (action === 'enqueue') {
129
+ const input = String(body.input ?? body.prompt ?? body.request ?? '').trim();
130
+ if (!input) {
131
+ sendJson(response, 400, { error: 'Missing input.' });
132
+ return;
133
+ }
134
+ validateContractInDev('controlMessage', { ...body, action, input });
135
+ const item = enqueueControlRequest(context, input);
136
+ void startNextControlRequest(context);
137
+ sendJson(response, 202, {
138
+ accepted: true,
139
+ item,
140
+ ...controlStatus(context, store),
141
+ });
142
+ return;
143
+ }
144
+ sendJson(response, 400, { error: 'Unsupported control action.' });
145
+ return;
146
+ }
147
+ if (request.method === 'GET' && url.pathname === '/config/profiles') {
148
+ if (typeof configProfiles !== 'function') {
149
+ sendJson(response, 501, { error: 'Config profiles are not supported.' });
150
+ return;
151
+ }
152
+ const workspace = workspaceFromUrl(url);
153
+ const context = await resolveContext({ workspace });
154
+ const result = await configProfiles(context);
155
+ sendJson(response, 200, result);
156
+ return;
157
+ }
158
+ if (request.method === 'POST' && url.pathname === '/config/use') {
159
+ if (typeof useConfigProfile !== 'function') {
160
+ sendJson(response, 501, { error: 'Config profile switching is not supported.' });
161
+ return;
162
+ }
163
+ const { body, context } = await resolveBodyContext(request, url);
164
+ if (context.running) {
165
+ sendJson(response, 409, { error: 'Cannot switch config while a runtime run is active.' });
166
+ return;
167
+ }
168
+ const profile = String(body.profile ?? '').trim();
169
+ if (!profile) {
170
+ sendJson(response, 400, { error: 'Missing profile.' });
171
+ return;
172
+ }
173
+ const result = await useConfigProfile(context, profile);
174
+ sendJson(response, 200, result);
175
+ return;
176
+ }
59
177
  if (request.method === 'GET' && url.pathname === '/events/stream') {
60
178
  const workspace = workspaceFromUrl(url);
61
179
  const context = workspace ? await resolveContext({ workspace }) : null;
@@ -65,16 +183,14 @@ export function startRuntimeServer({
65
183
  Connection: 'keep-alive',
66
184
  'X-Accel-Buffering': 'no',
67
185
  });
68
- 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`);
69
187
  const client = { response, workspace };
70
188
  clients.add(client);
71
189
  request.on('close', () => clients.delete(client));
72
190
  return;
73
191
  }
74
192
  if (request.method === 'POST' && url.pathname === '/run') {
75
- const body = await readJson(request);
76
- const workspace = workspaceFromBody(body) ?? workspaceFromUrl(url);
77
- const context = await resolveContext({ workspace });
193
+ const { body, context } = await resolveBodyContext(request, url);
78
194
  if (context.running) {
79
195
  sendJson(response, 409, { error: 'A runtime run is already active.' });
80
196
  return;
@@ -85,21 +201,9 @@ export function startRuntimeServer({
85
201
  sendJson(response, 400, { error: 'Missing input.' });
86
202
  return;
87
203
  }
88
- const runId = randomUUID();
89
- const runWorkspace = context.workspace ?? workspace ?? null;
90
- context.running = true;
91
- context.currentAbortController = new AbortController();
92
- const runBody = { ...body, workspace: runWorkspace, runId };
93
- const runPromise = run(context, runBody, { signal: context.currentAbortController.signal, runId });
94
- runPromise
95
- .catch((err) => {
96
- context.session?._onRuntimeError?.(err);
97
- })
98
- .finally(() => {
99
- context.running = false;
100
- context.currentAbortController = null;
101
- });
102
- sendJson(response, 202, { accepted: true, runId, workspace: runWorkspace });
204
+ validateContractInDev('runRequest', { ...body, input });
205
+ const accepted = startRuntimeRun(context, body);
206
+ sendJson(response, 202, accepted);
103
207
  } catch (err) {
104
208
  context.running = false;
105
209
  context.currentAbortController = null;
@@ -153,6 +257,7 @@ export function startRuntimeServer({
153
257
  host,
154
258
  port: typeof address === 'object' && address ? address.port : port,
155
259
  publish,
260
+ drainControl: (context) => startNextControlRequest(context),
156
261
  close: () => new Promise((closeResolve, closeReject) => {
157
262
  for (const client of clients) client.response.end();
158
263
  clients.clear();
@@ -165,6 +270,58 @@ export function startRuntimeServer({
165
270
  async function resolveContext({ workspace = null } = {}) {
166
271
  return resolvedGetContext(workspace);
167
272
  }
273
+
274
+ // Shared by POST handlers that take a JSON body carrying an optional
275
+ // `workspace` field: read the body, resolve the target workspace (body
276
+ // wins over the `?workspace=` query param), then resolve its context.
277
+ async function resolveBodyContext(request, url) {
278
+ const body = await readJson(request);
279
+ const workspace = workspaceFromBody(body) ?? workspaceFromUrl(url);
280
+ const context = await resolveContext({ workspace });
281
+ return { body, workspace, context };
282
+ }
283
+
284
+ function startRuntimeRun(context, body, { controlItemId = null } = {}) {
285
+ const runId = randomUUID();
286
+ const runWorkspace = context.workspace ?? body.workspace ?? null;
287
+ context.running = true;
288
+ context.currentAbortController = new AbortController();
289
+ context.currentRunId = runId;
290
+ context.currentRunWorkspace = runWorkspace;
291
+ const runBody = { ...body, workspace: runWorkspace, runId };
292
+ if (controlItemId) {
293
+ dispatchAgentEvent(context.session, createAgentEvent('control_started', {
294
+ origin: 'runtime',
295
+ runId,
296
+ workspace: runWorkspace,
297
+ payload: { id: controlItemId, runId },
298
+ }));
299
+ }
300
+ const runPromise = run(context, runBody, { signal: context.currentAbortController.signal, runId });
301
+ runPromise
302
+ .catch((err) => {
303
+ context.session?._onRuntimeError?.(err);
304
+ })
305
+ .finally(() => {
306
+ context.running = false;
307
+ context.currentAbortController = null;
308
+ context.currentRunId = null;
309
+ context.currentRunWorkspace = null;
310
+ void startNextControlRequest(context);
311
+ });
312
+ return { accepted: true, runId, workspace: runWorkspace };
313
+ }
314
+
315
+ function startNextControlRequest(context) {
316
+ if (!context?.session || context.running) return false;
317
+ const item = controlQueueFor(context.session).find((entry) => entry.status === 'queued');
318
+ if (!item) return false;
319
+ startRuntimeRun(context, {
320
+ input: item.input,
321
+ workspace: item.workspace ?? context.workspace ?? null,
322
+ }, { controlItemId: item.id });
323
+ return true;
324
+ }
168
325
  }
169
326
 
170
327
  function workspaceFromUrl(url) {
@@ -177,11 +334,315 @@ function workspaceFromBody(body) {
177
334
  return workspace == null ? null : String(workspace).trim() || null;
178
335
  }
179
336
 
337
+ function controlStatus(context, store) {
338
+ const workspace = context?.workspace ?? context?.session?.workspace ?? null;
339
+ const state = runtimeState(context, store, { workspace });
340
+ return {
341
+ ok: true,
342
+ ...state,
343
+ workspace: state.workspace ?? workspace,
344
+ running: Boolean(context?.running),
345
+ controlQueue: controlQueueFor(context?.session),
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,
357
+ };
358
+ }
359
+
360
+ function explainControlState(status) {
361
+ const plan = Array.isArray(status.plan) ? status.plan : [];
362
+ if (status.running) {
363
+ const runningStep = plan.find((step) => step.status === 'running');
364
+ return runningStep
365
+ ? `Runtime run is active. Current step: ${runningStep.description ?? runningStep.label ?? runningStep.step}.`
366
+ : 'Runtime run is active. No current plan step is available yet.';
367
+ }
368
+ const pendingApproval = status.approvals.find((approval) => approval.status === 'pending_approval');
369
+ if (pendingApproval) {
370
+ return `Runtime is waiting for approval: ${pendingApproval.reason ?? pendingApproval.id}.`;
371
+ }
372
+ const queued = status.controlQueue.filter((item) => item.status === 'queued');
373
+ if (queued.length > 0) {
374
+ return `${queued.length} control request${queued.length === 1 ? '' : 's'} queued. They are not applied to the active plan automatically.`;
375
+ }
376
+ if (plan.some((step) => step.status === 'pending')) {
377
+ return 'Runtime is idle with pending plan steps visible from the last run.';
378
+ }
379
+ return 'Runtime is idle.';
380
+ }
381
+
382
+ function controlQueueFor(session) {
383
+ return Array.isArray(session?.controlQueue) ? session.controlQueue : [];
384
+ }
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
+
447
+ function enqueueControlRequest(context, input) {
448
+ const now = new Date().toISOString();
449
+ const item = {
450
+ id: `control-${randomUUID()}`,
451
+ workspace: context?.workspace ?? context?.session?.workspace ?? null,
452
+ type: 'run_request',
453
+ input,
454
+ status: 'queued',
455
+ createdAt: now,
456
+ updatedAt: now,
457
+ };
458
+ dispatchAgentEvent(context.session, createAgentEvent('control_enqueued', {
459
+ origin: 'runtime',
460
+ workspace: item.workspace,
461
+ payload: item,
462
+ }));
463
+ return item;
464
+ }
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
+
180
628
  function isAuthorized(request, token) {
181
629
  if (!token) return true;
182
630
  const authorization = request.headers.authorization ?? '';
183
- if (authorization === `Bearer ${token}`) return true;
184
- 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);
185
646
  }
186
647
 
187
648
  function sendJson(response, statusCode, value) {
@@ -189,6 +650,15 @@ function sendJson(response, statusCode, value) {
189
650
  response.end(`${JSON.stringify(value)}\n`);
190
651
  }
191
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
+
192
662
  function readJson(request) {
193
663
  return new Promise((resolve, reject) => {
194
664
  let raw = '';