feat(sync): replace WS+DO transport with unified HTTP sync
Replace the WebSocket + Cloudflare Durable Object architecture with a single POST /sync endpoint. The CLI now operates autonomously with local state (tasks.json) and syncs bidirectionally via adaptive-interval HTTP polling (3s watching, 60s idle). - Remove transport_ws, transport_hybrid, transport_http (~2,600 lines) - Add SyncClient with adaptive interval loop - Add LocalState for CLI-side task persistence - Add TaskStateFromUpdate() helper (DRY) - Extract finalize() to deduplicate processTask/processTaskRetry - Consolidate shortID() into agent.ShortID (was in 3 packages) - Wire GetActiveCount so `unarr status` shows active tasks - Remove poll_interval, heartbeat_interval, ws_url from config - Simplify ProgressReporter (sync replaces direct HTTP reporting)
This commit is contained in:
parent
2398707cc1
commit
5d4a67c7a2
26 changed files with 1320 additions and 3400 deletions
195
internal/agent/sync.go
Normal file
195
internal/agent/sync.go
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"runtime"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// SyncIntervalWatching is the sync interval when someone is viewing the web UI.
|
||||
SyncIntervalWatching = 3 * time.Second
|
||||
// SyncIntervalIdle is the sync interval when nobody is watching.
|
||||
SyncIntervalIdle = 60 * time.Second
|
||||
)
|
||||
|
||||
// SyncClient handles bidirectional state synchronization between the CLI and server.
|
||||
// It sends the CLI's full execution state and receives all pending server actions
|
||||
// in a single HTTP round-trip, at an adaptive interval.
|
||||
type SyncClient struct {
|
||||
client *Client
|
||||
cfg DaemonConfig
|
||||
state *LocalState
|
||||
|
||||
// Callbacks — set by the daemon before calling Run.
|
||||
OnNewTasks func(tasks []Task)
|
||||
OnControl func(action, taskID string, deleteFiles bool)
|
||||
OnStreamRequest func(req StreamRequest)
|
||||
OnUpgrade func(version string)
|
||||
OnScan func()
|
||||
OnWatchingChange func(watching bool)
|
||||
OnSyncSuccess func() // called after each successful sync (e.g. to update state file)
|
||||
GetFreeSlots func() int
|
||||
GetTaskStates func() []TaskState // returns current state of all active + recently finished tasks
|
||||
|
||||
// SyncNow triggers an immediate sync (e.g., on task completion).
|
||||
SyncNow chan struct{}
|
||||
|
||||
watching atomic.Bool
|
||||
interval atomic.Int64 // stored as nanoseconds
|
||||
}
|
||||
|
||||
// NewSyncClient creates a sync client.
|
||||
func NewSyncClient(client *Client, cfg DaemonConfig, state *LocalState) *SyncClient {
|
||||
sc := &SyncClient{
|
||||
client: client,
|
||||
cfg: cfg,
|
||||
state: state,
|
||||
SyncNow: make(chan struct{}, 1),
|
||||
}
|
||||
sc.interval.Store(int64(SyncIntervalIdle))
|
||||
return sc
|
||||
}
|
||||
|
||||
// Watching returns whether someone is viewing the web UI.
|
||||
func (sc *SyncClient) Watching() bool {
|
||||
return sc.watching.Load()
|
||||
}
|
||||
|
||||
// TriggerSync requests an immediate sync cycle.
|
||||
func (sc *SyncClient) TriggerSync() {
|
||||
select {
|
||||
case sc.SyncNow <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Run starts the adaptive sync loop. Blocks until ctx is cancelled.
|
||||
func (sc *SyncClient) Run(ctx context.Context) error {
|
||||
// Initial sync immediately
|
||||
sc.doSync(ctx)
|
||||
|
||||
ticker := time.NewTicker(sc.currentInterval())
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Final sync to report latest state
|
||||
finalCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
sc.doSync(finalCtx)
|
||||
return nil
|
||||
|
||||
case <-ticker.C:
|
||||
sc.doSync(ctx)
|
||||
ticker.Reset(sc.currentInterval())
|
||||
|
||||
case <-sc.SyncNow:
|
||||
sc.doSync(ctx)
|
||||
ticker.Reset(sc.currentInterval())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (sc *SyncClient) currentInterval() time.Duration {
|
||||
return time.Duration(sc.interval.Load())
|
||||
}
|
||||
|
||||
func (sc *SyncClient) doSync(ctx context.Context) {
|
||||
req := sc.buildRequest()
|
||||
resp, err := sc.client.Sync(ctx, req)
|
||||
if err != nil {
|
||||
if ctx.Err() == nil {
|
||||
log.Printf("sync failed: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
sc.processResponse(resp)
|
||||
sc.adjustInterval(resp.Watching)
|
||||
if sc.OnSyncSuccess != nil {
|
||||
sc.OnSyncSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
func (sc *SyncClient) buildRequest() SyncRequest {
|
||||
req := SyncRequest{
|
||||
AgentID: sc.cfg.AgentID,
|
||||
Name: sc.cfg.AgentName,
|
||||
Version: sc.cfg.Version,
|
||||
OS: runtime.GOOS,
|
||||
Arch: runtime.GOARCH,
|
||||
DownloadDir: sc.cfg.DownloadDir,
|
||||
StreamPort: sc.cfg.StreamPort,
|
||||
LanIP: sc.cfg.LanIP,
|
||||
TailscaleIP: sc.cfg.TailscaleIP,
|
||||
}
|
||||
if sc.GetTaskStates != nil {
|
||||
req.Tasks = sc.GetTaskStates()
|
||||
} else {
|
||||
req.Tasks = sc.state.Snapshot()
|
||||
}
|
||||
if free, total, err := DiskInfo(sc.cfg.DownloadDir); err == nil {
|
||||
req.DiskFreeBytes = free
|
||||
req.DiskTotalBytes = total
|
||||
}
|
||||
if sc.GetFreeSlots != nil {
|
||||
req.FreeSlots = sc.GetFreeSlots()
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func (sc *SyncClient) processResponse(resp *SyncResponse) {
|
||||
// New tasks
|
||||
if len(resp.NewTasks) > 0 && sc.OnNewTasks != nil {
|
||||
log.Printf("sync: received %d new task(s)", len(resp.NewTasks))
|
||||
sc.OnNewTasks(resp.NewTasks)
|
||||
}
|
||||
|
||||
// Control signals
|
||||
for _, ctrl := range resp.Controls {
|
||||
log.Printf("sync: control %s on task %s", ctrl.Action, ShortID(ctrl.TaskID))
|
||||
if sc.OnControl != nil {
|
||||
sc.OnControl(ctrl.Action, ctrl.TaskID, ctrl.DeleteFiles)
|
||||
}
|
||||
}
|
||||
|
||||
// Stream requests
|
||||
for _, sr := range resp.StreamRequests {
|
||||
if sc.OnStreamRequest != nil {
|
||||
sc.OnStreamRequest(sr)
|
||||
}
|
||||
}
|
||||
|
||||
// Upgrade
|
||||
if resp.Upgrade != nil && resp.Upgrade.Version != "" && sc.OnUpgrade != nil {
|
||||
sc.OnUpgrade(resp.Upgrade.Version)
|
||||
}
|
||||
|
||||
// Scan
|
||||
if resp.Scan && sc.OnScan != nil {
|
||||
sc.OnScan()
|
||||
}
|
||||
}
|
||||
|
||||
func (sc *SyncClient) adjustInterval(watching bool) {
|
||||
prev := sc.watching.Load()
|
||||
sc.watching.Store(watching)
|
||||
|
||||
var newInterval time.Duration
|
||||
if watching {
|
||||
newInterval = SyncIntervalWatching
|
||||
} else {
|
||||
newInterval = SyncIntervalIdle
|
||||
}
|
||||
|
||||
if sc.interval.Swap(int64(newInterval)) != int64(newInterval) {
|
||||
log.Printf("sync: interval=%s (watching=%v)", newInterval, watching)
|
||||
}
|
||||
|
||||
if prev != watching && sc.OnWatchingChange != nil {
|
||||
sc.OnWatchingChange(watching)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue