@dotdrelle/wiki-manager 0.15.26 → 0.15.28
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/.env.example +18 -5
- package/README.md +35 -13
- package/agents.docker-compose.override.example.yml +82 -0
- package/agents.docker-compose.yml +8 -2
- package/docker-compose.override.example.yml +81 -0
- package/docker-compose.yml +23 -17
- package/package.json +4 -2
- package/src/cli/wiki-manager.js +13 -4
- package/src/commands/slash.js +53 -39
- package/src/core/agentEvents.js +20 -0
- package/src/core/agentEvents.test.js +38 -1
- package/src/core/buildInfo.json +2 -2
- package/src/core/commandFailure.js +81 -0
- package/src/core/commandFailure.test.js +67 -0
- package/src/core/compose.js +20 -3
- package/src/core/composeOverrides.test.js +74 -0
- package/src/core/dockerCompose.test.js +69 -2
- package/src/core/env.js +59 -0
- package/src/core/env.test.js +33 -1
- package/src/core/mcp.js +1 -1
- package/src/core/setEnvValue.test.js +96 -0
- package/src/runtime/client.js +18 -0
- package/src/runtime/lifecycle.js +5 -1
- package/src/runtime/server.js +43 -1
- package/src/runtime/server.test.js +117 -0
- package/src/runtime/store.js +47 -0
- package/src/runtime/store.test.js +89 -1
- package/src/shell/LeftPane.tsx +46 -16
- package/src/shell/RightPane.tsx +2 -4
- package/src/shell/SetupWizard.tsx +19 -7
- package/src/shell/externalLinks.js +24 -2
- package/src/shell/openExternal.js +53 -0
- package/src/shell/repl.js +33 -10
- package/src/shell/repl.test.js +107 -9
- package/src/shell/setupWizardPlaceholders.test.js +66 -0
- package/src/shell/tui.tsx +54 -25
- package/src/shell/useSession.ts +59 -6
- package/wiki-workspace +84 -6
package/src/runtime/client.js
CHANGED
|
@@ -150,6 +150,24 @@ export async function postRuntimeCancel({
|
|
|
150
150
|
return response.json();
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
// Redo support: keeps the conversation entry at `index` and drops everything
|
|
154
|
+
// the runtime recorded after it. Returns the server's payload so a caller can
|
|
155
|
+
// distinguish "nothing to truncate" from "a run is still active".
|
|
156
|
+
export async function postRuntimeConversationTruncate(index, {
|
|
157
|
+
url = runtimeUrlFromEnv(),
|
|
158
|
+
token = runtimeToken(),
|
|
159
|
+
workspace = null,
|
|
160
|
+
} = {}) {
|
|
161
|
+
const response = await fetch(runtimeEndpoint(url, '/conversation/truncate', workspace), {
|
|
162
|
+
method: 'POST',
|
|
163
|
+
headers: { ...runtimeHeaders(token), 'content-type': 'application/json' },
|
|
164
|
+
body: JSON.stringify({ index, ...(workspace ? { workspace } : {}) }),
|
|
165
|
+
});
|
|
166
|
+
const payload = await response.json().catch(() => ({}));
|
|
167
|
+
if (!response.ok) return { truncated: false, reason: payload?.reason ?? `http_${response.status}` };
|
|
168
|
+
return payload;
|
|
169
|
+
}
|
|
170
|
+
|
|
153
171
|
export async function postRuntimeKill({
|
|
154
172
|
url = runtimeUrlFromEnv(),
|
|
155
173
|
token = runtimeToken(),
|
package/src/runtime/lifecycle.js
CHANGED
|
@@ -55,7 +55,11 @@ export async function assertRuntimeNode(executable = runtimeNodeExecutable()) {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
export async function ensureRuntime({
|
|
58
|
-
|
|
58
|
+
// 0.0.0.0, like `wiki-workspace runtime up` has always used: the shell's
|
|
59
|
+
// autostarted runtime used to bind 127.0.0.1, so `serve` in Docker got
|
|
60
|
+
// ECONNREFUSED on host.docker.internal and the workspace UI reported no
|
|
61
|
+
// runtime. Exposing the port always generates an auth token.
|
|
62
|
+
host = process.env.WIKI_MANAGER_RUNTIME_HOST ?? '0.0.0.0',
|
|
59
63
|
port = Number(process.env.WIKI_MANAGER_RUNTIME_PORT ?? 7788),
|
|
60
64
|
stateDir = process.env.WIKI_MANAGER_STATE_DIR ?? defaultRuntimeStateDir(),
|
|
61
65
|
url = process.env.WIKI_MANAGER_RUNTIME_URL ?? `http://127.0.0.1:${port}`,
|
package/src/runtime/server.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
2
|
import { randomUUID, timingSafeEqual } from 'node:crypto';
|
|
3
|
-
import { createAgentEvent, dispatchAgentEvent, resetSessionProjection, reduceAgentEvents } from '../core/agentEvents.js';
|
|
3
|
+
import { conversationEventSequences, createAgentEvent, dispatchAgentEvent, resetSessionProjection, reduceAgentEvents } from '../core/agentEvents.js';
|
|
4
4
|
import { activeCacertPath } from '../core/cacert.js';
|
|
5
5
|
import { normalizePlanPatch, rebasePlanPatch } from '../core/planPatch.js';
|
|
6
6
|
import { validateContractInDev } from '../contracts/schemas.js';
|
|
@@ -420,6 +420,48 @@ export function startRuntimeServer({
|
|
|
420
420
|
});
|
|
421
421
|
return;
|
|
422
422
|
}
|
|
423
|
+
// Redo: keep the conversation entry at `index` (the question) and drop
|
|
424
|
+
// everything the runtime recorded after it. The conversation is derived
|
|
425
|
+
// from the event log, so a UI-side deletion alone would be undone by the
|
|
426
|
+
// next /state merge — the truncation has to happen here.
|
|
427
|
+
if (request.method === 'POST' && url.pathname === '/conversation/truncate') {
|
|
428
|
+
const { body, workspace, context } = await resolveBodyContext(request, url);
|
|
429
|
+
if (context?.running) {
|
|
430
|
+
// Truncating under a live run would delete events it is still
|
|
431
|
+
// appending to. The caller cancels first.
|
|
432
|
+
sendJson(response, 409, { truncated: false, reason: 'run_active' });
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
const resolvedWorkspace = context?.workspace ?? workspace ?? null;
|
|
436
|
+
if (!resolvedWorkspace) {
|
|
437
|
+
// Unscoped, redo would truncate every workspace's event history —
|
|
438
|
+
// never allow the global/no-workspace conversation to reach it.
|
|
439
|
+
sendJson(response, 400, { truncated: false, reason: 'workspace_required' });
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
const index = Number(body.index);
|
|
443
|
+
if (!Number.isInteger(index) || index < 0) {
|
|
444
|
+
sendJson(response, 400, { truncated: false, reason: 'invalid_index' });
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
const events = store.listEvents({ workspace: resolvedWorkspace });
|
|
448
|
+
const sequences = conversationEventSequences(events);
|
|
449
|
+
const boundary = sequences[index];
|
|
450
|
+
if (!Number.isFinite(boundary)) {
|
|
451
|
+
sendJson(response, 400, { truncated: false, reason: 'index_out_of_range' });
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
const removedEvents = store.deleteEventsAfter(boundary, { workspace: resolvedWorkspace });
|
|
455
|
+
// getState prefers the in-memory projection over the event log, so the
|
|
456
|
+
// deleted answers would survive in RAM without this rehydration.
|
|
457
|
+
if (context?.session) {
|
|
458
|
+
resetSessionProjection(context.session);
|
|
459
|
+
store.hydrateSession(context.session, { workspace: resolvedWorkspace });
|
|
460
|
+
}
|
|
461
|
+
publishState(resolvedWorkspace, context);
|
|
462
|
+
sendJson(response, 200, { truncated: true, index, removedEvents });
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
423
465
|
if (request.method === 'POST' && url.pathname === '/resume') {
|
|
424
466
|
const workspace = workspaceFromUrl(url);
|
|
425
467
|
const result = await resume?.({ workspace });
|
|
@@ -1614,3 +1614,120 @@ test('runtime health reports active runs across workspaces', async (t) => {
|
|
|
1614
1614
|
await handle.close();
|
|
1615
1615
|
}
|
|
1616
1616
|
});
|
|
1617
|
+
|
|
1618
|
+
test('redo truncation drops the aftermath of one question and refuses during a run', async (t) => {
|
|
1619
|
+
const events = [
|
|
1620
|
+
{ ...createAgentEvent('user_message', { origin: 'user', payload: { content: 'question' } }), sequence: 1 },
|
|
1621
|
+
{ ...createAgentEvent('assistant_message', { origin: 'runtime', payload: { content: 'answer' } }), sequence: 2 },
|
|
1622
|
+
];
|
|
1623
|
+
let deleted = null;
|
|
1624
|
+
let hydrated = false;
|
|
1625
|
+
let running = true;
|
|
1626
|
+
const store = {
|
|
1627
|
+
dbPath: ':memory:',
|
|
1628
|
+
getState: () => ({ status: 'idle' }),
|
|
1629
|
+
listEvents: () => events,
|
|
1630
|
+
deleteEventsAfter: (sequence, options) => { deleted = { sequence, ...options }; return 1; },
|
|
1631
|
+
hydrateSession: () => { hydrated = true; },
|
|
1632
|
+
};
|
|
1633
|
+
|
|
1634
|
+
let handle;
|
|
1635
|
+
try {
|
|
1636
|
+
handle = await startRuntimeServer({
|
|
1637
|
+
host: '127.0.0.1',
|
|
1638
|
+
port: 0,
|
|
1639
|
+
store,
|
|
1640
|
+
session: {},
|
|
1641
|
+
getContext: async () => ({ workspace: 'demo', session: {}, running }),
|
|
1642
|
+
run: async () => {},
|
|
1643
|
+
});
|
|
1644
|
+
} catch (err) {
|
|
1645
|
+
if (err?.code === 'EPERM') {
|
|
1646
|
+
t.skip('network listen is not permitted in this sandbox');
|
|
1647
|
+
return;
|
|
1648
|
+
}
|
|
1649
|
+
throw err;
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
try {
|
|
1653
|
+
const url = `http://127.0.0.1:${handle.port}/conversation/truncate`;
|
|
1654
|
+
const post = (body) => fetch(url, {
|
|
1655
|
+
method: 'POST',
|
|
1656
|
+
headers: { 'content-type': 'application/json' },
|
|
1657
|
+
body: JSON.stringify(body),
|
|
1658
|
+
});
|
|
1659
|
+
|
|
1660
|
+
// Truncating under a live run would delete events it is still appending.
|
|
1661
|
+
const active = await post({ index: 0 });
|
|
1662
|
+
assert.equal(active.status, 409);
|
|
1663
|
+
assert.equal((await active.json()).reason, 'run_active');
|
|
1664
|
+
assert.equal(deleted, null);
|
|
1665
|
+
|
|
1666
|
+
running = false;
|
|
1667
|
+
const outOfRange = await post({ index: 9 });
|
|
1668
|
+
assert.equal(outOfRange.status, 400);
|
|
1669
|
+
assert.equal((await outOfRange.json()).reason, 'index_out_of_range');
|
|
1670
|
+
|
|
1671
|
+
const invalid = await post({ index: -1 });
|
|
1672
|
+
assert.equal(invalid.status, 400);
|
|
1673
|
+
assert.equal((await invalid.json()).reason, 'invalid_index');
|
|
1674
|
+
|
|
1675
|
+
const ok = await post({ index: 0 });
|
|
1676
|
+
assert.equal(ok.status, 200);
|
|
1677
|
+
assert.deepEqual(await ok.json(), { truncated: true, index: 0, removedEvents: 1 });
|
|
1678
|
+
// Keeps the question (sequence 1) and drops everything recorded after it.
|
|
1679
|
+
assert.deepEqual(deleted, { sequence: 1, workspace: 'demo' });
|
|
1680
|
+
// getState prefers the in-memory projection, so the deleted answers would
|
|
1681
|
+
// survive in RAM without this rehydration.
|
|
1682
|
+
assert.equal(hydrated, true);
|
|
1683
|
+
} finally {
|
|
1684
|
+
await handle.close();
|
|
1685
|
+
}
|
|
1686
|
+
});
|
|
1687
|
+
|
|
1688
|
+
test('redo truncation refuses when no workspace can be resolved', async (t) => {
|
|
1689
|
+
// The global/no-workspace conversation is a real reachable mode (chatting
|
|
1690
|
+
// before /use <workspace>). Without this guard the handler would fall
|
|
1691
|
+
// through to an unscoped store.deleteEventsAfter, wiping every workspace's
|
|
1692
|
+
// event history for one redo click.
|
|
1693
|
+
let deleted = null;
|
|
1694
|
+
const store = {
|
|
1695
|
+
dbPath: ':memory:',
|
|
1696
|
+
getState: () => ({ status: 'idle' }),
|
|
1697
|
+
listEvents: () => [],
|
|
1698
|
+
deleteEventsAfter: (sequence, options) => { deleted = { sequence, ...options }; return 1; },
|
|
1699
|
+
hydrateSession: () => {},
|
|
1700
|
+
};
|
|
1701
|
+
|
|
1702
|
+
let handle;
|
|
1703
|
+
try {
|
|
1704
|
+
handle = await startRuntimeServer({
|
|
1705
|
+
host: '127.0.0.1',
|
|
1706
|
+
port: 0,
|
|
1707
|
+
store,
|
|
1708
|
+
session: {},
|
|
1709
|
+
getContext: async () => ({ workspace: null, session: {}, running: false }),
|
|
1710
|
+
run: async () => {},
|
|
1711
|
+
});
|
|
1712
|
+
} catch (err) {
|
|
1713
|
+
if (err?.code === 'EPERM') {
|
|
1714
|
+
t.skip('network listen is not permitted in this sandbox');
|
|
1715
|
+
return;
|
|
1716
|
+
}
|
|
1717
|
+
throw err;
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
try {
|
|
1721
|
+
const url = `http://127.0.0.1:${handle.port}/conversation/truncate`;
|
|
1722
|
+
const response = await fetch(url, {
|
|
1723
|
+
method: 'POST',
|
|
1724
|
+
headers: { 'content-type': 'application/json' },
|
|
1725
|
+
body: JSON.stringify({ index: 0 }),
|
|
1726
|
+
});
|
|
1727
|
+
assert.equal(response.status, 400);
|
|
1728
|
+
assert.equal((await response.json()).reason, 'workspace_required');
|
|
1729
|
+
assert.equal(deleted, null);
|
|
1730
|
+
} finally {
|
|
1731
|
+
await handle.close();
|
|
1732
|
+
}
|
|
1733
|
+
});
|
package/src/runtime/store.js
CHANGED
|
@@ -676,6 +676,52 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
|
|
|
676
676
|
return tasks.length;
|
|
677
677
|
}
|
|
678
678
|
|
|
679
|
+
// Drops every event strictly after `sequence`, i.e. everything that happened
|
|
680
|
+
// after one conversation entry. Backs the redo action: the question stays,
|
|
681
|
+
// the answers, plan steps, activities and logs it produced are removed.
|
|
682
|
+
// Scoped to the workspace so a redo in one workspace never touches another —
|
|
683
|
+
// requires a real workspace; callers must resolve one before calling this,
|
|
684
|
+
// an unscoped call would otherwise touch every workspace's history.
|
|
685
|
+
// Destructive: these events also feed listAuditTrail, so a redo erases that
|
|
686
|
+
// stretch of audit history too. Runs entirely produced by the deleted range
|
|
687
|
+
// (no event at/before the boundary) are removed as well, cascading their
|
|
688
|
+
// task_groups/tasks/task_dependencies/plan_revisions via ON DELETE CASCADE;
|
|
689
|
+
// the non-cascading side tables (task_assignments/task_attempts/
|
|
690
|
+
// task_results/approval_grants) are cleared explicitly, same as
|
|
691
|
+
// clearWorkspaceState below.
|
|
692
|
+
function deleteEventsAfter(sequence, { workspace = null } = {}) {
|
|
693
|
+
const boundary = Number(sequence);
|
|
694
|
+
if (!Number.isFinite(boundary) || !workspace) return 0;
|
|
695
|
+
const orphanedRunIds = db.prepare(`
|
|
696
|
+
SELECT DISTINCT run_id FROM events
|
|
697
|
+
WHERE sequence > ? AND workspace = ? AND run_id IS NOT NULL
|
|
698
|
+
AND run_id NOT IN (
|
|
699
|
+
SELECT run_id FROM events WHERE sequence <= ? AND workspace = ? AND run_id IS NOT NULL
|
|
700
|
+
)
|
|
701
|
+
`).all(boundary, workspace, boundary, workspace).map((row) => row.run_id);
|
|
702
|
+
db.exec('BEGIN');
|
|
703
|
+
try {
|
|
704
|
+
const delResults = db.prepare('DELETE FROM task_results WHERE task_id IN (SELECT id FROM tasks WHERE run_id = ?)');
|
|
705
|
+
const delAssignments = db.prepare('DELETE FROM task_assignments WHERE task_id IN (SELECT id FROM tasks WHERE run_id = ?)');
|
|
706
|
+
const delAttempts = db.prepare('DELETE FROM task_attempts WHERE run_id = ?');
|
|
707
|
+
const delApprovals = db.prepare('DELETE FROM approval_grants WHERE run_id = ?');
|
|
708
|
+
const delRun = db.prepare('DELETE FROM runs WHERE id = ?');
|
|
709
|
+
for (const runId of orphanedRunIds) {
|
|
710
|
+
delResults.run(runId);
|
|
711
|
+
delAssignments.run(runId);
|
|
712
|
+
delAttempts.run(runId);
|
|
713
|
+
delApprovals.run(runId);
|
|
714
|
+
delRun.run(runId);
|
|
715
|
+
}
|
|
716
|
+
const changes = db.prepare('DELETE FROM events WHERE sequence > ? AND workspace = ?').run(boundary, workspace).changes ?? 0;
|
|
717
|
+
db.exec('COMMIT');
|
|
718
|
+
return changes;
|
|
719
|
+
} catch (error) {
|
|
720
|
+
db.exec('ROLLBACK');
|
|
721
|
+
throw error;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
679
725
|
// Full wipe of a workspace's persisted runtime state: runs (and, via the
|
|
680
726
|
// ON DELETE CASCADE foreign keys, their task_groups/tasks/task_dependencies/
|
|
681
727
|
// plan_revisions/approval_grants), the event log, and the queue. The
|
|
@@ -1241,6 +1287,7 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
|
|
|
1241
1287
|
interruptRuns,
|
|
1242
1288
|
cancelActiveTasksForInterruptedRuns,
|
|
1243
1289
|
clearWorkspaceState,
|
|
1290
|
+
deleteEventsAfter,
|
|
1244
1291
|
saveQueue,
|
|
1245
1292
|
listQueue,
|
|
1246
1293
|
listTasks,
|
|
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { DatabaseSync } from 'node:sqlite';
|
|
6
6
|
import test from 'node:test';
|
|
7
|
-
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
7
|
+
import { conversationEventSequences, createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
8
8
|
import { openRuntimeStore } from './store.js';
|
|
9
9
|
|
|
10
10
|
function runtimeStateDir() {
|
|
@@ -1018,3 +1018,91 @@ test('runtime store identifies recoverable workspaces and interrupts stale runs'
|
|
|
1018
1018
|
assert.equal(store.listRuns({ workspace: 'acme' })[0].status, 'interrupted');
|
|
1019
1019
|
store.close();
|
|
1020
1020
|
});
|
|
1021
|
+
|
|
1022
|
+
test('deleteEventsAfter removes one question\'s aftermath and nothing else', () => {
|
|
1023
|
+
const stateDir = runtimeStateDir();
|
|
1024
|
+
const store = openRuntimeStore({ stateDir });
|
|
1025
|
+
const persist = (type, payload, workspace) => store.persistEvent({
|
|
1026
|
+
...createAgentEvent(type, { origin: 'runtime', payload }),
|
|
1027
|
+
workspace,
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
persist('user_message', { content: 'first question' }, 'docs');
|
|
1031
|
+
persist('assistant_message', { content: 'first answer' }, 'docs');
|
|
1032
|
+
persist('user_message', { content: 'second question' }, 'docs');
|
|
1033
|
+
persist('assistant_message', { content: 'second answer' }, 'docs');
|
|
1034
|
+
// A redo in one workspace must never touch another's history.
|
|
1035
|
+
persist('user_message', { content: 'other workspace' }, 'acme');
|
|
1036
|
+
|
|
1037
|
+
const events = store.listEvents({ workspace: 'docs' });
|
|
1038
|
+
const sequences = conversationEventSequences(events);
|
|
1039
|
+
// Keep "second question" (index 2), drop everything recorded after it.
|
|
1040
|
+
const removed = store.deleteEventsAfter(sequences[2], { workspace: 'docs' });
|
|
1041
|
+
|
|
1042
|
+
assert.equal(removed, 1);
|
|
1043
|
+
assert.deepEqual(
|
|
1044
|
+
store.getProjection({ workspace: 'docs' }).conversation.map((message) => message.content),
|
|
1045
|
+
['first question', 'first answer', 'second question'],
|
|
1046
|
+
);
|
|
1047
|
+
assert.equal(store.listEvents({ workspace: 'acme' }).length, 1);
|
|
1048
|
+
// A boundary that does not exist is a no-op rather than a silent wipe.
|
|
1049
|
+
assert.equal(store.deleteEventsAfter(undefined, { workspace: 'docs' }), 0);
|
|
1050
|
+
assert.equal(store.listEvents({ workspace: 'docs' }).length, 3);
|
|
1051
|
+
store.close();
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
test('deleteEventsAfter without a workspace is a no-op, never an unscoped wipe', () => {
|
|
1055
|
+
const stateDir = runtimeStateDir();
|
|
1056
|
+
const store = openRuntimeStore({ stateDir });
|
|
1057
|
+
store.persistEvent(createAgentEvent('user_message', { origin: 'user', workspace: 'docs', payload: { content: 'q' } }));
|
|
1058
|
+
store.persistEvent(createAgentEvent('user_message', { origin: 'user', workspace: 'acme', payload: { content: 'q' } }));
|
|
1059
|
+
|
|
1060
|
+
assert.equal(store.deleteEventsAfter(0, { workspace: null }), 0);
|
|
1061
|
+
assert.equal(store.deleteEventsAfter(0, {}), 0);
|
|
1062
|
+
assert.equal(store.listEvents({ workspace: 'docs' }).length, 1);
|
|
1063
|
+
assert.equal(store.listEvents({ workspace: 'acme' }).length, 1);
|
|
1064
|
+
store.close();
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
test('deleteEventsAfter removes bookkeeping only for runs entirely produced by the deleted range', () => {
|
|
1068
|
+
const stateDir = runtimeStateDir();
|
|
1069
|
+
const store = openRuntimeStore({ stateDir });
|
|
1070
|
+
const taskEvents = (runId, taskId) => [
|
|
1071
|
+
createAgentEvent('run_started', {
|
|
1072
|
+
origin: 'runtime', runId, workspace: 'docs', payload: { input: 'build docs', workspace: 'docs' },
|
|
1073
|
+
}),
|
|
1074
|
+
createAgentEvent('task.created', {
|
|
1075
|
+
origin: 'plan_integrator', runId, taskId, workspace: 'docs', payload: { runId, task: plannedTask(taskId) },
|
|
1076
|
+
}),
|
|
1077
|
+
createAgentEvent('task.assigned', {
|
|
1078
|
+
origin: 'assignment_manager', runId, taskId, workspace: 'docs',
|
|
1079
|
+
payload: { runId, taskId, attemptId: `${taskId}:attempt-1`, assignment: {
|
|
1080
|
+
attemptId: `${taskId}:attempt-1`, agentInstanceId: 'production-main', agentId: 'production', poolId: 'production-pool',
|
|
1081
|
+
} },
|
|
1082
|
+
}),
|
|
1083
|
+
createAgentEvent('task.started', {
|
|
1084
|
+
origin: 'dispatcher', runId, taskId, workspace: 'docs',
|
|
1085
|
+
payload: { runId, taskId, attemptId: `${taskId}:attempt-1`, jobId: 'job-1', startedAt: '2026-07-07T10:00:00.000Z' },
|
|
1086
|
+
}),
|
|
1087
|
+
];
|
|
1088
|
+
|
|
1089
|
+
// A run that completed before the redo boundary must survive untouched.
|
|
1090
|
+
for (const event of taskEvents('run-before', 'run-before:build-a')) store.persistEvent(event);
|
|
1091
|
+
const boundarySequence = store.listEvents({ workspace: 'docs' }).at(-1).sequence;
|
|
1092
|
+
// A run triggered by the redone question — entirely after the boundary — is
|
|
1093
|
+
// what redo is dropping, so its bookkeeping should go with it.
|
|
1094
|
+
for (const event of taskEvents('run-after', 'run-after:build-a')) store.persistEvent(event);
|
|
1095
|
+
|
|
1096
|
+
assert.equal(store.db.prepare('SELECT COUNT(*) AS n FROM runs').get().n, 2);
|
|
1097
|
+
assert.equal(store.db.prepare('SELECT COUNT(*) AS n FROM task_assignments').get().n, 2);
|
|
1098
|
+
assert.equal(store.db.prepare('SELECT COUNT(*) AS n FROM task_attempts').get().n, 2);
|
|
1099
|
+
|
|
1100
|
+
store.deleteEventsAfter(boundarySequence, { workspace: 'docs' });
|
|
1101
|
+
|
|
1102
|
+
assert.deepEqual(store.listRuns({ workspace: 'docs' }).map((run) => run.id), ['run-before']);
|
|
1103
|
+
assert.deepEqual(store.listTasks({ runId: 'run-before' }).map((task) => task.id), ['run-before:build-a']);
|
|
1104
|
+
assert.equal(store.listTasks({ runId: 'run-after' }).length, 0);
|
|
1105
|
+
assert.equal(store.db.prepare('SELECT COUNT(*) AS n FROM task_assignments').get().n, 1);
|
|
1106
|
+
assert.equal(store.db.prepare('SELECT COUNT(*) AS n FROM task_attempts').get().n, 1);
|
|
1107
|
+
store.close();
|
|
1108
|
+
});
|
package/src/shell/LeftPane.tsx
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/solid */
|
|
2
|
-
import { For, createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
|
|
2
|
+
import { For, Show, createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
|
|
3
3
|
import { StyledText, bg as styledBg, fg as styledFg, link as styledLink } from '@opentui/core';
|
|
4
|
-
import {
|
|
4
|
+
import { useRenderer } from '@opentui/solid';
|
|
5
5
|
import { colorForRenderedLine, helpCommandParts, keyValueParts, renderPlainMarkdown } from './renderer';
|
|
6
6
|
import { httpLinkParts, wrapHttpLinks } from './externalLinks.js';
|
|
7
|
+
import { normalizeExternalUrl } from './openExternal.js';
|
|
7
8
|
|
|
8
9
|
const LEGACY_DONNA_ROLE = 'do' + 't';
|
|
9
10
|
|
|
@@ -62,17 +63,11 @@ function styledSegments(segments: Segment[]) {
|
|
|
62
63
|
}));
|
|
63
64
|
}
|
|
64
65
|
|
|
65
|
-
|
|
66
|
+
// A row carries a link only when it points at exactly one target; two links on
|
|
67
|
+
// the same line would make a click ambiguous.
|
|
68
|
+
function singleLineUrl(segments: Segment[]) {
|
|
66
69
|
const urls = [...new Set(segments.map((segment) => segment.url).filter(Boolean))] as string[];
|
|
67
|
-
|
|
68
|
-
try {
|
|
69
|
-
const parsed = new URL(urls[0]);
|
|
70
|
-
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return;
|
|
71
|
-
const opener = process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
72
|
-
execFile(opener, [parsed.toString()], () => {});
|
|
73
|
-
} catch {
|
|
74
|
-
// Invalid URLs remain copyable text and never reach the system opener.
|
|
75
|
-
}
|
|
70
|
+
return urls.length === 1 ? normalizeExternalUrl(urls[0]) : null;
|
|
76
71
|
}
|
|
77
72
|
type RenderedLine = {
|
|
78
73
|
segments: Segment[];
|
|
@@ -82,6 +77,8 @@ type RenderedLine = {
|
|
|
82
77
|
statusLeft?: string;
|
|
83
78
|
statusRight?: string;
|
|
84
79
|
copyContent?: string;
|
|
80
|
+
redoContent?: string;
|
|
81
|
+
redoIndex?: number;
|
|
85
82
|
};
|
|
86
83
|
const STATUS_COLUMN_GAP = 2;
|
|
87
84
|
type HelpCard = { title: string; text: string; example: string };
|
|
@@ -145,11 +142,15 @@ function WelcomeHelpPanels(props: { width: number }) {
|
|
|
145
142
|
}
|
|
146
143
|
|
|
147
144
|
const COPY_BTN = ' [ copy ]';
|
|
145
|
+
// Only user messages carry it: redo re-asks *this* question, so the action has
|
|
146
|
+
// to sit on the message it acts upon.
|
|
147
|
+
const REDO_BTN = ' [ redo ]';
|
|
148
148
|
|
|
149
|
-
function messageHeaderSegments(role: string, columns: number): Segment[] {
|
|
149
|
+
function messageHeaderSegments(role: string, columns: number, withRedo = false): Segment[] {
|
|
150
150
|
const label = `[${roleLabel(role)}]`;
|
|
151
151
|
const left = '── ';
|
|
152
|
-
const
|
|
152
|
+
const actionsLength = COPY_BTN.length + (withRedo ? REDO_BTN.length : 0);
|
|
153
|
+
const rightLength = Math.max(2, columns - left.length - label.length - 1 - actionsLength);
|
|
153
154
|
return [
|
|
154
155
|
{ text: left, color: '#4B5563' },
|
|
155
156
|
{ text: label, color: roleColor(role) },
|
|
@@ -415,9 +416,14 @@ function conversationLines(messages: Array<{ role: string; content: string }>, c
|
|
|
415
416
|
const raw = String(message.content || '');
|
|
416
417
|
const previous = index > 0 ? messages[index - 1] : null;
|
|
417
418
|
const showHeader = !previous || headerGroupKey(previous.role) !== headerGroupKey(message.role);
|
|
419
|
+
const canRedo = message.role === 'user' && raw.trim().length > 0;
|
|
418
420
|
const headerLines: RenderedLine[] = showHeader
|
|
419
421
|
? [
|
|
420
|
-
{
|
|
422
|
+
{
|
|
423
|
+
segments: messageHeaderSegments(message.role, columns, canRedo),
|
|
424
|
+
copyContent: raw,
|
|
425
|
+
...(canRedo ? { redoContent: raw, redoIndex: index } : {}),
|
|
426
|
+
},
|
|
421
427
|
{ segments: [{ text: ' ', color: '#D6DEE8' }] },
|
|
422
428
|
]
|
|
423
429
|
: [];
|
|
@@ -466,7 +472,15 @@ export function ConversationView(props: {
|
|
|
466
472
|
onScroll: (delta: number) => void;
|
|
467
473
|
spinnerFrame: string;
|
|
468
474
|
onCopy?: (content: string) => void;
|
|
475
|
+
onRedo?: (index: number, content: string) => void;
|
|
476
|
+
onOpenLink?: (url: string) => void;
|
|
469
477
|
}) {
|
|
478
|
+
const renderer = useRenderer();
|
|
479
|
+
// Terminals have no CSS cursor, but OpenTUI can ask the emulator to swap the
|
|
480
|
+
// mouse pointer shape. Without it nothing on screen distinguishes a clickable
|
|
481
|
+
// link from ordinary blue text, so operators never discovered links were
|
|
482
|
+
// clickable at all.
|
|
483
|
+
const hoverLink = (url: string | null) => renderer?.setMousePointer?.(url ? 'pointer' : 'default');
|
|
470
484
|
const allLines = createMemo(() => conversationLines(props.messages, props.columns));
|
|
471
485
|
const visibleLines = () => {
|
|
472
486
|
const lines = allLines();
|
|
@@ -511,6 +525,13 @@ export function ConversationView(props: {
|
|
|
511
525
|
{(seg: Segment) => <text width={seg.width} fg={seg.color} bg={seg.bg}>{seg.text}</text>}
|
|
512
526
|
</For>
|
|
513
527
|
<text fg="#4B5563" content={COPY_BTN} onMouseUp={() => props.onCopy?.(line.copyContent!)} />
|
|
528
|
+
<Show when={line.redoContent !== undefined}>
|
|
529
|
+
<text
|
|
530
|
+
fg="#FBBF24"
|
|
531
|
+
content={REDO_BTN}
|
|
532
|
+
onMouseUp={() => props.onRedo?.(line.redoIndex!, line.redoContent!)}
|
|
533
|
+
/>
|
|
534
|
+
</Show>
|
|
514
535
|
</box>
|
|
515
536
|
) : line.role === 'user' && line.userBubbleWidth ? (
|
|
516
537
|
<box height={1} flexDirection="row" overflow="hidden">
|
|
@@ -567,7 +588,12 @@ export function ConversationView(props: {
|
|
|
567
588
|
height={1}
|
|
568
589
|
flexDirection="row"
|
|
569
590
|
overflow="hidden"
|
|
570
|
-
|
|
591
|
+
onMouseOver={() => hoverLink(singleLineUrl(line.segments))}
|
|
592
|
+
onMouseOut={() => hoverLink(null)}
|
|
593
|
+
onMouseUp={() => {
|
|
594
|
+
const url = singleLineUrl(line.segments);
|
|
595
|
+
if (url) props.onOpenLink?.(url);
|
|
596
|
+
}}
|
|
571
597
|
>
|
|
572
598
|
<text>{styledSegments(line.segments)}</text>
|
|
573
599
|
</box>
|
|
@@ -748,6 +774,8 @@ export function LeftPane(props: {
|
|
|
748
774
|
spinnerFrame: string;
|
|
749
775
|
onInputHeightChange: (height: number) => void;
|
|
750
776
|
onCopy?: (content: string) => void;
|
|
777
|
+
onRedo?: (index: number, content: string) => void;
|
|
778
|
+
onOpenLink?: (url: string) => void;
|
|
751
779
|
}) {
|
|
752
780
|
const modeColor = () => props.chatMode ? '#22C55E' : '#06B6D4';
|
|
753
781
|
const modeLabel = () => props.chatMode ? 'CHAT MODE direct LLM, no tools' : 'AGENTIC MODE LangGraph + MCP tools';
|
|
@@ -777,6 +805,8 @@ export function LeftPane(props: {
|
|
|
777
805
|
onScroll={props.scrollConversation}
|
|
778
806
|
spinnerFrame={props.spinnerFrame}
|
|
779
807
|
onCopy={props.onCopy}
|
|
808
|
+
onRedo={props.onRedo}
|
|
809
|
+
onOpenLink={props.onOpenLink}
|
|
780
810
|
/>
|
|
781
811
|
)}
|
|
782
812
|
<ChatInput
|
package/src/shell/RightPane.tsx
CHANGED
|
@@ -458,8 +458,7 @@ export function QueuePanel(props: { items: QueueItem[]; info: QueueInfo; width:
|
|
|
458
458
|
);
|
|
459
459
|
}
|
|
460
460
|
|
|
461
|
-
function TabHeader(props: { active: 'plan' | 'queue'; queueCount: number;
|
|
462
|
-
const lineWidth = () => Math.max(8, props.width - 2);
|
|
461
|
+
function TabHeader(props: { active: 'plan' | 'queue'; queueCount: number; onTabClick: (tab: 'plan' | 'queue') => void }) {
|
|
463
462
|
const planActive = () => props.active === 'plan';
|
|
464
463
|
return (
|
|
465
464
|
<box height={1} flexDirection="row" paddingX={1}>
|
|
@@ -476,7 +475,6 @@ function TabHeader(props: { active: 'plan' | 'queue'; queueCount: number; width:
|
|
|
476
475
|
content={` Queue (${props.queueCount}) `}
|
|
477
476
|
onMouseUp={() => props.onTabClick('queue')}
|
|
478
477
|
/>
|
|
479
|
-
<text fg="#7F8C8D" content={fit(' Ctrl+Q', Math.max(0, lineWidth() - 24))} />
|
|
480
478
|
</box>
|
|
481
479
|
);
|
|
482
480
|
}
|
|
@@ -510,7 +508,7 @@ export function RightPane(props: {
|
|
|
510
508
|
overflow="hidden"
|
|
511
509
|
focusable={false}
|
|
512
510
|
>
|
|
513
|
-
<TabHeader active={props.activeTab} queueCount={props.queueInfo.active}
|
|
511
|
+
<TabHeader active={props.activeTab} queueCount={props.queueInfo.active} onTabClick={props.onTabClick} />
|
|
514
512
|
<Show when={props.pendingApprovals.length > 0}>
|
|
515
513
|
<box height={2} flexDirection="column" border={['left']} borderStyle="heavy" borderColor="#FBBF24" paddingX={1}>
|
|
516
514
|
<text fg="#FBBF24" content={`${props.pendingApprovals.length} approbation(s) requise(s)`} />
|
|
@@ -29,7 +29,18 @@ type Step =
|
|
|
29
29
|
type LogEntry = { icon: string; label: string; detail?: string };
|
|
30
30
|
|
|
31
31
|
const PROVIDERS = ['OpenAI', 'Anthropic', 'Ollama (local)', 'Other (OpenAI-compatible)'];
|
|
32
|
-
|
|
32
|
+
// The scaffolded .wikirc.yaml ships fake endpoints and secrets so the file
|
|
33
|
+
// documents its own shape (`https://mon-provider.example.com/v1`,
|
|
34
|
+
// `http://infinity.local:7997/v1`, `YOUR_LLM_API_KEY`…). Preloading them into
|
|
35
|
+
// the wizard turned every field into a plausible-looking answer the operator
|
|
36
|
+
// had to notice and delete — and a skipped step silently wrote the fake value
|
|
37
|
+
// to the real config. Treat them as "not configured" instead.
|
|
38
|
+
const PLACEHOLDER_VALUE_RE = /YOUR_|<your|example\.com|infinity\.local/i;
|
|
39
|
+
|
|
40
|
+
function configuredValue(value: unknown) {
|
|
41
|
+
const text = typeof value === 'string' ? value.trim() : '';
|
|
42
|
+
return text && !PLACEHOLDER_VALUE_RE.test(text) ? text : null;
|
|
43
|
+
}
|
|
33
44
|
const MAIN_MENU = ['Agents', 'Workspaces', 'LLM configuration', 'Vector search', '---', 'Close'];
|
|
34
45
|
|
|
35
46
|
function defaultBaseUrl(provider: string) {
|
|
@@ -317,19 +328,20 @@ export function SetupWizard(props: {
|
|
|
317
328
|
const { config } = loadWikircProfile(workspacePath, context?.profileName ?? 'default');
|
|
318
329
|
if (config?.language) setLanguage(String(config.language));
|
|
319
330
|
if (config?.llm?.provider) {
|
|
320
|
-
const model = config.llm.model;
|
|
321
331
|
setLlm({
|
|
322
332
|
provider: normalizeProvider(config.llm.provider),
|
|
323
|
-
baseUrl: config.llm.baseUrl,
|
|
324
|
-
apiKey: config.llm.apiKey,
|
|
325
|
-
model: (
|
|
333
|
+
baseUrl: configuredValue(config.llm.baseUrl),
|
|
334
|
+
apiKey: configuredValue(config.llm.apiKey),
|
|
335
|
+
model: configuredValue(config.llm.model),
|
|
326
336
|
});
|
|
327
337
|
}
|
|
328
338
|
if (config?.retrieval?.vector) {
|
|
329
339
|
setVector({
|
|
330
340
|
provider: config.retrieval.vector.provider,
|
|
331
|
-
|
|
332
|
-
|
|
341
|
+
// Null here is what makes the embeddings step fall back to the base
|
|
342
|
+
// URL the operator just entered, instead of the scaffold's fake one.
|
|
343
|
+
baseUrl: configuredValue(config.retrieval.vector.baseUrl),
|
|
344
|
+
apiKey: configuredValue(config.retrieval.vector.apiKey),
|
|
333
345
|
embeddingModel: config.retrieval.vector.embeddingModel,
|
|
334
346
|
rerankEnabled: config.retrieval.vector.rerankEnabled,
|
|
335
347
|
rerankerModel: config.retrieval.vector.rerankerModel,
|
|
@@ -35,14 +35,36 @@ export function httpLinkParts(value) {
|
|
|
35
35
|
return parts.length > 0 ? parts : [{ text }];
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// The label the operator actually reads. It used to be `[link: <hostname>]`,
|
|
39
|
+
// which threw away the port and the path even when the whole URL fitted on the
|
|
40
|
+
// line: `/openui` printing `http://localhost:3100` was displayed as
|
|
41
|
+
// `[link: localhost]`, and nothing on screen said which port to open by hand.
|
|
42
|
+
// Show the real URL, and shorten it only when it genuinely does not fit.
|
|
43
|
+
export function linkLabel(url, width) {
|
|
44
|
+
const columns = Math.max(12, Number(width) || 12);
|
|
45
|
+
const text = String(url ?? '');
|
|
46
|
+
if (text.length <= columns) return text;
|
|
47
|
+
let host = '';
|
|
48
|
+
try {
|
|
49
|
+
// `host`, not `hostname`: the port is the part operators need most.
|
|
50
|
+
host = new URL(text).host;
|
|
51
|
+
} catch {
|
|
52
|
+
return text;
|
|
53
|
+
}
|
|
54
|
+
if (!host) return text;
|
|
55
|
+
const short = `${host}/…`;
|
|
56
|
+
// Never truncate below the host: a clipped `example.tes…` is unusable, while
|
|
57
|
+
// an overflowing host is still a real token the row simply clips.
|
|
58
|
+
return short.length <= columns ? short : host;
|
|
59
|
+
}
|
|
60
|
+
|
|
38
61
|
export function wrapHttpLinks(value, width) {
|
|
39
62
|
const columns = Math.max(12, Number(width) || 12);
|
|
40
63
|
const rows = [[]];
|
|
41
64
|
let used = 0;
|
|
42
65
|
for (const part of httpLinkParts(value)) {
|
|
43
66
|
if (part.url) {
|
|
44
|
-
const
|
|
45
|
-
const label = `[link: ${parsed.hostname}]`;
|
|
67
|
+
const label = linkLabel(part.url, columns);
|
|
46
68
|
if (used > 0 && used + label.length > columns) {
|
|
47
69
|
rows.push([]);
|
|
48
70
|
used = 0;
|