fix(terminal): support PostgreSQL task terminals (#3371) (#3378)

This commit is contained in:
Carlos Florêncio
2026-09-05 10:23:53 +00:00
committed by GitHub
parent ae5461963e
commit fc532aba6a
7 changed files with 444 additions and 9 deletions
@@ -0,0 +1,146 @@
package repository
import (
"context"
"errors"
"testing"
"github.com/kandev/kandev/internal/terminal/models"
"github.com/kandev/kandev/internal/testutil"
)
// @covers AC-TASKS-TASK-TERMINALS-001.1, AC-TASKS-TASK-TERMINALS-001.2,
// AC-TASKS-TASK-TERMINALS-001.3
func TestPostgresTerminalRepositoryLifecycle(t *testing.T) {
t.Run("create get and rename", testPostgresTerminalCreateGetRename)
t.Run("list and state", testPostgresTerminalListAndState)
t.Run("delete", testPostgresTerminalDelete)
}
func newPostgresTerminalTestRepo(t *testing.T) *Repository {
t.Helper()
db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t))
repo, err := NewWithDB(db, db, nil)
if err != nil {
t.Fatalf("new postgres repository: %v", err)
}
return repo
}
func testPostgresTerminalCreateGetRename(t *testing.T) {
repo := newPostgresTerminalTestRepo(t)
ctx := context.Background()
first, err := repo.Create(ctx, "task-1", "env-1", "terminal-1", "make dev")
if err != nil {
t.Fatalf("create first terminal: %v", err)
}
second, err := repo.Create(ctx, "task-1", "env-1", "terminal-2", "")
if err != nil {
t.Fatalf("create second terminal: %v", err)
}
if first.Seq != 1 || first.State != models.StateOpen || first.InitialCommand != "make dev" {
t.Fatalf("first terminal = %#v, want seq 1, open state, and initial command", first)
}
if second.Seq != 2 {
t.Fatalf("second terminal seq = %d, want 2", second.Seq)
}
name := "build watcher"
if err := repo.Rename(ctx, first.ID, &name); err != nil {
t.Fatalf("rename terminal: %v", err)
}
got, err := repo.Get(ctx, first.ID)
if err != nil {
t.Fatalf("get terminal: %v", err)
}
if got.CustomName == nil || *got.CustomName != name {
t.Fatalf("custom name = %v, want %q", got.CustomName, name)
}
if err := repo.Rename(ctx, first.ID, nil); err != nil {
t.Fatalf("clear terminal name: %v", err)
}
got, err = repo.Get(ctx, first.ID)
if err != nil {
t.Fatalf("get terminal after clear: %v", err)
}
if got.CustomName != nil {
t.Fatalf("custom name after clear = %v, want nil", got.CustomName)
}
}
func testPostgresTerminalListAndState(t *testing.T) {
repo := newPostgresTerminalTestRepo(t)
ctx := context.Background()
first, err := repo.Create(ctx, "task-1", "env-1", "terminal-1", "")
if err != nil {
t.Fatalf("create first terminal: %v", err)
}
second, err := repo.Create(ctx, "task-1", "env-1", "terminal-2", "")
if err != nil {
t.Fatalf("create second terminal: %v", err)
}
if err := repo.SetState(ctx, second.ID, models.StateParked); err != nil {
t.Fatalf("park terminal: %v", err)
}
open, err := repo.ListByTask(ctx, "task-1", false)
if err != nil {
t.Fatalf("list open terminals: %v", err)
}
if len(open) != 1 || open[0].ID != first.ID {
t.Fatalf("open terminals = %#v, want only %q", open, first.ID)
}
all, err := repo.ListByTask(ctx, "task-1", true)
if err != nil {
t.Fatalf("list all terminals: %v", err)
}
if len(all) != 2 || all[0].ID != first.ID || all[1].ID != second.ID {
t.Fatalf("all terminals = %#v, want %q then %q", all, first.ID, second.ID)
}
if err := repo.SetState(ctx, second.ID, models.StateOpen); err != nil {
t.Fatalf("resume terminal: %v", err)
}
open, err = repo.ListByTask(ctx, "task-1", false)
if err != nil {
t.Fatalf("list resumed terminals: %v", err)
}
if len(open) != 2 {
t.Fatalf("resumed terminal count = %d, want 2", len(open))
}
}
func testPostgresTerminalDelete(t *testing.T) {
repo := newPostgresTerminalTestRepo(t)
ctx := context.Background()
first, err := repo.Create(ctx, "task-1", "env-1", "terminal-1", "")
if err != nil {
t.Fatalf("create first terminal: %v", err)
}
if _, err := repo.Create(ctx, "task-1", "env-1", "terminal-2", ""); err != nil {
t.Fatalf("create second terminal: %v", err)
}
if _, err := repo.Create(ctx, "task-2", "env-2", "terminal-3", ""); err != nil {
t.Fatalf("create other task terminal: %v", err)
}
if err := repo.Delete(ctx, first.ID); err != nil {
t.Fatalf("delete terminal: %v", err)
}
if _, err := repo.Get(ctx, first.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("get deleted terminal error = %v, want ErrNotFound", err)
}
deleted, err := repo.DeleteByTask(ctx, "task-1")
if err != nil {
t.Fatalf("delete task terminals: %v", err)
}
if deleted != 1 {
t.Fatalf("deleted task terminal count = %d, want 1", deleted)
}
other, err := repo.ListByTask(ctx, "task-2", true)
if err != nil {
t.Fatalf("list other task terminals: %v", err)
}
if len(other) != 1 || other[0].ID != "terminal-3" {
t.Fatalf("other task terminals = %#v, want terminal-3", other)
}
}
@@ -74,9 +74,9 @@ func (r *Repository) initSchema() error {
// 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)
r.db.Rebind(`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 = ?`,
FROM user_terminals WHERE task_id = ?`),
id, taskID, envID, initialCommand, taskID,
); err != nil {
return nil, fmt.Errorf("insert terminal: %w", err)
@@ -89,8 +89,8 @@ func (r *Repository) Create(ctx context.Context, taskID, envID, id, initialComma
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 = ?`,
r.ro.Rebind(`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) {
@@ -112,6 +112,7 @@ func (r *Repository) ListByTask(ctx context.Context, taskID string, includeParke
q += ` AND state = 'open'`
}
q += ` ORDER BY seq ASC`
q = r.ro.Rebind(q)
var rows []*models.Terminal
if err := r.ro.SelectContext(ctx, &rows, q, args...); err != nil {
@@ -130,7 +131,7 @@ func (r *Repository) Rename(ctx context.Context, id string, name *string) error
nameArg = *name
}
res, err := r.db.ExecContext(ctx,
`UPDATE user_terminals SET custom_name = ? WHERE id = ?`,
r.db.Rebind(`UPDATE user_terminals SET custom_name = ? WHERE id = ?`),
nameArg, id,
)
if err != nil {
@@ -147,7 +148,7 @@ func (r *Repository) Rename(ctx context.Context, id string, name *string) error
// 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 = ?`,
r.db.Rebind(`UPDATE user_terminals SET state = ? WHERE id = ?`),
string(state), id,
)
if err != nil {
@@ -162,7 +163,7 @@ func (r *Repository) SetState(ctx context.Context, id string, state models.Termi
// 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)
_, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM user_terminals WHERE id = ?`), id)
if err != nil {
return fmt.Errorf("delete terminal: %w", err)
}
@@ -172,7 +173,7 @@ func (r *Repository) Delete(ctx context.Context, id string) error {
// 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)
res, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM user_terminals WHERE task_id = ?`), taskID)
if err != nil {
return 0, fmt.Errorf("delete by task: %w", err)
}
@@ -0,0 +1,78 @@
---
created: 2026-09-04
status: done
requirements:
- REQ-TASKS-TASK-TERMINALS-001
system_design:
- ../../specs/tasks/system-design/task-terminal-persistence.md
legacy_specs: []
---
# Implementation Plan: PostgreSQL Task Terminal Queries
## Overview
Make every parameterized ordinary task-terminal query use placeholder syntax
for the selected database driver. One work order adds PostgreSQL lifecycle
coverage first, applies the mechanical repository correction, and runs the
targeted backend checks.
## Scope
### In scope
- Add an environment-gated PostgreSQL regression for the ordinary terminal
repository lifecycle.
- Rebind every parameterized read and write in the ordinary terminal
repository.
- Preserve existing SQLite behavior and error wrapping.
### Out of scope
- Changes to terminal service, WebSocket handlers, or web components.
- Changes to terminal sequence allocation or concurrency semantics.
- New database schema or migrations.
- New terminal error presentation.
## Technical approach
Add `postgres_test.go` beside the existing SQLite repository tests. The test
constructs the real repository over an isolated PostgreSQL schema and exercises
all parameterized lifecycle methods. It must first fail with PostgreSQL syntax
error `42601` on the current implementation.
Update `apps/backend/internal/terminal/repository/sqlite.go` so reads call
`r.ro.Rebind` and writes call `r.db.Rebind` before passing statements to sqlx.
Do not change schema statements because they have no placeholders.
## Tests
- `AC-TASKS-TASK-TERMINALS-001.1` maps to PostgreSQL create and get assertions
plus the existing SQLite sequence tests.
- `AC-TASKS-TASK-TERMINALS-001.2` maps to PostgreSQL open/all list assertions
and ordered sequence assertions.
- `AC-TASKS-TASK-TERMINALS-001.3` maps to PostgreSQL rename, state, single
delete, and task-wide delete assertions.
## E2E tests
The existing browser terminal suites cover the unchanged user, WebSocket, and
service flow. The new real-PostgreSQL lifecycle test covers the driver boundary
that caused the end-to-end failure. No frontend or protocol behavior changes.
## Work orders
- [x] [Task 01: Rebind Task Terminal Queries](task-01-rebind-task-terminal-queries.md)
## Verification results
- The PostgreSQL lifecycle regression passed three subtests on PostgreSQL 16.
- The terminal package suite passed 29 tests in three packages.
- The changed-file backend linter reported zero issues.
## Risks
- Omitting one parameterized method can leave a later lifecycle action broken
on PostgreSQL even if creation starts working.
- PostgreSQL integration coverage skips when `KANDEV_TEST_POSTGRES_DSN` is not
configured, so the implementation run must provide an isolated database.
@@ -0,0 +1,89 @@
---
id: "01-rebind-task-terminal-queries"
title: "Rebind Task Terminal Queries"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-TASKS-TASK-TERMINALS-001
acceptance_criteria:
- AC-TASKS-TASK-TERMINALS-001.1
- AC-TASKS-TASK-TERMINALS-001.2
- AC-TASKS-TASK-TERMINALS-001.3
system_design:
- ../../specs/tasks/system-design/task-terminal-persistence.md
---
# Task 01: Rebind Task Terminal Queries
## Summary
Add a real PostgreSQL regression for the ordinary terminal descriptor
lifecycle. Then make each parameterized statement use placeholder syntax for
the selected driver. Preserve the current SQLite and terminal-service contracts.
## In scope
- Add PostgreSQL create, get, list, rename, state, removal, and task-wide removal
lifecycle assertions.
- Rebind all parameterized reads through the reader pool.
- Rebind all parameterized writes through the writer pool.
## Out of scope
- Schema, API, service, PTY, WebSocket, frontend, and localization changes.
- Sequence-allocation redesign or new concurrency behavior.
- Broad repository verification beyond the task-defined checks.
## Acceptance
- The PostgreSQL lifecycle regression fails before the production correction
with SQLSTATE `42601` and passes after it.
- Every parameterized ordinary terminal repository method works against both
SQLite and PostgreSQL with unchanged results.
- The targeted terminal packages and changed-file backend lint pass.
## Verification
```bash
go test ./internal/terminal/... -count=1
KANDEV_TEST_POSTGRES_DSN=<isolated-dsn> go test ./internal/terminal/repository -run TestPostgresTerminalRepositoryLifecycle -count=1 -v
golangci-lint run ./... --new-from-rev="$(git merge-base HEAD origin/main)" --timeout=5m
```
## Files likely touched
- `apps/backend/internal/terminal/repository/sqlite.go`
- `apps/backend/internal/terminal/repository/postgres_test.go`
## Dependencies
None.
## Risks
- The PostgreSQL test must use a real isolated schema so a SQLite-only or mock
test cannot hide placeholder syntax errors.
## Parallelism
`sequential`
## Inputs
- `REQ-TASKS-TASK-TERMINALS-001` and its acceptance criteria.
- `docs/specs/tasks/system-design/task-terminal-persistence.md`.
- GitHub issue #3371 and the isolated PostgreSQL reproduction evidence.
- Existing terminal repository tests and `internal/testutil` PostgreSQL helpers.
## Results
- Added `postgres_test.go` with create, get, list, rename, state, single-removal,
and task-wide removal coverage.
- Added driver rebinding to `Create`, `Get`, `ListByTask`, `Rename`, `SetState`,
`Delete`, and `DeleteByTask`.
- `TestPostgresTerminalRepositoryLifecycle` failed before the correction with
SQLSTATE `42601`. It passed all three subtests after the correction.
- `go test ./internal/terminal/... -count=1` passed 29 tests in three packages.
- The changed-file backend linter reported zero issues.
+3 -1
View File
@@ -76,6 +76,7 @@ signals, and task-scoped scheduling contracts.
- [Rich task title previews](requirements/rich-task-title-previews.md)
- [Queued run scheduling](requirements/run-scheduling.md)
- [Task Runtime Cleanup](requirements/runtime-cleanup.md)
- [Task Terminal Persistence](requirements/task-terminal-persistence.md)
- [Runtime Task-State Publication Order](requirements/runtime-state-publication-order.md)
- [Session Delete Preserves Task Workspaces](requirements/session-delete-resource-cleanup.md)
- [Sidebar Task Editing](requirements/sidebar-task-edit.md)
@@ -142,6 +143,7 @@ signals, and task-scoped scheduling contracts.
- [Task plan write consistency](system-design/plan-write-consistency.md)
- [Task plan write lifecycle](system-design/plan-write-lifecycle.md)
- [Task Runtime Cleanup](system-design/runtime-cleanup.md)
- [Task Terminal Persistence](system-design/task-terminal-persistence.md)
- [Runtime Task-State Publication Order](system-design/runtime-state-publication-order.md)
- [Queued Run Scheduling](system-design/run-scheduling.md)
- [Session Delete Preserves Task Workspaces](system-design/session-delete-resource-cleanup.md)
@@ -158,7 +160,7 @@ signals, and task-scoped scheduling contracts.
## Migration record
Migration remains in progress. The six requirements above now have
Migration remains in progress. The seven requirements above now have
authoritative, wrapper-free requirement/design pairs. Other migrated files still
need the same extraction before this system can return to a complete migration
state.
@@ -0,0 +1,45 @@
---
status: active
system: tasks
created: 2026-09-04
owners:
- kandev
---
# Task Terminal Persistence Requirements
## Overview
Ordinary task terminals are durable task-scoped resources. The task system owns
their identity and lifecycle. Each record belongs to one task and its execution
environment. These operations must remain available on every supported
persistence backend.
## Requirements
### REQ-TASKS-TASK-TERMINALS-001: Persistence backend parity
**Intent:** Users can create and manage ordinary task terminals without the
configured supported persistence backend changing the terminal lifecycle.
**User story:** As a Kandev user, I want task terminals to work on each supported
database.
#### Acceptance criteria
- **AC-TASKS-TASK-TERMINALS-001.1:** On SQLite and PostgreSQL, the system shall
persist and return each new ordinary task terminal with its sequence and open
state.
- **AC-TASKS-TASK-TERMINALS-001.2:** On SQLite and PostgreSQL, the system shall
list matching task terminals in sequence order and apply the requested parked
terminal filter.
- **AC-TASKS-TASK-TERMINALS-001.3:** On SQLite and PostgreSQL, each read, rename,
state change, single removal, and task-wide removal shall produce the same
persisted result.
## Out of scope
- Changing terminal numbering or concurrent sequence-allocation semantics.
- Changing PTY process lifetime, output buffering, or WebSocket transport.
- Changing how the web application presents a terminal persistence failure.
- Changing Quick Terminal or agent-login terminal behavior.
@@ -0,0 +1,74 @@
---
status: current
system: tasks
requirements:
- REQ-TASKS-TASK-TERMINALS-001
---
# Task Terminal Persistence System Design
## Purpose and boundaries
This design defines persistence-backend parity for ordinary task-terminal
records. The existing terminal service and WebSocket handlers continue to own
validation, PTY lifecycle, authorization, and client responses. The repository
continues to own the `user_terminals` table and its task-scoped lifecycle.
## Requirement mapping
| Requirement | Design section |
| --- | --- |
| `REQ-TASKS-TASK-TERMINALS-001` | [Portable query execution](#portable-query-execution), [Test contract](#test-contract) |
## Components and responsibilities
- `apps/backend/internal/terminal/repository` persists ordinary terminal
descriptors and initializes `user_terminals`.
- `apps/backend/internal/terminal/service` preserves the existing ordinary,
fixed, and script-terminal boundaries and delegates descriptor persistence to
the repository.
- `apps/backend/internal/agent/handlers` exposes the existing `user_shell.*`
WebSocket actions and propagates repository failures.
## Portable query execution
The terminal repository keeps `?` as the source placeholder syntax. Before a
parameterized statement reaches the database driver, the repository passes it
through the `sqlx.DB` associated with that operation:
- Writer operations use `r.db.Rebind`.
- Read operations use `r.ro.Rebind`.
- Schema statements contain no parameters and execute unchanged.
This follows the established repository convention. SQLite retains `?`
placeholders, while PostgreSQL receives numbered placeholders. Query text,
arguments, ordering, filters, and lifecycle semantics otherwise remain
unchanged.
## Failure and recovery
Database execution errors continue to carry the current repository operation
context to the terminal service and WebSocket caller. The repair does not add a
retry or convert a failed persistence operation into an in-memory terminal.
## Persistence
The `user_terminals` schema, unique task-sequence constraint, state values, and
delete behavior remain unchanged. This repair changes only how parameterized
statements are prepared for the selected driver and requires no migration.
## Test contract
An environment-gated PostgreSQL repository test uses
`internal/testutil.OpenIsolatedPostgres` and exercises the complete descriptor
lifecycle: create, get, filtered and unfiltered list, rename, state changes,
single delete, and task-wide delete. The existing SQLite tests continue to pin
sequence allocation and lifecycle behavior. Existing browser terminal tests
cover the unchanged WebSocket and web flow. The PostgreSQL test covers the only
driver-specific boundary changed by this repair.
## Related decisions
No architecture decision record is required. Query rebinding is an established
repository convention, and this repair introduces no new ownership or
persistence boundary.