@dotdrelle/wiki-manager 0.6.34 → 0.7.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/bin/wiki-manager.js +2 -2
- package/docker-compose.yml +25 -1
- package/package.json +2 -2
- package/src/agent/graph.js +2 -19
- package/src/cli/wiki-manager.js +236 -129
- package/src/commands/slash.js +17 -1
- package/src/commands/slash.test.js +78 -0
- package/src/core/activity.js +14 -0
- package/src/core/agentEvents.js +24 -2
- package/src/core/agentLoop.js +164 -0
- package/src/core/agentLoop.test.js +85 -0
- package/src/core/cacert.js +4 -3
- package/src/core/compose.js +4 -3
- package/src/core/dockerCompose.test.js +14 -0
- package/src/core/documentIntake.test.js +7 -7
- package/src/core/env.js +6 -0
- package/src/core/jobQueue.js +18 -4
- package/src/core/mcp.js +1 -1
- package/src/core/queueStore.js +27 -0
- package/src/core/queueStore.test.js +31 -0
- package/src/core/startupCheck.js +1 -1
- package/src/core/startupCheck.test.js +66 -0
- package/src/core/wikirc.js +2 -2
- package/src/core/workspaces.js +8 -1
- package/src/runtime/auth.js +35 -0
- package/src/runtime/auth.test.js +21 -0
- package/src/runtime/client.js +108 -0
- package/src/runtime/lifecycle.js +60 -0
- package/src/runtime/queueStore.js +6 -0
- package/src/runtime/runner.js +73 -0
- package/src/runtime/server.js +160 -0
- package/src/runtime/server.test.js +53 -0
- package/src/runtime/store.js +292 -0
- package/src/runtime/store.test.js +103 -0
- package/src/runtime/supervisor.js +98 -0
- package/src/runtime/supervisor.test.js +99 -0
- package/src/shell/repl.js +132 -17
- package/src/shell/repl.test.js +38 -0
- package/src/shell/tui.tsx +4 -1
- package/src/shell/useAgent.ts +20 -2
- package/src/shell/useSession.ts +146 -11
- package/wiki-workspace +39 -30
|
@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os';
|
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import test from 'node:test';
|
|
7
7
|
import { handleSlashCommand } from './slash.js';
|
|
8
|
+
import { completionContext } from '../shell/repl.js';
|
|
8
9
|
|
|
9
10
|
test('/workspace delete removes files and clears current session context after confirmation', async () => {
|
|
10
11
|
const root = await mkdtemp(join(tmpdir(), 'wiki-manager-delete-workspace-'));
|
|
@@ -66,3 +67,80 @@ test('/new without a name shows usage', async () => {
|
|
|
66
67
|
|
|
67
68
|
assert.match(result.output ?? '', /Usage/i);
|
|
68
69
|
});
|
|
70
|
+
|
|
71
|
+
test('/use loads only workspaces and /config use switches wikirc profiles', async () => {
|
|
72
|
+
const root = await mkdtemp(join(tmpdir(), 'wiki-manager-use-profile-'));
|
|
73
|
+
const registryRoot = join(root, 'registry');
|
|
74
|
+
const registryPath = join(registryRoot, 'demo');
|
|
75
|
+
const workspacePath = join(root, 'workspace');
|
|
76
|
+
mkdirSync(registryPath, { recursive: true });
|
|
77
|
+
mkdirSync(workspacePath, { recursive: true });
|
|
78
|
+
writeFileSync(join(registryPath, '.env'), [
|
|
79
|
+
'WORKSPACE_NAME=demo',
|
|
80
|
+
`WIKI_WORKSPACE_PATH=${workspacePath}`,
|
|
81
|
+
'',
|
|
82
|
+
].join('\n'), 'utf8');
|
|
83
|
+
writeFileSync(join(workspacePath, '.wikirc.yaml'), [
|
|
84
|
+
'language: fr',
|
|
85
|
+
'llm:',
|
|
86
|
+
' provider: default-provider',
|
|
87
|
+
' model: default-model',
|
|
88
|
+
'',
|
|
89
|
+
].join('\n'), 'utf8');
|
|
90
|
+
writeFileSync(join(workspacePath, '.wikirc.yaml.vpn'), [
|
|
91
|
+
'language: fr',
|
|
92
|
+
'llm:',
|
|
93
|
+
' provider: vpn-provider',
|
|
94
|
+
' model: vpn-model',
|
|
95
|
+
'',
|
|
96
|
+
].join('\n'), 'utf8');
|
|
97
|
+
|
|
98
|
+
const previousDir = process.env.WIKI_WORKSPACES_DIR;
|
|
99
|
+
process.env.WIKI_WORKSPACES_DIR = registryRoot;
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const session = {};
|
|
103
|
+
const listResult = await handleSlashCommand('/use', {
|
|
104
|
+
packageJson: { version: 'test' },
|
|
105
|
+
session,
|
|
106
|
+
});
|
|
107
|
+
assert.match(listResult.output ?? '', /Workspaces/);
|
|
108
|
+
assert.match(listResult.output ?? '', /demo\tavailable/);
|
|
109
|
+
assert.doesNotMatch(listResult.output ?? '', /vpn\t\.wikirc\.yaml\.vpn/);
|
|
110
|
+
|
|
111
|
+
const useResult = await handleSlashCommand('/use demo', {
|
|
112
|
+
packageJson: { version: 'test' },
|
|
113
|
+
session,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
assert.equal(session.workspace, 'demo');
|
|
117
|
+
assert.equal(session.wikirc.profile, 'default');
|
|
118
|
+
assert.match(useResult.output ?? '', /profile: default/);
|
|
119
|
+
assert.match(useResult.output ?? '', /\* default\t\.wikirc\.yaml/);
|
|
120
|
+
assert.match(useResult.output ?? '', /vpn\t\.wikirc\.yaml\.vpn/);
|
|
121
|
+
assert.match(useResult.output ?? '', /Switch config: \/config use <profile>/);
|
|
122
|
+
|
|
123
|
+
const invalidUse = await handleSlashCommand('/use demo vpn', {
|
|
124
|
+
packageJson: { version: 'test' },
|
|
125
|
+
session,
|
|
126
|
+
});
|
|
127
|
+
assert.match(invalidUse.output ?? '', /Usage: \/use <workspace>/);
|
|
128
|
+
assert.equal(session.wikirc.profile, 'default');
|
|
129
|
+
|
|
130
|
+
const result = await handleSlashCommand('/config use vpn', {
|
|
131
|
+
packageJson: { version: 'test' },
|
|
132
|
+
session,
|
|
133
|
+
});
|
|
134
|
+
assert.equal(session.workspace, 'demo');
|
|
135
|
+
assert.equal(session.wikirc.profile, 'vpn');
|
|
136
|
+
assert.match(result.output ?? '', /profile=vpn/);
|
|
137
|
+
|
|
138
|
+
const completion = completionContext('/use ', { commands: ['use'] });
|
|
139
|
+
assert.deepEqual(completion?.matches, ['demo']);
|
|
140
|
+
const configCompletion = completionContext('/config use ', session);
|
|
141
|
+
assert.deepEqual(configCompletion?.matches, ['default', 'vpn']);
|
|
142
|
+
} finally {
|
|
143
|
+
if (previousDir === undefined) delete process.env.WIKI_WORKSPACES_DIR;
|
|
144
|
+
else process.env.WIKI_WORKSPACES_DIR = previousDir;
|
|
145
|
+
}
|
|
146
|
+
});
|
package/src/core/activity.js
CHANGED
|
@@ -222,6 +222,20 @@ export function formatActivitySummary(source, action, resultText) {
|
|
|
222
222
|
return usefulLine ? `${source}.${action}: ${usefulLine.slice(0, 120)}` : null;
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
+
export function activitySnapshot(session) {
|
|
226
|
+
return new Set(sessionActivities(session).map((a) => a.key));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function newNonTerminalActivities(snapshotBefore, session) {
|
|
230
|
+
return sessionActivities(session).filter((a) => !snapshotBefore.has(a.key) && !a.terminal);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function terminalFailures(activities) {
|
|
234
|
+
return activities.filter(
|
|
235
|
+
(a) => a.terminal && ['failed', 'error', 'cancelled', 'canceled'].includes(String(a.status).toLowerCase()),
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
225
239
|
export function formatActivityError(source, action, err) {
|
|
226
240
|
const message = err instanceof Error ? err.message : String(err);
|
|
227
241
|
const lines = message
|
package/src/core/agentEvents.js
CHANGED
|
@@ -7,6 +7,18 @@ const SESSION_PROJECTION_EVENTS = new Set([
|
|
|
7
7
|
'plan_step_updated',
|
|
8
8
|
'activity_upserted',
|
|
9
9
|
'run_error',
|
|
10
|
+
'run_cancelled',
|
|
11
|
+
'runtime_log',
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
// Events that can mutate state.plan in applyEvent() — only these warrant the
|
|
15
|
+
// before/after plan comparison below (runtime_log fires far more often and
|
|
16
|
+
// never touches the plan).
|
|
17
|
+
const PLAN_MUTATING_EVENTS = new Set([
|
|
18
|
+
'run_started',
|
|
19
|
+
'plan_set',
|
|
20
|
+
'plan_step_updated',
|
|
21
|
+
'activity_upserted',
|
|
10
22
|
]);
|
|
11
23
|
|
|
12
24
|
export function createAgentEvent(type, { origin = 'system', payload = {}, runId = null, turnId = null } = {}) {
|
|
@@ -23,7 +35,8 @@ export function createAgentEvent(type, { origin = 'system', payload = {}, runId
|
|
|
23
35
|
|
|
24
36
|
export function dispatchAgentEvent(session, event) {
|
|
25
37
|
const normalized = event.id && event.ts ? event : createAgentEvent(event.type, event);
|
|
26
|
-
const
|
|
38
|
+
const tracksPlan = PLAN_MUTATING_EVENTS.has(normalized.type);
|
|
39
|
+
const previousPlan = tracksPlan ? JSON.stringify(session.headlessPlan ?? null) : null;
|
|
27
40
|
session.agentEvents ??= [];
|
|
28
41
|
session.agentEvents.push(normalized);
|
|
29
42
|
session._agentProjectionState ??= createProjectionState();
|
|
@@ -31,10 +44,11 @@ export function dispatchAgentEvent(session, event) {
|
|
|
31
44
|
session.agentProjection = publicProjection(session._agentProjectionState);
|
|
32
45
|
if (SESSION_PROJECTION_EVENTS.has(normalized.type)) {
|
|
33
46
|
applyAgentProjectionToSession(session, session.agentProjection);
|
|
34
|
-
if (JSON.stringify(session.headlessPlan ?? null) !== previousPlan) {
|
|
47
|
+
if (tracksPlan && JSON.stringify(session.headlessPlan ?? null) !== previousPlan) {
|
|
35
48
|
session._onPlanUpdate?.();
|
|
36
49
|
}
|
|
37
50
|
}
|
|
51
|
+
session._onAgentEvent?.(normalized, session.agentProjection);
|
|
38
52
|
return normalized;
|
|
39
53
|
}
|
|
40
54
|
|
|
@@ -133,10 +147,18 @@ function applyEvent(state, event) {
|
|
|
133
147
|
case 'run_done':
|
|
134
148
|
state.status = 'done';
|
|
135
149
|
return;
|
|
150
|
+
case 'run_cancelled':
|
|
151
|
+
state.status = 'cancelled';
|
|
152
|
+
state.logs.push(String(event.payload?.message ?? 'Agent run cancelled.'));
|
|
153
|
+
return;
|
|
136
154
|
case 'run_error':
|
|
137
155
|
state.status = 'error';
|
|
138
156
|
state.logs.push(String(event.payload?.message ?? 'Agent run failed.'));
|
|
139
157
|
return;
|
|
158
|
+
case 'runtime_log':
|
|
159
|
+
state.logs.push(String(event.payload?.message ?? ''));
|
|
160
|
+
state.logs = state.logs.slice(-200);
|
|
161
|
+
return;
|
|
140
162
|
default:
|
|
141
163
|
return;
|
|
142
164
|
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { buildAgentSystemPrompt, buildLimitedAgentResponse } from '../agent/graph.js';
|
|
2
|
+
import { createAgentEvent, dispatchAgentEvent } from './agentEvents.js';
|
|
3
|
+
import { activitySnapshot, newNonTerminalActivities } from './activity.js';
|
|
4
|
+
import { extractHeadlessPlan, formatCompletedActivities, formatPlanStatus } from './plan.js';
|
|
5
|
+
|
|
6
|
+
export function abortError(message = 'Agent run cancelled.') {
|
|
7
|
+
const err = new Error(message);
|
|
8
|
+
err.name = 'AbortError';
|
|
9
|
+
return err;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function throwIfAborted(signal, message) {
|
|
13
|
+
if (signal?.aborted) throw abortError(message);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function runAgentTurn(agent, session, input, {
|
|
17
|
+
messages = [],
|
|
18
|
+
signal = null,
|
|
19
|
+
} = {}) {
|
|
20
|
+
let streamedContent = '';
|
|
21
|
+
session._onStream = (delta) => { streamedContent += delta; };
|
|
22
|
+
session._onStreamReset = () => { streamedContent = ''; };
|
|
23
|
+
let result;
|
|
24
|
+
try {
|
|
25
|
+
result = await agent.invoke({ input, session, messages });
|
|
26
|
+
} finally {
|
|
27
|
+
delete session._onStream;
|
|
28
|
+
delete session._onStreamReset;
|
|
29
|
+
}
|
|
30
|
+
if (result.streamedInline) {
|
|
31
|
+
return streamedContent.trim() || buildLimitedAgentResponse({ input, session }, 'LLM stream ended without content');
|
|
32
|
+
}
|
|
33
|
+
if (result.response != null) return result.response;
|
|
34
|
+
if (result.readyToStream && session.llm?.stream) {
|
|
35
|
+
const { system, messages: streamMessages = [] } = result.streamContext ?? {};
|
|
36
|
+
let content = '';
|
|
37
|
+
for await (const delta of session.llm.stream({
|
|
38
|
+
system: system ?? buildAgentSystemPrompt({ input, session }),
|
|
39
|
+
messages: streamMessages,
|
|
40
|
+
signal,
|
|
41
|
+
})) {
|
|
42
|
+
content += delta;
|
|
43
|
+
}
|
|
44
|
+
return content.trim() || buildLimitedAgentResponse({ input, session }, 'LLM stream ended without content');
|
|
45
|
+
}
|
|
46
|
+
return buildLimitedAgentResponse({ input, session });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function runAgenticLoop(agent, session, initialInput, {
|
|
50
|
+
maxTurns,
|
|
51
|
+
timeoutMs,
|
|
52
|
+
signal = null,
|
|
53
|
+
runId = null,
|
|
54
|
+
runTurn = runAgentTurn,
|
|
55
|
+
waitForActivities,
|
|
56
|
+
planOrigin = 'llm',
|
|
57
|
+
onTurnStart = null,
|
|
58
|
+
onTurnResponse = null,
|
|
59
|
+
onAssistantMessage = null,
|
|
60
|
+
onPlanExtracted = null,
|
|
61
|
+
onPlanAlreadySet = null,
|
|
62
|
+
onComplete = null,
|
|
63
|
+
onPendingSteps = null,
|
|
64
|
+
onActivitiesStarted = null,
|
|
65
|
+
onActivitiesCompleted = null,
|
|
66
|
+
onMaxTurns = null,
|
|
67
|
+
abortMessage = 'Agent run cancelled.',
|
|
68
|
+
} = {}) {
|
|
69
|
+
if (!waitForActivities) throw new Error('runAgenticLoop requires waitForActivities.');
|
|
70
|
+
const conversationHistory = [];
|
|
71
|
+
let currentInput = initialInput;
|
|
72
|
+
|
|
73
|
+
for (let turn = 1; turn <= maxTurns; turn += 1) {
|
|
74
|
+
throwIfAborted(signal, abortMessage);
|
|
75
|
+
onTurnStart?.({ turn, maxTurns });
|
|
76
|
+
|
|
77
|
+
const snapshot = activitySnapshot(session);
|
|
78
|
+
const response = await runTurn(agent, session, currentInput, {
|
|
79
|
+
messages: conversationHistory,
|
|
80
|
+
signal,
|
|
81
|
+
});
|
|
82
|
+
onTurnResponse?.({ turn, response });
|
|
83
|
+
onAssistantMessage?.({ response, runId });
|
|
84
|
+
|
|
85
|
+
conversationHistory.push(
|
|
86
|
+
{ role: 'user', content: currentInput },
|
|
87
|
+
{ role: 'assistant', content: response },
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
if (turn === 1) {
|
|
91
|
+
if (session.headlessPlan === null) {
|
|
92
|
+
const extractedPlan = extractHeadlessPlan(response);
|
|
93
|
+
if (extractedPlan) {
|
|
94
|
+
dispatchAgentEvent(session, createAgentEvent('plan_set', {
|
|
95
|
+
origin: planOrigin,
|
|
96
|
+
runId,
|
|
97
|
+
payload: { steps: extractedPlan },
|
|
98
|
+
}));
|
|
99
|
+
onPlanExtracted?.({ steps: session.headlessPlan ?? extractedPlan, fallback: true });
|
|
100
|
+
}
|
|
101
|
+
} else {
|
|
102
|
+
onPlanAlreadySet?.({ steps: session.headlessPlan });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const newPending = newNonTerminalActivities(snapshot, session);
|
|
107
|
+
if (newPending.length === 0) {
|
|
108
|
+
const pendingSteps = (session.headlessPlan ?? []).filter((step) => step.status === 'pending');
|
|
109
|
+
if (pendingSteps.length === 0) {
|
|
110
|
+
onComplete?.();
|
|
111
|
+
return { ok: true };
|
|
112
|
+
}
|
|
113
|
+
onPendingSteps?.({ pendingSteps });
|
|
114
|
+
currentInput = pendingStepsPrompt(initialInput, session.headlessPlan);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
onActivitiesStarted?.({ activities: newPending });
|
|
119
|
+
const waitResult = await waitForActivities(session, newPending, { timeoutMs, signal });
|
|
120
|
+
if (!waitResult.ok) {
|
|
121
|
+
return {
|
|
122
|
+
ok: false,
|
|
123
|
+
timedOut: Boolean(waitResult.timedOut),
|
|
124
|
+
completed: waitResult.completed ?? [],
|
|
125
|
+
waitResult,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const completed = waitResult.completed ?? [];
|
|
130
|
+
const summary = formatCompletedActivities(completed);
|
|
131
|
+
onActivitiesCompleted?.({ completed, summary });
|
|
132
|
+
currentInput = completedActivitiesPrompt(initialInput, session.headlessPlan, summary);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
onMaxTurns?.({ maxTurns });
|
|
136
|
+
return { ok: false, maxTurns: true };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function pendingStepsPrompt(initialInput, plan) {
|
|
140
|
+
return [
|
|
141
|
+
'Original task:',
|
|
142
|
+
initialInput,
|
|
143
|
+
'',
|
|
144
|
+
`Plan status:\n${formatPlanStatus(plan)}`,
|
|
145
|
+
'',
|
|
146
|
+
'No new background activity was started in the previous turn.',
|
|
147
|
+
'Continue the original plan. Start the next pending step only.',
|
|
148
|
+
'If required information is missing and cannot be inferred, stop with a clear blocker.',
|
|
149
|
+
].join('\n');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function completedActivitiesPrompt(initialInput, plan, summary) {
|
|
153
|
+
return [
|
|
154
|
+
'Original task:',
|
|
155
|
+
initialInput,
|
|
156
|
+
'',
|
|
157
|
+
plan ? `Plan status:\n${formatPlanStatus(plan)}\n` : null,
|
|
158
|
+
'Completed activities:',
|
|
159
|
+
summary,
|
|
160
|
+
'',
|
|
161
|
+
'Continue the original plan. Start the next required step only.',
|
|
162
|
+
'If all steps are complete, provide the final summary.',
|
|
163
|
+
].filter(Boolean).join('\n');
|
|
164
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { createAgentEvent, dispatchAgentEvent } from './agentEvents.js';
|
|
4
|
+
import { runAgenticLoop } from './agentLoop.js';
|
|
5
|
+
|
|
6
|
+
test('runAgenticLoop waits for new activities and continues with a completion summary', async () => {
|
|
7
|
+
const session = {
|
|
8
|
+
activities: {},
|
|
9
|
+
headlessPlan: null,
|
|
10
|
+
};
|
|
11
|
+
const inputs = [];
|
|
12
|
+
const callbacks = [];
|
|
13
|
+
const agent = {
|
|
14
|
+
async invoke({ input, session: turnSession }) {
|
|
15
|
+
inputs.push(input);
|
|
16
|
+
if (inputs.length === 1) {
|
|
17
|
+
dispatchAgentEvent(turnSession, createAgentEvent('activity_upserted', {
|
|
18
|
+
payload: {
|
|
19
|
+
activity: {
|
|
20
|
+
id: 'job-1',
|
|
21
|
+
source: 'production',
|
|
22
|
+
kind: 'job',
|
|
23
|
+
label: 'production job',
|
|
24
|
+
status: 'running',
|
|
25
|
+
terminal: false,
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
}));
|
|
29
|
+
return { response: 'Started production job.' };
|
|
30
|
+
}
|
|
31
|
+
return { response: 'All done.' };
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const result = await runAgenticLoop(agent, session, 'Build workspace', {
|
|
36
|
+
maxTurns: 3,
|
|
37
|
+
timeoutMs: 1000,
|
|
38
|
+
waitForActivities: async (turnSession, startedActivities) => {
|
|
39
|
+
assert.equal(startedActivities.length, 1);
|
|
40
|
+
dispatchAgentEvent(turnSession, createAgentEvent('activity_upserted', {
|
|
41
|
+
payload: {
|
|
42
|
+
activity: {
|
|
43
|
+
id: 'job-1',
|
|
44
|
+
source: 'production',
|
|
45
|
+
kind: 'job',
|
|
46
|
+
label: 'production job',
|
|
47
|
+
status: 'done',
|
|
48
|
+
terminal: true,
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
}));
|
|
52
|
+
if (turnSession.headlessPlan?.[0]) turnSession.headlessPlan[0].status = 'done';
|
|
53
|
+
return { ok: true, completed: Object.values(turnSession.activities) };
|
|
54
|
+
},
|
|
55
|
+
onActivitiesStarted: ({ activities }) => callbacks.push(`started:${activities.length}`),
|
|
56
|
+
onActivitiesCompleted: ({ summary }) => callbacks.push(summary),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
assert.equal(result.ok, true);
|
|
60
|
+
assert.equal(inputs.length, 2);
|
|
61
|
+
assert.match(inputs[1], /Completed activities:/);
|
|
62
|
+
assert.match(inputs[1], /production job-1: done/);
|
|
63
|
+
assert.deepEqual(callbacks, ['started:1', '- production job-1: done']);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('runAgenticLoop extracts a fallback numbered plan from first response', async () => {
|
|
67
|
+
const session = {
|
|
68
|
+
activities: {},
|
|
69
|
+
headlessPlan: null,
|
|
70
|
+
};
|
|
71
|
+
const result = await runAgenticLoop({
|
|
72
|
+
async invoke() {
|
|
73
|
+
return { response: '1. Collect sources\n2. Build page' };
|
|
74
|
+
},
|
|
75
|
+
}, session, 'Plan task', {
|
|
76
|
+
maxTurns: 1,
|
|
77
|
+
timeoutMs: 1000,
|
|
78
|
+
waitForActivities: async () => assert.fail('No activities should be waited for.'),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
assert.equal(result.ok, false);
|
|
82
|
+
assert.equal(result.maxTurns, true);
|
|
83
|
+
assert.equal(session.headlessPlan.length, 2);
|
|
84
|
+
assert.equal(session.headlessPlan[0].description, 'Collect sources');
|
|
85
|
+
});
|
package/src/core/cacert.js
CHANGED
|
@@ -40,8 +40,7 @@ function composeOverrideContent(cacertPath, services) {
|
|
|
40
40
|
services: serviceEntries,
|
|
41
41
|
};
|
|
42
42
|
return [
|
|
43
|
-
'# Generated by wiki-manager.
|
|
44
|
-
'# Rewritten when --cacert is used with a Docker Compose command.',
|
|
43
|
+
'# Generated by wiki-manager. Edit freely; delete to regenerate.',
|
|
45
44
|
YAML.stringify(doc).trimEnd(),
|
|
46
45
|
'',
|
|
47
46
|
].join('\n');
|
|
@@ -61,6 +60,8 @@ export function ensureCacertComposeOverride(composeFilePath, fileName = 'cacert.
|
|
|
61
60
|
const runtimeDir = managerRuntimeDir();
|
|
62
61
|
mkdirSync(runtimeDir, { recursive: true });
|
|
63
62
|
const overridePath = join(runtimeDir, fileName);
|
|
64
|
-
|
|
63
|
+
if (!existsSync(overridePath)) {
|
|
64
|
+
writeFileSync(overridePath, composeOverrideContent(cacertPath, services), 'utf8');
|
|
65
|
+
}
|
|
65
66
|
return (_cacertOverrideCache = overridePath);
|
|
66
67
|
}
|
package/src/core/compose.js
CHANGED
|
@@ -9,7 +9,7 @@ import { managerRoot } from './workspaces.js';
|
|
|
9
9
|
|
|
10
10
|
const execFileAsync = promisify(execFile);
|
|
11
11
|
|
|
12
|
-
export const COMPOSE_SERVICES = ['serve', 'mcp-http', 'production-mcp'];
|
|
12
|
+
export const COMPOSE_SERVICES = ['serve', 'mcp-http', 'agent-runtime', 'production-mcp'];
|
|
13
13
|
const SERVICE_DESCRIPTION_LABEL = 'wiki-manager.description';
|
|
14
14
|
|
|
15
15
|
const DEFAULT_SERVICE_ALIASES = {
|
|
@@ -17,6 +17,7 @@ const DEFAULT_SERVICE_ALIASES = {
|
|
|
17
17
|
ui: ['serve'],
|
|
18
18
|
wiki: ['mcp-http'],
|
|
19
19
|
mcp: ['mcp-http'],
|
|
20
|
+
runtime: ['agent-runtime'],
|
|
20
21
|
production: ['production-mcp'],
|
|
21
22
|
};
|
|
22
23
|
|
|
@@ -113,6 +114,7 @@ function composeEnv(session) {
|
|
|
113
114
|
...cacertEnv(),
|
|
114
115
|
WORKSPACE_NAME: session.workspace,
|
|
115
116
|
WIKI_WORKSPACE_PATH: session.workspacePath,
|
|
117
|
+
...(session.wikirc?.fileName && { WIKI_CONFIG_PATH: session.wikirc.fileName }),
|
|
116
118
|
};
|
|
117
119
|
}
|
|
118
120
|
|
|
@@ -303,8 +305,7 @@ export async function runWikiCli(session, args, options = {}) {
|
|
|
303
305
|
if (!Array.isArray(args) || args.length === 0) {
|
|
304
306
|
throw new Error('Usage: /wiki run <args...>');
|
|
305
307
|
}
|
|
306
|
-
|
|
307
|
-
return runCompose(session, ['run', '--rm', ...configEnv, 'wiki', ...args], {
|
|
308
|
+
return runCompose(session, ['run', '--rm', 'wiki', ...args], {
|
|
308
309
|
timeout: options.timeout ?? 180_000,
|
|
309
310
|
maxBuffer: options.maxBuffer ?? 1024 * 1024 * 8,
|
|
310
311
|
onOutput: options.onOutput,
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import YAML from 'yaml';
|
|
5
|
+
|
|
6
|
+
test('agent-runtime compose command passes runtime args to the image entrypoint', async () => {
|
|
7
|
+
const raw = await readFile(new URL('../../docker-compose.yml', import.meta.url), 'utf8');
|
|
8
|
+
const compose = YAML.parse(raw);
|
|
9
|
+
const service = compose.services['agent-runtime'];
|
|
10
|
+
|
|
11
|
+
assert.equal(service.image, 'dotdrelle/llm-wiki-manager:latest');
|
|
12
|
+
assert.equal(service.command, 'runtime --host 0.0.0.0 --port 7788 --state-dir /state');
|
|
13
|
+
assert.ok(!service.command.startsWith('wiki-manager '));
|
|
14
|
+
});
|
|
@@ -16,16 +16,16 @@ test('document intake stores uploads without requiring documents MCP', async ()
|
|
|
16
16
|
const source = path.join(root, 'rapport.pdf');
|
|
17
17
|
await writeFile(source, 'fake pdf content');
|
|
18
18
|
const session = {
|
|
19
|
-
workspace: '
|
|
19
|
+
workspace: 'my-project',
|
|
20
20
|
mcp: {},
|
|
21
21
|
};
|
|
22
22
|
|
|
23
23
|
try {
|
|
24
24
|
const { record, converted } = await storeAndMaybeConvertDocument(session, source);
|
|
25
25
|
assert.equal(converted, false);
|
|
26
|
-
assert.equal(record.workspace, '
|
|
26
|
+
assert.equal(record.workspace, 'my-project');
|
|
27
27
|
assert.equal(record.status, 'stored');
|
|
28
|
-
assert.equal(record.agentPath.startsWith('/documents/input/
|
|
28
|
+
assert.equal(record.agentPath.startsWith('/documents/input/my-project/'), true);
|
|
29
29
|
assert.equal(await readFile(record.storedPath, 'utf8'), 'fake pdf content');
|
|
30
30
|
|
|
31
31
|
const uploads = await listDocumentUploads(session);
|
|
@@ -45,7 +45,7 @@ test('document intake accepts quoted absolute paths with spaces', async () => {
|
|
|
45
45
|
const source = path.join(root, 'Screenshot 2026-06-21 at 10.03.36.png');
|
|
46
46
|
await writeFile(source, 'fake png content');
|
|
47
47
|
const session = {
|
|
48
|
-
workspace: '
|
|
48
|
+
workspace: 'my-project',
|
|
49
49
|
mcp: {},
|
|
50
50
|
};
|
|
51
51
|
|
|
@@ -66,7 +66,7 @@ test('document intake accepts double-quoted absolute paths with spaces', async (
|
|
|
66
66
|
const source = path.join(root, 'scan avec espace.pdf');
|
|
67
67
|
await writeFile(source, 'fake pdf content');
|
|
68
68
|
const session = {
|
|
69
|
-
workspace: '
|
|
69
|
+
workspace: 'my-project',
|
|
70
70
|
mcp: {},
|
|
71
71
|
};
|
|
72
72
|
|
|
@@ -87,7 +87,7 @@ test('document intake replaces an existing upload with the same original filenam
|
|
|
87
87
|
process.env.AGENTS_DATA_DIR = agentsDataDir;
|
|
88
88
|
const source = path.join(root, 'rapport.pdf');
|
|
89
89
|
const session = {
|
|
90
|
-
workspace: '
|
|
90
|
+
workspace: 'my-project',
|
|
91
91
|
mcp: {},
|
|
92
92
|
};
|
|
93
93
|
|
|
@@ -98,7 +98,7 @@ test('document intake replaces an existing upload with the same original filenam
|
|
|
98
98
|
await mkdir(path.dirname(outputPath), { recursive: true });
|
|
99
99
|
await writeFile(outputPath, 'old markdown');
|
|
100
100
|
|
|
101
|
-
const manifest = path.join(agentsDataDir, 'documents', 'uploads', '
|
|
101
|
+
const manifest = path.join(agentsDataDir, 'documents', 'uploads', 'my-project.jsonl');
|
|
102
102
|
const firstRecord = JSON.parse(await readFile(manifest, 'utf8'));
|
|
103
103
|
await writeFile(manifest, `${JSON.stringify({ ...firstRecord, outputPath })}\n`, 'utf8');
|
|
104
104
|
|
package/src/core/env.js
CHANGED
|
@@ -15,6 +15,12 @@ export function managerRuntimeDir() {
|
|
|
15
15
|
return join(managerStateDir(), '.wiki-manager');
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
export function defaultRuntimeStateDir() {
|
|
19
|
+
return process.env.WIKI_MANAGER_STATE_DIR
|
|
20
|
+
? resolve(process.env.WIKI_MANAGER_STATE_DIR)
|
|
21
|
+
: managerRuntimeDir();
|
|
22
|
+
}
|
|
23
|
+
|
|
18
24
|
export function managerEnvFile() {
|
|
19
25
|
return process.env.WIKI_MANAGER_ENV_FILE
|
|
20
26
|
? resolve(process.env.WIKI_MANAGER_ENV_FILE)
|
package/src/core/jobQueue.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { extractActivity, parseJsonText, sessionActivities } from './activity.js';
|
|
2
2
|
import { createAgentEvent, dispatchAgentEvent } from './agentEvents.js';
|
|
3
3
|
import { callMcpTool, formatMcpToolResult } from './mcp.js';
|
|
4
|
+
import { queueStoreFor } from './queueStore.js';
|
|
4
5
|
|
|
5
6
|
const TERMINAL = new Set(['done', 'failed', 'cancelled', 'canceled', 'complete', 'completed', 'success', 'error']);
|
|
6
7
|
|
|
@@ -8,6 +9,10 @@ function now() {
|
|
|
8
9
|
return new Date().toISOString();
|
|
9
10
|
}
|
|
10
11
|
|
|
12
|
+
function notifyQueueUpdate(session) {
|
|
13
|
+
queueStoreFor(session).changed();
|
|
14
|
+
}
|
|
15
|
+
|
|
11
16
|
function shortId() {
|
|
12
17
|
return `q-${Math.random().toString(16).slice(2, 6)}`;
|
|
13
18
|
}
|
|
@@ -17,8 +22,7 @@ function terminalStatus(status) {
|
|
|
17
22
|
}
|
|
18
23
|
|
|
19
24
|
export function ensureJobQueue(session) {
|
|
20
|
-
session.
|
|
21
|
-
return session.jobQueue;
|
|
25
|
+
return queueStoreFor(session).list();
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
export function productionLockBusy(session) {
|
|
@@ -41,6 +45,7 @@ export function enqueueProductionJob(session, args = {}, reason = 'waiting') {
|
|
|
41
45
|
createdAt: now(),
|
|
42
46
|
};
|
|
43
47
|
ensureJobQueue(session).push(item);
|
|
48
|
+
notifyQueueUpdate(session);
|
|
44
49
|
return item;
|
|
45
50
|
}
|
|
46
51
|
|
|
@@ -85,10 +90,11 @@ export function formatQueue(session) {
|
|
|
85
90
|
export function clearFinishedQueueItems(session) {
|
|
86
91
|
const queue = ensureJobQueue(session);
|
|
87
92
|
const before = queue.length;
|
|
88
|
-
|
|
93
|
+
const next = queue.filter((item) =>
|
|
89
94
|
item.workspace !== session.workspace || !terminalStatus(item.status),
|
|
90
95
|
);
|
|
91
|
-
|
|
96
|
+
queueStoreFor(session).replace(next);
|
|
97
|
+
return before - next.length;
|
|
92
98
|
}
|
|
93
99
|
|
|
94
100
|
function findQueueItem(session, id) {
|
|
@@ -102,6 +108,7 @@ export async function cancelQueueItem(session, id) {
|
|
|
102
108
|
const label = item.status === 'starting' ? 'starting' : 'queued';
|
|
103
109
|
item.status = 'cancelled';
|
|
104
110
|
item.finishedAt = now();
|
|
111
|
+
notifyQueueUpdate(session);
|
|
105
112
|
return { ok: true, message: `Cancelled ${label} job ${id}.` };
|
|
106
113
|
}
|
|
107
114
|
if (item.status === 'running') {
|
|
@@ -109,6 +116,7 @@ export async function cancelQueueItem(session, id) {
|
|
|
109
116
|
await callMcpTool(session.mcp, 'production', 'production_cancel_job', { jobId: item.jobId });
|
|
110
117
|
item.status = 'cancelled';
|
|
111
118
|
item.finishedAt = now();
|
|
119
|
+
notifyQueueUpdate(session);
|
|
112
120
|
return { ok: true, message: `Cancellation requested for ${id} (${item.jobId}).` };
|
|
113
121
|
}
|
|
114
122
|
return { ok: false, message: `No cancel tool available for ${id}.` };
|
|
@@ -140,6 +148,7 @@ export async function startNextQueuedJob(session, hooks = {}) {
|
|
|
140
148
|
|
|
141
149
|
item.status = 'starting';
|
|
142
150
|
item.startedAt = now();
|
|
151
|
+
notifyQueueUpdate(session);
|
|
143
152
|
hooks.refresh?.();
|
|
144
153
|
hooks.addLog?.(`queue: starting ${item.id} ${queueSummary(item.args)}`);
|
|
145
154
|
|
|
@@ -154,6 +163,7 @@ export async function startNextQueuedJob(session, hooks = {}) {
|
|
|
154
163
|
if (payload?.ok === false && payload?.error === 'workspace_busy') {
|
|
155
164
|
item.status = 'waiting';
|
|
156
165
|
item.reason = 'workspace_busy';
|
|
166
|
+
notifyQueueUpdate(session);
|
|
157
167
|
hooks.addLog?.(`queue: ${item.id} still waiting, production lock busy`);
|
|
158
168
|
hooks.refresh?.();
|
|
159
169
|
return item;
|
|
@@ -164,6 +174,7 @@ export async function startNextQueuedJob(session, hooks = {}) {
|
|
|
164
174
|
item.jobId = activity.id;
|
|
165
175
|
item.activityKey = activity.key;
|
|
166
176
|
item.finishedAt = activity.terminal ? now() : undefined;
|
|
177
|
+
notifyQueueUpdate(session);
|
|
167
178
|
dispatchAgentEvent(session, createAgentEvent('activity_upserted', {
|
|
168
179
|
origin: 'queue',
|
|
169
180
|
payload: { activity },
|
|
@@ -171,6 +182,7 @@ export async function startNextQueuedJob(session, hooks = {}) {
|
|
|
171
182
|
} else {
|
|
172
183
|
item.status = 'done';
|
|
173
184
|
item.finishedAt = now();
|
|
185
|
+
notifyQueueUpdate(session);
|
|
174
186
|
}
|
|
175
187
|
hooks.addLog?.(`queue: ${item.id} ${item.status}${item.jobId ? ` job=${item.jobId}` : ''}`);
|
|
176
188
|
hooks.refresh?.();
|
|
@@ -179,6 +191,7 @@ export async function startNextQueuedJob(session, hooks = {}) {
|
|
|
179
191
|
item.status = 'failed';
|
|
180
192
|
item.error = err instanceof Error ? err.message : String(err);
|
|
181
193
|
item.finishedAt = now();
|
|
194
|
+
notifyQueueUpdate(session);
|
|
182
195
|
hooks.addLog?.(`queue: ${item.id} failed · ${item.error}`);
|
|
183
196
|
hooks.refresh?.();
|
|
184
197
|
return item;
|
|
@@ -193,5 +206,6 @@ export function syncQueueWithActivity(session, activity) {
|
|
|
193
206
|
item.activityKey = activity.key;
|
|
194
207
|
item.finishedAt = activity.terminal ? now() : item.finishedAt;
|
|
195
208
|
item.error = activity.error ?? item.error;
|
|
209
|
+
notifyQueueUpdate(session);
|
|
196
210
|
return item;
|
|
197
211
|
}
|
package/src/core/mcp.js
CHANGED
|
@@ -211,7 +211,7 @@ async function mcpRequest(endpoint, method, params, signal, options = {}) {
|
|
|
211
211
|
params: {
|
|
212
212
|
protocolVersion: '2025-06-18',
|
|
213
213
|
capabilities: {},
|
|
214
|
-
clientInfo: { name: 'wiki-manager', version: '0.
|
|
214
|
+
clientInfo: { name: 'wiki-manager', version: '0.7.3' },
|
|
215
215
|
},
|
|
216
216
|
}),
|
|
217
217
|
});
|