@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.
- package/README.md +21 -6
- package/agents.docker-compose.yml +5 -0
- package/package.json +5 -2
- package/src/agent/graph.js +68 -9
- package/src/agent/graph.test.js +64 -0
- package/src/cli/wiki-manager.js +1 -1
- package/src/commands/slash.js +6 -2
- package/src/contracts/README.md +16 -0
- package/src/contracts/schemas.js +302 -0
- package/src/contracts/schemas.test.js +93 -0
- package/src/core/activity.js +14 -2
- package/src/core/activity.test.js +4 -2
- package/src/core/agentEvents.js +158 -15
- package/src/core/agentEvents.test.js +54 -12
- package/src/core/agentLoop.js +32 -7
- package/src/core/mcp.js +3 -1
- package/src/core/plan.js +4 -0
- package/src/core/planPatch.js +224 -0
- package/src/core/planPatch.test.js +63 -0
- package/src/core/workflow.js +264 -0
- package/src/core/workflow.test.js +66 -0
- package/src/runtime/client.js +28 -1
- package/src/runtime/runner.js +432 -20
- package/src/runtime/runner.test.js +273 -1
- package/src/runtime/server.js +322 -15
- package/src/runtime/server.test.js +339 -0
- package/src/runtime/store.js +59 -10
- package/src/runtime/store.test.js +47 -1
- package/src/shell/RightPane.tsx +1 -7
- package/src/shell/StartupScreen.tsx +212 -0
- package/src/shell/repl.js +51 -7
- package/src/shell/repl.test.js +77 -1
- package/src/shell/textFit.ts +6 -0
- package/src/shell/tui.tsx +163 -0
- package/src/shell/useAgent.ts +17 -9
- package/src/shell/useSession.ts +34 -0
package/src/runtime/server.js
CHANGED
|
@@ -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,
|
|
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(
|
|
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 =
|
|
339
|
+
const state = runtimeState(context, store, { workspace });
|
|
286
340
|
return {
|
|
287
341
|
ok: true,
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
295
|
-
|
|
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 =
|
|
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 (
|
|
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
|
-
|
|
347
|
-
|
|
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 = '';
|