Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions internal/qapi/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,32 @@ func sameProfileInfo(before, after os.FileInfo) bool {
func profileDirectoryOrFileInfo(info os.FileInfo) bool {
return profileDirectoryInfo(info) || profileFileInfo(info)
}

// ProfileSelection reports the defaultProfile id and every profile id the
// user-owned profile below root defines, from the same single safe snapshot
// read route resolution uses. It reports ok=false when no source is present or
// when the source cannot be inspected or decoded safely, so a caller describes
// a refusal without opening a second, weaker read path (#374 S6).
//
// The ids come from qaroute.ProfileIDs and the default from
// qaroute.DefaultProfileID: the two existing decoders over one verified byte
// snapshot, never a second profile representation.
func ProfileSelection(root string) (string, []string, bool) {
source, ok := inspectProfileSource(root)
if !ok {
return "", nil, false
}
data, ok := source.read()
if !ok {
return "", nil, false
}
ids, ok := qaroute.ProfileIDs(data)
if !ok {
return "", nil, false
}
defaultID, ok := qaroute.DefaultProfileID(data)
if !ok {
return "", nil, false
}
return defaultID, ids, true
}
52 changes: 52 additions & 0 deletions internal/qapi/profile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,3 +153,55 @@ func writeProfile(t *testing.T, root string, data []byte) {
t.Fatal(err)
}
}

// TestProfileSelectionReportsTheSameSnapshotResolutionReads pins the S6 (#374)
// listing seam: the default id and the sorted admitted ids come from the one
// safe snapshot read the route policy uses, so a refusal enumerates ids the
// policy itself accepts instead of a second, weaker representation.
func TestProfileSelectionReportsTheSameSnapshotResolutionReads(t *testing.T) {
root := testProfileRoot(t)
writeProfile(t, root, []byte(`{"schemaVersion":1,"defaultProfile":"fleet","profiles":{"qa-fleet":{"routes":{"test-runner":{"pi":{"provider":"nan","model":"qwen3.6","effort":"low"}}}},"fleet":{"routes":{"test-runner":{"pi":{"provider":"nan","model":"glm5.3","effort":"high"}}}}}}`))
defaultID, ids, ok := ProfileSelection(root)
if !ok || defaultID != "fleet" {
t.Fatalf("ProfileSelection() = (%q, %q, %v), want the file's defaultProfile", defaultID, ids, ok)
}
if len(ids) != 2 || ids[0] != "fleet" || ids[1] != "qa-fleet" {
t.Fatalf("ProfileSelection() ids = %q, want [fleet qa-fleet]", ids)
}
// An absent or unsafe source has no selection at all: a caller reports the
// refusal instead of listing ids from a file it could not verify.
for name, setup := range map[string]func(t *testing.T, root string){
"absent": func(t *testing.T, root string) {},
"symlink": func(t *testing.T, root string) {
mustSymlink(t, filepath.Join(testProfileRoot(t), "elsewhere.json"), stagedProfilePath(t, root))
},
"malformed": func(t *testing.T, root string) {
writeProfile(t, root, []byte(`{"schemaVersion":1,"defaultProfile":"9bad","profiles":{}}`))
},
} {
t.Run(name, func(t *testing.T) {
root := testProfileRoot(t)
setup(t, root)
if defaultID, ids, ok := ProfileSelection(root); ok || defaultID != "" || ids != nil {
t.Fatalf("ProfileSelection() = (%q, %q, %v), want no selection", defaultID, ids, ok)
}
})
}
}

func mustSymlink(t *testing.T, target, link string) {
t.Helper()
if err := os.Symlink(target, link); err != nil {
t.Fatal(err)
}
}

// stagedProfilePath creates the profile directory of root and returns the file
// path inside it, without writing a file there.
func stagedProfilePath(t *testing.T, root string) string {
t.Helper()
if err := os.MkdirAll(filepath.Dir(profilePath(root)), 0o755); err != nil {
t.Fatal(err)
}
return profilePath(root)
}
20 changes: 20 additions & 0 deletions internal/qaroute/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"io"
"regexp"
"sort"
"unicode/utf8"

"github.com/refactor-ia/cortex/internal/qarole"
Expand Down Expand Up @@ -150,3 +151,22 @@ func DefaultProfileID(data []byte) (string, bool) {
}
return defaultID, true
}

// ProfileIDs lists the ids one profile snapshot defines, sorted, so a refusal
// can enumerate what the file actually offers (#374 S6). It decodes through
// decodeProfiles: one decoder and one validity rule, so every id this function
// lists is an id Resolve accepts, and an id it omits is one Resolve refuses.
// Bounds are decodeProfiles' own (snapshot size, at most 64 profiles, and the
// profile-id pattern), so this adds no second representation of a profile.
func ProfileIDs(data []byte) ([]string, bool) {
decoded, ok := decodeProfiles(data)
if !ok {
return nil, false
}
ids := make([]string, 0, len(decoded))
for id := range decoded {
ids = append(ids, id)
}
sort.Strings(ids)
return ids, true
}
33 changes: 33 additions & 0 deletions internal/qaroute/profile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,36 @@ func TestResolvePerBackendRoutes(t *testing.T) {
t.Fatalf("unadmitted backend key failure = %q, want profile_invalid", failure.Code)
}
}

// TestProfileIDsListsSortedAdmittedIDs pins the enumeration a refusal uses to
// name what a profile file actually offers (#374 S6): the ids come from the one
// existing decoder, so an id this function lists is an id Resolve accepts, and
// the order is sorted rather than the file's or Go's map order.
func TestProfileIDsListsSortedAdmittedIDs(t *testing.T) {
body := []byte(`{"schemaVersion":1,"defaultProfile":"fleet","profiles":{"qa-fleet":{"routes":{"requirements-analyst":{"pi":{"provider":"nan","model":"qwen3.6","effort":"low"}}}},"fleet":{"routes":{"requirements-analyst":{"pi":{"provider":"nan","model":"glm5.3","effort":"high"}}}},"alpha":{"routes":{"requirements-analyst":{"pi":{"provider":"nan","model":"deepseek-v4-flash","effort":"low"}}}}}}`)
ids, ok := ProfileIDs(body)
if !ok {
t.Fatal("ProfileIDs rejected a snapshot the route policy accepts")
}
want := []string{"alpha", "fleet", "qa-fleet"}
if len(ids) != len(want) {
t.Fatalf("ProfileIDs() = %q, want %q", ids, want)
}
for index, id := range want {
if ids[index] != id {
t.Fatalf("ProfileIDs() = %q, want %q", ids, want)
}
if _, failure := Resolve(Request{Role: qarole.RequirementsAnalyst, Backend: "pi", ProfileID: id}, Snapshot{Present: true, Bytes: body}); failure.Code != "" {
t.Fatalf("ProfileIDs listed %q but Resolve refused it: %q", id, failure.Code)
}
}
for name, invalid := range map[string][]byte{
"malformed": []byte(`{"schemaVersion":1,"defaultProfile":"9bad","profiles":{}}`),
"default absent": []byte(`{"schemaVersion":1,"defaultProfile":"missing","profiles":{"fleet":{"routes":{"requirements-analyst":{"pi":{"provider":"nan","model":"glm5.3","effort":"high"}}}}}}`),
"not an object": []byte(`[]`),
} {
if ids, ok := ProfileIDs(invalid); ok || ids != nil {
t.Fatalf("%s: ProfileIDs() = (%q, %v), want no enumeration", name, ids, ok)
}
}
}
Loading