network-ai 5.10.2 → 5.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.
Files changed (273) hide show
  1. package/INTEGRATION_GUIDE.md +2 -2
  2. package/QUICKSTART.md +10 -0
  3. package/README.md +32 -3
  4. package/SKILL.md +3 -3
  5. package/dist/esm/adapters/a2a-adapter.js +235 -0
  6. package/dist/esm/adapters/a2a-adapter.js.map +1 -0
  7. package/dist/esm/adapters/adapter-registry.js +613 -0
  8. package/dist/esm/adapters/adapter-registry.js.map +1 -0
  9. package/dist/esm/adapters/agno-adapter.js +140 -0
  10. package/dist/esm/adapters/agno-adapter.js.map +1 -0
  11. package/dist/esm/adapters/anthropic-computer-use-adapter.js +180 -0
  12. package/dist/esm/adapters/anthropic-computer-use-adapter.js.map +1 -0
  13. package/dist/esm/adapters/aps-adapter.js +289 -0
  14. package/dist/esm/adapters/aps-adapter.js.map +1 -0
  15. package/dist/esm/adapters/autogen-adapter.js +141 -0
  16. package/dist/esm/adapters/autogen-adapter.js.map +1 -0
  17. package/dist/esm/adapters/base-adapter.js +104 -0
  18. package/dist/esm/adapters/base-adapter.js.map +1 -0
  19. package/dist/esm/adapters/browser-agent-adapter.js +219 -0
  20. package/dist/esm/adapters/browser-agent-adapter.js.map +1 -0
  21. package/dist/esm/adapters/codex-adapter.js +318 -0
  22. package/dist/esm/adapters/codex-adapter.js.map +1 -0
  23. package/dist/esm/adapters/copilot-adapter.js +132 -0
  24. package/dist/esm/adapters/copilot-adapter.js.map +1 -0
  25. package/dist/esm/adapters/crewai-adapter.js +148 -0
  26. package/dist/esm/adapters/crewai-adapter.js.map +1 -0
  27. package/dist/esm/adapters/custom-adapter.js +142 -0
  28. package/dist/esm/adapters/custom-adapter.js.map +1 -0
  29. package/dist/esm/adapters/custom-streaming-adapter.js +181 -0
  30. package/dist/esm/adapters/custom-streaming-adapter.js.map +1 -0
  31. package/dist/esm/adapters/dspy-adapter.js +127 -0
  32. package/dist/esm/adapters/dspy-adapter.js.map +1 -0
  33. package/dist/esm/adapters/haystack-adapter.js +149 -0
  34. package/dist/esm/adapters/haystack-adapter.js.map +1 -0
  35. package/dist/esm/adapters/hermes-adapter.js +217 -0
  36. package/dist/esm/adapters/hermes-adapter.js.map +1 -0
  37. package/dist/esm/adapters/index.js +109 -0
  38. package/dist/esm/adapters/index.js.map +1 -0
  39. package/dist/esm/adapters/langchain-adapter.js +134 -0
  40. package/dist/esm/adapters/langchain-adapter.js.map +1 -0
  41. package/dist/esm/adapters/langchain-streaming-adapter.js +161 -0
  42. package/dist/esm/adapters/langchain-streaming-adapter.js.map +1 -0
  43. package/dist/esm/adapters/langgraph-adapter.js +119 -0
  44. package/dist/esm/adapters/langgraph-adapter.js.map +1 -0
  45. package/dist/esm/adapters/llamaindex-adapter.js +135 -0
  46. package/dist/esm/adapters/llamaindex-adapter.js.map +1 -0
  47. package/dist/esm/adapters/mcp-adapter.js +200 -0
  48. package/dist/esm/adapters/mcp-adapter.js.map +1 -0
  49. package/dist/esm/adapters/minimax-adapter.js +233 -0
  50. package/dist/esm/adapters/minimax-adapter.js.map +1 -0
  51. package/dist/esm/adapters/nemoclaw-adapter.js +465 -0
  52. package/dist/esm/adapters/nemoclaw-adapter.js.map +1 -0
  53. package/dist/esm/adapters/openai-agents-adapter.js +118 -0
  54. package/dist/esm/adapters/openai-agents-adapter.js.map +1 -0
  55. package/dist/esm/adapters/openai-assistants-adapter.js +130 -0
  56. package/dist/esm/adapters/openai-assistants-adapter.js.map +1 -0
  57. package/dist/esm/adapters/openclaw-adapter.js +107 -0
  58. package/dist/esm/adapters/openclaw-adapter.js.map +1 -0
  59. package/dist/esm/adapters/orchestrator-adapter.js +218 -0
  60. package/dist/esm/adapters/orchestrator-adapter.js.map +1 -0
  61. package/dist/esm/adapters/pydantic-ai-adapter.js +163 -0
  62. package/dist/esm/adapters/pydantic-ai-adapter.js.map +1 -0
  63. package/dist/esm/adapters/rlm-adapter.js +167 -0
  64. package/dist/esm/adapters/rlm-adapter.js.map +1 -0
  65. package/dist/esm/adapters/semantic-kernel-adapter.js +123 -0
  66. package/dist/esm/adapters/semantic-kernel-adapter.js.map +1 -0
  67. package/dist/esm/adapters/streaming-base-adapter.js +74 -0
  68. package/dist/esm/adapters/streaming-base-adapter.js.map +1 -0
  69. package/dist/esm/adapters/vertex-ai-adapter.js +166 -0
  70. package/dist/esm/adapters/vertex-ai-adapter.js.map +1 -0
  71. package/dist/esm/demo-control-plane.js +147 -0
  72. package/dist/esm/demo-control-plane.js.map +1 -0
  73. package/dist/esm/demo-worktree-dashboard.js +131 -0
  74. package/dist/esm/demo-worktree-dashboard.js.map +1 -0
  75. package/dist/esm/examples/01-hello-swarm.js +165 -0
  76. package/dist/esm/examples/01-hello-swarm.js.map +1 -0
  77. package/dist/esm/examples/02-fsm-pipeline.js +189 -0
  78. package/dist/esm/examples/02-fsm-pipeline.js.map +1 -0
  79. package/dist/esm/examples/03-parallel-agents.js +192 -0
  80. package/dist/esm/examples/03-parallel-agents.js.map +1 -0
  81. package/dist/esm/examples/05-code-review-swarm.js +1177 -0
  82. package/dist/esm/examples/05-code-review-swarm.js.map +1 -0
  83. package/dist/esm/examples/06-ai-pipeline-demo.js +263 -0
  84. package/dist/esm/examples/06-ai-pipeline-demo.js.map +1 -0
  85. package/dist/esm/examples/07-full-showcase.js +946 -0
  86. package/dist/esm/examples/07-full-showcase.js.map +1 -0
  87. package/dist/esm/examples/08-control-plane-stress-demo.js +186 -0
  88. package/dist/esm/examples/08-control-plane-stress-demo.js.map +1 -0
  89. package/dist/esm/examples/09-real-langchain.js +231 -0
  90. package/dist/esm/examples/09-real-langchain.js.map +1 -0
  91. package/dist/esm/examples/10-nemoclaw-sandbox-swarm.js +270 -0
  92. package/dist/esm/examples/10-nemoclaw-sandbox-swarm.js.map +1 -0
  93. package/dist/esm/examples/demo-runner.js +119 -0
  94. package/dist/esm/examples/demo-runner.js.map +1 -0
  95. package/dist/esm/index.js +1352 -0
  96. package/dist/esm/index.js.map +1 -0
  97. package/dist/esm/lib/adapter-hooks.js +216 -0
  98. package/dist/esm/lib/adapter-hooks.js.map +1 -0
  99. package/dist/esm/lib/adapter-test-harness.js +118 -0
  100. package/dist/esm/lib/adapter-test-harness.js.map +1 -0
  101. package/dist/esm/lib/agent-conversation.js +155 -0
  102. package/dist/esm/lib/agent-conversation.js.map +1 -0
  103. package/dist/esm/lib/agent-debate.js +146 -0
  104. package/dist/esm/lib/agent-debate.js.map +1 -0
  105. package/dist/esm/lib/agent-memory.js +336 -0
  106. package/dist/esm/lib/agent-memory.js.map +1 -0
  107. package/dist/esm/lib/agent-runtime.js +818 -0
  108. package/dist/esm/lib/agent-runtime.js.map +1 -0
  109. package/dist/esm/lib/agent-vcr.js +218 -0
  110. package/dist/esm/lib/agent-vcr.js.map +1 -0
  111. package/dist/esm/lib/anomaly-detector.js +178 -0
  112. package/dist/esm/lib/anomaly-detector.js.map +1 -0
  113. package/dist/esm/lib/approval-inbox.js +385 -0
  114. package/dist/esm/lib/approval-inbox.js.map +1 -0
  115. package/dist/esm/lib/auth-guardian.js +692 -0
  116. package/dist/esm/lib/auth-guardian.js.map +1 -0
  117. package/dist/esm/lib/auth-validator.js +32 -0
  118. package/dist/esm/lib/auth-validator.js.map +1 -0
  119. package/dist/esm/lib/blackboard-backend-crdt.js +251 -0
  120. package/dist/esm/lib/blackboard-backend-crdt.js.map +1 -0
  121. package/dist/esm/lib/blackboard-backend-redis.js +244 -0
  122. package/dist/esm/lib/blackboard-backend-redis.js.map +1 -0
  123. package/dist/esm/lib/blackboard-backend.js +141 -0
  124. package/dist/esm/lib/blackboard-backend.js.map +1 -0
  125. package/dist/esm/lib/blackboard-validator.js +985 -0
  126. package/dist/esm/lib/blackboard-validator.js.map +1 -0
  127. package/dist/esm/lib/circuit-breaker.js +164 -0
  128. package/dist/esm/lib/circuit-breaker.js.map +1 -0
  129. package/dist/esm/lib/claim-verifier.js +173 -0
  130. package/dist/esm/lib/claim-verifier.js.map +1 -0
  131. package/dist/esm/lib/comparison-runner.js +138 -0
  132. package/dist/esm/lib/comparison-runner.js.map +1 -0
  133. package/dist/esm/lib/compliance-monitor.js +261 -0
  134. package/dist/esm/lib/compliance-monitor.js.map +1 -0
  135. package/dist/esm/lib/confidence-filter.js +210 -0
  136. package/dist/esm/lib/confidence-filter.js.map +1 -0
  137. package/dist/esm/lib/config-watcher.js +215 -0
  138. package/dist/esm/lib/config-watcher.js.map +1 -0
  139. package/dist/esm/lib/consistency.js +274 -0
  140. package/dist/esm/lib/consistency.js.map +1 -0
  141. package/dist/esm/lib/console-ui.js +276 -0
  142. package/dist/esm/lib/console-ui.js.map +1 -0
  143. package/dist/esm/lib/context-throttler.js +171 -0
  144. package/dist/esm/lib/context-throttler.js.map +1 -0
  145. package/dist/esm/lib/control-plane.js +527 -0
  146. package/dist/esm/lib/control-plane.js.map +1 -0
  147. package/dist/esm/lib/cost-governor.js +128 -0
  148. package/dist/esm/lib/cost-governor.js.map +1 -0
  149. package/dist/esm/lib/cost-heatmap.js +161 -0
  150. package/dist/esm/lib/cost-heatmap.js.map +1 -0
  151. package/dist/esm/lib/coverage-gate.js +213 -0
  152. package/dist/esm/lib/coverage-gate.js.map +1 -0
  153. package/dist/esm/lib/coverage-reporter.js +177 -0
  154. package/dist/esm/lib/coverage-reporter.js.map +1 -0
  155. package/dist/esm/lib/crdt.js +141 -0
  156. package/dist/esm/lib/crdt.js.map +1 -0
  157. package/dist/esm/lib/dashboard-server.js +403 -0
  158. package/dist/esm/lib/dashboard-server.js.map +1 -0
  159. package/dist/esm/lib/dry-run.js +130 -0
  160. package/dist/esm/lib/dry-run.js.map +1 -0
  161. package/dist/esm/lib/env-manager.js +518 -0
  162. package/dist/esm/lib/env-manager.js.map +1 -0
  163. package/dist/esm/lib/errors.js +201 -0
  164. package/dist/esm/lib/errors.js.map +1 -0
  165. package/dist/esm/lib/event-bus.js +229 -0
  166. package/dist/esm/lib/event-bus.js.map +1 -0
  167. package/dist/esm/lib/explainability.js +102 -0
  168. package/dist/esm/lib/explainability.js.map +1 -0
  169. package/dist/esm/lib/fan-out.js +237 -0
  170. package/dist/esm/lib/fan-out.js.map +1 -0
  171. package/dist/esm/lib/federated-budget.js +322 -0
  172. package/dist/esm/lib/federated-budget.js.map +1 -0
  173. package/dist/esm/lib/fsm-journey.js +478 -0
  174. package/dist/esm/lib/fsm-journey.js.map +1 -0
  175. package/dist/esm/lib/goal-decomposer.js +698 -0
  176. package/dist/esm/lib/goal-decomposer.js.map +1 -0
  177. package/dist/esm/lib/goal-dsl.js +391 -0
  178. package/dist/esm/lib/goal-dsl.js.map +1 -0
  179. package/dist/esm/lib/job-queue.js +310 -0
  180. package/dist/esm/lib/job-queue.js.map +1 -0
  181. package/dist/esm/lib/landscape-agent.js +134 -0
  182. package/dist/esm/lib/landscape-agent.js.map +1 -0
  183. package/dist/esm/lib/learning-loop.js +181 -0
  184. package/dist/esm/lib/learning-loop.js.map +1 -0
  185. package/dist/esm/lib/lifecycle-hooks.js +148 -0
  186. package/dist/esm/lib/lifecycle-hooks.js.map +1 -0
  187. package/dist/esm/lib/locked-blackboard.js +1295 -0
  188. package/dist/esm/lib/locked-blackboard.js.map +1 -0
  189. package/dist/esm/lib/logger.js +150 -0
  190. package/dist/esm/lib/logger.js.map +1 -0
  191. package/dist/esm/lib/mcp-blackboard-tools.js +298 -0
  192. package/dist/esm/lib/mcp-blackboard-tools.js.map +1 -0
  193. package/dist/esm/lib/mcp-bridge.js +357 -0
  194. package/dist/esm/lib/mcp-bridge.js.map +1 -0
  195. package/dist/esm/lib/mcp-tool-consumer.js +287 -0
  196. package/dist/esm/lib/mcp-tool-consumer.js.map +1 -0
  197. package/dist/esm/lib/mcp-tools-control.js +392 -0
  198. package/dist/esm/lib/mcp-tools-control.js.map +1 -0
  199. package/dist/esm/lib/mcp-tools-extended.js +371 -0
  200. package/dist/esm/lib/mcp-tools-extended.js.map +1 -0
  201. package/dist/esm/lib/mcp-transport-http.js +528 -0
  202. package/dist/esm/lib/mcp-transport-http.js.map +1 -0
  203. package/dist/esm/lib/mcp-transport-sse.js +503 -0
  204. package/dist/esm/lib/mcp-transport-sse.js.map +1 -0
  205. package/dist/esm/lib/metrics.js +284 -0
  206. package/dist/esm/lib/metrics.js.map +1 -0
  207. package/dist/esm/lib/orchestrator-types.js +66 -0
  208. package/dist/esm/lib/orchestrator-types.js.map +1 -0
  209. package/dist/esm/lib/otel-bridge.js +167 -0
  210. package/dist/esm/lib/otel-bridge.js.map +1 -0
  211. package/dist/esm/lib/partition-planner.js +246 -0
  212. package/dist/esm/lib/partition-planner.js.map +1 -0
  213. package/dist/esm/lib/phase-pipeline.js +367 -0
  214. package/dist/esm/lib/phase-pipeline.js.map +1 -0
  215. package/dist/esm/lib/playground.js +224 -0
  216. package/dist/esm/lib/playground.js.map +1 -0
  217. package/dist/esm/lib/qa-orchestrator.js +296 -0
  218. package/dist/esm/lib/qa-orchestrator.js.map +1 -0
  219. package/dist/esm/lib/quadtree.js +259 -0
  220. package/dist/esm/lib/quadtree.js.map +1 -0
  221. package/dist/esm/lib/route-classifier.js +217 -0
  222. package/dist/esm/lib/route-classifier.js.map +1 -0
  223. package/dist/esm/lib/semantic-search.js +235 -0
  224. package/dist/esm/lib/semantic-search.js.map +1 -0
  225. package/dist/esm/lib/shared-blackboard.js +249 -0
  226. package/dist/esm/lib/shared-blackboard.js.map +1 -0
  227. package/dist/esm/lib/skill-composer.js +190 -0
  228. package/dist/esm/lib/skill-composer.js.map +1 -0
  229. package/dist/esm/lib/speculative-executor.js +107 -0
  230. package/dist/esm/lib/speculative-executor.js.map +1 -0
  231. package/dist/esm/lib/strategy-agent.js +626 -0
  232. package/dist/esm/lib/strategy-agent.js.map +1 -0
  233. package/dist/esm/lib/swarm-transport.js +307 -0
  234. package/dist/esm/lib/swarm-transport.js.map +1 -0
  235. package/dist/esm/lib/swarm-utils.js +510 -0
  236. package/dist/esm/lib/swarm-utils.js.map +1 -0
  237. package/dist/esm/lib/task-decomposer.js +272 -0
  238. package/dist/esm/lib/task-decomposer.js.map +1 -0
  239. package/dist/esm/lib/telemetry-provider.js +207 -0
  240. package/dist/esm/lib/telemetry-provider.js.map +1 -0
  241. package/dist/esm/lib/timeline-scrubber.js +173 -0
  242. package/dist/esm/lib/timeline-scrubber.js.map +1 -0
  243. package/dist/esm/lib/topology.js +591 -0
  244. package/dist/esm/lib/topology.js.map +1 -0
  245. package/dist/esm/lib/transport-agent.js +366 -0
  246. package/dist/esm/lib/transport-agent.js.map +1 -0
  247. package/dist/esm/lib/work-tree-dashboard.js +583 -0
  248. package/dist/esm/lib/work-tree-dashboard.js.map +1 -0
  249. package/dist/esm/lib/work-tree-ui.js +333 -0
  250. package/dist/esm/lib/work-tree-ui.js.map +1 -0
  251. package/dist/esm/lib/work-tree.js +480 -0
  252. package/dist/esm/lib/work-tree.js.map +1 -0
  253. package/dist/esm/run.js +144 -0
  254. package/dist/esm/run.js.map +1 -0
  255. package/dist/esm/security.js +1122 -0
  256. package/dist/esm/security.js.map +1 -0
  257. package/dist/index.d.ts +2 -0
  258. package/dist/index.d.ts.map +1 -1
  259. package/dist/index.js +6 -1
  260. package/dist/index.js.map +1 -1
  261. package/dist/lib/mcp-transport-http.d.ts +203 -0
  262. package/dist/lib/mcp-transport-http.d.ts.map +1 -0
  263. package/dist/lib/mcp-transport-http.js +528 -0
  264. package/dist/lib/mcp-transport-http.js.map +1 -0
  265. package/dist/lib/phase-pipeline.d.ts +31 -0
  266. package/dist/lib/phase-pipeline.d.ts.map +1 -1
  267. package/dist/lib/phase-pipeline.js +93 -1
  268. package/dist/lib/phase-pipeline.js.map +1 -1
  269. package/dist/lib/semantic-search.d.ts +42 -6
  270. package/dist/lib/semantic-search.d.ts.map +1 -1
  271. package/dist/lib/semantic-search.js +87 -6
  272. package/dist/lib/semantic-search.js.map +1 -1
  273. package/package.json +24 -4
@@ -0,0 +1,1295 @@
1
+ "use strict";
2
+ /**
3
+ * LockedBlackboard - Atomic Commitment Layer for Multi-Agent Coordination
4
+ *
5
+ * This module provides file-system mutex locks to ensure atomic writes to the
6
+ * swarm-blackboard.md, preventing split-brain scenarios when multiple agents
7
+ * attempt concurrent updates.
8
+ *
9
+ * FEATURES:
10
+ * - File-system mutexes (cross-platform)
11
+ * - Atomic propose → validate → commit workflow
12
+ * - Deadlock prevention with lock timeouts
13
+ * - Split-brain detection and recovery
14
+ *
15
+ * @module LockedBlackboard
16
+ * @version 1.0.0
17
+ * @license MIT
18
+ */
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.LockedBlackboard = exports.FileLock = void 0;
21
+ const fs_1 = require("fs");
22
+ const path_1 = require("path");
23
+ const crypto_1 = require("crypto");
24
+ const logger_1 = require("./logger");
25
+ const errors_1 = require("./errors");
26
+ const log = logger_1.Logger.create('LockedBlackboard');
27
+ // ============================================================================
28
+ // CONFIGURATION
29
+ // ============================================================================
30
+ const CONFIG = {
31
+ lockTimeoutMs: 10000, // 10 second lock timeout
32
+ lockRetryBaseMs: 50, // Initial backoff interval
33
+ lockRetryMaxMs: 1000, // Max backoff interval
34
+ staleLockThresholdMs: 30000, // Consider lock stale after 30s
35
+ maxPendingChanges: 100, // Prevent memory bloat
36
+ };
37
+ // ============================================================================
38
+ // FILE LOCK IMPLEMENTATION
39
+ // ============================================================================
40
+ /**
41
+ * Cross-platform file lock using lock files.
42
+ * Works on Windows, Linux, and macOS.
43
+ */
44
+ class FileLock {
45
+ lockPath;
46
+ lockHolder = null;
47
+ lockFd = null;
48
+ constructor(lockPath) {
49
+ this.lockPath = lockPath;
50
+ this.ensureDir();
51
+ }
52
+ ensureDir() {
53
+ const dir = (0, path_1.dirname)(this.lockPath);
54
+ // recursive: true is idempotent — no existsSync check needed
55
+ (0, fs_1.mkdirSync)(dir, { recursive: true, mode: 0o700 });
56
+ }
57
+ /**
58
+ * Attempt to acquire the lock with timeout.
59
+ * @param holderId Unique identifier for the lock holder
60
+ * @param timeoutMs Maximum time to wait for lock (default: CONFIG.lockTimeoutMs)
61
+ * @returns true if lock acquired, false if timeout
62
+ */
63
+ acquire(holderId, timeoutMs = CONFIG.lockTimeoutMs) {
64
+ const startTime = Date.now();
65
+ let retryMs = CONFIG.lockRetryBaseMs;
66
+ while (Date.now() - startTime < timeoutMs) {
67
+ // Check for stale lock — read directly to avoid existsSync+readFileSync TOCTOU
68
+ try {
69
+ const lockData = JSON.parse((0, fs_1.readFileSync)(this.lockPath, 'utf-8'));
70
+ const lockAge = Date.now() - new Date(lockData.acquired_at).getTime();
71
+ // If lock is stale, use compare-and-delete to avoid racing another waiter
72
+ // that may have already cleaned up this stale lock and created a fresh one.
73
+ if (lockAge > CONFIG.staleLockThresholdMs) {
74
+ log.warn('Stale lock detected, force releasing', { lockAgeMs: lockAge });
75
+ this.forceReleaseStale(lockData.acquired_at, lockData.pid);
76
+ }
77
+ else {
78
+ // Lock is held by someone else, wait and retry with backoff
79
+ this.sleep(retryMs);
80
+ retryMs = Math.min(retryMs * 2, CONFIG.lockRetryMaxMs);
81
+ continue;
82
+ }
83
+ }
84
+ catch (e) {
85
+ if (e.code !== 'ENOENT') {
86
+ // Corrupted lock file (cannot parse) — unconditional remove is safe here
87
+ // because no valid holder can read their own identity back from corrupted JSON.
88
+ this.forceRelease();
89
+ }
90
+ // ENOENT: no lock file yet, fall through to create
91
+ }
92
+ // Try to create lock file atomically
93
+ try {
94
+ // Use exclusive flag to prevent race conditions
95
+ this.lockFd = (0, fs_1.openSync)(this.lockPath, 'wx', 0o600);
96
+ const lockData = {
97
+ holder: holderId,
98
+ acquired_at: new Date().toISOString(),
99
+ timeout_at: new Date(Date.now() + CONFIG.lockTimeoutMs).toISOString(),
100
+ pid: process.pid
101
+ };
102
+ // Write via fd to avoid TOCTOU — no second path-based open
103
+ (0, fs_1.writeSync)(this.lockFd, JSON.stringify(lockData, null, 2));
104
+ this.lockHolder = holderId;
105
+ return true;
106
+ }
107
+ catch (error) {
108
+ if (error.code === 'EEXIST') {
109
+ // Lock file already exists, retry with backoff
110
+ this.sleep(retryMs);
111
+ retryMs = Math.min(retryMs * 2, CONFIG.lockRetryMaxMs);
112
+ continue;
113
+ }
114
+ throw error;
115
+ }
116
+ }
117
+ return false; // Timeout
118
+ }
119
+ /**
120
+ * Release the lock if we hold it.
121
+ * Verifies ownership before unlinking to prevent deleting a lock acquired by
122
+ * another process after ours was force-released as stale.
123
+ */
124
+ release() {
125
+ if (!this.lockHolder) {
126
+ return false;
127
+ }
128
+ try {
129
+ if (this.lockFd !== null) {
130
+ (0, fs_1.closeSync)(this.lockFd);
131
+ this.lockFd = null;
132
+ }
133
+ // Verify we still own the lock before deleting it.
134
+ // If another process force-released our stale lock and created its own,
135
+ // we must not delete theirs.
136
+ try {
137
+ const current = JSON.parse((0, fs_1.readFileSync)(this.lockPath, 'utf-8'));
138
+ if (current.holder === this.lockHolder && current.pid === process.pid) {
139
+ (0, fs_1.unlinkSync)(this.lockPath);
140
+ }
141
+ else {
142
+ log.warn('Release skipped: lock is owned by another holder', {
143
+ expected: this.lockHolder, found: current.holder, foundPid: current.pid
144
+ });
145
+ }
146
+ }
147
+ catch (e) {
148
+ if (e.code !== 'ENOENT') {
149
+ // File exists but unreadable — attempt removal as a best-effort
150
+ try {
151
+ (0, fs_1.unlinkSync)(this.lockPath);
152
+ }
153
+ catch { /* ignore */ }
154
+ }
155
+ // ENOENT: already removed (race with another force-release) — that's fine
156
+ }
157
+ this.lockHolder = null;
158
+ return true;
159
+ }
160
+ catch (error) {
161
+ log.error('Failed to release lock', { error: error instanceof Error ? error.message : String(error) });
162
+ return false;
163
+ }
164
+ }
165
+ /**
166
+ * Force release a stale lock (use with caution).
167
+ * Unconditional — only safe for corrupted lock files.
168
+ */
169
+ forceRelease() {
170
+ try {
171
+ if ((0, fs_1.existsSync)(this.lockPath)) {
172
+ (0, fs_1.unlinkSync)(this.lockPath);
173
+ }
174
+ }
175
+ catch {
176
+ // Ignore errors during force release
177
+ }
178
+ this.lockHolder = null;
179
+ this.lockFd = null;
180
+ }
181
+ /**
182
+ * Compare-and-delete a stale lock: only unlinks if the file still has the
183
+ * exact same identity (acquired_at + pid) we observed when we decided it
184
+ * was stale. Prevents deleting a valid lock that was freshly created by
185
+ * another waiter that beat us to the cleanup.
186
+ */
187
+ forceReleaseStale(expectedAcquiredAt, expectedPid) {
188
+ try {
189
+ const current = JSON.parse((0, fs_1.readFileSync)(this.lockPath, 'utf-8'));
190
+ if (current.acquired_at === expectedAcquiredAt && current.pid === expectedPid) {
191
+ (0, fs_1.unlinkSync)(this.lockPath);
192
+ }
193
+ // Identity changed — another process already cleaned up and acquired; leave it alone.
194
+ }
195
+ catch {
196
+ // ENOENT or parse error — file is gone or corrupt; nothing to do.
197
+ }
198
+ this.lockHolder = null;
199
+ this.lockFd = null;
200
+ }
201
+ /**
202
+ * Check current lock status.
203
+ */
204
+ getStatus() {
205
+ if (!(0, fs_1.existsSync)(this.lockPath)) {
206
+ return { locked: false };
207
+ }
208
+ try {
209
+ const lockData = JSON.parse((0, fs_1.readFileSync)(this.lockPath, 'utf-8'));
210
+ return {
211
+ locked: true,
212
+ holder: lockData.holder,
213
+ acquired_at: lockData.acquired_at,
214
+ timeout_at: lockData.timeout_at
215
+ };
216
+ }
217
+ catch {
218
+ return { locked: false };
219
+ }
220
+ }
221
+ /**
222
+ * Check if we hold the lock.
223
+ */
224
+ isHeldByMe() {
225
+ return this.lockHolder !== null;
226
+ }
227
+ sleep(ms) {
228
+ const end = Date.now() + ms;
229
+ while (Date.now() < end) {
230
+ // Busy wait (Node.js doesn't have sync sleep)
231
+ }
232
+ }
233
+ }
234
+ exports.FileLock = FileLock;
235
+ // ============================================================================
236
+ // LOCKED BLACKBOARD IMPLEMENTATION
237
+ // ============================================================================
238
+ /**
239
+ * LockedBlackboard - Thread-safe blackboard with atomic commits and audit trail.
240
+ *
241
+ * Every mutating operation (write, delete, commit) records an audit entry
242
+ * capturing the lock holder, operation duration, and change details when
243
+ * an optional {@link SecureAuditLogger} is provided.
244
+ *
245
+ * Usage:
246
+ * ```typescript
247
+ * const blackboard = new LockedBlackboard('./');
248
+ *
249
+ * // Atomic write workflow
250
+ * const changeId = blackboard.propose('task:123', { status: 'done' }, 'agent-1');
251
+ * const isValid = blackboard.validate(changeId, 'orchestrator');
252
+ * if (isValid) {
253
+ * blackboard.commit(changeId);
254
+ * } else {
255
+ * blackboard.abort(changeId);
256
+ * }
257
+ * ```
258
+ */
259
+ class LockedBlackboard {
260
+ basePath;
261
+ blackboardPath;
262
+ lockPath;
263
+ pendingDir;
264
+ lock;
265
+ cache = new Map();
266
+ pendingChanges = new Map();
267
+ auditLogger;
268
+ conflictResolution;
269
+ paused = false;
270
+ throttleMs = 0;
271
+ lastWriteTime = 0;
272
+ walPath = '';
273
+ walOpCounter = 0;
274
+ sweepTimer = null;
275
+ disableWal = false;
276
+ constructor(basePath = '.', auditLoggerOrOptions, options) {
277
+ // Resolve to an absolute path to prevent insecure relative/temp-dir path propagation
278
+ const resolvedBase = (0, path_1.resolve)(basePath);
279
+ this.basePath = resolvedBase;
280
+ // Support both signatures:
281
+ // new LockedBlackboard(path, auditLogger, options)
282
+ // new LockedBlackboard(path, options)
283
+ let env;
284
+ if (auditLoggerOrOptions && typeof auditLoggerOrOptions === 'object' && ('conflictResolution' in auditLoggerOrOptions || 'throttleMs' in auditLoggerOrOptions || 'env' in auditLoggerOrOptions || 'disableWal' in auditLoggerOrOptions)) {
285
+ const opts = auditLoggerOrOptions;
286
+ this.conflictResolution = opts.conflictResolution ?? 'first-commit-wins';
287
+ this.throttleMs = opts.throttleMs ?? 0;
288
+ env = opts.env;
289
+ this.disableWal = opts.disableWal ?? false;
290
+ }
291
+ else {
292
+ this.auditLogger = auditLoggerOrOptions;
293
+ this.conflictResolution = options?.conflictResolution ?? 'first-commit-wins';
294
+ this.throttleMs = options?.throttleMs ?? 0;
295
+ env = options?.env;
296
+ this.disableWal = options?.disableWal ?? false;
297
+ }
298
+ // Respect NETWORK_AI_MINIMAL env var for CI/test fast startup
299
+ if (process.env['NETWORK_AI_MINIMAL'] === '1') {
300
+ this.disableWal = true;
301
+ }
302
+ // Fall back to NETWORK_AI_ENV environment variable when env not supplied
303
+ const activeEnv = env ?? process.env['NETWORK_AI_ENV'] ?? '';
304
+ // Validate env name to prevent path traversal (CWE-22)
305
+ if (activeEnv && !/^[a-zA-Z0-9_-]+$/.test(activeEnv)) {
306
+ throw new Error(`Invalid environment name '${activeEnv}': only alphanumeric, dash, and underscore are allowed`);
307
+ }
308
+ if (activeEnv) {
309
+ // Scope all data to <basePath>/<env>/ for full environment isolation
310
+ const envBase = (0, path_1.join)(resolvedBase, activeEnv);
311
+ this.blackboardPath = (0, path_1.join)(envBase, 'swarm-blackboard.md');
312
+ this.lockPath = (0, path_1.join)(envBase, '.blackboard.lock');
313
+ this.pendingDir = (0, path_1.join)(envBase, 'pending_changes');
314
+ this.walPath = (0, path_1.join)(envBase, '.wal.jsonl');
315
+ }
316
+ else {
317
+ // Legacy paths — backward compatible with existing deployments
318
+ this.blackboardPath = (0, path_1.join)(resolvedBase, 'swarm-blackboard.md');
319
+ this.lockPath = (0, path_1.join)(resolvedBase, 'data', '.blackboard.lock');
320
+ this.pendingDir = (0, path_1.join)(resolvedBase, 'data', 'pending_changes');
321
+ this.walPath = (0, path_1.join)(resolvedBase, 'data', '.wal.jsonl');
322
+ }
323
+ this.lock = new FileLock(this.lockPath);
324
+ this.initialize();
325
+ }
326
+ initialize() {
327
+ // Ensure directories exist
328
+ if (!(0, fs_1.existsSync)((0, path_1.dirname)(this.blackboardPath))) {
329
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(this.blackboardPath), { recursive: true, mode: 0o700 });
330
+ }
331
+ if (!(0, fs_1.existsSync)(this.pendingDir)) {
332
+ (0, fs_1.mkdirSync)(this.pendingDir, { recursive: true, mode: 0o700 });
333
+ }
334
+ // Initialize blackboard file if needed
335
+ if (!(0, fs_1.existsSync)(this.blackboardPath)) {
336
+ this.writeInitialBlackboard();
337
+ }
338
+ // Load existing data
339
+ this.loadFromDisk();
340
+ if (!this.disableWal) {
341
+ this.replayWAL();
342
+ }
343
+ else if (process.env['NETWORK_AI_MINIMAL'] !== '1') {
344
+ // WAL was explicitly disabled via constructor option outside of the
345
+ // recognised CI/test fast-startup path. Warn so operators who
346
+ // accidentally carry NETWORK_AI_MINIMAL=1 into production notice.
347
+ log.warn('WAL is disabled: crash recovery is not active. Set disableWal:false or unset NETWORK_AI_MINIMAL to re-enable.');
348
+ }
349
+ this.loadPendingChanges();
350
+ }
351
+ writeInitialBlackboard() {
352
+ const content = `# Swarm Blackboard
353
+ Last Updated: ${new Date().toISOString()}
354
+ Content Hash: ${this.computeHash('')}
355
+
356
+ ## Active Tasks
357
+ | TaskID | Agent | Status | Started | Description |
358
+ |--------|-------|--------|---------|-------------|
359
+
360
+ ## Knowledge Cache
361
+ <!-- Cached results from agent operations -->
362
+
363
+ ## Coordination Signals
364
+ <!-- Agent availability status -->
365
+
366
+ ## Execution History
367
+ <!-- Chronological log of completed tasks -->
368
+ `;
369
+ // Atomic write: tmp + rename prevents partial writes on initial creation.
370
+ const tmpPath = `${this.blackboardPath}.tmp`;
371
+ (0, fs_1.writeFileSync)(tmpPath, content, { encoding: 'utf-8', mode: 0o600 });
372
+ (0, fs_1.renameSync)(tmpPath, this.blackboardPath);
373
+ }
374
+ computeHash(content) {
375
+ return (0, crypto_1.createHash)('sha256').update(content).digest('hex').substring(0, 16);
376
+ }
377
+ loadFromDisk() {
378
+ try {
379
+ const content = (0, fs_1.readFileSync)(this.blackboardPath, 'utf-8');
380
+ const cacheSection = content.match(/## Knowledge Cache\n([\s\S]*?)(?=\n## |$)/);
381
+ if (cacheSection) {
382
+ const entries = Array.from(cacheSection[1].matchAll(/### (\S+)\n```json\n([\s\S]*?)\n```/g));
383
+ for (const entry of entries) {
384
+ const key = entry[1];
385
+ try {
386
+ const data = JSON.parse(entry[2]);
387
+ this.cache.set(key, data);
388
+ }
389
+ catch {
390
+ // Skip malformed entries
391
+ }
392
+ }
393
+ }
394
+ }
395
+ catch (error) {
396
+ log.error('Failed to load from disk', { error: error instanceof Error ? error.message : String(error) });
397
+ }
398
+ }
399
+ loadPendingChanges() {
400
+ try {
401
+ if (!(0, fs_1.existsSync)(this.pendingDir))
402
+ return;
403
+ const files = (0, fs_1.readdirSync)(this.pendingDir);
404
+ for (const file of files) {
405
+ if (!file.endsWith('.json') || file.includes('.committed') || file.includes('.aborted')) {
406
+ continue;
407
+ }
408
+ try {
409
+ const content = (0, fs_1.readFileSync)((0, path_1.join)(this.pendingDir, file), 'utf-8');
410
+ const change = JSON.parse(content);
411
+ if (change.status === 'pending' || change.status === 'validated') {
412
+ // Reconcile against WAL-replayed cache: if the key was already
413
+ // committed (by WAL replay or a prior run), the current cache hash
414
+ // will differ from previous_hash. Such a change will always fail
415
+ // the conflict check in commit() — archive it now rather than
416
+ // leaving a zombie validated entry in pendingChanges.
417
+ if (change.status === 'validated' && this.cache.has(change.key)) {
418
+ const currentEntry = this.cache.get(change.key);
419
+ const currentHash = this.computeHash(JSON.stringify(currentEntry));
420
+ if (currentHash !== change.previous_hash) {
421
+ change.status = 'committed';
422
+ this.archivePendingChange(change);
423
+ continue;
424
+ }
425
+ }
426
+ this.pendingChanges.set(change.change_id, change);
427
+ }
428
+ }
429
+ catch {
430
+ // Skip corrupted files
431
+ }
432
+ }
433
+ // Enforce max pending changes limit
434
+ if (this.pendingChanges.size > CONFIG.maxPendingChanges) {
435
+ log.warn('Too many pending changes, cleaning up old ones', { count: this.pendingChanges.size });
436
+ this.cleanupOldPendingChanges();
437
+ }
438
+ }
439
+ catch (error) {
440
+ log.error('Failed to load pending changes', { error: error instanceof Error ? error.message : String(error) });
441
+ }
442
+ }
443
+ cleanupOldPendingChanges() {
444
+ // Sort lowest-priority first, then oldest-first within the same priority,
445
+ // so high-priority proposals (e.g., those awaiting approval gates) are
446
+ // never evicted before low-priority ones.
447
+ const sorted = Array.from(this.pendingChanges.entries())
448
+ .sort((a, b) => {
449
+ if (a[1].priority !== b[1].priority)
450
+ return a[1].priority - b[1].priority;
451
+ return new Date(a[1].proposed_at).getTime() - new Date(b[1].proposed_at).getTime();
452
+ });
453
+ // Keep only the newest/highest-priority half
454
+ const toRemove = sorted.slice(0, Math.floor(sorted.length / 2));
455
+ for (const [changeId] of toRemove) {
456
+ this.abort(changeId);
457
+ }
458
+ }
459
+ persistToDisk() {
460
+ const holderId = `writer-${(0, crypto_1.randomUUID)().substring(0, 8)}`;
461
+ if (!this.lock.acquire(holderId)) {
462
+ throw new errors_1.LockAcquisitionError('writing to blackboard');
463
+ }
464
+ try {
465
+ const cacheContent = Array.from(this.cache.entries())
466
+ .filter(([, entry]) => !this.isExpired(entry))
467
+ .map(([key, entry]) => `### ${key}\n\`\`\`json\n${JSON.stringify(entry, null, 2)}\n\`\`\``)
468
+ .join('\n\n');
469
+ const content = `# Swarm Blackboard
470
+ Last Updated: ${new Date().toISOString()}
471
+ Content Hash: ${this.computeHash(cacheContent)}
472
+
473
+ ## Active Tasks
474
+ | TaskID | Agent | Status | Started | Description |
475
+ |--------|-------|--------|---------|-------------|
476
+
477
+ ## Knowledge Cache
478
+ ${cacheContent}
479
+
480
+ ## Coordination Signals
481
+ <!-- Agent availability status -->
482
+
483
+ ## Execution History
484
+ <!-- Chronological log of completed tasks -->
485
+ `;
486
+ (0, fs_1.writeFileSync)(this.blackboardPath, content, { encoding: 'utf-8', mode: 0o600 });
487
+ }
488
+ finally {
489
+ this.lock.release();
490
+ }
491
+ }
492
+ isExpired(entry) {
493
+ if (!entry.ttl)
494
+ return false;
495
+ const expiresAt = new Date(entry.timestamp).getTime() + entry.ttl * 1000;
496
+ return Date.now() > expiresAt;
497
+ }
498
+ savePendingChange(change) {
499
+ const filePath = (0, path_1.join)(this.pendingDir, `${change.change_id}.json`);
500
+ (0, fs_1.writeFileSync)(filePath, JSON.stringify(change, null, 2), { encoding: 'utf-8', mode: 0o600 });
501
+ }
502
+ archivePendingChange(change) {
503
+ const archiveDir = (0, path_1.join)(this.pendingDir, 'archive');
504
+ if (!(0, fs_1.existsSync)(archiveDir)) {
505
+ (0, fs_1.mkdirSync)(archiveDir, { recursive: true, mode: 0o700 });
506
+ }
507
+ const sourcePath = (0, path_1.join)(this.pendingDir, `${change.change_id}.json`);
508
+ const archivePath = (0, path_1.join)(archiveDir, `${change.change_id}.${change.status}.json`);
509
+ try {
510
+ if ((0, fs_1.existsSync)(sourcePath)) {
511
+ (0, fs_1.writeFileSync)(archivePath, JSON.stringify(change, null, 2), { encoding: 'utf-8', mode: 0o600 });
512
+ (0, fs_1.unlinkSync)(sourcePath);
513
+ }
514
+ }
515
+ catch (error) {
516
+ log.error('Failed to archive change', { error: error instanceof Error ? error.message : String(error) });
517
+ }
518
+ }
519
+ // ==========================================================================
520
+ // FLOW CONTROL: PAUSE / RESUME / THROTTLE
521
+ // ==========================================================================
522
+ /**
523
+ * Pause all write and commit operations.
524
+ * Read operations continue to work while paused.
525
+ */
526
+ pause() {
527
+ this.paused = true;
528
+ }
529
+ /**
530
+ * Resume write and commit operations after a pause.
531
+ */
532
+ resume() {
533
+ this.paused = false;
534
+ }
535
+ /**
536
+ * Check if the blackboard is currently paused.
537
+ */
538
+ isPaused() {
539
+ return this.paused;
540
+ }
541
+ /**
542
+ * Set the minimum interval between write/commit operations.
543
+ * @param ms Milliseconds between writes (0 to disable throttling)
544
+ */
545
+ setThrottle(ms) {
546
+ this.throttleMs = Math.max(0, Math.floor(ms));
547
+ }
548
+ /**
549
+ * Get the current throttle interval.
550
+ */
551
+ getThrottle() {
552
+ return this.throttleMs;
553
+ }
554
+ /**
555
+ * Guard called before any mutating operation.
556
+ * Throws if paused; enforces throttle delay.
557
+ */
558
+ enforceFlowControl() {
559
+ if (this.paused) {
560
+ throw new errors_1.LockAcquisitionError('blackboard is paused');
561
+ }
562
+ if (this.throttleMs > 0) {
563
+ const elapsed = Date.now() - this.lastWriteTime;
564
+ if (elapsed < this.throttleMs) {
565
+ throw new errors_1.LockAcquisitionError(`throttled — retry after ${this.throttleMs - elapsed}ms`);
566
+ }
567
+ }
568
+ }
569
+ /**
570
+ * Record the timestamp of a successful mutating operation.
571
+ */
572
+ recordWrite() {
573
+ this.lastWriteTime = Date.now();
574
+ }
575
+ // ==========================================================================
576
+ // PUBLIC API: ATOMIC COMMIT WORKFLOW
577
+ // ==========================================================================
578
+ /**
579
+ * STEP 1: Propose a change (does NOT modify blackboard yet).
580
+ * @param key Blackboard key to write
581
+ * @param value Value to store
582
+ * @param sourceAgent Agent proposing the change
583
+ * @param ttl Optional time-to-live in seconds
584
+ * @param priority Agent priority (0=low, 1=normal, 2=high, 3=critical). Defaults to 0.
585
+ * @returns change_id for use in validate/commit/abort
586
+ */
587
+ propose(key, value, sourceAgent, ttl, priority) {
588
+ const changeId = `chg_${(0, crypto_1.randomUUID)().substring(0, 8)}`;
589
+ // Validate priority
590
+ const resolvedPriority = this.validatePriority(priority);
591
+ // Get current hash for conflict detection
592
+ const currentEntry = this.cache.get(key);
593
+ const previousHash = currentEntry
594
+ ? this.computeHash(JSON.stringify(currentEntry))
595
+ : null;
596
+ const change = {
597
+ change_id: changeId,
598
+ key,
599
+ value,
600
+ source_agent: sourceAgent,
601
+ proposed_at: new Date().toISOString(),
602
+ ttl: ttl ?? null,
603
+ status: 'pending',
604
+ previous_hash: previousHash,
605
+ priority: resolvedPriority
606
+ };
607
+ this.pendingChanges.set(changeId, change);
608
+ this.savePendingChange(change);
609
+ return changeId;
610
+ }
611
+ /**
612
+ * Validate and clamp priority to the AgentPriority range.
613
+ */
614
+ validatePriority(priority) {
615
+ if (priority === undefined || priority === null)
616
+ return 0;
617
+ if (typeof priority !== 'number' || !Number.isInteger(priority))
618
+ return 0;
619
+ return Math.max(0, Math.min(3, priority));
620
+ }
621
+ /**
622
+ * STEP 2: Validate a proposed change (check for conflicts).
623
+ *
624
+ * In `'first-commit-wins'` mode (default): fails if the key was modified since proposal.
625
+ * In `'priority-wins'` mode: allows higher-priority changes to preempt lower-priority
626
+ * pending/validated changes on the same key.
627
+ *
628
+ * @returns true if change can be safely committed
629
+ */
630
+ validate(changeId, validatorAgent) {
631
+ const change = this.pendingChanges.get(changeId);
632
+ if (!change) {
633
+ log.error('Change not found', { changeId });
634
+ return false;
635
+ }
636
+ if (change.status !== 'pending') {
637
+ log.error('Change cannot be validated', { changeId, status: change.status });
638
+ return false;
639
+ }
640
+ // Check for conflicts (has the key been modified since proposal?)
641
+ const currentEntry = this.cache.get(change.key);
642
+ const currentHash = currentEntry
643
+ ? this.computeHash(JSON.stringify(currentEntry))
644
+ : null;
645
+ if (change.previous_hash !== currentHash) {
646
+ // Conflict detected — check if priority-wins can resolve it
647
+ if (this.conflictResolution === 'priority-wins') {
648
+ const conflicting = this.findConflictingPendingChanges(change.key, changeId);
649
+ // Preempt any lower-priority pending changes
650
+ const lowerPriorityPending = conflicting.filter(c => c.priority < change.priority);
651
+ for (const victim of lowerPriorityPending) {
652
+ this.preempt(victim.change_id, changeId, change.priority, victim.priority);
653
+ }
654
+ // Check if any equal-or-higher-priority pending changes remain
655
+ const blockers = conflicting.filter(c => c.priority >= change.priority);
656
+ if (blockers.length > 0) {
657
+ // Blocked by equal/higher priority pending changes
658
+ log.warn('CONFLICT DETECTED (blocked by equal/higher priority pending)', {
659
+ key: change.key, priority: change.priority,
660
+ blockerPriorities: blockers.map(b => b.priority),
661
+ });
662
+ return false;
663
+ }
664
+ // No pending blockers — check against last committed priority
665
+ const lastCommittedPriority = this.getLastCommittedPriority(change.key);
666
+ if (change.priority > lastCommittedPriority) {
667
+ // Higher priority wins over committed value
668
+ change.previous_hash = currentHash;
669
+ log.info('Priority preemption during validate', {
670
+ changeId, key: change.key, priority: change.priority,
671
+ preemptedPending: lowerPriorityPending.length,
672
+ lastCommittedPriority,
673
+ });
674
+ }
675
+ else {
676
+ // Same or lower priority cannot overwrite committed value
677
+ log.warn('CONFLICT DETECTED (priority insufficient vs committed)', {
678
+ key: change.key, incomingPriority: change.priority,
679
+ committedPriority: lastCommittedPriority,
680
+ });
681
+ return false;
682
+ }
683
+ }
684
+ else {
685
+ log.warn('CONFLICT DETECTED', { key: change.key, expectedHash: change.previous_hash, actualHash: currentHash });
686
+ return false;
687
+ }
688
+ }
689
+ // Mark as validated
690
+ change.status = 'validated';
691
+ change.validation = {
692
+ validated_at: new Date().toISOString(),
693
+ validated_by: validatorAgent
694
+ };
695
+ this.savePendingChange(change);
696
+ return true;
697
+ }
698
+ /**
699
+ * STEP 3a: Commit a validated change (applies to blackboard).
700
+ * @returns CommitResult with success status
701
+ */
702
+ commit(changeId) {
703
+ this.enforceFlowControl();
704
+ const change = this.pendingChanges.get(changeId);
705
+ const lockStart = Date.now();
706
+ if (!change) {
707
+ return {
708
+ success: false,
709
+ change_id: changeId,
710
+ message: `Change ${changeId} not found`
711
+ };
712
+ }
713
+ if (change.status !== 'validated') {
714
+ return {
715
+ success: false,
716
+ change_id: changeId,
717
+ message: `Change ${changeId} is ${change.status}, must be validated first`
718
+ };
719
+ }
720
+ // Acquire lock and apply change atomically
721
+ const holderId = `commit-${changeId}`;
722
+ if (!this.lock.acquire(holderId)) {
723
+ this.audit('BLACKBOARD_COMMIT', change.source_agent, 'commit', 'failure', {
724
+ changeId, key: change.key, reason: 'lock_timeout', lockHolder: holderId,
725
+ });
726
+ return {
727
+ success: false,
728
+ change_id: changeId,
729
+ message: 'Failed to acquire lock for commit'
730
+ };
731
+ }
732
+ try {
733
+ // Double-check for conflicts under lock
734
+ const currentEntry = this.cache.get(change.key);
735
+ const currentHash = currentEntry
736
+ ? this.computeHash(JSON.stringify(currentEntry))
737
+ : null;
738
+ if (change.previous_hash !== currentHash) {
739
+ // Conflict under lock — check priority-wins
740
+ if (this.conflictResolution === 'priority-wins') {
741
+ // Check if the current entry was written by a lower-priority agent
742
+ const currentPriority = this.getLastCommittedPriority(change.key);
743
+ if (change.priority > currentPriority) {
744
+ // Higher priority wins — update hash and proceed
745
+ log.info('Priority preemption during commit (under lock)', {
746
+ changeId, key: change.key,
747
+ incomingPriority: change.priority,
748
+ existingPriority: currentPriority
749
+ });
750
+ this.audit('BLACKBOARD_PREEMPT', change.source_agent, 'preempt_commit', 'success', {
751
+ changeId, key: change.key,
752
+ winnerPriority: change.priority,
753
+ loserPriority: currentPriority,
754
+ lockHolder: holderId, lockDurationMs: Date.now() - lockStart,
755
+ });
756
+ }
757
+ else {
758
+ // Same or lower priority — abort
759
+ change.status = 'aborted';
760
+ this.savePendingChange(change);
761
+ this.archivePendingChange(change);
762
+ this.pendingChanges.delete(changeId);
763
+ this.audit('BLACKBOARD_COMMIT', change.source_agent, 'commit', 'failure', {
764
+ changeId, key: change.key, reason: 'conflict_priority_insufficient',
765
+ incomingPriority: change.priority, existingPriority: currentPriority,
766
+ lockHolder: holderId, lockDurationMs: Date.now() - lockStart,
767
+ });
768
+ return {
769
+ success: false,
770
+ change_id: changeId,
771
+ message: `CONFLICT: Key ${change.key} was modified by equal/higher priority agent`
772
+ };
773
+ }
774
+ }
775
+ else {
776
+ change.status = 'aborted';
777
+ this.savePendingChange(change);
778
+ this.archivePendingChange(change);
779
+ this.pendingChanges.delete(changeId);
780
+ this.audit('BLACKBOARD_COMMIT', change.source_agent, 'commit', 'failure', {
781
+ changeId, key: change.key, reason: 'conflict',
782
+ lockHolder: holderId, lockDurationMs: Date.now() - lockStart,
783
+ });
784
+ return {
785
+ success: false,
786
+ change_id: changeId,
787
+ message: `CONFLICT: Key ${change.key} was modified since validation`
788
+ };
789
+ }
790
+ }
791
+ // Apply the change
792
+ const newVersion = (currentEntry?.version ?? 0) + 1;
793
+ const entry = {
794
+ key: change.key,
795
+ value: change.value,
796
+ source_agent: change.source_agent,
797
+ timestamp: new Date().toISOString(),
798
+ ttl: change.ttl,
799
+ version: newVersion
800
+ };
801
+ this.cache.set(change.key, entry);
802
+ change.status = 'committed';
803
+ // WAL: record before disk write for crash recovery
804
+ const walOpId = `wal_${++this.walOpCounter}`;
805
+ this.appendToWAL({ op: 'write', opId: walOpId, key: change.key, entry });
806
+ // Persist to disk (still under lock)
807
+ this.persistToDiskInternal();
808
+ // WAL: checkpoint after successful disk write
809
+ this.checkpointWAL(walOpId);
810
+ this.recordWrite();
811
+ // Archive the change
812
+ this.archivePendingChange(change);
813
+ this.pendingChanges.delete(changeId);
814
+ this.audit('BLACKBOARD_COMMIT', change.source_agent, 'commit', 'success', {
815
+ changeId, key: change.key, version: newVersion,
816
+ lockHolder: holderId, lockDurationMs: Date.now() - lockStart,
817
+ validatedBy: change.validation?.validated_by,
818
+ });
819
+ return {
820
+ success: true,
821
+ change_id: changeId,
822
+ message: `Successfully committed ${change.key} (v${newVersion})`,
823
+ entry
824
+ };
825
+ }
826
+ finally {
827
+ this.lock.release();
828
+ }
829
+ }
830
+ /**
831
+ * STEP 3b: Abort a proposed/validated change.
832
+ */
833
+ abort(changeId) {
834
+ const change = this.pendingChanges.get(changeId);
835
+ if (!change) {
836
+ return false;
837
+ }
838
+ change.status = 'aborted';
839
+ this.archivePendingChange(change);
840
+ this.pendingChanges.delete(changeId);
841
+ return true;
842
+ }
843
+ // ==========================================================================
844
+ // PRIORITY & PREEMPTION HELPERS
845
+ // ==========================================================================
846
+ /**
847
+ * Find all pending/validated changes targeting the same key, excluding the given changeId.
848
+ */
849
+ findConflictingPendingChanges(key, excludeChangeId) {
850
+ return Array.from(this.pendingChanges.values()).filter(c => c.key === key && c.change_id !== excludeChangeId && (c.status === 'pending' || c.status === 'validated'));
851
+ }
852
+ /**
853
+ * Preempt a lower-priority change: abort it and emit an audit event.
854
+ */
855
+ preempt(victimChangeId, winnerChangeId, winnerPriority, victimPriority) {
856
+ const victim = this.pendingChanges.get(victimChangeId);
857
+ if (!victim)
858
+ return;
859
+ victim.status = 'aborted';
860
+ victim.preempted_by = winnerChangeId;
861
+ this.savePendingChange(victim);
862
+ this.archivePendingChange(victim);
863
+ this.pendingChanges.delete(victimChangeId);
864
+ log.info('Change preempted', {
865
+ victimChangeId, winnerChangeId, key: victim.key,
866
+ victimPriority, winnerPriority
867
+ });
868
+ this.audit('BLACKBOARD_PREEMPT', victim.source_agent, 'preempted', 'failure', {
869
+ victimChangeId, winnerChangeId, key: victim.key,
870
+ victimPriority, winnerPriority,
871
+ victimAgent: victim.source_agent,
872
+ });
873
+ }
874
+ /**
875
+ * Get the priority of the last committed change to a key.
876
+ * Falls back to 0 if unknown (legacy data without priority).
877
+ */
878
+ getLastCommittedPriority(key) {
879
+ // Check archived committed changes for this key (most recent first)
880
+ try {
881
+ const archiveDir = (0, path_1.join)(this.pendingDir, 'archive');
882
+ if (!(0, fs_1.existsSync)(archiveDir))
883
+ return 0;
884
+ const files = (0, fs_1.readdirSync)(archiveDir)
885
+ .filter(f => f.endsWith('.committed.json'))
886
+ .sort()
887
+ .reverse();
888
+ for (const file of files) {
889
+ try {
890
+ const content = (0, fs_1.readFileSync)((0, path_1.join)(archiveDir, file), 'utf-8');
891
+ const archived = JSON.parse(content);
892
+ if (archived.key === key) {
893
+ return this.validatePriority(archived.priority);
894
+ }
895
+ }
896
+ catch {
897
+ // Skip corrupted archive files
898
+ }
899
+ }
900
+ }
901
+ catch {
902
+ // Archive dir doesn't exist or read error
903
+ }
904
+ return 0;
905
+ }
906
+ /**
907
+ * Get the current conflict resolution strategy.
908
+ */
909
+ getConflictResolution() {
910
+ return this.conflictResolution;
911
+ }
912
+ // Internal persist without acquiring lock (called when already holding lock)
913
+ persistToDiskInternal() {
914
+ const cacheContent = Array.from(this.cache.entries())
915
+ .filter(([, entry]) => !this.isExpired(entry))
916
+ .map(([key, entry]) => `### ${key}\n\`\`\`json\n${JSON.stringify(entry, null, 2)}\n\`\`\``)
917
+ .join('\n\n');
918
+ const content = `# Swarm Blackboard
919
+ Last Updated: ${new Date().toISOString()}
920
+ Content Hash: ${this.computeHash(cacheContent)}
921
+
922
+ ## Active Tasks
923
+ | TaskID | Agent | Status | Started | Description |
924
+ |--------|-------|--------|---------|-------------|
925
+
926
+ ## Knowledge Cache
927
+ ${cacheContent}
928
+
929
+ ## Coordination Signals
930
+ <!-- Agent availability status -->
931
+
932
+ ## Execution History
933
+ <!-- Chronological log of completed tasks -->
934
+ `;
935
+ // Write to a temp file then rename atomically to prevent a crash mid-write
936
+ // from leaving a truncated/corrupt blackboard (which would lose committed data
937
+ // if the WAL was already compacted).
938
+ const tmpPath = `${this.blackboardPath}.tmp`;
939
+ (0, fs_1.writeFileSync)(tmpPath, content, { encoding: 'utf-8', mode: 0o600 });
940
+ (0, fs_1.renameSync)(tmpPath, this.blackboardPath);
941
+ }
942
+ // ==========================================================================
943
+ // PUBLIC API: SIMPLE READ/WRITE (with automatic locking)
944
+ // ==========================================================================
945
+ /**
946
+ * Read a value from the blackboard.
947
+ */
948
+ read(key) {
949
+ const entry = this.cache.get(key);
950
+ if (!entry)
951
+ return null;
952
+ if (this.isExpired(entry)) {
953
+ this.cache.delete(key);
954
+ this.persistToDisk();
955
+ return null;
956
+ }
957
+ return entry;
958
+ }
959
+ /**
960
+ * Direct write with automatic locking (use propose/validate/commit for multi-agent safety).
961
+ */
962
+ write(key, value, sourceAgent, ttl) {
963
+ this.enforceFlowControl();
964
+ const holderId = `write-${(0, crypto_1.randomUUID)().substring(0, 8)}`;
965
+ const lockStart = Date.now();
966
+ if (!this.lock.acquire(holderId)) {
967
+ this.audit('BLACKBOARD_WRITE', sourceAgent, 'write', 'failure', {
968
+ key, reason: 'lock_timeout', lockHolder: holderId,
969
+ });
970
+ throw new errors_1.LockAcquisitionError('write');
971
+ }
972
+ try {
973
+ const currentEntry = this.cache.get(key);
974
+ const newVersion = (currentEntry?.version ?? 0) + 1;
975
+ const entry = {
976
+ key,
977
+ value,
978
+ source_agent: sourceAgent,
979
+ timestamp: new Date().toISOString(),
980
+ ttl: ttl ?? null,
981
+ version: newVersion
982
+ };
983
+ this.cache.set(key, entry);
984
+ const walWriteId = `wal_${++this.walOpCounter}`;
985
+ this.appendToWAL({ op: 'write', opId: walWriteId, key, entry });
986
+ this.persistToDiskInternal();
987
+ this.checkpointWAL(walWriteId);
988
+ this.recordWrite();
989
+ this.audit('BLACKBOARD_WRITE', sourceAgent, 'write', 'success', {
990
+ key, version: newVersion, lockHolder: holderId,
991
+ lockDurationMs: Date.now() - lockStart,
992
+ hadPreviousValue: !!currentEntry,
993
+ });
994
+ return entry;
995
+ }
996
+ finally {
997
+ this.lock.release();
998
+ }
999
+ }
1000
+ /**
1001
+ * Delete a key from the blackboard.
1002
+ */
1003
+ delete(key) {
1004
+ this.enforceFlowControl();
1005
+ const holderId = `delete-${(0, crypto_1.randomUUID)().substring(0, 8)}`;
1006
+ const lockStart = Date.now();
1007
+ if (!this.lock.acquire(holderId)) {
1008
+ this.audit('BLACKBOARD_DELETE', 'system', 'delete', 'failure', {
1009
+ key, reason: 'lock_timeout', lockHolder: holderId,
1010
+ });
1011
+ throw new errors_1.LockAcquisitionError('delete');
1012
+ }
1013
+ try {
1014
+ if (this.cache.has(key)) {
1015
+ this.cache.delete(key);
1016
+ const walDelId = `wal_${++this.walOpCounter}`;
1017
+ this.appendToWAL({ op: 'delete', opId: walDelId, key });
1018
+ this.persistToDiskInternal();
1019
+ this.checkpointWAL(walDelId);
1020
+ this.audit('BLACKBOARD_DELETE', 'system', 'delete', 'success', {
1021
+ key, lockHolder: holderId,
1022
+ lockDurationMs: Date.now() - lockStart,
1023
+ });
1024
+ return true;
1025
+ }
1026
+ return false;
1027
+ }
1028
+ finally {
1029
+ this.lock.release();
1030
+ }
1031
+ }
1032
+ /**
1033
+ * List all valid keys.
1034
+ */
1035
+ listKeys() {
1036
+ return Array.from(this.cache.keys()).filter(key => {
1037
+ const entry = this.cache.get(key);
1038
+ return entry && !this.isExpired(entry);
1039
+ });
1040
+ }
1041
+ /**
1042
+ * Return metadata for a single blackboard entry without exposing its value.
1043
+ *
1044
+ * Useful for orchestrators that need to inspect entry shape, age, or size
1045
+ * before deciding whether to read the full value.
1046
+ *
1047
+ * @param key - Blackboard key to query.
1048
+ * @returns Metadata object, or `null` if the key does not exist / has expired.
1049
+ */
1050
+ readMetadata(key) {
1051
+ const entry = this.cache.get(key);
1052
+ if (!entry)
1053
+ return null;
1054
+ if (this.isExpired(entry)) {
1055
+ this.cache.delete(key);
1056
+ this.persistToDisk();
1057
+ return null;
1058
+ }
1059
+ return this._entryToMetadata(entry);
1060
+ }
1061
+ /**
1062
+ * Return metadata for all live (non-expired) blackboard entries.
1063
+ *
1064
+ * @returns Array of metadata objects — one per live key, in insertion order.
1065
+ */
1066
+ listMetadata() {
1067
+ const out = [];
1068
+ for (const [, entry] of Array.from(this.cache.entries())) {
1069
+ if (!this.isExpired(entry)) {
1070
+ out.push(this._entryToMetadata(entry));
1071
+ }
1072
+ }
1073
+ return out;
1074
+ }
1075
+ /** @internal */
1076
+ _entryToMetadata(entry) {
1077
+ let sizeBytes = 0;
1078
+ try {
1079
+ sizeBytes = Buffer.byteLength(JSON.stringify(entry.value), 'utf8');
1080
+ }
1081
+ catch {
1082
+ sizeBytes = 0;
1083
+ }
1084
+ return {
1085
+ key: entry.key,
1086
+ type: Array.isArray(entry.value) ? 'array' : typeof entry.value,
1087
+ sizeBytes,
1088
+ version: entry.version,
1089
+ timestamp: entry.timestamp,
1090
+ ttl: entry.ttl,
1091
+ };
1092
+ }
1093
+ /**
1094
+ * Get full snapshot of blackboard state.
1095
+ */
1096
+ getSnapshot() {
1097
+ const snapshot = {};
1098
+ for (const [key, entry] of Array.from(this.cache.entries())) {
1099
+ if (!this.isExpired(entry)) {
1100
+ snapshot[key] = entry;
1101
+ }
1102
+ }
1103
+ return snapshot;
1104
+ }
1105
+ /**
1106
+ * List all pending changes.
1107
+ */
1108
+ listPendingChanges() {
1109
+ return Array.from(this.pendingChanges.values());
1110
+ }
1111
+ /**
1112
+ * Get lock status.
1113
+ */
1114
+ getLockStatus() {
1115
+ return this.lock.getStatus();
1116
+ }
1117
+ /**
1118
+ * Attach an audit logger at runtime (useful when the logger is created
1119
+ * after the blackboard, e.g., in the orchestrator constructor).
1120
+ */
1121
+ setAuditLogger(logger) {
1122
+ this.auditLogger = logger;
1123
+ }
1124
+ // ---------- Internal audit helper ----------
1125
+ /**
1126
+ * Log an audit entry if an audit logger is attached.
1127
+ * Non-fatal: failures are swallowed so auditing never blocks operations.
1128
+ */
1129
+ audit(eventType, agentId, action, outcome, details) {
1130
+ if (!this.auditLogger)
1131
+ return;
1132
+ try {
1133
+ this.auditLogger.log(eventType, agentId, action, outcome, details);
1134
+ }
1135
+ catch {
1136
+ // Audit logging must never block blackboard operations
1137
+ }
1138
+ }
1139
+ // ==========================================================================
1140
+ // TTL SWEEP
1141
+ // ==========================================================================
1142
+ /**
1143
+ * Evict all expired entries from the in-memory cache and persist to disk.
1144
+ * Called automatically by `startSweep()` at the configured interval.
1145
+ *
1146
+ * @returns Number of entries evicted.
1147
+ */
1148
+ purgeExpired() {
1149
+ let evicted = 0;
1150
+ for (const [key, entry] of Array.from(this.cache.entries())) {
1151
+ if (this.isExpired(entry)) {
1152
+ this.cache.delete(key);
1153
+ evicted++;
1154
+ }
1155
+ }
1156
+ if (evicted > 0) {
1157
+ this.persistToDisk();
1158
+ }
1159
+ return evicted;
1160
+ }
1161
+ /**
1162
+ * Start a background sweep timer that calls `purgeExpired()` periodically.
1163
+ * Safe to call multiple times — stops any existing timer first.
1164
+ *
1165
+ * The timer is unref'd so it does not prevent process exit.
1166
+ *
1167
+ * @param intervalMs Sweep interval in milliseconds (default 60 000 = 1 min).
1168
+ */
1169
+ startSweep(intervalMs = 60_000) {
1170
+ this.stopSweep();
1171
+ this.sweepTimer = setInterval(() => { this.purgeExpired(); }, Math.max(1, intervalMs));
1172
+ // Don't prevent process exit
1173
+ if (typeof this.sweepTimer.unref === 'function')
1174
+ this.sweepTimer.unref();
1175
+ }
1176
+ /**
1177
+ * Stop the background sweep timer started by `startSweep()`.
1178
+ * Safe to call even if no sweep is running.
1179
+ */
1180
+ stopSweep() {
1181
+ if (this.sweepTimer !== null) {
1182
+ clearInterval(this.sweepTimer);
1183
+ this.sweepTimer = null;
1184
+ }
1185
+ }
1186
+ // ==========================================================================
1187
+ // WRITE-AHEAD LOG (WAL)
1188
+ // ==========================================================================
1189
+ /**
1190
+ * Append a WAL record for crash recovery.
1191
+ * Failures are logged but never propagate — WAL writes must not block ops.
1192
+ * @internal
1193
+ */
1194
+ appendToWAL(record) {
1195
+ try {
1196
+ const line = JSON.stringify({ ...record, ts: new Date().toISOString() }) + '\n';
1197
+ (0, fs_1.appendFileSync)(this.walPath, line, { encoding: 'utf-8', mode: 0o600 });
1198
+ }
1199
+ catch (err) {
1200
+ log.warn('WAL append failed', { error: err instanceof Error ? err.message : String(err) });
1201
+ }
1202
+ }
1203
+ /**
1204
+ * Append a checkpoint record — signals that the matching op reached disk.
1205
+ * @internal
1206
+ */
1207
+ checkpointWAL(opId) {
1208
+ try {
1209
+ const line = JSON.stringify({ op: 'checkpoint', opId, ts: new Date().toISOString() }) + '\n';
1210
+ (0, fs_1.appendFileSync)(this.walPath, line, { encoding: 'utf-8', mode: 0o600 });
1211
+ }
1212
+ catch (err) {
1213
+ log.warn('WAL checkpoint failed', { error: err instanceof Error ? err.message : String(err) });
1214
+ }
1215
+ }
1216
+ /**
1217
+ * Replay uncommitted WAL entries after a crash.
1218
+ *
1219
+ * Called automatically during construction (after `loadFromDisk()`).
1220
+ * Any WAL record without a matching `checkpoint` is replayed into the cache,
1221
+ * then the full state is persisted and the WAL is compacted.
1222
+ *
1223
+ * Malformed tail lines are silently skipped — partial writes at crash time
1224
+ * leave incomplete JSON that we must tolerate.
1225
+ */
1226
+ replayWAL() {
1227
+ if (!(0, fs_1.existsSync)(this.walPath))
1228
+ return;
1229
+ try {
1230
+ const raw = (0, fs_1.readFileSync)(this.walPath, 'utf-8');
1231
+ const lines = raw.split('\n').filter(l => l.trim().length > 0);
1232
+ const checkpointed = new Set();
1233
+ const pending = new Map();
1234
+ for (const line of lines) {
1235
+ try {
1236
+ const record = JSON.parse(line);
1237
+ if (record.op === 'checkpoint') {
1238
+ if (record.opId)
1239
+ checkpointed.add(record.opId);
1240
+ }
1241
+ else if (record.op === 'write' || record.op === 'delete') {
1242
+ pending.set(record.opId, record);
1243
+ }
1244
+ }
1245
+ catch {
1246
+ // Skip malformed / truncated tail lines — expected on crash
1247
+ }
1248
+ }
1249
+ let replayed = 0;
1250
+ for (const [opId, record] of pending.entries()) {
1251
+ if (checkpointed.has(opId))
1252
+ continue;
1253
+ if (record.op === 'write' && record.entry && record.key) {
1254
+ this.cache.set(record.key, record.entry);
1255
+ replayed++;
1256
+ }
1257
+ else if (record.op === 'delete' && record.key) {
1258
+ this.cache.delete(record.key);
1259
+ replayed++;
1260
+ }
1261
+ }
1262
+ if (replayed > 0) {
1263
+ log.warn(`WAL replay: recovered ${replayed} uncommitted operation(s) after crash`, { replayed });
1264
+ this.persistToDiskInternal();
1265
+ this.compactWAL();
1266
+ }
1267
+ }
1268
+ catch (err) {
1269
+ log.error('WAL replay failed', { error: err instanceof Error ? err.message : String(err) });
1270
+ }
1271
+ }
1272
+ /**
1273
+ * Truncate the WAL file.
1274
+ *
1275
+ * Call after a full-state snapshot has been flushed to disk to prevent
1276
+ * unbounded WAL growth during long-running processes.
1277
+ */
1278
+ compactWAL() {
1279
+ try {
1280
+ // Use a file descriptor to avoid TOCTOU (js/file-system-race).
1281
+ // openSync 'w' = O_WRONLY | O_CREAT | O_TRUNC — atomically truncates or creates.
1282
+ const fd = (0, fs_1.openSync)(this.walPath, 'w', 0o600);
1283
+ (0, fs_1.closeSync)(fd);
1284
+ }
1285
+ catch (err) {
1286
+ log.warn('WAL compact failed', { error: err instanceof Error ? err.message : String(err) });
1287
+ }
1288
+ }
1289
+ }
1290
+ exports.LockedBlackboard = LockedBlackboard;
1291
+ // ============================================================================
1292
+ // EXPORTS
1293
+ // ============================================================================
1294
+ exports.default = LockedBlackboard;
1295
+ //# sourceMappingURL=locked-blackboard.js.map