@swarmclawai/swarmclaw 1.3.6 → 1.4.2

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.
Files changed (126) hide show
  1. package/README.md +16 -52
  2. package/next.config.ts +9 -4
  3. package/package.json +18 -10
  4. package/scripts/build-bootstrap-env.mjs +24 -0
  5. package/scripts/run-next-build.mjs +74 -0
  6. package/scripts/run-next-typegen.mjs +61 -0
  7. package/src/app/api/.well-known/agent-card/route.ts +46 -0
  8. package/src/app/api/a2a/route.ts +56 -0
  9. package/src/app/api/a2a/tasks/[taskId]/status/route.ts +49 -0
  10. package/src/app/api/approvals/route.test.ts +29 -3
  11. package/src/app/api/approvals/route.ts +13 -7
  12. package/src/app/api/chats/[id]/chat/route.test.ts +64 -0
  13. package/src/app/api/chats/[id]/chat/route.ts +24 -8
  14. package/src/app/api/chats/[id]/deploy/route.ts +2 -2
  15. package/src/app/api/chats/chat-route.test.ts +68 -0
  16. package/src/app/api/connectors/[id]/doctor/route.test.ts +97 -0
  17. package/src/app/api/connectors/[id]/doctor/route.ts +26 -1
  18. package/src/app/api/connectors/connector-doctor-route.test.ts +1 -0
  19. package/src/app/api/logs/route.test.ts +61 -0
  20. package/src/app/api/logs/route.ts +35 -0
  21. package/src/app/api/openclaw/sync/route.ts +1 -1
  22. package/src/app/api/swarmfeed/channels/route.ts +14 -0
  23. package/src/app/api/swarmfeed/posts/route.ts +60 -0
  24. package/src/app/api/swarmfeed/route.ts +37 -0
  25. package/src/app/api/tts/route.test.ts +82 -0
  26. package/src/app/api/tts/route.ts +13 -6
  27. package/src/app/api/tts/stream/route.ts +12 -5
  28. package/src/app/error.tsx +32 -0
  29. package/src/app/global-error.tsx +33 -0
  30. package/src/app/protocols/builder/[templateId]/page.tsx +93 -0
  31. package/src/app/protocols/page.tsx +16 -7
  32. package/src/app/swarmfeed/page.tsx +7 -0
  33. package/src/cli/index.js +22 -0
  34. package/src/cli/spec.js +9 -0
  35. package/src/components/agents/agent-avatar.tsx +2 -5
  36. package/src/components/agents/agent-sheet.tsx +10 -0
  37. package/src/components/auth/access-key-gate.tsx +25 -0
  38. package/src/components/layout/error-boundary.tsx +12 -30
  39. package/src/components/layout/error-fallback.tsx +61 -0
  40. package/src/components/layout/sidebar-rail.tsx +52 -0
  41. package/src/components/protocols/builder/edge-editor.tsx +43 -0
  42. package/src/components/protocols/builder/edge-types/branch-edge.tsx +33 -0
  43. package/src/components/protocols/builder/edge-types/default-edge.tsx +18 -0
  44. package/src/components/protocols/builder/edge-types/index.ts +3 -0
  45. package/src/components/protocols/builder/edge-types/loop-edge.tsx +19 -0
  46. package/src/components/protocols/builder/node-inspector.tsx +227 -0
  47. package/src/components/protocols/builder/node-palette.tsx +97 -0
  48. package/src/components/protocols/builder/node-types/branch-node.tsx +34 -0
  49. package/src/components/protocols/builder/node-types/complete-node.tsx +17 -0
  50. package/src/components/protocols/builder/node-types/for-each-node.tsx +21 -0
  51. package/src/components/protocols/builder/node-types/index.ts +9 -0
  52. package/src/components/protocols/builder/node-types/join-node.tsx +18 -0
  53. package/src/components/protocols/builder/node-types/loop-node.tsx +22 -0
  54. package/src/components/protocols/builder/node-types/parallel-node.tsx +31 -0
  55. package/src/components/protocols/builder/node-types/phase-node.tsx +52 -0
  56. package/src/components/protocols/builder/node-types/subflow-node.tsx +23 -0
  57. package/src/components/protocols/builder/node-types/swarm-node.tsx +26 -0
  58. package/src/components/protocols/builder/protocol-builder-canvas.tsx +184 -0
  59. package/src/components/protocols/builder/run-overlay.tsx +29 -0
  60. package/src/components/protocols/builder/template-gallery.tsx +53 -0
  61. package/src/components/protocols/builder/validation-panel.tsx +57 -0
  62. package/src/components/skills/skills-workspace.tsx +1 -9
  63. package/src/features/protocols/builder/hooks/index.ts +2 -0
  64. package/src/features/protocols/builder/hooks/use-canvas-validation.ts +14 -0
  65. package/src/features/protocols/builder/hooks/use-run-overlay.ts +39 -0
  66. package/src/features/protocols/builder/hooks/use-template-sync.ts +45 -0
  67. package/src/features/protocols/builder/protocol-builder-store.ts +233 -0
  68. package/src/features/protocols/builder/utils/node-position-layout.ts +41 -0
  69. package/src/features/protocols/builder/utils/nodes-to-template.test.ts +179 -0
  70. package/src/features/protocols/builder/utils/nodes-to-template.ts +49 -0
  71. package/src/features/protocols/builder/utils/template-to-nodes.test.ts +314 -0
  72. package/src/features/protocols/builder/utils/template-to-nodes.ts +169 -0
  73. package/src/features/protocols/builder/validators/dag-validator.test.ts +150 -0
  74. package/src/features/protocols/builder/validators/dag-validator.ts +119 -0
  75. package/src/features/swarmfeed/agent-social-settings.tsx +277 -0
  76. package/src/features/swarmfeed/compose-post.tsx +139 -0
  77. package/src/features/swarmfeed/feed-page.tsx +136 -0
  78. package/src/features/swarmfeed/post-card.tsx +114 -0
  79. package/src/features/swarmfeed/queries.ts +28 -0
  80. package/src/lib/a2a/agent-card.ts +61 -0
  81. package/src/lib/a2a/auth.ts +54 -0
  82. package/src/lib/a2a/client.ts +133 -0
  83. package/src/lib/a2a/discovery.ts +116 -0
  84. package/src/lib/a2a/handlers.ts +176 -0
  85. package/src/lib/a2a/json-rpc-router.ts +38 -0
  86. package/src/lib/a2a/types.ts +95 -0
  87. package/src/lib/app/navigation.ts +1 -0
  88. package/src/lib/app/report-client-error.ts +52 -0
  89. package/src/lib/app/view-constants.ts +9 -1
  90. package/src/lib/providers/anthropic.ts +119 -107
  91. package/src/lib/providers/ollama.ts +34 -14
  92. package/src/lib/providers/openai.ts +154 -142
  93. package/src/lib/providers/openclaw.ts +3 -3
  94. package/src/lib/server/agents/main-agent-loop.test.ts +94 -0
  95. package/src/lib/server/agents/main-agent-loop.ts +377 -41
  96. package/src/lib/server/chat-execution/chat-execution.ts +12 -7
  97. package/src/lib/server/chat-execution/chat-turn-preparation.ts +19 -12
  98. package/src/lib/server/connectors/swarmdock.ts +1 -1
  99. package/src/lib/server/extensions.ts +11 -0
  100. package/src/lib/server/messages/message-repository.ts +31 -0
  101. package/src/lib/server/openclaw/sync.ts +4 -4
  102. package/src/lib/server/protocols/protocol-a2a-delegate.ts +135 -0
  103. package/src/lib/server/protocols/protocol-normalization.ts +1 -0
  104. package/src/lib/server/protocols/protocol-step-helpers.test.ts +1 -1
  105. package/src/lib/server/protocols/protocol-step-helpers.ts +1 -0
  106. package/src/lib/server/protocols/protocol-step-processors.ts +2 -0
  107. package/src/lib/server/protocols/protocol-types.ts +1 -0
  108. package/src/lib/server/provider-health.ts +19 -3
  109. package/src/lib/server/safe-parse-body.test.ts +32 -0
  110. package/src/lib/server/safe-parse-body.ts +20 -3
  111. package/src/lib/server/session-tools/delegate.ts +151 -77
  112. package/src/lib/server/storage-auth.ts +10 -2
  113. package/src/lib/server/storage-normalization.ts +11 -0
  114. package/src/lib/server/storage.ts +113 -4
  115. package/src/lib/server/working-state/service.test.ts +2 -3
  116. package/src/lib/server/working-state/service.ts +37 -6
  117. package/src/lib/swarmfeed-client.ts +157 -0
  118. package/src/lib/validation/schemas.ts +1 -1
  119. package/src/stores/slices/data-slice.ts +3 -0
  120. package/src/stores/use-approval-store.ts +4 -1
  121. package/src/types/agent.ts +31 -1
  122. package/src/types/index.ts +1 -0
  123. package/src/types/protocol.ts +19 -0
  124. package/src/types/session.ts +1 -1
  125. package/src/types/swarmfeed.ts +30 -0
  126. package/tsconfig.json +1 -2
@@ -513,7 +513,7 @@ export async function prepareChatTurn(input: ExecuteChatTurnInput): Promise<Prep
513
513
  const session = getSession(sessionId)
514
514
  if (!session) throw new Error(`Session not found: ${sessionId}`)
515
515
  const runStartedAt = Date.now()
516
- const runMessageStartIndex = getMessageCount(sessionId)
516
+ let runMessageStartIndex = getMessageCount(sessionId)
517
517
 
518
518
  const appSettings = loadSettings()
519
519
  const lifecycleRunId = runId || `${sessionId}:${runStartedAt}`
@@ -725,17 +725,10 @@ export async function prepareChatTurn(input: ExecuteChatTurnInput): Promise<Prep
725
725
  }
726
726
  }
727
727
 
728
- const providerType = sessionForRun.provider || 'claude-cli'
729
- const provider = getProvider(providerType)
730
- if (!provider) throw new Error(`Unknown provider: ${providerType}`)
731
-
732
- if (providerType === 'claude-cli' && !fs.existsSync(session.cwd)) {
733
- throw new Error(`Directory not found: ${session.cwd}`)
734
- }
735
-
736
- const apiKey = resolveApiKeyForSession(sessionForRun, provider)
737
- const hideAssistantTranscript = internal && source === 'main-loop-followup'
738
-
728
+ // Persist the user message BEFORE provider/credential resolution so that if
729
+ // provider resolution throws (unknown provider, missing credentials, etc.),
730
+ // the user message is already in the DB and won't disappear from the chat
731
+ // when the frontend's refreshMessages overwrites the optimistic local copy.
739
732
  const shouldPersistUserMessage = shouldPersistInboundUserMessage(internal, source)
740
733
  if (shouldPersistUserMessage) {
741
734
  const [linkAnalysis, semantics] = await Promise.all([
@@ -805,8 +798,22 @@ export async function prepareChatTurn(input: ExecuteChatTurnInput): Promise<Prep
805
798
  }
806
799
  }
807
800
  }
801
+ // Update runMessageStartIndex to account for newly appended user message(s)
802
+ // so that partial persistence and finalization don't overwrite them.
803
+ runMessageStartIndex = getMessageCount(sessionId)
808
804
  }
809
805
 
806
+ const providerType = sessionForRun.provider || 'claude-cli'
807
+ const provider = getProvider(providerType)
808
+ if (!provider) throw new Error(`Unknown provider: ${providerType}`)
809
+
810
+ if (providerType === 'claude-cli' && !fs.existsSync(session.cwd)) {
811
+ throw new Error(`Directory not found: ${session.cwd}`)
812
+ }
813
+
814
+ const apiKey = resolveApiKeyForSession(sessionForRun, provider)
815
+ const hideAssistantTranscript = internal && source === 'main-loop-followup'
816
+
810
817
  const useLocalOpenClawNativeRuntime = providerType === 'openclaw' && isLocalOpenClawEndpoint(sessionForRun.apiEndpoint)
811
818
  const enabledSessionExtensions = getEnabledCapabilityIds(sessionForRun)
812
819
  const hasExtensions = enabledSessionExtensions.length > 0
@@ -22,7 +22,7 @@ interface SwarmDockConfig {
22
22
  function parseConfig(connector: Connector): SwarmDockConfig {
23
23
  const c = connector.config || {}
24
24
  return {
25
- apiUrl: c.apiUrl || 'https://api.swarmdock.ai',
25
+ apiUrl: c.apiUrl || 'https://swarmdock-api.onrender.com',
26
26
  walletAddress: c.walletAddress || '',
27
27
  agentDescription: c.agentDescription || connector.name || '',
28
28
  skills: c.skills || '',
@@ -874,6 +874,17 @@ class ExtensionManager {
874
874
  try {
875
875
  const parsed = JSON.parse(fs.readFileSync(EXTENSION_FAILURES, 'utf8')) as Record<string, ExtensionFailureRecord>
876
876
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
877
+ // Prune records older than 7 days
878
+ const maxAgeMs = 7 * 24 * 60 * 60 * 1000
879
+ const now = Date.now()
880
+ let pruned = false
881
+ for (const key of Object.keys(parsed)) {
882
+ if (now - (parsed[key].lastFailedAt || 0) > maxAgeMs) {
883
+ delete parsed[key]
884
+ pruned = true
885
+ }
886
+ }
887
+ if (pruned) this.writeFailureState(parsed)
877
888
  return parsed
878
889
  } catch {
879
890
  return {}
@@ -286,6 +286,37 @@ export function clearMessages(sessionId: string): void {
286
286
  /** Replace the entire message list (used after in-memory prune operations). */
287
287
  export function replaceAllMessages(sessionId: string, messages: Message[]): void {
288
288
  perf.measureSync('message-repo', 'replaceAllMessages', () => {
289
+ // Safety guard: reload current user messages from DB and ensure none are
290
+ // dropped by the replacement. This prevents races where partial persistence
291
+ // or finalization load a stale snapshot that's missing recently-appended
292
+ // user messages.
293
+ const currentRows = stmts().selectAll.all(sessionId) as Array<{ data: string }>
294
+ const currentUserMessages: Message[] = []
295
+ for (const row of currentRows) {
296
+ const m = parseMsg(row.data)
297
+ if (m && m.role === 'user') currentUserMessages.push(m)
298
+ }
299
+ const replacementUserTimes = new Set(
300
+ messages.filter(m => m.role === 'user' && typeof m.time === 'number').map(m => m.time),
301
+ )
302
+ const missingUsers = currentUserMessages.filter(
303
+ m => typeof m.time === 'number' && !replacementUserTimes.has(m.time),
304
+ )
305
+ if (missingUsers.length > 0) {
306
+ // Re-insert missing user messages at their correct position (before the
307
+ // first assistant message that follows them chronologically).
308
+ for (const user of missingUsers) {
309
+ let insertIdx = messages.length
310
+ for (let i = 0; i < messages.length; i++) {
311
+ if (messages[i].role === 'assistant' && typeof messages[i].time === 'number'
312
+ && (messages[i].time as number) >= (user.time as number)) {
313
+ insertIdx = i
314
+ break
315
+ }
316
+ }
317
+ messages.splice(insertIdx, 0, user)
318
+ }
319
+ }
289
320
  withTransaction(() => {
290
321
  stmts().deleteAll.run(sessionId)
291
322
  const ins = stmts().insert
@@ -381,7 +381,7 @@ export function syncExtensionsFromOpenClaw(): { imported: number } {
381
381
  const openclawExtensionDir = path.join(config.workspacePath, 'plugins')
382
382
  if (!fs.existsSync(openclawExtensionDir)) return { imported: 0 }
383
383
 
384
- const localExtensionDir = path.join(DATA_DIR, 'plugins')
384
+ const localExtensionDir = path.join(DATA_DIR, 'extensions')
385
385
  ensureDir(localExtensionDir)
386
386
 
387
387
  const files = fs.readdirSync(openclawExtensionDir).filter((f) => f.endsWith('.js'))
@@ -432,7 +432,7 @@ export function setSharedDeviceToken(token: string): void {
432
432
 
433
433
  // --- Unified Sync Entry Point ---
434
434
 
435
- export type SyncType = 'memory' | 'workspace' | 'schedules' | 'credentials' | 'plugins'
435
+ export type SyncType = 'memory' | 'workspace' | 'schedules' | 'credentials' | 'extensions'
436
436
 
437
437
  export interface SyncResult {
438
438
  type: SyncType
@@ -467,7 +467,7 @@ export async function runSync(params: {
467
467
  case 'credentials':
468
468
  results.push({ type, action: 'push', result: pushCredentialsToOpenClaw() })
469
469
  break
470
- case 'plugins':
470
+ case 'extensions':
471
471
  // Extensions only pull from OpenClaw
472
472
  break
473
473
  }
@@ -492,7 +492,7 @@ export async function runSync(params: {
492
492
  case 'credentials':
493
493
  results.push({ type, action: 'pull', result: await pullCredentialsFromOpenClaw() })
494
494
  break
495
- case 'plugins':
495
+ case 'extensions':
496
496
  results.push({ type, action: 'pull', result: syncExtensionsFromOpenClaw() })
497
497
  break
498
498
  }
@@ -0,0 +1,135 @@
1
+ import { genId } from '@/lib/id'
2
+ import { log } from '@/lib/server/logger'
3
+ import { errorMessage } from '@/lib/shared-utils'
4
+ import { upsertTask } from '@/lib/server/tasks/task-repository'
5
+ import { notify } from '@/lib/server/ws-hub'
6
+ import { callA2AAgent } from '@/lib/a2a/client'
7
+ import { loadExternalAgents } from '@/lib/server/storage'
8
+ import { appendProtocolEvent, persistRun } from '@/lib/server/protocols/protocol-agent-turn'
9
+ import { now } from '@/lib/server/protocols/protocol-types'
10
+ import type { ProtocolRunDeps } from '@/lib/server/protocols/protocol-types'
11
+ import type { ProtocolPhaseDefinition, ProtocolRun, ProtocolRunPhaseState } from '@/types/protocol'
12
+ import type { BoardTask } from '@/types/task'
13
+
14
+ const TAG = 'protocol-a2a-delegate'
15
+
16
+ /**
17
+ * Process an a2a_delegate phase: call a remote A2A agent and wait for the result.
18
+ *
19
+ * Follows the same pattern as processDispatchDelegationPhase:
20
+ * 1. Create a BoardTask for tracking (with protocolRunId so wakeProtocolRunFromTaskCompletion fires)
21
+ * 2. Call the remote agent via HTTP
22
+ * 3. Set protocol run to 'waiting'
23
+ * 4. When the HTTP call completes, update the task → wake machinery resumes the run
24
+ */
25
+ export function processA2ADelegatePhase(
26
+ run: ProtocolRun,
27
+ phase: ProtocolPhaseDefinition,
28
+ deps?: ProtocolRunDeps,
29
+ ): ProtocolRun {
30
+ const config = phase.a2aDelegateConfig
31
+ if (!config?.taskName || !config?.taskMessage) {
32
+ appendProtocolEvent(run.id, {
33
+ type: 'failed',
34
+ phaseId: phase.id,
35
+ summary: `a2a_delegate phase "${phase.label}" missing a2aDelegateConfig`,
36
+ }, deps)
37
+ return persistRun({
38
+ ...run,
39
+ status: 'failed',
40
+ lastError: `a2a_delegate phase "${phase.label}" missing a2aDelegateConfig`,
41
+ endedAt: run.endedAt || now(deps),
42
+ updatedAt: now(deps),
43
+ })
44
+ }
45
+
46
+ // Resolve target URL
47
+ let targetUrl = config.targetUrl
48
+ if (!targetUrl && config.targetExternalAgentId) {
49
+ const externalAgents = loadExternalAgents()
50
+ const ea = externalAgents[config.targetExternalAgentId]
51
+ if (ea?.endpoint) {
52
+ targetUrl = ea.endpoint
53
+ }
54
+ }
55
+
56
+ if (!targetUrl) {
57
+ appendProtocolEvent(run.id, {
58
+ type: 'failed',
59
+ phaseId: phase.id,
60
+ summary: `a2a_delegate phase "${phase.label}" — no target URL resolved`,
61
+ }, deps)
62
+ return persistRun({
63
+ ...run,
64
+ status: 'failed',
65
+ lastError: `a2a_delegate phase "${phase.label}" — could not resolve target A2A agent URL`,
66
+ endedAt: run.endedAt || now(deps),
67
+ updatedAt: now(deps),
68
+ })
69
+ }
70
+
71
+ // Create a BoardTask for tracking
72
+ const taskId = genId()
73
+ const taskData: BoardTask = {
74
+ id: taskId,
75
+ title: `A2A: ${config.taskName}`,
76
+ description: config.taskMessage,
77
+ status: 'queued',
78
+ agentId: run.facilitatorAgentId || run.participantAgentIds?.[0] || '',
79
+ protocolRunId: run.id,
80
+ sourceType: 'delegation',
81
+ externalSource: { source: 'a2a', id: taskId },
82
+ queuedAt: now(deps),
83
+ createdAt: now(deps),
84
+ updatedAt: now(deps),
85
+ }
86
+ upsertTask(taskId, taskData)
87
+ notify('tasks')
88
+
89
+ appendProtocolEvent(run.id, {
90
+ type: 'delegation_dispatched',
91
+ summary: `Dispatched A2A delegation to ${targetUrl}: ${config.taskName}`,
92
+ phaseId: phase.id,
93
+ taskId,
94
+ }, deps)
95
+
96
+ log.info(TAG, `Calling remote A2A agent at ${targetUrl}`, { taskName: config.taskName, taskId })
97
+
98
+ // Fire the HTTP call asynchronously — when it completes, update the task
99
+ // The existing wakeProtocolRunFromTaskCompletion machinery will resume the run
100
+ const resolvedUrl = targetUrl
101
+ callA2AAgent(resolvedUrl, 'executeTask', {
102
+ taskId,
103
+ taskName: config.taskName,
104
+ message: config.taskMessage,
105
+ }, {
106
+ timeout: config.timeoutMs ?? 300_000,
107
+ credentialId: config.credentialId,
108
+ }).then(result => {
109
+ const resultStr = typeof result === 'string' ? result : JSON.stringify(result)
110
+ upsertTask(taskId, { ...taskData, status: 'completed', result: resultStr, updatedAt: Date.now(), completedAt: Date.now() })
111
+ notify('tasks')
112
+ log.info(TAG, `A2A delegation completed for task ${taskId}`)
113
+ // Dynamic import to break circular dependency (protocol-step-processors → protocol-a2a-delegate → protocol-run-lifecycle → protocol-step-processors)
114
+ import('@/lib/server/protocols/protocol-run-lifecycle').then(m => m.wakeProtocolRunFromTaskCompletion(taskId))
115
+ }).catch(err => {
116
+ log.error(TAG, `A2A delegation failed for task ${taskId}: ${errorMessage(err)}`)
117
+ if (config.onFailure === 'advance_with_warning') {
118
+ upsertTask(taskId, { ...taskData, status: 'completed', result: `A2A delegation failed: ${errorMessage(err)}`, error: errorMessage(err), updatedAt: Date.now(), completedAt: Date.now() })
119
+ } else {
120
+ upsertTask(taskId, { ...taskData, status: 'failed', error: errorMessage(err), updatedAt: Date.now() })
121
+ }
122
+ notify('tasks')
123
+ import('@/lib/server/protocols/protocol-run-lifecycle').then(m => m.wakeProtocolRunFromTaskCompletion(taskId))
124
+ })
125
+
126
+ const createdTaskIds = [...(run.createdTaskIds || []), taskId]
127
+ return persistRun({
128
+ ...run,
129
+ status: 'waiting',
130
+ waitingReason: `Waiting for A2A delegation: ${config.taskName}`,
131
+ createdTaskIds,
132
+ phaseState: { ...(run.phaseState || { phaseId: phase.id }), dispatchedTaskId: taskId } as ProtocolRunPhaseState,
133
+ updatedAt: now(deps),
134
+ })
135
+ }
@@ -171,6 +171,7 @@ function isDiscussionStepKindLocal(kind: string): boolean {
171
171
  'wait',
172
172
  'dispatch_task',
173
173
  'dispatch_delegation',
174
+ 'a2a_delegate',
174
175
  ].includes(kind)
175
176
  }
176
177
 
@@ -48,7 +48,7 @@ describe('protocol-step-helpers', () => {
48
48
  const kinds = [
49
49
  'present', 'collect_independent_inputs', 'round_robin',
50
50
  'compare', 'decide', 'summarize', 'emit_tasks', 'wait',
51
- 'dispatch_task', 'dispatch_delegation',
51
+ 'dispatch_task', 'dispatch_delegation', 'a2a_delegate',
52
52
  ]
53
53
  for (const kind of kinds) {
54
54
  const step = { id: `step-${kind}`, kind, label: kind } as never
@@ -58,6 +58,7 @@ export function phaseFromStep(step: ProtocolStepDefinition): ProtocolPhaseDefini
58
58
  completionCriteria: step.completionCriteria || null,
59
59
  taskConfig: step.taskConfig || null,
60
60
  delegationConfig: step.delegationConfig || null,
61
+ a2aDelegateConfig: step.a2aDelegateConfig || null,
61
62
  }
62
63
  }
63
64
 
@@ -23,6 +23,7 @@ import type * as ProtocolRunLifecycle from '@/lib/server/protocols/protocol-run-
23
23
  import { processForEachStep } from '@/lib/server/protocols/protocol-foreach'
24
24
  import { processSubflowStep } from '@/lib/server/protocols/protocol-subflow'
25
25
  import { processSwarmStep } from '@/lib/server/protocols/protocol-swarm'
26
+ import { processA2ADelegatePhase } from '@/lib/server/protocols/protocol-a2a-delegate'
26
27
  import { findRunStep } from '@/lib/server/protocols/protocol-normalization'
27
28
  import {
28
29
  appendProtocolEvent,
@@ -708,6 +709,7 @@ export async function stepProtocolRun(run: ProtocolRun, deps?: ProtocolRunDeps):
708
709
  if (phase.kind === 'emit_tasks') return processEmitTasksPhase(started, phase, deps)
709
710
  if (phase.kind === 'dispatch_task') return processDispatchTaskPhase(started, phase, deps)
710
711
  if (phase.kind === 'dispatch_delegation') return processDispatchDelegationPhase(started, phase, deps)
712
+ if (phase.kind === 'a2a_delegate') return processA2ADelegatePhase(started, phase, deps)
711
713
  return processWaitPhase(started, phase, deps)
712
714
  }
713
715
  if (step.kind === 'branch') return processBranchStep(run, step, deps)
@@ -150,5 +150,6 @@ export function isDiscussionStepKind(kind: ProtocolStepDefinition['kind'] | Prot
150
150
  'wait',
151
151
  'dispatch_task',
152
152
  'dispatch_delegation',
153
+ 'a2a_delegate',
153
154
  ].includes(kind)
154
155
  }
@@ -1,6 +1,9 @@
1
1
  import { spawnSync } from 'child_process'
2
2
  import { errorMessage, hmrSingleton, jitteredBackoff } from '@/lib/shared-utils'
3
3
  import { upsertStoredItem, loadCollection } from './storage'
4
+ import { log } from './logger'
5
+
6
+ const TAG = 'provider-health'
4
7
 
5
8
  type DelegateTool = 'delegate_to_claude_code' | 'delegate_to_codex_cli' | 'delegate_to_opencode_cli' | 'delegate_to_gemini_cli'
6
9
 
@@ -72,7 +75,12 @@ export function markProviderFailure(providerId: string, error: string, credentia
72
75
  })
73
76
  try {
74
77
  upsertStoredItem('provider_health', key, states.get(key)!)
75
- } catch {}
78
+ } catch (err) {
79
+ log.warn(TAG, 'Failed to persist provider failure state', {
80
+ providerKey: key,
81
+ error: errorMessage(err),
82
+ })
83
+ }
76
84
  }
77
85
 
78
86
  export function markProviderSuccess(providerId: string, credentialId?: string | null): void {
@@ -88,7 +96,12 @@ export function markProviderSuccess(providerId: string, credentialId?: string |
88
96
  })
89
97
  try {
90
98
  upsertStoredItem('provider_health', key, states.get(key)!)
91
- } catch {}
99
+ } catch (err) {
100
+ log.warn(TAG, 'Failed to persist provider success state', {
101
+ providerKey: key,
102
+ error: errorMessage(err),
103
+ })
104
+ }
92
105
  }
93
106
 
94
107
  export function isProviderCoolingDown(providerId: string, credentialId?: string | null): boolean {
@@ -195,7 +208,10 @@ export function restoreProviderHealthState(): number {
195
208
  }
196
209
  }
197
210
  return restored
198
- } catch { return 0 }
211
+ } catch (err) {
212
+ log.warn(TAG, 'Failed to restore persisted provider health state', { error: errorMessage(err) })
213
+ return 0
214
+ }
199
215
  }
200
216
 
201
217
  // ---------------------------------------------------------------------------
@@ -1,5 +1,6 @@
1
1
  import assert from 'node:assert/strict'
2
2
  import { describe, it, before, after } from 'node:test'
3
+ import { z } from 'zod'
3
4
 
4
5
  let safeParseBody: typeof import('@/lib/server/safe-parse-body').safeParseBody
5
6
  before(async () => {
@@ -50,4 +51,35 @@ describe('safeParseBody', () => {
50
51
  assert.equal(result.data!.name, 'test')
51
52
  assert.equal(result.data!.count, 7)
52
53
  })
54
+
55
+ it('validates the parsed body against a provided zod schema', async () => {
56
+ const result = await safeParseBody(
57
+ jsonRequest(JSON.stringify({ name: 'ok', count: 3 })),
58
+ z.object({
59
+ name: z.string().min(1),
60
+ count: z.number().int().nonnegative(),
61
+ }),
62
+ )
63
+ assert.equal(result.error, undefined)
64
+ assert.deepEqual(result.data, { name: 'ok', count: 3 })
65
+ })
66
+
67
+ it('returns a 400 validation error when schema parsing fails', async () => {
68
+ const result = await safeParseBody(
69
+ jsonRequest(JSON.stringify({ name: '', count: -1 })),
70
+ z.object({
71
+ name: z.string().min(1, 'name is required'),
72
+ count: z.number().int().nonnegative('count must be non-negative'),
73
+ }),
74
+ )
75
+ assert.equal(result.data, undefined)
76
+ assert.ok(result.error)
77
+ assert.equal(result.error.status, 400)
78
+ const body = await result.error.json()
79
+ assert.equal(body.error, 'Validation failed')
80
+ assert.deepEqual(body.issues, [
81
+ { path: 'name', message: 'name is required' },
82
+ { path: 'count', message: 'count must be non-negative' },
83
+ ])
84
+ })
53
85
  })
@@ -1,4 +1,7 @@
1
1
  import { NextResponse } from 'next/server'
2
+ import { z } from 'zod'
3
+
4
+ import { formatZodError } from '@/lib/validation/schemas'
2
5
 
3
6
  type SafeResult<T> = { data: T; error?: never } | { data?: never; error: NextResponse }
4
7
 
@@ -6,11 +9,25 @@ type SafeResult<T> = { data: T; error?: never } | { data?: never; error: NextRes
6
9
  * Wraps `req.json()` so malformed/empty bodies return a 400
7
10
  * instead of throwing an unhandled error (500).
8
11
  */
9
- export async function safeParseBody<T = Record<string, unknown>>(req: Request): Promise<SafeResult<T>> {
12
+ export async function safeParseBody<T = Record<string, unknown>>(
13
+ req: Request,
14
+ schema?: z.ZodType<T>,
15
+ ): Promise<SafeResult<T>> {
16
+ let raw: unknown
10
17
  try {
11
- const data = (await req.json()) as T
12
- return { data }
18
+ raw = await req.json()
13
19
  } catch {
14
20
  return { error: NextResponse.json({ error: 'Invalid or missing request body' }, { status: 400 }) }
15
21
  }
22
+
23
+ if (!schema) {
24
+ return { data: raw as T }
25
+ }
26
+
27
+ const parsed = schema.safeParse(raw)
28
+ if (!parsed.success) {
29
+ return { error: NextResponse.json(formatZodError(parsed.error), { status: 400 }) }
30
+ }
31
+
32
+ return { data: parsed.data }
16
33
  }