feat(workspace): Axis 2 Phase B — client→server workspace handshake
Wires the workspace handshake end to end so a session's workspace is bound
from the client's cwd at connect time and is event-sourced for replay.
- Go TUI sends Hello{workingDir=os.Getwd()} as the first WS frame on connect
(protocol.go encoder + client.go connect path; golden test pins the wire
format against the Kotlin discriminator).
- Server adds ClientMessage.Hello, stashes the per-connection workingDir, and
on session start resolves it through WorkspaceResolver's trust pipeline,
emits SessionWorkspaceBoundEvent (invariant #9), and threads the resolved
workspace into OrchestrationConfig for the live run. A Hello after the first
StartSession is ignored (warn); a rejected path binds the resolver fallback.
- Replay derives the workspace from the recorded event: SessionState gains
boundWorkspace, DefaultSessionReducer fills it from SessionWorkspaceBoundEvent,
and ReplayOrchestrator uses it (Path.of only, no filesystem re-query —
invariant #8) with graceful fallback to config for pre-Phase-B logs.
Absent Hello / null resolver degrades to the prior config-workspace behavior.
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
package protocol
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Golden wire frames as emitted by the Kotlin server (kotlinx.serialization). These
|
||||
// strings are the cross-language contract: the Kotlin side pins the encode in
|
||||
@@ -111,3 +114,44 @@ func TestDecodeGoldenServerFrames(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEncodeGoldenClientFrames pins the Go→Kotlin wire format for client messages.
|
||||
// The discriminator is the fully-qualified Kotlin class name (kotlinx default for
|
||||
// sealed classes without @SerialName), keyed under "type". These bytes must be
|
||||
// accepted verbatim by the Kotlin server's ProtocolSerializer / Json decoder.
|
||||
func TestEncodeGoldenClientFrames(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
frame []byte
|
||||
wantType string
|
||||
check func(t *testing.T, raw map[string]any)
|
||||
}{
|
||||
{
|
||||
// Hello is the first frame on every (re)connect — carries the client cwd so
|
||||
// the server can bind a workspace before any session starts.
|
||||
name: "Hello carries workingDir",
|
||||
frame: Hello("/home/kami/Projects/correx"),
|
||||
wantType: clientPrefix + "Hello",
|
||||
check: func(t *testing.T, raw map[string]any) {
|
||||
if raw["workingDir"] != "/home/kami/Projects/correx" {
|
||||
t.Fatalf("Hello workingDir = %v", raw["workingDir"])
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(c.frame, &raw); err != nil {
|
||||
t.Fatalf("unmarshal failed: %v", err)
|
||||
}
|
||||
if raw["type"] != c.wantType {
|
||||
t.Fatalf("type = %q, want %q", raw["type"], c.wantType)
|
||||
}
|
||||
if c.check != nil {
|
||||
c.check(t, raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,3 +254,9 @@ func CreateGrant(sessionID, scope string, permittedTiers []string, reason, toolN
|
||||
"toolName": toolName,
|
||||
})
|
||||
}
|
||||
|
||||
// Hello is the first frame sent on every (re)connect. It carries the client's
|
||||
// working directory so the server can bind a workspace before any session starts.
|
||||
func Hello(workingDir string) []byte {
|
||||
return encode("Hello", map[string]any{"workingDir": workingDir})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user