diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index e3ceaf1c..4df3f44c 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -22,7 +22,9 @@ jobs: - uses: actions/setup-go@v5 with: - go-version: "1.22" + # Read the version from go.mod so CI can't drift from the module + # (it previously pinned 1.22 while go.mod declared 1.25). + go-version-file: backend/go.mod cache: false - name: Check formatting @@ -42,3 +44,27 @@ jobs: - name: Test run: go test -race ./... + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: backend/go.mod + cache: false + + - name: golangci-lint + # v8 of the action drives golangci-lint v2 (the schema this config uses); + # the v6 action speaks v1 CLI flags and errors against a v2 binary. + uses: golangci/golangci-lint-action@v8 + with: + # Pinned for reproducibility: bump intentionally rather than letting an + # upstream release change CI. Must be built with Go >= the module's + # (go.mod is 1.25); v2.12.2 is built with go1.25 — older v2 tags + # (e.g. v2.1.x) are built with go1.24 and refuse to analyze 1.25 code. + version: v2.12.2 + working-directory: backend + # Blocking on the full ruleset: the tree is clean at zero findings, so + # any new issue fails CI rather than being grandfathered. diff --git a/backend/.golangci.yml b/backend/.golangci.yml new file mode 100644 index 00000000..438dd020 --- /dev/null +++ b/backend/.golangci.yml @@ -0,0 +1,115 @@ +# golangci-lint v2 config for the AO backend. +# Run: golangci-lint run ./... (from backend/) +version: "2" + +run: + timeout: 5m + +issues: + # Report every finding, not the default first-50-per-linter / 3-same. + max-issues-per-linter: 0 + max-same-issues: 0 + +linters: + default: none + enable: + # --- correctness --- + - errcheck # unchecked errors + - govet # suspicious constructs + - ineffassign # ineffectual assignments + - staticcheck # the big static analyzer + - unused # dead code (funcs/vars/types/fields) + - errorlint # error wrapping / comparison bugs + - bodyclose # unclosed HTTP response bodies + - sqlclosecheck # unclosed sql.Rows/Stmt + - rowserrcheck # missing rows.Err() + - nilerr # `return nil` after a non-nil err check + - makezero # append to a non-zero-len make() slice + - gocheckcompilerdirectives # malformed //go: directives + - reassign # reassigning package-level vars from other pkgs + # --- dead code / boilerplate (the "nuke" linters) --- + - unparam # unused function params / always-same returns + - unconvert # unnecessary type conversions + - wastedassign # assignments never read + - copyloopvar # redundant loop-var copies (Go 1.22+) + - prealloc # slices that could be preallocated + - dupl # copy-pasted code blocks + # --- style / quality --- + - revive # configurable golint successor + - gocritic # opinionated diagnostics + style + - misspell # typos in comments/strings + - usestdlibvars # use stdlib consts (http.MethodGet, etc.) + - predeclared # shadowing predeclared identifiers + - nakedret # naked returns in long funcs + # --- security --- + - gosec + + settings: + errcheck: + check-type-assertions: true + govet: + enable-all: true + disable: + - fieldalignment # struct field ordering is not worth the churn + - shadow # shadowing `err` in nested scopes is idiomatic Go + revive: + rules: + - { name: exported } # doc comments on every exported symbol + - { name: blank-imports } + - { name: context-as-argument } + - { name: context-keys-type } + - { name: dot-imports } + - { name: error-return } + - { name: error-strings } + - { name: error-naming } + - { name: indent-error-flow } + - { name: errorf } + - { name: empty-block } + - { name: superfluous-else } + - { name: unreachable-code } + - { name: redefines-builtin-id } + - { name: range } + - { name: time-naming } + - { name: var-declaration } + gocritic: + enabled-tags: [diagnostic, performance, style] + disabled-checks: + - ifElseChain # overlaps revive/superfluous-else + - commentedOutCode + - hugeParam # pass-by-pointer micro-opt; hurts clarity, risks nil/aliasing + - rangeValCopy # same — copying a struct in range is usually fine + - unnamedResult # named returns are a style choice, not a defect + dupl: + threshold: 140 + gosec: + excludes: + - G104 # unchecked errors — errcheck owns this + - G304 # file inclusion via variable — paths are config/run-file/worktree-derived, not user input + + exclusions: + generated: lax # skip sqlc/codegen ("Code generated ... DO NOT EDIT") + rules: + # Tests: relax the noisiest checks (deliberate error-drops, repeated setup, + # preallocation, and upgrade-response bodies that don't need closing). + - path: _test\.go + linters: [errcheck, dupl, gosec, unparam, gocritic, prealloc, bodyclose] + # status.go deliberately reports probe failures in the result struct + # (st.State/st.Error) and returns nil — a down daemon is the status being + # reported, not a failure of the status command itself. + - path: internal/cli/status\.go + linters: [nilerr] + # The reflect/unsafe field-inspection test is intentional. + - path: wiring_test\.go + linters: [gosec] + # Spawning git/agent subprocesses with computed args is the point. + - linters: [gosec] + text: "G204" + +formatters: + enable: + - gofmt + - goimports + settings: + goimports: + local-prefixes: + - github.com/aoagents/agent-orchestrator diff --git a/backend/internal/adapters/runtime/tmux/tmux.go b/backend/internal/adapters/runtime/tmux/tmux.go index ba7524ed..ae0d0445 100644 --- a/backend/internal/adapters/runtime/tmux/tmux.go +++ b/backend/internal/adapters/runtime/tmux/tmux.go @@ -25,12 +25,15 @@ var sessionIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) var getenv = os.Getenv +// Options configures a tmux Runtime; every field has a default (see New). type Options struct { Binary string Timeout time.Duration Shell string } +// Runtime runs agent sessions inside tmux sessions, driving them via the tmux +// CLI. It implements ports.Runtime. type Runtime struct { binary string timeout time.Duration @@ -50,6 +53,8 @@ func (execRunner) Run(ctx context.Context, name string, args ...string) ([]byte, return exec.CommandContext(ctx, name, args...).CombinedOutput() } +// New builds a tmux Runtime, filling unset Options with defaults: binary +// "tmux", shell from $SHELL (else /bin/sh), and the default timeout. func New(opts Options) *Runtime { binary := opts.Binary if binary == "" { @@ -69,6 +74,8 @@ func New(opts Options) *Runtime { return &Runtime{binary: binary, timeout: timeout, shell: shellPath, runner: execRunner{}} } +// Create starts a new tmux session in the workspace, running the agent's +// launch command, and returns a handle to it. func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) { id, err := tmuxSessionName(cfg.SessionID) if err != nil { @@ -92,6 +99,8 @@ func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.Ru return ports.RuntimeHandle{ID: id, RuntimeName: runtimeName}, nil } +// Destroy kills the handle's tmux session. An already-gone session is treated +// as success. func (r *Runtime) Destroy(ctx context.Context, handle ports.RuntimeHandle) error { id, err := handleID(handle) if err != nil { @@ -107,6 +116,8 @@ func (r *Runtime) Destroy(ctx context.Context, handle ports.RuntimeHandle) error return nil } +// SendMessage types a message into the session's pane and presses Enter, +// routing large messages through a tmux paste buffer. func (r *Runtime) SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error { id, err := handleID(handle) if err != nil { @@ -124,6 +135,7 @@ func (r *Runtime) SendMessage(ctx context.Context, handle ports.RuntimeHandle, m return nil } +// GetOutput captures the last `lines` lines of the session pane. func (r *Runtime) GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) { id, err := handleID(handle) if err != nil { @@ -139,6 +151,7 @@ func (r *Runtime) GetOutput(ctx context.Context, handle ports.RuntimeHandle, lin return string(out), nil } +// IsAlive reports whether the handle's tmux session still exists. func (r *Runtime) IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool, error) { id, err := handleID(handle) if err != nil { @@ -155,6 +168,8 @@ func (r *Runtime) IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool return false, fmt.Errorf("tmux runtime: probe session %s: %w", id, err) } +// AttachCommand returns the argv a human runs to attach their terminal to the +// session. func (r *Runtime) AttachCommand(handle ports.RuntimeHandle) ([]string, error) { id, err := handleID(handle) if err != nil { @@ -170,7 +185,7 @@ func (r *Runtime) sendViaBuffer(ctx context.Context, id, message string) error { return fmt.Errorf("tmux runtime: create message temp file: %w", err) } path := file.Name() - defer os.Remove(path) + defer func() { _ = os.Remove(path) }() if _, err := file.WriteString(message); err != nil { _ = file.Close() return fmt.Errorf("tmux runtime: write message temp file: %w", err) diff --git a/backend/internal/adapters/runtime/zellij/commands.go b/backend/internal/adapters/runtime/zellij/commands.go index 4cdf8654..d4ca7104 100644 --- a/backend/internal/adapters/runtime/zellij/commands.go +++ b/backend/internal/adapters/runtime/zellij/commands.go @@ -99,7 +99,7 @@ func layoutString(workspacePath, shellPath string, shellArgs []string, shellComm func shellLaunchCommand(cfg ports.RuntimeConfig, shellPath string, spec shellLaunchSpec) string { if len(spec.args) > 0 && spec.args[0] == "-NoLogo" { - return wrapLaunchCommandPowerShell(cfg, shellPath) + return wrapLaunchCommandPowerShell(cfg) } if len(spec.args) > 0 && spec.args[0] == "/D" { return wrapLaunchCommandCmd(cfg) @@ -136,7 +136,7 @@ func wrapLaunchCommandUnix(cfg ports.RuntimeConfig, shellPath string) string { return b.String() } -func wrapLaunchCommandPowerShell(cfg ports.RuntimeConfig, shellPath string) string { +func wrapLaunchCommandPowerShell(cfg ports.RuntimeConfig) string { path := cfg.Env["PATH"] if path == "" { path = getenv("PATH") diff --git a/backend/internal/adapters/runtime/zellij/zellij.go b/backend/internal/adapters/runtime/zellij/zellij.go index b98df849..aade6490 100644 --- a/backend/internal/adapters/runtime/zellij/zellij.go +++ b/backend/internal/adapters/runtime/zellij/zellij.go @@ -29,10 +29,12 @@ const ( ) var sessionIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) -var paneIDPattern = regexp.MustCompile(`^terminal_[0-9]+$`) +var paneIDPattern = regexp.MustCompile(`^terminal_\d+$`) var getenv = os.Getenv +// Options configures a zellij Runtime; every field has a sensible default +// (see New), so the zero value is usable. type Options struct { Binary string Timeout time.Duration @@ -42,6 +44,8 @@ type Options struct { ChunkSize int } +// Runtime runs agent sessions inside zellij sessions, driving them via the +// zellij CLI. It implements ports.Runtime. type Runtime struct { binary string timeout time.Duration @@ -68,6 +72,9 @@ func (execRunner) Run(ctx context.Context, env []string, name string, args ...st return cmd.CombinedOutput() } +// New builds a zellij Runtime, filling unset Options with defaults: binary +// "zellij", shell from $SHELL (else /bin/sh, or powershell.exe on Windows), and +// the default timeout and output chunk size. func New(opts Options) *Runtime { binary := opts.Binary if binary == "" { @@ -95,6 +102,8 @@ func New(opts Options) *Runtime { return &Runtime{binary: binary, timeout: timeout, shell: shellPath, socketDir: opts.SocketDir, configDir: opts.ConfigDir, chunkSize: chunkSize, runner: execRunner{}} } +// Create starts a new zellij session in the workspace, running the agent's +// launch command, and returns a handle to it. func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) { id, err := zellijSessionName(cfg.SessionID) if err != nil { @@ -114,7 +123,7 @@ func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.Ru if err != nil { return ports.RuntimeHandle{}, err } - defer os.Remove(layoutPath) + defer func() { _ = os.Remove(layoutPath) }() if _, err := r.run(ctx, createSessionArgs(id, layoutPath)...); err != nil { return ports.RuntimeHandle{}, fmt.Errorf("zellij runtime: create session %s: %w", id, err) @@ -127,6 +136,8 @@ func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.Ru return ports.RuntimeHandle{ID: handleIDValue(id, paneID), RuntimeName: runtimeName}, nil } +// Destroy kills the handle's zellij session. An already-gone session is treated +// as success. func (r *Runtime) Destroy(ctx context.Context, handle ports.RuntimeHandle) error { id, _, err := handleID(handle) if err != nil { @@ -142,6 +153,8 @@ func (r *Runtime) Destroy(ctx context.Context, handle ports.RuntimeHandle) error return nil } +// SendMessage pastes a message into the session's pane (chunked) and presses +// Enter to submit it. func (r *Runtime) SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error { id, paneID, err := handleID(handle) if err != nil { @@ -158,6 +171,7 @@ func (r *Runtime) SendMessage(ctx context.Context, handle ports.RuntimeHandle, m return nil } +// GetOutput returns the last `lines` lines of the session pane's screen dump. func (r *Runtime) GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) { id, paneID, err := handleID(handle) if err != nil { @@ -173,6 +187,8 @@ func (r *Runtime) GetOutput(ctx context.Context, handle ports.RuntimeHandle, lin return tailLines(string(out), lines), nil } +// IsAlive reports whether the handle's session still appears in `zellij +// list-sessions`. func (r *Runtime) IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool, error) { id, _, err := handleID(handle) if err != nil { @@ -189,6 +205,8 @@ func (r *Runtime) IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool return sessionListedAlive(string(out), id), nil } +// AttachCommand returns the argv a human runs to attach their terminal to the +// session. func (r *Runtime) AttachCommand(handle ports.RuntimeHandle) ([]string, error) { id, _, err := handleID(handle) if err != nil { @@ -420,7 +438,7 @@ func chunks(s string, maxBytes int) []string { return []string{s} } parts := []string{} - for len(s) > 0 { + for s != "" { if len(s) <= maxBytes { parts = append(parts, s) break diff --git a/backend/internal/adapters/tracker/github/auth.go b/backend/internal/adapters/tracker/github/auth.go index 9aa810df..7c448910 100644 --- a/backend/internal/adapters/tracker/github/auth.go +++ b/backend/internal/adapters/tracker/github/auth.go @@ -22,6 +22,7 @@ var ErrNoToken = errors.New("github tracker: no token configured") // StaticTokenSource is a literal token, typically used in tests. type StaticTokenSource string +// Token returns the literal token, or ErrNoToken if it is blank. func (s StaticTokenSource) Token(context.Context) (string, error) { t := strings.TrimSpace(string(s)) if t == "" { @@ -39,6 +40,8 @@ type EnvTokenSource struct { EnvVars []string } +// Token returns the first non-empty configured env var (falling back to +// GITHUB_TOKEN), or ErrNoToken if none is set. func (s EnvTokenSource) Token(context.Context) (string, error) { for _, name := range s.EnvVars { if v := strings.TrimSpace(os.Getenv(name)); v != "" { diff --git a/backend/internal/adapters/tracker/github/tracker.go b/backend/internal/adapters/tracker/github/tracker.go index bf6ffcbf..a184fb14 100644 --- a/backend/internal/adapters/tracker/github/tracker.go +++ b/backend/internal/adapters/tracker/github/tracker.go @@ -70,6 +70,7 @@ func (e *RateLimitError) Error() string { return ErrRateLimited.Error() } +// Is lets errors.Is match a *RateLimitError against the ErrRateLimited sentinel. func (e *RateLimitError) Is(target error) bool { return target == ErrRateLimited } // Options configures a Tracker. All fields except Token are optional — @@ -163,6 +164,7 @@ type ghUser struct { Login string `json:"login"` } +// Get fetches a single issue by id and maps it onto the normalized domain.Issue. func (t *Tracker) Get(ctx context.Context, id domain.TrackerID) (domain.Issue, error) { owner, repo, number, err := t.parseID(id) if err != nil { @@ -220,8 +222,7 @@ func issueFromGH(owner, repo string, raw ghIssue) domain.Issue { // surface onto the normalized state. "in-review" wins over "in-progress" // when both labels are present (the workflow is progress -> review -> done). func mapStateFromGitHub(state, reason string, labels []string) domain.NormalizedIssueState { - switch strings.ToLower(state) { - case stateClosedGH: + if strings.EqualFold(state, stateClosedGH) { if strings.EqualFold(reason, reasonNotPlan) { return domain.IssueCancelled } @@ -374,7 +375,7 @@ func (t *Tracker) do(ctx context.Context, method, path string, body any) ([]byte if err != nil { return nil, fmt.Errorf("github tracker: %s %s: %w", method, path, err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() respBody, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 200 && resp.StatusCode < 300 { return respBody, nil diff --git a/backend/internal/adapters/workspace/gitworktree/workspace.go b/backend/internal/adapters/workspace/gitworktree/workspace.go index da6d2d83..9c4cc993 100644 --- a/backend/internal/adapters/workspace/gitworktree/workspace.go +++ b/backend/internal/adapters/workspace/gitworktree/workspace.go @@ -18,16 +18,22 @@ const ( defaultBranch = "main" ) +// ErrUnsafePath is returned when a resolved worktree path escapes the managed +// root (path traversal guard). var ( ErrUnsafePath = errors.New("gitworktree: unsafe workspace path") ) +// RepoResolver maps a project to the absolute path of its source git repo. type RepoResolver interface { RepoPath(projectID domain.ProjectID) (string, error) } +// StaticRepoResolver is a RepoResolver backed by a fixed project→repo-path map. type StaticRepoResolver map[domain.ProjectID]string +// RepoPath returns the configured repo path for a project, or an error if none +// is configured. func (r StaticRepoResolver) RepoPath(projectID domain.ProjectID) (string, error) { path := r[projectID] if path == "" { @@ -36,6 +42,8 @@ func (r StaticRepoResolver) RepoPath(projectID domain.ProjectID) (string, error) return path, nil } +// Options configures a gitworktree Workspace. ManagedRoot and RepoResolver are +// required; Binary and DefaultBranch fall back to defaults. type Options struct { Binary string ManagedRoot string @@ -43,6 +51,8 @@ type Options struct { RepoResolver RepoResolver } +// Workspace creates per-session git worktrees under a managed root. It +// implements ports.Workspace. type Workspace struct { binary string managedRoot string @@ -55,6 +65,8 @@ type commandRunner func(ctx context.Context, binary string, args ...string) ([]b var _ ports.Workspace = (*Workspace)(nil) +// New builds a gitworktree Workspace, validating that ManagedRoot and +// RepoResolver are set and resolving the root to an absolute, symlink-free path. func New(opts Options) (*Workspace, error) { binary := opts.Binary if binary == "" { @@ -83,6 +95,8 @@ func New(opts Options) (*Workspace, error) { }, nil } +// Create adds a git worktree for the session under the managed root, checking +// out the requested branch, and returns where it landed. func (w *Workspace) Create(ctx context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) { if err := validateConfig(cfg); err != nil { return ports.WorkspaceInfo{}, err @@ -104,6 +118,8 @@ func (w *Workspace) Create(ctx context.Context, cfg ports.WorkspaceConfig) (port return ports.WorkspaceInfo{Path: path, Branch: cfg.Branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID}, nil } +// Destroy removes the session's worktree and prunes it from the repo, refusing +// (rather than force-deleting) if git still has the path registered afterwards. func (w *Workspace) Destroy(ctx context.Context, info ports.WorkspaceInfo) error { if info.ProjectID == "" { return errors.New("gitworktree: project id is required") @@ -139,6 +155,7 @@ func (w *Workspace) Destroy(ctx context.Context, info ports.WorkspaceInfo) error return nil } +// List returns the managed worktrees that belong to a project. func (w *Workspace) List(ctx context.Context, project domain.ProjectID) ([]ports.WorkspaceInfo, error) { if project == "" { return nil, errors.New("gitworktree: project id is required") @@ -158,6 +175,8 @@ func (w *Workspace) List(ctx context.Context, project domain.ProjectID) ([]ports return filterProjectWorktrees(records, projectRoot, project), nil } +// Restore re-attaches to an existing worktree for the session if one is still +// present, recreating the handle without disturbing its contents. func (w *Workspace) Restore(ctx context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) { if err := validateConfig(cfg); err != nil { return ports.WorkspaceInfo{}, err diff --git a/backend/internal/cdc/event.go b/backend/internal/cdc/event.go index 5d37f47e..16caaf74 100644 --- a/backend/internal/cdc/event.go +++ b/backend/internal/cdc/event.go @@ -17,6 +17,7 @@ import ( // EventType mirrors the event_type values the DB triggers write. type EventType string +// Event types, one per row-change the DB triggers emit into change_log. const ( EventSessionCreated EventType = "session_created" EventSessionUpdated EventType = "session_updated" diff --git a/backend/internal/cli/doctor.go b/backend/internal/cli/doctor.go index 4c6953f2..59ad221c 100644 --- a/backend/internal/cli/doctor.go +++ b/backend/internal/cli/doctor.go @@ -83,7 +83,7 @@ func (c *commandContext) runDoctor(ctx context.Context) []doctorCheck { Message: fmt.Sprintf("runFile=%s dataDir=%s port=%d", cfg.RunFilePath, cfg.DataDir, cfg.Port), }) - if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil { + if err := os.MkdirAll(cfg.DataDir, 0o750); err != nil { checks = append(checks, doctorCheck{Level: doctorFail, Name: "data-dir", Message: err.Error()}) } else { checks = append(checks, doctorCheck{Level: doctorPass, Name: "data-dir", Message: cfg.DataDir}) @@ -112,9 +112,11 @@ func (c *commandContext) runDoctor(ctx context.Context) []doctorCheck { checks = append(checks, doctorCheck{Level: level, Name: "daemon", Message: msg}) } - checks = append(checks, c.checkTool("git", true)) - checks = append(checks, c.checkTool("tmux", false)) - checks = append(checks, c.checkTool("zellij", false)) + checks = append(checks, + c.checkTool("git", true), + c.checkTool("tmux", false), + c.checkTool("zellij", false), + ) return checks } diff --git a/backend/internal/cli/start.go b/backend/internal/cli/start.go index 0787eba9..c6e7ee72 100644 --- a/backend/internal/cli/start.go +++ b/backend/internal/cli/start.go @@ -82,14 +82,14 @@ func (c *commandContext) startDaemon(ctx context.Context, opts startOptions) (da if logPath == "" { logPath = filepath.Join(filepath.Dir(cfg.RunFilePath), "daemon.log") } - if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil { + if err := os.MkdirAll(filepath.Dir(logPath), 0o750); err != nil { return daemonStatus{}, fmt.Errorf("create log dir: %w", err) } - logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) + logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) if err != nil { return daemonStatus{}, fmt.Errorf("open daemon log: %w", err) } - defer logFile.Close() + defer func() { _ = logFile.Close() }() if _, err := c.deps.StartProcess(processStartConfig{ Path: exe, diff --git a/backend/internal/cli/status.go b/backend/internal/cli/status.go index 85cbf5ac..8a020d5d 100644 --- a/backend/internal/cli/status.go +++ b/backend/internal/cli/status.go @@ -130,7 +130,7 @@ func (c *commandContext) readProbe(ctx context.Context, port int, path string) ( reqCtx, cancel := context.WithTimeout(ctx, probeTimeout) defer cancel() - req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, fmt.Sprintf("http://%s:%d/%s", config.LoopbackHost, port, path), nil) + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, fmt.Sprintf("http://%s:%d/%s", config.LoopbackHost, port, path), http.NoBody) if err != nil { return probeResult{}, err } @@ -138,7 +138,7 @@ func (c *commandContext) readProbe(ctx context.Context, port int, path string) ( if err != nil { return probeResult{}, fmt.Errorf("%s: %w", path, err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return probeResult{}, fmt.Errorf("%s: HTTP %d", path, resp.StatusCode) } diff --git a/backend/internal/cli/stop.go b/backend/internal/cli/stop.go index 9b00c1c4..b363b463 100644 --- a/backend/internal/cli/stop.go +++ b/backend/internal/cli/stop.go @@ -79,7 +79,7 @@ func (c *commandContext) requestShutdown(ctx context.Context, port int) error { reqCtx, cancel := context.WithTimeout(ctx, probeTimeout) defer cancel() - req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, fmt.Sprintf("http://%s:%d/shutdown", config.LoopbackHost, port), nil) + req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, fmt.Sprintf("http://%s:%d/shutdown", config.LoopbackHost, port), http.NoBody) if err != nil { return err } @@ -87,7 +87,7 @@ func (c *commandContext) requestShutdown(ctx context.Context, port int) error { if err != nil { return err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("HTTP %d", resp.StatusCode) } diff --git a/backend/internal/cli/version.go b/backend/internal/cli/version.go index dd8a2598..7297cc13 100644 --- a/backend/internal/cli/version.go +++ b/backend/internal/cli/version.go @@ -14,6 +14,8 @@ var ( Date = "" ) +// VersionString renders the build metadata as " commit built ", +// omitting the commit/date parts when they are unset. func VersionString() string { parts := []string{Version} if Commit != "" { diff --git a/backend/internal/daemon/daemon.go b/backend/internal/daemon/daemon.go index 556fe5f0..3cb4f45c 100644 --- a/backend/internal/daemon/daemon.go +++ b/backend/internal/daemon/daemon.go @@ -49,7 +49,7 @@ func Run() error { if err != nil { return fmt.Errorf("open store: %w", err) } - defer store.Close() + defer func() { _ = store.Close() }() // signal.NotifyContext cancels ctx on SIGINT/SIGTERM, which drives the // graceful shutdown inside Server.Run and stops the background goroutines. @@ -77,10 +77,7 @@ func Run() error { // Bring up the Lifecycle Manager (sole store writer) and the reaper (OBSERVE // timer). This makes the write path live end-to-end: LCM write -> store -> DB // trigger -> change_log -> poller -> broadcaster. - lcStack, err := startLifecycle(ctx, store, log) - if err != nil { - return err - } + lcStack := startLifecycle(ctx, store, log) // Bring up the Session Manager. Runtime (tmux) and Workspace (gitworktree) // are real on main; ports.Agent has no production adapter yet, so a loud diff --git a/backend/internal/daemon/lifecycle_wiring.go b/backend/internal/daemon/lifecycle_wiring.go index e96b5564..65308f0e 100644 --- a/backend/internal/daemon/lifecycle_wiring.go +++ b/backend/internal/daemon/lifecycle_wiring.go @@ -36,12 +36,12 @@ type lifecycleStack struct { // - noopMessenger — swap for the runtime/agent-plugin-backed AgentMessenger. // - reaper.MapRegistry{} — empty runtime registry, so the reaper ticks // escalations but probes nothing until the runtime plugins exist. -func startLifecycle(ctx context.Context, store *sqlite.Store, logger *slog.Logger) (*lifecycleStack, error) { +func startLifecycle(ctx context.Context, store *sqlite.Store, logger *slog.Logger) *lifecycleStack { renderer := notification.NewRenderer(store) notifier := notification.NewEnqueuer(store, renderer, logger) lcm := lifecycle.New(store, store, notifier, noopMessenger{}) rp := reaper.New(lcm, reaper.MapRegistry{}, reaper.Config{Logger: logger}) - return &lifecycleStack{LCM: lcm, Store: store, reaperDone: rp.Start(ctx)}, nil + return &lifecycleStack{LCM: lcm, Store: store, reaperDone: rp.Start(ctx)} } // Stop waits for the reaper goroutine to exit (the caller must have cancelled the diff --git a/backend/internal/daemon/wiring_test.go b/backend/internal/daemon/wiring_test.go index f83be0dd..3568eeb7 100644 --- a/backend/internal/daemon/wiring_test.go +++ b/backend/internal/daemon/wiring_test.go @@ -99,10 +99,7 @@ func TestWiring_SessionManagerSharesLifecycleStoreAndLCM(t *testing.T) { log := slog.New(slog.NewTextHandler(io.Discard, nil)) cfg := config.Config{DataDir: t.TempDir()} - lcStack, err := startLifecycle(ctx, store, log) - if err != nil { - t.Fatal(err) - } + lcStack := startLifecycle(ctx, store, log) // lcStack.Stop blocks on the reaper goroutine, which only exits once its // ctx is cancelled. Production main.go calls stop() before lcStack.Stop() // for the same reason — same ordering here. diff --git a/backend/internal/domain/decide/types.go b/backend/internal/domain/decide/types.go index 832fab6f..2e9a5c84 100644 --- a/backend/internal/domain/decide/types.go +++ b/backend/internal/domain/decide/types.go @@ -41,6 +41,7 @@ type ProbeInput struct { // ProcessLiveness mirrors isProcessRunning's three-valued answer. type ProcessLiveness string +// Process liveness readings. const ( ProcessAlive ProcessLiveness = "alive" ProcessDead ProcessLiveness = "dead" diff --git a/backend/internal/domain/lifecycle.go b/backend/internal/domain/lifecycle.go index a82ea85a..155c0999 100644 --- a/backend/internal/domain/lifecycle.go +++ b/backend/internal/domain/lifecycle.go @@ -52,6 +52,7 @@ type CanonicalSessionLifecycle struct { // AgentHarness identifies which agent CLI/runtime a session drives. type AgentHarness string +// Supported agent harnesses. const ( HarnessClaudeCode AgentHarness = "claude-code" HarnessCodex AgentHarness = "codex" @@ -61,8 +62,10 @@ const ( // ---- session sub-state ---- +// SessionState is the canonical lifecycle phase of a session. type SessionState string +// The canonical session states (see the package doc for the transition model). const ( SessionNotStarted SessionState = "not_started" SessionWorking SessionState = "working" @@ -81,6 +84,7 @@ const ( // the pr table, not persisted on the session. type TerminationReason string +// Termination reasons; TermNone is the non-terminal zero value. const ( TermNone TerminationReason = "" TermManuallyKilled TerminationReason = "manually_killed" @@ -92,6 +96,8 @@ const ( TermPRMerged TerminationReason = "pr_merged" ) +// SessionSubstate wraps the session phase in a struct so the persisted/CDC JSON +// shape can gain fields without a migration. type SessionSubstate struct { State SessionState `json:"state"` } @@ -115,8 +121,10 @@ type PRFacts struct { ReviewComments bool // has unresolved review comments (any author) to address } +// CIState is the aggregate CI status of a PR. type CIState string +// CI states. const ( CIUnknown CIState = "unknown" CIPending CIState = "pending" @@ -124,8 +132,10 @@ const ( CIFailing CIState = "failing" ) +// ReviewDecision is the aggregate human-review verdict on a PR. type ReviewDecision string +// Review decisions. const ( ReviewNone ReviewDecision = "none" ReviewApproved ReviewDecision = "approved" @@ -133,8 +143,10 @@ const ( ReviewRequired ReviewDecision = "review_required" ) +// Mergeability is whether a PR can currently be merged. type Mergeability string +// Mergeability states. const ( MergeUnknown Mergeability = "unknown" MergeMergeable Mergeability = "mergeable" @@ -145,8 +157,10 @@ const ( // ---- activity sub-state (decider input) ---- +// ActivityState is how busy the agent is, derived from its output/JSONL. type ActivityState string +// Activity states. WaitingInput and Blocked are sticky (see IsSticky). const ( ActivityActive ActivityState = "active" ActivityReady ActivityState = "ready" @@ -162,8 +176,11 @@ func (a ActivityState) IsSticky() bool { return a == ActivityWaitingInput || a == ActivityBlocked } +// ActivitySource records where an activity reading came from, so a weaker +// source can't override a stronger one. type ActivitySource string +// Activity signal sources, strongest first. const ( SourceNative ActivitySource = "native" SourceTerminal ActivitySource = "terminal" @@ -172,6 +189,8 @@ const ( SourceNone ActivitySource = "none" ) +// ActivitySubstate is the persisted activity reading: the state, when it was +// last observed, and which source reported it. type ActivitySubstate struct { State ActivityState `json:"state"` LastActivityAt time.Time `json:"lastActivityAt"` @@ -180,6 +199,9 @@ type ActivitySubstate struct { // ---- detecting quarantine memory (decider input) ---- +// DetectingState is the anti-flap quarantine memory carried while a session is +// detecting: how many ambiguous observations, since when, and a hash of the +// (timestamp-stripped) evidence to tell "same signal again" from "signal moved". type DetectingState struct { Attempts int `json:"attempts"` StartedAt time.Time `json:"startedAt"` diff --git a/backend/internal/domain/pr.go b/backend/internal/domain/pr.go index 77f94f27..a31b9958 100644 --- a/backend/internal/domain/pr.go +++ b/backend/internal/domain/pr.go @@ -6,7 +6,7 @@ import "time" // tables, shared by the PRWriter port and the sqlite store (the store maps them // to/from the sqlc gen.* models). They are flat by design — these tables carry // no nesting or derivation, so a single definition serves every layer. -// + // PRRow is the scalar facts of one tracked pull request (the pr table). A session // can own several PRs; a PR belongs to one session. PRFacts is the read-model // derived from these for display status; PRRow is what gets written. diff --git a/backend/internal/domain/session.go b/backend/internal/domain/session.go index 2b81088a..4d436e2a 100644 --- a/backend/internal/domain/session.go +++ b/backend/internal/domain/session.go @@ -2,16 +2,21 @@ package domain import "time" -// SessionID, ProjectID, IssueID are distinct string types so they can't be -// swapped at a call site by accident. +// These ID types are distinct string types so they can't be swapped at a call +// site by accident. type ( + // SessionID identifies a session. SessionID string + // ProjectID identifies a project. ProjectID string - IssueID string + // IssueID identifies a tracker issue. + IssueID string ) +// SessionKind distinguishes a worker session from an orchestrator session. type SessionKind string +// Session kinds. const ( KindWorker SessionKind = "worker" KindOrchestrator SessionKind = "orchestrator" diff --git a/backend/internal/domain/status.go b/backend/internal/domain/status.go index 3ae1e00c..5fa0f721 100644 --- a/backend/internal/domain/status.go +++ b/backend/internal/domain/status.go @@ -5,6 +5,7 @@ package domain // never persisted. type SessionStatus string +// The display statuses the dashboard renders. const ( StatusSpawning SessionStatus = "spawning" StatusWorking SessionStatus = "working" diff --git a/backend/internal/domain/tracker.go b/backend/internal/domain/tracker.go index 8fe0ed3b..c5f22262 100644 --- a/backend/internal/domain/tracker.go +++ b/backend/internal/domain/tracker.go @@ -6,6 +6,7 @@ package domain // NormalizedIssueState. type TrackerProvider string +// Supported tracker providers. const ( TrackerProviderGitHub TrackerProvider = "github" TrackerProviderGitLab TrackerProvider = "gitlab" @@ -27,6 +28,7 @@ type TrackerID struct { // here is a port-level decision because every adapter must map it. type NormalizedIssueState string +// The normalized cross-provider issue states. const ( IssueOpen NormalizedIssueState = "open" IssueInProgress NormalizedIssueState = "in_progress" @@ -64,6 +66,7 @@ type TrackerRepo struct { // Labels field of ListFilter. type ListStateFilter string +// Coarse list-state filters for Tracker.List. const ( // ListAll is the zero value and returns issues in any state. ListAll ListStateFilter = "" diff --git a/backend/internal/httpd/apispec/apispec_test.go b/backend/internal/httpd/apispec/apispec_test.go index b5bde562..a3072106 100644 --- a/backend/internal/httpd/apispec/apispec_test.go +++ b/backend/internal/httpd/apispec/apispec_test.go @@ -1,6 +1,7 @@ package apispec_test import ( + "net/http" "net/http/httptest" "strings" "testing" @@ -55,7 +56,7 @@ func TestOperation_InheritsPathParameters(t *testing.T) { // whole rather than reconstructing it from per-operation slices. func TestServeYAML(t *testing.T) { rec := httptest.NewRecorder() - req := httptest.NewRequest("GET", "/api/v1/openapi.yaml", nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/openapi.yaml", nil) apispec.ServeYAML(rec, req) if rec.Code != 200 { diff --git a/backend/internal/httpd/controllers/projects.go b/backend/internal/httpd/controllers/projects.go index 8fa9db1f..60e8159e 100644 --- a/backend/internal/httpd/controllers/projects.go +++ b/backend/internal/httpd/controllers/projects.go @@ -62,7 +62,7 @@ func (c *ProjectsController) list(w http.ResponseWriter, r *http.Request) { } projects, err := c.Mgr.List(r.Context()) if err != nil { - writeProjectError(w, r, err, http.StatusInternalServerError) + writeProjectError(w, r, err) return } envelope.WriteJSON(w, http.StatusOK, map[string]any{"projects": projects}) @@ -80,7 +80,7 @@ func (c *ProjectsController) add(w http.ResponseWriter, r *http.Request) { } p, err := c.Mgr.Add(r.Context(), in) if err != nil { - writeProjectError(w, r, err, http.StatusInternalServerError) + writeProjectError(w, r, err) return } envelope.WriteJSON(w, http.StatusCreated, map[string]any{"project": p}) @@ -93,7 +93,7 @@ func (c *ProjectsController) get(w http.ResponseWriter, r *http.Request) { } got, err := c.Mgr.Get(r.Context(), projectID(r)) if err != nil { - writeProjectError(w, r, err, http.StatusInternalServerError) + writeProjectError(w, r, err) return } if got.Status == "degraded" { @@ -123,7 +123,7 @@ func (c *ProjectsController) updateConfig(w http.ResponseWriter, r *http.Request } p, err := c.Mgr.UpdateConfig(r.Context(), projectID(r), patch) if err != nil { - writeProjectError(w, r, err, http.StatusInternalServerError) + writeProjectError(w, r, err) return } envelope.WriteJSON(w, http.StatusOK, map[string]any{"project": p}) @@ -136,7 +136,7 @@ func (c *ProjectsController) remove(w http.ResponseWriter, r *http.Request) { } result, err := c.Mgr.Remove(r.Context(), projectID(r)) if err != nil { - writeProjectError(w, r, err, http.StatusInternalServerError) + writeProjectError(w, r, err) return } envelope.WriteJSON(w, http.StatusOK, result) @@ -149,7 +149,7 @@ func (c *ProjectsController) repair(w http.ResponseWriter, r *http.Request) { } p, err := c.Mgr.Repair(r.Context(), projectID(r)) if err != nil { - writeProjectError(w, r, err, http.StatusInternalServerError) + writeProjectError(w, r, err) return } envelope.WriteJSON(w, http.StatusOK, map[string]any{"project": p}) @@ -162,7 +162,7 @@ func (c *ProjectsController) reload(w http.ResponseWriter, r *http.Request) { } result, err := c.Mgr.Reload(r.Context()) if err != nil { - writeProjectError(w, r, err, http.StatusInternalServerError) + writeProjectError(w, r, err) return } envelope.WriteJSON(w, http.StatusOK, result) @@ -196,10 +196,12 @@ func containsFrozenIdentityField(r *http.Request) ([]string, error) { return frozen, nil } -func writeProjectError(w http.ResponseWriter, r *http.Request, err error, fallbackStatus int) { +// writeProjectError maps a project.Error to its HTTP status, falling back to +// 500 for an unrecognized kind or a non-project.Error. +func writeProjectError(w http.ResponseWriter, r *http.Request, err error) { var pe *project.Error if errors.As(err, &pe) { - status := fallbackStatus + status := http.StatusInternalServerError switch pe.Kind { case "bad_request": status = http.StatusBadRequest @@ -215,5 +217,5 @@ func writeProjectError(w http.ResponseWriter, r *http.Request, err error, fallba envelope.WriteAPIError(w, r, status, pe.Kind, pe.Code, pe.Message, pe.Details) return } - envelope.WriteAPIError(w, r, fallbackStatus, "internal", "INTERNAL_ERROR", "Internal server error", nil) + envelope.WriteAPIError(w, r, http.StatusInternalServerError, "internal", "INTERNAL_ERROR", "Internal server error", nil) } diff --git a/backend/internal/httpd/router.go b/backend/internal/httpd/router.go index 5d132eb4..19590738 100644 --- a/backend/internal/httpd/router.go +++ b/backend/internal/httpd/router.go @@ -37,6 +37,8 @@ func NewRouter(cfg config.Config, log *slog.Logger, termMgr *terminal.Manager) c return NewRouterWithAPI(cfg, log, termMgr, APIDeps{}) } +// ControlDeps carries the daemon-control hooks the router exposes, such as the +// callback that requests a graceful shutdown. type ControlDeps struct { RequestShutdown func() } @@ -48,6 +50,8 @@ func NewRouterWithAPI(cfg config.Config, log *slog.Logger, termMgr *terminal.Man return NewRouterWithControl(cfg, log, termMgr, deps, ControlDeps{}) } +// NewRouterWithControl is NewRouterWithAPI plus daemon-control hooks: it mounts +// the same API surface and additionally wires the ControlDeps callbacks. func NewRouterWithControl(cfg config.Config, log *slog.Logger, termMgr *terminal.Manager, deps APIDeps, control ControlDeps) chi.Router { r := chi.NewRouter() diff --git a/backend/internal/httpd/server.go b/backend/internal/httpd/server.go index 0ed67eaf..a9ddcbde 100644 --- a/backend/internal/httpd/server.go +++ b/backend/internal/httpd/server.go @@ -71,7 +71,7 @@ func (s *Server) Run(ctx context.Context) error { StartedAt: time.Now().UTC(), } if err := runfile.Write(s.cfg.RunFilePath, info); err != nil { - s.listen.Close() + _ = s.listen.Close() return fmt.Errorf("write run-file: %w", err) } defer func() { diff --git a/backend/internal/integration/lifecycle_sqlite_test.go b/backend/internal/integration/lifecycle_sqlite_test.go index 67b781fb..e14a93fe 100644 --- a/backend/internal/integration/lifecycle_sqlite_test.go +++ b/backend/internal/integration/lifecycle_sqlite_test.go @@ -700,7 +700,7 @@ func assertNotificationCreatedCDC(t *testing.T, store *sqlite.Store, after int64 type pollerSource struct{ *sqlite.Store } func (s pollerSource) EventsAfter(ctx context.Context, after int64, limit int) ([]cdc.Event, error) { - rows, err := s.Store.ReadChangeLogAfter(ctx, after, limit) + rows, err := s.ReadChangeLogAfter(ctx, after, limit) if err != nil { return nil, err } @@ -718,7 +718,7 @@ func (s pollerSource) EventsAfter(ctx context.Context, after int64, limit int) ( return out, nil } func (s pollerSource) LatestSeq(ctx context.Context) (int64, error) { - return s.Store.MaxChangeLogSeq(ctx) + return s.MaxChangeLogSeq(ctx) } func anyEventType(evs []ports.Event, t string) bool { diff --git a/backend/internal/lifecycle/manager.go b/backend/internal/lifecycle/manager.go index dff0443d..19eada01 100644 --- a/backend/internal/lifecycle/manager.go +++ b/backend/internal/lifecycle/manager.go @@ -35,6 +35,9 @@ type Manager struct { var _ ports.LifecycleManager = (*Manager)(nil) +// New builds a Lifecycle Manager over its collaborators: the session store it +// is the sole writer of, the PR-facts writer, the notifier, and the messenger +// used to nudge running agents. func New(store ports.SessionStore, pr ports.PRWriter, notifier ports.Notifier, messenger ports.AgentMessenger) *Manager { return &Manager{ store: store, diff --git a/backend/internal/notification/enqueuer.go b/backend/internal/notification/enqueuer.go index 79e902bf..686490d2 100644 --- a/backend/internal/notification/enqueuer.go +++ b/backend/internal/notification/enqueuer.go @@ -24,6 +24,8 @@ type Enqueuer struct { var _ ports.Notifier = (*Enqueuer)(nil) +// NewEnqueuer returns a Notifier that renders events and persists the resulting +// notification rows via store, defaulting the logger to slog.Default. func NewEnqueuer(store Store, renderer *Renderer, logger *slog.Logger) *Enqueuer { if logger == nil { logger = slog.Default() @@ -31,6 +33,7 @@ func NewEnqueuer(store Store, renderer *Renderer, logger *slog.Logger) *Enqueuer return &Enqueuer{store: store, renderer: renderer, logger: logger} } +// Notify renders the event and enqueues the resulting notification row. func (e *Enqueuer) Notify(ctx context.Context, event ports.Event) error { row, err := e.renderer.Render(ctx, event) if err != nil { diff --git a/backend/internal/notification/payload.go b/backend/internal/notification/payload.go index 5492c19c..b4abaaca 100644 --- a/backend/internal/notification/payload.go +++ b/backend/internal/notification/payload.go @@ -17,6 +17,8 @@ type Payload struct { Merge *MergePayload `json:"merge,omitempty"` } +// SubjectPayload identifies what a notification is about — the session and, +// when relevant, its PR, issue, and branch. type SubjectPayload struct { Session *SessionSubjectPayload `json:"session,omitempty"` PR *PRSubjectPayload `json:"pr,omitempty"` @@ -24,40 +26,48 @@ type SubjectPayload struct { Branch string `json:"branch,omitempty"` } +// SessionSubjectPayload identifies the session a notification concerns. type SessionSubjectPayload struct { ID string `json:"id"` ProjectID string `json:"projectId"` } +// PRSubjectPayload identifies the PR a notification concerns. type PRSubjectPayload struct { Number int `json:"number,omitempty"` URL string `json:"url,omitempty"` Draft bool `json:"draft,omitempty"` } +// IssueSubjectPayload identifies the tracker issue a notification concerns. type IssueSubjectPayload struct { ID string `json:"id,omitempty"` } +// ReactionPayload carries the reaction that produced the notification. type ReactionPayload struct { Key string `json:"key"` Action string `json:"action"` } +// EscalationPayload carries the escalation that produced the notification. type EscalationPayload struct { Attempts int `json:"attempts"` Cause string `json:"cause"` DurationMs int64 `json:"durationMs"` } +// CIPayload is the CI context of a notification. type CIPayload struct { Status string `json:"status"` } +// ReviewPayload is the review context of a notification. type ReviewPayload struct { Decision string `json:"decision"` } +// MergePayload is the merge-readiness context of a notification. type MergePayload struct { Ready *bool `json:"ready,omitempty"` Conflicts *bool `json:"conflicts,omitempty"` diff --git a/backend/internal/notification/renderer.go b/backend/internal/notification/renderer.go index 21d41e37..e10872cf 100644 --- a/backend/internal/notification/renderer.go +++ b/backend/internal/notification/renderer.go @@ -24,10 +24,13 @@ type Renderer struct { clock func() time.Time } +// NewRenderer returns a Renderer that sources session/PR facts via reader. func NewRenderer(reader Reader) *Renderer { return &Renderer{reader: reader, clock: time.Now} } +// Render builds a durable Notification (subject + typed payload) from a +// lifecycle Event. func (r *Renderer) Render(ctx context.Context, event ports.Event) (domain.Notification, error) { if event.SessionID == "" { return domain.Notification{}, fmt.Errorf("render notification: missing session id") diff --git a/backend/internal/ports/facts.go b/backend/internal/ports/facts.go index 01a78961..b119ecf6 100644 --- a/backend/internal/ports/facts.go +++ b/backend/internal/ports/facts.go @@ -14,6 +14,8 @@ import ( // route to the detecting quarantine, never to a death conclusion. type ProbeResult string +// Probe readings. Alive/Dead are conclusions; Failed/Unknown route to the +// detecting quarantine instead of a death decision. const ( ProbeAlive ProbeResult = "alive" ProbeDead ProbeResult = "dead" diff --git a/backend/internal/ports/inbound.go b/backend/internal/ports/inbound.go index 00223ae9..fa472d00 100644 --- a/backend/internal/ports/inbound.go +++ b/backend/internal/ports/inbound.go @@ -40,6 +40,8 @@ type SessionManager interface { Cleanup(ctx context.Context, project domain.ProjectID) ([]domain.SessionID, error) } +// SpawnConfig is the request to start a new session: which project/issue, which +// agent harness, and the branch/prompt/rules the agent launches with. type SpawnConfig struct { ProjectID domain.ProjectID IssueID domain.IssueID diff --git a/backend/internal/ports/outbound.go b/backend/internal/ports/outbound.go index bc7321d3..58e1f509 100644 --- a/backend/internal/ports/outbound.go +++ b/backend/internal/ports/outbound.go @@ -44,8 +44,11 @@ type AgentMessenger interface { Send(ctx context.Context, id domain.SessionID, message string) error } +// Priority ranks a notification's urgency so a notifier can decide how loudly +// to surface it, from PriorityUrgent down to PriorityInfo. type Priority string +// Notification priorities, highest urgency first. const ( PriorityUrgent Priority = "urgent" PriorityAction Priority = "action" @@ -69,11 +72,15 @@ type Event struct { OccurredAt time.Time } +// ReactionEvent is the reaction context carried on an Event: which reaction +// fired and whether it merely notified or escalated. type ReactionEvent struct { Key string // agent-needs-input, approved-and-green, ci-failed, etc. Action string // notify | escalated } +// EscalationEvent is the escalation context carried on an Event once a reaction +// has exhausted its retry/attempt/duration budget. type EscalationEvent struct { Attempts int Cause string // max_retries | max_attempts | max_duration @@ -82,12 +89,15 @@ type EscalationEvent struct { // ---- runtime / agent / workspace plugin ports (used by the Session Manager) ---- +// Runtime is where a session's agent process runs — a tmux/zellij session or a +// bare process. The Session Manager creates one per session and tears it down. type Runtime interface { Create(ctx context.Context, cfg RuntimeConfig) (RuntimeHandle, error) Destroy(ctx context.Context, handle RuntimeHandle) error IsAlive(ctx context.Context, handle RuntimeHandle) (bool, error) } +// RuntimeConfig is the spec for launching a session's process in a Runtime. type RuntimeConfig struct { SessionID domain.SessionID WorkspacePath string @@ -95,35 +105,42 @@ type RuntimeConfig struct { Env map[string]string } +// RuntimeHandle identifies a live runtime instance (e.g. a tmux session). type RuntimeHandle struct { ID string RuntimeName string } +// Agent is the AI coding tool driving a session (claude-code, codex, …): it +// supplies the launch/restore commands and the process environment. type Agent interface { GetLaunchCommand(cfg AgentConfig) string GetEnvironment(cfg AgentConfig) map[string]string GetRestoreCommand(agentSessionID string) string } +// AgentConfig is the per-session input to an Agent's command and environment. type AgentConfig struct { SessionID domain.SessionID WorkspacePath string Prompt string } +// Workspace is the isolated checkout an agent works in (a git worktree or clone). type Workspace interface { Create(ctx context.Context, cfg WorkspaceConfig) (WorkspaceInfo, error) Destroy(ctx context.Context, info WorkspaceInfo) error Restore(ctx context.Context, cfg WorkspaceConfig) (WorkspaceInfo, error) } +// WorkspaceConfig is the spec for creating or restoring a session's workspace. type WorkspaceConfig struct { ProjectID domain.ProjectID SessionID domain.SessionID Branch string } +// WorkspaceInfo describes a created workspace — where it lives and its branch. type WorkspaceInfo struct { Path string Branch string diff --git a/backend/internal/project/manager.go b/backend/internal/project/manager.go index 93ca84d9..54c93b9a 100644 --- a/backend/internal/project/manager.go +++ b/backend/internal/project/manager.go @@ -19,6 +19,8 @@ type manager struct { var _ Manager = (*manager)(nil) +// NewManager returns a project Manager backed by the given Store, defaulting to +// an in-memory store when store is nil. func NewManager(store Store) Manager { if store == nil { store = NewMemoryStore() @@ -26,6 +28,8 @@ func NewManager(store Store) Manager { return &manager{store: store} } +// NewMemoryManager returns a project Manager backed by a fresh in-memory store, +// for tests and ephemeral use. func NewMemoryManager() Manager { return NewManager(NewMemoryStore()) } @@ -103,7 +107,7 @@ func (m *manager) Add(ctx context.Context, in AddInput) (Project, error) { }) } - row := ProjectRow{ + row := Row{ ID: string(id), Path: path, DisplayName: name, @@ -173,7 +177,7 @@ func (m *manager) suggestID(ctx context.Context, base domain.ProjectID) domain.P } } -func projectFromRow(row ProjectRow) Project { +func projectFromRow(row Row) Project { return Project{ ID: domain.ProjectID(row.ID), Name: displayName(row), @@ -183,7 +187,7 @@ func projectFromRow(row ProjectRow) Project { } } -func displayName(row ProjectRow) string { +func displayName(row Row) string { if strings.TrimSpace(row.DisplayName) != "" { return row.DisplayName } diff --git a/backend/internal/project/memory_store.go b/backend/internal/project/memory_store.go index 945a7826..e947136c 100644 --- a/backend/internal/project/memory_store.go +++ b/backend/internal/project/memory_store.go @@ -6,10 +6,10 @@ import ( "time" ) -// ProjectRow mirrors the project table shape from the sqlite storage PR. The +// Row mirrors the project table shape from the sqlite storage PR. The // memory store is intentionally row-based so the API layer does not depend on a // richer mock model than the real DB will provide. -type ProjectRow struct { +type Row struct { ID string Path string RepoOriginURL string @@ -18,11 +18,13 @@ type ProjectRow struct { ArchivedAt time.Time } +// Store is the project persistence the manager depends on; both the sqlite +// store and MemoryStore satisfy it. type Store interface { - List(ctx context.Context) ([]ProjectRow, error) - Get(ctx context.Context, id string) (ProjectRow, bool, error) - FindByPath(ctx context.Context, path string) (ProjectRow, bool, error) - Upsert(ctx context.Context, row ProjectRow) error + List(ctx context.Context) ([]Row, error) + Get(ctx context.Context, id string) (Row, bool, error) + FindByPath(ctx context.Context, path string) (Row, bool, error) + Upsert(ctx context.Context, row Row) error Archive(ctx context.Context, id string, at time.Time) (bool, error) } @@ -30,24 +32,26 @@ type Store interface { // process-local and intentionally small, but concurrency-safe for HTTP tests. type MemoryStore struct { mu sync.Mutex - projects map[string]ProjectRow + projects map[string]Row paths map[string]string } var _ Store = (*MemoryStore)(nil) +// NewMemoryStore returns an empty, ready-to-use in-memory project store. func NewMemoryStore() *MemoryStore { return &MemoryStore{ - projects: map[string]ProjectRow{}, + projects: map[string]Row{}, paths: map[string]string{}, } } -func (s *MemoryStore) List(context.Context) ([]ProjectRow, error) { +// List returns all non-archived projects, in unspecified order. +func (s *MemoryStore) List(context.Context) ([]Row, error) { s.mu.Lock() defer s.mu.Unlock() - out := make([]ProjectRow, 0, len(s.projects)) + out := make([]Row, 0, len(s.projects)) for _, row := range s.projects { if row.ArchivedAt.IsZero() { out = append(out, row) @@ -56,33 +60,36 @@ func (s *MemoryStore) List(context.Context) ([]ProjectRow, error) { return out, nil } -func (s *MemoryStore) Get(_ context.Context, id string) (ProjectRow, bool, error) { +// Get returns the project with the given id, or ok=false if absent. +func (s *MemoryStore) Get(_ context.Context, id string) (Row, bool, error) { s.mu.Lock() defer s.mu.Unlock() row, ok := s.projects[id] if !ok { - return ProjectRow{}, false, nil + return Row{}, false, nil } return row, true, nil } -func (s *MemoryStore) FindByPath(_ context.Context, path string) (ProjectRow, bool, error) { +// FindByPath returns the project registered at a filesystem path, or ok=false. +func (s *MemoryStore) FindByPath(_ context.Context, path string) (Row, bool, error) { s.mu.Lock() defer s.mu.Unlock() id, ok := s.paths[path] if !ok { - return ProjectRow{}, false, nil + return Row{}, false, nil } row, ok := s.projects[id] if !ok { - return ProjectRow{}, false, nil + return Row{}, false, nil } return row, true, nil } -func (s *MemoryStore) Upsert(_ context.Context, row ProjectRow) error { +// Upsert inserts or replaces a project, keeping the path→id index in sync. +func (s *MemoryStore) Upsert(_ context.Context, row Row) error { s.mu.Lock() defer s.mu.Unlock() @@ -94,6 +101,8 @@ func (s *MemoryStore) Upsert(_ context.Context, row ProjectRow) error { return nil } +// Archive soft-deletes a project by stamping ArchivedAt; returns ok=false if +// the project doesn't exist. func (s *MemoryStore) Archive(_ context.Context, id string, at time.Time) (bool, error) { s.mu.Lock() defer s.mu.Unlock() diff --git a/backend/internal/runfile/runfile.go b/backend/internal/runfile/runfile.go index 7dafe1be..3db84590 100644 --- a/backend/internal/runfile/runfile.go +++ b/backend/internal/runfile/runfile.go @@ -31,7 +31,7 @@ type Info struct { // partial file and a stale running.json from a crashed predecessor is // overwritten without an intermediate "no file" window. func Write(path string, info Info) error { - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { return fmt.Errorf("create run-file dir: %w", err) } data, err := json.MarshalIndent(info, "", " ") @@ -45,10 +45,10 @@ func Write(path string, info Info) error { return fmt.Errorf("create temp run-file: %w", err) } tmpName := tmp.Name() - defer os.Remove(tmpName) // no-op once the rename succeeds + defer func() { _ = os.Remove(tmpName) }() // no-op once the rename succeeds if _, err := tmp.Write(data); err != nil { - tmp.Close() + _ = tmp.Close() return fmt.Errorf("write temp run-file: %w", err) } if err := tmp.Close(); err != nil { diff --git a/backend/internal/session/manager.go b/backend/internal/session/manager.go index d7350f5f..37b1de81 100644 --- a/backend/internal/session/manager.go +++ b/backend/internal/session/manager.go @@ -14,6 +14,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) +// Sentinel errors returned by the Session Manager. var ( ErrNotFound = errors.New("session: not found") ErrNotRestorable = errors.New("session: not restorable (not terminal)") @@ -40,6 +41,7 @@ type Manager struct { var _ ports.SessionManager = (*Manager)(nil) +// Deps are the collaborators a Session Manager needs; New wires them together. type Deps struct { Runtime ports.Runtime Agent ports.Agent @@ -50,6 +52,8 @@ type Deps struct { Clock func() time.Time } +// New builds a Session Manager from its dependencies, defaulting the clock to +// time.Now when Deps.Clock is nil. func New(d Deps) *Manager { m := &Manager{ runtime: d.Runtime, @@ -184,6 +188,7 @@ func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Sess return m.Get(ctx, id) } +// List returns the project's sessions as enriched display models. func (m *Manager) List(ctx context.Context, project domain.ProjectID) ([]domain.Session, error) { recs, err := m.store.ListSessions(ctx, project) if err != nil { @@ -200,6 +205,7 @@ func (m *Manager) List(ctx context.Context, project domain.ProjectID) ([]domain. return out, nil } +// Get returns one session as a display model, or ErrNotFound if it is absent. func (m *Manager) Get(ctx context.Context, id domain.SessionID) (domain.Session, error) { rec, ok, err := m.store.GetSession(ctx, id) if err != nil { @@ -211,6 +217,7 @@ func (m *Manager) Get(ctx context.Context, id domain.SessionID) (domain.Session, return m.toSession(ctx, rec) } +// Send delivers a message to a running session's agent via the messenger. func (m *Manager) Send(ctx context.Context, id domain.SessionID, message string) error { if err := m.messenger.Send(ctx, id, message); err != nil { return fmt.Errorf("send %s: %w", id, err) diff --git a/backend/internal/storage/sqlite/db.go b/backend/internal/storage/sqlite/db.go index 7f8535bf..280b48e0 100644 --- a/backend/internal/storage/sqlite/db.go +++ b/backend/internal/storage/sqlite/db.go @@ -44,7 +44,7 @@ const maxReaders = 8 // - a READER pool (readDB, MaxOpenConns=maxReaders): all reads scale across // it; WAL readers see the latest committed snapshot. func Open(dataDir string) (*Store, error) { - if err := os.MkdirAll(dataDir, 0o755); err != nil { + if err := os.MkdirAll(dataDir, 0o750); err != nil { return nil, fmt.Errorf("create data dir: %w", err) } dsn := "file:" + filepath.Join(dataDir, "ao.db") + pragmas @@ -56,13 +56,13 @@ func Open(dataDir string) (*Store, error) { writeDB.SetMaxOpenConns(1) writeDB.SetMaxIdleConns(1) if err := migrate(writeDB); err != nil { - writeDB.Close() + _ = writeDB.Close() return nil, err } readDB, err := sql.Open("sqlite", dsn) if err != nil { - writeDB.Close() + _ = writeDB.Close() return nil, fmt.Errorf("open sqlite reader: %w", err) } readDB.SetMaxOpenConns(maxReaders) diff --git a/backend/internal/storage/sqlite/store.go b/backend/internal/storage/sqlite/store.go index 800c1824..34d028da 100644 --- a/backend/internal/storage/sqlite/store.go +++ b/backend/internal/storage/sqlite/store.go @@ -126,7 +126,7 @@ func (s *Store) inTx(ctx context.Context, what string, fn func(*gen.Queries) err if err != nil { return fmt.Errorf("begin %s: %w", what, err) } - defer tx.Rollback() + defer func() { _ = tx.Rollback() }() if err := fn(s.qw.WithTx(tx)); err != nil { return fmt.Errorf("%s: %w", what, err) } diff --git a/backend/internal/storage/sqlite/store_test.go b/backend/internal/storage/sqlite/store_test.go index 832bcfa4..426a37d2 100644 --- a/backend/internal/storage/sqlite/store_test.go +++ b/backend/internal/storage/sqlite/store_test.go @@ -248,9 +248,9 @@ func TestCDCTriggersPopulateChangeLog(t *testing.T) { if len(types) != 3 || types[0] != want[0] || types[1] != want[1] || types[2] != want[2] { t.Fatalf("change_log event types = %v, want %v (metadata-only update suppressed)", types, want) } - max, _ := s.MaxChangeLogSeq(ctx) - if max != int64(len(evs)) { - t.Fatalf("max seq = %d, want %d", max, len(evs)) + maxSeq, _ := s.MaxChangeLogSeq(ctx) + if maxSeq != int64(len(evs)) { + t.Fatalf("max seq = %d, want %d", maxSeq, len(evs)) } } diff --git a/backend/internal/terminal/ring.go b/backend/internal/terminal/ring.go index c0194a1b..ed55ca65 100644 --- a/backend/internal/terminal/ring.go +++ b/backend/internal/terminal/ring.go @@ -16,11 +16,11 @@ type ringBuffer struct { max int } -func newRingBuffer(max int) *ringBuffer { - if max <= 0 { - max = defaultRingMax +func newRingBuffer(maxBytes int) *ringBuffer { + if maxBytes <= 0 { + maxBytes = defaultRingMax } - return &ringBuffer{max: max} + return &ringBuffer{max: maxBytes} } // append adds p and drops the oldest bytes beyond max. A single write larger