Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion docs/guides/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ tools:
|------|------|--------|------|
| `selected_provider` | string | `openai` | 当前选择的 provider 名称 |
| `current_model` | string | 取决于 provider | 当前选择的模型名称 |
| `workdir` | string | `.` (当前目录) | 工作目录的绝对路径 |
| `workdir` | string | `.` (当前目录) | 工作目录的绝对路径;启动时要求路径已存在且必须是目录 |
| `shell` | string | `bash` (Linux/Mac)<br>`powershell` (Windows) | Shell 类型 |
| `max_loops` | int | `8` | Agent 推理循环最大轮数 |

Expand All @@ -93,6 +93,7 @@ tools:
**自动管理**:
- 首次启动时自动创建默认配置
- `workdir` 自动转换为绝对路径
- `workdir` 必须指向已存在的目录
- 无效配置会在启动时报错

**最小持久化**:
Expand Down
2 changes: 1 addition & 1 deletion docs/session-persistence-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ NeoCode 当前使用本地 JSON 文件持久化会话,以保持实现简单、

- 默认目录按工作区隔离:`~/.neocode/projects/<workspace-hash>/sessions/`
- 工作区哈希基于启动阶段确定的工作区根目录生成
- `session.Workdir` 表示会话当前实际执行命令使用的目录,可被运行期命令修改,但不参与分桶
- `session.Workdir` 表示会话最近一次运行实际使用的目录,由启动 `workdir` 或请求级覆盖值写回,但不参与分桶
- 旧的全局 `~/.neocode/sessions/` 开发期数据不迁移、不回读

## 数据模型
Expand Down
26 changes: 3 additions & 23 deletions internal/app/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,6 @@ package app

import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"

Expand Down Expand Up @@ -86,6 +83,8 @@ func BuildRuntime(ctx context.Context, opts BootstrapOptions) (RuntimeBundle, er
return RuntimeBundle{}, err
}

// Session Store 绑定到启动时的 workdir 哈希分桶,整个应用生命周期内不可变。
// 这意味着所有会话都归属到启动时指定的项目目录下,运行时不会因配置变更而迁移存储位置。
sessionStore := agentsession.NewStore(loader.BaseDir(), cfg.Workdir)
runtimeSvc := agentruntime.NewWithFactory(
manager,
Expand Down Expand Up @@ -139,26 +138,7 @@ func bootstrapDefaultConfig(opts BootstrapOptions) (*config.Config, error) {

// resolveBootstrapWorkdir 将 CLI 传入的工作区解析为存在的绝对目录。
func resolveBootstrapWorkdir(workdir string) (string, error) {
trimmed := strings.TrimSpace(workdir)
if trimmed == "" {
return "", fmt.Errorf("app: workdir is empty")
}

absolute, err := filepath.Abs(trimmed)
if err != nil {
return "", fmt.Errorf("app: resolve workdir %q: %w", workdir, err)
}
absolute = filepath.Clean(absolute)

info, err := os.Stat(absolute)
if err != nil {
return "", fmt.Errorf("app: resolve workdir %q: %w", workdir, err)
}
if !info.IsDir() {
return "", fmt.Errorf("app: workdir %q is not a directory", absolute)
}

return absolute, nil
return agentsession.ResolveExistingDir(workdir)
}

func buildToolRegistry(cfg config.Config) (*tools.Registry, error) {
Expand Down
24 changes: 24 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,30 @@ func TestConfigValidateFailures(t *testing.T) {
},
expectErr: "workdir must be absolute",
},
{
name: "non-existent workdir",
config: func() *Config {
cfg := testDefaultConfig()
cfg.ApplyDefaultsFrom(*testDefaultConfig())
cfg.Workdir = filepath.Join(t.TempDir(), "does-not-exist")
return cfg
}(),
expectErr: "workdir does not exist",
},
{
name: "workdir is a file",
config: func() *Config {
cfg := testDefaultConfig()
cfg.ApplyDefaultsFrom(*testDefaultConfig())
filePath := filepath.Join(t.TempDir(), "a-file.txt")
if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil {
t.Fatalf("setup: %v", err)
}
cfg.Workdir = filePath
return cfg
}(),
expectErr: "workdir is not a directory",
},
{
name: "selected provider model empty",
config: func() *Config {
Expand Down
7 changes: 7 additions & 0 deletions internal/config/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

"neo-code/internal/provider"
providertypes "neo-code/internal/provider/types"
agentsession "neo-code/internal/session"
)

const (
Expand Down Expand Up @@ -256,6 +257,12 @@ func (c *Config) Validate() error {
if !filepath.IsAbs(c.Workdir) {
return fmt.Errorf("config: workdir must be absolute, got %q", c.Workdir)
}
if _, err := agentsession.ResolveExistingDir(c.Workdir); err != nil {
if strings.Contains(err.Error(), "is not a directory") {
return fmt.Errorf("config: workdir is not a directory: %q", c.Workdir)
}
return fmt.Errorf("config: workdir does not exist: %q", c.Workdir)
}
if selected.Source != ProviderSourceCustom && strings.TrimSpace(selected.Model) == "" {
return fmt.Errorf("config: selected provider %q has empty model", selected.Name)
}
Expand Down
2 changes: 0 additions & 2 deletions internal/gateway/contracts.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,6 @@ type RuntimePort interface {
ListSessions(ctx context.Context) ([]SessionSummary, error)
// LoadSession 加载指定会话详情。
LoadSession(ctx context.Context, id string) (Session, error)
// SetSessionWorkdir 更新会话工作目录。
SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (Session, error)
}

// Gateway 定义网关主契约。
Expand Down
4 changes: 0 additions & 4 deletions internal/gateway/contracts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,4 @@ func (s *runtimePortCompileStub) LoadSession(_ context.Context, _ string) (Sessi
return Session{}, nil
}

func (s *runtimePortCompileStub) SetSessionWorkdir(_ context.Context, _, _ string) (Session, error) {
return Session{}, nil
}

var _ RuntimePort = (*runtimePortCompileStub)(nil)
2 changes: 0 additions & 2 deletions internal/gateway/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,6 @@ const (
FrameActionListSessions FrameAction = "list_sessions"
// FrameActionLoadSession 表示加载指定会话详情。
FrameActionLoadSession FrameAction = "load_session"
// FrameActionSetSessionWorkdir 表示设置会话工作目录。

This comment was marked as outdated.

FrameActionSetSessionWorkdir FrameAction = "set_session_workdir"
// FrameActionResolvePermission 表示提交一次权限审批决策。
FrameActionResolvePermission FrameAction = "resolve_permission"
)
Expand Down
8 changes: 0 additions & 8 deletions internal/gateway/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,6 @@ func validateRequestFrame(frame MessageFrame) *FrameError {
if strings.TrimSpace(frame.SessionID) == "" {
return NewMissingRequiredFieldError("session_id")
}
case FrameActionSetSessionWorkdir:
if strings.TrimSpace(frame.SessionID) == "" {
return NewMissingRequiredFieldError("session_id")
}
if strings.TrimSpace(frame.Workdir) == "" {
return NewMissingRequiredFieldError("workdir")
}
case FrameActionResolvePermission:
return validateResolvePermissionFrame(frame)
case FrameActionCancel, FrameActionListSessions:
Expand Down Expand Up @@ -180,7 +173,6 @@ func isValidFrameAction(action FrameAction) bool {
FrameActionCancel,
FrameActionListSessions,
FrameActionLoadSession,
FrameActionSetSessionWorkdir,
FrameActionResolvePermission:
return true
default:
Expand Down
20 changes: 0 additions & 20 deletions internal/gateway/validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,26 +72,6 @@ func TestValidateFrame_BasicRules(t *testing.T) {
wantCode: ErrorCodeMissingRequiredField.String(),
wantField: "session_id",
},
{
name: "set_session_workdir missing workdir",
frame: MessageFrame{
Type: FrameTypeRequest,
Action: FrameActionSetSessionWorkdir,
SessionID: "sess_1",
},
wantCode: ErrorCodeMissingRequiredField.String(),
wantField: "workdir",
},
{
name: "set_session_workdir valid",
frame: MessageFrame{
Type: FrameTypeRequest,
Action: FrameActionSetSessionWorkdir,
SessionID: "sess_1",
Workdir: "/workspace",
},
wantNil: true,
},
{
name: "resolve_permission valid struct payload",
frame: MessageFrame{
Expand Down
60 changes: 4 additions & 56 deletions internal/runtime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"fmt"
"log"
"math/rand/v2"
"os"
"path/filepath"
"sort"
"strings"
Expand Down Expand Up @@ -118,7 +117,6 @@ type Runtime interface {
Events() <-chan RuntimeEvent
ListSessions(ctx context.Context) ([]agentsession.Summary, error)
LoadSession(ctx context.Context, id string) (agentsession.Session, error)

This comment was marked as outdated.

This comment was marked as outdated.

SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentsession.Session, error)
}

type UserInput struct {
Expand Down Expand Up @@ -423,34 +421,6 @@ func (s *Service) LoadSession(ctx context.Context, id string) (agentsession.Sess
return session, nil

This comment was marked as outdated.

}

func (s *Service) SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentsession.Session, error) {
sessionID = strings.TrimSpace(sessionID)
if sessionID == "" {
return agentsession.Session{}, errors.New("runtime: session id is empty")
}

session, err := s.sessionStore.Load(ctx, sessionID)
if err != nil {
return agentsession.Session{}, err
}

cfg := s.configManager.Get()
resolved, err := resolveWorkdirForSession(cfg.Workdir, session.Workdir, workdir)
if err != nil {
return agentsession.Session{}, err
}
if session.Workdir == resolved {
return session, nil
}

session.Workdir = resolved
session.UpdatedAt = time.Now()
if err := s.sessionStore.Save(ctx, &session); err != nil {
return agentsession.Session{}, err
}
return session, nil
}

func (s *Service) loadOrCreateSession(
ctx context.Context,
sessionID string,
Expand Down Expand Up @@ -894,44 +864,22 @@ func (v permissionEventView) toResolvedPayload() PermissionResolvedPayload {
}

func effectiveSessionWorkdir(sessionWorkdir string, defaultWorkdir string) string {
workdir := strings.TrimSpace(sessionWorkdir)
if workdir != "" {
return workdir
}
return strings.TrimSpace(defaultWorkdir)
return agentsession.EffectiveWorkdir(sessionWorkdir, defaultWorkdir)
}

func resolveWorkdirForSession(defaultWorkdir string, currentWorkdir string, requestedWorkdir string) (string, error) {
base := effectiveSessionWorkdir(currentWorkdir, defaultWorkdir)
if strings.TrimSpace(requestedWorkdir) == "" {
return normalizeExistingWorkdir(base)
return agentsession.ResolveExistingDir(base)
}

target := strings.TrimSpace(requestedWorkdir)
if !filepath.IsAbs(target) {
target = filepath.Join(base, target)
}
return normalizeExistingWorkdir(target)
return agentsession.ResolveExistingDir(target)
}

func normalizeExistingWorkdir(workdir string) (string, error) {
trimmed := strings.TrimSpace(workdir)
if trimmed == "" {
return "", errors.New("runtime: workdir is empty")
}

absolute, err := filepath.Abs(trimmed)
if err != nil {
return "", fmt.Errorf("runtime: resolve workdir: %w", err)
}

info, err := os.Stat(absolute)
if err != nil {
return "", fmt.Errorf("runtime: resolve workdir: %w", err)
}
if !info.IsDir() {
return "", fmt.Errorf("runtime: workdir %q is not a directory", absolute)
}

return filepath.Clean(absolute), nil
return agentsession.ResolveExistingDir(workdir)
}
Loading
Loading