@dotdrelle/wiki-manager 0.8.2 → 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.
- package/README.md +36 -6
- package/agents.docker-compose.yml +5 -0
- package/package.json +5 -2
- package/src/agent/graph.js +68 -9
- package/src/agent/graph.test.js +64 -0
- package/src/cli/wiki-manager.js +34 -2
- package/src/commands/slash.js +9 -25
- package/src/contracts/README.md +16 -0
- package/src/contracts/schemas.js +302 -0
- package/src/contracts/schemas.test.js +93 -0
- package/src/core/activity.js +14 -2
- package/src/core/activity.test.js +4 -2
- package/src/core/agentEvents.js +212 -24
- package/src/core/agentEvents.test.js +85 -12
- package/src/core/agentLoop.js +32 -7
- package/src/core/mcp.js +3 -1
- package/src/core/plan.js +4 -0
- package/src/core/planPatch.js +224 -0
- package/src/core/planPatch.test.js +63 -0
- package/src/core/sessionConfig.js +24 -0
- package/src/core/workflow.js +264 -0
- package/src/core/workflow.test.js +66 -0
- package/src/runtime/client.js +28 -1
- package/src/runtime/runner.js +432 -20
- package/src/runtime/runner.test.js +273 -1
- package/src/runtime/server.js +494 -24
- package/src/runtime/server.test.js +661 -0
- package/src/runtime/store.js +59 -7
- package/src/runtime/store.test.js +72 -1
- package/src/shell/RightPane.tsx +1 -7
- package/src/shell/StartupScreen.tsx +212 -0
- package/src/shell/repl.js +51 -7
- package/src/shell/repl.test.js +77 -1
- package/src/shell/textFit.ts +6 -0
- package/src/shell/tui.tsx +163 -0
- package/src/shell/useAgent.ts +17 -9
- package/src/shell/useSession.ts +34 -0
|
@@ -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,24 @@
|
|
|
1
|
+
import { createLlmClientFromWikiConfig } from '../agent/llm.js';
|
|
2
|
+
import { loadWikircProfile, summarizeWikircConfig } from './wikirc.js';
|
|
3
|
+
|
|
4
|
+
export function applySessionWikircProfile(session, profileName = 'default') {
|
|
5
|
+
if (!session.workspacePath) {
|
|
6
|
+
throw new Error('No workspace loaded. Use /use <workspace>.');
|
|
7
|
+
}
|
|
8
|
+
const loaded = loadWikircProfile(session.workspacePath, profileName);
|
|
9
|
+
session.wikirc = {
|
|
10
|
+
profile: loaded.profile.name,
|
|
11
|
+
fileName: loaded.profile.fileName,
|
|
12
|
+
path: loaded.profile.path,
|
|
13
|
+
};
|
|
14
|
+
session.wikircConfig = loaded.config;
|
|
15
|
+
session.language = loaded.config?.language ?? null;
|
|
16
|
+
session.llm = createLlmClientFromWikiConfig(loaded.config);
|
|
17
|
+
if (session.mcp?.production) {
|
|
18
|
+
session.mcp.production.activeConfigPath = loaded.profile.fileName;
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
summary: summarizeWikircConfig(loaded.profile, loaded.config),
|
|
22
|
+
config: loaded.config,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { projectWorkflow } from './workflow.js';
|
|
4
|
+
|
|
5
|
+
test('projectWorkflow links structured plan tasks to activities and executors', () => {
|
|
6
|
+
const workflow = projectWorkflow({
|
|
7
|
+
status: 'running',
|
|
8
|
+
runId: 'run-1',
|
|
9
|
+
workspace: 'docs',
|
|
10
|
+
plan: [
|
|
11
|
+
{ step: 1, id: 'export', description: 'Export CME', status: 'done', executor: 'cme.cme_export_run', outputRefs: ['raw/untracked'] },
|
|
12
|
+
{ step: 2, id: 'build', description: 'Build deliverable', status: 'running', dependsOn: ['export'], executor: 'production.production_start_job', activityKey: 'production:job-1' },
|
|
13
|
+
],
|
|
14
|
+
activities: [{
|
|
15
|
+
key: 'production:job-1',
|
|
16
|
+
id: 'job-1',
|
|
17
|
+
source: 'production',
|
|
18
|
+
label: 'Production build',
|
|
19
|
+
status: 'running',
|
|
20
|
+
progress: { percent: 42, stepId: 'build' },
|
|
21
|
+
}],
|
|
22
|
+
queue: [],
|
|
23
|
+
approvals: [],
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
assert.equal(workflow.summary.status, 'running');
|
|
27
|
+
assert.equal(workflow.current.id, 'task:build');
|
|
28
|
+
assert.equal(workflow.next, null);
|
|
29
|
+
assert.equal(workflow.progress.mode, 'activity_percent');
|
|
30
|
+
assert.equal(workflow.progress.percent, 42);
|
|
31
|
+
assert.ok(workflow.relations.some((relation) => relation.type === 'depends_on' && relation.from === 'task:build' && relation.to === 'task:export'));
|
|
32
|
+
assert.ok(workflow.relations.some((relation) => relation.type === 'executed_by' && relation.from === 'task:build' && relation.to === 'activity:production:job-1'));
|
|
33
|
+
assert.ok(workflow.relations.some((relation) => relation.type === 'produces' && relation.from === 'task:export' && relation.to === 'output:raw/untracked'));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('projectWorkflow keeps old sequential runs readable', () => {
|
|
37
|
+
const workflow = projectWorkflow({
|
|
38
|
+
status: 'done',
|
|
39
|
+
plan: [
|
|
40
|
+
{ step: 1, description: 'Analyze', status: 'done' },
|
|
41
|
+
{ step: 2, description: 'Execute', status: 'done' },
|
|
42
|
+
],
|
|
43
|
+
activities: [],
|
|
44
|
+
queue: [],
|
|
45
|
+
approvals: [],
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
assert.equal(workflow.summary.status, 'done');
|
|
49
|
+
assert.equal(workflow.progress.mode, 'task_count');
|
|
50
|
+
assert.equal(workflow.progress.percent, 100);
|
|
51
|
+
assert.deepEqual(workflow.warnings, ['legacy_sequential_plan']);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('projectWorkflow reports approval and queue waiting reasons', () => {
|
|
55
|
+
const workflow = projectWorkflow({
|
|
56
|
+
status: 'running',
|
|
57
|
+
plan: [{ step: 1, id: 'send', description: 'Send email', status: 'pending_approval' }],
|
|
58
|
+
activities: [],
|
|
59
|
+
queue: [{ id: 'queued-1', status: 'queued', label: 'Future run' }],
|
|
60
|
+
approvals: [{ id: 'approval-1', status: 'pending_approval', reason: 'Confirm email' }],
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
assert.equal(workflow.current.id, 'task:send');
|
|
64
|
+
assert.ok(workflow.waitingReasons.includes('approval:approval-1'));
|
|
65
|
+
assert.ok(workflow.waitingReasons.includes('queue:queued-1'));
|
|
66
|
+
});
|
package/src/runtime/client.js
CHANGED
|
@@ -53,7 +53,34 @@ export async function postRuntimeRun(input, {
|
|
|
53
53
|
},
|
|
54
54
|
body: JSON.stringify(Object.assign({ input, workspace }, evaluate !== undefined && { evaluate }, replans !== undefined && { replans })),
|
|
55
55
|
});
|
|
56
|
-
if (!response.ok)
|
|
56
|
+
if (!response.ok) {
|
|
57
|
+
const err = new Error(`Runtime run failed: HTTP ${response.status}`);
|
|
58
|
+
err.status = response.status;
|
|
59
|
+
throw err;
|
|
60
|
+
}
|
|
61
|
+
return response.json();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function postRuntimeControl(action, {
|
|
65
|
+
url = runtimeUrlFromEnv(),
|
|
66
|
+
token = runtimeToken(),
|
|
67
|
+
workspace = null,
|
|
68
|
+
input = undefined,
|
|
69
|
+
intent = undefined,
|
|
70
|
+
} = {}) {
|
|
71
|
+
const response = await fetch(runtimeEndpoint(url, '/control', workspace), {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: {
|
|
74
|
+
...runtimeHeaders(token),
|
|
75
|
+
'Content-Type': 'application/json',
|
|
76
|
+
},
|
|
77
|
+
body: JSON.stringify(Object.assign({ action }, input !== undefined && { input }, intent !== undefined && { intent })),
|
|
78
|
+
});
|
|
79
|
+
if (!response.ok) {
|
|
80
|
+
const err = new Error(`Runtime control failed: HTTP ${response.status}`);
|
|
81
|
+
err.status = response.status;
|
|
82
|
+
throw err;
|
|
83
|
+
}
|
|
57
84
|
return response.json();
|
|
58
85
|
}
|
|
59
86
|
|