@dotdrelle/wiki-manager 0.9.3 → 0.10.4

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.
@@ -2,6 +2,7 @@ import { buildAgentSystemPrompt, buildLimitedAgentResponse } from '../agent/grap
2
2
  import { createAgentEvent, dispatchAgentEvent } from './agentEvents.js';
3
3
  import { activitySnapshot, newNonTerminalActivities } from './activity.js';
4
4
  import { extractHeadlessPlan, formatCompletedActivities, formatPlanStatus } from './plan.js';
5
+ import { formatReadyTaskPrompt, nextReadyPlanTask, readyPlanTasks } from './planPatch.js';
5
6
 
6
7
  export function abortError(message = 'Agent run cancelled.') {
7
8
  const err = new Error(message);
@@ -20,12 +21,14 @@ export async function runAgentTurn(agent, session, input, {
20
21
  let streamedContent = '';
21
22
  session._onStream = (delta) => { streamedContent += delta; };
22
23
  session._onStreamReset = () => { streamedContent = ''; };
24
+ if (signal) session._abortSignal = signal;
23
25
  let result;
24
26
  try {
25
- result = await agent.invoke({ input, session, messages });
27
+ result = await agent.invoke({ input, session, messages, signal });
26
28
  } finally {
27
29
  delete session._onStream;
28
30
  delete session._onStreamReset;
31
+ if (session._abortSignal === signal) delete session._abortSignal;
29
32
  }
30
33
  if (result.streamedInline) {
31
34
  return streamedContent.trim() || buildLimitedAgentResponse({ input, session }, 'LLM stream ended without content');
@@ -65,6 +68,7 @@ export async function runAgenticLoop(agent, session, initialInput, {
65
68
  onActivitiesCompleted = null,
66
69
  onMaxTurns = null,
67
70
  abortMessage = 'Agent run cancelled.',
71
+ parallelHandoff = false,
68
72
  } = {}) {
69
73
  if (!waitForActivities) throw new Error('runAgenticLoop requires waitForActivities.');
70
74
  const conversationHistory = [];
@@ -108,13 +112,25 @@ export async function runAgenticLoop(agent, session, initialInput, {
108
112
 
109
113
  const newPending = newNonTerminalActivities(snapshot, session);
110
114
  if (newPending.length === 0) {
111
- const pendingSteps = (session.headlessPlan ?? []).filter((step) => step.status === 'pending');
112
- if (pendingSteps.length === 0) {
115
+ // pending_approval is unfinished work too (a request_approval patch op
116
+ // sets it) — it must not be treated as "nothing left to do" just
117
+ // because no step is literally 'pending' anymore.
118
+ const pending = (session.headlessPlan ?? []).filter((step) => step.status === 'pending' || step.status === 'pending_approval');
119
+ const pendingSteps = readyPlanTasks(session.headlessPlan);
120
+ if (pending.length === 0) {
113
121
  onComplete?.();
114
122
  return { ok: true };
115
123
  }
124
+ if (pendingSteps.length === 0) {
125
+ const reason = pending.every((step) => step.status === 'pending_approval') ? 'awaiting_approval' : 'no_ready_plan_task';
126
+ onPendingSteps?.({ pendingSteps: pending, blocked: true });
127
+ return { ok: false, stalled: true, reason };
128
+ }
129
+ if (parallelHandoff && pendingSteps.length > 1) {
130
+ return { ok: true, handoff: true };
131
+ }
116
132
  onPendingSteps?.({ pendingSteps });
117
- currentInput = pendingStepsPrompt(initialInput, session.headlessPlan);
133
+ currentInput = pendingStepsPrompt(initialInput, session.headlessPlan, pendingSteps[0]);
118
134
  continue;
119
135
  }
120
136
 
@@ -132,6 +148,9 @@ export async function runAgenticLoop(agent, session, initialInput, {
132
148
  const completed = waitResult.completed ?? [];
133
149
  const summary = formatCompletedActivities(completed);
134
150
  onActivitiesCompleted?.({ completed, summary });
151
+ if (parallelHandoff && readyPlanTasks(session.headlessPlan).length > 1) {
152
+ return { ok: true, handoff: true };
153
+ }
135
154
  currentInput = completedActivitiesPrompt(initialInput, session.headlessPlan, summary);
136
155
  }
137
156
 
@@ -139,15 +158,18 @@ export async function runAgenticLoop(agent, session, initialInput, {
139
158
  return { ok: false, maxTurns: true };
140
159
  }
141
160
 
142
- function pendingStepsPrompt(initialInput, plan) {
161
+ function pendingStepsPrompt(initialInput, plan, readyTask) {
143
162
  return [
144
163
  'Original task:',
145
164
  initialInput,
146
165
  '',
147
166
  `Plan status:\n${formatPlanStatus(plan)}`,
148
167
  '',
168
+ formatReadyTaskPrompt(readyTask),
169
+ '',
149
170
  'No new background activity was started in the previous turn.',
150
- 'Continue the original plan. Start the next pending step only.',
171
+ 'Continue the original plan. Start exactly this next ready task only.',
172
+ 'Do not start tasks whose dependencies are not done.',
151
173
  'If required information is missing and cannot be inferred, stop with a clear blocker.',
152
174
  ].join('\n');
153
175
  }
@@ -161,7 +183,10 @@ function completedActivitiesPrompt(initialInput, plan, summary) {
161
183
  'Completed activities:',
162
184
  summary,
163
185
  '',
164
- 'Continue the original plan. Start the next required step only.',
186
+ formatReadyTaskPrompt(nextReadyPlanTask(plan)),
187
+ '',
188
+ 'Continue the original plan. Start exactly this next ready task only.',
189
+ 'Do not start tasks whose dependencies are not done.',
165
190
  'If all steps are complete, provide the final summary.',
166
191
  ].filter(Boolean).join('\n');
167
192
  }
package/src/core/mcp.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { managerEnvFile, managerMcpEndpointsFile, readEnvFile } from './env.js';
3
3
 
4
+ const WIKI_MANAGER_VERSION = '0.10.4';
5
+
4
6
  function envValue(key) {
5
7
  const filePath = managerEnvFile();
6
8
  if (existsSync(filePath)) {
@@ -230,7 +232,7 @@ async function mcpRequest(endpoint, method, params, signal, options = {}) {
230
232
  params: {
231
233
  protocolVersion: '2025-06-18',
232
234
  capabilities: {},
233
- clientInfo: { name: 'wiki-manager', version: '0.9.3' },
235
+ clientInfo: { name: 'wiki-manager', version: WIKI_MANAGER_VERSION },
234
236
  },
235
237
  }),
236
238
  });
package/src/core/plan.js CHANGED
@@ -15,6 +15,10 @@ export function ensurePlanFromActivity(session, activity) {
15
15
  id: s.id ?? null,
16
16
  description: s.label,
17
17
  status: 'pending',
18
+ dependsOn: Array.isArray(s.dependsOn) ? s.dependsOn.map(String) : [],
19
+ executor: s.executor ?? null,
20
+ executorQuery: s.executorQuery ?? null,
21
+ outputRefs: Array.isArray(s.outputRefs) ? s.outputRefs.map(String) : [],
18
22
  owner: 'activity',
19
23
  ownerActivityKey: activity.key,
20
24
  _activityKey: activity.key,
@@ -0,0 +1,224 @@
1
+ import { validateContractInDev } from '../contracts/schemas.js';
2
+
3
+ const PATCH_OPS = new Set([
4
+ 'add_task',
5
+ 'add_dependency',
6
+ 'remove_dependency',
7
+ 'cancel_task',
8
+ 'replace_executor',
9
+ 'request_approval',
10
+ ]);
11
+
12
+ export function normalizePlanRevision(value) {
13
+ const revision = Number(value);
14
+ return Number.isFinite(revision) && revision >= 0 ? Math.floor(revision) : 0;
15
+ }
16
+
17
+ export function normalizePlanPatch(raw = {}, { targetRunId = null, basePlanRevision = 0 } = {}) {
18
+ const operations = Array.isArray(raw.operations) ? raw.operations : [];
19
+ return validateContractInDev('planPatch', {
20
+ id: raw.id ?? null,
21
+ targetRunId: raw.targetRunId ?? targetRunId ?? null,
22
+ basePlanRevision: normalizePlanRevision(raw.basePlanRevision ?? basePlanRevision),
23
+ operations: operations.map(normalizePatchOperation).filter(Boolean),
24
+ reason: raw.reason ?? null,
25
+ });
26
+ }
27
+
28
+ export function applyPlanPatch(plan, patch, { currentRevision = 0 } = {}) {
29
+ const basePlanRevision = normalizePlanRevision(patch?.basePlanRevision);
30
+ const revision = normalizePlanRevision(currentRevision);
31
+ if (basePlanRevision !== revision) {
32
+ return {
33
+ ok: false,
34
+ reason: 'revision_mismatch',
35
+ basePlanRevision,
36
+ currentRevision: revision,
37
+ plan,
38
+ };
39
+ }
40
+ const nextPlan = (plan ?? []).map((step, index) => normalizeTask(step, index));
41
+ // Build the id index one task at a time (rather than `new Map(nextPlan.map(...))`)
42
+ // so a collision between a legacy step's positional-number fallback id and
43
+ // another step's explicit string id (e.g. a step with id:"3" alongside a
44
+ // legacy, id-less step at position 3) is rejected instead of silently
45
+ // dropping one of the two tasks from lookup (Map construction keeps the
46
+ // last entry on a duplicate key).
47
+ const tasksById = new Map();
48
+ for (const task of nextPlan) {
49
+ const id = taskId(task);
50
+ if (tasksById.has(id)) {
51
+ return { ok: false, reason: 'duplicate_task_id', plan, conflictingId: id };
52
+ }
53
+ tasksById.set(id, task);
54
+ }
55
+ // Two-phase: apply every add_task first regardless of its position in the
56
+ // operations array, then everything else — so e.g. an add_dependency op
57
+ // can reference a task added later in the same patch without the whole
58
+ // patch failing purely on operation order. Relative order is preserved
59
+ // within each phase.
60
+ const operations = patch?.operations ?? [];
61
+ const orderedOperations = [
62
+ ...operations.filter((operation) => operation.op === 'add_task'),
63
+ ...operations.filter((operation) => operation.op !== 'add_task'),
64
+ ];
65
+ for (const operation of orderedOperations) {
66
+ const result = applyOperation(nextPlan, tasksById, operation);
67
+ if (!result.ok) return { ...result, plan };
68
+ }
69
+ if (hasDependencyCycle(nextPlan)) {
70
+ return { ok: false, reason: 'dependency_cycle', plan };
71
+ }
72
+ resequence(nextPlan);
73
+ return {
74
+ ok: true,
75
+ plan: nextPlan,
76
+ planRevision: revision + 1,
77
+ };
78
+ }
79
+
80
+ export function rebasePlanPatch(patch, { currentRevision = 0 } = {}) {
81
+ return {
82
+ ...patch,
83
+ basePlanRevision: normalizePlanRevision(currentRevision),
84
+ rebasedFromRevision: normalizePlanRevision(patch?.basePlanRevision),
85
+ };
86
+ }
87
+
88
+ export function readyPlanTasks(plan) {
89
+ const tasks = (plan ?? []).map((step, index) => normalizeTask(step, index));
90
+ const done = new Set(tasks.filter((task) => task.status === 'done').map(taskId));
91
+ return tasks
92
+ .filter((task) => task.status === 'pending')
93
+ .filter((task) => task.dependsOn.every((dep) => done.has(String(dep))))
94
+ .sort((a, b) => a.step - b.step);
95
+ }
96
+
97
+ export function nextReadyPlanTask(plan) {
98
+ return readyPlanTasks(plan)[0] ?? null;
99
+ }
100
+
101
+ export function formatReadyTaskPrompt(task) {
102
+ if (!task) return 'No ready task is available.';
103
+ const lines = [
104
+ `Next ready task: ${task.step}. ${task.description}`,
105
+ task.id ? `Task id: ${task.id}` : null,
106
+ task.executor ? `Preferred executor: ${task.executor}` : null,
107
+ task.executorQuery ? `Executor query: ${JSON.stringify(task.executorQuery)}` : null,
108
+ task.dependsOn.length > 0 ? `Dependencies satisfied: ${task.dependsOn.join(', ')}` : null,
109
+ ].filter(Boolean);
110
+ return lines.join('\n');
111
+ }
112
+
113
+ export function normalizeTask(raw, index = 0) {
114
+ const item = raw && typeof raw === 'object' ? raw : { description: String(raw ?? '') };
115
+ return {
116
+ ...item,
117
+ step: Number(item.step ?? index + 1),
118
+ id: item.id != null ? String(item.id) : null,
119
+ description: String(item.description ?? item.label ?? item.name ?? `Step ${index + 1}`),
120
+ status: String(item.status ?? 'pending'),
121
+ dependsOn: Array.isArray(item.dependsOn) ? item.dependsOn.map(String) : [],
122
+ executor: item.executor ?? null,
123
+ executorQuery: item.executorQuery ?? null,
124
+ outputRefs: Array.isArray(item.outputRefs) ? item.outputRefs.map(String) : [],
125
+ };
126
+ }
127
+
128
+ function normalizePatchOperation(raw) {
129
+ if (!raw || typeof raw !== 'object') return null;
130
+ const op = String(raw.op ?? '');
131
+ if (!PATCH_OPS.has(op)) return null;
132
+ return { ...raw, op };
133
+ }
134
+
135
+ function normalizeAddedTask(raw, index) {
136
+ const task = normalizeTask(raw, index);
137
+ return {
138
+ ...task,
139
+ status: task.status === 'done' || task.status === 'running' ? task.status : 'pending',
140
+ owner: task.owner ?? 'orchestrator',
141
+ ownerActivityKey: task.ownerActivityKey ?? null,
142
+ _activityKey: task._activityKey ?? null,
143
+ };
144
+ }
145
+
146
+ function taskId(task) {
147
+ return String(task.id ?? task.step);
148
+ }
149
+
150
+ function targetTask(tasksById, operation) {
151
+ const id = operation.taskId ?? operation.id ?? operation.targetTaskId;
152
+ return id == null ? null : tasksById.get(String(id)) ?? null;
153
+ }
154
+
155
+ function applyOperation(plan, tasksById, operation) {
156
+ if (operation.op === 'add_task') {
157
+ const task = normalizeAddedTask(operation.task, plan.length);
158
+ if (!task.id) return { ok: false, reason: 'missing_task_id', operation };
159
+ if (tasksById.has(task.id)) return { ok: false, reason: 'duplicate_task_id', operation };
160
+ plan.push(task);
161
+ tasksById.set(task.id, task);
162
+ return { ok: true };
163
+ }
164
+ const task = targetTask(tasksById, operation);
165
+ if (!task) return { ok: false, reason: 'task_not_found', operation };
166
+ if (operation.op === 'add_dependency') {
167
+ const dep = String(operation.dependsOn ?? operation.dependency ?? operation.dependencyId ?? '');
168
+ if (!dep) return { ok: false, reason: 'missing_dependency', operation };
169
+ if (!tasksById.has(dep)) return { ok: false, reason: 'dependency_not_found', operation };
170
+ if (!task.dependsOn.includes(dep)) task.dependsOn.push(dep);
171
+ return { ok: true };
172
+ }
173
+ if (operation.op === 'remove_dependency') {
174
+ const dep = String(operation.dependsOn ?? operation.dependency ?? operation.dependencyId ?? '');
175
+ task.dependsOn = task.dependsOn.filter((item) => item !== dep);
176
+ return { ok: true };
177
+ }
178
+ if (operation.op === 'cancel_task') {
179
+ task.status = 'cancelled';
180
+ return { ok: true };
181
+ }
182
+ if (operation.op === 'replace_executor') {
183
+ task.executor = operation.executor ?? null;
184
+ task.executorQuery = operation.executorQuery ?? task.executorQuery ?? null;
185
+ return { ok: true };
186
+ }
187
+ if (operation.op === 'request_approval') {
188
+ task.status = 'pending_approval';
189
+ task.approvalReason = operation.reason ?? 'approval_required';
190
+ return { ok: true };
191
+ }
192
+ return { ok: false, reason: 'unsupported_operation', operation };
193
+ }
194
+
195
+ function resequence(plan) {
196
+ plan.forEach((task, index) => {
197
+ task.step = index + 1;
198
+ });
199
+ }
200
+
201
+ function hasDependencyCycle(plan) {
202
+ const tasks = new Map(plan.map((task) => [taskId(task), task]));
203
+ const visiting = new Set();
204
+ const visited = new Set();
205
+
206
+ function visit(id) {
207
+ if (visited.has(id)) return false;
208
+ if (visiting.has(id)) return true;
209
+ const task = tasks.get(id);
210
+ if (!task) return false;
211
+ visiting.add(id);
212
+ for (const dep of task.dependsOn) {
213
+ if (visit(String(dep))) return true;
214
+ }
215
+ visiting.delete(id);
216
+ visited.add(id);
217
+ return false;
218
+ }
219
+
220
+ for (const id of tasks.keys()) {
221
+ if (visit(id)) return true;
222
+ }
223
+ return false;
224
+ }
@@ -0,0 +1,63 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { applyPlanPatch, nextReadyPlanTask, readyPlanTasks, rebasePlanPatch } from './planPatch.js';
4
+
5
+ test('applyPlanPatch adds a task and increments the plan revision', () => {
6
+ const result = applyPlanPatch([
7
+ { step: 1, id: 'task-a', description: 'A', status: 'done' },
8
+ ], {
9
+ basePlanRevision: 4,
10
+ operations: [{
11
+ op: 'add_task',
12
+ task: {
13
+ id: 'task-b',
14
+ description: 'B',
15
+ dependsOn: ['task-a'],
16
+ executorQuery: { capability: 'build' },
17
+ },
18
+ }],
19
+ }, { currentRevision: 4 });
20
+
21
+ assert.equal(result.ok, true);
22
+ assert.equal(result.planRevision, 5);
23
+ assert.equal(result.plan[1].id, 'task-b');
24
+ assert.deepEqual(result.plan[1].dependsOn, ['task-a']);
25
+ assert.deepEqual(result.plan[1].executorQuery, { capability: 'build' });
26
+ });
27
+
28
+ test('applyPlanPatch rejects stale revisions for explicit rebase', () => {
29
+ const patch = { basePlanRevision: 2, operations: [] };
30
+ const result = applyPlanPatch([], patch, { currentRevision: 3 });
31
+
32
+ assert.equal(result.ok, false);
33
+ assert.equal(result.reason, 'revision_mismatch');
34
+ assert.deepEqual(rebasePlanPatch(patch, { currentRevision: 3 }), {
35
+ basePlanRevision: 3,
36
+ operations: [],
37
+ rebasedFromRevision: 2,
38
+ });
39
+ });
40
+
41
+ test('readyPlanTasks returns pending tasks whose dependencies are done', () => {
42
+ const plan = [
43
+ { step: 1, id: 'a', description: 'A', status: 'done' },
44
+ { step: 2, id: 'b', description: 'B', status: 'pending', dependsOn: ['a'] },
45
+ { step: 3, id: 'c', description: 'C', status: 'pending', dependsOn: ['b'] },
46
+ ];
47
+
48
+ assert.deepEqual(readyPlanTasks(plan).map((task) => task.id), ['b']);
49
+ assert.equal(nextReadyPlanTask(plan).id, 'b');
50
+ });
51
+
52
+ test('applyPlanPatch rejects dependency cycles', () => {
53
+ const result = applyPlanPatch([
54
+ { step: 1, id: 'a', description: 'A', status: 'pending', dependsOn: ['b'] },
55
+ { step: 2, id: 'b', description: 'B', status: 'pending' },
56
+ ], {
57
+ basePlanRevision: 0,
58
+ operations: [{ op: 'add_dependency', taskId: 'b', dependencyId: 'a' }],
59
+ }, { currentRevision: 0 });
60
+
61
+ assert.equal(result.ok, false);
62
+ assert.equal(result.reason, 'dependency_cycle');
63
+ });
@@ -0,0 +1,264 @@
1
+ const TERMINAL_STATUSES = new Set(['done', 'failed', 'cancelled', 'canceled', 'error', 'complete', 'completed', 'success']);
2
+ const RUNNING_STATUSES = new Set(['running', 'starting', 'queued', 'waiting', 'pending_approval']);
3
+
4
+ // Canonical workflow projection for 0.9.6.
5
+ //
6
+ // Decision: projectWorkflow consumes the existing event-sourced agentProjection
7
+ // instead of replacing it in this release. agentProjection remains the
8
+ // compatibility reducer/hydration format; workflow is the canonical read model
9
+ // for Serve and ShellTUI. Future releases can move the reducer internals behind
10
+ // this module without changing UI contracts.
11
+ export function projectWorkflow(state = {}, events = []) {
12
+ const run = currentRun(state, events);
13
+ const plan = Array.isArray(state.plan) ? state.plan : [];
14
+ const activities = Array.isArray(state.activities) ? state.activities : [];
15
+ const queue = Array.isArray(state.queue) ? state.queue : [];
16
+ const approvals = Array.isArray(state.approvals) ? state.approvals : [];
17
+ const replans = Array.isArray(state.replans) ? state.replans : [];
18
+ const evaluation = state.evaluation ?? null;
19
+ const nodes = [];
20
+ const relations = [];
21
+ const warnings = [];
22
+
23
+ if (run) nodes.push(run);
24
+ const planNodes = plan.map((step, index) => taskNode(step, index));
25
+ const activityNodes = activities.map(activityNode);
26
+ const queueNodes = queue.map(queueNode);
27
+ const approvalNodes = approvals.map(approvalNode);
28
+ nodes.push(...planNodes, ...activityNodes, ...queueNodes, ...approvalNodes);
29
+
30
+ for (const node of [...planNodes, ...activityNodes, ...queueNodes, ...approvalNodes]) {
31
+ if (run) relations.push({ type: 'contains', from: run.id, to: node.id });
32
+ }
33
+
34
+ const taskByStepId = new Map(planNodes.map((node) => [node.stepId, node]));
35
+ const taskByActivity = new Map();
36
+ for (const node of planNodes) {
37
+ for (const activityKey of [node.activityKey, node.ownerActivityKey].filter(Boolean)) {
38
+ if (!taskByActivity.has(activityKey)) taskByActivity.set(activityKey, []);
39
+ taskByActivity.get(activityKey).push(node);
40
+ }
41
+ for (const dep of node.dependsOn) {
42
+ const depNode = taskByStepId.get(String(dep)) ?? planNodes.find((candidate) => candidate.step === Number(dep));
43
+ if (depNode) relations.push({ type: 'depends_on', from: node.id, to: depNode.id });
44
+ }
45
+ if (node.executor) {
46
+ const executorId = `executor:${node.executor}`;
47
+ if (!nodes.some((candidate) => candidate.id === executorId)) {
48
+ nodes.push({ id: executorId, type: 'executor', label: node.executor, status: 'available' });
49
+ }
50
+ relations.push({ type: 'executed_by', from: node.id, to: executorId });
51
+ }
52
+ for (const ref of node.outputRefs) {
53
+ const outputId = `output:${String(ref)}`;
54
+ if (!nodes.some((candidate) => candidate.id === outputId)) {
55
+ nodes.push({ id: outputId, type: 'output', label: String(ref), status: 'done' });
56
+ }
57
+ relations.push({ type: 'produces', from: node.id, to: outputId });
58
+ }
59
+ }
60
+
61
+ for (const activity of activityNodes) {
62
+ const targets = taskByActivity.get(activity.key) ?? [];
63
+ if (targets.length > 0) {
64
+ for (const task of targets) relations.push({ type: 'executed_by', from: task.id, to: activity.id });
65
+ } else if (planNodes.length > 0 && isActiveStatus(activity.status)) {
66
+ const currentTask = planNodes.find((node) => node.status === 'running') ?? planNodes.find((node) => node.status === 'pending');
67
+ if (currentTask) relations.push({ type: 'executed_by', from: currentTask.id, to: activity.id });
68
+ }
69
+ }
70
+
71
+ for (const approval of approvalNodes) {
72
+ const target = approval.itemId ? queueNodes.find((node) => node.itemId === approval.itemId) : run;
73
+ if (target) relations.push({ type: 'approves', from: approval.id, to: target.id });
74
+ }
75
+
76
+ for (const [index, replan] of replans.entries()) {
77
+ const replanId = `replan:${replan.runId ?? index}`;
78
+ nodes.push({ id: replanId, type: 'replan', label: replan.reason || 'Replan', status: 'done', replan });
79
+ if (run) relations.push({ type: 'replaces', from: replanId, to: run.id });
80
+ }
81
+
82
+ if (planNodes.length > 0 && !planNodes.some((node) => node.structured)) {
83
+ warnings.push('legacy_sequential_plan');
84
+ }
85
+ if (events.some((event) => event.type === 'plan_set' && event.origin === 'llm')) {
86
+ warnings.push('deprecated_text_plan_extraction');
87
+ }
88
+
89
+ const current = findCurrentNode(nodes);
90
+ const next = findNextTask(planNodes);
91
+ const progress = computeProgress({ planNodes, activityNodes, state });
92
+ const waitingReasons = computeWaitingReasons({ nodes, queueNodes, approvalNodes });
93
+ const summary = buildSummary({ state, run, current, next, progress, evaluation });
94
+
95
+ return {
96
+ summary,
97
+ nodes,
98
+ relations,
99
+ current,
100
+ next,
101
+ progress,
102
+ waitingReasons,
103
+ warnings,
104
+ };
105
+ }
106
+
107
+ function currentRun(state, events) {
108
+ const runId = state.runId ?? state.runs?.find((run) => isActiveStatus(run.status))?.id ?? events.findLast?.((event) => event.runId)?.runId ?? null;
109
+ if (!runId && !state.status) return null;
110
+ return {
111
+ id: runId ? `run:${runId}` : 'run:current',
112
+ type: 'run',
113
+ runId,
114
+ label: state.summary || state.input || 'Runtime run',
115
+ status: normalizeStatus(state.status ?? 'idle'),
116
+ workspace: state.workspace ?? null,
117
+ startedAt: state.startedAt ?? state.runs?.find((run) => run.id === runId)?.createdAt ?? null,
118
+ updatedAt: state.updatedAt ?? state.runs?.find((run) => run.id === runId)?.updatedAt ?? null,
119
+ };
120
+ }
121
+
122
+ function taskNode(step, index) {
123
+ const stepId = String(step.id ?? step.step ?? index + 1);
124
+ const structured = Boolean(step.id || step.dependsOn || step.executor || step.outputRefs);
125
+ return {
126
+ id: `task:${stepId}`,
127
+ type: 'task',
128
+ step: Number(step.step ?? index + 1),
129
+ stepId,
130
+ label: String(step.description ?? step.label ?? step.name ?? `Step ${index + 1}`),
131
+ description: String(step.description ?? step.label ?? step.name ?? `Step ${index + 1}`),
132
+ status: normalizeStatus(step.status ?? 'pending'),
133
+ dependsOn: Array.isArray(step.dependsOn) ? step.dependsOn.map(String) : [],
134
+ executor: step.executor ?? null,
135
+ executorQuery: step.executorQuery ?? null,
136
+ outputRefs: Array.isArray(step.outputRefs) ? step.outputRefs.map(String) : [],
137
+ activityKey: step.activityKey ?? null,
138
+ ownerActivityKey: step.ownerActivityKey ?? null,
139
+ structured,
140
+ raw: { ...step },
141
+ };
142
+ }
143
+
144
+ function activityNode(activity, index) {
145
+ const derivedKey = [activity.source, activity.id ?? activity.jobId ?? activity.label].filter(Boolean).join(':');
146
+ const key = (activity.key ?? derivedKey) || `activity:${index + 1}`;
147
+ return {
148
+ id: `activity:${key}`,
149
+ type: 'activity',
150
+ key,
151
+ label: activity.label ?? activity.tool ?? key,
152
+ status: normalizeStatus(activity.status ?? 'running'),
153
+ source: activity.source ?? null,
154
+ progress: activity.progress ?? null,
155
+ terminal: Boolean(activity.terminal),
156
+ startedAt: activity.startedAt ?? null,
157
+ updatedAt: activity.updatedAt ?? null,
158
+ raw: { ...activity },
159
+ };
160
+ }
161
+
162
+ function queueNode(item) {
163
+ return {
164
+ id: `queue:${item.id ?? item.jobId ?? item.step ?? item.label}`,
165
+ type: 'queue',
166
+ itemId: item.id ?? null,
167
+ label: item.label ?? item.tool ?? item.type ?? item.input ?? 'Queued item',
168
+ status: normalizeStatus(item.status ?? 'queued'),
169
+ workspace: item.workspace ?? null,
170
+ raw: { ...item },
171
+ };
172
+ }
173
+
174
+ function approvalNode(approval) {
175
+ return {
176
+ id: `approval:${approval.id ?? approval.itemId ?? approval.runId}`,
177
+ type: 'approval',
178
+ label: approval.reason ?? approval.tool ?? 'Approval',
179
+ status: normalizeStatus(approval.status ?? 'pending_approval'),
180
+ itemId: approval.itemId ?? null,
181
+ runId: approval.runId ?? null,
182
+ raw: { ...approval },
183
+ };
184
+ }
185
+
186
+ function normalizeStatus(status) {
187
+ const value = String(status ?? 'pending').toLowerCase();
188
+ if (value === 'canceled') return 'cancelled';
189
+ if (value === 'complete' || value === 'completed' || value === 'success') return 'done';
190
+ if (value === 'error') return 'failed';
191
+ if (value === 'pending_approval') return 'pending_approval';
192
+ if (['pending', 'queued', 'running', 'waiting', 'done', 'failed', 'cancelled', 'stalled', 'added_during_run'].includes(value)) return value;
193
+ return value || 'pending';
194
+ }
195
+
196
+ function isActiveStatus(status) {
197
+ return RUNNING_STATUSES.has(normalizeStatus(status));
198
+ }
199
+
200
+ function isTerminalStatus(status) {
201
+ return TERMINAL_STATUSES.has(normalizeStatus(status));
202
+ }
203
+
204
+ function findCurrentNode(nodes) {
205
+ const workflowNodes = nodes.filter((node) => node.type !== 'run');
206
+ return workflowNodes.find((node) => node.status === 'running')
207
+ ?? workflowNodes.find((node) => node.status === 'pending_approval')
208
+ ?? workflowNodes.find((node) => node.status === 'waiting')
209
+ ?? workflowNodes.find((node) => node.status === 'queued')
210
+ ?? nodes.find((node) => node.status === 'running')
211
+ ?? null;
212
+ }
213
+
214
+ function findNextTask(tasks) {
215
+ const doneIds = new Set(tasks.filter((task) => task.status === 'done').map((task) => task.stepId));
216
+ return tasks.find((task) => task.status === 'pending' && task.dependsOn.every((dep) => doneIds.has(String(dep))))
217
+ ?? tasks.find((task) => task.status === 'pending')
218
+ ?? null;
219
+ }
220
+
221
+ function computeProgress({ planNodes, activityNodes, state }) {
222
+ const activityWithPercent = activityNodes.find((activity) => Number.isFinite(Number(activity.progress?.percent)));
223
+ if (activityWithPercent) {
224
+ return {
225
+ mode: 'activity_percent',
226
+ percent: Math.max(0, Math.min(100, Number(activityWithPercent.progress.percent))),
227
+ label: activityWithPercent.progress?.detail ?? activityWithPercent.label,
228
+ updatedAt: activityWithPercent.updatedAt ?? null,
229
+ };
230
+ }
231
+ const activityWithCounts = activityNodes.find((activity) => Number.isFinite(Number(activity.progress?.done)) && Number.isFinite(Number(activity.progress?.total)) && Number(activity.progress.total) > 0);
232
+ if (activityWithCounts) {
233
+ const done = Number(activityWithCounts.progress.done);
234
+ const total = Number(activityWithCounts.progress.total);
235
+ return { mode: 'activity_count', percent: Math.round((done / total) * 100), done, total, label: activityWithCounts.label };
236
+ }
237
+ const phase = activityNodes.find((activity) => activity.progress?.phase || activity.progress?.step)?.progress;
238
+ if (phase) return { mode: 'phase', percent: null, label: phase.phase ?? phase.step ?? phase.detail ?? null };
239
+ if (planNodes.length > 0) {
240
+ const terminal = planNodes.filter((task) => isTerminalStatus(task.status)).length;
241
+ return { mode: 'task_count', percent: Math.round((terminal / planNodes.length) * 100), done: terminal, total: planNodes.length };
242
+ }
243
+ return { mode: 'indeterminate', percent: null, label: state.status ?? null };
244
+ }
245
+
246
+ function computeWaitingReasons({ nodes, queueNodes, approvalNodes }) {
247
+ const reasons = [];
248
+ for (const approval of approvalNodes.filter((node) => node.status === 'pending_approval')) reasons.push(approval.id);
249
+ for (const item of queueNodes.filter((node) => ['queued', 'waiting'].includes(node.status))) reasons.push(item.id);
250
+ const stalled = nodes.filter((node) => node.status === 'stalled');
251
+ for (const item of stalled) reasons.push(`stalled:${item.id}`);
252
+ return reasons;
253
+ }
254
+
255
+ function buildSummary({ state, run, current, next, progress, evaluation }) {
256
+ return {
257
+ status: normalizeStatus(state.status ?? run?.status ?? 'idle'),
258
+ text: state.summary ?? current?.label ?? next?.label ?? null,
259
+ currentId: current?.id ?? null,
260
+ nextId: next?.id ?? null,
261
+ progress,
262
+ evaluation,
263
+ };
264
+ }