From a61115fcbd883a634a3257d190cfda52bfb9eaf9 Mon Sep 17 00:00:00 2001 From: StatPan Date: Sat, 23 May 2026 15:58:18 +0900 Subject: [PATCH] Add approval evidence to cache prune dry-runs --- docs/agent-kernel-adapter-contract.md | 2 +- internal/cli/cli.go | 4 ++ internal/cli/cli_test.go | 52 +++++++++++++++++++++++++ internal/gira/approval.go | 56 +++++++++++++++++++++++++++ internal/gira/cache_prune.go | 18 +++++++++ internal/gira/cache_prune_test.go | 17 ++++++++ 6 files changed, 148 insertions(+), 1 deletion(-) diff --git a/docs/agent-kernel-adapter-contract.md b/docs/agent-kernel-adapter-contract.md index aa46a28..9e5c0f5 100644 --- a/docs/agent-kernel-adapter-contract.md +++ b/docs/agent-kernel-adapter-contract.md @@ -236,7 +236,7 @@ before broad adapter use: | Gap | Impact | Follow-up | | --- | --- | --- | -| Not every mutating dry-run emits the shared approval evidence envelope yet. | `agent-kernel` can use `gira-approval-plan/v1` for ticket lifecycle, core config/registry, workspace repo-sync, repo/issue adoption, and milestone dry-runs, but sprint, Jira transition, and cache mutation plans may still need command-specific normalization. | Extend the shared `approval` object to the remaining non-ticket dry-run mutation reports. | +| Not every mutating dry-run emits the shared approval evidence envelope yet. | `agent-kernel` can use `gira-approval-plan/v1` for ticket lifecycle, core config/registry, workspace repo-sync, repo/issue adoption, milestone, and cache prune dry-runs, but sprint and Jira transition plans may still need command-specific normalization. | Extend the shared `approval` object to the remaining non-ticket dry-run mutation reports. | | Some command families remain text-first or partially JSON-covered. | Automation confidence drops and adapters need fragile parsing. | Add JSON contracts or mark those commands unsupported for adapters. | | No explicit post-apply verification link in every apply report. | Adapters need command-specific knowledge to know which read command proves completion. | Add `post_apply_verification` fields to apply reports. | diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 17fb8a4..43d69c0 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -2165,6 +2165,10 @@ func runCachePrune(args []string, stdout io.Writer, stderr io.Writer) int { return 1 } if *jsonOutput { + gira.EnsureCachePruneReportSchema(&report) + if report.DryRun { + report.Approval = gira.CachePruneApprovalEvidence(report) + } out, err := json.MarshalIndent(report, "", " ") if err != nil { fmt.Fprintf(stderr, "encode cache prune JSON: %v\n", err) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 79ea924..1ce6998 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -585,6 +585,22 @@ func TestCachePruneJSON(t *testing.T) { t.Fatalf("cache prune JSON missing %q:\n%s", want, stdout.String()) } } + var report gira.CachePruneReport + if err := json.Unmarshal(stdout.Bytes(), &report); err != nil { + t.Fatalf("decode cache prune JSON: %v\n%s", err, stdout.String()) + } + if report.SchemaVersion != gira.CachePruneReportSchemaVersion || report.Approval == nil { + t.Fatalf("cache prune dry-run JSON missing schema or approval:\n%s", stdout.String()) + } + if report.Approval.SchemaVersion != gira.ApprovalPlanSchemaVersion || report.Approval.CanonicalCommand != "gira cache prune" || report.Approval.OutputSchema != gira.CachePruneReportSchemaVersion { + t.Fatalf("unexpected cache prune approval evidence: %+v", report.Approval) + } + if report.Approval.ApplyCommand != "gira cache prune --root /tmp/gira-cache --apply" || report.Approval.PostApplyVerification != "gira cache prune --root /tmp/gira-cache --dry-run --json" { + t.Fatalf("unexpected cache prune approval commands: %+v", report.Approval) + } + if report.Approval.Blockers == nil || report.Approval.Warnings == nil { + t.Fatalf("approval blockers and warnings must be stable arrays: %+v", report.Approval) + } } func TestCachePruneApplyBuilderWiring(t *testing.T) { @@ -616,6 +632,42 @@ func TestCachePruneApplyBuilderWiring(t *testing.T) { } } +func TestCachePruneApplyJSONOmitsApprovalEvidence(t *testing.T) { + restore := newCachePruneReport + t.Cleanup(func() { newCachePruneReport = restore }) + newCachePruneReport = func(options gira.CachePruneOptions) (gira.CachePruneReport, error) { + if options.Root != "/tmp/gira-cache" || options.DryRun || !options.Apply { + t.Fatalf("unexpected cache prune options: %#v", options) + } + return gira.CachePruneReport{ + Command: "cache prune", + Root: "/tmp/gira-cache", + ActiveVersion: "v1.2.0", + Apply: true, + Counts: gira.CachePruneCounts{Applied: 1}, + Actions: []gira.CachePruneAction{ + {Action: "prune", Status: "applied", Name: "v1.1.0", Path: "/tmp/gira-cache/v1.1.0", Reason: "removed stale version directory"}, + }, + }, nil + } + + var stdout, stderr bytes.Buffer + code := Run([]string{"cache", "prune", "--root", "/tmp/gira-cache", "--apply", "--json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit code = %d, want 0; stderr: %s", code, stderr.String()) + } + var report gira.CachePruneReport + if err := json.Unmarshal(stdout.Bytes(), &report); err != nil { + t.Fatalf("decode cache prune JSON: %v\n%s", err, stdout.String()) + } + if report.SchemaVersion != gira.CachePruneReportSchemaVersion || !report.Apply { + t.Fatalf("unexpected cache prune apply report: %+v", report) + } + if report.Approval != nil { + t.Fatalf("apply output should not include dry-run approval evidence: %+v", report.Approval) + } +} + func TestGuideQuickstartDefault(t *testing.T) { var stdout, stderr bytes.Buffer code := Run([]string{"guide"}, &stdout, &stderr) diff --git a/internal/gira/approval.go b/internal/gira/approval.go index 9ca0b9c..bbd2378 100644 --- a/internal/gira/approval.go +++ b/internal/gira/approval.go @@ -1043,3 +1043,59 @@ func milestoneApprovalBlockers(report MilestoneReport) []string { } return stableStringSlice(blockers) } + +func CachePruneApprovalEvidence(report CachePruneReport) *ApprovalEvidence { + applyCommand := cachePruneApprovalCommand(report, "--apply") + dryRunCommand := cachePruneApprovalCommand(report, "--dry-run") + return &ApprovalEvidence{ + SchemaVersion: ApprovalPlanSchemaVersion, + Capability: AdapterCapabilityApplyMutation, + CanonicalCommand: "gira cache prune", + DryRunCommand: dryRunCommand, + ApplyCommand: applyCommand, + OutputSchema: CachePruneReportSchemaVersion, + PlannedActions: cachePruneApprovalActions(report), + Blockers: cachePruneApprovalBlockers(report), + Warnings: []string{}, + PostApplyVerification: dryRunCommand + " --json", + } +} + +func cachePruneApprovalCommand(report CachePruneReport, mode string) string { + args := []string{"gira cache prune"} + if strings.TrimSpace(report.Root) != "" { + args = append(args, "--root", QuoteShellArg(report.Root)) + } + args = append(args, mode) + return strings.Join(args, " ") +} + +func cachePruneApprovalActions(report CachePruneReport) []ApprovalPlannedAction { + actions := []ApprovalPlannedAction{} + for _, action := range report.Actions { + if action.Status != "planned" || action.Action != "prune" { + continue + } + detail := strings.TrimSpace(action.Reason) + if strings.TrimSpace(action.Version) != "" { + detail = appendApprovalDetail(detail, "version="+action.Version) + } + actions = append(actions, ApprovalPlannedAction{ + Action: "cache:prune", + Target: action.Path, + Detail: detail, + }) + } + return actions +} + +func cachePruneApprovalBlockers(report CachePruneReport) []string { + blockers := []string{} + if report.DryRun && report.Counts.Planned == 0 { + blockers = appendUniqueStrings(blockers, "cache_prune_no_planned_actions") + } + if report.Counts.Errors > 0 { + blockers = appendUniqueStrings(blockers, "cache_prune_action_error") + } + return stableStringSlice(blockers) +} diff --git a/internal/gira/cache_prune.go b/internal/gira/cache_prune.go index 6179f0f..d413145 100644 --- a/internal/gira/cache_prune.go +++ b/internal/gira/cache_prune.go @@ -8,6 +8,7 @@ import ( ) const cachePruneCommand = "cache prune" +const CachePruneReportSchemaVersion = "cache-prune-report/v1" type CachePruneOptions struct { Root string @@ -18,13 +19,22 @@ type CachePruneOptions struct { } type CachePruneReport struct { + SchemaVersion string `json:"schema_version,omitempty"` Command string `json:"command"` Root string `json:"root"` ActiveVersion string `json:"active_version"` ActiveComparable bool `json:"active_comparable"` DryRun bool `json:"dry_run"` + Apply bool `json:"apply"` Counts CachePruneCounts `json:"counts"` Actions []CachePruneAction `json:"actions"` + Approval *ApprovalEvidence `json:"approval,omitempty"` +} + +func EnsureCachePruneReportSchema(report *CachePruneReport) { + if report != nil && strings.TrimSpace(report.SchemaVersion) == "" { + report.SchemaVersion = CachePruneReportSchemaVersion + } } type CachePruneCounts struct { @@ -76,16 +86,21 @@ func BuildCachePruneReport(options CachePruneOptions) (CachePruneReport, error) activeVersion := normalizeBuildValue(options.ActiveVersion, "dev") _, activeComparable := semverParts(activeVersion) report := CachePruneReport{ + SchemaVersion: CachePruneReportSchemaVersion, Command: cachePruneCommand, Root: root, ActiveVersion: activeVersion, ActiveComparable: activeComparable, DryRun: options.DryRun, + Apply: options.Apply, } entries, err := os.ReadDir(root) if err != nil { if os.IsNotExist(err) { + if options.DryRun { + report.Approval = CachePruneApprovalEvidence(report) + } return report, nil } return CachePruneReport{}, fmt.Errorf("read cache root: %w", err) @@ -117,6 +132,9 @@ func BuildCachePruneReport(options CachePruneOptions) (CachePruneReport, error) } } + if options.DryRun { + report.Approval = CachePruneApprovalEvidence(report) + } return report, nil } diff --git a/internal/gira/cache_prune_test.go b/internal/gira/cache_prune_test.go index 5f529ff..6eda42b 100644 --- a/internal/gira/cache_prune_test.go +++ b/internal/gira/cache_prune_test.go @@ -3,6 +3,7 @@ package gira import ( "os" "path/filepath" + "strings" "testing" ) @@ -25,6 +26,19 @@ func TestBuildCachePruneReportSelectsOnlyOlderStableVersionDirs(t *testing.T) { if report.Counts.Planned != 1 { t.Fatalf("planned = %d, want 1; actions=%#v", report.Counts.Planned, report.Actions) } + if report.SchemaVersion != CachePruneReportSchemaVersion || report.Approval == nil { + t.Fatalf("expected cache prune schema and approval evidence: %+v", report) + } + expectedApply := "gira cache prune --root " + QuoteShellArg(root) + " --apply" + if report.Approval.SchemaVersion != ApprovalPlanSchemaVersion || report.Approval.CanonicalCommand != "gira cache prune" || report.Approval.OutputSchema != CachePruneReportSchemaVersion { + t.Fatalf("unexpected cache prune approval identity: %+v", report.Approval) + } + if report.Approval.ApplyCommand != expectedApply || report.Approval.PostApplyVerification != strings.Replace(expectedApply, " --apply", " --dry-run --json", 1) { + t.Fatalf("unexpected cache prune approval commands: %+v", report.Approval) + } + if report.Approval.Blockers == nil || report.Approval.Warnings == nil || !approvalHasAction(report.Approval.PlannedActions, "cache:prune") { + t.Fatalf("unexpected cache prune approval plan: %+v", report.Approval) + } assertAction(t, report, "v1.0.0", "prune", "planned") assertAction(t, report, "v1.2.0", "skip", "skipped") assertAction(t, report, "v1.3.0", "skip", "skipped") @@ -73,6 +87,9 @@ func TestBuildCachePruneReportApplyDeletesOnlyPlannedStaleDirs(t *testing.T) { if report.Counts.Applied != 1 { t.Fatalf("applied = %d, want 1; actions=%#v", report.Counts.Applied, report.Actions) } + if report.SchemaVersion != CachePruneReportSchemaVersion || !report.Apply || report.Approval != nil { + t.Fatalf("apply report should have schema/apply and omit dry-run approval: %+v", report) + } assertMissing(t, stale) assertExists(t, active) assertExists(t, newer)