-
Notifications
You must be signed in to change notification settings - Fork 0
fix: skip malformed repo agents instead of rejecting the whole source #553
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
9d38769
271a82a
2fe76b3
e7fbab1
21b177b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -76,7 +76,27 @@ type SourceInfo struct { | |
| Status SourceStatus `json:"status"` | ||
| Fingerprint string `json:"fingerprint,omitempty"` | ||
| Warnings []string `json:"warnings,omitempty"` | ||
| Error string `json:"error,omitempty"` | ||
| // Skipped records definitions this source declared but could not honor, so a | ||
| // caller can branch on the degradation instead of parsing Warnings prose. | ||
| // A source with Skipped entries and Status available loaded only in part. | ||
| Skipped []SkippedDefinition `json:"skipped,omitempty"` | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The new
Both carry Fix: add Reply inline to this comment. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. U-G1: the new exported slice field extends a per-field deep-copy obligation that lives in consumers, and two of the four copy sites were not updated. Suggested fix: put the copy contract on the type instead of re-deriving it at each site — add Reply inline to this comment. |
||
| Error string `json:"error,omitempty"` | ||
| } | ||
|
|
||
| // SkippedDefinition is one agent or category that was declared but not loaded. | ||
| // Agent is empty when the whole category was skipped. | ||
| type SkippedDefinition struct { | ||
| Category string `json:"category"` | ||
| Agent string `json:"agent,omitempty"` | ||
| Reason string `json:"reason"` | ||
| } | ||
|
|
||
| // String renders the entry for display surfaces that take prose. | ||
| func (s SkippedDefinition) String() string { | ||
| if s.Agent == "" { | ||
| return fmt.Sprintf("skipped category %q: %s", s.Category, s.Reason) | ||
| } | ||
| return fmt.Sprintf("skipped agent %s/%s: %s", s.Category, s.Agent, s.Reason) | ||
| } | ||
|
|
||
| // Provenance identifies the winning source for one loaded agent. | ||
|
|
@@ -90,6 +110,9 @@ type Provenance struct { | |
| CanonicalPath string `json:"canonical_path,omitempty"` | ||
| Fingerprint string `json:"fingerprint,omitempty"` | ||
| Warnings []string `json:"warnings,omitempty"` | ||
| // Skipped mirrors SourceInfo.Skipped so a SourceInfo round-tripped through | ||
| // Provenance keeps the definitions this source could not honor. | ||
| Skipped []SkippedDefinition `json:"skipped,omitempty"` | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. U-O1 (the premature-abstraction direction): It is also worth deciding whether it should ever be populated: Suggested fix: drop Reply inline to this comment. |
||
| } | ||
|
|
||
| // String returns the user-facing provenance label. | ||
|
|
@@ -124,6 +147,7 @@ func (p Provenance) SourceInfo() SourceInfo { | |
| Status: SourceStatusAvailable, | ||
| Fingerprint: p.Fingerprint, | ||
| Warnings: append([]string(nil), p.Warnings...), | ||
| Skipped: append([]SkippedDefinition(nil), p.Skipped...), | ||
| } | ||
| return info | ||
| } | ||
|
|
@@ -449,8 +473,19 @@ func readFileAgent(agentPath string, category Category, pathName string, provena | |
| return newAgent(category, pathName, index, string(prompt), provenance), nil | ||
| } | ||
|
|
||
| func loadRepoSource(ctx context.Context, source RepoSource, provenance Provenance, allowSoftFailures bool) ([]Agent, SourceInfo, error) { | ||
| func loadRepoSource(ctx context.Context, source RepoSource, provenance Provenance, allowSoftFailures bool) (loaded []Agent, info SourceInfo, err error) { | ||
| repoSource := provenance.SourceInfo() | ||
| var skipped []SkippedDefinition | ||
| // Flush on every return, including ones added later that do not go through | ||
| // fail(). Dropping the skip list would reproduce, one layer down, the silent | ||
| // failure this loader exists to remove — so the invariant is enforced here | ||
| // rather than left to each exit remembering. | ||
| defer func() { | ||
| for _, entry := range skipped { | ||
| info.Skipped = append(info.Skipped, entry) | ||
| info.Warnings = append(info.Warnings, entry.String()) | ||
| } | ||
| }() | ||
| fail := func(err error) ([]Agent, SourceInfo, error) { | ||
| if classified, ok := classifyRepoCatalogError(repoSource, err); allowSoftFailures && ok { | ||
| return nil, classified, nil | ||
|
|
@@ -493,29 +528,77 @@ func loadRepoSource(ctx context.Context, source RepoSource, provenance Provenanc | |
| continue | ||
| } | ||
| if err := validateName("category", categoryName); err != nil { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The 'skip if ErrInvalid, else fail' shape is now hand-duplicated four times (category-name validation at ~526, readRepoCategory at ~536, and the two branches inside readRepoAgents' loop at ~636 and ~645), each re-typing the same errors.Is(err, ErrInvalid) check and SkippedDefinition construction. This is exactly the kind of copy-pasted branch that drifts silently: a future error class that should also be skip-safe (or a fix to the classification logic itself) is one missed call site away from being applied to only three of the four. Extract a small helper, e.g. Reply inline to this comment. |
||
| if entry, ok := skipIfInvalid(err, SkippedDefinition{Category: categoryName}); ok { | ||
| skipped = append(skipped, entry) | ||
| continue | ||
| } | ||
| return fail(err) | ||
| } | ||
| categoryPath := path.Join(repoAgentsRoot, categoryName) | ||
| category, err := readRepoCategory(ctx, source.Reader, source.Ref, baseSHA, categoryPath, categoryName) | ||
| if err != nil { | ||
| if entry, ok := skipIfInvalid(err, SkippedDefinition{Category: categoryName}); ok { | ||
| skipped = append(skipped, entry) | ||
| continue | ||
| } | ||
| return fail(err) | ||
| } | ||
| categoryAgents, err := readRepoAgents(ctx, source.Reader, source.Ref, baseSHA, categoryPath, category, provenance) | ||
| categoryAgents, agentSkips, err := readRepoAgents(ctx, source.Reader, source.Ref, baseSHA, categoryPath, category, provenance) | ||
| skipped = append(skipped, agentSkips...) | ||
| if err != nil { | ||
| return fail(err) | ||
| } | ||
| if len(categoryAgents) == 0 { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. loadRepoSource has three distinct category-level skip-and-continue branches: invalid category name (line 500-506), malformed category index.yaml (line 508-515), and a category that loads but ends up with zero usable agents (line 521-524, 'no usable agents'). Only the second is covered by a sibling-continuation test (TestRepoLoadSkipsMalformedCategoryAndKeepsSiblingCategories). The invalid-category-name and 'no usable agents' branches are only exercised in single-category tests where the whole source ends up SourceStatusInvalid (TestRepoLoadRejectsUnsafeTreeAndYAMLNames's 'unsafe category tree name' case, and TestRepoLoadInvalidWhenEveryAgentIsMalformed), so a regression that turned either of these two branches back into a whole-source failure when a good sibling category exists would pass CI unnoticed — the same class of silent breakage this PR exists to close. Add a case per branch with a healthy sibling category present, mirroring TestRepoLoadSkipsMalformedCategoryAndKeepsSiblingCategories's shape (e.g. one category with an unsafe tree name plus one valid category; one category whose only agent is malformed, alongside a valid category), asserting catalog.Agents still contains the sibling's agent and Sources[0].Warnings names the skipped category. Reply inline to this comment. |
||
| return fail(fmt.Errorf("%w: repo source %s category %q contains no agents", ErrInvalid, repoAgentsRoot, categoryName)) | ||
| // When the agents skipped themselves they already carry the actionable | ||
| // detail; a second entry for the same root cause makes the operator read | ||
| // the failure twice, once without it. A category that declared nothing | ||
| // is a different situation and says so. | ||
| if len(agentSkips) == 0 { | ||
| skipped = append(skipped, SkippedDefinition{Category: categoryName, Reason: "declares no agents"}) | ||
| } | ||
| continue | ||
| } | ||
| loadedAgent = true | ||
| agents = append(agents, categoryAgents...) | ||
| } | ||
| // Only a source with nothing usable is treated as invalid. One malformed | ||
| // definition disabling every other agent in the repo is what this avoids: | ||
| // the failure is indistinguishable from a review that ran and found | ||
| // problems, so it can sit unnoticed for a long time. | ||
| if !loadedAgent { | ||
| return fail(fmt.Errorf("%w: repo source %s contains no agents", ErrInvalid, repoAgentsRoot)) | ||
| return fail(fmt.Errorf("%w: repo source %s contains no usable agents", ErrInvalid, repoAgentsRoot)) | ||
| } | ||
| return agents, repoSource, nil | ||
| } | ||
|
|
||
| // skipIfInvalid decides whether err disqualifies just this definition or the whole | ||
| // source. Kept in one place so a future skip-safe error class, or a fix to the | ||
| // classification itself, cannot reach three of the four call sites and miss one. | ||
| func skipIfInvalid(err error, def SkippedDefinition) (SkippedDefinition, bool) { | ||
| if !errors.Is(err, ErrInvalid) { | ||
| return SkippedDefinition{}, false | ||
| } | ||
| def.Reason = err.Error() | ||
| return def, true | ||
| } | ||
|
|
||
| // scopedToDefinition reclassifies a missing file *beneath* one agent or category | ||
| // directory as a malformed definition. Whether a failure disqualifies one agent or | ||
| // the whole source is a question of path scope, not error class: forgetting to commit | ||
| // prompt.md is as much a malformed agent as a bad field in index.yaml, and only the | ||
| // caller here knows the path is agent-scoped. Reader failures on the tree itself, and | ||
| // any non-NotFound transport error, are left alone and still fail the source. | ||
| func scopedToDefinition(err error, format string, args ...any) error { | ||
| if !errors.Is(err, gitprovider.ErrNotFound) { | ||
| return err | ||
| } | ||
| // Keep the provider error in the chain: these reasons reach the operator, and a | ||
| // NOT_FOUND raised for something other than an absent blob leaves nothing to | ||
| // diagnose with otherwise. Safe for classification — skipIfInvalid and | ||
| // classifyRepoCatalogError both test ErrInvalid first. | ||
| return fmt.Errorf("%w: %s: %w", ErrInvalid, fmt.Sprintf(format, args...), err) | ||
| } | ||
|
|
||
| func classifyRepoCatalogError(source SourceInfo, err error) (SourceInfo, bool) { | ||
| switch { | ||
| case errors.Is(err, ErrInvalid): | ||
|
|
@@ -539,22 +622,34 @@ func repoSourceError(source SourceInfo, status SourceStatus, err error) SourceIn | |
| func readRepoCategory(ctx context.Context, reader RepoReader, ref gitprovider.PRRef, gitRef, categoryPath, pathName string) (Category, error) { | ||
| var index categoryYAML | ||
| if err := decodeRepoYAML(ctx, reader, ref, gitRef, path.Join(categoryPath, "index.yaml"), &index); err != nil { | ||
| return Category{}, err | ||
| return Category{}, scopedToDefinition(err, "category %s is missing or unreadable index.yaml", pathName) | ||
| } | ||
| if err := validateMatchingName("category", pathName, index.Name); err != nil { | ||
| return Category{}, err | ||
| } | ||
| return Category{Name: pathName, Description: index.Description, Owner: index.Owner}, nil | ||
| } | ||
|
|
||
| func readRepoAgents(ctx context.Context, reader RepoReader, ref gitprovider.PRRef, gitRef, categoryPath string, category Category, provenance Provenance) ([]Agent, error) { | ||
| // readRepoAgents returns the agents it could load, plus a message per agent it | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. U-L1: the two loaders that feed the same There is a good rationale available — repo sources are PR-adjacent content the operator does not own, profile sources are the operator's own configuration where failing loudly is correct — but it is nowhere in the diff. Either say that in the comment (naming the trust boundary, not just the safety argument), or mirror the behavior in Reply inline to this comment. |
||
| // skipped. A malformed definition disqualifies that agent only — an unusable | ||
| // agent is never selected, so skipping it is as safe as refusing the whole | ||
| // source and leaves the rest of the repo's guidance working. | ||
| // | ||
| // This deliberately diverges from readFileAgents, which still fails on the first | ||
| // bad agent. The difference is the trust boundary, not an oversight: profile | ||
| // sources are the operator's own configuration, where failing loudly is what they | ||
| // want, while repo sources are PR-adjacent content the operator does not own and | ||
| // cannot fix in the run. One contributor's malformed agent should not silently | ||
| // disarm review for everyone else's PRs. | ||
| func readRepoAgents(ctx context.Context, reader RepoReader, ref gitprovider.PRRef, gitRef, categoryPath string, category Category, provenance Provenance) ([]Agent, []SkippedDefinition, error) { | ||
| entries, err := reader.ListTreeAtRef(ctx, ref, gitRef, categoryPath) | ||
| if err != nil { | ||
| return nil, err | ||
| return nil, nil, err | ||
| } | ||
| sortTreeEntries(entries) | ||
|
|
||
| var agents []Agent | ||
| var skipped []SkippedDefinition | ||
| for _, entry := range entries { | ||
| if entry.Type != "tree" { | ||
| continue | ||
|
|
@@ -564,22 +659,30 @@ func readRepoAgents(ctx context.Context, reader RepoReader, ref gitprovider.PRRe | |
| continue | ||
| } | ||
| if err := validateName("agent", agentName); err != nil { | ||
| return nil, err | ||
| if entry, ok := skipIfInvalid(err, SkippedDefinition{Category: category.Name, Agent: agentName}); ok { | ||
| skipped = append(skipped, entry) | ||
| continue | ||
| } | ||
| return nil, skipped, err | ||
| } | ||
| agentPath := path.Join(categoryPath, agentName) | ||
| agent, err := readRepoAgent(ctx, reader, ref, gitRef, agentPath, category, agentName, provenance) | ||
| if err != nil { | ||
| return nil, err | ||
| if entry, ok := skipIfInvalid(err, SkippedDefinition{Category: category.Name, Agent: agentName}); ok { | ||
| skipped = append(skipped, entry) | ||
| continue | ||
| } | ||
| return nil, skipped, err | ||
| } | ||
| agents = append(agents, agent) | ||
| } | ||
| return agents, nil | ||
| return agents, skipped, nil | ||
| } | ||
|
|
||
| func readRepoAgent(ctx context.Context, reader RepoReader, ref gitprovider.PRRef, gitRef, agentPath string, category Category, pathName string, provenance Provenance) (Agent, error) { | ||
| var index agentYAML | ||
| if err := decodeRepoYAML(ctx, reader, ref, gitRef, path.Join(agentPath, "index.yaml"), &index); err != nil { | ||
| return Agent{}, err | ||
| return Agent{}, scopedToDefinition(err, "agent %s:%s is missing or unreadable index.yaml", category.Name, pathName) | ||
| } | ||
| if err := validateMatchingName("agent", pathName, index.Name); err != nil { | ||
| return Agent{}, err | ||
|
|
@@ -589,7 +692,7 @@ func readRepoAgent(ctx context.Context, reader RepoReader, ref gitprovider.PRRef | |
| } | ||
| prompt, err := reader.GetFileAtRef(ctx, ref, gitRef, path.Join(agentPath, "prompt.md")) | ||
| if err != nil { | ||
| return Agent{}, err | ||
| return Agent{}, scopedToDefinition(err, "agent %s:%s is missing prompt.md", category.Name, pathName) | ||
| } | ||
| return newAgent(category, pathName, index, string(prompt), provenance), nil | ||
| } | ||
|
|
@@ -639,7 +742,7 @@ func validateAgentYAML(categoryName, agentName string, index agentYAML) error { | |
| } | ||
| for _, pattern := range index.FileGlobs { | ||
| if _, err := glob.Compile(pattern, '/'); err != nil { | ||
| return fmt.Errorf("%w: agent %s:%s file_glob %q is invalid: %w", ErrInvalid, categoryName, agentName, pattern, err) | ||
| return fmt.Errorf("%w: agent %s:%s file_glob %q is invalid: %w", ErrInvalid, categoryName, agentName, pattern, err) | ||
| } | ||
| } | ||
| } | ||
|
|
@@ -800,6 +903,7 @@ func provenanceFromSource(source SourceInfo) Provenance { | |
| CanonicalPath: source.CanonicalPath, | ||
| Fingerprint: source.Fingerprint, | ||
| Warnings: append([]string(nil), source.Warnings...), | ||
| Skipped: append([]SkippedDefinition(nil), source.Skipped...), | ||
| } | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The doc quotes the dossier skip-line label as "Guidance not honoured" (British spelling), but internal/dossier/dossier.go writes the literal string "Guidance not honored: " (American spelling, and with a trailing colon the doc omits). Since this is one of the small set of files that map cr's on-disk artifacts to what an operator or agent should grep for, an exact-string mismatch here is exactly the kind of drift this doc exists to prevent — someone searching a dossier for the documented label won't find it. Fix: quote the string exactly as emitted,
"Guidance not honored: ".Reply inline to this comment.