Skip to content
Open
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
9ed994f
feat: add workspace sources from files panel
carlosflorencio Jul 23, 2026
23a16e8
fix: preserve worktree identity during recovery
carlosflorencio Jul 23, 2026
db0c7ca
feat: unify workspace source actions
carlosflorencio Jul 23, 2026
09b76c4
fix: validate recovered workspace repositories
carlosflorencio Jul 23, 2026
1eef89c
fix: harden workspace source filesystem boundaries
carlosflorencio Jul 23, 2026
c31d674
test: split workspace repository chip coverage
carlosflorencio Jul 23, 2026
6f1755c
fix: restrict repository rescan recovery paths
carlosflorencio Jul 23, 2026
2794b13
fix: stabilize workspace source CI checks
carlosflorencio Jul 23, 2026
61ee0e4
test: canonicalize workspace source paths
carlosflorencio Jul 23, 2026
a0a5bc4
test: align workspace source container paths
carlosflorencio Jul 23, 2026
2ce34f0
fix: populate direct workspace repository names
carlosflorencio Jul 24, 2026
58477c0
test: preserve git fixture rewrites across restarts
carlosflorencio Jul 24, 2026
e60f1fd
test: isolate backend git fixture config
carlosflorencio Jul 24, 2026
4bf2d88
fix: resume remote workspace executions before creation
carlosflorencio Jul 24, 2026
63208fd
fix: preserve ssh agent identity during resume
carlosflorencio Jul 24, 2026
0b230e0
fix: preserve SSH workspace across backend restart
carlosflorencio Jul 24, 2026
e4e4e0f
fix: simplify workspace actions trigger
carlosflorencio Jul 24, 2026
3f1bc79
fix: refine add workspace repositories UX
carlosflorencio Jul 24, 2026
9e85078
docs: narrow workspace source UX updates
carlosflorencio Jul 24, 2026
60741dc
fix: avoid phantom turn on session resume
carlosflorencio Jul 24, 2026
daa1fd7
test(web): normalize workspace source request headers
carlosflorencio Jul 24, 2026
75dbcc6
feat(web): streamline workspace source picker
carlosflorencio Jul 24, 2026
50e64ce
docs: remove trailing spaces from workspace source plan
carlosflorencio Jul 24, 2026
da833fe
fix: prevent resume status from creating phantom turns
carlosflorencio Jul 24, 2026
714ca37
fix(web): stabilize workspace source attachment
carlosflorencio Jul 24, 2026
5881565
refactor(web): extract workspace source store contract
carlosflorencio Jul 24, 2026
326a719
fix: avoid phantom turns on idle agent resume
carlosflorencio Jul 25, 2026
7c4963d
fix: address backend static analysis findings
carlosflorencio Jul 25, 2026
a59ebe0
fix: preserve agentctl readiness status value
carlosflorencio Jul 25, 2026
a7b18db
fix: preserve resume messages without active turns
carlosflorencio Jul 25, 2026
a1534cb
fix: resume agents from promoted workspace root
carlosflorencio Jul 25, 2026
5c86f99
Merge remote-tracking branch 'origin/main' into feature/add-new-repo-…
carlosflorencio Jul 25, 2026
6196400
chore: merge origin/main into feature/add-new-repo-folder-d97
carlosflorencio Jul 25, 2026
5098751
feat(web): explain workspace source consequences
carlosflorencio Jul 25, 2026
59a101a
test(websocket): count session activity subscription
carlosflorencio Jul 25, 2026
aeb185e
Merge remote-tracking branch 'origin/main' into feature/add-new-repo-…
carlosflorencio Jul 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package client

import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
)

// MaterializeRepositoryRequest is the credential-free repository checkout
// request accepted by agentctl. It intentionally has no credential field;
// lifecycle wiring supplies Git credentials separately when that is needed.
type MaterializeRepositoryRequest struct {
RepositoryURL string `json:"repository_url"`
Destination string `json:"destination"`
BaseBranch string `json:"base_branch"`
CheckoutBranch string `json:"checkout_branch,omitempty"`
}

// MaterializeRepositoryResponse reports the adopted workspace subdirectory.
type MaterializeRepositoryResponse struct {
Destination string `json:"destination"`
Reused bool `json:"reused,omitempty"`
Error string `json:"error,omitempty"`
}

// RemoveMaterializedRepositoryRequest identifies an owned checkout for
// rollback after a later item in a remote materialization batch fails.
type RemoveMaterializedRepositoryRequest struct {
RepositoryURL string `json:"repository_url"`
Destination string `json:"destination"`
}

type removeMaterializedRepositoryResponse struct {
Removed bool `json:"removed"`
Error string `json:"error,omitempty"`
}

// WorkspaceMaterializationError preserves an actionable remote status without
// retaining or formatting the repository locator supplied by the caller.
type WorkspaceMaterializationError struct {
StatusCode int
Message string
}

func (e *WorkspaceMaterializationError) Error() string {
return fmt.Sprintf("workspace repository materialization failed (%d): %s", e.StatusCode, e.Message)
}

// MaterializeRepository asks the live agentctl instance to atomically clone
// and check out a repository under its current workspace root.
func (c *Client) MaterializeRepository(ctx context.Context, materialization MaterializeRepositoryRequest) (*MaterializeRepositoryResponse, error) {
body, err := json.Marshal(materialization)
if err != nil {
return nil, fmt.Errorf("marshal workspace materialization request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/workspace/materialize-repository", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("create workspace materialization request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("send workspace materialization request: %w", err)
}
defer func() { _ = resp.Body.Close() }()

var response MaterializeRepositoryResponse
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
return nil, fmt.Errorf("decode workspace materialization response: %w", err)
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices || response.Error != "" {
message := response.Error
if message == "" {
message = "remote agentctl rejected the request"
}
return &response, &WorkspaceMaterializationError{StatusCode: resp.StatusCode, Message: message}
}
return &response, nil
}

// RemoveMaterializedRepository removes only a checkout whose destination and
// origin match the supplied credential-free request. Nonexistent destinations
// are treated as a successful idempotent rollback by agentctl.
func (c *Client) RemoveMaterializedRepository(ctx context.Context, removal RemoveMaterializedRepositoryRequest) error {
body, err := json.Marshal(removal)
if err != nil {
return fmt.Errorf("marshal workspace cleanup request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/workspace/materialize-repository/remove", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("create workspace cleanup request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("send workspace cleanup request: %w", err)
}
defer func() { _ = resp.Body.Close() }()

var response removeMaterializedRepositoryResponse
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
return fmt.Errorf("decode workspace cleanup response: %w", err)
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices || response.Error != "" {
message := response.Error
if message == "" {
message = "remote agentctl rejected the cleanup request"
}
return &WorkspaceMaterializationError{StatusCode: resp.StatusCode, Message: message}
}
return nil
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package client

import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"

"github.com/kandev/kandev/internal/common/logger"
)

func TestMaterializeRepository_ReturnsTypedRemoteError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer test-token" {
t.Fatalf("authorization = %q", got)
}
var body MaterializeRepositoryRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body.BaseBranch != "main" || body.CheckoutBranch != "feature/work" {
t.Fatalf("branches = base:%q checkout:%q", body.BaseBranch, body.CheckoutBranch)
}
w.WriteHeader(http.StatusConflict)
_, _ = w.Write([]byte(`{"error":"destination already exists"}`))
}))
defer server.Close()

log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
if err != nil {
t.Fatal(err)
}
c := &Client{baseURL: server.URL, httpClient: server.Client(), logger: log}
c.httpClient.Transport = &authTransport{token: "test-token", base: c.httpClient.Transport}
_, err = c.MaterializeRepository(context.Background(), MaterializeRepositoryRequest{
RepositoryURL: "https://github.com/kdlbs/kandev.git",
Destination: "kandev",
BaseBranch: "main",
CheckoutBranch: "feature/work",
})

var remoteErr *WorkspaceMaterializationError
if !errors.As(err, &remoteErr) {
t.Fatalf("expected WorkspaceMaterializationError, got %v", err)
}
if remoteErr.StatusCode != http.StatusConflict || remoteErr.Message != "destination already exists" {
t.Fatalf("error = %#v", remoteErr)
}
}

func TestRemoveMaterializedRepository_UsesCleanupEndpoint(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/workspace/materialize-repository/remove" {
t.Fatalf("request = %s %s", r.Method, r.URL.Path)
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"removed":true}`))
}))
defer server.Close()
log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
if err != nil {
t.Fatal(err)
}
c := &Client{baseURL: server.URL, httpClient: server.Client(), logger: log}

err = c.RemoveMaterializedRepository(context.Background(), RemoveMaterializedRepositoryRequest{
RepositoryURL: "https://github.com/kdlbs/kandev.git",
Destination: "kandev",
})
if err != nil {
t.Fatalf("RemoveMaterializedRepository: %v", err)
}
}

func TestRescanWorkspace_UsesCurrentRootWhenWorkDirIsEmpty(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/workspace/rescan" {
t.Fatalf("request = %s %s", r.Method, r.URL.Path)
}
var body struct {
WorkDir string `json:"work_dir"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body.WorkDir != "" {
t.Fatalf("work_dir = %q, want current root", body.WorkDir)
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
if err != nil {
t.Fatal(err)
}
c := &Client{baseURL: server.URL, httpClient: server.Client(), logger: log}
if err := c.RescanWorkspace(context.Background(), ""); err != nil {
t.Fatalf("RescanWorkspace: %v", err)
}
}

func TestReconcileWorkspace_UsesExactReconciliationEndpoint(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/workspace/reconcile" {
t.Fatalf("request = %s %s", r.Method, r.URL.Path)
}
var body struct {
WorkspaceSourceRoots []string `json:"workspace_source_roots"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if len(body.WorkspaceSourceRoots) != 1 || body.WorkspaceSourceRoots[0] != "/sources/original" {
t.Fatalf("workspace_source_roots = %v", body.WorkspaceSourceRoots)
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
if err != nil {
t.Fatal(err)
}
c := &Client{baseURL: server.URL, httpClient: server.Client(), logger: log}
if err := c.ReconcileWorkspace(context.Background(), []string{"/sources/original"}); err != nil {
t.Fatalf("ReconcileWorkspace: %v", err)
}
}

func TestRebindWorkspace_UsesAuthenticatedRebindEndpoint(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/workspace/rebind" {
t.Fatalf("request = %s %s", r.Method, r.URL.Path)
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
if err != nil {
t.Fatal(err)
}
c := &Client{baseURL: server.URL, httpClient: server.Client(), logger: log}
if err := c.RebindWorkspace(context.Background(), "/workspace/task-1"); err != nil {
t.Fatalf("RebindWorkspace: %v", err)
}
}
4 changes: 4 additions & 0 deletions apps/backend/internal/agent/runtime/agentctl/control.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ type CreateInstanceRequest struct {
// single-repo tracker. Empty map disables the override and falls back
// to the hardcoded origin/main → master priority list inside agentctl.
BaseBranches map[string]string `json:"base_branches,omitempty"`
// WorkspaceSourceRoots are canonical host roots explicitly attached to the
// workspace. Agentctl permits file operations through links only beneath
// these roots.
WorkspaceSourceRoots []string `json:"workspace_source_roots,omitempty"`
}

// CreateInstanceResponse contains the result of creating a new agent instance.
Expand Down
50 changes: 39 additions & 11 deletions apps/backend/internal/agent/runtime/agentctl/workspace_rescan.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,29 +22,57 @@ import (
// Best-effort: failures are returned but do not block the materializer.
// The worktree still exists on disk; the next session relaunch will
// rebuild trackers via prepareMultiRepo even when this call missed.
func (c *Client) RescanWorkspace(ctx context.Context, workDir string) error {
body, err := json.Marshal(struct {
WorkDir string `json:"work_dir"`
}{WorkDir: workDir})
func (c *Client) RescanWorkspace(ctx context.Context, workDir string, sourceRoots ...[]string) error {
return c.updateWorkspace(ctx, "/api/v1/workspace/rescan", "rescan", struct {
WorkDir string `json:"work_dir"`
WorkspaceSourceRoots []string `json:"workspace_source_roots,omitempty"`
}{WorkDir: workDir, WorkspaceSourceRoots: firstSourceRoots(sourceRoots)})
}

// ReconcileWorkspace prunes tracker state against the current root after a
// rollback has removed newly-created workspace checkouts. It is intentionally
// distinct from RescanWorkspace, whose empty-root form only appends trackers.
func (c *Client) ReconcileWorkspace(ctx context.Context, sourceRoots ...[]string) error {
return c.updateWorkspace(ctx, "/api/v1/workspace/reconcile", "reconcile", struct {
WorkspaceSourceRoots []string `json:"workspace_source_roots,omitempty"`
}{WorkspaceSourceRoots: firstSourceRoots(sourceRoots)})
}

// RebindWorkspace replaces agentctl's workspace root and every tracker after
// lifecycle has stopped the native child. It is deliberately not a best-effort
// rescan: callers must treat a failure as an adoption failure and roll back.
func (c *Client) RebindWorkspace(ctx context.Context, workDir string, sourceRoots ...[]string) error {
return c.updateWorkspace(ctx, "/api/v1/workspace/rebind", "rebind", struct {
WorkDir string `json:"work_dir"`
WorkspaceSourceRoots []string `json:"workspace_source_roots,omitempty"`
}{WorkDir: workDir, WorkspaceSourceRoots: firstSourceRoots(sourceRoots)})
}

func (c *Client) updateWorkspace(ctx context.Context, path, operation string, payload any) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}

req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/workspace/rescan", bytes.NewReader(body))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")

resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode < 200 || resp.StatusCode >= 300 {
respBody, _ := readResponseBody(resp)
return fmt.Errorf("rescan workspace failed with status %d: %s", resp.StatusCode, string(respBody))
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
body, _ := readResponseBody(resp)
return fmt.Errorf("%s workspace failed with status %d: %s", operation, resp.StatusCode, string(body))
}
return nil
}

func firstSourceRoots(sourceRoots [][]string) []string {
if len(sourceRoots) == 0 {
return nil
}
return sourceRoots[0]
}
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,7 @@ type ExecutorCreateRequest struct {
AgentProfileID string
OfficeAgentProfileID string
WorkspacePath string
WorkspaceSourceRoots []string
Protocol string
Env map[string]string
AutoApprovePermissions bool
Expand Down Expand Up @@ -397,6 +398,7 @@ func (ri *ExecutorInstance) ToAgentExecution(req *ExecutorCreateRequest) *AgentE
ContainerID: ri.ContainerID,
ContainerIP: ri.ContainerIP,
WorkspacePath: workspacePath,
WorkspaceSourceRoots: append([]string(nil), req.WorkspaceSourceRoots...),
RuntimeName: ri.RuntimeName,
Status: v1.AgentStatusRunning,
StartedAt: time.Now(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package lifecycle

import (
"context"
"strings"
"testing"
)

func TestRemoteDockerExecutor_CreateInstanceRemainsExplicitlyUnsupported(t *testing.T) {
executor := NewRemoteDockerExecutor(newTestLogger())
_, err := executor.CreateInstance(context.Background(), &ExecutorCreateRequest{InstanceID: "instance-1"})
if err == nil || !strings.Contains(err.Error(), "not yet implemented") {
t.Fatalf("CreateInstance error=%v; want explicit unsupported implementation error", err)
}
}
Loading
Loading