Skip to content

Commit 13e5bb5

Browse files
authored
Merge pull request #567 from creatang/main
refactor (TUI):全面切换 Gateway 主链路,移除旧运行路径并新增 Checkpoint 指令+UI优化
2 parents b166b48 + 94495e1 commit 13e5bb5

30 files changed

Lines changed: 2941 additions & 351 deletions
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# TUI-Gateway Contract Matrix (Single-Version Baseline)
2+
3+
This document freezes the contract that TUI consumes from gateway.
4+
It is intentionally single-version and fail-fast by design.
5+
6+
## Scope
7+
8+
- Transport contract: JSON-RPC 2.0 (`internal/gateway/protocol`)
9+
- Runtime contract: gateway DTOs (`internal/gateway/contracts.go`)
10+
- Event payload version source of truth: `internal/runtime/controlplane/envelope.go`
11+
12+
## RPC Methods Used By TUI
13+
14+
| Method | Params Type | Result Payload | Notes |
15+
| --- | --- | --- | --- |
16+
| `gateway.authenticate` | `protocol.AuthenticateParams` | frame ack | Must succeed before runtime actions |
17+
| `gateway.bindStream` | `protocol.BindStreamParams` | frame ack | Binds session/run event stream |
18+
| `gateway.run` | `protocol.RunParams` | frame ack with `session_id`/`run_id` | Async acceptance only |
19+
| `gateway.compact` | `protocol.CompactParams` | `gateway.CompactResult` | Manual compact |
20+
| `gateway.executeSystemTool` | `protocol.ExecuteSystemToolParams` | `tools.ToolResult` | Tool execution passthrough |
21+
| `gateway.resolvePermission` | `protocol.ResolvePermissionParams` | frame ack | Permission decision submit |
22+
| `gateway.cancel` | `protocol.CancelParams` | frame ack | Cancels run by run/session binding |
23+
| `gateway.listSessions` | none | `[]gateway.SessionSummary` | Session list |
24+
| `gateway.loadSession` | `protocol.LoadSessionParams` | `gateway.Session` | Full session snapshot |
25+
| `gateway.activateSessionSkill` | `protocol.ActivateSessionSkillParams` | frame ack | Activate skill in session |
26+
| `gateway.deactivateSessionSkill` | `protocol.DeactivateSessionSkillParams` | frame ack | Deactivate skill in session |
27+
| `gateway.listSessionSkills` | `protocol.ListSessionSkillsParams` | `[]gateway.SessionSkillState` | Active skill states |
28+
| `gateway.listAvailableSkills` | `protocol.ListAvailableSkillsParams` | `[]gateway.AvailableSkillState` | Available skill catalog |
29+
30+
## Runtime Event Contract
31+
32+
- Notification method: `gateway.event`
33+
- TUI only accepts a runtime envelope payload with these required keys:
34+
- `runtime_event_type` (string)
35+
- `turn` (number)
36+
- `phase` (string)
37+
- `timestamp` (RFC3339 or RFC3339Nano)
38+
- `payload_version` (number)
39+
- `payload` (event-specific object/string)
40+
- `payload_version` must equal `controlplane.PayloadVersion`.
41+
- Version mismatch is treated as a hard incompatibility and must fail fast.
42+
43+
## Error Contract
44+
45+
TUI consumes standard JSON-RPC errors and gateway extended error codes from
46+
`protocol.JSONRPCError.Data.GatewayCode`.
47+
48+
Primary gateway codes used for UI mapping:
49+
50+
- `invalid_frame`
51+
- `invalid_action`
52+
- `invalid_multimodal_payload`
53+
- `missing_required_field`
54+
- `unsupported_action`
55+
- `internal_error`
56+
- `timeout`
57+
- `unsafe_path`
58+
- `unauthorized`
59+
- `access_denied`
60+
- `resource_not_found`
61+
62+
## Non-Goals
63+
64+
- No multi-version payload decoding.
65+
- No alias method fallback.
66+
- No legacy field fallback in event payload.

internal/cli/gateway_runtime_bridge_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2627,3 +2627,22 @@ func TestGatewayRuntimePortBridgeDeleteMCPServerSuccess(t *testing.T) {
26272627
t.Fatalf("servers = %+v, want [srv-2]", cfgMgr.cfg.Tools.MCP.Servers)
26282628
}
26292629
}
2630+
2631+
func TestDefaultBuildGatewayRuntimePortListSessionsWithoutExplicitWorkdir(t *testing.T) {
2632+
home := t.TempDir()
2633+
t.Setenv("HOME", home)
2634+
t.Setenv("USERPROFILE", home)
2635+
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
2636+
2637+
port, cleanup, err := defaultBuildGatewayRuntimePort(context.Background(), "")
2638+
if err != nil {
2639+
t.Fatalf("defaultBuildGatewayRuntimePort() error = %v", err)
2640+
}
2641+
if cleanup != nil {
2642+
defer func() { _ = cleanup() }()
2643+
}
2644+
2645+
if _, err := port.ListSessions(context.Background()); err != nil {
2646+
t.Fatalf("ListSessions() with empty cli workdir should succeed, got %v", err)
2647+
}
2648+
}

internal/gateway/multi_workspace_runtime.go

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@ import (
1414
// MultiWorkspaceRuntime 将多个工作区的 runtime 聚合为单个 gateway.RuntimePort。
1515
// 根据连接上下文中的 workspaceHash 路由到对应工作区的 runtime。
1616
type MultiWorkspaceRuntime struct {
17-
index *agentsession.WorkspaceIndex
18-
bundles map[string]*workspaceBundle
19-
mu sync.RWMutex
20-
buildPort func(ctx context.Context, workdir string) (RuntimePort, func() error, error)
21-
defaultHash string
17+
index *agentsession.WorkspaceIndex
18+
bundles map[string]*workspaceBundle
19+
mu sync.RWMutex
20+
buildPort func(ctx context.Context, workdir string) (RuntimePort, func() error, error)
21+
defaultHash string
2222
managementPort ManagementRuntimePort
2323

2424
events chan RuntimeEvent
@@ -55,16 +55,28 @@ func NewMultiWorkspaceRuntime(
5555
func (m *MultiWorkspaceRuntime) getPort(ctx context.Context) (RuntimePort, error) {
5656
hash := WorkspaceHashFromContext(ctx)
5757
if hash == "" {
58+
m.mu.RLock()
5859
hash = m.defaultHash
60+
m.mu.RUnlock()
5961
}
6062
if hash == "" {
63+
// Support startup flows where gateway preloads a default runtime bundle
64+
// but no explicit workspace hash has been persisted yet.
65+
m.mu.RLock()
66+
if preloaded := m.bundles[""]; preloaded != nil {
67+
port := preloaded.port
68+
m.mu.RUnlock()
69+
return port, nil
70+
}
71+
m.mu.RUnlock()
72+
6173
records := m.index.List()
6274
if len(records) > 0 {
6375
hash = records[0].Hash
6476
}
6577
}
6678
if hash == "" {
67-
return nil, fmt.Errorf("workspace hash is empty and no default configured")
79+
return nil, fmt.Errorf("%w: workspace hash is empty and no default configured", ErrRuntimeResourceNotFound)
6880
}
6981
return m.getPortForHash(hash)
7082
}
@@ -86,7 +98,7 @@ func (m *MultiWorkspaceRuntime) getPortForHash(hash string) (RuntimePort, error)
8698

8799
record, ok := m.index.Get(hash)
88100
if !ok {
89-
return nil, fmt.Errorf("workspace %s not found", hash)
101+
return nil, fmt.Errorf("%w: workspace %s not found", ErrRuntimeResourceNotFound, hash)
90102
}
91103

92104
port, cleanup, err := m.buildPort(context.Background(), record.Path)
@@ -161,7 +173,7 @@ func (m *MultiWorkspaceRuntime) Close() error {
161173
func (m *MultiWorkspaceRuntime) SwitchWorkspace(ctx context.Context, hash string) error {
162174
_, ok := m.index.Get(hash)
163175
if !ok {
164-
return fmt.Errorf("workspace %s not found", hash)
176+
return fmt.Errorf("%w: workspace %s not found", ErrRuntimeResourceNotFound, hash)
165177
}
166178
// 预加载对应 runtime,确保后续请求可用
167179
if _, err := m.getPortForHash(hash); err != nil {
@@ -210,6 +222,13 @@ func (m *MultiWorkspaceRuntime) DeleteWorkspace(hash string, removeData bool) er
210222
if ok {
211223
delete(m.bundles, hash)
212224
}
225+
if strings.EqualFold(strings.TrimSpace(hash), strings.TrimSpace(m.defaultHash)) {
226+
m.defaultHash = ""
227+
records := m.index.List()
228+
if len(records) > 0 {
229+
m.defaultHash = strings.TrimSpace(records[0].Hash)
230+
}
231+
}
213232
m.mu.Unlock()
214233

215234
if ok && b != nil && b.cleanup != nil {

internal/gateway/multi_workspace_runtime_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,12 +286,35 @@ func TestMultiWorkspaceRuntime_NoHashConfigured(t *testing.T) {
286286

287287
if _, err := mw.ListSessions(context.Background()); err == nil {
288288
t.Fatalf("expected error when no hash is configured")
289+
} else if !errors.Is(err, ErrRuntimeResourceNotFound) {
290+
t.Fatalf("expected ErrRuntimeResourceNotFound, got %v", err)
289291
}
290292
if got := builder.callCount(); got != 0 {
291293
t.Fatalf("buildPort should not be called when no hash, got %d", got)
292294
}
293295
}
294296

297+
func TestMultiWorkspaceRuntime_NoHashUsesPreloadedAnonymousBundle(t *testing.T) {
298+
idx := agentsession.NewWorkspaceIndex(t.TempDir())
299+
builder := newTestBuilder()
300+
301+
mw := NewMultiWorkspaceRuntime(idx, "", builder.build)
302+
t.Cleanup(func() { _ = mw.Close() })
303+
304+
preloaded := newRecordingPort("anonymous-default")
305+
mw.PreloadWorkspaceBundle("", preloaded, preloaded.cleanup)
306+
307+
if _, err := mw.ListSessions(context.Background()); err != nil {
308+
t.Fatalf("ListSessions with anonymous preloaded bundle: %v", err)
309+
}
310+
if got := preloaded.listSessionsCalls.Load(); got != 1 {
311+
t.Fatalf("anonymous preloaded listSessions calls = %d, want 1", got)
312+
}
313+
if got := builder.callCount(); got != 0 {
314+
t.Fatalf("buildPort should not be called when anonymous preloaded bundle exists; got %d", got)
315+
}
316+
}
317+
295318
func TestMultiWorkspaceRuntime_ContextHashOverridesDefault(t *testing.T) {
296319
idx, alpha, beta := setupIndex(t)
297320
builder := newTestBuilder()
@@ -356,6 +379,8 @@ func TestMultiWorkspaceRuntime_UnknownHashErrors(t *testing.T) {
356379
_, err := mw.ListSessions(ctxWithHash(t, "deadbeef"))
357380
if err == nil {
358381
t.Fatalf("expected error for unknown hash")
382+
} else if !errors.Is(err, ErrRuntimeResourceNotFound) {
383+
t.Fatalf("expected ErrRuntimeResourceNotFound, got %v", err)
359384
}
360385
if got := builder.callCount(); got != 0 {
361386
t.Fatalf("buildPort should not be invoked for unknown hash; got %d", got)
@@ -510,6 +535,27 @@ func TestMultiWorkspaceRuntime_RenameAndDeletePersist(t *testing.T) {
510535
}
511536
}
512537

538+
func TestMultiWorkspaceRuntime_DeleteDefaultHashFallsBackToRemainingWorkspace(t *testing.T) {
539+
idx, alpha, beta := setupIndex(t)
540+
builder := newTestBuilder()
541+
mw := NewMultiWorkspaceRuntime(idx, alpha.Hash, builder.build)
542+
t.Cleanup(func() { _ = mw.Close() })
543+
544+
if err := mw.DeleteWorkspace(alpha.Hash, false); err != nil {
545+
t.Fatalf("Delete default workspace: %v", err)
546+
}
547+
548+
if _, err := mw.ListSessions(context.Background()); err != nil {
549+
t.Fatalf("ListSessions fallback after deleting default: %v", err)
550+
}
551+
if builder.portFor(alpha.Path) != nil {
552+
t.Fatalf("alpha port should not be rebuilt after delete")
553+
}
554+
if builder.portFor(beta.Path) == nil {
555+
t.Fatalf("expected fallback to remaining workspace beta")
556+
}
557+
}
558+
513559
func TestMultiWorkspaceRuntime_DeleteUnknownErrors(t *testing.T) {
514560
idx, alpha, _ := setupIndex(t)
515561
builder := newTestBuilder()

internal/gateway/workspace_handlers.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,17 @@ func handleWorkspaceDeleteFrame(ctx context.Context, frame MessageFrame, runtime
153153
if deleteErr := mw.DeleteWorkspace(params.Hash, params.RemoveData); deleteErr != nil {
154154
return errorFrame(frame, NewFrameError(ErrorCodeInternalError, deleteErr.Error()))
155155
}
156+
if wsState, ok := ConnectionWorkspaceStateFromContext(ctx); ok {
157+
activeHash := strings.TrimSpace(wsState.GetWorkspaceHash())
158+
if strings.EqualFold(activeHash, strings.TrimSpace(params.Hash)) {
159+
wsState.SetWorkspaceHash("")
160+
if relay, relayOK := StreamRelayFromContext(ctx); relayOK {
161+
if connID, connOK := ConnectionIDFromContext(ctx); connOK {
162+
relay.ClearConnectionBindings(connID)
163+
}
164+
}
165+
}
166+
}
156167

157168
return MessageFrame{
158169
Type: FrameTypeAck,
@@ -270,4 +281,3 @@ func decodeWorkspaceDeletePayload(payload any) (workspaceDeleteParams, *FrameErr
270281
return workspaceDeleteParams{}, NewFrameError(ErrorCodeInvalidFrame, "invalid workspace.delete payload")
271282
}
272283
}
273-
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package gateway
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"neo-code/internal/gateway/protocol"
8+
)
9+
10+
func TestHandleWorkspaceDeleteFrameClearsActiveWorkspaceStateAndBindings(t *testing.T) {
11+
idx, alpha, _ := setupIndex(t)
12+
builder := newTestBuilder()
13+
mw := NewMultiWorkspaceRuntime(idx, alpha.Hash, builder.build)
14+
t.Cleanup(func() { _ = mw.Close() })
15+
16+
relay := NewStreamRelay(StreamRelayOptions{})
17+
connID := NewConnectionID()
18+
registerErr := relay.RegisterConnection(ConnectionRegistration{
19+
ConnectionID: connID,
20+
Channel: StreamChannelIPC,
21+
Context: context.Background(),
22+
Cancel: func() {},
23+
Write: func(message RelayMessage) error {
24+
return nil
25+
},
26+
Close: func() {},
27+
})
28+
if registerErr != nil {
29+
t.Fatalf("register connection: %v", registerErr)
30+
}
31+
t.Cleanup(func() { relay.dropConnection(connID) })
32+
33+
if bindErr := relay.BindConnection(connID, StreamBinding{
34+
SessionID: "session-delete-check",
35+
Channel: StreamChannelAll,
36+
Explicit: true,
37+
}); bindErr != nil {
38+
t.Fatalf("bind connection: %v", bindErr)
39+
}
40+
41+
wsState := NewConnectionWorkspaceState()
42+
wsState.SetWorkspaceHash(alpha.Hash)
43+
ctx := WithConnectionID(
44+
WithStreamRelay(
45+
WithConnectionWorkspaceState(context.Background(), wsState),
46+
relay,
47+
),
48+
connID,
49+
)
50+
51+
response := handleWorkspaceDeleteFrame(ctx, MessageFrame{
52+
Type: FrameTypeRequest,
53+
Action: FrameActionWorkspaceDelete,
54+
RequestID: "workspace-delete-active",
55+
Payload: protocol.DeleteWorkspaceParams{
56+
WorkspaceHash: alpha.Hash,
57+
},
58+
}, mw)
59+
if response.Type != FrameTypeAck {
60+
t.Fatalf("response type = %q, want %q", response.Type, FrameTypeAck)
61+
}
62+
63+
if got := wsState.GetWorkspaceHash(); got != "" {
64+
t.Fatalf("workspace hash should be cleared after deleting active workspace, got %q", got)
65+
}
66+
67+
relay.mu.RLock()
68+
_, exists := relay.connectionBindings[NormalizeConnectionID(connID)]
69+
relay.mu.RUnlock()
70+
if exists {
71+
t.Fatalf("connection bindings should be cleared after deleting active workspace")
72+
}
73+
}

internal/runtime/session_logs.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ type SessionLogEntry struct {
2121
Level string `json:"level"`
2222
Source string `json:"source"`
2323
Message string `json:"message"`
24+
Inline string `json:"inline_message,omitempty"`
2425
}
2526

2627
// LoadSessionLogEntries 按会话 ID 读取日志查看器持久化数据。

internal/tui/core/app/app.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ type logEntry struct {
2828
Level string
2929
Source string
3030
Message string
31+
Inline string
3132
}
3233

3334
type panel = tuistate.Panel
@@ -160,6 +161,9 @@ type appRuntimeState struct {
160161
logPersistDirty bool
161162
logPersistVersion int
162163
transcriptContent string
164+
transcriptProcessFoldAvailable bool
165+
transcriptProcessExpanded bool
166+
transcriptProcessExpandedOrdinal int
163167
transcriptScrollbarDrag bool
164168
startupScreenLocked bool
165169
suppressAssistantForRun string

0 commit comments

Comments
 (0)