@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/store.js
CHANGED
|
@@ -4,6 +4,7 @@ import { DatabaseSync } from 'node:sqlite';
|
|
|
4
4
|
import { applyAgentProjectionToSession, dispatchAgentEvent, reduceAgentEvents } from '../core/agentEvents.js';
|
|
5
5
|
import { defaultRuntimeStateDir } from '../core/env.js';
|
|
6
6
|
import { projectQueue } from '../core/jobQueue.js';
|
|
7
|
+
import { projectWorkflow } from '../core/workflow.js';
|
|
7
8
|
|
|
8
9
|
export { defaultRuntimeStateDir };
|
|
9
10
|
|
|
@@ -35,6 +36,7 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
|
|
|
35
36
|
type TEXT NOT NULL,
|
|
36
37
|
run_id TEXT,
|
|
37
38
|
turn_id TEXT,
|
|
39
|
+
task_id TEXT,
|
|
38
40
|
workspace TEXT,
|
|
39
41
|
origin TEXT,
|
|
40
42
|
payload TEXT NOT NULL
|
|
@@ -69,6 +71,7 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
|
|
|
69
71
|
`);
|
|
70
72
|
ensureColumn(db, 'events', 'sequence', 'INTEGER');
|
|
71
73
|
ensureColumn(db, 'events', 'workspace', 'TEXT');
|
|
74
|
+
ensureColumn(db, 'events', 'task_id', 'TEXT');
|
|
72
75
|
ensureColumn(db, 'runs', 'workspace', 'TEXT');
|
|
73
76
|
backfillEventSequence(db);
|
|
74
77
|
db.exec('CREATE INDEX IF NOT EXISTS idx_events_workspace ON events(workspace)');
|
|
@@ -78,17 +81,17 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
|
|
|
78
81
|
let lastEventSequence = null;
|
|
79
82
|
|
|
80
83
|
const insertEvent = db.prepare(`
|
|
81
|
-
INSERT OR IGNORE INTO events (sequence, id, ts, type, run_id, turn_id, workspace, origin, payload)
|
|
82
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
84
|
+
INSERT OR IGNORE INTO events (sequence, id, ts, type, run_id, turn_id, task_id, workspace, origin, payload)
|
|
85
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
83
86
|
`);
|
|
84
87
|
const nextEventSequenceStatement = db.prepare('SELECT COALESCE(MAX(sequence), 0) + 1 AS next_sequence FROM events');
|
|
85
88
|
const listEventsStatement = db.prepare(`
|
|
86
|
-
SELECT sequence, id, ts, type, run_id, turn_id, workspace, origin, payload
|
|
89
|
+
SELECT sequence, id, ts, type, run_id, turn_id, task_id, workspace, origin, payload
|
|
87
90
|
FROM events
|
|
88
91
|
ORDER BY sequence ASC
|
|
89
92
|
`);
|
|
90
93
|
const listEventsByWorkspaceStatement = db.prepare(`
|
|
91
|
-
SELECT sequence, id, ts, type, run_id, turn_id, workspace, origin, payload
|
|
94
|
+
SELECT sequence, id, ts, type, run_id, turn_id, task_id, workspace, origin, payload
|
|
92
95
|
FROM events
|
|
93
96
|
WHERE workspace = ?
|
|
94
97
|
ORDER BY sequence ASC
|
|
@@ -195,6 +198,7 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
|
|
|
195
198
|
event.type,
|
|
196
199
|
event.runId ?? null,
|
|
197
200
|
event.turnId ?? null,
|
|
201
|
+
event.taskId ?? event.payload?.taskId ?? null,
|
|
198
202
|
ws,
|
|
199
203
|
event.origin ?? null,
|
|
200
204
|
JSON.stringify(event.payload ?? {}),
|
|
@@ -240,6 +244,12 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
|
|
|
240
244
|
return rows.map(rowToEvent);
|
|
241
245
|
}
|
|
242
246
|
|
|
247
|
+
function listAuditTrail({ workspace = null, runId = null } = {}) {
|
|
248
|
+
return listEvents({ workspace })
|
|
249
|
+
.filter((event) => !runId || event.runId === runId || event.payload?.runId === runId)
|
|
250
|
+
.map(eventToAuditEntry);
|
|
251
|
+
}
|
|
252
|
+
|
|
243
253
|
function listRuns({ workspace = null } = {}) {
|
|
244
254
|
const rows = workspace
|
|
245
255
|
? listRunsByWorkspaceStatement.all(workspace)
|
|
@@ -362,12 +372,22 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
|
|
|
362
372
|
const events = session?.agentProjection ? null : listEvents({ workspace });
|
|
363
373
|
const projection = session?.agentProjection ?? reduceAgentEvents(events);
|
|
364
374
|
const rawQueue = session?.queueStore?.list() ?? listQueue({ workspace });
|
|
365
|
-
|
|
375
|
+
const queue = projectQueue(projection.plan, rawQueue, { workspace });
|
|
376
|
+
const controlQueue = Array.isArray(projection.controlQueue)
|
|
377
|
+
? projection.controlQueue.filter((item) => !workspace || item.workspace === workspace || !item.workspace).map((item) => ({ ...item }))
|
|
378
|
+
: [];
|
|
379
|
+
const baseState = {
|
|
366
380
|
...projection,
|
|
367
|
-
|
|
368
|
-
|
|
381
|
+
queue,
|
|
382
|
+
controlQueue,
|
|
369
383
|
eventsCursor: session?.agentEvents?.at(-1)?.sequence ?? events?.at(-1)?.sequence ?? lastEventSequence,
|
|
370
384
|
};
|
|
385
|
+
const runs = listRuns({ workspace });
|
|
386
|
+
return {
|
|
387
|
+
...baseState,
|
|
388
|
+
runs,
|
|
389
|
+
workflow: projectWorkflow({ ...baseState, runs, workspace }, events ?? session?.agentEvents ?? []),
|
|
390
|
+
};
|
|
371
391
|
}
|
|
372
392
|
|
|
373
393
|
function hydrateSession(session, { workspace = null } = {}) {
|
|
@@ -388,6 +408,7 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
|
|
|
388
408
|
persistEvent,
|
|
389
409
|
persistRun,
|
|
390
410
|
listEvents,
|
|
411
|
+
listAuditTrail,
|
|
391
412
|
listRuns,
|
|
392
413
|
listRecoverableRuns,
|
|
393
414
|
listRecoverableWorkspaces,
|
|
@@ -411,11 +432,42 @@ function rowToEvent(row) {
|
|
|
411
432
|
origin: row.origin ?? 'system',
|
|
412
433
|
runId: row.run_id ?? null,
|
|
413
434
|
turnId: row.turn_id ?? null,
|
|
435
|
+
taskId: row.task_id ?? null,
|
|
414
436
|
workspace: row.workspace ?? null,
|
|
415
437
|
payload: row.payload ? JSON.parse(row.payload) : {},
|
|
416
438
|
};
|
|
417
439
|
}
|
|
418
440
|
|
|
441
|
+
function eventToAuditEntry(event) {
|
|
442
|
+
const payload = event.payload ?? {};
|
|
443
|
+
const activity = payload.activity ?? {};
|
|
444
|
+
return {
|
|
445
|
+
sequence: event.sequence ?? null,
|
|
446
|
+
ts: event.ts,
|
|
447
|
+
type: event.type,
|
|
448
|
+
runId: event.runId ?? payload.runId ?? null,
|
|
449
|
+
turnId: event.turnId ?? payload.turnId ?? null,
|
|
450
|
+
taskId: event.taskId ?? payload.taskId ?? activity.taskId ?? null,
|
|
451
|
+
activityId: payload.activityId ?? activity.id ?? activity.key ?? null,
|
|
452
|
+
toolCallId: payload.toolCallId ?? payload.callId ?? null,
|
|
453
|
+
workspace: event.workspace ?? payload.workspace ?? null,
|
|
454
|
+
caller: event.origin ?? payload.caller ?? 'system',
|
|
455
|
+
status: payload.status ?? activity.status ?? null,
|
|
456
|
+
tool: payload.tool ?? payload.name ?? activity.tool ?? null,
|
|
457
|
+
summary: auditSummary(event),
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function auditSummary(event) {
|
|
462
|
+
const payload = event.payload ?? {};
|
|
463
|
+
if (typeof payload.message === 'string') return payload.message;
|
|
464
|
+
if (typeof payload.summary === 'string') return payload.summary;
|
|
465
|
+
if (typeof payload.input === 'string') return payload.input;
|
|
466
|
+
if (typeof payload.content === 'string') return payload.content.slice(0, 240);
|
|
467
|
+
if (payload.activity?.label) return payload.activity.label;
|
|
468
|
+
return event.type;
|
|
469
|
+
}
|
|
470
|
+
|
|
419
471
|
function ensureColumn(db, table, column, definition) {
|
|
420
472
|
const existing = db.prepare(`PRAGMA table_info(${table})`).all();
|
|
421
473
|
if (existing.some((row) => row.name === column)) return;
|
|
@@ -59,6 +59,7 @@ test('runtime store persists run identity fields on events', () => {
|
|
|
59
59
|
origin: 'agent',
|
|
60
60
|
runId: 'run-2',
|
|
61
61
|
turnId: 'run-2:turn-1',
|
|
62
|
+
taskId: 'task-1',
|
|
62
63
|
workspace: 'docs',
|
|
63
64
|
payload: { content: 'done' },
|
|
64
65
|
}));
|
|
@@ -66,10 +67,51 @@ test('runtime store persists run identity fields on events', () => {
|
|
|
66
67
|
const [event] = store.listEvents();
|
|
67
68
|
assert.equal(event.runId, 'run-2');
|
|
68
69
|
assert.equal(event.turnId, 'run-2:turn-1');
|
|
70
|
+
assert.equal(event.taskId, 'task-1');
|
|
69
71
|
assert.equal(event.workspace, 'docs');
|
|
70
72
|
store.close();
|
|
71
73
|
});
|
|
72
74
|
|
|
75
|
+
test('runtime store exposes a correlated audit trail', () => {
|
|
76
|
+
const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
|
|
77
|
+
const store = openRuntimeStore({ stateDir });
|
|
78
|
+
store.persistEvent(createAgentEvent('tool_call_started', {
|
|
79
|
+
origin: 'agent',
|
|
80
|
+
runId: 'run-audit',
|
|
81
|
+
turnId: 'run-audit:turn-1',
|
|
82
|
+
taskId: 'task-a',
|
|
83
|
+
workspace: 'docs',
|
|
84
|
+
payload: { callId: 'call-1', name: 'production_start_job' },
|
|
85
|
+
}));
|
|
86
|
+
store.persistEvent(createAgentEvent('activity_upserted', {
|
|
87
|
+
origin: 'tool',
|
|
88
|
+
runId: 'run-audit',
|
|
89
|
+
turnId: 'run-audit:turn-1',
|
|
90
|
+
taskId: 'task-a',
|
|
91
|
+
workspace: 'docs',
|
|
92
|
+
payload: {
|
|
93
|
+
activity: {
|
|
94
|
+
id: 'job-1',
|
|
95
|
+
source: 'production',
|
|
96
|
+
kind: 'job',
|
|
97
|
+
label: 'Production job',
|
|
98
|
+
status: 'running',
|
|
99
|
+
progress: {},
|
|
100
|
+
poll: null,
|
|
101
|
+
outputRefs: [],
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
}));
|
|
105
|
+
|
|
106
|
+
const audit = store.listAuditTrail({ workspace: 'docs', runId: 'run-audit' });
|
|
107
|
+
assert.equal(audit.length, 2);
|
|
108
|
+
assert.equal(audit[0].toolCallId, 'call-1');
|
|
109
|
+
assert.equal(audit[0].taskId, 'task-a');
|
|
110
|
+
assert.equal(audit[1].activityId, 'job-1');
|
|
111
|
+
assert.equal(audit[1].workspace, 'docs');
|
|
112
|
+
store.close();
|
|
113
|
+
});
|
|
114
|
+
|
|
73
115
|
test('runtime store replays evaluator verdict into state', () => {
|
|
74
116
|
const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
|
|
75
117
|
const store = openRuntimeStore({ stateDir });
|
|
@@ -404,12 +446,41 @@ test('runtime state exposes queue as blocked jobs and pending plan steps', () =>
|
|
|
404
446
|
{ id: 'q-done', workspace: 'docs', status: 'done', args: { type: 'done' } },
|
|
405
447
|
], { workspace: 'docs' });
|
|
406
448
|
|
|
407
|
-
const
|
|
449
|
+
const state = store.getState(session, { workspace: 'docs' });
|
|
450
|
+
const queue = state.queue;
|
|
408
451
|
assert.deepEqual(queue.map((item) => item.id), ['q-wait', 'plan-2', 'plan-3']);
|
|
409
452
|
assert.equal(queue[0].queueType, 'blocked_job');
|
|
410
453
|
assert.equal(queue[1].queueType, 'pending_step');
|
|
411
454
|
assert.equal(queue[1].args.type, 'Build');
|
|
455
|
+
assert.ok(state.workflow.nodes.some((node) => node.id === 'task:2'));
|
|
456
|
+
assert.ok(state.workflow.nodes.some((node) => node.id === 'queue:q-wait'));
|
|
457
|
+
assert.ok(state.workflow.waitingReasons.includes('queue:q-wait'));
|
|
458
|
+
store.close();
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
test('runtime store persists and replays control queue events without mixing MCP queue', () => {
|
|
462
|
+
const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
|
|
463
|
+
const store = openRuntimeStore({ stateDir });
|
|
464
|
+
store.persistEvent(createAgentEvent('control_enqueued', {
|
|
465
|
+
origin: 'runtime',
|
|
466
|
+
workspace: 'docs',
|
|
467
|
+
payload: { id: 'control-docs', workspace: 'docs', input: 'later' },
|
|
468
|
+
}));
|
|
469
|
+
store.persistEvent(createAgentEvent('control_enqueued', {
|
|
470
|
+
origin: 'runtime',
|
|
471
|
+
workspace: 'juno',
|
|
472
|
+
payload: { id: 'control-juno', workspace: 'juno', input: 'other' },
|
|
473
|
+
}));
|
|
412
474
|
store.close();
|
|
475
|
+
|
|
476
|
+
const reopened = openRuntimeStore({ stateDir });
|
|
477
|
+
const session = { activities: {}, headlessPlan: null };
|
|
478
|
+
reopened.hydrateSession(session, { workspace: 'docs' });
|
|
479
|
+
const state = reopened.getState(session, { workspace: 'docs' });
|
|
480
|
+
assert.deepEqual(state.controlQueue.map((item) => item.id), ['control-docs']);
|
|
481
|
+
assert.deepEqual(state.queue, []);
|
|
482
|
+
assert.deepEqual(session.controlQueue.map((item) => item.id), ['control-docs']);
|
|
483
|
+
reopened.close();
|
|
413
484
|
});
|
|
414
485
|
|
|
415
486
|
test('runtime store identifies recoverable workspaces and interrupts stale runs', () => {
|
package/src/shell/RightPane.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/solid */
|
|
2
2
|
import { createMemo, Index, Show } from 'solid-js';
|
|
3
|
+
import { fit } from './textFit';
|
|
3
4
|
|
|
4
5
|
type PlanStep = { step: number; description: string; status: string };
|
|
5
6
|
type QueueItem = {
|
|
@@ -45,13 +46,6 @@ function wrapLine(value: string, width: number) {
|
|
|
45
46
|
return lines;
|
|
46
47
|
}
|
|
47
48
|
|
|
48
|
-
function fit(value: string, width: number) {
|
|
49
|
-
const max = Math.max(1, width);
|
|
50
|
-
if (value.length <= max) return value;
|
|
51
|
-
if (max <= 1) return '…';
|
|
52
|
-
return value.slice(0, max - 1) + '…';
|
|
53
|
-
}
|
|
54
|
-
|
|
55
49
|
function logLineParts(line: string): LogLineParts {
|
|
56
50
|
const match = line.match(/^((?:\d{1,2}:\d{2}(?::\d{2})?(?:\s?[AP]M)?|\d{4}-\d{2}-\d{2}[T ][0-9:.]+Z?))\s+(.+)$/i);
|
|
57
51
|
if (!match) return { time: null, message: line };
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import { useKeyboard } from '@opentui/solid';
|
|
3
|
+
import { createMemo, createSignal, For } from 'solid-js';
|
|
4
|
+
import { fit } from './textFit';
|
|
5
|
+
|
|
6
|
+
export type StartupAction =
|
|
7
|
+
| 'new-conversation'
|
|
8
|
+
| 'open-workspace'
|
|
9
|
+
| 'run-workflow'
|
|
10
|
+
| 'init-workspace';
|
|
11
|
+
|
|
12
|
+
type StartupItem = {
|
|
13
|
+
action: StartupAction;
|
|
14
|
+
label: string;
|
|
15
|
+
detail: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function statusDot(ready: boolean) {
|
|
19
|
+
return ready ? '●' : '○';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function initWorkspaceItem(detail: string): StartupItem {
|
|
23
|
+
return { action: 'init-workspace', label: 'Initialize workspace', detail };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function StartupScreen(props: {
|
|
27
|
+
version: string;
|
|
28
|
+
model: string;
|
|
29
|
+
connectedMcpServers: number;
|
|
30
|
+
wikiReady: boolean;
|
|
31
|
+
workspaceName?: string | null;
|
|
32
|
+
profileName?: string | null;
|
|
33
|
+
workspaces: string[];
|
|
34
|
+
hasWorkspace: boolean;
|
|
35
|
+
width: number;
|
|
36
|
+
height: number;
|
|
37
|
+
onSelect: (action: StartupAction, workspaceName?: string) => void;
|
|
38
|
+
onQuit: () => void;
|
|
39
|
+
}) {
|
|
40
|
+
const [mode, setMode] = createSignal<'home' | 'workspace-select'>('home');
|
|
41
|
+
const [selected, setSelected] = createSignal(0);
|
|
42
|
+
const panelWidth = createMemo(() => Math.max(54, Math.min(86, props.width - 4)));
|
|
43
|
+
const panelHeight = createMemo(() => Math.max(24, Math.min(32, props.height - 2)));
|
|
44
|
+
const left = createMemo(() => Math.max(1, Math.floor((props.width - panelWidth()) / 2)));
|
|
45
|
+
const top = createMemo(() => Math.max(1, Math.floor((props.height - panelHeight()) / 2)));
|
|
46
|
+
|
|
47
|
+
const items = createMemo<StartupItem[]>(() => {
|
|
48
|
+
if (!props.hasWorkspace) {
|
|
49
|
+
return [initWorkspaceItem('Create and configure the default workspace')];
|
|
50
|
+
}
|
|
51
|
+
return [
|
|
52
|
+
{
|
|
53
|
+
action: 'new-conversation',
|
|
54
|
+
label: 'Start a new conversation',
|
|
55
|
+
detail: props.workspaceName ? `Load ${props.workspaceName} with default .wikirc` : 'Load default .wikirc',
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
action: 'open-workspace',
|
|
59
|
+
label: 'Open workspace',
|
|
60
|
+
detail: 'Select, load, then start agents and services',
|
|
61
|
+
},
|
|
62
|
+
initWorkspaceItem('Create and configure a new workspace'),
|
|
63
|
+
{
|
|
64
|
+
action: 'run-workflow',
|
|
65
|
+
label: 'Run a workflow',
|
|
66
|
+
detail: 'Switch to Agent mode after loading the workspace',
|
|
67
|
+
},
|
|
68
|
+
];
|
|
69
|
+
});
|
|
70
|
+
const workspaceItems = createMemo(() => props.workspaces.map((name) => ({
|
|
71
|
+
label: name,
|
|
72
|
+
detail: name === props.workspaceName ? 'default workspace' : 'available workspace',
|
|
73
|
+
})));
|
|
74
|
+
const currentLength = createMemo(() => mode() === 'workspace-select' ? workspaceItems().length : items().length);
|
|
75
|
+
|
|
76
|
+
const move = (delta: number) => {
|
|
77
|
+
setSelected((value) => (value + delta + currentLength()) % currentLength());
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const choose = (index = selected()) => {
|
|
81
|
+
if (mode() === 'workspace-select') {
|
|
82
|
+
const item = workspaceItems()[index];
|
|
83
|
+
if (item) props.onSelect('open-workspace', item.label);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const item = items()[index];
|
|
87
|
+
if (!item) return;
|
|
88
|
+
if (item.action === 'open-workspace') {
|
|
89
|
+
setMode('workspace-select');
|
|
90
|
+
setSelected(0);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
props.onSelect(item.action);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
useKeyboard((key: any) => {
|
|
97
|
+
const keyName = String(key.name ?? '').toLowerCase();
|
|
98
|
+
const sequence = String(key.sequence ?? '');
|
|
99
|
+
if ((key.ctrl || key.meta) && keyName === 'c') {
|
|
100
|
+
props.onQuit();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (keyName === 'escape' && mode() === 'workspace-select') {
|
|
104
|
+
setMode('home');
|
|
105
|
+
setSelected(0);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (keyName === 'escape') {
|
|
109
|
+
props.onQuit();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (keyName === 'up') {
|
|
113
|
+
move(-1);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (keyName === 'down') {
|
|
117
|
+
move(1);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (keyName === 'return' || keyName === 'enter') {
|
|
121
|
+
choose();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const numeric = Number.parseInt(sequence, 10);
|
|
125
|
+
if (Number.isInteger(numeric) && numeric >= 1 && numeric <= currentLength()) {
|
|
126
|
+
choose(numeric - 1);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const status = createMemo(() => {
|
|
131
|
+
if (!props.hasWorkspace) return 'Setup needed';
|
|
132
|
+
return props.wikiReady ? 'Ready' : 'Default .wikirc missing';
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const subtitle = createMemo(() => {
|
|
136
|
+
const workspace = props.workspaceName ?? 'no workspace';
|
|
137
|
+
const profile = props.profileName ?? 'default';
|
|
138
|
+
return `${workspace} / ${profile}`;
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const innerWidth = createMemo(() => Math.max(40, panelWidth() - 6));
|
|
142
|
+
const shortcutHint = createMemo(() => {
|
|
143
|
+
const count = currentLength();
|
|
144
|
+
const quick = count === 1 ? '1 quick select' : `1-${count} quick select`;
|
|
145
|
+
return mode() === 'workspace-select'
|
|
146
|
+
? `↑/↓ select Enter open ${quick} Esc back`
|
|
147
|
+
: `↑/↓ select Enter open ${quick} Esc quit`;
|
|
148
|
+
});
|
|
149
|
+
const menuTitle = createMemo(() => mode() === 'workspace-select' ? 'Select workspace' : '');
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<box width="100%" height="100%" backgroundColor="#0B0D12">
|
|
153
|
+
<box
|
|
154
|
+
position="absolute"
|
|
155
|
+
left={left()}
|
|
156
|
+
top={top()}
|
|
157
|
+
width={panelWidth()}
|
|
158
|
+
height={panelHeight()}
|
|
159
|
+
border
|
|
160
|
+
borderStyle="rounded"
|
|
161
|
+
borderColor="#8BD5CA"
|
|
162
|
+
backgroundColor="#111318"
|
|
163
|
+
padding={2}
|
|
164
|
+
flexDirection="column"
|
|
165
|
+
overflow="hidden"
|
|
166
|
+
>
|
|
167
|
+
<box height={1} flexDirection="row">
|
|
168
|
+
<text fg="#8BD5CA" content={fit(`DONNA v${props.version}`, Math.floor(innerWidth() * 0.5))} />
|
|
169
|
+
<text fg={props.wikiReady ? '#8BD5CA' : '#FBBF24'} content={fit(` ${statusDot(props.wikiReady)} ${status()}`, Math.floor(innerWidth() * 0.45))} />
|
|
170
|
+
</box>
|
|
171
|
+
<text height={1} fg="#7F8C8D" content={fit(subtitle(), innerWidth())} />
|
|
172
|
+
<text height={1}>{''}</text>
|
|
173
|
+
<text height={1} fg="#D6DEE8">██████╗ ██████╗ ███╗ ██╗███╗ ██╗ █████╗</text>
|
|
174
|
+
<text height={1} fg="#D6DEE8">██╔══██╗██╔═══██╗████╗ ██║████╗ ██║██╔══██╗</text>
|
|
175
|
+
<text height={1} fg="#D6DEE8">██║ ██║██║ ██║██╔██╗ ██║██╔██╗ ██║███████║</text>
|
|
176
|
+
<text height={1} fg="#D6DEE8">██║ ██║██║ ██║██║╚██╗██║██║╚██╗██║██╔══██║</text>
|
|
177
|
+
<text height={1} fg="#D6DEE8">██████╔╝╚██████╔╝██║ ╚████║██║ ╚████║██║ ██║</text>
|
|
178
|
+
<text height={1} fg="#D6DEE8">╚═════╝ ╚═════╝ ╚═╝ ╚═══╝╚═╝ ╚═══╝╚═╝ ╚═╝</text>
|
|
179
|
+
<text height={1}>{''}</text>
|
|
180
|
+
<text height={1} fg="#7F8C8D" content={menuTitle()} />
|
|
181
|
+
<box height={Math.max(1, currentLength())} flexDirection="column">
|
|
182
|
+
<For each={mode() === 'workspace-select' ? workspaceItems() : items()}>
|
|
183
|
+
{(item, index) => {
|
|
184
|
+
const active = () => index() === selected();
|
|
185
|
+
return (
|
|
186
|
+
<box
|
|
187
|
+
height={1}
|
|
188
|
+
flexDirection="row"
|
|
189
|
+
onMouseUp={() => choose(index())}
|
|
190
|
+
>
|
|
191
|
+
<text width={4} fg={active() ? '#111318' : '#7F8C8D'} bg={active() ? '#8BD5CA' : undefined}>
|
|
192
|
+
{active() ? '›' : ' '} {index() + 1}
|
|
193
|
+
</text>
|
|
194
|
+
<text width={28} fg={active() ? '#8BD5CA' : '#D6DEE8'} content={fit(item.label, 27)} />
|
|
195
|
+
<text fg="#7F8C8D" content={fit(item.detail, Math.max(8, innerWidth() - 34))} />
|
|
196
|
+
</box>
|
|
197
|
+
);
|
|
198
|
+
}}
|
|
199
|
+
</For>
|
|
200
|
+
</box>
|
|
201
|
+
<text height={1}>{''}</text>
|
|
202
|
+
<box height={3} flexDirection="column" border={['left']} borderStyle="heavy" borderColor="#5DADE2" paddingX={1}>
|
|
203
|
+
<text height={1} fg="#D6DEE8" content={fit(`LLM ${statusDot(Boolean(props.model))} ${props.model || 'not configured'}`, innerWidth() - 2)} />
|
|
204
|
+
<text height={1} fg="#D6DEE8" content={fit(`MCP ${statusDot(props.connectedMcpServers > 0)} ${props.connectedMcpServers} server(s) configured`, innerWidth() - 2)} />
|
|
205
|
+
<text height={1} fg="#D6DEE8" content={fit(`Wiki ${statusDot(props.wikiReady)} ${props.wikiReady ? 'default profile ready' : 'init required'}`, innerWidth() - 2)} />
|
|
206
|
+
</box>
|
|
207
|
+
<text height={1}>{''}</text>
|
|
208
|
+
<text height={1} fg="#7F8C8D" content={shortcutHint()} />
|
|
209
|
+
</box>
|
|
210
|
+
</box>
|
|
211
|
+
);
|
|
212
|
+
}
|
package/src/shell/repl.js
CHANGED
|
@@ -15,7 +15,7 @@ import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
|
15
15
|
import { listSkills } from '../core/skills.js';
|
|
16
16
|
import { listWikircProfiles } from '../core/wikirc.js';
|
|
17
17
|
import { listWorkspaces } from '../core/workspaces.js';
|
|
18
|
-
import { fetchRuntimeState, postRuntimeApprove, postRuntimeCancel, postRuntimeRun, streamRuntimeEvents } from '../runtime/client.js';
|
|
18
|
+
import { fetchRuntimeState, postRuntimeApprove, postRuntimeCancel, postRuntimeControl, postRuntimeRun, streamRuntimeEvents } from '../runtime/client.js';
|
|
19
19
|
|
|
20
20
|
marked.use(markedTerminal());
|
|
21
21
|
// marked-terminal's text renderer extracts token.text (raw string) instead of
|
|
@@ -743,7 +743,18 @@ export function applyRuntimeStateToShellSession(session, state) {
|
|
|
743
743
|
logs: Array.isArray(state.logs) ? [...state.logs] : [],
|
|
744
744
|
summary: state.summary ?? null,
|
|
745
745
|
status: state.status ?? 'idle',
|
|
746
|
+
planRevision: state.planRevision ?? 0,
|
|
747
|
+
planPatches: Array.isArray(state.planPatches) ? state.planPatches.map((patch) => ({ ...patch })) : [],
|
|
746
748
|
};
|
|
749
|
+
session.workflow = state.workflow && typeof state.workflow === 'object'
|
|
750
|
+
? {
|
|
751
|
+
...state.workflow,
|
|
752
|
+
nodes: Array.isArray(state.workflow.nodes) ? state.workflow.nodes.map((node) => ({ ...node })) : [],
|
|
753
|
+
relations: Array.isArray(state.workflow.relations) ? state.workflow.relations.map((relation) => ({ ...relation })) : [],
|
|
754
|
+
waitingReasons: Array.isArray(state.workflow.waitingReasons) ? [...state.workflow.waitingReasons] : [],
|
|
755
|
+
warnings: Array.isArray(state.workflow.warnings) ? [...state.workflow.warnings] : [],
|
|
756
|
+
}
|
|
757
|
+
: null;
|
|
747
758
|
session.headlessPlan = session.agentProjection.plan
|
|
748
759
|
? session.agentProjection.plan.map((step) => ({ ...step }))
|
|
749
760
|
: null;
|
|
@@ -772,6 +783,27 @@ export function applyRuntimeStateToShellSession(session, state) {
|
|
|
772
783
|
return true;
|
|
773
784
|
}
|
|
774
785
|
|
|
786
|
+
// Submits a prompt to the shared runtime. If the workspace is already busy
|
|
787
|
+
// (HTTP 409 from POST /run), route the input through the runtime control lane
|
|
788
|
+
// so status questions and plan-change proposals do not become future runs.
|
|
789
|
+
export async function submitRuntimeRun(line, { runtime, session }) {
|
|
790
|
+
const workspace = session.workspace ?? null;
|
|
791
|
+
try {
|
|
792
|
+
await postRuntimeRun(line, { url: runtime.url, workspace });
|
|
793
|
+
return { kind: 'accepted' };
|
|
794
|
+
} catch (err) {
|
|
795
|
+
if (err?.status !== 409) {
|
|
796
|
+
return { kind: 'error', message: err instanceof Error ? err.message : String(err) };
|
|
797
|
+
}
|
|
798
|
+
try {
|
|
799
|
+
const result = await postRuntimeControl('message', { url: runtime.url, workspace, input: line });
|
|
800
|
+
return { kind: result?.kind ?? 'control', result };
|
|
801
|
+
} catch (queueErr) {
|
|
802
|
+
return { kind: 'error', message: queueErr instanceof Error ? queueErr.message : String(queueErr) };
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
775
807
|
function activityText(session) {
|
|
776
808
|
const activity = sessionActivities(session).find((item) => !item.terminal)
|
|
777
809
|
?? (session.productionActivity?.label ? session.productionActivity : null);
|
|
@@ -1499,13 +1531,25 @@ async function runTuiShell({ agent, packageJson, session, runtime = null }) {
|
|
|
1499
1531
|
syncRuntimeState();
|
|
1500
1532
|
}
|
|
1501
1533
|
} else if (runtime?.url && !session.chatMode && !line.trim().startsWith('/')) {
|
|
1502
|
-
await postRuntimeRun(line, {
|
|
1503
|
-
url: runtime.url,
|
|
1504
|
-
workspace: session.workspace ?? null,
|
|
1505
|
-
});
|
|
1506
1534
|
conversationMessages(session).push({ role: 'user', content: line });
|
|
1507
|
-
|
|
1508
|
-
|
|
1535
|
+
const outcome = await submitRuntimeRun(line, { runtime, session });
|
|
1536
|
+
if (outcome.kind === 'accepted') {
|
|
1537
|
+
conversationMessages(session).push({ role: 'command', content: `Runtime run queued: ${runtime.url}` });
|
|
1538
|
+
activityLines = [...activityLines, 'runtime: run accepted'].slice(-LOWER_DETAIL_ROWS);
|
|
1539
|
+
} else if (outcome.kind === 'queued') {
|
|
1540
|
+
conversationMessages(session).push({ role: 'command', content: 'Runtime is busy — request added to the control queue, it will start automatically.' });
|
|
1541
|
+
activityLines = [...activityLines, 'runtime: control queued'].slice(-LOWER_DETAIL_ROWS);
|
|
1542
|
+
} else if (outcome.kind === 'observe' || outcome.kind === 'converse' || outcome.kind === 'mutate') {
|
|
1543
|
+
const explanation = outcome.result?.explanation ?? 'Runtime control message accepted.';
|
|
1544
|
+
conversationMessages(session).push({ role: 'command', content: explanation });
|
|
1545
|
+
activityLines = [...activityLines, `runtime: ${outcome.kind}`].slice(-LOWER_DETAIL_ROWS);
|
|
1546
|
+
} else if (outcome.kind === 'ambiguous') {
|
|
1547
|
+
conversationMessages(session).push({ role: 'command', content: 'Runtime could not classify that message. Use /queue for a future run, or ask/status more explicitly.' });
|
|
1548
|
+
activityLines = [...activityLines, 'runtime: ambiguous control'].slice(-LOWER_DETAIL_ROWS);
|
|
1549
|
+
} else {
|
|
1550
|
+
conversationMessages(session).push({ role: 'command', content: `Runtime error: ${outcome.message}` });
|
|
1551
|
+
activityLines = [...activityLines, `runtime error: ${outcome.message}`].slice(-LOWER_DETAIL_ROWS);
|
|
1552
|
+
}
|
|
1509
1553
|
syncRuntimeState();
|
|
1510
1554
|
} else {
|
|
1511
1555
|
const result = await runLine(line, { agent, packageJson, session, onUpdate: rerender, onStep });
|
package/src/shell/repl.test.js
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import test from 'node:test';
|
|
3
|
-
import { applyRuntimeStateToShellSession, createSession, conversationMessages } from './repl.js';
|
|
3
|
+
import { applyRuntimeStateToShellSession, createSession, conversationMessages, submitRuntimeRun } from './repl.js';
|
|
4
|
+
|
|
5
|
+
function stubFetch(handler) {
|
|
6
|
+
const original = globalThis.fetch;
|
|
7
|
+
globalThis.fetch = handler;
|
|
8
|
+
return () => { globalThis.fetch = original; };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function jsonResponse(status, body) {
|
|
12
|
+
return { ok: status >= 200 && status < 300, status, text: async () => JSON.stringify(body), json: async () => body };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function pathOf(url) {
|
|
16
|
+
return new URL(String(url)).pathname;
|
|
17
|
+
}
|
|
4
18
|
|
|
5
19
|
test('applyRuntimeStateToShellSession projects runtime state into shell session', () => {
|
|
6
20
|
const session = createSession();
|
|
@@ -22,6 +36,12 @@ test('applyRuntimeStateToShellSession projects runtime state into shell session'
|
|
|
22
36
|
terminal: false,
|
|
23
37
|
}],
|
|
24
38
|
queue: [{ id: 'q-1', status: 'waiting' }],
|
|
39
|
+
workflow: {
|
|
40
|
+
nodes: [{ id: 'task:build', type: 'task', label: 'Build', status: 'running' }],
|
|
41
|
+
relations: [{ type: 'contains', from: 'run:run-1', to: 'task:build' }],
|
|
42
|
+
waitingReasons: ['queue:q-1'],
|
|
43
|
+
warnings: ['legacy_sequential_plan'],
|
|
44
|
+
},
|
|
25
45
|
logs: ['agentic-loop: turn 1/20'],
|
|
26
46
|
});
|
|
27
47
|
|
|
@@ -31,8 +51,64 @@ test('applyRuntimeStateToShellSession projects runtime state into shell session'
|
|
|
31
51
|
assert.equal(session.activities['production:job-1'].status, 'running');
|
|
32
52
|
assert.equal(session.productionActivity.jobId, 'job-1');
|
|
33
53
|
assert.equal(session.jobQueue[0].id, 'q-1');
|
|
54
|
+
assert.equal(session.workflow.nodes[0].id, 'task:build');
|
|
55
|
+
assert.equal(session.workflow.relations[0].type, 'contains');
|
|
56
|
+
assert.deepEqual(session.workflow.waitingReasons, ['queue:q-1']);
|
|
34
57
|
assert.deepEqual(conversationMessages(session), [
|
|
35
58
|
{ role: 'user', content: 'Build docs' },
|
|
36
59
|
{ role: 'donna', content: 'Working.' },
|
|
37
60
|
]);
|
|
38
61
|
});
|
|
62
|
+
|
|
63
|
+
test('submitRuntimeRun reports acceptance without throwing', async () => {
|
|
64
|
+
const restore = stubFetch(async (url) => {
|
|
65
|
+
assert.equal(pathOf(url), '/run');
|
|
66
|
+
return jsonResponse(202, { accepted: true, runId: 'run-1' });
|
|
67
|
+
});
|
|
68
|
+
try {
|
|
69
|
+
const session = createSession();
|
|
70
|
+
const outcome = await submitRuntimeRun('build the doc', { runtime: { url: 'http://runtime.test' }, session });
|
|
71
|
+
assert.deepEqual(outcome, { kind: 'accepted' });
|
|
72
|
+
} finally {
|
|
73
|
+
restore();
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('submitRuntimeRun routes busy runtime input through the control lane', async () => {
|
|
78
|
+
let controlBody = null;
|
|
79
|
+
const restore = stubFetch(async (url, init) => {
|
|
80
|
+
const path = pathOf(url);
|
|
81
|
+
if (path === '/run') return jsonResponse(409, { error: 'A runtime run is already active.' });
|
|
82
|
+
if (path === '/control') {
|
|
83
|
+
controlBody = JSON.parse(String(init.body));
|
|
84
|
+
return jsonResponse(200, { accepted: true, kind: 'observe', explanation: 'Runtime run is active.' });
|
|
85
|
+
}
|
|
86
|
+
throw new Error(`unexpected fetch: ${url}`);
|
|
87
|
+
});
|
|
88
|
+
try {
|
|
89
|
+
const session = createSession();
|
|
90
|
+
const outcome = await submitRuntimeRun('Où en est le build ?', { runtime: { url: 'http://runtime.test' }, session });
|
|
91
|
+
assert.equal(outcome.kind, 'observe');
|
|
92
|
+
assert.equal(outcome.result.explanation, 'Runtime run is active.');
|
|
93
|
+
assert.deepEqual(controlBody, { action: 'message', input: 'Où en est le build ?' });
|
|
94
|
+
} finally {
|
|
95
|
+
restore();
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('submitRuntimeRun reports a non-409 error without throwing or calling /control', async () => {
|
|
100
|
+
let controlCalled = false;
|
|
101
|
+
const restore = stubFetch(async (url) => {
|
|
102
|
+
if (pathOf(url) === '/control') controlCalled = true;
|
|
103
|
+
return jsonResponse(503, { error: 'runtime unavailable' });
|
|
104
|
+
});
|
|
105
|
+
try {
|
|
106
|
+
const session = createSession();
|
|
107
|
+
const outcome = await submitRuntimeRun('build the doc', { runtime: { url: 'http://runtime.test' }, session });
|
|
108
|
+
assert.equal(outcome.kind, 'error');
|
|
109
|
+
assert.match(outcome.message, /503/);
|
|
110
|
+
assert.equal(controlCalled, false);
|
|
111
|
+
} finally {
|
|
112
|
+
restore();
|
|
113
|
+
}
|
|
114
|
+
});
|