cli-swarm 7.0.19 → 7.0.25
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 +16 -1
- package/broker.mjs +301 -0
- package/cli.mjs +54 -12
- package/installer.mjs +57 -154
- package/package.json +8 -3
- package/skill/SKILL.md +25 -18
- package/skill/references/autocoord.md +33 -0
- package/skill/skill.json +2 -2
- package/swarm-coordinator-fs.mjs +265 -0
- package/swarm-coordinator-model.mjs +272 -0
- package/swarm-coordinator.mjs +500 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { coordinatorError, identifier, relativePath } from './swarm-coordinator-fs.mjs'
|
|
3
|
+
|
|
4
|
+
const ACTIONS = new Set(['inspect', 'modify', 'build', 'deploy', 'publish', 'wait'])
|
|
5
|
+
const LOCK_TYPES = new Set(['file', 'build', 'deploy'])
|
|
6
|
+
const MESSAGE_TYPES = new Set([
|
|
7
|
+
'range-declare',
|
|
8
|
+
'conflict-alert',
|
|
9
|
+
'lock-granted',
|
|
10
|
+
'lock-denied',
|
|
11
|
+
'baseline-handshake',
|
|
12
|
+
'need-human',
|
|
13
|
+
'dependency-wait',
|
|
14
|
+
])
|
|
15
|
+
const WAIT_TIMEOUT_ACTIONS = new Set(['escalate-need-human', 'abandon-wait', 'continue-after-timeout'])
|
|
16
|
+
const WAIT_EVENT_ACTIONS = new Set(['wake-with-package'])
|
|
17
|
+
const TASK_STATUSES = new Set(['active', 'waiting', 'completed', 'failed', 'reclaimed'])
|
|
18
|
+
const SHA256_PATTERN = /^[0-9a-f]{64}$/
|
|
19
|
+
|
|
20
|
+
function requireString(value, label) {
|
|
21
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
22
|
+
coordinatorError('SWARM_COORD_FIELD_REQUIRED', `${label} is required`)
|
|
23
|
+
}
|
|
24
|
+
return value.trim()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function requireStringArray(value, label, options = {}) {
|
|
28
|
+
if (!Array.isArray(value) || (options.nonEmpty && value.length === 0)
|
|
29
|
+
|| value.some((item) => typeof item !== 'string' || !item.trim())) {
|
|
30
|
+
coordinatorError('SWARM_COORD_FIELD_INVALID', `${label} must be an array of non-empty strings`)
|
|
31
|
+
}
|
|
32
|
+
const normalized = value.map((item) => item.trim())
|
|
33
|
+
if (new Set(normalized).size !== normalized.length) {
|
|
34
|
+
coordinatorError('SWARM_COORD_FIELD_INVALID', `${label} must not contain duplicates`)
|
|
35
|
+
}
|
|
36
|
+
return normalized
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function normalizeTaskCard(input, now = new Date().toISOString()) {
|
|
40
|
+
const taskScope = requireStringArray(input.taskScope, 'taskScope', { nonEmpty: true })
|
|
41
|
+
.map((path) => relativePath(path, 'taskScope path'))
|
|
42
|
+
const plannedActions = requireStringArray(input.plannedActions, 'plannedActions', { nonEmpty: true })
|
|
43
|
+
if (plannedActions.some((action) => !ACTIONS.has(action))) {
|
|
44
|
+
coordinatorError('SWARM_COORD_ACTION_INVALID', 'plannedActions contains an unsupported action')
|
|
45
|
+
}
|
|
46
|
+
if (input.deployTarget !== null && (typeof input.deployTarget !== 'string' || !input.deployTarget.trim())) {
|
|
47
|
+
coordinatorError('SWARM_COORD_DEPLOY_TARGET_INVALID', 'deployTarget must be null or a non-empty string')
|
|
48
|
+
}
|
|
49
|
+
const baselineHash = requireString(input.baselineHash, 'baselineHash')
|
|
50
|
+
if (!SHA256_PATTERN.test(baselineHash)) {
|
|
51
|
+
coordinatorError('SWARM_COORD_BASELINE_INVALID', 'baselineHash must be a lowercase SHA-256 digest')
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
schemaVersion: 'swarm.task-card/1.0',
|
|
55
|
+
taskId: identifier(input.taskId, 'taskId'),
|
|
56
|
+
agentId: identifier(input.agentId, 'agentId'),
|
|
57
|
+
chainId: identifier(input.chainId, 'chainId'),
|
|
58
|
+
taskScope,
|
|
59
|
+
plannedActions,
|
|
60
|
+
deployTarget: input.deployTarget === null ? null : input.deployTarget.trim(),
|
|
61
|
+
eta: requireString(input.eta, 'eta'),
|
|
62
|
+
baselineHash,
|
|
63
|
+
archConstraints: requireStringArray(input.archConstraints, 'archConstraints'),
|
|
64
|
+
status: 'active',
|
|
65
|
+
baselineHandshake: null,
|
|
66
|
+
registeredAt: now,
|
|
67
|
+
updatedAt: now,
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function pathOverlap(left, right) {
|
|
72
|
+
return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function matchingScopes(left, right) {
|
|
76
|
+
return left.taskScope.flatMap((leftPath) => right.taskScope
|
|
77
|
+
.filter((rightPath) => pathOverlap(leftPath, rightPath))
|
|
78
|
+
.map((rightPath) => ({ leftPath, rightPath })))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function constraintClaims(card, prefix) {
|
|
82
|
+
return card.archConstraints
|
|
83
|
+
.filter((value) => value.startsWith(prefix))
|
|
84
|
+
.map((value) => value.slice(prefix.length).trim())
|
|
85
|
+
.filter(Boolean)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function architectureConflicts(left, right) {
|
|
89
|
+
const leftForbidden = constraintClaims(left, 'forbid-scope:')
|
|
90
|
+
const rightForbidden = constraintClaims(right, 'forbid-scope:')
|
|
91
|
+
return [
|
|
92
|
+
...leftForbidden.flatMap((blocked) => right.taskScope
|
|
93
|
+
.filter((path) => pathOverlap(relativePath(blocked, 'forbid-scope'), path))
|
|
94
|
+
.map((path) => ({ owner: left.taskId, blocked: path }))),
|
|
95
|
+
...rightForbidden.flatMap((blocked) => left.taskScope
|
|
96
|
+
.filter((path) => pathOverlap(relativePath(blocked, 'forbid-scope'), path))
|
|
97
|
+
.map((path) => ({ owner: right.taskId, blocked: path }))),
|
|
98
|
+
]
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function requirementConflicts(left, right) {
|
|
102
|
+
const parse = (card) => new Map(constraintClaims(card, 'requirement:').map((claim) => {
|
|
103
|
+
const separator = claim.indexOf('=')
|
|
104
|
+
if (separator < 1 || separator === claim.length - 1) {
|
|
105
|
+
coordinatorError('SWARM_COORD_REQUIREMENT_INVALID', 'requirement claims must use requirement:key=value')
|
|
106
|
+
}
|
|
107
|
+
return [claim.slice(0, separator).trim(), claim.slice(separator + 1).trim()]
|
|
108
|
+
}))
|
|
109
|
+
const leftClaims = parse(left)
|
|
110
|
+
const rightClaims = parse(right)
|
|
111
|
+
return [...leftClaims.entries()].filter(([key, value]) => (
|
|
112
|
+
rightClaims.has(key) && rightClaims.get(key) !== value
|
|
113
|
+
)).map(([key, value]) => ({ key, left: value, right: rightClaims.get(key) }))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function scanTaskConflicts(candidate, tasks) {
|
|
117
|
+
const conflicts = []
|
|
118
|
+
for (const task of tasks) {
|
|
119
|
+
if (task.taskId === candidate.taskId || task.status === 'completed' || task.status === 'failed') continue
|
|
120
|
+
const scopes = matchingScopes(candidate, task)
|
|
121
|
+
if (scopes.length) conflicts.push({ type: 'file-range', risk: 'medium', taskId: task.taskId, evidence: scopes })
|
|
122
|
+
const sameTarget = candidate.deployTarget !== null && candidate.deployTarget === task.deployTarget
|
|
123
|
+
const releaseActions = (card) => card.plannedActions.some((action) => action === 'build'
|
|
124
|
+
|| action === 'deploy' || action === 'publish')
|
|
125
|
+
if (sameTarget && releaseActions(candidate) && releaseActions(task)) {
|
|
126
|
+
conflicts.push({ type: 'release-sequence', risk: 'high', taskId: task.taskId,
|
|
127
|
+
evidence: { deployTarget: candidate.deployTarget } })
|
|
128
|
+
}
|
|
129
|
+
const architecture = architectureConflicts(candidate, task)
|
|
130
|
+
if (architecture.length) conflicts.push({ type: 'architecture', risk: 'medium', taskId: task.taskId, evidence: architecture })
|
|
131
|
+
const requirements = requirementConflicts(candidate, task)
|
|
132
|
+
if (requirements.length) conflicts.push({ type: 'requirement', risk: 'high', taskId: task.taskId, evidence: requirements })
|
|
133
|
+
}
|
|
134
|
+
return conflicts
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function normalizeLockRequest(input) {
|
|
138
|
+
const lockType = requireString(input.lockType, 'lockType')
|
|
139
|
+
if (!LOCK_TYPES.has(lockType)) coordinatorError('SWARM_COORD_LOCK_TYPE_INVALID', 'lockType is invalid')
|
|
140
|
+
if (typeof input.queueOnConflict !== 'boolean') {
|
|
141
|
+
coordinatorError('SWARM_COORD_QUEUE_POLICY_REQUIRED', 'queueOnConflict must be a boolean')
|
|
142
|
+
}
|
|
143
|
+
if (!Number.isInteger(input.ttlSeconds) || input.ttlSeconds < 1 || input.ttlSeconds > 3_600) {
|
|
144
|
+
coordinatorError('SWARM_COORD_LOCK_TTL_INVALID', 'ttlSeconds must be 1..3600')
|
|
145
|
+
}
|
|
146
|
+
const paths = lockType === 'file'
|
|
147
|
+
? requireStringArray(input.paths, 'paths', { nonEmpty: true }).map((path) => relativePath(path))
|
|
148
|
+
: []
|
|
149
|
+
const resource = lockType === 'file' ? 'repository-files' : requireString(input.resource, 'resource')
|
|
150
|
+
if (input.baselineHandshakeId !== null
|
|
151
|
+
&& (typeof input.baselineHandshakeId !== 'string' || !input.baselineHandshakeId.trim())) {
|
|
152
|
+
coordinatorError('SWARM_COORD_HANDSHAKE_INVALID', 'baselineHandshakeId must be null or a non-empty string')
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
taskId: identifier(input.taskId, 'taskId'),
|
|
156
|
+
agentId: identifier(input.agentId, 'agentId'),
|
|
157
|
+
chainId: identifier(input.chainId, 'chainId'),
|
|
158
|
+
lockType,
|
|
159
|
+
resource,
|
|
160
|
+
paths,
|
|
161
|
+
ttlSeconds: input.ttlSeconds,
|
|
162
|
+
queueOnConflict: input.queueOnConflict,
|
|
163
|
+
baselineHandshakeId: input.baselineHandshakeId,
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function locksConflict(request, lock) {
|
|
168
|
+
if (lock.status !== 'active' || request.lockType !== lock.lockType) return false
|
|
169
|
+
if (request.lockType === 'file') {
|
|
170
|
+
return request.paths.some((path) => lock.paths.some((lockedPath) => pathOverlap(path, lockedPath)))
|
|
171
|
+
}
|
|
172
|
+
return request.resource === lock.resource
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function normalizeWait(input, now = new Date().toISOString()) {
|
|
176
|
+
if (!Number.isInteger(input.expectedWithinMs) || input.expectedWithinMs < 1) {
|
|
177
|
+
coordinatorError('SWARM_COORD_WAIT_DURATION_INVALID', 'expectedWithinMs must be a positive integer')
|
|
178
|
+
}
|
|
179
|
+
if (!WAIT_EVENT_ACTIONS.has(input.onEvent) || !WAIT_TIMEOUT_ACTIONS.has(input.onTimeout)) {
|
|
180
|
+
coordinatorError('SWARM_COORD_WAIT_POLICY_INVALID', 'onEvent or onTimeout is invalid')
|
|
181
|
+
}
|
|
182
|
+
const startedAtMs = Date.parse(now)
|
|
183
|
+
return {
|
|
184
|
+
schemaVersion: 'swarm.dependency-wait/1.0',
|
|
185
|
+
waitId: `wait-${randomUUID()}`,
|
|
186
|
+
taskId: identifier(input.taskId, 'taskId'),
|
|
187
|
+
chainId: identifier(input.chainId, 'chainId'),
|
|
188
|
+
waiter: identifier(input.waiter, 'waiter'),
|
|
189
|
+
waitFor: identifier(input.waitFor, 'waitFor'),
|
|
190
|
+
event: identifier(input.event, 'event'),
|
|
191
|
+
purpose: requireString(input.purpose, 'purpose'),
|
|
192
|
+
expectedWithinMs: input.expectedWithinMs,
|
|
193
|
+
deadlineAt: new Date(startedAtMs + input.expectedWithinMs).toISOString(),
|
|
194
|
+
onEvent: input.onEvent,
|
|
195
|
+
onTimeout: input.onTimeout,
|
|
196
|
+
refetchPaths: requireStringArray(input.refetchPaths, 'refetchPaths')
|
|
197
|
+
.map((path) => relativePath(path, 'refetchPaths path')),
|
|
198
|
+
status: 'active',
|
|
199
|
+
startedAt: now,
|
|
200
|
+
resolvedAt: null,
|
|
201
|
+
resolution: null,
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function detectWaitCycles(waits) {
|
|
206
|
+
const graph = new Map()
|
|
207
|
+
for (const wait of waits.filter((item) => item.status === 'active')) {
|
|
208
|
+
if (!graph.has(wait.waiter)) graph.set(wait.waiter, new Set())
|
|
209
|
+
graph.get(wait.waiter).add(wait.waitFor)
|
|
210
|
+
}
|
|
211
|
+
const visiting = new Set()
|
|
212
|
+
const visited = new Set()
|
|
213
|
+
const path = []
|
|
214
|
+
const cycles = []
|
|
215
|
+
function visit(agent) {
|
|
216
|
+
if (visiting.has(agent)) {
|
|
217
|
+
const start = path.indexOf(agent)
|
|
218
|
+
cycles.push([...path.slice(start), agent])
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
if (visited.has(agent)) return
|
|
222
|
+
visiting.add(agent)
|
|
223
|
+
path.push(agent)
|
|
224
|
+
for (const dependency of graph.get(agent) ?? []) visit(dependency)
|
|
225
|
+
path.pop()
|
|
226
|
+
visiting.delete(agent)
|
|
227
|
+
visited.add(agent)
|
|
228
|
+
}
|
|
229
|
+
for (const agent of graph.keys()) visit(agent)
|
|
230
|
+
return cycles
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function coordinationMessage(type, from, to, payload, now = new Date().toISOString()) {
|
|
234
|
+
if (!MESSAGE_TYPES.has(type)) coordinatorError('SWARM_COORD_MESSAGE_TYPE_INVALID', 'message type is invalid')
|
|
235
|
+
return {
|
|
236
|
+
schemaVersion: 'swarm.coord-message/1.0',
|
|
237
|
+
messageId: `message-${randomUUID()}`,
|
|
238
|
+
type,
|
|
239
|
+
from,
|
|
240
|
+
to,
|
|
241
|
+
payload,
|
|
242
|
+
createdAt: now,
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function requireTaskStatus(value) {
|
|
247
|
+
if (!TASK_STATUSES.has(value)) coordinatorError('SWARM_COORD_TASK_STATUS_INVALID', 'task status is invalid')
|
|
248
|
+
return value
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function validateInputShape(input, schema) {
|
|
252
|
+
const missing = schema.required.filter((key) => !(key in input))
|
|
253
|
+
const unknown = Object.keys(input).filter((key) => !(key in schema.properties))
|
|
254
|
+
if (missing.length || unknown.length) {
|
|
255
|
+
coordinatorError('SWARM_COORD_INPUT_SCHEMA_INVALID',
|
|
256
|
+
`input shape is invalid; missing=${missing.join(',')}; unknown=${unknown.join(',')}`)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export {
|
|
261
|
+
coordinationMessage,
|
|
262
|
+
detectWaitCycles,
|
|
263
|
+
locksConflict,
|
|
264
|
+
normalizeLockRequest,
|
|
265
|
+
normalizeTaskCard,
|
|
266
|
+
normalizeWait,
|
|
267
|
+
pathOverlap,
|
|
268
|
+
requireString,
|
|
269
|
+
requireTaskStatus,
|
|
270
|
+
scanTaskConflicts,
|
|
271
|
+
validateInputShape,
|
|
272
|
+
}
|