fix: Handle Redis subscription confirmation in BFT consensus + remove legacy triad-orchestrator

- bft-consensus.js: Add try/catch around JSON.parse for Redis messages
  - Skip non-JSON messages (Redis subscription confirmation strings)
  - Prevents crash on 'subscribed' response from Redis pub/sub

- Remove legacy triad-orchestrator skill (7 files):
  - SKILL.md, package.json, scripts/triad-status.sh
  - src/deadlock-detector.js, index.js, proposal-tracker.js, vote-collector.js
  - Functionality superseded by governance skills and BFT consensus module
This commit is contained in:
John Doe
2026-04-02 20:06:11 -04:00
parent 643043148a
commit 8e1eea305e
8 changed files with 9 additions and 2201 deletions
-201
View File
@@ -1,201 +0,0 @@
---
name: triad-orchestrator
description: Manage triad deliberation workflows for OpenClaw governance. Provides proposal lifecycle tracking, vote collection and tabulation, deadlock detection and resolution, consensus ledger synchronization, and triad state visualization. Use when coordinating triad decisions, managing proposals, or resolving governance deadlocks.
---
# Triad Orchestrator
## When to use this skill
Use this skill when you need to:
- Create and track proposals through the deliberation lifecycle
- Collect and tabulate votes from triad members
- Detect and resolve deadlocks in triad decisions
- Synchronize consensus ledger state
- Visualize triad deliberation state
- Manage quorum requirements and voting rules
## When NOT to use this skill
Do NOT use this skill when:
- You need to manage agent lifecycle (use `agent-lifecycle-manager` skill)
- You need to monitor gateway health (use `gateway-pulse` skill)
- You need to perform individual agent operations
- You need to backup triad state (use `backup-ledger` skill)
## Inputs required
Before executing, determine:
1. **Operation type**: proposal, vote, status, or ledger
2. **Proposal ID**: for tracking specific proposals
3. **Triad members**: which agents participate in the vote
4. **Quorum requirements**: minimum votes needed for decision
## Workflow
### 1. Create a proposal
```bash
# Create new proposal
./scripts/triad-status.sh propose --title "Update agent configuration" --type config
# View pending proposals
./scripts/triad-status.sh proposals --status pending
```
### 2. Track proposal lifecycle
```bash
# View proposal status
./scripts/triad-status.sh proposal --id <proposal-id>
# View all proposals
./scripts/triad-status.sh proposals
# View proposal history
./scripts/triad-status.sh history --proposal <proposal-id>
```
**Proposal states**:
- `draft` - Proposal being prepared
- `pending` - Awaiting votes
- `voting` - Vote collection in progress
- `approved` - Quorum reached, approved
- `rejected` - Quorum reached, rejected
- `deadlocked` - Deadlock detected
- `executed` - Proposal executed
### 3. Collect votes
```bash
# Submit vote
./scripts/triad-status.sh vote --proposal <id> --vote approve|reject|abstain
# View vote status
./scripts/triad-status.sh votes --proposal <id>
# Tabulate results
./scripts/triad-status.sh tabulate --proposal <id>
```
### 4. Detect and resolve deadlocks
```bash
# Check for deadlocks
./scripts/triad-status.sh check-deadlock
# Resolve deadlock (tie-breaker)
./scripts/triad-status.sh resolve-deadlock --proposal <id> --method steward-tiebreak
```
**Deadlock resolution methods**:
- `steward-tiebreak` - Steward casts tie-breaking vote
- `timeout-expire` - Proposal expires after timeout
- `revote` - Trigger new vote round
- `escalate` - Escalate to higher authority
### 5. Synchronize consensus ledger
```bash
# Sync ledger state
./scripts/triad-status.sh sync-ledger
# View ledger entries
./scripts/triad-status.sh ledger --limit 50
# Verify ledger integrity
./scripts/triad-status.sh verify-ledger
```
### 6. View triad state dashboard
```bash
# Full triad dashboard
./scripts/triad-status.sh dashboard
# JSON output
./scripts/triad-status.sh status --json
# Watch mode
./scripts/triad-status.sh watch
```
## Files
- [`src/index.js`](src/index.js) - Main orchestration logic
- [`src/proposal-tracker.js`](src/proposal-tracker.js) - Proposal state machine
- [`src/vote-collector.js`](src/vote-collector.js) - Vote aggregation
- [`src/deadlock-detector.js`](src/deadlock-detector.js) - Deadlock resolution
- [`scripts/triad-status.sh`](scripts/triad-status.sh) - CLI wrapper
## Examples
### Example 1: Complete proposal workflow
```bash
# Create proposal
./scripts/triad-status.sh propose --title "Deploy new agent" --type deployment
# Collect votes from triad members
./scripts/triad-status.sh vote --proposal <id> --vote approve --voter alpha
./scripts/triad-status.sh vote --proposal <id> --vote approve --voter beta
./scripts/triad-status.sh vote --proposal <id> --vote reject --voter gamma
# Tabulate and execute
./scripts/triad-status.sh tabulate --proposal <id>
```
### Example 2: Deadlock resolution
```bash
# Check for deadlocks
./scripts/triad-status.sh check-deadlock
# Resolve with steward tie-breaker
./scripts/triad-status.sh resolve-deadlock --proposal <id> --method steward-tiebreak
```
### Example 3: Triad dashboard
```bash
# View current triad state
./scripts/triad-status.sh dashboard
```
## Troubleshooting
### Proposal stuck in pending state
1. Check vote collection: `./scripts/triad-status.sh votes --proposal <id>`
2. Verify triad member connectivity: `./scripts/triad-status.sh status`
3. Force vote timeout: `./scripts/triad-status.sh force-timeout --proposal <id>`
### Deadlock not detected
1. Check vote counts: `./scripts/triad-status.sh tabulate --proposal <id>`
2. Verify quorum rules: `./scripts/triad-status.sh quorum-rules`
3. Manual deadlock check: `./scripts/triad-status.sh check-deadlock --force`
### Ledger sync failed
1. Check Gateway connection: `curl http://127.0.0.1:18789/health`
2. Verify ledger file permissions
3. Re-sync from backup: `./scripts/triad-status.sh sync-ledger --force`
## Gateway Integration
This skill integrates with the OpenClaw Gateway WebSocket RPC on port 18789:
- Proposal state changes are broadcast via Gateway
- Votes are collected through Gateway-mediated messages
- Consensus ledger is synchronized through Gateway
## Triad Structure
The triad consists of:
- **Steward** - Orchestrator and tie-breaker
- **Alpha** - Primary decision maker
- **Beta** - Secondary decision maker
- **Gamma** - Tertiary decision maker (when needed)
Quorum requires 2/3 members for most decisions.
@@ -1,33 +0,0 @@
{
"name": "triad-orchestrator",
"version": "1.0.0",
"description": "Triad deliberation workflow management for OpenClaw governance",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"propose": "node src/index.js propose",
"vote": "node src/index.js vote",
"tabulate": "node src/index.js tabulate",
"status": "node src/index.js status",
"dashboard": "node src/index.js dashboard",
"test": "node tests/triad-orchestrator.test.js"
},
"keywords": [
"openclaw",
"triad",
"governance",
"deliberation",
"voting",
"consensus"
],
"author": "Heretek",
"license": "MIT",
"dependencies": {
"ws": "^8.14.2",
"node-fetch": "^2.7.0",
"uuid": "^9.0.0"
},
"engines": {
"node": ">=18.0.0"
}
}
@@ -1,404 +0,0 @@
#!/bin/bash
#
# Triad Orchestrator - Status CLI Wrapper
# ==============================================================================
# Provides CLI interface for triad deliberation operations.
# Supports proposal management, voting, and deadlock resolution.
#
# Usage:
# ./triad-status.sh <command> [options]
#
set -e
# Configuration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SKILL_DIR="$(dirname "$SCRIPT_DIR")"
STATE_DIR="${OPENCLAW_STATE_DIR:-/app/state}"
PROPOSALS_DIR="${STATE_DIR}/proposals"
LEDGER_FILE="${STATE_DIR}/triad-ledger.json"
GATEWAY_URL="${GATEWAY_URL:-ws://127.0.0.1:18789}"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m'
# Triad members
TRIAD_MEMBERS=("steward" "alpha" "beta" "gamma")
log_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
log_success() {
echo -e "${GREEN}[OK]${NC} $1"
}
log_warning() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# Ensure state directories exist
ensure_state_dir() {
if [ ! -d "$STATE_DIR" ]; then
mkdir -p "$STATE_DIR"
fi
if [ ! -d "$PROPOSALS_DIR" ]; then
mkdir -p "$PROPOSALS_DIR"
fi
}
# Generate UUID
generate_uuid() {
if command -v uuidgen &> /dev/null; then
uuidgen
else
cat /proc/sys/kernel/random/uuid 2>/dev/null || echo "$(date +%s)-$$-$RANDOM"
fi
}
# Create proposal
create_proposal() {
local title="$1"
local type="${2:-custom}"
local description="${3:-}"
local creator="${4:-cli}"
ensure_state_dir
local id=$(generate_uuid)
local now=$(date -Iseconds)
local timeout=$(date -Iseconds -d "+1 hour" 2>/dev/null || date -v+1H -Iseconds 2>/dev/null || echo "$now")
cat > "${PROPOSALS_DIR}/${id}.json" << EOF
{
"id": "$id",
"title": "$title",
"description": "$description",
"type": "$type",
"state": "pending",
"creator": "$creator",
"createdAt": "$now",
"updatedAt": "$now",
"timeoutAt": "$timeout",
"votes": {},
"voteHistory": [],
"metadata": {}
}
EOF
log_success "Proposal created: $id"
echo ""
echo "Title: $title"
echo "Type: $type"
echo "State: pending"
}
# Get proposal
get_proposal() {
local id="$1"
local file="${PROPOSALS_DIR}/${id}.json"
if [ -f "$file" ]; then
cat "$file" | jq .
else
log_error "Proposal not found: $id"
exit 1
fi
}
# List proposals
list_proposals() {
local status_filter="$1"
ensure_state_dir
echo ""
echo "=== Proposals ==="
echo ""
printf "%-36s %-30s %-12s %s\n" "ID" "TITLE" "STATE" "CREATED"
echo "----------------------------------------------------------------------------------------------------"
for file in "${PROPOSALS_DIR}"/*.json; do
[ -f "$file" ] || continue
local id=$(basename "$file" .json)
local title=$(jq -r '.title' "$file" | cut -c1-28)
local state=$(jq -r '.state' "$file")
local created=$(jq -r '.createdAt' "$file" | cut -dT1)
if [ -n "$status_filter" ] && [ "$state" != "$status_filter" ]; then
continue
fi
printf "%-36s %-30s %-12s %s\n" "$id" "$title" "$state" "$created"
done
echo ""
}
# Submit vote
submit_vote() {
local proposal_id="$1"
local voter="$2"
local vote="$3"
local file="${PROPOSALS_DIR}/${proposal_id}.json"
if [ ! -f "$file" ]; then
log_error "Proposal not found: $proposal_id"
exit 1
fi
local valid_votes=("approve" "reject" "abstain")
if [[ ! " ${valid_votes[@]} " =~ " ${vote} " ]]; then
log_error "Invalid vote: $vote. Must be one of: approve, reject, abstain"
exit 1
fi
local now=$(date -Iseconds)
local tmp_file=$(mktemp)
# Update proposal with vote
jq --arg voter "$voter" --arg vote "$vote" --arg ts "$now" \
'.votes[$voter] = {"value": $vote, "timestamp": $ts} | .updatedAt = $now | .voteHistory += [{"voter": $voter, "vote": $vote, "timestamp": $ts}]' \
"$file" > "$tmp_file" && mv "$tmp_file" "$file"
log_success "Vote recorded: $voter -> $vote for proposal $proposal_id"
}
# Get vote tally
get_vote_tally() {
local proposal_id="$1"
local file="${PROPOSALS_DIR}/${proposal_id}.json"
if [ ! -f "$file" ]; then
log_error "Proposal not found: $proposal_id"
exit 1
fi
echo ""
echo "=== Vote Tally for $proposal_id ==="
echo ""
local approve=$(jq '[.votes[] | select(.value == "approve")] | length' "$file")
local reject=$(jq '[.votes[] | select(.value == "reject")] | length' "$file")
local abstain=$(jq '[.votes[] | select(.value == "abstain")] | length' "$file")
local total=$((approve + reject + abstain))
echo "Approve: $approve"
echo "Reject: $reject"
echo "Abstain: $abstain"
echo "Total: $total"
# Check missing voters
echo ""
echo "Voting status:"
for member in "${TRIAD_MEMBERS[@]}"; do
local has_voted=$(jq --arg m "$member" '.votes[$m] != null' "$file")
if [ "$has_voted" = "true" ]; then
local vote=$(jq -r --arg m "$member" '.votes[$m].value' "$file")
echo " $member: $vote"
else
echo " $member: (not voted)"
fi
done
echo ""
}
# Check deadlock
check_deadlock() {
local proposal_id="$1"
local file="${PROPOSALS_DIR}/${proposal_id}.json"
if [ ! -f "$file" ]; then
log_error "Proposal not found: $proposal_id"
exit 1
fi
local approve=$(jq '[.votes[] | select(.value == "approve")] | length' "$file")
local reject=$(jq '[.votes[] | select(.value == "reject")] | length' "$file")
if [ "$approve" -eq "$reject" ] && [ "$approve" -gt 0 ]; then
log_warning "DEADLOCK DETECTED: Equal votes ($approve approve, $reject reject)"
echo ""
echo "Recommended resolution: steward-tiebreak"
return 0
fi
log_success "No deadlock detected"
}
# Show dashboard
show_dashboard() {
ensure_state_dir
echo ""
echo "╔══════════════════════════════════════════════════════════╗"
echo "║ TRIAD DELIBERATION DASHBOARD ║"
echo "╚══════════════════════════════════════════════════════════╝"
echo ""
# Count proposals by state
echo "=== Proposal States ==="
echo ""
local draft=0 pending=0 voting=0 approved=0 rejected=0 deadlocked=0 executed=0
for file in "${PROPOSALS_DIR}"/*.json; do
[ -f "$file" ] || continue
local state=$(jq -r '.state' "$file")
case "$state" in
draft) ((draft++)) ;;
pending) ((pending++)) ;;
voting) ((voting++)) ;;
approved) ((approved++)) ;;
rejected) ((rejected++)) ;;
deadlocked) ((deadlocked++)) ;;
executed) ((executed++)) ;;
esac
done
printf "%-15s %s\n" "Draft:" "$draft"
printf "%-15s %s\n" "Pending:" "$pending"
printf "%-15s %s\n" "Voting:" "$voting"
printf "%-15s %s\n" "Approved:" "$approved"
printf "%-15s %s\n" "Rejected:" "$rejected"
printf "%-15s %s\n" "Deadlocked:" "$deadlocked"
printf "%-15s %s\n" "Executed:" "$executed"
echo ""
echo "=== Triad Members ==="
echo ""
printf "%-15s %-10s\n" "MEMBER" "STATUS"
echo "----------------------------"
for member in "${TRIAD_MEMBERS[@]}"; do
printf "%-15s %-10s\n" "$member" "active"
done
echo ""
}
# Parse arguments
parse_args() {
COMMAND="$1"
shift
while [ $# -gt 0 ]; do
case "$1" in
--id|--proposal)
PROPOSAL_ID="$2"
shift 2
;;
--title)
TITLE="$2"
shift 2
;;
--type)
TYPE="$2"
shift 2
;;
--vote)
VOTE="$2"
shift 2
;;
--voter)
VOTER="$2"
shift 2
;;
--method)
METHOD="$2"
shift 2
;;
--status)
STATUS_FILTER="$2"
shift 2
;;
--json)
JSON_OUTPUT="true"
shift
;;
*)
shift
;;
esac
done
}
# Main
main() {
parse_args "$@"
case "$COMMAND" in
propose)
create_proposal "${TITLE:-Untitled}" "${TYPE:-custom}" "" "cli"
;;
proposal)
get_proposal "${PROPOSAL_ID:-}"
;;
proposals)
list_proposals "${STATUS_FILTER:-}"
;;
vote)
submit_vote "${PROPOSAL_ID:-}" "${VOTER:-cli}" "${VOTE:-}"
;;
votes|tabulate)
get_vote_tally "${PROPOSAL_ID:-}"
;;
check-deadlock)
check_deadlock "${PROPOSAL_ID:-}"
;;
dashboard|status)
show_dashboard
;;
*)
echo "
Triad Orchestrator - Status CLI
Usage: $0 <command> [options]
Commands:
propose Create a new proposal (--title required)
proposal View proposal details (--id required)
proposals List all proposals
vote Submit a vote (--proposal, --vote required)
votes View vote status (--proposal required)
tabulate Show vote tally (--proposal required)
check-deadlock Check for deadlock (--proposal required)
dashboard Show triad dashboard
status Show triad status
Options:
--id <id> Proposal ID
--proposal <id> Proposal ID
--title <title> Proposal title
--type <type> Proposal type (config, deployment, governance)
--vote <vote> Vote value (approve, reject, abstain)
--voter <id> Voter ID
--method <method> Resolution method
--status <state> Filter by status
--json Output in JSON format
Examples:
$0 propose --title \"Deploy update\" --type deployment
$0 proposal --id <proposal-id>
$0 vote --proposal <id> --vote approve --voter alpha
$0 tabulate --proposal <id>
$0 check-deadlock --proposal <id>
$0 dashboard
"
exit 1
;;
esac
}
main "$@"
@@ -1,348 +0,0 @@
/**
* Deadlock Detector - Deadlock Detection and Resolution for Triad
* ==============================================================================
* Detects voting deadlocks and provides resolution mechanisms.
* Supports multiple resolution strategies including tie-breakers and timeouts.
*/
const EventEmitter = require('events');
// Deadlock types
const DeadlockType = {
EQUAL_VOTES: 'equal_votes',
QUORUM_FAILURE: 'quorum_failure',
TIMEOUT: 'timeout',
CIRCULAR_DEPENDENCY: 'circular_dependency'
};
// Resolution methods
const ResolutionMethod = {
STEWARD_TIEBREAK: 'steward-tiebreak',
TIMEOUT_EXPIRE: 'timeout-expire',
REVOTE: 'revote',
ESCALATE: 'escalate',
RANDOM: 'random'
};
class DeadlockDetector extends EventEmitter {
constructor(config = {}) {
super();
this.triadMembers = config.triadMembers || ['steward', 'alpha', 'beta', 'gamma'];
this.stewardId = config.stewardId || 'steward';
this.timeoutMs = config.timeoutMs || 3600000; // 1 hour default
this.revoteDelay = config.revoteDelay || 300000; // 5 minutes
}
/**
* Check for deadlock in proposal votes
* @param {Object} proposal - Proposal object with votes
* @returns {Object|null} Deadlock info or null if no deadlock
*/
detectDeadlock(proposal) {
if (!proposal || !proposal.votes) {
return null;
}
const votes = proposal.votes;
const voteCounts = { approve: 0, reject: 0, abstain: 0 };
// Count votes
for (const voteData of Object.values(votes)) {
voteCounts[voteData.value]++;
}
// Check for equal votes deadlock
if (voteCounts.approve === voteCounts.reject && voteCounts.approve > 0) {
const deadlock = {
type: DeadlockType.EQUAL_VOTES,
proposalId: proposal.id,
detected: true,
timestamp: new Date().toISOString(),
details: {
approve: voteCounts.approve,
reject: voteCounts.reject,
abstain: voteCounts.abstain
}
};
this.emit('deadlock:detected', deadlock);
return deadlock;
}
// Check for timeout deadlock
const now = new Date();
const timeoutAt = new Date(proposal.timeoutAt || Date.now() + this.timeoutMs);
if (now > timeoutAt && proposal.state === 'voting') {
const deadlock = {
type: DeadlockType.TIMEOUT,
proposalId: proposal.id,
detected: true,
timestamp: now.toISOString(),
details: {
timeoutAt: proposal.timeoutAt,
votesReceived: Object.keys(votes).length,
votesRequired: this.triadMembers.length
}
};
this.emit('deadlock:detected', deadlock);
return deadlock;
}
// Check for quorum failure
const totalVotes = voteCounts.approve + voteCounts.reject + voteCounts.abstain;
const quorumRequired = Math.ceil(this.triadMembers.length / 2);
if (totalVotes > 0 && totalVotes < quorumRequired) {
const missingMembers = this.triadMembers.filter(m => !votes[m]);
if (missingMembers.length > 0 && now > timeoutAt) {
const deadlock = {
type: DeadlockType.QUORUM_FAILURE,
proposalId: proposal.id,
detected: true,
timestamp: now.toISOString(),
details: {
votesReceived: totalVotes,
quorumRequired: quorumRequired,
missingMembers
}
};
this.emit('deadlock:detected', deadlock);
return deadlock;
}
}
return null;
}
/**
* Resolve deadlock using specified method
* @param {Object} proposal - Proposal object
* @param {string} method - Resolution method
* @returns {Object} Resolution result
*/
resolveDeadlock(proposal, method) {
const deadlock = this.detectDeadlock(proposal);
if (!deadlock) {
return {
success: false,
error: 'No deadlock detected',
proposalId: proposal?.id
};
}
switch (method) {
case ResolutionMethod.STEWARD_TIEBREAK:
return this._resolveWithStewardTiebreak(proposal, deadlock);
case ResolutionMethod.TIMEOUT_EXPIRE:
return this._resolveWithTimeoutExpire(proposal, deadlock);
case ResolutionMethod.REVOTE:
return this._resolveWithRevote(proposal, deadlock);
case ResolutionMethod.ESCALATE:
return this._resolveWithEscalation(proposal, deadlock);
case ResolutionMethod.RANDOM:
return this._resolveWithRandom(proposal, deadlock);
default:
return {
success: false,
error: `Unknown resolution method: ${method}`,
availableMethods: Object.values(ResolutionMethod)
};
}
}
/**
* Resolve with steward tie-breaking vote
* @private
*/
_resolveWithStewardTiebreak(proposal, deadlock) {
// Check if steward has already voted
if (proposal.votes[this.stewardId]) {
return {
success: false,
error: 'Steward has already voted, cannot break tie',
proposalId: proposal.id,
deadlock
};
}
// Steward casts tie-breaking vote (defaults to approve in case of tie)
const tiebreakVote = 'approve';
this.emit('deadlock:resolved', {
proposalId: proposal.id,
method: ResolutionMethod.STEWARD_TIEBREAK,
result: {
tiebreakVoter: this.stewardId,
tiebreakVote,
outcome: 'approved'
}
});
return {
success: true,
proposalId: proposal.id,
method: ResolutionMethod.STEWARD_TIEBREAK,
result: {
tiebreakVoter: this.stewardId,
tiebreakVote,
outcome: 'approved',
newVotes: {
...proposal.votes,
[this.stewardId]: {
value: tiebreakVote,
timestamp: new Date().toISOString(),
isTiebreak: true
}
}
}
};
}
/**
* Resolve by expiring proposal due to timeout
* @private
*/
_resolveWithTimeoutExpire(proposal, deadlock) {
this.emit('deadlock:resolved', {
proposalId: proposal.id,
method: ResolutionMethod.TIMEOUT_EXPIRE,
result: {
outcome: 'expired',
reason: 'timeout'
}
});
return {
success: true,
proposalId: proposal.id,
method: ResolutionMethod.TIMEOUT_EXPIRE,
result: {
outcome: 'expired',
reason: 'proposal timeout',
timeoutAt: proposal.timeoutAt
}
};
}
/**
* Resolve by scheduling a revote
* @private
*/
_resolveWithRevote(proposal, deadlock) {
const revoteScheduledAt = new Date(Date.now() + this.revoteDelay);
this.emit('deadlock:resolved', {
proposalId: proposal.id,
method: ResolutionMethod.REVOTE,
result: {
revoteScheduledAt: revoteScheduledAt.toISOString(),
delayMinutes: this.revoteDelay / 60000
}
});
return {
success: true,
proposalId: proposal.id,
method: ResolutionMethod.REVOTE,
result: {
revoteScheduledAt: revoteScheduledAt.toISOString(),
delayMinutes: this.revoteDelay / 60000,
resetVotes: false, // Keep existing votes
notifyMembers: this.triadMembers
}
};
}
/**
* Resolve by escalating to higher authority
* @private
*/
_resolveWithEscalation(proposal, deadlock) {
this.emit('deadlock:resolved', {
proposalId: proposal.id,
method: ResolutionMethod.ESCALATE,
result: {
escalated: true,
requiresExternalDecision: true
}
});
return {
success: true,
proposalId: proposal.id,
method: ResolutionMethod.ESCALATE,
result: {
escalated: true,
requiresExternalDecision: true,
deadlockDetails: deadlock,
proposalData: proposal
}
};
}
/**
* Resolve with random decision (last resort)
* @private
*/
_resolveWithRandom(proposal, deadlock) {
const randomOutcome = Math.random() > 0.5 ? 'approved' : 'rejected';
this.emit('deadlock:resolved', {
proposalId: proposal.id,
method: ResolutionMethod.RANDOM,
result: {
outcome: randomOutcome,
note: 'Random resolution used as last resort'
}
});
return {
success: true,
proposalId: proposal.id,
method: ResolutionMethod.RANDOM,
result: {
outcome: randomOutcome,
note: 'Random resolution used as last resort'
}
};
}
/**
* Get recommended resolution method for a deadlock
* @param {Object} deadlock - Deadlock info
* @returns {string} Recommended method
*/
getRecommendedResolution(deadlock) {
if (!deadlock) {
return null;
}
switch (deadlock.type) {
case DeadlockType.EQUAL_VOTES:
return ResolutionMethod.STEWARD_TIEBREAK;
case DeadlockType.TIMEOUT:
case DeadlockType.QUORUM_FAILURE:
return ResolutionMethod.REVOTE;
default:
return ResolutionMethod.ESCALATE;
}
}
}
module.exports = {
DeadlockDetector,
DeadlockType,
ResolutionMethod
};
-474
View File
@@ -1,474 +0,0 @@
#!/usr/bin/env node
/**
* Triad Orchestrator - Main Entry Point
* ==============================================================================
* Manages triad deliberation workflows including proposals, votes,
* deadlock detection, and consensus ledger synchronization.
*/
const { ProposalTracker, ProposalState } = require('./proposal-tracker');
const VoteCollector = require('./vote-collector');
const { DeadlockDetector, DeadlockType, ResolutionMethod } = require('./deadlock-detector');
/**
* Parse command line arguments
* @returns {Object} Parsed arguments
*/
function parseArgs() {
const args = process.argv.slice(2);
const result = {
command: args[0],
options: {}
};
for (let i = 1; i < args.length; i++) {
const arg = args[i];
if (arg.startsWith('--')) {
const key = arg.slice(2);
const value = args[i + 1] && !args[i + 1].startsWith('--') ? args[++i] : true;
result.options[key] = value;
} else if (arg.startsWith('-')) {
const key = arg.slice(1);
const value = args[i + 1] && !args[i + 1].startsWith('-') ? args[++i] : true;
result.options[key] = value;
}
}
return result;
}
/**
* Format proposal for display
* @param {Object} proposal - Proposal data
*/
function formatProposal(proposal) {
if (!proposal) {
console.log('Proposal not found');
return;
}
console.log('\n=== Proposal Details ===\n');
console.log(`ID: ${proposal.id}`);
console.log(`Title: ${proposal.title}`);
console.log(`Type: ${proposal.type}`);
console.log(`State: ${proposal.state}`);
console.log(`Creator: ${proposal.creator}`);
console.log(`Created: ${new Date(proposal.createdAt).toLocaleString()}`);
console.log(`Updated: ${new Date(proposal.updatedAt).toLocaleString()}`);
console.log(`Timeout: ${new Date(proposal.timeoutAt).toLocaleString()}`);
console.log('\n--- Votes ---');
for (const [voter, voteData] of Object.entries(proposal.votes || {})) {
console.log(` ${voter}: ${voteData.value} (${new Date(voteData.timestamp).toLocaleTimeString()})`);
}
console.log('\n');
}
/**
* Format proposals list
* @param {Array<Object>} proposals - List of proposals
*/
function formatProposalsList(proposals) {
console.log('\n=== Proposals ===\n');
console.log('ID'.padEnd(36), 'TITLE'.padEnd(30), 'STATE'.padEnd(12), 'CREATED');
console.log('-'.repeat(100));
for (const proposal of proposals) {
const title = proposal.title.length > 28 ? proposal.title.slice(0, 28) + '...' : proposal.title;
console.log(
proposal.id.slice(0, 36).padEnd(36),
title.padEnd(30),
proposal.state.padEnd(12),
new Date(proposal.createdAt).toLocaleDateString()
);
}
console.log('\n');
}
/**
* Format vote tally
* @param {Object} tally - Vote tally
*/
function formatVoteTally(tally) {
console.log('\n=== Vote Tally ===\n');
console.log(`Approve: ${tally.approve}`);
console.log(`Reject: ${tally.reject}`);
console.log(`Abstain: ${tally.abstain}`);
console.log(`Total: ${tally.total}`);
if (tally.missing && tally.missing.length > 0) {
console.log(`\nMissing: ${tally.missing.join(', ')}`);
}
console.log('\n');
}
/**
* Format triad dashboard
* @param {Object} dashboard - Dashboard data
*/
function formatDashboard(dashboard) {
console.log('\n╔══════════════════════════════════════════════════════════╗');
console.log('║ TRIAD DELIBERATION DASHBOARD ║');
console.log('╚══════════════════════════════════════════════════════════╝\n');
console.log('=== Active Proposals ===\n');
console.log('STATE'.padEnd(15), 'COUNT');
console.log('-'.repeat(25));
for (const [state, count] of Object.entries(dashboard.proposalStates)) {
console.log(state.padEnd(15), count);
}
console.log('\n=== Triad Members ===\n');
console.log('MEMBER'.padEnd(15), 'STATUS'.padEnd(10), 'VOTES CAST');
console.log('-'.repeat(40));
for (const member of dashboard.triadMembers) {
console.log(
member.id.padEnd(15),
member.status.padEnd(10),
member.votesCast
);
}
console.log('\n=== Recent Activity ===\n');
for (const activity of dashboard.recentActivity.slice(0, 5)) {
console.log(`[${new Date(activity.timestamp).toLocaleTimeString()}] ${activity.type}: ${activity.details}`);
}
console.log('\n');
}
/**
* Main CLI handler
*/
async function main() {
const { command, options } = parseArgs();
const tracker = new ProposalTracker({
gatewayUrl: options.gateway || 'ws://127.0.0.1:18789'
});
const collector = new VoteCollector({
gatewayUrl: options.gateway || 'ws://127.0.0.1:18789'
});
const detector = new DeadlockDetector();
try {
switch (command) {
case 'propose':
{
const proposal = tracker.createProposal({
title: options.title || 'Untitled Proposal',
description: options.description || '',
type: options.type || 'custom',
creator: options.creator || 'cli'
});
console.log('Proposal created:');
formatProposal(proposal);
// Auto-transition to pending
tracker.updateState(proposal.id, ProposalState.PENDING);
console.log('Proposal moved to PENDING state');
}
break;
case 'proposal':
{
const proposalId = options.id || options.proposal;
if (!proposalId) {
console.log('Error: --id or --proposal required');
process.exit(1);
}
const proposal = tracker.getProposal(proposalId);
formatProposal(proposal);
}
break;
case 'proposals':
{
const filter = {};
if (options.status) filter.state = options.status;
if (options.type) filter.type = options.type;
const proposals = tracker.getAllProposals(filter);
formatProposalsList(proposals);
}
break;
case 'vote':
{
const proposalId = options.proposal || options.id;
const vote = options.vote;
const voter = options.voter || 'cli';
if (!proposalId || !vote) {
console.log('Error: --proposal and --vote required');
process.exit(1);
}
const result = await collector.submitVote(proposalId, voter, vote);
console.log('Vote result:', JSON.stringify(result, null, 2));
}
break;
case 'votes':
{
const proposalId = options.proposal || options.id;
if (!proposalId) {
console.log('Error: --proposal required');
process.exit(1);
}
const status = collector.getVoteStatus(proposalId);
console.log('Vote status:', JSON.stringify(status, null, 2));
}
break;
case 'tabulate':
{
const proposalId = options.proposal || options.id;
if (!proposalId) {
console.log('Error: --proposal required');
process.exit(1);
}
const tally = tracker.getVoteTally(proposalId);
formatVoteTally(tally);
// Determine outcome
const outcome = tracker.determineOutcome(proposalId);
console.log('Outcome:', JSON.stringify(outcome, null, 2));
}
break;
case 'check-deadlock':
{
const proposalId = options.proposal || options.id;
if (!proposalId) {
console.log('Error: --proposal required');
process.exit(1);
}
const proposal = tracker.getProposal(proposalId);
const deadlock = detector.detectDeadlock(proposal);
if (deadlock) {
console.log('Deadlock detected:');
console.log(JSON.stringify(deadlock, null, 2));
const recommended = detector.getRecommendedResolution(deadlock);
console.log(`Recommended resolution: ${recommended}`);
} else {
console.log('No deadlock detected');
}
}
break;
case 'resolve-deadlock':
{
const proposalId = options.proposal || options.id;
const method = options.method || 'steward-tiebreak';
if (!proposalId) {
console.log('Error: --proposal required');
process.exit(1);
}
const proposal = tracker.getProposal(proposalId);
const result = detector.resolveDeadlock(proposal, method);
console.log('Resolution result:');
console.log(JSON.stringify(result, null, 2));
if (result.success && result.result.outcome) {
// Update proposal state
const newState = result.result.outcome === 'approved'
? ProposalState.APPROVED
: ProposalState.REJECTED;
tracker.updateState(proposalId, newState);
console.log(`Proposal state updated to ${newState}`);
}
}
break;
case 'sync-ledger':
{
const ledger = tracker.getLedger(50);
console.log('Ledger entries (last 50):');
console.log(JSON.stringify(ledger, null, 2));
}
break;
case 'verify-ledger':
{
const result = tracker.verifyLedger();
console.log('Ledger verification:');
console.log(JSON.stringify(result, null, 2));
}
break;
case 'ledger':
{
const limit = parseInt(options.limit) || 50;
const ledger = tracker.getLedger(limit);
console.log(`Ledger entries (last ${limit}):`);
console.log(JSON.stringify(ledger, null, 2));
}
break;
case 'status':
{
const dashboard = {
proposalStates: {
draft: 0,
pending: 0,
voting: 0,
approved: 0,
rejected: 0,
deadlocked: 0,
executed: 0
},
triadMembers: [
{ id: 'steward', status: 'active', votesCast: 0 },
{ id: 'alpha', status: 'active', votesCast: 0 },
{ id: 'beta', status: 'active', votesCast: 0 },
{ id: 'gamma', status: 'active', votesCast: 0 }
],
recentActivity: tracker.getLedger(10)
};
// Count proposal states
const allProposals = tracker.getAllProposals();
for (const p of allProposals) {
dashboard.proposalStates[p.state]++;
}
if (options.json) {
console.log(JSON.stringify(dashboard, null, 2));
} else {
formatDashboard(dashboard);
}
}
break;
case 'dashboard':
{
const dashboard = {
proposalStates: {
draft: 0,
pending: 0,
voting: 0,
approved: 0,
rejected: 0,
deadlocked: 0,
executed: 0
},
triadMembers: [
{ id: 'steward', status: 'active', votesCast: 0 },
{ id: 'alpha', status: 'active', votesCast: 0 },
{ id: 'beta', status: 'active', votesCast: 0 },
{ id: 'gamma', status: 'active', votesCast: 0 }
],
recentActivity: tracker.getLedger(10)
};
const allProposals = tracker.getAllProposals();
for (const p of allProposals) {
dashboard.proposalStates[p.state]++;
}
formatDashboard(dashboard);
}
break;
case 'history':
{
const proposalId = options.proposal || options.id;
if (!proposalId) {
console.log('Error: --proposal required');
process.exit(1);
}
const proposal = tracker.getProposal(proposalId);
console.log('Proposal history:');
console.log(JSON.stringify(proposal?.voteHistory || [], null, 2));
}
break;
default:
console.log(`
Triad Orchestrator
Usage: node index.js <command> [options]
Commands:
propose Create a new proposal
proposal View proposal details (--id required)
proposals List all proposals
vote Submit a vote (--proposal, --vote required)
votes View vote status (--proposal required)
tabulate Tabulate votes (--proposal required)
check-deadlock Check for deadlock (--proposal required)
resolve-deadlock Resolve deadlock (--proposal, --method required)
sync-ledger Sync consensus ledger
verify-ledger Verify ledger integrity
ledger View ledger entries
status Show triad status
dashboard Show full triad dashboard
history View proposal vote history
Options:
--id <id> Proposal ID
--proposal <id> Proposal ID
--title <title> Proposal title
--type <type> Proposal type (config, deployment, governance, etc.)
--vote <vote> Vote value (approve, reject, abstain)
--voter <id> Voter ID
--method <method> Deadlock resolution method
--json Output in JSON format
--gateway <url> Gateway WebSocket URL
Examples:
node index.js propose --title "Deploy update" --type deployment
node index.js proposal --id <proposal-id>
node index.js vote --proposal <id> --vote approve --voter alpha
node index.js tabulate --proposal <id>
node index.js check-deadlock --proposal <id>
node index.js dashboard
`);
break;
}
} catch (error) {
console.error('Error:', error.message);
process.exit(1);
} finally {
await collector.disconnect();
}
}
// Export for programmatic use
module.exports = {
ProposalTracker,
VoteCollector,
DeadlockDetector,
ProposalState,
main
};
// Run CLI if executed directly
if (require.main === module) {
main();
}
@@ -1,467 +0,0 @@
/**
* Proposal Tracker - Proposal State Machine for Triad Deliberation
* ==============================================================================
* Manages proposal lifecycle through states: draft -> pending -> voting -> approved/rejected/deadlocked
* Tracks proposal metadata, votes, and execution status.
*/
const fs = require('fs');
const path = require('path');
const { v4: uuidv4 } = require('uuid');
const EventEmitter = require('events');
// Proposal states
const ProposalState = {
DRAFT: 'draft',
PENDING: 'pending',
VOTING: 'voting',
APPROVED: 'approved',
REJECTED: 'rejected',
DEADLOCKED: 'deadlocked',
EXECUTED: 'executed',
EXPIRED: 'expired'
};
// Proposal types
const ProposalType = {
CONFIG: 'config',
DEPLOYMENT: 'deployment',
GOVERNANCE: 'governance',
SECURITY: 'security',
MAINTENANCE: 'maintenance',
CUSTOM: 'custom'
};
class ProposalTracker extends EventEmitter {
constructor(config = {}) {
super();
this.ledgerPath = config.ledgerPath || '/app/state/triad-ledger.json';
this.proposalsPath = config.proposalsPath || '/app/state/proposals';
this.gatewayUrl = config.gatewayUrl || 'ws://127.0.0.1:18789';
// Quorum configuration
this.quorumConfig = {
minimumVotes: config.minimumVotes || 2,
approvalThreshold: config.approvalThreshold || 0.5, // 50% for approval
timeoutMs: config.timeoutMs || 3600000, // 1 hour default
triadMembers: config.triadMembers || ['steward', 'alpha', 'beta', 'gamma']
};
// Ensure directories exist
this._ensureDirectories();
}
/**
* Ensure required directories exist
* @private
*/
_ensureDirectories() {
const dir = path.dirname(this.proposalsPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
/**
* Create a new proposal
* @param {Object} proposal - Proposal data
* @returns {Object} Created proposal
*/
createProposal(proposal) {
const id = uuidv4();
const now = new Date().toISOString();
const newProposal = {
id,
title: proposal.title || 'Untitled Proposal',
description: proposal.description || '',
type: proposal.type || ProposalType.CUSTOM,
state: ProposalState.DRAFT,
creator: proposal.creator || 'unknown',
createdAt: now,
updatedAt: now,
timeoutAt: new Date(Date.now() + this.quorumConfig.timeoutMs).toISOString(),
votes: {},
voteHistory: [],
executionResult: null,
metadata: proposal.metadata || {}
};
// Save proposal
this._saveProposal(newProposal);
// Add to ledger
this._addToLedger({
type: 'proposal_created',
proposalId: id,
timestamp: now,
creator: newProposal.creator
});
this.emit('proposal:created', newProposal);
return newProposal;
}
/**
* Get proposal by ID
* @param {string} proposalId - Proposal identifier
* @returns {Object|null} Proposal or null
*/
getProposal(proposalId) {
const proposalPath = path.join(this.proposalsPath, `${proposalId}.json`);
if (fs.existsSync(proposalPath)) {
return JSON.parse(fs.readFileSync(proposalPath, 'utf8'));
}
return null;
}
/**
* Update proposal state
* @param {string} proposalId - Proposal identifier
* @param {string} newState - New state
* @param {Object} metadata - Additional metadata
* @returns {Object} Updated proposal
*/
updateState(proposalId, newState, metadata = {}) {
const proposal = this.getProposal(proposalId);
if (!proposal) {
throw new Error(`Proposal not found: ${proposalId}`);
}
const validTransitions = this._getValidTransitions(proposal.state);
if (!validTransitions.includes(newState)) {
throw new Error(`Invalid state transition from ${proposal.state} to ${newState}`);
}
const oldState = proposal.state;
proposal.state = newState;
proposal.updatedAt = new Date().toISOString();
Object.assign(proposal, metadata);
this._saveProposal(proposal);
// Log state change
this._addToLedger({
type: 'proposal_state_change',
proposalId,
oldState,
newState,
timestamp: proposal.updatedAt
});
this.emit('proposal:statechange', { proposal, oldState, newState });
return proposal;
}
/**
* Get valid state transitions for current state
* @private
*/
_getValidTransitions(state) {
const transitions = {
[ProposalState.DRAFT]: [ProposalState.PENDING, ProposalState.EXPIRED],
[ProposalState.PENDING]: [ProposalState.VOTING, ProposalState.EXPIRED],
[ProposalState.VOTING]: [ProposalState.APPROVED, ProposalState.REJECTED, ProposalState.DEADLOCKED, ProposalState.EXPIRED],
[ProposalState.APPROVED]: [ProposalState.EXECUTED],
[ProposalState.REJECTED]: [],
[ProposalState.DEADLOCKED]: [ProposalState.VOTING, ProposalState.EXPIRED],
[ProposalState.EXECUTED]: [],
[ProposalState.EXPIRED]: []
};
return transitions[state] || [];
}
/**
* Add vote to proposal
* @param {string} proposalId - Proposal identifier
* @param {string} voter - Voter identifier
* @param {string} vote - Vote value (approve, reject, abstain)
* @returns {Object} Updated proposal
*/
addVote(proposalId, voter, vote) {
const proposal = this.getProposal(proposalId);
if (!proposal) {
throw new Error(`Proposal not found: ${proposalId}`);
}
if (proposal.state !== ProposalState.VOTING && proposal.state !== ProposalState.PENDING) {
throw new Error(`Cannot vote on proposal in state ${proposal.state}`);
}
// Validate vote
const validVotes = ['approve', 'reject', 'abstain'];
if (!validVotes.includes(vote)) {
throw new Error(`Invalid vote: ${vote}. Must be one of: ${validVotes.join(', ')}`);
}
// Record vote
const previousVote = proposal.votes[voter];
proposal.votes[voter] = {
value: vote,
timestamp: new Date().toISOString()
};
proposal.voteHistory.push({
voter,
vote,
timestamp: proposal.votes[voter].timestamp,
previousVote
});
proposal.updatedAt = new Date().toISOString();
this._saveProposal(proposal);
// Log vote
this._addToLedger({
type: 'vote_cast',
proposalId,
voter,
vote,
previousVote,
timestamp: proposal.votes[voter].timestamp
});
this.emit('vote:cast', { proposalId, voter, vote });
// Auto-transition to voting if first vote
if (proposal.state === ProposalState.PENDING && Object.keys(proposal.votes).length > 0) {
this.updateState(proposalId, ProposalState.VOTING);
}
return proposal;
}
/**
* Get vote tally for proposal
* @param {string} proposalId - Proposal identifier
* @returns {Object} Vote tally
*/
getVoteTally(proposalId) {
const proposal = this.getProposal(proposalId);
if (!proposal) {
return null;
}
const tally = {
approve: 0,
reject: 0,
abstain: 0,
total: 0,
missing: []
};
for (const [voter, voteData] of Object.entries(proposal.votes)) {
tally[voteData.value]++;
tally.total++;
}
// Find missing votes
for (const member of this.quorumConfig.triadMembers) {
if (!proposal.votes[member]) {
tally.missing.push(member);
}
}
return tally;
}
/**
* Check if proposal has reached quorum
* @param {string} proposalId - Proposal identifier
* @returns {Object} Quorum status
*/
checkQuorum(proposalId) {
const tally = this.getVoteTally(proposalId);
if (!tally) {
return { hasQuorum: false, reason: 'proposal_not_found' };
}
const hasQuorum = tally.total >= this.quorumConfig.minimumVotes;
return {
hasQuorum,
votes: tally.total,
required: this.quorumConfig.minimumVotes,
missing: tally.missing
};
}
/**
* Determine proposal outcome based on votes
* @param {string} proposalId - Proposal identifier
* @returns {Object} Outcome determination
*/
determineOutcome(proposalId) {
const proposal = this.getProposal(proposalId);
const tally = this.getVoteTally(proposalId);
if (!proposal || !tally) {
return { outcome: 'unknown', reason: 'proposal_not_found' };
}
// Check for deadlock (equal approve/reject)
if (tally.approve === tally.reject && tally.approve > 0) {
return {
outcome: 'deadlock',
reason: 'equal_votes',
tally
};
}
// Check if quorum reached
if (tally.total < this.quorumConfig.minimumVotes) {
return {
outcome: 'pending',
reason: 'quorum_not_reached',
tally
};
}
// Determine winner
const approvalRate = tally.approve / (tally.approve + tally.reject);
if (approvalRate > this.quorumConfig.approvalThreshold) {
return {
outcome: 'approved',
approvalRate,
tally
};
} else {
return {
outcome: 'rejected',
approvalRate,
tally
};
}
}
/**
* Get all proposals
* @param {Object} filter - Filter options
* @returns {Array<Object>} List of proposals
*/
getAllProposals(filter = {}) {
if (!fs.existsSync(this.proposalsPath)) {
return [];
}
const files = fs.readdirSync(this.proposalsPath);
const proposals = [];
for (const file of files) {
if (!file.endsWith('.json')) continue;
try {
const proposal = this.getProposal(file.replace('.json', ''));
// Apply filters
if (filter.state && proposal.state !== filter.state) continue;
if (filter.type && proposal.type !== filter.type) continue;
if (filter.creator && proposal.creator !== filter.creator) continue;
proposals.push(proposal);
} catch (error) {
// Skip corrupted files
}
}
// Sort by creation date
proposals.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
return proposals;
}
/**
* Save proposal to disk
* @private
*/
_saveProposal(proposal) {
const proposalPath = path.join(this.proposalsPath, `${proposal.id}.json`);
fs.writeFileSync(proposalPath, JSON.stringify(proposal, null, 2));
}
/**
* Add entry to ledger
* @private
*/
_addToLedger(entry) {
let ledger = this._loadLedger();
ledger.entries.push(entry);
// Keep last 1000 entries
if (ledger.entries.length > 1000) {
ledger.entries = ledger.entries.slice(-1000);
}
ledger.updatedAt = new Date().toISOString();
fs.writeFileSync(this.ledgerPath, JSON.stringify(ledger, null, 2));
}
/**
* Load ledger from disk
* @private
*/
_loadLedger() {
if (fs.existsSync(this.ledgerPath)) {
return JSON.parse(fs.readFileSync(this.ledgerPath, 'utf8'));
}
return {
entries: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
}
/**
* Get ledger entries
* @param {number} limit - Maximum entries to return
* @returns {Array<Object>} Ledger entries
*/
getLedger(limit = 100) {
const ledger = this._loadLedger();
return ledger.entries.slice(-limit);
}
/**
* Verify ledger integrity
* @returns {Object} Verification result
*/
verifyLedger() {
const ledger = this._loadLedger();
const issues = [];
// Check for duplicate entries
const seen = new Set();
for (const entry of ledger.entries) {
const key = `${entry.type}-${entry.proposalId}-${entry.timestamp}`;
if (seen.has(key)) {
issues.push(`Duplicate entry: ${key}`);
}
seen.add(key);
}
return {
valid: issues.length === 0,
entryCount: ledger.entries.length,
issues
};
}
}
module.exports = {
ProposalTracker,
ProposalState,
ProposalType
};
@@ -1,273 +0,0 @@
/**
* Vote Collector - Vote Aggregation for Triad Deliberation
* ==============================================================================
* Collects, validates, and aggregates votes from triad members.
* Supports weighted voting and vote delegation.
*/
const EventEmitter = require('events');
const WebSocket = require('ws');
class VoteCollector extends EventEmitter {
constructor(config = {}) {
super();
this.gatewayUrl = config.gatewayUrl || 'ws://127.0.0.1:18789';
this.triadMembers = config.triadMembers || ['steward', 'alpha', 'beta', 'gamma'];
this.voteWeights = config.voteWeights || {
steward: 1.5,
alpha: 1.0,
beta: 1.0,
gamma: 1.0
};
this.ws = null;
this.pendingVotes = new Map();
this.voteTimeout = config.voteTimeout || 30000;
}
/**
* Connect to Gateway for vote collection
* @returns {Promise<boolean>} Connection status
*/
async connectToGateway() {
return new Promise((resolve, reject) => {
try {
this.ws = new WebSocket(this.gatewayUrl);
this.ws.on('open', () => {
this.emit('gateway:connected');
resolve(true);
});
this.ws.on('message', (data) => {
this._handleGatewayMessage(data);
});
this.ws.on('error', (error) => {
this.emit('gateway:error', error);
reject(error);
});
this.ws.on('close', () => {
this.emit('gateway:disconnected');
});
setTimeout(() => {
if (this.ws && this.ws.readyState === WebSocket.CONNECTING) {
reject(new Error('Gateway connection timeout'));
}
}, 10000);
} catch (error) {
reject(error);
}
});
}
/**
* Handle Gateway messages
* @private
*/
_handleGatewayMessage(data) {
try {
const message = JSON.parse(data.toString());
if (message.type === 'vote_submission') {
this._processVote(message.proposalId, message.voter, message.vote);
}
} catch (error) {
this.emit('error', { operation: 'handleGatewayMessage', error: error.message });
}
}
/**
* Process incoming vote
* @private
*/
_processVote(proposalId, voter, vote) {
if (this.pendingVotes.has(proposalId)) {
const voteData = this.pendingVotes.get(proposalId);
voteData.votes[voter] = {
value: vote,
timestamp: new Date().toISOString(),
weight: this.voteWeights[voter] || 1.0
};
this.emit('vote:received', { proposalId, voter, vote });
}
}
/**
* Submit vote for a proposal
* @param {string} proposalId - Proposal identifier
* @param {string} voter - Voter identifier
* @param {string} vote - Vote value
* @returns {Promise<Object>} Vote result
*/
async submitVote(proposalId, voter, vote) {
const validVotes = ['approve', 'reject', 'abstain'];
if (!validVotes.includes(vote)) {
return {
success: false,
error: `Invalid vote: ${vote}. Must be one of: ${validVotes.join(', ')}`
};
}
if (!this.triadMembers.includes(voter)) {
return {
success: false,
error: `Unknown voter: ${ voter }. Valid voters: ${this.triadMembers.join(', ')}`
};
}
// Send vote via Gateway
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
const voteMessage = {
type: 'vote_submission',
proposalId,
voter,
vote,
timestamp: new Date().toISOString()
};
this.ws.send(JSON.stringify(voteMessage));
return {
success: true,
proposalId,
voter,
vote,
weight: this.voteWeights[voter] || 1.0
};
} else {
// Store locally if Gateway not connected
if (!this.pendingVotes.has(proposalId)) {
this.pendingVotes.set(proposalId, {
proposalId,
votes: {},
createdAt: new Date().toISOString()
});
}
const voteData = this.pendingVotes.get(proposalId);
voteData.votes[voter] = {
value: vote,
timestamp: new Date().toISOString(),
weight: this.voteWeights[voter] || 1.0
};
return {
success: true,
proposalId,
voter,
vote,
weight: this.voteWeights[voter] || 1.0,
note: 'Stored locally (Gateway not connected)'
};
}
}
/**
* Get aggregated vote results
* @param {string} proposalId - Proposal identifier
* @returns {Object} Vote aggregation
*/
getAggregation(proposalId) {
const voteData = this.pendingVotes.get(proposalId);
if (!voteData) {
return null;
}
const aggregation = {
proposalId,
weighted: {
approve: 0,
reject: 0,
abstain: 0
},
raw: {
approve: 0,
reject: 0,
abstain: 0
},
byVoter: {},
totalWeight: 0,
timestamp: new Date().toISOString()
};
for (const [voter, voteInfo] of Object.entries(voteData.votes)) {
// Raw count
aggregation.raw[voteInfo.value]++;
// Weighted count
aggregation.weighted[voteInfo.value] += voteInfo.weight;
aggregation.totalWeight += voteInfo.weight;
// By voter
aggregation.byVoter[voter] = voteInfo;
}
// Calculate percentages
const totalVotes = aggregation.raw.approve + aggregation.raw.reject + aggregation.raw.abstain;
if (totalVotes > 0) {
aggregation.percentages = {
approve: (aggregation.raw.approve / totalVotes) * 100,
reject: (aggregation.raw.reject / totalVotes) * 100,
abstain: (aggregation.raw.abstain / totalVotes) * 100
};
}
return aggregation;
}
/**
* Get vote status for all triad members
* @param {string} proposalId - Proposal identifier
* @returns {Object} Vote status
*/
getVoteStatus(proposalId) {
const voteData = this.pendingVotes.get(proposalId);
const status = {
proposalId,
voted: [],
missing: [],
total: this.triadMembers.length
};
for (const member of this.triadMembers) {
if (voteData && voteData.votes[member]) {
status.voted.push({
member,
vote: voteData.votes[member].value,
timestamp: voteData.votes[member].timestamp
});
} else {
status.missing.push(member);
}
}
return status;
}
/**
* Clear vote data for a proposal
* @param {string} proposalId - Proposal identifier
*/
clearVotes(proposalId) {
this.pendingVotes.delete(proposalId);
this.emit('votes:cleared', proposalId);
}
/**
* Disconnect from Gateway
*/
async disconnect() {
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
}
module.exports = VoteCollector;
+9 -1
View File
@@ -232,7 +232,15 @@ class BFTConsensus {
const subscriber = this.redis.duplicate();
subscriber.subscribe('bft:consensus', async (message) => {
const msg = JSON.parse(message);
// Skip Redis subscription confirmation strings (not JSON)
if (!message || typeof message !== 'string') return;
let msg;
try {
msg = JSON.parse(message);
} catch {
// Not JSON — skip (Redis subscription confirmation)
return;
}
await this.handleMessage(msg);
});