@dotdrelle/wiki-manager 0.11.10 → 0.12.0
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 +32 -7
- package/agents.docker-compose.yml +3 -0
- package/docker-compose.yml +1 -0
- package/package.json +3 -2
- package/src/activity/activityAggregator.js +109 -0
- package/src/activity/activityAggregator.test.js +60 -0
- package/src/activity/activityDeduplicator.js +50 -0
- package/src/activity/progressCalculator.js +61 -0
- package/src/activity/runSynthesis.js +15 -0
- package/src/agent/graph.js +65 -30
- package/src/agent/graph.test.js +28 -5
- package/src/cli/wiki-manager.js +22 -0
- package/src/contracts/schemas.js +249 -13
- package/src/contracts/schemas.test.js +145 -0
- package/src/core/activity.js +8 -5
- package/src/core/agentEvents.js +260 -23
- package/src/core/agentEvents.test.js +53 -0
- package/src/core/compose.js +6 -1
- package/src/core/dockerCompose.test.js +12 -0
- package/src/core/documentIntake.js +33 -38
- package/src/core/documentIntake.test.js +25 -0
- package/src/core/env.js +11 -1
- package/src/core/jobQueue.js +26 -2
- package/src/core/mcp.js +1 -1
- package/src/core/plan.js +46 -4
- package/src/core/plan.test.js +16 -1
- package/src/core/planPatch.js +64 -3
- package/src/core/planPatch.test.js +110 -1
- package/src/core/queueStore.test.js +21 -0
- package/src/core/runtimeLog.js +119 -0
- package/src/core/runtimeLog.test.js +84 -0
- package/src/core/wikirc.js +22 -0
- package/src/core/wikirc.test.js +49 -1
- package/src/core/workflow.js +14 -3
- package/src/graph/graphAggregator.js +5 -0
- package/src/graph/graphPatch.js +8 -0
- package/src/graph/graphSnapshot.js +14 -0
- package/src/graph/graphVisibilityPolicy.js +40 -0
- package/src/graph/runGraphProjector.js +87 -0
- package/src/graph/runGraphProjector.test.js +111 -0
- package/src/orchestrator/agentRegistry.js +172 -0
- package/src/orchestrator/agentRegistry.test.js +114 -0
- package/src/orchestrator/approvalPolicy.js +126 -0
- package/src/orchestrator/approvalPolicy.test.js +78 -0
- package/src/orchestrator/assignmentManager.js +80 -0
- package/src/orchestrator/attemptManager.js +168 -0
- package/src/orchestrator/attemptManager.test.js +160 -0
- package/src/orchestrator/budgetManager.js +127 -0
- package/src/orchestrator/capabilityRegistry.js +64 -0
- package/src/orchestrator/capabilityRegistry.test.js +59 -0
- package/src/orchestrator/capabilityResolver.js +151 -0
- package/src/orchestrator/capabilityResolver.test.js +127 -0
- package/src/orchestrator/dependencyResolver.js +112 -0
- package/src/orchestrator/dispatcher.js +302 -0
- package/src/orchestrator/lockManager.js +51 -0
- package/src/orchestrator/planIntegrator.js +268 -0
- package/src/orchestrator/planIntegrator.test.js +213 -0
- package/src/orchestrator/planValidator.js +534 -0
- package/src/orchestrator/planValidator.test.js +262 -0
- package/src/orchestrator/resultAggregator.js +248 -0
- package/src/orchestrator/resultAggregator.test.js +211 -0
- package/src/orchestrator/scheduler.js +103 -0
- package/src/orchestrator/scheduler.test.js +138 -0
- package/src/runtime/approvals.js +130 -1
- package/src/runtime/donna-contract.test.js +293 -30
- package/src/runtime/recoveryManager.js +176 -0
- package/src/runtime/recoveryManager.test.js +162 -0
- package/src/runtime/runner.e2e.test.js +98 -12
- package/src/runtime/runner.js +261 -227
- package/src/runtime/runner.test.js +249 -452
- package/src/runtime/server.js +137 -20
- package/src/runtime/server.test.js +137 -7
- package/src/runtime/store.js +837 -2
- package/src/runtime/store.test.js +245 -4
- package/src/runtime/supervisor.js +33 -1
- package/src/runtime/supervisor.test.js +47 -1
- package/src/shell/RightPane.tsx +8 -5
- package/src/shell/repl.js +4 -9
- package/src/shell/repl.test.js +25 -4
- package/src/shell/tui.tsx +1 -0
- package/src/shell/useSession.ts +38 -10
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export function createLockManager({ locks = new Set() } = {}) {
|
|
2
|
+
return {
|
|
3
|
+
canAcquire(taskOrLocks) {
|
|
4
|
+
return locksFor(taskOrLocks).every((lock) => !locks.has(lock));
|
|
5
|
+
},
|
|
6
|
+
acquire(taskOrLocks) {
|
|
7
|
+
const lockNames = locksFor(taskOrLocks);
|
|
8
|
+
if (lockNames.some((lock) => locks.has(lock))) return null;
|
|
9
|
+
for (const lock of lockNames) locks.add(lock);
|
|
10
|
+
let released = false;
|
|
11
|
+
return {
|
|
12
|
+
locks: lockNames,
|
|
13
|
+
release() {
|
|
14
|
+
if (released) return;
|
|
15
|
+
released = true;
|
|
16
|
+
for (const lock of lockNames) locks.delete(lock);
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
},
|
|
20
|
+
release(taskOrLocks) {
|
|
21
|
+
for (const lock of locksFor(taskOrLocks)) locks.delete(lock);
|
|
22
|
+
},
|
|
23
|
+
clear() {
|
|
24
|
+
locks.clear();
|
|
25
|
+
},
|
|
26
|
+
snapshot() {
|
|
27
|
+
return [...locks].sort();
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function locksForTask(task) {
|
|
33
|
+
const locks = new Set();
|
|
34
|
+
const explicit = task?.locks ?? task?.writeLocks ?? null;
|
|
35
|
+
if (Array.isArray(explicit)) {
|
|
36
|
+
for (const lock of explicit) if (lock) locks.add(String(lock));
|
|
37
|
+
} else if (explicit && typeof explicit === 'object') {
|
|
38
|
+
if (explicit.workspaceWrite || explicit.workspace) locks.add('workspace:write');
|
|
39
|
+
for (const value of explicit.deliverableWrites ?? explicit.deliverables ?? []) locks.add(`deliverable:${value}`);
|
|
40
|
+
for (const value of explicit.wikiPageWrites ?? explicit.wikiPages ?? []) locks.add(`wiki-page:${value}`);
|
|
41
|
+
}
|
|
42
|
+
for (const value of task?.deliverableWrites ?? []) locks.add(`deliverable:${value}`);
|
|
43
|
+
for (const value of task?.wikiPageWrites ?? []) locks.add(`wiki-page:${value}`);
|
|
44
|
+
if (task?.workspaceWrite) locks.add('workspace:write');
|
|
45
|
+
return [...locks].sort();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function locksFor(taskOrLocks) {
|
|
49
|
+
if (Array.isArray(taskOrLocks)) return [...new Set(taskOrLocks.map(String).filter(Boolean))].sort();
|
|
50
|
+
return locksForTask(taskOrLocks);
|
|
51
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
2
|
+
import { readyPlanTasks } from '../core/planPatch.js';
|
|
3
|
+
import { applyApprovalCoverage } from './approvalPolicy.js';
|
|
4
|
+
import { isValidatedFragment, validateFragment } from './planValidator.js';
|
|
5
|
+
|
|
6
|
+
const TERMINAL_STATUSES = new Set(['done', 'failed', 'cancelled', 'canceled', 'error', 'complete', 'completed', 'success']);
|
|
7
|
+
|
|
8
|
+
export function integrate(runId, fragment, {
|
|
9
|
+
registry,
|
|
10
|
+
budgets = {},
|
|
11
|
+
session = null,
|
|
12
|
+
store = null,
|
|
13
|
+
workspace = null,
|
|
14
|
+
insertBeforeTasks = [],
|
|
15
|
+
insertAfterTasks = [],
|
|
16
|
+
approvals = null,
|
|
17
|
+
enforceApprovalCoverage = false,
|
|
18
|
+
now = () => new Date(),
|
|
19
|
+
} = {}) {
|
|
20
|
+
if (!runId) throw new Error('integrate requires runId.');
|
|
21
|
+
const received = emitPlanEvent('plan.received', { runId, session, store, workspace, now, payload: { runId, fragment } });
|
|
22
|
+
const validation = isValidatedFragment(fragment)
|
|
23
|
+
? { ok: true, errors: [], normalizedFragment: fragment }
|
|
24
|
+
: validateFragment(fragment, { registry, run: { plannerAgentInstanceId: fragment?.agentInstanceId }, budgets });
|
|
25
|
+
|
|
26
|
+
if (!validation.ok) {
|
|
27
|
+
emitPlanEvent('plan.rejected', {
|
|
28
|
+
runId,
|
|
29
|
+
session,
|
|
30
|
+
store,
|
|
31
|
+
workspace,
|
|
32
|
+
now,
|
|
33
|
+
payload: { runId, receivedEventId: received.id, errors: validation.errors },
|
|
34
|
+
});
|
|
35
|
+
return { ok: false, errors: validation.errors, planRevision: currentRevision(session), readyTasks: [] };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const normalizedFragment = validation.normalizedFragment;
|
|
39
|
+
const currentPlan = clonePlan(session?.headlessPlan ?? session?.agentProjection?.plan ?? []);
|
|
40
|
+
const current = new Map(currentPlan.map((task) => [String(task.id ?? task.step), task]));
|
|
41
|
+
const beforeIds = stringifyList(insertBeforeTasks);
|
|
42
|
+
const afterIds = stringifyList(insertAfterTasks);
|
|
43
|
+
const terminalViolation = firstTerminalMutation(current, beforeIds, afterIds);
|
|
44
|
+
if (terminalViolation) {
|
|
45
|
+
const errors = [{ code: 'terminal_task_mutation', message: `Cannot modify terminal task: ${terminalViolation}`, details: { taskId: terminalViolation } }];
|
|
46
|
+
emitPlanEvent('plan.rejected', {
|
|
47
|
+
runId,
|
|
48
|
+
session,
|
|
49
|
+
store,
|
|
50
|
+
workspace,
|
|
51
|
+
now,
|
|
52
|
+
payload: { runId, receivedEventId: received.id, errors },
|
|
53
|
+
});
|
|
54
|
+
return { ok: false, errors, planRevision: currentRevision(session), readyTasks: [] };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const globalized = globalizeFragment(runId, normalizedFragment, current);
|
|
58
|
+
const integrated = mergePlan(currentPlan, globalized.tasks, { insertBeforeTasks: beforeIds, insertAfterTasks: afterIds });
|
|
59
|
+
const planRevision = currentRevision(session) + 1;
|
|
60
|
+
const approvalRequests = applyApprovalCoverage(integrated.plan, {
|
|
61
|
+
runId,
|
|
62
|
+
workspaceId: workspace ?? session?.workspace ?? null,
|
|
63
|
+
planRevision,
|
|
64
|
+
approvals: approvals ?? approvalList(session),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
emitPlanEvent('plan.validated', {
|
|
68
|
+
runId,
|
|
69
|
+
session,
|
|
70
|
+
store,
|
|
71
|
+
workspace,
|
|
72
|
+
now,
|
|
73
|
+
payload: { runId, receivedEventId: received.id, fragment: normalizedFragment },
|
|
74
|
+
});
|
|
75
|
+
for (const group of globalized.groups) {
|
|
76
|
+
emitPlanEvent('task_group.created', {
|
|
77
|
+
runId,
|
|
78
|
+
session,
|
|
79
|
+
store,
|
|
80
|
+
workspace,
|
|
81
|
+
now,
|
|
82
|
+
payload: { runId, group },
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
for (const task of integrated.createdTasks) {
|
|
86
|
+
emitPlanEvent('task.created', {
|
|
87
|
+
runId,
|
|
88
|
+
taskId: task.id,
|
|
89
|
+
session,
|
|
90
|
+
store,
|
|
91
|
+
workspace,
|
|
92
|
+
now,
|
|
93
|
+
payload: { runId, task },
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
emitPlanEvent('plan.revision_changed', {
|
|
97
|
+
runId,
|
|
98
|
+
session,
|
|
99
|
+
store,
|
|
100
|
+
workspace,
|
|
101
|
+
now,
|
|
102
|
+
payload: {
|
|
103
|
+
runId,
|
|
104
|
+
planRevision,
|
|
105
|
+
previousRevision: planRevision - 1,
|
|
106
|
+
taskIds: integrated.plan.map((task) => task.id),
|
|
107
|
+
tasks: integrated.plan,
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
for (const request of approvalRequests) {
|
|
111
|
+
emitPlanEvent('approval.requested', {
|
|
112
|
+
runId,
|
|
113
|
+
taskId: request.taskId,
|
|
114
|
+
session,
|
|
115
|
+
store,
|
|
116
|
+
workspace,
|
|
117
|
+
now,
|
|
118
|
+
payload: request,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const readyTasks = readyPlanTasks(integrated.plan);
|
|
123
|
+
return {
|
|
124
|
+
ok: true,
|
|
125
|
+
errors: [],
|
|
126
|
+
normalizedFragment,
|
|
127
|
+
planRevision,
|
|
128
|
+
plan: integrated.plan,
|
|
129
|
+
createdTasks: integrated.createdTasks,
|
|
130
|
+
readyTasks,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function emitPlanEvent(type, { runId, taskId = null, session, store, workspace, now, payload }) {
|
|
135
|
+
const event = createAgentEvent(type, {
|
|
136
|
+
origin: 'plan_integrator',
|
|
137
|
+
runId,
|
|
138
|
+
taskId,
|
|
139
|
+
workspace,
|
|
140
|
+
payload,
|
|
141
|
+
});
|
|
142
|
+
event.ts = now().toISOString();
|
|
143
|
+
const dispatched = session ? dispatchAgentEvent(session, event) : event;
|
|
144
|
+
store?.persistEvent?.(dispatched);
|
|
145
|
+
return dispatched;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function globalizeFragment(runId, fragment, current) {
|
|
149
|
+
const usedIds = new Set([
|
|
150
|
+
...current.keys(),
|
|
151
|
+
...[...current.values()].map((task) => task.groupId).filter(Boolean),
|
|
152
|
+
]);
|
|
153
|
+
const idMap = new Map();
|
|
154
|
+
const groups = fragment.groups.map((group) => {
|
|
155
|
+
const globalId = uniqueGlobalId(runId, group.id, usedIds);
|
|
156
|
+
idMap.set(group.id, globalId);
|
|
157
|
+
return { ...group, id: globalId, localId: group.id };
|
|
158
|
+
});
|
|
159
|
+
const tasks = fragment.tasks.map((task) => {
|
|
160
|
+
const globalId = uniqueGlobalId(runId, task.id, usedIds);
|
|
161
|
+
idMap.set(task.id, globalId);
|
|
162
|
+
return {
|
|
163
|
+
...task,
|
|
164
|
+
id: globalId,
|
|
165
|
+
localId: task.id,
|
|
166
|
+
description: task.description ?? task.label,
|
|
167
|
+
status: task.status ?? 'pending',
|
|
168
|
+
};
|
|
169
|
+
});
|
|
170
|
+
for (const task of tasks) {
|
|
171
|
+
task.dependsOn = task.dependsOn.map((dep) => idMap.get(dep) ?? dep);
|
|
172
|
+
if (task.groupId != null) task.groupId = idMap.get(task.groupId) ?? task.groupId;
|
|
173
|
+
if (task.dependsOnGroup != null) task.dependsOnGroup = idMap.get(task.dependsOnGroup) ?? task.dependsOnGroup;
|
|
174
|
+
}
|
|
175
|
+
return { groups, tasks };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function mergePlan(currentPlan, newTasks, { insertBeforeTasks, insertAfterTasks }) {
|
|
179
|
+
const plan = clonePlan(currentPlan);
|
|
180
|
+
const createdTasks = clonePlan(newTasks);
|
|
181
|
+
const newTaskIds = new Set(createdTasks.map((task) => task.id));
|
|
182
|
+
const roots = createdTasks.filter((task) => task.dependsOn.length === 0).map((task) => task.id);
|
|
183
|
+
const leaves = createdTasks
|
|
184
|
+
.filter((task) => !createdTasks.some((candidate) => candidate.dependsOn.includes(task.id)))
|
|
185
|
+
.map((task) => task.id);
|
|
186
|
+
|
|
187
|
+
if (insertBeforeTasks.length > 0) {
|
|
188
|
+
const beforeSet = new Set(insertBeforeTasks);
|
|
189
|
+
for (const target of plan.filter((task) => beforeSet.has(task.id))) {
|
|
190
|
+
const previousDeps = [...(target.dependsOn ?? [])];
|
|
191
|
+
for (const task of createdTasks) {
|
|
192
|
+
if (roots.includes(task.id)) task.dependsOn = uniqueStrings([...task.dependsOn, ...previousDeps]);
|
|
193
|
+
}
|
|
194
|
+
target.dependsOn = uniqueStrings([...leaves]);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (insertAfterTasks.length > 0) {
|
|
199
|
+
const afterSet = new Set(insertAfterTasks);
|
|
200
|
+
for (const task of createdTasks) {
|
|
201
|
+
if (roots.includes(task.id)) task.dependsOn = uniqueStrings([...task.dependsOn, ...afterSet]);
|
|
202
|
+
}
|
|
203
|
+
for (const existing of plan) {
|
|
204
|
+
if (!Array.isArray(existing.dependsOn)) existing.dependsOn = [];
|
|
205
|
+
const touched = existing.dependsOn.some((dep) => afterSet.has(dep));
|
|
206
|
+
if (!touched || newTaskIds.has(existing.id)) continue;
|
|
207
|
+
existing.dependsOn = uniqueStrings(existing.dependsOn.flatMap((dep) => (afterSet.has(dep) ? leaves : [dep])));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const next = [...plan, ...createdTasks].map((task, index) => ({
|
|
212
|
+
...task,
|
|
213
|
+
step: index + 1,
|
|
214
|
+
dependsOn: uniqueStrings(task.dependsOn ?? []),
|
|
215
|
+
}));
|
|
216
|
+
return { plan: next, createdTasks };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function firstTerminalMutation(current, beforeIds, afterIds) {
|
|
220
|
+
for (const id of beforeIds) {
|
|
221
|
+
if (isTerminal(current.get(id))) return id;
|
|
222
|
+
}
|
|
223
|
+
for (const id of afterIds) {
|
|
224
|
+
const dependents = [...current.values()].filter((task) => (task.dependsOn ?? []).includes(id));
|
|
225
|
+
const terminal = dependents.find(isTerminal);
|
|
226
|
+
if (terminal) return terminal.id;
|
|
227
|
+
}
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function approvalList(session) {
|
|
232
|
+
const projectionApprovals = session?.agentProjection?.approvals;
|
|
233
|
+
if (Array.isArray(projectionApprovals) && projectionApprovals.length > 0) return projectionApprovals;
|
|
234
|
+
return Array.isArray(session?.approvals) ? session.approvals : [];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function uniqueGlobalId(runId, localId, usedIds) {
|
|
238
|
+
const base = `${runId}:${localId}`;
|
|
239
|
+
if (!usedIds.has(base)) {
|
|
240
|
+
usedIds.add(base);
|
|
241
|
+
return base;
|
|
242
|
+
}
|
|
243
|
+
let suffix = 2;
|
|
244
|
+
while (usedIds.has(`${base}-${suffix}`)) suffix += 1;
|
|
245
|
+
const id = `${base}-${suffix}`;
|
|
246
|
+
usedIds.add(id);
|
|
247
|
+
return id;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function currentRevision(session) {
|
|
251
|
+
return Number.isInteger(session?.planRevision) && session.planRevision >= 0 ? session.planRevision : 0;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function isTerminal(task) {
|
|
255
|
+
return TERMINAL_STATUSES.has(String(task?.status ?? '').toLowerCase());
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function stringifyList(value) {
|
|
259
|
+
return Array.isArray(value) ? value.map(String).filter(Boolean) : [];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function uniqueStrings(values) {
|
|
263
|
+
return [...new Set(values.map(String).filter(Boolean))];
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function clonePlan(plan) {
|
|
267
|
+
return Array.isArray(plan) ? plan.map((task) => ({ ...task, dependsOn: [...(task.dependsOn ?? [])] })) : [];
|
|
268
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtempSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
7
|
+
import { openRuntimeStore } from '../runtime/store.js';
|
|
8
|
+
import { integrate } from './planIntegrator.js';
|
|
9
|
+
|
|
10
|
+
function registry() {
|
|
11
|
+
return {
|
|
12
|
+
providersFor(capability) {
|
|
13
|
+
if (capability !== 'knowledge.update') return [];
|
|
14
|
+
return [{
|
|
15
|
+
agentInstanceId: 'production-main',
|
|
16
|
+
health: 'available',
|
|
17
|
+
capability: {
|
|
18
|
+
id: 'knowledge.update',
|
|
19
|
+
version: '1',
|
|
20
|
+
description: 'Knowledge update',
|
|
21
|
+
inputSchema: {
|
|
22
|
+
type: 'object',
|
|
23
|
+
required: ['inputs'],
|
|
24
|
+
additionalProperties: true,
|
|
25
|
+
properties: {
|
|
26
|
+
inputs: { type: 'array', items: { type: 'string' } },
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
outputSchema: {},
|
|
30
|
+
supportedOperations: ['ingest'],
|
|
31
|
+
mutationClass: 'workspace',
|
|
32
|
+
defaultRequiresApproval: true,
|
|
33
|
+
},
|
|
34
|
+
description: { contractVersion: '1' },
|
|
35
|
+
}];
|
|
36
|
+
},
|
|
37
|
+
isCompatible(contractVersion) {
|
|
38
|
+
return String(contractVersion) === '1';
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function fragment(tasks = [task('ingest-a')]) {
|
|
44
|
+
return {
|
|
45
|
+
contractVersion: '1',
|
|
46
|
+
agentInstanceId: 'production-main',
|
|
47
|
+
capability: 'knowledge.update',
|
|
48
|
+
summary: {
|
|
49
|
+
label: 'Update knowledge',
|
|
50
|
+
initialSynthesis: ['Ingest sources.'],
|
|
51
|
+
estimatedTasks: tasks.length,
|
|
52
|
+
},
|
|
53
|
+
groups: [{
|
|
54
|
+
id: 'ingest',
|
|
55
|
+
label: 'Ingest sources',
|
|
56
|
+
recommendedConcurrency: 2,
|
|
57
|
+
progressWeight: tasks.length,
|
|
58
|
+
}],
|
|
59
|
+
tasks,
|
|
60
|
+
expectedOutputs: [{ type: 'directory', ref: 'wiki' }],
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function task(id, dependsOn = []) {
|
|
65
|
+
return {
|
|
66
|
+
id,
|
|
67
|
+
label: `Task ${id}`,
|
|
68
|
+
requiredCapability: 'knowledge.update',
|
|
69
|
+
operation: 'ingest',
|
|
70
|
+
arguments: { inputs: [`raw/untracked/${id}.md`] },
|
|
71
|
+
groupId: 'ingest',
|
|
72
|
+
dependsOn,
|
|
73
|
+
parallelizable: true,
|
|
74
|
+
inputRefs: [{ type: 'file', ref: `raw/untracked/${id}.md` }],
|
|
75
|
+
expectedOutputRefs: [{ type: 'file', ref: `.wiki/ingest-plans/${id}.json` }],
|
|
76
|
+
locks: ['workspace-write'],
|
|
77
|
+
requiresApproval: true,
|
|
78
|
+
idempotencyKey: `idem-${id}`,
|
|
79
|
+
progressWeight: 1,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function storeAndSession() {
|
|
84
|
+
const store = openRuntimeStore({ stateDir: mkdtempSync(join(tmpdir(), 'wiki-manager-plan-integrator-')) });
|
|
85
|
+
const session = { activities: {}, headlessPlan: null };
|
|
86
|
+
return { store, session };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
test('planIntegrator integrates an initial fragment, persists DAG tables and emits events', () => {
|
|
90
|
+
const { store, session } = storeAndSession();
|
|
91
|
+
const result = integrate('run-1', fragment(), {
|
|
92
|
+
registry: registry(),
|
|
93
|
+
session,
|
|
94
|
+
store,
|
|
95
|
+
workspace: 'docs',
|
|
96
|
+
now: () => new Date('2026-07-07T10:00:00.000Z'),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
assert.equal(result.ok, true);
|
|
100
|
+
assert.equal(result.planRevision, 1);
|
|
101
|
+
assert.deepEqual(session.headlessPlan.map((item) => item.id), ['run-1:ingest-a']);
|
|
102
|
+
assert.deepEqual(result.readyTasks.map((item) => item.id), []);
|
|
103
|
+
assert.deepEqual(session.agentEvents.map((event) => event.type), [
|
|
104
|
+
'plan.received',
|
|
105
|
+
'plan.validated',
|
|
106
|
+
'task_group.created',
|
|
107
|
+
'task.created',
|
|
108
|
+
'plan.revision_changed',
|
|
109
|
+
'approval.requested',
|
|
110
|
+
]);
|
|
111
|
+
assert.equal(store.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'tasks'").get().name, 'tasks');
|
|
112
|
+
assert.equal(store.listTasks({ runId: 'run-1' })[0].id, 'run-1:ingest-a');
|
|
113
|
+
assert.equal(store.listTasks({ runId: 'run-1' })[0].status, 'waiting_approval');
|
|
114
|
+
assert.equal(store.listApprovalGrants({ runId: 'run-1' })[0].status, 'pending_approval');
|
|
115
|
+
assert.equal(store.listTaskDependencies({ runId: 'run-1' }).length, 0);
|
|
116
|
+
assert.equal(store.listPlanRevisions({ runId: 'run-1' })[0].revision, 1);
|
|
117
|
+
store.close();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('planIntegrator inserts before a task and rewires its dependencies', () => {
|
|
121
|
+
const { store, session } = storeAndSession();
|
|
122
|
+
integrate('run-2', fragment([task('a'), task('b', ['a'])]), { registry: registry(), session, store, workspace: 'docs' });
|
|
123
|
+
|
|
124
|
+
const result = integrate('run-2', fragment([task('x')]), {
|
|
125
|
+
registry: registry(),
|
|
126
|
+
session,
|
|
127
|
+
store,
|
|
128
|
+
workspace: 'docs',
|
|
129
|
+
insertBeforeTasks: ['run-2:b'],
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
assert.equal(result.ok, true);
|
|
133
|
+
const plan = Object.fromEntries(session.headlessPlan.map((item) => [item.id, item]));
|
|
134
|
+
assert.deepEqual(plan['run-2:x'].dependsOn, ['run-2:a']);
|
|
135
|
+
assert.deepEqual(plan['run-2:b'].dependsOn, ['run-2:x']);
|
|
136
|
+
assert.ok(store.listTaskDependencies({ runId: 'run-2' }).some((dep) => dep.taskId === 'run-2:b' && dep.dependsOnTaskId === 'run-2:x'));
|
|
137
|
+
assert.equal(session.planRevision, 2);
|
|
138
|
+
store.close();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('planIntegrator inserts after a task and rewires existing dependents', () => {
|
|
142
|
+
const { store, session } = storeAndSession();
|
|
143
|
+
integrate('run-3', fragment([task('a'), task('b', ['a'])]), { registry: registry(), session, store, workspace: 'docs' });
|
|
144
|
+
|
|
145
|
+
const result = integrate('run-3', fragment([task('x')]), {
|
|
146
|
+
registry: registry(),
|
|
147
|
+
session,
|
|
148
|
+
store,
|
|
149
|
+
workspace: 'docs',
|
|
150
|
+
insertAfterTasks: ['run-3:a'],
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
assert.equal(result.ok, true);
|
|
154
|
+
const plan = Object.fromEntries(session.headlessPlan.map((item) => [item.id, item]));
|
|
155
|
+
assert.deepEqual(plan['run-3:x'].dependsOn, ['run-3:a']);
|
|
156
|
+
assert.deepEqual(plan['run-3:b'].dependsOn, ['run-3:x']);
|
|
157
|
+
store.close();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('planIntegrator resolves global id collisions', () => {
|
|
161
|
+
const { store, session } = storeAndSession();
|
|
162
|
+
integrate('run-4', fragment([task('a')]), { registry: registry(), session, store, workspace: 'docs' });
|
|
163
|
+
const result = integrate('run-4', fragment([task('a')]), { registry: registry(), session, store, workspace: 'docs' });
|
|
164
|
+
|
|
165
|
+
assert.equal(result.ok, true);
|
|
166
|
+
assert.deepEqual(session.headlessPlan.map((item) => item.id), ['run-4:a', 'run-4:a-2']);
|
|
167
|
+
assert.deepEqual(store.listTasks({ runId: 'run-4' }).map((item) => item.id), ['run-4:a', 'run-4:a-2']);
|
|
168
|
+
store.close();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('planIntegrator rejects insertions that would modify a terminal task', () => {
|
|
172
|
+
const { store, session } = storeAndSession();
|
|
173
|
+
integrate('run-5', fragment([task('a')]), { registry: registry(), session, store, workspace: 'docs' });
|
|
174
|
+
dispatchAgentEvent(session, createAgentEvent('plan_step_updated', {
|
|
175
|
+
origin: 'test',
|
|
176
|
+
runId: 'run-5',
|
|
177
|
+
workspace: 'docs',
|
|
178
|
+
taskId: 'run-5:a',
|
|
179
|
+
payload: { taskId: 'run-5:a', status: 'done' },
|
|
180
|
+
}));
|
|
181
|
+
|
|
182
|
+
const result = integrate('run-5', fragment([task('x')]), {
|
|
183
|
+
registry: registry(),
|
|
184
|
+
session,
|
|
185
|
+
store,
|
|
186
|
+
workspace: 'docs',
|
|
187
|
+
insertBeforeTasks: ['run-5:a'],
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
assert.equal(result.ok, false);
|
|
191
|
+
assert.equal(result.errors[0].code, 'terminal_task_mutation');
|
|
192
|
+
assert.equal(session.headlessPlan.length, 1);
|
|
193
|
+
assert.equal(session.agentEvents.at(-1).type, 'plan.rejected');
|
|
194
|
+
store.close();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('planIntegrator replay reconstructs the same DAG projection', () => {
|
|
198
|
+
const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-plan-integrator-'));
|
|
199
|
+
const store = openRuntimeStore({ stateDir });
|
|
200
|
+
const session = { activities: {}, headlessPlan: null };
|
|
201
|
+
integrate('run-6', fragment([task('a'), task('b', ['a'])]), { registry: registry(), session, store, workspace: 'docs' });
|
|
202
|
+
const originalPlan = session.headlessPlan.map(({ id, dependsOn, status }) => ({ id, dependsOn, status }));
|
|
203
|
+
store.close();
|
|
204
|
+
|
|
205
|
+
const reopened = openRuntimeStore({ stateDir });
|
|
206
|
+
const replayed = { activities: {}, headlessPlan: null };
|
|
207
|
+
reopened.hydrateSession(replayed, { workspace: 'docs' });
|
|
208
|
+
assert.deepEqual(
|
|
209
|
+
replayed.headlessPlan.map(({ id, dependsOn, status }) => ({ id, dependsOn, status })),
|
|
210
|
+
originalPlan,
|
|
211
|
+
);
|
|
212
|
+
reopened.close();
|
|
213
|
+
});
|