cab7b28ed9
* 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>
182 lines
5.7 KiB
Go
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
|
|
}
|