@dotdrelle/wiki-manager 0.8.2 → 0.9.3
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 +15 -0
- package/package.json +1 -1
- package/src/cli/wiki-manager.js +33 -1
- package/src/commands/slash.js +3 -23
- package/src/core/agentEvents.js +54 -9
- package/src/core/agentEvents.test.js +31 -0
- package/src/core/mcp.js +1 -1
- package/src/core/sessionConfig.js +24 -0
- package/src/runtime/server.js +181 -18
- package/src/runtime/server.test.js +322 -0
- package/src/runtime/store.js +3 -0
- package/src/runtime/store.test.js +25 -0
package/README.md
CHANGED
|
@@ -460,6 +460,21 @@ or the shell command `/approve item <id>`. The approval timeout defaults to 10
|
|
|
460
460
|
minutes and can be changed with `WIKI_MANAGER_APPROVAL_TIMEOUT_MS` or
|
|
461
461
|
`approvalTimeoutMs` in the `/run` body.
|
|
462
462
|
|
|
463
|
+
While a run is active, `GET`/`POST /control` still answers without waiting for
|
|
464
|
+
it to finish: `{"action":"status"}` returns the current run/plan/queue state,
|
|
465
|
+
`{"action":"explain"}` adds a one-line plain-language summary, and
|
|
466
|
+
`{"action":"enqueue","input":"..."}` accepts a new request without touching the
|
|
467
|
+
active plan. A queued request starts automatically as soon as the workspace
|
|
468
|
+
goes idle — either because the enqueue call itself found the workspace free,
|
|
469
|
+
or because the run in progress finished and drained the next queued item.
|
|
470
|
+
|
|
471
|
+
`GET /config/profiles` lists the `.wikirc` profiles for a workspace and
|
|
472
|
+
`POST /config/use {"profile":"..."}` switches the active one — the same
|
|
473
|
+
switch as the shell's `/config use`, rejected with 409 while a run is active.
|
|
474
|
+
The manager is the source of truth for which profile is active; `llm-wiki
|
|
475
|
+
serve`'s config-profile picker mirrors whatever the manager reports rather
|
|
476
|
+
than tracking its own state.
|
|
477
|
+
|
|
463
478
|
### Starting external agents
|
|
464
479
|
|
|
465
480
|
Start CME, documents, and mailer once for all workspaces:
|
package/package.json
CHANGED
package/src/cli/wiki-manager.js
CHANGED
|
@@ -8,6 +8,8 @@ import { createAgentGraph } from '../agent/graph.js';
|
|
|
8
8
|
import { handleSlashCommand, printHelp, printVersion, refreshMcpRuntimeStatus } from '../commands/slash.js';
|
|
9
9
|
import { runShell } from '../shell/repl.js';
|
|
10
10
|
import { runChecks } from '../core/startupCheck.js';
|
|
11
|
+
import { applySessionWikircProfile } from '../core/sessionConfig.js';
|
|
12
|
+
import { listWikircProfiles } from '../core/wikirc.js';
|
|
11
13
|
import { callMcpTool, formatMcpToolResult } from '../core/mcp.js';
|
|
12
14
|
import { extractActivity, parseJsonText, sessionActivities, terminalFailures } from '../core/activity.js';
|
|
13
15
|
import { syncActivitiesToPlan, formatPlanStatus } from '../core/plan.js';
|
|
@@ -512,11 +514,14 @@ async function runRuntime(argv, agent) {
|
|
|
512
514
|
};
|
|
513
515
|
}
|
|
514
516
|
emitRuntimeLog(context.session, manual ? 'runtime: manual resume completed' : 'runtime: recovery completed');
|
|
517
|
+
const controlStarted = pollingActivities.length === 0
|
|
518
|
+
? serverHandle?.drainControl?.(context) === true
|
|
519
|
+
: false;
|
|
515
520
|
return {
|
|
516
521
|
workspace: context.workspace ?? workspace ?? null,
|
|
517
522
|
resumed: true,
|
|
518
523
|
interrupted: 0,
|
|
519
|
-
mode: pollingActivities.length > 0 ? 'activity_poll' : 'context',
|
|
524
|
+
mode: controlStarted ? 'control_queue' : pollingActivities.length > 0 ? 'activity_poll' : 'context',
|
|
520
525
|
};
|
|
521
526
|
} catch (err) {
|
|
522
527
|
const interrupted = store.interruptRuns({ workspace });
|
|
@@ -629,6 +634,33 @@ async function runRuntime(argv, agent) {
|
|
|
629
634
|
const context = await getWorkspaceContext(workspace);
|
|
630
635
|
return context.approvalManager?.approve({ runId, itemId, approvalId }) ?? { approved: false };
|
|
631
636
|
},
|
|
637
|
+
configProfiles: async (context) => {
|
|
638
|
+
const profiles = listWikircProfiles(context.session.workspacePath);
|
|
639
|
+
return {
|
|
640
|
+
profiles: profiles.map((profile) => profile.name),
|
|
641
|
+
active: context.session.wikirc?.profile ?? null,
|
|
642
|
+
items: profiles.map((profile) => ({
|
|
643
|
+
name: profile.name,
|
|
644
|
+
fileName: profile.fileName,
|
|
645
|
+
default: Boolean(profile.default),
|
|
646
|
+
})),
|
|
647
|
+
};
|
|
648
|
+
},
|
|
649
|
+
useConfigProfile: async (context, profile) => {
|
|
650
|
+
const { summary, config } = applySessionWikircProfile(context.session, profile);
|
|
651
|
+
await refreshMcpRuntimeStatus(context.session);
|
|
652
|
+
dispatchAgentEvent(context.session, createAgentEvent('runtime_log', {
|
|
653
|
+
origin: 'runtime',
|
|
654
|
+
payload: { message: `runtime: config profile switched to ${context.session.wikirc?.profile ?? profile}` },
|
|
655
|
+
}));
|
|
656
|
+
return {
|
|
657
|
+
ok: true,
|
|
658
|
+
active: context.session.wikirc?.profile ?? profile,
|
|
659
|
+
fileName: context.session.wikirc?.fileName ?? null,
|
|
660
|
+
summary,
|
|
661
|
+
config,
|
|
662
|
+
};
|
|
663
|
+
},
|
|
632
664
|
token: auth.token,
|
|
633
665
|
});
|
|
634
666
|
const recovery = await recoverRuntime();
|
package/src/commands/slash.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
2
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
3
3
|
import { join, relative } from 'node:path';
|
|
4
|
-
import { createLlmClientFromWikiConfig } from '../agent/llm.js';
|
|
5
4
|
import { composeServices, listServices, runWikiCli, serviceLogs, serviceStates, startService, stopService } from '../core/compose.js';
|
|
6
5
|
import {
|
|
7
6
|
applyMcpRuntimeStatus,
|
|
@@ -26,10 +25,10 @@ import {
|
|
|
26
25
|
} from '../core/jobQueue.js';
|
|
27
26
|
import {
|
|
28
27
|
listWikircProfiles,
|
|
29
|
-
loadWikircProfile,
|
|
30
28
|
resolveWikircProfile,
|
|
31
29
|
summarizeWikircConfig,
|
|
32
30
|
} from '../core/wikirc.js';
|
|
31
|
+
import { applySessionWikircProfile } from '../core/sessionConfig.js';
|
|
33
32
|
import { deleteWorkspaceAndFiles, startAgents, stopAgents } from '../core/wikiSetup.js';
|
|
34
33
|
import {
|
|
35
34
|
cleanDocumentUploads,
|
|
@@ -546,25 +545,6 @@ function loadWorkspaceSystemPrompt(workspacePath) {
|
|
|
546
545
|
return existsSync(promptPath) ? readFileSync(promptPath, 'utf8').trim() || null : null;
|
|
547
546
|
}
|
|
548
547
|
|
|
549
|
-
function loadSessionWikirc(session, profileName = 'default') {
|
|
550
|
-
if (!session.workspacePath) {
|
|
551
|
-
throw new Error('No workspace loaded. Use /use <workspace>.');
|
|
552
|
-
}
|
|
553
|
-
const loaded = loadWikircProfile(session.workspacePath, profileName);
|
|
554
|
-
session.wikirc = {
|
|
555
|
-
profile: loaded.profile.name,
|
|
556
|
-
fileName: loaded.profile.fileName,
|
|
557
|
-
path: loaded.profile.path,
|
|
558
|
-
};
|
|
559
|
-
session.wikircConfig = loaded.config;
|
|
560
|
-
session.language = loaded.config?.language ?? null;
|
|
561
|
-
session.llm = createLlmClientFromWikiConfig(loaded.config);
|
|
562
|
-
if (session.mcp?.production) {
|
|
563
|
-
session.mcp.production.activeConfigPath = loaded.profile.fileName;
|
|
564
|
-
}
|
|
565
|
-
return summarizeWikircConfig(loaded.profile, loaded.config);
|
|
566
|
-
}
|
|
567
|
-
|
|
568
548
|
function clearWorkspaceSession(session) {
|
|
569
549
|
session.workspace = null;
|
|
570
550
|
session.workspacePath = null;
|
|
@@ -720,7 +700,7 @@ export async function handleSlashCommand(line, context) {
|
|
|
720
700
|
context.session.systemPrompt = loadWorkspaceSystemPrompt(workspace.workspacePath);
|
|
721
701
|
try {
|
|
722
702
|
step(`Workspace: loading ${workspace.name} config…`);
|
|
723
|
-
const summary =
|
|
703
|
+
const { summary } = applySessionWikircProfile(context.session, 'default');
|
|
724
704
|
step(`Workspace: discovering ${workspace.name} MCP tools…`);
|
|
725
705
|
await refreshMcpRuntimeStatus(context.session);
|
|
726
706
|
return {
|
|
@@ -759,7 +739,7 @@ export async function handleSlashCommand(line, context) {
|
|
|
759
739
|
return { output: 'Usage: /config use <default|name>' };
|
|
760
740
|
}
|
|
761
741
|
try {
|
|
762
|
-
const summary =
|
|
742
|
+
const { summary } = applySessionWikircProfile(context.session, profileName);
|
|
763
743
|
await refreshMcpRuntimeStatus(context.session);
|
|
764
744
|
return {
|
|
765
745
|
output: [
|
package/src/core/agentEvents.js
CHANGED
|
@@ -12,6 +12,8 @@ const SESSION_PROJECTION_EVENTS = new Set([
|
|
|
12
12
|
'run_approved',
|
|
13
13
|
'tool_pending_approval',
|
|
14
14
|
'tool_approved',
|
|
15
|
+
'control_enqueued',
|
|
16
|
+
'control_started',
|
|
15
17
|
'run_done',
|
|
16
18
|
'run_error',
|
|
17
19
|
'run_cancelled',
|
|
@@ -101,6 +103,7 @@ function createProjectionState() {
|
|
|
101
103
|
evaluation: null,
|
|
102
104
|
replans: [],
|
|
103
105
|
approvals: [],
|
|
106
|
+
controlQueue: [],
|
|
104
107
|
summary: null,
|
|
105
108
|
status: 'idle',
|
|
106
109
|
};
|
|
@@ -116,6 +119,7 @@ function publicProjection(state) {
|
|
|
116
119
|
evaluation: state.evaluation ? { ...state.evaluation } : null,
|
|
117
120
|
replans: state.replans.map((replan) => ({ ...replan, plan: [...(replan.plan ?? [])] })),
|
|
118
121
|
approvals: state.approvals.map((approval) => ({ ...approval })),
|
|
122
|
+
controlQueue: state.controlQueue.map((item) => ({ ...item })),
|
|
119
123
|
summary: state.summary,
|
|
120
124
|
status: state.status,
|
|
121
125
|
};
|
|
@@ -124,6 +128,7 @@ function publicProjection(state) {
|
|
|
124
128
|
export function applyAgentProjectionToSession(session, projection) {
|
|
125
129
|
session.headlessPlan = projection.plan ? projection.plan.map((step) => ({ ...step })) : null;
|
|
126
130
|
session.activities = Object.fromEntries((projection.activities ?? []).map((activity) => [activity.key, { ...activity }]));
|
|
131
|
+
session.controlQueue = (projection.controlQueue ?? []).map((item) => ({ ...item }));
|
|
127
132
|
const production = (projection.activities ?? []).filter((activity) => activity.source === 'production').at(-1);
|
|
128
133
|
session.productionActivity = production ? {
|
|
129
134
|
jobId: production.id,
|
|
@@ -226,14 +231,36 @@ function applyEvent(state, event) {
|
|
|
226
231
|
case 'run_done':
|
|
227
232
|
state.status = 'done';
|
|
228
233
|
finishPendingPlanSteps(state.plan);
|
|
234
|
+
finishControlByRun(state.controlQueue, event.runId ?? event.payload?.runId ?? null, 'done', event.ts);
|
|
229
235
|
return;
|
|
230
236
|
case 'run_cancelled':
|
|
231
237
|
state.status = 'cancelled';
|
|
232
238
|
state.logs.push(String(event.payload?.message ?? 'Agent run cancelled.'));
|
|
239
|
+
finishControlByRun(state.controlQueue, event.runId ?? event.payload?.runId ?? null, 'cancelled', event.ts);
|
|
233
240
|
return;
|
|
234
241
|
case 'run_error':
|
|
235
242
|
state.status = 'error';
|
|
236
243
|
state.logs.push(String(event.payload?.message ?? 'Agent run failed.'));
|
|
244
|
+
finishControlByRun(state.controlQueue, event.runId ?? event.payload?.runId ?? null, 'failed', event.ts);
|
|
245
|
+
return;
|
|
246
|
+
case 'control_enqueued':
|
|
247
|
+
upsertControlItem(state.controlQueue, {
|
|
248
|
+
id: event.payload?.id ?? event.id,
|
|
249
|
+
workspace: event.workspace ?? event.payload?.workspace ?? null,
|
|
250
|
+
input: String(event.payload?.input ?? ''),
|
|
251
|
+
status: 'queued',
|
|
252
|
+
createdAt: event.payload?.createdAt ?? event.ts,
|
|
253
|
+
updatedAt: event.ts,
|
|
254
|
+
});
|
|
255
|
+
return;
|
|
256
|
+
case 'control_started':
|
|
257
|
+
upsertControlItem(state.controlQueue, {
|
|
258
|
+
id: event.payload?.id ?? null,
|
|
259
|
+
runId: event.runId ?? event.payload?.runId ?? null,
|
|
260
|
+
status: 'running',
|
|
261
|
+
startedAt: event.payload?.startedAt ?? event.ts,
|
|
262
|
+
updatedAt: event.ts,
|
|
263
|
+
});
|
|
237
264
|
return;
|
|
238
265
|
case 'runtime_log':
|
|
239
266
|
state.logs.push(String(event.payload?.message ?? ''));
|
|
@@ -244,6 +271,32 @@ function applyEvent(state, event) {
|
|
|
244
271
|
}
|
|
245
272
|
}
|
|
246
273
|
|
|
274
|
+
function upsertById(list, next) {
|
|
275
|
+
const index = list.findIndex((item) => item.id === next.id);
|
|
276
|
+
if (index === -1) {
|
|
277
|
+
list.push(next);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
list[index] = {
|
|
281
|
+
...list[index],
|
|
282
|
+
...next,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function upsertControlItem(queue, next) {
|
|
287
|
+
if (!next.id) return;
|
|
288
|
+
upsertById(queue, next);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function finishControlByRun(queue, runId, status, finishedAt) {
|
|
292
|
+
if (!runId) return;
|
|
293
|
+
const item = queue.find((entry) => entry.runId === runId && entry.status === 'running');
|
|
294
|
+
if (!item) return;
|
|
295
|
+
item.status = status;
|
|
296
|
+
item.finishedAt = finishedAt;
|
|
297
|
+
item.updatedAt = finishedAt;
|
|
298
|
+
}
|
|
299
|
+
|
|
247
300
|
function appendAssistantDelta(state, delta) {
|
|
248
301
|
if (!delta) return;
|
|
249
302
|
const last = state.conversation.at(-1);
|
|
@@ -279,15 +332,7 @@ function finishToolCall(state, payload = {}) {
|
|
|
279
332
|
}
|
|
280
333
|
|
|
281
334
|
function upsertApproval(state, next) {
|
|
282
|
-
|
|
283
|
-
if (index === -1) {
|
|
284
|
-
state.approvals.push(next);
|
|
285
|
-
return;
|
|
286
|
-
}
|
|
287
|
-
state.approvals[index] = {
|
|
288
|
-
...state.approvals[index],
|
|
289
|
-
...next,
|
|
290
|
-
};
|
|
335
|
+
upsertById(state.approvals, next);
|
|
291
336
|
}
|
|
292
337
|
|
|
293
338
|
function finishPendingPlanSteps(plan) {
|
|
@@ -210,6 +210,37 @@ test('reduceAgentEvents: approvals move from pending to approved', () => {
|
|
|
210
210
|
assert.deepEqual(projection.approvals[0].plan, ['Build']);
|
|
211
211
|
});
|
|
212
212
|
|
|
213
|
+
test('reduceAgentEvents: control queue is event sourced and follows run status', () => {
|
|
214
|
+
const projection = reduceAgentEvents([
|
|
215
|
+
createAgentEvent('control_enqueued', {
|
|
216
|
+
origin: 'runtime',
|
|
217
|
+
workspace: 'docs',
|
|
218
|
+
payload: {
|
|
219
|
+
id: 'control-1',
|
|
220
|
+
workspace: 'docs',
|
|
221
|
+
input: 'Run after current task',
|
|
222
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
223
|
+
},
|
|
224
|
+
}),
|
|
225
|
+
createAgentEvent('control_started', {
|
|
226
|
+
origin: 'runtime',
|
|
227
|
+
runId: 'run-control-1',
|
|
228
|
+
workspace: 'docs',
|
|
229
|
+
payload: { id: 'control-1', runId: 'run-control-1' },
|
|
230
|
+
}),
|
|
231
|
+
createAgentEvent('run_done', {
|
|
232
|
+
origin: 'runtime',
|
|
233
|
+
runId: 'run-control-1',
|
|
234
|
+
workspace: 'docs',
|
|
235
|
+
}),
|
|
236
|
+
]);
|
|
237
|
+
|
|
238
|
+
assert.equal(projection.controlQueue.length, 1);
|
|
239
|
+
assert.equal(projection.controlQueue[0].id, 'control-1');
|
|
240
|
+
assert.equal(projection.controlQueue[0].status, 'done');
|
|
241
|
+
assert.equal(projection.controlQueue[0].runId, 'run-control-1');
|
|
242
|
+
});
|
|
243
|
+
|
|
213
244
|
test('reduceAgentEvents: activity-owned plan is used when no orchestrator plan exists', () => {
|
|
214
245
|
const projection = reduceAgentEvents([
|
|
215
246
|
createAgentEvent('activity_upserted', {
|
package/src/core/mcp.js
CHANGED
|
@@ -230,7 +230,7 @@ async function mcpRequest(endpoint, method, params, signal, options = {}) {
|
|
|
230
230
|
params: {
|
|
231
231
|
protocolVersion: '2025-06-18',
|
|
232
232
|
capabilities: {},
|
|
233
|
-
clientInfo: { name: 'wiki-manager', version: '0.
|
|
233
|
+
clientInfo: { name: 'wiki-manager', version: '0.9.3' },
|
|
234
234
|
},
|
|
235
235
|
}),
|
|
236
236
|
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createLlmClientFromWikiConfig } from '../agent/llm.js';
|
|
2
|
+
import { loadWikircProfile, summarizeWikircConfig } from './wikirc.js';
|
|
3
|
+
|
|
4
|
+
export function applySessionWikircProfile(session, profileName = 'default') {
|
|
5
|
+
if (!session.workspacePath) {
|
|
6
|
+
throw new Error('No workspace loaded. Use /use <workspace>.');
|
|
7
|
+
}
|
|
8
|
+
const loaded = loadWikircProfile(session.workspacePath, profileName);
|
|
9
|
+
session.wikirc = {
|
|
10
|
+
profile: loaded.profile.name,
|
|
11
|
+
fileName: loaded.profile.fileName,
|
|
12
|
+
path: loaded.profile.path,
|
|
13
|
+
};
|
|
14
|
+
session.wikircConfig = loaded.config;
|
|
15
|
+
session.language = loaded.config?.language ?? null;
|
|
16
|
+
session.llm = createLlmClientFromWikiConfig(loaded.config);
|
|
17
|
+
if (session.mcp?.production) {
|
|
18
|
+
session.mcp.production.activeConfigPath = loaded.profile.fileName;
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
summary: summarizeWikircConfig(loaded.profile, loaded.config),
|
|
22
|
+
config: loaded.config,
|
|
23
|
+
};
|
|
24
|
+
}
|
package/src/runtime/server.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
3
4
|
import { runtimeTokenFromEnv } from './auth.js';
|
|
4
5
|
|
|
5
6
|
export function startRuntimeServer({
|
|
@@ -13,6 +14,8 @@ export function startRuntimeServer({
|
|
|
13
14
|
cancel,
|
|
14
15
|
resume,
|
|
15
16
|
approve,
|
|
17
|
+
configProfiles,
|
|
18
|
+
useConfigProfile,
|
|
16
19
|
} = {}) {
|
|
17
20
|
const clients = new Set();
|
|
18
21
|
const defaultContext = { workspace: null, session, running: false, currentAbortController: null };
|
|
@@ -56,6 +59,72 @@ export function startRuntimeServer({
|
|
|
56
59
|
sendJson(response, 200, { events: store.listEvents({ workspace }) });
|
|
57
60
|
return;
|
|
58
61
|
}
|
|
62
|
+
if (request.method === 'GET' && url.pathname === '/control') {
|
|
63
|
+
const workspace = workspaceFromUrl(url);
|
|
64
|
+
const context = await resolveContext({ workspace });
|
|
65
|
+
sendJson(response, 200, controlStatus(context, store));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (request.method === 'POST' && url.pathname === '/control') {
|
|
69
|
+
const { body, context } = await resolveBodyContext(request, url);
|
|
70
|
+
const action = String(body.action ?? 'status').trim().toLowerCase();
|
|
71
|
+
if (action === 'status') {
|
|
72
|
+
sendJson(response, 200, controlStatus(context, store));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (action === 'explain') {
|
|
76
|
+
const status = controlStatus(context, store);
|
|
77
|
+
sendJson(response, 200, { ...status, explanation: explainControlState(status) });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (action === 'enqueue') {
|
|
81
|
+
const input = String(body.input ?? body.prompt ?? body.request ?? '').trim();
|
|
82
|
+
if (!input) {
|
|
83
|
+
sendJson(response, 400, { error: 'Missing input.' });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const item = enqueueControlRequest(context, input);
|
|
87
|
+
void startNextControlRequest(context);
|
|
88
|
+
sendJson(response, 202, {
|
|
89
|
+
accepted: true,
|
|
90
|
+
item,
|
|
91
|
+
...controlStatus(context, store),
|
|
92
|
+
});
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
sendJson(response, 400, { error: 'Unsupported control action.' });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (request.method === 'GET' && url.pathname === '/config/profiles') {
|
|
99
|
+
if (typeof configProfiles !== 'function') {
|
|
100
|
+
sendJson(response, 501, { error: 'Config profiles are not supported.' });
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const workspace = workspaceFromUrl(url);
|
|
104
|
+
const context = await resolveContext({ workspace });
|
|
105
|
+
const result = await configProfiles(context);
|
|
106
|
+
sendJson(response, 200, result);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (request.method === 'POST' && url.pathname === '/config/use') {
|
|
110
|
+
if (typeof useConfigProfile !== 'function') {
|
|
111
|
+
sendJson(response, 501, { error: 'Config profile switching is not supported.' });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const { body, context } = await resolveBodyContext(request, url);
|
|
115
|
+
if (context.running) {
|
|
116
|
+
sendJson(response, 409, { error: 'Cannot switch config while a runtime run is active.' });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const profile = String(body.profile ?? '').trim();
|
|
120
|
+
if (!profile) {
|
|
121
|
+
sendJson(response, 400, { error: 'Missing profile.' });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const result = await useConfigProfile(context, profile);
|
|
125
|
+
sendJson(response, 200, result);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
59
128
|
if (request.method === 'GET' && url.pathname === '/events/stream') {
|
|
60
129
|
const workspace = workspaceFromUrl(url);
|
|
61
130
|
const context = workspace ? await resolveContext({ workspace }) : null;
|
|
@@ -72,9 +141,7 @@ export function startRuntimeServer({
|
|
|
72
141
|
return;
|
|
73
142
|
}
|
|
74
143
|
if (request.method === 'POST' && url.pathname === '/run') {
|
|
75
|
-
const body = await
|
|
76
|
-
const workspace = workspaceFromBody(body) ?? workspaceFromUrl(url);
|
|
77
|
-
const context = await resolveContext({ workspace });
|
|
144
|
+
const { body, context } = await resolveBodyContext(request, url);
|
|
78
145
|
if (context.running) {
|
|
79
146
|
sendJson(response, 409, { error: 'A runtime run is already active.' });
|
|
80
147
|
return;
|
|
@@ -85,21 +152,8 @@ export function startRuntimeServer({
|
|
|
85
152
|
sendJson(response, 400, { error: 'Missing input.' });
|
|
86
153
|
return;
|
|
87
154
|
}
|
|
88
|
-
const
|
|
89
|
-
|
|
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 });
|
|
155
|
+
const accepted = startRuntimeRun(context, body);
|
|
156
|
+
sendJson(response, 202, accepted);
|
|
103
157
|
} catch (err) {
|
|
104
158
|
context.running = false;
|
|
105
159
|
context.currentAbortController = null;
|
|
@@ -153,6 +207,7 @@ export function startRuntimeServer({
|
|
|
153
207
|
host,
|
|
154
208
|
port: typeof address === 'object' && address ? address.port : port,
|
|
155
209
|
publish,
|
|
210
|
+
drainControl: (context) => startNextControlRequest(context),
|
|
156
211
|
close: () => new Promise((closeResolve, closeReject) => {
|
|
157
212
|
for (const client of clients) client.response.end();
|
|
158
213
|
clients.clear();
|
|
@@ -165,6 +220,54 @@ export function startRuntimeServer({
|
|
|
165
220
|
async function resolveContext({ workspace = null } = {}) {
|
|
166
221
|
return resolvedGetContext(workspace);
|
|
167
222
|
}
|
|
223
|
+
|
|
224
|
+
// Shared by POST handlers that take a JSON body carrying an optional
|
|
225
|
+
// `workspace` field: read the body, resolve the target workspace (body
|
|
226
|
+
// wins over the `?workspace=` query param), then resolve its context.
|
|
227
|
+
async function resolveBodyContext(request, url) {
|
|
228
|
+
const body = await readJson(request);
|
|
229
|
+
const workspace = workspaceFromBody(body) ?? workspaceFromUrl(url);
|
|
230
|
+
const context = await resolveContext({ workspace });
|
|
231
|
+
return { body, workspace, context };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function startRuntimeRun(context, body, { controlItemId = null } = {}) {
|
|
235
|
+
const runId = randomUUID();
|
|
236
|
+
const runWorkspace = context.workspace ?? body.workspace ?? null;
|
|
237
|
+
context.running = true;
|
|
238
|
+
context.currentAbortController = new AbortController();
|
|
239
|
+
const runBody = { ...body, workspace: runWorkspace, runId };
|
|
240
|
+
if (controlItemId) {
|
|
241
|
+
dispatchAgentEvent(context.session, createAgentEvent('control_started', {
|
|
242
|
+
origin: 'runtime',
|
|
243
|
+
runId,
|
|
244
|
+
workspace: runWorkspace,
|
|
245
|
+
payload: { id: controlItemId, runId },
|
|
246
|
+
}));
|
|
247
|
+
}
|
|
248
|
+
const runPromise = run(context, runBody, { signal: context.currentAbortController.signal, runId });
|
|
249
|
+
runPromise
|
|
250
|
+
.catch((err) => {
|
|
251
|
+
context.session?._onRuntimeError?.(err);
|
|
252
|
+
})
|
|
253
|
+
.finally(() => {
|
|
254
|
+
context.running = false;
|
|
255
|
+
context.currentAbortController = null;
|
|
256
|
+
void startNextControlRequest(context);
|
|
257
|
+
});
|
|
258
|
+
return { accepted: true, runId, workspace: runWorkspace };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function startNextControlRequest(context) {
|
|
262
|
+
if (!context?.session || context.running) return false;
|
|
263
|
+
const item = controlQueueFor(context.session).find((entry) => entry.status === 'queued');
|
|
264
|
+
if (!item) return false;
|
|
265
|
+
startRuntimeRun(context, {
|
|
266
|
+
input: item.input,
|
|
267
|
+
workspace: item.workspace ?? context.workspace ?? null,
|
|
268
|
+
}, { controlItemId: item.id });
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
168
271
|
}
|
|
169
272
|
|
|
170
273
|
function workspaceFromUrl(url) {
|
|
@@ -177,6 +280,66 @@ function workspaceFromBody(body) {
|
|
|
177
280
|
return workspace == null ? null : String(workspace).trim() || null;
|
|
178
281
|
}
|
|
179
282
|
|
|
283
|
+
function controlStatus(context, store) {
|
|
284
|
+
const workspace = context?.workspace ?? context?.session?.workspace ?? null;
|
|
285
|
+
const state = store.getState(context?.session ?? null, { workspace });
|
|
286
|
+
return {
|
|
287
|
+
ok: true,
|
|
288
|
+
workspace,
|
|
289
|
+
status: context?.running ? 'running' : state.status ?? 'idle',
|
|
290
|
+
running: Boolean(context?.running),
|
|
291
|
+
plan: Array.isArray(state.plan) ? state.plan : [],
|
|
292
|
+
queue: Array.isArray(state.queue) ? state.queue : [],
|
|
293
|
+
controlQueue: controlQueueFor(context?.session),
|
|
294
|
+
approvals: Array.isArray(state.approvals) ? state.approvals : [],
|
|
295
|
+
summary: state.summary ?? null,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function explainControlState(status) {
|
|
300
|
+
if (status.running) {
|
|
301
|
+
const runningStep = status.plan.find((step) => step.status === 'running');
|
|
302
|
+
return runningStep
|
|
303
|
+
? `Runtime run is active. Current step: ${runningStep.description ?? runningStep.label ?? runningStep.step}.`
|
|
304
|
+
: 'Runtime run is active. No current plan step is available yet.';
|
|
305
|
+
}
|
|
306
|
+
const pendingApproval = status.approvals.find((approval) => approval.status === 'pending_approval');
|
|
307
|
+
if (pendingApproval) {
|
|
308
|
+
return `Runtime is waiting for approval: ${pendingApproval.reason ?? pendingApproval.id}.`;
|
|
309
|
+
}
|
|
310
|
+
const queued = status.controlQueue.filter((item) => item.status === 'queued');
|
|
311
|
+
if (queued.length > 0) {
|
|
312
|
+
return `${queued.length} control request${queued.length === 1 ? '' : 's'} queued. They are not applied to the active plan automatically.`;
|
|
313
|
+
}
|
|
314
|
+
if (status.plan.some((step) => step.status === 'pending')) {
|
|
315
|
+
return 'Runtime is idle with pending plan steps visible from the last run.';
|
|
316
|
+
}
|
|
317
|
+
return 'Runtime is idle.';
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function controlQueueFor(session) {
|
|
321
|
+
return Array.isArray(session?.controlQueue) ? session.controlQueue : [];
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function enqueueControlRequest(context, input) {
|
|
325
|
+
const now = new Date().toISOString();
|
|
326
|
+
const item = {
|
|
327
|
+
id: `control-${randomUUID()}`,
|
|
328
|
+
workspace: context?.workspace ?? context?.session?.workspace ?? null,
|
|
329
|
+
type: 'run_request',
|
|
330
|
+
input,
|
|
331
|
+
status: 'queued',
|
|
332
|
+
createdAt: now,
|
|
333
|
+
updatedAt: now,
|
|
334
|
+
};
|
|
335
|
+
dispatchAgentEvent(context.session, createAgentEvent('control_enqueued', {
|
|
336
|
+
origin: 'runtime',
|
|
337
|
+
workspace: item.workspace,
|
|
338
|
+
payload: item,
|
|
339
|
+
}));
|
|
340
|
+
return item;
|
|
341
|
+
}
|
|
342
|
+
|
|
180
343
|
function isAuthorized(request, token) {
|
|
181
344
|
if (!token) return true;
|
|
182
345
|
const authorization = request.headers.authorization ?? '';
|
|
@@ -306,3 +306,325 @@ test('runtime server exposes approval endpoint', async (t) => {
|
|
|
306
306
|
await handle.close();
|
|
307
307
|
}
|
|
308
308
|
});
|
|
309
|
+
|
|
310
|
+
test('runtime server exposes control status and explanation', async (t) => {
|
|
311
|
+
const session = {
|
|
312
|
+
workspace: 'juno',
|
|
313
|
+
controlQueue: [{ id: 'control-1', workspace: 'juno', status: 'queued', input: 'later' }],
|
|
314
|
+
};
|
|
315
|
+
let handle;
|
|
316
|
+
try {
|
|
317
|
+
handle = await startRuntimeServer({
|
|
318
|
+
host: '127.0.0.1',
|
|
319
|
+
port: 0,
|
|
320
|
+
store: {
|
|
321
|
+
dbPath: ':memory:',
|
|
322
|
+
getState: () => ({
|
|
323
|
+
status: 'idle',
|
|
324
|
+
plan: [{ step: 1, description: 'Check status', status: 'pending' }],
|
|
325
|
+
queue: [],
|
|
326
|
+
controlQueue: session.controlQueue,
|
|
327
|
+
approvals: [],
|
|
328
|
+
summary: null,
|
|
329
|
+
}),
|
|
330
|
+
listEvents: () => [],
|
|
331
|
+
},
|
|
332
|
+
getContext: async () => ({
|
|
333
|
+
workspace: 'juno',
|
|
334
|
+
session,
|
|
335
|
+
running: false,
|
|
336
|
+
currentAbortController: null,
|
|
337
|
+
}),
|
|
338
|
+
run: async () => {},
|
|
339
|
+
});
|
|
340
|
+
} catch (err) {
|
|
341
|
+
if (err?.code === 'EPERM') {
|
|
342
|
+
t.skip('network listen is not permitted in this sandbox');
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
throw err;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
try {
|
|
349
|
+
const status = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=juno`);
|
|
350
|
+
assert.equal(status.status, 200);
|
|
351
|
+
const statusBody = await status.json();
|
|
352
|
+
assert.equal(statusBody.status, 'idle');
|
|
353
|
+
assert.equal(statusBody.running, false);
|
|
354
|
+
assert.equal(statusBody.controlQueue[0].id, 'control-1');
|
|
355
|
+
|
|
356
|
+
const explain = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=juno`, {
|
|
357
|
+
method: 'POST',
|
|
358
|
+
headers: { 'Content-Type': 'application/json' },
|
|
359
|
+
body: JSON.stringify({ action: 'explain' }),
|
|
360
|
+
});
|
|
361
|
+
assert.equal(explain.status, 200);
|
|
362
|
+
assert.match((await explain.json()).explanation, /control request/);
|
|
363
|
+
} finally {
|
|
364
|
+
await handle.close();
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test('runtime server control enqueue emits events but does not patch an active plan or start a run', async (t) => {
|
|
369
|
+
const session = {
|
|
370
|
+
workspace: 'juno',
|
|
371
|
+
controlQueue: [],
|
|
372
|
+
};
|
|
373
|
+
const events = [];
|
|
374
|
+
session._onAgentEvent = (event) => events.push(event);
|
|
375
|
+
let runCount = 0;
|
|
376
|
+
let handle;
|
|
377
|
+
try {
|
|
378
|
+
handle = await startRuntimeServer({
|
|
379
|
+
host: '127.0.0.1',
|
|
380
|
+
port: 0,
|
|
381
|
+
store: {
|
|
382
|
+
dbPath: ':memory:',
|
|
383
|
+
getState: () => ({
|
|
384
|
+
status: 'running',
|
|
385
|
+
plan: [{ step: 1, description: 'Active step', status: 'running' }],
|
|
386
|
+
queue: [],
|
|
387
|
+
controlQueue: session.controlQueue,
|
|
388
|
+
approvals: [],
|
|
389
|
+
summary: null,
|
|
390
|
+
}),
|
|
391
|
+
listEvents: () => [],
|
|
392
|
+
},
|
|
393
|
+
getContext: async () => ({
|
|
394
|
+
workspace: 'juno',
|
|
395
|
+
session,
|
|
396
|
+
running: true,
|
|
397
|
+
currentAbortController: new AbortController(),
|
|
398
|
+
}),
|
|
399
|
+
run: async () => { runCount += 1; },
|
|
400
|
+
});
|
|
401
|
+
} catch (err) {
|
|
402
|
+
if (err?.code === 'EPERM') {
|
|
403
|
+
t.skip('network listen is not permitted in this sandbox');
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
throw err;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
try {
|
|
410
|
+
const response = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=juno`, {
|
|
411
|
+
method: 'POST',
|
|
412
|
+
headers: { 'Content-Type': 'application/json' },
|
|
413
|
+
body: JSON.stringify({ action: 'enqueue', input: 'run this after current work' }),
|
|
414
|
+
});
|
|
415
|
+
assert.equal(response.status, 202);
|
|
416
|
+
const body = await response.json();
|
|
417
|
+
assert.equal(body.accepted, true);
|
|
418
|
+
assert.equal(body.item.status, 'queued');
|
|
419
|
+
assert.equal(body.controlQueue.length, 1);
|
|
420
|
+
assert.equal(body.plan[0].description, 'Active step');
|
|
421
|
+
assert.equal(session.controlQueue[0].input, 'run this after current work');
|
|
422
|
+
assert.equal(events.filter((event) => event.type === 'control_enqueued').length, 1);
|
|
423
|
+
assert.equal(runCount, 0);
|
|
424
|
+
} finally {
|
|
425
|
+
await handle.close();
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test('runtime server drains queued control requests when idle', async (t) => {
|
|
430
|
+
const session = {
|
|
431
|
+
workspace: 'juno',
|
|
432
|
+
controlQueue: [],
|
|
433
|
+
};
|
|
434
|
+
const events = [];
|
|
435
|
+
session._onAgentEvent = (event) => events.push(event);
|
|
436
|
+
let receivedBody = null;
|
|
437
|
+
let handle;
|
|
438
|
+
try {
|
|
439
|
+
handle = await startRuntimeServer({
|
|
440
|
+
host: '127.0.0.1',
|
|
441
|
+
port: 0,
|
|
442
|
+
store: {
|
|
443
|
+
dbPath: ':memory:',
|
|
444
|
+
getState: () => ({
|
|
445
|
+
status: 'idle',
|
|
446
|
+
plan: [],
|
|
447
|
+
queue: [],
|
|
448
|
+
controlQueue: session.controlQueue,
|
|
449
|
+
approvals: [],
|
|
450
|
+
summary: null,
|
|
451
|
+
}),
|
|
452
|
+
listEvents: () => [],
|
|
453
|
+
},
|
|
454
|
+
getContext: async () => ({
|
|
455
|
+
workspace: 'juno',
|
|
456
|
+
session,
|
|
457
|
+
running: false,
|
|
458
|
+
currentAbortController: null,
|
|
459
|
+
}),
|
|
460
|
+
run: async (_context, body) => {
|
|
461
|
+
receivedBody = body;
|
|
462
|
+
},
|
|
463
|
+
});
|
|
464
|
+
} catch (err) {
|
|
465
|
+
if (err?.code === 'EPERM') {
|
|
466
|
+
t.skip('network listen is not permitted in this sandbox');
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
throw err;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
try {
|
|
473
|
+
const response = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=juno`, {
|
|
474
|
+
method: 'POST',
|
|
475
|
+
headers: { 'Content-Type': 'application/json' },
|
|
476
|
+
body: JSON.stringify({ action: 'enqueue', input: 'run from control queue' }),
|
|
477
|
+
});
|
|
478
|
+
assert.equal(response.status, 202);
|
|
479
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
480
|
+
assert.equal(receivedBody.input, 'run from control queue');
|
|
481
|
+
assert.equal(receivedBody.workspace, 'juno');
|
|
482
|
+
assert.match(receivedBody.runId, /^[0-9a-f-]{36}$/);
|
|
483
|
+
assert.equal(session.controlQueue[0].status, 'running');
|
|
484
|
+
assert.equal(session.controlQueue[0].runId, receivedBody.runId);
|
|
485
|
+
assert.deepEqual(events.map((event) => event.type), ['control_enqueued', 'control_started']);
|
|
486
|
+
} finally {
|
|
487
|
+
await handle.close();
|
|
488
|
+
}
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
test('runtime server handle drains a pre-existing hydrated control request', async (t) => {
|
|
492
|
+
const session = {
|
|
493
|
+
workspace: 'juno',
|
|
494
|
+
controlQueue: [{ id: 'control-existing', workspace: 'juno', status: 'queued', input: 'resume queued control' }],
|
|
495
|
+
};
|
|
496
|
+
const events = [];
|
|
497
|
+
session._onAgentEvent = (event) => events.push(event);
|
|
498
|
+
const context = {
|
|
499
|
+
workspace: 'juno',
|
|
500
|
+
session,
|
|
501
|
+
running: false,
|
|
502
|
+
currentAbortController: null,
|
|
503
|
+
};
|
|
504
|
+
let receivedBody = null;
|
|
505
|
+
let handle;
|
|
506
|
+
try {
|
|
507
|
+
handle = await startRuntimeServer({
|
|
508
|
+
host: '127.0.0.1',
|
|
509
|
+
port: 0,
|
|
510
|
+
store: {
|
|
511
|
+
dbPath: ':memory:',
|
|
512
|
+
getState: () => ({ status: 'idle' }),
|
|
513
|
+
listEvents: () => [],
|
|
514
|
+
},
|
|
515
|
+
getContext: async () => context,
|
|
516
|
+
run: async (_context, body) => {
|
|
517
|
+
receivedBody = body;
|
|
518
|
+
},
|
|
519
|
+
});
|
|
520
|
+
} catch (err) {
|
|
521
|
+
if (err?.code === 'EPERM') {
|
|
522
|
+
t.skip('network listen is not permitted in this sandbox');
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
throw err;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
try {
|
|
529
|
+
assert.equal(handle.drainControl(context), true);
|
|
530
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
531
|
+
assert.equal(receivedBody.input, 'resume queued control');
|
|
532
|
+
assert.equal(session.controlQueue[0].status, 'running');
|
|
533
|
+
assert.equal(events[0].type, 'control_started');
|
|
534
|
+
} finally {
|
|
535
|
+
await handle.close();
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
test('runtime server exposes config profile list and switch endpoints', async (t) => {
|
|
540
|
+
const context = {
|
|
541
|
+
workspace: 'juno',
|
|
542
|
+
session: { workspace: 'juno', wikirc: { profile: 'default' } },
|
|
543
|
+
running: false,
|
|
544
|
+
currentAbortController: null,
|
|
545
|
+
};
|
|
546
|
+
let switchedProfile = null;
|
|
547
|
+
let handle;
|
|
548
|
+
try {
|
|
549
|
+
handle = await startRuntimeServer({
|
|
550
|
+
host: '127.0.0.1',
|
|
551
|
+
port: 0,
|
|
552
|
+
store: {
|
|
553
|
+
dbPath: ':memory:',
|
|
554
|
+
getState: () => ({ status: 'idle' }),
|
|
555
|
+
listEvents: () => [],
|
|
556
|
+
},
|
|
557
|
+
getContext: async () => context,
|
|
558
|
+
run: async () => {},
|
|
559
|
+
configProfiles: async () => ({ profiles: ['default', 'vpn'], active: context.session.wikirc.profile }),
|
|
560
|
+
useConfigProfile: async (_context, profile) => {
|
|
561
|
+
switchedProfile = profile;
|
|
562
|
+
context.session.wikirc.profile = profile;
|
|
563
|
+
return { ok: true, active: profile, config: { llm: { model: 'model-vpn' } } };
|
|
564
|
+
},
|
|
565
|
+
});
|
|
566
|
+
} catch (err) {
|
|
567
|
+
if (err?.code === 'EPERM') {
|
|
568
|
+
t.skip('network listen is not permitted in this sandbox');
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
throw err;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
try {
|
|
575
|
+
const profiles = await fetch(`http://127.0.0.1:${handle.port}/config/profiles?workspace=juno`);
|
|
576
|
+
assert.equal(profiles.status, 200);
|
|
577
|
+
assert.deepEqual(await profiles.json(), { profiles: ['default', 'vpn'], active: 'default' });
|
|
578
|
+
|
|
579
|
+
const use = await fetch(`http://127.0.0.1:${handle.port}/config/use?workspace=juno`, {
|
|
580
|
+
method: 'POST',
|
|
581
|
+
headers: { 'Content-Type': 'application/json' },
|
|
582
|
+
body: JSON.stringify({ profile: 'vpn' }),
|
|
583
|
+
});
|
|
584
|
+
assert.equal(use.status, 200);
|
|
585
|
+
assert.equal(switchedProfile, 'vpn');
|
|
586
|
+
assert.deepEqual(await use.json(), { ok: true, active: 'vpn', config: { llm: { model: 'model-vpn' } } });
|
|
587
|
+
} finally {
|
|
588
|
+
await handle.close();
|
|
589
|
+
}
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
test('runtime server rejects config switching while a run is active', async (t) => {
|
|
593
|
+
let handle;
|
|
594
|
+
try {
|
|
595
|
+
handle = await startRuntimeServer({
|
|
596
|
+
host: '127.0.0.1',
|
|
597
|
+
port: 0,
|
|
598
|
+
store: {
|
|
599
|
+
dbPath: ':memory:',
|
|
600
|
+
getState: () => ({ status: 'running' }),
|
|
601
|
+
listEvents: () => [],
|
|
602
|
+
},
|
|
603
|
+
getContext: async () => ({
|
|
604
|
+
workspace: 'juno',
|
|
605
|
+
session: { workspace: 'juno' },
|
|
606
|
+
running: true,
|
|
607
|
+
currentAbortController: null,
|
|
608
|
+
}),
|
|
609
|
+
run: async () => {},
|
|
610
|
+
useConfigProfile: async () => ({ ok: true }),
|
|
611
|
+
});
|
|
612
|
+
} catch (err) {
|
|
613
|
+
if (err?.code === 'EPERM') {
|
|
614
|
+
t.skip('network listen is not permitted in this sandbox');
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
throw err;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
try {
|
|
621
|
+
const response = await fetch(`http://127.0.0.1:${handle.port}/config/use?workspace=juno`, {
|
|
622
|
+
method: 'POST',
|
|
623
|
+
headers: { 'Content-Type': 'application/json' },
|
|
624
|
+
body: JSON.stringify({ profile: 'vpn' }),
|
|
625
|
+
});
|
|
626
|
+
assert.equal(response.status, 409);
|
|
627
|
+
} finally {
|
|
628
|
+
await handle.close();
|
|
629
|
+
}
|
|
630
|
+
});
|
package/src/runtime/store.js
CHANGED
|
@@ -366,6 +366,9 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
|
|
|
366
366
|
...projection,
|
|
367
367
|
runs: listRuns({ workspace }),
|
|
368
368
|
queue: projectQueue(projection.plan, rawQueue, { workspace }),
|
|
369
|
+
controlQueue: Array.isArray(projection.controlQueue)
|
|
370
|
+
? projection.controlQueue.filter((item) => !workspace || item.workspace === workspace || !item.workspace).map((item) => ({ ...item }))
|
|
371
|
+
: [],
|
|
369
372
|
eventsCursor: session?.agentEvents?.at(-1)?.sequence ?? events?.at(-1)?.sequence ?? lastEventSequence,
|
|
370
373
|
};
|
|
371
374
|
}
|
|
@@ -412,6 +412,31 @@ test('runtime state exposes queue as blocked jobs and pending plan steps', () =>
|
|
|
412
412
|
store.close();
|
|
413
413
|
});
|
|
414
414
|
|
|
415
|
+
test('runtime store persists and replays control queue events without mixing MCP queue', () => {
|
|
416
|
+
const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
|
|
417
|
+
const store = openRuntimeStore({ stateDir });
|
|
418
|
+
store.persistEvent(createAgentEvent('control_enqueued', {
|
|
419
|
+
origin: 'runtime',
|
|
420
|
+
workspace: 'docs',
|
|
421
|
+
payload: { id: 'control-docs', workspace: 'docs', input: 'later' },
|
|
422
|
+
}));
|
|
423
|
+
store.persistEvent(createAgentEvent('control_enqueued', {
|
|
424
|
+
origin: 'runtime',
|
|
425
|
+
workspace: 'juno',
|
|
426
|
+
payload: { id: 'control-juno', workspace: 'juno', input: 'other' },
|
|
427
|
+
}));
|
|
428
|
+
store.close();
|
|
429
|
+
|
|
430
|
+
const reopened = openRuntimeStore({ stateDir });
|
|
431
|
+
const session = { activities: {}, headlessPlan: null };
|
|
432
|
+
reopened.hydrateSession(session, { workspace: 'docs' });
|
|
433
|
+
const state = reopened.getState(session, { workspace: 'docs' });
|
|
434
|
+
assert.deepEqual(state.controlQueue.map((item) => item.id), ['control-docs']);
|
|
435
|
+
assert.deepEqual(state.queue, []);
|
|
436
|
+
assert.deepEqual(session.controlQueue.map((item) => item.id), ['control-docs']);
|
|
437
|
+
reopened.close();
|
|
438
|
+
});
|
|
439
|
+
|
|
415
440
|
test('runtime store identifies recoverable workspaces and interrupts stale runs', () => {
|
|
416
441
|
const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
|
|
417
442
|
const store = openRuntimeStore({ stateDir });
|