mirror of
https://github.com/Heretek-AI/heretek-openclaw-core.git
synced 2026-07-01 14:17:57 -04:00
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:
@@ -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
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user