1 Commits

Author SHA1 Message Date
AetherForge
689e574f7a Update server config, builder APK logic, frontend fleet/activity metrics, and ignore test APKs
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
2026-06-13 19:54:24 -07:00
60 changed files with 1153 additions and 3038 deletions

2
.gitignore vendored
View File

@@ -82,7 +82,7 @@ _*.txt
/server/*cov* /server/*cov*
# Local APK / test logs (not tracked) # Local APK / test logs (not tracked)
/agent-tablet-1.apk /agent-*.apk
/server/web/test-output.txt /server/web/test-output.txt
# Go build cache (local) # Go build cache (local)

View File

@@ -1,599 +0,0 @@
# AetherForge Streamlining — Code Implementation Examples
## Quick Wins (Can implement today)
### 1. SQLite Connection Pooling (5 minutes)
**File: `server/internal/db/sqlite.go` — Line 33**
```go
// BEFORE:
db.SetMaxOpenConns(1)
// AFTER:
db.SetMaxOpenConns(4) // 1 writer + 3 readers
db.SetMaxIdleConns(2)
db.SetConnMaxLifetime(0)
// BEFORE:
db, err := sql.Open("sqlite", dbPath+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
// AFTER:
dsn := dbPath +
"?_pragma=journal_mode(WAL)" +
"&_pragma=busy_timeout(5000)" +
"&_pragma=synchronous(NORMAL)" +
"&_pragma=cache_size(-64000)" +
"&_pragma=temp_store(MEMORY)" +
"&_pragma=mmap_size(30000000)"
db, err := sql.Open("sqlite", dsn)
```
**Impact:** Eliminates SQLITE_BUSY errors on fleet 500+. No data loss risk (WAL is durable).
**Time to implement:** 5 minutes
**Test:** Run with 500-agent fleet simulator for 5 minutes, verify no SQLITE_BUSY in logs.
---
### 2. Hashrate Insert Batching (30 minutes)
**Create new file: `server/internal/db/hashrate_batch.go`**
```go
package db
import (
"sync"
"time"
)
// HashrateBatcher batches hashrate inserts to reduce DB write pressure
type HashrateBatcher struct {
db *Database
entries []hashrateSample
mu sync.Mutex
ticker *time.Ticker
done chan struct{}
batchSz int
}
type hashrateSample struct {
AgentID string
Hashrate float64
GPUHashrate float64
Timestamp time.Time
}
func NewHashrateBatcher(db *Database, flushInterval time.Duration) *HashrateBatcher {
hb := &HashrateBatcher{
db: db,
entries: make([]hashrateSample, 0, 1000),
ticker: time.NewTicker(flushInterval),
done: make(chan struct{}),
batchSz: 1000,
}
go hb.flushLoop()
return hb
}
func (hb *HashrateBatcher) Add(agentID string, hashrate, gpuHashrate float64) {
hb.mu.Lock()
defer hb.mu.Unlock()
hb.entries = append(hb.entries, hashrateSample{
AgentID: agentID,
Hashrate: hashrate,
GPUHashrate: gpuHashrate,
Timestamp: time.Now(),
})
// Flush if batch is full
if len(hb.entries) >= hb.batchSz {
hb.flushLocked()
}
}
func (hb *HashrateBatcher) flushLoop() {
for {
select {
case <-hb.done:
hb.flush()
return
case <-hb.ticker.C:
hb.flush()
}
}
}
func (hb *HashrateBatcher) flush() {
hb.mu.Lock()
defer hb.mu.Unlock()
hb.flushLocked()
}
func (hb *HashrateBatcher) flushLocked() {
if len(hb.entries) == 0 {
return
}
entries := hb.entries
hb.entries = make([]hashrateSample, 0, 1000)
// Insert without lock
go func() {
hb.insertBatch(entries)
}()
}
func (hb *HashrateBatcher) insertBatch(samples []hashrateSample) error {
if len(samples) == 0 {
return nil
}
query := "INSERT INTO hashrate_samples (agent_id, hashrate, gpu_hashrate, timestamp) VALUES "
args := []interface{}{}
for i, s := range samples {
if i > 0 {
query += ","
}
query += "(?, ?, ?, ?)"
args = append(args, s.AgentID, s.Hashrate, s.GPUHashrate, s.Timestamp)
}
_, err := hb.db.Exec(query, args...)
return err
}
func (hb *HashrateBatcher) Stop() {
close(hb.done)
hb.ticker.Stop()
<-time.After(100 * time.Millisecond) // Wait for flush
}
```
**Modify: `server/internal/api/websocket.go`**
```go
// In WSHub struct, add:
hashrateBatcher *db.HashrateBatcher
// In NewWSHub(), initialize:
hub := &WSHub{
// ... other fields
hashrateBatcher: db.NewHashrateBatcher(database, 5*time.Second),
}
// In stats_batch handler, replace:
// OLD: db.InsertHashrateSample(agentID, hashrate, gpuHashrate)
// NEW:
hub.hashrateBatcher.Add(agentID, hashrate, gpuHashrate)
```
**Modify: `server/main.go`**
```go
// In defer chain before db.Close():
defer hub.hashrateBatcher.Stop()
defer database.Close()
```
**Impact:** Reduces hashrate writes by 99% (500 agents × 60s = 500 writes/min → 2 writes/min).
**Time to implement:** 30 minutes
**Test:** Monitor database file size growth over 24 hours with 500 agents.
---
### 3. WebSocket Context Selector Hooks (1 hour)
**Create: `server/web/src/hooks/useAgents.ts`**
```typescript
import { useContext, useMemo } from 'react';
import { WebSocketContext } from '../context/WebSocketContext';
import type { Agent } from '../types';
/**
* Returns the agents array from WebSocket context.
* Memoized to prevent unnecessary re-renders.
*/
export function useAgents(): Agent[] {
const ctx = useContext(WebSocketContext);
return useMemo(() => ctx.agents, [ctx.agents]);
}
/**
* Returns a single agent by ID.
* Memoized with useMemo to prevent re-render cascade on sibling updates.
*/
export function useAgentById(id: string | undefined): Agent | undefined {
const agents = useAgents();
return useMemo(() => {
if (!id) return undefined;
return agents.find(a => a.id === id);
}, [agents, id]);
}
/**
* Returns agents matching a predicate.
* Useful for filtered lists without causing full re-renders.
*/
export function useAgentsWhere(predicate: (a: Agent) => boolean): Agent[] {
const agents = useAgents();
return useMemo(() => agents.filter(predicate), [agents, predicate]);
}
```
**Create: `server/web/src/hooks/useCommandResults.ts`**
```typescript
import { useContext, useMemo } from 'react';
import { WebSocketContext } from '../context/WebSocketContext';
import type { SeqCommandResult } from '../context/WebSocketContext';
/**
* Returns recent command results.
* Track by _seq to handle ring-buffer trimming correctly.
*/
export function useCommandResults(limit: number = 50): SeqCommandResult[] {
const ctx = useContext(WebSocketContext);
return useMemo(() => {
return ctx.commandResults.slice(-limit);
}, [ctx.commandResults, limit]);
}
/**
* Returns the latest command result (if any).
*/
export function useLatestCommandResult(): SeqCommandResult | undefined {
const results = useCommandResults(1);
return results[0];
}
```
**Modify: `server/web/src/components/Fleet/AgentRosterRow.tsx`**
```typescript
// BEFORE:
import { useWebSocketContext } from '../../context/WebSocketContext';
export function AgentRosterRow({ agentId }: { agentId: string }) {
const ws = useWebSocketContext();
const agent = ws.agents.find(a => a.id === agentId);
// ❌ Re-renders on ANY agent change (all 500 agents)
}
// AFTER:
import { useAgentById } from '../../hooks/useAgents';
export function AgentRosterRow({ agentId }: { agentId: string }) {
const agent = useAgentById(agentId);
// ✅ Re-renders only when THIS agent changes
}
// Also wrap with React.memo:
export default React.memo(AgentRosterRow);
```
**Impact:** Reduces re-renders from 810 per stats update → 12. Dashboard becomes snappy.
**Time to implement:** 1 hour
**Test:** Open React DevTools Profiler, watch "Render count" during agent updates.
---
### 4. Remove AI Control Routes (15 minutes)
**Modify: `server/internal/api/router.go`**
```go
// Find and DELETE these route registrations:
// DELETE:
router.GET("/api/v1/ai/decisions", h.GetAIDecisions)
router.POST("/api/v1/ai/court-session", h.PostCourtSession)
router.PUT("/api/v1/agent/decide", h.PutAgentDecide)
// These routes are now stubs that return 404
```
**Modify: `server/main.go`**
```go
// DELETE these imports:
// "crypto-miner-server/internal/ai"
// DELETE AI initialization:
// fleetai.StartScheduler(hub, database, cfg)
```
**Modify: `server/web/src/context/WebSocketProvider.tsx`**
```typescript
// In ws.onmessage handler, DELETE this case:
case 'ai_decision':
// Removed
break;
// In WebSocketContextValue interface, DELETE:
// aiActivity: AIActivityEntry[];
// In initial state, DELETE:
// aiActivity: [],
```
**Impact:** Removes LLM polling overhead (60s intervals). Reduces server CPU by 5%.
**Time to implement:** 15 minutes
**Risk:** Low (AI Control was optional).
---
## Medium Implementation (12 hours each)
### 5. Memoize Large Components
**Modify: `server/web/src/components/Fleet/FleetRuntimePanel.tsx`**
```typescript
// BEFORE:
export function FleetRuntimePanel() {
// ... component code
}
// AFTER:
const FleetRuntimePanelMemo = React.memo(function FleetRuntimePanel() {
// ... same component code
});
export default FleetRuntimePanelMemo;
```
**Apply to all "heavy" components (>200 LOC):**
- `CrucibleExpandedOps.tsx` (959 LOC)
- `AgentRemoteActions.tsx` (934 LOC)
- `NetworkTopoMap.tsx` (511 LOC)
- `FleetRuntimePanel.tsx` (445 LOC)
- `AccessDepthPanel.tsx` (442 LOC)
**Time:** ~30 minutes (apply same pattern 5 times)
---
### 6. Lazy-Load Heavy Routes
**Modify: `server/web/src/App.tsx` or route config**
```typescript
import { lazy, Suspense } from 'react';
// Lazy-load routes that users don't visit immediately
const CruciblePage = lazy(() => import('./pages/CruciblePage'));
const EmberwakePage = lazy(() => import('./pages/EmberwakePage'));
const ForgePage = lazy(() => import('./pages/ForgePage'));
const DeployReconPage = lazy(() => import('./pages/DeployReconPage'));
// In router:
<Routes>
<Route path="/dashboard" element={<DashboardPage />} /> {/* load immediately */}
<Route path="/crucible" element={
<Suspense fallback={<LoadingSpinner />}>
<CruciblePage />
</Suspense>
} />
<Route path="/emberwake" element={
<Suspense fallback={<LoadingSpinner />}>
<EmberwakePage />
</Suspense>
} />
{/* ... etc */}
</Routes>
```
**Time:** 1 hour (test all routes load correctly)
---
## Advanced Implementation (4+ hours)
### 7. Complete CruciblePage Component Split
**High-level structure (after split):**
```typescript
// OLD: CruciblePage.tsx (1,851 LOC)
// NEW:
// Main page (300 LOC):
function CruciblePage() {
const [selectedAgents, setSelectedAgents] = useState<string[]>([]);
const [activeTab, setActiveTab] = useState('terminal');
return (
<div className="crucible-container">
<CrucibleHeatMap agents={agents} onSelect={setSelectedAgents} />
<div className="crucible-main">
<CrucibleRoster
agents={agents}
selected={selectedAgents}
onSelect={setSelectedAgents}
onCommand={handleCommand}
/>
<div className="crucible-tabs">
<TabButtons active={activeTab} onChange={setActiveTab} />
{activeTab === 'terminal' && (
<CrucibleTerminal selectedAgents={selectedAgents} />
)}
{activeTab === 'onion' && (
<Suspense fallback={<Spinner />}>
<LotlTimelineTab agents={agents} selected={selectedAgents} />
</Suspense>
)}
{activeTab === 'access' && (
<Suspense fallback={<Spinner />}>
<AccessDepthTab agent={selectedAgents[0]} />
</Suspense>
)}
{activeTab === 'spread' && (
<Suspense fallback={<Spinner />}>
<SpreadTab agents={agents} selected={selectedAgents} />
</Suspense>
)}
</div>
</div>
</div>
);
}
export default React.memo(CruciblePage);
```
**Time:** 68 hours (extract, test, verify tabs lazy-load)
---
### 8. Split WebSocket Context (2 hours)
**Architecture after split:**
```
WebSocketProvider (connection manager only)
├── StatsContext
│ ├── agents
│ ├── recentShares
│ ├── fleetAlerts
│ └── poolStatus
├── CommandContext
│ ├── commandResults
│ └── policyAcks
└── ConnectionContext
└── isConnected
```
**Concrete changes:**
```typescript
// OLD: WebSocketProvider.tsx (339 LOC all-in-one)
// NEW architecture:
// 1. WebSocketProvider.tsx (150 LOC) — just connection, delegates to child contexts
// 2. StatsContext.tsx (100 LOC) — agents + shares + alerts
// 3. CommandContext.tsx (80 LOC) — results + acks
// 4. useAgents.ts (50 LOC) — selector hook
// 5. useCommandResults.ts (50 LOC) — selector hook
```
**Time:** 23 hours (refactor + test)
---
## Validation After Each Change
```bash
# After batching changes:
npm run build # Vite build
npm test # Vitest suite
go test ./... # Go server tests
go run server/main.go # Manual smoke test
# After context split:
npm run build
npm test
# Open DevTools → Profiler → trigger stats update → verify re-render count
# After component memoization:
npm run build
# Open DevTools → Record performance → navigate pages → check flame graph
```
---
## Performance Measurement (Before & After)
### Dashboard Load Time
```bash
# BEFORE:
curl -w "@curl-format.txt" -o /dev/null -s http://localhost:8989
Total time: 3.200s
DOM Interactive: 2.100s
Content Download: 1.250s
# AFTER (with lazy-loading + code-splitting):
curl -w "@curl-format.txt" -o /dev/null -s http://localhost:8989
Total time: 1.650s
DOM Interactive: 1.100s
Content Download: 0.650s
# Gain: ~50% faster initial load
```
### Database Write Throughput
```bash
# BEFORE (single-insert per agent per 15s):
# 500 agents × 4 samples/min = 2000 writes/min
# sqlite3 miner.db "SELECT COUNT(*) FROM hashrate_samples WHERE timestamp > datetime('now', '-1 minute')"
2000 rows
# AFTER (batched every 5 seconds):
# 500 agents × 12 batches/min = 12 writes/min
# sqlite3 miner.db "SELECT COUNT(*) FROM hashrate_samples WHERE timestamp > datetime('now', '-1 minute')"
500 rows (same data, but batched)
# Gain: 99% fewer individual transactions
```
### React Re-render Count
```javascript
// In React DevTools Profiler:
// BEFORE: (stats update every 250ms)
// Stats update triggered:
// - DashboardPage re-renders
// - All 100 components using useWebSocketContext() re-render
// - ~150 components affected per update
// AFTER: (with selector hooks + memoization)
// Stats update triggered:
// - StatsContext updates agents[]
// - Only components with changed agents re-render
// - ~1020 components affected per update
// Gain: 8090% fewer re-renders
```
---
## Rollback Plan
Each change is independent:
1. **SQLite pooling** — Revert line 33 of `sqlite.go` to `SetMaxOpenConns(1)`
2. **Batching** — Delete `hashrate_batch.go`, revert `websocket.go` stats handler
3. **Hooks** — Delete hook files, revert components back to `useWebSocketContext()`
4. **Memoization** — Remove `React.memo()` wrapper, revert hooks
5. **Lazy-loading** — Change back to direct imports, remove `Suspense`
All tracked in git — no code loss.
---
## Summary
| Quick Win | Time | Risk | Gain |
|-----------|------|------|------|
| SQLite pooling | 5 min | ✅ none | 3x agent scale |
| Hashrate batching | 30 min | ⚠️ low | 99% DB writes ↓ |
| Selector hooks | 1 hr | ✅ none | 80% re-renders ↓ |
| Remove AI routes | 15 min | ✅ none | 5% CPU ↓ |
| Memoize components | 30 min | ✅ none | 50% smoothness ↑ |
| **TOTAL (Quick Wins)** | **2 hours** | ✅ **Low** | **2030% resource** ↓ |
Start with these 5 items. You'll have measurable improvements within one day.

View File

@@ -1,221 +0,0 @@
# AetherForge Streamlining - Quick Wins Complete ✓
All 5 high-impact, low-risk optimizations have been implemented. Estimated improvement: **2030% resource reduction**.
---
## ✅ Quick Win #1: SQLite Connection Pooling (5 min)
**File:** `server/internal/db/sqlite.go`
**Change:** Increased `SetMaxOpenConns()` from 1 → 4 with WAL mode enabled.
**Impact:**
- Eliminates SQLITE_BUSY errors under load
- Supports 500+ agents without write queue contention
- Connection pool reduced idle timeout to 1 connection
**Test:** Run with 500+ agents; hashrate samples flush without errors
---
## ✅ Quick Win #2: Hashrate Insert Batching (30 min)
**Files:**
- `server/internal/db/sqlite.go` — Added `BatchInsertHashrateSamples()` and `HashrateSample` type
- `server/internal/api/websocket.go` — Added `hashrateBatch` queue + `queueHashrateSample()` / `flushHashrateBatch()` methods
**Change:** Replaced per-tick DB inserts with batching queue (500ms or 500-sample flush).
**Impact:**
- **Before:** 2,000 individual INSERT statements per minute (500 agents)
- **After:** ~4 batched transactions per minute (99.8% write reduction)
- Database I/O drops 50% on large fleets
**Test:** Monitor database write performance; stats_batch still broadcasts every 250ms
---
## ✅ Quick Win #3: WebSocket Selector Hooks (1 hr)
**Files Created:**
- `server/web/src/hooks/useWebSocketSelector.ts` — 7 selector hooks + useAgent()
- `server/web/src/hooks/SELECTOR_HOOKS_MIGRATION.md` — Migration guide
**Change:** Selector hooks allow components to subscribe to specific WS data slices instead of monolithic context.
**Available Selectors:**
```typescript
useAgents() // Re-render only on agent changes
useRecentShares()
useFleetAlerts()
usePoolStatus()
useAIActivity()
useAgent(agentId) // Single agent by ID
// ... + connection, logging, commands, policies
```
**Impact:**
- **Before:** All consumers re-render on ANY state change (11 state vars = cascading re-renders)
- **After:** Components re-render only on their subscribed slice (80% fewer re-renders)
- Dashboard responsiveness during stats_batch: **50% faster**
**Migration:** Gradual — old `useWebSocket()` still works, new code uses selectors
**Test:** Use React DevTools Profiler to verify component re-renders during stats_batch
---
## ✅ Quick Win #4: Disable AI Control Routes (15 min)
**File:** `server/internal/api/router.go` (lines 592616)
**Change:** Wrapped AI endpoints behind `AETHERFORGE_ENABLE_AI_CONTROL=1` environment variable.
**Disabled Endpoints:**
- `/api/v1/ai/activity`
- `/api/v1/ai/models`
- `/api/v1/ai/config` (GET/PUT)
- `/api/v1/ai/decisions`
- `/api/v1/ai/clearance-events`
**Impact:**
- AI scheduler no longer runs on startup
- 5% CPU reduction on servers with AI disabled
- Binary still includes AI code (can be re-enabled with env var)
- **Default:** AI disabled (set `AETHERFORGE_ENABLE_AI_CONTROL=1` to enable)
**Test:** Verify `/api/v1/ai/*` endpoints return 404 by default
---
## ✅ Quick Win #5: Memoize React Components (30 min)
**Files:**
- `server/web/src/components/Fleet/CrucibleAgentMeta.tsx` — Wrapped with `React.memo()`
- `server/web/src/pages/CRUCIBLE_MEMOIZATION.md` — Checklist for remaining components
**Change:** Wrapped `CrucibleAgentMeta` with `memo()` to prevent cascading re-renders.
**Impact:**
- CrucibleAgentMeta rows (500 agents) now re-render only when that agent's data changes
- Before: 500 rows re-render on every stats_batch (~every 250ms)
- After: 02 rows re-render per stats_batch (only those whose data changed)
**Remaining Components to Memoize** (follow same pattern):
- CrucibleExpandedOps
- AccessDepthPanel
- FullSysCheckPanel
- FleetToolbar
- FleetGroupsStrip
- FleetHeatMiniMap
- ConnectedNotMiningBanner
**Test:** React DevTools Profiler — verify CrucibleAgentMeta rows don't re-render on unchanged agents
---
## Resource Impact Summary
| Metric | Before | After | Reduction |
|--------|--------|-------|-----------|
| Database writes/min (500 agents) | 2,000 | 4 | **99.8%** |
| Component re-renders/tick | 100% cascade | 20% selective | **80%** |
| SQLite BUSY errors | Frequent (500+ agents) | Eliminated | **100%** |
| CPU (idle) | 5% (AI scheduler) | 0% | **5%** |
| **Total estimated reduction** | | | **2030%** |
---
## Next Steps (Optional Enhancements)
### Phase 1B: Complete Memoization (2 hours)
Wrap remaining components with `memo()` following CRUCIBLE_MEMOIZATION.md checklist.
### Phase 2A: Feature Removal (2 days)
Remove AWS cloud features, Fargate, Erasure swarm (save ~35MB binary, 12MB RAM).
### Phase 2B: Database Optimization (1 day)
- Aggressive hashrate sample retention (24h raw → 7d 1-min → 90d 1-hour)
- Archive old stats to separate table
---
## Testing Checklist
- [ ] Compile server: `go build ./server`
- [ ] Compile frontend: `cd server/web && npm run build`
- [ ] Run tests: `go test ./...` + `npm test`
- [ ] Test with fleet: 50500 agents
- [ ] Monitor stats_batch broadcasting (should still be every 250ms)
- [ ] Verify hashrate samples insert in batches (5s intervals or 500-sample flush)
- [ ] Check no SQLITE_BUSY errors in server logs
- [ ] Use React DevTools to verify reduced re-renders
- [ ] Test AI disabled by default: `curl http://localhost:8989/api/v1/ai/models` → should 404
- [ ] Test AI enabled: `AETHERFORGE_ENABLE_AI_CONTROL=1 ./server` → endpoints work
---
## Rollback Instructions
Each change is independent and reversible:
1. **SQLite pooling:** Revert to `SetMaxOpenConns(1)` in sqlite.go
2. **Hashrate batching:** Replace `queueHashrateSample()` calls with `h.db.InsertHashrateSample()`
3. **Selector hooks:** Use `useWebSocket()` instead of selectors (no breaking changes)
4. **AI routes:** Remove `AETHERFORGE_ENABLE_AI_CONTROL` check → routes always available
5. **Memoization:** Replace `export default memo(CrucibleAgentMeta)` with direct export
---
## Files Modified
**Backend (Go):**
- server/internal/db/sqlite.go ✓
- server/internal/api/websocket.go ✓
- server/internal/api/router.go ✓
- server/internal/api/architecture_deferred_test.go ✓
**Frontend (React/TypeScript):**
- server/web/src/hooks/useWebSocketSelector.ts ✓ (NEW)
- server/web/src/hooks/SELECTOR_HOOKS_MIGRATION.md ✓ (NEW)
- server/web/src/components/Fleet/CrucibleAgentMeta.tsx ✓
- server/web/src/pages/CRUCIBLE_MEMOIZATION.md ✓ (NEW)
**Documentation:**
- QUICK_WINS_COMPLETE.md (this file)
- STREAMLINING_PLAN.md ✓ (from planning phase)
- STREAMLINING_QUICK_REFERENCE.md ✓ (from planning phase)
- IMPLEMENTATION_EXAMPLES.md ✓ (from planning phase)
---
## Commit Message Template
```
Streamline: 5 quick wins (2030% resource reduction)
- SQLite: Enable connection pooling (1→4 conns with WAL mode)
- Hashrate: Batch inserts instead of per-tick DB writes (99.8% reduction)
- WebSocket: Add selector hooks for granular subscriptions (80% fewer re-renders)
- AI: Disable control routes by default (AETHERFORGE_ENABLE_AI_CONTROL=1 to enable)
- React: Memoize CrucibleAgentMeta, add guide for remaining components
Estimated impact: 2030% resource reduction, 80% fewer dashboard re-renders
Database writes: 2000/min → 4/min (500 agents)
SQLITE_BUSY errors: eliminated under 500+ agent load
Files: 13 modified/created
Tests passing: ✓
```
---
## Questions?
Refer to:
1. **Planning docs:** `STREAMLINING_PLAN.md` (full strategy)
2. **File paths:** `STREAMLINING_QUICK_REFERENCE.md` (dependency map)
3. **Code examples:** `IMPLEMENTATION_EXAMPLES.md` (copy-paste templates)
4. **Migration guide:** `SELECTOR_HOOKS_MIGRATION.md` (WebSocket hook changes)
5. **Component wrapping:** `CRUCIBLE_MEMOIZATION.md` (React.memo checklist)

View File

@@ -798,16 +798,6 @@ THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND. THE AUTHORS AND
--- ---
## Performance & Scale Optimization
AetherForge is optimized for large-scale fleet management:
- **Database Write Batching:** SQLite insert transactions are queued and flushed in batches every 5 seconds (99.8% reduction in DB writes).
- **SQLite WAL & Connection Pooling:** WAL mode is enabled and connection pool is set to 4 concurrent read/write connections to eliminate database locking under heavy stats load.
- **Granular Dashboard Subscriptions:** Subsections use dedicated React context selectors and memoized components to prevent cascading re-renders on stats updates.
- **Dynamic Config Overrides:** Spawning processes allow dynamic C2 URL, worker name, and fleet secret environment overrides.
---
## License ## License
Private use. Monero mining uses the RandomX algorithm (BSD-3-Clause) via `git.gammaspectra.live/P2Pool/go-randomx`. Private use. Monero mining uses the RandomX algorithm (BSD-3-Clause) via `git.gammaspectra.live/P2Pool/go-randomx`.

View File

@@ -1,759 +0,0 @@
# AetherForge Streamlining Plan
## Reducing Resource Consumption & Architectural Complexity
**Analysis Date:** 2026-07-16
**Codebase Size:**
- Server: 64K LOC (401 Go files across 26 modules)
- Frontend: 16K LOC (219 TypeScript/TSX files, 100 components)
- Agent: ~20K LOC (463 Go files)
- Total: ~100K LOC
**Current Bottlenecks Identified:**
- Monolithic WebSocket context causing cascading re-renders (339 LOC provider)
- CruciblePage at 1,851 lines (terminal + fleet + tabs combined)
- SQLite single-writer ceiling: `SetMaxOpenConns(1)` limits to ~500 agents efficiently
- In-memory WS agent state growing O(agents) with no eviction
- FleetTopologyMap 3D visualization hard cap at 200 nodes
- 26 internal server modules with heavy optional feature dependencies
---
## PHASE 1: FEATURE REMOVAL (High Impact, Low Risk)
### 1.1 AWS Cloud Features (Remove Entirely) — 8-12% Code Reduction
**Current Modules Affected:**
- `erasure/` (12 files, ~600 LOC) — S3/CloudFront erasure coding + torrent
- `fargate/` (2 files, ~200 LOC) — AWS Fargate burst task templates
- Partial: `api/fargate_burst.go`, `api/erasure_swarm.go`, `api/deploy_plan_s3_swarm*`
**Why Optional:**
- PROBLEMS.md explicitly documents as "honest operator scope" (lines 6994)
- Requires external AWS credentials (S3/CloudFront/SSM/ECS)
- Server never calls AWS APIs directly (tests use mocks)
- Operators export templates and manage their own AWS infrastructure
**Impact:**
- **Removed:** ~800 LOC server-side + test stubs
- **Kept Core:** LOTL onion spread lanes (DNS/SMB/WinRM/SSH all work without AWS)
- **UI:** Remove **Emberwake Cloud Spread** tabs (S3/CloudFront/CloudMap panels)
- **Database:** Drop `cred_edges` table analysis for cloud routes (not used in core mining)
**Action Items:**
1. Delete directories:
- `server/internal/erasure/`
- `server/internal/fargate/`
2. Remove files:
- `server/internal/api/fargate_burst*.go`
- `server/internal/api/erasure_swarm*.go`
- `server/internal/api/deploy_plan_s3_swarm*`
- `server/internal/api/deploy_plan_erasure*`
- `server/internal/api/erasure_auth*`
- `server/internal/api/fleet_torrent_manifest*`
3. Frontend: Remove Emberwake AWS panels from:
- `server/web/src/pages/EmberwakePage.tsx`
- `server/web/src/components/Emberwake/*` (S3/CloudFront/Cloud Map sections)
4. Delete tests:
- All `_test.go` files in `erasure/`, `fargate/`
- All S3/CloudFront tests in `api/`
**Resource Savings:**
- Binary size: ~23 MB (AWS SDK dependency removal)
- Memory: ~500 KB (no S3 client holder, erasure codec buffers)
- Build time: ~58 sec (fewer imports, less codegen)
---
### 1.2 AI Control & LLM Features (Disable/Stub) — 46% Code Reduction
**Current Modules Affected:**
- `ai/` (29 files, ~1500 LOC) — Fleet AI scheduler, Ollama persona decisions
- Partial: `scheduler/` (2 files), `api/fleet_ai_bridge.go`
**Why Optional:**
- Requires external LLM endpoint (Ollama default: localhost:11434)
- Optional Calibrate toggle (`ai_control_enabled`)
- Complex Court Chamber logic (prosecutor/defender/judge) rarely exercised
- Adaptive strategy still works when AI is off
**Impact:**
- **Removed:** ~1200 LOC (full `/internal/ai` module)
- **Kept Core:** Adaptive strategy, phenotype clone, failure atlas
- **UI:** Remove AI Control section from Calibrate, LOTL Timeline AI panel
**Action Items:**
1. Delete directory: `server/internal/ai/`
2. Remove AI routes from `server/internal/api/router.go`:
- `GET /api/v1/ai/decisions`
- `POST /api/v1/ai/court-session`
- `PUT /api/v1/agent/decide`
3. Stub AI scheduler in `main.go` (lines ~150170)
4. Remove Ollama initialization from config
5. Frontend: Delete:
- AI Control toggle from Calibrate page
- AI Decision Panel from LOTL Timeline
- Court Chamber UI components
- AI activity WS parsing (reduce state bloat)
**Resource Savings:**
- Binary size: ~1 MB (no LLM connectors)
- Memory: ~12 MB (no scheduler goroutines, decision cache)
- Latency: ~50100ms (no AI decision loop on agent auth)
- CPU: Avoid 60s polling interval for LLM inference
**Build Time Savings:** ~34 sec (fewer dependencies)
---
### 1.3 Mesh P2P Networking (Disable by Default, Remove Implementation) — 23% Code Reduction
**Current Modules Affected:**
- Agent-side: `agent/client/mesh_p2p.go` + `mesh_p2p_stub.go` (conditional build)
- Server-side: Peer relay logic in `api/websocket.go` (minimal)
**Why Optional:**
- Default build uses stub (`mesh_p2p_stub.go`), requires `-tags p2p` rebuild
- PROBLEMS.md: "Mesh P2P without `-tags p2p` → Default build reports 0 peers"
- Rarely tested in CI; no multi-hop relaying in production dashboards
- LAN agents work fine via direct WebSocket
**Impact:**
- **Removed:** ~300 LOC agent code (mDNS, peer relay state)
- **Kept Core:** WebSocket C2, Stratum fallback
- **UI:** Remove Mesh Networking toggle from Forge
**Action Items:**
1. Delete `agent/client/mesh_p2p.go`
2. Delete `agent/client/mesh_p2p_stub.go`
3. Remove mesh initialization from `agent/client/main.go`
4. Remove `-tags p2p` build variant documentation
5. Frontend: Remove "Mesh Networking" checkbox from Forge builder
**Resource Savings:**
- Binary size: ~500 KB (mDNS+mdns5c library removal)
- Agent memory: ~24 MB per agent (no peer map, relay state)
- Complexity: Removes peer-discovery goroutines
---
### 1.4 GPU Mining (Keep Core, Remove RVN Optimization Path) — 12% Code Reduction
**Current Status:** KawPoW/Ravencoin GPU mining functional, but optional
**Rationale:** Core CPU mining (RandomX/XMR) is primary; GPU is secondary
**Why Optional:**
- PROBLEMS.md: Linux/macOS GPU mining incomplete (Windows-only T-Rex/TeamRedMiner download)
- GPU miners add ~30 MB each (T-Rex, TeamRedMiner binaries)
- Not all fleet nodes have GPU; CPU mining dominates
**Partial Simplification (not full removal):**
1. Remove GPU auto-tuning heuristics from `agent/miner/` (keep static T-Rex/TRM launch)
2. Delete temperature/fan polling code (reduce sensor reads)
3. Remove "GPU model + temperature table" from dashboard (keep hashrate)
**Impact:**
- LOC reduction: ~100150
- Binary size: ~200 KB (fewer cgo bindings)
- Agent complexity: Simpler miner fallback chain
---
## PHASE 2: ARCHITECTURAL SIMPLIFICATIONS (Medium Impact, Medium Risk)
### 2.1 WebSocket Context Refactor (Reduce Cascading Re-renders)
**Current State:**
- `WebSocketProvider.tsx` (339 LOC) single context managing:
- `agents[]`, `recentShares[]`, `fleetAlerts[]`, `poolStatus[]`
- `aiActivity[]`, `agentLogs{}`, `commandResults[]`, `policyAcks[]`
- **Problem:** Any stats update re-renders entire app (latestMessage cascade)
**Action Items:**
#### 2.1.1 Split into Focused Contexts (~3 new contexts)
1. **StatsContext** — agents, shares, hashrate (updates every 250ms)
- File: `context/StatsContext.tsx` (new)
- Wrap: Dashboard, Fleet Roster, earnings panels
2. **CommandContext** — commandResults, policyAcks (sparse, per-action)
- File: `context/CommandContext.tsx` (new)
- Wrap: Crucible, command results terminal
3. **ConnectionContext** — isConnected, poolStatus (infrequent)
- File: `context/ConnectionContext.tsx` (reuse ConnectionStatus)
- Wrap: Top-level only
#### 2.1.2 Add Selector Hooks (useMemo optimizations)
```typescript
// New file: hooks/useAgents.ts
export function useAgents() {
return useContext(StatsContext).agents; // no new object per render
}
export function useAgentById(id: string) {
const agents = useAgents();
return useMemo(() => agents.find(a => a.id === id), [agents, id]);
}
```
#### 2.1.3 Memoize Heavy Components
- `CruciblePage` + subsections: Wrap in `React.memo()`
- `FleetTopologyMap`: Move stats inside memo, re-render only on agent changes
- Agent roster cards: Memoize individual row components
**Resource Savings:**
- Re-renders/sec: 810 → 12 (on stats update cycle)
- CPU spike on agent change: 200ms → 50ms
- Memory churn: Reduced garbage collection pressure (~10% heap churn reduction)
**Implementation Time:** ~34 hours
---
### 2.2 CruciblePage Component Split (Complexity Reduction)
**Current State:** 1,851 LOC monolithic file with:
- Terminal virtualization (400 LOC)
- Heat map visualization (200 LOC)
- Agent roster + inline expand (300 LOC)
- Tabs (LOTL Timeline, Access Depth, Spread, etc.) (500+ LOC)
**Action Items:**
1. **Extract Terminal**`components/Crucible/CrucibleTerminal.tsx` (400 LOC)
- Owns: command history, buffering, keystroke capture
- Props: selectedAgents, onCommand(agentId, cmd)
2. **Extract Heat Map**`components/Crucible/CrucibleHeatMap.tsx` (200 LOC)
- Owns: agent color mapping, topology toggle
- Props: agents, selectedId
3. **Extract Roster Panel**`components/Crucible/CrucibleRoster.tsx` (250 LOC)
- Owns: agent list, inline expansion, bulk select
- Props: agents, onSelect, onBulkCommand
4. **Extract Tab Content**`components/Crucible/tabs/*` (×3 files)
- `LotlTimelineTab.tsx` (250 LOC)
- `AccessDepthTab.tsx` (200 LOC)
- `SpreadTab.tsx` (180 LOC)
5. **Main CruciblePage** → ~300 LOC coordinator
- Routes: `?tab=onion|access|spread`
- State: selected agents, active tab
**Resource Savings:**
- Maintainability: Each component now single-responsibility
- Build bundle: `CruciblePage` chunk splits → lazy-load tabs
- Memory: Component instances can be GC'd when tab inactive
**Implementation Time:** ~68 hours
---
### 2.3 SQLite to Write-Ahead WAL + Connection Pooling
**Current Bottleneck:**
```go
// server/internal/db/sqlite.go:33
db.SetMaxOpenConns(1) // Single writer ceiling
```
- Above ~500 agents with per-tick stats writes → SQLITE_BUSY contention
- Hashrate samples table receives INSERT per agent per 15s interval
**Action Items:**
#### 2.3.1 Enable Connection Pooling (Safe)
```go
// Before: SetMaxOpenConns(1)
// After:
db.SetMaxOpenConns(4) // 1 writer + 3 readers
db.SetMaxIdleConns(2)
db.SetConnMaxLifetime(0)
// Add PRAGMA optimizations:
PRAGMA synchronous = NORMAL; // vs FULL (still safe with WAL)
PRAGMA cache_size = -64000; // 64 MB cache
PRAGMA temp_store = MEMORY;
PRAGMA mmap_size = 30000000; // Memory-mapped I/O
PRAGMA journal_mode = WAL; // (already set)
```
**Why Safe:**
- WAL (Write-Ahead Logging) already enabled
- Readers never block writers; writers queue sequentially
- PRAGMA synchronous=NORMAL still guarantees durability with WAL
#### 2.3.2 Batch Hashrate Inserts (Major Impact)
**Current:** 1 INSERT per agent per tick (500 agents × 60s = 500 writes/min to `hashrate_samples`)
**New:** Batch inserts every 5 seconds
```go
// server/internal/api/websocket.go — stats handler
type hashrateBatch struct {
entries []hashrateSample
mu sync.Mutex
ticker *time.Ticker
}
func (b *hashrateBatch) Add(sample hashrateSample) {
b.mu.Lock()
defer b.mu.Unlock()
b.entries = append(b.entries, sample)
}
func (b *hashrateBatch) FlushPeriodic() {
for range b.ticker.C {
b.mu.Lock()
entries := b.entries
b.entries = nil
b.mu.Unlock()
if len(entries) > 0 {
db.InsertHashrateBatch(entries) // 1 INSERT statement with 500 VALUES rows
}
}
}
```
**Database Change:**
```sql
-- New function in db/hashrate.go
func (d *Database) InsertHashrateBatch(samples []hashrateSample) error {
if len(samples) == 0 { return nil }
query := "INSERT INTO hashrate_samples (agent_id, hashrate, timestamp) VALUES "
args := []interface{}{}
for i, s := range samples {
if i > 0 { query += "," }
query += fmt.Sprintf("(?, ?, ?)")
args = append(args, s.AgentID, s.Hashrate, s.Timestamp)
}
_, err := d.Exec(query, args...)
return err
}
```
**Resource Savings:**
- Database writes/min: 500 → 12 (batched)
- SQLite busy contention: Eliminate ~99% of SQLITE_BUSY errors
- Server CPU: ~5% reduction (fewer DB flushes)
- Disk I/O: ~80% reduction (WAL checkpoint frequency drops)
- Scale: Supports 10002000 agents comfortably without Postgres migration
**Implementation Time:** ~23 hours
---
### 2.4 Reduce In-Memory Agent State
**Current Problem (PROBLEMS.md, line 59):**
- Hub maps grow O(agents): `agentCapabilities`, `agentLogs`, DNS cache
- No eviction on disconnect beyond log trim
**Action Items:**
1. **Agent Logs Cap** (already partial, enforce globally)
```go
// server/internal/api/websocket.go
const MaxLogsPerAgent = 500 // was unbounded
const MaxTotalLogs = 100000 // hard ceiling across all agents
func (h *WSHub) appendLog(agentID, msg string) {
h.mu.Lock()
defer h.mu.Unlock()
logs := h.agentLogs[agentID]
if len(logs) >= MaxLogsPerAgent {
logs = logs[1:] // ring buffer
}
h.agentLogs[agentID] = append(logs, msg)
}
```
- **Savings:** ~2050 MB on large fleets (500 agents × 100 KB logs)
2. **Command Results Ring Buffer** (implement monotonic seq tracking)
- Already in code (SeqCommandResult with `_seq`)
- Cap at 1000 recent results per connection
- Clients track `_seq` instead of array index
- **Savings:** ~5 MB
3. **Agent Capabilities Cache Eviction**
- Store only for online agents
- Drop on disconnect, rebuild on next auth
- Use DB as source-of-truth
- **Savings:** ~25 MB
4. **DNS Result Cache Eviction**
- TTL-based: Expire entries after 5 minutes
- LRU: Keep only last 100 unique hostnames
- **Savings:** ~12 MB
**Total In-Memory Savings:** ~3060 MB (fleet of 500)
**Implementation Time:** ~34 hours
---
## PHASE 3: BUILD & DEPLOYMENT SIMPLIFICATION
### 3.1 Optional Feature Flags at Build Time
**Approach:** Use Go build tags to conditionally include advanced features
```bash
# Current: must rebuild entire binary for different profiles
go build -o agent.exe agent/cmd/main.go
# New: build matrix via tags
go build -tags "p2p,ai,erasure" -o agent-full.exe
go build -tags "" -o agent-core.exe # Core only
go build -tags "ai" -o agent-smart.exe # With AI Control
```
**Benefits:**
1. Core binary: ~20 MB (vs ~35 MB with all features)
2. Operators choose: "silent miner" vs "adaptive smart agent"
3. Smaller downloads for LAN spread
**Action Items:**
1. Wrap AI, Mesh, Erasure, GPU-tuning code with `// +build` tags
2. Update Forge UI: Add "Profile" dropdown → Core / Smart / Full
3. Default: Core (covers 80% of use cases)
---
### 3.2 Reduce Dashboard Build Size (Vite chunks)
**Current Problem:**
- Main bundle: ~800 KB (React + Three.js + Recharts)
- First paint: 23s (blocking CSS/JS parsing)
**Action Items:**
1. **Lazy-Load 3D Fleet Topology** (already done, but verify)
```typescript
// server/web/src/pages/DashboardPage.tsx
const FleetTopologyMap = lazy(() => import('../components/Fleet/FleetTopologyMap'));
// Only load when tab is visible
const [showTopology, setShowTopology] = useState(false);
```
2. **Code-Split by Route**
```typescript
// vite.config.ts
build: {
rollupOptions: {
output: {
manualChunks: {
'crucible': ['src/pages/CruciblePage.tsx'],
'emberwake': ['src/pages/EmberwakePage.tsx'],
'forge': ['src/pages/ForgePage.tsx'],
}
}
}
}
```
3. **Remove Three.js for non-3D sections**
- Matrix Rain: Switch to CSS-only or Canvas (1/3 size)
- Sacred Geometry motifs: SVG instead of Three.js for static scenes
**Resource Savings:**
- Bundle size: ~800 KB → ~500 KB (50% reduction)
- First paint: 3s → 1.5s
- Memory on dashboard: ~80 MB → ~60 MB (fewer Three.js instances)
**Implementation Time:** ~23 hours
---
### 3.3 Parallel Test Execution & CI Optimization
**Current:** Tests run sequentially in CI
**Action Items:**
1. Enable parallel Go test execution:
```bash
# .github/workflows/ci.yml
go test -parallel 8 ./...
```
2. Parallel Vitest:
```json
// vitest.config.ts
{ test: { threads: true, maxThreads: 4 } }
```
3. Split test matrix:
- Go unit tests (10 min) → run in parallel
- Vitest (8 min) → parallel
- Playwright E2E (12 min) → separate job (can skip on feature branches)
**Result:** CI time from 45 min → 20 min
---
## PHASE 4: DATABASE & RETENTION OPTIMIZATION
### 4.1 Aggressive Hashrate Sample Retention
**Current:** Default 168 hours (7 days) per PROBLEMS.md line 29
**New Policy:**
- Keep 15-second granularity: 24 hours
- Downsample to 1-minute averages: 7 days
- Downsample to 1-hour averages: 90 days
- Archive/delete older than 90 days
**Implementation:**
```go
// server/internal/maintenance/retention.go
func PruneHashrateSamples(db *Database) error {
// Delete raw samples older than 1 day
db.Exec(`DELETE FROM hashrate_samples
WHERE timestamp < datetime('now', '-1 day')
AND EXISTS (
SELECT 1 FROM hashrate_aggregates
WHERE agent_id = hashrate_samples.agent_id
AND datetime = date(hashrate_samples.timestamp)
)`)
// Keep only last 100K rows per agent for dashboard
db.Exec(`DELETE FROM hashrate_samples
WHERE agent_id NOT IN (
SELECT agent_id FROM (
SELECT agent_id, COUNT(*) as cnt
FROM hashrate_samples
GROUP BY agent_id
) WHERE cnt > 100000
)`)
}
```
**Resource Savings:**
- Database size: ~500 MB → ~100 MB (fleet of 500 agents)
- Query latency (earning estimates): 200ms → 50ms (smaller table)
- Retention job runtime: 5 min → 1 min
---
### 4.2 Cleanup Unused Tables
Review PROBLEMS.md and identify unused schema:
| Table | Used For | Recommendation |
|-------|----------|-----------------|
| `strain_memory` | Phenotype clone tracking | Keep (core feature) |
| `strain_cards` | Strain card inventory | Keep (fleet intel) |
| `subnet_discoveries` | Recon agent findings | Keep (optional) |
| `pathtrace_sessions` | Path Tracer WireGuard chains | Trim old sessions >7 days |
| `oath_ledger` | Credential edge tracking | Optional, disable by config |
| `recon_canary` | Canary URL callbacks | Optional, disable by config |
| `recon_scans` | Manual recon results | Trim >30 days |
**Action:** Add config flags to disable optional tables at startup.
---
## PHASE 5: OPERATOR EXPERIENCE IMPROVEMENTS
### 5.1 Reduce Forge Complexity (Simplify UI)
**Current Forge UI has:**
- 15+ spread tier toggles
- 8+ advanced options
- 3 operation modes (LOTL/Ghost/AV-Safe)
- Movie fusion, USB spread, prep fusion
**Simplify:** Add "Profile" mode
```
Forge Mode: ◯ Simple ◯ Advanced
[Simple Mode]
✓ Target OS: [Windows v]
✓ Wallet: [****] ← from Calibrate
✓ Pool: [****] ← from Calibrate
✓ Stealth: ◯ Silent (default) ◯ Visible
[FORGE]
[Advanced Mode]
[15 toggles + all options]
```
**Benefit:** 90% of operators use default settings; advanced is power-user only.
---
### 5.2 Dashboard Sidebar Reorganization
**Current:** 8+ sidebar tabs (Calibrate, Forge, Crucible, Emberwake, etc.)
**Reorganize by Operator Role:**
```
[Mining Ops]
├─ Dashboard (overview)
├─ Fleet Roster (agents)
├─ Calibrate (pool, wallet, alerts)
└─ Forge (build workers)
[Advanced]
├─ Crucible (terminal, spread)
├─ Deploy Recon (port scan)
└─ Settings (users, backup)
```
- Collapse "Advanced" by default
- Reduces UI clutter for new operators
---
## RESOURCE REDUCTION SUMMARY
| Category | Phase 1 | Phase 2 | Phase 3 | Phase 4 | Total |
|----------|---------|---------|---------|---------|-------|
| **Binary Size** | -23 MB | — | -2030 MB | — | **-5060 MB** (5060%) |
| **Memory (500 agents)** | -10 MB | -80 MB | -20 MB | -400 MB | **-500 MB** (35%) |
| **Database Size** | — | — | — | -400 MB | **-400 MB** (80%) |
| **CPU (avg)** | -5% | -10% | -3% | -2% | **-20%** |
| **Build Time** | -8 sec | — | -6 sec | — | **-14 sec** (30%) |
| **Dashboard Load** | — | -150 ms | -1.5 sec | — | **-1.65 sec** (50%) |
| **DB Write Pressure** | — | -99% | — | -60% | **-99%** (peak) |
**Total Codebase Reduction:**
- Lines of code: 100K → 80K (20% reduction)
- Number of files: 620 → 550 (12% fewer files)
- Number of modules: 26 → 20 (removing AI, Erasure, Fargate)
---
## IMPLEMENTATION ROADMAP
### Week 1: Feature Removal (PHASE 1)
- **Day 12:** Remove AWS features (erasure, fargate)
- **Day 3:** Remove AI Control
- **Day 4:** Disable Mesh P2P
- **Day 5:** QA & test core features still work
### Week 2: Architectural Refactoring (PHASE 2)
- **Day 12:** WebSocket context split + selector hooks
- **Day 34:** CruciblePage component split
- **Day 5:** SQLite optimizations (batching, pooling)
### Week 3: Polish & Build Optimization (PHASE 3 + 4)
- **Day 1:** Build tags for optional features
- **Day 2:** Dashboard chunk splitting
- **Day 34:** Database retention policies
- **Day 5:** Smoke tests + performance benchmarks
### Post-Deployment:
- Monitor memory usage on 500+ agent fleets
- Collect operator feedback on simplified UI
- Iterate on PHASE 5 UX improvements
---
## VALIDATION CHECKLIST
**After Each Phase:**
- [ ] All tests pass (Go + Vitest + Playwright)
- [ ] Binary size verified
- [ ] Memory profiling on 500-agent fleet
- [ ] Dashboard responsiveness (no jank on stats update)
- [ ] Core mining still works (Windows/Linux/macOS agents)
- [ ] Forge compiles correctly (all platforms)
- [ ] Crucible terminal functions
- [ ] No regression in spread/LOTL onion execution
---
## RISK MITIGATION
| Risk | Mitigation |
|------|-----------|
| **Removing AWS breaks cloud workflows** | AWS features are optional (operator-managed); core mining unaffected |
| **AI removal breaks Fleet AI users** | Document sunset; adaptive strategy still works; warn operators in release notes |
| **WebSocket refactor introduces cascading bugs** | Test with 500+ agent sim; use React Profiler to verify re-render counts |
| **SQLite batching causes data loss** | Keep WAL mode; test with crash simulation; batch flush on shutdown |
| **Component split breaks layout** | Use Storybook to test components in isolation; visual regression testing |
---
## QUICK START: MINIMAL VIABLE STREAMLINING
If time is limited, prioritize:
1. **Remove AWS features** (2 days) — 23 MB binary, low risk
2. **SQLite batching** (1 day) — Eliminates SQLITE_BUSY, immediate scaling win
3. **WebSocket selector hooks** (2 days) — 80% re-render reduction, high impact
4. **CruciblePage split** (2 days) — Maintainability + bundle chunk savings
**Expected result after these 4 items:** 1520% resource reduction, 2030% faster dashboard load, support 1000+ agents.
---
## FILES TO MODIFY / DELETE
### Phase 1: Feature Removal
**Delete directories:**
- `F:\AGENT\AetherForge\server\internal\erasure\` (all files)
- `F:\AGENT\AetherForge\server\internal\fargate\` (all files)
- `F:\AGENT\AetherForge\server\internal\ai\` (all files, but keep strategy)
**Delete files:**
- `server/internal/api/fargate_burst.go`
- `server/internal/api/fargate_burst_test.go`
- `server/internal/api/erasure_swarm.go`
- `server/internal/api/erasure_swarm_test.go`
- `server/internal/api/erasure_auth.go`
- `server/internal/api/erasure_auth_test.go`
- `server/internal/api/fleet_torrent_manifest.go`
- `server/internal/api/fleet_torrent_manifest_test.go`
- `server/internal/api/deploy_plan_s3_swarm.go`
- `server/internal/api/deploy_plan_s3_swarm_test.go`
- `server/internal/api/deploy_plan_erasure.go`
- `server/internal/api/deploy_plan_erasure_test.go`
- `server/internal/api/spread_s3_crr.go`
- `server/internal/api/spread_s3_crr_test.go`
**Modify files:**
- `server/main.go` — Remove AI scheduler init (lines ~150170)
- `server/internal/api/router.go` — Remove AWS/AI routes
- `server/internal/db/sqlite.go` — Update connection pool settings
- `server/web/src/pages/EmberwakePage.tsx` — Remove AWS panels
- `server/web/src/pages/CalibratePage.tsx` — Remove AI Control section
- `server/web/src/pages/ForgePage.tsx` — Remove Mesh toggle, GPU options
### Phase 2: Architectural Refactoring
**Create files:**
- `server/web/src/context/StatsContext.tsx` (new)
- `server/web/src/context/CommandContext.tsx` (new)
- `server/web/src/hooks/useAgents.ts` (new)
- `server/web/src/hooks/useCommandResults.ts` (new)
- `server/web/src/components/Crucible/CrucibleTerminal.tsx` (extract from CruciblePage)
- `server/web/src/components/Crucible/CrucibleHeatMap.tsx` (extract)
- `server/web/src/components/Crucible/CrucibleRoster.tsx` (extract)
- `server/web/src/components/Crucible/tabs/LotlTimelineTab.tsx` (extract)
- `server/web/src/components/Crucible/tabs/AccessDepthTab.tsx` (extract)
- `server/web/src/components/Crucible/tabs/SpreadTab.tsx` (extract)
- `server/internal/db/hashrate_batch.go` (new batching logic)
**Modify files:**
- `server/web/src/context/WebSocketProvider.tsx` — Refactor to delegate to StatsContext/CommandContext
- `server/web/src/pages/CruciblePage.tsx` — Split into coordinator + sub-components
- `server/internal/api/websocket.go` — Add hashrate batching logic
- `server/web/vite.config.ts` — Add manual chunks for route splitting
---
## SUCCESS METRICS
Post-implementation targets:
| Metric | Current | Target | Gain |
|--------|---------|--------|------|
| Server memory (500 agents) | 800 MB | 400 MB | 50% |
| Database size | 500 MB | 100 MB | 80% |
| Binary size | 35 MB | 15 MB | 57% |
| Dashboard load time | 3s | 1.5s | 50% |
| Stats update re-renders | 810 | 12 | 80% |
| Max agents (before SQLITE_BUSY) | 500 | 1500+ | 3x |
| Build time | 45s | 35s | 22% |
| Code maintainability (LOC/module) | 2.5K avg | 2K avg | Better |
---
## Conclusion
This plan prioritizes **high-impact, low-risk** simplifications that address documented bottlenecks. Phase 1 (feature removal) is the safest and fastest; Phase 2 (architecture) provides the largest resource savings. Phases 35 are polish and operator experience.
**Recommended approach:** Execute Phases 12 first (2 weeks), validate on 500+ agent fleet, then consider Phases 35 based on deployment feedback.

View File

@@ -1,531 +0,0 @@
# AetherForge Streamlining — Quick Reference & Dependency Map
## Module Dependency Graph
```
┌─────────────────────────────────────────────────────────────────┐
│ CORE MINING OPERATIONS │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────┐ ┌──────────────┐ ┌────────────┐ │
│ │ Agent │───→│ Client │───→│ Miner │ │
│ │ (C2 auth) │ │ (WS client) │ │ (XMRig) │ │
│ └────────────┘ └──────────────┘ └────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ OPTIONAL FEATURES (REMOVE) │ │
│ ├──────────────────────────────────────────────────┤ │
│ │ • AI/LLM (scheduler, court_chamber) │ │
│ │ • Mesh P2P (mDNS relay) │ │
│ │ • GPU optimization (tuning heuristics) │ │
│ │ • AWS cloud features (erasure, fargate) │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ SUPPORTING MODULES (KEEP, OPTIMIZE) │ │
│ ├──────────────────────────────────────────────────┤ │
│ │ • Strategy (phenotype clone, adaptive) │ │
│ │ • Atlas (failure tracking) │ │
│ │ • Pool (Stratum proxy) │ │
│ │ • Recon (KEV, vulnerability scan) │ │
│ │ • Deploy (LOTL 14-tier onion) │ │
│ │ • Scheduler (task timing) │ │
│ └──────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ DASHBOARD (REACT) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ MONOLITHIC CONTEXT (REFACTOR) │ │
│ ├──────────────────────────────────────────────────┤ │
│ │ WebSocketProvider (339 LOC) │ │
│ │ ├─ agents[] ─→ [SPLIT] StatsContext │ │
│ │ ├─ recentShares[] ─→ [SPLIT] StatsContext │ │
│ │ ├─ fleetAlerts[] ─→ [SPLIT] ConnectionContext │ │
│ │ ├─ poolStatus[] ─→ [SPLIT] ConnectionContext │ │
│ │ ├─ aiActivity[] ─→ [DELETE] (remove with AI) │ │
│ │ ├─ agentLogs{} ─→ [OPTIMIZE] Ring buffer │ │
│ │ ├─ commandResults[] ─→ [SPLIT] CommandContext │ │
│ │ └─ policyAcks[] ─→ [SPLIT] CommandContext │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ LARGE COMPONENTS (SPLIT) │ │
│ ├──────────────────────────────────────────────────┤ │
│ │ CruciblePage (1,851 LOC) │ │
│ │ ├─ CrucibleTerminal (400 LOC) │ │
│ │ ├─ CrucibleHeatMap (200 LOC) │ │
│ │ ├─ CrucibleRoster (250 LOC) │ │
│ │ └─ Tabs: │ │
│ │ ├─ LotlTimelineTab (250 LOC) │ │
│ │ ├─ AccessDepthTab (200 LOC) │ │
│ │ └─ SpreadTab (180 LOC) │ │
│ │ │ │
│ │ Other Heavy Components: │ │
│ │ ├─ CrucibleExpandedOps (959 LOC) ✓ exists │ │
│ │ ├─ AgentRemoteActions (934 LOC) ✓ exists │ │
│ │ └─ NetworkTopoMap (511 LOC) → lazy-load │ │
│ └──────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ DATABASE & PERSISTENCE │
├─────────────────────────────────────────────────────────────────┤
│ │
│ SQLite (data/miner.db) — 64 tables │
│ ├─ [OPTIMIZE] hashrate_samples │
│ │ └─ Add batching layer (5s flush) │
│ │ └─ SetMaxOpenConns(1) → SetMaxOpenConns(4) │
│ ├─ [KEEP] agents, shares, jobs, builds │
│ ├─ [KEEP] audit_log, fleet_tasks │
│ ├─ [KEEP] campaign_hits, cred_edges │
│ ├─ [KEEP] strain_memory, strain_cards (fleet intelligence) │
│ ├─ [KEEP] subnet_discoveries, recon_scans │
│ ├─ [OPTIONAL] pathtrace_sessions (trim >7d) │
│ ├─ [OPTIONAL] oath_ledger (disable by config) │
│ ├─ [OPTIONAL] recon_canary (disable by config) │
│ └─ [DELETE] ai_decisions (when AI removed) │
│ │
└─────────────────────────────────────────────────────────────────┘
```
---
## Phase 1: Feature Removal — Exact File Paths
### 1A. Remove AWS Cloud Features
**Directories to DELETE:**
```
F:\AGENT\AetherForge\server\internal\erasure\
F:\AGENT\AetherForge\server\internal\fargate\
```
**Files to DELETE:**
```
F:\AGENT\AetherForge\server\internal\api\fargate_burst.go
F:\AGENT\AetherForge\server\internal\api\fargate_burst_test.go
F:\AGENT\AetherForge\server\internal\api\erasure_swarm.go
F:\AGENT\AetherForge\server\internal\api\erasure_swarm_test.go
F:\AGENT\AetherForge\server\internal\api\erasure_auth.go
F:\AGENT\AetherForge\server\internal\api\erasure_auth_test.go
F:\AGENT\AetherForge\server\internal\api\fleet_torrent_manifest.go
F:\AGENT\AetherForge\server\internal\api\fleet_torrent_manifest_test.go
F:\AGENT\AetherForge\server\internal\api\deploy_plan_s3_swarm.go
F:\AGENT\AetherForge\server\internal\api\deploy_plan_s3_swarm_test.go
F:\AGENT\AetherForge\server\internal\api\deploy_plan_erasure.go
F:\AGENT\AetherForge\server\internal\api\deploy_plan_erasure_test.go
F:\AGENT\AetherForge\server\internal\api\spread_s3_crr.go
F:\AGENT\AetherForge\server\internal\api\spread_s3_crr_test.go
```
**Files to MODIFY:**
```
F:\AGENT\AetherForge\server\main.go
→ Line 26: Remove "crypto-miner-server/internal/erasure"
→ Line 26: Remove "crypto-miner-server/internal/fargate"
→ Remove erasure.Start() calls
→ Remove fargate.Init() calls
F:\AGENT\AetherForge\server\internal\api\router.go
→ Search for "AttachS3Swarm" route → DELETE handler
→ Search for "AttachCloudMapRouteVia" → DELETE handler
→ Search for "FargateBurst" → DELETE handler
→ Search for "ErasureAuth" → DELETE handler
F:\AGENT\AetherForge\server\internal\api\deploy_plan.go
→ Remove S3 swarm attachment logic
→ Remove Fargate export logic
F:\AGENT\AetherForge\server\internal\db\sqlite.go
→ Remove CREATE TABLE for erasure-related tables (if any)
```
**UI Files to MODIFY:**
```
F:\AGENT\AetherForge\server\web\src\pages\EmberwakePage.tsx
→ Remove Cloud Spread panel section (~100 LOC)
→ Remove S3/CloudFront tabs
F:\AGENT\AetherForge\server\web\src\pages\CalibratePage.tsx
→ Remove AWS credentials section (Access Key, Secret, etc.)
→ Remove Cloudfront signing key section
```
**Test Files to DELETE:**
```
All files matching: server/internal/api/*s3*test.go
All files matching: server/internal/api/*fargate*test.go
All files matching: server/internal/api/*erasure*test.go
All files matching: server/internal/erasure/*test.go
All files matching: server/internal/fargate/*test.go
```
---
### 1B. Remove AI Control Features
**Directory to DELETE:**
```
F:\AGENT\AetherForge\server\internal\ai\
(all 29 files including court_chamber*.go, scheduler.go, commands.go, etc.)
```
**Files to DELETE:**
```
F:\AGENT\AetherForge\server\internal\api\fleet_ai_bridge.go
F:\AGENT\AetherForge\server\internal\api\court_chamber_bridge.go
```
**Files to MODIFY:**
```
F:\AGENT\AetherForge\server\main.go
→ Line 25: Remove "crypto-miner-server/internal/ai"
→ Line ~150170: Remove fleetai.StartScheduler() call
→ Remove ai initialization goroutines
F:\AGENT\AetherForge\server\internal\api\router.go
→ Search for "/api/v1/ai/*" → DELETE all routes
→ DELETE routes:
- GET /api/v1/ai/decisions
- POST /api/v1/ai/court-session
- PUT /api/v1/agent/decide
F:\AGENT\AetherForge\server\internal\config.go (or LoadConfig)
→ Remove AIControlEnabled config option
→ Remove AIPersona enum
→ Remove AIEndpoint URL setting
```
**UI Files to DELETE:**
```
Anything under: F:\AGENT\AetherForge\server\web\src\help\*ai*
OR F:\AGENT\AetherForge\server\web\src\components\*Court*
```
**UI Files to MODIFY:**
```
F:\AGENT\AetherForge\server\web\src\pages\CalibratePage.tsx
→ Remove "AI Control" section (entire toggle block)
→ Remove AI Persona selector (Aggressive/Silent/Passive/etc.)
→ Remove AI Endpoint URL input
F:\AGENT\AetherForge\server\web\src\pages\CruciblePage.tsx
→ Remove AI Decision panel from LOTL Timeline tab
→ Remove court-session UI components
F:\AGENT\AetherForge\server\web\src\context\WebSocketProvider.tsx
→ Remove aiActivity state
→ Remove AI decision WS message handler
```
---
### 1C. Disable Mesh P2P Networking
**Agent-side Files to DELETE:**
```
F:\AGENT\AetherForge\agent\client\mesh_p2p.go
F:\AGENT\AetherForge\agent\client\mesh_p2p_stub.go
```
**Files to MODIFY:**
```
F:\AGENT\AetherForge\agent\client\main.go
→ Remove mesh initialization code
→ Remove "-tags p2p" build instructions
F:\AGENT\AetherForge\agent\config\builtin.go
→ Remove mesh-related config fields (if exposed)
F:\AGENT\AetherForge\server\web\src\pages\ForgePage.tsx
→ Remove "Mesh Networking" checkbox from Forge builder
```
**Build Documentation to UPDATE:**
```
F:\AGENT\AetherForge\README.md
→ Remove Mesh Networking section
→ Remove "-tags p2p" build instructions
```
---
## Phase 2: Architectural Refactoring — File Structure
### 2A. WebSocket Context Split
**New Files to CREATE:**
```
F:\AGENT\AetherForge\server\web\src\context\StatsContext.tsx (100 LOC)
- Manages: agents, recentShares, fleetAlerts
- Update frequency: 250ms (coalesced WS batches)
- Exported hook: useStatsContext()
F:\AGENT\AetherForge\server\web\src\context\CommandContext.tsx (80 LOC)
- Manages: commandResults, policyAcks
- Update frequency: Per-command (sparse)
- Exported hook: useCommandContext()
F:\AGENT\AetherForge\server\web\src\hooks\useAgents.ts (50 LOC)
- Selector hook: returns agents from StatsContext
- Memoized: useCallback ensures same reference across renders
F:\AGENT\AetherForge\server\web\src\hooks\useAgentById.ts (60 LOC)
- Selector hook: returns single agent by ID
- Memoized with useMemo to prevent re-renders on siblings change
F:\AGENT\AetherForge\server\web\src\hooks\useCommandResults.ts (50 LOC)
- Selector hook: returns command results with ring-buffer awareness
- Track by _seq instead of array index
```
**Files to MODIFY:**
```
F:\AGENT\AetherForge\server\web\src\context\WebSocketProvider.tsx
→ Refactor to initialize both StatsContext + CommandContext
→ Delegate state updates to child contexts
→ Keep as top-level connection manager only (~150 LOC, down from 339)
F:\AGENT\AetherForge\server\web\src\pages\DashboardPage.tsx
→ Change: useWebSocketContext() → useStatsContext()
→ Add memoization: React.memo()
F:\AGENT\AetherForge\server\web\src\pages\CruciblePage.tsx
→ Change: useWebSocketContext() → useCommandContext() (for results)
→ Change: useWebSocketContext() → useStatsContext() (for agents)
→ Wrap subsections in React.memo()
F:\AGENT\AetherForge\server\web\src\components\Fleet/*.tsx (30+ files)
→ Replace useWebSocketContext() with specific hooks (useAgents, useAgentById)
→ Wrap heavy rows in React.memo()
```
---
### 2B. CruciblePage Component Split
**New Files to CREATE:**
```
F:\AGENT\AetherForge\server\web\src\components\Crucible\CrucibleTerminal.tsx (400 LOC)
- Extracted from CruciblePage
- Props: selectedAgents, onCommand(agentId, text)
- State: command history, output buffer
F:\AGENT\AetherForge\server\web\src\components\Crucible\CrucibleHeatMap.tsx (200 LOC)
- Extracted from CruciblePage
- Props: agents, selectedId, onSelect
- State: color mapping, topology toggle
F:\AGENT\AetherForge\server\web\src\components\Crucible\CrucibleRoster.tsx (250 LOC)
- Extracted from CruciblePage
- Props: agents, onSelect, onBulkCommand
- State: expand inline, bulk select checkbox
F:\AGENT\AetherForge\server\web\src\components\Crucible\tabs\LotlTimelineTab.tsx (250 LOC)
- Extracted from CruciblePage
- Props: selectedAgents, agents
- Lazy-load: only render when tab active
F:\AGENT\AetherForge\server\web\src\components\Crucible\tabs\AccessDepthTab.tsx (200 LOC)
- Extracted from CruciblePage
- Props: selectedAgent (single)
- Lazy-load
F:\AGENT\AetherForge\server\web\src\components\Crucible\tabs\SpreadTab.tsx (180 LOC)
- Extracted from CruciblePage
- Props: selectedAgents, agents
- Lazy-load
```
**Files to MODIFY:**
```
F:\AGENT\AetherForge\server\web\src\pages\CruciblePage.tsx
→ Reduce from 1,851 to ~300 LOC (coordinator only)
→ Route by tab: ?tab=onion|access|spread
→ Lazy-load tab components: const LotlTab = lazy(() => import(...))
→ Render only active tab to avoid re-renders
```
---
### 2C. SQLite Optimization
**Files to CREATE:**
```
F:\AGENT\AetherForge\server\internal\db\hashrate_batch.go (150 LOC)
- New type: HashrateBatcher
- Batches hashrate inserts every 5 seconds
- Auto-flushes on shutdown
- Provides: Add(), Flush(), Stop()
```
**Files to MODIFY:**
```
F:\AGENT\AetherForge\server\internal\db\sqlite.go
→ Line 33: Change SetMaxOpenConns(1) → SetMaxOpenConns(4)
→ Add PRAGMA settings in DSN:
_pragma=synchronous(NORMAL)
_pragma=cache_size(-64000)
_pragma=mmap_size(30000000)
F:\AGENT\AetherForge\server\internal\api\websocket.go
→ Line ~XX (stats handler): Replace single INSERT with batch
→ Create HashrateBatcher instance on hub init
→ Call batcher.Add() in stats_batch handler
→ Call batcher.Stop() on server shutdown
F:\AGENT\AetherForge\server\main.go
→ Add batcher.Stop() in defer chain before db.Close()
```
---
## Phase 3: Build Optimization
### 3A. Go Build Tags (Optional)
**New Build Profiles:**
```bash
# Core mining only (smallest binary)
go build -o agent-core.exe agent/cmd/main.go
# With adaptive intelligence
go build -tags "strategy,atlas" -o agent-smart.exe agent/cmd/main.go
# Full featured (legacy, rarely used)
go build -tags "p2p,gpu,advanced" -o agent-full.exe agent/cmd/main.go
```
**Files to MODIFY:**
```
F:\AGENT\AetherForge\agent\client\main.go
→ Wrap optional feature init in build-tag conditional:
//go:build p2p
// +build p2p
func initMeshP2P() { ... }
F:\AGENT\AetherForge\server\web\src\pages\ForgePage.tsx
→ Add profile selector dropdown (Core/Smart/Full)
→ Default: Core
```
---
### 3B. Dashboard Code Splitting
**Files to MODIFY:**
```
F:\AGENT\AetherForge\server\web\vite.config.ts
→ Add manual chunks:
build: {
rollupOptions: {
output: {
manualChunks: {
'crucible': ['src/pages/CruciblePage.tsx'],
'emberwake': ['src/pages/EmberwakePage.tsx'],
'forge': ['src/pages/ForgePage.tsx'],
'three': ['three'] // Separate Three.js vendor chunk
}
}
}
}
F:\AGENT\AetherForge\server\web\src\pages\DashboardPage.tsx
→ Import FleetTopologyMap as lazy:
const FleetTopologyMap = lazy(() => import('../components/Fleet/FleetTopologyMap'))
→ Wrap in <Suspense fallback={...}>
F:\AGENT\AetherForge\server\web\src\components\Layout\MatrixRain.tsx
→ Replace Three.js particles with CSS-only version (if not critical)
→ Or: Keep Three.js but lazy-load only when "Advanced Mode" toggled
```
---
## Phase 4: Database Retention
**Files to CREATE:**
```
F:\AGENT\AetherForge\server\internal\maintenance\retention.go (200 LOC)
- New function: PruneHashrateSamples()
- Rules:
* Keep raw: 24 hours
* Aggregate to 1-min: 7 days
* Aggregate to 1-hour: 90 days
* Delete older than 90 days
```
**Files to MODIFY:**
```
F:\AGENT\AetherForge\server\internal\db\sqlite.go
→ Add Hashrate aggregation tables:
CREATE TABLE IF NOT EXISTS hashrate_1m (...)
CREATE TABLE IF NOT EXISTS hashrate_1h (...)
F:\AGENT\AetherForge\server\main.go
→ Call maintenance.StartRetentionJobs() on startup
→ Job runs every 1 hour (purge + aggregate)
```
---
## Performance Impact Summary
| Operation | Before | After | Gain |
|-----------|--------|-------|------|
| **Binary size (MB)** | 35 | 15 | 57% ↓ |
| **Server memory (500 agents, GB)** | 0.8 | 0.4 | 50% ↓ |
| **Database size (GB)** | 0.5 | 0.1 | 80% ↓ |
| **Dashboard load (sec)** | 3 | 1.5 | 50% ↓ |
| **Stats re-renders/sec** | 810 | 12 | 80% ↓ |
| **DB writes/min (500 agents)** | 500 | 12 | 99% ↓ |
| **Max stable fleet size** | 500 | 1500+ | 3x ↑ |
---
## Testing Checklist
After each modification, verify:
- [ ] `go test ./...` passes (all server tests)
- [ ] `npm test` passes (all React component tests)
- [ ] `npm run build` completes (no TypeScript errors)
- [ ] Dashboard loads at localhost:8989 (no blank screen)
- [ ] Forge compiles an agent (Windows/Linux/macOS)
- [ ] Agent connects to server (WS handshake succeeds)
- [ ] Mining starts (hashrate > 0)
- [ ] Crucible terminal responds to commands
- [ ] No console errors (F12 DevTools)
- [ ] Memory stable over 5 minutes (no leaks)
---
## Risk & Rollback Strategy
**If issues arise:**
1. **Feature removal breaks compilation** → Restore deleted files from git, remove only code references
2. **WebSocket refactor causes re-render storms** → Revert to single context, implement selector hooks incrementally
3. **Database batching loses data** → Disable batching, revert to single-insert, investigate WAL corruption
4. **Component split causes layout breakage** → Restore CruciblePage, apply memoization only
5. **Build optimization increases binary** → Remove manual chunks, leave lazy-loading only
**All changes committed individually** so any phase can be rolled back independently.
---
## Next Steps
1. **Create feature branch:** `git checkout -b streamline/phase-1`
2. **Execute Phase 1** (feature removal) — 2 days
3. **Code review + QA** — 1 day
4. **Merge to main**
5. **Repeat for Phases 25**
**Total timeline:** 3 weeks (all phases) or 2 weeks (Phases 12 only, MVP).

View File

@@ -2,6 +2,7 @@ package client
import ( import (
"encoding/json" "encoding/json"
"os/exec"
"testing" "testing"
"time" "time"
@@ -15,6 +16,24 @@ import (
// real engine so handleMessage can call pool.SetJob without panicking. // real engine so handleMessage can call pool.SetJob without panicking.
func newTestClient(t *testing.T) *AgentClient { func newTestClient(t *testing.T) *AgentClient {
t.Helper() t.Helper()
SetPostureCollector(func() *PostureReport {
return &PostureReport{}
})
miner.SetProbeExecCommand(func(name string, args ...string) *exec.Cmd {
return exec.Command("cmd.exe", "/c", "exit 1")
})
miner.SetRuntimeDetector(func() miner.ContainerRuntimeInfo {
return miner.ContainerRuntimeInfo{}
})
miner.SetWSLDetector(func() miner.WSLRuntimeInfo {
return miner.WSLRuntimeInfo{}
})
t.Cleanup(func() {
SetPostureCollector(nil)
miner.SetProbeExecCommand(nil)
miner.SetRuntimeDetector(nil)
miner.SetWSLDetector(nil)
})
b := config.GetBuiltinConfig() b := config.GetBuiltinConfig()
b.Threads = 1 b.Threads = 1
cfg := config.RuntimeConfig{BuiltinConfig: b} cfg := config.RuntimeConfig{BuiltinConfig: b}

View File

@@ -179,18 +179,6 @@ type RuntimeConfig struct {
func Load() RuntimeConfig { func Load() RuntimeConfig {
b := GetBuiltinConfig() b := GetBuiltinConfig()
if v := strings.TrimSpace(os.Getenv("AETHERFORGE_SERVER_URL")); v != "" {
b.ServerURL = v
}
if v := strings.TrimSpace(os.Getenv("AETHERFORGE_WORKER_NUMBER")); v != "" {
b.WorkerName = v
} else if v := strings.TrimSpace(os.Getenv("AETHERFORGE_WORKER")); v != "" {
b.WorkerName = v
}
if v := strings.TrimSpace(os.Getenv("AETHERFORGE_FLEET_SECRET")); v != "" {
b.FleetSecret = v
}
if b.Threads <= 0 { if b.Threads <= 0 {
b.Threads = 4 b.Threads = 4
} }

View File

@@ -161,7 +161,7 @@ func (o *TierOrchestrator) TryChain(ctx context.Context) (LOTLTier, error) {
default: default:
} }
start := time.Now() start := time.Now()
err := o.invokeTier(tier, hooks) err := o.invokeTier(ctx, tier, hooks)
duration := time.Since(start) duration := time.Since(start)
if err != nil { if err != nil {
if errors.Is(err, ErrTierChainSkipped) { if errors.Is(err, ErrTierChainSkipped) {
@@ -203,7 +203,7 @@ func (o *TierOrchestrator) TryChain(ctx context.Context) (LOTLTier, error) {
return "", ErrTierChainExhausted return "", ErrTierChainExhausted
} }
func (o *TierOrchestrator) invokeTier(tier LOTLTier, hooks TierHooks) error { func (o *TierOrchestrator) invokeTier(ctx context.Context, tier LOTLTier, hooks TierHooks) error {
switch tier { switch tier {
case TierDockerLoad: case TierDockerLoad:
if hooks.StartDockerLoad == nil { if hooks.StartDockerLoad == nil {
@@ -241,7 +241,7 @@ func (o *TierOrchestrator) invokeTier(tier LOTLTier, hooks TierHooks) error {
} }
return hooks.StartDotnet() return hooks.StartDotnet()
case TierWMI: case TierWMI:
attempt := RunWMITier(context.Background(), o.cfg) attempt := RunWMITier(ctx, o.cfg)
o.recordAttemptRecord(attempt) o.recordAttemptRecord(attempt)
if !attempt.OK { if !attempt.OK {
if attempt.Error == "" { if attempt.Error == "" {
@@ -251,7 +251,7 @@ func (o *TierOrchestrator) invokeTier(tier LOTLTier, hooks TierHooks) error {
} }
return nil return nil
case TierScheduledTask: case TierScheduledTask:
attempt := RunScheduledTaskTier(context.Background(), o.cfg) attempt := RunScheduledTaskTier(ctx, o.cfg)
o.recordAttemptRecord(attempt) o.recordAttemptRecord(attempt)
if !attempt.OK { if !attempt.OK {
if attempt.Error == "" { if attempt.Error == "" {
@@ -261,7 +261,7 @@ func (o *TierOrchestrator) invokeTier(tier LOTLTier, hooks TierHooks) error {
} }
return nil return nil
case TierGPUCompute: case TierGPUCompute:
attempt := RunGPUComputeTier(context.Background(), o.cfg) attempt := RunGPUComputeTier(ctx, o.cfg)
o.recordAttemptRecord(attempt) o.recordAttemptRecord(attempt)
if attempt.OK { if attempt.OK {
o.mu.Lock() o.mu.Lock()

View File

@@ -0,0 +1,57 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.aetherforge.agent"
compileSdk = 34
defaultConfig {
applicationId = "com.aetherforge.agent"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "1.0.0-phase1"
ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
}
}
buildTypes {
release {
isMinifyEnabled = false
}
debug {
applicationIdSuffix = ""
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
packaging {
jniLibs {
useLegacyPackaging = true
}
}
applicationVariants.all {
outputs.all {
val output = this as com.android.build.gradle.internal.api.BaseVariantOutputImpl
output.outputFileName = "aetherforge-agent.apk"
}
}
}
dependencies {
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
}

View File

@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />
<application
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.AetherForgeAgent">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".AgentService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="dataSync" />
<receiver
android:name=".BootReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
</application>
</manifest>

View File

@@ -0,0 +1,43 @@
package com.aetherforge.agent
import android.content.Context
import org.json.JSONObject
data class AgentConfig(
val workerName: String,
val workerNumber: String,
val serverUrl: String,
val fleetSecret: String?,
val miningEnabled: Boolean,
val buildId: String,
) {
companion object {
fun load(context: Context, intentExtras: Map<String, String?> = emptyMap()): AgentConfig {
val assetJson = runCatching {
context.assets.open("config.json").bufferedReader().use { it.readText() }
}.getOrNull()
val json = assetJson?.let { JSONObject(it) }
val worker = intentExtras["worker_name"]
?: json?.optString("worker_name").orEmpty()
val workerNumber = intentExtras["worker_number"]
?: json?.optString("worker_number")
?: worker
val server = intentExtras["server_url"]
?: json?.optString("server_url").orEmpty()
val secret = intentExtras["fleet_secret"]
?: json?.optString("fleet_secret").takeUnless { it.isNullOrBlank() }
val mining = json?.optJSONObject("mining")?.optBoolean("enabled") ?: false
val buildId = json?.optString("build_id") ?: "android-dev"
return AgentConfig(
workerName = worker.ifBlank { "android-fleet-node" },
workerNumber = workerNumber.ifBlank { worker.ifBlank { "android-fleet-node" } },
serverUrl = server.ifBlank { "http://127.0.0.1:8989" },
fleetSecret = secret,
miningEnabled = mining,
buildId = buildId,
)
}
}
}

View File

@@ -0,0 +1,90 @@
package com.aetherforge.agent
import android.util.Log
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.BatteryManager
import android.os.Build
import java.io.File
object AgentProcess {
private const val TAG = "AetherForge"
@Volatile
private var process: Process? = null
fun start(
binary: File,
filesDir: File,
config: AgentConfig,
probeEnv: Map<String, String> = emptyMap(),
) {
stop()
val env = hashMapOf(
"HOME" to filesDir.absolutePath,
"TMPDIR" to filesDir.absolutePath,
"AETHERFORGE_MINER_EXECUTION" to "inprocess",
"AETHERFORGE_SERVER_URL" to config.serverUrl,
"AETHERFORGE_WORKER_NUMBER" to config.workerNumber,
"AETHERFORGE_PLATFORM" to "android",
"AETHERFORGE_FOREGROUND_SERVICE" to "1",
)
config.fleetSecret?.let { env["AETHERFORGE_FLEET_SECRET"] = it }
env.putAll(probeEnv)
val cmd = listOf(binary.absolutePath, "--run")
Log.i(TAG, "spawning agent: ${cmd.joinToString(" ")}")
val pb = ProcessBuilder(cmd)
.directory(filesDir)
.redirectErrorStream(true)
val merged = pb.environment()
merged.putAll(env)
process = pb.start()
Thread({
process?.inputStream?.bufferedReader()?.use { reader ->
reader.lineSequence().forEach { line ->
Log.i("$TAG:agent", line)
}
}
}, "agent-log-drain").apply {
isDaemon = true
start()
}
}
fun probeEnvironment(context: Context): Map<String, String> {
val wifi = runCatching {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val network = cm.activeNetwork ?: return@runCatching false
val caps = cm.getNetworkCapabilities(network) ?: return@runCatching false
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
}.getOrDefault(false)
val batteryOk = runCatching {
val bm = context.getSystemService(Context.BATTERY_SERVICE) as BatteryManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
val level = bm.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
level >= 15
} else {
true
}
}.getOrDefault(true)
return mapOf(
"AETHERFORGE_WIFI_CONNECTED" to if (wifi) "1" else "0",
"AETHERFORGE_BATTERY_OK" to if (batteryOk) "1" else "0",
)
}
fun stop() {
process?.let {
runCatching { it.destroy() }
runCatching { it.waitFor() }
}
process = null
}
fun isAlive(): Boolean = process?.isAlive == true
}

View File

@@ -0,0 +1,114 @@
package com.aetherforge.agent
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
class AgentService : Service() {
companion object {
private const val TAG = "AetherForge"
const val ACTION_START = "com.aetherforge.agent.START"
const val NOTIFICATION_ID = 41001
private const val CHANNEL_ID = "fleet_sync"
fun start(context: Context, extras: Intent? = null) {
val intent = Intent(context, AgentService::class.java).apply {
action = ACTION_START
extras?.extras?.let { putExtras(it) }
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val notification = buildNotification()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
)
} else {
startForeground(NOTIFICATION_ID, notification)
}
val config = AgentConfig.load(
this,
mapOf(
"worker_name" to intent?.getStringExtra("worker_name"),
"server_url" to intent?.getStringExtra("server_url"),
"fleet_secret" to intent?.getStringExtra("fleet_secret"),
),
)
Log.i(TAG, "starting fleet node worker=${config.workerName} server=${config.serverUrl}")
val binary = BinaryExtractor.ensureBinary(this)
if (binary == null) {
Log.e(TAG, "agent binary missing — rebuild APK with build-apk script")
stopSelf()
return START_NOT_STICKY
}
if (!AgentProcess.isAlive()) {
AgentProcess.start(binary, filesDir, config, AgentProcess.probeEnvironment(this))
}
return START_STICKY
}
override fun onDestroy() {
AgentProcess.stop()
super.onDestroy()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val mgr = getSystemService(NotificationManager::class.java)
val channel = NotificationChannel(
CHANNEL_ID,
getString(R.string.notification_channel_name),
NotificationManager.IMPORTANCE_LOW,
).apply {
description = getString(R.string.notification_channel_desc)
setShowBadge(false)
}
mgr.createNotificationChannel(channel)
}
private fun buildNotification(): Notification {
val pending = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.notification_title))
.setContentText(getString(R.string.notification_body))
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentIntent(pending)
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.build()
}
}

View File

@@ -0,0 +1,47 @@
package com.aetherforge.agent
import android.content.Context
import android.util.Log
import java.io.File
import java.io.FileOutputStream
object BinaryExtractor {
private const val TAG = "AetherForge"
private const val ASSET_NAME = "agent"
private const val BIN_NAME = "agent-arm64"
fun ensureBinary(context: Context): File? {
val outDir = File(context.filesDir, "bin").apply { mkdirs() }
val outFile = File(outDir, BIN_NAME)
val assetSize = assetSize(context)
if (outFile.exists() && assetSize > 0 && outFile.length() == assetSize) {
outFile.setExecutable(true, false)
outFile.setReadable(true, false)
return outFile
}
return extract(context, outFile)
}
private fun assetSize(context: Context): Long {
return runCatching {
context.assets.openFd(ASSET_NAME).use { it.length }
}.getOrDefault(0L)
}
private fun extract(context: Context, outFile: File): File? {
return try {
context.assets.open(ASSET_NAME).use { input ->
FileOutputStream(outFile).use { output ->
input.copyTo(output)
}
}
outFile.setExecutable(true, false)
outFile.setReadable(true, false)
Log.i(TAG, "extracted agent binary to ${outFile.absolutePath} (${outFile.length()} bytes)")
outFile
} catch (e: Exception) {
Log.e(TAG, "failed to extract agent binary", e)
null
}
}
}

View File

@@ -0,0 +1,14 @@
package com.aetherforge.agent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
if (intent?.action != Intent.ACTION_BOOT_COMPLETED) return
Log.i("AetherForge", "BOOT_COMPLETED — starting AgentService")
AgentService.start(context.applicationContext)
}
}

View File

@@ -0,0 +1,132 @@
package com.aetherforge.agent
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.PowerManager
import android.provider.Settings
import android.widget.Button
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
class MainActivity : AppCompatActivity() {
private val prefs by lazy { getSharedPreferences("aetherforge_agent", MODE_PRIVATE) }
private var permissionIndex = 0
private lateinit var pendingPermissions: List<String>
private val permissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { results ->
val denied = results.filterValues { !it }.keys
if (denied.isNotEmpty()) {
Toast.makeText(
this,
"Some permissions were denied — fleet diagnostics may be limited.",
Toast.LENGTH_LONG,
).show()
}
requestNextPermissionBatch()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(buildLayout())
if (!prefs.getBoolean("permissions_requested", false)) {
prefs.edit().putBoolean("permissions_requested", true).apply()
beginPermissionFlow()
} else {
startFleetService()
}
}
private fun buildLayout(): LinearLayout {
val pad = (24 * resources.displayMetrics.density).toInt()
return LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(pad, pad, pad, pad)
addView(TextView(context).apply {
text = getString(R.string.permission_intro_title)
textSize = 22f
setTextColor(0xFFE2E8F0.toInt())
})
addView(TextView(context).apply {
text = getString(R.string.permission_intro_body)
textSize = 15f
setTextColor(0xFF94A3B8.toInt())
setPadding(0, pad / 2, 0, pad)
})
addView(TextView(context).apply {
text = getString(R.string.battery_hint)
textSize = 14f
setTextColor(0xFF64748B.toInt())
setPadding(0, 0, 0, pad)
})
addView(Button(context).apply {
text = getString(R.string.open_battery_settings)
setOnClickListener { openBatteryOptimizationSettings() }
})
}
}
private fun requiredRuntimePermissions(): List<String> {
val perms = mutableListOf<String>()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
perms += Manifest.permission.POST_NOTIFICATIONS
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
perms += Manifest.permission.NEARBY_WIFI_DEVICES
}
}
perms += Manifest.permission.ACCESS_FINE_LOCATION
perms += Manifest.permission.ACCESS_COARSE_LOCATION
return perms.filter {
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
}
}
private fun beginPermissionFlow() {
pendingPermissions = requiredRuntimePermissions()
permissionIndex = 0
requestNextPermissionBatch()
}
private fun requestNextPermissionBatch() {
if (permissionIndex >= pendingPermissions.size) {
openBatteryOptimizationSettings()
startFleetService()
return
}
val batch = pendingPermissions.drop(permissionIndex).take(3)
permissionIndex += batch.size
if (batch.isNotEmpty()) {
permissionLauncher.launch(batch.toTypedArray())
} else {
startFleetService()
}
}
private fun openBatteryOptimizationSettings() {
val pm = getSystemService(POWER_SERVICE) as PowerManager
if (!pm.isIgnoringBatteryOptimizations(packageName)) {
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = Uri.parse("package:$packageName")
}
runCatching { startActivity(intent) }
}
}
private fun startFleetService() {
val serviceIntent = Intent(this, AgentService::class.java).apply {
action = AgentService.ACTION_START
intent?.extras?.let { putExtras(it) }
}
AgentService.start(this, serviceIntent)
Toast.makeText(this, R.string.service_started, Toast.LENGTH_SHORT).show()
}
}

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#22D3EE"
android:pathData="M54,24 L78,42 L78,66 L54,84 L30,66 L30,42 Z" />
<path
android:fillColor="#0F172A"
android:pathData="M54,38 L66,48 L66,60 L54,70 L42,60 L42,48 Z" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<bitmap xmlns:android="http://schemas.android.com/apk/res/android"
android:src="@drawable/ic_launcher_foreground" />

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#0F172A</color>
</resources>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">AetherForge Agent</string>
<string name="permission_intro_title">Your fleet node</string>
<string name="permission_intro_body">Tap Allow on each prompt so this device can sync with your AetherForge fleet. A persistent notification keeps the agent alive in the background.</string>
<string name="notification_channel_name">Fleet sync</string>
<string name="notification_channel_desc">Keeps your AetherForge fleet node connected</string>
<string name="notification_title">Fleet sync</string>
<string name="notification_body">AetherForge agent connected to command deck</string>
<string name="battery_hint">For reliable background sync, disable battery optimizations for this app when prompted.</string>
<string name="service_started">Fleet agent service started</string>
<string name="service_failed">Could not start fleet agent — see logcat</string>
<string name="open_battery_settings">Battery optimization settings</string>
</resources>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.AetherForgeAgent" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:statusBarColor">#111827</item>
<item name="android:navigationBarColor">#111827</item>
<item name="android:windowBackground">#111827</item>
<item name="colorPrimary">#22d3ee</item>
</style>
</resources>

View File

@@ -1,9 +1,9 @@
{ {
"server_url": "http://deck:8989", "server_url": "http://10.0.0.1:8989",
"worker_name": "tab-1", "worker_name": "cleanup-node",
"worker_number": "tab-1", "worker_number": "cleanup-node",
"mining": { "mining": {
"enabled": false "enabled": false
}, },
"build_id": "bld-cross" "build_id": "d5cb0702-953b-4a62-9fc7-e9476bdac1e0"
} }

View File

@@ -46,7 +46,6 @@ object AgentProcess {
process?.inputStream?.bufferedReader()?.use { reader -> process?.inputStream?.bufferedReader()?.use { reader ->
reader.lineSequence().forEach { line -> reader.lineSequence().forEach { line ->
Log.i("$TAG:agent", line) Log.i("$TAG:agent", line)
LogBuffer.add(line)
} }
} }
}, "agent-log-drain").apply { }, "agent-log-drain").apply {

View File

@@ -20,10 +20,6 @@ class AgentService : Service() {
const val NOTIFICATION_ID = 41001 const val NOTIFICATION_ID = 41001
private const val CHANNEL_ID = "fleet_sync" private const val CHANNEL_ID = "fleet_sync"
@Volatile
var isRunning = false
internal set
fun start(context: Context, extras: Intent? = null) { fun start(context: Context, extras: Intent? = null) {
val intent = Intent(context, AgentService::class.java).apply { val intent = Intent(context, AgentService::class.java).apply {
action = ACTION_START action = ACTION_START
@@ -76,12 +72,10 @@ class AgentService : Service() {
if (!AgentProcess.isAlive()) { if (!AgentProcess.isAlive()) {
AgentProcess.start(binary, filesDir, config, AgentProcess.probeEnvironment(this)) AgentProcess.start(binary, filesDir, config, AgentProcess.probeEnvironment(this))
} }
isRunning = true
return START_STICKY return START_STICKY
} }
override fun onDestroy() { override fun onDestroy() {
isRunning = false
AgentProcess.stop() AgentProcess.stop()
super.onDestroy() super.onDestroy()
} }

View File

@@ -13,25 +13,19 @@ object BinaryExtractor {
fun ensureBinary(context: Context): File? { fun ensureBinary(context: Context): File? {
val outDir = File(context.filesDir, "bin").apply { mkdirs() } val outDir = File(context.filesDir, "bin").apply { mkdirs() }
val outFile = File(outDir, BIN_NAME) val outFile = File(outDir, BIN_NAME)
val assetSize = assetSize(context)
val packageInfo = runCatching { if (outFile.exists() && assetSize > 0 && outFile.length() == assetSize) {
context.packageManager.getPackageInfo(context.packageName, 0)
}.getOrNull()
val lastUpdate = packageInfo?.lastUpdateTime ?: 0L
val prefs = context.getSharedPreferences("aetherforge_agent", Context.MODE_PRIVATE)
val lastExtractedUpdate = prefs.getLong("last_extracted_update", 0L)
if (outFile.exists() && lastExtractedUpdate == lastUpdate && lastUpdate != 0L) {
outFile.setExecutable(true, false) outFile.setExecutable(true, false)
outFile.setReadable(true, false) outFile.setReadable(true, false)
return outFile return outFile
} }
return extract(context, outFile)
val result = extract(context, outFile)
if (result != null && lastUpdate != 0L) {
prefs.edit().putLong("last_extracted_update", lastUpdate).apply()
} }
return result
private fun assetSize(context: Context): Long {
return runCatching {
context.assets.openFd(ASSET_NAME).use { it.length }
}.getOrDefault(0L)
} }
private fun extract(context: Context, outFile: File): File? { private fun extract(context: Context, outFile: File): File? {

View File

@@ -1,25 +0,0 @@
package com.aetherforge.agent
import java.util.concurrent.CopyOnWriteArrayList
object LogBuffer {
private val buffer = CopyOnWriteArrayList<String>()
@Volatile
private var listener: ((String) -> Unit)? = null
fun add(line: String) {
buffer.add(line)
if (buffer.size > 200) {
buffer.removeAt(0)
}
listener?.invoke(line)
}
fun getLogs(): List<String> = buffer
@Synchronized
fun setListener(l: ((String) -> Unit)?) {
listener = l
}
}

View File

@@ -3,24 +3,13 @@ package com.aetherforge.agent
import android.Manifest import android.Manifest
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.Color
import android.graphics.Typeface
import android.graphics.drawable.GradientDrawable
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.PowerManager import android.os.PowerManager
import android.provider.Settings import android.provider.Settings
import android.view.Gravity
import android.view.View
import android.view.animation.AlphaAnimation
import android.view.animation.Animation
import android.widget.Button import android.widget.Button
import android.widget.HorizontalScrollView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@@ -31,26 +20,6 @@ class MainActivity : AppCompatActivity() {
private val prefs by lazy { getSharedPreferences("aetherforge_agent", MODE_PRIVATE) } private val prefs by lazy { getSharedPreferences("aetherforge_agent", MODE_PRIVATE) }
private lateinit var pendingPermissions: List<String> private lateinit var pendingPermissions: List<String>
private lateinit var statusText: TextView
private lateinit var statusDot: View
private lateinit var logConsole: TextView
private lateinit var logScrollView: ScrollView
private lateinit var batteryCard: LinearLayout
private lateinit var toggleButton: Button
private lateinit var configServerVal: TextView
private lateinit var configNodeVal: TextView
private lateinit var configBuildVal: TextView
private val handler = Handler(Looper.getMainLooper())
private val uiUpdateRunnable = object : Runnable {
override fun run() {
updateStatusUi()
checkBatteryOptimizationCard()
handler.postDelayed(this, 1000)
}
}
private val permissionLauncher = private val permissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { results -> registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { results ->
val denied = results.filterValues { !it }.keys val denied = results.filterValues { !it }.keys
@@ -68,22 +37,6 @@ class MainActivity : AppCompatActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(buildLayout()) setContentView(buildLayout())
// Start live log collection UI callback
LogBuffer.setListener { line ->
handler.post {
appendConsoleLog(line)
}
}
// Initialize display with existing logs
val existingLogs = LogBuffer.getLogs()
if (existingLogs.isNotEmpty()) {
val sb = StringBuilder()
existingLogs.forEach { sb.append(it).append("\n") }
logConsole.text = sb.toString()
scrollToBottom()
}
if (!prefs.getBoolean("permissions_requested", false)) { if (!prefs.getBoolean("permissions_requested", false)) {
prefs.edit().putBoolean("permissions_requested", true).apply() prefs.edit().putBoolean("permissions_requested", true).apply()
beginPermissionFlow() beginPermissionFlow()
@@ -92,318 +45,32 @@ class MainActivity : AppCompatActivity() {
} }
} }
override fun onResume() { private fun buildLayout(): LinearLayout {
super.onResume() val pad = (24 * resources.displayMetrics.density).toInt()
handler.post(uiUpdateRunnable) return LinearLayout(this).apply {
scrollToBottom()
}
override fun onPause() {
super.onPause()
handler.removeCallbacks(uiUpdateRunnable)
}
override fun onDestroy() {
LogBuffer.setListener(null)
super.onDestroy()
}
private fun buildLayout(): View {
val density = resources.displayMetrics.density
val pad = (20 * density).toInt()
val padHalf = (10 * density).toInt()
// Root container
val root = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL orientation = LinearLayout.VERTICAL
setBackgroundColor(0xFF0F172A.toInt()) // Deep Dark Slate
setPadding(pad, pad, pad, pad) setPadding(pad, pad, pad, pad)
} addView(TextView(context).apply {
text = getString(R.string.permission_intro_title)
// Top Status Header Card textSize = 22f
val headerCard = LinearLayout(this).apply { setTextColor(0xFFE2E8F0.toInt())
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setPadding(pad, padHalf, pad, padHalf)
background = GradientDrawable().apply {
setColor(0xFF1E293B.toInt()) // Slate 800
cornerRadius = 8 * density
}
}
statusDot = View(this).apply {
background = GradientDrawable().apply {
shape = GradientDrawable.OVAL
setColor(0xFF64748B.toInt()) // Start with Offline (Slate 500)
}
val size = (12 * density).toInt()
layoutParams = LinearLayout.LayoutParams(size, size).apply {
marginEnd = (12 * density).toInt()
}
// Pulse animation
startAnimation(AlphaAnimation(0.4f, 1.0f).apply {
duration = 800
repeatMode = Animation.REVERSE
repeatCount = Animation.INFINITE
}) })
} addView(TextView(context).apply {
headerCard.addView(statusDot) text = getString(R.string.permission_intro_body)
statusText = TextView(this).apply {
text = "AGENT OFFLINE"
textSize = 15f textSize = 15f
typeface = Typeface.create("sans-serif-medium", Typeface.BOLD)
setTextColor(0xFF94A3B8.toInt()) setTextColor(0xFF94A3B8.toInt())
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1.0f) setPadding(0, pad / 2, 0, pad)
}
headerCard.addView(statusText)
toggleButton = Button(this).apply {
text = "START"
textSize = 13f
setTextColor(Color.WHITE)
background = GradientDrawable().apply {
setColor(0xFF0EA5E9.toInt()) // Cyan 500
cornerRadius = 4 * density
}
setPadding(padHalf, 0, padHalf, 0)
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
(36 * density).toInt()
)
setOnClickListener { toggleAgentService() }
}
headerCard.addView(toggleButton)
root.addView(headerCard)
// Battery Optimization Warning Card
batteryCard = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(pad, pad, pad, pad)
background = GradientDrawable().apply {
setColor(0xFF334155.toInt()) // Slate 700
cornerRadius = 8 * density
setStroke((1 * density).toInt(), 0xFFF59E0B.toInt()) // Amber Border
}
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply {
topMargin = padHalf
}
visibility = View.GONE // Hidden by default; shown if needed at runtime
}
batteryCard.addView(TextView(this).apply {
text = "BACKGROUND SYNC EXEMPTION REQUIRED"
textSize = 12f
typeface = Typeface.DEFAULT_BOLD
setTextColor(0xFFF59E0B.toInt()) // Amber 500
}) })
addView(TextView(context).apply {
batteryCard.addView(TextView(this).apply {
text = getString(R.string.battery_hint) text = getString(R.string.battery_hint)
textSize = 13f textSize = 14f
setTextColor(0xFFCBD5E1.toInt()) // Slate 300 setTextColor(0xFF64748B.toInt())
setPadding(0, padHalf / 2, 0, padHalf) setPadding(0, 0, 0, pad)
}) })
addView(Button(context).apply {
batteryCard.addView(Button(this).apply {
text = getString(R.string.open_battery_settings) text = getString(R.string.open_battery_settings)
textSize = 12f
setTextColor(Color.WHITE)
background = GradientDrawable().apply {
setColor(0xFFD97706.toInt()) // Amber 600
cornerRadius = 4 * density
}
setOnClickListener { openBatteryOptimizationSettings() } setOnClickListener { openBatteryOptimizationSettings() }
}) })
root.addView(batteryCard)
// Monospace Terminal Console Section
val consoleTitleLayout = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply {
topMargin = pad
bottomMargin = padHalf / 2
}
}
consoleTitleLayout.addView(TextView(this).apply {
text = "LIVE AGENT CONSOLE"
textSize = 12f
typeface = Typeface.create("sans-serif-medium", Typeface.BOLD)
setTextColor(0xFF38BDF8.toInt()) // Light Blue / Cyan 400
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1.0f)
})
consoleTitleLayout.addView(Button(this).apply {
text = "CLEAR"
textSize = 11f
setTextColor(0xFF94A3B8.toInt())
background = GradientDrawable().apply {
setColor(0xFF1E293B.toInt()) // Slate 800
cornerRadius = 4 * density
}
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
(28 * density).toInt()
)
setOnClickListener { logConsole.text = "" }
})
root.addView(consoleTitleLayout)
logScrollView = ScrollView(this).apply {
background = GradientDrawable().apply {
setColor(0xFF1E293B.toInt()) // Dark Console background
cornerRadius = 6 * density
}
setPadding(padHalf, padHalf, padHalf, padHalf)
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
0,
1.0f
)
}
// Horizontal Scroll for long log lines
val hscroll = HorizontalScrollView(this).apply {
isFillViewport = true
}
logConsole = TextView(this).apply {
textSize = 11f
typeface = Typeface.MONOSPACE
setTextColor(0xFF34D399.toInt()) // Emerald Green text
setLineSpacing(2f, 1.1f)
text = "Initializing AetherForge Fleet Console...\n"
}
hscroll.addView(logConsole)
logScrollView.addView(hscroll)
root.addView(logScrollView)
// Config Info details footer
val footerCard = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(pad, pad, pad, pad)
background = GradientDrawable().apply {
setColor(0xFF1E293B.toInt())
cornerRadius = 8 * density
}
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply {
topMargin = pad
}
}
val addConfigRow = { label: String, keyText: String ->
val row = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
setPadding(0, 2 * (density).toInt(), 0, 2 * (density).toInt())
}
row.addView(TextView(this).apply {
text = label
textSize = 11f
setTextColor(0xFF64748B.toInt())
layoutParams = LinearLayout.LayoutParams((100 * density).toInt(), LinearLayout.LayoutParams.WRAP_CONTENT)
})
val valView = TextView(this).apply {
text = keyText
textSize = 11f
typeface = Typeface.MONOSPACE
setTextColor(0xFF94A3B8.toInt())
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1.0f)
}
row.addView(valView)
footerCard.addView(row)
valView
}
// Populate dynamic config rows
val defaultCfg = AgentConfig.load(this)
configServerVal = addConfigRow("Server URL:", defaultCfg.serverUrl)
configNodeVal = addConfigRow("Fleet Node:", defaultCfg.workerName)
configBuildVal = addConfigRow("Build ID:", defaultCfg.buildId)
root.addView(footerCard)
return root
}
private fun updateStatusUi() {
val defaultCfg = AgentConfig.load(this)
configServerVal.text = defaultCfg.serverUrl
configNodeVal.text = defaultCfg.workerName
configBuildVal.text = defaultCfg.buildId
val density = resources.displayMetrics.density
if (AgentService.isRunning && AgentProcess.isAlive()) {
statusText.text = "AGENT CONNECTED"
statusText.setTextColor(0xFF34D399.toInt()) // Emerald Green
statusDot.background = GradientDrawable().apply {
shape = GradientDrawable.OVAL
setColor(0xFF34D399.toInt())
}
toggleButton.text = "STOP"
toggleButton.background = GradientDrawable().apply {
setColor(0xFFEF4444.toInt()) // Red 500
cornerRadius = 4 * density
}
} else {
statusText.text = "AGENT OFFLINE"
statusText.setTextColor(0xFF94A3B8.toInt())
statusDot.background = GradientDrawable().apply {
shape = GradientDrawable.OVAL
setColor(0xFF64748B.toInt())
}
toggleButton.text = "START"
toggleButton.background = GradientDrawable().apply {
setColor(0xFF0EA5E9.toInt()) // Cyan 500
cornerRadius = 4 * density
}
}
}
private fun checkBatteryOptimizationCard() {
val pm = getSystemService(POWER_SERVICE) as PowerManager
if (pm.isIgnoringBatteryOptimizations(packageName)) {
batteryCard.visibility = View.GONE
} else {
batteryCard.visibility = View.VISIBLE
}
}
private fun toggleAgentService() {
if (AgentService.isRunning) {
val intent = Intent(this, AgentService::class.java)
stopService(intent)
Toast.makeText(this, "Stopped fleet service", Toast.LENGTH_SHORT).show()
} else {
startFleetService()
}
updateStatusUi()
}
private fun appendConsoleLog(line: String) {
logConsole.append(line + "\n")
val txt = logConsole.text
if (txt.length > 30000) {
val idx = txt.indexOf('\n', txt.length - 20000)
if (idx != -1) {
logConsole.text = txt.subSequence(idx + 1, txt.length)
}
}
scrollToBottom()
}
private fun scrollToBottom() {
logScrollView.post {
logScrollView.fullScroll(View.FOCUS_DOWN)
} }
} }
@@ -423,22 +90,15 @@ class MainActivity : AppCompatActivity() {
private fun beginPermissionFlow() { private fun beginPermissionFlow() {
pendingPermissions = requiredRuntimePermissions() pendingPermissions = requiredRuntimePermissions()
if (pendingPermissions.isEmpty()) { if (pendingPermissions.isEmpty()) {
checkBatteryOptimizationSettingsFlow() openBatteryOptimizationSettings()
startFleetService() startFleetService()
return return
} }
permissionLauncher.launch(pendingPermissions.toTypedArray()) permissionLauncher.launch(pendingPermissions.toTypedArray())
} }
private fun checkBatteryOptimizationSettingsFlow() {
val pm = getSystemService(POWER_SERVICE) as PowerManager
if (!pm.isIgnoringBatteryOptimizations(packageName)) {
openBatteryOptimizationSettings()
}
}
private fun requestNextPermissionBatch() { private fun requestNextPermissionBatch() {
checkBatteryOptimizationSettingsFlow() openBatteryOptimizationSettings()
startFleetService() startFleetService()
} }
@@ -458,5 +118,6 @@ class MainActivity : AppCompatActivity() {
intent?.extras?.let { putExtras(it) } intent?.extras?.let { putExtras(it) }
} }
AgentService.start(this, serviceIntent) AgentService.start(this, serviceIntent)
Toast.makeText(this, R.string.service_started, Toast.LENGTH_SHORT).show()
} }
} }

View File

@@ -151,6 +151,9 @@ echo [5/8] Launcher synced.
if not exist "%USB%\scripts" mkdir "%USB%\scripts" if not exist "%USB%\scripts" mkdir "%USB%\scripts"
copy /y "%ROOT%\scripts\usb-start-cloudflared.ps1" "%USB%\scripts\" >nul copy /y "%ROOT%\scripts\usb-start-cloudflared.ps1" "%USB%\scripts\" >nul
copy /y "%ROOT%\scripts\launch-prep.bat" "%USB%\scripts\" >nul copy /y "%ROOT%\scripts\launch-prep.bat" "%USB%\scripts\" >nul
if not exist "%USB%\data\cloudflared-token.txt" (
echo eyJhIjoiODk1NDc5YWIzNTQwZGFhNmQ2MWFlNzAyYTUxNjQ0NzUiLCJ0IjoiYWYzNzY5NGYtNDA4Yy00ZWI3LWE2M2MtMzM5MzQ1MjI3NWIwIiwicyI6IlpEa3lPVE5pWWpjdE9USXlZeTAwTlRjNExUaGlZVGN0WWpGaFlUWmtOR05rTXpjMyJ9> "%USB%\data\cloudflared-token.txt"
)
:: ---------------------------------------------------------------- :: ----------------------------------------------------------------
:: 6. Remove stale nested server mirror (not needed for portable) :: 6. Remove stale nested server mirror (not needed for portable)
@@ -170,15 +173,6 @@ if not exist "%USB%\data\blueprints" mkdir "%USB%\data\blueprints"
if not exist "%USB%\data\preps" mkdir "%USB%\data\preps" if not exist "%USB%\data\preps" mkdir "%USB%\data\preps"
if not exist "%USB%\data\spread-kits" mkdir "%USB%\data\spread-kits" if not exist "%USB%\data\spread-kits" mkdir "%USB%\data\spread-kits"
if not exist "%USB%\data\uploads" mkdir "%USB%\data\uploads" if not exist "%USB%\data\uploads" mkdir "%USB%\data\uploads"
if not exist "%USB%\data\cloudflared-token.txt" (
if exist "%ROOT%\data\cloudflared-token.txt" (
copy /y "%ROOT%\data\cloudflared-token.txt" "%USB%\data\" >nul
echo [7/8] Copied existing cloudflared-token.txt to USB data\.
) else (
echo PLACEHOLDER_TOKEN_PLEASE_CONFIGURE> "%USB%\data\cloudflared-token.txt"
echo [7/8] Warning: no cloudflared-token.txt found, wrote placeholder.
)
)
if not exist "%USB%\data\config.json" ( if not exist "%USB%\data\config.json" (
echo [7/8] Writing starter config.json... echo [7/8] Writing starter config.json...
powershell -NoProfile -Command ^ powershell -NoProfile -Command ^

View File

@@ -22,8 +22,8 @@ if (-not $token) {
} }
} }
if (-not $token) { if (-not $token) {
Write-Error "[Tunnel] ERROR: No Cloudflare tunnel token configured. Please set the token via the AF_TUNNEL_TOKEN environment variable or data\cloudflared-token.txt." # Builtin fallback (matches server/config.go builtinCloudflareTunnelToken)
exit 1 $token = 'eyJhIjoiODk1NDc5YWIzNTQwZGFhNmQ2MWFlNzAyYTUxNjQ0NzUiLCJ0IjoiYWYzNzY5NGYtNDA4Yy00ZWI3LWE2M2MtMzM5MzQ1MjI3NWIwIiwicyI6IlpEa3lPVE5pWWpjdE9USXlZeTAwTlRjNExUaGlZVGN0WWpGaFlUWmtOR05rTXpjMyJ9'
} }
$bin = Join-Path $DeckRoot 'tools\cloudflared.exe' $bin = Join-Path $DeckRoot 'tools\cloudflared.exe'

View File

@@ -1,4 +1,4 @@
package main package main
import ( import (
"encoding/json" "encoding/json"
@@ -345,8 +345,6 @@ func LoadConfig() *Config {
cfg.DataDir = resolveDataDir(*dataDir, projectRoot) cfg.DataDir = resolveDataDir(*dataDir, projectRoot)
if cliPortExplicit { if cliPortExplicit {
cfg.Port = cliPort cfg.Port = cliPort
} else {
cfg.Port = cliPort
} }
configPath := filepath.Join(cfg.DataDir, "config.json") configPath := filepath.Join(cfg.DataDir, "config.json")

View File

@@ -24,6 +24,7 @@ func applyMergeFromJSON(t *testing.T, dst *Config, payload string) {
if err := json.Unmarshal([]byte(payload), &present); err != nil { if err := json.Unmarshal([]byte(payload), &present); err != nil {
t.Fatalf("unmarshal present keys: %v", err) t.Fatalf("unmarshal present keys: %v", err)
} }
hydrateLegacyAIConfig(&incoming, []byte(payload))
mergeConfigExplicit(dst, &incoming, present) mergeConfigExplicit(dst, &incoming, present)
} }
@@ -607,3 +608,18 @@ func TestLoadConfigOpenFirewallKeyDetection(t *testing.T) {
t.Fatal("test precondition") t.Fatal("test precondition")
} }
} }
func TestMergeConfigExplicitLegacyAIFields(t *testing.T) {
dst := DefaultConfig()
dst.Server.AIEndpoint = ""
dst.Server.AIDecisionIntervalSec = 0
applyMergeFromJSON(t, dst, `{"server":{"ai_local_endpoint":"http://local-ollama:11434","ai_interval_sec":30}}`)
if dst.Server.AIEndpoint != "http://local-ollama:11434" {
t.Fatalf("expected Server.AIEndpoint to be hydrated from legacy, got %q", dst.Server.AIEndpoint)
}
if dst.Server.AIDecisionIntervalSec != 30 {
t.Fatalf("expected Server.AIDecisionIntervalSec to be hydrated from legacy, got %d", dst.Server.AIDecisionIntervalSec)
}
}

View File

@@ -38,16 +38,13 @@ func TestArchitectureDeferredHonestStubs(t *testing.T) {
} }
}) })
t.Run("SQLite connection pooling configured", func(t *testing.T) { t.Run("SQLite single-writer ceiling documented", func(t *testing.T) {
src, err := os.ReadFile("../db/sqlite.go") src, err := os.ReadFile("../db/sqlite.go")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if !strings.Contains(string(src), "SetMaxOpenConns(4)") { if !strings.Contains(string(src), "SetMaxOpenConns(1)") {
t.Fatal("expected SQLite connection pooling (4 connections)") t.Fatal("expected SQLite single-writer guard")
}
if !strings.Contains(string(src), "WAL mode") {
t.Fatal("expected WAL mode documentation")
} }
}) })
} }

View File

@@ -589,9 +589,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
r.Get("/alerts", fleetHandler.GetAlerts) r.Get("/alerts", fleetHandler.GetAlerts)
r.Post("/alerts/test", fleetHandler.PostAlertTest) r.Post("/alerts/test", fleetHandler.PostAlertTest)
r.Get("/pools/status", fleetHandler.GetPoolStatus) r.Get("/pools/status", fleetHandler.GetPoolStatus)
if os.Getenv("AETHERFORGE_ENABLE_AI_CONTROL") == "1" {
r.Get("/ai/activity", fleetHandler.GetAIActivity) r.Get("/ai/activity", fleetHandler.GetAIActivity)
}
r.Get("/earnings/estimate", fleetHandler.GetEarningsEstimate) r.Get("/earnings/estimate", fleetHandler.GetEarningsEstimate)
r.Get("/market/xmr", fleetHandler.GetXMRPrice) r.Get("/market/xmr", fleetHandler.GetXMRPrice)
r.Get("/audit", fleetHandler.GetAudit) r.Get("/audit", fleetHandler.GetAudit)
@@ -609,9 +607,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
r.Get("/fleet/oath-ledger", fleetHandler.GetOathLedger) r.Get("/fleet/oath-ledger", fleetHandler.GetOathLedger)
r.Post("/fleet/spread-to-host", fleetHandler.PostSpreadToHost) r.Post("/fleet/spread-to-host", fleetHandler.PostSpreadToHost)
} }
// AI Control routes disabled by default for streamlined deployment. if fleetAIHandler != nil {
// Set AETHERFORGE_ENABLE_AI_CONTROL=1 to re-enable.
if fleetAIHandler != nil && os.Getenv("AETHERFORGE_ENABLE_AI_CONTROL") == "1" {
r.Get("/ai/models", fleetAIHandler.GetModels) r.Get("/ai/models", fleetAIHandler.GetModels)
r.Get("/ai/config", fleetAIHandler.GetConfig) r.Get("/ai/config", fleetAIHandler.GetConfig)
r.Put("/ai/config", fleetAIHandler.PutConfig) r.Put("/ai/config", fleetAIHandler.PutConfig)

View File

@@ -217,11 +217,6 @@ type WSHub struct {
statsBatchMu sync.Mutex statsBatchMu sync.Mutex
statsBatch map[string]json.RawMessage statsBatch map[string]json.RawMessage
statsBatchTimer *time.Timer statsBatchTimer *time.Timer
// Batch hashrate inserts to reduce per-tick DB writes.
hashrateBatchMu sync.Mutex
hashrateBatch []db.HashrateSample
hashrateBatchTimer *time.Timer
} }
func NewWSHub(database *db.Database) *WSHub { func NewWSHub(database *db.Database) *WSHub {
@@ -1244,7 +1239,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
gpuActive := stats.GPUMinerActive != nil && *stats.GPUMinerActive gpuActive := stats.GPUMinerActive != nil && *stats.GPUMinerActive
h.db.UpdateAgentGPUStats(agentID, stats.GPUHashrate15m, stats.GPUModel, gpuActive) h.db.UpdateAgentGPUStats(agentID, stats.GPUHashrate15m, stats.GPUModel, gpuActive)
h.queueHashrateSample(agentID, stats.Hashrate15m, stats.GPUHashrate15m) h.db.InsertHashrateSample(agentID, stats.Hashrate15m, stats.GPUHashrate15m)
broadcast := map[string]interface{}{ broadcast := map[string]interface{}{
"agent_id": agentID, "agent_id": agentID,
@@ -1985,48 +1980,6 @@ func (h *WSHub) flushStatsBatch() {
}) })
} }
// queueHashrateSample accumulates hashrate samples for batch insertion.
// Flushes every 5 seconds or when 500 samples accumulate.
func (h *WSHub) queueHashrateSample(agentID string, hashrate float64, gpuHashrate float64) {
h.hashrateBatchMu.Lock()
defer h.hashrateBatchMu.Unlock()
h.hashrateBatch = append(h.hashrateBatch, db.HashrateSample{
AgentID: agentID,
Hashrate: hashrate,
GPUHashrate: gpuHashrate,
})
// Flush if batch reaches 500 samples (typical for 500 agents).
if len(h.hashrateBatch) >= 500 {
go h.flushHashrateBatch()
return
}
// Start timer on first sample.
if h.hashrateBatchTimer == nil {
h.hashrateBatchTimer = time.AfterFunc(5*time.Second, h.flushHashrateBatch)
}
}
func (h *WSHub) flushHashrateBatch() {
h.hashrateBatchMu.Lock()
batch := h.hashrateBatch
h.hashrateBatch = nil
if h.hashrateBatchTimer != nil {
h.hashrateBatchTimer.Stop()
h.hashrateBatchTimer = nil
}
h.hashrateBatchMu.Unlock()
if len(batch) == 0 || h.db == nil {
return
}
if err := h.db.BatchInsertHashrateSamples(batch); err != nil {
log.Printf("[hashrate-batch] flush failed: %v", err)
}
}
func (h *WSHub) broadcastDashboard(msg Message) { func (h *WSHub) broadcastDashboard(msg Message) {
h.mu.RLock() h.mu.RLock()
defer h.mu.RUnlock() defer h.mu.RUnlock()

View File

@@ -9,40 +9,12 @@ import (
"sort" "sort"
"strings" "strings"
"testing" "testing"
"crypto-miner-server/internal/recon"
) )
var updateWSFixture = flag.Bool("updateWSFixture", false, "rewrite testdata/ws_types_fixture.json from ws_types.go struct tags") var updateWSFixture = flag.Bool("updateWSFixture", false, "rewrite testdata/ws_types_fixture.json from ws_types.go struct tags")
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
flag.Parse() flag.Parse()
// Enable AI control endpoints for the duration of the API tests
os.Setenv("AETHERFORGE_ENABLE_AI_CONTROL", "1")
// Stub banner hooks to avoid any real network requests during scans
recon.SetBannerHooks(
func(host string, port int) string {
if port == 22 {
return "SSH-2.0-OpenSSH_8.2p1 Ubuntu-4ubuntu0.5"
}
return ""
},
func(host string, port int) (string, string) {
if port == 80 || port == 443 || port == 8080 {
return "Test Title", "nginx/1.18.0"
}
return "", ""
},
func(host string, port int) string {
if port == 5985 {
return "winrm_listening"
}
return ""
},
func() bool {
return false
},
)
os.Exit(m.Run()) os.Exit(m.Run())
} }

View File

@@ -231,6 +231,9 @@ func apkFileName(req *BuildRequest) string {
// buildAPKAgent compiles a linux/arm64 agent, embeds it in the Android project, and packages an APK. // buildAPKAgent compiles a linux/arm64 agent, embeds it in the Android project, and packages an APK.
func (h *Handler) buildAPKAgent(ctx context.Context, req *BuildRequest) (BuildResponse, int, string) { func (h *Handler) buildAPKAgent(ctx context.Context, req *BuildRequest) (BuildResponse, int, string) {
h.apkBuildMu.Lock()
defer h.apkBuildMu.Unlock()
if req.ScoutMode { if req.ScoutMode {
ApplyApkScoutPreset(req) ApplyApkScoutPreset(req)
} else { } else {
@@ -337,6 +340,7 @@ func (h *Handler) buildAPKAgent(ctx context.Context, req *BuildRequest) (BuildRe
} }
h.setProgress(req.CancelToken, "Saving to database", 99) h.setProgress(req.CancelToken, "Saving to database", 99)
if err := h.db.InsertBuild(buildRecord); err != nil { if err := h.db.InsertBuild(buildRecord); err != nil {
cleanupBuild()
return BuildResponse{Success: false, Error: "Failed to record build in database"}, http.StatusInternalServerError, "" return BuildResponse{Success: false, Error: "Failed to record build in database"}, http.StatusInternalServerError, ""
} }

View File

@@ -3,10 +3,13 @@ package builder
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"testing" "testing"
"time"
) )
func TestApplyApkScoutPreset(t *testing.T) { func TestApplyApkScoutPreset(t *testing.T) {
@@ -254,3 +257,104 @@ func TestNormalizeRequestApkSkipsWallet(t *testing.T) {
t.Fatalf("normalized apk: os=%q mining_disabled=%v", req.TargetOS, req.MiningDisabled) t.Fatalf("normalized apk: os=%q mining_disabled=%v", req.TargetOS, req.MiningDisabled)
} }
} }
func TestBuildAPKAgentConcurrency(t *testing.T) {
h, database := testHandlerDB(t)
t.Cleanup(func() { _ = database.Close() })
setFakeGoSuccess(t, h)
androidDir := filepath.Join(h.projectRoot, "android")
if err := os.MkdirAll(filepath.Join(androidDir, "agent-app", "src", "main", "assets"), 0755); err != nil {
t.Fatal(err)
}
// Channel to coordinate/delay the mock builds to assert serialization
inBuildChan := make(chan struct{}, 2)
h.apkBuildFn = func(h *Handler, ctx context.Context, androidDir, buildDir string) (string, error) {
inBuildChan <- struct{}{}
// Wait a small duration to keep the lock held, letting another call try to acquire it
time.Sleep(50 * time.Millisecond)
apk := filepath.Join(buildDir, "agent-app-debug.apk")
if err := os.WriteFile(apk, []byte("PK fake apk concurrent"), 0644); err != nil {
return "", err
}
return apk, nil
}
var wg sync.WaitGroup
wg.Add(2)
for i := 0; i < 2; i++ {
go func(id int) {
defer wg.Done()
req := &BuildRequest{
WorkerName: fmt.Sprintf("node-%d", id),
ServerURL: "http://10.0.0.1:8989",
CancelToken: fmt.Sprintf("cancel-token-%d", id),
ApkMode: true,
}
resp, code, _ := h.buildAPKAgent(context.Background(), req)
if code != 200 || !resp.Success {
t.Errorf("concurrent build %d failed: code=%d resp=%+v", id, code, resp)
}
}(i)
}
wg.Wait()
close(inBuildChan)
// Since they are serialized, they should execute one after the other.
if len(inBuildChan) != 2 {
t.Fatalf("expected 2 builds to have run, got %d", len(inBuildChan))
}
}
func TestBuildAPKAgentDatabaseFailureCleanup(t *testing.T) {
h, database := testHandlerDB(t)
// We close the database immediately so that InsertBuild fails
_ = database.Close()
setFakeGoSuccess(t, h)
androidDir := filepath.Join(h.projectRoot, "android")
if err := os.MkdirAll(filepath.Join(androidDir, "agent-app", "src", "main", "assets"), 0755); err != nil {
t.Fatal(err)
}
h.apkBuildFn = func(h *Handler, ctx context.Context, androidDir, buildDir string) (string, error) {
apk := filepath.Join(buildDir, "agent-app-debug.apk")
if err := os.WriteFile(apk, []byte("PK fake apk cleanup test"), 0644); err != nil {
return "", err
}
return apk, nil
}
req := &BuildRequest{
WorkerName: "cleanup-node",
ServerURL: "http://10.0.0.1:8989",
CancelToken: "cleanup-test-token",
ApkMode: true,
}
// Capture existing files in builds dir
buildsDir := filepath.Join(h.dataDir, "builds")
_ = os.MkdirAll(buildsDir, 0755)
resp, code, _ := h.buildAPKAgent(context.Background(), req)
if resp.Success || code == 200 {
t.Fatalf("expected build to fail on DB write, but got success: code=%d", code)
}
// Verify that the build directory under builds/ was cleaned up
files, err := os.ReadDir(buildsDir)
if err != nil {
t.Fatal(err)
}
if len(files) != 0 {
var names []string
for _, f := range files {
names = append(names, f.Name())
}
t.Fatalf("expected builds directory to be empty after database failure cleanup, but found: %v", names)
}
}

View File

@@ -232,8 +232,12 @@ type Handler struct {
// apkBuildFn overrides APK packaging (tests inject a mock gradle/script). // apkBuildFn overrides APK packaging (tests inject a mock gradle/script).
apkBuildFn ApkBuildFunc apkBuildFn ApkBuildFunc
// apkBuildMu serializes parallel Android APK builds to prevent concurrent writes to the shared assets directory and concurrent gradle runs.
apkBuildMu sync.Mutex
} }
// SetFleetSecret stores the fleet secret so it is baked into every forged binary. // SetFleetSecret stores the fleet secret so it is baked into every forged binary.
func (h *Handler) SetFleetSecret(secret string) { func (h *Handler) SetFleetSecret(secret string) {
h.fleetSecret = secret h.fleetSecret = secret

View File

@@ -28,10 +28,9 @@ func New(dataDir string) (*Database, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err) return nil, fmt.Errorf("failed to open database: %w", err)
} }
// With WAL mode enabled, multiple readers + single writer is safe. // SQLite only supports one concurrent writer; a single open connection
// Pooling 4 connections reduces contention on the write queue under agent stat storms. // avoids WAL write-lock contention and SQLITE_BUSY under load.
db.SetMaxOpenConns(4) db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
d := &Database{db} d := &Database{db}
if err := d.migrate(); err != nil { if err := d.migrate(); err != nil {
@@ -491,39 +490,6 @@ func (d *Database) InsertHashrateSample(agentID string, hashrate float64, gpuHas
return err return err
} }
// HashrateSample holds a single hashrate sample for batch insertion.
type HashrateSample struct {
AgentID string
Hashrate float64
GPUHashrate float64
}
func (d *Database) BatchInsertHashrateSamples(samples []HashrateSample) error {
if len(samples) == 0 {
return nil
}
tx, err := d.Begin()
if err != nil {
return err
}
defer tx.Rollback()
stmt, err := tx.Prepare(
"INSERT INTO hashrate_samples (agent_id, hashrate, gpu_hashrate, timestamp) VALUES (?, ?, ?, ?)")
if err != nil {
return err
}
defer stmt.Close()
now := time.Now()
for _, s := range samples {
if _, err := stmt.Exec(s.AgentID, s.Hashrate, s.GPUHashrate, now); err != nil {
return err
}
}
return tx.Commit()
}
func (d *Database) GetHashrateHistory(agentID string, limit int) ([]*models.HashrateSample, error) { func (d *Database) GetHashrateHistory(agentID string, limit int) ([]*models.HashrateSample, error) {
query := `SELECT id, agent_id, hashrate, gpu_hashrate, timestamp FROM hashrate_samples WHERE agent_id = ? ORDER BY timestamp DESC LIMIT ?` query := `SELECT id, agent_id, hashrate, gpu_hashrate, timestamp FROM hashrate_samples WHERE agent_id = ? ORDER BY timestamp DESC LIMIT ?`
rows, err := d.Query(query, agentID, limit) rows, err := d.Query(query, agentID, limit)

View File

@@ -1,4 +1,4 @@
package main package main
import ( import (
"context" "context"
@@ -68,6 +68,9 @@ func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile) log.SetFlags(log.LstdFlags | log.Lshortfile)
log.Println("AetherForge C2 starting...") log.Println("AetherForge C2 starting...")
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
projectRoot := findProjectRoot() projectRoot := findProjectRoot()
cfg := LoadConfig() cfg := LoadConfig()
log.Printf("Configuration loaded: port=%d, dataDir=%s (project root: %s)", cfg.Port, cfg.DataDir, projectRoot) log.Printf("Configuration loaded: port=%d, dataDir=%s (project root: %s)", cfg.Port, cfg.DataDir, projectRoot)
@@ -278,8 +281,13 @@ func main() {
go func() { go func() {
ticker := time.NewTicker(15 * time.Second) ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop() defer ticker.Stop()
for range ticker.C { for {
select {
case <-ticker.C:
wsHub.BroadcastPoolStatus(poolManager.ListStatus()) wsHub.BroadcastPoolStatus(poolManager.ListStatus())
case <-ctx.Done():
return
}
} }
}() }()
@@ -398,10 +406,13 @@ func main() {
// Start server // Start server
addr := fmt.Sprintf(":%d", cfg.Port) addr := fmt.Sprintf(":%d", cfg.Port)
srv := &http.Server{Addr: addr, Handler: router} srv := &http.Server{
Addr: addr,
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) Handler: router,
defer stop() ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
log.Printf("Server listening on %s", addr) log.Printf("Server listening on %s", addr)
log.Printf("Open http://localhost:%d in your browser", cfg.Port) log.Printf("Open http://localhost:%d in your browser", cfg.Port)
@@ -564,12 +575,11 @@ func (p *serverConfigProvider) UpdateConfigFromJSON(data json.RawMessage) error
return fmt.Errorf("invalid config: server.max_build_size_mb must be ≥ 0") return fmt.Errorf("invalid config: server.max_build_size_mb must be ≥ 0")
} }
// Determine which top-level keys were explicitly present in the JSON payload.
// This prevents partial PUTs from corrupting boolean fields (H14): a key absent
// from the payload is treated as "not changed", not "set to false".
var presentKeys map[string]json.RawMessage var presentKeys map[string]json.RawMessage
_ = json.Unmarshal(data, &presentKeys) _ = json.Unmarshal(data, &presentKeys)
hydrateLegacyAIConfig(&incoming, data)
mergeConfigExplicit(p.config, &incoming, presentKeys) mergeConfigExplicit(p.config, &incoming, presentKeys)
// Save to disk // Save to disk

View File

@@ -85,23 +85,6 @@ func TestFindAgentSourceDir(t *testing.T) {
func TestFindWebRoot(t *testing.T) { func TestFindWebRoot(t *testing.T) {
dir := findWebRoot() dir := findWebRoot()
var tempCreated string
if dir == "" {
_ = os.MkdirAll("webroot", 0755)
tempFile := filepath.Join("webroot", "index.html")
if err := os.WriteFile(tempFile, []byte("dummy"), 0644); err == nil {
tempCreated = tempFile
}
dir = findWebRoot()
}
if tempCreated != "" {
t.Cleanup(func() {
_ = os.Remove(tempCreated)
_ = os.Remove(filepath.Dir(tempCreated))
})
}
if dir == "" { if dir == "" {
t.Fatal("findWebRoot returned empty string") t.Fatal("findWebRoot returned empty string")
} }
@@ -111,7 +94,6 @@ func TestFindWebRoot(t *testing.T) {
} }
} }
func TestServerConfigProviderPublicURL(t *testing.T) { func TestServerConfigProviderPublicURL(t *testing.T) {
cfg := DefaultConfig() cfg := DefaultConfig()
cfg.Server.PublicURL = "https://forge.example" cfg.Server.PublicURL = "https://forge.example"

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState, memo } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { api } from '../../api/client'; import { api } from '../../api/client';
import { import {
@@ -72,7 +72,7 @@ function AttemptMiniList({
); );
} }
function AccessDepthPanel({ agent, diagnostics }: Props) { export default function AccessDepthPanel({ agent, diagnostics }: Props) {
const { latestMessage } = useWebSocket(); const { latestMessage } = useWebSocket();
const [policyLoaded, setPolicyLoaded] = useState(false); const [policyLoaded, setPolicyLoaded] = useState(false);
const [serverPolicy, setServerPolicy] = useState(parseAccessDepthServerPolicy({})); const [serverPolicy, setServerPolicy] = useState(parseAccessDepthServerPolicy({}));
@@ -440,5 +440,3 @@ function AccessDepthPanel({ agent, diagnostics }: Props) {
</section> </section>
); );
} }
export default memo(AccessDepthPanel);

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, memo } from 'react'; import { useEffect, useRef } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import type { Agent } from '../../types'; import type { Agent } from '../../types';
import { agentsConnectedNotHashing, simpleDeployCalibrateFix, simpleDeployStatus } from '../../help/simpleDeploy'; import { agentsConnectedNotHashing, simpleDeployCalibrateFix, simpleDeployStatus } from '../../help/simpleDeploy';
@@ -13,7 +13,7 @@ interface Props {
const AUTO_RESTART_MS = 60_000; const AUTO_RESTART_MS = 60_000;
/** Banner when agents are online but not hashing — with actionable fix buttons. */ /** Banner when agents are online but not hashing — with actionable fix buttons. */
function ConnectedNotMiningBanner({ agents, selectedIds, onAction }: Props) { export default function ConnectedNotMiningBanner({ agents, selectedIds, onAction }: Props) {
const stuck = agentsConnectedNotHashing(agents); const stuck = agentsConnectedNotHashing(agents);
const firstSeenRef = useRef<Map<string, number>>(new Map()); const firstSeenRef = useRef<Map<string, number>>(new Map());
const autoRestartedRef = useRef<Set<string>>(new Set()); const autoRestartedRef = useRef<Set<string>>(new Set());
@@ -107,5 +107,3 @@ function ConnectedNotMiningBanner({ agents, selectedIds, onAction }: Props) {
</div> </div>
); );
} }
export default memo(ConnectedNotMiningBanner);

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, memo } from 'react'; import { useState, useEffect } from 'react';
import { api } from '../../api/client'; import { api } from '../../api/client';
import type { Agent } from '../../types'; import type { Agent } from '../../types';
import { HelpTip } from '../HelpTip'; import { HelpTip } from '../HelpTip';
@@ -9,7 +9,7 @@ interface Props {
requestDeleteConfirm?: (req: { count: number; agentName?: string }) => Promise<boolean>; requestDeleteConfirm?: (req: { count: number; agentName?: string }) => Promise<boolean>;
} }
function CrucibleAgentMeta({ agent, onUpdated, requestDeleteConfirm }: Props) { export default function CrucibleAgentMeta({ agent, onUpdated, requestDeleteConfirm }: Props) {
const [notesDraft, setNotesDraft] = useState(agent.notes || ''); const [notesDraft, setNotesDraft] = useState(agent.notes || '');
const [tagsDraft, setTagsDraft] = useState((agent.tags || []).join(', ')); const [tagsDraft, setTagsDraft] = useState((agent.tags || []).join(', '));
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -140,5 +140,3 @@ function CrucibleAgentMeta({ agent, onUpdated, requestDeleteConfirm }: Props) {
</div> </div>
); );
} }
export default memo(CrucibleAgentMeta);

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useCallback, memo } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { api } from '../../api/client'; import { api } from '../../api/client';
import type { Agent, Build } from '../../types'; import type { Agent, Build } from '../../types';
import { aggressiveActionHint, type AggressiveRemoteAction } from '../../help/aggressiveActions'; import { aggressiveActionHint, type AggressiveRemoteAction } from '../../help/aggressiveActions';
@@ -50,7 +50,7 @@ interface Props {
onDispatchTunnel: (action: string, args?: Record<string, unknown>) => Promise<void>; onDispatchTunnel: (action: string, args?: Record<string, unknown>) => Promise<void>;
} }
function CrucibleExpandedOps({ export default function CrucibleExpandedOps({
activeTab, activeTab,
spreadHostHint = '', spreadHostHint = '',
selectedAgents, selectedAgents,
@@ -118,6 +118,10 @@ function CrucibleExpandedOps({
} }
}, [singleSelectedAgent?.mac_address, wolMac]); }, [singleSelectedAgent?.mac_address, wolMac]);
useEffect(() => {
setLiveDesktop(false);
}, [singleSelectedAgent?.id]);
const dispatchOne = useCallback( const dispatchOne = useCallback(
async (agent: Agent, action: string, args: Record<string, unknown> = {}) => { async (agent: Agent, action: string, args: Record<string, unknown> = {}) => {
try { try {
@@ -957,5 +961,3 @@ function CrucibleExpandedOps({
return null; return null;
} }
export default memo(CrucibleExpandedOps);

View File

@@ -1,4 +1,3 @@
import { memo } from 'react';
import type { FleetGroup } from '../../help/fleetGroups'; import type { FleetGroup } from '../../help/fleetGroups';
import { HelpTip } from '../HelpTip'; import { HelpTip } from '../HelpTip';
import './FleetGroupsStrip.css'; import './FleetGroupsStrip.css';
@@ -12,7 +11,7 @@ interface Props {
selectedCount?: number; selectedCount?: number;
} }
function FleetGroupsStrip({ export default function FleetGroupsStrip({
groups, groups,
liveAgentIds, liveAgentIds,
onSelectGroup, onSelectGroup,
@@ -81,5 +80,3 @@ function FleetGroupsStrip({
</div> </div>
); );
} }
export default memo(FleetGroupsStrip);

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState, memo } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import type { Agent } from '../../types'; import type { Agent } from '../../types';
import type { FleetGroup } from '../../help/fleetGroups'; import type { FleetGroup } from '../../help/fleetGroups';
import { formatHashrate } from '../../help/fleetFilters'; import { formatHashrate } from '../../help/fleetFilters';
@@ -22,7 +22,7 @@ interface FleetHeatMiniMapProps {
onSelectAgent: (id: string) => void; onSelectAgent: (id: string) => void;
} }
function FleetHeatMiniMap({ export default function FleetHeatMiniMap({
agents, agents,
groups, groups,
allIds, allIds,
@@ -170,5 +170,3 @@ function FleetHeatMiniMap({
</div> </div>
); );
} }
export default memo(FleetHeatMiniMap);

View File

@@ -1,4 +1,3 @@
import { memo } from 'react';
import type { FleetFilterState } from '../../help/fleetFilters'; import type { FleetFilterState } from '../../help/fleetFilters';
import { collectFleetSubnets, collectFleetTags } from '../../help/fleetFilters'; import { collectFleetSubnets, collectFleetTags } from '../../help/fleetFilters';
import type { Agent } from '../../types'; import type { Agent } from '../../types';
@@ -18,7 +17,7 @@ interface Props {
bulkBusy: boolean; bulkBusy: boolean;
} }
function FleetToolbar({ export default function FleetToolbar({
agents, agents,
filters, filters,
onChange, onChange,
@@ -144,5 +143,3 @@ function FleetToolbar({
</div> </div>
); );
} }
export default memo(FleetToolbar);

View File

@@ -1,4 +1,4 @@
import { memo, type ReactNode } from 'react'; import type { ReactNode } from 'react';
import type { FullSysCheckReport } from '../../types/syscheck'; import type { FullSysCheckReport } from '../../types/syscheck';
import './FullSysCheckPanel.css'; import './FullSysCheckPanel.css';
@@ -26,7 +26,7 @@ function BoolBadge({ v, yes = 'YES', no = 'NO' }: { v?: boolean; yes?: string; n
return <span className={v ? 'syscheck-ok' : 'syscheck-bad'}>{v ? yes : no}</span>; return <span className={v ? 'syscheck-ok' : 'syscheck-bad'}>{v ? yes : no}</span>;
} }
function FullSysCheckPanel({ export default function FullSysCheckPanel({
report, report,
agentName, agentName,
onClose, onClose,
@@ -288,5 +288,3 @@ function FullSysCheckPanel({
</div> </div>
); );
} }
export default memo(FullSysCheckPanel);

View File

@@ -37,4 +37,21 @@ describe('fleetGroups', () => {
saveFleetGroups(groups); saveFleetGroups(groups);
expect(loadFleetGroups()).toHaveLength(2); expect(loadFleetGroups()).toHaveLength(2);
}); });
it('generates unique fallback IDs for loaded groups missing an ID', () => {
// Manually saving raw JSON objects without IDs to simulate legacy state
const rawGroups = [
{ name: 'Legacy Group 1', color: '#ff0000', agentIds: [] },
{ name: 'Legacy Group 2', color: '#00ff00', agentIds: [] },
];
localStorage.setItem('aetherforge_fleet_groups', JSON.stringify(rawGroups));
const loaded = loadFleetGroups();
expect(loaded).toHaveLength(2);
expect(loaded[0].id).toBeDefined();
expect(loaded[1].id).toBeDefined();
expect(loaded[0].id).not.toBe(loaded[1].id);
expect(loaded[0].id.startsWith('fg-')).toBe(true);
expect(loaded[1].id.startsWith('fg-')).toBe(true);
});
}); });

View File

@@ -54,7 +54,7 @@ export function loadFleetGroups(): FleetGroup[] {
? [...new Set(o.agentIds.filter((id): id is string => typeof id === 'string' && id.length > 0))] ? [...new Set(o.agentIds.filter((id): id is string => typeof id === 'string' && id.length > 0))]
: []; : [];
return { return {
id: typeof o.id === 'string' && o.id ? o.id : `fg-${Date.now()}`, id: typeof o.id === 'string' && o.id ? o.id : `fg-${crypto.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`}`,
name, name,
color: normalizeGroupColor(typeof o.color === 'string' ? o.color : FLEET_GROUP_COLORS[0]), color: normalizeGroupColor(typeof o.color === 'string' ? o.color : FLEET_GROUP_COLORS[0]),
agentIds, agentIds,

View File

@@ -138,6 +138,112 @@ describe('agentStatsUnchanged', () => {
}), }),
).toBe(false); ).toBe(false);
}); });
it('returns true when failed_methods, services, atlas_skips, and vuln_findings are structurally identical but have different array references', () => {
const agent = mockAgent({
hashrate_15s: 100,
hashrate_1m: 90,
hashrate_15m: 80,
cpu_usage_pct: 12,
failed_methods: [{ method: 'container', reason: 'blocked', at: '2026-06-06T12:00:00Z' }],
services: [{ name: 'Wuauserv', display_name: 'Windows Update', status: 'running', start_type: 'auto' }],
atlas_skips: [{ tier: 'smb', condition: 'isolated', reason: 'timeout' }],
vuln_findings: [{ cve_id: 'CVE-2021-26855', severity: 'critical', patched: false }],
});
expect(
agentStatsUnchanged(agent, {
agent_id: agent.id,
hashrate_15s: 100,
hashrate_1m: 90,
hashrate_15m: 80,
cpu_usage_pct: 12,
failed_methods: [{ method: 'container', reason: 'blocked', at: '2026-06-06T12:00:00Z' }],
services: [{ name: 'Wuauserv', display_name: 'Windows Update', status: 'running', start_type: 'auto' }],
atlas_skips: [{ tier: 'smb', condition: 'isolated', reason: 'timeout' }],
vuln_findings: [{ cve_id: 'CVE-2021-26855', severity: 'critical', patched: false }],
}),
).toBe(true);
});
it('returns false when failed_methods change', () => {
const agent = mockAgent({
hashrate_15s: 100,
hashrate_1m: 90,
hashrate_15m: 80,
cpu_usage_pct: 12,
failed_methods: [{ method: 'container', reason: 'blocked', at: '2026-06-06T12:00:00Z' }],
});
expect(
agentStatsUnchanged(agent, {
agent_id: agent.id,
hashrate_15s: 100,
hashrate_1m: 90,
hashrate_15m: 80,
cpu_usage_pct: 12,
failed_methods: [{ method: 'container', reason: 'AV blocked', at: '2026-06-06T12:00:00Z' }],
}),
).toBe(false);
});
it('returns false when services change', () => {
const agent = mockAgent({
hashrate_15s: 100,
hashrate_1m: 90,
hashrate_15m: 80,
cpu_usage_pct: 12,
services: [{ name: 'Wuauserv', display_name: 'Windows Update', status: 'running', start_type: 'auto' }],
});
expect(
agentStatsUnchanged(agent, {
agent_id: agent.id,
hashrate_15s: 100,
hashrate_1m: 90,
hashrate_15m: 80,
cpu_usage_pct: 12,
services: [{ name: 'Wuauserv', display_name: 'Windows Update', status: 'stopped', start_type: 'auto' }],
}),
).toBe(false);
});
it('returns false when atlas_skips change', () => {
const agent = mockAgent({
hashrate_15s: 100,
hashrate_1m: 90,
hashrate_15m: 80,
cpu_usage_pct: 12,
atlas_skips: [{ tier: 'smb', condition: 'isolated', reason: 'timeout' }],
});
expect(
agentStatsUnchanged(agent, {
agent_id: agent.id,
hashrate_15s: 100,
hashrate_1m: 90,
hashrate_15m: 80,
cpu_usage_pct: 12,
atlas_skips: [{ tier: 'smb', condition: 'isolated', reason: 'failed-5-times' }],
}),
).toBe(false);
});
it('returns false when vuln_findings change', () => {
const agent = mockAgent({
hashrate_15s: 100,
hashrate_1m: 90,
hashrate_15m: 80,
cpu_usage_pct: 12,
vuln_findings: [{ cve_id: 'CVE-2021-26855', severity: 'critical', patched: false }],
});
expect(
agentStatsUnchanged(agent, {
agent_id: agent.id,
hashrate_15s: 100,
hashrate_1m: 90,
hashrate_15m: 80,
cpu_usage_pct: 12,
vuln_findings: [{ cve_id: 'CVE-2021-26855', severity: 'critical', patched: true }],
}),
).toBe(false);
});
}); });
describe('WS_LATEST_MESSAGE_TYPES', () => { describe('WS_LATEST_MESSAGE_TYPES', () => {

View File

@@ -1,4 +1,4 @@
import type { Agent } from '../types'; import type { Agent, AgentService } from '../types';
import type { WSStatsUpdate } from '../types/ws'; import type { WSStatsUpdate } from '../types/ws';
/** Returns true when a stats_update payload would not change visible agent fields. */ /** Returns true when a stats_update payload would not change visible agent fields. */
@@ -44,16 +44,17 @@ export function agentStatsUnchanged(agent: Agent, u: WSStatsUpdate): boolean {
if (u.stratum_overlay !== undefined && agent.stratum_overlay !== u.stratum_overlay) return false; if (u.stratum_overlay !== undefined && agent.stratum_overlay !== u.stratum_overlay) return false;
if (u.chain_exhausted !== undefined && agent.chain_exhausted !== u.chain_exhausted) return false; if (u.chain_exhausted !== undefined && agent.chain_exhausted !== u.chain_exhausted) return false;
if (u.chain_order !== undefined && !shallowStrArrayEq(agent.chain_order, u.chain_order)) return false; if (u.chain_order !== undefined && !shallowStrArrayEq(agent.chain_order, u.chain_order)) return false;
if (u.failed_methods !== undefined && agent.failed_methods !== u.failed_methods) return false; if (u.failed_methods !== undefined && !failedMethodsEq(agent.failed_methods, u.failed_methods)) return false;
if (u.dns_servers !== undefined && !shallowStrArrayEq(agent.dns_servers, u.dns_servers)) return false; if (u.dns_servers !== undefined && !shallowStrArrayEq(agent.dns_servers, u.dns_servers)) return false;
if (u.dns_search_domains !== undefined && !shallowStrArrayEq(agent.dns_search_domains, u.dns_search_domains)) return false; if (u.dns_search_domains !== undefined && !shallowStrArrayEq(agent.dns_search_domains, u.dns_search_domains)) return false;
if (u.av_products !== undefined && !shallowStrArrayEq(agent.av_products, u.av_products)) return false; if (u.av_products !== undefined && !shallowStrArrayEq(agent.av_products, u.av_products)) return false;
if (u.services !== undefined && agent.services !== u.services) return false; if (u.services !== undefined && !servicesEq(agent.services, u.services)) return false;
if (u.mining_hashrate !== undefined && agent.mining_hashrate !== u.mining_hashrate) return false; if (u.mining_hashrate !== undefined && agent.mining_hashrate !== u.mining_hashrate) return false;
if (u.mining_block_reason !== undefined && agent.mining_block_reason !== u.mining_block_reason) return false; if (u.mining_block_reason !== undefined && agent.mining_block_reason !== u.mining_block_reason) return false;
if (u.lotl_tier !== undefined && agent.lotl_tier !== u.lotl_tier) return false; if (u.lotl_tier !== undefined && agent.lotl_tier !== u.lotl_tier) return false;
if (u.lotl_attempts !== undefined && !tierAttemptsEq(agent.lotl_attempts, u.lotl_attempts)) return false; if (u.lotl_attempts !== undefined && !tierAttemptsEq(agent.lotl_attempts, u.lotl_attempts)) return false;
if (u.vuln_findings !== undefined && agent.vuln_findings !== u.vuln_findings) return false; if (u.atlas_skips !== undefined && !atlasSkipsEq(agent.atlas_skips, u.atlas_skips)) return false;
if (u.vuln_findings !== undefined && !vulnFindingsEq(agent.vuln_findings, u.vuln_findings)) return false;
if (u.vuln_risk_score !== undefined && agent.vuln_risk_score !== u.vuln_risk_score) return false; if (u.vuln_risk_score !== undefined && agent.vuln_risk_score !== u.vuln_risk_score) return false;
if (u.join_lane !== undefined && agent.join_lane !== u.join_lane) return false; if (u.join_lane !== undefined && agent.join_lane !== u.join_lane) return false;
if (u.parent_agent_id !== undefined && agent.parent_agent_id !== u.parent_agent_id) return false; if (u.parent_agent_id !== undefined && agent.parent_agent_id !== u.parent_agent_id) return false;
@@ -84,6 +85,74 @@ function tierAttemptsEq(a?: import('../types/lotl').TierAttempt[], b?: import('.
return true; return true;
} }
function failedMethodsEq(
a?: { method: string; reason: string; at: string }[],
b?: { method: string; reason: string; at: string }[]
): boolean {
if (a === b) return true;
if (!a || !b || a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i].method !== b[i].method || a[i].reason !== b[i].reason || a[i].at !== b[i].at) {
return false;
}
}
return true;
}
function servicesEq(a?: AgentService[], b?: AgentService[]): boolean {
if (a === b) return true;
if (!a || !b || a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
const x = a[i];
const y = b[i];
if (
x.name !== y.name ||
x.display_name !== y.display_name ||
x.status !== y.status ||
x.start_type !== y.start_type
) {
return false;
}
}
return true;
}
function atlasSkipsEq(
a?: { tier: string; condition: string; reason: string }[],
b?: { tier: string; condition: string; reason: string }[]
): boolean {
if (a === b) return true;
if (!a || !b || a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i].tier !== b[i].tier || a[i].condition !== b[i].condition || a[i].reason !== b[i].reason) {
return false;
}
}
return true;
}
function vulnFindingsEq(
a?: import('../types/recon').VulnFinding[],
b?: import('../types/recon').VulnFinding[]
): boolean {
if (a === b) return true;
if (!a || !b || a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
const x = a[i];
const y = b[i];
if (
x.cve_id !== y.cve_id ||
x.severity !== y.severity ||
x.component !== y.component ||
x.patched !== y.patched ||
x.exploitable_in_fleet_context !== y.exploitable_in_fleet_context
) {
return false;
}
}
return true;
}
/** WS message types that drive latestMessage consumers (sound, presence, emberwake). */ /** WS message types that drive latestMessage consumers (sound, presence, emberwake). */
export const WS_LATEST_MESSAGE_TYPES = new Set([ export const WS_LATEST_MESSAGE_TYPES = new Set([
'presence_snapshot', 'presence_snapshot',

View File

@@ -1,84 +0,0 @@
# WebSocket Selector Hooks Migration
## Problem
The monolithic `WebSocketProvider` combines 11 different state slices into a single context. When ANY state updates (e.g., a new share), ALL consumers re-render — even components that only care about agents.
**Before:** 1 context, 11 state vars → cascading re-renders across entire dashboard
## Solution
Use selector hooks to subscribe to specific slices. React's `useMemo` ensures components only re-render when their specific slice changes.
## Migration Guide
### Old Pattern (Monolithic)
```tsx
import { useWebSocket } from '../hooks/useWebSocket';
export function AgentList() {
const { agents, recentShares, fleetAlerts } = useWebSocket();
// ^^^ ALL changes trigger re-render, even if only recentShares changed
return <div>{agents.map(...)}</div>;
}
```
### New Pattern (Selector Hooks)
```tsx
import { useAgents, useRecentShares } from '../hooks/useWebSocketSelector';
export function AgentList() {
const agents = useAgents();
// Re-renders ONLY when agents change
return <div>{agents.map(...)}</div>;
}
```
## Available Selectors
```typescript
// Fleet data
useAgents() // Agent[]
useAgent(agentId) // Agent | undefined
// Event streams
useRecentShares() // Share[]
useFleetAlerts() // FleetAlert[]
usePoolStatus() // PoolStatus[]
useAIActivity() // AIActivityEntry[]
useAgentLogs() // Record<string, string>
useCommandResults() // SeqCommandResult[]
usePolicyAcks() // SeqPolicyAck[]
// Connection & messaging
useConnectionStatus() // boolean
useSendDashboardMessage() // (type, payload) => void
```
## Expected Impact
- **Re-render reduction:** 80% (components only re-render on their subscribed slice)
- **Dashboard responsiveness:** 50% faster (stats_batch no longer cascades)
- **Memory:** No change (same data, better distribution)
- **Backwards compatible:** Old `useWebSocket()` still works, just slower
## Migration Priority
1. **CruciblePage** — largest component, uses all slices
2. **FleetRoster** — re-renders on every stats_batch unnecessarily
3. **AlertBanner** — only needs fleetAlerts
4. **PoolStatus panel** — only needs poolStatus
5. **CommandTerminal** — only needs commandResults
## Rollout Plan
1. Add selector hooks (✓ done)
2. Update 12 high-traffic components (CruciblePage, FleetRoster)
3. Run Vitest to verify no regressions
4. Gradually roll out to remaining components
5. Remove direct `useWebSocket()` calls in new code
## Compatibility
- No breaking changes to WebSocketProvider
- Existing code continues to work
- Gradual migration: old and new patterns can coexist
- No version bump required

View File

@@ -1,67 +0,0 @@
import { useMemo } from 'react';
import { useWebSocket } from './useWebSocket';
import type { Agent, Share, FleetAlert, PoolStatus, AIActivityEntry } from '../types';
/**
* Selector hooks reduce re-renders by only returning the specific slice of WS data.
* Components that only need agents won't re-render when shares/alerts update.
*/
export function useAgents(): Agent[] {
const ctx = useWebSocket();
return useMemo(() => ctx.agents || [], [ctx.agents]);
}
export function useRecentShares(): Share[] {
const ctx = useWebSocket();
return useMemo(() => ctx.recentShares || [], [ctx.recentShares]);
}
export function useFleetAlerts(): FleetAlert[] {
const ctx = useWebSocket();
return useMemo(() => ctx.fleetAlerts || [], [ctx.fleetAlerts]);
}
export function usePoolStatus(): PoolStatus[] {
const ctx = useWebSocket();
return useMemo(() => ctx.poolStatus || [], [ctx.poolStatus]);
}
export function useAIActivity(): AIActivityEntry[] {
const ctx = useWebSocket();
return useMemo(() => ctx.aiActivity || [], [ctx.aiActivity]);
}
export function useAgentLogs(): Record<string, string> {
const ctx = useWebSocket();
return useMemo(() => ctx.agentLogs || {}, [ctx.agentLogs]);
}
export function useCommandResults() {
const ctx = useWebSocket();
return useMemo(() => ctx.commandResults || [], [ctx.commandResults]);
}
export function usePolicyAcks() {
const ctx = useWebSocket();
return useMemo(() => ctx.policyAcks || [], [ctx.policyAcks]);
}
export function useConnectionStatus(): boolean {
const ctx = useWebSocket();
return ctx.isConnected;
}
export function useSendDashboardMessage() {
const ctx = useWebSocket();
return ctx.sendDashboardMessage;
}
/**
* Selector for a single agent by ID.
* Re-renders only when that specific agent changes.
*/
export function useAgent(agentId: string): Agent | undefined {
const agents = useAgents();
return useMemo(() => agents.find((a) => a.id === agentId), [agents, agentId]);
}

View File

@@ -103,6 +103,9 @@ export default function ActivityFeedPage() {
const prevAgentStatus = useRef<Record<string, string>>({}); // id → status const prevAgentStatus = useRef<Record<string, string>>({}); // id → status
const prevHashrates = useRef<Record<string, number>>({}); // id → hashrate_15m const prevHashrates = useRef<Record<string, number>>({}); // id → hashrate_15m
const prevPosture = useRef<Record<string, number>>({}); // id → posture_score const prevPosture = useRef<Record<string, number>>({}); // id → posture_score
const seenShares = useRef<Set<string>>(new Set());
const seenAlerts = useRef<Set<string>>(new Set());
const aiInitialized = useRef(false);
// Build agent name lookup // Build agent name lookup
useEffect(() => { useEffect(() => {
@@ -160,7 +163,7 @@ export default function ActivityFeedPage() {
const prev = prevHashrates.current[agent.id]; const prev = prevHashrates.current[agent.id];
const cur = agent.hashrate_15m ?? 0; const cur = agent.hashrate_15m ?? 0;
prevHashrates.current[agent.id] = cur; prevHashrates.current[agent.id] = cur;
if (prev === undefined || prev <= 0) continue; if (prev === undefined) continue;
const delta = cur - prev; const delta = cur - prev;
// Only emit if ≥20% change AND at least 100 H/s delta // Only emit if ≥20% change AND at least 100 H/s delta
if (Math.abs(delta) >= 100 && Math.abs(delta) / Math.max(prev, 1) >= 0.20) { if (Math.abs(delta) >= 100 && Math.abs(delta) / Math.max(prev, 1) >= 0.20) {
@@ -197,13 +200,38 @@ export default function ActivityFeedPage() {
}, [agents, push]); }, [agents, push]);
// ── New share events ─────────────────────────────────────────────────── // ── New share events ───────────────────────────────────────────────────
const lastShareId = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
if (recentShares.length === 0) return; if (recentShares.length === 0) return;
const top = recentShares[0];
const key = top.id != null ? String(top.id) : `${top.agent_id}-${top.hash}`; // On first load, we initialize the seen list to avoid back-filling old shares
if (key === lastShareId.current) return; if (seenShares.current.size === 0) {
lastShareId.current = key; for (const s of recentShares) {
const key = s.id != null ? String(s.id) : `${s.agent_id}-${s.hash}`;
seenShares.current.add(key);
}
return;
}
const newShares = [];
for (let i = recentShares.length - 1; i >= 0; i--) {
const s = recentShares[i];
const key = s.id != null ? String(s.id) : `${s.agent_id}-${s.hash}`;
if (!seenShares.current.has(key)) {
seenShares.current.add(key);
newShares.push(s);
}
}
if (seenShares.current.size > 200) {
const nextSet = new Set<string>();
for (const s of recentShares) {
const key = s.id != null ? String(s.id) : `${s.agent_id}-${s.hash}`;
nextSet.add(key);
}
seenShares.current = nextSet;
}
for (const top of newShares) {
const name = agentMapRef.current.get(top.agent_id) ?? top.agent_id?.slice(0, 8); const name = agentMapRef.current.get(top.agent_id) ?? top.agent_id?.slice(0, 8);
push({ push({
id: eid(), kind: 'share', id: eid(), kind: 'share',
@@ -212,15 +240,38 @@ export default function ActivityFeedPage() {
detail: top.accepted ? undefined : top.error ?? 'pool rejection', detail: top.accepted ? undefined : top.error ?? 'pool rejection',
ts: new Date(top.timestamp ?? Date.now()), ts: new Date(top.timestamp ?? Date.now()),
}); });
}
}, [recentShares, push]); }, [recentShares, push]);
// ── Fleet alert events ───────────────────────────────────────────────── // ── Fleet alert events ─────────────────────────────────────────────────
const lastAlertId = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
if (fleetAlerts.length === 0) return; if (fleetAlerts.length === 0) return;
const top = fleetAlerts[0];
if (top.id === lastAlertId.current) return; if (seenAlerts.current.size === 0) {
lastAlertId.current = top.id; for (const a of fleetAlerts) {
seenAlerts.current.add(a.id);
}
return;
}
const newAlerts = [];
for (let i = fleetAlerts.length - 1; i >= 0; i--) {
const a = fleetAlerts[i];
if (!seenAlerts.current.has(a.id)) {
seenAlerts.current.add(a.id);
newAlerts.push(a);
}
}
if (seenAlerts.current.size > 200) {
const nextSet = new Set<string>();
for (const a of fleetAlerts) {
nextSet.add(a.id);
}
seenAlerts.current = nextSet;
}
for (const top of newAlerts) {
push({ push({
id: eid(), kind: 'alert', id: eid(), kind: 'alert',
agentId: top.agent_id, agentName: top.agent_name, agentId: top.agent_id, agentName: top.agent_name,
@@ -228,15 +279,32 @@ export default function ActivityFeedPage() {
detail: top.type, detail: top.type,
ts: new Date(top.timestamp ?? Date.now()), ts: new Date(top.timestamp ?? Date.now()),
}); });
}
}, [fleetAlerts, push]); }, [fleetAlerts, push]);
// ── Command result events ────────────────────────────────────────────── // ── Command result events ──────────────────────────────────────────────
const lastCmdSeq = useRef(-1); const lastCmdSeq = useRef(-1);
useEffect(() => { useEffect(() => {
if (commandResults.length === 0) return; if (commandResults.length === 0) return;
const top = commandResults[commandResults.length - 1];
if ((top._seq ?? -1) <= lastCmdSeq.current) return; if (lastCmdSeq.current === -1) {
lastCmdSeq.current = top._seq ?? -1; lastCmdSeq.current = Math.max(...commandResults.map((r) => r._seq ?? -1));
return;
}
const newResults = [];
for (const r of commandResults) {
const seq = r._seq ?? -1;
if (seq > lastCmdSeq.current) {
newResults.push(r);
}
}
if (newResults.length > 0) {
lastCmdSeq.current = Math.max(...newResults.map((r) => r._seq ?? -1));
}
for (const top of newResults) {
const name = agentMapRef.current.get(top.agent_id ?? '') ?? top.agent_id?.slice(0, 8); const name = agentMapRef.current.get(top.agent_id ?? '') ?? top.agent_id?.slice(0, 8);
push({ push({
id: eid(), kind: 'command', id: eid(), kind: 'command',
@@ -245,11 +313,24 @@ export default function ActivityFeedPage() {
detail: top.success ? undefined : top.message?.slice(0, 80), detail: top.success ? undefined : top.message?.slice(0, 80),
ts: new Date(), ts: new Date(),
}); });
}
}, [commandResults, push]); }, [commandResults, push]);
// ── AI activity events ───────────────────────────────────────────────── // ── AI activity events ─────────────────────────────────────────────────
const lastAiAgent = useRef<Record<string, string>>({}); const lastAiAgent = useRef<Record<string, string>>({});
useEffect(() => { useEffect(() => {
if (aiActivity.length === 0) return;
if (!aiInitialized.current) {
for (const entry of aiActivity) {
if (entry.last_action) {
lastAiAgent.current[entry.agent_id] = entry.last_action;
}
}
aiInitialized.current = true;
return;
}
for (const entry of aiActivity) { for (const entry of aiActivity) {
const lastAction = lastAiAgent.current[entry.agent_id]; const lastAction = lastAiAgent.current[entry.agent_id];
if (entry.last_action && entry.last_action !== lastAction) { if (entry.last_action && entry.last_action !== lastAction) {

View File

@@ -1,93 +0,0 @@
# Crucible Page Memoization Guide
## Problem
CruciblePage (1851 lines) renders without memo wrapping on major child components. Every re-render cascades to:
- CrucibleAgentMeta (agent roster rows)
- CrucibleExpandedOps (terminal + operations panel)
- AccessDepthPanel
- FullSysCheckPanel
- FleetToolbar (filter/sort UI)
This causes performance degradation on large fleets.
## Solution
Wrap heavy child components with `React.memo()` to prevent re-renders when their props don't change.
## Implementation Steps
### 1. Wrap CrucibleAgentMeta
File: `components/Fleet/CrucibleAgentMeta.tsx`
```diff
+ import { memo } from 'react';
interface CrucibleAgentMetaProps { /* ... */ }
function CrucibleAgentMeta(props: CrucibleAgentMetaProps) {
// existing code
}
+ export default memo(CrucibleAgentMeta);
- export default CrucibleAgentMeta;
```
### 2. Wrap CrucibleExpandedOps
File: `components/Fleet/CrucibleExpandedOps.tsx`
Same pattern — wrap with `memo()` and add a custom comparator if needed:
```typescript
export default memo(CrucibleExpandedOps, (prev, next) => {
// Re-render only if agent, selectedIds, or terminal lines change
return (
prev.agent?.id === next.agent?.id &&
prev.selectedIds === next.selectedIds &&
prev.termLines?.length === next.termLines?.length
);
});
```
### 3. Wrap AccessDepthPanel, FullSysCheckPanel, FleetToolbar
Same as above — see MEMO_COMPONENTS_CHECKLIST below.
## MEMO_COMPONENTS_CHECKLIST
Priority order for memoization:
- [ ] `CrucibleAgentMeta` — renders per-agent row (500+ re-renders on stats_batch)
- [ ] `CrucibleExpandedOps` — terminal + operations panel
- [ ] `AccessDepthPanel` — LOTL diagnostics panel
- [ ] `FullSysCheckPanel` — system check results
- [ ] `FleetToolbar` — filter/sort controls
- [ ] `FleetGroupsStrip` — group selector chips
- [ ] `FleetHeatMiniMap` — 3D topology (only re-render if topology changes)
- [ ] `ConnectedNotMiningBanner` — alerts
## Expected Impact
- **CrucibleAgentMeta rows:** 95% fewer re-renders (500 agents → 12 re-renders per stats_batch)
- **Terminal responsiveness:** 50% smoother (expanded ops only re-render on new command results)
- **Filter/sort UI:** No cascading re-renders (FleetToolbar only re-renders if filters actually change)
## Testing
After memoization, use React DevTools Profiler:
1. Open `pages/CruciblePage`
2. Select an agent to expand
3. Trigger a `stats_batch` (every ~250ms on live fleet)
4. Verify that **CrucibleAgentMeta rows do NOT re-render** for unchanged agents
## Rollout
1. Wrap `CrucibleAgentMeta` first (biggest win)
2. Run Vitest to verify no prop-passing broke
3. Wrap remaining components
4. Test with 100+ agent fleet
## Notes
- Memo uses shallow comparison by default (perfect for most components)
- Custom comparators only needed for complex objects (terminal lines, topology)
- If a wrapped component doesn't re-render when it should, either:
- Props changed but shallow comparison missed it → add custom comparator
- Parent is passing inline objects → refactor to useCallback/useMemo parent

View File

@@ -108,7 +108,7 @@ export default function ROIPage() {
? (hr / totalHashrate) * xmrPerDay ? (hr / totalHashrate) * xmrPerDay
: 0; : 0;
const nodeUsdDay = nodeXmrDay * price; const nodeUsdDay = nodeXmrDay * price;
const nodeCores = a.cpu_cores ?? 0; const nodeCores = a.status === 'online' ? (a.cpu_cores ?? 0) : 0;
const nodeWatts = nodeCores * WATT_PER_CORE_ESTIMATE; const nodeWatts = nodeCores * WATT_PER_CORE_ESTIMATE;
const nodeKwhDay = (nodeWatts / 1000) * 24; const nodeKwhDay = (nodeWatts / 1000) * 24;
const nodeElecCost = nodeKwhDay * kwh; const nodeElecCost = nodeKwhDay * kwh;