Files
kandev/apps/backend/internal/terminal/repository/sqlite.go
T
João Salavisa cab7b28ed9 chore: round 4 dead-code cleanup (transport helpers, detect, orphan methods) (#1156)
* chore(agentctl): delete duplicate procattr files in adapter package

setProcGroup and killProcessGroup are defined in both
agentctl/server/adapter/ and agentctl/server/process/. The process/
copies are the live ones (called by process/runner.go, manager.go,
vscode.go); the adapter/ copies have zero callers and are removed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(agentctl): remove orphan helpers from transport/shared

Left over from the ACP refactor. All had zero callers:
- DebugEnabled(): internal debugMode var is checked directly by
  LogRawEvent/LogNormalizedEvent — the exported wrapper was unused.
- GetBool, GetMap, GetSlice: only GetString/GetInt are called in acp/normalize.go.
- NormalizeShellResult, NormalizeReadResult, NormalizeCodeSearchResult,
  NormalizeModifyResult: normalization moved to acp/normalize.go.
- TruncateIfNeeded, MaxContentLength, MaxFileCount: only used by the
  four Normalize* helpers above; removed transitively.
- TraceControlMessage: zero callers in the adapter transport layer.

GenerateUnifiedDiff, SplitLines, DetectLanguage, Tracer, ShutdownTracing,
TraceProtocolEvent, TraceProtocolRequest are alive and kept.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(agentctl): remove unused tool_payload constructors

NewHttpRequest, NewCreateTask, NewSubagentTask, NewShowPlan,
NewManageTodos, and NewMisc have zero callers — ACP normalization
builds NormalizedPayload via struct literals and JSON unmarshaling,
not these factory functions. NewReadFile, NewModifyFile, NewShellExec,
NewCodeSearch, and NewGeneric are kept (live callers in acp/normalize.go).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(agents/detect): remove dead fluent options and OSPaths type

WithFileExists, WithCommandOutput, WithEnvVar, expandHomePath (their
transitive helper), and OSPaths (struct + Resolve + Expanded) have
zero callers. Only WithCommand and WithNpxRunnable are used by agent
IsInstalled implementations. Imports trimmed to context and os/exec.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(office): remove dead channel methods superseded by service/

ListComments, DeleteComment, and UpdateChannelStatus on ChannelService
have zero callers — the live implementations live in
internal/office/service/comments.go and service/channels.go.
CreateComment is kept: it is called by HandleChannelInbound.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: remove scattered orphan helpers

Remove dead single-symbol exports verified to have zero callers:
- ParseCronExpression (office/shared/cron.go): NextCronTime covers
  all live usages; the validator wrapper was unused.
- IsValidPathComponent, MustJSON (office/shared/helpers.go): zero
  callers; GenerateSlug is kept.
- DiscoverUserSkillsAt (office/skills/userhome_discovery.go): exported
  wrapper over the private discoverUserSkillsAt; only the internal
  DiscoverUserSkills method is wired up.
- Manager.Get (agent/loginpty/manager.go): zero callers; GetByID and
  Stop are kept (Stop is tested in manager_test.go).
- DetachedWithValues (common/appctx/appctx.go): zero callers; Detached
  is kept.
- Repository.Close no-ops (task/share, terminal/repository): both are
  documented no-ops with zero callers.
- TaskBranchName (worktree/config.go): zero callers; the deterministic
  TaskBranchNameWithSuffix is kept.

Note: dialect.Now was initially targeted but is tested in
dialect_test.go (same-package call — missed by package-qualified grep).
It remains unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(share): remove stale Close reference from provider.go comment

Repository.Close() was removed as a no-op in the round-4 cleanup.
The comment in provider.go still referenced it by name; updated to
reflect that the repo simply doesn't own its DB connections.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(office/shared): delete helpers.go — GenerateSlug had zero callers

The triage step mistakenly identified skills/service.go as a caller,
but that package defines its own local GenerateSlug (different regex,
adds TrimSpace and a 'skill' fallback default). shared.GenerateSlug
was never called anywhere. helpers.go is now empty; delete the file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(worktree): fix stale TaskBranchName reference in TaskBranchNameWithSuffix doc

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:18:10 +01:00

182 lines
5.7 KiB
Go

// Package repository persists ordinary user terminals in SQLite.
//
// The bottom-panel and script terminals are not persisted here — guards in
// the terminal service decide which terminals get a row. This package is
// scope-agnostic; it just stores what it's told.
package repository
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/terminal/models"
)
// ErrNotFound is returned by Get when no terminal matches the id.
var ErrNotFound = errors.New("terminal not found")
// Repository persists user terminals.
type Repository struct {
db *sqlx.DB // writer
ro *sqlx.DB // reader
log *logger.Logger
}
// NewWithDB constructs a Repository sharing the supplied writer/reader pools.
// initSchema creates the table on first call; subsequent calls are no-ops.
func NewWithDB(writer, reader *sqlx.DB, log *logger.Logger) (*Repository, error) {
r := &Repository{db: writer, ro: reader, log: log}
if err := r.initSchema(); err != nil {
return nil, fmt.Errorf("init terminal schema: %w", err)
}
return r, nil
}
func (r *Repository) initSchema() error {
stmts := []string{
`CREATE TABLE IF NOT EXISTS user_terminals (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
environment_id TEXT NOT NULL,
seq INTEGER NOT NULL,
custom_name TEXT,
state TEXT NOT NULL DEFAULT 'open',
initial_command TEXT NOT NULL DEFAULT '',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(task_id, seq)
)`,
`CREATE INDEX IF NOT EXISTS idx_user_terminals_task ON user_terminals(task_id)`,
`CREATE INDEX IF NOT EXISTS idx_user_terminals_env ON user_terminals(environment_id)`,
}
for _, s := range stmts {
if _, err := r.db.Exec(s); err != nil {
return err
}
}
return nil
}
// Create inserts a new terminal row. The seq is computed as MAX(seq)+1 for
// the task — never reused, so deletes leave gaps.
//
// Seq computation and insertion are folded into a single INSERT … SELECT
// statement so the MAX read and the new row write happen atomically inside
// SQLite's per-statement transaction. Two concurrent creates for the same
// task can no longer pick the same seq — the second statement reads the
// first's already-inserted row.
//
// The caller supplies the id (must be a UUID that's also used as the
// agentctl PTY id). initialCommand is "" for plain shells.
func (r *Repository) Create(ctx context.Context, taskID, envID, id, initialCommand string) (*models.Terminal, error) {
if _, err := r.db.ExecContext(ctx,
`INSERT INTO user_terminals (id, task_id, environment_id, seq, custom_name, state, initial_command)
SELECT ?, ?, ?, COALESCE(MAX(seq), 0) + 1, NULL, 'open', ?
FROM user_terminals WHERE task_id = ?`,
id, taskID, envID, initialCommand, taskID,
); err != nil {
return nil, fmt.Errorf("insert terminal: %w", err)
}
return r.Get(ctx, id)
}
// Get returns the terminal by id, or ErrNotFound.
func (r *Repository) Get(ctx context.Context, id string) (*models.Terminal, error) {
var t models.Terminal
err := r.ro.GetContext(ctx, &t,
`SELECT id, task_id, environment_id, seq, custom_name, state, initial_command, created_at
FROM user_terminals WHERE id = ?`,
id,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("get terminal: %w", err)
}
return &t, nil
}
// ListByTask returns terminals for taskID ordered by seq ascending.
// includeParked controls whether parked rows are included.
func (r *Repository) ListByTask(ctx context.Context, taskID string, includeParked bool) ([]*models.Terminal, error) {
q := `SELECT id, task_id, environment_id, seq, custom_name, state, initial_command, created_at
FROM user_terminals WHERE task_id = ?`
args := []any{taskID}
if !includeParked {
q += ` AND state = 'open'`
}
q += ` ORDER BY seq ASC`
var rows []*models.Terminal
if err := r.ro.SelectContext(ctx, &rows, q, args...); err != nil {
return nil, fmt.Errorf("list terminals: %w", err)
}
return rows, nil
}
// Rename sets or clears the custom_name. Pass nil to clear, a non-empty
// string to set.
func (r *Repository) Rename(ctx context.Context, id string, name *string) error {
var nameArg any
if name == nil {
nameArg = nil
} else {
nameArg = *name
}
res, err := r.db.ExecContext(ctx,
`UPDATE user_terminals SET custom_name = ? WHERE id = ?`,
nameArg, id,
)
if err != nil {
return fmt.Errorf("rename terminal: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
// SetState updates the open/parked state. Does not touch any PTY — that's
// the service's job.
func (r *Repository) SetState(ctx context.Context, id string, state models.TerminalState) error {
res, err := r.db.ExecContext(ctx,
`UPDATE user_terminals SET state = ? WHERE id = ?`,
string(state), id,
)
if err != nil {
return fmt.Errorf("set state: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
// Delete removes a single terminal row.
func (r *Repository) Delete(ctx context.Context, id string) error {
_, err := r.db.ExecContext(ctx, `DELETE FROM user_terminals WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete terminal: %w", err)
}
return nil
}
// DeleteByTask removes every row for a task and returns the count. Used by
// the task.deleted cascade subscriber.
func (r *Repository) DeleteByTask(ctx context.Context, taskID string) (int, error) {
res, err := r.db.ExecContext(ctx, `DELETE FROM user_terminals WHERE task_id = ?`, taskID)
if err != nil {
return 0, fmt.Errorf("delete by task: %w", err)
}
n, _ := res.RowsAffected()
return int(n), nil
}