Skip to content

Commit 1f167ce

Browse files
Codex/fix hosted mcp token issuance (#15)
* Fix ownerless MCP token minting * Restore workspace-first change routes (#9) * Fix config-scoped state and unchanged checkpoints (#12) --------- Co-authored-by: Andrew Brookins <a.m.brookins@gmail.com>
1 parent 17319a6 commit 1f167ce

12 files changed

Lines changed: 450 additions & 11 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
@@ -815,8 +815,9 @@ func (s *afsMCPServer) toolCheckpointCreate(ctx context.Context, args map[string
815815
return nil, err
816816
}
817817
saved, err := saveAFSWorkspaceOrLiveRoot(ctx, s.cfg, s.store, workspace, checkpointID, false, controlplane.SaveCheckpointFromLiveOptions{
818-
Kind: controlplane.CheckpointKindManual,
819-
Source: controlplane.CheckpointSourceMCP,
818+
Kind: controlplane.CheckpointKindManual,
819+
Source: controlplane.CheckpointSourceMCP,
820+
AllowUnchanged: true,
820821
})
821822
if err != nil {
822823
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"
@@ -480,24 +485,50 @@ func defaultWorkRoot() string {
480485
return filepath.Join(stateDir(), "workspaces")
481486
}
482487

483-
func statePath() string {
488+
func defaultStatePath() string {
484489
return filepath.Join(stateDir(), "state.json")
485490
}
486491

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

498517
func loadState() (state, error) {
518+
if st, err := loadStateFromPath(statePath()); err == nil {
519+
return st, nil
520+
} else if !errors.Is(err, os.ErrNotExist) {
521+
return state{}, err
522+
}
523+
if sameConfigPath(statePath(), defaultStatePath()) {
524+
return state{}, os.ErrNotExist
525+
}
526+
return loadStateFromPath(defaultStatePath())
527+
}
528+
529+
func loadStateFromPath(path string) (state, error) {
499530
var st state
500-
b, err := os.ReadFile(statePath())
531+
b, err := os.ReadFile(path)
501532
if err != nil {
502533
return st, err
503534
}

cmd/afs/sync_integration_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1316,6 +1316,77 @@ func TestCmdFileCreateExclusiveRoundTrip(t *testing.T) {
13161316
}
13171317
}
13181318

1319+
func TestCmdFileCreateExclusiveUsesConfigScopedState(t *testing.T) {
1320+
t.Helper()
1321+
1322+
env := newSyncTestEnv(t)
1323+
env.startDaemon(t)
1324+
defer env.stopDaemon()
1325+
1326+
oldCfgPathOverride := cfgPathOverride
1327+
cfgPathOverride = filepath.Join(t.TempDir(), "afs.config.json")
1328+
t.Cleanup(func() {
1329+
cfgPathOverride = oldCfgPathOverride
1330+
})
1331+
1332+
cfg := defaultConfig()
1333+
cfg.ProductMode = productModeLocal
1334+
cfg.Mode = modeSync
1335+
cfg.RedisAddr = env.mr.Addr()
1336+
cfg.RedisDB = 0
1337+
cfg.LocalPath = env.localRoot
1338+
cfg.CurrentWorkspace = env.workspace
1339+
if err := saveConfig(cfg); err != nil {
1340+
t.Fatalf("saveConfig() returned error: %v", err)
1341+
}
1342+
1343+
st := state{
1344+
ProductMode: productModeLocal,
1345+
RedisAddr: env.mr.Addr(),
1346+
RedisDB: 0,
1347+
CurrentWorkspace: env.workspace,
1348+
LocalPath: env.localRoot,
1349+
Mode: modeSync,
1350+
SyncPID: os.Getpid(),
1351+
}
1352+
if err := saveState(st); err != nil {
1353+
t.Fatalf("saveState() returned error: %v", err)
1354+
}
1355+
if sameConfigPath(statePath(), defaultStatePath()) {
1356+
t.Fatalf("statePath() = %q, want config-scoped path distinct from legacy %q", statePath(), defaultStatePath())
1357+
}
1358+
1359+
legacyState := state{
1360+
ProductMode: productModeLocal,
1361+
RedisAddr: "127.0.0.1:1",
1362+
RedisDB: 99,
1363+
CurrentWorkspace: "legacy-workspace",
1364+
LocalPath: t.TempDir(),
1365+
Mode: modeSync,
1366+
SyncPID: os.Getpid(),
1367+
}
1368+
rawLegacyState, err := json.MarshalIndent(legacyState, "", " ")
1369+
if err != nil {
1370+
t.Fatalf("json.MarshalIndent(legacyState) returned error: %v", err)
1371+
}
1372+
if err := os.MkdirAll(filepath.Dir(defaultStatePath()), 0o700); err != nil {
1373+
t.Fatalf("MkdirAll(defaultStatePath dir) returned error: %v", err)
1374+
}
1375+
if err := os.WriteFile(defaultStatePath(), rawLegacyState, 0o600); err != nil {
1376+
t.Fatalf("WriteFile(defaultStatePath) returned error: %v", err)
1377+
}
1378+
1379+
if err := cmdFS([]string{"fs", "create-exclusive", "--content", "agent-c\n", "/tasks/003.claim"}); err != nil {
1380+
t.Fatalf("cmdFS(create-exclusive with config-scoped state) returned error: %v", err)
1381+
}
1382+
assertEventually(t, 3*time.Second, "remote 003.claim", func() bool {
1383+
return env.remoteExists(t, "tasks/003.claim")
1384+
})
1385+
if got := env.readRemoteFile(t, "tasks/003.claim"); got != "agent-c\n" {
1386+
t.Fatalf("remote content = %q, want %q", got, "agent-c\n")
1387+
}
1388+
}
1389+
13191390
// Scenario 1 (burst variant): a batch of files written before startup all
13201391
// land remotely, and the steady-state has no spurious echo loops.
13211392
func TestSyncStartupUploadBurst(t *testing.T) {

internal/controlplane/database_manager.go

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1161,7 +1161,25 @@ func (m *DatabaseManager) ListChangelog(ctx context.Context, databaseID, workspa
11611161
if err != nil {
11621162
return ChangelogListResponse{}, err
11631163
}
1164-
return service.ListChangelog(ctx, route.WorkspaceID, req)
1164+
response, err := service.ListChangelog(ctx, route.WorkspaceID, req)
1165+
if err != nil {
1166+
return ChangelogListResponse{}, err
1167+
}
1168+
m.attachDatabaseToChangelog(&response, route.DatabaseID)
1169+
return response, nil
1170+
}
1171+
1172+
func (m *DatabaseManager) ListResolvedChangelog(ctx context.Context, workspace string, req ChangelogListRequest) (ChangelogListResponse, error) {
1173+
service, _, route, err := m.resolveWorkspace(ctx, workspace)
1174+
if err != nil {
1175+
return ChangelogListResponse{}, err
1176+
}
1177+
response, err := service.ListChangelog(ctx, route.WorkspaceID, req)
1178+
if err != nil {
1179+
return ChangelogListResponse{}, err
1180+
}
1181+
m.attachDatabaseToChangelog(&response, route.DatabaseID)
1182+
return response, nil
11651183
}
11661184

11671185
// GetSessionChangelogSummary returns the per-session rollup (op counts, delta bytes).
@@ -1173,6 +1191,14 @@ func (m *DatabaseManager) GetSessionChangelogSummary(ctx context.Context, databa
11731191
return service.GetSessionChangelogSummary(ctx, route.WorkspaceID, sessionID)
11741192
}
11751193

1194+
func (m *DatabaseManager) GetResolvedSessionChangelogSummary(ctx context.Context, workspace, sessionID string) (SessionChangelogSummary, error) {
1195+
service, _, route, err := m.resolveWorkspace(ctx, workspace)
1196+
if err != nil {
1197+
return SessionChangelogSummary{}, err
1198+
}
1199+
return service.GetSessionChangelogSummary(ctx, route.WorkspaceID, sessionID)
1200+
}
1201+
11761202
// GetPathLastWriter returns the last-writer metadata for a single path.
11771203
func (m *DatabaseManager) GetPathLastWriter(ctx context.Context, databaseID, workspace, path string) (PathLastWriter, error) {
11781204
service, _, route, err := m.resolveScopedWorkspace(ctx, databaseID, workspace)
@@ -1182,6 +1208,14 @@ func (m *DatabaseManager) GetPathLastWriter(ctx context.Context, databaseID, wor
11821208
return service.GetPathLastWriter(ctx, route.WorkspaceID, path)
11831209
}
11841210

1211+
func (m *DatabaseManager) GetResolvedPathLastWriter(ctx context.Context, workspace, path string) (PathLastWriter, error) {
1212+
service, _, route, err := m.resolveWorkspace(ctx, workspace)
1213+
if err != nil {
1214+
return PathLastWriter{}, err
1215+
}
1216+
return service.GetPathLastWriter(ctx, route.WorkspaceID, path)
1217+
}
1218+
11851219
func (m *DatabaseManager) ForkWorkspace(ctx context.Context, databaseID, sourceWorkspace, newWorkspace string) error {
11861220
service, _, route, err := m.resolveScopedWorkspace(ctx, databaseID, sourceWorkspace)
11871221
if err != nil {

internal/controlplane/http.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1949,6 +1949,64 @@ func handleResolvedWorkspaceRoute(
19491949
return
19501950
}
19511951
writeJSON(w, http.StatusOK, response)
1952+
case strings.HasSuffix(workspacePath, "/changes"):
1953+
workspace := strings.TrimSuffix(workspacePath, "/changes")
1954+
if r.Method != http.MethodGet {
1955+
writeError(w, fmt.Errorf("%s not allowed", r.Method))
1956+
return
1957+
}
1958+
limit, err := queryInt(r, "limit", 100)
1959+
if err != nil {
1960+
writeError(w, err)
1961+
return
1962+
}
1963+
req := ChangelogListRequest{
1964+
SessionID: strings.TrimSpace(r.URL.Query().Get("session_id")),
1965+
Path: strings.TrimSpace(r.URL.Query().Get("path")),
1966+
Since: strings.TrimSpace(r.URL.Query().Get("since")),
1967+
Until: strings.TrimSpace(r.URL.Query().Get("until")),
1968+
Limit: limit,
1969+
Reverse: strings.EqualFold(r.URL.Query().Get("direction"), "desc"),
1970+
}
1971+
response, err := manager.ListResolvedChangelog(r.Context(), workspace, req)
1972+
if err != nil {
1973+
writeError(w, err)
1974+
return
1975+
}
1976+
writeJSON(w, http.StatusOK, response)
1977+
case strings.Contains(workspacePath, "/sessions/") && strings.HasSuffix(workspacePath, "/summary"):
1978+
parts := strings.Split(strings.Trim(workspacePath, "/"), "/")
1979+
if len(parts) != 4 || parts[1] != "sessions" || parts[3] != "summary" {
1980+
writeError(w, os.ErrNotExist)
1981+
return
1982+
}
1983+
if r.Method != http.MethodGet {
1984+
writeError(w, fmt.Errorf("%s not allowed", r.Method))
1985+
return
1986+
}
1987+
response, err := manager.GetResolvedSessionChangelogSummary(r.Context(), parts[0], parts[2])
1988+
if err != nil {
1989+
writeError(w, err)
1990+
return
1991+
}
1992+
writeJSON(w, http.StatusOK, response)
1993+
case strings.HasSuffix(workspacePath, "/path-last"):
1994+
workspace := strings.TrimSuffix(workspacePath, "/path-last")
1995+
if r.Method != http.MethodGet {
1996+
writeError(w, fmt.Errorf("%s not allowed", r.Method))
1997+
return
1998+
}
1999+
path := strings.TrimSpace(r.URL.Query().Get("path"))
2000+
if path == "" {
2001+
writeError(w, fmt.Errorf("path query parameter is required"))
2002+
return
2003+
}
2004+
response, err := manager.GetResolvedPathLastWriter(r.Context(), workspace, path)
2005+
if err != nil {
2006+
writeError(w, err)
2007+
return
2008+
}
2009+
writeJSON(w, http.StatusOK, response)
19522010
case strings.HasSuffix(workspacePath, "/mcp-tokens"):
19532011
workspace := strings.TrimSuffix(workspacePath, "/mcp-tokens")
19542012
switch r.Method {

0 commit comments

Comments
 (0)