• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

umputun / ralphex / 21461163438

29 Jan 2026 12:25AM UTC coverage: 79.94% (+1.4%) from 78.528%
21461163438

Pull #36

github

melonamin
Improve watch-only dashboard UX
Pull Request #36: feat(web): interactive plan creation from web dashboard

808 of 932 new or added lines in 11 files covered. (86.7%)

334 existing lines in 3 files now uncovered.

4531 of 5668 relevant lines covered (79.94%)

54.44 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

83.03
/pkg/web/server.go
1
package web
2

3
import (
4
        "context"
5
        "embed"
6
        "encoding/json"
7
        "errors"
8
        "fmt"
9
        "html/template"
10
        "io/fs"
11
        "log"
12
        "net/http"
13
        "path/filepath"
14
        "sort"
15
        "strings"
16
        "sync"
17
        "time"
18
)
19

20
//go:embed templates static
21
var embeddedFS embed.FS
22

23
// ServerConfig holds configuration for the web server.
24
type ServerConfig struct {
25
        Port          int    // port to listen on
26
        PlanName      string // plan name to display in dashboard
27
        Branch        string // git branch name
28
        PlanFile      string // path to plan file for /api/plan endpoint
29
        WatchExplicit bool   // true when watch dirs were explicitly configured
30
}
31

32
// Server provides HTTP server for the real-time dashboard.
33
type Server struct {
34
        cfg     ServerConfig
35
        session *Session        // used for single-session mode (direct execution)
36
        sm      *SessionManager // used for multi-session mode (dashboard)
37
        srv     *http.Server
38
        tmpl    *template.Template
39

40
        // plan caching - set after first successful load (single-session mode)
41
        planMu    sync.Mutex
42
        planCache *Plan
43

44
        // planRunner for web-initiated plan creation (optional)
45
        planRunner *PlanRunner
46
}
47

48
// NewServer creates a new web server for single-session mode (direct execution).
49
// returns an error if the embedded template fails to parse.
50
func NewServer(cfg ServerConfig, session *Session) (*Server, error) {
27✔
51
        tmpl, err := template.ParseFS(embeddedFS, "templates/base.html")
27✔
52
        if err != nil {
27✔
53
                return nil, fmt.Errorf("parse template: %w", err)
×
54
        }
×
55

56
        return &Server{
27✔
57
                cfg:     cfg,
27✔
58
                session: session,
27✔
59
                tmpl:    tmpl,
27✔
60
        }, nil
27✔
61
}
62

63
// NewServerWithSessions creates a new web server for multi-session mode (dashboard).
64
// returns an error if the embedded template fails to parse.
65
func NewServerWithSessions(cfg ServerConfig, sm *SessionManager) (*Server, error) {
39✔
66
        tmpl, err := template.ParseFS(embeddedFS, "templates/base.html")
39✔
67
        if err != nil {
39✔
68
                return nil, fmt.Errorf("parse template: %w", err)
×
69
        }
×
70

71
        return &Server{
39✔
72
                cfg:  cfg,
39✔
73
                sm:   sm,
39✔
74
                tmpl: tmpl,
39✔
75
        }, nil
39✔
76
}
77

78
// Start begins listening for HTTP requests.
79
// blocks until the server is stopped or an error occurs.
80
func (s *Server) Start(ctx context.Context) error {
2✔
81
        mux := http.NewServeMux()
2✔
82

2✔
83
        // register routes
2✔
84
        mux.HandleFunc("/", s.handleIndex)
2✔
85
        mux.HandleFunc("/events", s.handleEvents)
2✔
86
        mux.HandleFunc("/api/plan", s.handlePlanDispatch)
2✔
87
        mux.HandleFunc("/api/plan/resume", s.handleResumePlan)
2✔
88
        mux.HandleFunc("/api/resumable", s.handleResumable)
2✔
89
        mux.HandleFunc("/api/sessions", s.handleSessions)
2✔
90
        mux.HandleFunc("/api/sessions/", s.handleSessionsSubpath)
2✔
91
        mux.HandleFunc("/api/answer", s.handleAnswer)
2✔
92
        mux.HandleFunc("/api/recent-dirs", s.handleRecentDirs)
2✔
93

2✔
94
        // static files
2✔
95
        staticFS, err := fs.Sub(embeddedFS, "static")
2✔
96
        if err != nil {
2✔
97
                return fmt.Errorf("static filesystem: %w", err)
×
98
        }
×
99
        mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
2✔
100

2✔
101
        s.srv = &http.Server{
2✔
102
                Addr:              fmt.Sprintf("127.0.0.1:%d", s.cfg.Port),
2✔
103
                Handler:           mux,
2✔
104
                ReadHeaderTimeout: 10 * time.Second,
2✔
105
        }
2✔
106

2✔
107
        // start shutdown listener
2✔
108
        go func() {
4✔
109
                <-ctx.Done()
2✔
110
                shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
2✔
111
                defer cancel()
2✔
112
                _ = s.srv.Shutdown(shutdownCtx)
2✔
113
        }()
2✔
114

115
        err = s.srv.ListenAndServe()
2✔
116
        if errors.Is(err, http.ErrServerClosed) {
4✔
117
                return nil
2✔
118
        }
2✔
119
        return fmt.Errorf("http server: %w", err)
×
120
}
121

122
// Stop gracefully shuts down the server.
123
func (s *Server) Stop() error {
4✔
124
        if s.srv == nil {
8✔
125
                return nil
4✔
126
        }
4✔
127
        ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
×
128
        defer cancel()
×
129
        if err := s.srv.Shutdown(ctx); err != nil {
×
130
                return fmt.Errorf("shutdown server: %w", err)
×
131
        }
×
132
        return nil
×
133
}
134

135
// Session returns the server's session (for single-session mode).
136
func (s *Server) Session() *Session {
2✔
137
        return s.session
2✔
138
}
2✔
139

140
// SetPlanRunner sets the plan runner for web-initiated plan creation.
141
func (s *Server) SetPlanRunner(runner *PlanRunner) {
11✔
142
        s.planRunner = runner
11✔
143
}
11✔
144

145
// handlePlanDispatch routes /api/plan requests based on method.
146
// GET requests go to handlePlan, POST requests go to handleStartPlan.
147
func (s *Server) handlePlanDispatch(w http.ResponseWriter, r *http.Request) {
5✔
148
        switch r.Method {
5✔
149
        case http.MethodGet:
1✔
150
                s.handlePlan(w, r)
1✔
151
        case http.MethodPost:
1✔
152
                s.handleStartPlan(w, r)
1✔
153
        default:
3✔
154
                w.Header().Set("Allow", "GET, POST")
3✔
155
                http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
3✔
156
        }
157
}
158

159
// handleSessionsSubpath routes /api/sessions/{id}/... requests.
160
func (s *Server) handleSessionsSubpath(w http.ResponseWriter, r *http.Request) {
4✔
161
        // extract session ID and action from path
4✔
162
        path := strings.TrimPrefix(r.URL.Path, "/api/sessions/")
4✔
163
        parts := strings.Split(path, "/")
4✔
164

4✔
165
        if len(parts) < 1 || parts[0] == "" {
5✔
166
                http.NotFound(w, r)
1✔
167
                return
1✔
168
        }
1✔
169

170
        sessionID := parts[0]
3✔
171

3✔
172
        if len(parts) == 2 && parts[1] == "cancel" {
4✔
173
                s.handleCancelSession(w, r, sessionID)
1✔
174
                return
1✔
175
        }
1✔
176

177
        http.NotFound(w, r)
2✔
178
}
179

180
// templateData holds data for the dashboard template.
181
type templateData struct {
182
        PlanName string
183
        Branch   string
184
}
185

186
// handleIndex serves the main dashboard page.
187
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
3✔
188
        if r.URL.Path != "/" {
4✔
189
                http.NotFound(w, r)
1✔
190
                return
1✔
191
        }
1✔
192

193
        w.Header().Set("Content-Type", "text/html; charset=utf-8")
2✔
194

2✔
195
        data := templateData{
2✔
196
                PlanName: s.cfg.PlanName,
2✔
197
                Branch:   s.cfg.Branch,
2✔
198
        }
2✔
199

2✔
200
        if err := s.tmpl.Execute(w, data); err != nil {
2✔
201
                http.Error(w, "template execution error", http.StatusInternalServerError)
×
202
                return
×
203
        }
×
204
}
205

206
// handlePlan serves the parsed plan as JSON.
207
// in single-session mode, uses the server's configured plan file with caching.
208
// in multi-session mode, accepts ?session=<id> to load plan from session metadata.
209
func (s *Server) handlePlan(w http.ResponseWriter, r *http.Request) {
12✔
210
        if r.Method != http.MethodGet {
15✔
211
                w.Header().Set("Allow", http.MethodGet)
3✔
212
                http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
3✔
213
                return
3✔
214
        }
3✔
215

216
        sessionID := r.URL.Query().Get("session")
9✔
217

9✔
218
        // multi-session mode with session ID
9✔
219
        if s.sm != nil && sessionID != "" {
13✔
220
                s.handleSessionPlan(w, sessionID)
4✔
221
                return
4✔
222
        }
4✔
223

224
        // single-session mode - use cached server plan
225
        if s.cfg.PlanFile == "" {
6✔
226
                http.Error(w, "no plan file configured", http.StatusNotFound)
1✔
227
                return
1✔
228
        }
1✔
229

230
        plan, err := s.loadPlan()
4✔
231
        if err != nil {
5✔
232
                log.Printf("[WARN] failed to load plan file %s: %v", s.cfg.PlanFile, err)
1✔
233
                http.Error(w, "unable to load plan", http.StatusInternalServerError)
1✔
234
                return
1✔
235
        }
1✔
236

237
        data, err := plan.JSON()
3✔
238
        if err != nil {
3✔
239
                log.Printf("[WARN] failed to encode plan: %v", err)
×
240
                http.Error(w, "unable to encode plan", http.StatusInternalServerError)
×
241
                return
×
242
        }
×
243

244
        w.Header().Set("Content-Type", "application/json")
3✔
245
        _, _ = w.Write(data)
3✔
246
}
247

248
// handleSessionPlan handles plan requests for a specific session in multi-session mode.
249
func (s *Server) handleSessionPlan(w http.ResponseWriter, sessionID string) {
4✔
250
        session := s.sm.Get(sessionID)
4✔
251
        if session == nil {
5✔
252
                http.Error(w, "session not found: "+sessionID, http.StatusNotFound)
1✔
253
                return
1✔
254
        }
1✔
255

256
        meta := session.GetMetadata()
3✔
257
        if meta.PlanPath == "" {
4✔
258
                http.Error(w, "no plan file for session", http.StatusNotFound)
1✔
259
                return
1✔
260
        }
1✔
261

262
        // resolve plan path: absolute paths used as-is, relative paths resolved from session directory
263
        var planPath string
2✔
264
        if filepath.IsAbs(meta.PlanPath) {
2✔
265
                planPath = meta.PlanPath
×
266
        } else {
2✔
267
                sessionDir := filepath.Dir(session.Path)
2✔
268
                planPath = filepath.Join(sessionDir, meta.PlanPath)
2✔
269
        }
2✔
270

271
        plan, err := loadPlanWithFallback(planPath)
2✔
272
        if err != nil {
2✔
273
                log.Printf("[WARN] failed to load plan file %s: %v", meta.PlanPath, err)
×
274
                http.Error(w, "unable to load plan", http.StatusInternalServerError)
×
275
                return
×
276
        }
×
277

278
        data, err := plan.JSON()
2✔
279
        if err != nil {
2✔
280
                log.Printf("[WARN] failed to encode plan: %v", err)
×
281
                http.Error(w, "unable to encode plan", http.StatusInternalServerError)
×
282
                return
×
283
        }
×
284

285
        w.Header().Set("Content-Type", "application/json")
2✔
286
        _, _ = w.Write(data)
2✔
287
}
288

289
// loadPlan returns a cached plan or loads it from disk (with completed/ fallback).
290
func (s *Server) loadPlan() (*Plan, error) {
4✔
291
        s.planMu.Lock()
4✔
292
        defer s.planMu.Unlock()
4✔
293

4✔
294
        if s.planCache != nil {
4✔
295
                return s.planCache, nil
×
296
        }
×
297

298
        plan, err := loadPlanWithFallback(s.cfg.PlanFile)
4✔
299
        if err != nil {
5✔
300
                return nil, err
1✔
301
        }
1✔
302

303
        s.planCache = plan
3✔
304
        return plan, nil
3✔
305
}
306

307
// loadPlanWithFallback loads a plan from disk with completed/ directory fallback.
308
// does not cache - each call reads from disk.
309
func loadPlanWithFallback(path string) (*Plan, error) {
9✔
310
        plan, err := ParsePlanFile(path)
9✔
311
        if err != nil && errors.Is(err, fs.ErrNotExist) {
14✔
312
                completedPath := filepath.Join(filepath.Dir(path), "completed", filepath.Base(path))
5✔
313
                plan, err = ParsePlanFile(completedPath)
5✔
314
        }
5✔
315
        return plan, err
9✔
316
}
317

318
// handleEvents serves the SSE stream.
319
// in multi-session mode, accepts ?session=<id> query parameter.
320
func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
3✔
321
        sessionID := r.URL.Query().Get("session")
3✔
322
        log.Printf("[SSE] connection request: session=%s", sessionID)
3✔
323

3✔
324
        // get session for SSE handling
3✔
325
        session, err := s.getSession(r)
3✔
326
        if err != nil {
6✔
327
                log.Printf("[SSE] session not found: %s - %v", sessionID, err)
3✔
328
                http.Error(w, err.Error(), http.StatusNotFound)
3✔
329
                return
3✔
330
        }
3✔
331

332
        // delegate to go-sse Server which handles:
333
        // - SSE protocol (headers, event formatting)
334
        // - Connection management
335
        // - History replay via FiniteReplayer
336
        // - Graceful disconnection
337
        session.SSE.ServeHTTP(w, r)
×
338
        log.Printf("[SSE] connection closed: session=%s", sessionID)
×
339
}
340

341
// getSession returns the session for the request.
342
// in single-session mode, returns the server's session.
343
// in multi-session mode, looks up the session by ID from query parameter.
344
func (s *Server) getSession(r *http.Request) (*Session, error) {
3✔
345
        sessionID := r.URL.Query().Get("session")
3✔
346

3✔
347
        // single-session mode (no session manager)
3✔
348
        if s.sm == nil {
3✔
NEW
349
                return s.getSingleSession(sessionID)
×
NEW
350
        }
×
351

352
        if sessionID == "" {
5✔
353
                return nil, errors.New("no session specified")
2✔
354
        }
2✔
355

356
        // multi-session mode - look up session
357
        session := s.sm.Get(sessionID)
1✔
358
        if session == nil {
2✔
359
                log.Printf("[SSE] session lookup failed: %s (not in manager)", sessionID)
1✔
360
                return nil, fmt.Errorf("session not found: %s", sessionID)
1✔
361
        }
1✔
362

363
        return session, nil
×
364
}
365

366
func (s *Server) getSingleSession(sessionID string) (*Session, error) {
5✔
367
        if sessionID == "" {
7✔
368
                if s.session == nil {
3✔
369
                        return nil, errors.New("no session specified")
1✔
370
                }
1✔
371
                return s.session, nil
1✔
372
        }
373
        if s.planRunner != nil {
4✔
374
                if planSession := s.planRunner.GetSession(sessionID); planSession != nil {
2✔
375
                        return planSession, nil
1✔
376
                }
1✔
377
        }
378
        if s.session != nil && s.session.ID == sessionID {
3✔
379
                return s.session, nil
1✔
380
        }
1✔
381
        return nil, fmt.Errorf("session not found: %s", sessionID)
1✔
382
}
383

384
// SessionInfo represents session data for the API response.
385
type SessionInfo struct {
386
        ID    string       `json:"id"`
387
        State SessionState `json:"state"`
388
        // dir is the short display name for the project (last path segment of session directory).
389
        Dir string `json:"dir"`
390
        // DirPath is the full filesystem path to the session directory (used for grouping and copy-to-clipboard).
391
        DirPath      string    `json:"dirPath,omitempty"`
392
        ProgressPath string    `json:"progressPath,omitempty"`
393
        PlanPath     string    `json:"planPath,omitempty"`
394
        Branch       string    `json:"branch,omitempty"`
395
        Mode         string    `json:"mode,omitempty"`
396
        StartTime    time.Time `json:"startTime"`
397
        LastModified time.Time `json:"lastModified"`
398
}
399

400
// handleSessions returns a list of all discovered sessions.
401
func (s *Server) handleSessions(w http.ResponseWriter, r *http.Request) {
5✔
402
        if r.Method != http.MethodGet {
8✔
403
                w.Header().Set("Allow", http.MethodGet)
3✔
404
                http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
3✔
405
                return
3✔
406
        }
3✔
407

408
        // single-session mode - return empty list
409
        if s.sm == nil {
3✔
410
                w.Header().Set("Content-Type", "application/json")
1✔
411
                _, _ = w.Write([]byte("[]"))
1✔
412
                return
1✔
413
        }
1✔
414

415
        sessions := s.sm.All()
1✔
416

1✔
417
        // sort by last modified (most recent first)
1✔
418
        sort.Slice(sessions, func(i, j int) bool {
1✔
419
                return sessions[i].GetLastModified().After(sessions[j].GetLastModified())
×
420
        })
×
421

422
        // convert to API response format
423
        infos := make([]SessionInfo, 0, len(sessions))
1✔
424
        for _, session := range sessions {
2✔
425
                meta := session.GetMetadata()
1✔
426
                dirPath := extractDirPath(session.Path)
1✔
427
                infos = append(infos, SessionInfo{
1✔
428
                        ID:           session.ID,
1✔
429
                        State:        session.GetState(),
1✔
430
                        Dir:          extractProjectDir(session.Path),
1✔
431
                        DirPath:      dirPath,
1✔
432
                        ProgressPath: session.Path,
1✔
433
                        PlanPath:     meta.PlanPath,
1✔
434
                        Branch:       meta.Branch,
1✔
435
                        Mode:         meta.Mode,
1✔
436
                        StartTime:    meta.StartTime,
1✔
437
                        LastModified: session.GetLastModified(),
1✔
438
                })
1✔
439
        }
1✔
440

441
        data, err := json.Marshal(infos)
1✔
442
        if err != nil {
1✔
443
                log.Printf("[WARN] failed to encode sessions: %v", err)
×
444
                http.Error(w, "unable to encode sessions", http.StatusInternalServerError)
×
445
                return
×
446
        }
×
447

448
        w.Header().Set("Content-Type", "application/json")
1✔
449
        _, _ = w.Write(data)
1✔
450
}
451

452
// extractProjectDir extracts project directory name from session path.
453
// handles edge cases where path has no meaningful parent directory.
454
func extractProjectDir(path string) string {
7✔
455
        dir := filepath.Dir(path)
7✔
456
        name := filepath.Base(dir)
7✔
457

7✔
458
        // handle edge cases: root paths, current directory, relative paths
7✔
459
        if name == "" || name == "." || name == ".." || name == string(filepath.Separator) {
11✔
460
                return "Unknown"
4✔
461
        }
4✔
462
        return name
3✔
463
}
464

465
// extractDirPath returns the absolute directory path for a session path.
466
// returns empty string for relative paths like "." or "..".
467
func extractDirPath(path string) string {
5✔
468
        if absPath, err := filepath.Abs(path); err == nil {
10✔
469
                return filepath.Dir(absPath)
5✔
470
        }
5✔
NEW
471
        dirPath := filepath.Dir(path)
×
NEW
472
        if dirPath == "." || dirPath == ".." {
×
NEW
473
                return ""
×
NEW
474
        }
×
NEW
475
        return dirPath
×
476
}
477

478
// StartPlanRequest is the request body for POST /api/plan.
479
type StartPlanRequest struct {
480
        Dir         string `json:"dir"`
481
        Description string `json:"description"`
482
}
483

484
// StartPlanResponse is the response body for POST /api/plan.
485
type StartPlanResponse struct {
486
        SessionID string `json:"session_id"`
487
        Success   bool   `json:"success"`
488
}
489

490
// handleStartPlan handles POST /api/plan to start a new plan creation.
491
func (s *Server) handleStartPlan(w http.ResponseWriter, r *http.Request) {
9✔
492
        if r.Method != http.MethodPost {
12✔
493
                w.Header().Set("Allow", http.MethodPost)
3✔
494
                http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
3✔
495
                return
3✔
496
        }
3✔
497

498
        if s.planRunner == nil {
8✔
499
                http.Error(w, "plan creation not available", http.StatusServiceUnavailable)
2✔
500
                return
2✔
501
        }
2✔
502

503
        var req StartPlanRequest
4✔
504
        if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
5✔
505
                http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest)
1✔
506
                return
1✔
507
        }
1✔
508

509
        if req.Dir == "" {
4✔
510
                http.Error(w, "directory required", http.StatusBadRequest)
1✔
511
                return
1✔
512
        }
1✔
513

514
        if req.Description == "" {
3✔
515
                http.Error(w, "description required", http.StatusBadRequest)
1✔
516
                return
1✔
517
        }
1✔
518

519
        session, err := s.planRunner.StartPlan(req.Dir, req.Description)
1✔
520
        if err != nil {
2✔
521
                log.Printf("[ERROR] failed to start plan: %v", err)
1✔
522
                http.Error(w, "failed to start plan: "+err.Error(), http.StatusBadRequest)
1✔
523
                return
1✔
524
        }
1✔
525

NEW
526
        resp := StartPlanResponse{
×
NEW
527
                SessionID: session.ID,
×
NEW
528
                Success:   true,
×
NEW
529
        }
×
NEW
530

×
NEW
531
        w.Header().Set("Content-Type", "application/json")
×
NEW
532
        if err := json.NewEncoder(w).Encode(resp); err != nil {
×
NEW
533
                log.Printf("[WARN] failed to encode response: %v", err)
×
NEW
534
        }
×
535
}
536

537
// AnswerRequest is the request body for POST /api/answer.
538
type AnswerRequest struct {
539
        SessionID  string `json:"session_id"`
540
        QuestionID string `json:"question_id"`
541
        Answer     string `json:"answer"`
542
}
543

544
// AnswerResponse is the response body for POST /api/answer.
545
type AnswerResponse struct {
546
        Success bool `json:"success"`
547
}
548

549
// handleAnswer handles POST /api/answer to submit an answer to a pending question.
550
func (s *Server) handleAnswer(w http.ResponseWriter, r *http.Request) {
11✔
551
        if r.Method != http.MethodPost {
14✔
552
                w.Header().Set("Allow", http.MethodPost)
3✔
553
                http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
3✔
554
                return
3✔
555
        }
3✔
556

557
        if s.planRunner == nil {
9✔
558
                http.Error(w, "plan creation not available", http.StatusServiceUnavailable)
1✔
559
                return
1✔
560
        }
1✔
561

562
        var req AnswerRequest
7✔
563
        if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
8✔
564
                http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest)
1✔
565
                return
1✔
566
        }
1✔
567

568
        if req.SessionID == "" || req.QuestionID == "" || req.Answer == "" {
10✔
569
                http.Error(w, "session_id, question_id, and answer required", http.StatusBadRequest)
4✔
570
                return
4✔
571
        }
4✔
572

573
        session := s.planRunner.GetSession(req.SessionID)
2✔
574
        if session == nil {
3✔
575
                http.Error(w, "session not found: "+req.SessionID, http.StatusNotFound)
1✔
576
                return
1✔
577
        }
1✔
578

579
        collector := session.GetInputCollector()
1✔
580
        if collector == nil {
2✔
581
                http.Error(w, "no input collector for session", http.StatusBadRequest)
1✔
582
                return
1✔
583
        }
1✔
584

NEW
585
        if err := collector.SubmitAnswer(req.QuestionID, req.Answer); err != nil {
×
NEW
586
                http.Error(w, "failed to submit answer: "+err.Error(), http.StatusBadRequest)
×
NEW
587
                return
×
NEW
588
        }
×
589

NEW
590
        resp := AnswerResponse{Success: true}
×
NEW
591
        w.Header().Set("Content-Type", "application/json")
×
NEW
592
        if err := json.NewEncoder(w).Encode(resp); err != nil {
×
NEW
593
                log.Printf("[WARN] failed to encode response: %v", err)
×
NEW
594
        }
×
595
}
596

597
// handleCancelSession handles POST /api/sessions/{id}/cancel to cancel a plan creation.
598
func (s *Server) handleCancelSession(w http.ResponseWriter, r *http.Request, sessionID string) {
7✔
599
        if r.Method != http.MethodPost {
10✔
600
                w.Header().Set("Allow", http.MethodPost)
3✔
601
                http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
3✔
602
                return
3✔
603
        }
3✔
604

605
        if s.planRunner == nil {
6✔
606
                http.Error(w, "plan creation not available", http.StatusServiceUnavailable)
2✔
607
                return
2✔
608
        }
2✔
609

610
        if err := s.planRunner.CancelPlan(sessionID); err != nil {
3✔
611
                http.Error(w, "failed to cancel plan: "+err.Error(), http.StatusNotFound)
1✔
612
                return
1✔
613
        }
1✔
614

615
        w.Header().Set("Content-Type", "application/json")
1✔
616
        _, _ = w.Write([]byte(`{"success":true}`))
1✔
617
}
618

619
// RecentDirsResponse is the response body for GET /api/recent-dirs.
620
type RecentDirsResponse struct {
621
        Dirs   []string `json:"dirs"`
622
        Locked bool     `json:"locked,omitempty"`
623
}
624

625
func (s *Server) isWatchOnlyMode() bool {
6✔
626
        return s.cfg.PlanName == "(watch mode)" && s.cfg.PlanFile == "" && s.sm != nil
6✔
627
}
6✔
628

629
// handleRecentDirs handles GET /api/recent-dirs.
630
// Returns directories from config.ProjectDirs and from existing sessions.
631
func (s *Server) handleRecentDirs(w http.ResponseWriter, r *http.Request) {
9✔
632
        if r.Method != http.MethodGet {
12✔
633
                w.Header().Set("Allow", http.MethodGet)
3✔
634
                http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
3✔
635
                return
3✔
636
        }
3✔
637

638
        if s.isWatchOnlyMode() && !s.cfg.WatchExplicit && s.planRunner != nil && s.planRunner.config != nil {
7✔
639
                dirs := uniqueDirs(s.planRunner.config.WatchDirs)
1✔
640
                resp := RecentDirsResponse{Dirs: dirs, Locked: len(dirs) == 1}
1✔
641
                w.Header().Set("Content-Type", "application/json")
1✔
642
                if err := json.NewEncoder(w).Encode(resp); err != nil {
1✔
NEW
643
                        log.Printf("[WARN] failed to encode response: %v", err)
×
NEW
644
                }
×
645
                return
1✔
646
        }
647

648
        // use a map to deduplicate directories
649
        seen := make(map[string]bool)
5✔
650
        var dirs []string
5✔
651

5✔
652
        // add directories from config (these come first, in order)
5✔
653
        if s.planRunner != nil && s.planRunner.config != nil {
8✔
654
                for _, dir := range s.planRunner.config.ProjectDirs {
8✔
655
                        if !seen[dir] {
10✔
656
                                seen[dir] = true
5✔
657
                                dirs = append(dirs, dir)
5✔
658
                        }
5✔
659
                }
660
        }
661

662
        // add directories from existing sessions
663
        if s.sm != nil {
9✔
664
                for _, session := range s.sm.All() {
8✔
665
                        // extract directory from progress file path
4✔
666
                        dir := filepath.Dir(session.Path)
4✔
667
                        if dir != "" && !seen[dir] {
7✔
668
                                seen[dir] = true
3✔
669
                                dirs = append(dirs, dir)
3✔
670
                        }
3✔
671
                }
672
        }
673

674
        if dirs == nil {
6✔
675
                dirs = []string{}
1✔
676
        }
1✔
677

678
        resp := RecentDirsResponse{Dirs: dirs}
5✔
679
        w.Header().Set("Content-Type", "application/json")
5✔
680
        if err := json.NewEncoder(w).Encode(resp); err != nil {
5✔
NEW
681
                log.Printf("[WARN] failed to encode response: %v", err)
×
NEW
682
        }
×
683
}
684

685
// ResumableResponse is the response body for GET /api/resumable.
686
type ResumableResponse struct {
687
        Sessions []ResumableSession `json:"sessions"`
688
}
689

690
// handleResumable handles GET /api/resumable to list resumable plan sessions.
691
func (s *Server) handleResumable(w http.ResponseWriter, r *http.Request) {
6✔
692
        if r.Method != http.MethodGet {
9✔
693
                w.Header().Set("Allow", http.MethodGet)
3✔
694
                http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
3✔
695
                return
3✔
696
        }
3✔
697

698
        if s.planRunner == nil {
4✔
699
                http.Error(w, "plan creation not available", http.StatusServiceUnavailable)
1✔
700
                return
1✔
701
        }
1✔
702

703
        sessions, err := s.planRunner.GetResumableSessions()
2✔
704
        if err != nil {
2✔
NEW
705
                log.Printf("[ERROR] failed to get resumable sessions: %v", err)
×
NEW
706
                http.Error(w, "failed to get resumable sessions: "+err.Error(), http.StatusInternalServerError)
×
NEW
707
                return
×
NEW
708
        }
×
709

710
        if sessions == nil {
3✔
711
                sessions = []ResumableSession{}
1✔
712
        }
1✔
713

714
        resp := ResumableResponse{Sessions: sessions}
2✔
715
        w.Header().Set("Content-Type", "application/json")
2✔
716
        if err := json.NewEncoder(w).Encode(resp); err != nil {
2✔
NEW
717
                log.Printf("[WARN] failed to encode response: %v", err)
×
NEW
718
        }
×
719
}
720

721
// ResumePlanRequest is the request body for POST /api/plan/resume.
722
type ResumePlanRequest struct {
723
        ProgressPath string `json:"progress_path"`
724
}
725

726
// ResumePlanResponse is the response body for POST /api/plan/resume.
727
type ResumePlanResponse struct {
728
        SessionID string `json:"session_id"`
729
        Success   bool   `json:"success"`
730
}
731

732
// handleResumePlan handles POST /api/plan/resume to resume an interrupted plan.
733
func (s *Server) handleResumePlan(w http.ResponseWriter, r *http.Request) {
7✔
734
        if r.Method != http.MethodPost {
10✔
735
                w.Header().Set("Allow", http.MethodPost)
3✔
736
                http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
3✔
737
                return
3✔
738
        }
3✔
739

740
        if s.planRunner == nil {
5✔
741
                http.Error(w, "plan creation not available", http.StatusServiceUnavailable)
1✔
742
                return
1✔
743
        }
1✔
744

745
        var req ResumePlanRequest
3✔
746
        if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
4✔
747
                http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest)
1✔
748
                return
1✔
749
        }
1✔
750

751
        if req.ProgressPath == "" {
3✔
752
                http.Error(w, "progress_path required", http.StatusBadRequest)
1✔
753
                return
1✔
754
        }
1✔
755

756
        session, err := s.planRunner.ResumePlan(req.ProgressPath)
1✔
757
        if err != nil {
2✔
758
                log.Printf("[ERROR] failed to resume plan: %v", err)
1✔
759
                http.Error(w, "failed to resume plan: "+err.Error(), http.StatusBadRequest)
1✔
760
                return
1✔
761
        }
1✔
762

NEW
763
        resp := ResumePlanResponse{
×
NEW
764
                SessionID: session.ID,
×
NEW
765
                Success:   true,
×
NEW
766
        }
×
NEW
767

×
NEW
768
        w.Header().Set("Content-Type", "application/json")
×
NEW
769
        if err := json.NewEncoder(w).Encode(resp); err != nil {
×
NEW
770
                log.Printf("[WARN] failed to encode response: %v", err)
×
NEW
771
        }
×
772
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc