Files
bri-sandbox-development-pla…/control-plane/internal/store/store.go
T
opencode 2bc3ff73a2 Slice 1: build green, MVP core flow
- New agentlib module (gitutil + deployer with NewGo / NewPHP) replaces
  agent-micro/internal so both agents can share it (Go's internal/ rule
  was blocking agent-gateway from importing agent-micro's packages).
- Migrate agents from legacy github.com/docker/docker/client to the
  current github.com/moby/moby/client v0.5.0 / moby/moby/api v1.55.0.
- Fix compile errors in the original committed code: missing
  gorilla/websocket import in control-plane/internal/ws/handlers.go,
  unaliased dockerclient reference, wrong SQLite driver name
  (sqlite3 -> sqlite), Dialer.Dial 3-return-value mismatch.
- scripts/build.sh: Go 1.23 -> 1.24, apk add git, safe.directory for
  bind-mounted host tree, chmod inside container (host can't chmod
  files owned by container root).
- README and REQUIREMENTS updated to reflect the actual architecture
  (Go + SQLite, no Spring Boot, moby SDK, per-deploy no image build)
  with a per-feature status checklist at the end of REQUIREMENTS.
2026-06-24 01:43:43 +00:00

162 lines
3.9 KiB
Go

// Package store persists deployment progress in SQLite and log lines in
// append-only .log files. The hot path is AppendEvent — agents emit a lot
// of these and the dashboard wants them live.
package store
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"sync"
"time"
_ "modernc.org/sqlite"
"github.com/sdp/protocol"
)
type Store struct {
db *sql.DB
dir string
logs map[string]*os.File // deploymentID -> file
mu sync.Mutex
}
func Open(dir string) (*Store, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
if err := os.MkdirAll(filepath.Join(dir, "logs"), 0o755); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", filepath.Join(dir, "sdp.db"))
if err != nil {
return nil, err
}
if _, err := db.Exec(`
CREATE TABLE IF NOT EXISTS deployments (
id TEXT PRIMARY KEY,
repository TEXT,
branch TEXT,
user TEXT,
state TEXT,
started_at INTEGER,
completed_at INTEGER
);
CREATE TABLE IF NOT EXISTS progress (
deployment_id TEXT,
stage TEXT,
ok INTEGER,
at INTEGER
);
`); err != nil {
return nil, err
}
return &Store{db: db, dir: dir, logs: make(map[string]*os.File)}, nil
}
func (s *Store) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
for _, f := range s.logs {
_ = f.Close()
}
return s.db.Close()
}
// StartDeployment records a new deployment row. Idempotent on id.
func (s *Store) StartDeployment(id, repo, branch, user string) error {
_, err := s.db.Exec(
`INSERT OR IGNORE INTO deployments(id, repository, branch, user, state, started_at) VALUES(?,?,?,?,?,?)`,
id, repo, branch, user, "QUEUED", time.Now().UnixMilli(),
)
return err
}
// FinishDeployment marks the final state. completed_at is set if state is terminal.
func (s *Store) FinishDeployment(id, state string) error {
_, err := s.db.Exec(
`UPDATE deployments SET state=?, completed_at=? WHERE id=?`,
state, time.Now().UnixMilli(), id,
)
return err
}
// MarkStage records a stage transition. ok=1 success, 0 failure.
func (s *Store) MarkStage(id, stage string, ok bool) error {
v := 0
if ok {
v = 1
}
_, err := s.db.Exec(
`INSERT INTO progress(deployment_id, stage, ok, at) VALUES(?,?,?,?)`,
id, stage, v, time.Now().UnixMilli(),
)
return err
}
// AppendEvent writes an event. Log lines go to .log; progress/status hit SQLite.
// The deployment's running state is also updated so /api/deployments/{id} can
// serve a snapshot without replaying the whole log.
func (s *Store) AppendEvent(e protocol.Event) error {
switch e.Kind {
case "log":
return s.appendLog(e)
case "status":
_, err := s.db.Exec(`UPDATE deployments SET state=? WHERE id=?`, e.State, e.DeploymentID)
return err
case "progress":
return s.MarkStage(e.DeploymentID, e.Stage, e.State != "FAILED")
}
return nil
}
func (s *Store) appendLog(e protocol.Event) error {
s.mu.Lock()
f, ok := s.logs[e.DeploymentID]
if !ok {
path := filepath.Join(s.dir, "logs", e.DeploymentID+".log")
var err error
f, err = os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
s.mu.Unlock()
return err
}
s.logs[e.DeploymentID] = f
}
s.mu.Unlock()
ts := time.UnixMilli(e.At).Format("15:04:05.000")
_, err := fmt.Fprintf(f, "%s %s\n", ts, e.Line)
return err
}
// TailLogs returns the last n lines of a deployment's log file. Used by the
// dashboard on first connect to backfill.
func (s *Store) TailLogs(id string, n int) ([]string, error) {
path := filepath.Join(s.dir, "logs", id+".log")
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
// ponytail: O(n) scan, fine for tail use. swap to a ring buffer if logs get huge.
var lines []string
start := 0
for i, b := range data {
if b == '\n' {
lines = append(lines, string(data[start:i]))
start = i + 1
}
}
if start < len(data) {
lines = append(lines, string(data[start:]))
}
if n > 0 && len(lines) > n {
lines = lines[len(lines)-n:]
}
return lines, nil
}