@dotdrelle/wiki-manager 0.11.9 → 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
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { copyFile, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
4
|
-
import { basename, extname,
|
|
4
|
+
import { basename, extname, join, resolve } from 'node:path';
|
|
5
5
|
import { callMcpTool, formatMcpToolResult } from './mcp.js';
|
|
6
6
|
import { parseJsonText } from './activity.js';
|
|
7
|
-
import {
|
|
7
|
+
import { resolveAgentsDataDir } from './env.js';
|
|
8
8
|
|
|
9
9
|
const SUPPORTED_EXTENSIONS = new Set([
|
|
10
10
|
'.txt', '.md', '.markdown', '.csv', '.json', '.xml', '.yaml', '.yml', '.html', '.htm', '.rtf',
|
|
@@ -13,17 +13,12 @@ const SUPPORTED_EXTENSIONS = new Set([
|
|
|
13
13
|
'.pdf',
|
|
14
14
|
]);
|
|
15
15
|
|
|
16
|
-
function
|
|
17
|
-
|
|
18
|
-
return isAbsolute(configured) ? configured : resolve(userManagerDir(), configured);
|
|
16
|
+
function documentInputRoot(session = null) {
|
|
17
|
+
return resolve(resolveAgentsDataDir(session), 'documents', 'input');
|
|
19
18
|
}
|
|
20
19
|
|
|
21
|
-
function
|
|
22
|
-
return resolve(
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function uploadsRoot() {
|
|
26
|
-
return resolve(agentsDataDir(), 'documents', 'uploads');
|
|
20
|
+
function uploadsRoot(session = null) {
|
|
21
|
+
return resolve(resolveAgentsDataDir(session), 'documents', 'uploads');
|
|
27
22
|
}
|
|
28
23
|
|
|
29
24
|
function requireWorkspace(session) {
|
|
@@ -70,12 +65,12 @@ function assertSupportedFile(filename, size) {
|
|
|
70
65
|
}
|
|
71
66
|
}
|
|
72
67
|
|
|
73
|
-
function manifestPath(workspace) {
|
|
74
|
-
return join(uploadsRoot(), `${workspace}.jsonl`);
|
|
68
|
+
function manifestPath(workspace, session = null) {
|
|
69
|
+
return join(uploadsRoot(session), `${workspace}.jsonl`);
|
|
75
70
|
}
|
|
76
71
|
|
|
77
|
-
async function readManifest(workspace) {
|
|
78
|
-
const file = manifestPath(workspace);
|
|
72
|
+
async function readManifest(workspace, session = null) {
|
|
73
|
+
const file = manifestPath(workspace, session);
|
|
79
74
|
if (!existsSync(file)) return [];
|
|
80
75
|
const raw = await readFile(file, 'utf8');
|
|
81
76
|
return raw
|
|
@@ -90,26 +85,26 @@ async function readManifest(workspace) {
|
|
|
90
85
|
});
|
|
91
86
|
}
|
|
92
87
|
|
|
93
|
-
async function writeManifest(workspace, records) {
|
|
94
|
-
const file = manifestPath(workspace);
|
|
95
|
-
await mkdir(uploadsRoot(), { recursive: true });
|
|
88
|
+
async function writeManifest(workspace, records, session = null) {
|
|
89
|
+
const file = manifestPath(workspace, session);
|
|
90
|
+
await mkdir(uploadsRoot(session), { recursive: true });
|
|
96
91
|
const body = records.map((record) => JSON.stringify(record)).join('\n');
|
|
97
92
|
const tmp = `${file}.tmp.${process.pid}`;
|
|
98
93
|
await writeFile(tmp, body ? `${body}\n` : '', 'utf8');
|
|
99
94
|
await rename(tmp, file);
|
|
100
95
|
}
|
|
101
96
|
|
|
102
|
-
async function upsertUpload(record) {
|
|
103
|
-
const records = await readManifest(record.workspace);
|
|
97
|
+
async function upsertUpload(record, session = null) {
|
|
98
|
+
const records = await readManifest(record.workspace, session);
|
|
104
99
|
const index = records.findIndex((item) => item.id === record.id);
|
|
105
100
|
if (index === -1) records.unshift(record);
|
|
106
101
|
else records[index] = { ...records[index], ...record };
|
|
107
|
-
await writeManifest(record.workspace, records);
|
|
102
|
+
await writeManifest(record.workspace, records, session);
|
|
108
103
|
return record;
|
|
109
104
|
}
|
|
110
105
|
|
|
111
|
-
async function removeExistingUploadsForFilename(workspace, filename) {
|
|
112
|
-
const records = await readManifest(workspace);
|
|
106
|
+
async function removeExistingUploadsForFilename(workspace, filename, session = null) {
|
|
107
|
+
const records = await readManifest(workspace, session);
|
|
113
108
|
const removed = records.filter((item) => item.filename === filename);
|
|
114
109
|
if (removed.length === 0) return [];
|
|
115
110
|
|
|
@@ -119,7 +114,7 @@ async function removeExistingUploadsForFilename(workspace, filename) {
|
|
|
119
114
|
if (file) await rm(file, { force: true }).catch(() => {});
|
|
120
115
|
}
|
|
121
116
|
}
|
|
122
|
-
await writeManifest(workspace, keep);
|
|
117
|
+
await writeManifest(workspace, keep, session);
|
|
123
118
|
return removed;
|
|
124
119
|
}
|
|
125
120
|
|
|
@@ -159,11 +154,11 @@ export async function storeDocumentUpload(session, sourcePath) {
|
|
|
159
154
|
const filename = sanitizeFilename(absolutePath);
|
|
160
155
|
assertSupportedFile(filename, info.size);
|
|
161
156
|
|
|
162
|
-
await removeExistingUploadsForFilename(workspace, filename);
|
|
157
|
+
await removeExistingUploadsForFilename(workspace, filename, session);
|
|
163
158
|
|
|
164
159
|
const id = randomUUID().slice(0, 8);
|
|
165
160
|
const storedFilename = `${id}-${filename}`;
|
|
166
|
-
const workspaceInput = join(documentInputRoot(), workspace);
|
|
161
|
+
const workspaceInput = join(documentInputRoot(session), workspace);
|
|
167
162
|
await mkdir(workspaceInput, { recursive: true });
|
|
168
163
|
const storedPath = join(workspaceInput, storedFilename);
|
|
169
164
|
await copyFile(absolutePath, storedPath);
|
|
@@ -184,13 +179,13 @@ export async function storeDocumentUpload(session, sourcePath) {
|
|
|
184
179
|
createdAt: now,
|
|
185
180
|
updatedAt: now,
|
|
186
181
|
};
|
|
187
|
-
await upsertUpload(record);
|
|
182
|
+
await upsertUpload(record, session);
|
|
188
183
|
return record;
|
|
189
184
|
}
|
|
190
185
|
|
|
191
186
|
export async function convertStoredDocument(session, id, options = {}) {
|
|
192
187
|
const workspace = requireWorkspace(session);
|
|
193
|
-
const records = await readManifest(workspace);
|
|
188
|
+
const records = await readManifest(workspace, session);
|
|
194
189
|
const record = records.find((item) => item.id === id);
|
|
195
190
|
if (!record) throw new Error(`Unknown upload id: ${id}`);
|
|
196
191
|
if (!existsSync(record.storedPath)) throw new Error(`Stored file is missing: ${record.storedPath}`);
|
|
@@ -199,7 +194,7 @@ export async function convertStoredDocument(session, id, options = {}) {
|
|
|
199
194
|
record.provider = null;
|
|
200
195
|
record.error = 'documents MCP is not connected';
|
|
201
196
|
record.updatedAt = new Date().toISOString();
|
|
202
|
-
await upsertUpload(record);
|
|
197
|
+
await upsertUpload(record, session);
|
|
203
198
|
return { record, converted: false };
|
|
204
199
|
}
|
|
205
200
|
|
|
@@ -207,7 +202,7 @@ export async function convertStoredDocument(session, id, options = {}) {
|
|
|
207
202
|
record.provider = 'documents';
|
|
208
203
|
record.error = null;
|
|
209
204
|
record.updatedAt = new Date().toISOString();
|
|
210
|
-
await upsertUpload(record);
|
|
205
|
+
await upsertUpload(record, session);
|
|
211
206
|
|
|
212
207
|
try {
|
|
213
208
|
const toolArgs = {
|
|
@@ -223,7 +218,7 @@ export async function convertStoredDocument(session, id, options = {}) {
|
|
|
223
218
|
if (activity && !activity.terminal) {
|
|
224
219
|
record.jobId = payload.jobId ?? null;
|
|
225
220
|
record.updatedAt = new Date().toISOString();
|
|
226
|
-
await upsertUpload(record);
|
|
221
|
+
await upsertUpload(record, session);
|
|
227
222
|
return { record, converted: false, activity };
|
|
228
223
|
}
|
|
229
224
|
|
|
@@ -232,13 +227,13 @@ export async function convertStoredDocument(session, id, options = {}) {
|
|
|
232
227
|
record.method = payload.method ?? null;
|
|
233
228
|
record.error = null;
|
|
234
229
|
record.updatedAt = new Date().toISOString();
|
|
235
|
-
await upsertUpload(record);
|
|
230
|
+
await upsertUpload(record, session);
|
|
236
231
|
return { record, converted: true, payload, activity };
|
|
237
232
|
} catch (err) {
|
|
238
233
|
record.status = 'failed';
|
|
239
234
|
record.error = err instanceof Error ? err.message : String(err);
|
|
240
235
|
record.updatedAt = new Date().toISOString();
|
|
241
|
-
await upsertUpload(record);
|
|
236
|
+
await upsertUpload(record, session);
|
|
242
237
|
return { record, converted: false };
|
|
243
238
|
}
|
|
244
239
|
}
|
|
@@ -247,7 +242,7 @@ export async function storeAndMaybeConvertDocument(session, sourcePath, options
|
|
|
247
242
|
const record = await storeDocumentUpload(session, sourcePath);
|
|
248
243
|
if (!documentsConverter(session)) {
|
|
249
244
|
record.error = 'documents MCP is not connected';
|
|
250
|
-
await upsertUpload(record);
|
|
245
|
+
await upsertUpload(record, session);
|
|
251
246
|
return { record, converted: false };
|
|
252
247
|
}
|
|
253
248
|
return convertStoredDocument(session, record.id, options);
|
|
@@ -255,12 +250,12 @@ export async function storeAndMaybeConvertDocument(session, sourcePath, options
|
|
|
255
250
|
|
|
256
251
|
export async function listDocumentUploads(session) {
|
|
257
252
|
const workspace = requireWorkspace(session);
|
|
258
|
-
return (await readManifest(workspace)).map(publicRecord);
|
|
253
|
+
return (await readManifest(workspace, session)).map(publicRecord);
|
|
259
254
|
}
|
|
260
255
|
|
|
261
256
|
export async function convertPendingDocumentUploads(session, options = {}) {
|
|
262
257
|
const workspace = requireWorkspace(session);
|
|
263
|
-
const records = await readManifest(workspace);
|
|
258
|
+
const records = await readManifest(workspace, session);
|
|
264
259
|
const pending = records.filter((record) => ['stored', 'failed'].includes(record.status));
|
|
265
260
|
const results = [];
|
|
266
261
|
for (const record of pending) {
|
|
@@ -281,7 +276,7 @@ function parseAgeMs(value = '30d') {
|
|
|
281
276
|
export async function cleanDocumentUploads(session, olderThan = '30d') {
|
|
282
277
|
const workspace = requireWorkspace(session);
|
|
283
278
|
const cutoff = Date.now() - parseAgeMs(olderThan);
|
|
284
|
-
const records = await readManifest(workspace);
|
|
279
|
+
const records = await readManifest(workspace, session);
|
|
285
280
|
const keep = [];
|
|
286
281
|
const removed = [];
|
|
287
282
|
for (const record of records) {
|
|
@@ -295,7 +290,7 @@ export async function cleanDocumentUploads(session, olderThan = '30d') {
|
|
|
295
290
|
keep.push(record);
|
|
296
291
|
}
|
|
297
292
|
}
|
|
298
|
-
if (removed.length > 0) await writeManifest(workspace, keep);
|
|
293
|
+
if (removed.length > 0) await writeManifest(workspace, keep, session);
|
|
299
294
|
return { removed, kept: keep };
|
|
300
295
|
}
|
|
301
296
|
|
|
@@ -38,6 +38,31 @@ test('document intake stores uploads without requiring documents MCP', async ()
|
|
|
38
38
|
}
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
+
test('document intake falls back to workspace root agents data, not cwd', async () => {
|
|
42
|
+
const originalAgentsDataDir = process.env.AGENTS_DATA_DIR;
|
|
43
|
+
const root = await mkdtemp(path.join(os.tmpdir(), 'wiki-manager-doc-intake-root-'));
|
|
44
|
+
const workspacesRoot = path.join(root, 'workspaces');
|
|
45
|
+
const workspacePath = path.join(workspacesRoot, 'my-project');
|
|
46
|
+
await mkdir(workspacePath, { recursive: true });
|
|
47
|
+
const source = path.join(root, 'rapport.pdf');
|
|
48
|
+
await writeFile(source, 'fake pdf content');
|
|
49
|
+
const session = {
|
|
50
|
+
workspace: 'my-project',
|
|
51
|
+
workspacePath,
|
|
52
|
+
mcp: {},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
delete process.env.AGENTS_DATA_DIR;
|
|
57
|
+
const { record } = await storeAndMaybeConvertDocument(session, source);
|
|
58
|
+
assert.equal(record.storedPath.startsWith(path.join(workspacesRoot, '.agents-data')), true);
|
|
59
|
+
assert.equal(await readFile(record.storedPath, 'utf8'), 'fake pdf content');
|
|
60
|
+
} finally {
|
|
61
|
+
if (originalAgentsDataDir === undefined) delete process.env.AGENTS_DATA_DIR;
|
|
62
|
+
else process.env.AGENTS_DATA_DIR = originalAgentsDataDir;
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
41
66
|
test('document intake accepts quoted absolute paths with spaces', async () => {
|
|
42
67
|
const originalAgentsDataDir = process.env.AGENTS_DATA_DIR;
|
|
43
68
|
const root = await mkdtemp(path.join(os.tmpdir(), 'wiki-manager-doc-intake-'));
|
package/src/core/env.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
-
import { dirname, join, resolve } from 'node:path';
|
|
2
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
3
3
|
|
|
4
4
|
export function userManagerDir() {
|
|
5
5
|
return process.cwd();
|
|
@@ -31,6 +31,16 @@ export function managerMcpEndpointsFile() {
|
|
|
31
31
|
return join(managerStateDir(), 'mcp.endpoints.json');
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// Single source of truth for where `.agents-data` lives, shared by the
|
|
35
|
+
// manager's own document intake and by the host path it mounts into agent
|
|
36
|
+
// containers — keeping them in sync avoids the two silently drifting apart.
|
|
37
|
+
export function resolveAgentsDataDir(session = null) {
|
|
38
|
+
const configured = process.env.AGENTS_DATA_DIR;
|
|
39
|
+
if (configured) return isAbsolute(configured) ? configured : resolve(defaultRuntimeStateDir(), configured);
|
|
40
|
+
if (session?.workspacePath) return resolve(dirname(session.workspacePath), '.agents-data');
|
|
41
|
+
return resolve(defaultRuntimeStateDir(), 'agents-data');
|
|
42
|
+
}
|
|
43
|
+
|
|
34
44
|
function parseEnvValue(value) {
|
|
35
45
|
if (value.startsWith('"') && value.endsWith('"')) {
|
|
36
46
|
return value
|
package/src/core/jobQueue.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
2
3
|
import { extractActivity, parseJsonText, sessionActivities } from './activity.js';
|
|
3
4
|
import { createAgentEvent, dispatchAgentEvent } from './agentEvents.js';
|
|
4
5
|
import { callMcpTool, formatMcpToolResult } from './mcp.js';
|
|
@@ -34,13 +35,18 @@ export function productionLockBusy(session) {
|
|
|
34
35
|
|
|
35
36
|
export function enqueueProductionJob(session, args = {}, reason = 'waiting') {
|
|
36
37
|
const workspace = session.workspace ?? null;
|
|
38
|
+
const workspacePath = session.workspacePath ? resolve(session.workspacePath) : null;
|
|
39
|
+
if (!workspace || !workspacePath) {
|
|
40
|
+
throw new Error('Cannot enqueue production job: no active workspace path. Use /use <workspace> first.');
|
|
41
|
+
}
|
|
42
|
+
const normalizedArgs = normalizeProductionJobArgsForWorkspace(args, workspacePath);
|
|
37
43
|
const item = {
|
|
38
44
|
id: shortId(),
|
|
39
45
|
workspace,
|
|
40
46
|
server: 'production',
|
|
41
47
|
tool: 'production_start_job',
|
|
42
|
-
args:
|
|
43
|
-
lockKey:
|
|
48
|
+
args: normalizedArgs,
|
|
49
|
+
lockKey: `production:${workspace}`,
|
|
44
50
|
status: 'waiting',
|
|
45
51
|
reason,
|
|
46
52
|
createdAt: now(),
|
|
@@ -50,6 +56,24 @@ export function enqueueProductionJob(session, args = {}, reason = 'waiting') {
|
|
|
50
56
|
return item;
|
|
51
57
|
}
|
|
52
58
|
|
|
59
|
+
function normalizeProductionJobArgsForWorkspace(args, workspacePath) {
|
|
60
|
+
const normalized = { ...args };
|
|
61
|
+
if (!Array.isArray(normalized.inputs)) return normalized;
|
|
62
|
+
normalized.inputs = normalized.inputs.map((input) => normalizeWorkspaceInput(input, workspacePath));
|
|
63
|
+
return normalized;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function normalizeWorkspaceInput(input, workspacePath) {
|
|
67
|
+
const raw = String(input ?? '').trim();
|
|
68
|
+
if (!raw) return raw;
|
|
69
|
+
const absolute = isAbsolute(raw) ? resolve(raw) : resolve(workspacePath, raw);
|
|
70
|
+
const rel = relative(workspacePath, absolute);
|
|
71
|
+
if (rel.startsWith('..') || isAbsolute(rel)) {
|
|
72
|
+
throw new Error(`Cannot enqueue production job: input is outside the active workspace: ${raw}`);
|
|
73
|
+
}
|
|
74
|
+
return rel.split(sep).join('/');
|
|
75
|
+
}
|
|
76
|
+
|
|
53
77
|
export function queueSummary(args = {}) {
|
|
54
78
|
const parts = [
|
|
55
79
|
args.type,
|
package/src/core/mcp.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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.
|
|
4
|
+
const WIKI_MANAGER_VERSION = '0.12.0';
|
|
5
5
|
|
|
6
6
|
function envValue(key) {
|
|
7
7
|
const filePath = managerEnvFile();
|
package/src/core/plan.js
CHANGED
|
@@ -54,17 +54,24 @@ export function matchCompletedToPlan(plan, completed) {
|
|
|
54
54
|
|
|
55
55
|
export function syncActivitiesToPlan(plan, activities) {
|
|
56
56
|
if (!plan) return;
|
|
57
|
+
const contractTaskPlan = isContractTaskPlan(plan);
|
|
57
58
|
for (const activity of activities ?? []) {
|
|
58
59
|
const terminal = Boolean(activity.terminal);
|
|
59
60
|
const failed = ['failed', 'error', 'cancelled', 'canceled'].includes(String(activity.status).toLowerCase());
|
|
60
61
|
const actKey = activity.key ?? activity.id ?? activity.jobId ?? null;
|
|
61
|
-
const
|
|
62
|
+
const structuredMatch = findMatchingPlanStepByStructure(plan, activity);
|
|
63
|
+
const matched = structuredMatch ?? findMatchingPlanStep(plan, activity);
|
|
62
64
|
if (!matched) continue;
|
|
63
65
|
|
|
64
66
|
if (terminal && !failed) {
|
|
67
|
+
const ownedSteps = actKey ? plan.filter((s) => s._activityKey === actKey) : [];
|
|
68
|
+
if (contractTaskPlan && structuredMatch && ownedSteps.length === 0) {
|
|
69
|
+
matched.status = 'done';
|
|
70
|
+
matched.activityKey = actKey;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
65
73
|
// Structured plan: mark all steps owned by this activity as done.
|
|
66
74
|
// Legacy plan (no _activityKey): mark all steps up to matched as done (sequential assumption).
|
|
67
|
-
const ownedSteps = actKey ? plan.filter((s) => s._activityKey === actKey) : [];
|
|
68
75
|
if (ownedSteps.length > 0) {
|
|
69
76
|
for (const step of ownedSteps) {
|
|
70
77
|
if (step.status !== 'failed') {
|
|
@@ -85,6 +92,11 @@ export function syncActivitiesToPlan(plan, activities) {
|
|
|
85
92
|
matched.status = 'failed';
|
|
86
93
|
matched.activityKey = actKey;
|
|
87
94
|
} else if (!failed) {
|
|
95
|
+
if (contractTaskPlan && structuredMatch) {
|
|
96
|
+
matched.status = 'running';
|
|
97
|
+
matched.activityKey = actKey;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
88
100
|
// Running: matched step is in progress; preceding pending steps are implicitly done.
|
|
89
101
|
for (const step of plan) {
|
|
90
102
|
if (step.status === 'failed') continue;
|
|
@@ -104,11 +116,30 @@ export function formatPlanStatus(plan) {
|
|
|
104
116
|
return plan
|
|
105
117
|
.map((s) => {
|
|
106
118
|
const icon = s.status === 'done' ? '✓' : s.status === 'failed' ? '✗' : s.status === 'running' ? '…' : ' ';
|
|
107
|
-
return `${s.step}. [${icon}] ${s
|
|
119
|
+
return `${s.step}. [${icon}] ${formatPlanStep(s)}`;
|
|
108
120
|
})
|
|
109
121
|
.join('\n');
|
|
110
122
|
}
|
|
111
123
|
|
|
124
|
+
export function formatPlanStep(step) {
|
|
125
|
+
if (step == null) return '';
|
|
126
|
+
if (typeof step === 'string') return step;
|
|
127
|
+
if (typeof step !== 'object') return String(step);
|
|
128
|
+
for (const key of ['description', 'label', 'id', 'name']) {
|
|
129
|
+
if (step[key] != null) return formatPlanStep(step[key]);
|
|
130
|
+
}
|
|
131
|
+
return '';
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function formatConfigValue(value) {
|
|
135
|
+
if (value == null) return '';
|
|
136
|
+
if (Array.isArray(value)) return value.map(formatConfigValue).join(', ');
|
|
137
|
+
if (typeof value !== 'object') return String(value);
|
|
138
|
+
return Object.entries(value)
|
|
139
|
+
.map(([key, item]) => `${key}: ${formatConfigValue(item)}`)
|
|
140
|
+
.join(', ');
|
|
141
|
+
}
|
|
142
|
+
|
|
112
143
|
export function formatCompletedActivities(activities) {
|
|
113
144
|
return activities
|
|
114
145
|
.filter((a) => a.terminal)
|
|
@@ -196,7 +227,10 @@ function statusRank(status) {
|
|
|
196
227
|
export function attachActivityToExistingPlan(plan, activity) {
|
|
197
228
|
const actKey = activity.key ?? activity.id ?? activity.jobId ?? null;
|
|
198
229
|
if (!actKey) return;
|
|
199
|
-
const
|
|
230
|
+
const contractTaskPlan = isContractTaskPlan(plan);
|
|
231
|
+
const structuredMatch = findMatchingPlanStepByStructure(plan, activity);
|
|
232
|
+
const matched = structuredMatch
|
|
233
|
+
?? plan.find((step) => step.activityKey === actKey)
|
|
200
234
|
?? plan.find((step) => step.ownerActivityKey === actKey)
|
|
201
235
|
?? plan.find((step) => step.status === 'pending')
|
|
202
236
|
?? plan.find((step) => step.status === 'running');
|
|
@@ -209,6 +243,10 @@ export function attachActivityToExistingPlan(plan, activity) {
|
|
|
209
243
|
return;
|
|
210
244
|
}
|
|
211
245
|
if (!failed) {
|
|
246
|
+
if (contractTaskPlan && structuredMatch) {
|
|
247
|
+
matched.status = 'running';
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
212
250
|
for (const step of plan) {
|
|
213
251
|
if (step.status === 'failed') continue;
|
|
214
252
|
if (step.step < matched.step) step.status = 'done';
|
|
@@ -216,3 +254,7 @@ export function attachActivityToExistingPlan(plan, activity) {
|
|
|
216
254
|
}
|
|
217
255
|
}
|
|
218
256
|
}
|
|
257
|
+
|
|
258
|
+
function isContractTaskPlan(plan) {
|
|
259
|
+
return (plan ?? []).some((step) => step.requiredCapability || step.operation);
|
|
260
|
+
}
|
package/src/core/plan.test.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import { ensurePlanFromActivity, syncActivitiesToPlan, extractHeadlessPlan } from './plan.js';
|
|
3
|
+
import { ensurePlanFromActivity, syncActivitiesToPlan, extractHeadlessPlan, formatConfigValue, formatPlanStatus, formatPlanStep } from './plan.js';
|
|
4
4
|
|
|
5
5
|
test('ensurePlanFromActivity: creates multi-step plan from activity.plan.steps', () => {
|
|
6
6
|
const session = { headlessPlan: null };
|
|
@@ -166,3 +166,18 @@ test('extractHeadlessPlan: parses numbered list from text (legacy)', () => {
|
|
|
166
166
|
assert.equal(plan[0].description, 'CME export');
|
|
167
167
|
assert.equal(plan[1].step, 2);
|
|
168
168
|
});
|
|
169
|
+
|
|
170
|
+
test('formatPlanStep renders structured steps without object interpolation', () => {
|
|
171
|
+
assert.equal(formatPlanStep('Build'), 'Build');
|
|
172
|
+
assert.equal(formatPlanStep({ description: 'Build deliverable', label: 'Build' }), 'Build deliverable');
|
|
173
|
+
assert.equal(formatPlanStep({ label: 'Build' }), 'Build');
|
|
174
|
+
assert.equal(formatPlanStep({ id: 'build-doc' }), 'build-doc');
|
|
175
|
+
assert.equal(formatPlanStatus([{ step: 1, status: 'pending', description: { label: 'Build' } }]), '1. [ ] Build');
|
|
176
|
+
assert.doesNotMatch(formatPlanStatus([{ step: 1, status: 'pending', description: { label: 'Build' } }]), /\[object Object\]/);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('formatConfigValue renders objects as key-value text', () => {
|
|
180
|
+
assert.equal(formatConfigValue({ requestsPerMinute: 60 }), 'requestsPerMinute: 60');
|
|
181
|
+
assert.equal(formatConfigValue({ limits: { requestsPerMinute: 60 } }), 'limits: requestsPerMinute: 60');
|
|
182
|
+
assert.doesNotMatch(formatConfigValue({ requestsPerMinute: 60 }), /\[object Object\]/);
|
|
183
|
+
});
|
package/src/core/planPatch.js
CHANGED
|
@@ -112,17 +112,41 @@ export function formatReadyTaskPrompt(task) {
|
|
|
112
112
|
|
|
113
113
|
export function normalizeTask(raw, index = 0) {
|
|
114
114
|
const item = raw && typeof raw === 'object' ? raw : { description: String(raw ?? '') };
|
|
115
|
-
|
|
115
|
+
const label = String(item.label ?? item.description ?? item.name ?? `Step ${index + 1}`);
|
|
116
|
+
const task = {
|
|
116
117
|
...item,
|
|
117
118
|
step: Number(item.step ?? index + 1),
|
|
118
119
|
id: item.id != null ? String(item.id) : null,
|
|
119
|
-
|
|
120
|
+
label,
|
|
121
|
+
description: String(item.description ?? label),
|
|
120
122
|
status: String(item.status ?? 'pending'),
|
|
121
123
|
dependsOn: Array.isArray(item.dependsOn) ? item.dependsOn.map(String) : [],
|
|
124
|
+
requiredCapability: toNullableString(item.requiredCapability),
|
|
125
|
+
operation: toNullableString(item.operation),
|
|
126
|
+
arguments: item.arguments && typeof item.arguments === 'object' && !Array.isArray(item.arguments) ? { ...item.arguments } : {},
|
|
127
|
+
groupId: toNullableString(item.groupId),
|
|
128
|
+
dependsOnGroup: toNullableString(item.dependsOnGroup),
|
|
129
|
+
barrier: item.barrier === true,
|
|
130
|
+
parallelizable: item.parallelizable === true,
|
|
131
|
+
recommendedConcurrency: normalizeOptionalPositiveInteger(item.recommendedConcurrency),
|
|
132
|
+
inputRefs: normalizeReferenceArray(item.inputRefs),
|
|
133
|
+
expectedOutputRefs: normalizeReferenceArray(item.expectedOutputRefs),
|
|
134
|
+
locks: Array.isArray(item.locks) ? item.locks.map(String) : [],
|
|
135
|
+
requiresApproval: item.requiresApproval === true,
|
|
136
|
+
approvalClass: toNullableString(item.approvalClass),
|
|
137
|
+
approvalSummary: toNullableString(item.approvalSummary),
|
|
138
|
+
idempotencyKey: toNullableString(item.idempotencyKey),
|
|
139
|
+
progressWeight: normalizeProgressWeight(item.progressWeight),
|
|
140
|
+
priority: normalizeOptionalNumber(item.priority),
|
|
141
|
+
retryPolicy: item.retryPolicy == null ? undefined : normalizeRetryPolicy(item.retryPolicy),
|
|
122
142
|
executor: item.executor ?? null,
|
|
123
143
|
executorQuery: item.executorQuery ?? null,
|
|
124
|
-
outputRefs:
|
|
144
|
+
outputRefs: normalizeReferenceArray(item.outputRefs),
|
|
125
145
|
};
|
|
146
|
+
for (const key of ['recommendedConcurrency', 'priority', 'retryPolicy']) {
|
|
147
|
+
if (task[key] === undefined) delete task[key];
|
|
148
|
+
}
|
|
149
|
+
return task;
|
|
126
150
|
}
|
|
127
151
|
|
|
128
152
|
export function sanitizePlanForExecution(plan) {
|
|
@@ -162,6 +186,43 @@ function normalizePatchOperation(raw) {
|
|
|
162
186
|
return { ...raw, op };
|
|
163
187
|
}
|
|
164
188
|
|
|
189
|
+
function toNullableString(value) {
|
|
190
|
+
return value != null ? String(value) : null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function normalizeReferenceArray(value) {
|
|
194
|
+
if (!Array.isArray(value)) return [];
|
|
195
|
+
return value.map((item) => {
|
|
196
|
+
if (item && typeof item === 'object' && !Array.isArray(item)) return { ...item };
|
|
197
|
+
return String(item);
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function normalizeOptionalPositiveInteger(value) {
|
|
202
|
+
const number = Number(value);
|
|
203
|
+
return Number.isInteger(number) && number >= 1 ? number : undefined;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function normalizeOptionalNumber(value) {
|
|
207
|
+
const number = Number(value);
|
|
208
|
+
return Number.isFinite(number) ? number : undefined;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function normalizeProgressWeight(value) {
|
|
212
|
+
const number = Number(value);
|
|
213
|
+
return Number.isFinite(number) && number >= 0 ? number : 1;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function normalizeRetryPolicy(value) {
|
|
217
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
218
|
+
const maxAttempts = Number(value.maxAttempts);
|
|
219
|
+
return {
|
|
220
|
+
maxAttempts: Number.isInteger(maxAttempts) && maxAttempts >= 1 ? maxAttempts : 1,
|
|
221
|
+
retryableErrors: Array.isArray(value.retryableErrors) ? value.retryableErrors.map(String) : [],
|
|
222
|
+
allowAgentFallback: value.allowAgentFallback === true,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
165
226
|
function normalizeAddedTask(raw, index) {
|
|
166
227
|
const task = normalizeTask(raw, index);
|
|
167
228
|
return {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import test from 'node:test';
|
|
3
|
-
import { applyPlanPatch, nextReadyPlanTask, readyPlanTasks, rebasePlanPatch, sanitizePlanForExecution } from './planPatch.js';
|
|
3
|
+
import { applyPlanPatch, nextReadyPlanTask, normalizeTask, readyPlanTasks, rebasePlanPatch, sanitizePlanForExecution } from './planPatch.js';
|
|
4
4
|
|
|
5
5
|
test('applyPlanPatch adds a task and increments the plan revision', () => {
|
|
6
6
|
const result = applyPlanPatch([
|
|
@@ -83,3 +83,112 @@ test('sanitizePlanForExecution breaks cycles with declaration-order fallback', (
|
|
|
83
83
|
assert.match(result.warnings.join('\n'), /cycle/);
|
|
84
84
|
assert.deepEqual(readyPlanTasks(result.plan).map((task) => task.id), ['a']);
|
|
85
85
|
});
|
|
86
|
+
|
|
87
|
+
test('normalizeTask carries task contract fields with safe defaults', () => {
|
|
88
|
+
const task = normalizeTask({
|
|
89
|
+
id: 'ingest-a',
|
|
90
|
+
label: 'Ingest A',
|
|
91
|
+
requiredCapability: 'knowledge.update',
|
|
92
|
+
operation: 'ingest',
|
|
93
|
+
arguments: { files: ['raw/untracked/a.md'] },
|
|
94
|
+
groupId: 'ingest',
|
|
95
|
+
dependsOn: ['scan'],
|
|
96
|
+
dependsOnGroup: 'sources',
|
|
97
|
+
barrier: true,
|
|
98
|
+
parallelizable: true,
|
|
99
|
+
recommendedConcurrency: 3,
|
|
100
|
+
inputRefs: [{ type: 'file', ref: 'raw/untracked/a.md' }],
|
|
101
|
+
expectedOutputRefs: ['wiki/a.md'],
|
|
102
|
+
locks: ['workspace:wiki'],
|
|
103
|
+
requiresApproval: true,
|
|
104
|
+
approvalClass: 'mutation',
|
|
105
|
+
approvalSummary: 'Update wiki',
|
|
106
|
+
idempotencyKey: 'idem-1',
|
|
107
|
+
progressWeight: 2,
|
|
108
|
+
priority: 5,
|
|
109
|
+
retryPolicy: {
|
|
110
|
+
maxAttempts: 2,
|
|
111
|
+
retryableErrors: ['timeout'],
|
|
112
|
+
allowAgentFallback: true,
|
|
113
|
+
},
|
|
114
|
+
}, 0);
|
|
115
|
+
|
|
116
|
+
assert.equal(task.description, 'Ingest A');
|
|
117
|
+
assert.equal(task.parallelizable, true);
|
|
118
|
+
assert.deepEqual(task.inputRefs, [{ type: 'file', ref: 'raw/untracked/a.md' }]);
|
|
119
|
+
assert.deepEqual(task.expectedOutputRefs, ['wiki/a.md']);
|
|
120
|
+
assert.deepEqual(task.locks, ['workspace:wiki']);
|
|
121
|
+
assert.equal(task.requiresApproval, true);
|
|
122
|
+
assert.equal(task.progressWeight, 2);
|
|
123
|
+
assert.deepEqual(task.retryPolicy, {
|
|
124
|
+
maxAttempts: 2,
|
|
125
|
+
retryableErrors: ['timeout'],
|
|
126
|
+
allowAgentFallback: true,
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('normalizeTask supplies safe defaults for new execution fields', () => {
|
|
131
|
+
const task = normalizeTask({ id: 'legacy', description: 'Legacy task' }, 0);
|
|
132
|
+
|
|
133
|
+
assert.equal(task.label, 'Legacy task');
|
|
134
|
+
assert.equal(task.requiredCapability, null);
|
|
135
|
+
assert.equal(task.operation, null);
|
|
136
|
+
assert.deepEqual(task.arguments, {});
|
|
137
|
+
assert.equal(task.parallelizable, false);
|
|
138
|
+
assert.deepEqual(task.inputRefs, []);
|
|
139
|
+
assert.deepEqual(task.locks, []);
|
|
140
|
+
assert.equal(task.requiresApproval, false);
|
|
141
|
+
assert.equal(task.idempotencyKey, null);
|
|
142
|
+
assert.equal(task.progressWeight, 1);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('sanitizePlanForExecution preserves task contract fields through cleanup', () => {
|
|
146
|
+
const result = sanitizePlanForExecution([
|
|
147
|
+
{
|
|
148
|
+
id: 'scan',
|
|
149
|
+
label: 'Scan inputs',
|
|
150
|
+
requiredCapability: 'knowledge.update',
|
|
151
|
+
operation: 'scan',
|
|
152
|
+
dependsOn: [],
|
|
153
|
+
parallelizable: false,
|
|
154
|
+
inputRefs: [{ type: 'directory', ref: 'raw/untracked' }],
|
|
155
|
+
locks: [],
|
|
156
|
+
requiresApproval: false,
|
|
157
|
+
idempotencyKey: null,
|
|
158
|
+
progressWeight: 1,
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
id: 'ingest',
|
|
162
|
+
label: 'Ingest inputs',
|
|
163
|
+
requiredCapability: 'knowledge.update',
|
|
164
|
+
operation: 'ingest',
|
|
165
|
+
dependsOn: ['scan', 'missing'],
|
|
166
|
+
parallelizable: true,
|
|
167
|
+
inputRefs: ['raw/untracked/a.md'],
|
|
168
|
+
locks: ['workspace:wiki'],
|
|
169
|
+
requiresApproval: false,
|
|
170
|
+
idempotencyKey: null,
|
|
171
|
+
progressWeight: 3,
|
|
172
|
+
},
|
|
173
|
+
]);
|
|
174
|
+
|
|
175
|
+
assert.deepEqual(result.plan[1].dependsOn, ['scan']);
|
|
176
|
+
assert.equal(result.plan[1].requiredCapability, 'knowledge.update');
|
|
177
|
+
assert.equal(result.plan[1].operation, 'ingest');
|
|
178
|
+
assert.equal(result.plan[1].parallelizable, true);
|
|
179
|
+
assert.deepEqual(result.plan[1].inputRefs, ['raw/untracked/a.md']);
|
|
180
|
+
assert.deepEqual(result.plan[1].locks, ['workspace:wiki']);
|
|
181
|
+
assert.equal(result.plan[1].progressWeight, 3);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('legacy event plan replay remains readable after task contract extension', () => {
|
|
185
|
+
const result = sanitizePlanForExecution([
|
|
186
|
+
{ step: 1, id: 'a', description: 'Old A', status: 'done' },
|
|
187
|
+
{ step: 2, id: 'b', description: 'Old B', status: 'pending', dependsOn: ['a'] },
|
|
188
|
+
]);
|
|
189
|
+
|
|
190
|
+
assert.deepEqual(result.warnings, []);
|
|
191
|
+
assert.deepEqual(result.plan.map((task) => task.id), ['a', 'b']);
|
|
192
|
+
assert.deepEqual(result.plan.map((task) => task.label), ['Old A', 'Old B']);
|
|
193
|
+
assert.deepEqual(readyPlanTasks(result.plan).map((task) => task.id), ['b']);
|
|
194
|
+
});
|