chore(backend): add golangci-lint with a strong ruleset and clear the tree

Introduces backend/.golangci.yml (27 linters across correctness, dead-code/
boilerplate, style, and security), wires it into CI as a blocking job, and
fixes every finding so the tree starts at zero.

Config:
- 27 linters: errcheck, govet, staticcheck, errorlint, bodyclose,
  sqlclosecheck, rowserrcheck, nilerr, makezero, unused, unparam, unconvert,
  wastedassign, copyloopvar, prealloc, dupl, revive (incl. exported-symbol doc
  comments), gocritic, misspell, usestdlibvars, predeclared, nakedret, gosec, …
- Tuned for signal over noise: govet/shadow and gocritic hugeParam/rangeValCopy/
  unnamedResult disabled (idiomatic-Go false positives); sqlc-generated code and
  tests get scoped exclusions; gosec G304 excluded (paths are config/run-file/
  worktree-derived, not user input); nilerr excluded in cli/status.go (probe
  failures are the reported status, not a command error).

CI:
- New blocking lint job (golangci-lint-action, latest binary for Go-version
  compatibility).
- go-version now read from go.mod (was pinned 1.22 while go.mod declares 1.25).

Cleanup to reach zero (no behavior change):
- errcheck: wrap deferred/inline Close()/Remove()/Rollback() with `_ =`.
- gosec: tighten dir/file perms (0755->0750, 0644->0600).
- unparam: drop always-nil error return from startLifecycle; drop unused
  shellPath param (zellij PowerShell) and always-500 fallbackStatus param
  (writeProjectError).
- gocritic: regexp \d, s != "", switch->if, combined appends.
- revive: doc comments on all exported symbols; rename project.ProjectRow ->
  project.Row (stutter); rename `max` locals shadowing the builtin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
prateek 2026-06-01 04:04:57 +05:30 committed by itrytoohard
parent c4bbbf73c4
commit 8df074b1c9
43 changed files with 378 additions and 83 deletions

View File

@ -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.

115
backend/.golangci.yml Normal file
View File

@ -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

View File

@ -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)

View File

@ -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")

View File

@ -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

View File

@ -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 != "" {

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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
}

View File

@ -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,

View File

@ -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)
}

View File

@ -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)
}

View File

@ -14,6 +14,8 @@ var (
Date = ""
)
// VersionString renders the build metadata as "<version> commit <c> built <d>",
// omitting the commit/date parts when they are unset.
func VersionString() string {
parts := []string{Version}
if Commit != "" {

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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"

View File

@ -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"`

View File

@ -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.

View File

@ -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 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"

View File

@ -5,6 +5,7 @@ package domain
// never persisted.
type SessionStatus string
// The display statuses the dashboard renders.
const (
StatusSpawning SessionStatus = "spawning"
StatusWorking SessionStatus = "working"

View File

@ -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 = ""

View File

@ -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 {

View File

@ -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)
}

View File

@ -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()

View File

@ -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() {

View File

@ -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 {

View File

@ -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,

View File

@ -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 {

View File

@ -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"`

View File

@ -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")

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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
}

View File

@ -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()

View File

@ -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 {

View File

@ -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)

View File

@ -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)

View File

@ -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)
}

View File

@ -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))
}
}

View File

@ -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