@dotdrelle/wiki-manager 0.6.17

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.
@@ -0,0 +1,134 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createAgentEvent, dispatchAgentEvent, reduceAgentEvents } from './agentEvents.js';
4
+
5
+ test('reduceAgentEvents: run_started clears stale plan', () => {
6
+ const projection = reduceAgentEvents([
7
+ createAgentEvent('activity_upserted', {
8
+ origin: 'tool',
9
+ payload: {
10
+ activity: {
11
+ key: 'production:old',
12
+ id: 'old',
13
+ source: 'production',
14
+ label: 'Old job',
15
+ status: 'running',
16
+ },
17
+ },
18
+ }),
19
+ createAgentEvent('plan_set', {
20
+ origin: 'tool',
21
+ payload: { steps: ['Old action'] },
22
+ }),
23
+ createAgentEvent('run_started', { origin: 'user' }),
24
+ ]);
25
+ assert.equal(projection.plan, null);
26
+ assert.equal(projection.activities.length, 0);
27
+ assert.equal(projection.status, 'running');
28
+ });
29
+
30
+ test('reduceAgentEvents: tracks manual plan and step updates', () => {
31
+ const projection = reduceAgentEvents([
32
+ createAgentEvent('plan_set', {
33
+ origin: 'tool',
34
+ payload: { steps: ['Export CME', 'Build deliverable'] },
35
+ }),
36
+ createAgentEvent('plan_step_updated', {
37
+ origin: 'tool',
38
+ payload: { step: 1, status: 'done' },
39
+ }),
40
+ ]);
41
+ assert.equal(projection.plan.length, 2);
42
+ assert.equal(projection.plan[0].status, 'done');
43
+ assert.equal(projection.plan[1].status, 'pending');
44
+ });
45
+
46
+ test('reduceAgentEvents: activity with plan creates visible plan and progress', () => {
47
+ const projection = reduceAgentEvents([
48
+ createAgentEvent('activity_upserted', {
49
+ origin: 'tool',
50
+ payload: {
51
+ activity: {
52
+ key: 'production:job-1',
53
+ id: 'job-1',
54
+ source: 'production',
55
+ label: 'Pipeline',
56
+ status: 'running',
57
+ plan: { steps: [{ id: 'build', label: 'Build' }, { id: 'polish', label: 'Polish' }] },
58
+ progress: { stepId: 'build' },
59
+ },
60
+ },
61
+ }),
62
+ ]);
63
+ assert.equal(projection.activities.length, 1);
64
+ assert.equal(projection.plan.length, 2);
65
+ assert.equal(projection.plan[0].description, 'Build');
66
+ assert.equal(projection.plan[0].status, 'running');
67
+ assert.equal(projection.plan[1].status, 'pending');
68
+ });
69
+
70
+ test('reduceAgentEvents: real activity replaces minimal MCP plan', () => {
71
+ const projection = reduceAgentEvents([
72
+ createAgentEvent('plan_set', {
73
+ origin: 'tool',
74
+ payload: { steps: [{ description: 'cme.cme_export_run', status: 'running', _activityKey: null }] },
75
+ }),
76
+ createAgentEvent('activity_upserted', {
77
+ origin: 'tool',
78
+ payload: {
79
+ activity: {
80
+ key: 'cme:export-1',
81
+ id: 'export-1',
82
+ source: 'cme',
83
+ label: 'CME export',
84
+ status: 'running',
85
+ },
86
+ },
87
+ }),
88
+ ]);
89
+ assert.equal(projection.plan.length, 1);
90
+ assert.equal(projection.plan[0].description, 'CME export');
91
+ assert.equal(projection.plan[0]._activityKey, 'cme:export-1');
92
+ });
93
+
94
+ test('dispatchAgentEvent: writes compatibility projections to session', () => {
95
+ const session = {};
96
+ dispatchAgentEvent(session, createAgentEvent('plan_set', {
97
+ origin: 'tool',
98
+ payload: { steps: ['Check status'] },
99
+ }));
100
+ assert.equal(session.agentEvents.length, 1);
101
+ assert.equal(session.headlessPlan.length, 1);
102
+ assert.equal(session.headlessPlan[0].description, 'Check status');
103
+ assert.deepEqual(session.activities, {});
104
+ });
105
+
106
+ test('dispatchAgentEvent: assistant deltas do not rewrite session plan or activities', () => {
107
+ let planUpdates = 0;
108
+ const session = { _onPlanUpdate: () => { planUpdates += 1; } };
109
+ dispatchAgentEvent(session, createAgentEvent('activity_upserted', {
110
+ origin: 'tool',
111
+ payload: {
112
+ activity: {
113
+ key: 'cme:export-1',
114
+ id: 'export-1',
115
+ source: 'cme',
116
+ label: 'CME export',
117
+ status: 'running',
118
+ },
119
+ },
120
+ }));
121
+ const activitiesRef = session.activities;
122
+ const planRef = session.headlessPlan;
123
+ const updatesAfterActivity = planUpdates;
124
+
125
+ dispatchAgentEvent(session, createAgentEvent('assistant_delta', {
126
+ origin: 'llm',
127
+ payload: { delta: 'bonjour' },
128
+ }));
129
+
130
+ assert.strictEqual(session.activities, activitiesRef);
131
+ assert.strictEqual(session.headlessPlan, planRef);
132
+ assert.equal(planUpdates, updatesAfterActivity);
133
+ assert.equal(session.agentProjection.conversation.at(-1).content, 'bonjour');
134
+ });
@@ -0,0 +1,310 @@
1
+ import { execFile, spawn } from 'node:child_process';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ import YAML from 'yaml';
6
+ import { managerEnvFile, readEnvFile } from './env.js';
7
+ import { managerRoot } from './workspaces.js';
8
+
9
+ const execFileAsync = promisify(execFile);
10
+
11
+ export const COMPOSE_SERVICES = ['serve', 'mcp-http', 'production-mcp'];
12
+ const SERVICE_DESCRIPTION_LABEL = 'wiki-manager.description';
13
+
14
+ const DEFAULT_SERVICE_ALIASES = {
15
+ all: COMPOSE_SERVICES,
16
+ ui: ['serve'],
17
+ wiki: ['mcp-http'],
18
+ mcp: ['mcp-http'],
19
+ production: ['production-mcp'],
20
+ };
21
+
22
+ function requireWorkspace(session) {
23
+ if (!session.workspace || !session.workspacePath || !session.workspaceEnv?.WORKSPACE_NAME) {
24
+ throw new Error('No workspace loaded. Use /use <workspace>.');
25
+ }
26
+ }
27
+
28
+ function composeFile() {
29
+ return join(managerRoot(), 'docker-compose.yml');
30
+ }
31
+
32
+ function readManagerEnv() {
33
+ const envPath = managerEnvFile();
34
+ return existsSync(envPath) ? readEnvFile(envPath) : {};
35
+ }
36
+
37
+ function readComposeConfig() {
38
+ try {
39
+ return YAML.parse(readFileSync(composeFile(), 'utf8')) ?? {};
40
+ } catch {
41
+ return {};
42
+ }
43
+ }
44
+
45
+ function normalizeLabels(labels) {
46
+ if (!labels) return {};
47
+ if (!Array.isArray(labels)) return labels;
48
+ return Object.fromEntries(
49
+ labels.flatMap((entry) => {
50
+ const text = String(entry ?? '');
51
+ const index = text.indexOf('=');
52
+ return index > 0 ? [[text.slice(0, index), text.slice(index + 1)]] : [];
53
+ }),
54
+ );
55
+ }
56
+
57
+ function composeAliasMetadata() {
58
+ return readComposeConfig()?.['x-wiki-manager']?.['service-aliases'] ?? {};
59
+ }
60
+
61
+ function serviceAliases() {
62
+ const aliases = { ...DEFAULT_SERVICE_ALIASES };
63
+ for (const [name, value] of Object.entries(composeAliasMetadata())) {
64
+ const targets = Array.isArray(value?.targets) ? value.targets.map(String).filter(Boolean) : [];
65
+ if (targets.length > 0) aliases[name] = targets;
66
+ }
67
+ return aliases;
68
+ }
69
+
70
+ function serviceDescriptions() {
71
+ const config = readComposeConfig();
72
+ const descriptions = {};
73
+ for (const [name, service] of Object.entries(config.services ?? {})) {
74
+ const labels = normalizeLabels(service?.labels);
75
+ if (labels[SERVICE_DESCRIPTION_LABEL]) descriptions[name] = String(labels[SERVICE_DESCRIPTION_LABEL]);
76
+ }
77
+ for (const [name, value] of Object.entries(composeAliasMetadata())) {
78
+ if (value?.description) descriptions[name] = String(value.description);
79
+ }
80
+ return descriptions;
81
+ }
82
+
83
+ export function serviceNames() {
84
+ return [...new Set([...COMPOSE_SERVICES, ...Object.keys(serviceAliases())])].sort();
85
+ }
86
+
87
+ export function serviceDescription(name) {
88
+ return serviceDescriptions()[name] ?? null;
89
+ }
90
+
91
+ function projectName(session) {
92
+ return `wiki-${session.workspace}`.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase();
93
+ }
94
+
95
+ function composeBaseArgs(session) {
96
+ const args = ['compose', '-f', composeFile(), '-p', projectName(session)];
97
+ const managerEnvPath = managerEnvFile();
98
+ if (existsSync(managerEnvPath)) {
99
+ args.push('--env-file', managerEnvPath);
100
+ }
101
+ if (session.workspaceEnvFile && existsSync(session.workspaceEnvFile)) {
102
+ args.push('--env-file', session.workspaceEnvFile);
103
+ }
104
+ return args;
105
+ }
106
+
107
+ function composeEnv(session) {
108
+ return {
109
+ ...process.env,
110
+ ...readManagerEnv(),
111
+ ...(session.workspaceEnv ?? {}),
112
+ WORKSPACE_NAME: session.workspace,
113
+ WIKI_WORKSPACE_PATH: session.workspacePath,
114
+ };
115
+ }
116
+
117
+ export async function runCompose(session, args, options = {}) {
118
+ requireWorkspace(session);
119
+ if (typeof options.onOutput === 'function') {
120
+ return runComposeStreaming(session, args, options);
121
+ }
122
+ const { stdout, stderr } = await execFileAsync(
123
+ 'docker',
124
+ [...composeBaseArgs(session), ...args],
125
+ {
126
+ cwd: managerRoot(),
127
+ env: composeEnv(session),
128
+ maxBuffer: options.maxBuffer ?? 1024 * 1024 * 4,
129
+ timeout: options.timeout ?? 120_000,
130
+ },
131
+ );
132
+ return [stdout, stderr].filter(Boolean).join('\n').trim();
133
+ }
134
+
135
+ function emitLines(buffer, chunk, onLine) {
136
+ const text = buffer + chunk;
137
+ const lines = text.split(/\r?\n/);
138
+ const rest = lines.pop() ?? '';
139
+ for (const line of lines) {
140
+ if (line.trim()) onLine(line);
141
+ }
142
+ return rest;
143
+ }
144
+
145
+ async function runComposeStreaming(session, args, options = {}) {
146
+ const composeArgs = [...composeBaseArgs(session), ...args];
147
+ const chunks = [];
148
+ const timeout = options.timeout ?? 120_000;
149
+ const maxBuffer = options.maxBuffer ?? 1024 * 1024 * 4;
150
+ return new Promise((resolve, reject) => {
151
+ const child = spawn('docker', composeArgs, {
152
+ cwd: managerRoot(),
153
+ env: composeEnv(session),
154
+ stdio: ['ignore', 'pipe', 'pipe'],
155
+ });
156
+ let stdoutBuffer = '';
157
+ let stderrBuffer = '';
158
+ let settled = false;
159
+ let outputSize = 0;
160
+ const timer = setTimeout(() => {
161
+ settled = true;
162
+ child.kill('SIGTERM');
163
+ reject(new Error(`Command timed out after ${timeout}ms: docker ${composeArgs.join(' ')}`));
164
+ }, timeout);
165
+
166
+ const collect = (source) => (chunk) => {
167
+ const text = chunk.toString();
168
+ chunks.push(text);
169
+ outputSize += text.length;
170
+ if (outputSize > maxBuffer) {
171
+ if (settled) return;
172
+ settled = true;
173
+ clearTimeout(timer);
174
+ child.kill('SIGTERM');
175
+ reject(new Error(`Command output exceeded maxBuffer: docker ${composeArgs.join(' ')}`));
176
+ return;
177
+ }
178
+ if (source === 'stderr') {
179
+ stderrBuffer = emitLines(stderrBuffer, text, options.onOutput);
180
+ } else {
181
+ stdoutBuffer = emitLines(stdoutBuffer, text, options.onOutput);
182
+ }
183
+ };
184
+
185
+ child.stdout.on('data', collect('stdout'));
186
+ child.stderr.on('data', collect('stderr'));
187
+ child.on('error', (err) => {
188
+ if (settled) return;
189
+ settled = true;
190
+ clearTimeout(timer);
191
+ reject(err);
192
+ });
193
+ child.on('close', (code) => {
194
+ if (settled) return;
195
+ settled = true;
196
+ clearTimeout(timer);
197
+ for (const line of [stdoutBuffer, stderrBuffer]) {
198
+ if (line.trim()) options.onOutput(line);
199
+ }
200
+ const output = chunks.join('').trim();
201
+ if (code === 0) {
202
+ resolve(output);
203
+ } else {
204
+ const err = new Error(`Command failed: docker ${composeArgs.join(' ')}\n${output}`);
205
+ err.code = code;
206
+ reject(err);
207
+ }
208
+ });
209
+ });
210
+ }
211
+
212
+ export async function listServices(session) {
213
+ const services = await composeServices(session);
214
+ const ps = await composePs(session).catch((err) => {
215
+ const message = err instanceof Error ? err.message : String(err);
216
+ return `docker compose ps unavailable: ${message}`;
217
+ });
218
+ return [
219
+ 'Services:',
220
+ services
221
+ .filter(Boolean)
222
+ .map((service) => `- ${service}`)
223
+ .join('\n'),
224
+ '',
225
+ 'Status:',
226
+ ps || 'No running containers.',
227
+ ].join('\n');
228
+ }
229
+
230
+ export async function composeServices(session) {
231
+ const output = await runCompose(session, ['config', '--services'], { timeout: 30_000 });
232
+ return output.split(/\r?\n/).filter(Boolean).sort();
233
+ }
234
+
235
+ export async function composePs(session) {
236
+ return runCompose(session, ['ps'], { timeout: 30_000 });
237
+ }
238
+
239
+ function parseComposePsJson(output) {
240
+ const text = output.trim();
241
+ if (!text) return [];
242
+ try {
243
+ const parsed = JSON.parse(text);
244
+ return Array.isArray(parsed) ? parsed : [parsed];
245
+ } catch {
246
+ return text
247
+ .split(/\r?\n/)
248
+ .filter(Boolean)
249
+ .flatMap((line) => {
250
+ try {
251
+ return [JSON.parse(line)];
252
+ } catch {
253
+ return [];
254
+ }
255
+ });
256
+ }
257
+ }
258
+
259
+ export async function serviceStates(session) {
260
+ const output = await runCompose(session, ['ps', '--format', 'json'], { timeout: 30_000 });
261
+ const entries = parseComposePsJson(output);
262
+ const states = {};
263
+ for (const entry of entries) {
264
+ const service = entry.Service ?? entry.service;
265
+ if (!service) continue;
266
+ const state = String(entry.State ?? entry.state ?? entry.Status ?? entry.status ?? '').toLowerCase();
267
+ states[service] = {
268
+ name: entry.Name ?? entry.name ?? service,
269
+ state,
270
+ running: state.includes('running') || state.includes('up'),
271
+ };
272
+ }
273
+ return states;
274
+ }
275
+
276
+ export async function startService(session, service) {
277
+ const aliases = serviceAliases();
278
+ const targets = service ? (aliases[service] ?? [service]) : COMPOSE_SERVICES;
279
+ const output = await runCompose(session, ['up', '-d', ...targets], { timeout: 180_000 });
280
+ return [`Started: ${targets.join(', ')}`, output].filter(Boolean).join('\n');
281
+ }
282
+
283
+ export async function stopService(session, service) {
284
+ const aliases = serviceAliases();
285
+ const targets = service ? (aliases[service] ?? [service]) : COMPOSE_SERVICES;
286
+ const output = await runCompose(session, ['stop', ...targets], { timeout: 120_000 });
287
+ return [`Stopped: ${targets.join(', ')}`, output].filter(Boolean).join('\n');
288
+ }
289
+
290
+ export async function serviceLogs(session, service, options = {}) {
291
+ if (!service) throw new Error('Usage: /logs <service> [tail]');
292
+ const tail = String(Number.isFinite(options.tail) ? options.tail : 120);
293
+ const output = await runCompose(session, ['logs', '--tail', tail, service], {
294
+ timeout: 60_000,
295
+ maxBuffer: 1024 * 1024 * 8,
296
+ });
297
+ return output || `No logs for ${service}.`;
298
+ }
299
+
300
+ export async function runWikiCli(session, args, options = {}) {
301
+ if (!Array.isArray(args) || args.length === 0) {
302
+ throw new Error('Usage: /wiki run <args...>');
303
+ }
304
+ const configEnv = session.wikirc?.fileName ? ['-e', `WIKI_CONFIG_PATH=${session.wikirc.fileName}`] : [];
305
+ return runCompose(session, ['run', '--rm', ...configEnv, 'wiki', ...args], {
306
+ timeout: options.timeout ?? 180_000,
307
+ maxBuffer: options.maxBuffer ?? 1024 * 1024 * 8,
308
+ onOutput: options.onOutput,
309
+ });
310
+ }