@swarmclawai/swarmclaw 1.3.5 → 1.4.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 +37 -1
- package/package.json +10 -3
- package/src/.env.local +4 -0
- package/src/app/api/.well-known/agent-card/route.ts +46 -0
- package/src/app/api/a2a/route.ts +56 -0
- package/src/app/api/a2a/tasks/[taskId]/status/route.ts +49 -0
- package/src/app/api/chats/[id]/deploy/route.ts +2 -2
- package/src/app/api/openclaw/sync/route.ts +1 -1
- package/src/app/api/swarmfeed/channels/route.ts +14 -0
- package/src/app/api/swarmfeed/posts/route.ts +60 -0
- package/src/app/api/swarmfeed/route.ts +37 -0
- package/src/app/protocols/builder/[templateId]/page.tsx +93 -0
- package/src/app/protocols/page.tsx +16 -7
- package/src/app/swarmfeed/page.tsx +7 -0
- package/src/cli/index.js +19 -0
- package/src/cli/spec.js +8 -0
- package/src/components/agents/agent-avatar.tsx +2 -5
- package/src/components/agents/agent-sheet.tsx +10 -0
- package/src/components/auth/access-key-gate.tsx +25 -0
- package/src/components/layout/sidebar-rail.tsx +52 -0
- package/src/components/protocols/builder/edge-editor.tsx +43 -0
- package/src/components/protocols/builder/edge-types/branch-edge.tsx +33 -0
- package/src/components/protocols/builder/edge-types/default-edge.tsx +18 -0
- package/src/components/protocols/builder/edge-types/index.ts +3 -0
- package/src/components/protocols/builder/edge-types/loop-edge.tsx +19 -0
- package/src/components/protocols/builder/node-inspector.tsx +227 -0
- package/src/components/protocols/builder/node-palette.tsx +97 -0
- package/src/components/protocols/builder/node-types/branch-node.tsx +34 -0
- package/src/components/protocols/builder/node-types/complete-node.tsx +17 -0
- package/src/components/protocols/builder/node-types/for-each-node.tsx +21 -0
- package/src/components/protocols/builder/node-types/index.ts +9 -0
- package/src/components/protocols/builder/node-types/join-node.tsx +18 -0
- package/src/components/protocols/builder/node-types/loop-node.tsx +22 -0
- package/src/components/protocols/builder/node-types/parallel-node.tsx +31 -0
- package/src/components/protocols/builder/node-types/phase-node.tsx +52 -0
- package/src/components/protocols/builder/node-types/subflow-node.tsx +23 -0
- package/src/components/protocols/builder/node-types/swarm-node.tsx +26 -0
- package/src/components/protocols/builder/protocol-builder-canvas.tsx +184 -0
- package/src/components/protocols/builder/run-overlay.tsx +29 -0
- package/src/components/protocols/builder/template-gallery.tsx +53 -0
- package/src/components/protocols/builder/validation-panel.tsx +57 -0
- package/src/components/skills/skills-workspace.tsx +1 -9
- package/src/features/protocols/builder/hooks/index.ts +2 -0
- package/src/features/protocols/builder/hooks/use-canvas-validation.ts +14 -0
- package/src/features/protocols/builder/hooks/use-run-overlay.ts +39 -0
- package/src/features/protocols/builder/hooks/use-template-sync.ts +45 -0
- package/src/features/protocols/builder/protocol-builder-store.ts +233 -0
- package/src/features/protocols/builder/utils/node-position-layout.ts +41 -0
- package/src/features/protocols/builder/utils/nodes-to-template.test.ts +179 -0
- package/src/features/protocols/builder/utils/nodes-to-template.ts +49 -0
- package/src/features/protocols/builder/utils/template-to-nodes.test.ts +314 -0
- package/src/features/protocols/builder/utils/template-to-nodes.ts +169 -0
- package/src/features/protocols/builder/validators/dag-validator.test.ts +150 -0
- package/src/features/protocols/builder/validators/dag-validator.ts +119 -0
- package/src/features/swarmfeed/agent-social-settings.tsx +277 -0
- package/src/features/swarmfeed/compose-post.tsx +139 -0
- package/src/features/swarmfeed/feed-page.tsx +136 -0
- package/src/features/swarmfeed/post-card.tsx +114 -0
- package/src/features/swarmfeed/queries.ts +28 -0
- package/src/lib/a2a/agent-card.ts +61 -0
- package/src/lib/a2a/auth.ts +54 -0
- package/src/lib/a2a/client.ts +133 -0
- package/src/lib/a2a/discovery.ts +116 -0
- package/src/lib/a2a/handlers.ts +176 -0
- package/src/lib/a2a/json-rpc-router.ts +38 -0
- package/src/lib/a2a/types.ts +95 -0
- package/src/lib/app/navigation.ts +1 -0
- package/src/lib/app/view-constants.ts +9 -1
- package/src/lib/providers/anthropic.ts +111 -107
- package/src/lib/providers/openai.ts +146 -142
- package/src/lib/server/agents/main-agent-loop.test.ts +94 -0
- package/src/lib/server/agents/main-agent-loop.ts +377 -41
- package/src/lib/server/chat-execution/chat-execution-disabled.test.ts +14 -31
- package/src/lib/server/chat-execution/chat-execution-eval-history.test.ts +11 -34
- package/src/lib/server/chat-execution/chat-execution-grounding.test.ts +15 -34
- package/src/lib/server/chat-execution/chat-execution-session-sync.test.ts +35 -36
- package/src/lib/server/chat-execution/chat-execution.ts +12 -7
- package/src/lib/server/extensions.ts +11 -0
- package/src/lib/server/knowledge-sources.test.ts +46 -0
- package/src/lib/server/knowledge-sources.ts +34 -16
- package/src/lib/server/openclaw/sync.ts +4 -4
- package/src/lib/server/protocols/protocol-a2a-delegate.ts +135 -0
- package/src/lib/server/protocols/protocol-normalization.ts +1 -0
- package/src/lib/server/protocols/protocol-step-helpers.test.ts +1 -1
- package/src/lib/server/protocols/protocol-step-helpers.ts +1 -0
- package/src/lib/server/protocols/protocol-step-processors.ts +2 -0
- package/src/lib/server/protocols/protocol-types.ts +1 -0
- package/src/lib/server/session-tools/delegate.ts +151 -77
- package/src/lib/server/storage-auth.ts +10 -2
- package/src/lib/server/storage-normalization.ts +11 -0
- package/src/lib/server/storage.ts +100 -0
- package/src/lib/server/test-utils/run-with-temp-data-dir.ts +15 -2
- package/src/lib/server/working-state/service.test.ts +2 -3
- package/src/lib/server/working-state/service.ts +37 -6
- package/src/lib/swarmfeed-client.ts +157 -0
- package/src/lib/validation/schemas.ts +1 -1
- package/src/stores/slices/data-slice.ts +3 -0
- package/src/stores/use-approval-store.ts +4 -1
- package/src/types/agent.ts +31 -1
- package/src/types/index.ts +1 -0
- package/src/types/protocol.ts +19 -0
- package/src/types/session.ts +1 -1
- package/src/types/swarmfeed.ts +30 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { getNodeTypeForKind, templateToNodes } from './template-to-nodes'
|
|
4
|
+
import type { ProtocolTemplate, ProtocolStepDefinition, ProtocolPhaseDefinition } from '@/types'
|
|
5
|
+
|
|
6
|
+
// --- getNodeTypeForKind ---
|
|
7
|
+
|
|
8
|
+
describe('getNodeTypeForKind', () => {
|
|
9
|
+
it('maps all ProtocolPhaseKind values to "phase"', () => {
|
|
10
|
+
const phaseKinds = [
|
|
11
|
+
'present',
|
|
12
|
+
'collect_independent_inputs',
|
|
13
|
+
'round_robin',
|
|
14
|
+
'compare',
|
|
15
|
+
'decide',
|
|
16
|
+
'summarize',
|
|
17
|
+
'emit_tasks',
|
|
18
|
+
'wait',
|
|
19
|
+
'dispatch_task',
|
|
20
|
+
'dispatch_delegation',
|
|
21
|
+
] as const
|
|
22
|
+
|
|
23
|
+
for (const kind of phaseKinds) {
|
|
24
|
+
assert.strictEqual(getNodeTypeForKind(kind), 'phase')
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('maps branch to "branch"', () => {
|
|
29
|
+
assert.strictEqual(getNodeTypeForKind('branch'), 'branch')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('maps repeat to "loop"', () => {
|
|
33
|
+
assert.strictEqual(getNodeTypeForKind('repeat'), 'loop')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('maps parallel to "parallel"', () => {
|
|
37
|
+
assert.strictEqual(getNodeTypeForKind('parallel'), 'parallel')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('maps join to "join"', () => {
|
|
41
|
+
assert.strictEqual(getNodeTypeForKind('join'), 'join')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('maps complete to "complete"', () => {
|
|
45
|
+
assert.strictEqual(getNodeTypeForKind('complete'), 'complete')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('maps for_each to "forEach"', () => {
|
|
49
|
+
assert.strictEqual(getNodeTypeForKind('for_each'), 'forEach')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('maps subflow to "subflow"', () => {
|
|
53
|
+
assert.strictEqual(getNodeTypeForKind('subflow'), 'subflow')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('maps swarm_claim to "swarm"', () => {
|
|
57
|
+
assert.strictEqual(getNodeTypeForKind('swarm_claim'), 'swarm')
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
// --- templateToNodes ---
|
|
62
|
+
|
|
63
|
+
function makeTemplate(overrides: Partial<ProtocolTemplate> = {}): ProtocolTemplate {
|
|
64
|
+
return {
|
|
65
|
+
id: 'tpl-1',
|
|
66
|
+
name: 'Test Template',
|
|
67
|
+
description: 'A test template',
|
|
68
|
+
builtIn: false,
|
|
69
|
+
defaultPhases: [],
|
|
70
|
+
...overrides,
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function makeStep(overrides: Partial<ProtocolStepDefinition> = {}): ProtocolStepDefinition {
|
|
75
|
+
return {
|
|
76
|
+
id: 'step-1',
|
|
77
|
+
kind: 'present',
|
|
78
|
+
label: 'Step One',
|
|
79
|
+
...overrides,
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe('templateToNodes — node creation', () => {
|
|
84
|
+
it('creates one node per step', () => {
|
|
85
|
+
const steps: ProtocolStepDefinition[] = [
|
|
86
|
+
makeStep({ id: 'step-1', label: 'Step One' }),
|
|
87
|
+
makeStep({ id: 'step-2', label: 'Step Two', kind: 'decide' }),
|
|
88
|
+
]
|
|
89
|
+
const { nodes } = templateToNodes(makeTemplate({ steps }))
|
|
90
|
+
assert.strictEqual(nodes.length, 2)
|
|
91
|
+
assert.deepStrictEqual(nodes.map((n) => n.id), ['step-1', 'step-2'])
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('sets correct node type from kind', () => {
|
|
95
|
+
const steps: ProtocolStepDefinition[] = [
|
|
96
|
+
makeStep({ id: 'phase-step', kind: 'present' }),
|
|
97
|
+
makeStep({ id: 'branch-step', kind: 'branch' }),
|
|
98
|
+
makeStep({ id: 'loop-step', kind: 'repeat' }),
|
|
99
|
+
makeStep({ id: 'parallel-step', kind: 'parallel' }),
|
|
100
|
+
makeStep({ id: 'join-step', kind: 'join' }),
|
|
101
|
+
makeStep({ id: 'complete-step', kind: 'complete' }),
|
|
102
|
+
makeStep({ id: 'foreach-step', kind: 'for_each' }),
|
|
103
|
+
makeStep({ id: 'subflow-step', kind: 'subflow' }),
|
|
104
|
+
makeStep({ id: 'swarm-step', kind: 'swarm_claim' }),
|
|
105
|
+
]
|
|
106
|
+
const { nodes } = templateToNodes(makeTemplate({ steps }))
|
|
107
|
+
const typeMap = Object.fromEntries(nodes.map((n) => [n.id, n.type]))
|
|
108
|
+
assert.strictEqual(typeMap['phase-step'], 'phase')
|
|
109
|
+
assert.strictEqual(typeMap['branch-step'], 'branch')
|
|
110
|
+
assert.strictEqual(typeMap['loop-step'], 'loop')
|
|
111
|
+
assert.strictEqual(typeMap['parallel-step'], 'parallel')
|
|
112
|
+
assert.strictEqual(typeMap['join-step'], 'join')
|
|
113
|
+
assert.strictEqual(typeMap['complete-step'], 'complete')
|
|
114
|
+
assert.strictEqual(typeMap['foreach-step'], 'forEach')
|
|
115
|
+
assert.strictEqual(typeMap['subflow-step'], 'subflow')
|
|
116
|
+
assert.strictEqual(typeMap['swarm-step'], 'swarm')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('copies step data fields into node data', () => {
|
|
120
|
+
const step = makeStep({
|
|
121
|
+
id: 'step-1',
|
|
122
|
+
kind: 'decide',
|
|
123
|
+
label: 'My Step',
|
|
124
|
+
instructions: 'Do the thing',
|
|
125
|
+
turnLimit: 3,
|
|
126
|
+
completionCriteria: 'Done when X',
|
|
127
|
+
outputKey: 'result',
|
|
128
|
+
dependsOnStepIds: ['step-0'],
|
|
129
|
+
})
|
|
130
|
+
const { nodes } = templateToNodes(makeTemplate({ steps: [step] }))
|
|
131
|
+
const data = nodes[0].data
|
|
132
|
+
assert.strictEqual(data.label, 'My Step')
|
|
133
|
+
assert.strictEqual(data.kind, 'decide')
|
|
134
|
+
assert.strictEqual(data.instructions, 'Do the thing')
|
|
135
|
+
assert.strictEqual(data.turnLimit, 3)
|
|
136
|
+
assert.strictEqual(data.completionCriteria, 'Done when X')
|
|
137
|
+
assert.strictEqual(data.outputKey, 'result')
|
|
138
|
+
assert.deepStrictEqual(data.dependsOnStepIds, ['step-0'])
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
describe('templateToNodes — edge creation', () => {
|
|
143
|
+
it('creates a default edge from nextStepId', () => {
|
|
144
|
+
const steps: ProtocolStepDefinition[] = [
|
|
145
|
+
makeStep({ id: 'step-1', nextStepId: 'step-2' }),
|
|
146
|
+
makeStep({ id: 'step-2' }),
|
|
147
|
+
]
|
|
148
|
+
const { edges } = templateToNodes(makeTemplate({ steps }))
|
|
149
|
+
assert.strictEqual(edges.length, 1)
|
|
150
|
+
const edge = edges[0]
|
|
151
|
+
assert.strictEqual(edge.id, 'step-1--step-2')
|
|
152
|
+
assert.strictEqual(edge.source, 'step-1')
|
|
153
|
+
assert.strictEqual(edge.target, 'step-2')
|
|
154
|
+
assert.strictEqual(edge.type, 'default')
|
|
155
|
+
assert.strictEqual(edge.data?.edgeType, 'default')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('creates branch edges from branchCases', () => {
|
|
159
|
+
const steps: ProtocolStepDefinition[] = [
|
|
160
|
+
makeStep({
|
|
161
|
+
id: 'branch-step',
|
|
162
|
+
kind: 'branch',
|
|
163
|
+
branchCases: [
|
|
164
|
+
{ id: 'case-yes', label: 'Yes', nextStepId: 'step-yes' },
|
|
165
|
+
{ id: 'case-no', label: 'No', nextStepId: 'step-no' },
|
|
166
|
+
],
|
|
167
|
+
}),
|
|
168
|
+
makeStep({ id: 'step-yes' }),
|
|
169
|
+
makeStep({ id: 'step-no' }),
|
|
170
|
+
]
|
|
171
|
+
const { edges } = templateToNodes(makeTemplate({ steps }))
|
|
172
|
+
const branchEdges = edges.filter((e) => e.data?.edgeType === 'branch')
|
|
173
|
+
assert.strictEqual(branchEdges.length, 2)
|
|
174
|
+
|
|
175
|
+
const yesEdge = branchEdges.find((e) => e.id === 'branch-step--case-yes')
|
|
176
|
+
assert.notStrictEqual(yesEdge, undefined)
|
|
177
|
+
assert.strictEqual(yesEdge?.source, 'branch-step')
|
|
178
|
+
assert.strictEqual(yesEdge?.target, 'step-yes')
|
|
179
|
+
assert.strictEqual(yesEdge?.sourceHandle, 'case-yes')
|
|
180
|
+
assert.strictEqual(yesEdge?.data?.branchCaseId, 'case-yes')
|
|
181
|
+
assert.strictEqual(yesEdge?.data?.label, 'Yes')
|
|
182
|
+
|
|
183
|
+
const noEdge = branchEdges.find((e) => e.id === 'branch-step--case-no')
|
|
184
|
+
assert.notStrictEqual(noEdge, undefined)
|
|
185
|
+
assert.strictEqual(noEdge?.target, 'step-no')
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('creates a branch edge for defaultNextStepId with label "Default"', () => {
|
|
189
|
+
const steps: ProtocolStepDefinition[] = [
|
|
190
|
+
makeStep({
|
|
191
|
+
id: 'branch-step',
|
|
192
|
+
kind: 'branch',
|
|
193
|
+
branchCases: [
|
|
194
|
+
{ id: 'case-a', label: 'Option A', nextStepId: 'step-a' },
|
|
195
|
+
],
|
|
196
|
+
defaultNextStepId: 'step-default',
|
|
197
|
+
}),
|
|
198
|
+
makeStep({ id: 'step-a' }),
|
|
199
|
+
makeStep({ id: 'step-default' }),
|
|
200
|
+
]
|
|
201
|
+
const { edges } = templateToNodes(makeTemplate({ steps }))
|
|
202
|
+
const defaultEdge = edges.find((e) => e.id === 'branch-step--default')
|
|
203
|
+
assert.notStrictEqual(defaultEdge, undefined)
|
|
204
|
+
assert.strictEqual(defaultEdge?.source, 'branch-step')
|
|
205
|
+
assert.strictEqual(defaultEdge?.target, 'step-default')
|
|
206
|
+
assert.strictEqual(defaultEdge?.sourceHandle, 'default')
|
|
207
|
+
assert.strictEqual(defaultEdge?.type, 'branch')
|
|
208
|
+
assert.strictEqual(defaultEdge?.label, 'Default')
|
|
209
|
+
assert.strictEqual(defaultEdge?.data?.edgeType, 'branch')
|
|
210
|
+
assert.strictEqual(defaultEdge?.data?.label, 'Default')
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('creates a loop edge from repeat.bodyStepId', () => {
|
|
214
|
+
const steps: ProtocolStepDefinition[] = [
|
|
215
|
+
makeStep({
|
|
216
|
+
id: 'loop-step',
|
|
217
|
+
kind: 'repeat',
|
|
218
|
+
repeat: {
|
|
219
|
+
bodyStepId: 'body-step',
|
|
220
|
+
maxIterations: 5,
|
|
221
|
+
},
|
|
222
|
+
}),
|
|
223
|
+
makeStep({ id: 'body-step' }),
|
|
224
|
+
]
|
|
225
|
+
const { edges } = templateToNodes(makeTemplate({ steps }))
|
|
226
|
+
const loopEdge = edges.find((e) => e.id === 'loop-step--loop')
|
|
227
|
+
assert.notStrictEqual(loopEdge, undefined)
|
|
228
|
+
assert.strictEqual(loopEdge?.source, 'loop-step')
|
|
229
|
+
assert.strictEqual(loopEdge?.target, 'body-step')
|
|
230
|
+
assert.strictEqual(loopEdge?.sourceHandle, 'loop-back')
|
|
231
|
+
assert.strictEqual(loopEdge?.type, 'loop')
|
|
232
|
+
assert.strictEqual(loopEdge?.data?.edgeType, 'loop')
|
|
233
|
+
assert.strictEqual(loopEdge?.data?.isLoopback, true)
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('creates no edges when steps have no connections', () => {
|
|
237
|
+
const steps: ProtocolStepDefinition[] = [
|
|
238
|
+
makeStep({ id: 'step-1' }),
|
|
239
|
+
makeStep({ id: 'step-2' }),
|
|
240
|
+
]
|
|
241
|
+
const { edges } = templateToNodes(makeTemplate({ steps }))
|
|
242
|
+
assert.strictEqual(edges.length, 0)
|
|
243
|
+
})
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
describe('templateToNodes — fallback to defaultPhases', () => {
|
|
247
|
+
it('uses defaultPhases when steps is undefined', () => {
|
|
248
|
+
const phases: ProtocolPhaseDefinition[] = [
|
|
249
|
+
{ id: 'phase-1', kind: 'present', label: 'Present' },
|
|
250
|
+
{ id: 'phase-2', kind: 'decide', label: 'Decide' },
|
|
251
|
+
]
|
|
252
|
+
const { nodes } = templateToNodes(makeTemplate({ defaultPhases: phases }))
|
|
253
|
+
assert.strictEqual(nodes.length, 2)
|
|
254
|
+
assert.strictEqual(nodes[0].id, 'phase-1')
|
|
255
|
+
assert.strictEqual(nodes[1].id, 'phase-2')
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('uses defaultPhases when steps is an empty array', () => {
|
|
259
|
+
const phases: ProtocolPhaseDefinition[] = [
|
|
260
|
+
{ id: 'phase-1', kind: 'summarize', label: 'Summarize' },
|
|
261
|
+
]
|
|
262
|
+
const { nodes } = templateToNodes(makeTemplate({ defaultPhases: phases, steps: [] }))
|
|
263
|
+
assert.strictEqual(nodes.length, 1)
|
|
264
|
+
assert.strictEqual(nodes[0].id, 'phase-1')
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('creates sequential edges between defaultPhases', () => {
|
|
268
|
+
const phases: ProtocolPhaseDefinition[] = [
|
|
269
|
+
{ id: 'phase-1', kind: 'present', label: 'Present' },
|
|
270
|
+
{ id: 'phase-2', kind: 'decide', label: 'Decide' },
|
|
271
|
+
{ id: 'phase-3', kind: 'summarize', label: 'Summarize' },
|
|
272
|
+
]
|
|
273
|
+
const { edges } = templateToNodes(makeTemplate({ defaultPhases: phases }))
|
|
274
|
+
assert.strictEqual(edges.length, 2)
|
|
275
|
+
assert.strictEqual(edges[0].id, 'phase-1--phase-2')
|
|
276
|
+
assert.strictEqual(edges[0].source, 'phase-1')
|
|
277
|
+
assert.strictEqual(edges[0].target, 'phase-2')
|
|
278
|
+
assert.strictEqual(edges[1].id, 'phase-2--phase-3')
|
|
279
|
+
assert.strictEqual(edges[1].source, 'phase-2')
|
|
280
|
+
assert.strictEqual(edges[1].target, 'phase-3')
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('copies phase data fields into node data', () => {
|
|
284
|
+
const phases: ProtocolPhaseDefinition[] = [
|
|
285
|
+
{
|
|
286
|
+
id: 'phase-1',
|
|
287
|
+
kind: 'present',
|
|
288
|
+
label: 'Present Phase',
|
|
289
|
+
instructions: 'Do the presentation',
|
|
290
|
+
turnLimit: 2,
|
|
291
|
+
completionCriteria: 'All presented',
|
|
292
|
+
},
|
|
293
|
+
]
|
|
294
|
+
const { nodes } = templateToNodes(makeTemplate({ defaultPhases: phases }))
|
|
295
|
+
const data = nodes[0].data
|
|
296
|
+
assert.strictEqual(data.label, 'Present Phase')
|
|
297
|
+
assert.strictEqual(data.kind, 'present')
|
|
298
|
+
assert.strictEqual(data.instructions, 'Do the presentation')
|
|
299
|
+
assert.strictEqual(data.turnLimit, 2)
|
|
300
|
+
assert.strictEqual(data.completionCriteria, 'All presented')
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('uses steps when both steps and defaultPhases are present and steps is non-empty', () => {
|
|
304
|
+
const steps: ProtocolStepDefinition[] = [
|
|
305
|
+
makeStep({ id: 'step-1', label: 'The Step' }),
|
|
306
|
+
]
|
|
307
|
+
const phases: ProtocolPhaseDefinition[] = [
|
|
308
|
+
{ id: 'phase-1', kind: 'present', label: 'The Phase' },
|
|
309
|
+
]
|
|
310
|
+
const { nodes } = templateToNodes(makeTemplate({ steps, defaultPhases: phases }))
|
|
311
|
+
assert.strictEqual(nodes.length, 1)
|
|
312
|
+
assert.strictEqual(nodes[0].id, 'step-1')
|
|
313
|
+
})
|
|
314
|
+
})
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type { Node, Edge } from '@xyflow/react'
|
|
2
|
+
import type { ProtocolTemplate, ProtocolStepKind, ProtocolStepDefinition, ProtocolPhaseDefinition } from '@/types'
|
|
3
|
+
import type { BuilderNodeData, BuilderEdgeData } from '../protocol-builder-store'
|
|
4
|
+
|
|
5
|
+
export function getNodeTypeForKind(kind: ProtocolStepKind): string {
|
|
6
|
+
switch (kind) {
|
|
7
|
+
case 'branch':
|
|
8
|
+
return 'branch'
|
|
9
|
+
case 'repeat':
|
|
10
|
+
return 'loop'
|
|
11
|
+
case 'parallel':
|
|
12
|
+
return 'parallel'
|
|
13
|
+
case 'join':
|
|
14
|
+
return 'join'
|
|
15
|
+
case 'for_each':
|
|
16
|
+
return 'forEach'
|
|
17
|
+
case 'subflow':
|
|
18
|
+
return 'subflow'
|
|
19
|
+
case 'swarm_claim':
|
|
20
|
+
return 'swarm'
|
|
21
|
+
case 'complete':
|
|
22
|
+
return 'complete'
|
|
23
|
+
default:
|
|
24
|
+
// All ProtocolPhaseKind values map to 'phase'
|
|
25
|
+
return 'phase'
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function stepToNode(step: ProtocolStepDefinition): Node<BuilderNodeData> {
|
|
30
|
+
return {
|
|
31
|
+
id: step.id,
|
|
32
|
+
type: getNodeTypeForKind(step.kind),
|
|
33
|
+
position: { x: 0, y: 0 },
|
|
34
|
+
data: {
|
|
35
|
+
label: step.label,
|
|
36
|
+
kind: step.kind,
|
|
37
|
+
instructions: step.instructions ?? null,
|
|
38
|
+
turnLimit: step.turnLimit ?? null,
|
|
39
|
+
completionCriteria: step.completionCriteria ?? null,
|
|
40
|
+
taskConfig: step.taskConfig ?? null,
|
|
41
|
+
delegationConfig: step.delegationConfig ?? null,
|
|
42
|
+
repeat: step.repeat ?? null,
|
|
43
|
+
parallel: step.parallel ?? null,
|
|
44
|
+
join: step.join ?? null,
|
|
45
|
+
forEach: step.forEach ?? null,
|
|
46
|
+
subflow: step.subflow ?? null,
|
|
47
|
+
swarm: step.swarm ?? null,
|
|
48
|
+
branchCases: step.branchCases,
|
|
49
|
+
defaultNextStepId: step.defaultNextStepId ?? null,
|
|
50
|
+
outputKey: step.outputKey ?? null,
|
|
51
|
+
dependsOnStepIds: step.dependsOnStepIds,
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function phaseToNode(phase: ProtocolPhaseDefinition): Node<BuilderNodeData> {
|
|
57
|
+
return {
|
|
58
|
+
id: phase.id,
|
|
59
|
+
type: getNodeTypeForKind(phase.kind),
|
|
60
|
+
position: { x: 0, y: 0 },
|
|
61
|
+
data: {
|
|
62
|
+
label: phase.label,
|
|
63
|
+
kind: phase.kind,
|
|
64
|
+
instructions: phase.instructions ?? null,
|
|
65
|
+
turnLimit: phase.turnLimit ?? null,
|
|
66
|
+
completionCriteria: phase.completionCriteria ?? null,
|
|
67
|
+
taskConfig: phase.taskConfig ?? null,
|
|
68
|
+
delegationConfig: phase.delegationConfig ?? null,
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface TemplateToNodesResult {
|
|
74
|
+
nodes: Node<BuilderNodeData>[]
|
|
75
|
+
edges: Edge<BuilderEdgeData>[]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function templateToNodes(template: ProtocolTemplate): TemplateToNodesResult {
|
|
79
|
+
const useSteps = Array.isArray(template.steps) && template.steps.length > 0
|
|
80
|
+
|
|
81
|
+
if (!useSteps) {
|
|
82
|
+
// Fall back to defaultPhases — phases have no edge wiring defined, so connect them sequentially
|
|
83
|
+
const nodes = template.defaultPhases.map(phaseToNode)
|
|
84
|
+
const edges: Edge<BuilderEdgeData>[] = []
|
|
85
|
+
|
|
86
|
+
for (let i = 0; i < template.defaultPhases.length - 1; i++) {
|
|
87
|
+
const source = template.defaultPhases[i]
|
|
88
|
+
const target = template.defaultPhases[i + 1]
|
|
89
|
+
edges.push({
|
|
90
|
+
id: `${source.id}--${target.id}`,
|
|
91
|
+
source: source.id,
|
|
92
|
+
target: target.id,
|
|
93
|
+
type: 'default',
|
|
94
|
+
data: { edgeType: 'default' },
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { nodes, edges }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const steps = template.steps!
|
|
102
|
+
const nodes = steps.map(stepToNode)
|
|
103
|
+
const edges: Edge<BuilderEdgeData>[] = []
|
|
104
|
+
|
|
105
|
+
for (const step of steps) {
|
|
106
|
+
// Default next step edge
|
|
107
|
+
if (step.nextStepId) {
|
|
108
|
+
edges.push({
|
|
109
|
+
id: `${step.id}--${step.nextStepId}`,
|
|
110
|
+
source: step.id,
|
|
111
|
+
target: step.nextStepId,
|
|
112
|
+
type: 'default',
|
|
113
|
+
data: { edgeType: 'default' },
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Branch case edges
|
|
118
|
+
if (step.branchCases && step.branchCases.length > 0) {
|
|
119
|
+
for (const branchCase of step.branchCases) {
|
|
120
|
+
edges.push({
|
|
121
|
+
id: `${step.id}--${branchCase.id}`,
|
|
122
|
+
source: step.id,
|
|
123
|
+
target: branchCase.nextStepId,
|
|
124
|
+
sourceHandle: branchCase.id,
|
|
125
|
+
type: 'branch',
|
|
126
|
+
label: branchCase.label,
|
|
127
|
+
data: {
|
|
128
|
+
edgeType: 'branch',
|
|
129
|
+
branchCaseId: branchCase.id,
|
|
130
|
+
label: branchCase.label,
|
|
131
|
+
},
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Default branch edge (for branch steps)
|
|
137
|
+
if (step.defaultNextStepId) {
|
|
138
|
+
edges.push({
|
|
139
|
+
id: `${step.id}--default`,
|
|
140
|
+
source: step.id,
|
|
141
|
+
target: step.defaultNextStepId,
|
|
142
|
+
sourceHandle: 'default',
|
|
143
|
+
type: 'branch',
|
|
144
|
+
label: 'Default',
|
|
145
|
+
data: {
|
|
146
|
+
edgeType: 'branch',
|
|
147
|
+
label: 'Default',
|
|
148
|
+
},
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Loop body edge
|
|
153
|
+
if (step.repeat?.bodyStepId) {
|
|
154
|
+
edges.push({
|
|
155
|
+
id: `${step.id}--loop`,
|
|
156
|
+
source: step.id,
|
|
157
|
+
target: step.repeat.bodyStepId,
|
|
158
|
+
sourceHandle: 'loop-back',
|
|
159
|
+
type: 'loop',
|
|
160
|
+
data: {
|
|
161
|
+
edgeType: 'loop',
|
|
162
|
+
isLoopback: true,
|
|
163
|
+
},
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return { nodes, edges }
|
|
169
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import type { BuilderNode, BuilderEdge } from '../protocol-builder-store'
|
|
4
|
+
import type { ProtocolStepKind } from '../../../../types'
|
|
5
|
+
import { getReachableNodes, validateDAG } from './dag-validator'
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Helpers
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
function makeNode(id: string, kind: ProtocolStepKind = 'present', label?: string): BuilderNode {
|
|
12
|
+
return {
|
|
13
|
+
id,
|
|
14
|
+
type: 'builderNode',
|
|
15
|
+
position: { x: 0, y: 0 },
|
|
16
|
+
data: {
|
|
17
|
+
label: label ?? id,
|
|
18
|
+
kind,
|
|
19
|
+
},
|
|
20
|
+
} as BuilderNode
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function makeEdge(source: string, target: string, edgeType: 'default' | 'branch' | 'loop' = 'default'): BuilderEdge {
|
|
24
|
+
return {
|
|
25
|
+
id: `${source}->${target}`,
|
|
26
|
+
source,
|
|
27
|
+
target,
|
|
28
|
+
data: {
|
|
29
|
+
edgeType,
|
|
30
|
+
},
|
|
31
|
+
} as BuilderEdge
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// getReachableNodes
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
describe('getReachableNodes', () => {
|
|
39
|
+
it('returns all connected nodes from start', () => {
|
|
40
|
+
const edges: BuilderEdge[] = [
|
|
41
|
+
makeEdge('a', 'b'),
|
|
42
|
+
makeEdge('b', 'c'),
|
|
43
|
+
makeEdge('c', 'd'),
|
|
44
|
+
]
|
|
45
|
+
const result = getReachableNodes('a', edges, ['a', 'b', 'c', 'd'])
|
|
46
|
+
assert.deepStrictEqual(result, new Set(['a', 'b', 'c', 'd']))
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('excludes disconnected nodes', () => {
|
|
50
|
+
const edges: BuilderEdge[] = [
|
|
51
|
+
makeEdge('a', 'b'),
|
|
52
|
+
]
|
|
53
|
+
const result = getReachableNodes('a', edges, ['a', 'b', 'c'])
|
|
54
|
+
assert.strictEqual(result.has('c'), false)
|
|
55
|
+
assert.deepStrictEqual(result, new Set(['a', 'b']))
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// validateDAG
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
describe('validateDAG', () => {
|
|
64
|
+
it('returns empty errors and warnings for an empty graph', () => {
|
|
65
|
+
const { errors, warnings } = validateDAG([], [])
|
|
66
|
+
assert.strictEqual(errors.length, 0)
|
|
67
|
+
assert.strictEqual(warnings.length, 0)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('valid linear graph has no errors', () => {
|
|
71
|
+
const nodes = [makeNode('a'), makeNode('b'), makeNode('c', 'complete')]
|
|
72
|
+
const edges = [makeEdge('a', 'b'), makeEdge('b', 'c')]
|
|
73
|
+
const { errors } = validateDAG(nodes, edges)
|
|
74
|
+
assert.strictEqual(errors.length, 0)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('detects orphan nodes (not connected to any edge)', () => {
|
|
78
|
+
const nodes = [makeNode('entry'), makeNode('orphan', 'present', 'Orphan'), makeNode('end', 'complete')]
|
|
79
|
+
const edges = [makeEdge('entry', 'end')]
|
|
80
|
+
const { errors } = validateDAG(nodes, edges)
|
|
81
|
+
const orphanError = errors.find((e) => e.nodeId === 'orphan')
|
|
82
|
+
assert.notStrictEqual(orphanError, undefined)
|
|
83
|
+
assert.ok(orphanError?.message.includes('not connected to any edge'))
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('detects unreachable nodes (connected but not reachable from entry)', () => {
|
|
87
|
+
// 'island' has an edge but points away from entry; entry cannot reach it
|
|
88
|
+
const nodes = [makeNode('entry'), makeNode('middle'), makeNode('island'), makeNode('end', 'complete')]
|
|
89
|
+
const edges = [
|
|
90
|
+
makeEdge('entry', 'middle'),
|
|
91
|
+
makeEdge('middle', 'end'),
|
|
92
|
+
makeEdge('island', 'end'), // island has an edge but isn't reachable from entry
|
|
93
|
+
]
|
|
94
|
+
const { errors } = validateDAG(nodes, edges)
|
|
95
|
+
const unreachableError = errors.find((e) => e.nodeId === 'island')
|
|
96
|
+
assert.notStrictEqual(unreachableError, undefined)
|
|
97
|
+
assert.ok(unreachableError?.message.includes('not reachable from the entry node'))
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('warns about nodes with no outgoing edge', () => {
|
|
101
|
+
const nodes = [makeNode('entry'), makeNode('dead-end', 'present', 'Dead End')]
|
|
102
|
+
const edges = [makeEdge('entry', 'dead-end')]
|
|
103
|
+
const { warnings } = validateDAG(nodes, edges)
|
|
104
|
+
const deadEndWarning = warnings.find((w) => w.nodeId === 'dead-end')
|
|
105
|
+
assert.notStrictEqual(deadEndWarning, undefined)
|
|
106
|
+
assert.ok(deadEndWarning?.message.includes('no outgoing edge'))
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('does not warn about complete nodes with no outgoing edge', () => {
|
|
110
|
+
const nodes = [makeNode('entry'), makeNode('done', 'complete', 'Done')]
|
|
111
|
+
const edges = [makeEdge('entry', 'done')]
|
|
112
|
+
const { warnings } = validateDAG(nodes, edges)
|
|
113
|
+
const completeWarning = warnings.find((w) => w.nodeId === 'done')
|
|
114
|
+
assert.strictEqual(completeWarning, undefined)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('detects branch cases without target edges', () => {
|
|
118
|
+
const branchNode: BuilderNode = {
|
|
119
|
+
id: 'branch1',
|
|
120
|
+
type: 'builderNode',
|
|
121
|
+
position: { x: 0, y: 0 },
|
|
122
|
+
data: {
|
|
123
|
+
label: 'My Branch',
|
|
124
|
+
kind: 'branch',
|
|
125
|
+
branchCases: [
|
|
126
|
+
{ id: 'case-yes', label: 'Yes', nextStepId: 'nodeYes' },
|
|
127
|
+
{ id: 'case-no', label: 'No', nextStepId: 'nodeNo' },
|
|
128
|
+
],
|
|
129
|
+
},
|
|
130
|
+
} as BuilderNode
|
|
131
|
+
|
|
132
|
+
// Only provide an edge for case-yes, not case-no
|
|
133
|
+
const yesEdge: BuilderEdge = {
|
|
134
|
+
id: 'branch1->nodeYes',
|
|
135
|
+
source: 'branch1',
|
|
136
|
+
target: 'nodeYes',
|
|
137
|
+
data: { edgeType: 'branch', branchCaseId: 'case-yes' },
|
|
138
|
+
} as BuilderEdge
|
|
139
|
+
|
|
140
|
+
const nodes = [makeNode('entry'), branchNode, makeNode('nodeYes', 'complete'), makeNode('nodeNo', 'complete')]
|
|
141
|
+
const edges = [makeEdge('entry', 'branch1'), yesEdge, makeEdge('nodeYes', 'nodeNo')]
|
|
142
|
+
|
|
143
|
+
const { errors } = validateDAG(nodes, edges)
|
|
144
|
+
const branchError = errors.find((e) => e.nodeId === 'branch1' && e.message.includes('No'))
|
|
145
|
+
assert.notStrictEqual(branchError, undefined)
|
|
146
|
+
// Should not flag case-yes since it has an edge
|
|
147
|
+
const yesError = errors.find((e) => e.nodeId === 'branch1' && e.message.includes('Yes'))
|
|
148
|
+
assert.strictEqual(yesError, undefined)
|
|
149
|
+
})
|
|
150
|
+
})
|