@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
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import test from 'node:test';
|
|
3
|
+
import { mkdtempSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
3
6
|
import { enqueueProductionJob, ensureJobQueue } from './jobQueue.js';
|
|
4
7
|
|
|
5
8
|
test('job queue uses an injected queue store', () => {
|
|
@@ -7,6 +10,7 @@ test('job queue uses an injected queue store', () => {
|
|
|
7
10
|
let changed = 0;
|
|
8
11
|
const session = {
|
|
9
12
|
workspace: 'docs',
|
|
13
|
+
workspacePath: mkdtempSync(join(tmpdir(), 'wiki-manager-queue-')),
|
|
10
14
|
queueStore: {
|
|
11
15
|
list() {
|
|
12
16
|
return queue;
|
|
@@ -30,3 +34,20 @@ test('job queue uses an injected queue store', () => {
|
|
|
30
34
|
assert.equal(ensureJobQueue(session), queue);
|
|
31
35
|
assert.equal(changed, 1);
|
|
32
36
|
});
|
|
37
|
+
|
|
38
|
+
test('job queue refuses production jobs without an active workspace path', () => {
|
|
39
|
+
assert.throws(
|
|
40
|
+
() => enqueueProductionJob({ workspace: 'docs' }, { type: 'ingest', inputs: ['raw/untracked/*.md'] }),
|
|
41
|
+
/no active workspace path/i,
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('job queue normalizes production input paths against workspace path', () => {
|
|
46
|
+
const workspacePath = mkdtempSync(join(tmpdir(), 'wiki-manager-queue-inputs-'));
|
|
47
|
+
const item = enqueueProductionJob(
|
|
48
|
+
{ workspace: 'docs', workspacePath },
|
|
49
|
+
{ type: 'ingest', inputs: ['raw/untracked/*.md', join(workspacePath, 'raw', 'untracked', 'doc.md')] },
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
assert.deepEqual(item.args.inputs, ['raw/untracked/*.md', 'raw/untracked/doc.md']);
|
|
53
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
const FIELD_ALIASES = {
|
|
2
|
+
runId: 'run',
|
|
3
|
+
planRevision: 'plan',
|
|
4
|
+
groupId: 'group',
|
|
5
|
+
taskId: 'task',
|
|
6
|
+
attemptId: 'attempt',
|
|
7
|
+
agentType: 'agentType',
|
|
8
|
+
agentInstanceId: 'agentInstance',
|
|
9
|
+
agentId: 'agent',
|
|
10
|
+
jobId: 'job',
|
|
11
|
+
workspaceId: 'workspace',
|
|
12
|
+
capability: 'capability',
|
|
13
|
+
operation: 'operation',
|
|
14
|
+
file: 'file',
|
|
15
|
+
error: 'error',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const ORDERED_FIELDS = [
|
|
19
|
+
'runId',
|
|
20
|
+
'planRevision',
|
|
21
|
+
'groupId',
|
|
22
|
+
'taskId',
|
|
23
|
+
'attemptId',
|
|
24
|
+
'agentType',
|
|
25
|
+
'agentInstanceId',
|
|
26
|
+
'agentId',
|
|
27
|
+
'jobId',
|
|
28
|
+
'workspaceId',
|
|
29
|
+
'capability',
|
|
30
|
+
'operation',
|
|
31
|
+
'file',
|
|
32
|
+
'error',
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
export function normalizeRuntimeLog(input, { session = null } = {}) {
|
|
36
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
37
|
+
return { message: String(input ?? '') };
|
|
38
|
+
}
|
|
39
|
+
const workspaceId = input.workspaceId ?? input.workspace ?? session?.workspace ?? session?._currentRunIdentity?.workspace ?? null;
|
|
40
|
+
return {
|
|
41
|
+
event: input.event ? String(input.event) : 'runtime.log',
|
|
42
|
+
runId: input.runId ?? session?._currentRunIdentity?.runId ?? null,
|
|
43
|
+
planRevision: input.planRevision ?? session?.planRevision ?? null,
|
|
44
|
+
groupId: input.groupId ?? null,
|
|
45
|
+
taskId: input.taskId ?? null,
|
|
46
|
+
attemptId: input.attemptId ?? null,
|
|
47
|
+
agentType: input.agentType ?? null,
|
|
48
|
+
agentInstanceId: input.agentInstanceId ?? null,
|
|
49
|
+
agentId: input.agentId ?? null,
|
|
50
|
+
jobId: input.jobId ?? null,
|
|
51
|
+
workspaceId,
|
|
52
|
+
capability: input.capability ?? null,
|
|
53
|
+
operation: input.operation ?? null,
|
|
54
|
+
file: input.file ?? input.fileRef ?? null,
|
|
55
|
+
error: input.error ?? null,
|
|
56
|
+
status: input.status ?? null,
|
|
57
|
+
outputs: input.outputs ?? input.outputRefs ?? null,
|
|
58
|
+
percent: input.percent ?? null,
|
|
59
|
+
detail: input.detail ?? input.message ?? null,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function formatRuntimeLogPayload(payload = {}, ts = null) {
|
|
64
|
+
if (payload?.message != null && !payload.event) return String(payload.message);
|
|
65
|
+
const time = timeLabel(ts);
|
|
66
|
+
const event = eventLabel(payload.event);
|
|
67
|
+
const fields = ORDERED_FIELDS
|
|
68
|
+
.map((key) => formatField(FIELD_ALIASES[key], payload[key]))
|
|
69
|
+
.filter(Boolean);
|
|
70
|
+
if (payload.status != null) fields.push(formatField('status', payload.status));
|
|
71
|
+
if (payload.percent != null) fields.push(formatField('percent', payload.percent));
|
|
72
|
+
if (payload.outputs != null) fields.push(formatField('outputs', payload.outputs));
|
|
73
|
+
if (payload.detail != null && payload.detail !== '') fields.push(`detail=${quoteIfNeeded(payload.detail)}`);
|
|
74
|
+
return [time, event, ...fields].filter(Boolean).join(' ');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function runtimeLogMatchesFilter(line, filter = '') {
|
|
78
|
+
const query = String(filter ?? '').trim();
|
|
79
|
+
if (!query) return true;
|
|
80
|
+
const haystack = String(line ?? '').toLowerCase();
|
|
81
|
+
return query
|
|
82
|
+
.split(/\s+/)
|
|
83
|
+
.filter(Boolean)
|
|
84
|
+
.every((token) => haystack.includes(token.toLowerCase()));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function filterRuntimeLogs(logs = [], filter = '') {
|
|
88
|
+
return logs.filter((line) => runtimeLogMatchesFilter(line, filter));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function timeLabel(ts) {
|
|
92
|
+
const date = ts ? new Date(ts) : new Date();
|
|
93
|
+
if (Number.isNaN(date.getTime())) return null;
|
|
94
|
+
return date.toISOString().slice(11, 19);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function eventLabel(event) {
|
|
98
|
+
const raw = String(event ?? 'runtime.log').trim();
|
|
99
|
+
const part = raw.includes('.') ? raw.split('.').at(-1) : raw;
|
|
100
|
+
return part.replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_+|_+$/g, '').toUpperCase() || 'LOG';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function formatField(key, value) {
|
|
104
|
+
if (value == null || value === '') return null;
|
|
105
|
+
return `${key}=${quoteIfNeeded(value)}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function quoteIfNeeded(value) {
|
|
109
|
+
const text = Array.isArray(value)
|
|
110
|
+
? value.map((item) => refString(item)).filter(Boolean).join(',')
|
|
111
|
+
: refString(value);
|
|
112
|
+
return /[\s"]/u.test(text) ? JSON.stringify(text) : text;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function refString(value) {
|
|
116
|
+
if (value == null) return '';
|
|
117
|
+
if (typeof value === 'object') return String(value.ref ?? value.path ?? value.id ?? JSON.stringify(value));
|
|
118
|
+
return String(value);
|
|
119
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
|
|
4
|
+
import { createAgentEvent, dispatchAgentEvent } from './agentEvents.js';
|
|
5
|
+
import { formatRuntimeLogPayload } from './runtimeLog.js';
|
|
6
|
+
import { emitRuntimeLog } from '../runtime/supervisor.js';
|
|
7
|
+
|
|
8
|
+
const CYCLE_EVENTS = [
|
|
9
|
+
'task.ready',
|
|
10
|
+
'capability.resolving',
|
|
11
|
+
'agent.selected',
|
|
12
|
+
'task.assigned',
|
|
13
|
+
'attempt.created',
|
|
14
|
+
'lock.acquired',
|
|
15
|
+
'agent_execute',
|
|
16
|
+
'job.accepted',
|
|
17
|
+
'agent_status',
|
|
18
|
+
'task.result_returned',
|
|
19
|
+
'lock.released',
|
|
20
|
+
'task.completed',
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
function payload(event) {
|
|
24
|
+
return {
|
|
25
|
+
event,
|
|
26
|
+
runId: 'run-123',
|
|
27
|
+
planRevision: 4,
|
|
28
|
+
groupId: 'group-build',
|
|
29
|
+
taskId: 'task-build',
|
|
30
|
+
attemptId: 'attempt-1',
|
|
31
|
+
agentType: 'production',
|
|
32
|
+
agentInstanceId: 'production-main',
|
|
33
|
+
agentId: 'worker-02',
|
|
34
|
+
jobId: 'job-789',
|
|
35
|
+
workspaceId: 'docs',
|
|
36
|
+
capability: 'document.build',
|
|
37
|
+
operation: 'build',
|
|
38
|
+
detail: 'cycle detail',
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
test('formatRuntimeLogPayload formats every dispatcher cycle event with required ids', () => {
|
|
43
|
+
for (const event of CYCLE_EVENTS) {
|
|
44
|
+
const line = formatRuntimeLogPayload(payload(event), '2026-07-08T14:42:18.000Z');
|
|
45
|
+
assert.match(line, /^14:42:18 [A-Z_]+ /);
|
|
46
|
+
assert.match(line, /run=run-123/);
|
|
47
|
+
assert.match(line, /plan=4/);
|
|
48
|
+
assert.match(line, /group=group-build/);
|
|
49
|
+
assert.match(line, /task=task-build/);
|
|
50
|
+
assert.match(line, /attempt=attempt-1/);
|
|
51
|
+
assert.match(line, /agentType=production/);
|
|
52
|
+
assert.match(line, /agentInstance=production-main/);
|
|
53
|
+
assert.match(line, /agent=worker-02/);
|
|
54
|
+
assert.match(line, /job=job-789/);
|
|
55
|
+
assert.match(line, /workspace=docs/);
|
|
56
|
+
assert.match(line, /capability=document\.build/);
|
|
57
|
+
assert.match(line, /operation=build/);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('emitRuntimeLog accepts structured payloads and preserves legacy strings', () => {
|
|
62
|
+
const session = {
|
|
63
|
+
workspace: 'docs',
|
|
64
|
+
planRevision: 2,
|
|
65
|
+
_currentRunIdentity: { runId: 'run-structured', workspace: 'docs' },
|
|
66
|
+
};
|
|
67
|
+
emitRuntimeLog(session, {
|
|
68
|
+
event: 'task.assigned',
|
|
69
|
+
taskId: 'task-a',
|
|
70
|
+
attemptId: 'attempt-a',
|
|
71
|
+
capability: 'knowledge.update',
|
|
72
|
+
operation: 'ingest',
|
|
73
|
+
detail: 'assigned',
|
|
74
|
+
});
|
|
75
|
+
dispatchAgentEvent(session, createAgentEvent('runtime_log', {
|
|
76
|
+
origin: 'test',
|
|
77
|
+
payload: { message: 'legacy line' },
|
|
78
|
+
}));
|
|
79
|
+
|
|
80
|
+
assert.match(session.agentProjection.logs[0], /ASSIGNED/);
|
|
81
|
+
assert.match(session.agentProjection.logs[0], /run=run-structured/);
|
|
82
|
+
assert.match(session.agentProjection.logs[0], /workspace=docs/);
|
|
83
|
+
assert.equal(session.agentProjection.logs[1], 'legacy line');
|
|
84
|
+
});
|
package/src/core/wikirc.js
CHANGED
|
@@ -47,6 +47,7 @@ export function loadWikircProfile(workspacePath, profileName = 'default') {
|
|
|
47
47
|
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
48
48
|
throw new Error('wikirc YAML invalide: objet attendu a la racine');
|
|
49
49
|
}
|
|
50
|
+
config.capabilityRouting = normalizeCapabilityRouting(config.capabilityRouting);
|
|
50
51
|
return { profile, config };
|
|
51
52
|
}
|
|
52
53
|
|
|
@@ -54,6 +55,27 @@ function isPlainObject(value) {
|
|
|
54
55
|
return value && typeof value === 'object' && !Array.isArray(value);
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
export function normalizeCapabilityRouting(value) {
|
|
59
|
+
if (!isPlainObject(value)) return {};
|
|
60
|
+
return Object.fromEntries(
|
|
61
|
+
Object.entries(value)
|
|
62
|
+
.filter(([capability]) => String(capability).trim())
|
|
63
|
+
.map(([capability, routing]) => [
|
|
64
|
+
String(capability).trim(),
|
|
65
|
+
{
|
|
66
|
+
preferredAgents: normalizeAgentList(routing?.preferredAgents),
|
|
67
|
+
fallbackAgents: normalizeAgentList(routing?.fallbackAgents),
|
|
68
|
+
allowedAgents: normalizeAgentList(routing?.allowedAgents),
|
|
69
|
+
},
|
|
70
|
+
]),
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function normalizeAgentList(value) {
|
|
75
|
+
if (!Array.isArray(value)) return [];
|
|
76
|
+
return value.filter((item) => item != null).map((item) => String(item).trim()).filter(Boolean);
|
|
77
|
+
}
|
|
78
|
+
|
|
57
79
|
function setYamlValue(map, key, value) {
|
|
58
80
|
if (isPlainObject(value)) {
|
|
59
81
|
let child = map.get(key, true);
|
package/src/core/wikirc.test.js
CHANGED
|
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import test from 'node:test';
|
|
6
6
|
import YAML from 'yaml';
|
|
7
|
-
import { patchWikircProfile } from './wikirc.js';
|
|
7
|
+
import { loadWikircProfile, normalizeCapabilityRouting, patchWikircProfile } from './wikirc.js';
|
|
8
8
|
import { finalizeCreatedWorkspace, writeVectorConfig } from './wikiSetup.js';
|
|
9
9
|
|
|
10
10
|
test('patchWikircProfile merges keys and preserves existing values', () => {
|
|
@@ -42,6 +42,54 @@ test('patchWikircProfile merges keys and preserves existing values', () => {
|
|
|
42
42
|
assert.equal(parsed.retrieval.vector.enabled, true);
|
|
43
43
|
});
|
|
44
44
|
|
|
45
|
+
test('loadWikircProfile parses capabilityRouting agent lists', () => {
|
|
46
|
+
const root = mkdtempSync(join(tmpdir(), 'wikirc-routing-'));
|
|
47
|
+
writeFileSync(join(root, '.wikirc.yaml'), [
|
|
48
|
+
'language: en-US',
|
|
49
|
+
'capabilityRouting:',
|
|
50
|
+
' external-source.export:',
|
|
51
|
+
' preferredAgents:',
|
|
52
|
+
' - commercial-main',
|
|
53
|
+
' fallbackAgents:',
|
|
54
|
+
' - cme-main',
|
|
55
|
+
' knowledge.update:',
|
|
56
|
+
' allowedAgents:',
|
|
57
|
+
' - production-main',
|
|
58
|
+
'',
|
|
59
|
+
].join('\n'), 'utf8');
|
|
60
|
+
|
|
61
|
+
const { config } = loadWikircProfile(root);
|
|
62
|
+
|
|
63
|
+
assert.deepEqual(config.capabilityRouting, {
|
|
64
|
+
'external-source.export': {
|
|
65
|
+
preferredAgents: ['commercial-main'],
|
|
66
|
+
fallbackAgents: ['cme-main'],
|
|
67
|
+
allowedAgents: [],
|
|
68
|
+
},
|
|
69
|
+
'knowledge.update': {
|
|
70
|
+
preferredAgents: [],
|
|
71
|
+
fallbackAgents: [],
|
|
72
|
+
allowedAgents: ['production-main'],
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('normalizeCapabilityRouting ignores invalid routing values safely', () => {
|
|
78
|
+
assert.deepEqual(normalizeCapabilityRouting({
|
|
79
|
+
'knowledge.update': {
|
|
80
|
+
preferredAgents: ['production-main', '', null],
|
|
81
|
+
fallbackAgents: 'production-secondary',
|
|
82
|
+
allowedAgents: ['production-main'],
|
|
83
|
+
},
|
|
84
|
+
}), {
|
|
85
|
+
'knowledge.update': {
|
|
86
|
+
preferredAgents: ['production-main'],
|
|
87
|
+
fallbackAgents: [],
|
|
88
|
+
allowedAgents: ['production-main'],
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
45
93
|
test('writeVectorConfig writes llm-wiki vector and rerank keys', () => {
|
|
46
94
|
const root = mkdtempSync(join(tmpdir(), 'wikirc-vector-'));
|
|
47
95
|
writeFileSync(join(root, '.wikirc.yaml'), [
|
package/src/core/workflow.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { aggregateActivity } from '../activity/activityAggregator.js';
|
|
2
|
+
import { calculateWeightedProgress } from '../activity/progressCalculator.js';
|
|
3
|
+
import { aggregateGraph } from '../graph/graphAggregator.js';
|
|
4
|
+
|
|
1
5
|
const TERMINAL_STATUSES = new Set(['done', 'failed', 'cancelled', 'canceled', 'error', 'complete', 'completed', 'success']);
|
|
2
6
|
const RUNNING_STATUSES = new Set(['running', 'starting', 'queued', 'waiting', 'pending_approval']);
|
|
3
7
|
|
|
@@ -88,20 +92,24 @@ export function projectWorkflow(state = {}, events = []) {
|
|
|
88
92
|
|
|
89
93
|
const current = findCurrentNode(nodes);
|
|
90
94
|
const next = findNextTask(planNodes);
|
|
91
|
-
const
|
|
95
|
+
const activity = aggregateActivity(state, events);
|
|
96
|
+
const progress = computeProgress({ planNodes, activityNodes, state, activity });
|
|
92
97
|
const waitingReasons = computeWaitingReasons({ nodes, queueNodes, approvalNodes });
|
|
93
98
|
const summary = buildSummary({ state, run, current, next, progress, evaluation });
|
|
94
99
|
|
|
95
|
-
|
|
100
|
+
const projected = {
|
|
96
101
|
summary,
|
|
97
102
|
nodes,
|
|
98
103
|
relations,
|
|
99
104
|
current,
|
|
100
105
|
next,
|
|
101
106
|
progress,
|
|
107
|
+
activity,
|
|
102
108
|
waitingReasons,
|
|
103
109
|
warnings,
|
|
104
110
|
};
|
|
111
|
+
projected.graph = aggregateGraph(projected, events);
|
|
112
|
+
return projected;
|
|
105
113
|
}
|
|
106
114
|
|
|
107
115
|
function currentRun(state, events) {
|
|
@@ -218,7 +226,10 @@ function findNextTask(tasks) {
|
|
|
218
226
|
?? null;
|
|
219
227
|
}
|
|
220
228
|
|
|
221
|
-
function computeProgress({ planNodes, activityNodes, state }) {
|
|
229
|
+
function computeProgress({ planNodes, activityNodes, state, activity }) {
|
|
230
|
+
if (planNodes.some((task) => Number.isFinite(Number(task.raw?.progressWeight)))) {
|
|
231
|
+
return activity.progress ?? calculateWeightedProgress(planNodes.map((node) => node.raw), activityNodes);
|
|
232
|
+
}
|
|
222
233
|
const activityWithPercent = activityNodes.find((activity) => Number.isFinite(Number(activity.progress?.percent)));
|
|
223
234
|
if (activityWithPercent) {
|
|
224
235
|
return {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export function diffGraphSnapshots(before = {}, after = {}) {
|
|
2
|
+
const beforeNodes = new Map((before.nodes ?? []).map((node) => [node.id, node]));
|
|
3
|
+
const afterNodes = new Map((after.nodes ?? []).map((node) => [node.id, node]));
|
|
4
|
+
const addedNodes = [...afterNodes.values()].filter((node) => !beforeNodes.has(node.id));
|
|
5
|
+
const removedNodes = [...beforeNodes.values()].filter((node) => !afterNodes.has(node.id));
|
|
6
|
+
const updatedNodes = [...afterNodes.values()].filter((node) => beforeNodes.has(node.id) && JSON.stringify(beforeNodes.get(node.id)) !== JSON.stringify(node));
|
|
7
|
+
return { addedNodes, removedNodes, updatedNodes };
|
|
8
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { projectRunGraph } from './runGraphProjector.js';
|
|
2
|
+
import { applyGraphVisibility } from './graphVisibilityPolicy.js';
|
|
3
|
+
|
|
4
|
+
export function createGraphSnapshot(workflow = {}, events = [], options = {}) {
|
|
5
|
+
const graph = projectRunGraph(workflow, events);
|
|
6
|
+
const visible = applyGraphVisibility(graph, options);
|
|
7
|
+
return {
|
|
8
|
+
nodes: graph.nodes,
|
|
9
|
+
edges: graph.edges,
|
|
10
|
+
visibleNodes: visible.visibleNodes,
|
|
11
|
+
visibleEdges: visible.visibleEdges,
|
|
12
|
+
aggregations: visible.aggregations,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
const ALWAYS_VISIBLE = new Set(['run', 'task_group', 'barrier', 'approval']);
|
|
2
|
+
const ACTIVE = new Set(['running', 'queued', 'pending_approval', 'waiting_approval']);
|
|
3
|
+
const ERROR = new Set(['failed', 'error', 'cancelled']);
|
|
4
|
+
|
|
5
|
+
export function applyGraphVisibility(graph, { maxNodes = 18 } = {}) {
|
|
6
|
+
const nodes = graph.nodes ?? [];
|
|
7
|
+
if (nodes.length <= maxNodes) return { ...graph, visibleNodes: nodes, visibleEdges: graph.edges ?? [], aggregations: [] };
|
|
8
|
+
const visible = [];
|
|
9
|
+
const hidden = [];
|
|
10
|
+
for (const node of nodes) {
|
|
11
|
+
if (mustShow(node) || visible.length < maxNodes) visible.push(node);
|
|
12
|
+
else hidden.push(node);
|
|
13
|
+
}
|
|
14
|
+
if (hidden.length > 0) {
|
|
15
|
+
const aggregate = {
|
|
16
|
+
id: 'aggregate:hidden',
|
|
17
|
+
type: 'aggregate',
|
|
18
|
+
label: `${hidden.length} hidden node${hidden.length > 1 ? 's' : ''}`,
|
|
19
|
+
status: 'collapsed',
|
|
20
|
+
raw: { count: hidden.length, nodeIds: hidden.map((node) => node.id) },
|
|
21
|
+
};
|
|
22
|
+
if (visible.length >= maxNodes) visible.pop();
|
|
23
|
+
visible.push(aggregate);
|
|
24
|
+
}
|
|
25
|
+
const ids = new Set(visible.map((node) => node.id));
|
|
26
|
+
return {
|
|
27
|
+
...graph,
|
|
28
|
+
visibleNodes: visible,
|
|
29
|
+
visibleEdges: (graph.edges ?? []).filter((edge) => ids.has(edge.from) && ids.has(edge.to)),
|
|
30
|
+
aggregations: hidden.length > 0 ? [{ id: 'aggregate:hidden', count: hidden.length }] : [],
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function mustShow(node) {
|
|
35
|
+
const status = String(node.status ?? '').toLowerCase();
|
|
36
|
+
return ALWAYS_VISIBLE.has(node.type)
|
|
37
|
+
|| ACTIVE.has(status)
|
|
38
|
+
|| ERROR.has(status)
|
|
39
|
+
|| node.type === 'plan_expansion';
|
|
40
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
export function projectRunGraph(workflow = {}, events = []) {
|
|
2
|
+
const nodes = new Map();
|
|
3
|
+
const edges = [];
|
|
4
|
+
for (const node of workflow.nodes ?? []) addNode(nodes, graphNodeFromWorkflow(node));
|
|
5
|
+
for (const relation of workflow.relations ?? []) edges.push({ ...relation });
|
|
6
|
+
for (const event of events ?? []) projectEvent(event, nodes, edges);
|
|
7
|
+
return { nodes: [...nodes.values()], edges };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function projectEvent(event, nodes, edges) {
|
|
11
|
+
const runId = event.runId ?? event.payload?.runId ?? null;
|
|
12
|
+
const taskId = event.taskId ?? event.payload?.taskId ?? null;
|
|
13
|
+
if (event.type === 'task_group.created') {
|
|
14
|
+
const group = event.payload?.group ?? {};
|
|
15
|
+
addNode(nodes, {
|
|
16
|
+
id: `group:${group.id}`,
|
|
17
|
+
type: 'task_group',
|
|
18
|
+
label: group.label ?? group.id,
|
|
19
|
+
status: 'pending',
|
|
20
|
+
raw: group,
|
|
21
|
+
});
|
|
22
|
+
if (runId) edges.push({ type: 'contains', from: `run:${runId}`, to: `group:${group.id}` });
|
|
23
|
+
}
|
|
24
|
+
if (event.type === 'task.created') {
|
|
25
|
+
const task = event.payload?.task ?? {};
|
|
26
|
+
if (task.groupId) edges.push({ type: 'in_group', from: `task:${task.id ?? taskId}`, to: `group:${task.groupId}` });
|
|
27
|
+
if (task.barrier || task.dependsOnGroup) {
|
|
28
|
+
const id = `barrier:${task.dependsOnGroup ?? task.groupId ?? task.id ?? taskId}`;
|
|
29
|
+
addNode(nodes, { id, type: 'barrier', label: `Barrier ${task.dependsOnGroup ?? task.groupId ?? ''}`.trim(), status: task.status ?? 'pending', raw: task });
|
|
30
|
+
edges.push({ type: 'barrier_for', from: id, to: `task:${task.id ?? taskId}` });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (event.type === 'task.assigned') {
|
|
34
|
+
const assignment = event.payload?.assignment ?? {};
|
|
35
|
+
const assignmentId = `assignment:${taskId}:${event.payload?.attemptId ?? assignment.attemptId ?? 'current'}`;
|
|
36
|
+
addNode(nodes, { id: assignmentId, type: 'assignment', label: assignment.agentInstanceId ?? 'Assignment', status: 'done', raw: assignment });
|
|
37
|
+
addNode(nodes, { id: `agent:${assignment.agentInstanceId}`, type: 'agent_instance', label: assignment.agentInstanceId ?? 'Agent', status: 'available', raw: assignment.agent ?? {} });
|
|
38
|
+
edges.push({ type: 'assigned_to', from: `task:${taskId}`, to: assignmentId });
|
|
39
|
+
edges.push({ type: 'uses_agent', from: assignmentId, to: `agent:${assignment.agentInstanceId}` });
|
|
40
|
+
}
|
|
41
|
+
if (['task.started', 'task.retry_scheduled'].includes(event.type)) {
|
|
42
|
+
const attemptId = event.payload?.attemptId ?? event.payload?.retryAssignment?.attemptId ?? `${taskId}:attempt`;
|
|
43
|
+
addNode(nodes, { id: `attempt:${attemptId}`, type: 'attempt', label: String(attemptId), status: event.type === 'task.retry_scheduled' ? 'retry_scheduled' : 'running', raw: event.payload ?? {} });
|
|
44
|
+
edges.push({ type: 'attempt_of', from: `attempt:${attemptId}`, to: `task:${taskId}` });
|
|
45
|
+
}
|
|
46
|
+
if (['task.result_returned', 'task.completed', 'task.failed'].includes(event.type)) {
|
|
47
|
+
const result = event.payload?.result ?? {};
|
|
48
|
+
const attemptId = result.attemptId ?? event.payload?.attemptId ?? `${taskId}:attempt`;
|
|
49
|
+
const resultId = `result:${attemptId}`;
|
|
50
|
+
addNode(nodes, { id: resultId, type: 'result', label: result.status ?? event.type, status: event.type === 'task.failed' ? 'failed' : 'done', raw: result });
|
|
51
|
+
edges.push({ type: 'result_of', from: resultId, to: `attempt:${attemptId}` });
|
|
52
|
+
}
|
|
53
|
+
if (event.type === 'plan.revision_changed' && event.payload?.previousRevision != null) {
|
|
54
|
+
const id = `plan_expansion:${runId}:${event.payload.planRevision}`;
|
|
55
|
+
addNode(nodes, { id, type: 'plan_expansion', label: `Plan revision ${event.payload.planRevision}`, status: 'done', raw: event.payload });
|
|
56
|
+
if (runId) edges.push({ type: 'expands', from: id, to: `run:${runId}` });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function graphNodeFromWorkflow(node) {
|
|
61
|
+
return {
|
|
62
|
+
id: node.id,
|
|
63
|
+
type: node.type,
|
|
64
|
+
label: node.label,
|
|
65
|
+
status: node.status,
|
|
66
|
+
tooltip: tooltipFor(node),
|
|
67
|
+
raw: node.raw ?? node,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function tooltipFor(node) {
|
|
72
|
+
if (node.type !== 'task') return `${node.type}: ${node.label}`;
|
|
73
|
+
const raw = node.raw ?? {};
|
|
74
|
+
return [
|
|
75
|
+
`Tache : ${node.label}`,
|
|
76
|
+
raw.requiredCapability ? `Capacite : ${raw.requiredCapability}` : null,
|
|
77
|
+
raw.agentInstanceId ? `Agent : ${raw.agentInstanceId}` : null,
|
|
78
|
+
raw.retryState?.attempts ? `Tentative : ${raw.retryState.attempts}` : null,
|
|
79
|
+
`Etat : ${node.status}`,
|
|
80
|
+
node.progress?.percent != null ? `Progression : ${Math.round(Number(node.progress.percent))} %` : null,
|
|
81
|
+
].filter(Boolean).join('\n');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function addNode(nodes, node) {
|
|
85
|
+
if (!node?.id) return;
|
|
86
|
+
nodes.set(node.id, { ...(nodes.get(node.id) ?? {}), ...node });
|
|
87
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
|
|
4
|
+
import { createAgentEvent } from '../core/agentEvents.js';
|
|
5
|
+
import { projectWorkflow } from '../core/workflow.js';
|
|
6
|
+
|
|
7
|
+
function sampleEvents() {
|
|
8
|
+
return [
|
|
9
|
+
createAgentEvent('run_started', { runId: 'run-graph', payload: { input: 'Run graph' } }),
|
|
10
|
+
createAgentEvent('task_group.created', {
|
|
11
|
+
runId: 'run-graph',
|
|
12
|
+
payload: { group: { id: 'ingest', label: 'Ingest group' } },
|
|
13
|
+
}),
|
|
14
|
+
createAgentEvent('task.created', {
|
|
15
|
+
runId: 'run-graph',
|
|
16
|
+
taskId: 'run-graph:ingest',
|
|
17
|
+
payload: {
|
|
18
|
+
task: {
|
|
19
|
+
id: 'run-graph:ingest',
|
|
20
|
+
label: 'Ingest',
|
|
21
|
+
description: 'Ingest',
|
|
22
|
+
status: 'running',
|
|
23
|
+
groupId: 'ingest',
|
|
24
|
+
requiredCapability: 'knowledge.update',
|
|
25
|
+
operation: 'ingest',
|
|
26
|
+
dependsOn: [],
|
|
27
|
+
outputRefs: [],
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
}),
|
|
31
|
+
createAgentEvent('task.created', {
|
|
32
|
+
runId: 'run-graph',
|
|
33
|
+
taskId: 'run-graph:apply',
|
|
34
|
+
payload: {
|
|
35
|
+
task: {
|
|
36
|
+
id: 'run-graph:apply',
|
|
37
|
+
label: 'Apply',
|
|
38
|
+
description: 'Apply',
|
|
39
|
+
status: 'pending',
|
|
40
|
+
dependsOnGroup: 'ingest',
|
|
41
|
+
barrier: true,
|
|
42
|
+
requiredCapability: 'knowledge.update',
|
|
43
|
+
operation: 'ingest_apply',
|
|
44
|
+
dependsOn: ['run-graph:ingest'],
|
|
45
|
+
outputRefs: [],
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
}),
|
|
49
|
+
createAgentEvent('task.assigned', {
|
|
50
|
+
runId: 'run-graph',
|
|
51
|
+
taskId: 'run-graph:ingest',
|
|
52
|
+
payload: {
|
|
53
|
+
taskId: 'run-graph:ingest',
|
|
54
|
+
attemptId: 'attempt-1',
|
|
55
|
+
assignment: { agentInstanceId: 'production-main' },
|
|
56
|
+
},
|
|
57
|
+
}),
|
|
58
|
+
createAgentEvent('task.started', {
|
|
59
|
+
runId: 'run-graph',
|
|
60
|
+
taskId: 'run-graph:ingest',
|
|
61
|
+
payload: { taskId: 'run-graph:ingest', attemptId: 'attempt-1', jobId: 'job-1' },
|
|
62
|
+
}),
|
|
63
|
+
createAgentEvent('task.result_returned', {
|
|
64
|
+
runId: 'run-graph',
|
|
65
|
+
taskId: 'run-graph:ingest',
|
|
66
|
+
payload: { result: { attemptId: 'attempt-1', status: 'succeeded', outputRefs: [{ type: 'file', ref: 'out.json' }] } },
|
|
67
|
+
}),
|
|
68
|
+
createAgentEvent('plan.revision_changed', {
|
|
69
|
+
runId: 'run-graph',
|
|
70
|
+
payload: { runId: 'run-graph', previousRevision: 1, planRevision: 2, taskIds: ['run-graph:ingest'] },
|
|
71
|
+
}),
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
test('run graph snapshot includes task groups, barrier, agent, assignment, attempt, result and expansion', () => {
|
|
76
|
+
const workflow = projectWorkflow({
|
|
77
|
+
status: 'running',
|
|
78
|
+
runId: 'run-graph',
|
|
79
|
+
plan: [
|
|
80
|
+
{ id: 'run-graph:ingest', description: 'Ingest', status: 'running', groupId: 'ingest', requiredCapability: 'knowledge.update', operation: 'ingest', dependsOn: [] },
|
|
81
|
+
{ id: 'run-graph:apply', description: 'Apply', status: 'pending', dependsOnGroup: 'ingest', barrier: true, requiredCapability: 'knowledge.update', operation: 'ingest_apply', dependsOn: ['run-graph:ingest'] },
|
|
82
|
+
],
|
|
83
|
+
activities: [],
|
|
84
|
+
queue: [],
|
|
85
|
+
approvals: [],
|
|
86
|
+
}, sampleEvents());
|
|
87
|
+
|
|
88
|
+
const types = new Set(workflow.graph.nodes.map((node) => node.type));
|
|
89
|
+
for (const type of ['task_group', 'barrier', 'agent_instance', 'assignment', 'attempt', 'result', 'plan_expansion']) {
|
|
90
|
+
assert.ok(types.has(type), `missing graph node type ${type}`);
|
|
91
|
+
}
|
|
92
|
+
assert.ok(workflow.graph.visibleNodes.length <= 18);
|
|
93
|
+
assert.ok(workflow.graph.nodes.find((node) => node.id === 'task:run-graph:ingest')?.tooltip.includes('Capacite : knowledge.update'));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('run graph replay from the same events is deterministic', () => {
|
|
97
|
+
const state = {
|
|
98
|
+
status: 'running',
|
|
99
|
+
runId: 'run-graph',
|
|
100
|
+
plan: [
|
|
101
|
+
{ id: 'run-graph:ingest', description: 'Ingest', status: 'running', groupId: 'ingest', requiredCapability: 'knowledge.update', operation: 'ingest', dependsOn: [] },
|
|
102
|
+
],
|
|
103
|
+
activities: [],
|
|
104
|
+
queue: [],
|
|
105
|
+
approvals: [],
|
|
106
|
+
};
|
|
107
|
+
const first = projectWorkflow(state, sampleEvents()).graph;
|
|
108
|
+
const second = projectWorkflow(state, sampleEvents()).graph;
|
|
109
|
+
|
|
110
|
+
assert.deepEqual(second, first);
|
|
111
|
+
});
|