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.
- package/INTEGRATION_GUIDE.md +2 -2
- package/QUICKSTART.md +10 -0
- package/README.md +32 -3
- package/SKILL.md +3 -3
- package/dist/esm/adapters/a2a-adapter.js +235 -0
- package/dist/esm/adapters/a2a-adapter.js.map +1 -0
- package/dist/esm/adapters/adapter-registry.js +613 -0
- package/dist/esm/adapters/adapter-registry.js.map +1 -0
- package/dist/esm/adapters/agno-adapter.js +140 -0
- package/dist/esm/adapters/agno-adapter.js.map +1 -0
- package/dist/esm/adapters/anthropic-computer-use-adapter.js +180 -0
- package/dist/esm/adapters/anthropic-computer-use-adapter.js.map +1 -0
- package/dist/esm/adapters/aps-adapter.js +289 -0
- package/dist/esm/adapters/aps-adapter.js.map +1 -0
- package/dist/esm/adapters/autogen-adapter.js +141 -0
- package/dist/esm/adapters/autogen-adapter.js.map +1 -0
- package/dist/esm/adapters/base-adapter.js +104 -0
- package/dist/esm/adapters/base-adapter.js.map +1 -0
- package/dist/esm/adapters/browser-agent-adapter.js +219 -0
- package/dist/esm/adapters/browser-agent-adapter.js.map +1 -0
- package/dist/esm/adapters/codex-adapter.js +318 -0
- package/dist/esm/adapters/codex-adapter.js.map +1 -0
- package/dist/esm/adapters/copilot-adapter.js +132 -0
- package/dist/esm/adapters/copilot-adapter.js.map +1 -0
- package/dist/esm/adapters/crewai-adapter.js +148 -0
- package/dist/esm/adapters/crewai-adapter.js.map +1 -0
- package/dist/esm/adapters/custom-adapter.js +142 -0
- package/dist/esm/adapters/custom-adapter.js.map +1 -0
- package/dist/esm/adapters/custom-streaming-adapter.js +181 -0
- package/dist/esm/adapters/custom-streaming-adapter.js.map +1 -0
- package/dist/esm/adapters/dspy-adapter.js +127 -0
- package/dist/esm/adapters/dspy-adapter.js.map +1 -0
- package/dist/esm/adapters/haystack-adapter.js +149 -0
- package/dist/esm/adapters/haystack-adapter.js.map +1 -0
- package/dist/esm/adapters/hermes-adapter.js +217 -0
- package/dist/esm/adapters/hermes-adapter.js.map +1 -0
- package/dist/esm/adapters/index.js +109 -0
- package/dist/esm/adapters/index.js.map +1 -0
- package/dist/esm/adapters/langchain-adapter.js +134 -0
- package/dist/esm/adapters/langchain-adapter.js.map +1 -0
- package/dist/esm/adapters/langchain-streaming-adapter.js +161 -0
- package/dist/esm/adapters/langchain-streaming-adapter.js.map +1 -0
- package/dist/esm/adapters/langgraph-adapter.js +119 -0
- package/dist/esm/adapters/langgraph-adapter.js.map +1 -0
- package/dist/esm/adapters/llamaindex-adapter.js +135 -0
- package/dist/esm/adapters/llamaindex-adapter.js.map +1 -0
- package/dist/esm/adapters/mcp-adapter.js +200 -0
- package/dist/esm/adapters/mcp-adapter.js.map +1 -0
- package/dist/esm/adapters/minimax-adapter.js +233 -0
- package/dist/esm/adapters/minimax-adapter.js.map +1 -0
- package/dist/esm/adapters/nemoclaw-adapter.js +465 -0
- package/dist/esm/adapters/nemoclaw-adapter.js.map +1 -0
- package/dist/esm/adapters/openai-agents-adapter.js +118 -0
- package/dist/esm/adapters/openai-agents-adapter.js.map +1 -0
- package/dist/esm/adapters/openai-assistants-adapter.js +130 -0
- package/dist/esm/adapters/openai-assistants-adapter.js.map +1 -0
- package/dist/esm/adapters/openclaw-adapter.js +107 -0
- package/dist/esm/adapters/openclaw-adapter.js.map +1 -0
- package/dist/esm/adapters/orchestrator-adapter.js +218 -0
- package/dist/esm/adapters/orchestrator-adapter.js.map +1 -0
- package/dist/esm/adapters/pydantic-ai-adapter.js +163 -0
- package/dist/esm/adapters/pydantic-ai-adapter.js.map +1 -0
- package/dist/esm/adapters/rlm-adapter.js +167 -0
- package/dist/esm/adapters/rlm-adapter.js.map +1 -0
- package/dist/esm/adapters/semantic-kernel-adapter.js +123 -0
- package/dist/esm/adapters/semantic-kernel-adapter.js.map +1 -0
- package/dist/esm/adapters/streaming-base-adapter.js +74 -0
- package/dist/esm/adapters/streaming-base-adapter.js.map +1 -0
- package/dist/esm/adapters/vertex-ai-adapter.js +166 -0
- package/dist/esm/adapters/vertex-ai-adapter.js.map +1 -0
- package/dist/esm/demo-control-plane.js +147 -0
- package/dist/esm/demo-control-plane.js.map +1 -0
- package/dist/esm/demo-worktree-dashboard.js +131 -0
- package/dist/esm/demo-worktree-dashboard.js.map +1 -0
- package/dist/esm/examples/01-hello-swarm.js +165 -0
- package/dist/esm/examples/01-hello-swarm.js.map +1 -0
- package/dist/esm/examples/02-fsm-pipeline.js +189 -0
- package/dist/esm/examples/02-fsm-pipeline.js.map +1 -0
- package/dist/esm/examples/03-parallel-agents.js +192 -0
- package/dist/esm/examples/03-parallel-agents.js.map +1 -0
- package/dist/esm/examples/05-code-review-swarm.js +1177 -0
- package/dist/esm/examples/05-code-review-swarm.js.map +1 -0
- package/dist/esm/examples/06-ai-pipeline-demo.js +263 -0
- package/dist/esm/examples/06-ai-pipeline-demo.js.map +1 -0
- package/dist/esm/examples/07-full-showcase.js +946 -0
- package/dist/esm/examples/07-full-showcase.js.map +1 -0
- package/dist/esm/examples/08-control-plane-stress-demo.js +186 -0
- package/dist/esm/examples/08-control-plane-stress-demo.js.map +1 -0
- package/dist/esm/examples/09-real-langchain.js +231 -0
- package/dist/esm/examples/09-real-langchain.js.map +1 -0
- package/dist/esm/examples/10-nemoclaw-sandbox-swarm.js +270 -0
- package/dist/esm/examples/10-nemoclaw-sandbox-swarm.js.map +1 -0
- package/dist/esm/examples/demo-runner.js +119 -0
- package/dist/esm/examples/demo-runner.js.map +1 -0
- package/dist/esm/index.js +1352 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/lib/adapter-hooks.js +216 -0
- package/dist/esm/lib/adapter-hooks.js.map +1 -0
- package/dist/esm/lib/adapter-test-harness.js +118 -0
- package/dist/esm/lib/adapter-test-harness.js.map +1 -0
- package/dist/esm/lib/agent-conversation.js +155 -0
- package/dist/esm/lib/agent-conversation.js.map +1 -0
- package/dist/esm/lib/agent-debate.js +146 -0
- package/dist/esm/lib/agent-debate.js.map +1 -0
- package/dist/esm/lib/agent-memory.js +336 -0
- package/dist/esm/lib/agent-memory.js.map +1 -0
- package/dist/esm/lib/agent-runtime.js +818 -0
- package/dist/esm/lib/agent-runtime.js.map +1 -0
- package/dist/esm/lib/agent-vcr.js +218 -0
- package/dist/esm/lib/agent-vcr.js.map +1 -0
- package/dist/esm/lib/anomaly-detector.js +178 -0
- package/dist/esm/lib/anomaly-detector.js.map +1 -0
- package/dist/esm/lib/approval-inbox.js +385 -0
- package/dist/esm/lib/approval-inbox.js.map +1 -0
- package/dist/esm/lib/auth-guardian.js +692 -0
- package/dist/esm/lib/auth-guardian.js.map +1 -0
- package/dist/esm/lib/auth-validator.js +32 -0
- package/dist/esm/lib/auth-validator.js.map +1 -0
- package/dist/esm/lib/blackboard-backend-crdt.js +251 -0
- package/dist/esm/lib/blackboard-backend-crdt.js.map +1 -0
- package/dist/esm/lib/blackboard-backend-redis.js +244 -0
- package/dist/esm/lib/blackboard-backend-redis.js.map +1 -0
- package/dist/esm/lib/blackboard-backend.js +141 -0
- package/dist/esm/lib/blackboard-backend.js.map +1 -0
- package/dist/esm/lib/blackboard-validator.js +985 -0
- package/dist/esm/lib/blackboard-validator.js.map +1 -0
- package/dist/esm/lib/circuit-breaker.js +164 -0
- package/dist/esm/lib/circuit-breaker.js.map +1 -0
- package/dist/esm/lib/claim-verifier.js +173 -0
- package/dist/esm/lib/claim-verifier.js.map +1 -0
- package/dist/esm/lib/comparison-runner.js +138 -0
- package/dist/esm/lib/comparison-runner.js.map +1 -0
- package/dist/esm/lib/compliance-monitor.js +261 -0
- package/dist/esm/lib/compliance-monitor.js.map +1 -0
- package/dist/esm/lib/confidence-filter.js +210 -0
- package/dist/esm/lib/confidence-filter.js.map +1 -0
- package/dist/esm/lib/config-watcher.js +215 -0
- package/dist/esm/lib/config-watcher.js.map +1 -0
- package/dist/esm/lib/consistency.js +274 -0
- package/dist/esm/lib/consistency.js.map +1 -0
- package/dist/esm/lib/console-ui.js +276 -0
- package/dist/esm/lib/console-ui.js.map +1 -0
- package/dist/esm/lib/context-throttler.js +171 -0
- package/dist/esm/lib/context-throttler.js.map +1 -0
- package/dist/esm/lib/control-plane.js +527 -0
- package/dist/esm/lib/control-plane.js.map +1 -0
- package/dist/esm/lib/cost-governor.js +128 -0
- package/dist/esm/lib/cost-governor.js.map +1 -0
- package/dist/esm/lib/cost-heatmap.js +161 -0
- package/dist/esm/lib/cost-heatmap.js.map +1 -0
- package/dist/esm/lib/coverage-gate.js +213 -0
- package/dist/esm/lib/coverage-gate.js.map +1 -0
- package/dist/esm/lib/coverage-reporter.js +177 -0
- package/dist/esm/lib/coverage-reporter.js.map +1 -0
- package/dist/esm/lib/crdt.js +141 -0
- package/dist/esm/lib/crdt.js.map +1 -0
- package/dist/esm/lib/dashboard-server.js +403 -0
- package/dist/esm/lib/dashboard-server.js.map +1 -0
- package/dist/esm/lib/dry-run.js +130 -0
- package/dist/esm/lib/dry-run.js.map +1 -0
- package/dist/esm/lib/env-manager.js +518 -0
- package/dist/esm/lib/env-manager.js.map +1 -0
- package/dist/esm/lib/errors.js +201 -0
- package/dist/esm/lib/errors.js.map +1 -0
- package/dist/esm/lib/event-bus.js +229 -0
- package/dist/esm/lib/event-bus.js.map +1 -0
- package/dist/esm/lib/explainability.js +102 -0
- package/dist/esm/lib/explainability.js.map +1 -0
- package/dist/esm/lib/fan-out.js +237 -0
- package/dist/esm/lib/fan-out.js.map +1 -0
- package/dist/esm/lib/federated-budget.js +322 -0
- package/dist/esm/lib/federated-budget.js.map +1 -0
- package/dist/esm/lib/fsm-journey.js +478 -0
- package/dist/esm/lib/fsm-journey.js.map +1 -0
- package/dist/esm/lib/goal-decomposer.js +698 -0
- package/dist/esm/lib/goal-decomposer.js.map +1 -0
- package/dist/esm/lib/goal-dsl.js +391 -0
- package/dist/esm/lib/goal-dsl.js.map +1 -0
- package/dist/esm/lib/job-queue.js +310 -0
- package/dist/esm/lib/job-queue.js.map +1 -0
- package/dist/esm/lib/landscape-agent.js +134 -0
- package/dist/esm/lib/landscape-agent.js.map +1 -0
- package/dist/esm/lib/learning-loop.js +181 -0
- package/dist/esm/lib/learning-loop.js.map +1 -0
- package/dist/esm/lib/lifecycle-hooks.js +148 -0
- package/dist/esm/lib/lifecycle-hooks.js.map +1 -0
- package/dist/esm/lib/locked-blackboard.js +1295 -0
- package/dist/esm/lib/locked-blackboard.js.map +1 -0
- package/dist/esm/lib/logger.js +150 -0
- package/dist/esm/lib/logger.js.map +1 -0
- package/dist/esm/lib/mcp-blackboard-tools.js +298 -0
- package/dist/esm/lib/mcp-blackboard-tools.js.map +1 -0
- package/dist/esm/lib/mcp-bridge.js +357 -0
- package/dist/esm/lib/mcp-bridge.js.map +1 -0
- package/dist/esm/lib/mcp-tool-consumer.js +287 -0
- package/dist/esm/lib/mcp-tool-consumer.js.map +1 -0
- package/dist/esm/lib/mcp-tools-control.js +392 -0
- package/dist/esm/lib/mcp-tools-control.js.map +1 -0
- package/dist/esm/lib/mcp-tools-extended.js +371 -0
- package/dist/esm/lib/mcp-tools-extended.js.map +1 -0
- package/dist/esm/lib/mcp-transport-http.js +528 -0
- package/dist/esm/lib/mcp-transport-http.js.map +1 -0
- package/dist/esm/lib/mcp-transport-sse.js +503 -0
- package/dist/esm/lib/mcp-transport-sse.js.map +1 -0
- package/dist/esm/lib/metrics.js +284 -0
- package/dist/esm/lib/metrics.js.map +1 -0
- package/dist/esm/lib/orchestrator-types.js +66 -0
- package/dist/esm/lib/orchestrator-types.js.map +1 -0
- package/dist/esm/lib/otel-bridge.js +167 -0
- package/dist/esm/lib/otel-bridge.js.map +1 -0
- package/dist/esm/lib/partition-planner.js +246 -0
- package/dist/esm/lib/partition-planner.js.map +1 -0
- package/dist/esm/lib/phase-pipeline.js +367 -0
- package/dist/esm/lib/phase-pipeline.js.map +1 -0
- package/dist/esm/lib/playground.js +224 -0
- package/dist/esm/lib/playground.js.map +1 -0
- package/dist/esm/lib/qa-orchestrator.js +296 -0
- package/dist/esm/lib/qa-orchestrator.js.map +1 -0
- package/dist/esm/lib/quadtree.js +259 -0
- package/dist/esm/lib/quadtree.js.map +1 -0
- package/dist/esm/lib/route-classifier.js +217 -0
- package/dist/esm/lib/route-classifier.js.map +1 -0
- package/dist/esm/lib/semantic-search.js +235 -0
- package/dist/esm/lib/semantic-search.js.map +1 -0
- package/dist/esm/lib/shared-blackboard.js +249 -0
- package/dist/esm/lib/shared-blackboard.js.map +1 -0
- package/dist/esm/lib/skill-composer.js +190 -0
- package/dist/esm/lib/skill-composer.js.map +1 -0
- package/dist/esm/lib/speculative-executor.js +107 -0
- package/dist/esm/lib/speculative-executor.js.map +1 -0
- package/dist/esm/lib/strategy-agent.js +626 -0
- package/dist/esm/lib/strategy-agent.js.map +1 -0
- package/dist/esm/lib/swarm-transport.js +307 -0
- package/dist/esm/lib/swarm-transport.js.map +1 -0
- package/dist/esm/lib/swarm-utils.js +510 -0
- package/dist/esm/lib/swarm-utils.js.map +1 -0
- package/dist/esm/lib/task-decomposer.js +272 -0
- package/dist/esm/lib/task-decomposer.js.map +1 -0
- package/dist/esm/lib/telemetry-provider.js +207 -0
- package/dist/esm/lib/telemetry-provider.js.map +1 -0
- package/dist/esm/lib/timeline-scrubber.js +173 -0
- package/dist/esm/lib/timeline-scrubber.js.map +1 -0
- package/dist/esm/lib/topology.js +591 -0
- package/dist/esm/lib/topology.js.map +1 -0
- package/dist/esm/lib/transport-agent.js +366 -0
- package/dist/esm/lib/transport-agent.js.map +1 -0
- package/dist/esm/lib/work-tree-dashboard.js +583 -0
- package/dist/esm/lib/work-tree-dashboard.js.map +1 -0
- package/dist/esm/lib/work-tree-ui.js +333 -0
- package/dist/esm/lib/work-tree-ui.js.map +1 -0
- package/dist/esm/lib/work-tree.js +480 -0
- package/dist/esm/lib/work-tree.js.map +1 -0
- package/dist/esm/run.js +144 -0
- package/dist/esm/run.js.map +1 -0
- package/dist/esm/security.js +1122 -0
- package/dist/esm/security.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/mcp-transport-http.d.ts +203 -0
- package/dist/lib/mcp-transport-http.d.ts.map +1 -0
- package/dist/lib/mcp-transport-http.js +528 -0
- package/dist/lib/mcp-transport-http.js.map +1 -0
- package/dist/lib/phase-pipeline.d.ts +31 -0
- package/dist/lib/phase-pipeline.d.ts.map +1 -1
- package/dist/lib/phase-pipeline.js +93 -1
- package/dist/lib/phase-pipeline.js.map +1 -1
- package/dist/lib/semantic-search.d.ts +42 -6
- package/dist/lib/semantic-search.d.ts.map +1 -1
- package/dist/lib/semantic-search.js +87 -6
- package/dist/lib/semantic-search.js.map +1 -1
- 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
|