Merge pull request #61 from aoagents/chore/golangci-lint
chore(ci): add golangci-lint with a strong ruleset + fix CI Go version
This commit is contained in:
commit
80f46719d9
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 != "" {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 != "" {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ package domain
|
|||
// never persisted.
|
||||
type SessionStatus string
|
||||
|
||||
// The display statuses the dashboard renders.
|
||||
const (
|
||||
StatusSpawning SessionStatus = "spawning"
|
||||
StatusWorking SessionStatus = "working"
|
||||
|
|
|
|||
|
|
@ -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 = ""
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue