@dotdrelle/wiki-manager 0.6.47 → 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/cli/wiki-manager.js +236 -129
- 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 +2 -1
- 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/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 +131 -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 +23 -18
|
@@ -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
|
|
|
@@ -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
|
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export function createQueueStore(session, { persist = () => {} } = {}) {
|
|
2
|
+
session.jobQueue ??= [];
|
|
3
|
+
return {
|
|
4
|
+
list() {
|
|
5
|
+
session.jobQueue ??= [];
|
|
6
|
+
return session.jobQueue;
|
|
7
|
+
},
|
|
8
|
+
replace(queue) {
|
|
9
|
+
session.jobQueue = Array.isArray(queue) ? queue : [];
|
|
10
|
+
persist();
|
|
11
|
+
return session.jobQueue;
|
|
12
|
+
},
|
|
13
|
+
changed() {
|
|
14
|
+
session.jobQueue ??= [];
|
|
15
|
+
persist();
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function createMemoryQueueStore(session) {
|
|
21
|
+
return createQueueStore(session, { persist: () => session._onQueueUpdate?.(session.jobQueue) });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function queueStoreFor(session) {
|
|
25
|
+
session.queueStore ??= createMemoryQueueStore(session);
|
|
26
|
+
return session.queueStore;
|
|
27
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { enqueueProductionJob, ensureJobQueue } from './jobQueue.js';
|
|
4
|
+
|
|
5
|
+
test('job queue uses an injected queue store', () => {
|
|
6
|
+
const queue = [];
|
|
7
|
+
let changed = 0;
|
|
8
|
+
const session = {
|
|
9
|
+
workspace: 'docs',
|
|
10
|
+
queueStore: {
|
|
11
|
+
list() {
|
|
12
|
+
return queue;
|
|
13
|
+
},
|
|
14
|
+
replace(next) {
|
|
15
|
+
queue.splice(0, queue.length, ...next);
|
|
16
|
+
changed += 1;
|
|
17
|
+
return queue;
|
|
18
|
+
},
|
|
19
|
+
changed() {
|
|
20
|
+
changed += 1;
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const item = enqueueProductionJob(session, { type: 'build' }, 'workspace_busy');
|
|
26
|
+
|
|
27
|
+
assert.equal(item.workspace, 'docs');
|
|
28
|
+
assert.equal(queue.length, 1);
|
|
29
|
+
assert.equal(ensureJobQueue(session), queue);
|
|
30
|
+
assert.equal(changed, 1);
|
|
31
|
+
});
|
package/src/core/workspaces.js
CHANGED
|
@@ -33,12 +33,19 @@ export function listWorkspaces() {
|
|
|
33
33
|
if (!existsSync(envFile)) return [];
|
|
34
34
|
const env = readEnvFile(envFile);
|
|
35
35
|
const workspacePath = env.WIKI_WORKSPACE_PATH || registryPath;
|
|
36
|
+
let resolvedWorkspacePath;
|
|
37
|
+
try {
|
|
38
|
+
resolvedWorkspacePath = realpathSync.native?.(workspacePath) ?? realpathSync(workspacePath);
|
|
39
|
+
} catch {
|
|
40
|
+
// Host-absolute WIKI_WORKSPACE_PATH not accessible here (e.g. inside a container); use registry dir.
|
|
41
|
+
resolvedWorkspacePath = registryPath;
|
|
42
|
+
}
|
|
36
43
|
return [
|
|
37
44
|
{
|
|
38
45
|
name: env.WORKSPACE_NAME || entry.name,
|
|
39
46
|
registryPath,
|
|
40
47
|
envFile,
|
|
41
|
-
workspacePath:
|
|
48
|
+
workspacePath: resolvedWorkspacePath,
|
|
42
49
|
env,
|
|
43
50
|
},
|
|
44
51
|
];
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { defaultRuntimeStateDir } from '../core/env.js';
|
|
5
|
+
|
|
6
|
+
export function runtimeTokenPath(stateDir = defaultRuntimeStateDir()) {
|
|
7
|
+
return join(resolve(stateDir), 'runtime.token');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function runtimeTokenFromEnv() {
|
|
11
|
+
return process.env.WIKI_MANAGER_RUNTIME_TOKEN ?? process.env.RUNTIME_AUTH_TOKEN ?? null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function resolveRuntimeAuthToken({
|
|
15
|
+
host = '127.0.0.1',
|
|
16
|
+
stateDir = defaultRuntimeStateDir(),
|
|
17
|
+
explicitToken = runtimeTokenFromEnv(),
|
|
18
|
+
} = {}) {
|
|
19
|
+
if (explicitToken) return { token: explicitToken, source: 'env', tokenPath: null };
|
|
20
|
+
if (!isExposedHost(host)) return { token: null, source: 'none', tokenPath: null };
|
|
21
|
+
|
|
22
|
+
const tokenPath = runtimeTokenPath(stateDir);
|
|
23
|
+
if (existsSync(tokenPath)) {
|
|
24
|
+
const token = readFileSync(tokenPath, 'utf8').trim();
|
|
25
|
+
if (token) return { token, source: 'file', tokenPath };
|
|
26
|
+
}
|
|
27
|
+
const token = randomBytes(32).toString('hex');
|
|
28
|
+
mkdirSync(resolve(stateDir), { recursive: true });
|
|
29
|
+
writeFileSync(tokenPath, `${token}\n`, { mode: 0o600 });
|
|
30
|
+
return { token, source: 'generated', tokenPath };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isExposedHost(host) {
|
|
34
|
+
return ['0.0.0.0', '::', '[::]'].includes(String(host ?? '').trim());
|
|
35
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtempSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
import { resolveRuntimeAuthToken } from './auth.js';
|
|
7
|
+
|
|
8
|
+
test('resolveRuntimeAuthToken: loopback host does not require token', () => {
|
|
9
|
+
const result = resolveRuntimeAuthToken({ host: '127.0.0.1', explicitToken: null });
|
|
10
|
+
assert.equal(result.token, null);
|
|
11
|
+
assert.equal(result.source, 'none');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('resolveRuntimeAuthToken: exposed host generates and reuses token', () => {
|
|
15
|
+
const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-auth-'));
|
|
16
|
+
const first = resolveRuntimeAuthToken({ host: '0.0.0.0', stateDir, explicitToken: null });
|
|
17
|
+
const second = resolveRuntimeAuthToken({ host: '0.0.0.0', stateDir, explicitToken: null });
|
|
18
|
+
assert.equal(first.source, 'generated');
|
|
19
|
+
assert.equal(second.source, 'file');
|
|
20
|
+
assert.equal(second.token, first.token);
|
|
21
|
+
});
|