// Per-child harness session state (AGENTS.md §7, FR-2, §11 Phase 4). // // The Compass fetcher (patchright) keeps a browser storage state — cookies and // localStorage — so a later scan can reuse the login instead of signing in // again. That state is a secret: it lives in the database volume only, is never // exported to git, never logged, and never printed by a command. package store import ( "context" "database/sql" "fmt" "time" ) // ChildSession is the stored harness session for one child. type ChildSession struct { ChildID int64 Child string State string UpdatedAt time.Time } // PutSession writes (or refreshes) the harness session state for one child. The // state is opaque to the store; the fetcher owns its format. func (s *Store) PutSession(ctx context.Context, childID int64, state string) error { if state == "" { return fmt.Errorf("put session: state is empty") } if _, err := s.db.ExecContext(ctx, `INSERT INTO session (child_id, state, updated_at) VALUES (?, ?, datetime('now')) ON CONFLICT(child_id) DO UPDATE SET state = excluded.state, updated_at = datetime('now')`, childID, state); err != nil { return fmt.Errorf("put session for child %d: %w", childID, err) } return nil } // GetSession returns the stored session for one child, or nil when it has none. func (s *Store) GetSession(ctx context.Context, childID int64) (*ChildSession, error) { row := s.db.QueryRowContext(ctx, `SELECT se.child_id, COALESCE(ch.name, ''), se.state, se.updated_at FROM session se LEFT JOIN children ch ON ch.id = se.child_id WHERE se.child_id = ?`, childID) var cs ChildSession var updated string if err := row.Scan(&cs.ChildID, &cs.Child, &cs.State, &updated); err != nil { if err == sql.ErrNoRows { return nil, nil } return nil, fmt.Errorf("get session for child %d: %w", childID, err) } cs.UpdatedAt = parseTime(updated) return &cs, nil } // ListSessions returns the stored sessions, oldest child first. Each state is // returned in full; callers that print must never print the state. func (s *Store) ListSessions(ctx context.Context) ([]ChildSession, error) { rows, err := s.db.QueryContext(ctx, `SELECT se.child_id, COALESCE(ch.name, ''), se.state, se.updated_at FROM session se LEFT JOIN children ch ON ch.id = se.child_id ORDER BY se.child_id`) if err != nil { return nil, fmt.Errorf("list sessions: %w", err) } defer rows.Close() var out []ChildSession for rows.Next() { var cs ChildSession var updated string if err := rows.Scan(&cs.ChildID, &cs.Child, &cs.State, &updated); err != nil { return nil, fmt.Errorf("scan session: %w", err) } cs.UpdatedAt = parseTime(updated) out = append(out, cs) } return out, rows.Err() } // DeleteSession removes the stored session for one child. A signed-out or // rejected login is not a failure; it just means the next scan logs in again. func (s *Store) DeleteSession(ctx context.Context, childID int64) error { if _, err := s.db.ExecContext(ctx, `DELETE FROM session WHERE child_id = ?`, childID); err != nil { return fmt.Errorf("delete session for child %d: %w", childID, err) } return nil }