cli-swarm 7.0.19 → 7.0.28

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.
@@ -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
+ }