Skip to content

Commit 9d6d0df

Browse files
authored
Fix config-scoped state and unchanged checkpoints (#12)
1 parent 7409620 commit 9d6d0df

9 files changed

Lines changed: 255 additions & 10 deletions

File tree

cmd/afs/afs_live_workspace.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,13 @@ func saveLiveWorkspaceCheckpoint(ctx context.Context, store *afsStore, workspace
120120
if err != nil {
121121
return false, err
122122
}
123+
var metadata controlplane.SaveCheckpointFromLiveOptions
124+
if len(options) > 0 {
125+
metadata = options[0]
126+
}
123127
if dirty, known, err := store.workspaceRootDirtyState(ctx, workspace); err != nil {
124128
return false, err
125-
} else if known && !dirty {
129+
} else if known && !dirty && !metadata.AllowUnchanged {
126130
if printResult {
127131
fmt.Println("No changes to save")
128132
}

cmd/afs/afs_mcp.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -826,8 +826,9 @@ func (s *afsMCPServer) toolCheckpointCreate(ctx context.Context, args map[string
826826
return nil, err
827827
}
828828
saved, err := saveAFSWorkspaceOrLiveRoot(ctx, s.cfg, s.store, workspace, checkpointID, false, controlplane.SaveCheckpointFromLiveOptions{
829-
Kind: controlplane.CheckpointKindManual,
830-
Source: controlplane.CheckpointSourceMCP,
829+
Kind: controlplane.CheckpointKindManual,
830+
Source: controlplane.CheckpointSourceMCP,
831+
AllowUnchanged: true,
831832
})
832833
if err != nil {
833834
return nil, err

cmd/afs/afs_mcp_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,43 @@ func TestAFSMCPCheckpointCreatePersistsPendingWrite(t *testing.T) {
219219
}
220220
}
221221

222+
func TestAFSMCPCheckpointCreateAllowsUnchangedWorkspace(t *testing.T) {
223+
t.Helper()
224+
225+
server, closeFn := setupAFSMCPTestServer(t)
226+
defer closeFn()
227+
server.profile = controlplane.MCPProfileWorkspaceRWCheckpoint
228+
229+
checkpointResult := server.callTool(context.Background(), "checkpoint_create", map[string]any{
230+
"checkpoint": "unchanged-head",
231+
})
232+
if checkpointResult.IsError {
233+
t.Fatalf("checkpoint_create on unchanged workspace returned error result: %+v", checkpointResult)
234+
}
235+
236+
var checkpointPayload map[string]any
237+
if err := decodeStructuredContent(checkpointResult.StructuredContent, &checkpointPayload); err != nil {
238+
t.Fatalf("decodeStructuredContent(checkpoint unchanged) returned error: %v", err)
239+
}
240+
if created, _ := checkpointPayload["created"].(bool); !created {
241+
t.Fatalf("checkpoint_create created = %#v, want true", checkpointPayload["created"])
242+
}
243+
if checkpoint, _ := checkpointPayload["checkpoint"].(string); checkpoint != "unchanged-head" {
244+
t.Fatalf("checkpoint_create checkpoint = %#v, want %q", checkpointPayload["checkpoint"], "unchanged-head")
245+
}
246+
247+
if _, err := server.store.getSavepointMeta(context.Background(), "repo", "unchanged-head"); err != nil {
248+
t.Fatalf("getSavepointMeta(unchanged-head) returned error: %v", err)
249+
}
250+
251+
restoreResult := server.callTool(context.Background(), "checkpoint_restore", map[string]any{
252+
"checkpoint": "unchanged-head",
253+
})
254+
if restoreResult.IsError {
255+
t.Fatalf("checkpoint_restore after unchanged create returned error result: %+v", restoreResult)
256+
}
257+
}
258+
222259
func TestAFSMCPFileWriteDoesNotRematerializeLocalWorkspaceCache(t *testing.T) {
223260
t.Helper()
224261

cmd/afs/config_state.go

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package main
22

33
import (
44
"crypto/rand"
5+
"crypto/sha256"
56
"encoding/hex"
67
"encoding/json"
78
"errors"
@@ -19,6 +20,10 @@ func configPath() string {
1920
if cfgPathOverride != "" {
2021
return cfgPathOverride
2122
}
23+
return defaultConfigPath()
24+
}
25+
26+
func defaultConfigPath() string {
2227
exe, err := executablePath()
2328
if err != nil {
2429
return "afs.config.json"
@@ -477,24 +482,50 @@ func defaultWorkRoot() string {
477482
return filepath.Join(stateDir(), "workspaces")
478483
}
479484

480-
func statePath() string {
485+
func defaultStatePath() string {
481486
return filepath.Join(stateDir(), "state.json")
482487
}
483488

489+
func statePathForConfig(configFile string) string {
490+
cleanConfig := cleanConfigPath(configFile)
491+
if cleanConfig == "" || cleanConfig == cleanConfigPath(defaultConfigPath()) {
492+
return defaultStatePath()
493+
}
494+
sum := sha256.Sum256([]byte(cleanConfig))
495+
return filepath.Join(stateDir(), "configs", hex.EncodeToString(sum[:8])+".json")
496+
}
497+
498+
func statePath() string {
499+
return statePathForConfig(configPath())
500+
}
501+
484502
func saveState(st state) error {
485-
if err := os.MkdirAll(stateDir(), 0o700); err != nil {
503+
target := statePath()
504+
if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
486505
return err
487506
}
488507
b, err := json.MarshalIndent(st, "", " ")
489508
if err != nil {
490509
return err
491510
}
492-
return os.WriteFile(statePath(), b, 0o600)
511+
return os.WriteFile(target, b, 0o600)
493512
}
494513

495514
func loadState() (state, error) {
515+
if st, err := loadStateFromPath(statePath()); err == nil {
516+
return st, nil
517+
} else if !errors.Is(err, os.ErrNotExist) {
518+
return state{}, err
519+
}
520+
if sameConfigPath(statePath(), defaultStatePath()) {
521+
return state{}, os.ErrNotExist
522+
}
523+
return loadStateFromPath(defaultStatePath())
524+
}
525+
526+
func loadStateFromPath(path string) (state, error) {
496527
var st state
497-
b, err := os.ReadFile(statePath())
528+
b, err := os.ReadFile(path)
498529
if err != nil {
499530
return st, err
500531
}

cmd/afs/sync_integration_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1194,6 +1194,77 @@ func TestCmdFileCreateExclusiveRoundTrip(t *testing.T) {
11941194
}
11951195
}
11961196

1197+
func TestCmdFileCreateExclusiveUsesConfigScopedState(t *testing.T) {
1198+
t.Helper()
1199+
1200+
env := newSyncTestEnv(t)
1201+
env.startDaemon(t)
1202+
defer env.stopDaemon()
1203+
1204+
oldCfgPathOverride := cfgPathOverride
1205+
cfgPathOverride = filepath.Join(t.TempDir(), "afs.config.json")
1206+
t.Cleanup(func() {
1207+
cfgPathOverride = oldCfgPathOverride
1208+
})
1209+
1210+
cfg := defaultConfig()
1211+
cfg.ProductMode = productModeLocal
1212+
cfg.Mode = modeSync
1213+
cfg.RedisAddr = env.mr.Addr()
1214+
cfg.RedisDB = 0
1215+
cfg.LocalPath = env.localRoot
1216+
cfg.CurrentWorkspace = env.workspace
1217+
if err := saveConfig(cfg); err != nil {
1218+
t.Fatalf("saveConfig() returned error: %v", err)
1219+
}
1220+
1221+
st := state{
1222+
ProductMode: productModeLocal,
1223+
RedisAddr: env.mr.Addr(),
1224+
RedisDB: 0,
1225+
CurrentWorkspace: env.workspace,
1226+
LocalPath: env.localRoot,
1227+
Mode: modeSync,
1228+
SyncPID: os.Getpid(),
1229+
}
1230+
if err := saveState(st); err != nil {
1231+
t.Fatalf("saveState() returned error: %v", err)
1232+
}
1233+
if sameConfigPath(statePath(), defaultStatePath()) {
1234+
t.Fatalf("statePath() = %q, want config-scoped path distinct from legacy %q", statePath(), defaultStatePath())
1235+
}
1236+
1237+
legacyState := state{
1238+
ProductMode: productModeLocal,
1239+
RedisAddr: "127.0.0.1:1",
1240+
RedisDB: 99,
1241+
CurrentWorkspace: "legacy-workspace",
1242+
LocalPath: t.TempDir(),
1243+
Mode: modeSync,
1244+
SyncPID: os.Getpid(),
1245+
}
1246+
rawLegacyState, err := json.MarshalIndent(legacyState, "", " ")
1247+
if err != nil {
1248+
t.Fatalf("json.MarshalIndent(legacyState) returned error: %v", err)
1249+
}
1250+
if err := os.MkdirAll(filepath.Dir(defaultStatePath()), 0o700); err != nil {
1251+
t.Fatalf("MkdirAll(defaultStatePath dir) returned error: %v", err)
1252+
}
1253+
if err := os.WriteFile(defaultStatePath(), rawLegacyState, 0o600); err != nil {
1254+
t.Fatalf("WriteFile(defaultStatePath) returned error: %v", err)
1255+
}
1256+
1257+
if err := cmdFS([]string{"fs", "create-exclusive", "--content", "agent-c\n", "/tasks/003.claim"}); err != nil {
1258+
t.Fatalf("cmdFS(create-exclusive with config-scoped state) returned error: %v", err)
1259+
}
1260+
assertEventually(t, 3*time.Second, "remote 003.claim", func() bool {
1261+
return env.remoteExists(t, "tasks/003.claim")
1262+
})
1263+
if got := env.readRemoteFile(t, "tasks/003.claim"); got != "agent-c\n" {
1264+
t.Fatalf("remote content = %q, want %q", got, "agent-c\n")
1265+
}
1266+
}
1267+
11971268
// Scenario 1 (burst variant): a batch of files written before startup all
11981269
// land remotely, and the steady-state has no spurious echo loops.
11991270
func TestSyncStartupUploadBurst(t *testing.T) {

internal/controlplane/mcp_hosted.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -549,8 +549,9 @@ func (p *hostedMCPProvider) callWorkspaceTool(ctx context.Context, name string,
549549
if err = validateHostedMCPName("checkpoint", checkpointID); err == nil {
550550
var saved bool
551551
saved, err = p.manager.SaveCheckpointFromLiveWithOptions(ctx, p.databaseID, p.workspace, checkpointID, SaveCheckpointFromLiveOptions{
552-
Kind: CheckpointKindManual,
553-
Source: CheckpointSourceMCP,
552+
Kind: CheckpointKindManual,
553+
Source: CheckpointSourceMCP,
554+
AllowUnchanged: true,
554555
})
555556
value = map[string]any{
556557
"workspace": p.workspace,

internal/controlplane/mcp_hosted_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,43 @@ func TestHostedMCPFileCreateExclusiveFailsWhenFileExists(t *testing.T) {
7979
}
8080
}
8181

82+
func TestHostedMCPCheckpointCreateAllowsUnchangedWorkspace(t *testing.T) {
83+
t.Helper()
84+
85+
manager, databaseID := newTestManager(t)
86+
provider := &hostedMCPProvider{
87+
manager: manager,
88+
databaseID: databaseID,
89+
workspace: "repo",
90+
profile: MCPProfileWorkspaceRWCheckpoint,
91+
}
92+
93+
checkpointResult := provider.CallTool(context.Background(), "checkpoint_create", map[string]any{
94+
"checkpoint": "unchanged-head",
95+
})
96+
if checkpointResult.IsError {
97+
t.Fatalf("checkpoint_create on unchanged workspace returned error result: %+v", checkpointResult)
98+
}
99+
100+
var checkpointPayload map[string]any
101+
if err := decodeHostedStructuredContent(checkpointResult.StructuredContent, &checkpointPayload); err != nil {
102+
t.Fatalf("decodeHostedStructuredContent(checkpoint unchanged) returned error: %v", err)
103+
}
104+
if created, _ := checkpointPayload["created"].(bool); !created {
105+
t.Fatalf("checkpoint_create created = %#v, want true", checkpointPayload["created"])
106+
}
107+
if checkpoint, _ := checkpointPayload["checkpoint"].(string); checkpoint != "unchanged-head" {
108+
t.Fatalf("checkpoint_create checkpoint = %#v, want %q", checkpointPayload["checkpoint"], "unchanged-head")
109+
}
110+
111+
restoreResult := provider.CallTool(context.Background(), "checkpoint_restore", map[string]any{
112+
"checkpoint": "unchanged-head",
113+
})
114+
if restoreResult.IsError {
115+
t.Fatalf("checkpoint_restore after unchanged create returned error result: %+v", restoreResult)
116+
}
117+
}
118+
82119
func TestHostedMCPWorkspaceVersioningPolicyToolsRoundTrip(t *testing.T) {
83120
t.Helper()
84121

sdk/python/tests/test_client.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import unittest
22
from unittest.mock import patch
33

4-
from redis_afs.client import AFSError, FSClient, MCPHttpClient, MountedFS, _MountedWorkspace, _normalize_mcp_endpoint
4+
from redis_afs.client import AFSError, CheckpointClient, FSClient, MCPHttpClient, MountedFS, _MountedWorkspace, _normalize_mcp_endpoint
55

66

77
class FakeMCP:
@@ -32,6 +32,8 @@ def call_tool(self, name, arguments=None):
3232
return {"entries": entries}
3333
if name == "checkpoint_create":
3434
return {"workspace": "workspace", "checkpoint": arguments.get("checkpoint") or "auto", "created": True}
35+
if name == "checkpoint_restore":
36+
return {"workspace": "workspace", "checkpoint": arguments["checkpoint"], "restored": True}
3537
raise AssertionError(f"unexpected tool {name}")
3638

3739

@@ -90,6 +92,8 @@ def call_tool(self, name, arguments=None):
9092
return {"entries": entries}
9193
if name == "checkpoint_create":
9294
return {"workspace": "workspace", "checkpoint": arguments.get("checkpoint") or "auto", "created": True}
95+
if name == "checkpoint_restore":
96+
return {"workspace": "workspace", "checkpoint": arguments["checkpoint"], "restored": True}
9397
raise AssertionError(f"unexpected tool {name}")
9498

9599

@@ -144,6 +148,17 @@ def test_fs_mount_issues_workspace_token_and_reads_and_writes_files(self):
144148

145149

146150
class EndpointTest(unittest.TestCase):
151+
def test_checkpoint_create_and_restore_round_trip(self):
152+
checkpoint = CheckpointClient(FakeMCP())
153+
154+
created = checkpoint.create(workspace="repo", checkpoint="unchanged-head")
155+
restored = checkpoint.restore(workspace="repo", checkpoint="unchanged-head")
156+
157+
self.assertTrue(created["created"])
158+
self.assertEqual(created["checkpoint"], "unchanged-head")
159+
self.assertTrue(restored["restored"])
160+
self.assertEqual(restored["checkpoint"], "unchanged-head")
161+
147162
def test_normalizes_mcp_endpoint(self):
148163
self.assertEqual(_normalize_mcp_endpoint("https://afs.cloud"), "https://afs.cloud/mcp")
149164
self.assertEqual(_normalize_mcp_endpoint("https://afs.cloud/mcp"), "https://afs.cloud/mcp")

sdk/typescript/test/sdk.test.mjs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,51 @@ test("single-workspace mounts allow workspace-relative paths", async () => {
5656
assert.equal(await fs.readFile("/foobar/src/README.md"), "hello");
5757
assert.deepEqual(fs.workspaceNames, ["foobar"]);
5858
});
59+
60+
test("checkpoint.create and checkpoint.restore round-trip through MCP", async () => {
61+
const calls = [];
62+
const afs = new AFS({
63+
apiKey: "test",
64+
baseUrl: "https://afs.cloud",
65+
fetch: async (_url, init) => {
66+
const body = JSON.parse(String(init.body));
67+
calls.push(body);
68+
let structuredContent;
69+
if (body.params.name === "checkpoint_create") {
70+
structuredContent = {
71+
workspace: body.params.arguments.workspace,
72+
checkpoint: body.params.arguments.checkpoint,
73+
created: true,
74+
};
75+
} else if (body.params.name === "checkpoint_restore") {
76+
structuredContent = {
77+
workspace: body.params.arguments.workspace,
78+
checkpoint: body.params.arguments.checkpoint,
79+
restored: true,
80+
};
81+
} else {
82+
throw new Error(`unexpected tool ${body.params.name}`);
83+
}
84+
return new Response(
85+
JSON.stringify({
86+
jsonrpc: "2.0",
87+
id: body.id,
88+
result: { structuredContent },
89+
}),
90+
{ status: 200, headers: { "content-type": "application/json" } },
91+
);
92+
},
93+
});
94+
95+
const created = await afs.checkpoint.create({ workspace: "repo", checkpoint: "unchanged-head" });
96+
const restored = await afs.checkpoint.restore({ workspace: "repo", checkpoint: "unchanged-head" });
97+
98+
assert.equal(created.created, true);
99+
assert.equal(created.checkpoint, "unchanged-head");
100+
assert.equal(restored.restored, true);
101+
assert.equal(restored.checkpoint, "unchanged-head");
102+
assert.deepEqual(
103+
calls.map((call) => call.params.name),
104+
["checkpoint_create", "checkpoint_restore"],
105+
);
106+
});

0 commit comments

Comments
 (0)