@dotdrelle/wiki-manager 0.7.3 → 0.8.2

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,270 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
4
+ import { finishRuntimeRun, runRuntimeAgenticWorkflow } from './runner.js';
5
+
6
+ test('finishRuntimeRun emits evaluation before run_done', async () => {
7
+ const events = [];
8
+ const session = {
9
+ activities: {},
10
+ headlessPlan: [
11
+ { step: 1, description: 'Analyze', status: 'done' },
12
+ { step: 2, description: 'Execute', status: 'done' },
13
+ ],
14
+ agentProjection: {
15
+ conversation: [{ role: 'assistant', content: 'Done.' }],
16
+ },
17
+ llm: {
18
+ async completeWithTools({ system, tools, messages }) {
19
+ assert.match(system, /strict evaluator/);
20
+ assert.deepEqual(tools, []);
21
+ assert.match(messages[0].content, /Original task:/);
22
+ return { content: '{"ok":true,"reason":"Task complete.","suggestedAction":null}' };
23
+ },
24
+ },
25
+ _onAgentEvent: (event) => events.push(event),
26
+ };
27
+
28
+ const result = await finishRuntimeRun(session, 'Build workspace', { runId: 'run-1' });
29
+
30
+ assert.equal(result.ok, true);
31
+ assert.deepEqual(events.map((event) => event.type), ['runtime_log', 'run_evaluated', 'run_done']);
32
+ assert.equal(session.agentProjection.evaluation.ok, true);
33
+ assert.equal(session.agentProjection.status, 'done');
34
+ });
35
+
36
+ test('finishRuntimeRun turns negative evaluation into run_error', async () => {
37
+ const events = [];
38
+ const session = {
39
+ activities: {},
40
+ headlessPlan: [{ step: 1, description: 'Export', status: 'done' }],
41
+ agentProjection: {
42
+ conversation: [{ role: 'assistant', content: 'Done.' }],
43
+ },
44
+ llm: {
45
+ async completeWithTools() {
46
+ return { content: '{"ok":false,"reason":"Export file missing.","suggestedAction":"Run export again."}' };
47
+ },
48
+ },
49
+ _onAgentEvent: (event) => events.push(event),
50
+ };
51
+
52
+ const result = await finishRuntimeRun(session, 'Export deliverable', { runId: 'run-2' });
53
+
54
+ assert.equal(result.ok, false);
55
+ assert.equal(result.evaluationRejected, true);
56
+ assert.deepEqual(events.map((event) => event.type), ['runtime_log', 'run_evaluated', 'run_error']);
57
+ assert.equal(session.agentProjection.evaluation.ok, false);
58
+ assert.equal(session.agentProjection.status, 'error');
59
+ assert.match(session.agentProjection.logs.at(-1), /Export file missing/);
60
+ });
61
+
62
+ test('finishRuntimeRun falls back open when evaluator response is invalid', async () => {
63
+ const session = {
64
+ activities: {},
65
+ headlessPlan: null,
66
+ agentProjection: { conversation: [] },
67
+ llm: {
68
+ async completeWithTools() {
69
+ return { content: 'not json' };
70
+ },
71
+ },
72
+ };
73
+
74
+ const result = await finishRuntimeRun(session, 'Do work', { runId: 'run-3' });
75
+
76
+ assert.equal(result.ok, true);
77
+ assert.equal(session.agentProjection.evaluation.ok, true);
78
+ assert.match(session.agentProjection.evaluation.reason, /Evaluator unavailable/);
79
+ assert.equal(session.agentProjection.status, 'done');
80
+ });
81
+
82
+ test('finishRuntimeRun can skip evaluation', async () => {
83
+ let called = false;
84
+ const session = {
85
+ activities: {},
86
+ headlessPlan: null,
87
+ agentProjection: { conversation: [] },
88
+ llm: {
89
+ async completeWithTools() {
90
+ called = true;
91
+ return { content: '{"ok":true,"reason":"ok"}' };
92
+ },
93
+ },
94
+ };
95
+
96
+ const result = await finishRuntimeRun(session, 'Do work', { runId: 'run-4', evaluate: false });
97
+
98
+ assert.equal(result.ok, true);
99
+ assert.equal(result.evaluation, null);
100
+ assert.equal(called, false);
101
+ assert.equal(session.agentProjection.evaluation, null);
102
+ assert.equal(session.agentProjection.status, 'done');
103
+ });
104
+
105
+ test('runRuntimeAgenticWorkflow replans after negative evaluation', async () => {
106
+ const events = [];
107
+ const llmCalls = [];
108
+ const session = {
109
+ activities: {},
110
+ headlessPlan: null,
111
+ llm: {
112
+ async completeWithTools({ system }) {
113
+ llmCalls.push(system);
114
+ if (/strict evaluator/.test(system) && llmCalls.length === 1) {
115
+ return { content: '{"ok":false,"reason":"Export missing.","suggestedAction":"Run export."}' };
116
+ }
117
+ if (/replanner/.test(system)) {
118
+ return { content: '{"steps":["Run export"]}' };
119
+ }
120
+ return { content: '{"ok":true,"reason":"Export complete.","suggestedAction":null}' };
121
+ },
122
+ },
123
+ _onAgentEvent: (event) => events.push(event),
124
+ };
125
+ let turns = 0;
126
+ const agent = {
127
+ async invoke({ session: turnSession }) {
128
+ turns += 1;
129
+ if (turnSession.headlessPlan?.[0]?.status === 'pending') {
130
+ dispatchAgentEvent(turnSession, createAgentEvent('plan_step_updated', {
131
+ origin: 'tool',
132
+ payload: { step: 1, status: 'done' },
133
+ }));
134
+ }
135
+ return { response: turns === 1 ? 'Initial done.' : 'Export done.' };
136
+ },
137
+ };
138
+
139
+ const result = await runRuntimeAgenticWorkflow(agent, session, 'Export deliverable', {
140
+ runId: 'run-replan-eval',
141
+ timeoutMs: 1000,
142
+ maxTurns: 2,
143
+ maxReplans: 1,
144
+ });
145
+
146
+ assert.equal(result.ok, true);
147
+ assert.equal(turns, 2);
148
+ assert.ok(events.some((event) => event.type === 'run_replanned'));
149
+ assert.deepEqual(session.agentProjection.replans[0].plan, ['Run export']);
150
+ assert.equal(session.agentProjection.status, 'done');
151
+ });
152
+
153
+ test('runRuntimeAgenticWorkflow replans after terminal activity error', async () => {
154
+ const originalFetch = globalThis.fetch;
155
+ let pollAttempts = 0;
156
+ globalThis.fetch = async () => {
157
+ pollAttempts += 1;
158
+ return {
159
+ ok: true,
160
+ status: 200,
161
+ headers: { get: () => null },
162
+ text: async () => JSON.stringify({
163
+ result: {
164
+ content: [{ type: 'text', text: JSON.stringify({
165
+ _activity: {
166
+ id: 'job-failed',
167
+ source: 'production',
168
+ label: 'Production build',
169
+ status: 'error',
170
+ terminal: true,
171
+ error: 'build failed',
172
+ },
173
+ }) }],
174
+ },
175
+ }),
176
+ };
177
+ };
178
+ const session = {
179
+ mcp: {
180
+ production: {
181
+ status: 'connected',
182
+ url: 'http://127.0.0.1:3000/mcp/',
183
+ retry: { maxAttempts: 1, backoffMs: 0 },
184
+ },
185
+ },
186
+ activities: {},
187
+ headlessPlan: null,
188
+ llm: {
189
+ async completeWithTools({ system }) {
190
+ if (/replanner/.test(system)) return { content: '{"steps":["Retry build"]}' };
191
+ return { content: '{"ok":true,"reason":"Build complete.","suggestedAction":null}' };
192
+ },
193
+ },
194
+ };
195
+ let turns = 0;
196
+ const agent = {
197
+ async invoke({ session: turnSession }) {
198
+ turns += 1;
199
+ if (turns === 1) {
200
+ dispatchAgentEvent(turnSession, createAgentEvent('activity_upserted', {
201
+ payload: {
202
+ activity: {
203
+ id: 'job-failed',
204
+ source: 'production',
205
+ label: 'Production build',
206
+ status: 'running',
207
+ terminal: false,
208
+ poll: { server: 'production', tool: 'production_job_status', args: { jobId: 'job-failed' }, intervalMs: 0 },
209
+ },
210
+ },
211
+ }));
212
+ return { response: 'Started build.' };
213
+ }
214
+ if (turnSession.headlessPlan?.[0]?.status === 'pending') {
215
+ dispatchAgentEvent(turnSession, createAgentEvent('plan_step_updated', {
216
+ origin: 'tool',
217
+ payload: { step: 1, status: 'done' },
218
+ }));
219
+ }
220
+ return { response: 'Retry done.' };
221
+ },
222
+ };
223
+
224
+ try {
225
+ const result = await runRuntimeAgenticWorkflow(agent, session, 'Build workspace', {
226
+ runId: 'run-replan-activity',
227
+ timeoutMs: 1000,
228
+ maxTurns: 3,
229
+ maxReplans: 1,
230
+ });
231
+
232
+ assert.equal(result.ok, true);
233
+ assert.equal(pollAttempts, 1);
234
+ assert.equal(turns, 2);
235
+ assert.equal(session.agentProjection.replans[0].reason, 'Production build ended with error: build failed');
236
+ assert.deepEqual(session.agentProjection.replans[0].plan, ['Retry build']);
237
+ assert.equal(session.agentProjection.status, 'done');
238
+ } finally {
239
+ globalThis.fetch = originalFetch;
240
+ }
241
+ });
242
+
243
+ test('runRuntimeAgenticWorkflow stops after replan budget is exhausted', async () => {
244
+ const session = {
245
+ activities: {},
246
+ headlessPlan: null,
247
+ llm: {
248
+ async completeWithTools() {
249
+ return { content: '{"ok":false,"reason":"Still missing.","suggestedAction":"Try again."}' };
250
+ },
251
+ },
252
+ };
253
+ const agent = {
254
+ async invoke() {
255
+ return { response: 'Done.' };
256
+ },
257
+ };
258
+
259
+ const result = await runRuntimeAgenticWorkflow(agent, session, 'Do task', {
260
+ runId: 'run-replan-limit',
261
+ timeoutMs: 1000,
262
+ maxTurns: 1,
263
+ maxReplans: 0,
264
+ });
265
+
266
+ assert.equal(result.ok, false);
267
+ assert.equal(result.evaluationRejected, true);
268
+ assert.equal(session.agentProjection.status, 'error');
269
+ assert.equal(session.agentProjection.replans.length, 0);
270
+ });
@@ -1,4 +1,5 @@
1
1
  import { createServer } from 'node:http';
2
+ import { randomUUID } from 'node:crypto';
2
3
  import { runtimeTokenFromEnv } from './auth.js';
3
4
 
4
5
  export function startRuntimeServer({
@@ -6,18 +7,22 @@ export function startRuntimeServer({
6
7
  port = 7788,
7
8
  token = runtimeTokenFromEnv(),
8
9
  store,
9
- session,
10
+ session = null,
11
+ getContext,
10
12
  run,
11
13
  cancel,
14
+ resume,
15
+ approve,
12
16
  } = {}) {
13
17
  const clients = new Set();
14
- let running = false;
15
- let currentAbortController = null;
18
+ const defaultContext = { workspace: null, session, running: false, currentAbortController: null };
19
+ const resolvedGetContext = getContext ?? (() => defaultContext);
16
20
 
17
21
  function publish(event) {
18
22
  const payload = `event: agent_event\ndata: ${JSON.stringify(event)}\n\n`;
19
- for (const response of clients) {
20
- response.write(payload);
23
+ for (const client of clients) {
24
+ if (client.workspace && event.workspace !== client.workspace) continue;
25
+ client.response.write(payload);
21
26
  }
22
27
  }
23
28
 
@@ -30,69 +35,106 @@ export function startRuntimeServer({
30
35
 
31
36
  const url = new URL(request.url ?? '/', `http://${request.headers.host ?? 'localhost'}`);
32
37
  if (request.method === 'GET' && url.pathname === '/health') {
33
- sendJson(response, 200, { ok: true, status: running ? 'running' : 'idle', dbPath: store.dbPath });
38
+ const workspace = workspaceFromUrl(url);
39
+ const context = workspace ? await resolveContext({ workspace }) : null;
40
+ sendJson(response, 200, {
41
+ ok: true,
42
+ status: context?.running ? 'running' : 'idle',
43
+ workspace: context?.workspace ?? workspace ?? null,
44
+ dbPath: store.dbPath,
45
+ });
34
46
  return;
35
47
  }
36
48
  if (request.method === 'GET' && url.pathname === '/state') {
37
- sendJson(response, 200, store.getState(session));
49
+ const workspace = workspaceFromUrl(url);
50
+ const context = workspace ? await resolveContext({ workspace }) : null;
51
+ sendJson(response, 200, store.getState(context?.session ?? session, { workspace }));
38
52
  return;
39
53
  }
40
54
  if (request.method === 'GET' && url.pathname === '/events') {
41
- sendJson(response, 200, { events: store.listEvents() });
55
+ const workspace = workspaceFromUrl(url);
56
+ sendJson(response, 200, { events: store.listEvents({ workspace }) });
42
57
  return;
43
58
  }
44
59
  if (request.method === 'GET' && url.pathname === '/events/stream') {
60
+ const workspace = workspaceFromUrl(url);
61
+ const context = workspace ? await resolveContext({ workspace }) : null;
45
62
  response.writeHead(200, {
46
63
  'Content-Type': 'text/event-stream',
47
64
  'Cache-Control': 'no-cache',
48
65
  Connection: 'keep-alive',
49
66
  'X-Accel-Buffering': 'no',
50
67
  });
51
- response.write(`event: state\ndata: ${JSON.stringify(store.getState(session))}\n\n`);
52
- clients.add(response);
53
- request.on('close', () => clients.delete(response));
68
+ response.write(`event: state\ndata: ${JSON.stringify(store.getState(context?.session ?? session, { workspace }))}\n\n`);
69
+ const client = { response, workspace };
70
+ clients.add(client);
71
+ request.on('close', () => clients.delete(client));
54
72
  return;
55
73
  }
56
74
  if (request.method === 'POST' && url.pathname === '/run') {
57
- if (running) {
75
+ const body = await readJson(request);
76
+ const workspace = workspaceFromBody(body) ?? workspaceFromUrl(url);
77
+ const context = await resolveContext({ workspace });
78
+ if (context.running) {
58
79
  sendJson(response, 409, { error: 'A runtime run is already active.' });
59
80
  return;
60
81
  }
61
- running = true;
62
- currentAbortController = new AbortController();
63
82
  try {
64
- const body = await readJson(request);
65
83
  const input = String(body.input ?? body.prompt ?? '').trim();
66
84
  if (!input) {
67
- running = false;
68
- currentAbortController = null;
69
85
  sendJson(response, 400, { error: 'Missing input.' });
70
86
  return;
71
87
  }
72
- run(body, { signal: currentAbortController.signal })
88
+ const runId = randomUUID();
89
+ const runWorkspace = context.workspace ?? workspace ?? null;
90
+ context.running = true;
91
+ context.currentAbortController = new AbortController();
92
+ const runBody = { ...body, workspace: runWorkspace, runId };
93
+ const runPromise = run(context, runBody, { signal: context.currentAbortController.signal, runId });
94
+ runPromise
73
95
  .catch((err) => {
74
- session._onRuntimeError?.(err);
96
+ context.session?._onRuntimeError?.(err);
75
97
  })
76
98
  .finally(() => {
77
- running = false;
78
- currentAbortController = null;
99
+ context.running = false;
100
+ context.currentAbortController = null;
79
101
  });
80
- sendJson(response, 202, { accepted: true });
102
+ sendJson(response, 202, { accepted: true, runId, workspace: runWorkspace });
81
103
  } catch (err) {
82
- running = false;
83
- currentAbortController = null;
104
+ context.running = false;
105
+ context.currentAbortController = null;
84
106
  throw err;
85
107
  }
86
108
  return;
87
109
  }
88
110
  if (request.method === 'POST' && url.pathname === '/cancel') {
89
- if (!running || !currentAbortController) {
111
+ const workspace = workspaceFromUrl(url);
112
+ const context = await resolveContext({ workspace });
113
+ if (!context.running || !context.currentAbortController) {
90
114
  sendJson(response, 200, { cancelled: false, reason: 'no active run' });
91
115
  return;
92
116
  }
93
- currentAbortController.abort();
94
- await cancel?.();
95
- sendJson(response, 202, { cancelled: true });
117
+ context.currentAbortController.abort();
118
+ await cancel?.(context);
119
+ sendJson(response, 202, { cancelled: true, workspace: context.workspace ?? workspace ?? null });
120
+ return;
121
+ }
122
+ if (request.method === 'POST' && url.pathname === '/resume') {
123
+ const workspace = workspaceFromUrl(url);
124
+ const result = await resume?.({ workspace });
125
+ sendJson(response, 202, result ?? { resumed: false, workspace: workspace ?? null });
126
+ return;
127
+ }
128
+ if (request.method === 'POST' && url.pathname === '/approve') {
129
+ const body = await readJson(request);
130
+ const workspace = workspaceFromBody(body) ?? workspaceFromUrl(url);
131
+ const result = await approve?.({
132
+ workspace,
133
+ runId: url.searchParams.get('runId') ?? body.runId ?? null,
134
+ itemId: url.searchParams.get('itemId') ?? body.itemId ?? null,
135
+ approvalId: url.searchParams.get('approvalId') ?? body.approvalId ?? null,
136
+ });
137
+ sendJson(response, result?.approved ? 202 : 404, result ?? { approved: false });
96
138
  return;
97
139
  }
98
140
 
@@ -112,13 +154,27 @@ export function startRuntimeServer({
112
154
  port: typeof address === 'object' && address ? address.port : port,
113
155
  publish,
114
156
  close: () => new Promise((closeResolve, closeReject) => {
115
- for (const response of clients) response.end();
157
+ for (const client of clients) client.response.end();
116
158
  clients.clear();
117
159
  server.close((err) => (err ? closeReject(err) : closeResolve()));
118
160
  }),
119
161
  });
120
162
  });
121
163
  });
164
+
165
+ async function resolveContext({ workspace = null } = {}) {
166
+ return resolvedGetContext(workspace);
167
+ }
168
+ }
169
+
170
+ function workspaceFromUrl(url) {
171
+ const workspace = url.searchParams.get('workspace');
172
+ return workspace ? workspace.trim() || null : null;
173
+ }
174
+
175
+ function workspaceFromBody(body) {
176
+ const workspace = body?.workspace;
177
+ return workspace == null ? null : String(workspace).trim() || null;
122
178
  }
123
179
 
124
180
  function isAuthorized(request, token) {