Skip to content

Commit 682ec05

Browse files
committed
Fix live workspace mounts
1 parent 1dffeca commit 682ec05

11 files changed

Lines changed: 329 additions & 81 deletions

AGENTS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,9 @@ The most important implementation seams are:
212212
`go list` can trigger dependency downloads in those nested modules.
213213
- `afs setup` owns only the default local mode prompt. Workspace selection and
214214
local directory prompts belong under `afs ws mount`, not setup.
215+
- `example.afs.config.json` should mirror the canonical persisted config shape:
216+
an empty `workspace.default` to force an explicit user choice; no root
217+
`currentWorkspace` or `localPath` keys.
215218
- If a Vite UI change is present in source but `localhost:5173` still shows old
216219
behavior, check for duplicate Vite listeners from sibling worktrees.
217220
`localhost` can hit an IPv6 listener from another checkout while

README.md

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -171,18 +171,22 @@ The current config shape is nested and workspace-oriented:
171171
"tls": false
172172
},
173173
"mode": "sync",
174-
"currentWorkspace": "my-repo",
175-
"mount": {
176-
"backend": "none",
177-
"allowOther": false,
178-
"mountBin": "",
179-
"nfsBin": "",
180-
"nfsHost": "127.0.0.1",
181-
"nfsPort": 20490
174+
"workspace": {
175+
"default": "my-repo"
182176
},
183-
"logs": {
184-
"mount": "/tmp/afs-mount.log",
185-
"sync": "/tmp/afs-sync.log"
177+
"runtime": {
178+
"mount": {
179+
"backend": "none",
180+
"allowOther": false,
181+
"mountBin": "",
182+
"nfsBin": "",
183+
"nfsHost": "127.0.0.1",
184+
"nfsPort": 20490
185+
},
186+
"logs": {
187+
"mount": "/tmp/afs-mount.log",
188+
"sync": "/tmp/afs-sync.log"
189+
}
186190
},
187191
"sync": {
188192
"fileSizeCapMB": 2048

cmd/afs/config_commands_test.go

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,15 @@ func TestSaveConfigPersistsDefaultWorkspaceOutsideLegacyRuntimeKeys(t *testing.T
107107
if workspace["default"] != "demo" || workspace["defaultID"] != "ws_demo" {
108108
t.Fatalf("workspace config = %#v, want demo/ws_demo", workspace)
109109
}
110+
runtime, ok := saved["runtime"].(map[string]any)
111+
if !ok {
112+
t.Fatalf("config should persist runtime mount/log settings under runtime key: %s", string(raw))
113+
}
114+
for _, key := range []string{"currentWorkspace", "currentWorkspaceID"} {
115+
if _, ok := runtime[key]; ok {
116+
t.Fatalf("runtime config should not persist workspace key %q: %s", key, string(raw))
117+
}
118+
}
110119
loaded, err := loadConfig()
111120
if err != nil {
112121
t.Fatalf("loadConfig() returned error: %v", err)
@@ -614,15 +623,19 @@ func TestLoadConfigForUpPromptsForMissingDatabaseAndMountpoint(t *testing.T) {
614623
}
615624

616625
raw := `{
617-
"redis": {
618-
"addr": "` + mr.Addr() + `"
619-
},
620-
"currentWorkspace": "demo",
621-
"mount": {
622-
"backend": "nfs",
623-
"nfsBin": "/usr/bin/true"
624-
}
625-
}`
626+
"redis": {
627+
"addr": "` + mr.Addr() + `"
628+
},
629+
"workspace": {
630+
"default": "demo"
631+
},
632+
"runtime": {
633+
"mount": {
634+
"backend": "nfs",
635+
"nfsBin": "/usr/bin/true"
636+
}
637+
}
638+
}`
626639
if err := os.WriteFile(configFile, []byte(raw), 0o644); err != nil {
627640
t.Fatalf("WriteFile(config) returned error: %v", err)
628641
}

cmd/afs/config_state.go

Lines changed: 4 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,8 @@ type persistedConfig struct {
6969
}
7070

7171
type persistedRuntime struct {
72-
CurrentWorkspace string `json:"currentWorkspace,omitempty"`
73-
CurrentWorkspaceID string `json:"currentWorkspaceID,omitempty"`
74-
Mount mountSettings `json:"mount,omitempty"`
75-
Logs logSettings `json:"logs,omitempty"`
72+
Mount mountSettings `json:"mount,omitempty"`
73+
Logs logSettings `json:"logs,omitempty"`
7674
}
7775

7876
func persistedConfigFromRuntime(cfg config) persistedConfig {
@@ -113,10 +111,8 @@ func persistedConfigFromRuntime(cfg config) persistedConfig {
113111
out.Sync = &syncSettings{SyncFileSizeCapMB: cfg.SyncFileSizeCapMB}
114112
}
115113
out.Runtime = &persistedRuntime{
116-
CurrentWorkspace: strings.TrimSpace(cfg.CurrentWorkspace),
117-
CurrentWorkspaceID: strings.TrimSpace(cfg.CurrentWorkspaceID),
118-
Mount: persistedMountSettings(cfg.mountSettings),
119-
Logs: cfg.logSettings,
114+
Mount: persistedMountSettings(cfg.mountSettings),
115+
Logs: cfg.logSettings,
120116
}
121117
return out
122118
}
@@ -163,15 +159,6 @@ func loadConfig() (config, error) {
163159
}
164160
cfg.CurrentWorkspace = strings.TrimSpace(raw.Workspace.DefaultWorkspace)
165161
cfg.CurrentWorkspaceID = strings.TrimSpace(raw.Workspace.DefaultWorkspaceID)
166-
if cfg.CurrentWorkspace == "" && cfg.CurrentWorkspaceID == "" {
167-
if raw.Runtime != nil {
168-
cfg.CurrentWorkspace = strings.TrimSpace(raw.Runtime.CurrentWorkspace)
169-
cfg.CurrentWorkspaceID = strings.TrimSpace(raw.Runtime.CurrentWorkspaceID)
170-
} else {
171-
cfg.CurrentWorkspace = legacy.CurrentWorkspace
172-
cfg.CurrentWorkspaceID = legacy.CurrentWorkspaceID
173-
}
174-
}
175162

176163
if raw.Runtime != nil {
177164
cfg.LocalPath = ""

cmd/afs/mount_commands.go

Lines changed: 105 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ type unmountOptions struct {
3434
deleteLocal bool
3535
}
3636

37+
var (
38+
startMountServicesForWorkspaceMount = startMountServices
39+
startSyncMountForWorkspaceMount = startSyncMount
40+
)
41+
3742
func cmdMountArgs(args []string) error {
3843
if len(args) > 0 && isHelpArg(args[0]) {
3944
fmt.Fprint(os.Stderr, mountUsageText(filepath.Base(os.Args[0])))
@@ -165,11 +170,22 @@ func mountWorkspace(opts mountOptions) error {
165170
if err != nil {
166171
return err
167172
}
168-
cfg.Mode = modeSync
169-
cfg.MountBackend = mountBackendNone
173+
mode, err := effectiveMode(cfg)
174+
if err != nil {
175+
return err
176+
}
177+
if mode == modeNone {
178+
return fmt.Errorf("mode is set to none; update config first")
179+
}
170180
cfg.CurrentWorkspace = opts.workspace
171181
cfg.CurrentWorkspaceID = ""
172182
cfg.LocalPath = localPath
183+
if mode == modeSync {
184+
cfg.Mode = modeSync
185+
cfg.MountBackend = mountBackendNone
186+
} else {
187+
cfg.Mode = modeMount
188+
}
173189

174190
ctx := context.Background()
175191
resolvedCfg, service, closeStore, err := openAFSControlPlaneForConfig(ctx, cfg)
@@ -189,9 +205,80 @@ func mountWorkspace(opts mountOptions) error {
189205
resolvedCfg.CurrentWorkspace = selection.Name
190206
resolvedCfg.CurrentWorkspaceID = selection.ID
191207
resolvedCfg.LocalPath = localPath
208+
if mode == modeMount {
209+
if err := applyWorkspaceSelection(&resolvedCfg, selection); err != nil {
210+
return err
211+
}
212+
resolvedCfg.LocalPath = localPath
213+
resolvedCfg.Mode = modeMount
214+
resolvedCfg.ReadOnly = opts.readonly
215+
return startLiveMount(resolvedCfg, selection, opts)
216+
}
192217
resolvedCfg.Mode = modeSync
193218
resolvedCfg.MountBackend = mountBackendNone
194-
return startSyncMount(ctx, resolvedCfg, selection, opts)
219+
return startSyncMountForWorkspaceMount(ctx, resolvedCfg, selection, opts)
220+
}
221+
222+
func startLiveMount(cfg config, selection workspaceSelection, opts mountOptions) error {
223+
if opts.dryRun {
224+
return fmt.Errorf("--dry-run is only available for sync-mode mounts")
225+
}
226+
if strings.TrimSpace(cfg.LocalPath) == "" {
227+
return errors.New("mount requires a local directory")
228+
}
229+
if err := validateUpModeSelection(cfg); err != nil {
230+
return err
231+
}
232+
st, err := startMountServicesForWorkspaceMount(cfg, opts.sessionName)
233+
if err != nil {
234+
return err
235+
}
236+
if strings.TrimSpace(st.CurrentWorkspace) == "" {
237+
st.CurrentWorkspace = selection.Name
238+
}
239+
if strings.TrimSpace(st.CurrentWorkspaceID) == "" {
240+
st.CurrentWorkspaceID = selection.ID
241+
}
242+
if err := registerLiveMount(st); err != nil {
243+
_ = stopMount(mountRecordFromLiveState(st, ""), false)
244+
return err
245+
}
246+
recordMountShellDirectory(st.LocalPath)
247+
return nil
248+
}
249+
250+
func registerLiveMount(st state) error {
251+
reg, err := loadMountRegistry()
252+
if err != nil {
253+
return err
254+
}
255+
id, err := randomSuffix()
256+
if err != nil {
257+
return err
258+
}
259+
upsertMount(&reg, mountRecordFromLiveState(st, "mnt_"+id))
260+
return saveMountRegistry(reg)
261+
}
262+
263+
func mountRecordFromLiveState(st state, id string) mountRecord {
264+
return mountRecord{
265+
ID: id,
266+
Workspace: st.CurrentWorkspace,
267+
WorkspaceID: st.CurrentWorkspaceID,
268+
LocalPath: st.LocalPath,
269+
Mode: modeMount,
270+
MountBackend: st.MountBackend,
271+
ProductMode: st.ProductMode,
272+
ControlPlaneURL: st.ControlPlaneURL,
273+
ControlPlaneDatabase: st.ControlPlaneDatabase,
274+
SessionID: st.SessionID,
275+
RedisAddr: st.RedisAddr,
276+
RedisDB: st.RedisDB,
277+
RedisKey: st.RedisKey,
278+
PID: st.MountPID,
279+
ReadOnly: st.ReadOnly,
280+
StartedAt: st.StartedAt,
281+
}
195282
}
196283

197284
func startSyncMount(ctx context.Context, cfg config, selection workspaceSelection, opts mountOptions) error {
@@ -829,6 +916,18 @@ func unmountPromptRows(records []mountRecord) [][]string {
829916
}
830917

831918
func stopMount(rec mountRecord, deleteLocal bool) error {
919+
if strings.TrimSpace(rec.Mode) == modeMount {
920+
cfg := configFromMount(rec)
921+
backend, _, err := backendForConfig(cfg)
922+
if err != nil {
923+
return err
924+
}
925+
if localPath := strings.TrimSpace(rec.LocalPath); localPath != "" && backend.IsMounted(localPath) {
926+
if err := backend.Unmount(localPath); err != nil {
927+
return err
928+
}
929+
}
930+
}
832931
if rec.PID > 0 && processAlive(rec.PID) {
833932
if err := terminatePID(rec.PID, 5*time.Second); err != nil {
834933
return err
@@ -879,6 +978,9 @@ func configFromMount(rec mountRecord) config {
879978
cfg.CurrentWorkspaceID = rec.WorkspaceID
880979
cfg.LocalPath = rec.LocalPath
881980
cfg.Mode = rec.Mode
981+
if strings.TrimSpace(rec.MountBackend) != "" {
982+
cfg.MountBackend = rec.MountBackend
983+
}
882984
cfg.SyncLog = rec.SyncLog
883985
return cfg
884986
}

cmd/afs/mount_commands_test.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,108 @@
11
package main
22

33
import (
4+
"context"
45
"os"
56
"path/filepath"
67
"strings"
78
"testing"
89
"time"
10+
11+
"github.com/alicebob/miniredis/v2"
12+
"github.com/redis/go-redis/v9"
913
)
1014

15+
func TestMountWorkspaceUsesConfiguredLiveMountMode(t *testing.T) {
16+
t.Helper()
17+
18+
withTempHome(t)
19+
mr := miniredis.RunT(t)
20+
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
21+
t.Cleanup(func() {
22+
_ = rdb.Close()
23+
})
24+
25+
sourceDir := t.TempDir()
26+
writeTestFile(t, filepath.Join(sourceDir, "README.md"), "hello\n")
27+
seedWorkspaceFromDirectory(t, newAFSStore(rdb), "repo", "initial", sourceDir)
28+
29+
cfg := defaultConfig()
30+
cfg.RedisAddr = mr.Addr()
31+
cfg.Mode = modeMount
32+
cfg.MountBackend = mountBackendNFS
33+
cfg.NFSBin = "/bin/true"
34+
saveTempConfig(t, cfg)
35+
36+
origStartMount := startMountServicesForWorkspaceMount
37+
origStartSync := startSyncMountForWorkspaceMount
38+
t.Cleanup(func() {
39+
startMountServicesForWorkspaceMount = origStartMount
40+
startSyncMountForWorkspaceMount = origStartSync
41+
})
42+
43+
var captured config
44+
var capturedSession string
45+
startMountServicesForWorkspaceMount = func(cfg config, sessionName string) (state, error) {
46+
captured = cfg
47+
capturedSession = sessionName
48+
return state{
49+
StartedAt: time.Now().UTC(),
50+
ProductMode: cfg.ProductMode,
51+
RedisAddr: cfg.RedisAddr,
52+
RedisDB: cfg.RedisDB,
53+
CurrentWorkspace: cfg.CurrentWorkspace,
54+
MountPID: 4242,
55+
MountBackend: cfg.MountBackend,
56+
LocalPath: cfg.LocalPath,
57+
Mode: modeMount,
58+
RedisKey: "ws_repo",
59+
ReadOnly: cfg.ReadOnly,
60+
}, nil
61+
}
62+
startSyncMountForWorkspaceMount = func(ctx context.Context, cfg config, selection workspaceSelection, opts mountOptions) error {
63+
t.Fatalf("startSyncMount called for mode=mount config: cfg=%+v selection=%+v opts=%+v", cfg, selection, opts)
64+
return nil
65+
}
66+
67+
localPath := filepath.Join(t.TempDir(), "repo")
68+
if err := mountWorkspace(mountOptions{
69+
workspace: "repo",
70+
directory: localPath,
71+
sessionName: "live edit",
72+
readonly: true,
73+
}); err != nil {
74+
t.Fatalf("mountWorkspace() returned error: %v", err)
75+
}
76+
77+
if captured.Mode != modeMount {
78+
t.Fatalf("captured Mode = %q, want %q", captured.Mode, modeMount)
79+
}
80+
if captured.MountBackend != mountBackendNFS {
81+
t.Fatalf("captured MountBackend = %q, want %q", captured.MountBackend, mountBackendNFS)
82+
}
83+
if captured.LocalPath != localPath {
84+
t.Fatalf("captured LocalPath = %q, want %q", captured.LocalPath, localPath)
85+
}
86+
if !captured.ReadOnly {
87+
t.Fatal("captured ReadOnly = false, want true")
88+
}
89+
if capturedSession != "live edit" {
90+
t.Fatalf("captured sessionName = %q, want live edit", capturedSession)
91+
}
92+
93+
reg, err := loadMountRegistry()
94+
if err != nil {
95+
t.Fatalf("loadMountRegistry() returned error: %v", err)
96+
}
97+
rec, ok := mountByPath(reg, localPath)
98+
if !ok {
99+
t.Fatalf("expected live mount registry record at %s; got %#v", localPath, reg.Mounts)
100+
}
101+
if rec.Mode != modeMount || rec.MountBackend != mountBackendNFS {
102+
t.Fatalf("mount registry mode/backend = %q/%q, want %q/%q", rec.Mode, rec.MountBackend, modeMount, mountBackendNFS)
103+
}
104+
}
105+
11106
func TestParseMountOptionsAllowsOptionalDirectory(t *testing.T) {
12107
t.Helper()
13108

cmd/afs/mount_state.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ type mountRecord struct {
2525
WorkspaceID string `json:"workspace_id,omitempty"`
2626
LocalPath string `json:"local_path"`
2727
Mode string `json:"mode"`
28+
MountBackend string `json:"mount_backend,omitempty"`
2829
ProductMode string `json:"product_mode,omitempty"`
2930
ControlPlaneURL string `json:"control_plane_url,omitempty"`
3031
ControlPlaneDatabase string `json:"control_plane_database,omitempty"`

0 commit comments

Comments
 (0)