@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.
- package/README.md +36 -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 +34 -2
- package/src/commands/slash.js +9 -25
- 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 +212 -24
- package/src/core/agentEvents.test.js +85 -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/sessionConfig.js +24 -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 +494 -24
- package/src/runtime/server.test.js +661 -0
- package/src/runtime/store.js +59 -7
- package/src/runtime/store.test.js +72 -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,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,
|
|
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(
|
|
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
|
|
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
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
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
|
-
|
|
184
|
-
|
|
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 = '';
|