diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b13f399bae..7d22ab4bd7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,6 +65,9 @@ jobs: - name: Build run: task build + - name: Cross-compile plan storage + run: task check-plan-cross + - name: Run tests run: | task test diff --git a/Taskfile.yml b/Taskfile.yml index 59c32564bf..4379da3a57 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -48,6 +48,15 @@ tasks: cmds: - CHECK_MODELS_SNAPSHOT_FRESHNESS=1 go test -run TestSnapshotDateIsFresh ./pkg/modelsdev/ + check-plan-cross: + desc: Cross-compile the plan storage and host plans packages for every file-lock build-tag variant + cmds: + - GOOS=linux GOARCH=amd64 go build ./pkg/tools/builtin/plan/ ./pkg/plans/ + - GOOS=windows GOARCH=amd64 go build ./pkg/tools/builtin/plan/ ./pkg/plans/ + - GOOS=js GOARCH=wasm go build ./pkg/tools/builtin/plan/ ./pkg/plans/ + - GOOS=wasip1 GOARCH=wasm go build ./pkg/tools/builtin/plan/ ./pkg/plans/ + - GOOS=plan9 GOARCH=amd64 go build ./pkg/tools/builtin/plan/ ./pkg/plans/ + deploy-local: desc: Deploy the cli-plugin aliases: [install] diff --git a/cmd/root/plans.go b/cmd/root/plans.go new file mode 100644 index 0000000000..8ed1c75a20 --- /dev/null +++ b/cmd/root/plans.go @@ -0,0 +1,892 @@ +package root + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "path/filepath" + "strconv" + "strings" + "text/tabwriter" + "time" + + "github.com/docker/cli/cli" + "github.com/spf13/cobra" + + "github.com/docker/docker-agent/pkg/plans" + "github.com/docker/docker-agent/pkg/telemetry" + "github.com/docker/docker-agent/pkg/tools/builtin/plan" +) + +// plansSchemaVersion stamps every JSON document the plans commands emit +// (stdout results and stderr errors) so consumers can detect the format. +const plansSchemaVersion = "1" + +// plansConflictExitCode is the exit code for version conflicts, distinct from +// the generic failure code 1 so scripts can branch on "re-read and retry". +const plansConflictExitCode = 3 + +// Stable machine-readable error codes of the --json stderr contract. +const ( + plansErrCodeConflict = "conflict" + plansErrCodeNotFound = "not_found" + plansErrCodeInvalid = "invalid_argument" + plansErrCodeUnsupported = "unsupported" + plansErrCodeCorrupt = "corrupt" + plansErrCodeStorage = "storage" + plansErrCodeUnknown = "error" +) + +// plansCmdOption customizes newPlansCmd; used by tests to inject the service. +type plansCmdOption func(*plansOptions) + +type plansOptions struct { + service plans.Service +} + +// withPlansService injects the plans.Service every subcommand uses, so tests +// run hermetically against temp-backed storage instead of the user's data. +func withPlansService(svc plans.Service) plansCmdOption { + return func(o *plansOptions) { o.service = svc } +} + +// resolveService builds the production service. It must only run at RunE +// time: plan.SharedStorage() resolves the plans directory on first use, so +// touching it before the root persistent pre-run has applied --data-dir would +// capture (and memoize process-wide) the wrong default directory. +func (o *plansOptions) resolveService() plans.Service { + if o.service != nil { + return o.service + } + return plans.NewService(plan.SharedStorage()) +} + +func newPlansCmd(opts ...plansCmdOption) *cobra.Command { + options := &plansOptions{} + for _, opt := range opts { + opt(options) + } + + cmd := &cobra.Command{ + Use: "plans", + Short: "Manage shared and session plans", + Long: `Manage the plans agents collaborate on, from the host. + +Two kinds of plans exist: + + - shared plans: the named, versioned documents of the plan toolset, + collaborated on across sessions. Fully manageable here. + - session plans: the single per-session plan of the "draft, review, + execute" workflow. Read-only here (list, get, export); they belong to + their session and are changed from within it. + +Mutations guard against concurrent edits: pass --expected-version (the +version from a previous get or list) to fail with exit code 3 when the plan +changed in the meantime, or pass --force to deliberately write without the +guard. The CLI never prompts. + +Every subcommand accepts --json for stable machine-readable output on stdout; +failures are then reported as a single JSON object on stderr.`, + Example: ` docker-agent plans list + docker-agent plans create release --file ./plan.md --title "Release plan" + docker-agent plans get release + docker-agent plans update release --file ./plan.md --expected-version 1 + docker-agent plans status release done --expected-version 2 + docker-agent plans export release --output ./plan.md + docker-agent plans delete release --expected-version 3 + docker-agent plans get --session `, + GroupID: "advanced", + SilenceUsage: true, + } + + cmd.AddCommand( + newPlansListCmd(options), + newPlansGetCmd(options), + newPlansCreateCmd(options), + newPlansUpdateCmd(options), + newPlansStatusCmd(options), + newPlansExportCmd(options), + newPlansDeleteCmd(options), + ) + + // Flag-parse failures (unknown flag, bad flag value) short-circuit before + // RunE; route them through the --json error contract. Scoped to the plans + // tree: subcommands inherit this func, the root command is untouched. + cmd.SetFlagErrorFunc(plansValidationError) + for _, sub := range cmd.Commands() { + hardenPlansValidation(sub) + } + + return cmd +} + +// runPlans adapts a subcommand handler into a cobra RunE: it tracks +// telemetry, renders failures (human text, or the JSON error contract when +// --json is set), and maps them to process exit codes via cli.StatusError. +func (o *plansOptions) runPlans(sub string, jsonOut *bool, handler func(cmd *cobra.Command, svc plans.Service, args []string) error) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + trackArgs := append([]string{sub}, args...) + telemetry.TrackCommand(ctx, "plans", trackArgs) + err := handler(cmd, o.resolveService(), args) + telemetry.TrackCommandError(ctx, "plans", trackArgs, err) + if err == nil { + return nil + } + // The failure is fully rendered here; silence cobra so stderr stays a + // single message (a single JSON object in --json mode). Only the exit + // code travels up: main honours cli.StatusError. + cmd.SilenceErrors = true + printPlansError(cmd.ErrOrStderr(), err, *jsonOut) + return cli.StatusError{StatusCode: plansExitCode(err), Cause: err} + } +} + +func plansExitCode(err error) int { + var conflict *plans.ConflictError + if errors.As(err, &conflict) { + return plansConflictExitCode + } + return 1 +} + +// plansUsageError marks CLI-level input mistakes (bad flag combinations, +// unreadable --file paths) so they map to the invalid_argument error code. +type plansUsageError struct{ msg string } + +func (e *plansUsageError) Error() string { return e.msg } + +func plansUsagef(format string, a ...any) error { + return &plansUsageError{msg: fmt.Sprintf(format, a...)} +} + +// plansJSONRequested reports whether the subcommand's --json flag has been +// parsed to true. Flags are parsed left-to-right and parsing stops at the +// first bad token, so during a flag-parse failure --json is only detected +// when it precedes the failing flag (a documented residual of the contract). +func plansJSONRequested(cmd *cobra.Command) bool { + jsonOut, err := cmd.Flags().GetBool("json") + return err == nil && jsonOut +} + +// plansValidationError adapts a cobra-level validation failure (unknown +// flag, bad positional args, missing required flag, violated flag group) to +// the --json error contract. Without --json the error passes through for +// cobra's usual plain-text rendering; with it, the failure is printed as the +// single JSON error object and cobra's own printing is silenced so stderr +// carries no duplicate prose. +func plansValidationError(cmd *cobra.Command, err error) error { + if err == nil || !plansJSONRequested(cmd) { + return err + } + cmd.SilenceErrors = true + usageErr := &plansUsageError{msg: err.Error()} + printPlansError(cmd.ErrOrStderr(), usageErr, true) + return cli.StatusError{StatusCode: plansExitCode(usageErr), Cause: usageErr} +} + +// hardenPlansValidation re-renders the validations cobra runs before RunE +// through plansValidationError. Positional args are covered by wrapping the +// command's Args; required flags and flag groups are checked from PreRunE, +// immediately before cobra's own — then passing — checks, so nothing is +// validated twice with two different renderings. +func hardenPlansValidation(cmd *cobra.Command) { + if inner := cmd.Args; inner != nil { + cmd.Args = func(cmd *cobra.Command, args []string) error { + return plansValidationError(cmd, inner(cmd, args)) + } + } + cmd.PreRunE = func(cmd *cobra.Command, _ []string) error { + if err := cmd.ValidateRequiredFlags(); err != nil { + return plansValidationError(cmd, err) + } + return plansValidationError(cmd, cmd.ValidateFlagGroups()) + } +} + +// plansErrorBody is the "error" object of the JSON stderr contract. Scope, +// name, op, and the conflict versions are included where the failure carries +// them. +type plansErrorBody struct { + Code string `json:"code"` + Message string `json:"message"` + Scope plans.Scope `json:"scope,omitempty"` + Name string `json:"name,omitempty"` + Op string `json:"op,omitempty"` + ExpectedVersion *int `json:"expected_version,omitempty"` + CurrentVersion *int `json:"current_version,omitempty"` +} + +type plansErrorDocument struct { + SchemaVersion string `json:"schema_version"` + Error plansErrorBody `json:"error"` +} + +// plansErrorBodyFor classifies by the typed errors of pkg/plans, never by +// error text. +func plansErrorBodyFor(err error) plansErrorBody { + var ( + conflict *plans.ConflictError + notFound *plans.NotFoundError + unsupported *plans.UnsupportedError + corrupt *plans.CorruptError + storageErr *plans.StorageError + validation *plans.ValidationError + usage *plansUsageError + ) + switch { + case errors.As(err, &conflict): + // Conflicts only exist for shared plans, which are the only versioned + // scope. + return plansErrorBody{ + Code: plansErrCodeConflict, + Message: err.Error(), + Scope: plans.ScopeShared, + Name: conflict.Name, + ExpectedVersion: new(conflict.Expected), + CurrentVersion: new(conflict.Current), + } + case errors.As(err, ¬Found): + return plansErrorBody{Code: plansErrCodeNotFound, Message: err.Error(), Scope: notFound.Scope, Name: notFound.Name} + case errors.As(err, &unsupported): + return plansErrorBody{Code: plansErrCodeUnsupported, Message: err.Error(), Scope: unsupported.Scope, Op: unsupported.Op} + case errors.As(err, &corrupt): + return plansErrorBody{Code: plansErrCodeCorrupt, Message: err.Error(), Scope: corrupt.Scope, Name: corrupt.Name} + case errors.As(err, &storageErr): + return plansErrorBody{Code: plansErrCodeStorage, Message: err.Error(), Scope: storageErr.Scope, Op: storageErr.Op} + case errors.As(err, &validation), errors.As(err, &usage): + return plansErrorBody{Code: plansErrCodeInvalid, Message: err.Error()} + default: + return plansErrorBody{Code: plansErrCodeUnknown, Message: err.Error()} + } +} + +func printPlansError(w io.Writer, err error, jsonOut bool) { + if jsonOut { + // Single-line JSON so stderr is one machine-detectable object. + _ = json.NewEncoder(w).Encode(plansErrorDocument{SchemaVersion: plansSchemaVersion, Error: plansErrorBodyFor(err)}) + return + } + fmt.Fprintln(w, "Error:", err.Error()) +} + +// planRefFlags selects the plan a subcommand addresses: the shared plan named +// by the positional argument (the default), or a session's plan via +// --session. --scope disambiguates explicitly; --session alone implies +// session scope. +type planRefFlags struct { + scope string + session string +} + +func (f *planRefFlags) register(cmd *cobra.Command) { + cmd.Flags().StringVar(&f.scope, "scope", "", `Plan scope: "shared" or "session" (default "shared"; "session" is implied by --session)`) + cmd.Flags().StringVar(&f.session, "session", "", "Session ID whose plan to address (session scope)") +} + +func (f *planRefFlags) sessionSelected() bool { + return f.session != "" || f.scope == string(plans.ScopeSession) +} + +func (f *planRefFlags) resolve(name string) (plans.Ref, error) { + scope := plans.Scope(f.scope) + if f.scope == "" { + scope = plans.ScopeShared + if f.session != "" { + scope = plans.ScopeSession + } + } + switch scope { + case plans.ScopeShared: + if f.session != "" { + return plans.Ref{}, plansUsagef("--session selects a session plan: drop --scope shared or use --scope session") + } + if name == "" { + return plans.Ref{}, plansUsagef("a plan name is required for shared plans") + } + return plans.SharedRef(name), nil + case plans.ScopeSession: + if f.session == "" { + return plans.Ref{}, plansUsagef("--scope session requires --session ") + } + if name != "" { + return plans.Ref{}, plansUsagef("session plans are addressed by --session , not by name; drop %q", name) + } + return plans.SessionRef(f.session), nil + default: + return plans.Ref{}, plansUsagef("invalid --scope %q: use %q or %q", f.scope, plans.ScopeShared, plans.ScopeSession) + } +} + +// planRefName is the plan's identity within its scope, for messages and the +// delete document. +func planRefName(ref plans.Ref) string { + if ref.Scope == plans.ScopeSession { + return ref.SessionID + } + return ref.Name +} + +// planGuardFlags implements the mutation write-guard: --expected-version +// enables optimistic locking, --force deliberately opts out of it. Exactly +// one must be given (enforced by cobra before RunE), so a nil expected +// version is never sent by accident. +type planGuardFlags struct { + expectedVersion int + force bool +} + +func (f *planGuardFlags) register(cmd *cobra.Command) { + cmd.Flags().IntVar(&f.expectedVersion, "expected-version", 0, "Version the plan is expected to be at; the write fails with a version conflict (exit code 3) when it changed") + cmd.Flags().BoolVar(&f.force, "force", false, "Write unconditionally, without the optimistic-lock guard") + cmd.MarkFlagsOneRequired("expected-version", "force") + cmd.MarkFlagsMutuallyExclusive("expected-version", "force") +} + +// expected returns the guard to send to the service: the validated expected +// version, or nil for a deliberate --force. +func (f *planGuardFlags) expected() (*int, error) { + if f.force { + return nil, nil + } + if f.expectedVersion < 1 { + return nil, plansUsagef("--expected-version must be at least 1, got %d", f.expectedVersion) + } + return &f.expectedVersion, nil +} + +// readPlanContent loads the new plan body from path, or from stdin when path +// is "-" so scripts can pipe content in. Both sources are bounded by the plan +// storage's own content cap (plan.MaxPlanContentSize): content of exactly the +// cap is accepted, anything beyond it is refused as invalid input since the +// storage would refuse to persist it anyway. A regular file is required — a +// directory, device, or named pipe is rejected — and every check runs on the +// opened descriptor (File.Stat), never on the path, so the file that is +// validated is exactly the file that is read and a concurrent path swap +// cannot slip a non-regular file past the checks. +// The open itself cannot hang on a FIFO with no writer (see +// plan.OpenContentFile), and a device like /dev/zero is rejected before any +// read. The CLI never prompts: content only ever arrives through --file. +func readPlanContent(cmd *cobra.Command, path string) (string, error) { + if path == "-" { + content, err := readBoundedPlanContent(cmd.InOrStdin()) + if err != nil { + return "", plansUsagef("reading plan content from stdin: %v", err) + } + return content, nil + } + + f, err := plan.OpenContentFile(filepath.Clean(path)) + if err != nil { + return "", plansUsagef("reading plan content from %q: %v", path, err) + } + defer f.Close() + + info, err := f.Stat() + if err != nil { + return "", plansUsagef("reading plan content from %q: %v", path, err) + } + if info.IsDir() { + return "", plansUsagef("plan content path %q is a directory, not a file", path) + } + if !info.Mode().IsRegular() { + return "", plansUsagef("plan content path %q is not a regular file (e.g. a device or named pipe)", path) + } + if info.Size() > plan.MaxPlanContentSize { + return "", plansUsagef("reading plan content from %q: content exceeds the maximum plan size (%d bytes)", path, plan.MaxPlanContentSize) + } + + content, err := readBoundedPlanContent(f) + if err != nil { + return "", plansUsagef("reading plan content from %q: %v", path, err) + } + return content, nil +} + +// readBoundedPlanContent reads r up to the shared plan content cap. It reads +// one byte past the cap so an over-cap source is detected without trusting +// any pre-declared size (stdin has none, and a file can grow between the +// descriptor size check and the read). +func readBoundedPlanContent(r io.Reader) (string, error) { + data, err := io.ReadAll(io.LimitReader(r, plan.MaxPlanContentSize+1)) + if err != nil { + return "", err + } + if len(data) > plan.MaxPlanContentSize { + return "", fmt.Errorf("content exceeds the maximum plan size (%d bytes)", plan.MaxPlanContentSize) + } + return string(data), nil +} + +func firstArg(args []string) string { + if len(args) == 0 { + return "" + } + return args[0] +} + +func formatPlanVersion(v *int) string { + if v == nil { + return "-" + } + return strconv.Itoa(*v) +} + +func formatPlanTime(t time.Time) string { + if t.IsZero() { + return "-" + } + return t.UTC().Format(time.RFC3339) +} + +func orDash(s string) string { + if s == "" { + return "-" + } + return s +} + +func writePlansJSON(w io.Writer, doc any) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(doc) +} + +type plansListDocument struct { + SchemaVersion string `json:"schema_version"` + Plans []plans.Plan `json:"plans"` + Warnings []string `json:"warnings,omitempty"` +} + +type plansPlanDocument struct { + SchemaVersion string `json:"schema_version"` + Plan plans.Plan `json:"plan"` +} + +type plansExportDocument struct { + SchemaVersion string `json:"schema_version"` + Export plans.ExportResult `json:"export"` +} + +type plansDeletedDocument struct { + SchemaVersion string `json:"schema_version"` + Deleted plansRefBody `json:"deleted"` +} + +type plansRefBody struct { + Scope plans.Scope `json:"scope"` + Name string `json:"name"` +} + +func printPlanMutation(cmd *cobra.Command, jsonOut bool, p plans.Plan, verb string) error { + if jsonOut { + return writePlansJSON(cmd.OutOrStdout(), plansPlanDocument{SchemaVersion: plansSchemaVersion, Plan: p}) + } + fmt.Fprintf(cmd.OutOrStdout(), "%s %s plan %q (version %s)\n", verb, p.Scope, p.Name, formatPlanVersion(p.Version)) + return nil +} + +// printPlanMetadata renders the concise one-line metadata of a plan. `get` +// sends it to stderr so stdout stays pure content while the metadata remains +// visible. +func printPlanMetadata(w io.Writer, p plans.Plan) { + details := make([]string, 0, 5) + if p.Title != "" { + details = append(details, "title: "+p.Title) + } + if p.Status != "" { + details = append(details, "status: "+p.Status) + } + details = append(details, "version: "+formatPlanVersion(p.Version)) + if !p.UpdatedAt.IsZero() { + details = append(details, "updated: "+formatPlanTime(p.UpdatedAt)) + } + if p.Path != "" { + details = append(details, "path: "+p.Path) + } + fmt.Fprintf(w, "%s plan %q (%s)\n", p.Scope, p.Name, strings.Join(details, ", ")) +} + +func printPlansTable(w io.Writer, list []plans.Plan) { + if len(list) == 0 { + fmt.Fprintln(w, "No plans found.") + return + } + tw := tabwriter.NewWriter(w, 0, 2, 3, ' ', 0) + fmt.Fprintln(tw, "SCOPE\tNAME\tSTATUS\tVERSION\tUPDATED\tTITLE") + for _, p := range list { + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", + p.Scope, p.Name, orDash(p.Status), formatPlanVersion(p.Version), formatPlanTime(p.UpdatedAt), orDash(p.Title)) + } + tw.Flush() +} + +func newPlansListCmd(o *plansOptions) *cobra.Command { + var flags struct { + json bool + session string + } + + cmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List plans", + Long: `List every shared plan with its metadata (content is not included). + +With --session , that session's plan is listed first when it exists; a +session without a plan is simply not listed. Plans that exist but cannot be +read are reported as warnings on stderr (in the "warnings" field with --json) +so they are never mistaken for missing.`, + Args: cobra.NoArgs, + } + cmd.RunE = o.runPlans("list", &flags.json, func(cmd *cobra.Command, svc plans.Service, _ []string) error { + result, err := svc.List(cmd.Context(), plans.ListOptions{SessionID: flags.session}) + if err != nil { + return err + } + // Defensive against injected services: an empty listing must encode + // as [] rather than null. + if result.Plans == nil { + result.Plans = []plans.Plan{} + } + if flags.json { + return writePlansJSON(cmd.OutOrStdout(), plansListDocument{ + SchemaVersion: plansSchemaVersion, + Plans: result.Plans, + Warnings: result.Warnings, + }) + } + printPlansTable(cmd.OutOrStdout(), result.Plans) + for _, warning := range result.Warnings { + fmt.Fprintln(cmd.ErrOrStderr(), "Warning:", warning) + } + return nil + }) + + cmd.Flags().BoolVar(&flags.json, "json", false, "Output as JSON") + cmd.Flags().StringVar(&flags.session, "session", "", "Also include this session's plan when it exists") + + return cmd +} + +func newPlansGetCmd(o *plansOptions) *cobra.Command { + var flags struct { + json bool + ref planRefFlags + } + + cmd := &cobra.Command{ + Use: "get []", + Short: "Print a plan's content and metadata", + Long: `Print a plan: its content goes to stdout and a concise metadata line goes +to stderr, so redirecting stdout captures the content alone (use export for a +byte-exact file copy). + +By default addresses a shared plan. Use --session to print that +session's plan instead; the name is then omitted.`, + Example: ` docker-agent plans get release + docker-agent plans get release --json + docker-agent plans get --session + docker-agent plans get --scope session --session `, + Args: cobra.MaximumNArgs(1), + } + cmd.RunE = o.runPlans("get", &flags.json, func(cmd *cobra.Command, svc plans.Service, args []string) error { + ref, err := flags.ref.resolve(firstArg(args)) + if err != nil { + return err + } + p, err := svc.Get(cmd.Context(), ref) + if err != nil { + return err + } + if flags.json { + return writePlansJSON(cmd.OutOrStdout(), plansPlanDocument{SchemaVersion: plansSchemaVersion, Plan: p}) + } + printPlanMetadata(cmd.ErrOrStderr(), p) + out := cmd.OutOrStdout() + fmt.Fprint(out, p.Content) + if !strings.HasSuffix(p.Content, "\n") { + fmt.Fprintln(out) + } + return nil + }) + + flags.ref.register(cmd) + cmd.Flags().BoolVar(&flags.json, "json", false, "Output as JSON") + + return cmd +} + +func newPlansCreateCmd(o *plansOptions) *cobra.Command { + var flags struct { + json bool + file string + title string + author string + status string + } + + cmd := &cobra.Command{ + Use: "create ", + Short: "Create a new shared plan", + Long: `Create a new shared plan with content from --file (required; use "-" to +read stdin). The CLI never prompts for content. + +Create is create-only: when a plan with the same name already exists the +command fails with a version conflict (exit code 3) instead of overwriting.`, + Example: ` docker-agent plans create release --file ./plan.md --title "Release plan" + cat plan.md | docker-agent plans create release --file -`, + Args: cobra.ExactArgs(1), + } + cmd.RunE = o.runPlans("create", &flags.json, func(cmd *cobra.Command, svc plans.Service, args []string) error { + content, err := readPlanContent(cmd, flags.file) + if err != nil { + return err + } + p, err := svc.Create(cmd.Context(), plans.CreateRequest{ + Ref: plans.SharedRef(args[0]), + Content: content, + Title: flags.title, + Author: flags.author, + Status: flags.status, + }) + if err != nil { + return err + } + return printPlanMutation(cmd, flags.json, p, "Created") + }) + + cmd.Flags().StringVar(&flags.file, "file", "", `File with the plan content ("-" reads stdin); required`) + cmd.Flags().StringVar(&flags.title, "title", "", "Human-readable plan title") + cmd.Flags().StringVar(&flags.author, "author", "", "Label identifying who wrote the plan") + cmd.Flags().StringVar(&flags.status, "status", "", "Free-form lifecycle status (e.g. draft, in-progress)") + cmd.Flags().BoolVar(&flags.json, "json", false, "Output as JSON") + _ = cmd.MarkFlagRequired("file") + + return cmd +} + +func newPlansUpdateCmd(o *plansOptions) *cobra.Command { + var flags struct { + json bool + file string + title string + author string + status string + ref planRefFlags + guard planGuardFlags + } + + cmd := &cobra.Command{ + Use: "update ", + Short: "Replace the content of an existing shared plan", + Long: `Replace the content of an existing shared plan with the contents of --file +(required; use "-" to read stdin). Update never creates a plan. + +Metadata flags that are omitted keep their previous value; passing them +(including an empty value) overwrites it. + +Exactly one of --expected-version or --force must be given: the former fails +with exit code 3 when the plan changed since it was read, the latter +deliberately replaces it unconditionally. Session plans cannot be updated.`, + Example: ` docker-agent plans update release --file ./plan.md --expected-version 1 + docker-agent plans update release --file ./plan.md --force --status in-progress`, + Args: cobra.MaximumNArgs(1), + } + cmd.RunE = o.runPlans("update", &flags.json, func(cmd *cobra.Command, svc plans.Service, args []string) error { + ref, err := flags.ref.resolve(firstArg(args)) + if err != nil { + return err + } + expected, err := flags.guard.expected() + if err != nil { + return err + } + content, err := readPlanContent(cmd, flags.file) + if err != nil { + return err + } + req := plans.UpdateRequest{Ref: ref, Content: content, ExpectedVersion: expected} + // Only changed metadata flags overwrite; omitted ones are preserved. + if cmd.Flags().Changed("title") { + req.Title = &flags.title + } + if cmd.Flags().Changed("author") { + req.Author = &flags.author + } + if cmd.Flags().Changed("status") { + req.Status = &flags.status + } + p, err := svc.Update(cmd.Context(), req) + if err != nil { + return err + } + return printPlanMutation(cmd, flags.json, p, "Updated") + }) + + flags.ref.register(cmd) + flags.guard.register(cmd) + cmd.Flags().StringVar(&flags.file, "file", "", `File with the new plan content ("-" reads stdin); required`) + cmd.Flags().StringVar(&flags.title, "title", "", "New plan title (omit to preserve the current one)") + cmd.Flags().StringVar(&flags.author, "author", "", "New author label (omit to preserve the current one)") + cmd.Flags().StringVar(&flags.status, "status", "", "New lifecycle status (omit to preserve the current one)") + cmd.Flags().BoolVar(&flags.json, "json", false, "Output as JSON") + _ = cmd.MarkFlagRequired("file") + + return cmd +} + +func newPlansStatusCmd(o *plansOptions) *cobra.Command { + var flags struct { + json bool + ref planRefFlags + guard planGuardFlags + } + + cmd := &cobra.Command{ + Use: "status ", + Short: "Set the status of an existing shared plan", + Long: `Set a shared plan's free-form status (e.g. "in-progress", "blocked", +"done") without touching its body. Setting the status is a write and bumps +the version. + +Exactly one of --expected-version or --force must be given. Session plans +have no status.`, + Example: ` docker-agent plans status release done --expected-version 2 + docker-agent plans status release blocked --force`, + Args: cobra.RangeArgs(1, 2), + } + cmd.RunE = o.runPlans("status", &flags.json, func(cmd *cobra.Command, svc plans.Service, args []string) error { + var name, status string + if flags.ref.sessionSelected() { + // Session plans have no name, so the only positional is the + // status; the service then rejects the mutation as unsupported. + if len(args) != 1 { + return plansUsagef("session plans take only a status: plans status --session ") + } + status = args[0] + } else { + if len(args) != 2 { + return plansUsagef("shared plans take a name and a status: plans status ") + } + name, status = args[0], args[1] + } + ref, err := flags.ref.resolve(name) + if err != nil { + return err + } + expected, err := flags.guard.expected() + if err != nil { + return err + } + p, err := svc.SetStatus(cmd.Context(), plans.SetStatusRequest{Ref: ref, Status: status, ExpectedVersion: expected}) + if err != nil { + return err + } + if flags.json { + return writePlansJSON(cmd.OutOrStdout(), plansPlanDocument{SchemaVersion: plansSchemaVersion, Plan: p}) + } + fmt.Fprintf(cmd.OutOrStdout(), "Set status of %s plan %q to %q (version %s)\n", + p.Scope, p.Name, p.Status, formatPlanVersion(p.Version)) + return nil + }) + + flags.ref.register(cmd) + flags.guard.register(cmd) + cmd.Flags().BoolVar(&flags.json, "json", false, "Output as JSON") + + return cmd +} + +func newPlansExportCmd(o *plansOptions) *cobra.Command { + var flags struct { + json bool + output string + ref planRefFlags + } + + cmd := &cobra.Command{ + Use: "export []", + Short: "Write a plan's content to a file", + Long: `Write a plan's content, byte-exact, to --output (required). Parent +directories are created and the write is atomic, so a reader never observes +a partial export. Works for both scopes: shared plans by name, a session's +plan via --session .`, + Example: ` docker-agent plans export release --output ./plan.md + docker-agent plans export --session --output ./plan.md`, + Args: cobra.MaximumNArgs(1), + } + cmd.RunE = o.runPlans("export", &flags.json, func(cmd *cobra.Command, svc plans.Service, args []string) error { + ref, err := flags.ref.resolve(firstArg(args)) + if err != nil { + return err + } + result, err := svc.Export(cmd.Context(), plans.ExportRequest{Ref: ref, Path: flags.output}) + if err != nil { + return err + } + if flags.json { + return writePlansJSON(cmd.OutOrStdout(), plansExportDocument{SchemaVersion: plansSchemaVersion, Export: result}) + } + fmt.Fprintf(cmd.OutOrStdout(), "Exported %s plan %q (version %s) to %s (%d bytes)\n", + result.Scope, result.Name, formatPlanVersion(result.Version), result.Path, result.BytesWritten) + return nil + }) + + flags.ref.register(cmd) + cmd.Flags().StringVar(&flags.output, "output", "", "Destination file for the plan content; required") + cmd.Flags().BoolVar(&flags.json, "json", false, "Output as JSON") + _ = cmd.MarkFlagRequired("output") + + return cmd +} + +func newPlansDeleteCmd(o *plansOptions) *cobra.Command { + var flags struct { + json bool + ref planRefFlags + guard planGuardFlags + } + + cmd := &cobra.Command{ + Use: "delete ", + Aliases: []string{"rm"}, + Short: "Delete a shared plan", + Long: `Delete a shared plan. The CLI never prompts, so exactly one of +--expected-version or --force must be given: the former fails with exit +code 3 when the plan changed since it was read (leaving it in place), the +latter deletes unconditionally — which is also how a corrupt plan is +recovered. Session plans cannot be deleted from the host.`, + Example: ` docker-agent plans delete release --expected-version 3 + docker-agent plans delete release --force`, + Args: cobra.MaximumNArgs(1), + } + cmd.RunE = o.runPlans("delete", &flags.json, func(cmd *cobra.Command, svc plans.Service, args []string) error { + ref, err := flags.ref.resolve(firstArg(args)) + if err != nil { + return err + } + expected, err := flags.guard.expected() + if err != nil { + return err + } + if err := svc.Delete(cmd.Context(), plans.DeleteRequest{Ref: ref, ExpectedVersion: expected}); err != nil { + return err + } + if flags.json { + return writePlansJSON(cmd.OutOrStdout(), plansDeletedDocument{ + SchemaVersion: plansSchemaVersion, + Deleted: plansRefBody{Scope: ref.Scope, Name: planRefName(ref)}, + }) + } + fmt.Fprintf(cmd.OutOrStdout(), "Deleted %s plan %q\n", ref.Scope, planRefName(ref)) + return nil + }) + + flags.ref.register(cmd) + flags.guard.register(cmd) + cmd.Flags().BoolVar(&flags.json, "json", false, "Output as JSON") + + return cmd +} diff --git a/cmd/root/plans_test.go b/cmd/root/plans_test.go new file mode 100644 index 0000000000..db07d28441 --- /dev/null +++ b/cmd/root/plans_test.go @@ -0,0 +1,1190 @@ +package root + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/docker/cli/cli" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/plans" + "github.com/docker/docker-agent/pkg/tools/builtin/plan" + "github.com/docker/docker-agent/pkg/tools/builtin/sessionplan" +) + +// newPlansTestService builds a hermetic plans.Service over temp directories, +// returning both so tests can plant files directly. No test ever touches the +// real user data directory. +func newPlansTestService(t *testing.T) (svc plans.Service, sharedDir, sessionDir string) { + t.Helper() + sharedDir = t.TempDir() + sessionDir = t.TempDir() + svc = plans.NewService(plan.NewFilesystemStorage(sharedDir), plans.WithSessionDir(sessionDir)) + return svc, sharedDir, sessionDir +} + +func executePlansIn(t *testing.T, svc plans.Service, stdin io.Reader, args ...string) (stdout, stderr string, err error) { + t.Helper() + cmd := newPlansCmd(withPlansService(svc)) + var outBuf, errBuf bytes.Buffer + cmd.SetOut(&outBuf) + cmd.SetErr(&errBuf) + cmd.SetIn(stdin) + cmd.SetArgs(args) + cmd.SetContext(t.Context()) + err = cmd.Execute() + return outBuf.String(), errBuf.String(), err +} + +func executePlans(t *testing.T, svc plans.Service, args ...string) (stdout, stderr string, err error) { + t.Helper() + return executePlansIn(t, svc, strings.NewReader(""), args...) +} + +func mustCreatePlan(t *testing.T, svc plans.Service, name, content string) plans.Plan { + t.Helper() + p, err := svc.Create(t.Context(), plans.CreateRequest{Ref: plans.SharedRef(name), Content: content}) + require.NoError(t, err) + return p +} + +func mustGetPlan(t *testing.T, svc plans.Service, ref plans.Ref) plans.Plan { + t.Helper() + p, err := svc.Get(t.Context(), ref) + require.NoError(t, err) + return p +} + +func writePlanContentFile(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "content.md") + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + return path +} + +func writeSessionPlanFile(t *testing.T, dir, sessionID, content string) string { + t.Helper() + path, err := sessionplan.WriteContent(dir, sessionID, content) + require.NoError(t, err) + return path +} + +func requirePlansStatusCode(t *testing.T, err error, want int) { + t.Helper() + require.Error(t, err) + statusErr, ok := errors.AsType[cli.StatusError](err) + require.True(t, ok, "expected a cli.StatusError, got %T", err) + assert.Equal(t, want, statusErr.StatusCode) +} + +type plansTestError struct { + Code string `json:"code"` + Message string `json:"message"` + Scope string `json:"scope"` + Name string `json:"name"` + Op string `json:"op"` + ExpectedVersion *int `json:"expected_version"` + CurrentVersion *int `json:"current_version"` +} + +// decodePlansError asserts stderr is exactly one JSON object honouring the +// error contract and returns its "error" body. +func decodePlansError(t *testing.T, stderr string) plansTestError { + t.Helper() + assert.Equal(t, 1, strings.Count(stderr, "\n"), "stderr must be a single JSON line: %q", stderr) + var doc struct { + SchemaVersion string `json:"schema_version"` + Error plansTestError `json:"error"` + } + require.NoError(t, json.Unmarshal([]byte(stderr), &doc), "stderr must be JSON: %q", stderr) + assert.Equal(t, "1", doc.SchemaVersion) + require.NotEmpty(t, doc.Error.Code) + require.NotEmpty(t, doc.Error.Message) + return doc.Error +} + +type plansTestListDocument struct { + SchemaVersion string `json:"schema_version"` + Plans []plans.Plan `json:"plans"` + Warnings []string `json:"warnings"` +} + +type plansTestPlanDocument struct { + SchemaVersion string `json:"schema_version"` + Plan plans.Plan `json:"plan"` +} + +// --- Registration -------------------------------------------------------------- + +func TestPlansCommand_RegisteredOnRoot(t *testing.T) { + t.Parallel() + + cmd, _, err := NewRootCmd().Find([]string{"plans"}) + require.NoError(t, err) + require.Equal(t, "plans", cmd.Name()) + + var subs []string + for _, sub := range cmd.Commands() { + subs = append(subs, sub.Name()) + } + for _, want := range []string{"list", "get", "create", "update", "status", "export", "delete"} { + assert.Contains(t, subs, want) + } +} + +// --- List ---------------------------------------------------------------------- + +func TestPlansList_EmptyJSON(t *testing.T) { + t.Parallel() + svc, _, _ := newPlansTestService(t) + + stdout, stderr, err := executePlans(t, svc, "list", "--json") + require.NoError(t, err) + assert.Empty(t, stderr) + + var doc plansTestListDocument + require.NoError(t, json.Unmarshal([]byte(stdout), &doc)) + assert.Equal(t, "1", doc.SchemaVersion) + assert.Empty(t, doc.Plans) + assert.Empty(t, doc.Warnings) + // The empty listing must encode as [], never null. + assert.Contains(t, stdout, `"plans": []`) +} + +func TestPlansList_HumanShowsMetadataAndSendsWarningsToStderr(t *testing.T) { + t.Parallel() + svc, sharedDir, sessionDir := newPlansTestService(t) + _, err := svc.Create(t.Context(), plans.CreateRequest{ + Ref: plans.SharedRef("alpha"), Content: "body", Title: "Alpha plan", Status: "draft", + }) + require.NoError(t, err) + writeSessionPlanFile(t, sessionDir, "sess-1", "# session plan") + require.NoError(t, os.WriteFile(filepath.Join(sharedDir, "bad.json"), []byte("{nope"), 0o600)) + + stdout, stderr, err := executePlans(t, svc, "list", "--session", "sess-1") + require.NoError(t, err) + + assert.Regexp(t, `SCOPE\s+NAME\s+STATUS\s+VERSION\s+UPDATED\s+TITLE`, stdout) + assert.Regexp(t, `shared\s+alpha\s+draft\s+1\s+\S+\s+Alpha plan`, stdout) + // Session plans have no version or status: shown as "-". + assert.Regexp(t, `session\s+sess-1\s+-\s+-\s+\S+\s+-`, stdout) + assert.NotContains(t, stdout, "\x1b[", "human output must be ANSI-free") + + // Human-mode warnings go to stderr, not stdout. + assert.Contains(t, stderr, "Warning:") + assert.Contains(t, stderr, "bad") + assert.NotContains(t, stdout, "Warning") +} + +func TestPlansList_JSONMetadataAndWarnings(t *testing.T) { + t.Parallel() + svc, sharedDir, sessionDir := newPlansTestService(t) + _, err := svc.Create(t.Context(), plans.CreateRequest{ + Ref: plans.SharedRef("alpha"), Content: "body", Title: "Alpha plan", Author: "alice", Status: "draft", + }) + require.NoError(t, err) + writeSessionPlanFile(t, sessionDir, "sess-1", "# session plan") + require.NoError(t, os.WriteFile(filepath.Join(sharedDir, "bad.json"), []byte("{nope"), 0o600)) + + stdout, stderr, err := executePlans(t, svc, "list", "--session", "sess-1", "--json") + require.NoError(t, err) + assert.Empty(t, stderr, "JSON mode must not write warnings to stderr") + + var doc plansTestListDocument + require.NoError(t, json.Unmarshal([]byte(stdout), &doc)) + require.Len(t, doc.Plans, 2) + + sess := doc.Plans[0] + assert.Equal(t, plans.ScopeSession, sess.Scope) + assert.Equal(t, "sess-1", sess.Name) + assert.Equal(t, "sess-1", sess.SessionID) + assert.Nil(t, sess.Version) + assert.Empty(t, sess.Content, "list is metadata only") + + shared := doc.Plans[1] + assert.Equal(t, plans.ScopeShared, shared.Scope) + assert.Equal(t, "alpha", shared.Name) + assert.Equal(t, "Alpha plan", shared.Title) + assert.Equal(t, "alice", shared.Author) + assert.Equal(t, "draft", shared.Status) + require.NotNil(t, shared.Version) + assert.Equal(t, 1, *shared.Version) + + require.Len(t, doc.Warnings, 1) + assert.Contains(t, doc.Warnings[0], "bad") +} + +func TestPlansList_InvalidSessionID(t *testing.T) { + t.Parallel() + svc, _, _ := newPlansTestService(t) + + stdout, stderr, err := executePlans(t, svc, "list", "--session", "../escape", "--json") + requirePlansStatusCode(t, err, 1) + assert.Empty(t, stdout) + body := decodePlansError(t, stderr) + assert.Equal(t, "invalid_argument", body.Code) +} + +// --- Get ----------------------------------------------------------------------- + +func TestPlansGet_SharedHuman(t *testing.T) { + t.Parallel() + svc, _, _ := newPlansTestService(t) + _, err := svc.Create(t.Context(), plans.CreateRequest{ + Ref: plans.SharedRef("release"), Content: "# release\n\nstep 1\n", Title: "Release", Status: "draft", + }) + require.NoError(t, err) + + stdout, stderr, err := executePlans(t, svc, "get", "release") + require.NoError(t, err) + // Content alone on stdout; metadata stays visible on stderr. + assert.Equal(t, "# release\n\nstep 1\n", stdout) + assert.Contains(t, stderr, `shared plan "release"`) + assert.Contains(t, stderr, "title: Release") + assert.Contains(t, stderr, "status: draft") + assert.Contains(t, stderr, "version: 1") +} + +func TestPlansGet_SharedJSON(t *testing.T) { + t.Parallel() + svc, _, _ := newPlansTestService(t) + _, err := svc.Create(t.Context(), plans.CreateRequest{ + Ref: plans.SharedRef("release"), Content: "the body", Title: "Release", Author: "alice", Status: "draft", + }) + require.NoError(t, err) + + stdout, stderr, err := executePlans(t, svc, "get", "release", "--json") + require.NoError(t, err) + assert.Empty(t, stderr, "JSON mode keeps metadata in the document, not on stderr") + + var doc plansTestPlanDocument + require.NoError(t, json.Unmarshal([]byte(stdout), &doc)) + assert.Equal(t, "1", doc.SchemaVersion) + assert.Equal(t, plans.ScopeShared, doc.Plan.Scope) + assert.Equal(t, "release", doc.Plan.Name) + assert.Equal(t, "the body", doc.Plan.Content) + assert.Equal(t, "Release", doc.Plan.Title) + assert.Equal(t, "alice", doc.Plan.Author) + assert.Equal(t, "draft", doc.Plan.Status) + require.NotNil(t, doc.Plan.Version) + assert.Equal(t, 1, *doc.Plan.Version) + assert.False(t, doc.Plan.UpdatedAt.IsZero()) + + // The host contract is snake_case; the agent tools' camelCase keys must + // never leak into it. + assert.Contains(t, stdout, `"updated_at"`) + assert.NotContains(t, stdout, `"updatedAt"`) +} + +func TestPlansGet_Session(t *testing.T) { + t.Parallel() + svc, _, sessionDir := newPlansTestService(t) + path := writeSessionPlanFile(t, sessionDir, "sess-1", "# session plan\n") + + // --session alone implies session scope; --scope session spells it out. + for _, args := range [][]string{ + {"get", "--session", "sess-1"}, + {"get", "--scope", "session", "--session", "sess-1"}, + } { + stdout, stderr, err := executePlans(t, svc, args...) + require.NoError(t, err, "args %v", args) + assert.Equal(t, "# session plan\n", stdout) + assert.Contains(t, stderr, `session plan "sess-1"`) + assert.Contains(t, stderr, "version: -", "session plans have no version") + } + + stdout, _, err := executePlans(t, svc, "get", "--session", "sess-1", "--json") + require.NoError(t, err) + var doc plansTestPlanDocument + require.NoError(t, json.Unmarshal([]byte(stdout), &doc)) + assert.Equal(t, plans.ScopeSession, doc.Plan.Scope) + assert.Equal(t, "sess-1", doc.Plan.SessionID) + assert.Equal(t, "# session plan\n", doc.Plan.Content) + assert.Equal(t, path, doc.Plan.Path) + assert.Nil(t, doc.Plan.Version) + assert.Contains(t, stdout, `"session_id"`) + assert.NotContains(t, stdout, `"sessionId"`) +} + +func TestPlansGet_RefValidation(t *testing.T) { + t.Parallel() + svc, _, _ := newPlansTestService(t) + + tests := []struct { + args []string + wantMsg string + }{ + {[]string{"get"}, "a plan name is required"}, + {[]string{"get", "p", "--session", "sess-1"}, "addressed by --session"}, + {[]string{"get", "--scope", "session"}, "requires --session"}, + {[]string{"get", "--scope", "shared", "--session", "sess-1"}, "--session selects a session plan"}, + {[]string{"get", "p", "--scope", "bogus"}, "invalid --scope"}, + } + for _, tt := range tests { + stdout, stderr, err := executePlans(t, svc, append(tt.args, "--json")...) + requirePlansStatusCode(t, err, 1) + assert.Empty(t, stdout, "args %v", tt.args) + body := decodePlansError(t, stderr) + assert.Equal(t, "invalid_argument", body.Code, "args %v", tt.args) + assert.Contains(t, body.Message, tt.wantMsg, "args %v", tt.args) + } +} + +func TestPlansGet_NotFoundJSON(t *testing.T) { + t.Parallel() + svc, _, _ := newPlansTestService(t) + + stdout, stderr, err := executePlans(t, svc, "get", "missing", "--json") + requirePlansStatusCode(t, err, 1) + assert.Empty(t, stdout) + body := decodePlansError(t, stderr) + assert.Equal(t, "not_found", body.Code) + assert.Equal(t, "shared", body.Scope) + assert.Equal(t, "missing", body.Name) +} + +func TestPlansGet_NotFoundHuman(t *testing.T) { + t.Parallel() + svc, _, _ := newPlansTestService(t) + + stdout, stderr, err := executePlans(t, svc, "get", "missing") + requirePlansStatusCode(t, err, 1) + assert.Empty(t, stdout) + assert.Contains(t, stderr, `shared plan "missing" not found`) + // The error is rendered exactly once, by the command itself. + assert.Equal(t, 1, strings.Count(stderr, "not found")) +} + +func TestPlansGet_InvalidName(t *testing.T) { + t.Parallel() + svc, _, _ := newPlansTestService(t) + + _, stderr, err := executePlans(t, svc, "get", "UPPER", "--json") + requirePlansStatusCode(t, err, 1) + body := decodePlansError(t, stderr) + assert.Equal(t, "invalid_argument", body.Code) + assert.Contains(t, body.Message, "invalid plan name") +} + +func TestPlansGet_Corrupt(t *testing.T) { + t.Parallel() + svc, sharedDir, _ := newPlansTestService(t) + require.NoError(t, os.WriteFile(filepath.Join(sharedDir, "broken.json"), []byte("{not json"), 0o600)) + + _, stderr, err := executePlans(t, svc, "get", "broken", "--json") + requirePlansStatusCode(t, err, 1) + body := decodePlansError(t, stderr) + assert.Equal(t, "corrupt", body.Code, "a corrupt plan must never read as missing") + assert.Equal(t, "shared", body.Scope) + assert.Equal(t, "broken", body.Name) +} + +// --- Create -------------------------------------------------------------------- + +func TestPlansCreate_FromFile(t *testing.T) { + t.Parallel() + svc, _, _ := newPlansTestService(t) + file := writePlanContentFile(t, "plan body") + + stdout, stderr, err := executePlans(t, svc, + "create", "release", "--file", file, "--title", "Release", "--author", "alice", "--status", "draft") + require.NoError(t, err) + assert.Empty(t, stderr) + assert.Equal(t, "Created shared plan \"release\" (version 1)\n", stdout) + + p := mustGetPlan(t, svc, plans.SharedRef("release")) + assert.Equal(t, "plan body", p.Content) + assert.Equal(t, "Release", p.Title) + assert.Equal(t, "alice", p.Author) + assert.Equal(t, "draft", p.Status) +} + +func TestPlansCreate_FromStdin(t *testing.T) { + t.Parallel() + svc, _, _ := newPlansTestService(t) + + // Headless: content is piped through a non-TTY stdin via --file -. + stdout, _, err := executePlansIn(t, svc, strings.NewReader("piped body"), "create", "p", "--file", "-", "--json") + require.NoError(t, err) + + var doc plansTestPlanDocument + require.NoError(t, json.Unmarshal([]byte(stdout), &doc)) + assert.Equal(t, "1", doc.SchemaVersion) + assert.Equal(t, "piped body", doc.Plan.Content) + require.NotNil(t, doc.Plan.Version) + assert.Equal(t, 1, *doc.Plan.Version) +} + +func TestPlansCreate_StdinAtSizeLimit(t *testing.T) { + t.Parallel() + svc, _, _ := newPlansTestService(t) + + // Exactly the cap is not "over" it: the bounded reader must pass the + // content through (no off-by-one) and the real filesystem storage must + // persist it, since the advertised content cap is separate from the + // stored file's encoded bound. + content := strings.Repeat("a", plan.MaxPlanContentSize) + stdout, stderr, err := executePlansIn(t, svc, strings.NewReader(content), "create", "p", "--file", "-", "--json") + require.NoError(t, err, "stderr: %s", stderr) + + var doc plansTestPlanDocument + require.NoError(t, json.Unmarshal([]byte(stdout), &doc)) + require.NotNil(t, doc.Plan.Version) + assert.Equal(t, 1, *doc.Plan.Version) + + p := mustGetPlan(t, svc, plans.SharedRef("p")) + assert.Len(t, p.Content, plan.MaxPlanContentSize) +} + +func TestPlansCreate_FileAtSizeLimit(t *testing.T) { + t.Parallel() + svc, _, _ := newPlansTestService(t) + file := writePlanContentFile(t, strings.Repeat("b", plan.MaxPlanContentSize)) + + _, stderr, err := executePlans(t, svc, "create", "p", "--file", file) + require.NoError(t, err, "stderr: %s", stderr) + assert.Len(t, mustGetPlan(t, svc, plans.SharedRef("p")).Content, plan.MaxPlanContentSize) +} + +func TestPlansCreate_StdinOverSizeLimit(t *testing.T) { + t.Parallel() + svc, _, _ := newPlansTestService(t) + + // One byte over the cap must be detected and refused. + content := strings.Repeat("a", plan.MaxPlanContentSize+1) + stdout, stderr, err := executePlansIn(t, svc, strings.NewReader(content), "create", "p", "--file", "-", "--json") + requirePlansStatusCode(t, err, 1) + assert.Empty(t, stdout) + body := decodePlansError(t, stderr) + assert.Equal(t, "invalid_argument", body.Code) + assert.Contains(t, body.Message, "stdin") + assert.Contains(t, body.Message, "exceeds the maximum plan size") + + _, err = svc.Get(t.Context(), plans.SharedRef("p")) + require.Error(t, err, "the refused create must not store a plan") +} + +func TestPlansCreate_EmptyStdin(t *testing.T) { + t.Parallel() + svc, _, _ := newPlansTestService(t) + + // Empty piped content reaches the service and is rejected there, like an + // empty --file. + _, stderr, err := executePlansIn(t, svc, strings.NewReader(""), "create", "p", "--file", "-", "--json") + requirePlansStatusCode(t, err, 1) + body := decodePlansError(t, stderr) + assert.Equal(t, "invalid_argument", body.Code) + assert.Contains(t, body.Message, "content must not be empty") +} + +func TestPlansCreate_OversizedFile(t *testing.T) { + t.Parallel() + svc, _, _ := newPlansTestService(t) + big := filepath.Join(t.TempDir(), "big.md") + require.NoError(t, os.WriteFile(big, make([]byte, plan.MaxPlanContentSize+1), 0o600)) + + _, stderr, err := executePlans(t, svc, "create", "p", "--file", big, "--json") + requirePlansStatusCode(t, err, 1) + body := decodePlansError(t, stderr) + assert.Equal(t, "invalid_argument", body.Code) + assert.Contains(t, body.Message, "exceeds the maximum plan size") +} + +func TestPlansCreate_DirectoryAsFile(t *testing.T) { + t.Parallel() + svc, _, _ := newPlansTestService(t) + + _, stderr, err := executePlans(t, svc, "create", "p", "--file", t.TempDir(), "--json") + requirePlansStatusCode(t, err, 1) + body := decodePlansError(t, stderr) + assert.Equal(t, "invalid_argument", body.Code) + assert.Contains(t, body.Message, "is a directory, not a file") +} + +// stdinMustNotBeRead fails the test when a command reads stdin without being +// asked to via --file -. +type stdinMustNotBeRead struct{ t *testing.T } + +func (r stdinMustNotBeRead) Read([]byte) (int, error) { + r.t.Error("stdin must not be read when --file is not given") + return 0, io.EOF +} + +func TestPlansCreate_RequiresFileWithoutPrompting(t *testing.T) { + t.Parallel() + svc, _, _ := newPlansTestService(t) + + // Without --file the command must fail immediately instead of waiting on + // an interactive terminal for content. + _, _, err := executePlansIn(t, svc, stdinMustNotBeRead{t: t}, "create", "p") + require.Error(t, err) + assert.Contains(t, err.Error(), `"file" not set`) +} + +func TestPlansCreate_ExistingNameConflicts(t *testing.T) { + t.Parallel() + svc, _, _ := newPlansTestService(t) + mustCreatePlan(t, svc, "p", "original") + file := writePlanContentFile(t, "clobber") + + stdout, stderr, err := executePlans(t, svc, "create", "p", "--file", file, "--json") + requirePlansStatusCode(t, err, plansConflictExitCode) + assert.Empty(t, stdout) + body := decodePlansError(t, stderr) + assert.Equal(t, "conflict", body.Code) + assert.Equal(t, "p", body.Name) + require.NotNil(t, body.CurrentVersion) + assert.Equal(t, 1, *body.CurrentVersion) + // The advice must be actionable for a create: there is no create --force, + // so the message points at a different name or an update instead. + assert.Contains(t, body.Message, "already exists") + assert.Contains(t, body.Message, "update") + assert.NotContains(t, body.Message, "force") + + p := mustGetPlan(t, svc, plans.SharedRef("p")) + assert.Equal(t, "original", p.Content, "a conflicting create must not overwrite") +} + +func TestPlansCreate_ExistingNameConflictHuman(t *testing.T) { + t.Parallel() + svc, _, _ := newPlansTestService(t) + mustCreatePlan(t, svc, "p", "original") + file := writePlanContentFile(t, "clobber") + + stdout, stderr, err := executePlans(t, svc, "create", "p", "--file", file) + requirePlansStatusCode(t, err, plansConflictExitCode) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "already exists") + assert.Contains(t, stderr, "update") + assert.NotContains(t, stderr, "force", "create has no --force; the human message must not suggest one") +} + +// TestPlansCreate_ExistingRevisionZeroFileConflicts proves the create-only +// guard is existence-driven end to end: a valid stored plan whose revision +// field is omitted (reading back as revision 0) still conflicts with exit +// code 3 and stays byte-identical, and a fresh name keeps working. +func TestPlansCreate_ExistingRevisionZeroFileConflicts(t *testing.T) { + t.Parallel() + svc, sharedDir, _ := newPlansTestService(t) + + original := `{"name":"planted","content":"precious content"}` + plantedPath := filepath.Join(sharedDir, "planted.json") + require.NoError(t, os.WriteFile(plantedPath, []byte(original), 0o600)) + file := writePlanContentFile(t, "clobber") + + stdout, stderr, err := executePlans(t, svc, "create", "planted", "--file", file, "--json") + requirePlansStatusCode(t, err, plansConflictExitCode) + assert.Empty(t, stdout) + body := decodePlansError(t, stderr) + assert.Equal(t, "conflict", body.Code) + assert.Equal(t, "planted", body.Name) + require.NotNil(t, body.ExpectedVersion) + assert.Equal(t, 0, *body.ExpectedVersion) + require.NotNil(t, body.CurrentVersion) + assert.Equal(t, 0, *body.CurrentVersion) + assert.Contains(t, body.Message, "already exists") + + data, err := os.ReadFile(plantedPath) + require.NoError(t, err) + assert.Equal(t, original, string(data), "the refused create must leave the existing file byte-identical") + + // A normal create is unaffected by the guard. + stdout, _, err = executePlans(t, svc, "create", "fresh", "--file", file, "--json") + require.NoError(t, err) + assert.Contains(t, stdout, `"version": 1`) +} + +func TestPlansCreate_Validation(t *testing.T) { + t.Parallel() + svc, _, _ := newPlansTestService(t) + + // Empty content is rejected by the service. + empty := writePlanContentFile(t, "") + _, stderr, err := executePlans(t, svc, "create", "p", "--file", empty, "--json") + requirePlansStatusCode(t, err, 1) + body := decodePlansError(t, stderr) + assert.Equal(t, "invalid_argument", body.Code) + assert.Contains(t, body.Message, "content must not be empty") + + // An unreadable content file is a clear input error. + _, stderr, err = executePlans(t, svc, "create", "p", "--file", filepath.Join(t.TempDir(), "missing.md"), "--json") + requirePlansStatusCode(t, err, 1) + body = decodePlansError(t, stderr) + assert.Equal(t, "invalid_argument", body.Code) + assert.Contains(t, body.Message, "reading plan content") + + // Invalid plan names are rejected by the service's canonical rule. + file := writePlanContentFile(t, "x") + _, stderr, err = executePlans(t, svc, "create", "../escape", "--file", file, "--json") + requirePlansStatusCode(t, err, 1) + body = decodePlansError(t, stderr) + assert.Equal(t, "invalid_argument", body.Code) + assert.Contains(t, body.Message, "invalid plan name") +} + +// --- Update -------------------------------------------------------------------- + +func TestPlansUpdate_Success(t *testing.T) { + t.Parallel() + svc, _, _ := newPlansTestService(t) + _, err := svc.Create(t.Context(), plans.CreateRequest{ + Ref: plans.SharedRef("p"), Content: "v1", Title: "Original", Status: "draft", + }) + require.NoError(t, err) + file := writePlanContentFile(t, "v2") + + stdout, _, err := executePlans(t, svc, "update", "p", "--file", file, "--expected-version", "1", "--author", "bob") + require.NoError(t, err) + assert.Equal(t, "Updated shared plan \"p\" (version 2)\n", stdout) + + p := mustGetPlan(t, svc, plans.SharedRef("p")) + assert.Equal(t, "v2", p.Content) + assert.Equal(t, "Original", p.Title, "omitted metadata flags preserve the previous value") + assert.Equal(t, "bob", p.Author) + assert.Equal(t, "draft", p.Status) + + // An explicitly empty metadata flag overwrites rather than preserves. + stdout, _, err = executePlans(t, svc, "update", "p", "--file", file, "--expected-version", "2", "--title", "") + require.NoError(t, err) + assert.Contains(t, stdout, "version 3") + assert.Empty(t, mustGetPlan(t, svc, plans.SharedRef("p")).Title) +} + +func TestPlansUpdate_StaleConflict(t *testing.T) { + t.Parallel() + svc, _, _ := newPlansTestService(t) + mustCreatePlan(t, svc, "p", "v1") + _, err := svc.Update(t.Context(), plans.UpdateRequest{Ref: plans.SharedRef("p"), Content: "v2", ExpectedVersion: new(1)}) + require.NoError(t, err) + file := writePlanContentFile(t, "stale write") + + stdout, stderr, err := executePlans(t, svc, "update", "p", "--file", file, "--expected-version", "1", "--json") + requirePlansStatusCode(t, err, plansConflictExitCode) + assert.Empty(t, stdout, "JSON mode must keep stdout free of prose") + + // The typed conflict stays reachable through the returned error. + conflictErr, ok := errors.AsType[*plans.ConflictError](err) + require.True(t, ok, "the cli.StatusError must wrap the typed conflict") + assert.Equal(t, 2, conflictErr.Current) + + body := decodePlansError(t, stderr) + assert.Equal(t, "conflict", body.Code) + assert.Equal(t, "shared", body.Scope) + assert.Equal(t, "p", body.Name) + require.NotNil(t, body.ExpectedVersion) + assert.Equal(t, 1, *body.ExpectedVersion) + require.NotNil(t, body.CurrentVersion) + assert.Equal(t, 2, *body.CurrentVersion) + + p := mustGetPlan(t, svc, plans.SharedRef("p")) + assert.Equal(t, "v2", p.Content, "the losing write must not touch the plan") +} + +func TestPlansUpdate_Force(t *testing.T) { + t.Parallel() + svc, _, _ := newPlansTestService(t) + mustCreatePlan(t, svc, "p", "v1") + _, err := svc.Update(t.Context(), plans.UpdateRequest{Ref: plans.SharedRef("p"), Content: "v2", ExpectedVersion: new(1)}) + require.NoError(t, err) + file := writePlanContentFile(t, "forced") + + stdout, _, err := executePlans(t, svc, "update", "p", "--file", file, "--force") + require.NoError(t, err) + assert.Contains(t, stdout, "version 3") + assert.Equal(t, "forced", mustGetPlan(t, svc, plans.SharedRef("p")).Content) +} + +func TestPlansUpdate_RequiresGuard(t *testing.T) { + t.Parallel() + svc, _, _ := newPlansTestService(t) + mustCreatePlan(t, svc, "p", "v1") + file := writePlanContentFile(t, "v2") + + // Neither --expected-version nor --force: refuse instead of passing a + // nil expected version by accident. + _, _, err := executePlans(t, svc, "update", "p", "--file", file) + require.Error(t, err) + assert.Contains(t, err.Error(), "at least one of the flags in the group [expected-version force] is required") + assert.Equal(t, "v1", mustGetPlan(t, svc, plans.SharedRef("p")).Content) +} + +func TestPlansUpdate_NotFound(t *testing.T) { + t.Parallel() + svc, _, _ := newPlansTestService(t) + file := writePlanContentFile(t, "x") + + _, stderr, err := executePlans(t, svc, "update", "ghost", "--file", file, "--force", "--json") + requirePlansStatusCode(t, err, 1) + body := decodePlansError(t, stderr) + assert.Equal(t, "not_found", body.Code) + assert.Equal(t, "ghost", body.Name) +} + +func TestPlansMutations_ExpectedVersionMustBePositive(t *testing.T) { + t.Parallel() + svc, _, _ := newPlansTestService(t) + mustCreatePlan(t, svc, "p", "v1") + file := writePlanContentFile(t, "v2") + + for _, args := range [][]string{ + {"update", "p", "--file", file, "--expected-version", "0"}, + {"status", "p", "done", "--expected-version", "-1"}, + {"delete", "p", "--expected-version", "0"}, + } { + _, stderr, err := executePlans(t, svc, append(args, "--json")...) + requirePlansStatusCode(t, err, 1) + body := decodePlansError(t, stderr) + assert.Equal(t, "invalid_argument", body.Code, "args %v", args) + assert.Contains(t, body.Message, "--expected-version must be at least 1", "args %v", args) + } +} + +// --- Status -------------------------------------------------------------------- + +func TestPlansStatus_Success(t *testing.T) { + t.Parallel() + svc, _, _ := newPlansTestService(t) + mustCreatePlan(t, svc, "p", "body") + + stdout, _, err := executePlans(t, svc, "status", "p", "in-progress", "--expected-version", "1") + require.NoError(t, err) + assert.Equal(t, "Set status of shared plan \"p\" to \"in-progress\" (version 2)\n", stdout) + + p := mustGetPlan(t, svc, plans.SharedRef("p")) + assert.Equal(t, "in-progress", p.Status) + assert.Equal(t, "body", p.Content, "setting the status must preserve the body") +} + +func TestPlansStatus_StaleConflict(t *testing.T) { + t.Parallel() + svc, _, _ := newPlansTestService(t) + _, err := svc.Create(t.Context(), plans.CreateRequest{Ref: plans.SharedRef("p"), Content: "body", Status: "draft"}) + require.NoError(t, err) + _, err = svc.Update(t.Context(), plans.UpdateRequest{Ref: plans.SharedRef("p"), Content: "v2", ExpectedVersion: new(1)}) + require.NoError(t, err) + + _, stderr, err := executePlans(t, svc, "status", "p", "done", "--expected-version", "1", "--json") + requirePlansStatusCode(t, err, plansConflictExitCode) + body := decodePlansError(t, stderr) + assert.Equal(t, "conflict", body.Code) + require.NotNil(t, body.CurrentVersion) + assert.Equal(t, 2, *body.CurrentVersion) + + assert.Equal(t, "draft", mustGetPlan(t, svc, plans.SharedRef("p")).Status, "the losing status write must not touch the plan") +} + +func TestPlansStatus_Validation(t *testing.T) { + t.Parallel() + svc, _, _ := newPlansTestService(t) + mustCreatePlan(t, svc, "p", "body") + + // An empty status string is rejected by the service. + _, stderr, err := executePlans(t, svc, "status", "p", "", "--force", "--json") + requirePlansStatusCode(t, err, 1) + body := decodePlansError(t, stderr) + assert.Equal(t, "invalid_argument", body.Code) + assert.Contains(t, body.Message, "status must not be empty") + + // A single positional in shared scope is missing either name or status. + _, stderr, err = executePlans(t, svc, "status", "p", "--force", "--json") + requirePlansStatusCode(t, err, 1) + body = decodePlansError(t, stderr) + assert.Equal(t, "invalid_argument", body.Code) + assert.Contains(t, body.Message, "plans status ") +} + +// TestPlansMetadata_LargeLabelsAccepted proves metadata stays free-form +// through the CLI: title, author, and status beyond 4 KiB are accepted by +// create and status, preserved across a content-only update, and never +// misclassified as invalid_argument (issue #3844: labels are user-defined). +func TestPlansMetadata_LargeLabelsAccepted(t *testing.T) { + t.Parallel() + svc, _, _ := newPlansTestService(t) + + bigTitle := strings.Repeat("t", 5<<10) + bigAuthor := strings.Repeat("a", 5<<10) + bigStatus := strings.Repeat("s", 5<<10) + + file := writePlanContentFile(t, "body") + _, stderr, err := executePlans(t, svc, "create", "p", "--file", file, + "--title", bigTitle, "--author", bigAuthor, "--status", bigStatus) + require.NoError(t, err, "large metadata must be accepted: %s", stderr) + + biggerStatus := strings.Repeat("z", 6<<10) + _, stderr, err = executePlans(t, svc, "status", "p", biggerStatus, "--expected-version", "1") + require.NoError(t, err, "a large status must be accepted: %s", stderr) + + // A content-only update preserves the large labels. + newFile := writePlanContentFile(t, "new body") + _, stderr, err = executePlans(t, svc, "update", "p", "--file", newFile, "--expected-version", "2") + require.NoError(t, err, "stderr: %s", stderr) + + p := mustGetPlan(t, svc, plans.SharedRef("p")) + assert.Equal(t, bigTitle, p.Title) + assert.Equal(t, bigAuthor, p.Author) + assert.Equal(t, biggerStatus, p.Status) + assert.Equal(t, "new body", p.Content) +} + +// --- Session mutations are unsupported ------------------------------------------- + +func TestPlansMutations_SessionUnsupported(t *testing.T) { + t.Parallel() + svc, _, sessionDir := newPlansTestService(t) + writeSessionPlanFile(t, sessionDir, "sess-1", "# plan") + file := writePlanContentFile(t, "new content") + + tests := []struct { + args []string + wantOp string + }{ + {[]string{"update", "--session", "sess-1", "--file", file, "--force"}, "update"}, + {[]string{"status", "--session", "sess-1", "done", "--force"}, "set_status"}, + {[]string{"delete", "--session", "sess-1", "--force"}, "delete"}, + } + for _, tt := range tests { + stdout, stderr, err := executePlans(t, svc, append(tt.args, "--json")...) + requirePlansStatusCode(t, err, 1) + assert.Empty(t, stdout, "args %v", tt.args) + body := decodePlansError(t, stderr) + assert.Equal(t, "unsupported", body.Code, "args %v", tt.args) + assert.Equal(t, "session", body.Scope, "args %v", tt.args) + assert.Equal(t, tt.wantOp, body.Op, "args %v", tt.args) + assert.Contains(t, body.Message, "within its session", "the error must tell the caller what to do instead") + } + + // The refused mutations left the session plan untouched. + p := mustGetPlan(t, svc, plans.SessionRef("sess-1")) + assert.Equal(t, "# plan", p.Content) +} + +// --- Export -------------------------------------------------------------------- + +func TestPlansExport_Shared(t *testing.T) { + t.Parallel() + svc, _, _ := newPlansTestService(t) + mustCreatePlan(t, svc, "release", "the body") + dest := filepath.Join(t.TempDir(), "nested", "plan.md") + + stdout, stderr, err := executePlans(t, svc, "export", "release", "--output", dest) + require.NoError(t, err) + assert.Empty(t, stderr) + assert.Equal(t, "Exported shared plan \"release\" (version 1) to "+dest+" (8 bytes)\n", stdout) + + data, err := os.ReadFile(dest) + require.NoError(t, err) + assert.Equal(t, "the body", string(data)) +} + +func TestPlansExport_SessionJSON(t *testing.T) { + t.Parallel() + svc, _, sessionDir := newPlansTestService(t) + writeSessionPlanFile(t, sessionDir, "sess-2", "# session plan") + dest := filepath.Join(t.TempDir(), "plan.md") + + stdout, _, err := executePlans(t, svc, "export", "--session", "sess-2", "--output", dest, "--json") + require.NoError(t, err) + + var doc struct { + SchemaVersion string `json:"schema_version"` + Export plans.ExportResult `json:"export"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &doc)) + assert.Equal(t, "1", doc.SchemaVersion) + assert.Equal(t, plans.ScopeSession, doc.Export.Scope) + assert.Equal(t, "sess-2", doc.Export.Name) + assert.Equal(t, dest, doc.Export.Path) + assert.Nil(t, doc.Export.Version, "session plans have no version to export") + assert.Equal(t, len("# session plan"), doc.Export.BytesWritten) + assert.Contains(t, stdout, `"bytes_written"`) + assert.NotContains(t, stdout, `"bytesWritten"`) + + data, err := os.ReadFile(dest) + require.NoError(t, err) + assert.Equal(t, "# session plan", string(data)) +} + +func TestPlansExport_NotFound(t *testing.T) { + t.Parallel() + svc, _, _ := newPlansTestService(t) + dest := filepath.Join(t.TempDir(), "plan.md") + + _, stderr, err := executePlans(t, svc, "export", "ghost", "--output", dest, "--json") + requirePlansStatusCode(t, err, 1) + body := decodePlansError(t, stderr) + assert.Equal(t, "not_found", body.Code) + assert.NoFileExists(t, dest) +} + +func TestPlansExport_RequiresOutput(t *testing.T) { + t.Parallel() + svc, _, _ := newPlansTestService(t) + mustCreatePlan(t, svc, "p", "body") + + _, _, err := executePlans(t, svc, "export", "p") + require.Error(t, err) + assert.Contains(t, err.Error(), `"output" not set`) +} + +// --- Delete -------------------------------------------------------------------- + +func TestPlansDelete_WithExpectedVersion(t *testing.T) { + t.Parallel() + svc, _, _ := newPlansTestService(t) + mustCreatePlan(t, svc, "p", "body") + + stdout, _, err := executePlans(t, svc, "delete", "p", "--expected-version", "1") + require.NoError(t, err) + assert.Equal(t, "Deleted shared plan \"p\"\n", stdout) + + _, err = svc.Get(t.Context(), plans.SharedRef("p")) + notFound, ok := errors.AsType[*plans.NotFoundError](err) + require.True(t, ok, "expected a *plans.NotFoundError, got %v", err) + assert.Equal(t, "p", notFound.Name) +} + +func TestPlansDelete_JSON(t *testing.T) { + t.Parallel() + svc, _, _ := newPlansTestService(t) + mustCreatePlan(t, svc, "p", "body") + + stdout, _, err := executePlans(t, svc, "delete", "p", "--force", "--json") + require.NoError(t, err) + + var doc struct { + SchemaVersion string `json:"schema_version"` + Deleted struct { + Scope string `json:"scope"` + Name string `json:"name"` + } `json:"deleted"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &doc)) + assert.Equal(t, "1", doc.SchemaVersion) + assert.Equal(t, "shared", doc.Deleted.Scope) + assert.Equal(t, "p", doc.Deleted.Name) +} + +func TestPlansDelete_StaleConflictPreservesPlan(t *testing.T) { + t.Parallel() + svc, _, _ := newPlansTestService(t) + mustCreatePlan(t, svc, "p", "v1") + _, err := svc.Update(t.Context(), plans.UpdateRequest{Ref: plans.SharedRef("p"), Content: "v2", ExpectedVersion: new(1)}) + require.NoError(t, err) + + _, stderr, err := executePlans(t, svc, "delete", "p", "--expected-version", "1", "--json") + requirePlansStatusCode(t, err, plansConflictExitCode) + body := decodePlansError(t, stderr) + assert.Equal(t, "conflict", body.Code) + require.NotNil(t, body.CurrentVersion) + assert.Equal(t, 2, *body.CurrentVersion) + + assert.Equal(t, "v2", mustGetPlan(t, svc, plans.SharedRef("p")).Content, "the conflicting delete must leave the plan in place") +} + +func TestPlansDelete_SafetyRequiresGuardOrForce(t *testing.T) { + t.Parallel() + svc, _, _ := newPlansTestService(t) + mustCreatePlan(t, svc, "p", "body") + + // The CLI never prompts, so a bare delete is refused. + _, _, err := executePlans(t, svc, "delete", "p") + require.Error(t, err) + assert.Contains(t, err.Error(), "at least one of the flags in the group [expected-version force] is required") + + // The guard and the deliberate opt-out are mutually exclusive. + _, _, err = executePlans(t, svc, "delete", "p", "--expected-version", "1", "--force") + require.Error(t, err) + assert.Contains(t, err.Error(), "none of the others can be") + + _, err = svc.Get(t.Context(), plans.SharedRef("p")) + require.NoError(t, err, "the refused deletes must not remove the plan") +} + +func TestPlansDelete_NotFound(t *testing.T) { + t.Parallel() + svc, _, _ := newPlansTestService(t) + + _, stderr, err := executePlans(t, svc, "delete", "ghost", "--force", "--json") + requirePlansStatusCode(t, err, 1) + body := decodePlansError(t, stderr) + assert.Equal(t, "not_found", body.Code) + assert.Equal(t, "ghost", body.Name) +} + +// --- Cobra validation errors in JSON mode ------------------------------------------ + +// TestPlansValidation_JSONContract proves the validation cobra performs +// before RunE (missing required flags, violated flag groups, bad positional +// args, unknown flags) also honours the --json error contract: a single +// schema-versioned JSON object on stderr, nothing on stdout, exit code 1. +func TestPlansValidation_JSONContract(t *testing.T) { + t.Parallel() + svc, _, _ := newPlansTestService(t) + file := writePlanContentFile(t, "body") + + tests := []struct { + name string + args []string + wantMsg string + }{ + {"missing required --file", []string{"create", "p", "--json"}, `"file" not set`}, + {"missing required --output", []string{"export", "p", "--json"}, `"output" not set`}, + {"missing guard flag", []string{"delete", "p", "--json"}, "at least one of the flags in the group [expected-version force] is required"}, + {"mutually exclusive guard flags", []string{"update", "p", "--file", file, "--expected-version", "1", "--force", "--json"}, "none of the others can be"}, + {"too many args", []string{"get", "a", "b", "--json"}, "accepts at most 1 arg(s), received 2"}, + {"unexpected arg", []string{"list", "nope", "--json"}, `unknown command "nope"`}, + {"unknown flag", []string{"list", "--json", "--frobnicate"}, "unknown flag: --frobnicate"}, + {"invalid flag value", []string{"delete", "p", "--json", "--expected-version", "abc"}, `invalid argument "abc"`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + stdout, stderr, err := executePlans(t, svc, tt.args...) + requirePlansStatusCode(t, err, 1) + assert.Empty(t, stdout, "JSON mode must keep stdout free of prose") + body := decodePlansError(t, stderr) + assert.Equal(t, "invalid_argument", body.Code) + assert.Contains(t, body.Message, tt.wantMsg) + // decodePlansError already pins stderr to a single JSON line, so + // cobra's own "Error: ..." rendering cannot have run as well. + assert.NotContains(t, stderr, "Error:") + assert.NotContains(t, stderr, "Usage:") + }) + } +} + +// TestPlansValidation_UnknownFlagBeforeJSONIsPlainText documents the one +// residual of the contract: flag parsing stops at the first unknown flag, so +// --json is only honoured when it was parsed before the failure. +func TestPlansValidation_UnknownFlagBeforeJSONIsPlainText(t *testing.T) { + t.Parallel() + svc, _, _ := newPlansTestService(t) + + stdout, stderr, err := executePlans(t, svc, "list", "--frobnicate", "--json") + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown flag: --frobnicate") + assert.Empty(t, stdout) + assert.NotContains(t, stderr, "schema_version", "an unparsed --json cannot enable the JSON contract") +} + +// TestPlansValidation_HumanModeKeepsCobraRendering proves validation errors +// without --json keep cobra's plain-text behaviour: the error is returned to +// the caller and rendered once, never as JSON. +func TestPlansValidation_HumanModeKeepsCobraRendering(t *testing.T) { + t.Parallel() + svc, _, _ := newPlansTestService(t) + + _, stderr, err := executePlans(t, svc, "get", "a", "b") + require.Error(t, err) + assert.Contains(t, err.Error(), "accepts at most 1 arg(s), received 2") + assert.NotContains(t, stderr, "schema_version") + assert.LessOrEqual(t, strings.Count(stderr, "accepts at most"), 1, "the error must not be rendered twice") +} + +// TestPlansValidation_JSONThroughRootCommand exercises the full production +// command tree (NewRootCmd, not a plans tree built directly by the test) to +// prove the interception is wired where real invocations run. Only failures +// raised before the root persistent pre-run are used, so the test touches no +// global state (no telemetry setup, no data-dir resolution). +func TestPlansValidation_JSONThroughRootCommand(t *testing.T) { + t.Parallel() + + for _, args := range [][]string{ + {"plans", "get", "a", "b", "--json"}, + {"plans", "list", "--json", "--frobnicate"}, + } { + t.Run(strings.Join(args, " "), func(t *testing.T) { + t.Parallel() + cmd := NewRootCmd() + var outBuf, errBuf bytes.Buffer + cmd.SetOut(&outBuf) + cmd.SetErr(&errBuf) + cmd.SetIn(strings.NewReader("")) + cmd.SetArgs(args) + cmd.SetContext(t.Context()) + + err := cmd.Execute() + requirePlansStatusCode(t, err, 1) + assert.Empty(t, outBuf.String()) + body := decodePlansError(t, errBuf.String()) + assert.Equal(t, "invalid_argument", body.Code) + }) + } +} + +// TestRootCommand_FlagErrorFuncStaysDefault guards the scoping of the plans +// flag-error interception: other commands keep cobra's default plain-text +// flag errors. +func TestRootCommand_FlagErrorFuncStaysDefault(t *testing.T) { + t.Parallel() + + cmd := NewRootCmd() + var outBuf, errBuf bytes.Buffer + cmd.SetOut(&outBuf) + cmd.SetErr(&errBuf) + cmd.SetIn(strings.NewReader("")) + cmd.SetArgs([]string{"version", "--frobnicate"}) + cmd.SetContext(t.Context()) + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown flag: --frobnicate") + assert.NotContains(t, errBuf.String(), "schema_version") +} + +// --- Storage failures ------------------------------------------------------------- + +// failingPlanStorage is a plan.Storage whose every method fails, to verify +// backend failures surface through the CLI as the storage error code. +type failingPlanStorage struct{ err error } + +var _ plan.Storage = failingPlanStorage{} + +func (f failingPlanStorage) Get(context.Context, string) (plan.Plan, bool, error) { + return plan.Plan{}, false, f.err +} + +func (f failingPlanStorage) Upsert(context.Context, plan.UpsertRequest) (plan.Plan, error) { + return plan.Plan{}, f.err +} + +func (f failingPlanStorage) List(context.Context) ([]plan.Summary, []string, error) { + return nil, nil, f.err +} + +func (f failingPlanStorage) Delete(context.Context, string, *int) (bool, error) { + return false, f.err +} + +func TestPlansList_StorageErrorJSON(t *testing.T) { + t.Parallel() + svc := plans.NewService(failingPlanStorage{err: errors.New("backend boom")}, plans.WithSessionDir(t.TempDir())) + + stdout, stderr, err := executePlans(t, svc, "list", "--json") + requirePlansStatusCode(t, err, 1) + assert.Empty(t, stdout) + body := decodePlansError(t, stderr) + assert.Equal(t, "storage", body.Code) + assert.Equal(t, "shared", body.Scope) + assert.Equal(t, "list", body.Op) + assert.Contains(t, body.Message, "backend boom") +} + +func TestPlansList_StorageErrorHuman(t *testing.T) { + t.Parallel() + svc := plans.NewService(failingPlanStorage{err: errors.New("backend boom")}, plans.WithSessionDir(t.TempDir())) + + stdout, stderr, err := executePlans(t, svc, "list") + requirePlansStatusCode(t, err, 1) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "Error:") + assert.Contains(t, stderr, "backend boom") +} diff --git a/cmd/root/plans_unix_test.go b/cmd/root/plans_unix_test.go new file mode 100644 index 0000000000..118356bfd3 --- /dev/null +++ b/cmd/root/plans_unix_test.go @@ -0,0 +1,124 @@ +//go:build !windows + +package root + +import ( + "os" + "path/filepath" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestPlansCreate_RejectsNamedPipe proves a non-regular --file (here a named +// pipe with no writer) is rejected without hanging and without being read. +// The pipe is opened with O_NONBLOCK — a plain open would block forever +// waiting for a writer — and then refused when the opened descriptor turns +// out not to be a regular file. The timeout guards against a regression to a +// blocking open. +func TestPlansCreate_RejectsNamedPipe(t *testing.T) { + t.Parallel() + svc, _, _ := newPlansTestService(t) + + fifo := filepath.Join(t.TempDir(), "content.pipe") + if err := syscall.Mkfifo(fifo, 0o600); err != nil { + t.Skipf("cannot create a FIFO on this system: %v", err) + } + + type result struct { + stdout, stderr string + err error + } + done := make(chan result, 1) + go func() { + stdout, stderr, err := executePlans(t, svc, "create", "p", "--file", fifo, "--json") + done <- result{stdout, stderr, err} + }() + + select { + case res := <-done: + requirePlansStatusCode(t, res.err, 1) + assert.Empty(t, res.stdout) + body := decodePlansError(t, res.stderr) + assert.Equal(t, "invalid_argument", body.Code) + assert.Contains(t, body.Message, "not a regular file") + case <-time.After(5 * time.Second): + t.Fatal("create blocked on a named pipe; the open must not block and the descriptor must be rejected before any read") + } +} + +// TestPlansCreate_RejectsDevice proves a device --file is rejected on the +// opened descriptor without being read: reading /dev/zero would stream +// unbounded data and trip the size cap at best, so the distinct "not a +// regular file" message shows no read happened. +func TestPlansCreate_RejectsDevice(t *testing.T) { + t.Parallel() + svc, _, _ := newPlansTestService(t) + + if _, err := os.Stat("/dev/zero"); err != nil { + t.Skipf("/dev/zero not available: %v", err) + } + + stdout, stderr, err := executePlans(t, svc, "create", "p", "--file", "/dev/zero", "--json") + requirePlansStatusCode(t, err, 1) + assert.Empty(t, stdout) + body := decodePlansError(t, stderr) + assert.Equal(t, "invalid_argument", body.Code) + assert.Contains(t, body.Message, "not a regular file") +} + +// TestPlansGetList_FIFOPlanFileFailsFast proves a FIFO squatting on a +// stored plan file cannot wedge the read commands: get reports it as a +// typed corrupt plan and list degrades it to a warning while still listing +// healthy plans — both promptly. The timeout guards against a regression to +// a blocking open in the storage's load path. +func TestPlansGetList_FIFOPlanFileFailsFast(t *testing.T) { + t.Parallel() + svc, sharedDir, _ := newPlansTestService(t) + mustCreatePlan(t, svc, "good", "content") + + if err := syscall.Mkfifo(filepath.Join(sharedDir, "wedged.json"), 0o600); err != nil { + t.Skipf("cannot create a FIFO on this system: %v", err) + } + + type result struct { + stdout, stderr string + err error + } + + getDone := make(chan result, 1) + go func() { + stdout, stderr, err := executePlans(t, svc, "get", "wedged", "--json") + getDone <- result{stdout, stderr, err} + }() + select { + case res := <-getDone: + requirePlansStatusCode(t, res.err, 1) + assert.Empty(t, res.stdout) + body := decodePlansError(t, res.stderr) + assert.Equal(t, "corrupt", body.Code) + assert.Equal(t, "wedged", body.Name) + assert.Contains(t, body.Message, "not a regular file") + case <-time.After(5 * time.Second): + t.Fatal("plans get blocked on a FIFO plan file; the open must not block and the descriptor must be rejected before any read") + } + + listDone := make(chan result, 1) + go func() { + stdout, stderr, err := executePlans(t, svc, "list", "--json") + listDone <- result{stdout, stderr, err} + }() + select { + case res := <-listDone: + require.NoError(t, res.err) + assert.Contains(t, res.stdout, `"name": "good"`, "the healthy plan must still be listed") + assert.NotContains(t, res.stdout, `"name": "wedged"`) + assert.Contains(t, res.stdout, "warnings", "the FIFO must surface as a warning") + assert.Contains(t, res.stdout, "wedged") + case <-time.After(5 * time.Second): + t.Fatal("plans list blocked on a FIFO plan file; the open must not block and the descriptor must be rejected before any read") + } +} diff --git a/cmd/root/root.go b/cmd/root/root.go index 92d54ba5eb..1ff4f1ef46 100644 --- a/cmd/root/root.go +++ b/cmd/root/root.go @@ -184,6 +184,7 @@ We collect anonymous usage data to help improve docker agent. To disable: newDoctorCmd(), newDebugCmd(), newAliasCmd(), + newPlansCmd(), newSandboxCmd(), newBoardCmd(), newServeCmd(), diff --git a/docs/features/cli/index.md b/docs/features/cli/index.md index 149393a183..30302d663e 100644 --- a/docs/features/cli/index.md +++ b/docs/features/cli/index.md @@ -602,6 +602,62 @@ $ docker agent sandbox deny api.example.com Entries are unioned with the gateway, the kit-resolved tool install hosts, and any `runtime.network_allowlist` declared by the agent. The launch summary lists every source separately so you can see which holes were punched by which layer. +### `docker agent plans` + +Manage the plans agents collaborate on, from the host — without starting a session. Two plan systems are covered: + +- **Shared plans** — the named, versioned documents of the [plan toolset](../../tools/plan/index.md). Fully manageable: create, update, set status, export, delete. +- **Session plans** — the single per-session plan of the "draft, review, execute" workflow. Read-only here (`list`, `get`, `export`); they belong to their session and are changed from within it. A mutation aimed at a session plan fails with an `unsupported` error explaining what to do instead. + +```bash +$ docker agent plans [flags] +``` + +| Subcommand | Description | +| ---------- | ----------- | +| `list [--session ]` | List shared plans with scope, name, status, version, updated time, and title. With `--session`, that session's plan is listed first when it exists. Plans that exist but cannot be read are reported as warnings on stderr (in the `warnings` field with `--json`), so they are never mistaken for missing. | +| `get ` | Print a plan. Content goes to stdout and a concise metadata line goes to stderr, so `> file` captures the content alone (use `export` for a byte-exact copy). `get --session ` prints a session's plan; the name is then omitted (`--scope shared\|session` disambiguates explicitly, and `--session` alone implies session scope). | +| `create --file ` | Create a new shared plan with content from `--file` (required — the CLI never prompts; `--file -` reads stdin). Create-only: an existing name fails with a version conflict instead of overwriting. `--title`, `--author`, and `--status` set metadata. | +| `update --file ` | Replace the content of an existing shared plan (never creates). Omitted `--title`/`--author`/`--status` flags preserve the current values; passing them (even empty) overwrites. | +| `status ` | Set a shared plan's free-form status without touching its body (bumps the version). | +| `export --output ` | Write a plan's content, byte-exact, to a file (parents created, atomic write). Works for both scopes: `export --session --output `. | +| `delete ` | Delete a shared plan. A `--force` delete also recovers a corrupt plan. | + +Plan content passed via `--file` (a regular file, or stdin with `--file -`) is capped at 10 MiB — the same limit the plan storage itself enforces — and a directory or non-regular file (device, named pipe) is rejected up front; violations fail with an `invalid_argument` error. + +**Concurrency guard:** every mutation (`update`, `status`, `delete`) requires exactly one of two mutually exclusive flags — the CLI is headless and never prompts: + +- `--expected-version ` — the version you last read (from `get` or `list`; must be ≥ 1). When the plan changed in the meantime the command fails with a version conflict, reports the current version, leaves the plan untouched, and exits with code **3** (all other failures exit with 1). +- `--force` — deliberately write without the optimistic-lock guard (last writer wins). + +`create` takes no guard: it is inherently create-only and conflicts (exit code 3) when the name already exists. + +**JSON output:** every subcommand accepts `--json`. Success documents go to stdout with a top-level `"schema_version": "1"` marker and stable service-model keys (`plans`, `plan`, `export`, `deleted`) whose fields are snake_case (`updated_at`, `session_id`, `bytes_written`; a zero/unknown `updated_at` is omitted); empty plan lists encode as `[]`, and no prose or ANSI is mixed in. Failures print a single JSON object to stderr: + +```json +{"schema_version":"1","error":{"code":"conflict","message":"...","scope":"shared","name":"p","expected_version":1,"current_version":2}} +``` + +with `code` one of `conflict` (including `expected_version` and `current_version`), `not_found`, `invalid_argument`, `unsupported`, `corrupt`, `storage`, or `error`; `scope`, `name`, and `op` are included where the failure carries them. Validation performed before a subcommand runs is covered too: a missing required flag, a violated `--expected-version`/`--force` group rule, and wrong positional arguments are reported as the same JSON object (code `invalid_argument`) whenever `--json` is present. One residual: flags are parsed left-to-right and parsing stops at the first unknown flag or invalid flag value, so such an error is reported as JSON only when `--json` appears before it on the command line; errors raised before a `plans` subcommand is resolved at all (e.g. an unknown subcommand) also remain plain text. + +```bash +# Examples +$ docker agent plans list +$ docker agent plans list --json | jq '.plans[].name' +$ docker agent plans create release --file ./plan.md --title "Release plan" --status draft +$ cat plan.md | docker agent plans create release --file - +$ docker agent plans get release > plan.md # content only; metadata on stderr +$ docker agent plans update release --file ./plan.md --expected-version 1 +$ docker agent plans status release done --expected-version 2 +$ docker agent plans export release --output ./plan.md +$ docker agent plans delete release --expected-version 3 +$ docker agent plans delete scratch --force +$ docker agent plans get --session # a session's plan +$ docker agent plans export --session --output ./session-plan.md +``` + +Plans live under the data directory (`~/.cagent/plans/` and `~/.cagent/session_plans/` by default), so `--data-dir` selects which store the commands operate on. + ### `docker agent debug` Troubleshooting subcommands for inspecting how an agent config resolves and generating diagnostic output — useful when a config isn't behaving the way you expect. `debug` doesn't appear in `docker agent --help` (it's a diagnostic surface, not a day-to-day command), but every subcommand below is stable and fully supported. diff --git a/docs/features/tui/index.md b/docs/features/tui/index.md index 6ebc743ea4..ca143454bf 100644 --- a/docs/features/tui/index.md +++ b/docs/features/tui/index.md @@ -77,6 +77,7 @@ Type `/` during a session to see available commands, or press Ctrl+`, or `/effort` alone to pick from the supported levels; reasoning models only). Press Tab after `/effort` and a space to complete a level the current model supports | | `/settings` | Manage appearance, behavior, and notification preferences | diff --git a/docs/tools/plan/index.md b/docs/tools/plan/index.md index 23761d8a6a..bea761a6f8 100644 --- a/docs/tools/plan/index.md +++ b/docs/tools/plan/index.md @@ -13,7 +13,7 @@ _Shared persistent scratchpad for multi-agent collaboration._ The plan tool gives agents a shared, persistent scratchpad of named documents. Any agent in a multi-agent config that loads the `plan` toolset can read and write the same plans, and those plans survive across sessions. This makes it straightforward to wire a planner agent that sketches work and one or more executor agents that consume it without any custom tool wiring. -Plans are stored as JSON files in the Docker Agent data directory (`~/.cagent/plans/` by default). All agents that share a process serialize on a single mutex so concurrent reads and writes are safe. Writes are atomic (temp file + rename), so a reader never observes partial content. +Plans are stored as JSON files in the Docker Agent data directory (`~/.cagent/plans/` by default). Agents that share a process serialize on a single mutex, and every write or delete additionally holds an advisory lock on a sentinel file in the plans directory, so writers in *separate* Docker Agent processes are serialized too: concurrent edits can never silently overwrite each other, and a stale revision always fails with a deterministic version conflict. Writes are atomic (temp file + rename), so a reader never observes partial content. ## Configuration @@ -62,8 +62,11 @@ overwrite each other. Every read returns a `revision` number; pass the value you last read as `last_known_revision` to `write_plan`, `update_plan_from_file`, `set_plan_status`, or `delete_plan`. If the plan changed since (its current revision no longer matches), the write is rejected with a version-conflict -error and the caller should re-read the plan and retry. Omit -`last_known_revision` to write unconditionally (last writer wins). +error and the caller should re-read the plan and retry. The revision check and +the write happen under the storage's cross-process file lock, so the conflict +is detected reliably even when the competing writer runs in a different Docker +Agent process. Omit `last_known_revision` to write unconditionally (last +writer wins). ### Plan Names @@ -127,6 +130,48 @@ See [`examples/shared_plan.yaml`](https://github.com/docker/docker-agent/blob/ma - `list_plans` skips corrupt entries but reports them in a `warnings` field so an agent can detect and recover from a bad state (e.g., by calling `delete_plan`). - `delete_plan` can remove a corrupt plan to recover from a bad state. +## Managing plans from the host + +Shared plans can also be inspected and managed outside a session with the [`docker agent plans`](../../features/cli/index.md#docker-agent-plans) command group: list, get, create, update, set status, export, and delete — with the same optimistic-locking semantics as the tools (`--expected-version` guards a write and a stale version fails with exit code 3; `--force` writes unconditionally). Session plans (the per-session "draft, review, execute" plan) can be listed, read, and exported through the same commands but stay owned by their session and cannot be mutated from the host. + +```bash +$ docker agent plans list +$ docker agent plans get release > plan.md +$ docker agent plans update release --file ./plan.md --expected-version 1 +``` + +### The `/plans` browser in the TUI + +Inside the full-screen TUI, the `/plans` slash command (also in the Ctrl+K command palette) opens a plan browser over the same store the agents use, so changes made by agents mid-session appear immediately. The list shows every shared plan plus the current session's [session plan](../session_plan/index.md), with each plan's scope, identity (name, or session ID for the session plan), status, version (`-` for the unversioned session plan), last update time, and title. + +Keybindings: + +| Key | Action | +| --- | ------ | +| /, mouse | Navigate; Enter or double-click opens a detail view with the full metadata and scrollable markdown content | +| / | Filter by name, title, status, or scope (Esc leaves filter mode) | +| r | Refresh from storage | +| x | Export the selected plan to `.md` (shared) or `session-plan-.md` (session) in the session's working directory. An existing file is never overwritten — the export fails with a notification instead | +| s | Set a shared plan's free-form status via a small input dialog | +| e | Edit a shared plan's content in `$VISUAL`/`$EDITOR` | +| n | Create a new shared plan: pick a name, then draft the content in `$VISUAL`/`$EDITOR` (an empty draft aborts) | +| d | Delete a shared plan after a confirmation that names the plan and its version | +| Esc | Close the detail view / the browser | + +Every mutation is guarded by the version shown on screen (the same optimistic locking as `last_known_revision`): if an agent changed the plan in the meantime, the write is rejected, a notification reports the current version, the newer content is left intact and re-read into the browser, and an edit draft is kept in a temp file so nothing is lost. Session plans are read-only here — status, edit, and delete report why instead of attempting the write. The browser also refreshes live when agents in the same process write, re-status, or delete plans (and when this session's agent updates its session plan); in the lean TUI, which has no overlays, `/plans` is unavailable. + +## Related plan work + +Plan support grew across several issues and pull requests; they are useful context for the design decisions on this page: + +- [#2788](https://github.com/docker/docker-agent/issues/2788) — proposed `/plan`, approval UX, and plan/execution separation; superseded by read-only planner sub-agents and the dedicated plan tools. +- [#3227](https://github.com/docker/docker-agent/pull/3227) / [#3237](https://github.com/docker/docker-agent/issues/3237) — introduced the shared `plan` toolset and its pluggable-storage direction. +- [#3239](https://github.com/docker/docker-agent/pull/3239) — added the pluggable `Storage` interface. +- [#3263](https://github.com/docker/docker-agent/issues/3263) / [#3274](https://github.com/docker/docker-agent/pull/3274) — file-based revisions, export, free-form status, and optimistic locking. +- [#3292](https://github.com/docker/docker-agent/pull/3292) / [#3305](https://github.com/docker/docker-agent/pull/3305) — the per-session markdown plan of the [session plan toolset](../session_plan/index.md) and its `exit_plan_mode` marker, alongside the shared plan toolset. +- [#3140](https://github.com/docker/docker-agent/pull/3140) / [#3168](https://github.com/docker/docker-agent/pull/3168) — related plan-mode and persona history. +- [#3844](https://github.com/docker/docker-agent/issues/3844) — the host-facing management layer: the [`docker agent plans` CLI](../../features/cli/index.md#docker-agent-plans) and the TUI `/plans` browser documented above. + > [!TIP] > **Plan vs. Todo vs. Tasks** > diff --git a/docs/tools/session_plan/index.md b/docs/tools/session_plan/index.md index e39bfd60f9..ad186a1990 100644 --- a/docs/tools/session_plan/index.md +++ b/docs/tools/session_plan/index.md @@ -76,6 +76,13 @@ A `session_plan_updated` event is emitted whenever `write_session_plan` succeeds Embedders that render the plan inline can subscribe and update without re-reading the file. +## Managing session plans from the host + +A session plan belongs to its session: hosts can read and export it, never change it. + +- **CLI** — the [`docker agent plans`](../../features/cli/index.md#docker-agent-plans) command group lists, reads (`get --session `), and exports session plans alongside shared plans. Mutations (`update`, `status`, `delete`) are refused with an `unsupported` error explaining the ownership rule. +- **TUI** — the `/plans` browser (see the [plan toolset docs](../plan/index.md#the-plans-browser-in-the-tui) for the full keybinding table) includes the **current session's** plan as the `session` scope row; plans of other sessions are never enumerated. Its identity is the session ID and its version column shows `-` — session plans have no versions. Enter opens the read-only detail (scope, session ID, update time, scrollable markdown) and x exports to `session-plan-.md` in the working directory (refusing to overwrite an existing file). Status, edit, and delete visibly report that session plans don't support them instead of attempting the write. The browser refreshes live on the `session_plan_updated` event, so a plan the agent just wrote appears without reopening. + ## Example A two-agent workflow: `root` executes, `planner` plans. `/plan` hands off to the planner; `exit_plan_mode` signals "ready", and the host decides what happens next. diff --git a/pkg/plans/errors.go b/pkg/plans/errors.go new file mode 100644 index 0000000000..8275cd6048 --- /dev/null +++ b/pkg/plans/errors.go @@ -0,0 +1,87 @@ +package plans + +import "fmt" + +// NotFoundError reports that the addressed plan does not exist in its scope. +type NotFoundError struct { + Scope Scope + // Name is the plan name or the session ID, matching Scope. + Name string +} + +func (e *NotFoundError) Error() string { + return fmt.Sprintf("%s plan %q not found", e.Scope, e.Name) +} + +// ValidationError reports invalid caller input: a malformed plan name or +// session ID, an unknown scope, empty or oversized content, or an empty +// status. +type ValidationError struct { + Message string +} + +func (e *ValidationError) Error() string { return e.Message } + +// CorruptError reports a plan that exists but cannot be read or decoded, so a +// caller never mistakes a damaged plan for a missing one and recreates it. +type CorruptError struct { + Scope Scope + Name string + Err error +} + +func (e *CorruptError) Error() string { + return fmt.Sprintf("%s plan %q: %v", e.Scope, e.Name, e.Err) +} + +func (e *CorruptError) Unwrap() error { return e.Err } + +// StorageError reports a backend failure (I/O, permissions, ...) that is +// neither a missing nor a corrupt plan. +type StorageError struct { + Scope Scope + Op string + Err error +} + +func (e *StorageError) Error() string { + return fmt.Sprintf("%s plan storage failure during %s: %v", e.Scope, e.Op, e.Err) +} + +func (e *StorageError) Unwrap() error { return e.Err } + +// ConflictError reports a mutation rejected because the caller's expected +// version no longer matches the plan's current one. Current carries the +// version the plan is actually at, so a frontend can offer "re-read and +// retry" or a deliberate force. Expected 0 identifies a failed create: the +// plan already exists. +type ConflictError struct { + Name string + Expected int + Current int +} + +func (e *ConflictError) Error() string { + // A create (expected version 0) cannot be forced or retried against a + // newer version; the way out is a different name or an update. + if e.Expected == 0 { + return fmt.Sprintf("plan %q already exists (current version %d); pick a different name, or update the existing plan", e.Name, e.Current) + } + return fmt.Sprintf("version conflict on plan %q: expected version %d does not match current version %d; re-read the plan and retry, or force to overwrite", e.Name, e.Expected, e.Current) +} + +// UnsupportedError reports an operation the plan's scope does not support, +// with Reason telling the caller what to do instead. +type UnsupportedError struct { + Scope Scope + Op string + Reason string +} + +func (e *UnsupportedError) Error() string { + msg := fmt.Sprintf("%s is not supported for %s plans", e.Op, e.Scope) + if e.Reason != "" { + msg += ": " + e.Reason + } + return msg +} diff --git a/pkg/plans/json_test.go b/pkg/plans/json_test.go new file mode 100644 index 0000000000..2ae5e2eb26 --- /dev/null +++ b/pkg/plans/json_test.go @@ -0,0 +1,96 @@ +package plans + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// These tests pin the host-facing JSON wire contract: field names are stable +// snake_case, absent values are omitted, and the zero UpdatedAt is dropped by +// omitzero (time.Time.IsZero) rather than serialized as "0001-01-01...". +// JSONEq compares full key sets and values, so any accidental tag change +// fails loudly. + +func mustMarshal(t *testing.T, v any) string { + t.Helper() + data, err := json.Marshal(v) + require.NoError(t, err) + return string(data) +} + +func TestPlanJSON_SharedShape(t *testing.T) { + t.Parallel() + p := Plan{ + Scope: ScopeShared, + Name: "release", + Title: "Release plan", + Author: "alice", + Status: "draft", + Content: "body", + Version: new(3), + UpdatedAt: time.Date(2024, 5, 6, 7, 8, 9, 0, time.UTC), + } + + assert.JSONEq(t, + `{"scope":"shared","name":"release","title":"Release plan","author":"alice","status":"draft","content":"body","version":3,"updated_at":"2024-05-06T07:08:09Z"}`, + mustMarshal(t, p)) +} + +func TestPlanJSON_SessionShape(t *testing.T) { + t.Parallel() + p := Plan{ + Scope: ScopeSession, + Name: "sess-1", + Content: "# plan", + UpdatedAt: time.Date(2024, 5, 6, 7, 8, 9, 0, time.UTC), + SessionID: "sess-1", + Path: "/data/session_plans/sess-1.md", + } + + // No version, title, author, or status: session plans never carry them. + assert.JSONEq(t, + `{"scope":"session","name":"sess-1","content":"# plan","updated_at":"2024-05-06T07:08:09Z","session_id":"sess-1","path":"/data/session_plans/sess-1.md"}`, + mustMarshal(t, p)) +} + +func TestPlanJSON_ZeroValuesOmitted(t *testing.T) { + t.Parallel() + + // A zero UpdatedAt means "unknown" and must be omitted, not rendered as + // the zero time; all optional strings and the nil version disappear too. + assert.JSONEq(t, + `{"scope":"shared","name":"p"}`, + mustMarshal(t, Plan{Scope: ScopeShared, Name: "p"})) +} + +func TestPlanJSON_RoundTrip(t *testing.T) { + t.Parallel() + p := Plan{ + Scope: ScopeSession, + Name: "sess-1", + UpdatedAt: time.Date(2024, 5, 6, 7, 8, 9, 0, time.UTC), + SessionID: "sess-1", + Path: "/x/plan.md", + } + + var got Plan + require.NoError(t, json.Unmarshal([]byte(mustMarshal(t, p)), &got)) + assert.Equal(t, p, got) +} + +func TestExportResultJSON_Shapes(t *testing.T) { + t.Parallel() + + assert.JSONEq(t, + `{"scope":"shared","name":"p","path":"/out/plan.md","version":2,"bytes_written":5}`, + mustMarshal(t, ExportResult{Scope: ScopeShared, Name: "p", Path: "/out/plan.md", Version: new(2), BytesWritten: 5})) + + // Session exports have no version to report. + assert.JSONEq(t, + `{"scope":"session","name":"sess-1","path":"/out/plan.md","bytes_written":0}`, + mustMarshal(t, ExportResult{Scope: ScopeSession, Name: "sess-1", Path: "/out/plan.md"})) +} diff --git a/pkg/plans/plans.go b/pkg/plans/plans.go new file mode 100644 index 0000000000..8d63bd6ada --- /dev/null +++ b/pkg/plans/plans.go @@ -0,0 +1,194 @@ +// Package plans is the host-facing contract for managing plans from a +// frontend such as a CLI or TUI. It unifies the two plan systems behind one +// model and one Service: the shared, named plans agents collaborate on +// (pkg/tools/builtin/plan) and the single per-session plan owned by the +// runtime (pkg/tools/builtin/sessionplan). +// +// The package wraps the existing storage rather than duplicating it. Shared +// plans go through a caller-supplied plan.Storage — pass plan.SharedStorage() +// to operate on the same store, and thus the same mutex, as the plan tools of +// agents running in this process. The session plan is read through the +// sessionplan helpers. Session plans have no revisions or optimistic locking +// and belong to their session, so the Service exposes them read/export-only +// and rejects mutations with a typed *UnsupportedError. +package plans + +import ( + "context" + "time" +) + +// Scope identifies which plan system a plan belongs to. +type Scope string + +const ( + // ScopeShared is the cross-session store of named plans that agents + // collaborate on. Shared plans are versioned and fully mutable. + ScopeShared Scope = "shared" + // ScopeSession is the per-session plan of the "draft, review, execute" + // workflow. At most one exists per session; it has no versions and is + // read/export-only through the Service. + ScopeSession Scope = "session" +) + +// Mutable reports whether plans in this scope can be created, updated, and +// deleted through the Service, so a frontend can disable editing up front +// instead of provoking an *UnsupportedError. +func (s Scope) Mutable() bool { return s == ScopeShared } + +// Plan is the host-facing view of a plan from either scope. +// +// The JSON tags are a stable, snake_case wire contract for host consumers +// (e.g. the plans CLI --json output). It is deliberately independent of the +// agent tool JSON of pkg/tools/builtin/plan, which keeps its historical +// camelCase fields (updatedAt, bytesWritten) for backward compatibility. +type Plan struct { + // Scope tells which plan system the plan lives in. + Scope Scope `json:"scope"` + // Name is the canonical identity within the scope: the validated plan + // name for shared plans, the session ID for session plans. + Name string `json:"name"` + // Title, Author, and Status are shared-plan metadata, empty for session + // plans. Status is a free-form lifecycle label with no fixed vocabulary. + Title string `json:"title,omitempty"` + Author string `json:"author,omitempty"` + Status string `json:"status,omitempty"` + // Content is the plan body. List returns metadata only, so Content is + // empty there; Get populates it. + Content string `json:"content,omitempty"` + // Version is the optimistic-lock revision of a shared plan. It is nil for + // session plans, which have no revisions: nil means version-guarded + // operations are unsupported, not "version zero". + Version *int `json:"version,omitempty"` + // UpdatedAt is the time of the last write: the stored timestamp for + // shared plans, the plan file's modification time for session plans. + // Zero when unknown (and then omitted from JSON via omitzero, which + // consults time.Time.IsZero; omitempty would keep the zero struct). + UpdatedAt time.Time `json:"updated_at,omitzero"` + // SessionID is the owning session of a session plan, empty for shared + // plans. + SessionID string `json:"session_id,omitempty"` + // Path is the backing file of a session plan. It is empty for shared + // plans, whose storage backend is pluggable and opaque. + Path string `json:"path,omitempty"` +} + +// Ref addresses a plan in either scope: Name addresses a shared plan, +// SessionID the plan of that session. The field matching Scope must be set. +type Ref struct { + Scope Scope + Name string + SessionID string +} + +// SharedRef addresses the named shared plan. +func SharedRef(name string) Ref { return Ref{Scope: ScopeShared, Name: name} } + +// SessionRef addresses the plan of the given session. +func SessionRef(sessionID string) Ref { return Ref{Scope: ScopeSession, SessionID: sessionID} } + +// ListOptions controls List. +type ListOptions struct { + // SessionID, when non-empty, also includes that session's plan in the + // listing if one exists. Only the identified session is consulted; plan + // files left behind by other sessions are never enumerated. + SessionID string +} + +// ListResult is the outcome of List. Warnings carries plans that exist but +// could not be read, so a caller can tell "no plans" apart from "some plans +// failed to load". +type ListResult struct { + Plans []Plan `json:"plans"` + Warnings []string `json:"warnings,omitempty"` +} + +// CreateRequest creates a new shared plan. Create is create-only: the write +// is guarded by expected version 0, so it fails with a *ConflictError instead +// of silently overwriting a plan that already exists. +type CreateRequest struct { + Ref Ref + Content string + Title string + Author string + Status string +} + +// UpdateRequest replaces the content of an existing shared plan. Nil metadata +// pointers preserve the previous values; explicit values (including "") +// overwrite them. +// +// ExpectedVersion is the version the caller last read: the write is rejected +// with a *ConflictError when it no longer matches. An explicit nil means +// unconditional replacement and must only be sent when the user deliberately +// chose to force. +type UpdateRequest struct { + Ref Ref + Content string + Title *string + Author *string + Status *string + ExpectedVersion *int +} + +// SetStatusRequest sets the free-form status of an existing shared plan +// without touching its body. ExpectedVersion follows the same rules as in +// UpdateRequest. +type SetStatusRequest struct { + Ref Ref + Status string + ExpectedVersion *int +} + +// DeleteRequest removes a shared plan. ExpectedVersion follows the same rules +// as in UpdateRequest: nil deletes unconditionally. +type DeleteRequest struct { + Ref Ref + ExpectedVersion *int +} + +// ExportRequest writes a plan's content to a file on disk. Export works for +// both scopes. +type ExportRequest struct { + Ref Ref + Path string +} + +// ExportResult reports a completed export. Version is the exported shared +// plan's version, nil for session plans. +type ExportResult struct { + Scope Scope `json:"scope"` + Name string `json:"name"` + Path string `json:"path"` + Version *int `json:"version,omitempty"` + BytesWritten int `json:"bytes_written"` +} + +// Service is the host-facing contract for managing plans across both scopes. +// Mutations address shared plans only; a mutation aimed at a session plan +// fails with a typed *UnsupportedError. Failures are reported as the typed +// errors of this package so frontends never classify by error text. +type Service interface { + // List returns plan metadata (Content is left empty): every shared plan + // sorted by name and, when opts.SessionID is set, that session's plan + // first. A missing session plan is simply not included, never an error. + List(ctx context.Context, opts ListOptions) (ListResult, error) + // Get returns the full plan, including content. A missing plan is a + // *NotFoundError in either scope. + Get(ctx context.Context, ref Ref) (Plan, error) + // Create adds a new shared plan; a name that already exists fails with a + // *ConflictError carrying the current version. + Create(ctx context.Context, req CreateRequest) (Plan, error) + // Update replaces the content (and optionally metadata) of an existing + // shared plan, honouring req.ExpectedVersion. + Update(ctx context.Context, req UpdateRequest) (Plan, error) + // SetStatus sets the free-form status of an existing shared plan, + // honouring req.ExpectedVersion. + SetStatus(ctx context.Context, req SetStatusRequest) (Plan, error) + // Delete removes a shared plan, honouring req.ExpectedVersion. Deleting a + // missing plan is a *NotFoundError. + Delete(ctx context.Context, req DeleteRequest) error + // Export writes a plan's content to req.Path, creating parent directories + // as needed and replacing an existing file atomically. + Export(ctx context.Context, req ExportRequest) (ExportResult, error) +} diff --git a/pkg/plans/service.go b/pkg/plans/service.go new file mode 100644 index 0000000000..f542d569ff --- /dev/null +++ b/pkg/plans/service.go @@ -0,0 +1,409 @@ +package plans + +import ( + "context" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/docker/docker-agent/pkg/atomicfile" + "github.com/docker/docker-agent/pkg/tools/builtin/plan" + "github.com/docker/docker-agent/pkg/tools/builtin/sessionplan" +) + +// sessionMutationReason makes the unsupported-operation error actionable: +// it names the constraint and what to do instead. +const sessionMutationReason = "session plans have no versions and belong to their session; change the plan from within its session, or use a shared plan for cross-session collaboration" + +type service struct { + storage plan.Storage + sessionDir string +} + +var _ Service = (*service)(nil) + +// Option configures a Service built by NewService. +type Option func(*service) + +// WithSessionDir overrides the directory the session plan is read from, +// defaulting to sessionplan.DefaultDir(). +func WithSessionDir(dir string) Option { + return func(s *service) { s.sessionDir = dir } +} + +// NewService returns a Service over the given shared-plan storage. Pass +// plan.SharedStorage() to operate on the same store — and serialize on the +// same mutex — as the plan tools of agents running in this process; any other +// plan.Storage yields an isolated service. The storage must not be nil. +func NewService(storage plan.Storage, opts ...Option) Service { + if storage == nil { + panic("plans: storage must not be nil") + } + s := &service{storage: storage, sessionDir: sessionplan.DefaultDir()} + for _, opt := range opts { + opt(s) + } + return s +} + +func (s *service) List(ctx context.Context, opts ListOptions) (ListResult, error) { + // Always a non-nil slice so an empty listing serializes as [] not null. + result := ListResult{Plans: []Plan{}} + + if opts.SessionID != "" { + p, ok, warning, err := s.statSessionPlan(opts.SessionID) + if err != nil { + return ListResult{}, err + } + if warning != "" { + result.Warnings = append(result.Warnings, warning) + } + if ok { + result.Plans = append(result.Plans, p) + } + } + + summaries, warnings, err := s.storage.List(ctx) + if err != nil { + return ListResult{}, &StorageError{Scope: ScopeShared, Op: "list", Err: err} + } + result.Warnings = append(result.Warnings, warnings...) + // The documented order is by name; enforce it here so it holds for any + // injected Storage, not only backends that happen to sort. + sort.SliceStable(summaries, func(i, j int) bool { return summaries[i].Name < summaries[j].Name }) + for _, sum := range summaries { + result.Plans = append(result.Plans, Plan{ + Scope: ScopeShared, + Name: sum.Name, + Title: sum.Title, + Author: sum.Author, + Status: sum.Status, + Version: new(sum.Revision), + UpdatedAt: parseUpdatedAt(sum.UpdatedAt), + }) + } + return result, nil +} + +func (s *service) Get(ctx context.Context, ref Ref) (Plan, error) { + switch ref.Scope { + case ScopeShared: + return s.getShared(ctx, ref.Name) + case ScopeSession: + return s.getSession(ref.SessionID) + default: + return Plan{}, invalidScopeError(ref.Scope) + } +} + +func (s *service) Create(ctx context.Context, req CreateRequest) (Plan, error) { + if err := checkSharedMutation("create", req.Ref); err != nil { + return Plan{}, err + } + if err := validateContent(req.Content); err != nil { + return Plan{}, err + } + // MustNotExist makes the write create-only by existence, not by + // revision: a plan that already exists conflicts even when its stored + // revision is 0 (a hand-written or foreign file that omits the field). + // ExpectedRevision 0 is kept alongside it as a defensive guard for + // injected backends that predate MustNotExist: those still conflict for + // any plan that ever took a revision bump. + p, err := s.storage.Upsert(ctx, plan.UpsertRequest{ + Name: req.Ref.Name, + Content: &req.Content, + Title: &req.Title, + Author: &req.Author, + Status: &req.Status, + ExpectedRevision: new(0), + MustNotExist: true, + }) + if err != nil { + return Plan{}, sharedError("create", req.Ref.Name, err) + } + return sharedPlan(p), nil +} + +func (s *service) Update(ctx context.Context, req UpdateRequest) (Plan, error) { + if err := checkSharedMutation("update", req.Ref); err != nil { + return Plan{}, err + } + if err := validateContent(req.Content); err != nil { + return Plan{}, err + } + p, err := s.storage.Upsert(ctx, plan.UpsertRequest{ + Name: req.Ref.Name, + Content: &req.Content, + Title: req.Title, + Author: req.Author, + Status: req.Status, + ExpectedRevision: req.ExpectedVersion, + MustExist: true, + }) + if err != nil { + return Plan{}, sharedError("update", req.Ref.Name, err) + } + return sharedPlan(p), nil +} + +func (s *service) SetStatus(ctx context.Context, req SetStatusRequest) (Plan, error) { + if err := checkSharedMutation("set_status", req.Ref); err != nil { + return Plan{}, err + } + if req.Status == "" { + return Plan{}, &ValidationError{Message: "status must not be empty"} + } + p, err := s.storage.Upsert(ctx, plan.UpsertRequest{ + Name: req.Ref.Name, + Status: &req.Status, + ExpectedRevision: req.ExpectedVersion, + MustExist: true, + }) + if err != nil { + return Plan{}, sharedError("set_status", req.Ref.Name, err) + } + return sharedPlan(p), nil +} + +func (s *service) Delete(ctx context.Context, req DeleteRequest) error { + if err := checkSharedMutation("delete", req.Ref); err != nil { + return err + } + deleted, err := s.storage.Delete(ctx, req.Ref.Name, req.ExpectedVersion) + if err != nil { + return sharedError("delete", req.Ref.Name, err) + } + if !deleted { + return &NotFoundError{Scope: ScopeShared, Name: req.Ref.Name} + } + return nil +} + +func (s *service) Export(ctx context.Context, req ExportRequest) (ExportResult, error) { + if req.Path == "" { + return ExportResult{}, &ValidationError{Message: "path must not be empty"} + } + p, err := s.Get(ctx, req.Ref) + if err != nil { + return ExportResult{}, err + } + if err := writeExportFile(p.Scope, req.Path, p.Content); err != nil { + return ExportResult{}, err + } + return ExportResult{ + Scope: p.Scope, + Name: p.Name, + Path: req.Path, + Version: p.Version, + BytesWritten: len(p.Content), + }, nil +} + +func (s *service) getShared(ctx context.Context, name string) (Plan, error) { + if err := plan.ValidateName(name); err != nil { + return Plan{}, &ValidationError{Message: err.Error()} + } + p, ok, err := s.storage.Get(ctx, name) + if err != nil { + return Plan{}, sharedError("get", name, err) + } + if !ok { + return Plan{}, &NotFoundError{Scope: ScopeShared, Name: name} + } + return sharedPlan(p), nil +} + +func (s *service) getSession(sessionID string) (Plan, error) { + path, err := sessionplan.Path(s.sessionDir, sessionID) + if err != nil { + return Plan{}, sessionError("get", sessionID, err) + } + content, modTime, err := readSessionPlanFile(sessionID, path) + if err != nil { + return Plan{}, err + } + p := sessionPlan(sessionID, path, modTime) + p.Content = content + return p, nil +} + +// readSessionPlanFile reads a session plan's markdown bounded by the same +// content cap as shared plans, so a hostile or damaged file in the session +// plans directory can never cause unbounded allocation. Every check runs on +// the opened descriptor, never on the path, and the open itself is hang-safe +// (see plan.OpenContentFile). A plan that exists but is not a readable plan +// file — a directory, a device, or oversized content — is a *CorruptError so +// it is never mistaken for a missing plan; genuine I/O failures remain +// *StorageError. +func readSessionPlanFile(sessionID, path string) (content string, modTime time.Time, err error) { + f, err := plan.OpenContentFile(path) + if errors.Is(err, fs.ErrNotExist) { + return "", time.Time{}, &NotFoundError{Scope: ScopeSession, Name: sessionID} + } + if err != nil { + return "", time.Time{}, &StorageError{Scope: ScopeSession, Op: "get", Err: err} + } + defer f.Close() + + info, err := f.Stat() + if err != nil { + return "", time.Time{}, &StorageError{Scope: ScopeSession, Op: "get", Err: err} + } + if !info.Mode().IsRegular() { + return "", time.Time{}, &CorruptError{Scope: ScopeSession, Name: sessionID, Err: fmt.Errorf("%s is not a regular file", path)} + } + + // Read one byte past the cap so an over-cap file is detected without + // trusting a stat size that could change under us. + data, err := io.ReadAll(io.LimitReader(f, plan.MaxPlanContentSize+1)) + if err != nil { + return "", time.Time{}, &StorageError{Scope: ScopeSession, Op: "get", Err: err} + } + if len(data) > plan.MaxPlanContentSize { + return "", time.Time{}, &CorruptError{Scope: ScopeSession, Name: sessionID, Err: fmt.Errorf("plan file exceeds %d bytes", plan.MaxPlanContentSize)} + } + return string(data), info.ModTime().UTC(), nil +} + +// statSessionPlan returns list metadata for the session's plan without +// reading its content. A missing plan is (_, false, "", nil); an existing but +// unreadable one is surfaced as a warning, mirroring how List reports +// unreadable shared plans. +func (s *service) statSessionPlan(sessionID string) (p Plan, ok bool, warning string, err error) { + path, err := sessionplan.Path(s.sessionDir, sessionID) + if err != nil { + return Plan{}, false, "", sessionError("list", sessionID, err) + } + info, err := os.Stat(path) + switch { + case errors.Is(err, fs.ErrNotExist): + return Plan{}, false, "", nil + case err != nil: + return Plan{}, false, fmt.Sprintf("skipped session plan %q: %v", sessionID, err), nil + case info.IsDir(): + return Plan{}, false, fmt.Sprintf("skipped session plan %q: %s is a directory", sessionID, path), nil + case !info.Mode().IsRegular(): + return Plan{}, false, fmt.Sprintf("skipped session plan %q: %s is not a regular file", sessionID, path), nil + case info.Size() > plan.MaxPlanContentSize: + return Plan{}, false, fmt.Sprintf("skipped session plan %q: plan file exceeds %d bytes", sessionID, plan.MaxPlanContentSize), nil + } + return sessionPlan(sessionID, path, info.ModTime().UTC()), true, "", nil +} + +// validateContent gates mutation content: it must be non-empty and within +// the advertised content cap, refused as invalid input before the storage is +// touched. Content of exactly the cap is accepted. +func validateContent(content string) error { + if content == "" { + return &ValidationError{Message: "content must not be empty"} + } + if len(content) > plan.MaxPlanContentSize { + return &ValidationError{Message: fmt.Sprintf("content exceeds the maximum plan size (%d bytes; max %d)", len(content), plan.MaxPlanContentSize)} + } + return nil +} + +// checkSharedMutation gates every mutation: session plans are refused with a +// typed, actionable error, unknown scopes are invalid input, and shared names +// are validated with the storage's canonical rule before touching it. +func checkSharedMutation(op string, ref Ref) error { + switch ref.Scope { + case ScopeShared: + if err := plan.ValidateName(ref.Name); err != nil { + return &ValidationError{Message: err.Error()} + } + return nil + case ScopeSession: + return &UnsupportedError{Scope: ScopeSession, Op: op, Reason: sessionMutationReason} + default: + return invalidScopeError(ref.Scope) + } +} + +// sharedError maps a plan.Storage failure to this package's typed errors by +// the storage contract's own types, never by matching error text. +func sharedError(op, name string, err error) error { + var conflict *plan.VersionConflictError + if errors.As(err, &conflict) { + return &ConflictError{Name: conflict.Name, Expected: conflict.Expected, Current: conflict.Current} + } + var corrupt *plan.CorruptPlanError + if errors.As(err, &corrupt) { + return &CorruptError{Scope: ScopeShared, Name: name, Err: err} + } + if errors.Is(err, plan.ErrPlanNotFound) { + return &NotFoundError{Scope: ScopeShared, Name: name} + } + return &StorageError{Scope: ScopeShared, Op: op, Err: err} +} + +func sessionError(op, sessionID string, err error) error { + switch { + case errors.Is(err, sessionplan.ErrPlanNotFound): + return &NotFoundError{Scope: ScopeSession, Name: sessionID} + case errors.Is(err, sessionplan.ErrInvalidSessionID): + return &ValidationError{Message: err.Error()} + default: + return &StorageError{Scope: ScopeSession, Op: op, Err: err} + } +} + +func invalidScopeError(scope Scope) error { + return &ValidationError{Message: fmt.Sprintf("invalid plan scope %q: use %q or %q", scope, ScopeShared, ScopeSession)} +} + +func sharedPlan(p plan.Plan) Plan { + return Plan{ + Scope: ScopeShared, + Name: p.Name, + Title: p.Title, + Author: p.Author, + Status: p.Status, + Content: p.Content, + Version: new(p.Revision), + UpdatedAt: parseUpdatedAt(p.UpdatedAt), + } +} + +func sessionPlan(sessionID, path string, modTime time.Time) Plan { + return Plan{ + Scope: ScopeSession, + Name: sessionID, + SessionID: sessionID, + UpdatedAt: modTime, + Path: path, + } +} + +// parseUpdatedAt tolerates a missing or malformed stored timestamp: it is +// display metadata, so it degrades to the zero time instead of failing a read. +func parseUpdatedAt(s string) time.Time { + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return time.Time{} + } + return t +} + +// writeExportFile mirrors the plan toolset's export behaviour: parent +// directories are created and the write is atomic (temp + rename), so a +// reader never observes a partial export. +func writeExportFile(scope Scope, path, content string) error { + clean := filepath.Clean(path) + if info, err := os.Stat(clean); err == nil && info.IsDir() { + return &ValidationError{Message: fmt.Sprintf("path %q is a directory, not a file", path)} + } + if err := os.MkdirAll(filepath.Dir(clean), 0o700); err != nil { + return &StorageError{Scope: scope, Op: "export", Err: err} + } + if err := atomicfile.Write(clean, strings.NewReader(content), 0o600); err != nil { + return &StorageError{Scope: scope, Op: "export", Err: err} + } + return nil +} diff --git a/pkg/plans/service_test.go b/pkg/plans/service_test.go new file mode 100644 index 0000000000..8cdd3e0783 --- /dev/null +++ b/pkg/plans/service_test.go @@ -0,0 +1,1064 @@ +package plans + +import ( + "context" + "errors" + "os" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/tools/builtin/plan" + "github.com/docker/docker-agent/pkg/tools/builtin/sessionplan" +) + +// newTestService builds a Service over a fresh filesystem-backed shared +// storage and an isolated session-plans directory, returning both directories +// for tests that plant files directly. +func newTestService(t *testing.T) (svc Service, sharedDir, sessionDir string) { + t.Helper() + sharedDir = t.TempDir() + sessionDir = t.TempDir() + svc = NewService(plan.NewFilesystemStorage(sharedDir), WithSessionDir(sessionDir)) + return svc, sharedDir, sessionDir +} + +func mustCreate(t *testing.T, svc Service, name, content string) Plan { + t.Helper() + p, err := svc.Create(t.Context(), CreateRequest{Ref: SharedRef(name), Content: content}) + require.NoError(t, err) + return p +} + +func writeSessionPlan(t *testing.T, dir, sessionID, content string) string { + t.Helper() + path, err := sessionplan.WriteContent(dir, sessionID, content) + require.NoError(t, err) + return path +} + +// --- List -------------------------------------------------------------------- + +func TestService_ListEmpty(t *testing.T) { + t.Parallel() + svc, _, _ := newTestService(t) + + result, err := svc.List(t.Context(), ListOptions{}) + require.NoError(t, err) + assert.NotNil(t, result.Plans) + assert.Empty(t, result.Plans) + assert.Empty(t, result.Warnings) +} + +func TestService_ListEmptyWithSessionID(t *testing.T) { + t.Parallel() + svc, _, _ := newTestService(t) + + // A missing session plan is not an error during List. + result, err := svc.List(t.Context(), ListOptions{SessionID: "sess-1"}) + require.NoError(t, err) + assert.Empty(t, result.Plans) + assert.Empty(t, result.Warnings) +} + +func TestService_ListSharedMetadataOnly(t *testing.T) { + t.Parallel() + svc, _, _ := newTestService(t) + + _, err := svc.Create(t.Context(), CreateRequest{Ref: SharedRef("beta"), Content: "b", Status: "draft"}) + require.NoError(t, err) + _, err = svc.Create(t.Context(), CreateRequest{Ref: SharedRef("alpha"), Content: "a", Title: "Alpha", Author: "alice"}) + require.NoError(t, err) + + result, err := svc.List(t.Context(), ListOptions{}) + require.NoError(t, err) + require.Len(t, result.Plans, 2) + + // Sorted by name, metadata carried through, content omitted. + first := result.Plans[0] + assert.Equal(t, ScopeShared, first.Scope) + assert.Equal(t, "alpha", first.Name) + assert.Equal(t, "Alpha", first.Title) + assert.Equal(t, "alice", first.Author) + assert.Empty(t, first.Content) + require.NotNil(t, first.Version) + assert.Equal(t, 1, *first.Version) + assert.False(t, first.UpdatedAt.IsZero()) + assert.WithinDuration(t, time.Now(), first.UpdatedAt, time.Minute) + + assert.Equal(t, "beta", result.Plans[1].Name) + assert.Equal(t, "draft", result.Plans[1].Status) +} + +func TestService_ListIncludesCurrentSessionPlanFirst(t *testing.T) { + t.Parallel() + svc, _, sessionDir := newTestService(t) + mustCreate(t, svc, "alpha", "a") + path := writeSessionPlan(t, sessionDir, "sess-1", "# session plan") + + result, err := svc.List(t.Context(), ListOptions{SessionID: "sess-1"}) + require.NoError(t, err) + require.Len(t, result.Plans, 2) + + sess := result.Plans[0] + assert.Equal(t, ScopeSession, sess.Scope) + assert.Equal(t, "sess-1", sess.Name) + assert.Equal(t, "sess-1", sess.SessionID) + assert.Equal(t, path, sess.Path) + assert.Nil(t, sess.Version, "session plans have no version") + assert.Empty(t, sess.Content, "List is metadata only") + + // The timestamp comes from file metadata. + info, err := os.Stat(path) + require.NoError(t, err) + assert.True(t, sess.UpdatedAt.Equal(info.ModTime().UTC())) + + assert.Equal(t, ScopeShared, result.Plans[1].Scope) + assert.Equal(t, "alpha", result.Plans[1].Name) +} + +func TestService_ListConsultsOnlySuppliedSession(t *testing.T) { + t.Parallel() + svc, _, sessionDir := newTestService(t) + writeSessionPlan(t, sessionDir, "current", "mine") + writeSessionPlan(t, sessionDir, "stale-other", "left behind") + + result, err := svc.List(t.Context(), ListOptions{SessionID: "current"}) + require.NoError(t, err) + require.Len(t, result.Plans, 1) + assert.Equal(t, "current", result.Plans[0].Name) +} + +func TestService_ListInvalidSessionID(t *testing.T) { + t.Parallel() + svc, _, _ := newTestService(t) + + _, err := svc.List(t.Context(), ListOptions{SessionID: "../escape"}) + var invalid *ValidationError + require.ErrorAs(t, err, &invalid) +} + +func TestService_ListWarnsOnCorruptShared(t *testing.T) { + t.Parallel() + svc, sharedDir, _ := newTestService(t) + mustCreate(t, svc, "good", "ok") + require.NoError(t, os.WriteFile(filepath.Join(sharedDir, "bad.json"), []byte("{nope"), 0o600)) + + result, err := svc.List(t.Context(), ListOptions{}) + require.NoError(t, err) + require.Len(t, result.Plans, 1) + assert.Equal(t, "good", result.Plans[0].Name) + require.Len(t, result.Warnings, 1) + assert.Contains(t, result.Warnings[0], "bad") +} + +func TestService_ListStorageFailure(t *testing.T) { + t.Parallel() + base := errors.New("backend boom") + svc := NewService(failingStorage{err: base}, WithSessionDir(t.TempDir())) + + _, err := svc.List(t.Context(), ListOptions{}) + var storageErr *StorageError + require.ErrorAs(t, err, &storageErr) + assert.Equal(t, ScopeShared, storageErr.Scope) + assert.Equal(t, "list", storageErr.Op) + assert.ErrorIs(t, err, base) +} + +// TestService_ListSortsSharedPlansFromUnsortedStorage pins the documented +// sort order independently of the backend: an injected Storage that lists in +// arbitrary order must still yield a name-sorted listing, with the session +// plan first. +func TestService_ListSortsSharedPlansFromUnsortedStorage(t *testing.T) { + t.Parallel() + sessionDir := t.TempDir() + svc := NewService(unsortedStorage{names: []string{"zeta", "alpha", "mid"}}, WithSessionDir(sessionDir)) + writeSessionPlan(t, sessionDir, "sess-1", "# session plan") + + result, err := svc.List(t.Context(), ListOptions{SessionID: "sess-1"}) + require.NoError(t, err) + require.Len(t, result.Plans, 4) + + names := make([]string, 0, len(result.Plans)) + for _, p := range result.Plans { + names = append(names, p.Name) + } + assert.Equal(t, []string{"sess-1", "alpha", "mid", "zeta"}, names, + "the session plan comes first, shared plans sorted by name regardless of storage order") +} + +// --- Get --------------------------------------------------------------------- + +func TestService_GetShared(t *testing.T) { + t.Parallel() + svc, _, _ := newTestService(t) + _, err := svc.Create(t.Context(), CreateRequest{ + Ref: SharedRef("release"), Content: "the body", Title: "Release", Author: "alice", Status: "draft", + }) + require.NoError(t, err) + + p, err := svc.Get(t.Context(), SharedRef("release")) + require.NoError(t, err) + assert.Equal(t, ScopeShared, p.Scope) + assert.Equal(t, "release", p.Name) + assert.Equal(t, "the body", p.Content) + assert.Equal(t, "Release", p.Title) + assert.Equal(t, "alice", p.Author) + assert.Equal(t, "draft", p.Status) + require.NotNil(t, p.Version) + assert.Equal(t, 1, *p.Version) + assert.False(t, p.UpdatedAt.IsZero()) + assert.Empty(t, p.SessionID) + assert.Empty(t, p.Path) +} + +func TestService_GetSharedNotFound(t *testing.T) { + t.Parallel() + svc, _, _ := newTestService(t) + + _, err := svc.Get(t.Context(), SharedRef("missing")) + var notFound *NotFoundError + require.ErrorAs(t, err, ¬Found) + assert.Equal(t, ScopeShared, notFound.Scope) + assert.Equal(t, "missing", notFound.Name) +} + +func TestService_GetSharedInvalidName(t *testing.T) { + t.Parallel() + svc, _, _ := newTestService(t) + + for _, name := range []string{"", "UPPER", "../escape", "a/b", "has space"} { + _, err := svc.Get(t.Context(), SharedRef(name)) + var invalid *ValidationError + require.ErrorAs(t, err, &invalid, "name %q should be invalid", name) + assert.Contains(t, invalid.Message, "invalid plan name") + } +} + +func TestService_GetSharedCorrupt(t *testing.T) { + t.Parallel() + svc, sharedDir, _ := newTestService(t) + require.NoError(t, os.WriteFile(filepath.Join(sharedDir, "broken.json"), []byte("{not json"), 0o600)) + + _, err := svc.Get(t.Context(), SharedRef("broken")) + var corrupt *CorruptError + require.ErrorAs(t, err, &corrupt) + assert.Equal(t, ScopeShared, corrupt.Scope) + assert.Equal(t, "broken", corrupt.Name) + // The storage's typed cause is preserved through the wrap. + var cause *plan.CorruptPlanError + require.ErrorAs(t, err, &cause) + + var notFound *NotFoundError + require.NotErrorAs(t, err, ¬Found, "a corrupt plan must not read as missing") +} + +func TestService_GetSession(t *testing.T) { + t.Parallel() + svc, _, sessionDir := newTestService(t) + path := writeSessionPlan(t, sessionDir, "sess-1", "# my plan\nstep 1\n") + + p, err := svc.Get(t.Context(), SessionRef("sess-1")) + require.NoError(t, err) + assert.Equal(t, ScopeSession, p.Scope) + assert.Equal(t, "sess-1", p.Name) + assert.Equal(t, "sess-1", p.SessionID) + assert.Equal(t, "# my plan\nstep 1\n", p.Content) + assert.Equal(t, path, p.Path) + assert.Nil(t, p.Version, "session plans must not expose a version") + assert.Empty(t, p.Status) + + info, err := os.Stat(path) + require.NoError(t, err) + assert.True(t, p.UpdatedAt.Equal(info.ModTime().UTC())) +} + +func TestService_GetSessionNotFound(t *testing.T) { + t.Parallel() + svc, _, _ := newTestService(t) + + // Missing is not-found during Get, unlike List where it is skipped. + _, err := svc.Get(t.Context(), SessionRef("ghost")) + var notFound *NotFoundError + require.ErrorAs(t, err, ¬Found) + assert.Equal(t, ScopeSession, notFound.Scope) + assert.Equal(t, "ghost", notFound.Name) +} + +func TestService_GetSessionInvalidID(t *testing.T) { + t.Parallel() + svc, _, _ := newTestService(t) + + for _, id := range []string{"", "../escape", "a/b"} { + _, err := svc.Get(t.Context(), SessionRef(id)) + var invalid *ValidationError + require.ErrorAs(t, err, &invalid, "session ID %q should be invalid", id) + } +} + +// TestService_GetSessionOversized proves the host read of session-plan +// markdown is bounded: a file past the shared content cap is a typed +// *CorruptError — the plan exists but cannot be treated as a plan — never an +// unbounded read or a not-found. +func TestService_GetSessionOversized(t *testing.T) { + t.Parallel() + svc, _, sessionDir := newTestService(t) + require.NoError(t, os.MkdirAll(sessionDir, 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(sessionDir, "sess-1.md"), make([]byte, plan.MaxPlanContentSize+1), 0o600)) + + _, err := svc.Get(t.Context(), SessionRef("sess-1")) + var corrupt *CorruptError + require.ErrorAs(t, err, &corrupt) + assert.Equal(t, ScopeSession, corrupt.Scope) + assert.Equal(t, "sess-1", corrupt.Name) + assert.Contains(t, err.Error(), "exceeds") + + var notFound *NotFoundError + require.NotErrorAs(t, err, ¬Found, "an oversized plan must not read as missing") + + // Export goes through Get and must refuse the same way, writing nothing. + dest := filepath.Join(t.TempDir(), "export.md") + _, err = svc.Export(t.Context(), ExportRequest{Ref: SessionRef("sess-1"), Path: dest}) + require.ErrorAs(t, err, &corrupt) + assert.NoFileExists(t, dest) + + // List skips it with a warning, mirroring unreadable shared plans. + result, err := svc.List(t.Context(), ListOptions{SessionID: "sess-1"}) + require.NoError(t, err) + assert.Empty(t, result.Plans) + require.Len(t, result.Warnings, 1) + assert.Contains(t, result.Warnings[0], "sess-1") + assert.Contains(t, result.Warnings[0], "exceeds") +} + +// TestService_GetSessionAtSizeCap proves the bound is exact: a session plan +// of exactly the content cap reads back whole. +func TestService_GetSessionAtSizeCap(t *testing.T) { + t.Parallel() + svc, _, sessionDir := newTestService(t) + content := strings.Repeat("a", plan.MaxPlanContentSize) + writeSessionPlan(t, sessionDir, "sess-1", content) + + p, err := svc.Get(t.Context(), SessionRef("sess-1")) + require.NoError(t, err) + assert.Len(t, p.Content, plan.MaxPlanContentSize) + assert.False(t, p.UpdatedAt.IsZero()) +} + +// TestService_GetSessionNotRegularFile proves a directory squatting on the +// session plan path is a *CorruptError on Get and a warning on List. +func TestService_GetSessionNotRegularFile(t *testing.T) { + t.Parallel() + svc, _, sessionDir := newTestService(t) + require.NoError(t, os.MkdirAll(filepath.Join(sessionDir, "sess-1.md"), 0o700)) + + _, err := svc.Get(t.Context(), SessionRef("sess-1")) + var corrupt *CorruptError + require.ErrorAs(t, err, &corrupt) + assert.Equal(t, ScopeSession, corrupt.Scope) + + result, err := svc.List(t.Context(), ListOptions{SessionID: "sess-1"}) + require.NoError(t, err) + assert.Empty(t, result.Plans) + require.Len(t, result.Warnings, 1) + assert.Contains(t, result.Warnings[0], "directory") +} + +func TestService_GetUnknownScope(t *testing.T) { + t.Parallel() + svc, _, _ := newTestService(t) + + _, err := svc.Get(t.Context(), Ref{Scope: "bogus", Name: "p"}) + var invalid *ValidationError + require.ErrorAs(t, err, &invalid) + assert.Contains(t, invalid.Message, "scope") +} + +// --- Create ------------------------------------------------------------------ + +func TestService_Create(t *testing.T) { + t.Parallel() + svc, _, _ := newTestService(t) + + p, err := svc.Create(t.Context(), CreateRequest{ + Ref: SharedRef("release"), Content: "v1", Title: "T", Author: "alice", Status: "draft", + }) + require.NoError(t, err) + assert.Equal(t, ScopeShared, p.Scope) + assert.Equal(t, "release", p.Name) + assert.Equal(t, "v1", p.Content) + assert.Equal(t, "T", p.Title) + assert.Equal(t, "alice", p.Author) + assert.Equal(t, "draft", p.Status) + require.NotNil(t, p.Version) + assert.Equal(t, 1, *p.Version) +} + +func TestService_CreateIsCreateOnly(t *testing.T) { + t.Parallel() + svc, _, _ := newTestService(t) + mustCreate(t, svc, "p", "original") + + // A second create conflicts instead of overwriting, exposing the + // current version so the frontend can switch to an update. + _, err := svc.Create(t.Context(), CreateRequest{Ref: SharedRef("p"), Content: "clobber"}) + var conflict *ConflictError + require.ErrorAs(t, err, &conflict) + assert.Equal(t, "p", conflict.Name) + assert.Equal(t, 0, conflict.Expected) + assert.Equal(t, 1, conflict.Current) + + // A create conflict cannot be forced or retried; the message must point + // at a different name or an update instead. + assert.Contains(t, conflict.Error(), "already exists") + assert.Contains(t, conflict.Error(), "update") + assert.NotContains(t, conflict.Error(), "force") + + got, err := svc.Get(t.Context(), SharedRef("p")) + require.NoError(t, err) + assert.Equal(t, "original", got.Content) +} + +// TestService_CreateConflictsWithExistingRevisionZeroFile proves create is +// existence-driven, not revision-driven: a valid stored plan whose revision +// field is omitted (reading back as revision 0) still conflicts and stays +// byte-identical, and creating under a fresh name keeps working. +func TestService_CreateConflictsWithExistingRevisionZeroFile(t *testing.T) { + t.Parallel() + svc, sharedDir, _ := newTestService(t) + + original := `{"name":"planted","content":"precious content"}` + path := filepath.Join(sharedDir, "planted.json") + require.NoError(t, os.WriteFile(path, []byte(original), 0o600)) + + _, err := svc.Create(t.Context(), CreateRequest{Ref: SharedRef("planted"), Content: "clobber"}) + var conflict *ConflictError + require.ErrorAs(t, err, &conflict) + assert.Equal(t, "planted", conflict.Name) + assert.Equal(t, 0, conflict.Expected) + assert.Equal(t, 0, conflict.Current) + assert.Contains(t, conflict.Error(), "already exists") + + data, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, original, string(data), "the refused create must leave the existing file byte-identical") + + // A normal create is unaffected by the guard. + p, err := svc.Create(t.Context(), CreateRequest{Ref: SharedRef("fresh"), Content: "new plan"}) + require.NoError(t, err) + assert.Equal(t, 1, *p.Version) +} + +func TestService_CreateValidation(t *testing.T) { + t.Parallel() + svc, _, _ := newTestService(t) + + _, err := svc.Create(t.Context(), CreateRequest{Ref: SharedRef("p"), Content: ""}) + var invalid *ValidationError + require.ErrorAs(t, err, &invalid) + assert.Contains(t, invalid.Message, "content must not be empty") + + for _, name := range []string{"", "UPPER", "../escape"} { + _, err := svc.Create(t.Context(), CreateRequest{Ref: SharedRef(name), Content: "x"}) + require.ErrorAs(t, err, &invalid, "name %q should be invalid", name) + assert.Contains(t, invalid.Message, "invalid plan name") + } +} + +// TestService_ContentSizeCap proves the advertised 10 MiB content cap end to +// end: exactly the cap is accepted by Create and Update, one byte over is a +// typed *ValidationError before the storage is touched. +func TestService_ContentSizeCap(t *testing.T) { + t.Parallel() + svc, _, _ := newTestService(t) + + atCap := strings.Repeat("a", plan.MaxPlanContentSize) + p, err := svc.Create(t.Context(), CreateRequest{Ref: SharedRef("big"), Content: atCap}) + require.NoError(t, err, "content of exactly the cap must be accepted") + assert.Equal(t, 1, *p.Version) + + got, err := svc.Get(t.Context(), SharedRef("big")) + require.NoError(t, err) + assert.Len(t, got.Content, plan.MaxPlanContentSize) + + overCap := atCap + "b" + var invalid *ValidationError + _, err = svc.Create(t.Context(), CreateRequest{Ref: SharedRef("big2"), Content: overCap}) + require.ErrorAs(t, err, &invalid) + assert.Contains(t, invalid.Message, "maximum plan size") + + _, err = svc.Update(t.Context(), UpdateRequest{Ref: SharedRef("big"), Content: overCap, ExpectedVersion: new(1)}) + require.ErrorAs(t, err, &invalid) + + got, err = svc.Get(t.Context(), SharedRef("big")) + require.NoError(t, err) + assert.Equal(t, 1, *got.Version, "the refused update must not touch the plan") +} + +// TestService_LargeMetadataAccepted proves metadata stays free-form through +// the service: title, author, and status beyond 4 KiB are accepted on Create, +// SetStatus, and content-only Update — and preserved — because only content +// is size-capped (issue #3844: labels have no fixed vocabulary or size). +func TestService_LargeMetadataAccepted(t *testing.T) { + t.Parallel() + svc, _, _ := newTestService(t) + + bigTitle := strings.Repeat("t", 5<<10) + bigAuthor := strings.Repeat("a", 5<<10) + bigStatus := strings.Repeat("s", 5<<10) + + p, err := svc.Create(t.Context(), CreateRequest{ + Ref: SharedRef("p"), Content: "body", Title: bigTitle, Author: bigAuthor, Status: bigStatus, + }) + require.NoError(t, err, "metadata beyond 4 KiB must be accepted") + assert.Equal(t, bigStatus, p.Status) + + biggerStatus := strings.Repeat("z", 6<<10) + p, err = svc.SetStatus(t.Context(), SetStatusRequest{Ref: SharedRef("p"), Status: biggerStatus, ExpectedVersion: new(1)}) + require.NoError(t, err, "a large status must be accepted") + assert.Equal(t, biggerStatus, p.Status) + + // A content-only update preserves the large labels. + p, err = svc.Update(t.Context(), UpdateRequest{Ref: SharedRef("p"), Content: "new body", ExpectedVersion: new(2)}) + require.NoError(t, err) + assert.Equal(t, bigTitle, p.Title) + assert.Equal(t, bigAuthor, p.Author) + assert.Equal(t, biggerStatus, p.Status) + + got, err := svc.Get(t.Context(), SharedRef("p")) + require.NoError(t, err) + assert.Equal(t, bigTitle, got.Title) + assert.Equal(t, biggerStatus, got.Status) +} + +// --- Update ------------------------------------------------------------------ + +func TestService_UpdatePreservesOmittedMetadata(t *testing.T) { + t.Parallel() + svc, _, _ := newTestService(t) + _, err := svc.Create(t.Context(), CreateRequest{ + Ref: SharedRef("p"), Content: "v1", Title: "Original", Author: "alice", Status: "draft", + }) + require.NoError(t, err) + + p, err := svc.Update(t.Context(), UpdateRequest{ + Ref: SharedRef("p"), Content: "v2", Author: new("bob"), ExpectedVersion: new(1), + }) + require.NoError(t, err) + assert.Equal(t, "v2", p.Content) + assert.Equal(t, "Original", p.Title, "nil title pointer preserves the previous value") + assert.Equal(t, "bob", p.Author) + assert.Equal(t, "draft", p.Status) + require.NotNil(t, p.Version) + assert.Equal(t, 2, *p.Version) + + // An explicit empty string overwrites rather than preserves. + p, err = svc.Update(t.Context(), UpdateRequest{ + Ref: SharedRef("p"), Content: "v3", Title: new(""), ExpectedVersion: new(2), + }) + require.NoError(t, err) + assert.Empty(t, p.Title) +} + +func TestService_UpdateStaleConflictPreservesNewerContent(t *testing.T) { + t.Parallel() + svc, _, _ := newTestService(t) + mustCreate(t, svc, "p", "v1") + _, err := svc.Update(t.Context(), UpdateRequest{Ref: SharedRef("p"), Content: "v2", ExpectedVersion: new(1)}) + require.NoError(t, err) + + _, err = svc.Update(t.Context(), UpdateRequest{Ref: SharedRef("p"), Content: "stale", ExpectedVersion: new(1)}) + var conflict *ConflictError + require.ErrorAs(t, err, &conflict) + assert.Equal(t, 1, conflict.Expected) + assert.Equal(t, 2, conflict.Current, "the conflict must expose the current version") + + got, err := svc.Get(t.Context(), SharedRef("p")) + require.NoError(t, err) + assert.Equal(t, "v2", got.Content, "the losing write must not touch the plan") + assert.Equal(t, 2, *got.Version) +} + +func TestService_UpdateForceReplacesUnconditionally(t *testing.T) { + t.Parallel() + svc, _, _ := newTestService(t) + mustCreate(t, svc, "p", "v1") + _, err := svc.Update(t.Context(), UpdateRequest{Ref: SharedRef("p"), Content: "v2", ExpectedVersion: new(1)}) + require.NoError(t, err) + + // nil expected version is the deliberate force policy. + p, err := svc.Update(t.Context(), UpdateRequest{Ref: SharedRef("p"), Content: "forced"}) + require.NoError(t, err) + assert.Equal(t, "forced", p.Content) + assert.Equal(t, 3, *p.Version) +} + +func TestService_UpdateNotFound(t *testing.T) { + t.Parallel() + svc, _, _ := newTestService(t) + + var notFound *NotFoundError + + // Update never creates, not even when forced. + _, err := svc.Update(t.Context(), UpdateRequest{Ref: SharedRef("ghost"), Content: "x"}) + require.ErrorAs(t, err, ¬Found) + + _, err = svc.Update(t.Context(), UpdateRequest{Ref: SharedRef("ghost"), Content: "x", ExpectedVersion: new(1)}) + require.ErrorAs(t, err, ¬Found) + + _, err = svc.Get(t.Context(), SharedRef("ghost")) + require.ErrorAs(t, err, ¬Found, "the failed update must not have created the plan") +} + +func TestService_UpdateEmptyContent(t *testing.T) { + t.Parallel() + svc, _, _ := newTestService(t) + mustCreate(t, svc, "p", "v1") + + _, err := svc.Update(t.Context(), UpdateRequest{Ref: SharedRef("p"), Content: ""}) + var invalid *ValidationError + require.ErrorAs(t, err, &invalid) + assert.Contains(t, invalid.Message, "content must not be empty") +} + +// --- SetStatus --------------------------------------------------------------- + +func TestService_SetStatusFreeForm(t *testing.T) { + t.Parallel() + svc, _, _ := newTestService(t) + mustCreate(t, svc, "p", "body") + + // Status is free-form: any non-empty string is accepted. + for i, status := range []string{"in-progress", "🚀 shipping", "waiting on review/QA"} { + p, err := svc.SetStatus(t.Context(), SetStatusRequest{Ref: SharedRef("p"), Status: status, ExpectedVersion: new(i + 1)}) + require.NoError(t, err) + assert.Equal(t, status, p.Status) + assert.Equal(t, i+2, *p.Version, "setting the status is a write and bumps the version") + assert.Equal(t, "body", p.Content, "the body is preserved") + } +} + +func TestService_SetStatusStaleConflict(t *testing.T) { + t.Parallel() + svc, _, _ := newTestService(t) + _, err := svc.Create(t.Context(), CreateRequest{Ref: SharedRef("p"), Content: "body", Status: "draft"}) + require.NoError(t, err) + _, err = svc.Update(t.Context(), UpdateRequest{Ref: SharedRef("p"), Content: "v2", ExpectedVersion: new(1)}) + require.NoError(t, err) + + _, err = svc.SetStatus(t.Context(), SetStatusRequest{Ref: SharedRef("p"), Status: "done", ExpectedVersion: new(1)}) + var conflict *ConflictError + require.ErrorAs(t, err, &conflict) + assert.Equal(t, 2, conflict.Current) + + got, err := svc.Get(t.Context(), SharedRef("p")) + require.NoError(t, err) + assert.Equal(t, "draft", got.Status, "the losing status write must not touch the plan") +} + +func TestService_SetStatusValidation(t *testing.T) { + t.Parallel() + svc, _, _ := newTestService(t) + mustCreate(t, svc, "p", "body") + + _, err := svc.SetStatus(t.Context(), SetStatusRequest{Ref: SharedRef("p"), Status: ""}) + var invalid *ValidationError + require.ErrorAs(t, err, &invalid) + assert.Contains(t, invalid.Message, "status must not be empty") +} + +func TestService_SetStatusNotFound(t *testing.T) { + t.Parallel() + svc, _, _ := newTestService(t) + + _, err := svc.SetStatus(t.Context(), SetStatusRequest{Ref: SharedRef("ghost"), Status: "done"}) + var notFound *NotFoundError + require.ErrorAs(t, err, ¬Found) +} + +// --- Delete ------------------------------------------------------------------ + +func TestService_DeleteWithMatchingVersion(t *testing.T) { + t.Parallel() + svc, _, _ := newTestService(t) + mustCreate(t, svc, "p", "body") + + require.NoError(t, svc.Delete(t.Context(), DeleteRequest{Ref: SharedRef("p"), ExpectedVersion: new(1)})) + + _, err := svc.Get(t.Context(), SharedRef("p")) + var notFound *NotFoundError + require.ErrorAs(t, err, ¬Found) +} + +func TestService_DeleteStaleConflictPreservesPlan(t *testing.T) { + t.Parallel() + svc, _, _ := newTestService(t) + mustCreate(t, svc, "p", "v1") + _, err := svc.Update(t.Context(), UpdateRequest{Ref: SharedRef("p"), Content: "v2", ExpectedVersion: new(1)}) + require.NoError(t, err) + + err = svc.Delete(t.Context(), DeleteRequest{Ref: SharedRef("p"), ExpectedVersion: new(1)}) + var conflict *ConflictError + require.ErrorAs(t, err, &conflict) + assert.Equal(t, 2, conflict.Current) + + got, err := svc.Get(t.Context(), SharedRef("p")) + require.NoError(t, err) + assert.Equal(t, "v2", got.Content, "the conflicting delete must leave the plan in place") +} + +func TestService_DeleteForce(t *testing.T) { + t.Parallel() + svc, _, _ := newTestService(t) + mustCreate(t, svc, "p", "v1") + + // nil expected version deletes unconditionally. + require.NoError(t, svc.Delete(t.Context(), DeleteRequest{Ref: SharedRef("p")})) +} + +func TestService_DeleteNotFound(t *testing.T) { + t.Parallel() + svc, _, _ := newTestService(t) + + err := svc.Delete(t.Context(), DeleteRequest{Ref: SharedRef("ghost")}) + var notFound *NotFoundError + require.ErrorAs(t, err, ¬Found) +} + +func TestService_DeleteCorrupt(t *testing.T) { + t.Parallel() + svc, sharedDir, _ := newTestService(t) + require.NoError(t, os.WriteFile(filepath.Join(sharedDir, "broken.json"), []byte("{nope"), 0o600)) + + // A guarded delete cannot verify the revision of a corrupt plan. + err := svc.Delete(t.Context(), DeleteRequest{Ref: SharedRef("broken"), ExpectedVersion: new(1)}) + var corrupt *CorruptError + require.ErrorAs(t, err, &corrupt) + + // A force delete still recovers it. + require.NoError(t, svc.Delete(t.Context(), DeleteRequest{Ref: SharedRef("broken")})) + assert.NoFileExists(t, filepath.Join(sharedDir, "broken.json")) +} + +// --- Session mutations are unsupported ---------------------------------------- + +func TestService_SessionMutationsUnsupported(t *testing.T) { + t.Parallel() + svc, _, sessionDir := newTestService(t) + writeSessionPlan(t, sessionDir, "sess-1", "# plan") + ref := SessionRef("sess-1") + ctx := t.Context() + + calls := map[string]func() error{ + "create": func() error { + _, err := svc.Create(ctx, CreateRequest{Ref: ref, Content: "x"}) + return err + }, + "update": func() error { + _, err := svc.Update(ctx, UpdateRequest{Ref: ref, Content: "x"}) + return err + }, + "set_status": func() error { + _, err := svc.SetStatus(ctx, SetStatusRequest{Ref: ref, Status: "done"}) + return err + }, + "delete": func() error { + return svc.Delete(ctx, DeleteRequest{Ref: ref}) + }, + } + + for op, call := range calls { + err := call() + var unsupported *UnsupportedError + require.ErrorAs(t, err, &unsupported, "op %s", op) + assert.Equal(t, ScopeSession, unsupported.Scope) + assert.Equal(t, op, unsupported.Op) + assert.NotEmpty(t, unsupported.Reason, "the error must tell the caller what to do instead") + } + + // The session plan is untouched by the refused mutations. + p, err := svc.Get(ctx, ref) + require.NoError(t, err) + assert.Equal(t, "# plan", p.Content) +} + +// --- Export ------------------------------------------------------------------ + +func TestService_ExportShared(t *testing.T) { + t.Parallel() + svc, _, _ := newTestService(t) + mustCreate(t, svc, "p", "the body") + + dest := filepath.Join(t.TempDir(), "nested", "export.md") + result, err := svc.Export(t.Context(), ExportRequest{Ref: SharedRef("p"), Path: dest}) + require.NoError(t, err) + assert.Equal(t, ScopeShared, result.Scope) + assert.Equal(t, "p", result.Name) + assert.Equal(t, dest, result.Path) + require.NotNil(t, result.Version) + assert.Equal(t, 1, *result.Version) + assert.Equal(t, len("the body"), result.BytesWritten) + + data, err := os.ReadFile(dest) + require.NoError(t, err) + assert.Equal(t, "the body", string(data)) +} + +func TestService_ExportSession(t *testing.T) { + t.Parallel() + svc, _, sessionDir := newTestService(t) + writeSessionPlan(t, sessionDir, "sess-1", "# session plan") + + dest := filepath.Join(t.TempDir(), "export.md") + result, err := svc.Export(t.Context(), ExportRequest{Ref: SessionRef("sess-1"), Path: dest}) + require.NoError(t, err) + assert.Equal(t, ScopeSession, result.Scope) + assert.Equal(t, "sess-1", result.Name) + assert.Nil(t, result.Version, "session plans have no version to export") + assert.Equal(t, len("# session plan"), result.BytesWritten) + + data, err := os.ReadFile(dest) + require.NoError(t, err) + assert.Equal(t, "# session plan", string(data)) +} + +func TestService_ExportNotFound(t *testing.T) { + t.Parallel() + svc, _, _ := newTestService(t) + var notFound *NotFoundError + + dest := filepath.Join(t.TempDir(), "export.md") + _, err := svc.Export(t.Context(), ExportRequest{Ref: SharedRef("ghost"), Path: dest}) + require.ErrorAs(t, err, ¬Found) + assert.NoFileExists(t, dest) + + _, err = svc.Export(t.Context(), ExportRequest{Ref: SessionRef("ghost"), Path: dest}) + require.ErrorAs(t, err, ¬Found) + assert.NoFileExists(t, dest) +} + +func TestService_ExportValidation(t *testing.T) { + t.Parallel() + svc, _, _ := newTestService(t) + mustCreate(t, svc, "p", "body") + var invalid *ValidationError + + _, err := svc.Export(t.Context(), ExportRequest{Ref: SharedRef("p"), Path: ""}) + require.ErrorAs(t, err, &invalid) + assert.Contains(t, invalid.Message, "path must not be empty") + + _, err = svc.Export(t.Context(), ExportRequest{Ref: SharedRef("p"), Path: t.TempDir()}) + require.ErrorAs(t, err, &invalid) + assert.Contains(t, invalid.Message, "directory") +} + +// --- Storage failures and injection ------------------------------------------- + +func TestService_StorageFailuresAreTyped(t *testing.T) { + t.Parallel() + base := errors.New("backend boom") + svc := NewService(failingStorage{err: base}, WithSessionDir(t.TempDir())) + ctx := t.Context() + + calls := map[string]func() error{ + "get": func() error { + _, err := svc.Get(ctx, SharedRef("p")) + return err + }, + "create": func() error { + _, err := svc.Create(ctx, CreateRequest{Ref: SharedRef("p"), Content: "x"}) + return err + }, + "update": func() error { + _, err := svc.Update(ctx, UpdateRequest{Ref: SharedRef("p"), Content: "x"}) + return err + }, + "set_status": func() error { + _, err := svc.SetStatus(ctx, SetStatusRequest{Ref: SharedRef("p"), Status: "done"}) + return err + }, + "delete": func() error { + return svc.Delete(ctx, DeleteRequest{Ref: SharedRef("p")}) + }, + "export": func() error { + _, err := svc.Export(ctx, ExportRequest{Ref: SharedRef("p"), Path: filepath.Join(t.TempDir(), "x.md")}) + return err + }, + } + + for op, call := range calls { + err := call() + var storageErr *StorageError + require.ErrorAs(t, err, &storageErr, "op %s", op) + assert.Equal(t, ScopeShared, storageErr.Scope, "op %s", op) + assert.ErrorIs(t, err, base, "op %s must preserve the cause", op) + } +} + +// TestService_InjectedInMemoryStorage proves the service works against any +// plan.Storage, not just the filesystem default. +func TestService_InjectedInMemoryStorage(t *testing.T) { + t.Parallel() + svc := NewService(newMemStorage(), WithSessionDir(t.TempDir())) + ctx := t.Context() + + p, err := svc.Create(ctx, CreateRequest{Ref: SharedRef("p"), Content: "v1", Status: "draft"}) + require.NoError(t, err) + assert.Equal(t, 1, *p.Version) + + _, err = svc.Create(ctx, CreateRequest{Ref: SharedRef("p"), Content: "again"}) + var conflict *ConflictError + require.ErrorAs(t, err, &conflict) + + p, err = svc.Update(ctx, UpdateRequest{Ref: SharedRef("p"), Content: "v2", ExpectedVersion: new(1)}) + require.NoError(t, err) + assert.Equal(t, 2, *p.Version) + assert.Equal(t, "draft", p.Status) + + list, err := svc.List(ctx, ListOptions{}) + require.NoError(t, err) + require.Len(t, list.Plans, 1) + assert.Equal(t, "p", list.Plans[0].Name) + + require.NoError(t, svc.Delete(ctx, DeleteRequest{Ref: SharedRef("p"), ExpectedVersion: new(2)})) + _, err = svc.Get(ctx, SharedRef("p")) + var notFound *NotFoundError + require.ErrorAs(t, err, ¬Found) +} + +func TestNewService_NilStoragePanics(t *testing.T) { + t.Parallel() + assert.Panics(t, func() { + NewService(nil) + }) +} + +func TestScope_Mutable(t *testing.T) { + t.Parallel() + assert.True(t, ScopeShared.Mutable()) + assert.False(t, ScopeSession.Mutable()) +} + +// --- Test doubles -------------------------------------------------------------- + +// failingStorage is a plan.Storage whose every method fails, to verify the +// service classifies backend failures as *StorageError. +type failingStorage struct{ err error } + +var _ plan.Storage = failingStorage{} + +func (f failingStorage) Get(context.Context, string) (plan.Plan, bool, error) { + return plan.Plan{}, false, f.err +} + +func (f failingStorage) Upsert(context.Context, plan.UpsertRequest) (plan.Plan, error) { + return plan.Plan{}, f.err +} + +func (f failingStorage) List(context.Context) ([]plan.Summary, []string, error) { + return nil, nil, f.err +} + +func (f failingStorage) Delete(context.Context, string, *int) (bool, error) { + return false, f.err +} + +// unsortedStorage lists a fixed set of plans in deliberately unsorted order, +// to prove Service.List owns the documented ordering. +type unsortedStorage struct{ names []string } + +var _ plan.Storage = unsortedStorage{} + +func (s unsortedStorage) Get(context.Context, string) (plan.Plan, bool, error) { + return plan.Plan{}, false, nil +} + +func (s unsortedStorage) Upsert(context.Context, plan.UpsertRequest) (plan.Plan, error) { + return plan.Plan{}, errors.New("read-only") +} + +func (s unsortedStorage) List(context.Context) ([]plan.Summary, []string, error) { + out := make([]plan.Summary, 0, len(s.names)) + for _, name := range s.names { + out = append(out, plan.Summary{Name: name, Revision: 1}) + } + return out, nil, nil +} + +func (s unsortedStorage) Delete(context.Context, string, *int) (bool, error) { + return false, nil +} + +// memStorage is a minimal in-memory plan.Storage honouring the same contract +// as the filesystem default: Upsert owns the revision bump, the optimistic +// lock, and the must-exist guard. +type memStorage struct { + plans map[string]plan.Plan +} + +var _ plan.Storage = (*memStorage)(nil) + +func newMemStorage() *memStorage { + return &memStorage{plans: map[string]plan.Plan{}} +} + +func (s *memStorage) Get(_ context.Context, name string) (plan.Plan, bool, error) { + p, ok := s.plans[name] + return p, ok, nil +} + +func (s *memStorage) Upsert(_ context.Context, req plan.UpsertRequest) (plan.Plan, error) { + p, exists := s.plans[req.Name] + if req.MustExist && !exists { + return plan.Plan{}, plan.ErrPlanNotFound + } + if req.MustNotExist && exists { + return plan.Plan{}, &plan.VersionConflictError{Name: req.Name, Expected: 0, Current: p.Revision} + } + if req.ExpectedRevision != nil && p.Revision != *req.ExpectedRevision { + return plan.Plan{}, &plan.VersionConflictError{Name: req.Name, Expected: *req.ExpectedRevision, Current: p.Revision} + } + p.Name = req.Name + if req.Content != nil { + p.Content = *req.Content + } + if req.Title != nil { + p.Title = *req.Title + } + if req.Author != nil { + p.Author = *req.Author + } + if req.Status != nil { + p.Status = *req.Status + } + p.Revision++ + p.UpdatedAt = "2024-01-01T00:00:00Z" + s.plans[req.Name] = p + return p, nil +} + +func (s *memStorage) List(context.Context) ([]plan.Summary, []string, error) { + out := make([]plan.Summary, 0, len(s.plans)) + for name, p := range s.plans { + out = append(out, plan.Summary{Name: name, Title: p.Title, Author: p.Author, Status: p.Status, Revision: p.Revision, UpdatedAt: p.UpdatedAt}) + } + slices.SortFunc(out, func(a, b plan.Summary) int { return strings.Compare(a.Name, b.Name) }) + return out, nil, nil +} + +func (s *memStorage) Delete(_ context.Context, name string, expectedRevision *int) (bool, error) { + p, ok := s.plans[name] + if !ok { + return false, nil + } + if expectedRevision != nil && p.Revision != *expectedRevision { + return false, &plan.VersionConflictError{Name: name, Expected: *expectedRevision, Current: p.Revision} + } + delete(s.plans, name) + return true, nil +} diff --git a/pkg/runtime/client.go b/pkg/runtime/client.go index 195e83c9bb..74720a9605 100644 --- a/pkg/runtime/client.go +++ b/pkg/runtime/client.go @@ -89,6 +89,7 @@ func NewClient(baseURL string, opts ...ClientOption) (*Client, error) { "shell": func() Event { return &ShellOutputEvent{} }, "session_title": func() Event { return &SessionTitleEvent{} }, "session_plan_updated": func() Event { return &SessionPlanUpdatedEvent{} }, + "plan_changed": func() Event { return &PlanChangedEvent{} }, "session_summary": func() Event { return &SessionSummaryEvent{} }, "session_compaction": func() Event { return &SessionCompactionEvent{} }, "partial_tool_call": func() Event { return &PartialToolCallEvent{} }, diff --git a/pkg/runtime/event.go b/pkg/runtime/event.go index ad4567fc85..e2da85565a 100644 --- a/pkg/runtime/event.go +++ b/pkg/runtime/event.go @@ -467,6 +467,34 @@ func SessionPlanUpdated(sessionID, content, path, agentName string) Event { func (e *SessionPlanUpdatedEvent) GetSessionID() string { return e.SessionID } +// PlanChangedEvent fires after an agent successfully mutates a shared plan +// (write, status change, or delete) through the plan toolset. It carries +// identity and version only — no content — so a UI refreshes through its own +// plan service instead of trusting an event payload. It is deliberately not +// session-scoped: shared plans are global across sessions. +type PlanChangedEvent struct { + AgentContext + + Type string `json:"type"` + Scope string `json:"scope"` + Name string `json:"name"` + Action string `json:"action"` + // Version is the plan version after a write, or the guard version of a + // guarded delete (0 when unguarded). + Version int `json:"version,omitempty"` +} + +func PlanChanged(scope, name, action string, version int, agentName string) Event { + return &PlanChangedEvent{ + Type: "plan_changed", + Scope: scope, + Name: name, + Action: action, + Version: version, + AgentContext: newAgentContext(agentName), + } +} + type SessionSummaryEvent struct { AgentContext diff --git a/pkg/runtime/loop.go b/pkg/runtime/loop.go index d62c18da73..8184a00ed4 100644 --- a/pkg/runtime/loop.go +++ b/pkg/runtime/loop.go @@ -5,6 +5,7 @@ import ( "fmt" "log/slog" "path/filepath" + "reflect" "slices" "strings" "time" @@ -29,6 +30,7 @@ import ( "github.com/docker/docker-agent/pkg/tools/builtin/backgroundjobs" "github.com/docker/docker-agent/pkg/tools/builtin/handoff" "github.com/docker/docker-agent/pkg/tools/builtin/modelpicker" + "github.com/docker/docker-agent/pkg/tools/builtin/plan" "github.com/docker/docker-agent/pkg/tools/builtin/sessioncontext" "github.com/docker/docker-agent/pkg/tools/builtin/sessionplan" "github.com/docker/docker-agent/pkg/tools/builtin/skills" @@ -334,6 +336,13 @@ func (r *LocalRuntime) runStreamLoop(ctx context.Context, sess *session.Session, // the events channel closes, so it can never be stranded. defer r.finishLiveSession(ctx, liveEntry) + // Subscribe this stream to shared-plan change notifications for its + // whole lifecycle. Registered once here rather than per iteration, and + // the deferred release runs (LIFO) before finalizeEventChannel closes + // the events channel — on every exit path, including errors and + // cancellation — so no subscription outlives its sink. + defer r.subscribePlanChanges(sess, sink)() + a := r.resolveSessionAgent(sess) // session_start fires once per RunStream. Its AdditionalContext @@ -1313,6 +1322,85 @@ func (r *LocalRuntime) configureToolsetHandlers(a *agent.Agent, events EventSink } } +// subscribePlanChanges subscribes this stream's event sink to every plan +// ChangeNotifier reachable from the team's toolsets (plus the session's +// skill-provided extra toolsets) so an open /plans browser refreshes live, +// and returns a release function that unsubscribes them all. Called once per +// stream from runStreamLoop — not per iteration — and released before the +// events channel closes, so subscriptions never leak past the stream. +// Notifier instances are deduplicated: the registry-created plan toolset is +// a process-wide singleton shared by all agents, and one mutation must emit +// one event per stream, not one per agent. +// +// The emitted event carries no agent attribution: the shared toolset +// executes mutations for whichever session's agent is calling, so this +// subscriber cannot know the mutator (see plan.Change). An empty agent name +// follows the AgentContext convention for events without a meaningful agent; +// consumers read the plan's Author field for collaborative attribution. +// +// Non-blocking sink like the RAG forwarder: the callback fires from +// another session's tool call, and a blocking send into this stream's +// events channel could hang that session's tool call. +func (r *LocalRuntime) subscribePlanChanges(sess *session.Session, events EventSink) (release func()) { + sink := nonBlocking(events) + forward := func(c plan.Change) { + sink.Emit(PlanChanged("shared", c.Name, c.Action, c.Revision, "")) + } + + toolsets := slices.Clone(sess.ExtraToolSets) + for _, name := range r.team.AgentNames() { + if a, err := r.team.Agent(name); err == nil { + toolsets = append(toolsets, a.ToolSets()...) + } + } + + seen := make(map[notifierIdentity]struct{}) + var unsubs []func() + for _, ts := range toolsets { + notifier, ok := tools.As[plan.ChangeNotifier](ts) + if !ok { + continue + } + if key, identifiable := notifierDedupKey(notifier); identifiable { + if _, dup := seen[key]; dup { + continue + } + seen[key] = struct{}{} + } + unsubs = append(unsubs, notifier.SubscribeChanges(forward)) + } + return func() { + for _, unsub := range unsubs { + unsub() + } + } +} + +// notifierIdentity keys subscribePlanChanges' dedup map: dynamic type plus +// pointer, so two pointers of different types sharing an address (an outer +// struct and its first field) stay distinct. +type notifierIdentity struct { + typ reflect.Type + ptr uintptr +} + +// notifierDedupKey derives a dedup identity for n, reporting ok=false when +// none is safe. Interface map keys hash the dynamic value, which panics at +// runtime for non-comparable implementations (e.g. a value struct with a +// func field) — and even a comparable struct type panics when an interface +// field holds a non-comparable value. Pointer identity is the only +// universally hash-safe choice, and it covers the case dedup exists for: +// the registry-created *plan.ToolSet singleton shared by every agent. +// Non-pointer implementations skip dedup — a duplicate subscription is +// benign, a panic is not. +func notifierDedupKey(n plan.ChangeNotifier) (notifierIdentity, bool) { + v := reflect.ValueOf(n) + if v.Kind() != reflect.Pointer { + return notifierIdentity{}, false + } + return notifierIdentity{typ: v.Type(), ptr: v.Pointer()}, true +} + // emitAgentWarnings drains and emits any pending toolset warnings as // persistent TUI notifications. Failures ("start failed", "list failed") // are surfaced so the user can act on them; recoveries are intentionally diff --git a/pkg/runtime/plan_events_test.go b/pkg/runtime/plan_events_test.go new file mode 100644 index 0000000000..7b2e783ff1 --- /dev/null +++ b/pkg/runtime/plan_events_test.go @@ -0,0 +1,562 @@ +package runtime + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/agent" + "github.com/docker/docker-agent/pkg/chat" + "github.com/docker/docker-agent/pkg/session" + "github.com/docker/docker-agent/pkg/team" + "github.com/docker/docker-agent/pkg/tools" + "github.com/docker/docker-agent/pkg/tools/builtin/plan" +) + +func TestPlanChangedEventSerialization(t *testing.T) { + t.Parallel() + + ev := PlanChanged("shared", "release", plan.ChangeActionWrite, 3, "architect") + data, err := json.Marshal(ev) + require.NoError(t, err) + + var decoded PlanChangedEvent + require.NoError(t, json.Unmarshal(data, &decoded)) + assert.Equal(t, "plan_changed", decoded.Type) + assert.Equal(t, "shared", decoded.Scope) + assert.Equal(t, "release", decoded.Name) + assert.Equal(t, "write", decoded.Action) + assert.Equal(t, 3, decoded.Version) + assert.Equal(t, "architect", decoded.GetAgentName()) + assert.NotContains(t, string(data), "content", "the event must never carry plan content") +} + +// TestClient_DecodesPlanChangedEvent verifies the event is registered in the +// client's event registry, so remote runtimes deliver it typed rather than +// dropping it. +func TestClient_DecodesPlanChangedEvent(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "data: {\"type\":\"plan_changed\",\"scope\":\"shared\",\"name\":\"release\",\"action\":\"status\",\"version\":4}\n\n") + })) + t.Cleanup(srv.Close) + + c, err := NewClient(srv.URL) + require.NoError(t, err) + + ch, err := c.StreamSessionEvents(t.Context(), "s") + require.NoError(t, err) + + var got *PlanChangedEvent + for ev := range ch { + if planEv, ok := ev.(*PlanChangedEvent); ok { + got = planEv + break + } + } + require.NotNil(t, got, "plan_changed must decode to *PlanChangedEvent") + assert.Equal(t, "release", got.Name) + assert.Equal(t, "status", got.Action) + assert.Equal(t, 4, got.Version) + assert.Equal(t, "shared", got.Scope) +} + +// planToolHandler finds one of the plan toolset's tools by name. The write +// goes through the tool handler (not the storage) so the test exercises the +// same code path as an agent's tool call. +func planToolHandler(t *testing.T, ts tools.ToolSet, name string) tools.ToolHandler { + t.Helper() + all, err := ts.Tools(t.Context()) + require.NoError(t, err) + for _, tl := range all { + if tl.Name == name { + return tl.Handler + } + } + t.Fatalf("tool %q not found", name) + return nil +} + +func planToolCall(t *testing.T, args any) tools.ToolCall { + t.Helper() + payload, err := json.Marshal(args) + require.NoError(t, err) + call := tools.ToolCall{} + call.Function.Arguments = string(payload) + return call +} + +// newPlanRuntime builds a runtime whose only agent carries the given plan +// toolset, mirroring how agents share the process-wide plan singleton. +func newPlanRuntime(t *testing.T, planTool *plan.ToolSet) *LocalRuntime { + t.Helper() + prov := &mockProvider{id: "test/plan-model", stream: &mockStream{}} + root := agent.New("root", "agent", + agent.WithModel(prov), + agent.WithToolSets(planTool), + ) + tm := team.New(team.WithAgents(root)) + rt, err := NewLocalRuntime(t.Context(), tm, WithCurrentAgent("root"), WithModelStore(mockModelStore{})) + require.NoError(t, err) + return rt +} + +// TestSubscribePlanChanges_EmitsPlanChangeEvents verifies that a stream's +// plan subscription reaches the plan toolset through the agent's toolset +// wrappers (agent.WithToolSets wraps in a StartableToolSet) and that +// successful mutations emit PlanChangedEvent while failed ones emit nothing. +func TestSubscribePlanChanges_EmitsPlanChangeEvents(t *testing.T) { + t.Parallel() + + planTool := plan.New(plan.WithStorage(plan.NewFilesystemStorage(t.TempDir()))) + rt := newPlanRuntime(t, planTool) + + events := make(chan Event, 8) + release := rt.subscribePlanChanges(session.New(), NewChannelSink(events)) + t.Cleanup(release) + + write := planToolHandler(t, planTool, plan.ToolNameWritePlan) + setStatus := planToolHandler(t, planTool, plan.ToolNameSetPlanStatus) + del := planToolHandler(t, planTool, plan.ToolNameDeletePlan) + + // Successful write emits a versioned event without content. The agent + // name is deliberately empty: the shared toolset mutates on behalf of + // whichever session's agent calls it, so the subscriber cannot know the + // mutator and must not claim one. + result, err := write(t.Context(), planToolCall(t, plan.WritePlanArgs{Name: "release", Content: "v1"}), nil) + require.NoError(t, err) + require.False(t, result.IsError) + ev := requirePlanChanged(t, events) + assert.Equal(t, "shared", ev.Scope) + assert.Equal(t, "release", ev.Name) + assert.Equal(t, plan.ChangeActionWrite, ev.Action) + assert.Equal(t, 1, ev.Version) + assert.Empty(t, ev.GetAgentName(), "shared-plan events carry neutral attribution") + + // Successful status change. + result, err = setStatus(t.Context(), planToolCall(t, plan.SetPlanStatusArgs{Name: "release", Status: "done"}), nil) + require.NoError(t, err) + require.False(t, result.IsError) + ev = requirePlanChanged(t, events) + assert.Equal(t, plan.ChangeActionStatus, ev.Action) + assert.Equal(t, 2, ev.Version) + + // Failed write (version conflict) emits nothing. + stale := 42 + result, err = write(t.Context(), planToolCall(t, plan.WritePlanArgs{Name: "release", Content: "v2", LastKnownRevision: &stale}), nil) + require.NoError(t, err) + require.True(t, result.IsError) + requireNoEvent(t, events) + + // Successful guarded delete. + rev := 2 + result, err = del(t.Context(), planToolCall(t, plan.DeletePlanArgs{Name: "release", LastKnownRevision: &rev}), nil) + require.NoError(t, err) + require.False(t, result.IsError) + ev = requirePlanChanged(t, events) + assert.Equal(t, plan.ChangeActionDelete, ev.Action) + assert.Equal(t, 2, ev.Version) +} + +// TestSubscribePlanChanges_FanOutAndReleaseIsolation proves the subscription +// semantics the single-callback slot could not provide: every subscribed +// stream receives every change exactly once, releasing one subscription +// neither disturbs the others nor fires into the released sink again, and +// release is idempotent. +func TestSubscribePlanChanges_FanOutAndReleaseIsolation(t *testing.T) { + t.Parallel() + + planTool := plan.New(plan.WithStorage(plan.NewFilesystemStorage(t.TempDir()))) + rt := newPlanRuntime(t, planTool) + write := planToolHandler(t, planTool, plan.ToolNameWritePlan) + + eventsA := make(chan Event, 8) + eventsB := make(chan Event, 8) + releaseA := rt.subscribePlanChanges(session.New(), NewChannelSink(eventsA)) + releaseB := rt.subscribePlanChanges(session.New(), NewChannelSink(eventsB)) + + _, err := write(t.Context(), planToolCall(t, plan.WritePlanArgs{Name: "release", Content: "v1"}), nil) + require.NoError(t, err) + assert.Equal(t, 1, requirePlanChanged(t, eventsA).Version, "subscriber A must receive the change") + requireNoEvent(t, eventsA) + assert.Equal(t, 1, requirePlanChanged(t, eventsB).Version, "subscriber B must receive the change") + requireNoEvent(t, eventsB) + + releaseA() + releaseA() // idempotent: a second release must not disturb subscriber B + + _, err = write(t.Context(), planToolCall(t, plan.WritePlanArgs{Name: "release", Content: "v2"}), nil) + require.NoError(t, err) + requireNoEvent(t, eventsA) + assert.Equal(t, 2, requirePlanChanged(t, eventsB).Version, "releasing A must not silence B") + + releaseB() + _, err = write(t.Context(), planToolCall(t, plan.WritePlanArgs{Name: "release", Content: "v3"}), nil) + require.NoError(t, err) + requireNoEvent(t, eventsA) + requireNoEvent(t, eventsB) +} + +// TestSubscribePlanChanges_DedupesSharedNotifierAcrossAgents pins the +// dedup invariant: the registry-created plan toolset is one process-wide +// singleton shared by every agent, so a stream whose team has several +// plan-capable agents must still receive one event per mutation. +func TestSubscribePlanChanges_DedupesSharedNotifierAcrossAgents(t *testing.T) { + t.Parallel() + + planTool := plan.New(plan.WithStorage(plan.NewFilesystemStorage(t.TempDir()))) + a1 := agent.New("a1", "agent", + agent.WithModel(&mockProvider{id: "test/plan-model", stream: &mockStream{}}), + agent.WithToolSets(planTool), + ) + a2 := agent.New("a2", "agent", + agent.WithModel(&mockProvider{id: "test/plan-model", stream: &mockStream{}}), + agent.WithToolSets(planTool), + ) + rt, err := NewLocalRuntime(t.Context(), team.New(team.WithAgents(a1, a2)), WithModelStore(mockModelStore{})) + require.NoError(t, err) + + events := make(chan Event, 8) + release := rt.subscribePlanChanges(session.New(), NewChannelSink(events)) + t.Cleanup(release) + + write := planToolHandler(t, planTool, plan.ToolNameWritePlan) + _, err = write(t.Context(), planToolCall(t, plan.WritePlanArgs{Name: "release", Content: "v1"}), nil) + require.NoError(t, err) + + requirePlanChanged(t, events) + requireNoEvent(t, events) +} + +// TestSubscribePlanChanges_IncludesSessionExtraToolSets covers skill +// sub-sessions: their assistive toolsets ride on the session rather than an +// agent, and a plan toolset among them must still notify the stream. +func TestSubscribePlanChanges_IncludesSessionExtraToolSets(t *testing.T) { + t.Parallel() + + planTool := plan.New(plan.WithStorage(plan.NewFilesystemStorage(t.TempDir()))) + root := agent.New("root", "agent", + agent.WithModel(&mockProvider{id: "test/plan-model", stream: &mockStream{}}), + ) + rt, err := NewLocalRuntime(t.Context(), team.New(team.WithAgents(root)), WithModelStore(mockModelStore{})) + require.NoError(t, err) + + sess := session.New() + sess.ExtraToolSets = []tools.ToolSet{planTool} + + events := make(chan Event, 8) + release := rt.subscribePlanChanges(sess, NewChannelSink(events)) + t.Cleanup(release) + + write := planToolHandler(t, planTool, plan.ToolNameWritePlan) + _, err = write(t.Context(), planToolCall(t, plan.WritePlanArgs{Name: "release", Content: "v1"}), nil) + require.NoError(t, err) + requirePlanChanged(t, events) +} + +// funcSubscribeNotifier is a valid ChangeNotifier whose dynamic type is not +// comparable (the func field makes the value struct unhashable), so using it +// as a map key panics at runtime. Subscriptions delegate to a real registry +// so delivery can be asserted end to end. +type funcSubscribeNotifier struct { + subscribe func(cb func(plan.Change)) func() +} + +var ( + _ tools.ToolSet = funcSubscribeNotifier{} + _ plan.ChangeNotifier = funcSubscribeNotifier{} +) + +func (f funcSubscribeNotifier) Tools(context.Context) ([]tools.Tool, error) { return nil, nil } + +func (f funcSubscribeNotifier) SubscribeChanges(cb func(plan.Change)) func() { + return f.subscribe(cb) +} + +// TestSubscribePlanChanges_NonComparableNotifierDoesNotPanic pins the dedup +// bookkeeping against unhashable implementations: a value-type notifier +// reached through the agent's StartableToolSet wrapper must not panic the +// subscription loop — it skips dedup but still subscribes, delivers, and +// releases. +func TestSubscribePlanChanges_NonComparableNotifierDoesNotPanic(t *testing.T) { + t.Parallel() + + planTool := plan.New(plan.WithStorage(plan.NewFilesystemStorage(t.TempDir()))) + root := agent.New("root", "agent", + agent.WithModel(&mockProvider{id: "test/plan-model", stream: &mockStream{}}), + agent.WithToolSets(funcSubscribeNotifier{subscribe: planTool.SubscribeChanges}), + ) + rt, err := NewLocalRuntime(t.Context(), team.New(team.WithAgents(root)), WithModelStore(mockModelStore{})) + require.NoError(t, err) + + events := make(chan Event, 8) + var release func() + require.NotPanics(t, func() { + release = rt.subscribePlanChanges(session.New(), NewChannelSink(events)) + }) + + write := planToolHandler(t, planTool, plan.ToolNameWritePlan) + _, err = write(t.Context(), planToolCall(t, plan.WritePlanArgs{Name: "release", Content: "v1"}), nil) + require.NoError(t, err) + assert.Equal(t, 1, requirePlanChanged(t, events).Version, "a non-comparable notifier must still deliver changes") + + release() + _, err = write(t.Context(), planToolCall(t, plan.WritePlanArgs{Name: "release", Content: "v2"}), nil) + require.NoError(t, err) + requireNoEvent(t, events) +} + +// fakePlanNotifier is a toolset that records subscription lifecycle calls so +// stream tests can prove RunStream subscribes exactly once per stream and +// releases exactly once when the stream ends. +type fakePlanNotifier struct { + toolsList []tools.Tool + + mu sync.Mutex + subs map[int]func(plan.Change) + nextID int + subscribed int + unsubscribed int +} + +var _ plan.ChangeNotifier = (*fakePlanNotifier)(nil) + +func (f *fakePlanNotifier) Tools(context.Context) ([]tools.Tool, error) { return f.toolsList, nil } + +func (f *fakePlanNotifier) SubscribeChanges(cb func(plan.Change)) func() { + f.mu.Lock() + defer f.mu.Unlock() + if f.subs == nil { + f.subs = make(map[int]func(plan.Change)) + } + id := f.nextID + f.nextID++ + f.subs[id] = cb + f.subscribed++ + return func() { + f.mu.Lock() + defer f.mu.Unlock() + if _, ok := f.subs[id]; ok { + delete(f.subs, id) + f.unsubscribed++ + } + } +} + +func (f *fakePlanNotifier) counts() (subscribed, unsubscribed, live int) { + f.mu.Lock() + defer f.mu.Unlock() + return f.subscribed, f.unsubscribed, len(f.subs) +} + +// TestRunStream_PlanSubscriptionOncePerStream drives a stream through two +// loop iterations (a tool-call turn, then a stop turn) and proves the plan +// subscription is a stream-lifecycle concern: registered exactly once — not +// re-registered per iteration — and released exactly once when the events +// channel closes. +func TestRunStream_PlanSubscriptionOncePerStream(t *testing.T) { + t.Parallel() + + noop := tools.Tool{ + Name: "noop", + Parameters: map[string]any{}, + Handler: func(context.Context, tools.ToolCall, tools.Runtime) (*tools.ToolCallResult, error) { + return tools.ResultSuccess("ok"), nil + }, + } + fake := &fakePlanNotifier{toolsList: []tools.Tool{noop}} + + prov := &queueProvider{id: "test/mock-model", streams: []chat.MessageStream{ + newStreamBuilder(). + AddToolCallName("call_1", "noop"). + AddToolCallArguments("call_1", `{}`). + AddToolCallStopWithUsage(2, 2). + Build(), + newStreamBuilder().AddStopWithUsage(1, 1).Build(), + }} + root := agent.New("root", "test agent", + agent.WithModel(prov), + agent.WithToolSets(fake), + ) + rt, err := NewLocalRuntime(t.Context(), team.New(team.WithAgents(root)), WithSessionCompaction(false), WithModelStore(mockModelStore{})) + require.NoError(t, err) + + sess := session.New(session.WithUserMessage("do the thing"), session.WithToolsApproved(true)) + for range rt.RunStream(t.Context(), sess) { + } + + subscribed, unsubscribed, live := fake.counts() + assert.Equal(t, 1, subscribed, "subscription must happen once per stream, not per iteration") + assert.Equal(t, 1, unsubscribed, "the stream must release its subscription when it ends") + assert.Zero(t, live, "no subscription may leak past the stream") +} + +// TestRunStream_PlanSubscriptionReleasedOnCancel proves the release also runs +// on the cancellation path, so an interrupted stream cannot leak its +// subscription into the shared toolset. +func TestRunStream_PlanSubscriptionReleasedOnCancel(t *testing.T) { + t.Parallel() + + fake := &fakePlanNotifier{} + release := make(chan struct{}) + t.Cleanup(func() { close(release) }) + root := agent.New("root", "test agent", + agent.WithModel(&activeRootBlockingProvider{id: "test/mock-model", release: release}), + agent.WithToolSets(fake), + ) + rt, err := NewLocalRuntime(t.Context(), team.New(team.WithAgents(root)), WithSessionCompaction(false), WithModelStore(mockModelStore{})) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(t.Context()) + events := rt.RunStream(ctx, session.New(session.WithUserMessage("block"))) + awaitEvent[*StreamStartedEvent](t, events, "stream start") + cancel() + for range events { + } + + subscribed, unsubscribed, live := fake.counts() + assert.Equal(t, 1, subscribed) + assert.Equal(t, 1, unsubscribed, "cancellation must still release the subscription") + assert.Zero(t, live) +} + +// writePlanStream scripts a model turn that calls write_plan once. +func writePlanStream(t *testing.T, callID, name, content string) *mockStream { + t.Helper() + args, err := json.Marshal(plan.WritePlanArgs{Name: name, Content: content}) + require.NoError(t, err) + return newStreamBuilder(). + AddToolCallName(callID, plan.ToolNameWritePlan). + AddToolCallArguments(callID, string(args)). + AddToolCallStopWithUsage(2, 2). + Build() +} + +// TestRunStream_TwoActiveStreams_BothReceivePlanChanges is the end-to-end +// fan-out proof: two live streams share one plan toolset (the singleton +// arrangement), a mutation made by one agent's tool call reaches both +// streams' sinks, and a stream that has ended neither receives further +// events nor silences the survivors. Under the previous single-slot +// callback the writer's registration overwrote the waiter's, so the waiter +// never saw any event and this test would fail. +func TestRunStream_TwoActiveStreams_BothReceivePlanChanges(t *testing.T) { + t.Parallel() + + planTool := plan.New(plan.WithStorage(plan.NewFilesystemStorage(t.TempDir()))) + + waiterRelease := make(chan struct{}) + waiter := agent.New("waiter", "parks in its model call", + agent.WithModel(&activeRootBlockingProvider{id: "test/mock-model", release: waiterRelease}), + agent.WithToolSets(planTool), + ) + + // Each writer run consumes two streams: a write_plan tool-call turn, + // then a clean stop. + writerProv := &queueProvider{id: "test/mock-model", streams: []chat.MessageStream{ + writePlanStream(t, "call_1", "release", "v1"), + newStreamBuilder().AddStopWithUsage(1, 1).Build(), + writePlanStream(t, "call_2", "release", "v2"), + newStreamBuilder().AddStopWithUsage(1, 1).Build(), + }} + writer := agent.New("writer", "writes shared plans", + agent.WithModel(writerProv), + agent.WithToolSets(planTool), + ) + + rt, err := NewLocalRuntime(t.Context(), team.New(team.WithAgents(waiter, writer)), WithSessionCompaction(false), WithModelStore(mockModelStore{})) + require.NoError(t, err) + + // Stream A subscribes at stream start, then parks inside its model call. + // StreamStarted is emitted after the subscription is in place. + eventsA := rt.RunStream(t.Context(), session.New(session.WithUserMessage("wait"), session.WithAgentName("waiter"))) + awaitEvent[*StreamStartedEvent](t, eventsA, "waiter stream start") + + runWriter := func(label string) { + t.Helper() + sess := session.New( + session.WithUserMessage("write the plan"), + session.WithAgentName("writer"), + session.WithToolsApproved(true), + ) + got := 0 + for ev := range rt.RunStream(t.Context(), sess) { + if planEv, ok := ev.(*PlanChangedEvent); ok { + got++ + assert.Empty(t, planEv.GetAgentName(), "%s: shared-plan events carry neutral attribution", label) + } + } + assert.Equal(t, 1, got, "%s: the mutating stream must receive its own plan change exactly once", label) + } + + // First writer stream: both the writer and the parked waiter see the write. + runWriter("writer run 1") + ev := awaitEvent[*PlanChangedEvent](t, eventsA, "plan change from writer run 1") + assert.Equal(t, 1, ev.Version) + assert.Empty(t, ev.GetAgentName()) + + // The first writer stream has ended (its channel closed and its + // subscription released); a second writer stream must still fan out to + // the waiter, proving one stream ending does not silence another. + runWriter("writer run 2") + ev = awaitEvent[*PlanChangedEvent](t, eventsA, "plan change from writer run 2") + assert.Equal(t, 2, ev.Version) + + // Unblock the waiter and drain: no further plan events may arrive. + close(waiterRelease) + for evA := range eventsA { + if _, isPlan := evA.(*PlanChangedEvent); isPlan { + t.Fatalf("waiter received an unexpected extra plan change") + } + } +} + +// awaitEvent reads from ch until an event of type T arrives, failing the +// test if the channel closes or ten seconds pass first. +func awaitEvent[T Event](t *testing.T, ch <-chan Event, what string) T { + t.Helper() + timeout := time.After(10 * time.Second) + for { + select { + case ev, ok := <-ch: + require.True(t, ok, "event channel closed while waiting for %s", what) + if typed, isT := ev.(T); isT { + return typed + } + case <-timeout: + t.Fatalf("timed out waiting for %s", what) + } + } +} + +func requirePlanChanged(t *testing.T, events chan Event) *PlanChangedEvent { + t.Helper() + select { + case ev := <-events: + planEv, ok := ev.(*PlanChangedEvent) + require.True(t, ok, "expected *PlanChangedEvent, got %T", ev) + return planEv + default: + t.Fatal("expected a PlanChangedEvent, channel is empty") + return nil + } +} + +func requireNoEvent(t *testing.T, events chan Event) { + t.Helper() + select { + case ev := <-events: + t.Fatalf("expected no event, got %T", ev) + default: + } +} diff --git a/pkg/tools/builtin/plan/lock.go b/pkg/tools/builtin/plan/lock.go new file mode 100644 index 0000000000..9dda1296e4 --- /dev/null +++ b/pkg/tools/builtin/plan/lock.go @@ -0,0 +1,62 @@ +package plan + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" +) + +// lockFileName is the persistent sentinel file, inside the plans directory, +// that FilesystemStorage locks to serialize plan mutations across processes. +// It is intentionally never deleted: removing it would let two processes lock +// different inodes for the same logical directory and lose mutual exclusion. +// It can never be mistaken for a plan: List only considers .json files and no +// valid plan name can contain a dot. +const lockFileName = ".plans.lock" + +// lockRetryInterval is how often a blocked acquireFileLock retries its +// non-blocking lock attempt while another process holds the lock. +const lockRetryInterval = 10 * time.Millisecond + +// acquireFileLock takes an exclusive cross-process advisory lock on the +// sentinel file at path, creating the file (and any missing parent directory) +// first. It blocks until the lock is acquired or ctx is done. The returned +// release closure unlocks and closes the sentinel; defer it in the caller. +// Lock attempts are non-blocking with a retry loop because the platform lock +// primitives cannot be interrupted, so this is what honours cancellation +// while waiting. +func acquireFileLock(ctx context.Context, path string) (release func(), err error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return nil, fmt.Errorf("creating plans directory: %w", err) + } + f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600) + if err != nil { + return nil, fmt.Errorf("opening plans lock file %q: %w", path, err) + } + for { + err := lockFileExclusive(f) + if err == nil { + // Release errors are ignored: closing the descriptor drops the + // OS lock regardless, and there is no useful way to surface them. + return func() { + _ = unlockFile(f) + _ = f.Close() + }, nil + } + if !isLockUnavailable(err) { + _ = f.Close() + return nil, fmt.Errorf("locking plans lock file %q: %w", path, err) + } + select { + case <-ctx.Done(): + _ = f.Close() + return nil, ctx.Err() + case <-time.After(lockRetryInterval): + } + } +} diff --git a/pkg/tools/builtin/plan/lock_other.go b/pkg/tools/builtin/plan/lock_other.go new file mode 100644 index 0000000000..586cdc5384 --- /dev/null +++ b/pkg/tools/builtin/plan/lock_other.go @@ -0,0 +1,24 @@ +//go:build !unix && !windows + +package plan + +import "os" + +// The platforms without a usable cross-process lock primitive (js/wasm, +// wasip1, plan9) get no-op lock operations, so acquireFileLock degrades to +// just creating the sentinel file. Under js/wasm the runtime is +// single-threaded and there is no second process to coordinate with; on +// wasip1 and plan9 mutations are NOT serialized against other processes — +// only the in-process mutex of FilesystemStorage (s.mu) still guards +// concurrent mutations within this process. +func lockFileExclusive(_ *os.File) error { + return nil +} + +func unlockFile(_ *os.File) error { + return nil +} + +func isLockUnavailable(error) bool { + return false +} diff --git a/pkg/tools/builtin/plan/lock_unix.go b/pkg/tools/builtin/plan/lock_unix.go new file mode 100644 index 0000000000..c99371aa87 --- /dev/null +++ b/pkg/tools/builtin/plan/lock_unix.go @@ -0,0 +1,26 @@ +//go:build unix + +package plan + +import ( + "errors" + "os" + + "golang.org/x/sys/unix" +) + +// lockFileExclusive attempts to acquire an exclusive advisory lock without +// blocking. The retry loop in acquireFileLock handles waiting and +// cancellation. flock locks are per open file description, so two +// FilesystemStorage instances in the same process exclude each other too. +func lockFileExclusive(f *os.File) error { + return unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB) +} + +func unlockFile(f *os.File) error { + return unix.Flock(int(f.Fd()), unix.LOCK_UN) +} + +func isLockUnavailable(err error) bool { + return errors.Is(err, unix.EWOULDBLOCK) || errors.Is(err, unix.EAGAIN) +} diff --git a/pkg/tools/builtin/plan/lock_windows.go b/pkg/tools/builtin/plan/lock_windows.go new file mode 100644 index 0000000000..024ab29625 --- /dev/null +++ b/pkg/tools/builtin/plan/lock_windows.go @@ -0,0 +1,46 @@ +//go:build windows + +package plan + +import ( + "errors" + "os" + + "golang.org/x/sys/windows" +) + +// maxLockRange asks LockFileEx / UnlockFileEx to cover the whole file by +// passing 0xFFFFFFFF for both the low and high 32 bits of the range. +const maxLockRange = ^uint32(0) + +// lockFileExclusive attempts to acquire an exclusive lock without blocking. +// Windows has no flock, so we use LockFileEx with LOCKFILE_FAIL_IMMEDIATELY; +// the retry loop in acquireFileLock handles waiting and cancellation. Since +// only the sentinel file is locked (never a plan file), plan reads stay +// unaffected by the mandatory nature of Windows locks. +func lockFileExclusive(f *os.File) error { + var ol windows.Overlapped + return windows.LockFileEx( + windows.Handle(f.Fd()), + windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, + 0, + maxLockRange, + maxLockRange, + &ol, + ) +} + +func unlockFile(f *os.File) error { + var ol windows.Overlapped + return windows.UnlockFileEx( + windows.Handle(f.Fd()), + 0, + maxLockRange, + maxLockRange, + &ol, + ) +} + +func isLockUnavailable(err error) bool { + return errors.Is(err, windows.ERROR_LOCK_VIOLATION) || errors.Is(err, windows.ERROR_SHARING_VIOLATION) +} diff --git a/pkg/tools/builtin/plan/open_other.go b/pkg/tools/builtin/plan/open_other.go new file mode 100644 index 0000000000..65b0bf29e7 --- /dev/null +++ b/pkg/tools/builtin/plan/open_other.go @@ -0,0 +1,17 @@ +//go:build !unix + +package plan + +import "os" + +// OpenContentFile opens a plan-content source file read-only so the caller +// can validate what it actually opened — with File.Stat on the returned +// descriptor — before reading. Hanging opens are a Unix FIFO/device semantic +// defused there with O_NONBLOCK; the remaining platforms (Windows, js) have +// no O_NONBLOCK and no Unix FIFOs, and opening a filesystem path does not +// block the same way, so a plain open is used. Non-regular files (e.g. +// Windows device names like NUL) are still rejected by the caller's +// descriptor check before any read. +func OpenContentFile(path string) (*os.File, error) { + return os.Open(path) +} diff --git a/pkg/tools/builtin/plan/open_unix.go b/pkg/tools/builtin/plan/open_unix.go new file mode 100644 index 0000000000..da179d9753 --- /dev/null +++ b/pkg/tools/builtin/plan/open_unix.go @@ -0,0 +1,24 @@ +//go:build unix + +package plan + +import ( + "os" + "syscall" +) + +// OpenContentFile opens a plan-content source file read-only so the caller +// can validate what it actually opened — with File.Stat on the returned +// descriptor — before reading, instead of trusting a pre-open stat of the +// path that a concurrent swap could invalidate. The open itself must not +// hang: a plain open(2) of a FIFO with no writer blocks forever, and some +// devices (e.g. serial lines) block on open too, so the file is opened with +// O_NONBLOCK, which makes those opens return immediately. Callers reject +// anything non-regular after inspecting the descriptor; for the regular +// files that remain, O_NONBLOCK has no effect on reads, so the flag is left +// set. It is exported so host-side callers (e.g. the docker agent plans CLI) +// open user-supplied content paths with the same hang-safety instead of +// duplicating the platform logic. +func OpenContentFile(path string) (*os.File, error) { + return os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0) +} diff --git a/pkg/tools/builtin/plan/plan.go b/pkg/tools/builtin/plan/plan.go index a3bb3b05e6..89b4895872 100644 --- a/pkg/tools/builtin/plan/plan.go +++ b/pkg/tools/builtin/plan/plan.go @@ -8,13 +8,14 @@ // // Concurrency: agents that share one ToolSet instance also share its Storage, // which serializes their operations. The default FilesystemStorage guards its -// read-modify-write revision bump with a mutex and writes atomically -// (write-to-temp + rename), so a reader — including a separate docker-agent -// process — never observes a partially written plan. Two distinct processes -// writing the *same* plan at the very same instant can still race on the -// revision bump (last writer wins); this is acceptable for the intended -// in-process multi-agent collaboration. Other backends can make the bump -// atomic. +// read-modify-write revision bump with a mutex within the process and with an +// exclusive advisory lock on a sentinel file in the plans directory across +// processes, and writes atomically (write-to-temp + rename). A reader — +// including a separate docker-agent process — never observes a partially +// written plan, and two processes writing the *same* plan at the very same +// instant cannot both pass an optimistic-lock check: exactly one wins and the +// other gets a deterministic *VersionConflictError. Other backends own the +// same guarantees through their Upsert/Delete implementations. package plan import ( @@ -48,11 +49,35 @@ const ( ToolNameGetPlanStatus = "get_plan_status" ) -// maxPlanFileSize caps how much update_plan_from_file will read from disk, so a -// pathological or wrong path cannot make the agent pull an arbitrarily large -// file into a plan (and into the model's context). 10 MiB is far above any -// realistic plan while still bounding memory. -const maxPlanFileSize = 10 << 20 +// MaxPlanContentSize caps a plan's content (its markdown body): it bounds how +// much update_plan_from_file will read from disk, so a pathological or wrong +// path cannot make the agent pull an arbitrarily large file into a plan (and +// into the model's context), and FilesystemStorage refuses to persist content +// beyond it. 10 MiB is far above any realistic plan while still bounding +// memory, and content of exactly this size is accepted. It is exported so +// host-side callers (e.g. the docker agent plans CLI) bound plan content with +// the same limit instead of duplicating the number. +const MaxPlanContentSize = 10 << 20 + +// MaxPlanFileSize is the name MaxPlanContentSize was first exported under. +// +// Deprecated: use MaxPlanContentSize. Kept as an alias so external callers +// built against the original export keep compiling and share the same bound. +const MaxPlanFileSize = MaxPlanContentSize + +// maxEncodedPlanSize bounds a stored plan file on the way back in: load +// refuses to decode anything beyond it, so a corrupt or foreign oversized +// file in the plans directory cannot cause unbounded allocation. It is +// deliberately larger than MaxPlanContentSize: JSON escaping expands a +// content byte to at most 6 bytes (\u00XX), so content of exactly the cap +// encodes to at most 6*MaxPlanContentSize. The additional 10 MiB budgets +// the free-form metadata (title, author, status), the fixed fields, and the +// envelope. Metadata deliberately has no per-field cap — labels are +// free-form and plans stored by earlier builds must keep loading — so this +// budget is at least the headroom metadata effectively had under the +// previous 10 MiB whole-file bound; save re-checks the encoded size, so +// only pathological aggregate metadata is ever refused, as a storage limit. +const maxEncodedPlanSize = 6*MaxPlanContentSize + 10<<20 // ErrPlanNotFound is returned by write operations that require an existing plan // (set_plan_status) when the named plan does not exist, so callers can tell a @@ -73,6 +98,21 @@ func (e *VersionConflictError) Error() string { return fmt.Sprintf("version conflict on plan %q: last_known_revision %d does not match current revision %d; re-read the plan and retry", e.Name, e.Expected, e.Current) } +// CorruptPlanError is returned when a plan file exists but cannot be decoded, +// so callers can tell a damaged plan apart from a missing one or a plain I/O +// failure without matching on error text. +type CorruptPlanError struct { + // File is the base name of the plan file that failed to decode. + File string + Err error +} + +func (e *CorruptPlanError) Error() string { + return fmt.Sprintf("plan file %s is corrupt: %v", e.File, e.Err) +} + +func (e *CorruptPlanError) Unwrap() error { return e.Err } + // namePattern defines the accepted plan-name format: a lowercase slug made of // alphanumerics, '-' and '_'. Names are validated against it rather than being // silently rewritten, so two different inputs can never collapse onto the same @@ -80,6 +120,16 @@ func (e *VersionConflictError) Error() string { // plans directory via path separators or "..". var namePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`) +// ValidateName rejects names that do not match namePattern. It is the +// canonical plan-name rule, exported so host-side callers (e.g. pkg/plans) +// validate exactly like the storage instead of duplicating the pattern. +func ValidateName(name string) error { + if !namePattern.MatchString(name) { + return fmt.Errorf("invalid plan name %q: use only lowercase letters, digits, '-' and '_', starting with a letter or digit", name) + } + return nil +} + // Plan is a shared document collaborated on by the agents. type Plan struct { Name string `json:"name"` @@ -139,7 +189,12 @@ type ExportResult struct { // enables optimistic locking: when non-nil, the write is rejected with a // *VersionConflictError unless it equals the plan's current revision. MustExist // makes the write fail with ErrPlanNotFound when the plan does not already -// exist (used by set_plan_status, which must not create a plan). +// exist (used by set_plan_status, which must not create a plan). MustNotExist +// makes the write create-only by existence, not by revision: any existing +// plan is rejected with a *VersionConflictError carrying Expected 0 and the +// plan's current revision — including revision 0, which a stored plan can +// legitimately carry (a hand-written or foreign file that omits the field), +// so ExpectedRevision 0 alone is not a create-only guard. type UpsertRequest struct { Name string Content *string @@ -148,6 +203,7 @@ type UpsertRequest struct { Status *string ExpectedRevision *int MustExist bool + MustNotExist bool } // ListResult is the output of list_plans. Warnings lists plan files that could @@ -170,10 +226,11 @@ type Storage interface { Get(ctx context.Context, name string) (Plan, bool, error) // Upsert creates or updates a plan as described by req: nil fields are // preserved from the previous revision, the optimistic-lock check and the - // must-exist guard are honoured atomically with the write, the revision is + // existence guards are honoured atomically with the write, the revision is // bumped and UpdatedAt stamped. It returns a *VersionConflictError on a - // revision mismatch and ErrPlanNotFound when req.MustExist is set but the - // plan is absent. + // revision mismatch or when req.MustNotExist is set but the plan exists + // (Expected 0, Current the plan's revision — even revision 0), and + // ErrPlanNotFound when req.MustExist is set but the plan is absent. Upsert(ctx context.Context, req UpsertRequest) (Plan, error) // List returns a summary of every stored plan. Warnings carries entries // that could not be read, so a caller can tell "no plans" apart from "some @@ -186,16 +243,104 @@ type Storage interface { Delete(ctx context.Context, name string, expectedRevision *int) (deleted bool, err error) } +// Change action labels reported through ChangeNotifier subscriptions. +const ( + ChangeActionWrite = "write" + ChangeActionStatus = "status" + ChangeActionDelete = "delete" +) + +// Change describes one successful mutation of a shared plan. It carries +// identity and version only — never content — so observers refresh through +// their own storage view instead of trusting an event payload. Revision is +// the version after a write, or the guard version of a guarded delete (0 +// when the delete was unguarded). It deliberately carries no mutator +// identity: the shared singleton toolset executes tool calls for whichever +// session's agent is running, so a subscriber must not attribute a change to +// its own agent. The plan's Author field is the collaborative attribution. +type Change struct { + Name string + Action string + Revision int +} + +// ChangeNotifier is the optional host-notification capability of the plan +// toolset: hosts (e.g. one runtime stream per TUI tab) subscribe callbacks +// that fire after every successful mutation and never for failed ones. +// Discover it through toolset wrappers with tools.As. Any number of +// subscribers may be active at once — every one receives every change — +// because the shared singleton toolset serves all sessions in the process. +// Callbacks must be safe for concurrent use and must not block; they do not +// alter tool schemas, outputs, or storage behavior in any way. +type ChangeNotifier interface { + // SubscribeChanges registers cb and returns an idempotent unsubscribe + // function that removes exactly this subscription. + SubscribeChanges(cb func(Change)) (unsubscribe func()) +} + type ToolSet struct { storage Storage + + // subsMu guards the subscriber registry. A mutex (not an atomic slot) + // because the shared singleton toolset serves several sessions at once: + // each active stream holds its own subscription, and subscriptions come + // and go while other sessions' tool calls are notifying concurrently. + subsMu sync.Mutex + subscribers map[uint64]func(Change) + nextSubID uint64 } var ( _ tools.ToolSet = (*ToolSet)(nil) _ tools.Instructable = (*ToolSet)(nil) _ tools.Describer = (*ToolSet)(nil) + _ ChangeNotifier = (*ToolSet)(nil) ) +// SubscribeChanges registers cb to run after every successful plan mutation +// and returns an unsubscribe function. Every active subscriber receives every +// change exactly once; unsubscribing removes only that subscription and is +// idempotent. A nil cb registers nothing and returns a no-op unsubscribe. +// Implements ChangeNotifier. +func (t *ToolSet) SubscribeChanges(cb func(Change)) func() { + if cb == nil { + return func() {} + } + + t.subsMu.Lock() + defer t.subsMu.Unlock() + if t.subscribers == nil { + t.subscribers = make(map[uint64]func(Change)) + } + id := t.nextSubID + t.nextSubID++ + t.subscribers[id] = cb + return func() { + t.subsMu.Lock() + defer t.subsMu.Unlock() + // Deleting a missing key is a no-op, which makes unsubscribe + // idempotent and safe to call from multiple teardown paths. + delete(t.subscribers, id) + } +} + +// notifyChange fans change out to all current subscribers. The subscriber +// set is copied under the lock and invoked outside it, so a callback can +// subscribe or unsubscribe without deadlocking the registry and a slow +// callback never blocks concurrent subscription changes. +func (t *ToolSet) notifyChange(change Change) { + t.subsMu.Lock() + subs := make([]func(Change), 0, len(t.subscribers)) + for _, cb := range t.subscribers { + subs = append(subs, cb) + } + t.subsMu.Unlock() + + for _, cb := range subs { + cb(change) + } +} + // Option configures a ToolSet. type Option func(*ToolSet) @@ -231,6 +376,14 @@ func CreateToolSet() (tools.ToolSet, error) { return sharedToolSet(), nil } +// SharedStorage returns the Storage behind the process-wide shared ToolSet +// (CreateToolSet). Host code that manages plans outside agent tool calls +// (e.g. a TUI) should use it so both sides serialize on the same instance +// and mutex. ToolSets built with New keep their own independent storage. +func SharedStorage() Storage { + return sharedToolSet().storage +} + // DefaultDir is the global shared folder where plans are stored, under the // docker-agent data directory. func DefaultDir() string { @@ -335,6 +488,7 @@ func (t *ToolSet) writePlan(ctx context.Context, params WritePlanArgs) (*tools.T return tools.ResultError(err.Error()), nil } + t.notifyChange(Change{Name: plan.Name, Action: ChangeActionWrite, Revision: plan.Revision}) return tools.ResultJSON(plan), nil } @@ -372,6 +526,7 @@ func (t *ToolSet) updatePlanFromFile(ctx context.Context, params UpdatePlanFromF return tools.ResultError(err.Error()), nil } + t.notifyChange(Change{Name: plan.Name, Action: ChangeActionWrite, Revision: plan.Revision}) return tools.ResultJSON(plan), nil } @@ -428,6 +583,7 @@ func (t *ToolSet) setPlanStatus(ctx context.Context, params SetPlanStatusArgs) ( return tools.ResultError(err.Error()), nil } + t.notifyChange(Change{Name: plan.Name, Action: ChangeActionStatus, Revision: plan.Revision}) return tools.ResultJSON(StatusView{Name: plan.Name, Status: plan.Status, Revision: plan.Revision}), nil } @@ -491,51 +647,57 @@ func (t *ToolSet) deletePlan(ctx context.Context, params DeletePlanArgs) (*tools return tools.ResultError(fmt.Sprintf("plan %q not found", params.Name)), nil } + revision := 0 + if params.LastKnownRevision != nil { + revision = *params.LastKnownRevision + } + t.notifyChange(Change{Name: params.Name, Action: ChangeActionDelete, Revision: revision}) return tools.ResultJSON(map[string]string{"deleted": params.Name}), nil } // readPlanFile reads plan content from a file for update_plan_from_file. It -// rejects directories, non-regular files, and oversized files up front so a -// wrong path fails with a clear message instead of pulling unexpected data into -// a plan. The non-regular check matters for safety as well as clarity: a device -// (e.g. /dev/zero) or a named pipe reports size 0 from stat yet would stream -// unbounded data or block forever if opened, so it is rejected before any open. -// The read itself goes through an io.LimitReader rather than trusting the stat -// size, which closes the race where the file grows between stat and read. +// rejects directories, non-regular files, and oversized files with a clear +// message instead of pulling unexpected data into a plan. Every check runs on +// the opened descriptor (File.Stat), never on the path, so the file that is +// validated is exactly the file that is read and a concurrent path swap +// cannot slip a directory, device, or named pipe past the checks. The open +// itself is hang-safe for those cases too (see OpenContentFile), and a +// device like /dev/zero, which would stream unbounded data, is rejected +// before any read. The read goes through an io.LimitReader rather than +// trusting the stat size, which closes the race where the file grows between +// the size check and the read. func readPlanFile(path string) (string, error) { - clean := filepath.Clean(path) - - info, err := os.Stat(clean) + f, err := OpenContentFile(filepath.Clean(path)) if err != nil { if errors.Is(err, os.ErrNotExist) { return "", fmt.Errorf("file %q does not exist", path) } return "", fmt.Errorf("reading plan file: %w", err) } + defer f.Close() + + info, err := f.Stat() + if err != nil { + return "", fmt.Errorf("reading plan file: %w", err) + } if info.IsDir() { return "", fmt.Errorf("path %q is a directory, not a file", path) } if !info.Mode().IsRegular() { return "", fmt.Errorf("path %q is not a regular file (e.g. a device or named pipe)", path) } - if info.Size() > maxPlanFileSize { - return "", fmt.Errorf("file %q is too large (%d bytes; max %d)", path, info.Size(), maxPlanFileSize) + if info.Size() > MaxPlanContentSize { + return "", fmt.Errorf("file %q is too large (%d bytes; max %d)", path, info.Size(), MaxPlanContentSize) } - f, err := os.Open(clean) - if err != nil { - return "", fmt.Errorf("reading plan file: %w", err) - } - defer f.Close() - // Read one byte past the cap so an over-cap file is detected even if it grew - // since the stat above. - data, err := io.ReadAll(io.LimitReader(f, maxPlanFileSize+1)) + // since the size check above. + data, err := io.ReadAll(io.LimitReader(f, MaxPlanContentSize+1)) if err != nil { return "", fmt.Errorf("reading plan file: %w", err) } - if len(data) > maxPlanFileSize { - return "", fmt.Errorf("file %q is too large (max %d bytes)", path, maxPlanFileSize) + if len(data) > MaxPlanContentSize { + return "", fmt.Errorf("file %q is too large (max %d bytes)", path, MaxPlanContentSize) } return string(data), nil } @@ -658,9 +820,19 @@ func (t *ToolSet) Tools(context.Context) ([]tools.Tool, error) { // FilesystemStorage is the default Storage. It persists each plan as a JSON // file named .json in a directory, with atomic temp+rename writes, plan -// name validation, and unreadable-file warnings on List. A mutex serializes its -// operations so the read-modify-write revision bump in Upsert is consistent -// within a process. +// name validation, and unreadable-file warnings on List. Upsert and Delete +// hold an exclusive advisory lock on a persistent sentinel file +// (lockFileName) in the same directory, so their check-and-mutate windows are +// serialized against other processes sharing the directory and a stale +// ExpectedRevision always fails deterministically instead of clobbering a +// concurrent write. A mutex serializes the local check-and-mutate windows +// within the process; mutations take it only after the sentinel lock is held, +// so a writer waiting for a contended sentinel never blocks another local +// mutation's chance to fail fast on the sentinel. Get and List take neither +// the file lock nor the mutex: writes are atomic (temp + rename), so a +// lock-free reader always sees a complete plan — either the previous or the +// new revision, never a partial file — and a mutation blocked on wedged +// storage can never stall readers. type FilesystemStorage struct { mu sync.Mutex dir string @@ -685,8 +857,8 @@ func (s *FilesystemStorage) String() string { // which guarantees a one-to-one mapping between names and files and prevents // path traversal. func (s *FilesystemStorage) planPath(name string) (string, error) { - if !namePattern.MatchString(name) { - return "", fmt.Errorf("invalid plan name %q: use only lowercase letters, digits, '-' and '_', starting with a letter or digit", name) + if err := ValidateName(name); err != nil { + return "", err } return filepath.Join(s.dir, name+".json"), nil } @@ -694,23 +866,63 @@ func (s *FilesystemStorage) planPath(name string) (string, error) { // load reads and decodes the plan at path. It distinguishes a missing plan // (false, nil) from a real failure such as a permission error or corrupt JSON // (false, err), so callers can report the latter instead of masking it as -// "not found". +// "not found". The open is hang-safe (see OpenContentFile) and the descriptor +// is validated before any read — a .json entry that is a directory, device, +// or FIFO is a foreign file, reported as a typed *CorruptPlanError — so a +// FIFO with no writer can never wedge Get, List, or an Upsert's pre-read. +// Checking the opened descriptor (File.Stat), never the path, means a +// concurrent path swap cannot slip a non-regular file past the check. The +// read is bounded by maxEncodedPlanSize: this package never persists a +// larger file (save validates content and metadata against caps that always +// encode within it), so anything beyond it is a foreign or damaged file and +// is reported as corrupt rather than being slurped into memory whole. func (s *FilesystemStorage) load(path string) (Plan, bool, error) { - data, err := os.ReadFile(path) + f, err := OpenContentFile(path) if errors.Is(err, os.ErrNotExist) { return Plan{}, false, nil } if err != nil { return Plan{}, false, fmt.Errorf("reading plan: %w", err) } + defer f.Close() + + info, err := f.Stat() + if err != nil { + return Plan{}, false, fmt.Errorf("reading plan: %w", err) + } + if info.IsDir() { + return Plan{}, false, &CorruptPlanError{File: filepath.Base(path), Err: errors.New("is a directory, not a plan file")} + } + if !info.Mode().IsRegular() { + return Plan{}, false, &CorruptPlanError{File: filepath.Base(path), Err: errors.New("not a regular file (e.g. a device or named pipe)")} + } + + // Read one byte past the cap so an over-cap file is detected without + // trusting a stat size that could change under us. + data, err := io.ReadAll(io.LimitReader(f, maxEncodedPlanSize+1)) + if err != nil { + return Plan{}, false, fmt.Errorf("reading plan: %w", err) + } + if len(data) > maxEncodedPlanSize { + return Plan{}, false, &CorruptPlanError{File: filepath.Base(path), Err: fmt.Errorf("file exceeds %d bytes", maxEncodedPlanSize)} + } var p Plan if err := json.Unmarshal(data, &p); err != nil { - return Plan{}, false, fmt.Errorf("plan file %s is corrupt: %w", filepath.Base(path), err) + return Plan{}, false, &CorruptPlanError{File: filepath.Base(path), Err: err} } return p, true, nil } func (s *FilesystemStorage) save(path string, p Plan) error { + // Validate the content cap before marshaling so an oversized plan is + // refused with a precise message and without first allocating its + // encoding. Content of exactly MaxPlanContentSize is accepted. Metadata + // (title, author, status) is free-form and has no per-field cap: plans + // stored by earlier builds must keep loading and re-saving whatever + // labels they carry. + if len(p.Content) > MaxPlanContentSize { + return fmt.Errorf("plan %q content is too large to store (%d bytes; max %d)", p.Name, len(p.Content), MaxPlanContentSize) + } if err := os.MkdirAll(s.dir, 0o700); err != nil { return fmt.Errorf("creating plans directory: %w", err) } @@ -718,6 +930,13 @@ func (s *FilesystemStorage) save(path string, p Plan) error { if err != nil { return fmt.Errorf("marshaling plan: %w", err) } + // Storage limit, not a semantic cap: valid content always fits (the + // bound absorbs its worst-case escaping), so only pathological aggregate + // metadata can land here, and a plan is never persisted in a form load + // would then refuse to decode. + if len(data) > maxEncodedPlanSize { + return fmt.Errorf("plan %q is too large to store (%d bytes; max %d)", p.Name, len(data), maxEncodedPlanSize) + } // Atomic write (temp file + rename): readers in other agents or processes // see either the old or the new content, never a partial file, and an // existing symlink at path is replaced rather than followed. @@ -727,15 +946,22 @@ func (s *FilesystemStorage) save(path string, p Plan) error { return nil } -func (s *FilesystemStorage) Get(_ context.Context, name string) (Plan, bool, error) { +func (s *FilesystemStorage) Get(ctx context.Context, name string) (Plan, bool, error) { path, err := s.planPath(name) if err != nil { return Plan{}, false, err } + // Observe cancellation before touching the filesystem. A blocked open on + // truly wedged storage (e.g. a dead NFS mount) cannot be interrupted, but + // a caller whose deadline already expired gets its context error instead + // of another read. + if err := ctx.Err(); err != nil { + return Plan{}, false, err + } - s.mu.Lock() - defer s.mu.Unlock() - + // Deliberately lock-free (no s.mu): writes are atomic (temp + rename), so + // this always reads a complete plan, and a mutation blocked on wedged + // storage while holding s.mu can never stall a reader. plan, ok, err := s.load(path) if ok { // The filename is the authoritative key (List and Upsert key off it), so @@ -747,12 +973,24 @@ func (s *FilesystemStorage) Get(_ context.Context, name string) (Plan, bool, err return plan, ok, err } -func (s *FilesystemStorage) Upsert(_ context.Context, req UpsertRequest) (Plan, error) { +func (s *FilesystemStorage) Upsert(ctx context.Context, req UpsertRequest) (Plan, error) { path, err := s.planPath(req.Name) if err != nil { return Plan{}, err } + // Lock order: cross-process sentinel first, s.mu second, with s.mu held + // only around the local load/check/save window. s.mu serializes the + // check-and-mutate windows of same-process writers where the sentinel + // lock is a no-op (js/wasm, wasip1, plan9); Get and List are lock-free, + // so a mutation blocked here never stalls readers. Every mutation takes + // the locks in this order, so they cannot deadlock. + release, err := acquireFileLock(ctx, filepath.Join(s.dir, lockFileName)) + if err != nil { + return Plan{}, err + } + defer release() + s.mu.Lock() defer s.mu.Unlock() @@ -763,9 +1001,19 @@ func (s *FilesystemStorage) Upsert(_ context.Context, req UpsertRequest) (Plan, if req.MustExist && !exists { return Plan{}, fmt.Errorf("%w: %q", ErrPlanNotFound, req.Name) } + // Create-only guard: any existing plan conflicts, whatever its revision. + // Existence is checked directly rather than through the optimistic lock + // below because revision 0 is not proof of absence — a stored plan file + // that omits the revision field legitimately reads back as revision 0 + // and must not be overwritten by a create. + if req.MustNotExist && exists { + return Plan{}, &VersionConflictError{Name: req.Name, Expected: 0, Current: plan.Revision} + } // Optimistic-lock check: reject the write if another writer bumped the - // revision since the caller last read it. Checked under the same lock as - // the load+save below so the compare-and-set is atomic within the process. + // revision since the caller last read it. Checked while both the mutex and + // the cross-process file lock are held, so the compare-and-set is atomic + // against writers in this process and in any other process sharing the + // directory. if req.ExpectedRevision != nil && plan.Revision != *req.ExpectedRevision { return Plan{}, &VersionConflictError{Name: req.Name, Expected: *req.ExpectedRevision, Current: plan.Revision} } @@ -796,9 +1044,13 @@ func (s *FilesystemStorage) Upsert(_ context.Context, req UpsertRequest) (Plan, return plan, nil } -func (s *FilesystemStorage) List(_ context.Context) ([]Summary, []string, error) { - s.mu.Lock() - defer s.mu.Unlock() +func (s *FilesystemStorage) List(ctx context.Context) ([]Summary, []string, error) { + // Deliberately lock-free (no s.mu), like Get: every plan file is read + // through the same atomic-write guarantee, so holding the mutex across a + // whole directory walk would only let a wedged mutation stall listings. + if err := ctx.Err(); err != nil { + return nil, nil, err + } entries, err := os.ReadDir(s.dir) if err != nil { @@ -811,6 +1063,12 @@ func (s *FilesystemStorage) List(_ context.Context) ([]Summary, []string, error) plans := []Summary{} var warnings []string for _, entry := range entries { + // The directory is read file by file; observe cancellation between + // files so an expired deadline stops the walk instead of pointlessly + // finishing it. + if err := ctx.Err(); err != nil { + return nil, nil, err + } if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { continue } @@ -847,20 +1105,30 @@ func (s *FilesystemStorage) List(_ context.Context) ([]Summary, []string, error) return plans, warnings, nil } -func (s *FilesystemStorage) Delete(_ context.Context, name string, expectedRevision *int) (bool, error) { +func (s *FilesystemStorage) Delete(ctx context.Context, name string, expectedRevision *int) (bool, error) { path, err := s.planPath(name) if err != nil { return false, err } + // Same lock order as Upsert: sentinel first, s.mu only around the local + // load/check/remove window, so a delete blocked on the sentinel never + // stalls readers. + release, err := acquireFileLock(ctx, filepath.Join(s.dir, lockFileName)) + if err != nil { + return false, err + } + defer release() + s.mu.Lock() defer s.mu.Unlock() // With an optimistic-lock guard we must read the current revision first, so - // the compare-and-delete is atomic under the lock. This means a guarded - // delete of a corrupt plan fails (its revision can't be read to compare); - // recovering from a corrupt plan is done with an unguarded delete, which - // removes the file directly without pre-loading it. + // the compare-and-delete is atomic under the locks (in-process mutex plus + // cross-process file lock). This means a guarded delete of a corrupt plan + // fails (its revision can't be read to compare); recovering from a corrupt + // plan is done with an unguarded delete, which removes the file directly + // without pre-loading it. if expectedRevision != nil { plan, ok, err := s.load(path) if err != nil { diff --git a/pkg/tools/builtin/plan/plan_change_test.go b/pkg/tools/builtin/plan/plan_change_test.go new file mode 100644 index 0000000000..7225351085 --- /dev/null +++ b/pkg/tools/builtin/plan/plan_change_test.go @@ -0,0 +1,284 @@ +package plan + +import ( + "fmt" + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/tools" +) + +// changeRecorder collects Change notifications for assertions. +type changeRecorder struct { + changes []Change +} + +func (r *changeRecorder) callback() func(Change) { + return func(c Change) { r.changes = append(r.changes, c) } +} + +func writeTempFile(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "content.md") + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + return path +} + +func TestSubscribeChanges_WriteEmitsOnSuccess(t *testing.T) { + t.Parallel() + tool := newTestPlanTool(t) + rec := &changeRecorder{} + tool.SubscribeChanges(rec.callback()) + + result, err := tool.writePlan(t.Context(), WritePlanArgs{Name: "p", Content: "v1"}) + require.NoError(t, err) + require.False(t, result.IsError) + + require.Len(t, rec.changes, 1) + assert.Equal(t, Change{Name: "p", Action: ChangeActionWrite, Revision: 1}, rec.changes[0]) + + result, err = tool.writePlan(t.Context(), WritePlanArgs{Name: "p", Content: "v2"}) + require.NoError(t, err) + require.False(t, result.IsError) + require.Len(t, rec.changes, 2) + assert.Equal(t, Change{Name: "p", Action: ChangeActionWrite, Revision: 2}, rec.changes[1]) +} + +func TestSubscribeChanges_UpdateFromFileEmitsWrite(t *testing.T) { + t.Parallel() + tool := newTestPlanTool(t) + rec := &changeRecorder{} + tool.SubscribeChanges(rec.callback()) + + path := writeTempFile(t, "content from file") + result, err := tool.updatePlanFromFile(t.Context(), UpdatePlanFromFileArgs{Name: "p", Path: path}) + require.NoError(t, err) + require.False(t, result.IsError) + + require.Len(t, rec.changes, 1) + assert.Equal(t, Change{Name: "p", Action: ChangeActionWrite, Revision: 1}, rec.changes[0]) +} + +func TestSubscribeChanges_SetStatusEmitsStatus(t *testing.T) { + t.Parallel() + tool := newTestPlanTool(t) + _, err := tool.writePlan(t.Context(), WritePlanArgs{Name: "p", Content: "v1"}) + require.NoError(t, err) + + rec := &changeRecorder{} + tool.SubscribeChanges(rec.callback()) + + result, err := tool.setPlanStatus(t.Context(), SetPlanStatusArgs{Name: "p", Status: "done"}) + require.NoError(t, err) + require.False(t, result.IsError) + + require.Len(t, rec.changes, 1) + assert.Equal(t, Change{Name: "p", Action: ChangeActionStatus, Revision: 2}, rec.changes[0]) +} + +func TestSubscribeChanges_DeleteEmitsDelete(t *testing.T) { + t.Parallel() + tool := newTestPlanTool(t) + _, err := tool.writePlan(t.Context(), WritePlanArgs{Name: "p", Content: "v1"}) + require.NoError(t, err) + + rec := &changeRecorder{} + tool.SubscribeChanges(rec.callback()) + + rev := 1 + result, err := tool.deletePlan(t.Context(), DeletePlanArgs{Name: "p", LastKnownRevision: &rev}) + require.NoError(t, err) + require.False(t, result.IsError) + + require.Len(t, rec.changes, 1) + assert.Equal(t, Change{Name: "p", Action: ChangeActionDelete, Revision: 1}, rec.changes[0]) +} + +func TestSubscribeChanges_UnguardedDeleteEmitsZeroRevision(t *testing.T) { + t.Parallel() + tool := newTestPlanTool(t) + _, err := tool.writePlan(t.Context(), WritePlanArgs{Name: "p", Content: "v1"}) + require.NoError(t, err) + + rec := &changeRecorder{} + tool.SubscribeChanges(rec.callback()) + + result, err := tool.deletePlan(t.Context(), DeletePlanArgs{Name: "p"}) + require.NoError(t, err) + require.False(t, result.IsError) + + require.Len(t, rec.changes, 1) + assert.Equal(t, Change{Name: "p", Action: ChangeActionDelete, Revision: 0}, rec.changes[0]) +} + +func TestSubscribeChanges_NoEmissionOnFailure(t *testing.T) { + t.Parallel() + tool := newTestPlanTool(t) + _, err := tool.writePlan(t.Context(), WritePlanArgs{Name: "p", Content: "v1"}) + require.NoError(t, err) + + rec := &changeRecorder{} + tool.SubscribeChanges(rec.callback()) + + // Empty content: validation failure. + result, err := tool.writePlan(t.Context(), WritePlanArgs{Name: "p", Content: ""}) + require.NoError(t, err) + assert.True(t, result.IsError) + + // Version conflict. + stale := 42 + result, err = tool.writePlan(t.Context(), WritePlanArgs{Name: "p", Content: "v2", LastKnownRevision: &stale}) + require.NoError(t, err) + assert.True(t, result.IsError) + + result, err = tool.setPlanStatus(t.Context(), SetPlanStatusArgs{Name: "p", Status: "done", LastKnownRevision: &stale}) + require.NoError(t, err) + assert.True(t, result.IsError) + + // Deleting a missing plan. + result, err = tool.deletePlan(t.Context(), DeletePlanArgs{Name: "missing"}) + require.NoError(t, err) + assert.True(t, result.IsError) + + assert.Empty(t, rec.changes, "failed writes must not notify") +} + +func TestSubscribeChanges_MultipleSubscribersEachReceiveOnce(t *testing.T) { + t.Parallel() + tool := newTestPlanTool(t) + recA := &changeRecorder{} + recB := &changeRecorder{} + recC := &changeRecorder{} + tool.SubscribeChanges(recA.callback()) + tool.SubscribeChanges(recB.callback()) + tool.SubscribeChanges(recC.callback()) + + result, err := tool.writePlan(t.Context(), WritePlanArgs{Name: "p", Content: "v1"}) + require.NoError(t, err) + require.False(t, result.IsError) + + want := Change{Name: "p", Action: ChangeActionWrite, Revision: 1} + for name, rec := range map[string]*changeRecorder{"A": recA, "B": recB, "C": recC} { + require.Len(t, rec.changes, 1, "subscriber %s must receive the change exactly once", name) + assert.Equal(t, want, rec.changes[0], "subscriber %s", name) + } +} + +func TestSubscribeChanges_UnsubscribeIsIsolatedAndIdempotent(t *testing.T) { + t.Parallel() + tool := newTestPlanTool(t) + recA := &changeRecorder{} + recB := &changeRecorder{} + unsubA := tool.SubscribeChanges(recA.callback()) + unsubB := tool.SubscribeChanges(recB.callback()) + + _, err := tool.writePlan(t.Context(), WritePlanArgs{Name: "p", Content: "v1"}) + require.NoError(t, err) + require.Len(t, recA.changes, 1) + require.Len(t, recB.changes, 1) + + unsubA() + unsubA() // idempotent: a second call must not disturb other subscriptions + + _, err = tool.writePlan(t.Context(), WritePlanArgs{Name: "p", Content: "v2"}) + require.NoError(t, err) + assert.Len(t, recA.changes, 1, "unsubscribed A must not be called again") + require.Len(t, recB.changes, 2, "B must keep receiving after A unsubscribed") + + unsubB() + _, err = tool.writePlan(t.Context(), WritePlanArgs{Name: "p", Content: "v3"}) + require.NoError(t, err) + assert.Len(t, recA.changes, 1) + assert.Len(t, recB.changes, 2) +} + +func TestSubscribeChanges_NilCallbackIsSafe(t *testing.T) { + t.Parallel() + tool := newTestPlanTool(t) + + // Never subscribed: writes succeed without any subscriber. + result, err := tool.writePlan(t.Context(), WritePlanArgs{Name: "p", Content: "v1"}) + require.NoError(t, err) + require.False(t, result.IsError) + + // A nil callback registers nothing and yields a callable unsubscribe. + unsub := tool.SubscribeChanges(nil) + require.NotNil(t, unsub) + unsub() + unsub() + + result, err = tool.writePlan(t.Context(), WritePlanArgs{Name: "p", Content: "v2"}) + require.NoError(t, err) + require.False(t, result.IsError) +} + +// TestSubscribeChanges_ConcurrentSubscribeUnsubscribeNotify hammers the +// registry from three directions at once; run with -race to prove the +// subscription lifecycle is race-clean. Note: an in-flight notification that +// snapshotted the subscribers before an unsubscribe may still deliver once +// (copy-then-invoke keeps callbacks out of the registry lock), so this test +// asserts race-cleanliness and exact delivery to a steady subscriber, not +// cut-off timing for churning ones. +func TestSubscribeChanges_ConcurrentSubscribeUnsubscribeNotify(t *testing.T) { + t.Parallel() + tool := newTestPlanTool(t) + + const ( + writers = 4 + writesPerGo = 10 + churners = 4 + churnsPerGo = 25 + ) + + var steady atomic.Int64 + unsubSteady := tool.SubscribeChanges(func(Change) { steady.Add(1) }) + defer unsubSteady() + + var wg sync.WaitGroup + for w := range writers { + wg.Go(func() { + for i := range writesPerGo { + name := fmt.Sprintf("plan-%d", w) + _, err := tool.writePlan(t.Context(), WritePlanArgs{Name: name, Content: fmt.Sprintf("v%d", i)}) + assert.NoError(t, err) + } + }) + } + for range churners { + wg.Go(func() { + for range churnsPerGo { + var count atomic.Int64 + unsub := tool.SubscribeChanges(func(Change) { count.Add(1) }) + unsub() + unsub() // idempotent under concurrency too + } + }) + } + wg.Wait() + + assert.Equal(t, int64(writers*writesPerGo), steady.Load(), + "the steady subscriber must see every successful write exactly once") +} + +func TestChangeNotifier_DiscoverableThroughWrapper(t *testing.T) { + t.Parallel() + tool := newTestPlanTool(t) + wrapped := tools.NewStartable(tool) + + notifier, ok := tools.As[ChangeNotifier](wrapped) + require.True(t, ok, "ChangeNotifier must be reachable through toolset wrappers") + + rec := &changeRecorder{} + unsub := notifier.SubscribeChanges(rec.callback()) + defer unsub() + + _, err := tool.writePlan(t.Context(), WritePlanArgs{Name: "p", Content: "v1"}) + require.NoError(t, err) + require.Len(t, rec.changes, 1) +} diff --git a/pkg/tools/builtin/plan/plan_lock_test.go b/pkg/tools/builtin/plan/plan_lock_test.go new file mode 100644 index 0000000000..46ae1f4ff6 --- /dev/null +++ b/pkg/tools/builtin/plan/plan_lock_test.go @@ -0,0 +1,655 @@ +package plan + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// helperEnv gates TestPlanLockHelperProcess so it only runs as a spawned +// subprocess, never as a regular test. +const helperEnv = "PLAN_LOCK_HELPER" + +// Helper exit codes, the subprocess's only way to report its outcome. +const ( + helperExitSuccess = 0 + helperExitConflict = 3 + helperExitFailure = 4 +) + +// helperContentSize is how much content each racing helper writes, large +// enough that a torn or interleaved write would be detectable. +const helperContentSize = 256 << 10 + +func TestStorage_LockSentinelPersistsAndIsNotAPlan(t *testing.T) { + t.Parallel() + dir := t.TempDir() + s := NewFilesystemStorage(dir) + + _, err := s.Upsert(t.Context(), UpsertRequest{Name: "p", Content: new("v1")}) + require.NoError(t, err) + require.FileExists(t, filepath.Join(dir, lockFileName)) + + // The sentinel is invisible to List: no extra plan, no warning. + plans, warnings, err := s.List(t.Context()) + require.NoError(t, err) + require.Len(t, plans, 1) + assert.Equal(t, "p", plans[0].Name) + assert.Empty(t, warnings) + + // Deleting the last plan leaves the sentinel in place: removing it would + // let another process lock a different inode and lose mutual exclusion. + deleted, err := s.Delete(t.Context(), "p", nil) + require.NoError(t, err) + assert.True(t, deleted) + require.FileExists(t, filepath.Join(dir, lockFileName)) +} + +func TestStorage_WriteHonorsContextWhileLockHeld(t *testing.T) { + t.Parallel() + dir := t.TempDir() + s := NewFilesystemStorage(dir) + + _, err := s.Upsert(t.Context(), UpsertRequest{Name: "p", Content: new("v1")}) + require.NoError(t, err) + + release, err := acquireFileLock(t.Context(), filepath.Join(dir, lockFileName)) + require.NoError(t, err) + defer release() + + // A write blocked on the lock gives up when its context expires... + ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) + defer cancel() + _, err = s.Upsert(ctx, UpsertRequest{Name: "p", Content: new("v2")}) + require.ErrorIs(t, err, context.DeadlineExceeded) + + // ...and so does a delete. + ctx2, cancel2 := context.WithTimeout(t.Context(), 100*time.Millisecond) + defer cancel2() + _, err = s.Delete(ctx2, "p", new(1)) + require.ErrorIs(t, err, context.DeadlineExceeded) + + // The plan is untouched by the abandoned operations. + got, ok, err := s.Get(t.Context(), "p") + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, "v1", got.Content) + assert.Equal(t, 1, got.Revision) + + // An already-cancelled context fails fast without touching the lock. + cancelled, cancelNow := context.WithCancel(t.Context()) + cancelNow() + _, err = s.Upsert(cancelled, UpsertRequest{Name: "p", Content: new("v2")}) + require.ErrorIs(t, err, context.Canceled) +} + +// TestStorage_ReadsStayResponsiveWhileWriterWaitsForLock guards the lock +// ordering fix: mutations acquire the cross-process sentinel before the +// in-process mutex and hold the mutex only for their local window, so a +// writer parked on a sentinel held elsewhere (here: held externally, playing +// the role of another process) must never stall Get or List on the same +// storage. +func TestStorage_ReadsStayResponsiveWhileWriterWaitsForLock(t *testing.T) { + t.Parallel() + dir := t.TempDir() + s := NewFilesystemStorage(dir) + + _, err := s.Upsert(t.Context(), UpsertRequest{Name: "p", Content: new("v1")}) + require.NoError(t, err) + _, err = s.Upsert(t.Context(), UpsertRequest{Name: "q", Content: new("w1")}) + require.NoError(t, err) + + release, err := acquireFileLock(t.Context(), filepath.Join(dir, lockFileName)) + require.NoError(t, err) + released := false + defer func() { + if !released { + release() + } + }() + + // One Upsert and one Delete block on the held sentinel. Both goroutines + // are joined below, so the test also proves neither leaks. + upsertDone := make(chan error, 1) + go func() { + _, err := s.Upsert(t.Context(), UpsertRequest{Name: "p", Content: new("v2")}) + upsertDone <- err + }() + deleteDone := make(chan error, 1) + go func() { + deleted, err := s.Delete(t.Context(), "q", new(1)) + if err == nil && !deleted { + err = errors.New("delete reported nothing to delete") + } + deleteDone <- err + }() + + // Give both writers time to reach the sentinel wait; neither may finish. + select { + case err := <-upsertDone: + t.Fatalf("upsert completed while the sentinel was held externally (err=%v)", err) + case err := <-deleteDone: + t.Fatalf("delete completed while the sentinel was held externally (err=%v)", err) + case <-time.After(200 * time.Millisecond): + } + + // Get and List must complete with the old, complete revisions while the + // writers stay blocked. They run in a goroutine so a regression (reader + // queued behind the blocked writer) fails the test instead of hanging it; + // the goroutine uses assert, never require, as FailNow must not be called + // off the test goroutine. + readsDone := make(chan struct{}) + go func() { + defer close(readsDone) + got, ok, err := s.Get(t.Context(), "p") + assert.NoError(t, err) + assert.True(t, ok) + assert.Equal(t, 1, got.Revision) + assert.Equal(t, "v1", got.Content) + + plans, warnings, err := s.List(t.Context()) + assert.NoError(t, err) + assert.Empty(t, warnings) + assert.Len(t, plans, 2) + }() + select { + case <-readsDone: + case <-time.After(5 * time.Second): + t.Fatal("Get/List blocked behind a writer waiting for the cross-process lock") + } + + // The reads finished while both writers were still parked on the sentinel. + select { + case err := <-upsertDone: + t.Fatalf("upsert completed while the sentinel was held externally (err=%v)", err) + case err := <-deleteDone: + t.Fatalf("delete completed while the sentinel was held externally (err=%v)", err) + default: + } + + release() + released = true + + for name, done := range map[string]chan error{"upsert": upsertDone, "delete": deleteDone} { + select { + case err := <-done: + require.NoError(t, err, "%s failed after the lock was released", name) + case <-time.After(10 * time.Second): + t.Fatalf("%s did not complete after the lock was released", name) + } + } + + got, ok, err := s.Get(t.Context(), "p") + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, 2, got.Revision) + assert.Equal(t, "v2", got.Content) + + _, ok, err = s.Get(t.Context(), "q") + require.NoError(t, err) + assert.False(t, ok, "q must be deleted once the blocked delete went through") +} + +// TestStorage_BlockedWritersCancelWithoutLeak proves mutations parked on the +// sentinel are abandoned promptly when their context is cancelled — each +// goroutine exits with context.Canceled instead of leaking — and that the +// plan is untouched and still readable afterwards, sentinel still held. +func TestStorage_BlockedWritersCancelWithoutLeak(t *testing.T) { + t.Parallel() + dir := t.TempDir() + s := NewFilesystemStorage(dir) + + _, err := s.Upsert(t.Context(), UpsertRequest{Name: "p", Content: new("v1")}) + require.NoError(t, err) + + release, err := acquireFileLock(t.Context(), filepath.Join(dir, lockFileName)) + require.NoError(t, err) + defer release() + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + upsertDone := make(chan error, 1) + go func() { + _, err := s.Upsert(ctx, UpsertRequest{Name: "p", Content: new("v2")}) + upsertDone <- err + }() + deleteDone := make(chan error, 1) + go func() { + _, err := s.Delete(ctx, "p", new(1)) + deleteDone <- err + }() + + select { + case err := <-upsertDone: + t.Fatalf("upsert completed while the sentinel was held externally (err=%v)", err) + case err := <-deleteDone: + t.Fatalf("delete completed while the sentinel was held externally (err=%v)", err) + case <-time.After(200 * time.Millisecond): + } + + cancel() + for name, done := range map[string]chan error{"upsert": upsertDone, "delete": deleteDone} { + select { + case err := <-done: + require.ErrorIs(t, err, context.Canceled, "%s must give up on cancellation", name) + case <-time.After(10 * time.Second): + t.Fatalf("cancelled %s did not return; writer goroutine leaked", name) + } + } + + // The abandoned mutations changed nothing, and reads still work with the + // sentinel held. + got, ok, err := s.Get(t.Context(), "p") + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, 1, got.Revision) + assert.Equal(t, "v1", got.Content) +} + +// TestStorage_LockBlocksGuardedWriteAcrossProcesses proves the cross-process +// guarantee directly: while this process holds the filesystem lock, a second +// docker-agent process cannot enter its guarded write; once released, the +// write goes through. +func TestStorage_LockBlocksGuardedWriteAcrossProcesses(t *testing.T) { + dir := t.TempDir() + s := NewFilesystemStorage(dir) + + _, err := s.Upsert(t.Context(), UpsertRequest{Name: "p", Content: new("v1")}) + require.NoError(t, err) + + release, err := acquireFileLock(t.Context(), filepath.Join(dir, lockFileName)) + require.NoError(t, err) + released := false + defer func() { + if !released { + release() + } + }() + + helper := startLockHelper(t, dir, "p", 1, "A", 16, filepath.Join(t.TempDir(), "go")) + done := make(chan error, 1) + go func() { done <- helper.cmd.Wait() }() + + // The ready file flags that the helper is past setup; the go signal then + // sends it into Upsert, so from here on "has not exited" means "is blocked + // on the lock". + waitForFile(t, helper.readyFile) + require.NoError(t, os.WriteFile(helper.goFile, nil, 0o600)) + + select { + case err := <-done: + t.Fatalf("helper completed a guarded write while the parent held the lock (err=%v, stderr=%s)", err, helper.stderr) + case <-time.After(300 * time.Millisecond): + } + + // The blocked writer changed nothing. + got, ok, err := s.Get(t.Context(), "p") + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, 1, got.Revision) + assert.Equal(t, "v1", got.Content) + + release() + released = true + + select { + case err := <-done: + require.NoError(t, err, "helper stderr: %s", helper.stderr) + case <-time.After(10 * time.Second): + _ = helper.cmd.Process.Kill() + t.Fatalf("helper did not acquire the lock after the parent released it (stderr: %s)", helper.stderr) + } + + got, ok, err = s.Get(t.Context(), "p") + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, 2, got.Revision) + assert.Equal(t, strings.Repeat("A", 16), got.Content) +} + +// TestStorage_CrossProcessRaceOneWinnerOneConflict spawns two separate +// processes that both try to write the same plan with the same expected +// revision. A shared go-file barrier releases both into Upsert at the same +// moment — without depending on the lock under test — so their +// read-modify-write windows overlap: exactly one must win, the other must get +// a version conflict, the winner's content must be complete, and the revision +// must advance only once. +func TestStorage_CrossProcessRaceOneWinnerOneConflict(t *testing.T) { + dir := t.TempDir() + s := NewFilesystemStorage(dir) + + _, err := s.Upsert(t.Context(), UpsertRequest{Name: "p", Content: new("v1")}) + require.NoError(t, err) + + goFile := filepath.Join(t.TempDir(), "go") + helperA := startLockHelper(t, dir, "p", 1, "a", helperContentSize, goFile) + helperB := startLockHelper(t, dir, "p", 1, "b", helperContentSize, goFile) + + // Both helpers are lined up spinning on the go file; releasing it starts + // both guarded writes within a few hundred microseconds of each other. + waitForFile(t, helperA.readyFile) + waitForFile(t, helperB.readyFile) + require.NoError(t, os.WriteFile(goFile, nil, 0o600)) + + exitA := waitHelperExit(t, helperA) + exitB := waitHelperExit(t, helperB) + + require.ElementsMatch(t, []int{helperExitSuccess, helperExitConflict}, []int{exitA, exitB}, + "exactly one racer must win and one must get a version conflict (A=%d stderr=%s; B=%d stderr=%s)", + exitA, helperA.stderr, exitB, helperB.stderr) + + got, ok, err := s.Get(t.Context(), "p") + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, 2, got.Revision, "the revision must advance exactly once") + + winner := "a" + if exitB == helperExitSuccess { + winner = "b" + } + assert.Equal(t, strings.Repeat(winner, helperContentSize), got.Content, + "the winner's content must be preserved in full") +} + +// TestPlanLockHelperProcess is not a real test: it is the subprocess body for +// the cross-process tests above, gated on PLAN_LOCK_HELPER. It touches its +// ready file, waits for the go signal, performs one guarded write, and +// reports the outcome through its exit code: 0 on success, 3 on a version +// conflict, 4 on any other failure. +func TestPlanLockHelperProcess(t *testing.T) { + if os.Getenv(helperEnv) != "1" { + return + } + args := os.Args + for i, arg := range args { + if arg == "--" { + args = args[i+1:] + break + } + } + if len(args) != 7 { + fmt.Fprintf(os.Stderr, "helper: want 7 args after --, got %v\n", args) + os.Exit(helperExitFailure) + } + dir, name, revStr, marker, sizeStr, readyFile, goFile := args[0], args[1], args[2], args[3], args[4], args[5], args[6] + rev, err := strconv.Atoi(revStr) + if err != nil { + fmt.Fprintf(os.Stderr, "helper: bad revision %q: %v\n", revStr, err) + os.Exit(helperExitFailure) + } + size, err := strconv.Atoi(sizeStr) + if err != nil || size <= 0 { + fmt.Fprintf(os.Stderr, "helper: bad size %q: %v\n", sizeStr, err) + os.Exit(helperExitFailure) + } + + content := strings.Repeat(marker, size) + if err := os.WriteFile(readyFile, nil, 0o600); err != nil { + fmt.Fprintf(os.Stderr, "helper: writing ready file: %v\n", err) + os.Exit(helperExitFailure) + } + + // Spin on the go signal with a tight interval so sibling helpers enter + // Upsert as close to simultaneously as possible. + deadline := time.Now().Add(10 * time.Second) + for { + if _, err := os.Stat(goFile); err == nil { + break + } + if time.Now().After(deadline) { + fmt.Fprintln(os.Stderr, "helper: timed out waiting for go signal") + os.Exit(helperExitFailure) + } + time.Sleep(200 * time.Microsecond) //nolint:forbidigo // cross-process file barrier: no in-process primitive to synchronize two processes on + } + + _, err = NewFilesystemStorage(dir).Upsert(t.Context(), UpsertRequest{ + Name: name, + Content: &content, + ExpectedRevision: &rev, + }) + var conflict *VersionConflictError + switch { + case err == nil: + os.Exit(helperExitSuccess) + case errors.As(err, &conflict): + os.Exit(helperExitConflict) + default: + fmt.Fprintf(os.Stderr, "helper: upsert: %v\n", err) + os.Exit(helperExitFailure) + } +} + +// lockHelper tracks one spawned helper process and its diagnostics. +type lockHelper struct { + cmd *exec.Cmd + stderr *bytes.Buffer + readyFile string + goFile string +} + +// startLockHelper re-executes the test binary as a separate process that +// performs one guarded write of size bytes of marker on the named plan. The +// helper reports readiness through its ready file and then waits for goFile +// to exist before writing, so the caller controls when the write starts. +func startLockHelper(t *testing.T, dir, name string, expectedRevision int, marker string, size int, goFile string) *lockHelper { + t.Helper() + readyFile := filepath.Join(t.TempDir(), "ready") + cmd := exec.CommandContext(t.Context(), os.Args[0], + "-test.run=TestPlanLockHelperProcess", "--", + dir, name, strconv.Itoa(expectedRevision), marker, strconv.Itoa(size), readyFile, goFile) + stderr := &bytes.Buffer{} + cmd.Stderr = stderr + cmd.Env = append(os.Environ(), helperEnv+"=1") + require.NoError(t, cmd.Start()) + return &lockHelper{cmd: cmd, stderr: stderr, readyFile: readyFile, goFile: goFile} +} + +// waitHelperExit waits for the helper to finish and returns its exit code. +func waitHelperExit(t *testing.T, h *lockHelper) int { + t.Helper() + done := make(chan error, 1) + go func() { done <- h.cmd.Wait() }() + select { + case err := <-done: + if err == nil { + return helperExitSuccess + } + var exit *exec.ExitError + if errors.As(err, &exit) && exit.ExitCode() >= 0 { + return exit.ExitCode() + } + t.Fatalf("helper did not exit cleanly: %v (stderr: %s)", err, h.stderr) + case <-time.After(10 * time.Second): + _ = h.cmd.Process.Kill() + t.Fatalf("helper did not finish in time (stderr: %s)", h.stderr) + } + return -1 +} + +// waitForFile polls until path exists, failing the test after a deadline. +func waitForFile(t *testing.T, path string) { + t.Helper() + require.Eventuallyf(t, func() bool { + _, err := os.Stat(path) + return err == nil + }, 10*time.Second, 5*time.Millisecond, "timed out waiting for %s", path) +} + +// --- Bounded decoding -------------------------------------------------------- + +// TestStorage_OversizedPlanFileReportedCorrupt proves a stored file beyond +// maxEncodedPlanSize is never slurped into memory whole: it surfaces as a +// typed corrupt-plan error on Get, a warning on List, blocks guarded writes, +// and is recoverable with an unguarded delete — exactly like undecodable +// JSON. +func TestStorage_OversizedPlanFileReportedCorrupt(t *testing.T) { + t.Parallel() + dir := t.TempDir() + s := NewFilesystemStorage(dir) + + require.NoError(t, os.WriteFile(filepath.Join(dir, "big.json"), make([]byte, maxEncodedPlanSize+1), 0o600)) + + _, _, err := s.Get(t.Context(), "big") + var corrupt *CorruptPlanError + require.ErrorAs(t, err, &corrupt) + assert.Equal(t, "big.json", corrupt.File) + assert.Contains(t, err.Error(), "exceeds") + + plans, warnings, err := s.List(t.Context()) + require.NoError(t, err) + assert.Empty(t, plans) + require.Len(t, warnings, 1) + assert.Contains(t, warnings[0], "big") + + // A write cannot silently replace it: the pre-write load fails. + _, err = s.Upsert(t.Context(), UpsertRequest{Name: "big", Content: new("x")}) + require.ErrorAs(t, err, &corrupt) + + deleted, err := s.Delete(t.Context(), "big", nil) + require.NoError(t, err) + assert.True(t, deleted) +} + +// TestStorage_ContentAtSizeCapIsStored proves the advertised content cap is +// exact: content of exactly MaxPlanContentSize — including bytes JSON must +// escape — is persisted and read back intact, because the stored-file bound +// accounts for worst-case escaping instead of capping the encoded form at the +// content limit. +func TestStorage_ContentAtSizeCapIsStored(t *testing.T) { + t.Parallel() + dir := t.TempDir() + s := NewFilesystemStorage(dir) + + // A quarter of escapable bytes (" expands to 2, \n to 2) proves escaping + // headroom without paying for the 6x worst case in test time. + content := strings.Repeat(`ab"`+"\n", MaxPlanContentSize/4) + require.Len(t, content, MaxPlanContentSize) + + _, err := s.Upsert(t.Context(), UpsertRequest{Name: "big", Content: &content}) + require.NoError(t, err, "content of exactly the advertised cap must be accepted") + + got, ok, err := s.Get(t.Context(), "big") + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, content, got.Content) +} + +// TestStorage_WorstCaseEscapedContentAtSizeCapIsStored exercises the exact +// worst case maxEncodedPlanSize is derived from: NUL bytes escape as \u0000 +// (6 bytes each, the maximum JSON expansion), so content of exactly +// MaxPlanContentSize made entirely of them encodes to the full +// 6*MaxPlanContentSize — and must still be persisted and read back intact. +func TestStorage_WorstCaseEscapedContentAtSizeCapIsStored(t *testing.T) { + t.Parallel() + + // Pin the 6x expansion formula the bound relies on before paying for the + // full-size write: every NUL encodes to exactly 6 bytes (plus quotes). + encoded, err := json.Marshal(strings.Repeat("\x00", 1024)) + require.NoError(t, err) + require.Len(t, encoded, 6*1024+2, "a control byte must escape to exactly 6 bytes") + + dir := t.TempDir() + s := NewFilesystemStorage(dir) + content := strings.Repeat("\x00", MaxPlanContentSize) + + _, err = s.Upsert(t.Context(), UpsertRequest{Name: "worst", Content: &content}) + require.NoError(t, err, "worst-case escaped content of exactly the cap must be accepted") + + info, err := os.Stat(filepath.Join(dir, "worst.json")) + require.NoError(t, err) + assert.Greater(t, info.Size(), int64(6*MaxPlanContentSize), "the stored file must carry the 6x-escaped body") + assert.LessOrEqual(t, info.Size(), int64(maxEncodedPlanSize), "the stored file must fit the bound load applies") + + got, ok, err := s.Get(t.Context(), "worst") + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, content, got.Content) +} + +// TestStorage_LargeMetadataBackwardCompatible proves free-form metadata has +// no per-field cap: a plan stored by an earlier build with labels far beyond +// any "reasonable" size keeps loading, survives a content-only update with +// its metadata preserved, and new writes may carry equally large labels. +func TestStorage_LargeMetadataBackwardCompatible(t *testing.T) { + t.Parallel() + dir := t.TempDir() + s := NewFilesystemStorage(dir) + + // Plant a legacy plan file directly, as a previous build (whole-file + // bound only, no metadata cap) could have written it: every metadata + // field well past 4 KiB. + bigStatus := strings.Repeat("s", 8<<10) + bigTitle := strings.Repeat("t", 8<<10) + bigAuthor := strings.Repeat("a", 8<<10) + legacy, err := json.Marshal(Plan{ + Name: "legacy", + Title: bigTitle, + Author: bigAuthor, + Status: bigStatus, + Content: "legacy content", + Revision: 3, + UpdatedAt: "2024-01-02T03:04:05Z", + }) + require.NoError(t, err) + require.NoError(t, os.MkdirAll(dir, 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "legacy.json"), legacy, 0o600)) + + got, ok, err := s.Get(t.Context(), "legacy") + require.NoError(t, err, "a legacy plan with large metadata must keep loading") + require.True(t, ok) + assert.Equal(t, bigStatus, got.Status) + + // A content-only update must succeed and preserve the large metadata. + updated, err := s.Upsert(t.Context(), UpsertRequest{Name: "legacy", Content: new("new content")}) + require.NoError(t, err, "updating only the content of a legacy plan must not trip any metadata cap") + assert.Equal(t, "new content", updated.Content) + assert.Equal(t, bigTitle, updated.Title) + assert.Equal(t, bigAuthor, updated.Author) + assert.Equal(t, bigStatus, updated.Status) + assert.Equal(t, 4, updated.Revision) + + // New writes may set equally large labels. + _, err = s.Upsert(t.Context(), UpsertRequest{Name: "fresh", Content: new("body"), Status: &bigStatus}) + require.NoError(t, err) + got, ok, err = s.Get(t.Context(), "fresh") + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, bigStatus, got.Status) +} + +// TestStorage_SaveRejectsPlanOverSizeCap proves the write side of the cap: a +// plan whose content exceeds the advertised bound is rejected up front, so +// the storage never persists a file it would then refuse to decode. +func TestStorage_SaveRejectsPlanOverSizeCap(t *testing.T) { + t.Parallel() + dir := t.TempDir() + s := NewFilesystemStorage(dir) + + _, err := s.Upsert(t.Context(), UpsertRequest{Name: "p", Content: new("v1")}) + require.NoError(t, err) + + huge := strings.Repeat("x", MaxPlanContentSize+1) + _, err = s.Upsert(t.Context(), UpsertRequest{Name: "p", Content: &huge}) + require.Error(t, err) + assert.Contains(t, err.Error(), "content is too large") + + // The rejected write left the previous revision fully intact. + got, ok, err := s.Get(t.Context(), "p") + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, "v1", got.Content) + assert.Equal(t, 1, got.Revision) +} diff --git a/pkg/tools/builtin/plan/plan_test.go b/pkg/tools/builtin/plan/plan_test.go index 26b9d28b53..fbecca0532 100644 --- a/pkg/tools/builtin/plan/plan_test.go +++ b/pkg/tools/builtin/plan/plan_test.go @@ -1,6 +1,7 @@ package plan import ( + "bytes" "context" "encoding/json" "errors" @@ -8,6 +9,7 @@ import ( "os" "path/filepath" "sort" + "strings" "sync" "testing" @@ -529,6 +531,28 @@ func runStorageConformance(t *testing.T, s Storage) { require.True(t, ok) assert.Equal(t, p, got) + // MustNotExist is the create-only guard: it conflicts with any existing + // plan — deterministically carrying Expected 0 and the current revision — + // and leaves it untouched. + var conflict *VersionConflictError + _, err = s.Upsert(ctx, UpsertRequest{Name: "release", Content: new("clobber"), MustNotExist: true}) + require.ErrorAs(t, err, &conflict) + assert.Equal(t, 0, conflict.Expected) + assert.Equal(t, 1, conflict.Current) + got, ok, err = s.Get(ctx, "release") + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, p, got, "the refused create must not touch the plan") + + // A MustNotExist write of a plan that does not exist yet succeeds like + // any first write, and the guarded delete below cleans it up again. + fresh, err := s.Upsert(ctx, UpsertRequest{Name: "fresh", Content: new("f"), MustNotExist: true}) + require.NoError(t, err) + assert.Equal(t, 1, fresh.Revision) + freshDeleted, err := s.Delete(ctx, "fresh", new(1)) + require.NoError(t, err) + assert.True(t, freshDeleted) + // Upsert with nil fields bumps the revision and preserves title, author, // and status; only the content changes. p2, err := s.Upsert(ctx, UpsertRequest{Name: "release", Content: new("v2")}) @@ -563,7 +587,6 @@ func runStorageConformance(t *testing.T, s Storage) { _, err = s.Upsert(ctx, UpsertRequest{Name: "release", Content: new("stale"), ExpectedRevision: new(4)}) require.Error(t, err) - var conflict *VersionConflictError require.ErrorAs(t, err, &conflict) assert.Equal(t, 4, conflict.Expected) assert.Equal(t, 5, conflict.Current) @@ -608,6 +631,156 @@ func runStorageConformance(t *testing.T, s Storage) { assert.False(t, deleted) } +// TestFilesystemStorage_MustNotExistRejectsRevisionZeroFile proves the +// create-only guard is existence-driven, not revision-driven: a valid stored +// plan whose revision field is omitted (reading back as revision 0) still +// conflicts — with Expected 0 and Current 0 — and stays byte-identical, +// where an ExpectedRevision-0 guard alone would have overwritten it. +func TestFilesystemStorage_MustNotExistRejectsRevisionZeroFile(t *testing.T) { + t.Parallel() + dir := t.TempDir() + s := NewFilesystemStorage(dir) + + original := `{"name":"planted","content":"precious content"}` + path := filepath.Join(dir, "planted.json") + require.NoError(t, os.WriteFile(path, []byte(original), 0o600)) + + _, err := s.Upsert(t.Context(), UpsertRequest{Name: "planted", Content: new("clobber"), MustNotExist: true}) + var conflict *VersionConflictError + require.ErrorAs(t, err, &conflict) + assert.Equal(t, 0, conflict.Expected) + assert.Equal(t, 0, conflict.Current) + + data, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, original, string(data), "the refused create must leave the existing file byte-identical") +} + +// TestFilesystemStorage_DirectoryPlanFileIsCorrupt proves a directory +// squatting on .json is a typed corrupt plan on Get — validated on the +// opened descriptor, never read — and a warning on List, so it is never +// mistaken for a missing plan or a plain I/O failure. +func TestFilesystemStorage_DirectoryPlanFileIsCorrupt(t *testing.T) { + t.Parallel() + dir := t.TempDir() + s := NewFilesystemStorage(dir) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "squatter.json"), 0o700)) + + _, ok, err := s.Get(t.Context(), "squatter") + assert.False(t, ok) + var corrupt *CorruptPlanError + require.ErrorAs(t, err, &corrupt) + assert.Contains(t, corrupt.Error(), "directory") + + plans, warnings, err := s.List(t.Context()) + require.NoError(t, err) + assert.Empty(t, plans) + // ReadDir-level directory entries are skipped before load, so no warning + // is required here; the point is that List neither fails nor lists it. + assert.Empty(t, warnings) +} + +// TestFilesystemStorage_ReadsObserveContext proves Get and List honour an +// already-expired context instead of starting filesystem work: the refresh +// pipelines above them pass bounded contexts and rely on reads not outliving +// their deadline when storage is healthy enough to return at all. +func TestFilesystemStorage_ReadsObserveContext(t *testing.T) { + t.Parallel() + s := NewFilesystemStorage(t.TempDir()) + _, err := s.Upsert(t.Context(), UpsertRequest{Name: "p", Content: new("x")}) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + _, _, err = s.Get(ctx, "p") + require.ErrorIs(t, err, context.Canceled) + _, _, err = s.List(ctx) + require.ErrorIs(t, err, context.Canceled) +} + +// TestFilesystemStorage_LockFreeReadsUnderConcurrentWrites hammers the +// lock-free Get and List with concurrent mutations. Writes are atomic +// (temp + rename), so every read must observe a complete, decodable plan — +// never a partial file, a temp file in the listing, or a spurious warning — +// and the race detector proves the in-process consistency of dropping the +// reader mutex. +func TestFilesystemStorage_LockFreeReadsUnderConcurrentWrites(t *testing.T) { + t.Parallel() + s := NewFilesystemStorage(t.TempDir()) + ctx := t.Context() + _, err := s.Upsert(ctx, UpsertRequest{Name: "p", Content: new("content-seed")}) + require.NoError(t, err) + + const writes = 25 + done := make(chan struct{}) + var writeErr error + go func() { + defer close(done) + for i := range writes { + content := fmt.Sprintf("content-%d", i) + if _, err := s.Upsert(ctx, UpsertRequest{Name: "p", Content: &content}); err != nil { + writeErr = err + return + } + } + }() + + // Readers report violations as errors; all assertions run on the test + // goroutine after the workers join. + readOnce := func() error { + plan, ok, err := s.Get(ctx, "p") + switch { + case err != nil: + return fmt.Errorf("lock-free Get failed: %w", err) + case !ok: + return errors.New("lock-free Get lost the plan") + case !strings.Contains(plan.Content, "content-"): + return fmt.Errorf("Get observed a torn plan: %q", plan.Content) + } + plans, warnings, err := s.List(ctx) + switch { + case err != nil: + return fmt.Errorf("lock-free List failed: %w", err) + case len(warnings) != 0: + return fmt.Errorf("List surfaced warnings despite atomic writes: %v", warnings) + case len(plans) != 1: + return fmt.Errorf("List observed %d plans; temp files must never be listed", len(plans)) + } + return nil + } + + var wg sync.WaitGroup + readerErrs := make([]error, 4) + for i := range readerErrs { + wg.Go(func() { + for { + if err := readOnce(); err != nil { + readerErrs[i] = err + return + } + select { + case <-done: + return + default: + } + } + }) + } + wg.Wait() + <-done + require.NoError(t, writeErr) + for i, err := range readerErrs { + require.NoError(t, err, "reader %d", i) + } + + plan, ok, err := s.Get(ctx, "p") + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, writes+1, plan.Revision) + assert.Equal(t, fmt.Sprintf("content-%d", writes-1), plan.Content) +} + // memoryStorage is an in-memory Storage used to exercise the toolset through a // custom backend. It mirrors the filesystem default's contract: Upsert owns the // revision bump and preserves title/author when omitted. @@ -636,6 +809,9 @@ func (s *memoryStorage) Upsert(_ context.Context, req UpsertRequest) (Plan, erro if req.MustExist && !exists { return Plan{}, fmt.Errorf("%w: %q", ErrPlanNotFound, req.Name) } + if req.MustNotExist && exists { + return Plan{}, &VersionConflictError{Name: req.Name, Expected: 0, Current: p.Revision} + } if req.ExpectedRevision != nil && p.Revision != *req.ExpectedRevision { return Plan{}, &VersionConflictError{Name: req.Name, Expected: *req.ExpectedRevision, Current: p.Revision} } @@ -1058,7 +1234,7 @@ func TestPlanTool_UpdateFromFileTooLarge(t *testing.T) { tool := newTestPlanTool(t) src := filepath.Join(t.TempDir(), "big.md") - require.NoError(t, os.WriteFile(src, make([]byte, maxPlanFileSize+1), 0o600)) + require.NoError(t, os.WriteFile(src, make([]byte, MaxPlanContentSize+1), 0o600)) result, err := tool.updatePlanFromFile(t.Context(), UpdatePlanFromFileArgs{Name: "p", Path: src}) require.NoError(t, err) @@ -1066,6 +1242,73 @@ func TestPlanTool_UpdateFromFileTooLarge(t *testing.T) { assert.Contains(t, result.Output, "too large") } +// TestPlanTool_UpdateFromFileAtSizeCap proves the advertised content cap is +// inclusive end to end: a file of exactly MaxPlanContentSize is read and the +// plan is persisted through the real storage. +func TestPlanTool_UpdateFromFileAtSizeCap(t *testing.T) { + t.Parallel() + tool := newTestPlanTool(t) + + src := filepath.Join(t.TempDir(), "exact.md") + content := bytes.Repeat([]byte("a"), MaxPlanContentSize) + require.NoError(t, os.WriteFile(src, content, 0o600)) + + result, err := tool.updatePlanFromFile(t.Context(), UpdatePlanFromFileArgs{Name: "p", Path: src}) + require.NoError(t, err) + require.False(t, result.IsError, "exactly the cap must be accepted: %s", result.Output) + + got, ok, err := tool.storage.Get(t.Context(), "p") + require.NoError(t, err) + require.True(t, ok) + assert.Len(t, got.Content, MaxPlanContentSize) +} + +// TestPlanTool_LargeMetadataAccepted proves metadata stays free-form through +// the tool surface: title, author, and status beyond 4 KiB are written, +// preserved across a content-only write, and read back intact. Labels have +// no per-field cap (issue #3844: status semantics are user-defined). +func TestPlanTool_LargeMetadataAccepted(t *testing.T) { + t.Parallel() + tool := newTestPlanTool(t) + + bigTitle := strings.Repeat("t", 5<<10) + bigAuthor := strings.Repeat("a", 5<<10) + bigStatus := strings.Repeat("s", 5<<10) + + result, err := tool.writePlan(t.Context(), WritePlanArgs{ + Name: "p", Content: "body", Title: bigTitle, Author: bigAuthor, Status: bigStatus, + }) + require.NoError(t, err) + require.False(t, result.IsError, "large metadata must be accepted: %s", result.Output) + + // set_plan_status takes the same free-form labels. + biggerStatus := strings.Repeat("z", 6<<10) + result, err = tool.setPlanStatus(t.Context(), SetPlanStatusArgs{Name: "p", Status: biggerStatus}) + require.NoError(t, err) + require.False(t, result.IsError, "a large status must be accepted: %s", result.Output) + + // A content-only write preserves the large labels. + result, err = tool.writePlan(t.Context(), WritePlanArgs{Name: "p", Content: "new body"}) + require.NoError(t, err) + require.False(t, result.IsError) + + got, ok, err := tool.storage.Get(t.Context(), "p") + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, bigTitle, got.Title) + assert.Equal(t, bigAuthor, got.Author) + assert.Equal(t, biggerStatus, got.Status) + assert.Equal(t, "new body", got.Content) +} + +// TestMaxPlanFileSizeAlias pins the deprecated exported alias: embedders +// built against the original MaxPlanFileSize export must keep compiling and +// get the same bound as MaxPlanContentSize. +func TestMaxPlanFileSizeAlias(t *testing.T) { + t.Parallel() + assert.Equal(t, MaxPlanContentSize, MaxPlanFileSize) +} + // TestPlanTool_ReadNormalizesNameFromFilename proves read_plan returns the name // the caller can actually use (the filename), not a drifted "name" field stored // inside the file, so a read -> write round-trip can't land on the wrong plan. @@ -1300,3 +1543,49 @@ func TestPlanTool_NewToolsRegistered(t *testing.T) { assert.True(t, names[want], "tool %q should be registered", want) } } + +// --- Host-facing surface (pkg/plans) ----------------------------------------- + +func TestValidateName(t *testing.T) { + t.Parallel() + for _, name := range []string{"release", "release-2025", "db_migration", "a", "1plan"} { + require.NoError(t, ValidateName(name), "name %q should be valid", name) + } + for _, name := range []string{"", "///", "Has Space", "UPPER", "../escape", "a/b", "-leading", "with.dot"} { + err := ValidateName(name) + require.Error(t, err, "name %q should be rejected", name) + assert.Contains(t, err.Error(), "invalid plan name") + } +} + +func TestSharedStorage(t *testing.T) { + t.Parallel() + first := SharedStorage() + require.NotNil(t, first) + // Stable across calls, so every host-side caller shares one mutex. + assert.Same(t, first, SharedStorage()) + + // Identical to the storage behind the process-wide toolset singleton. + ts, err := CreateToolSet() + require.NoError(t, err) + assert.Same(t, ts.(*ToolSet).storage, first) + + // Per-instance toolsets built with New keep their own storage. + assert.NotSame(t, first, New().storage) +} + +// TestStorage_CorruptErrorIsTyped proves a corrupt plan surfaces as a +// *CorruptPlanError (with its historical message) so callers can classify it +// without matching on error text. +func TestStorage_CorruptErrorIsTyped(t *testing.T) { + t.Parallel() + dir := t.TempDir() + storage := NewFilesystemStorage(dir) + require.NoError(t, os.WriteFile(filepath.Join(dir, "broken.json"), []byte("{not json"), 0o600)) + + _, _, err := storage.Get(t.Context(), "broken") + var corrupt *CorruptPlanError + require.ErrorAs(t, err, &corrupt) + assert.Equal(t, "broken.json", corrupt.File) + assert.Contains(t, err.Error(), "plan file broken.json is corrupt") +} diff --git a/pkg/tools/builtin/plan/plan_unix_test.go b/pkg/tools/builtin/plan/plan_unix_test.go index 88a2339836..7d59fd99d8 100644 --- a/pkg/tools/builtin/plan/plan_unix_test.go +++ b/pkg/tools/builtin/plan/plan_unix_test.go @@ -3,6 +3,7 @@ package plan import ( + "os" "path/filepath" "syscall" "testing" @@ -12,11 +13,93 @@ import ( "github.com/stretchr/testify/require" ) +// TestFilesystemStorage_FIFOPlanFileFailsFast proves a .json entry +// that is a FIFO with no writer cannot wedge the storage: Get reports it as +// a typed corrupt plan and List degrades it to a warning while listing the +// healthy plans — both promptly. The open is hang-safe (a plain open of a +// FIFO with no writer blocks forever) and the descriptor is rejected before +// any read; the timeouts guard against a regression to a blocking open. +func TestFilesystemStorage_FIFOPlanFileFailsFast(t *testing.T) { + t.Parallel() + dir := t.TempDir() + s := NewFilesystemStorage(dir) + _, err := s.Upsert(t.Context(), UpsertRequest{Name: "good", Content: new("ok")}) + require.NoError(t, err) + require.NoError(t, syscall.Mkfifo(filepath.Join(dir, "wedged.json"), 0o600)) + + type getResult struct { + ok bool + err error + } + getDone := make(chan getResult, 1) + go func() { + _, ok, err := s.Get(t.Context(), "wedged") + getDone <- getResult{ok: ok, err: err} + }() + select { + case res := <-getDone: + assert.False(t, res.ok) + var corrupt *CorruptPlanError + require.ErrorAs(t, res.err, &corrupt) + assert.Equal(t, "wedged.json", corrupt.File) + assert.Contains(t, corrupt.Error(), "not a regular file") + case <-time.After(5 * time.Second): + t.Fatal("Get blocked on a FIFO plan file; the open must not block and the descriptor must be rejected before any read") + } + + type listResult struct { + plans []Summary + warnings []string + err error + } + listDone := make(chan listResult, 1) + go func() { + plans, warnings, err := s.List(t.Context()) + listDone <- listResult{plans: plans, warnings: warnings, err: err} + }() + select { + case res := <-listDone: + require.NoError(t, res.err) + require.Len(t, res.plans, 1, "the healthy plan must still be listed") + assert.Equal(t, "good", res.plans[0].Name) + require.Len(t, res.warnings, 1, "the FIFO must surface as a warning, not abort the listing") + assert.Contains(t, res.warnings[0], "wedged") + assert.Contains(t, res.warnings[0], "not a regular file") + case <-time.After(5 * time.Second): + t.Fatal("List blocked on a FIFO plan file; the open must not block and the descriptor must be rejected before any read") + } +} + +// TestFilesystemStorage_UpsertRejectsFIFOPlanFileFast proves the mutation +// path fails fast too: Upsert's pre-read goes through the same hang-safe +// load, so a FIFO squatting on the plan file yields a typed corrupt error +// instead of a wedged write holding the cross-process lock forever. +func TestFilesystemStorage_UpsertRejectsFIFOPlanFileFast(t *testing.T) { + t.Parallel() + dir := t.TempDir() + s := NewFilesystemStorage(dir) + require.NoError(t, syscall.Mkfifo(filepath.Join(dir, "wedged.json"), 0o600)) + + done := make(chan error, 1) + go func() { + _, err := s.Upsert(t.Context(), UpsertRequest{Name: "wedged", Content: new("x")}) + done <- err + }() + select { + case err := <-done: + var corrupt *CorruptPlanError + require.ErrorAs(t, err, &corrupt) + case <-time.After(5 * time.Second): + t.Fatal("Upsert blocked on a FIFO plan file; the pre-read must not block") + } +} + // TestPlanTool_UpdateFromFileRejectsNamedPipe proves a non-regular file (here a -// named pipe with no writer) is rejected at the stat stage and never opened. -// Opening such a pipe would block forever and reading a device like /dev/zero -// would stream unbounded data, so the implementation must refuse it up front. -// The timeout guards against a regression that opens the file and hangs. +// named pipe with no writer) is rejected without hanging and without being +// read. The pipe is opened with O_NONBLOCK — a plain open would block forever +// waiting for a writer — and then refused when the opened descriptor turns out +// not to be a regular file. The timeout guards against a regression to a +// blocking open. func TestPlanTool_UpdateFromFileRejectsNamedPipe(t *testing.T) { t.Parallel() tool := newTestPlanTool(t) @@ -42,6 +125,67 @@ func TestPlanTool_UpdateFromFileRejectsNamedPipe(t *testing.T) { assert.True(t, res.isError) assert.Contains(t, res.out, "regular file") case <-time.After(5 * time.Second): - t.Fatal("updatePlanFromFile blocked on a named pipe; it must reject non-regular files without opening them") + t.Fatal("updatePlanFromFile blocked on a named pipe; the open must not block and the descriptor must be rejected before any read") } } + +// TestReadPlanFile_RejectsNamedPipe exercises the reader directly: a FIFO with +// no writer must be opened without blocking and rejected on the descriptor +// check, never read. The timeout guards against a regression to a blocking +// open. +func TestReadPlanFile_RejectsNamedPipe(t *testing.T) { + t.Parallel() + + fifo := filepath.Join(t.TempDir(), "pipe") + require.NoError(t, syscall.Mkfifo(fifo, 0o600)) + + type result struct { + content string + err error + } + done := make(chan result, 1) + go func() { + content, err := readPlanFile(fifo) + done <- result{content, err} + }() + + select { + case res := <-done: + require.Error(t, res.err) + assert.Contains(t, res.err.Error(), "not a regular file") + assert.Empty(t, res.content) + case <-time.After(5 * time.Second): + t.Fatal("readPlanFile blocked on a named pipe; the open must not block and the descriptor must be rejected before any read") + } +} + +// TestReadPlanFile_RejectsDevice proves a device node is rejected on the +// opened descriptor without being read: reading /dev/zero would stream +// unbounded zeroes and surface as a "too large" error at best, so the +// distinct "not a regular file" message shows no read happened. +func TestReadPlanFile_RejectsDevice(t *testing.T) { + t.Parallel() + + if _, err := os.Stat("/dev/zero"); err != nil { + t.Skipf("/dev/zero not available: %v", err) + } + + content, err := readPlanFile("/dev/zero") + require.Error(t, err) + assert.Contains(t, err.Error(), "not a regular file") + assert.Empty(t, content) +} + +// TestReadPlanFile_RegularFileWithNonBlockingOpen pins the happy path of the +// hang-safe open on this platform: a regular file opened through +// OpenContentFile (O_NONBLOCK) reads back byte-exact. +func TestReadPlanFile_RegularFileWithNonBlockingOpen(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "content.md") + require.NoError(t, os.WriteFile(path, []byte("plan body\n"), 0o600)) + + content, err := readPlanFile(path) + require.NoError(t, err) + assert.Equal(t, "plan body\n", content) +} diff --git a/pkg/tui/commands/commands.go b/pkg/tui/commands/commands.go index 614085c5ed..db86109114 100644 --- a/pkg/tui/commands/commands.go +++ b/pkg/tui/commands/commands.go @@ -299,6 +299,17 @@ func builtInSessionCommands() []Item { return core.CmdHandler(messages.ShowPermissionsDialogMsg{}) }, }, + { + ID: "session.plans", + Label: "Plans", + SlashCommand: "/plans", + Description: "Browse and manage shared plans and this session's plan", + Category: "Session", + Immediate: true, + Execute: func(string) tea.Cmd { + return core.CmdHandler(messages.ShowPlanBrowserMsg{}) + }, + }, { ID: "session.history", Label: "Sessions", diff --git a/pkg/tui/commands/commands_plans_test.go b/pkg/tui/commands/commands_plans_test.go new file mode 100644 index 0000000000..cb75c1ca1c --- /dev/null +++ b/pkg/tui/commands/commands_plans_test.go @@ -0,0 +1,40 @@ +package commands + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/tui/messages" +) + +func TestParseSlashCommand_Plans(t *testing.T) { + t.Parallel() + parser := newTestParser() + + cmd := parser.Parse("/plans") + require.NotNil(t, cmd, "should return a command for /plans") + + msg := cmd() + _, ok := msg.(messages.ShowPlanBrowserMsg) + assert.True(t, ok, "should return ShowPlanBrowserMsg") +} + +func TestPlansCommandRegistration(t *testing.T) { + t.Parallel() + + var item *Item + for _, cmd := range builtInSessionCommands() { + if cmd.ID == "session.plans" { + item = &cmd + break + } + } + require.NotNil(t, item, "session.plans must be registered") + assert.Equal(t, "Plans", item.Label) + assert.Equal(t, "/plans", item.SlashCommand) + assert.True(t, item.Immediate, "/plans must run immediately, not queue as chat input") + assert.False(t, item.Hidden, "/plans must be discoverable in the command palette") + assert.NotEmpty(t, item.Description) +} diff --git a/pkg/tui/dialog/dialog.go b/pkg/tui/dialog/dialog.go index 45fe29f297..fe1759b75e 100644 --- a/pkg/tui/dialog/dialog.go +++ b/pkg/tui/dialog/dialog.go @@ -28,6 +28,14 @@ type CloseDialogMsg struct{} // CloseAllDialogsMsg is sent to close all dialogs in the stack type CloseAllDialogsMsg struct{} +// Broadcastable marks messages the manager delivers to every dialog in the +// stack instead of only the topmost one. Data-refresh messages implement it +// so dialogs buried under another dialog (e.g. the plan browser under its +// detail dialog) stay fresh. +type Broadcastable interface { + BroadcastToDialogs() +} + // Dialog defines the interface that all dialogs must implement type Dialog interface { layout.Model @@ -54,6 +62,10 @@ type Manager interface { // so the same instance (with any in-progress input) can be re-opened on // return. TopDialog() Dialog + // HasDialog reports whether pred matches any dialog in the stack, + // visiting bottom to top. Unlike TopDialog it also sees dialogs buried + // under other dialogs, e.g. a plan browser under a help dialog. + HasDialog(pred func(Dialog) bool) bool } // dialogEntry pairs a dialog with its drag offset so the two stay in sync. @@ -112,6 +124,9 @@ func (d *manager) Update(msg tea.Msg) (layout.Model, tea.Cmd) { case CloseDialogMsg: return d.handleClose() + case ClosePlanDetailMsg: + return d.handleClosePlanDetail(msg) + case CloseAllDialogsMsg: return d.handleCloseAll() @@ -143,6 +158,11 @@ func (d *manager) Update(msg tea.Msg) (layout.Model, tea.Cmd) { return d, cmd } + if _, ok := msg.(Broadcastable); ok { + cmd := d.broadcastToAll(msg) + return d, cmd + } + // Forward non-mouse messages to top dialog cmd := d.forwardToTop(msg) return d, cmd @@ -287,6 +307,18 @@ func (d *manager) handleClose() (layout.Model, tea.Cmd) { return d, nil } +// handleClosePlanDetail pops the top dialog only when it is a plan detail +// viewer showing exactly msg.Ref; anything else (another dialog on top, a +// detail for a different plan, an empty stack) is left untouched. Checking +// at apply time makes the close idempotent: duplicates for the same vanished +// plan cannot pop a second dialog. +func (d *manager) handleClosePlanDetail(msg ClosePlanDetailMsg) (layout.Model, tea.Cmd) { + if viewer, ok := d.TopDialog().(PlanDetailViewer); ok && viewer.PlanRef() == msg.Ref { + return d.handleClose() + } + return d, nil +} + // handleCloseAll closes all dialogs in the stack func (d *manager) handleCloseAll() (layout.Model, tea.Cmd) { d.stack = nil @@ -339,6 +371,17 @@ func (d *manager) TopDialog() Dialog { return d.stack[len(d.stack)-1].dialog } +// HasDialog reports whether pred matches any dialog in the stack, bottom to +// top. +func (d *manager) HasDialog(pred func(Dialog) bool) bool { + for i := range d.stack { + if pred(d.stack[i].dialog) { + return true + } + } + return false +} + func (d *manager) SetSize(width, height int) tea.Cmd { d.width = width d.height = height diff --git a/pkg/tui/dialog/manager_test.go b/pkg/tui/dialog/manager_test.go index 68e89fe474..ea18c1c2f2 100644 --- a/pkg/tui/dialog/manager_test.go +++ b/pkg/tui/dialog/manager_test.go @@ -4,6 +4,9 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/plans" ) // TestManagerBackgroundDialog verifies that opening a dialog with a non-nil @@ -55,3 +58,85 @@ func TestManagerBackgroundDialog(t *testing.T) { assert.Nil(t, mgr.TopBackgroundEvent()) assert.Nil(t, mgr.TopDialog()) } + +// TestManagerHasDialog verifies HasDialog sees the whole stack, including +// dialogs buried under the topmost one, unlike TopDialog. +func TestManagerHasDialog(t *testing.T) { + t.Parallel() + + mgr := New().(*manager) + isExit := func(d Dialog) bool { + _, ok := d.(*exitConfirmationDialog) + return ok + } + + assert.False(t, mgr.HasDialog(isExit), "empty manager matches nothing") + + mgr.handleOpen(OpenDialogMsg{Model: NewExitConfirmationDialog()}) + assert.True(t, mgr.HasDialog(isExit)) + + // Bury it under another dialog: TopDialog no longer sees it, HasDialog does. + mgr.handleOpen(OpenDialogMsg{Model: NewHelpDialog(nil)}) + _, topIsExit := mgr.TopDialog().(*exitConfirmationDialog) + require.False(t, topIsExit) + assert.True(t, mgr.HasDialog(isExit), "a buried dialog must still be found") + + assert.False(t, mgr.HasDialog(func(Dialog) bool { return false })) +} + +// TestManagerClosePlanDetail verifies the targeted plan-detail close is +// idempotent: it pops the topmost dialog only when it is the detail for +// exactly the given ref, so duplicates, wrong refs, another dialog on top, +// or an already-closed detail never pop the wrong dialog. +func TestManagerClosePlanDetail(t *testing.T) { + t.Parallel() + + ref := plans.SharedRef("release") + newDetail := func() Dialog { + return NewPlanDetailDialog(plans.Plan{Scope: plans.ScopeShared, Name: "release"}) + } + + t.Run("closes the matching top detail once, duplicates are no-ops", func(t *testing.T) { + t.Parallel() + mgr := New().(*manager) + browser := NewPlanBrowserDialog(plans.ListResult{}) + mgr.handleOpen(OpenDialogMsg{Model: browser}) + mgr.handleOpen(OpenDialogMsg{Model: newDetail()}) + + mgr.Update(ClosePlanDetailMsg{Ref: ref}) + require.Same(t, browser, mgr.TopDialog(), "the detail closes, the browser surfaces") + + // The duplicated close arrives after the detail already closed. + mgr.Update(ClosePlanDetailMsg{Ref: ref}) + assert.Same(t, browser, mgr.TopDialog(), "a duplicate close must not pop the browser") + + mgr.handleClose() + mgr.Update(ClosePlanDetailMsg{Ref: ref}) + assert.False(t, mgr.Open(), "a close on an empty stack is a no-op") + }) + + t.Run("a detail showing another plan is left alone", func(t *testing.T) { + t.Parallel() + mgr := New().(*manager) + other := NewPlanDetailDialog(plans.Plan{Scope: plans.ScopeShared, Name: "other"}) + mgr.handleOpen(OpenDialogMsg{Model: other}) + + mgr.Update(ClosePlanDetailMsg{Ref: ref}) + assert.Same(t, other, mgr.TopDialog(), "a close for a different ref is a no-op") + }) + + t.Run("a non-detail dialog on top is left alone", func(t *testing.T) { + t.Parallel() + mgr := New().(*manager) + mgr.handleOpen(OpenDialogMsg{Model: newDetail()}) + help := NewHelpDialog(nil) + mgr.handleOpen(OpenDialogMsg{Model: help}) + + mgr.Update(ClosePlanDetailMsg{Ref: ref}) + assert.Same(t, help, mgr.TopDialog(), "the covering dialog must not be popped") + assert.True(t, mgr.HasDialog(func(d Dialog) bool { + viewer, ok := d.(PlanDetailViewer) + return ok && viewer.PlanRef() == ref + }), "the buried detail stays open") + }) +} diff --git a/pkg/tui/dialog/plan_browser.go b/pkg/tui/dialog/plan_browser.go new file mode 100644 index 0000000000..bfc9203f83 --- /dev/null +++ b/pkg/tui/dialog/plan_browser.go @@ -0,0 +1,900 @@ +package dialog + +import ( + "fmt" + "strconv" + "strings" + "time" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/docker/docker-agent/pkg/plans" + "github.com/docker/docker-agent/pkg/tui/components/notification" + "github.com/docker/docker-agent/pkg/tui/components/scrollview" + "github.com/docker/docker-agent/pkg/tui/components/toolcommon" + "github.com/docker/docker-agent/pkg/tui/core" + "github.com/docker/docker-agent/pkg/tui/core/layout" + "github.com/docker/docker-agent/pkg/tui/messages" + "github.com/docker/docker-agent/pkg/tui/styles" +) + +// PlanBrowserDataMsg replaces the plan browser's rows with a fresh listing. +// It is Broadcastable so a browser buried under the detail dialog stays in +// sync after a write. +type PlanBrowserDataMsg struct { + Result plans.ListResult +} + +// BroadcastToDialogs implements Broadcastable. +func (PlanBrowserDataMsg) BroadcastToDialogs() {} + +// PlanDetailDataMsg replaces the plan shown by an open detail dialog. It is +// Broadcastable for the same reason as PlanBrowserDataMsg; a detail dialog +// only applies it when the plan identity matches what it is showing. +type PlanDetailDataMsg struct { + Plan plans.Plan +} + +// BroadcastToDialogs implements Broadcastable. +func (PlanDetailDataMsg) BroadcastToDialogs() {} + +// ClosePlanDetailMsg closes the topmost dialog only when it is a plan detail +// dialog showing exactly Ref. The manager checks the current top when the +// message is applied — not when it was emitted — so duplicated closes for +// the same vanished plan pop at most one dialog, and a close arriving after +// the detail was already closed (or covered by another dialog) is a no-op +// instead of popping the wrong dialog. +type ClosePlanDetailMsg struct { + Ref plans.Ref +} + +// PlanDialog is implemented by every dialog of the /plans flow, so the app +// model can tell whether plan data is on screen and needs live refreshing. +type PlanDialog interface { + planDialog() +} + +// PlanDetailViewer identifies an open plan detail dialog and the plan it +// shows, so the app model can re-fetch exactly that plan on refresh. +type PlanDetailViewer interface { + PlanRef() plans.Ref +} + +// PlanBrowserViewer identifies the /plans browser dialog on the stack, so +// the app model can refuse stacking a duplicate browser. The marker method +// is unexported like PlanDialog's: only this package's browser implements +// it, while other packages can still assert against the interface. +type PlanBrowserViewer interface { + planBrowserDialog() +} + +// planBrowserKeyMap defines key bindings for the plan browser. +type planBrowserKeyMap struct { + Up key.Binding + Down key.Binding + Enter key.Binding + Escape key.Binding + Filter key.Binding + Refresh key.Binding + Export key.Binding + Status key.Binding + Delete key.Binding + New key.Binding + Edit key.Binding +} + +func defaultPlanBrowserKeyMap() planBrowserKeyMap { + return planBrowserKeyMap{ + Up: key.NewBinding(key.WithKeys("up", "ctrl+k")), + Down: key.NewBinding(key.WithKeys("down", "ctrl+j")), + Enter: key.NewBinding(key.WithKeys("enter")), + Escape: key.NewBinding(key.WithKeys("esc")), + Filter: key.NewBinding(key.WithKeys("/")), + Refresh: key.NewBinding(key.WithKeys("r")), + Export: key.NewBinding(key.WithKeys("x")), + Status: key.NewBinding(key.WithKeys("s")), + Delete: key.NewBinding(key.WithKeys("d")), + New: key.NewBinding(key.WithKeys("n")), + Edit: key.NewBinding(key.WithKeys("e")), + } +} + +// Plan browser dialog dimension constants, mirroring the session browser. +const ( + planBrowserListOverhead = 12 // title(1) + space(1) + input(1) + separator(1) + separator(1) + footer(1) + space(1) + help(1) + borders(2) + extra(2) + planBrowserListStartY = 6 // border(1) + padding(1) + title(1) + space(1) + input(1) + separator(1) +) + +// Column widths of a plan row; the title takes the remaining width. +const ( + planColScope = 7 + planColName = 22 + planColStatus = 12 + planColVersion = 4 + planColUpdated = 8 + planColGap = 2 +) + +type planBrowserDialog struct { + BaseDialog + + filterInput textinput.Model + // filtering is true while keystrokes go to the filter input; outside + // filter mode plain letters are action keys (r/x/s/d/n/e). + filtering bool + + all []plans.Plan + warnings []string + filtered []plans.Plan + selected int + + scrollview *scrollview.Model + keyMap planBrowserKeyMap + // now supplies the reference time for relative "updated" ages, so they + // advance on every render. Injectable for deterministic tests. + now func() time.Time + + // Double-click detection + lastClickTime time.Time + lastClickIndex int +} + +var ( + _ Dialog = (*planBrowserDialog)(nil) + _ PlanDialog = (*planBrowserDialog)(nil) + _ PlanBrowserViewer = (*planBrowserDialog)(nil) +) + +// NewPlanBrowserDialog creates the /plans browser over a listing produced by +// the pkg/plans service. The dialog never touches plan storage itself: every +// action is emitted as a message the app model services. +func NewPlanBrowserDialog(result plans.ListResult) Dialog { + ti := textinput.New() + ti.Placeholder = "Filter plans…" + ti.CharLimit = 100 + ti.SetWidth(50) + + d := &planBrowserDialog{ + filterInput: ti, + scrollview: scrollview.New(scrollview.WithReserveScrollbarSpace(true)), + keyMap: defaultPlanBrowserKeyMap(), + now: time.Now, + lastClickIndex: -1, + } + d.setData(result) + return d +} + +func (d *planBrowserDialog) planDialog() {} + +func (d *planBrowserDialog) planBrowserDialog() {} + +func (d *planBrowserDialog) Init() tea.Cmd { + return nil +} + +// setData replaces the listing and re-applies the filter. When the selected +// plan still exists the selection follows it and the viewport keeps its +// scroll position (clamped if the list shrank), so a live refresh never +// yanks the view back to the top mid-scroll. +func (d *planBrowserDialog) setData(result plans.ListResult) { + var selectedRef *plans.Ref + if p, ok := d.selectedPlan(); ok { + ref := planRef(p) + selectedRef = &ref + } + offset := d.scrollview.ScrollOffset() + + d.all = result.Plans + d.warnings = result.Warnings + d.applyFilter() + + if selectedRef != nil { + for i, p := range d.filtered { + if planRef(p) == *selectedRef { + d.selected = i + // SetScrollOffset clamps against the new row count, so a + // shrunken list can never leave the viewport past the end. + d.scrollview.SetScrollOffset(offset) + break + } + } + } + d.ensureSelectedVisible() +} + +func (d *planBrowserDialog) selectedPlan() (plans.Plan, bool) { + if d.selected < 0 || d.selected >= len(d.filtered) { + return plans.Plan{}, false + } + return d.filtered[d.selected], true +} + +// planRef derives the service address of a listed plan. +func planRef(p plans.Plan) plans.Ref { + if p.Scope == plans.ScopeSession { + return plans.SessionRef(p.SessionID) + } + return plans.SharedRef(p.Name) +} + +func (d *planBrowserDialog) applyFilter() { + query := strings.ToLower(strings.TrimSpace(d.filterInput.Value())) + d.filtered = d.filtered[:0] + for _, p := range d.all { + if query == "" || + strings.Contains(strings.ToLower(p.Name), query) || + strings.Contains(strings.ToLower(p.Title), query) || + strings.Contains(strings.ToLower(p.Status), query) || + strings.Contains(string(p.Scope), query) { + d.filtered = append(d.filtered, p) + } + } + if d.selected >= len(d.filtered) { + d.selected = max(0, len(d.filtered)-1) + } + d.scrollview.SetContent(nil, len(d.filtered)) + d.scrollview.SetScrollOffset(0) +} + +func (d *planBrowserDialog) ensureSelectedVisible() { + if d.selected >= 0 && d.selected < len(d.filtered) { + d.scrollview.EnsureLineVisible(d.selected) + } +} + +func (d *planBrowserDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) { + if handled, cmd := d.scrollview.Update(msg); handled { + return d, cmd + } + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + cmd := d.SetSize(msg.Width, msg.Height) + return d, cmd + + case PlanBrowserDataMsg: + d.setData(msg.Result) + return d, nil + + case tea.PasteMsg: + if d.filtering { + var cmd tea.Cmd + d.filterInput, cmd = d.filterInput.Update(msg) + d.applyFilter() + return d, cmd + } + return d, nil + + case tea.MouseClickMsg: + return d.handleMouseClick(msg) + + case tea.KeyPressMsg: + return d.handleKeyPress(msg) + } + + return d, nil +} + +func (d *planBrowserDialog) handleMouseClick(msg tea.MouseClickMsg) (layout.Model, tea.Cmd) { + if msg.Button != tea.MouseLeft { + return d, nil + } + idx := d.mouseYToPlanIndex(msg.Y) + if idx < 0 { + return d, nil + } + now := time.Now() + if idx == d.lastClickIndex && now.Sub(d.lastClickTime) < styles.DoubleClickThreshold { + d.selected = idx + d.lastClickTime = time.Time{} + cmd := d.openDetailCmd() + return d, cmd + } + d.selected = idx + d.lastClickTime = now + d.lastClickIndex = idx + return d, nil +} + +func (d *planBrowserDialog) handleKeyPress(msg tea.KeyPressMsg) (layout.Model, tea.Cmd) { + if cmd := HandleQuit(msg); cmd != nil { + return d, cmd + } + + // Navigation works in both modes: the filter input has no use for + // up/down/enter. + switch { + case key.Matches(msg, d.keyMap.Up): + if d.selected > 0 { + d.selected-- + d.ensureSelectedVisible() + } + return d, nil + + case key.Matches(msg, d.keyMap.Down): + if d.selected < len(d.filtered)-1 { + d.selected++ + d.ensureSelectedVisible() + } + return d, nil + + case key.Matches(msg, d.keyMap.Enter): + d.stopFiltering() + cmd := d.openDetailCmd() + return d, cmd + + case key.Matches(msg, d.keyMap.Escape): + if d.filtering { + d.stopFiltering() + return d, nil + } + return d, core.CmdHandler(CloseDialogMsg{}) + } + + if d.filtering { + var cmd tea.Cmd + d.filterInput, cmd = d.filterInput.Update(msg) + d.applyFilter() + return d, cmd + } + + switch { + case key.Matches(msg, d.keyMap.Filter): + d.filtering = true + return d, d.filterInput.Focus() + + case key.Matches(msg, d.keyMap.Refresh): + return d, core.CmdHandler(messages.RefreshPlansMsg{}) + + case key.Matches(msg, d.keyMap.Export): + if p, ok := d.selectedPlan(); ok { + return d, core.CmdHandler(messages.ExportPlanMsg{Ref: planRef(p)}) + } + return d, nil + + case key.Matches(msg, d.keyMap.Status): + cmd := d.statusCmd() + return d, cmd + + case key.Matches(msg, d.keyMap.Delete): + cmd := d.deleteCmd() + return d, cmd + + case key.Matches(msg, d.keyMap.New): + return d, core.CmdHandler(OpenDialogMsg{Model: newPlanNameDialog()}) + + case key.Matches(msg, d.keyMap.Edit): + cmd := d.editCmd() + return d, cmd + } + + return d, nil +} + +func (d *planBrowserDialog) stopFiltering() { + d.filtering = false + d.filterInput.Blur() +} + +func (d *planBrowserDialog) openDetailCmd() tea.Cmd { + p, ok := d.selectedPlan() + if !ok { + return nil + } + return core.CmdHandler(messages.OpenPlanDetailMsg{Ref: planRef(p)}) +} + +// guardedSharedPlan returns the selected plan when the given mutation applies +// to it: it must be a shared plan with a displayed version. Session plans get +// an explanatory notification instead of a failed service call. +func (d *planBrowserDialog) guardedSharedPlan(action string) (plans.Plan, tea.Cmd, bool) { + p, ok := d.selectedPlan() + if !ok { + return plans.Plan{}, nil, false + } + if cmd := planMutationGuard(p, action); cmd != nil { + return plans.Plan{}, cmd, false + } + return p, nil, true +} + +func (d *planBrowserDialog) statusCmd() tea.Cmd { + p, cmd, ok := d.guardedSharedPlan("status") + if !ok { + return cmd + } + return core.CmdHandler(OpenDialogMsg{Model: newPlanStatusDialog(p.Name, p.Status, *p.Version)}) +} + +func (d *planBrowserDialog) deleteCmd() tea.Cmd { + p, cmd, ok := d.guardedSharedPlan("delete") + if !ok { + return cmd + } + return core.CmdHandler(OpenDialogMsg{Model: newPlanDeleteConfirmDialog(p.Name, *p.Version)}) +} + +func (d *planBrowserDialog) editCmd() tea.Cmd { + p, cmd, ok := d.guardedSharedPlan("edit") + if !ok { + return cmd + } + return core.CmdHandler(messages.EditPlanMsg{Ref: planRef(p), ExpectedVersion: *p.Version}) +} + +// planMutationGuard returns an explanatory notification when the plan cannot +// be mutated from the host: session plans are read-only here, and a shared +// plan without a version (which the service always provides) is refused +// rather than mutated unguarded. +func planMutationGuard(p plans.Plan, action string) tea.Cmd { + if p.Scope == plans.ScopeSession { + return notification.InfoCmd(fmt.Sprintf( + "Session plans don't support %s: they belong to their session. Change the plan from within its session, or use a shared plan.", action)) + } + if p.Version == nil { + return notification.ErrorCmd(fmt.Sprintf("Cannot %s %q: no version is known; refresh (r) and retry.", action, p.Name)) + } + return nil +} + +func (d *planBrowserDialog) mouseYToPlanIndex(y int) int { + dialogRow, _ := d.Position() + visLines := d.scrollview.VisibleHeight() + listStartY := dialogRow + planBrowserListStartY + + if y < listStartY || y >= listStartY+visLines { + return -1 + } + idx := d.scrollview.ScrollOffset() + (y - listStartY) + if idx < 0 || idx >= len(d.filtered) { + return -1 + } + return idx +} + +func (d *planBrowserDialog) dialogSize() (dialogWidth, maxHeight, contentWidth int) { + dialogWidth = d.ComputeDialogWidth(85, 60, 120) + maxHeight = min(d.Height()*70/100, 30) + contentWidth = dialogWidth - 6 - d.scrollview.ReservedCols() + return dialogWidth, maxHeight, contentWidth +} + +func (d *planBrowserDialog) View() string { + dialogWidth, _, contentWidth := d.dialogSize() + d.filterInput.SetWidth(contentWidth) + + regionWidth := contentWidth + d.scrollview.ReservedCols() + visibleLines := d.scrollview.VisibleHeight() + + dialogRow, dialogCol := d.Position() + d.scrollview.SetPosition(dialogCol+3, dialogRow+planBrowserListStartY) + + total := len(d.filtered) + d.scrollview.SetContent(nil, total) + d.scrollview.SetScrollOffset(d.scrollview.ScrollOffset()) + + var scrollableContent string + if total == 0 { + message := "No plans yet — press n to create a shared plan" + if strings.TrimSpace(d.filterInput.Value()) != "" { + message = "No plans match the filter" + } + emptyLines := []string{"", styles.DialogContentStyle. + Italic(true).Align(lipgloss.Center).Width(contentWidth). + Render(message)} + for len(emptyLines) < visibleLines { + emptyLines = append(emptyLines, "") + } + scrollableContent = d.scrollview.ViewWithLines(emptyLines) + } else { + offset := d.scrollview.ScrollOffset() + end := min(offset+visibleLines, total) + windowLines := make([]string, 0, end-offset) + for i := offset; i < end; i++ { + windowLines = append(windowLines, d.renderPlan(d.filtered[i], i == d.selected, contentWidth)) + } + scrollableContent = d.scrollview.ViewWithLines(windowLines) + } + + var countLabel string + if len(d.filtered) == len(d.all) { + countLabel = strconv.Itoa(len(d.all)) + } else { + countLabel = fmt.Sprintf("%d/%d", len(d.filtered), len(d.all)) + } + title := fmt.Sprintf("Plans (%s)", countLabel) + + filterView := d.filterInput.View() + if !d.filtering && strings.TrimSpace(d.filterInput.Value()) == "" { + filterView = styles.MutedStyle.Render("Press / to filter") + } + + footer := d.footerLine(contentWidth) + + content := NewContent(regionWidth). + AddTitle(title). + AddSpace(). + AddContent(filterView). + AddSeparator(). + AddContent(scrollableContent). + AddSeparator(). + AddContent(footer). + AddSpace(). + AddHelpKeys("↑/↓", "navigate", "enter", "detail", "/", "filter", "r", "refresh", "esc", "close"). + AddHelpKeys("n", "new", "e", "edit", "s", "status", "x", "export", "d", "delete"). + Build() + + return styles.DialogStyle.Width(dialogWidth).Render(content) +} + +// footerLine shows load warnings when present, otherwise the identity of the +// selected plan (useful for truncated names such as session IDs). +func (d *planBrowserDialog) footerLine(contentWidth int) string { + if len(d.warnings) > 0 { + text := fmt.Sprintf("⚠ %d plan(s) could not be read: %s", len(d.warnings), d.warnings[0]) + return styles.WarningStyle.Render(toolcommon.TruncateText(text, contentWidth)) + } + p, ok := d.selectedPlan() + if !ok { + return "" + } + label := string(p.Scope) + " plan: " + return styles.MutedStyle.Render(label) + styles.SecondaryStyle.Render(toolcommon.TruncateText(p.Name, max(1, contentWidth-lipgloss.Width(label)))) +} + +// SetSize sets the dialog dimensions and configures the scrollview region. +func (d *planBrowserDialog) SetSize(width, height int) tea.Cmd { + cmd := d.BaseDialog.SetSize(width, height) + _, maxHeight, contentWidth := d.dialogSize() + regionWidth := contentWidth + d.scrollview.ReservedCols() + visibleLines := max(1, maxHeight-planBrowserListOverhead) + d.scrollview.SetSize(regionWidth, visibleLines) + return cmd +} + +func (d *planBrowserDialog) renderPlan(p plans.Plan, selected bool, maxWidth int) string { + mainStyle, metaStyle := styles.PaletteUnselectedActionStyle, styles.PaletteUnselectedDescStyle + scopeStyle := styles.MutedStyle + if p.Scope == plans.ScopeSession { + scopeStyle = styles.WarningStyle + } + if selected { + mainStyle, metaStyle = styles.PaletteSelectedActionStyle, styles.PaletteSelectedDescStyle + scopeStyle = metaStyle + } + + gap := strings.Repeat(" ", planColGap) + fixed := planColScope + planColName + planColStatus + planColVersion + planColUpdated + 5*planColGap + titleWidth := max(0, maxWidth-fixed) + + row := scopeStyle.Render(planCell(string(p.Scope), planColScope)) + gap + + mainStyle.Render(planCell(p.Name, planColName)) + gap + + metaStyle.Render(planCell(planLabel(p.Status), planColStatus)) + gap + + metaStyle.Render(planCell(planVersionLabel(p.Version), planColVersion)) + gap + + metaStyle.Render(planCell(planTimeAgo(d.now(), p.UpdatedAt), planColUpdated)) + gap + + metaStyle.Render(toolcommon.TruncateText(planLabel(p.Title), titleWidth)) + + // Hard cap so a pathological row can never overflow the dialog. + return lipgloss.NewStyle().MaxWidth(maxWidth).Render(row) +} + +// planCell truncates s to width and pads it to exactly width columns. +func planCell(s string, width int) string { + s = toolcommon.TruncateText(s, width) + return s + strings.Repeat(" ", max(0, width-lipgloss.Width(s))) +} + +// planLabel substitutes a dash for empty display metadata. +func planLabel(s string) string { + if strings.TrimSpace(s) == "" { + return "-" + } + return s +} + +// planVersionLabel renders a shared plan's version and "-" for session +// plans, which have none. +func planVersionLabel(version *int) string { + if version == nil { + return "-" + } + return "v" + strconv.Itoa(*version) +} + +func planTimeAgo(now, t time.Time) string { + if t.IsZero() { + return "-" + } + elapsed := now.Sub(t) + switch { + case elapsed < time.Minute: + return fmt.Sprintf("%ds ago", max(0, int(elapsed.Seconds()))) + case elapsed < time.Hour: + return fmt.Sprintf("%dm ago", int(elapsed.Minutes())) + case elapsed < 24*time.Hour: + return fmt.Sprintf("%dh ago", int(elapsed.Hours())) + case elapsed < 7*24*time.Hour: + return fmt.Sprintf("%dd ago", int(elapsed.Hours()/24)) + default: + return t.Format("Jan 2") + } +} + +func (d *planBrowserDialog) Position() (row, col int) { + dialogWidth, maxHeight, _ := d.dialogSize() + return CenterPosition(d.Width(), d.Height(), dialogWidth, maxHeight) +} + +// --- Status input dialog --- + +// planStatusDialog is the small text-input dialog behind the `s` action. It +// emits SetPlanStatusMsg guarded by the version that was displayed when the +// action started. +type planStatusDialog struct { + BaseDialog + + input textinput.Model + name string + version int +} + +var ( + _ Dialog = (*planStatusDialog)(nil) + _ PlanDialog = (*planStatusDialog)(nil) +) + +func newPlanStatusDialog(name, currentStatus string, version int) Dialog { + ti := textinput.New() + ti.Placeholder = "e.g. in-progress, blocked, done" + ti.CharLimit = 100 + ti.SetWidth(50) + ti.SetValue(currentStatus) + ti.Focus() + + return &planStatusDialog{input: ti, name: name, version: version} +} + +func (d *planStatusDialog) planDialog() {} + +func (d *planStatusDialog) Init() tea.Cmd { + return textinput.Blink +} + +func (d *planStatusDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + cmd := d.SetSize(msg.Width, msg.Height) + return d, cmd + + case tea.PasteMsg: + var cmd tea.Cmd + d.input, cmd = d.input.Update(msg) + return d, cmd + + case tea.KeyPressMsg: + if cmd := HandleQuit(msg); cmd != nil { + return d, cmd + } + switch msg.String() { + case "esc": + return d, core.CmdHandler(CloseDialogMsg{}) + case "enter": + status := strings.TrimSpace(d.input.Value()) + if status == "" { + return d, notification.ErrorCmd("Status must not be empty.") + } + return d, tea.Sequence( + core.CmdHandler(CloseDialogMsg{}), + core.CmdHandler(messages.SetPlanStatusMsg{ + Ref: plans.SharedRef(d.name), + Status: status, + ExpectedVersion: d.version, + }), + ) + default: + var cmd tea.Cmd + d.input, cmd = d.input.Update(msg) + return d, cmd + } + } + return d, nil +} + +func (d *planStatusDialog) View() string { + dialogWidth := d.ComputeDialogWidth(60, 40, 70) + contentWidth := d.ContentWidth(dialogWidth, 2) + d.input.SetWidth(contentWidth) + + content := NewContent(contentWidth). + AddTitle(fmt.Sprintf("Set status: %s (v%d)", d.name, d.version)). + AddSeparator(). + AddSpace(). + AddContent(d.input.View()). + AddSpace(). + AddHelpKeys("enter", "apply", "esc", "cancel"). + Build() + + return styles.DialogStyle.Padding(1, 2).Width(dialogWidth).Render(content) +} + +func (d *planStatusDialog) Position() (row, col int) { + return d.CenterDialog(d.View()) +} + +// --- Delete confirmation dialog --- + +// planDeleteConfirmDialog names the plan and the version the delete is +// guarded by; the actual delete only happens in the app model after Yes. +type planDeleteConfirmDialog struct { + BaseDialog + + name string + version int + keyMap ConfirmKeyMap + escape key.Binding +} + +var ( + _ Dialog = (*planDeleteConfirmDialog)(nil) + _ PlanDialog = (*planDeleteConfirmDialog)(nil) +) + +func newPlanDeleteConfirmDialog(name string, version int) Dialog { + return &planDeleteConfirmDialog{ + name: name, + version: version, + keyMap: DefaultConfirmKeyMap(), + escape: key.NewBinding(key.WithKeys("esc")), + } +} + +func (d *planDeleteConfirmDialog) planDialog() {} + +func (d *planDeleteConfirmDialog) Init() tea.Cmd { + return nil +} + +func (d *planDeleteConfirmDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + cmd := d.SetSize(msg.Width, msg.Height) + return d, cmd + + case tea.KeyPressMsg: + if cmd := HandleQuit(msg); cmd != nil { + return d, cmd + } + if key.Matches(msg, d.escape) { + return d, core.CmdHandler(CloseDialogMsg{}) + } + if model, cmd, handled := HandleConfirmKeys(msg, d.keyMap, + func() (layout.Model, tea.Cmd) { + return d, tea.Sequence( + core.CmdHandler(CloseDialogMsg{}), + core.CmdHandler(messages.DeletePlanMsg{ + Ref: plans.SharedRef(d.name), + ExpectedVersion: d.version, + }), + ) + }, + func() (layout.Model, tea.Cmd) { + return d, core.CmdHandler(CloseDialogMsg{}) + }, + ); handled { + return model, cmd + } + } + return d, nil +} + +func (d *planDeleteConfirmDialog) View() string { + dialogWidth := d.ComputeDialogWidth(60, 40, 70) + contentWidth := d.ContentWidth(dialogWidth, 2) + + content := NewContent(contentWidth). + AddTitle("Delete plan"). + AddSeparator(). + AddSpace(). + AddQuestion(fmt.Sprintf("Delete shared plan %q at version %d? This cannot be undone.", d.name, d.version)). + AddSpace(). + AddHelpKeys("Y", "delete", "N/esc", "cancel"). + Build() + + return styles.DialogStyle.Padding(1, 2).Width(dialogWidth).Render(content) +} + +func (d *planDeleteConfirmDialog) Position() (row, col int) { + return d.CenterDialog(d.View()) +} + +// --- New plan name dialog --- + +// planNameDialog asks for the name of a new shared plan; the content is then +// drafted in the external editor by the app model. +type planNameDialog struct { + BaseDialog + + input textinput.Model +} + +var ( + _ Dialog = (*planNameDialog)(nil) + _ PlanDialog = (*planNameDialog)(nil) +) + +func newPlanNameDialog() Dialog { + ti := textinput.New() + ti.Placeholder = "plan-name (lowercase letters, digits, - and _)" + ti.CharLimit = 100 + ti.SetWidth(50) + ti.Focus() + + return &planNameDialog{input: ti} +} + +func (d *planNameDialog) planDialog() {} + +func (d *planNameDialog) Init() tea.Cmd { + return textinput.Blink +} + +func (d *planNameDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + cmd := d.SetSize(msg.Width, msg.Height) + return d, cmd + + case tea.PasteMsg: + var cmd tea.Cmd + d.input, cmd = d.input.Update(msg) + return d, cmd + + case tea.KeyPressMsg: + if cmd := HandleQuit(msg); cmd != nil { + return d, cmd + } + switch msg.String() { + case "esc": + return d, core.CmdHandler(CloseDialogMsg{}) + case "enter": + name := strings.TrimSpace(d.input.Value()) + if name == "" { + return d, notification.ErrorCmd("Plan name must not be empty.") + } + return d, tea.Sequence( + core.CmdHandler(CloseDialogMsg{}), + core.CmdHandler(messages.CreatePlanMsg{Name: name}), + ) + default: + var cmd tea.Cmd + d.input, cmd = d.input.Update(msg) + return d, cmd + } + } + return d, nil +} + +func (d *planNameDialog) View() string { + dialogWidth := d.ComputeDialogWidth(60, 40, 70) + contentWidth := d.ContentWidth(dialogWidth, 2) + d.input.SetWidth(contentWidth) + + content := NewContent(contentWidth). + AddTitle("New shared plan"). + AddSeparator(). + AddSpace(). + AddContent(d.input.View()). + AddSpace(). + AddHelpKeys("enter", "open editor", "esc", "cancel"). + Build() + + return styles.DialogStyle.Padding(1, 2).Width(dialogWidth).Render(content) +} + +func (d *planNameDialog) Position() (row, col int) { + return d.CenterDialog(d.View()) +} diff --git a/pkg/tui/dialog/plan_browser_test.go b/pkg/tui/dialog/plan_browser_test.go new file mode 100644 index 0000000000..a953dd9d5c --- /dev/null +++ b/pkg/tui/dialog/plan_browser_test.go @@ -0,0 +1,647 @@ +package dialog + +import ( + "fmt" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/plans" + "github.com/docker/docker-agent/pkg/tui/components/notification" + "github.com/docker/docker-agent/pkg/tui/messages" +) + +func letterKey(r rune) tea.KeyPressMsg { + return tea.KeyPressMsg{Code: r, Text: string(r)} +} + +// testPlanListing builds a listing with the current session's plan first +// (the service's ordering) and two shared plans. +func testPlanListing() plans.ListResult { + now := time.Now().UTC() + return plans.ListResult{ + Plans: []plans.Plan{ + { + Scope: plans.ScopeSession, + Name: "11112222-3333-4444-5555-666677778888", + SessionID: "11112222-3333-4444-5555-666677778888", + UpdatedAt: now.Add(-5 * time.Minute), + }, + { + Scope: plans.ScopeShared, + Name: "release", + Title: "Release plan for 2025", + Author: "architect", + Status: "in-progress", + Version: new(3), + UpdatedAt: now.Add(-2 * time.Hour), + }, + { + Scope: plans.ScopeShared, + Name: "db-migration", + Title: "Database migration", + Status: "draft", + Version: new(1), + UpdatedAt: now.Add(-30 * time.Minute), + }, + }, + } +} + +func newTestPlanBrowser(t *testing.T, result plans.ListResult) *planBrowserDialog { + t.Helper() + d := NewPlanBrowserDialog(result).(*planBrowserDialog) + d.Init() + d.Update(tea.WindowSizeMsg{Width: 100, Height: 50}) + return d +} + +func firstMsgOfType[T any](msgs []tea.Msg) (T, bool) { + for _, msg := range msgs { + if typed, ok := msg.(T); ok { + return typed, true + } + } + var zero T + return zero, false +} + +func TestPlanBrowserEmptyList(t *testing.T) { + t.Parallel() + d := newTestPlanBrowser(t, plans.ListResult{Plans: []plans.Plan{}}) + + view := d.View() + assert.Contains(t, view, "No plans yet") + assert.Contains(t, view, "Plans (0)") + + // Actions on an empty list are safe no-ops. + _, cmd := d.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + assert.Nil(t, cmd) + _, cmd = d.Update(letterKey('x')) + assert.Nil(t, cmd) + _, cmd = d.Update(letterKey('d')) + assert.Nil(t, cmd) +} + +func TestPlanBrowserRendersScopeIdentityStatusVersionTimeTitle(t *testing.T) { + t.Parallel() + d := newTestPlanBrowser(t, testPlanListing()) + + view := d.View() + assert.Contains(t, view, "session", "scope column must name the session scope") + assert.Contains(t, view, "shared", "scope column must name the shared scope") + assert.Contains(t, view, "11112222-3333-4444-55", "session plan identity is its session ID") + assert.Contains(t, view, "release") + assert.Contains(t, view, "in-progress") + assert.Contains(t, view, "v3", "shared plan version must be shown") + assert.Contains(t, view, "2h ago", "updated time must be shown") + assert.Contains(t, view, "Release plan", "title must be shown") + + // The session plan row renders "-" for its nonexistent version. + sessionRow := d.renderPlan(d.filtered[0], false, 90) + assert.Contains(t, sessionRow, "-") +} + +func TestPlanBrowserRowsTruncateSafely(t *testing.T) { + t.Parallel() + long := plans.Plan{ + Scope: plans.ScopeShared, + Name: strings.Repeat("very-long-name-", 10), + Title: strings.Repeat("An extremely long title that cannot fit ", 20), + Status: strings.Repeat("status", 10), + Version: new(123456), + UpdatedAt: time.Now(), + } + d := newTestPlanBrowser(t, plans.ListResult{Plans: []plans.Plan{long}}) + + const width = 80 + row := d.renderPlan(long, false, width) + assert.LessOrEqual(t, lipgloss.Width(row), width, "row must never overflow the dialog") + + for line := range strings.SplitSeq(d.View(), "\n") { + assert.LessOrEqual(t, lipgloss.Width(line), 120, "no dialog line may exceed the dialog width") + } +} + +func TestPlanBrowserNavigation(t *testing.T) { + t.Parallel() + d := newTestPlanBrowser(t, testPlanListing()) + + require.Equal(t, 0, d.selected) + d.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + assert.Equal(t, 1, d.selected) + d.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + assert.Equal(t, 2, d.selected) + d.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + assert.Equal(t, 2, d.selected, "selection stays at the end of the list") + d.Update(tea.KeyPressMsg{Code: tea.KeyUp}) + assert.Equal(t, 1, d.selected) +} + +func TestPlanBrowserFilter(t *testing.T) { + t.Parallel() + d := newTestPlanBrowser(t, testPlanListing()) + + // "/" enters filter mode; typing narrows the list. + d.Update(letterKey('/')) + require.True(t, d.filtering) + d.Update(letterKey('r')) + d.Update(letterKey('e')) + d.Update(letterKey('l')) + require.Len(t, d.filtered, 1) + assert.Equal(t, "release", d.filtered[0].Name) + + // Esc leaves filter mode without closing the dialog. + _, cmd := d.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + assert.False(t, d.filtering) + assert.Nil(t, cmd) + + // Outside filter mode, esc closes. + _, cmd = d.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + msgs := collectMsgs(cmd) + _, ok := firstMsgOfType[CloseDialogMsg](msgs) + assert.True(t, ok) +} + +func TestPlanBrowserFilterNoMatches(t *testing.T) { + t.Parallel() + d := newTestPlanBrowser(t, testPlanListing()) + + d.Update(letterKey('/')) + d.Update(letterKey('z')) + d.Update(letterKey('z')) + require.Empty(t, d.filtered) + assert.Contains(t, d.View(), "No plans match the filter") +} + +func TestPlanBrowserEnterOpensDetail(t *testing.T) { + t.Parallel() + d := newTestPlanBrowser(t, testPlanListing()) + + // Session plan (first row): detail is addressed by session ref. + _, cmd := d.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + msg, ok := firstMsgOfType[messages.OpenPlanDetailMsg](collectMsgs(cmd)) + require.True(t, ok, "enter must open the detail dialog, not invoke agent tools") + assert.Equal(t, plans.SessionRef("11112222-3333-4444-5555-666677778888"), msg.Ref) + + // Shared plan. + d.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + _, cmd = d.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + msg, ok = firstMsgOfType[messages.OpenPlanDetailMsg](collectMsgs(cmd)) + require.True(t, ok) + assert.Equal(t, plans.SharedRef("release"), msg.Ref) +} + +func TestPlanBrowserRefreshAndExportKeys(t *testing.T) { + t.Parallel() + d := newTestPlanBrowser(t, testPlanListing()) + + _, cmd := d.Update(letterKey('r')) + _, ok := firstMsgOfType[messages.RefreshPlansMsg](collectMsgs(cmd)) + assert.True(t, ok, "r must request a refresh") + + _, cmd = d.Update(letterKey('x')) + exportMsg, ok := firstMsgOfType[messages.ExportPlanMsg](collectMsgs(cmd)) + require.True(t, ok, "x must request an export") + assert.Equal(t, plans.SessionRef("11112222-3333-4444-5555-666677778888"), exportMsg.Ref) +} + +func TestPlanBrowserStatusFlow(t *testing.T) { + t.Parallel() + d := newTestPlanBrowser(t, testPlanListing()) + d.Update(tea.KeyPressMsg{Code: tea.KeyDown}) // select release, v3 + + _, cmd := d.Update(letterKey('s')) + openMsg, ok := firstMsgOfType[OpenDialogMsg](collectMsgs(cmd)) + require.True(t, ok, "s must open the status input dialog") + statusDialog, ok := openMsg.Model.(*planStatusDialog) + require.True(t, ok) + assert.Equal(t, 3, statusDialog.version, "the displayed version guards the write") + assert.Contains(t, statusDialog.View(), "release") + assert.Contains(t, statusDialog.View(), "v3") + + // The input is prefilled with the current status; replace it. + statusDialog.input.SetValue("done") + _, cmd = statusDialog.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + msgs := collectMsgs(cmd) + _, closed := firstMsgOfType[CloseDialogMsg](msgs) + assert.True(t, closed) + statusMsg, ok := firstMsgOfType[messages.SetPlanStatusMsg](msgs) + require.True(t, ok) + assert.Equal(t, plans.SharedRef("release"), statusMsg.Ref) + assert.Equal(t, "done", statusMsg.Status) + assert.Equal(t, 3, statusMsg.ExpectedVersion) +} + +func TestPlanBrowserStatusEmptyRejected(t *testing.T) { + t.Parallel() + sd := newPlanStatusDialog("release", "", 3).(*planStatusDialog) + sd.input.SetValue(" ") + + _, cmd := sd.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + msgs := collectMsgs(cmd) + _, gotStatus := firstMsgOfType[messages.SetPlanStatusMsg](msgs) + assert.False(t, gotStatus, "an empty status must not be submitted") + note, ok := firstMsgOfType[notification.ShowMsg](msgs) + require.True(t, ok) + assert.Equal(t, notification.TypeError, note.Type) +} + +func TestPlanBrowserSessionMutationsUnsupported(t *testing.T) { + t.Parallel() + d := newTestPlanBrowser(t, testPlanListing()) // session plan selected + + for _, r := range []rune{'s', 'd', 'e'} { + _, cmd := d.Update(letterKey(r)) + msgs := collectMsgs(cmd) + note, ok := firstMsgOfType[notification.ShowMsg](msgs) + require.True(t, ok, "%c on a session plan must show an explanatory notification", r) + assert.Contains(t, note.Text, "Session plans") + _, opened := firstMsgOfType[OpenDialogMsg](msgs) + assert.False(t, opened, "%c must not open an action dialog for session plans", r) + _, statusEmitted := firstMsgOfType[messages.SetPlanStatusMsg](msgs) + assert.False(t, statusEmitted) + _, deleteEmitted := firstMsgOfType[messages.DeletePlanMsg](msgs) + assert.False(t, deleteEmitted) + _, editEmitted := firstMsgOfType[messages.EditPlanMsg](msgs) + assert.False(t, editEmitted) + } +} + +func TestPlanBrowserDeleteFlow(t *testing.T) { + t.Parallel() + d := newTestPlanBrowser(t, testPlanListing()) + d.Update(tea.KeyPressMsg{Code: tea.KeyDown}) // select release, v3 + + _, cmd := d.Update(letterKey('d')) + openMsg, ok := firstMsgOfType[OpenDialogMsg](collectMsgs(cmd)) + require.True(t, ok, "d must open a confirmation dialog") + confirm, ok := openMsg.Model.(*planDeleteConfirmDialog) + require.True(t, ok) + + view := confirm.View() + assert.Contains(t, view, "release", "the confirmation must name the plan") + assert.Contains(t, view, "version 3", "the confirmation must name the version") + + // N cancels without emitting a delete. + _, cmd = confirm.Update(letterKey('n')) + msgs := collectMsgs(cmd) + _, deleted := firstMsgOfType[messages.DeletePlanMsg](msgs) + assert.False(t, deleted) + _, closed := firstMsgOfType[CloseDialogMsg](msgs) + assert.True(t, closed) + + // Y confirms with the displayed version as the guard. + _, cmd = confirm.Update(letterKey('y')) + msgs = collectMsgs(cmd) + deleteMsg, ok := firstMsgOfType[messages.DeletePlanMsg](msgs) + require.True(t, ok) + assert.Equal(t, plans.SharedRef("release"), deleteMsg.Ref) + assert.Equal(t, 3, deleteMsg.ExpectedVersion) +} + +func TestPlanBrowserNewFlow(t *testing.T) { + t.Parallel() + d := newTestPlanBrowser(t, testPlanListing()) + + _, cmd := d.Update(letterKey('n')) + openMsg, ok := firstMsgOfType[OpenDialogMsg](collectMsgs(cmd)) + require.True(t, ok, "n must open the name dialog") + nameDialog, ok := openMsg.Model.(*planNameDialog) + require.True(t, ok) + + for _, r := range "my-plan" { + nameDialog.Update(letterKey(r)) + } + _, cmd = nameDialog.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + msgs := collectMsgs(cmd) + createMsg, ok := firstMsgOfType[messages.CreatePlanMsg](msgs) + require.True(t, ok) + assert.Equal(t, "my-plan", createMsg.Name) +} + +func TestPlanBrowserEditKey(t *testing.T) { + t.Parallel() + d := newTestPlanBrowser(t, testPlanListing()) + d.Update(tea.KeyPressMsg{Code: tea.KeyDown}) // select release, v3 + + _, cmd := d.Update(letterKey('e')) + editMsg, ok := firstMsgOfType[messages.EditPlanMsg](collectMsgs(cmd)) + require.True(t, ok) + assert.Equal(t, plans.SharedRef("release"), editMsg.Ref) + assert.Equal(t, 3, editMsg.ExpectedVersion) +} + +func TestPlanBrowserDataMsgReplacesRowsAndKeepsSelection(t *testing.T) { + t.Parallel() + d := newTestPlanBrowser(t, testPlanListing()) + d.Update(tea.KeyPressMsg{Code: tea.KeyDown}) // select release + + updated := plans.ListResult{Plans: []plans.Plan{ + {Scope: plans.ScopeShared, Name: "db-migration", Version: new(1)}, + {Scope: plans.ScopeShared, Name: "release", Status: "done", Version: new(4)}, + }} + d.Update(PlanBrowserDataMsg{Result: updated}) + + require.Len(t, d.filtered, 2) + p, ok := d.selectedPlan() + require.True(t, ok) + assert.Equal(t, "release", p.Name, "selection follows the plan identity across refreshes") + assert.Equal(t, 4, *p.Version) + assert.NotContains(t, d.View(), "11112222", "removed rows must disappear") +} + +// manyPlansListing builds n shared plans named plan-00, plan-01, … so tests +// can scroll a list taller than the viewport. +func manyPlansListing(n int) plans.ListResult { + result := plans.ListResult{Plans: make([]plans.Plan, 0, n)} + for i := range n { + result.Plans = append(result.Plans, plans.Plan{ + Scope: plans.ScopeShared, + Name: fmt.Sprintf("plan-%02d", i), + Version: new(1), + }) + } + return result +} + +// TestPlanBrowserDataMsgPreservesScrollOffset proves a live data refresh +// never yanks a scrolled viewport back to the top while the selected plan +// still exists — independent of how the offset came about (wheel, scrollbar, +// or keys). +func TestPlanBrowserDataMsgPreservesScrollOffset(t *testing.T) { + t.Parallel() + listing := manyPlansListing(40) + d := newTestPlanBrowser(t, listing) + viewport := d.scrollview.VisibleHeight() + require.Greater(t, len(listing.Plans), viewport, "the list must be taller than the viewport") + + // Scroll down and select a row inside the scrolled window. + d.selected = 30 + d.ensureSelectedVisible() + offset := d.scrollview.ScrollOffset() + require.Positive(t, offset) + + d.Update(PlanBrowserDataMsg{Result: manyPlansListing(40)}) + + assert.Equal(t, offset, d.scrollview.ScrollOffset(), "a same-shape refresh must keep the viewport position") + p, ok := d.selectedPlan() + require.True(t, ok) + assert.Equal(t, "plan-30", p.Name) +} + +// TestPlanBrowserDataMsgClampsScrollOffsetWhenRowsShrink proves a refresh +// that removes rows clamps the preserved offset instead of scrolling past +// the end of the shorter list. +func TestPlanBrowserDataMsgClampsScrollOffsetWhenRowsShrink(t *testing.T) { + t.Parallel() + d := newTestPlanBrowser(t, manyPlansListing(40)) + viewport := d.scrollview.VisibleHeight() + + d.selected = 19 // survives the shrink below + d.scrollview.SetScrollOffset(13) + require.Equal(t, 13, d.scrollview.ScrollOffset()) + + shrunk := manyPlansListing(20) + d.Update(PlanBrowserDataMsg{Result: shrunk}) + + wantMax := max(0, len(shrunk.Plans)-viewport) + assert.LessOrEqual(t, d.scrollview.ScrollOffset(), wantMax, "the offset must clamp to the shorter list") + p, ok := d.selectedPlan() + require.True(t, ok) + assert.Equal(t, "plan-19", p.Name) + // The selected row is still within the visible window. + assert.GreaterOrEqual(t, d.selected, d.scrollview.ScrollOffset()) + assert.Less(t, d.selected, d.scrollview.ScrollOffset()+viewport) +} + +// TestPlanBrowserFilterResetsScrollOffset pins that user-initiated filtering +// still starts from the top: only live data refreshes preserve the offset. +func TestPlanBrowserFilterResetsScrollOffset(t *testing.T) { + t.Parallel() + d := newTestPlanBrowser(t, manyPlansListing(40)) + d.scrollview.SetScrollOffset(13) + + d.Update(letterKey('/')) + d.Update(letterKey('p')) + assert.Zero(t, d.scrollview.ScrollOffset(), "filtering starts from the top of the matches") +} + +// TestPlanBrowserUpdatedAgesAdvance proves relative "updated" ages are +// rendered against the current time, not the time the dialog was opened, so +// a plan can never stay "0s ago" forever. +func TestPlanBrowserUpdatedAgesAdvance(t *testing.T) { + t.Parallel() + base := time.Date(2024, 5, 6, 7, 8, 9, 0, time.UTC) + listing := plans.ListResult{Plans: []plans.Plan{ + {Scope: plans.ScopeShared, Name: "release", Version: new(1), UpdatedAt: base}, + }} + d := newTestPlanBrowser(t, listing) + + d.now = func() time.Time { return base.Add(30 * time.Second) } + assert.Contains(t, d.View(), "30s ago") + + d.now = func() time.Time { return base.Add(3 * time.Minute) } + assert.Contains(t, d.View(), "3m ago", "the age must advance with the clock, without any data message") +} + +func TestPlanBrowserWarningsShown(t *testing.T) { + t.Parallel() + result := testPlanListing() + result.Warnings = []string{`skipped "broken": corrupt`} + d := newTestPlanBrowser(t, result) + + assert.Contains(t, d.View(), "could not be read") +} + +// --- Detail dialog --- + +func newTestPlanDetail(t *testing.T, p plans.Plan) *planDetailDialog { + t.Helper() + d := NewPlanDetailDialog(p).(*planDetailDialog) + d.Init() + d.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) + return d +} + +func sharedDetailPlan() plans.Plan { + return plans.Plan{ + Scope: plans.ScopeShared, + Name: "release", + Title: "Release plan", + Author: "architect", + Status: "in-progress", + Content: "# Release\n\nStep one.", + Version: new(3), + UpdatedAt: time.Now().Add(-time.Hour), + } +} + +func TestPlanDetailRendersSharedMetadata(t *testing.T) { + t.Parallel() + d := newTestPlanDetail(t, sharedDetailPlan()) + + view := d.View() + assert.Contains(t, view, "shared — collaborative, versioned") + assert.Contains(t, view, "release") + assert.Contains(t, view, "in-progress") + assert.Contains(t, view, "v3") + assert.Contains(t, view, "architect") + assert.Contains(t, view, "1h ago") + assert.Contains(t, view, "Step one.") +} + +func TestPlanDetailRendersSessionMetadata(t *testing.T) { + t.Parallel() + p := plans.Plan{ + Scope: plans.ScopeSession, + Name: "11112222-3333-4444-5555-666677778888", + SessionID: "11112222-3333-4444-5555-666677778888", + Content: "session plan body", + UpdatedAt: time.Now(), + } + d := newTestPlanDetail(t, p) + + view := d.View() + assert.Contains(t, view, "read-only here", "session scope must be explicit") + assert.Contains(t, view, "11112222-3333-4444-5555-666677778888") + assert.Contains(t, view, "session plans have no versions") + assert.NotContains(t, view, "status", "session detail must not advertise unsupported actions") + assert.Contains(t, view, "session plan body") +} + +func TestPlanDetailScrollsLongContent(t *testing.T) { + t.Parallel() + p := sharedDetailPlan() + var body strings.Builder + for i := range 200 { + body.WriteString("line ") + body.WriteString(strings.Repeat("x", i%10)) + body.WriteString("\n") + } + p.Content = body.String() + d := newTestPlanDetail(t, p) + + before := d.View() + require.Equal(t, 0, d.scrollview.ScrollOffset()) + d.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + assert.Equal(t, 1, d.scrollview.ScrollOffset(), "down must scroll the content") + d.Update(tea.KeyPressMsg{Code: tea.KeyPgDown}) + assert.Greater(t, d.scrollview.ScrollOffset(), 1) + + after := d.View() + assert.Equal(t, lipgloss.Height(before), lipgloss.Height(after), "dialog height must stay stable while scrolling") +} + +func TestPlanDetailKeysEmitIntents(t *testing.T) { + t.Parallel() + d := newTestPlanDetail(t, sharedDetailPlan()) + + _, cmd := d.Update(letterKey('r')) + _, ok := firstMsgOfType[messages.RefreshPlansMsg](collectMsgs(cmd)) + assert.True(t, ok) + + _, cmd = d.Update(letterKey('x')) + exportMsg, ok := firstMsgOfType[messages.ExportPlanMsg](collectMsgs(cmd)) + require.True(t, ok) + assert.Equal(t, plans.SharedRef("release"), exportMsg.Ref) + + _, cmd = d.Update(letterKey('e')) + editMsg, ok := firstMsgOfType[messages.EditPlanMsg](collectMsgs(cmd)) + require.True(t, ok) + assert.Equal(t, 3, editMsg.ExpectedVersion) + + _, cmd = d.Update(letterKey('s')) + openMsg, ok := firstMsgOfType[OpenDialogMsg](collectMsgs(cmd)) + require.True(t, ok) + _, isStatus := openMsg.Model.(*planStatusDialog) + assert.True(t, isStatus) + + _, cmd = d.Update(letterKey('d')) + openMsg, ok = firstMsgOfType[OpenDialogMsg](collectMsgs(cmd)) + require.True(t, ok) + _, isConfirm := openMsg.Model.(*planDeleteConfirmDialog) + assert.True(t, isConfirm) + + _, cmd = d.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + _, closed := firstMsgOfType[CloseDialogMsg](collectMsgs(cmd)) + assert.True(t, closed) +} + +func TestPlanDetailSessionActionsUnsupported(t *testing.T) { + t.Parallel() + p := plans.Plan{ + Scope: plans.ScopeSession, + Name: "sess-1", + SessionID: "sess-1", + Content: "body", + } + d := newTestPlanDetail(t, p) + + for _, r := range []rune{'s', 'd', 'e'} { + _, cmd := d.Update(letterKey(r)) + msgs := collectMsgs(cmd) + note, ok := firstMsgOfType[notification.ShowMsg](msgs) + require.True(t, ok, "%c must explain that session plans are read-only", r) + assert.Contains(t, note.Text, "Session plans") + _, opened := firstMsgOfType[OpenDialogMsg](msgs) + assert.False(t, opened) + } +} + +func TestPlanDetailDataMsgAppliesOnlyMatchingPlan(t *testing.T) { + t.Parallel() + d := newTestPlanDetail(t, sharedDetailPlan()) + + other := sharedDetailPlan() + other.Name = "other" + other.Status = "done" + d.Update(PlanDetailDataMsg{Plan: other}) + assert.Contains(t, d.View(), "in-progress", "data for another plan must be ignored") + + updated := sharedDetailPlan() + updated.Status = "done" + updated.Version = new(4) + updated.Content = "new content" + d.Update(PlanDetailDataMsg{Plan: updated}) + view := d.View() + assert.Contains(t, view, "done") + assert.Contains(t, view, "v4") + assert.Contains(t, view, "new content") +} + +// TestPlanDetailUpdatedAgeAdvances proves the detail's relative "updated" +// age is rendered against the current time, so it advances without a data +// refresh. +func TestPlanDetailUpdatedAgeAdvances(t *testing.T) { + t.Parallel() + p := sharedDetailPlan() + base := time.Date(2024, 5, 6, 7, 8, 9, 0, time.UTC) + p.UpdatedAt = base + d := newTestPlanDetail(t, p) + + d.now = func() time.Time { return base.Add(45 * time.Minute) } + assert.Contains(t, d.View(), "45m ago") + + d.now = func() time.Time { return base.Add(2 * time.Hour) } + assert.Contains(t, d.View(), "2h ago", "the age must advance with the clock, without any data message") +} + +func TestPlanDialogMarkers(t *testing.T) { + t.Parallel() + + _, isPlanDialog := NewPlanBrowserDialog(plans.ListResult{}).(PlanDialog) + assert.True(t, isPlanDialog) + detail := NewPlanDetailDialog(sharedDetailPlan()) + _, isPlanDialog = detail.(PlanDialog) + assert.True(t, isPlanDialog) + + viewer, ok := detail.(PlanDetailViewer) + require.True(t, ok) + assert.Equal(t, plans.SharedRef("release"), viewer.PlanRef()) +} diff --git a/pkg/tui/dialog/plan_detail.go b/pkg/tui/dialog/plan_detail.go new file mode 100644 index 0000000000..0692deab33 --- /dev/null +++ b/pkg/tui/dialog/plan_detail.go @@ -0,0 +1,302 @@ +package dialog + +import ( + "fmt" + "strings" + "time" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/docker/docker-agent/pkg/plans" + "github.com/docker/docker-agent/pkg/tui/components/markdown" + "github.com/docker/docker-agent/pkg/tui/components/scrollview" + "github.com/docker/docker-agent/pkg/tui/components/toolcommon" + "github.com/docker/docker-agent/pkg/tui/core" + "github.com/docker/docker-agent/pkg/tui/core/layout" + "github.com/docker/docker-agent/pkg/tui/messages" + "github.com/docker/docker-agent/pkg/tui/styles" +) + +// planDetailKeyMap defines key bindings for the plan detail dialog. Scrolling +// is handled by the scrollview's read-only key map. +type planDetailKeyMap struct { + Escape key.Binding + Refresh key.Binding + Export key.Binding + Status key.Binding + Delete key.Binding + Edit key.Binding +} + +func defaultPlanDetailKeyMap() planDetailKeyMap { + return planDetailKeyMap{ + Escape: key.NewBinding(key.WithKeys("esc", "q")), + Refresh: key.NewBinding(key.WithKeys("r")), + Export: key.NewBinding(key.WithKeys("x")), + Status: key.NewBinding(key.WithKeys("s")), + Delete: key.NewBinding(key.WithKeys("d")), + Edit: key.NewBinding(key.WithKeys("e")), + } +} + +// planDetailChrome is border (2) + padding (2) of the dialog frame. +const planDetailChrome = 4 + +// planDetailDialog shows one plan in full: explicit scope, identity and +// metadata, and the markdown content in a scrollable viewport. It emits the +// same action intents as the browser; the app model owns all persistence. +type planDetailDialog struct { + BaseDialog + + plan plans.Plan + scrollview *scrollview.Model + keyMap planDetailKeyMap + // now supplies the reference time for the relative "updated" age, so it + // advances on every render. Injectable for deterministic tests. + now func() time.Time + + // Markdown render cache, invalidated on data refresh or width change. + contentLines []string + renderedFor int +} + +var ( + _ Dialog = (*planDetailDialog)(nil) + _ PlanDialog = (*planDetailDialog)(nil) + _ PlanDetailViewer = (*planDetailDialog)(nil) +) + +// NewPlanDetailDialog creates the detail dialog for a plan fetched through +// the pkg/plans service (content included). +func NewPlanDetailDialog(p plans.Plan) Dialog { + return &planDetailDialog{ + plan: p, + scrollview: scrollview.New( + scrollview.WithKeyMap(scrollview.ReadOnlyScrollKeyMap()), + scrollview.WithReserveScrollbarSpace(true), + ), + keyMap: defaultPlanDetailKeyMap(), + now: time.Now, + } +} + +func (d *planDetailDialog) planDialog() {} + +// PlanRef implements PlanDetailViewer so the app model can re-fetch the +// shown plan on refresh. +func (d *planDetailDialog) PlanRef() plans.Ref { return planRef(d.plan) } + +func (d *planDetailDialog) Init() tea.Cmd { + return nil +} + +func (d *planDetailDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) { + if handled, cmd := d.scrollview.Update(msg); handled { + return d, cmd + } + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + cmd := d.SetSize(msg.Width, msg.Height) + return d, cmd + + case PlanDetailDataMsg: + // Broadcast refresh: only apply data for the plan this dialog shows. + if planRef(msg.Plan) == d.PlanRef() { + d.plan = msg.Plan + d.contentLines = nil + d.renderedFor = 0 + } + return d, nil + + case tea.KeyPressMsg: + return d.handleKeyPress(msg) + } + + return d, nil +} + +func (d *planDetailDialog) handleKeyPress(msg tea.KeyPressMsg) (layout.Model, tea.Cmd) { + if cmd := HandleQuit(msg); cmd != nil { + return d, cmd + } + + switch { + case key.Matches(msg, d.keyMap.Escape): + return d, core.CmdHandler(CloseDialogMsg{}) + + case key.Matches(msg, d.keyMap.Refresh): + return d, core.CmdHandler(messages.RefreshPlansMsg{}) + + case key.Matches(msg, d.keyMap.Export): + return d, core.CmdHandler(messages.ExportPlanMsg{Ref: d.PlanRef()}) + + case key.Matches(msg, d.keyMap.Status): + if cmd := planMutationGuard(d.plan, "status"); cmd != nil { + return d, cmd + } + return d, core.CmdHandler(OpenDialogMsg{Model: newPlanStatusDialog(d.plan.Name, d.plan.Status, *d.plan.Version)}) + + case key.Matches(msg, d.keyMap.Delete): + if cmd := planMutationGuard(d.plan, "delete"); cmd != nil { + return d, cmd + } + return d, core.CmdHandler(OpenDialogMsg{Model: newPlanDeleteConfirmDialog(d.plan.Name, *d.plan.Version)}) + + case key.Matches(msg, d.keyMap.Edit): + if cmd := planMutationGuard(d.plan, "edit"); cmd != nil { + return d, cmd + } + return d, core.CmdHandler(messages.EditPlanMsg{Ref: d.PlanRef(), ExpectedVersion: *d.plan.Version}) + } + + return d, nil +} + +func (d *planDetailDialog) dialogSize() (dialogWidth, maxHeight, contentWidth int) { + dialogWidth = d.ComputeDialogWidth(80, 60, 110) + maxHeight = min(d.Height()*80/100, 40) + contentWidth = d.ContentWidth(dialogWidth, 2) - d.scrollview.ReservedCols() + return dialogWidth, maxHeight, contentWidth +} + +// headerLines renders the fixed metadata block above the scrollable content. +func (d *planDetailDialog) headerLines(contentWidth int) []string { + p := d.plan + + title := "Plan: " + p.Name + if p.Scope == plans.ScopeSession { + title = "Session plan" + } + + lines := []string{ + RenderTitle(toolcommon.TruncateText(title, contentWidth), contentWidth, styles.DialogTitleStyle), + RenderSeparator(contentWidth), + } + + field := func(label, value string) string { + l := styles.MutedStyle.Render(planCell(label, 9)) + return l + styles.DialogContentStyle.Render(toolcommon.TruncateText(value, max(1, contentWidth-10))) + } + + if p.Scope == plans.ScopeSession { + lines = append(lines, + field("Scope", "session — owned by its session, read-only here"), + field("Session", p.SessionID), + field("Version", "- (session plans have no versions)"), + ) + } else { + lines = append(lines, + field("Scope", "shared — collaborative, versioned"), + field("Name", p.Name), + ) + if p.Title != "" { + lines = append(lines, field("Title", p.Title)) + } + lines = append(lines, + field("Status", planLabel(p.Status)), + field("Version", planVersionLabel(p.Version)), + field("Author", planLabel(p.Author)), + ) + } + lines = append(lines, field("Updated", d.updatedLabel()), RenderSeparator(contentWidth)) + return lines +} + +func (d *planDetailDialog) updatedLabel() string { + if d.plan.UpdatedAt.IsZero() { + return "-" + } + return fmt.Sprintf("%s (%s)", d.plan.UpdatedAt.Local().Format("2006-01-02 15:04"), planTimeAgo(d.now(), d.plan.UpdatedAt)) +} + +// renderContent returns the markdown-rendered plan body, cached per width. +func (d *planDetailDialog) renderContent(contentWidth int) []string { + if d.contentLines != nil && d.renderedFor == contentWidth { + return d.contentLines + } + + content := strings.TrimRight(d.plan.Content, "\n") + var lines []string + if strings.TrimSpace(content) == "" { + lines = []string{styles.MutedStyle.Italic(true).Render("(empty plan)")} + } else if rendered, err := markdown.NewRendererWithoutCopyIcon(contentWidth).Render(content); err == nil { + lines = strings.Split(strings.TrimRight(rendered, "\n"), "\n") + } else { + // Fallback: raw text, word-wrapped so long lines can't overflow. + lines = toolcommon.WrapLinesWords(content, contentWidth) + } + + // Belt and braces: cap every line so pathological content never breaks + // the dialog frame. + capStyle := lipgloss.NewStyle().MaxWidth(contentWidth) + for i, l := range lines { + if lipgloss.Width(l) > contentWidth { + lines[i] = capStyle.Render(l) + } + } + + d.contentLines = lines + d.renderedFor = contentWidth + return lines +} + +func (d *planDetailDialog) helpKeys() []string { + keys := []string{"↑/↓", "scroll", "r", "refresh", "x", "export"} + if d.plan.Scope.Mutable() { + keys = append(keys, "s", "status", "e", "edit", "d", "delete") + } + return append(keys, "esc", "close") +} + +// viewport computes the number of scrollable content lines that fit. +func (d *planDetailDialog) viewport(headerCount int) int { + _, maxHeight, _ := d.dialogSize() + // header + space(1) + help(1) + frame + return max(1, maxHeight-headerCount-2-planDetailChrome) +} + +func (d *planDetailDialog) View() string { + dialogWidth, _, contentWidth := d.dialogSize() + regionWidth := contentWidth + d.scrollview.ReservedCols() + + header := d.headerLines(contentWidth) + contentLines := d.renderContent(contentWidth) + viewport := d.viewport(len(header)) + + d.scrollview.SetSize(regionWidth, viewport) + dialogRow, dialogCol := d.Position() + d.scrollview.SetPosition(dialogCol+3, dialogRow+planDetailChrome/2+len(header)) + d.scrollview.SetContent(contentLines, len(contentLines)) + + scrollOut := d.scrollview.View() + scrollLines := strings.Split(scrollOut, "\n") + for len(scrollLines) < viewport { + scrollLines = append(scrollLines, "") + } + scrollLines = scrollLines[:viewport] + + parts := make([]string, 0, len(header)+viewport+2) + parts = append(parts, header...) + parts = append(parts, scrollLines...) + parts = append(parts, "", RenderHelpKeys(regionWidth, d.helpKeys()...)) + + content := lipgloss.JoinVertical(lipgloss.Left, parts...) + return styles.DialogStyle.Padding(1, 2).Width(dialogWidth).Render(content) +} + +// SetSize sets the dialog dimensions and configures the scrollview region. +func (d *planDetailDialog) SetSize(width, height int) tea.Cmd { + cmd := d.BaseDialog.SetSize(width, height) + _, _, contentWidth := d.dialogSize() + regionWidth := contentWidth + d.scrollview.ReservedCols() + d.scrollview.SetSize(regionWidth, d.viewport(len(d.headerLines(contentWidth)))) + return cmd +} + +func (d *planDetailDialog) Position() (row, col int) { + dialogWidth, maxHeight, _ := d.dialogSize() + return CenterPosition(d.Width(), d.Height(), dialogWidth, maxHeight) +} diff --git a/pkg/tui/messages/messages.go b/pkg/tui/messages/messages.go index b93dbdaed1..37827aed4e 100644 --- a/pkg/tui/messages/messages.go +++ b/pkg/tui/messages/messages.go @@ -7,6 +7,7 @@ // - toggle.go: UI state toggles (YOLO, sidebar) // - input.go: Editor input, attachments, and speech // - mcp.go: MCP prompt interactions +// - plans.go: Plan management (/plans browser) // // This organization follows the Elm Architecture principle of grouping // messages by the domain they affect, making it easier to understand diff --git a/pkg/tui/messages/plans.go b/pkg/tui/messages/plans.go new file mode 100644 index 0000000000..f237b16e06 --- /dev/null +++ b/pkg/tui/messages/plans.go @@ -0,0 +1,52 @@ +package messages + +import "github.com/docker/docker-agent/pkg/plans" + +// Plan management messages drive the /plans browser. Dialogs emit these +// intents; the app model services them through the pkg/plans host service and +// pushes fresh data back into the open dialogs. Dialogs never touch storage. +// +// Mutation messages carry the version that was displayed when the user chose +// the action (never nil), so every write is guarded by optimistic locking and +// a concurrent change surfaces as an actionable conflict instead of a silent +// overwrite. +type ( + // ShowPlanBrowserMsg opens the /plans browser dialog. + ShowPlanBrowserMsg struct{} + + // RefreshPlansMsg reloads plan data into the open plan dialogs. + RefreshPlansMsg struct{} + + // OpenPlanDetailMsg opens the detail dialog for one plan. + OpenPlanDetailMsg struct{ Ref plans.Ref } + + // ExportPlanMsg exports a plan's content to the default file in the + // active working directory. The app refuses to overwrite an existing + // file. + ExportPlanMsg struct{ Ref plans.Ref } + + // SetPlanStatusMsg sets a shared plan's free-form status. + SetPlanStatusMsg struct { + Ref plans.Ref + Status string + ExpectedVersion int + } + + // DeletePlanMsg deletes a shared plan after the user confirmed the + // named plan at the named version. + DeletePlanMsg struct { + Ref plans.Ref + ExpectedVersion int + } + + // CreatePlanMsg creates a new shared plan by drafting its content in + // the external $VISUAL/$EDITOR. + CreatePlanMsg struct{ Name string } + + // EditPlanMsg edits a shared plan's content in the external + // $VISUAL/$EDITOR. + EditPlanMsg struct { + Ref plans.Ref + ExpectedVersion int + } +) diff --git a/pkg/tui/plans.go b/pkg/tui/plans.go new file mode 100644 index 0000000000..220dafef46 --- /dev/null +++ b/pkg/tui/plans.go @@ -0,0 +1,969 @@ +package tui + +import ( + "cmp" + "context" + "errors" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + goruntime "runtime" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + + "github.com/docker/docker-agent/pkg/plans" + "github.com/docker/docker-agent/pkg/runtime" + "github.com/docker/docker-agent/pkg/tools/builtin/plan" + "github.com/docker/docker-agent/pkg/tui/components/notification" + "github.com/docker/docker-agent/pkg/tui/core" + "github.com/docker/docker-agent/pkg/tui/dialog" + "github.com/docker/docker-agent/pkg/tui/messages" +) + +// defaultPlanMutationTimeout bounds every plan persistence call issued from +// the TUI (create, update, set-status, delete). Those writes take a +// cross-process file lock, so a lock wedged by another process must surface +// as an actionable timeout notification instead of freezing the event loop +// indefinitely. +const defaultPlanMutationTimeout = 5 * time.Second + +// defaultPlanReadTimeout bounds every plan read issued from the TUI (list, +// get, export, and the read preparing an edit). Reads never take the plans +// lock, but storage can still wedge (network filesystem, dying disk); the +// deliberately generous bound turns a stuck read into an actionable +// notification instead of dialogs that never load, and guarantees the +// refresh pipeline's in-flight flag is always cleared. +const defaultPlanReadTimeout = 10 * time.Second + +// WithPlansService injects the plans host service, replacing the lazily +// built default over plan.SharedStorage(). Intended for tests, which run +// against temp-backed storage instead of the user's data directory. +func WithPlansService(svc plans.Service) Option { + return func(m *appModel) { + if svc != nil { + m.plansSvc = svc + } + } +} + +// planMutationTimeoutOrDefault returns the persistence timeout for this +// model, falling back to the package default. Tests set the field to keep +// timeout scenarios fast. +func (m *appModel) planMutationTimeoutOrDefault() time.Duration { + return cmp.Or(m.planMutationTimeout, defaultPlanMutationTimeout) +} + +// planReadTimeoutOrDefault returns the read timeout for this model, falling +// back to the package default. Tests set the field to keep timeout scenarios +// fast. +func (m *appModel) planReadTimeoutOrDefault() time.Duration { + return cmp.Or(m.planReadTimeout, defaultPlanReadTimeout) +} + +// plansService returns the host-facing plan service, building it on first +// use. The lazy build matters: plan.SharedStorage() memoizes the plans +// directory process-wide, so it must not be touched before the CLI pre-run +// has applied path configuration (e.g. --data-dir). +func (m *appModel) plansService() plans.Service { + if m.plansSvc == nil { + m.plansSvc = plans.NewService(plan.SharedStorage()) + } + return m.plansSvc +} + +// currentPlanSessionID identifies the active session whose plan is included +// in listings. Only this session is ever consulted; plan files left behind +// by other sessions are never enumerated. +func (m *appModel) currentPlanSessionID() string { + if m.application == nil { + return "" + } + if sess := m.application.Session(); sess != nil { + return sess.ID + } + return "" +} + +// planListOptions snapshots the listing options from model state; commands +// run off the event loop and must not touch the model. +func (m *appModel) planListOptions() plans.ListOptions { + return plans.ListOptions{SessionID: m.currentPlanSessionID()} +} + +// planDialogOpen reports whether any dialog of the /plans flow is on the +// stack — topmost or buried under another dialog — i.e. plan data is on +// screen and worth live-refreshing. +func (m *appModel) planDialogOpen() bool { + return m.dialogMgr.HasDialog(func(d dialog.Dialog) bool { + _, ok := d.(dialog.PlanDialog) + return ok + }) +} + +// planBrowserOpen reports whether the /plans browser dialog is on the stack, +// topmost or buried. +func (m *appModel) planBrowserOpen() bool { + return m.dialogMgr.HasDialog(func(d dialog.Dialog) bool { + _, ok := d.(dialog.PlanBrowserViewer) + return ok + }) +} + +// planDetailOpen reports whether a detail dialog for exactly ref is on the +// stack, topmost or buried. +func (m *appModel) planDetailOpen(ref plans.Ref) bool { + return m.dialogMgr.HasDialog(func(d dialog.Dialog) bool { + viewer, ok := d.(dialog.PlanDetailViewer) + return ok && viewer.PlanRef() == ref + }) +} + +func (m *appModel) handleShowPlanBrowser() (tea.Model, tea.Cmd) { + // One browser only: with a browser already on the stack (even buried) or + // its opening read already in flight for this session, a repeated /plans + // must not start a second List or stack a duplicate browser. A request + // for a different session may launch — the superseded read's result is + // dropped as stale in handlePlanBrowserLoaded. + if m.planBrowserOpen() { + return m, nil + } + svc, ctx, opts := m.plansService(), m.ctx(), m.planListOptions() + if m.planBrowserLoadInFlight && m.planBrowserLoadSessionID == opts.SessionID { + return m, nil + } + m.planBrowserLoadInFlight = true + m.planBrowserLoadSessionID = opts.SessionID + timeout := m.planReadTimeoutOrDefault() + return m, func() tea.Msg { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + result, err := svc.List(ctx, opts) + return planBrowserLoadedMsg{sessionID: opts.SessionID, result: result, err: err} + } +} + +// handlePlanBrowserLoaded opens the /plans browser with the completed +// listing. A result whose session no longer matches the active one is +// dropped: the user switched tabs while the read was in flight, and popping +// a browser that lists the previous tab's session plan would mislead. +func (m *appModel) handlePlanBrowserLoaded(msg planBrowserLoadedMsg) (tea.Model, tea.Cmd) { + // Clear the guard first, whatever the outcome, so a failed or stale open + // never wedges /plans. A result whose session differs from the guard's + // belongs to a superseded launch; the guard keeps tracking the newer + // in-flight read. + if msg.sessionID == m.planBrowserLoadSessionID { + m.planBrowserLoadInFlight = false + } + if msg.sessionID != m.currentPlanSessionID() { + return m, nil + } + if msg.err != nil { + cmd := m.planReadFailureCmd(msg.err) + return m, cmd + } + // A browser that appeared while this read was in flight wins; stacking a + // second one would duplicate it. + if m.planBrowserOpen() { + return m, nil + } + cmds := []tea.Cmd{core.CmdHandler(dialog.OpenDialogMsg{Model: dialog.NewPlanBrowserDialog(msg.result)})} + cmds = append(cmds, planWarningsCmds(msg.result.Warnings)...) + return m, tea.Sequence(cmds...) +} + +func (m *appModel) handleRefreshPlans() (tea.Model, tea.Cmd) { + cmd := m.planRefreshCmd(true) + return m, cmd +} + +// planRefreshCmd returns a command that reloads plan data for the open plan +// dialogs off the event loop: fresh rows for the browser and fresh content +// for every open detail dialog, buried ones included. The reads run in the +// command — never in Update, which a slow disk would stall — and report back +// as one planRefreshedMsg so the data is applied in a deterministic order. +// At most one reload runs at a time: requests arriving while one is in +// flight are coalesced into a single follow-up reload launched when the +// in-flight result lands, so event bursts cannot pile up redundant reads. +// Returns nil when the request was coalesced. +func (m *appModel) planRefreshCmd(notifyWarnings bool) tea.Cmd { + if m.planRefreshInFlight { + m.planRefreshQueued = true + m.planRefreshQueuedWarnings = m.planRefreshQueuedWarnings || notifyWarnings + return nil + } + m.planRefreshInFlight = true + svc, ctx, opts := m.plansService(), m.ctx(), m.planListOptions() + timeout := m.planReadTimeoutOrDefault() + refs := m.openPlanDetailRefs() + return func() tea.Msg { + // One shared deadline for the whole reload: however wedged storage + // is, the result always lands and clears the in-flight flag, so the + // refresh pipeline can never get stuck. + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + msg := planRefreshedMsg{sessionID: opts.SessionID, notifyWarnings: notifyWarnings} + msg.list, msg.listErr = svc.List(ctx, opts) + for _, ref := range refs { + p, err := svc.Get(ctx, ref) + msg.details = append(msg.details, planDetailFetch{ref: ref, plan: p, err: err}) + } + return msg + } +} + +// appendPlanRefreshCmd appends a coalesced refresh (without warning +// notifications) when one was launched; a coalesced request adds nothing. +func (m *appModel) appendPlanRefreshCmd(cmds []tea.Cmd) []tea.Cmd { + if cmd := m.planRefreshCmd(false); cmd != nil { + cmds = append(cmds, cmd) + } + return cmds +} + +// handlePlanRefreshed applies a completed reload to the open plan dialogs. +// The in-flight flag is always cleared first, whatever the outcome, so the +// refresh pipeline can never wedge. With no plan dialog left the result — +// data, errors, and warnings alike — is dropped along with any queued +// follow-up: it has nowhere to land and a notification would reference +// nothing on screen. A result read for another session's listing (the user +// switched tabs while it was in flight) is dropped too, and exactly one +// fresh reload for the current session replaces it, folding in the queued +// intent. Otherwise the list data (or its failure) surfaces +// unconditionally. Detail read failures surface only for a detail that is +// the topmost dialog now, when the result is applied: a plan that +// disappeared closes it — through the targeted ClosePlanDetailMsg, so +// duplicated results for the same vanished plan can never pop a second +// dialog — with one notification, and any other failure notifies without +// closing. A buried detail cannot be closed without popping the wrong +// dialog, and notifying on every refresh would repeat the same warning +// indefinitely, so a buried failing ref is skipped silently: it is notified +// (and closed if gone) once it surfaces and the next refresh runs. +func (m *appModel) handlePlanRefreshed(msg planRefreshedMsg) (tea.Model, tea.Cmd) { + m.planRefreshInFlight = false + + if !m.planDialogOpen() { + m.planRefreshQueued = false + m.planRefreshQueuedWarnings = false + return m, nil + } + if msg.sessionID != m.currentPlanSessionID() { + notify := msg.notifyWarnings || m.planRefreshQueuedWarnings + m.planRefreshQueued = false + m.planRefreshQueuedWarnings = false + cmd := m.planRefreshCmd(notify) + return m, cmd + } + + var cmds []tea.Cmd + if msg.listErr != nil { + cmds = append(cmds, m.planReadFailureCmd(msg.listErr)) + } else { + cmds = append(cmds, core.CmdHandler(dialog.PlanBrowserDataMsg{Result: msg.list})) + if msg.notifyWarnings { + cmds = append(cmds, planWarningsCmds(msg.list.Warnings)...) + } + } + for _, fetch := range msg.details { + if fetch.err != nil { + if viewer, ok := m.dialogMgr.TopDialog().(dialog.PlanDetailViewer); !ok || viewer.PlanRef() != fetch.ref { + continue + } + var notFound *plans.NotFoundError + if errors.As(fetch.err, ¬Found) { + cmds = append(cmds, core.CmdHandler(dialog.ClosePlanDetailMsg{Ref: fetch.ref})) + } + cmds = append(cmds, m.planReadFailureCmd(fetch.err)) + continue + } + cmds = append(cmds, core.CmdHandler(dialog.PlanDetailDataMsg{Plan: fetch.plan})) + } + + // Replay the requests that arrived while this reload was in flight as + // one follow-up reload. + if m.planRefreshQueued { + m.planRefreshQueued = false + notify := m.planRefreshQueuedWarnings + m.planRefreshQueuedWarnings = false + if cmd := m.planRefreshCmd(notify); cmd != nil { + cmds = append(cmds, cmd) + } + } + return m, tea.Sequence(cmds...) +} + +// openPlanDetailRefs returns the refs shown by every open plan detail +// dialog, including ones buried under other dialogs. The predicate never +// matches: it is used as a visitor over the whole stack. +func (m *appModel) openPlanDetailRefs() []plans.Ref { + var refs []plans.Ref + m.dialogMgr.HasDialog(func(d dialog.Dialog) bool { + if viewer, ok := d.(dialog.PlanDetailViewer); ok { + refs = append(refs, viewer.PlanRef()) + } + return false + }) + return refs +} + +// planDetailFetch is one detail dialog's re-fetched plan (or the failure to +// fetch it) inside a planRefreshedMsg. +type planDetailFetch struct { + ref plans.Ref + plan plans.Plan + err error +} + +// planBrowserLoadedMsg reports the listing read that backs opening the +// /plans browser. sessionID is the session the listing was requested for, +// so a result that raced a tab switch can be told apart and dropped. +type planBrowserLoadedMsg struct { + sessionID string + result plans.ListResult + err error +} + +// planRefreshedMsg reports a completed asynchronous reload of the open plan +// dialogs: the browser listing plus the full plan of every detail dialog +// that was open when the reload started. sessionID is the session the +// listing was read for, so a reload that raced a tab switch can be told +// apart, dropped, and replaced by a fresh one. +type planRefreshedMsg struct { + sessionID string + list plans.ListResult + listErr error + details []planDetailFetch + notifyWarnings bool +} + +// planDetailLoadedMsg reports the read that backs opening a detail dialog. +// ref identifies the request so its in-flight guard is cleared on every +// outcome. +type planDetailLoadedMsg struct { + ref plans.Ref + plan plans.Plan + err error +} + +// planExportResultMsg reports a completed asynchronous export to path. +type planExportResultMsg struct { + path string + result plans.ExportResult + // exists marks a refused export: the default path already existed. + exists bool + // statErr reports a failed overwrite pre-check; the export was never + // attempted. + statErr error + err error +} + +func (m *appModel) handleOpenPlanDetail(ref plans.Ref) (tea.Model, tea.Cmd) { + // One detail per plan: with a detail for this ref already on the stack or + // its opening read already in flight, a repeated open must not start a + // second Get or stack a duplicate dialog. + if m.planDetailOpen(ref) { + return m, nil + } + if _, inFlight := m.planDetailLoadsInFlight[ref]; inFlight { + return m, nil + } + if m.planDetailLoadsInFlight == nil { + m.planDetailLoadsInFlight = make(map[plans.Ref]struct{}) + } + m.planDetailLoadsInFlight[ref] = struct{}{} + svc, ctx := m.plansService(), m.ctx() + timeout := m.planReadTimeoutOrDefault() + return m, func() tea.Msg { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + p, err := svc.Get(ctx, ref) + return planDetailLoadedMsg{ref: ref, plan: p, err: err} + } +} + +// handlePlanDetailLoaded opens the detail dialog for a completed read. The +// result is dropped when no plan dialog is open anymore: the detail was +// requested from the browser, so after the user closed /plans while the +// read was in flight no dialog may pop up out of nowhere. +func (m *appModel) handlePlanDetailLoaded(msg planDetailLoadedMsg) (tea.Model, tea.Cmd) { + // Clear the guard first, whatever the outcome, so a failed open can be + // retried. + delete(m.planDetailLoadsInFlight, msg.ref) + if !m.planDialogOpen() { + return m, nil + } + if msg.err != nil { + cmd := m.planReadFailureCmd(msg.err) + return m, cmd + } + // A detail for this plan that appeared while the read was in flight wins; + // stacking a second copy would duplicate it. + if m.planDetailOpen(msg.ref) { + return m, nil + } + return m, core.CmdHandler(dialog.OpenDialogMsg{Model: dialog.NewPlanDetailDialog(msg.plan)}) +} + +// handleExportPlan resolves the export destination from model state, then +// runs the read and the file write in a command so a large plan or a slow +// disk never stalls the event loop. At most one export per destination is +// in flight: concurrent duplicates would race the no-overwrite pre-check +// against the write and could both pass it. planExportsInFlight is only +// touched here and in handlePlanExportResult — never from the command. +func (m *appModel) handleExportPlan(ref plans.Ref) (tea.Model, tea.Cmd) { + path := filepath.Join(m.activeWorkingDir(), planExportFilename(ref)) + if _, inFlight := m.planExportsInFlight[path]; inFlight { + return m, notification.InfoCmd("An export to " + path + " is already running — wait for its result.") + } + if m.planExportsInFlight == nil { + m.planExportsInFlight = make(map[string]struct{}) + } + m.planExportsInFlight[path] = struct{}{} + svc, ctx := m.plansService(), m.ctx() + timeout := m.planReadTimeoutOrDefault() + return m, func() tea.Msg { + msg := planExportResultMsg{path: path} + // Refuse to overwrite: the default path is deterministic, so a repeat + // export must fail loudly instead of clobbering the previous file. + if _, err := os.Stat(path); err == nil { + msg.exists = true + return msg + } else if !errors.Is(err, fs.ErrNotExist) { + msg.statErr = err + return msg + } + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + msg.result, msg.err = svc.Export(ctx, plans.ExportRequest{Ref: ref, Path: path}) + return msg + } +} + +func (m *appModel) handlePlanExportResult(msg planExportResultMsg) (tea.Model, tea.Cmd) { + // Whatever the outcome, the destination is no longer being exported to. + delete(m.planExportsInFlight, msg.path) + switch { + case msg.exists: + return m, notification.ErrorCmd( + msg.path + " already exists — move it away, or export to a custom path with 'docker agent plans export'.") + case msg.statErr != nil: + return m, notification.ErrorCmd(fmt.Sprintf("Cannot export to %s: %v", msg.path, msg.statErr)) + case msg.err != nil: + cmd := m.planReadFailureCmd(msg.err) + return m, cmd + } + return m, notification.SuccessCmd(fmt.Sprintf("Exported %s plan to %s (%d bytes)", msg.result.Scope, msg.result.Path, msg.result.BytesWritten)) +} + +// planExportFilename is the deterministic default export target: the plan +// name for shared plans, a short session marker for session plans. +func planExportFilename(ref plans.Ref) string { + if ref.Scope == plans.ScopeSession { + return "session-plan-" + planShortSessionID(ref.SessionID) + ".md" + } + return ref.Name + ".md" +} + +func planShortSessionID(id string) string { + if r := []rune(id); len(r) > 8 { + return string(r[:8]) + } + return id +} + +// activeWorkingDir is the working directory of the active tab's runtime, +// falling back to the process working directory. +func (m *appModel) activeWorkingDir() string { + if m.supervisor != nil { + if runner := m.supervisor.GetRunner(m.supervisor.ActiveID()); runner != nil && runner.WorkingDir != "" { + return runner.WorkingDir + } + } + if wd, err := os.Getwd(); err == nil && wd != "" { + return wd + } + return "." +} + +// planStatusResultMsg reports a completed asynchronous set-status write. +type planStatusResultMsg struct { + plan plans.Plan + err error +} + +// planDeleteResultMsg reports a completed asynchronous delete. +type planDeleteResultMsg struct { + ref plans.Ref + expectedVersion int + err error +} + +// planWriteResultMsg reports a completed asynchronous editor-driven create +// or update. draftPath is the draft file the content came from; it is only +// removed after the service confirmed the write (or the draft turned out +// empty) and is preserved on every error so no edit is ever lost. +type planWriteResultMsg struct { + ref plans.Ref + create bool + draftPath string + plan plans.Plan + // emptyDraft marks a draft with no content: nothing was written and the + // draft file was removed. + emptyDraft bool + // readErr reports a draft that could not be read (or was refused as + // non-regular or oversized); the write was never attempted. + readErr error + // err reports a failed persistence call for a successfully read draft. + err error +} + +// handleSetPlanStatus starts the guarded status write in a command so a +// contended plans lock can never freeze the event loop; the outcome arrives +// as a planStatusResultMsg. +func (m *appModel) handleSetPlanStatus(msg messages.SetPlanStatusMsg) (tea.Model, tea.Cmd) { + svc := m.plansService() + ctx, timeout := m.ctx(), m.planMutationTimeoutOrDefault() + return m, func() tea.Msg { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + expected := msg.ExpectedVersion + p, err := svc.SetStatus(ctx, plans.SetStatusRequest{ + Ref: msg.Ref, + Status: msg.Status, + ExpectedVersion: &expected, + }) + return planStatusResultMsg{plan: p, err: err} + } +} + +func (m *appModel) handlePlanStatusResult(msg planStatusResultMsg) (tea.Model, tea.Cmd) { + if msg.err != nil { + cmd := m.planWriteFailureCmd(msg.err) + return m, cmd + } + cmds := []tea.Cmd{notification.SuccessCmd(fmt.Sprintf("Status of %q set to %q (now v%d)", msg.plan.Name, msg.plan.Status, planVersionOf(msg.plan)))} + cmds = m.appendPlanRefreshCmd(cmds) + return m, tea.Sequence(cmds...) +} + +// handleDeletePlan starts the guarded delete in a command; the outcome +// arrives as a planDeleteResultMsg. +func (m *appModel) handleDeletePlan(msg messages.DeletePlanMsg) (tea.Model, tea.Cmd) { + svc := m.plansService() + ctx, timeout := m.ctx(), m.planMutationTimeoutOrDefault() + return m, func() tea.Msg { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + expected := msg.ExpectedVersion + err := svc.Delete(ctx, plans.DeleteRequest{Ref: msg.Ref, ExpectedVersion: &expected}) + return planDeleteResultMsg{ref: msg.Ref, expectedVersion: msg.ExpectedVersion, err: err} + } +} + +func (m *appModel) handlePlanDeleteResult(msg planDeleteResultMsg) (tea.Model, tea.Cmd) { + if msg.err != nil { + cmd := m.planWriteFailureCmd(msg.err) + return m, cmd + } + cmds := []tea.Cmd{notification.SuccessCmd(fmt.Sprintf("Deleted shared plan %q (was v%d)", msg.ref.Name, msg.expectedVersion))} + // A detail dialog showing the deleted plan has nothing left to show. + // The targeted close re-checks the top when it is applied, so it can + // never pop anything but that exact detail. The browser row is only + // removed by the refresh below — never before the service confirmed the + // delete. + if viewer, ok := m.dialogMgr.TopDialog().(dialog.PlanDetailViewer); ok && viewer.PlanRef() == msg.ref { + cmds = append(cmds, core.CmdHandler(dialog.ClosePlanDetailMsg{Ref: msg.ref})) + } + cmds = m.appendPlanRefreshCmd(cmds) + return m, tea.Sequence(cmds...) +} + +func (m *appModel) handleCreatePlan(name string) (tea.Model, tea.Cmd) { + name = strings.TrimSpace(name) + // Validate before opening the editor so a bad name fails fast instead + // of discarding a drafted document. + if err := plan.ValidateName(name); err != nil { + return m, notification.ErrorCmd(err.Error()) + } + tmpPath, err := planDraftFile("cagent-plan-new-*.md", "") + if err != nil { + return m, notification.ErrorCmd(fmt.Sprintf("Failed to create draft file: %v", err)) + } + cmd := m.execPlanEditor(planEditorClosedMsg{ref: plans.SharedRef(name), create: true, path: tmpPath}) + return m, cmd +} + +// handleEditPlan prepares an editor-driven edit off the event loop: the +// current plan is read and the draft file seeded with its content in a +// command — wedged storage or a slow disk must never stall Update — and the +// outcome reports back as a planEditReadyMsg. +func (m *appModel) handleEditPlan(msg messages.EditPlanMsg) (tea.Model, tea.Cmd) { + svc, ctx := m.plansService(), m.ctx() + timeout := m.planReadTimeoutOrDefault() + return m, func() tea.Msg { + ready := planEditReadyMsg{ref: msg.Ref, expectedVersion: msg.ExpectedVersion} + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + p, err := svc.Get(ctx, msg.Ref) + if err != nil { + ready.err = err + return ready + } + ready.currentVersion = planVersionOf(p) + if ready.currentVersion != msg.ExpectedVersion { + // No draft for a drifted base; handlePlanEditReady refreshes + // instead of editing. + return ready + } + ready.draftPath, ready.draftErr = planDraftFile("cagent-plan-"+msg.Ref.Name+"-*.md", p.Content) + return ready + } +} + +// planEditReadyMsg reports the preparation behind an editor-driven edit: +// the plan's current version and, when it still matches the version the +// user saw, the draft file seeded with the plan's content. +type planEditReadyMsg struct { + ref plans.Ref + expectedVersion int + // currentVersion is the version read from storage. When it differs from + // expectedVersion no draft was created and the data on screen is + // refreshed instead of edited. + currentVersion int + draftPath string + // draftErr reports a draft file that could not be created; the editor is + // never opened. + draftErr error + // err reports a failed plan read; nothing else was attempted. + err error +} + +// handlePlanEditReady launches the external editor over the prepared draft, +// or surfaces why there is nothing to edit. +func (m *appModel) handlePlanEditReady(msg planEditReadyMsg) (tea.Model, tea.Cmd) { + if msg.err != nil { + cmd := m.planReadFailureCmd(msg.err) + return m, cmd + } + // The plan moved on since the version on screen: refresh instead of + // editing a base the user has not seen. + if msg.currentVersion != msg.expectedVersion { + cmds := []tea.Cmd{notification.WarningCmd(fmt.Sprintf( + "Plan %q is at v%d now (you read v%d). Data refreshed — review and press e again.", + msg.ref.Name, msg.currentVersion, msg.expectedVersion))} + cmds = m.appendPlanRefreshCmd(cmds) + return m, tea.Sequence(cmds...) + } + if msg.draftErr != nil { + return m, notification.ErrorCmd(fmt.Sprintf("Failed to create draft file: %v", msg.draftErr)) + } + // The user closed the plan dialogs while the edit was being prepared: + // taking over the terminal with an editor now would be disruptive. The + // draft holds only the stored content, so removing it loses nothing. + if !m.planDialogOpen() { + _ = os.Remove(msg.draftPath) + return m, nil + } + cmd := m.execPlanEditor(planEditorClosedMsg{ref: msg.ref, expectedVersion: msg.expectedVersion, path: msg.draftPath}) + return m, cmd +} + +// planEditorClosedMsg reports that the external editor for a plan draft has +// exited; the app model then reads the draft and performs the guarded write. +type planEditorClosedMsg struct { + ref plans.Ref + expectedVersion int + create bool + path string + err error +} + +// planDraftFile writes content to a fresh temp markdown file and returns its +// path. +func planDraftFile(pattern, content string) (string, error) { + f, err := os.CreateTemp("", pattern) + if err != nil { + return "", err + } + path := f.Name() + if _, err := f.WriteString(content); err != nil { + _ = f.Close() + _ = os.Remove(path) + return "", err + } + if err := f.Close(); err != nil { + _ = os.Remove(path) + return "", err + } + return path, nil +} + +// execPlanEditor opens result.path in $VISUAL/$EDITOR via tea.ExecProcess +// and reports back with result once the editor exits. +func (m *appModel) execPlanEditor(result planEditorClosedMsg) tea.Cmd { + parts := strings.Fields(cmp.Or(os.Getenv("VISUAL"), os.Getenv("EDITOR"))) + if len(parts) == 0 { + if goruntime.GOOS == "windows" { + parts = []string{"notepad"} + } else { + parts = []string{"vi"} + } + } + args := append(parts[1:], result.path) + // The editor process is owned by tea.ExecProcess, so exec.Command is intentional. + cmd := exec.Command(parts[0], args...) //nolint:noctx // owned by tea.ExecProcess + return tea.ExecProcess(cmd, func(err error) tea.Msg { + result.err = err + return result + }) +} + +func (m *appModel) handlePlanEditorClosed(msg planEditorClosedMsg) (tea.Model, tea.Cmd) { + if msg.err != nil { + // The editor may have failed after the user saved content (e.g. it + // exited non-zero); the draft is kept so no edit is ever lost. + return m, tea.Sequence( + notification.ErrorCmd(fmt.Sprintf("Editor error: %v", msg.err)), + notification.InfoCmd("Your draft is kept at "+msg.path)) + } + + // Both the draft read and the guarded write run in a command: reading in + // Update would stall the event loop on a draft path swapped for a FIFO + // or device and allocate unbounded memory for a runaway file, and a + // contended plans lock could freeze it just the same. The draft file + // outlives the command until the service confirms the write. + svc := m.plansService() + ctx, timeout := m.ctx(), m.planMutationTimeoutOrDefault() + return m, func() tea.Msg { + result := planWriteResultMsg{ref: msg.ref, create: msg.create, draftPath: msg.path} + content, err := readPlanDraft(msg.path) + if err != nil { + result.readErr = err + return result + } + if strings.TrimSpace(content) == "" { + _ = os.Remove(msg.path) + result.emptyDraft = true + return result + } + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + if msg.create { + result.plan, result.err = svc.Create(ctx, plans.CreateRequest{Ref: msg.ref, Content: content}) + } else { + expected := msg.expectedVersion + result.plan, result.err = svc.Update(ctx, plans.UpdateRequest{Ref: msg.ref, Content: content, ExpectedVersion: &expected}) + } + return result + } +} + +// readPlanDraft reads the edited draft back, bounded and hang-safe: the open +// cannot block on a FIFO with no writer (plan.OpenContentFile), only a +// regular file is accepted — checked on the opened descriptor, so a +// concurrent path swap cannot slip a device past the check — and the read is +// capped at the plan content limit so a runaway draft can never exhaust +// memory. The service would refuse over-cap content anyway; refusing here +// skips reading and shipping megabytes that cannot be persisted. +func readPlanDraft(path string) (string, error) { + f, err := plan.OpenContentFile(path) + if err != nil { + return "", err + } + defer f.Close() + + info, err := f.Stat() + if err != nil { + return "", err + } + if !info.Mode().IsRegular() { + return "", fmt.Errorf("draft %s is not a regular file", path) + } + if info.Size() > plan.MaxPlanContentSize { + return "", fmt.Errorf("draft exceeds the maximum plan size (%d bytes; max %d)", info.Size(), plan.MaxPlanContentSize) + } + + // Read one byte past the cap so a draft that grew since the size check + // is still detected without trusting the stat size. + data, err := io.ReadAll(io.LimitReader(f, plan.MaxPlanContentSize+1)) + if err != nil { + return "", err + } + if len(data) > plan.MaxPlanContentSize { + return "", fmt.Errorf("draft exceeds the maximum plan size (max %d bytes)", plan.MaxPlanContentSize) + } + return string(data), nil +} + +func (m *appModel) handlePlanWriteResult(msg planWriteResultMsg) (tea.Model, tea.Cmd) { + switch { + case msg.readErr != nil: + return m, tea.Sequence( + notification.ErrorCmd(fmt.Sprintf("Failed to read edited plan: %v", msg.readErr)), + notification.InfoCmd("Your draft is kept at "+msg.draftPath)) + case msg.emptyDraft: + if msg.create { + return m, notification.InfoCmd(fmt.Sprintf("Plan %q not created: the draft was empty.", msg.ref.Name)) + } + return m, notification.InfoCmd(fmt.Sprintf("Plan %q left unchanged: an empty draft is never committed.", msg.ref.Name)) + case msg.err != nil: + cmd := m.planEditorFailureCmd(msg.err, msg.draftPath) + return m, cmd + } + _ = os.Remove(msg.draftPath) + + verb := "Updated" + if msg.create { + verb = "Created" + } + cmds := []tea.Cmd{notification.SuccessCmd(fmt.Sprintf("%s shared plan %q (now v%d)", verb, msg.plan.Name, planVersionOf(msg.plan)))} + cmds = m.appendPlanRefreshCmd(cmds) + return m, tea.Sequence(cmds...) +} + +// planEditorFailureCmd reports a failed editor-driven write. The draft file +// is deliberately kept so a conflict, timeout, or storage failure never +// loses the user's edits; the newer plan content stays intact and is re-read +// into the open dialogs. +func (m *appModel) planEditorFailureCmd(err error, draftPath string) tea.Cmd { + if timeout := m.planTimeoutCmd(err); timeout != nil { + return tea.Sequence(timeout, notification.InfoCmd("Your draft is kept at "+draftPath)) + } + var conflict *plans.ConflictError + if errors.As(err, &conflict) { + text := fmt.Sprintf( + "Version conflict on %q: it is at v%d, you edited v%d. Your draft is kept at %s — refresh and retry from it.", + conflict.Name, conflict.Current, conflict.Expected, draftPath) + if conflict.Expected == 0 { + text = fmt.Sprintf( + "Plan %q already exists (v%d). Your draft is kept at %s — pick another name or edit the existing plan.", + conflict.Name, conflict.Current, draftPath) + } + cmds := []tea.Cmd{notification.ErrorCmd(text)} + cmds = m.appendPlanRefreshCmd(cmds) + return tea.Sequence(cmds...) + } + return tea.Sequence(planErrorCmd(err), notification.InfoCmd("Your draft is kept at "+draftPath)) +} + +// planWriteFailureCmd reports a failed status/delete write. Conflicts +// refresh the open dialogs so the newer version is visible right away. +func (m *appModel) planWriteFailureCmd(err error) tea.Cmd { + if timeout := m.planTimeoutCmd(err); timeout != nil { + return timeout + } + var conflict *plans.ConflictError + if errors.As(err, &conflict) { + cmds := []tea.Cmd{notification.ErrorCmd(fmt.Sprintf( + "Version conflict on %q: it changed to v%d since you read v%d. Data refreshed — review and retry.", + conflict.Name, conflict.Current, conflict.Expected))} + cmds = m.appendPlanRefreshCmd(cmds) + return tea.Sequence(cmds...) + } + return planErrorCmd(err) +} + +// planTimeoutCmd returns the actionable notification for a persistence +// operation that hit the bounded mutation timeout — most likely the +// cross-process plans lock is held by a wedged process — or nil when err is +// not a timeout. +func (m *appModel) planTimeoutCmd(err error) tea.Cmd { + if !errors.Is(err, context.DeadlineExceeded) { + return nil + } + return notification.ErrorCmd(fmt.Sprintf( + "Plan write timed out after %s — the plan store may be locked by another process. Retry shortly.", + m.planMutationTimeoutOrDefault())) +} + +// planReadFailureCmd reports a failed plan read (list, get, export, or the +// read preparing an edit), mapping a hit read deadline onto an actionable +// timeout notification; every other failure goes through the typed +// planErrorCmd notifications. +func (m *appModel) planReadFailureCmd(err error) tea.Cmd { + if !errors.Is(err, context.DeadlineExceeded) { + return planErrorCmd(err) + } + return notification.ErrorCmd(fmt.Sprintf( + "Plan read timed out after %s — plan storage may be unavailable. Retry shortly.", + m.planReadTimeoutOrDefault())) +} + +// planVersionOf reads a shared plan's version defensively; the service +// always sets it for shared plans. +func planVersionOf(p plans.Plan) int { + if p.Version == nil { + return 0 + } + return *p.Version +} + +// planErrorCmd maps the typed pkg/plans errors onto distinguishable +// user-facing notifications, never classifying by error text. +func planErrorCmd(err error) tea.Cmd { + var ( + conflict *plans.ConflictError + notFound *plans.NotFoundError + validation *plans.ValidationError + corrupt *plans.CorruptError + unsupported *plans.UnsupportedError + ) + switch { + case errors.As(err, &conflict): + return notification.ErrorCmd(fmt.Sprintf( + "Version conflict on plan %q: expected v%d but it is at v%d. Refresh (r) and retry.", + conflict.Name, conflict.Expected, conflict.Current)) + case errors.As(err, ¬Found): + return notification.WarningCmd(fmt.Sprintf("No %s plan %q — it may have been deleted; refresh (r).", notFound.Scope, notFound.Name)) + case errors.As(err, &validation): + return notification.ErrorCmd("Invalid input: " + validation.Message) + case errors.As(err, &corrupt): + return notification.ErrorCmd(fmt.Sprintf("Plan %q is corrupt and cannot be read (%v). Delete it to recover.", corrupt.Name, corrupt.Err)) + case errors.As(err, &unsupported): + return notification.InfoCmd("Unsupported: " + unsupported.Error()) + default: + return notification.ErrorCmd("Plan storage failure: " + err.Error()) + } +} + +func planWarningsCmds(warnings []string) []tea.Cmd { + if len(warnings) == 0 { + return nil + } + return []tea.Cmd{notification.WarningCmd(fmt.Sprintf( + "%d plan(s) could not be read: %s", len(warnings), strings.Join(warnings, "; ")))} +} + +// handleSessionPlanUpdatedEvent forwards the event to the chat page like any +// runtime event and live-refreshes open plan dialogs when the active +// session's plan changed. The refresh reads run in a command, never here. +func (m *appModel) handleSessionPlanUpdatedEvent(msg *runtime.SessionPlanUpdatedEvent) (tea.Model, tea.Cmd) { + if name := msg.GetAgentName(); name != "" { + m.sessionState.SetCurrentAgentName(name) + } + chatCmd := m.updateChatCmd(msg) + var refresh tea.Cmd + if m.planDialogOpen() && msg.SessionID == m.currentPlanSessionID() { + refresh = m.planRefreshCmd(false) + } + return m, tea.Batch(chatCmd, refresh) +} + +// handlePlanChangedEvent live-refreshes open plan dialogs after an agent +// mutated a shared plan. Shared plans are scope-global, so the refresh does +// not depend on which session emitted the event. +func (m *appModel) handlePlanChangedEvent(msg *runtime.PlanChangedEvent) (tea.Model, tea.Cmd) { + if name := msg.GetAgentName(); name != "" { + m.sessionState.SetCurrentAgentName(name) + } + chatCmd := m.updateChatCmd(msg) + var refresh tea.Cmd + if m.planDialogOpen() { + refresh = m.planRefreshCmd(false) + } + return m, tea.Batch(chatCmd, refresh) +} diff --git a/pkg/tui/plans_test.go b/pkg/tui/plans_test.go new file mode 100644 index 0000000000..6189835330 --- /dev/null +++ b/pkg/tui/plans_test.go @@ -0,0 +1,1677 @@ +package tui + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/app" + "github.com/docker/docker-agent/pkg/plans" + "github.com/docker/docker-agent/pkg/runtime" + "github.com/docker/docker-agent/pkg/session" + "github.com/docker/docker-agent/pkg/tools/builtin/plan" + "github.com/docker/docker-agent/pkg/tools/builtin/sessionplan" + "github.com/docker/docker-agent/pkg/tui/components/notification" + "github.com/docker/docker-agent/pkg/tui/dialog" + "github.com/docker/docker-agent/pkg/tui/messages" + "github.com/docker/docker-agent/pkg/tui/service" + "github.com/docker/docker-agent/pkg/tui/service/supervisor" +) + +// newPlansTestModel wires an appModel around a temp-backed plans service and +// a real session, returning the model, the service, the active session, and +// the session-plans directory for planting files. +func newPlansTestModel(t *testing.T) (*appModel, plans.Service, *session.Session, string) { + t.Helper() + m, _ := newTestModel(t) + + sessionDir := t.TempDir() + svc := plans.NewService(plan.NewFilesystemStorage(t.TempDir()), plans.WithSessionDir(sessionDir)) + WithPlansService(svc)(m) + + sess := session.New() + m.application = app.New(t.Context(), stubRuntime{}, sess) + m.sessionState = service.NewSessionState(sess) + return m, svc, sess, sessionDir +} + +func mustCreatePlan(t *testing.T, svc plans.Service, name, content string) plans.Plan { + t.Helper() + p, err := svc.Create(t.Context(), plans.CreateRequest{Ref: plans.SharedRef(name), Content: content}) + require.NoError(t, err) + return p +} + +// switchPlansTestSession replaces the model's active session with a fresh +// one, as a tab switch would, and returns it. +func switchPlansTestSession(t *testing.T, m *appModel) *session.Session { + t.Helper() + sess := session.New() + m.application = app.New(t.Context(), stubRuntime{}, sess) + m.sessionState = service.NewSessionState(sess) + return sess +} + +// openPlanBrowser puts a plan browser dialog on the model's dialog stack, as +// if /plans had been run. +func openPlanBrowser(t *testing.T, m *appModel, result plans.ListResult) { + t.Helper() + openDialog(t, m, dialog.NewPlanBrowserDialog(result)) +} + +// openDialog pushes d onto the model's dialog stack. +func openDialog(t *testing.T, m *appModel, d dialog.Dialog) { + t.Helper() + updated, _ := m.dialogMgr.Update(dialog.OpenDialogMsg{Model: d}) + m.dialogMgr = updated.(dialog.Manager) + require.True(t, m.dialogMgr.Open()) +} + +// sizeDialogs gives the dialog manager (and every dialog opened afterwards) +// real dimensions so buried dialogs render meaningful views. +func sizeDialogs(t *testing.T, m *appModel) { + t.Helper() + updated, _ := m.dialogMgr.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) + m.dialogMgr = updated.(dialog.Manager) +} + +// runPlanFlow drives msg through the model like the Bubble Tea runtime +// would: produced commands run on the test goroutine and the typed results +// of asynchronous plan reads and mutations are dispatched back into Update. +// All other produced messages are returned in order. +func runPlanFlow(t *testing.T, m *appModel, msg tea.Msg) []tea.Msg { + t.Helper() + _, cmd := m.Update(msg) + return drainPlanFlow(t, m, cmd) +} + +// drainPlanFlow executes cmd, feeding every produced plan result message +// back into Update until the flow settles. +func drainPlanFlow(t *testing.T, m *appModel, cmd tea.Cmd) []tea.Msg { + t.Helper() + var out []tea.Msg + queue := collectMsgs(cmd) + for len(queue) > 0 { + next := queue[0] + queue = queue[1:] + switch next.(type) { + case planStatusResultMsg, planDeleteResultMsg, planWriteResultMsg, + planBrowserLoadedMsg, planRefreshedMsg, planDetailLoadedMsg, + planEditReadyMsg, planExportResultMsg: + _, cmd := m.Update(next) + queue = append(queue, collectMsgs(cmd)...) + default: + out = append(out, next) + } + } + return out +} + +func firstOfType[T any](msgs []tea.Msg) (T, bool) { + for _, msg := range msgs { + if typed, ok := msg.(T); ok { + return typed, true + } + } + var zero T + return zero, false +} + +func countOfType[T any](msgs []tea.Msg) int { + n := 0 + for _, msg := range msgs { + if _, ok := msg.(T); ok { + n++ + } + } + return n +} + +func notificationTexts(msgs []tea.Msg) []string { + var texts []string + for _, msg := range msgs { + if note, ok := msg.(notification.ShowMsg); ok { + texts = append(texts, note.Text) + } + } + return texts +} + +func TestHandleShowPlanBrowser_OpensDialog(t *testing.T) { + t.Parallel() + m, svc, _, _ := newPlansTestModel(t) + mustCreatePlan(t, svc, "release", "content") + + msgs := runPlanFlow(t, m, messages.ShowPlanBrowserMsg{}) + openMsg, ok := firstOfType[dialog.OpenDialogMsg](msgs) + require.True(t, ok, "/plans must open the browser dialog") + + openMsg.Model.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) + assert.Contains(t, openMsg.Model.View(), "release") +} + +func TestPlanList_IncludesOnlyActiveSessionPlan(t *testing.T) { + t.Parallel() + m, svc, sess, sessionDir := newPlansTestModel(t) + mustCreatePlan(t, svc, "shared-one", "content") + openPlanBrowser(t, m, plans.ListResult{Plans: []plans.Plan{}}) + + // Current session's plan plus a stale plan from another session that + // must never be enumerated. + _, err := sessionplan.WriteContent(sessionDir, sess.ID, "my plan") + require.NoError(t, err) + staleSess := session.New() + _, err = sessionplan.WriteContent(sessionDir, staleSess.ID, "stale plan") + require.NoError(t, err) + + dataMsg, ok := firstOfType[dialog.PlanBrowserDataMsg](runPlanFlow(t, m, messages.RefreshPlansMsg{})) + require.True(t, ok) + + names := make([]string, 0, len(dataMsg.Result.Plans)) + for _, p := range dataMsg.Result.Plans { + names = append(names, p.Name) + } + assert.ElementsMatch(t, []string{sess.ID, "shared-one"}, names, + "the listing includes the active session's plan and shared plans, never stale session plans") +} + +func TestHandleSetPlanStatus_RefreshesAfterWrite(t *testing.T) { + t.Parallel() + m, svc, _, _ := newPlansTestModel(t) + created := mustCreatePlan(t, svc, "release", "content") + require.Equal(t, 1, *created.Version) + openPlanBrowser(t, m, plans.ListResult{Plans: []plans.Plan{created}}) + + msgs := runPlanFlow(t, m, messages.SetPlanStatusMsg{ + Ref: plans.SharedRef("release"), + Status: "done", + ExpectedVersion: 1, + }) + + texts := notificationTexts(msgs) + require.NotEmpty(t, texts) + assert.Contains(t, texts[0], "done") + + dataMsg, ok := firstOfType[dialog.PlanBrowserDataMsg](msgs) + require.True(t, ok, "a successful write must refresh the browser data") + require.Len(t, dataMsg.Result.Plans, 1) + assert.Equal(t, "done", dataMsg.Result.Plans[0].Status) + assert.Equal(t, 2, *dataMsg.Result.Plans[0].Version) + + stored, err := svc.Get(t.Context(), plans.SharedRef("release")) + require.NoError(t, err) + assert.Equal(t, "done", stored.Status) +} + +func TestHandleSetPlanStatus_StaleConflictPreservesNewerData(t *testing.T) { + t.Parallel() + m, svc, _, _ := newPlansTestModel(t) + mustCreatePlan(t, svc, "release", "content") + openPlanBrowser(t, m, plans.ListResult{Plans: []plans.Plan{}}) + + // Someone else moved the plan to v2 with a newer status. + v1 := 1 + _, err := svc.SetStatus(t.Context(), plans.SetStatusRequest{ + Ref: plans.SharedRef("release"), Status: "newer", ExpectedVersion: &v1, + }) + require.NoError(t, err) + + msgs := runPlanFlow(t, m, messages.SetPlanStatusMsg{ + Ref: plans.SharedRef("release"), + Status: "stale-write", + ExpectedVersion: 1, + }) + + texts := notificationTexts(msgs) + require.NotEmpty(t, texts) + assert.Contains(t, texts[0], "conflict") + assert.Contains(t, texts[0], "v2", "the conflict notification must name the current version") + + // The newer content stays intact and is re-read into the dialogs. + dataMsg, ok := firstOfType[dialog.PlanBrowserDataMsg](msgs) + require.True(t, ok, "a conflict must refresh so the newer version is visible") + assert.Equal(t, "newer", dataMsg.Result.Plans[0].Status) + + stored, err := svc.Get(t.Context(), plans.SharedRef("release")) + require.NoError(t, err) + assert.Equal(t, "newer", stored.Status, "the stale write must not clobber the newer status") + assert.Equal(t, 2, *stored.Version) +} + +func TestHandleDeletePlan_Semantics(t *testing.T) { + t.Parallel() + m, svc, _, _ := newPlansTestModel(t) + mustCreatePlan(t, svc, "release", "content") + openPlanBrowser(t, m, plans.ListResult{Plans: []plans.Plan{}}) + + // Stale guard: delete is refused and the plan survives. + v1 := 1 + _, err := svc.SetStatus(t.Context(), plans.SetStatusRequest{ + Ref: plans.SharedRef("release"), Status: "moved-on", ExpectedVersion: &v1, + }) + require.NoError(t, err) + + msgs := runPlanFlow(t, m, messages.DeletePlanMsg{Ref: plans.SharedRef("release"), ExpectedVersion: 1}) + texts := notificationTexts(msgs) + require.NotEmpty(t, texts) + assert.Contains(t, texts[0], "conflict") + _, err = svc.Get(t.Context(), plans.SharedRef("release")) + require.NoError(t, err, "a stale delete must leave the plan intact") + + // Correct guard: delete succeeds and the refresh drops the row. + msgs = runPlanFlow(t, m, messages.DeletePlanMsg{Ref: plans.SharedRef("release"), ExpectedVersion: 2}) + texts = notificationTexts(msgs) + require.NotEmpty(t, texts) + assert.Contains(t, texts[0], "Deleted") + + dataMsg, ok := firstOfType[dialog.PlanBrowserDataMsg](msgs) + require.True(t, ok) + assert.Empty(t, dataMsg.Result.Plans) + + _, err = svc.Get(t.Context(), plans.SharedRef("release")) + require.Error(t, err) +} + +func TestHandleExportPlan_RefusesOverwrite(t *testing.T) { + m, svc, _, _ := newPlansTestModel(t) + mustCreatePlan(t, svc, "release", "the content") + + workDir := t.TempDir() + t.Chdir(workDir) + + msgs := runPlanFlow(t, m, messages.ExportPlanMsg{Ref: plans.SharedRef("release")}) + note, ok := firstOfType[notification.ShowMsg](msgs) + require.True(t, ok) + assert.Equal(t, notification.TypeSuccess, note.Type) + + path := filepath.Join(workDir, "release.md") + data, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, "the content", string(data)) + + // Second export to the same default path must refuse to overwrite. + require.NoError(t, os.WriteFile(path, []byte("precious local edits"), 0o600)) + msgs = runPlanFlow(t, m, messages.ExportPlanMsg{Ref: plans.SharedRef("release")}) + note, ok = firstOfType[notification.ShowMsg](msgs) + require.True(t, ok) + assert.Equal(t, notification.TypeError, note.Type) + assert.Contains(t, note.Text, "already exists") + + data, err = os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, "precious local edits", string(data), "an existing file must never be overwritten") +} + +func TestHandleExportPlan_SessionDefaultFilename(t *testing.T) { + m, _, sess, sessionDir := newPlansTestModel(t) + _, err := sessionplan.WriteContent(sessionDir, sess.ID, "session body") + require.NoError(t, err) + + workDir := t.TempDir() + t.Chdir(workDir) + + msgs := runPlanFlow(t, m, messages.ExportPlanMsg{Ref: plans.SessionRef(sess.ID)}) + note, ok := firstOfType[notification.ShowMsg](msgs) + require.True(t, ok) + assert.Equal(t, notification.TypeSuccess, note.Type) + + path := filepath.Join(workDir, "session-plan-"+sess.ID[:8]+".md") + data, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, "session body", string(data)) +} + +func TestHandlePlanEditorClosed_CreatesPlan(t *testing.T) { + t.Parallel() + m, svc, _, _ := newPlansTestModel(t) + + draft := filepath.Join(t.TempDir(), "draft.md") + require.NoError(t, os.WriteFile(draft, []byte("# fresh plan"), 0o600)) + + msgs := runPlanFlow(t, m, planEditorClosedMsg{ref: plans.SharedRef("fresh"), create: true, path: draft}) + texts := notificationTexts(msgs) + require.NotEmpty(t, texts) + assert.Contains(t, texts[0], "Created") + + stored, err := svc.Get(t.Context(), plans.SharedRef("fresh")) + require.NoError(t, err) + assert.Equal(t, "# fresh plan", stored.Content) + + _, err = os.Stat(draft) + assert.True(t, os.IsNotExist(err), "the draft is removed after a successful write") +} + +func TestHandlePlanEditorClosed_EmptyDraftAborts(t *testing.T) { + t.Parallel() + m, svc, _, _ := newPlansTestModel(t) + + draft := filepath.Join(t.TempDir(), "draft.md") + require.NoError(t, os.WriteFile(draft, []byte(" \n \n"), 0o600)) + + msgs := runPlanFlow(t, m, planEditorClosedMsg{ref: plans.SharedRef("fresh"), create: true, path: draft}) + texts := notificationTexts(msgs) + require.NotEmpty(t, texts) + assert.Contains(t, texts[0], "not created") + + _, err := svc.Get(t.Context(), plans.SharedRef("fresh")) + require.Error(t, err, "an empty draft must not create a plan") + + _, err = os.Stat(draft) + assert.True(t, os.IsNotExist(err), "an empty draft is removed, not kept") +} + +// TestHandlePlanEditorClosed_OversizedDraftRefusedBounded proves the draft +// read is bounded: a draft past the plan content cap is refused with an +// actionable notification — detected from the descriptor size, never by +// slurping the file whole — and the draft is preserved for the user to trim. +func TestHandlePlanEditorClosed_OversizedDraftRefusedBounded(t *testing.T) { + t.Parallel() + m, svc, _, _ := newPlansTestModel(t) + + draft := filepath.Join(t.TempDir(), "draft.md") + require.NoError(t, os.WriteFile(draft, make([]byte, plan.MaxPlanContentSize+1), 0o600)) + + msgs := runPlanFlow(t, m, planEditorClosedMsg{ref: plans.SharedRef("fresh"), create: true, path: draft}) + texts := notificationTexts(msgs) + require.NotEmpty(t, texts) + assert.Contains(t, texts[0], "maximum plan size") + assert.Contains(t, strings.Join(texts, " "), draft, "the notification must point at the kept draft") + + _, err := svc.Get(t.Context(), plans.SharedRef("fresh")) + require.Error(t, err, "an oversized draft must not create a plan") + _, err = os.Stat(draft) + require.NoError(t, err, "the draft must be kept when it is refused") +} + +// TestHandlePlanEditorClosed_NonRegularDraftRejected proves a draft path +// that is not a regular file (here: a directory) is refused on the opened +// descriptor instead of read, with the path preserved. +func TestHandlePlanEditorClosed_NonRegularDraftRejected(t *testing.T) { + t.Parallel() + m, svc, _, _ := newPlansTestModel(t) + + draftDir := t.TempDir() + msgs := runPlanFlow(t, m, planEditorClosedMsg{ref: plans.SharedRef("fresh"), create: true, path: draftDir}) + texts := notificationTexts(msgs) + require.NotEmpty(t, texts) + assert.Contains(t, texts[0], "not a regular file") + + _, err := svc.Get(t.Context(), plans.SharedRef("fresh")) + require.Error(t, err, "nothing may be written from an unreadable draft") + _, err = os.Stat(draftDir) + require.NoError(t, err, "the draft path must be preserved on a read failure") +} + +func TestHandlePlanEditorClosed_ConflictKeepsDraftAndNewerContent(t *testing.T) { + t.Parallel() + m, svc, _, _ := newPlansTestModel(t) + mustCreatePlan(t, svc, "release", "v1 content") + + // The plan moved to v2 while the user was editing v1. + v1 := 1 + _, err := svc.Update(t.Context(), plans.UpdateRequest{ + Ref: plans.SharedRef("release"), Content: "newer content", ExpectedVersion: &v1, + }) + require.NoError(t, err) + + draft := filepath.Join(t.TempDir(), "draft.md") + require.NoError(t, os.WriteFile(draft, []byte("stale edited content"), 0o600)) + + msgs := runPlanFlow(t, m, planEditorClosedMsg{ref: plans.SharedRef("release"), expectedVersion: 1, path: draft}) + texts := notificationTexts(msgs) + require.NotEmpty(t, texts) + assert.Contains(t, texts[0], "conflict") + assert.Contains(t, texts[0], "v2") + assert.Contains(t, texts[0], draft, "the notification must point at the kept draft") + + _, err = os.Stat(draft) + require.NoError(t, err, "the draft must be kept on conflict") + + stored, err := svc.Get(t.Context(), plans.SharedRef("release")) + require.NoError(t, err) + assert.Equal(t, "newer content", stored.Content, "the newer content must stay intact") +} + +func TestHandleEditPlan_VersionDriftRefreshesInsteadOfEditing(t *testing.T) { + t.Parallel() + m, svc, _, _ := newPlansTestModel(t) + mustCreatePlan(t, svc, "release", "v1 content") + openPlanBrowser(t, m, plans.ListResult{Plans: []plans.Plan{}}) + v1 := 1 + _, err := svc.Update(t.Context(), plans.UpdateRequest{ + Ref: plans.SharedRef("release"), Content: "v2 content", ExpectedVersion: &v1, + }) + require.NoError(t, err) + + _, cmd := m.Update(messages.EditPlanMsg{Ref: plans.SharedRef("release"), ExpectedVersion: 1}) + msgs := drainPlanFlow(t, m, cmd) + texts := notificationTexts(msgs) + require.NotEmpty(t, texts) + assert.Contains(t, texts[0], "v2") + + _, refreshed := firstOfType[dialog.PlanBrowserDataMsg](msgs) + assert.True(t, refreshed, "version drift must refresh the data on screen") +} + +func TestSessionPlanUpdatedEvent_RefreshesOpenPlanDialogs(t *testing.T) { + t.Parallel() + m, _, sess, sessionDir := newPlansTestModel(t) + openPlanBrowser(t, m, plans.ListResult{Plans: []plans.Plan{}}) + + // The agent writes the session plan; the browser must pick it up. + _, err := sessionplan.WriteContent(sessionDir, sess.ID, "plan body") + require.NoError(t, err) + + msgs := runPlanFlow(t, m, runtime.SessionPlanUpdated(sess.ID, "plan body", "", "root")) + dataMsg, ok := firstOfType[dialog.PlanBrowserDataMsg](msgs) + require.True(t, ok, "an open plan browser must live-refresh on session plan writes") + require.Len(t, dataMsg.Result.Plans, 1) + assert.Equal(t, sess.ID, dataMsg.Result.Plans[0].SessionID) +} + +func TestSessionPlanUpdatedEvent_NoRefreshWithoutPlanDialog(t *testing.T) { + t.Parallel() + m, _, sess, _ := newPlansTestModel(t) + + msgs := runPlanFlow(t, m, runtime.SessionPlanUpdated(sess.ID, "plan body", "", "root")) + _, refreshed := firstOfType[dialog.PlanBrowserDataMsg](msgs) + assert.False(t, refreshed, "no plan dialog open, nothing to refresh") +} + +func TestPlanChangedEvent_RefreshesOpenPlanDialogs(t *testing.T) { + t.Parallel() + m, svc, _, _ := newPlansTestModel(t) + mustCreatePlan(t, svc, "release", "content") + openPlanBrowser(t, m, plans.ListResult{Plans: []plans.Plan{}}) + + msgs := runPlanFlow(t, m, runtime.PlanChanged("shared", "release", plan.ChangeActionWrite, 1, "root")) + dataMsg, ok := firstOfType[dialog.PlanBrowserDataMsg](msgs) + require.True(t, ok, "an open plan browser must live-refresh on shared plan mutations") + require.Len(t, dataMsg.Result.Plans, 1) + assert.Equal(t, "release", dataMsg.Result.Plans[0].Name) +} + +func TestPlanChangedEvent_BackgroundSessionStillRefreshes(t *testing.T) { + t.Parallel() + m, svc, _, _ := newPlansTestModel(t) + mustCreatePlan(t, svc, "release", "content") + + sv := supervisor.New(nil) + activeID := sv.AddSession(t.Context(), nil, session.New(), "", nil) + backgroundID := sv.AddSession(t.Context(), nil, session.New(), "", nil) + m.supervisor = sv + require.Equal(t, activeID, sv.ActiveID()) + m.chatPages[backgroundID] = &mockChatPage{} + + openPlanBrowser(t, m, plans.ListResult{Plans: []plans.Plan{}}) + + msgs := runPlanFlow(t, m, messages.RoutedMsg{ + SessionID: backgroundID, + Inner: runtime.PlanChanged("shared", "release", plan.ChangeActionStatus, 2, "bg-agent"), + }) + _, ok := firstOfType[dialog.PlanBrowserDataMsg](msgs) + assert.True(t, ok, "shared plans are scope-global: background mutations refresh the open browser") +} + +func TestLeanModeDropsPlanBrowser(t *testing.T) { + t.Parallel() + // Lean mode short-circuits before any handler runs, so no plans service + // or application is needed. + m, _ := newTestModel(t) + m.leanMode = true + + _, cmd := m.Update(messages.ShowPlanBrowserMsg{}) + assert.Nil(t, cmd, "lean mode has no overlays; /plans is dropped like /settings") +} + +// --- Asynchronous mutations under a wedged lock -------------------------------- + +// blockingPlansService simulates a wedged cross-process plans lock: every +// mutation blocks until its context expires and reports the deadline as a +// typed storage error, exactly like the real storage's lock acquisition. +// Reads delegate to the embedded real service. +type blockingPlansService struct { + plans.Service + + mutationsStarted atomic.Int32 +} + +func (s *blockingPlansService) block(ctx context.Context, op string) error { + s.mutationsStarted.Add(1) + <-ctx.Done() + return &plans.StorageError{Scope: plans.ScopeShared, Op: op, Err: ctx.Err()} +} + +func (s *blockingPlansService) Create(ctx context.Context, _ plans.CreateRequest) (plans.Plan, error) { + return plans.Plan{}, s.block(ctx, "create") +} + +func (s *blockingPlansService) Update(ctx context.Context, _ plans.UpdateRequest) (plans.Plan, error) { + return plans.Plan{}, s.block(ctx, "update") +} + +func (s *blockingPlansService) SetStatus(ctx context.Context, _ plans.SetStatusRequest) (plans.Plan, error) { + return plans.Plan{}, s.block(ctx, "set_status") +} + +func (s *blockingPlansService) Delete(ctx context.Context, _ plans.DeleteRequest) error { + return s.block(ctx, "delete") +} + +// TestHandleSetPlanStatus_WedgedLockTimesOutAsynchronously proves the +// mutation never runs inside Update: the event loop stays responsive while +// the write is pending, and a wedged lock surfaces as a bounded, actionable +// timeout notification instead of a freeze. +func TestHandleSetPlanStatus_WedgedLockTimesOutAsynchronously(t *testing.T) { + t.Parallel() + m, svc, _, _ := newPlansTestModel(t) + mustCreatePlan(t, svc, "release", "content") + openPlanBrowser(t, m, plans.ListResult{Plans: []plans.Plan{}}) + blocking := &blockingPlansService{Service: svc} + WithPlansService(blocking)(m) + m.planMutationTimeout = 50 * time.Millisecond + + _, cmd := m.Update(messages.SetPlanStatusMsg{ + Ref: plans.SharedRef("release"), + Status: "done", + ExpectedVersion: 1, + }) + require.NotNil(t, cmd) + assert.Zero(t, blocking.mutationsStarted.Load(), "Update must defer the mutation to a command, never run it inline") + + // The TUI stays responsive while the mutation is pending: a read-driven + // refresh is processed before the mutation command has even run. + _, ok := firstOfType[dialog.PlanBrowserDataMsg](runPlanFlow(t, m, messages.RefreshPlansMsg{})) + assert.True(t, ok, "reads keep working while a mutation is pending") + + // Running the command blocks on the wedged lock until the bounded + // timeout fires, then reports back as a typed result message. + start := time.Now() + result := cmd() + elapsed := time.Since(start) + statusResult, ok := result.(planStatusResultMsg) + require.True(t, ok, "the command must yield a typed result, got %T", result) + assert.Equal(t, int32(1), blocking.mutationsStarted.Load()) + assert.GreaterOrEqual(t, elapsed, 40*time.Millisecond, "the command waits for the bounded timeout") + assert.Less(t, elapsed, time.Second, "the bounded timeout must fire, not the 5s default or never") + require.Error(t, statusResult.err) + require.ErrorIs(t, statusResult.err, context.DeadlineExceeded) + + // Dispatching the result yields the actionable notification. + _, notifyCmd := m.Update(result) + note, ok := firstOfType[notification.ShowMsg](collectMsgs(notifyCmd)) + require.True(t, ok) + assert.Equal(t, notification.TypeError, note.Type) + assert.Contains(t, note.Text, "timed out") + assert.Contains(t, note.Text, "locked") +} + +func TestHandleDeletePlan_WedgedLockTimesOutAsynchronously(t *testing.T) { + t.Parallel() + m, svc, _, _ := newPlansTestModel(t) + mustCreatePlan(t, svc, "release", "content") + blocking := &blockingPlansService{Service: svc} + WithPlansService(blocking)(m) + m.planMutationTimeout = 50 * time.Millisecond + + _, cmd := m.Update(messages.DeletePlanMsg{Ref: plans.SharedRef("release"), ExpectedVersion: 1}) + require.NotNil(t, cmd) + assert.Zero(t, blocking.mutationsStarted.Load()) + + result := cmd() + deleteResult, ok := result.(planDeleteResultMsg) + require.True(t, ok, "the command must yield a typed result, got %T", result) + require.ErrorIs(t, deleteResult.err, context.DeadlineExceeded) + + _, notifyCmd := m.Update(result) + note, ok := firstOfType[notification.ShowMsg](collectMsgs(notifyCmd)) + require.True(t, ok) + assert.Equal(t, notification.TypeError, note.Type) + assert.Contains(t, note.Text, "timed out") + + // The plan survives the timed-out delete. + _, err := svc.Get(t.Context(), plans.SharedRef("release")) + require.NoError(t, err) +} + +// TestHandlePlanEditorClosed_TimeoutKeepsDraft proves a timed-out +// editor-driven write behaves like any other failed write: the draft file +// survives and the notification points at it. +func TestHandlePlanEditorClosed_TimeoutKeepsDraft(t *testing.T) { + t.Parallel() + m, svc, _, _ := newPlansTestModel(t) + blocking := &blockingPlansService{Service: svc} + WithPlansService(blocking)(m) + m.planMutationTimeout = 50 * time.Millisecond + + draft := filepath.Join(t.TempDir(), "draft.md") + require.NoError(t, os.WriteFile(draft, []byte("drafted content"), 0o600)) + + _, cmd := m.Update(planEditorClosedMsg{ref: plans.SharedRef("fresh"), create: true, path: draft}) + require.NotNil(t, cmd) + assert.Zero(t, blocking.mutationsStarted.Load(), "Update must defer the write to a command") + + result := cmd() + writeResult, ok := result.(planWriteResultMsg) + require.True(t, ok, "the command must yield a typed result, got %T", result) + require.Error(t, writeResult.err) + require.ErrorIs(t, writeResult.err, context.DeadlineExceeded) + + _, notifyCmd := m.Update(result) + texts := notificationTexts(collectMsgs(notifyCmd)) + require.NotEmpty(t, texts) + assert.Contains(t, texts[0], "timed out") + assert.Contains(t, strings.Join(texts, " "), draft, "the notification must point at the kept draft") + + _, err := os.Stat(draft) + require.NoError(t, err, "the draft must be kept when the write times out") +} + +// --- Live refresh reaches buried plan dialogs ----------------------------------- + +// TestPlanChangedEvent_RefreshesBuriedPlanDialogs stacks a real non-plan +// dialog on top of the plan browser and detail, fires a PlanChanged event, +// and proves both buried dialogs receive the updated data once the produced +// commands are applied. +func TestPlanChangedEvent_RefreshesBuriedPlanDialogs(t *testing.T) { + t.Parallel() + m, svc, _, _ := newPlansTestModel(t) + mustCreatePlan(t, svc, "release", "v1 content") + + sizeDialogs(t, m) + browser := dialog.NewPlanBrowserDialog(plans.ListResult{Plans: []plans.Plan{}}) + openDialog(t, m, browser) + p, err := svc.Get(t.Context(), plans.SharedRef("release")) + require.NoError(t, err) + detail := dialog.NewPlanDetailDialog(p) + openDialog(t, m, detail) + openDialog(t, m, dialog.NewHelpDialog(nil)) + + // An agent moves the plan to v2 while the plan dialogs are buried. + v1 := 1 + _, err = svc.Update(t.Context(), plans.UpdateRequest{ + Ref: plans.SharedRef("release"), Content: "v2 content", ExpectedVersion: &v1, + }) + require.NoError(t, err) + + msgs := runPlanFlow(t, m, runtime.PlanChanged("shared", "release", plan.ChangeActionWrite, 2, "root")) + + listMsg, ok := firstOfType[dialog.PlanBrowserDataMsg](msgs) + require.True(t, ok, "a buried browser must still trigger a list refresh") + require.Len(t, listMsg.Result.Plans, 1) + + detailMsg, ok := firstOfType[dialog.PlanDetailDataMsg](msgs) + require.True(t, ok, "a buried detail must be refreshed by its exact ref") + assert.Equal(t, "release", detailMsg.Plan.Name) + assert.Equal(t, "v2 content", detailMsg.Plan.Content) + + // Apply the broadcasts and prove the buried instances updated. + _, _ = m.Update(listMsg) + _, _ = m.Update(detailMsg) + assert.Contains(t, browser.View(), "release", "the buried browser must render the refreshed rows") + assert.Contains(t, detail.View(), "v2 content", "the buried detail must render the refreshed plan") +} + +func TestSessionPlanUpdatedEvent_RefreshesBuriedBrowser(t *testing.T) { + t.Parallel() + m, _, sess, sessionDir := newPlansTestModel(t) + + sizeDialogs(t, m) + browser := dialog.NewPlanBrowserDialog(plans.ListResult{Plans: []plans.Plan{}}) + openDialog(t, m, browser) + openDialog(t, m, dialog.NewHelpDialog(nil)) // real non-plan dialog on top + + _, err := sessionplan.WriteContent(sessionDir, sess.ID, "plan body") + require.NoError(t, err) + + msgs := runPlanFlow(t, m, runtime.SessionPlanUpdated(sess.ID, "plan body", "", "root")) + dataMsg, ok := firstOfType[dialog.PlanBrowserDataMsg](msgs) + require.True(t, ok, "a buried plan browser must still live-refresh on session plan writes") + require.Len(t, dataMsg.Result.Plans, 1) + + // Apply the broadcast: the buried browser instance receives the rows. + _, _ = m.Update(dataMsg) + assert.Contains(t, browser.View(), sess.ID[:8], "the buried browser must render the refreshed rows") +} + +// failingGetPlansService wraps a real service and fails Get for one exact +// ref with a configured error, so tests drive detail-refresh failures +// deterministically instead of through fragile filesystem state. +type failingGetPlansService struct { + plans.Service + + failRef plans.Ref + err error +} + +func (s *failingGetPlansService) Get(ctx context.Context, ref plans.Ref) (plans.Plan, error) { + if s.err != nil && ref == s.failRef { + return plans.Plan{}, s.err + } + return s.Service.Get(ctx, ref) +} + +// TestPlanRefresh_BuriedDetailSuppressesErrorsUntilSurfaced proves a detail +// dialog whose plan can no longer be read does not spam notifications while +// it is buried under another dialog, whatever the error: refreshes skip it +// silently (a buried detail cannot be closed without popping the wrong +// dialog), and exactly one notification — plus a close only for a plan that +// disappeared — happens once it surfaces and the next refresh runs. +func TestPlanRefresh_BuriedDetailSuppressesErrorsUntilSurfaced(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + getErr error + wantClose bool + wantText string + }{ + { + name: "not found closes and notifies", + getErr: &plans.NotFoundError{Scope: plans.ScopeShared, Name: "release"}, + wantClose: true, + wantText: "No shared plan", + }, + { + name: "corrupt notifies without closing", + getErr: &plans.CorruptError{Scope: plans.ScopeShared, Name: "release", Err: errors.New("invalid frontmatter")}, + wantText: "corrupt", + }, + { + name: "storage failure notifies without closing", + getErr: &plans.StorageError{Scope: plans.ScopeShared, Op: "get", Err: errors.New("device gone")}, + wantText: "storage failure", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + m, svc, _, _ := newPlansTestModel(t) + p := mustCreatePlan(t, svc, "release", "content") + WithPlansService(&failingGetPlansService{ + Service: svc, + failRef: plans.SharedRef("release"), + err: tt.getErr, + })(m) + + sizeDialogs(t, m) + openDialog(t, m, dialog.NewPlanDetailDialog(p)) + openDialog(t, m, dialog.NewHelpDialog(nil)) + + // Repeated refreshes neither notify nor try to close the buried + // detail. + for range 2 { + msgs := runPlanFlow(t, m, messages.RefreshPlansMsg{}) + assert.Empty(t, notificationTexts(msgs), "a buried failing detail must not notify on refresh") + _, closed := firstOfType[dialog.ClosePlanDetailMsg](msgs) + assert.False(t, closed, "a buried detail must never be closed") + } + + // The covering dialog closes, the failing detail surfaces: the + // next refresh notifies exactly once, closing only a vanished + // plan. + updated, _ := m.dialogMgr.Update(dialog.CloseDialogMsg{}) + m.dialogMgr = updated.(dialog.Manager) + + msgs := runPlanFlow(t, m, messages.RefreshPlansMsg{}) + closeMsg, closed := firstOfType[dialog.ClosePlanDetailMsg](msgs) + assert.Equal(t, tt.wantClose, closed, "only a vanished plan closes the surfaced detail") + if closed { + assert.Equal(t, plans.SharedRef("release"), closeMsg.Ref, "the close must target the vanished detail") + } + + texts := notificationTexts(msgs) + require.Len(t, texts, 1, "the surfaced failing detail notifies exactly once") + assert.Contains(t, texts[0], tt.wantText) + }) + } +} + +// TestPlanRefresh_DuplicateVanishedDetailClosesOnlyDetail reproduces the +// adversarial double-close: two refreshes are requested while a detail's +// plan has vanished, and both results are dispatched before either close is +// applied, so each one still sees the detail on top and emits a targeted +// close. Applying both must pop only the detail — never the browser +// underneath. Notifications may repeat; the dialog stack must stay correct. +func TestPlanRefresh_DuplicateVanishedDetailClosesOnlyDetail(t *testing.T) { + t.Parallel() + m, svc, _, _ := newPlansTestModel(t) + p := mustCreatePlan(t, svc, "release", "content") + WithPlansService(&failingGetPlansService{ + Service: svc, + failRef: plans.SharedRef("release"), + err: &plans.NotFoundError{Scope: plans.ScopeShared, Name: "release"}, + })(m) + + sizeDialogs(t, m) + browser := dialog.NewPlanBrowserDialog(plans.ListResult{Plans: []plans.Plan{p}}) + openDialog(t, m, browser) + openDialog(t, m, dialog.NewPlanDetailDialog(p)) + + // Two refreshes are requested back-to-back; the second coalesces behind + // the in-flight first one. + _, cmd1 := m.Update(messages.RefreshPlansMsg{}) + require.NotNil(t, cmd1) + _, cmd2 := m.Update(messages.RefreshPlansMsg{}) + assert.Nil(t, cmd2, "a refresh requested while one is in flight is coalesced") + + // Handle the first result. It emits the first targeted close (the close + // is NOT applied yet) and replays the coalesced refresh, whose read runs + // here and yields the second result. + result1, ok := firstOfType[planRefreshedMsg](collectMsgs(cmd1)) + require.True(t, ok) + _, cmd := m.Update(result1) + out1 := collectMsgs(cmd) + assert.Equal(t, 1, countOfType[dialog.ClosePlanDetailMsg](out1)) + result2, ok := firstOfType[planRefreshedMsg](out1) + require.True(t, ok, "the coalesced refresh must be replayed") + + // Handle the second result before the first close was applied: the + // detail is still on top, so a second close for the same ref is emitted. + _, cmd = m.Update(result2) + out2 := collectMsgs(cmd) + assert.Equal(t, 1, countOfType[dialog.ClosePlanDetailMsg](out2)) + + // Apply everything — both targeted closes included — like the runtime + // would. Only the detail may close; the browser survives as top. + for _, msg := range append(out1, out2...) { + if _, ok := msg.(planRefreshedMsg); ok { + continue // already handled above + } + _, _ = m.Update(msg) + } + require.True(t, m.dialogMgr.Open(), "the browser must survive both closes") + assert.Same(t, browser, m.dialogMgr.TopDialog(), "exactly one pop: the detail closed, the browser is top") +} + +// TestPlanRefresh_CoalescesInFlightRequests proves refresh requests arriving +// while a reload is in flight collapse into exactly one follow-up reload. +func TestPlanRefresh_CoalescesInFlightRequests(t *testing.T) { + t.Parallel() + m, svc, _, _ := newPlansTestModel(t) + mustCreatePlan(t, svc, "release", "content") + openPlanBrowser(t, m, plans.ListResult{Plans: []plans.Plan{}}) + + _, cmd1 := m.Update(messages.RefreshPlansMsg{}) + require.NotNil(t, cmd1) + _, cmd2 := m.Update(messages.RefreshPlansMsg{}) + assert.Nil(t, cmd2, "the second request coalesces behind the in-flight reload") + _, cmd3 := m.Update(messages.RefreshPlansMsg{}) + assert.Nil(t, cmd3, "the third request folds into the same follow-up") + + msgs := drainPlanFlow(t, m, cmd1) + assert.Equal(t, 2, countOfType[dialog.PlanBrowserDataMsg](msgs), + "the in-flight reload plus exactly one coalesced follow-up") + + // The pipeline is idle again: a new request launches immediately. + _, cmd4 := m.Update(messages.RefreshPlansMsg{}) + assert.NotNil(t, cmd4) +} + +// TestHandlePlanEditorClosed_EditorErrorKeepsDraft proves an editor failure +// (e.g. a non-zero exit after the user saved) never deletes the draft: the +// content survives on disk and the notifications name both the error and +// the kept path. +func TestHandlePlanEditorClosed_EditorErrorKeepsDraft(t *testing.T) { + t.Parallel() + m, svc, _, _ := newPlansTestModel(t) + + draft := filepath.Join(t.TempDir(), "draft.md") + require.NoError(t, os.WriteFile(draft, []byte("# saved before the editor failed"), 0o600)) + + msgs := runPlanFlow(t, m, planEditorClosedMsg{ + ref: plans.SharedRef("fresh"), + create: true, + path: draft, + err: errors.New("exit status 1"), + }) + texts := notificationTexts(msgs) + require.Len(t, texts, 2) + assert.Contains(t, texts[0], "Editor error") + assert.Contains(t, texts[0], "exit status 1") + assert.Contains(t, texts[1], draft, "the notification must point at the kept draft") + + data, err := os.ReadFile(draft) + require.NoError(t, err, "the draft must survive an editor error") + assert.Equal(t, "# saved before the editor failed", string(data)) + + _, err = svc.Get(t.Context(), plans.SharedRef("fresh")) + require.Error(t, err, "nothing may be written when the editor failed") +} + +// --- Reads never run inline in Update ------------------------------------- + +// blockingReadPlansService blocks every read until release is closed or the +// read's context expires, counting the reads that started. It simulates +// slow storage: if Update ran a read inline it would block forever on the +// unreleased channel and fail the test by timeout, the same proof style as +// the FIFO draft-read test. An expired context reports the deadline as a +// typed storage error, exactly like real storage that respects +// cancellation, so bounded-read tests never leak a blocked goroutine. +type blockingReadPlansService struct { + plans.Service + + readsStarted atomic.Int32 + release chan struct{} +} + +func newBlockingReadPlansService(svc plans.Service) *blockingReadPlansService { + return &blockingReadPlansService{Service: svc, release: make(chan struct{})} +} + +func (s *blockingReadPlansService) await(ctx context.Context, op string) error { + s.readsStarted.Add(1) + select { + case <-s.release: + return nil + case <-ctx.Done(): + return &plans.StorageError{Scope: plans.ScopeShared, Op: op, Err: ctx.Err()} + } +} + +func (s *blockingReadPlansService) List(ctx context.Context, opts plans.ListOptions) (plans.ListResult, error) { + if err := s.await(ctx, "list"); err != nil { + return plans.ListResult{}, err + } + return s.Service.List(ctx, opts) +} + +func (s *blockingReadPlansService) Get(ctx context.Context, ref plans.Ref) (plans.Plan, error) { + if err := s.await(ctx, "get"); err != nil { + return plans.Plan{}, err + } + return s.Service.Get(ctx, ref) +} + +func (s *blockingReadPlansService) Export(ctx context.Context, req plans.ExportRequest) (plans.ExportResult, error) { + if err := s.await(ctx, "export"); err != nil { + return plans.ExportResult{}, err + } + return s.Service.Export(ctx, req) +} + +// requireDeferredRead asserts Update produced a command without starting any +// service read inline, then releases the blocked reads, runs the command, +// and returns every message the settled flow produced. +func requireDeferredRead(t *testing.T, m *appModel, blocking *blockingReadPlansService, msg tea.Msg) []tea.Msg { + t.Helper() + _, cmd := m.Update(msg) + require.NotNil(t, cmd, "Update must defer the read to a command") + assert.Zero(t, blocking.readsStarted.Load(), "Update must never read the plan service inline") + + close(blocking.release) + return drainPlanFlow(t, m, cmd) +} + +func TestHandleShowPlanBrowser_ReadsAsynchronously(t *testing.T) { + t.Parallel() + m, svc, _, _ := newPlansTestModel(t) + mustCreatePlan(t, svc, "release", "content") + blocking := newBlockingReadPlansService(svc) + WithPlansService(blocking)(m) + + msgs := requireDeferredRead(t, m, blocking, messages.ShowPlanBrowserMsg{}) + _, ok := firstOfType[dialog.OpenDialogMsg](msgs) + assert.True(t, ok, "the browser opens once the deferred read completes") +} + +func TestHandleRefreshPlans_ReadsAsynchronously(t *testing.T) { + t.Parallel() + m, svc, _, _ := newPlansTestModel(t) + p := mustCreatePlan(t, svc, "release", "content") + blocking := newBlockingReadPlansService(svc) + WithPlansService(blocking)(m) + + sizeDialogs(t, m) + openPlanBrowser(t, m, plans.ListResult{Plans: []plans.Plan{p}}) + openDialog(t, m, dialog.NewPlanDetailDialog(p)) + + msgs := requireDeferredRead(t, m, blocking, messages.RefreshPlansMsg{}) + _, ok := firstOfType[dialog.PlanBrowserDataMsg](msgs) + assert.True(t, ok, "the browser rows arrive once the deferred reads complete") + detailMsg, ok := firstOfType[dialog.PlanDetailDataMsg](msgs) + require.True(t, ok, "the open detail is re-fetched off the event loop too") + assert.Equal(t, "content", detailMsg.Plan.Content) + assert.Equal(t, int32(2), blocking.readsStarted.Load(), "one List plus one Get for the open detail") +} + +func TestHandleOpenPlanDetail_ReadsAsynchronously(t *testing.T) { + t.Parallel() + m, svc, _, _ := newPlansTestModel(t) + mustCreatePlan(t, svc, "release", "content") + blocking := newBlockingReadPlansService(svc) + WithPlansService(blocking)(m) + + // The detail is requested from an open browser; its result is dropped + // once no plan dialog is open anymore. + sizeDialogs(t, m) + openPlanBrowser(t, m, plans.ListResult{Plans: []plans.Plan{}}) + + msgs := requireDeferredRead(t, m, blocking, messages.OpenPlanDetailMsg{Ref: plans.SharedRef("release")}) + openMsg, ok := firstOfType[dialog.OpenDialogMsg](msgs) + require.True(t, ok, "the detail opens once the deferred read completes") + viewer, ok := openMsg.Model.(dialog.PlanDetailViewer) + require.True(t, ok) + assert.Equal(t, plans.SharedRef("release"), viewer.PlanRef()) +} + +func TestHandleExportPlan_ExportsAsynchronously(t *testing.T) { + m, svc, _, _ := newPlansTestModel(t) + mustCreatePlan(t, svc, "release", "the content") + blocking := newBlockingReadPlansService(svc) + WithPlansService(blocking)(m) + + workDir := t.TempDir() + t.Chdir(workDir) + path := filepath.Join(workDir, "release.md") + + _, cmd := m.Update(messages.ExportPlanMsg{Ref: plans.SharedRef("release")}) + require.NotNil(t, cmd, "Update must defer the export to a command") + assert.Zero(t, blocking.readsStarted.Load(), "Update must never read the plan service inline") + _, err := os.Stat(path) + assert.True(t, os.IsNotExist(err), "nothing may be written before the command runs") + + close(blocking.release) + msgs := drainPlanFlow(t, m, cmd) + note, ok := firstOfType[notification.ShowMsg](msgs) + require.True(t, ok) + assert.Equal(t, notification.TypeSuccess, note.Type) + + data, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, "the content", string(data)) +} + +func TestPlanChangedEvent_RefreshReadsAsynchronously(t *testing.T) { + t.Parallel() + m, svc, _, _ := newPlansTestModel(t) + mustCreatePlan(t, svc, "release", "content") + blocking := newBlockingReadPlansService(svc) + WithPlansService(blocking)(m) + + sizeDialogs(t, m) + openPlanBrowser(t, m, plans.ListResult{Plans: []plans.Plan{}}) + + msgs := requireDeferredRead(t, m, blocking, runtime.PlanChanged("shared", "release", plan.ChangeActionWrite, 1, "root")) + _, ok := firstOfType[dialog.PlanBrowserDataMsg](msgs) + assert.True(t, ok, "the event-driven refresh lands once the deferred read completes") +} + +// TestHandleEditPlan_PreparesAsynchronously proves the whole edit +// preparation — the Get and the draft-file write seeded from it — runs in a +// command, never inline in Update: with reads blocked, Update returns +// immediately and no read has started (and since the draft is seeded from +// the read plan, no draft was written either). +func TestHandleEditPlan_PreparesAsynchronously(t *testing.T) { + t.Parallel() + + t.Run("matching version drafts and launches the editor", func(t *testing.T) { + t.Parallel() + m, svc, _, _ := newPlansTestModel(t) + p := mustCreatePlan(t, svc, "release", "v1 content") + blocking := newBlockingReadPlansService(svc) + WithPlansService(blocking)(m) + + sizeDialogs(t, m) + openPlanBrowser(t, m, plans.ListResult{Plans: []plans.Plan{p}}) + + _, cmd := m.Update(messages.EditPlanMsg{Ref: plans.SharedRef("release"), ExpectedVersion: 1}) + require.NotNil(t, cmd, "Update must defer the edit preparation to a command") + assert.Zero(t, blocking.readsStarted.Load(), "Update must never read the plan service inline") + + close(blocking.release) + result := cmd() + ready, ok := result.(planEditReadyMsg) + require.True(t, ok, "the command must yield a typed result, got %T", result) + require.NoError(t, ready.err) + require.NoError(t, ready.draftErr) + require.NotEmpty(t, ready.draftPath) + t.Cleanup(func() { _ = os.Remove(ready.draftPath) }) + + data, err := os.ReadFile(ready.draftPath) + require.NoError(t, err) + assert.Equal(t, "v1 content", string(data), "the draft must be seeded with the current content") + + // Dispatching the result launches the editor — an exec command, not a + // notification — and the draft survives for the editor to open. + _, editorCmd := m.Update(result) + require.NotNil(t, editorCmd, "the prepared edit must launch the editor") + assert.Empty(t, notificationTexts(collectMsgs(editorCmd)), "the launch must not be a notification") + _, err = os.Stat(ready.draftPath) + require.NoError(t, err, "the draft must be kept for the editor") + }) + + t.Run("version drift refreshes instead of editing", func(t *testing.T) { + t.Parallel() + m, svc, _, _ := newPlansTestModel(t) + mustCreatePlan(t, svc, "release", "v1 content") + openPlanBrowser(t, m, plans.ListResult{Plans: []plans.Plan{}}) + v1 := 1 + _, err := svc.Update(t.Context(), plans.UpdateRequest{ + Ref: plans.SharedRef("release"), Content: "v2 content", ExpectedVersion: &v1, + }) + require.NoError(t, err) + blocking := newBlockingReadPlansService(svc) + WithPlansService(blocking)(m) + + _, cmd := m.Update(messages.EditPlanMsg{Ref: plans.SharedRef("release"), ExpectedVersion: 1}) + require.NotNil(t, cmd) + assert.Zero(t, blocking.readsStarted.Load(), "Update must never read the plan service inline") + + close(blocking.release) + result := cmd() + ready, ok := result.(planEditReadyMsg) + require.True(t, ok, "the command must yield a typed result, got %T", result) + require.NoError(t, ready.err) + assert.Equal(t, 2, ready.currentVersion) + assert.Empty(t, ready.draftPath, "no draft may be created for a drifted base") + + msgs := drainPlanFlow(t, m, func() tea.Msg { return result }) + texts := notificationTexts(msgs) + require.NotEmpty(t, texts) + assert.Contains(t, texts[0], "v2") + _, refreshed := firstOfType[dialog.PlanBrowserDataMsg](msgs) + assert.True(t, refreshed, "version drift must refresh the data on screen") + }) +} + +// --- Concurrent duplicate exports ----------------------------------------- + +// TestHandleExportPlan_DuplicateInFlightRefused proves two exports to the +// same destination cannot race the no-overwrite pre-check against the +// write: the second request — arriving before the first command has even +// run — is refused with a notification and never starts an export, exactly +// one write happens, and once it completed the pre-check refuses the +// existing file. +func TestHandleExportPlan_DuplicateInFlightRefused(t *testing.T) { + m, svc, _, _ := newPlansTestModel(t) + mustCreatePlan(t, svc, "release", "the content") + blocking := newBlockingReadPlansService(svc) + WithPlansService(blocking)(m) + + workDir := t.TempDir() + t.Chdir(workDir) + path := filepath.Join(workDir, "release.md") + + // Two exports of the same plan before the first command executes. + _, cmd1 := m.Update(messages.ExportPlanMsg{Ref: plans.SharedRef("release")}) + require.NotNil(t, cmd1) + _, cmd2 := m.Update(messages.ExportPlanMsg{Ref: plans.SharedRef("release")}) + require.NotNil(t, cmd2) + + // The duplicate yields only the refusal notification — no export command. + dupMsgs := collectMsgs(cmd2) + assert.Zero(t, countOfType[planExportResultMsg](dupMsgs), "the duplicate must not start an export") + note, ok := firstOfType[notification.ShowMsg](dupMsgs) + require.True(t, ok, "the duplicate must be refused with a notification") + assert.Equal(t, notification.TypeInfo, note.Type) + assert.Contains(t, note.Text, path) + assert.Contains(t, note.Text, "already running") + assert.Zero(t, blocking.readsStarted.Load(), "no export may have reached the service yet") + + // The first export completes: exactly one write happened. + close(blocking.release) + msgs := drainPlanFlow(t, m, cmd1) + note, ok = firstOfType[notification.ShowMsg](msgs) + require.True(t, ok) + assert.Equal(t, notification.TypeSuccess, note.Type) + assert.Equal(t, int32(1), blocking.readsStarted.Load(), "exactly one export reaches the service") + data, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, "the content", string(data)) + assert.Empty(t, m.planExportsInFlight, "a completed export must clear its in-flight key") + + // The destination is registrable again — and the no-overwrite pre-check + // now sees the exported file and refuses. + msgs = runPlanFlow(t, m, messages.ExportPlanMsg{Ref: plans.SharedRef("release")}) + note, ok = firstOfType[notification.ShowMsg](msgs) + require.True(t, ok) + assert.Equal(t, notification.TypeError, note.Type) + assert.Contains(t, note.Text, "already exists") + assert.Empty(t, m.planExportsInFlight, "a refused export must clear its in-flight key too") + data, err = os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, "the content", string(data), "the existing file must never be overwritten") +} + +// --- Duplicate async browser/detail opens ---------------------------------- + +// TestHandleShowPlanBrowser_DuplicateRequestsDropped proves two /plans +// requests racing one in-flight listing read start exactly one List and +// stack exactly one browser. +func TestHandleShowPlanBrowser_DuplicateRequestsDropped(t *testing.T) { + t.Parallel() + m, svc, _, _ := newPlansTestModel(t) + mustCreatePlan(t, svc, "release", "content") + blocking := newBlockingReadPlansService(svc) + WithPlansService(blocking)(m) + + _, cmd1 := m.Update(messages.ShowPlanBrowserMsg{}) + require.NotNil(t, cmd1) + _, cmd2 := m.Update(messages.ShowPlanBrowserMsg{}) + assert.Nil(t, cmd2, "a second /plans while the listing read is in flight must not start another read") + + close(blocking.release) + msgs := drainPlanFlow(t, m, cmd1) + assert.Equal(t, int32(1), blocking.readsStarted.Load(), "exactly one List reaches the service") + assert.Equal(t, 1, countOfType[dialog.OpenDialogMsg](msgs), "exactly one browser opens") + assert.False(t, m.planBrowserLoadInFlight, "the guard must clear when the result lands") +} + +// TestHandleShowPlanBrowser_RefusedWhenBrowserAlreadyOpen proves /plans with +// a browser already on the dialog stack neither reads nor stacks a second +// browser. +func TestHandleShowPlanBrowser_RefusedWhenBrowserAlreadyOpen(t *testing.T) { + t.Parallel() + m, svc, _, _ := newPlansTestModel(t) + blocking := newBlockingReadPlansService(svc) + WithPlansService(blocking)(m) + openPlanBrowser(t, m, plans.ListResult{Plans: []plans.Plan{}}) + + _, cmd := m.Update(messages.ShowPlanBrowserMsg{}) + assert.Nil(t, cmd, "a /plans with the browser already open must not start a read") + assert.Zero(t, blocking.readsStarted.Load()) +} + +// TestHandleShowPlanBrowser_SessionSwitchAllowsFreshLaunch proves the +// browser-load guard tracks session identity: /plans for the new session +// launches while the previous session's read is still in flight, the stale +// result opens nothing, and the fresh one opens exactly one browser. +func TestHandleShowPlanBrowser_SessionSwitchAllowsFreshLaunch(t *testing.T) { + t.Parallel() + m, svc, _, _ := newPlansTestModel(t) + mustCreatePlan(t, svc, "release", "content") + blocking := newBlockingReadPlansService(svc) + WithPlansService(blocking)(m) + + _, cmdA := m.Update(messages.ShowPlanBrowserMsg{}) + require.NotNil(t, cmdA) + + switchPlansTestSession(t, m) + _, cmdB := m.Update(messages.ShowPlanBrowserMsg{}) + require.NotNil(t, cmdB, "/plans for the new session must launch despite the stale in-flight read") + + close(blocking.release) + msgsA := drainPlanFlow(t, m, cmdA) + assert.Zero(t, countOfType[dialog.OpenDialogMsg](msgsA), "the stale session's listing must not open a browser") + msgsB := drainPlanFlow(t, m, cmdB) + assert.Equal(t, 1, countOfType[dialog.OpenDialogMsg](msgsB), "the fresh session's listing opens the browser") + assert.False(t, m.planBrowserLoadInFlight) +} + +// TestHandleOpenPlanDetail_DuplicateRequestsDropped proves two open requests +// for the same plan racing one in-flight read start exactly one Get and +// stack exactly one detail dialog. +func TestHandleOpenPlanDetail_DuplicateRequestsDropped(t *testing.T) { + t.Parallel() + m, svc, _, _ := newPlansTestModel(t) + mustCreatePlan(t, svc, "release", "content") + blocking := newBlockingReadPlansService(svc) + WithPlansService(blocking)(m) + + sizeDialogs(t, m) + openPlanBrowser(t, m, plans.ListResult{Plans: []plans.Plan{}}) + + _, cmd1 := m.Update(messages.OpenPlanDetailMsg{Ref: plans.SharedRef("release")}) + require.NotNil(t, cmd1) + _, cmd2 := m.Update(messages.OpenPlanDetailMsg{Ref: plans.SharedRef("release")}) + assert.Nil(t, cmd2, "a repeated open for the same plan while its read is in flight must not start another Get") + + close(blocking.release) + msgs := drainPlanFlow(t, m, cmd1) + assert.Equal(t, int32(1), blocking.readsStarted.Load(), "exactly one Get reaches the service") + assert.Equal(t, 1, countOfType[dialog.OpenDialogMsg](msgs), "exactly one detail opens") + assert.Empty(t, m.planDetailLoadsInFlight, "the guard must clear when the result lands") +} + +// TestHandleOpenPlanDetail_RefusedWhenDetailAlreadyOpen proves an open for a +// plan whose detail is already on the stack neither reads nor stacks a +// duplicate, while a different plan still loads. +func TestHandleOpenPlanDetail_RefusedWhenDetailAlreadyOpen(t *testing.T) { + t.Parallel() + m, svc, _, _ := newPlansTestModel(t) + p := mustCreatePlan(t, svc, "release", "content") + mustCreatePlan(t, svc, "other", "content") + blocking := newBlockingReadPlansService(svc) + WithPlansService(blocking)(m) + + sizeDialogs(t, m) + openPlanBrowser(t, m, plans.ListResult{Plans: []plans.Plan{p}}) + openDialog(t, m, dialog.NewPlanDetailDialog(p)) + + _, cmd := m.Update(messages.OpenPlanDetailMsg{Ref: plans.SharedRef("release")}) + assert.Nil(t, cmd, "an open for an already-shown detail must not start a read") + assert.Zero(t, blocking.readsStarted.Load()) + + _, cmd = m.Update(messages.OpenPlanDetailMsg{Ref: plans.SharedRef("other")}) + assert.NotNil(t, cmd, "a different plan's detail may still load") +} + +// TestHandlePlanDetailLoaded_DuplicateOpenRefused proves a completed read +// for a plan whose detail appeared in the meantime does not stack a second +// copy. +func TestHandlePlanDetailLoaded_DuplicateOpenRefused(t *testing.T) { + t.Parallel() + m, svc, _, _ := newPlansTestModel(t) + p := mustCreatePlan(t, svc, "release", "content") + + sizeDialogs(t, m) + openPlanBrowser(t, m, plans.ListResult{Plans: []plans.Plan{p}}) + openDialog(t, m, dialog.NewPlanDetailDialog(p)) + + _, cmd := m.Update(planDetailLoadedMsg{ref: plans.SharedRef("release"), plan: p}) + assert.Nil(t, cmd, "a result for an already-open detail must not stack a duplicate") +} + +// --- Bounded reads under wedged storage ------------------------------------ + +// TestPlanReads_WedgedStorageTimesOut proves every plan read command is +// bounded: against storage that never answers, Update stays responsive (no +// read ever runs inline), the command returns a typed deadline result +// within the configured read timeout instead of hanging forever, and +// dispatching the result yields the actionable timeout notification and +// clears any in-flight bookkeeping. +// +// Not parallel: the export case pins the working directory via t.Chdir. +func TestPlanReads_WedgedStorageTimesOut(t *testing.T) { + tests := []struct { + name string + msg tea.Msg + // openBrowser puts a plan dialog on the stack for results that are + // dropped without one. + openBrowser bool + chdir bool + resultErr func(t *testing.T, result tea.Msg) error + after func(t *testing.T, m *appModel) + }{ + { + name: "show browser", + msg: messages.ShowPlanBrowserMsg{}, + resultErr: func(t *testing.T, result tea.Msg) error { + t.Helper() + msg, ok := result.(planBrowserLoadedMsg) + require.True(t, ok, "got %T", result) + return msg.err + }, + after: func(t *testing.T, m *appModel) { + t.Helper() + assert.False(t, m.planBrowserLoadInFlight, "a timed-out open must clear the browser-load guard") + _, cmd := m.Update(messages.ShowPlanBrowserMsg{}) + assert.NotNil(t, cmd, "/plans must relaunch after a timeout") + }, + }, + { + name: "open detail", + msg: messages.OpenPlanDetailMsg{Ref: plans.SharedRef("release")}, + openBrowser: true, + resultErr: func(t *testing.T, result tea.Msg) error { + t.Helper() + msg, ok := result.(planDetailLoadedMsg) + require.True(t, ok, "got %T", result) + return msg.err + }, + after: func(t *testing.T, m *appModel) { + t.Helper() + assert.Empty(t, m.planDetailLoadsInFlight, "a timed-out open must clear the detail-load guard") + _, cmd := m.Update(messages.OpenPlanDetailMsg{Ref: plans.SharedRef("release")}) + assert.NotNil(t, cmd, "the detail open must relaunch after a timeout") + }, + }, + { + name: "refresh", + msg: messages.RefreshPlansMsg{}, + openBrowser: true, + resultErr: func(t *testing.T, result tea.Msg) error { + t.Helper() + msg, ok := result.(planRefreshedMsg) + require.True(t, ok, "got %T", result) + return msg.listErr + }, + after: func(t *testing.T, m *appModel) { + t.Helper() + assert.False(t, m.planRefreshInFlight, "a timed-out reload must clear the in-flight flag") + _, cmd := m.Update(messages.RefreshPlansMsg{}) + assert.NotNil(t, cmd, "the refresh pipeline must accept new requests after a timeout") + }, + }, + { + name: "export", + msg: messages.ExportPlanMsg{Ref: plans.SharedRef("release")}, + chdir: true, + resultErr: func(t *testing.T, result tea.Msg) error { + t.Helper() + msg, ok := result.(planExportResultMsg) + require.True(t, ok, "got %T", result) + return msg.err + }, + after: func(t *testing.T, m *appModel) { + t.Helper() + assert.Empty(t, m.planExportsInFlight, "a timed-out export must clear its in-flight key") + }, + }, + { + name: "edit", + msg: messages.EditPlanMsg{Ref: plans.SharedRef("release"), ExpectedVersion: 1}, + resultErr: func(t *testing.T, result tea.Msg) error { + t.Helper() + msg, ok := result.(planEditReadyMsg) + require.True(t, ok, "got %T", result) + return msg.err + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m, svc, _, _ := newPlansTestModel(t) + mustCreatePlan(t, svc, "release", "content") + blocking := newBlockingReadPlansService(svc) // never released: reads answer only via ctx + WithPlansService(blocking)(m) + m.planReadTimeout = 50 * time.Millisecond + + if tt.openBrowser { + sizeDialogs(t, m) + openPlanBrowser(t, m, plans.ListResult{Plans: []plans.Plan{}}) + } + if tt.chdir { + t.Chdir(t.TempDir()) + } + + _, cmd := m.Update(tt.msg) + require.NotNil(t, cmd, "Update must defer the read to a command") + assert.Zero(t, blocking.readsStarted.Load(), "Update must never read the plan service inline") + + start := time.Now() + result := cmd() + elapsed := time.Since(start) + assert.GreaterOrEqual(t, elapsed, 40*time.Millisecond, "the command waits for the bounded timeout") + assert.Less(t, elapsed, time.Second, "the bounded timeout must fire, not the 10s default or never") + + err := tt.resultErr(t, result) + require.Error(t, err) + require.ErrorIs(t, err, context.DeadlineExceeded) + + _, notifyCmd := m.Update(result) + texts := notificationTexts(collectMsgs(notifyCmd)) + require.NotEmpty(t, texts, "the deadline must surface as a notification") + assert.Contains(t, texts[0], "timed out") + assert.Contains(t, texts[0], "unavailable") + + if tt.after != nil { + tt.after(t, m) + } + }) + } +} + +// TestPlanRefresh_TimeoutClearsInFlightAndRunsQueued proves a timed-out +// reload cannot wedge the refresh pipeline: the deadline result clears the +// in-flight flag and the refresh that was coalesced behind it launches as +// the follow-up reload. +func TestPlanRefresh_TimeoutClearsInFlightAndRunsQueued(t *testing.T) { + t.Parallel() + m, svc, _, _ := newPlansTestModel(t) + mustCreatePlan(t, svc, "release", "content") + openPlanBrowser(t, m, plans.ListResult{Plans: []plans.Plan{}}) + blocking := newBlockingReadPlansService(svc) // never released + WithPlansService(blocking)(m) + m.planReadTimeout = 50 * time.Millisecond + + _, cmd1 := m.Update(messages.RefreshPlansMsg{}) + require.NotNil(t, cmd1) + _, cmd2 := m.Update(messages.RefreshPlansMsg{}) + assert.Nil(t, cmd2, "the second refresh coalesces behind the in-flight one") + + result := cmd1() + refreshed, ok := result.(planRefreshedMsg) + require.True(t, ok, "the reload must report back despite wedged storage, got %T", result) + require.ErrorIs(t, refreshed.listErr, context.DeadlineExceeded) + + _, cmd := m.Update(result) + assert.True(t, m.planRefreshInFlight, "the coalesced refresh must be relaunched as the follow-up") + + out := collectMsgs(cmd) + texts := notificationTexts(out) + require.NotEmpty(t, texts) + assert.Contains(t, texts[0], "timed out") + + followUp, ok := firstOfType[planRefreshedMsg](out) + require.True(t, ok, "the queued refresh must run once the timed-out reload lands") + require.ErrorIs(t, followUp.listErr, context.DeadlineExceeded) + + _, _ = m.Update(followUp) + assert.False(t, m.planRefreshInFlight, "the pipeline must be idle again") +} + +// --- Stale async results after the user moved on --------------------------- + +// TestStalePlanResults_Dropped proves slow read results cannot disrupt a +// user who moved on: a detail result opens no dialog after /plans was +// closed, a prepared edit launches no editor (and leaves no draft behind), +// and a listing read for a previous tab's session opens no browser. +func TestStalePlanResults_Dropped(t *testing.T) { + t.Parallel() + + t.Run("detail result after /plans closed opens no dialog", func(t *testing.T) { + t.Parallel() + m, svc, _, _ := newPlansTestModel(t) + p := mustCreatePlan(t, svc, "release", "content") + + // No plan dialog is open anymore when the read lands. + _, cmd := m.Update(planDetailLoadedMsg{plan: p}) + assert.Nil(t, cmd, "a stale detail result must not open a dialog") + }) + + t.Run("edit result after /plans closed launches no editor", func(t *testing.T) { + t.Parallel() + m, _, _, _ := newPlansTestModel(t) + draft := filepath.Join(t.TempDir(), "draft.md") + require.NoError(t, os.WriteFile(draft, []byte("stored content"), 0o600)) + + _, cmd := m.Update(planEditReadyMsg{ + ref: plans.SharedRef("release"), + expectedVersion: 1, + currentVersion: 1, + draftPath: draft, + }) + assert.Nil(t, cmd, "a stale edit must not take over the terminal with an editor") + _, err := os.Stat(draft) + assert.True(t, os.IsNotExist(err), "the unused draft holds no user edits and is removed") + }) + + t.Run("browser listing for a switched session opens no dialog", func(t *testing.T) { + t.Parallel() + m, svc, _, _ := newPlansTestModel(t) + p := mustCreatePlan(t, svc, "release", "content") + + _, cmd := m.Update(planBrowserLoadedMsg{ + sessionID: "previous-tab-session", + result: plans.ListResult{Plans: []plans.Plan{p}}, + }) + assert.Nil(t, cmd, "a listing read for another session must not open the browser") + }) +} + +// --- Stale refresh across session switches --------------------------------- + +// TestPlanRefresh_StaleSessionResultDroppedAndRelaunched reproduces the +// verifier proof: a reload launched for session A lands after the user +// switched to session B. A's listing — naming A's session plan — must not +// reach the dialogs, and exactly one fresh reload for B must replace it. +func TestPlanRefresh_StaleSessionResultDroppedAndRelaunched(t *testing.T) { + t.Parallel() + m, svc, sessA, sessionDir := newPlansTestModel(t) + mustCreatePlan(t, svc, "release", "content") + _, err := sessionplan.WriteContent(sessionDir, sessA.ID, "session A plan") + require.NoError(t, err) + + openPlanBrowser(t, m, plans.ListResult{Plans: []plans.Plan{}}) + + // The reload launches against session A's listing. + _, cmd := m.Update(messages.RefreshPlansMsg{}) + require.NotNil(t, cmd) + result := cmd() + refreshed, ok := result.(planRefreshedMsg) + require.True(t, ok, "got %T", result) + require.Equal(t, sessA.ID, refreshed.sessionID) + + // The user switches to session B before the result lands. + sessB := switchPlansTestSession(t, m) + _, err = sessionplan.WriteContent(sessionDir, sessB.ID, "session B plan") + require.NoError(t, err) + + // Dispatching A's stale result broadcasts nothing and relaunches once. + _, cmd = m.Update(result) + require.NotNil(t, cmd, "a fresh reload for the current session must launch") + assert.True(t, m.planRefreshInFlight, "the relaunched reload must be in flight") + + msgs := drainPlanFlow(t, m, cmd) + require.Equal(t, 1, countOfType[dialog.PlanBrowserDataMsg](msgs), + "exactly one fresh reload broadcasts; the stale one never does") + dataMsg, ok := firstOfType[dialog.PlanBrowserDataMsg](msgs) + require.True(t, ok) + var sessionRows []string + for _, p := range dataMsg.Result.Plans { + if p.Scope == plans.ScopeSession { + sessionRows = append(sessionRows, p.SessionID) + } + } + assert.Equal(t, []string{sessB.ID}, sessionRows, "only the current session's plan may be applied") + assert.False(t, m.planRefreshInFlight, "the pipeline must settle") +} + +// TestPlanRefresh_ResultWithoutDialogsDropsSilently proves a reload result +// (here: a failed one) arriving after every plan dialog closed yields no +// orphan notification and leaves the pipeline clean, queued intent included. +func TestPlanRefresh_ResultWithoutDialogsDropsSilently(t *testing.T) { + t.Parallel() + m, svc, _, _ := newPlansTestModel(t) + mustCreatePlan(t, svc, "release", "content") + + // A reload was in flight (with a queued follow-up) when the user closed + // the last plan dialog. + m.planRefreshInFlight = true + m.planRefreshQueued = true + m.planRefreshQueuedWarnings = true + + _, cmd := m.Update(planRefreshedMsg{sessionID: m.currentPlanSessionID(), listErr: errors.New("boom")}) + assert.Nil(t, cmd, "no notification and no follow-up may be produced") + assert.False(t, m.planRefreshInFlight, "the pipeline must be idle") + assert.False(t, m.planRefreshQueued, "the queued follow-up must be dropped") + assert.False(t, m.planRefreshQueuedWarnings) + + // The pipeline accepts new requests immediately. + openPlanBrowser(t, m, plans.ListResult{Plans: []plans.Plan{}}) + _, cmd = m.Update(messages.RefreshPlansMsg{}) + assert.NotNil(t, cmd) +} diff --git a/pkg/tui/plans_unix_test.go b/pkg/tui/plans_unix_test.go new file mode 100644 index 0000000000..5da94890b9 --- /dev/null +++ b/pkg/tui/plans_unix_test.go @@ -0,0 +1,110 @@ +//go:build !windows + +package tui + +import ( + "os" + "path/filepath" + "syscall" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/app" + "github.com/docker/docker-agent/pkg/plans" + "github.com/docker/docker-agent/pkg/session" + "github.com/docker/docker-agent/pkg/tools/builtin/plan" + "github.com/docker/docker-agent/pkg/tui/dialog" + "github.com/docker/docker-agent/pkg/tui/messages" + "github.com/docker/docker-agent/pkg/tui/service" +) + +// TestHandlePlanEditorClosed_NoInlineDraftRead proves Update never reads the +// draft on the event loop: the draft path is a FIFO with no writer, on which +// an inline read (a plain open without O_NONBLOCK) would block forever and +// hang this test. Update must return a command immediately; the command then +// opens the FIFO hang-safe, rejects it on the descriptor as non-regular, and +// leaves the path in place. +func TestHandlePlanEditorClosed_NoInlineDraftRead(t *testing.T) { + t.Parallel() + m, svc, _, _ := newPlansTestModel(t) + + fifo := filepath.Join(t.TempDir(), "draft.md") + if err := syscall.Mkfifo(fifo, 0o600); err != nil { + t.Skipf("cannot create a FIFO on this system: %v", err) + } + + // A regression to an inline draft read would block this call forever + // and fail the test by timeout. + _, cmd := m.Update(planEditorClosedMsg{ref: plans.SharedRef("fresh"), create: true, path: fifo}) + require.NotNil(t, cmd, "Update must defer the draft read to a command") + + result := cmd() + writeResult, ok := result.(planWriteResultMsg) + require.True(t, ok, "the command must yield a typed result, got %T", result) + require.Error(t, writeResult.readErr) + assert.Contains(t, writeResult.readErr.Error(), "not a regular file") + + _, notifyCmd := m.Update(result) + texts := notificationTexts(collectMsgs(notifyCmd)) + require.NotEmpty(t, texts) + assert.Contains(t, texts[0], "not a regular file") + + _, err := svc.Get(t.Context(), plans.SharedRef("fresh")) + require.Error(t, err, "nothing may be written from an unreadable draft") + _, err = os.Stat(fifo) + require.NoError(t, err, "the draft path must be preserved on a read failure") +} + +// TestShowPlanBrowser_FIFOPlanFileDoesNotHang proves the TUI's read timeout +// is not the only line of defense for the known FIFO trigger: a FIFO +// squatting on a stored plan file fails fast inside the real filesystem +// storage, so /plans completes promptly — browser opened, healthy plan +// listed, FIFO degraded to a warning — instead of the read command hanging +// (before the hang-safe open: forever, with the deadline unable to interrupt +// the blocked open). +func TestShowPlanBrowser_FIFOPlanFileDoesNotHang(t *testing.T) { + t.Parallel() + m, _ := newTestModel(t) + sharedDir := t.TempDir() + svc := plans.NewService(plan.NewFilesystemStorage(sharedDir), plans.WithSessionDir(t.TempDir())) + WithPlansService(svc)(m) + sess := session.New() + m.application = app.New(t.Context(), stubRuntime{}, sess) + m.sessionState = service.NewSessionState(sess) + + mustCreatePlan(t, svc, "good", "content") + if err := syscall.Mkfifo(filepath.Join(sharedDir, "wedged.json"), 0o600); err != nil { + t.Skipf("cannot create a FIFO on this system: %v", err) + } + + _, cmd := m.Update(messages.ShowPlanBrowserMsg{}) + require.NotNil(t, cmd) + + done := make(chan tea.Msg, 1) + go func() { done <- cmd() }() + + var result tea.Msg + select { + case result = <-done: + case <-time.After(5 * time.Second): + t.Fatal("the /plans listing read blocked on a FIFO plan file; the storage open must not block") + } + + loaded, ok := result.(planBrowserLoadedMsg) + require.True(t, ok, "got %T", result) + require.NoError(t, loaded.err, "the listing must complete for real, not through the read deadline") + + msgs := drainPlanFlow(t, m, func() tea.Msg { return result }) + openMsg, ok := firstOfType[dialog.OpenDialogMsg](msgs) + require.True(t, ok, "the browser must open despite the FIFO plan file") + openMsg.Model.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) + assert.Contains(t, openMsg.Model.View(), "good", "the healthy plan must be listed") + + texts := notificationTexts(msgs) + require.NotEmpty(t, texts, "the FIFO must surface as a warning") + assert.Contains(t, texts[0], "wedged") +} diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index 06a26f8c6e..83c3688815 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -23,6 +23,7 @@ import ( "github.com/docker/docker-agent/pkg/app" "github.com/docker/docker-agent/pkg/audio/transcribe" "github.com/docker/docker-agent/pkg/history" + "github.com/docker/docker-agent/pkg/plans" "github.com/docker/docker-agent/pkg/runtime" "github.com/docker/docker-agent/pkg/session" "github.com/docker/docker-agent/pkg/tui/animation" @@ -92,6 +93,52 @@ type appModel struct { tabBar *tabbar.TabBar tuiStore *tuistate.Store + // plansSvc is the host-facing plan service behind /plans. Built lazily + // by plansService() so plan.SharedStorage() only resolves its directory + // after path configuration; tests inject one via WithPlansService. + plansSvc plans.Service + + // planMutationTimeout and planReadTimeout override the bounded timeouts + // of plan persistence and plan read commands. Zero means the package + // defaults (defaultPlanMutationTimeout / defaultPlanReadTimeout); tests + // set them per-model so timeout scenarios stay fast and parallel-safe. + planMutationTimeout time.Duration + planReadTimeout time.Duration + + // planRefreshInFlight coalesces plan-dialog refreshes: at most one read + // command runs at a time, and requests arriving meanwhile only mark + // planRefreshQueued (plus planRefreshQueuedWarnings when the request + // wanted listing warnings notified) so a single follow-up reload is + // launched when the in-flight result lands. All three fields are touched + // exclusively from Update. + planRefreshInFlight bool + planRefreshQueued bool + planRefreshQueuedWarnings bool + + // planBrowserLoadInFlight and planBrowserLoadSessionID guard the /plans + // browser-opening read: a repeated request for the same session while its + // List is in flight is dropped, so duplicate browsers can never stack and + // no redundant read starts. A request for a different session (the user + // switched tabs) may launch; the superseded result is dropped as stale by + // its session stamp and only the matching result clears the guard. Both + // fields are touched exclusively from Update. + planBrowserLoadInFlight bool + planBrowserLoadSessionID string + + // planDetailLoadsInFlight tracks the refs of running detail-opening + // reads, so repeated open requests for the same plan cannot pile up + // redundant Gets or stack duplicate detail dialogs. Refs are registered + // in handleOpenPlanDetail and always cleared in handlePlanDetailLoaded; + // the map is touched exclusively from Update. + planDetailLoadsInFlight map[plans.Ref]struct{} + + // planExportsInFlight tracks the destination paths of running plan + // export commands, so a duplicate export cannot race the no-overwrite + // pre-check against the write. Keys are registered in handleExportPlan + // and always cleared in handlePlanExportResult; the map is touched + // exclusively from Update. + planExportsInFlight map[string]struct{} + // Per-session chat pages (kept alive for streaming continuity) chatPages map[string]chat.Page sessionStates map[string]*service.SessionState @@ -818,7 +865,8 @@ func (m *appModel) update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.(type) { case messages.SpawnSessionMsg, messages.SwitchTabMsg, messages.CloseTabMsg, messages.ReorderTabMsg, - messages.ToggleSidebarMsg, messages.OpenSettingsDialogMsg: + messages.ToggleSidebarMsg, messages.OpenSettingsDialogMsg, + messages.ShowPlanBrowserMsg: return m, nil } } @@ -1044,7 +1092,7 @@ func (m *appModel) update(msg tea.Msg) (tea.Model, tea.Cmd) { // --- Dialog lifecycle --- - case dialog.OpenDialogMsg, dialog.CloseDialogMsg: + case dialog.OpenDialogMsg, dialog.CloseDialogMsg, dialog.ClosePlanDetailMsg: return m.forwardDialog(msg) case dialog.ExitConfirmedMsg: @@ -1117,6 +1165,12 @@ func (m *appModel) update(msg tea.Msg) (tea.Model, tea.Cmd) { m.sessionState.SetSessionTitle(msg.Title) return m.forwardChat(msg) + case *runtime.SessionPlanUpdatedEvent: + return m.handleSessionPlanUpdatedEvent(msg) + + case *runtime.PlanChangedEvent: + return m.handlePlanChangedEvent(msg) + // --- New session (slash command /new) --- case messages.NewSessionMsg: @@ -1263,6 +1317,64 @@ func (m *appModel) update(msg tea.Msg) (tea.Model, tea.Cmd) { case messages.ShowSkillsDialogMsg: return m.handleShowSkillsDialog() + // --- Plan browser (/plans) --- + + case messages.ShowPlanBrowserMsg: + return m.handleShowPlanBrowser() + + case messages.RefreshPlansMsg: + return m.handleRefreshPlans() + + case messages.OpenPlanDetailMsg: + return m.handleOpenPlanDetail(msg.Ref) + + case messages.ExportPlanMsg: + return m.handleExportPlan(msg.Ref) + + case messages.SetPlanStatusMsg: + return m.handleSetPlanStatus(msg) + + case messages.DeletePlanMsg: + return m.handleDeletePlan(msg) + + case messages.CreatePlanMsg: + return m.handleCreatePlan(msg.Name) + + case messages.EditPlanMsg: + return m.handleEditPlan(msg) + + case planEditorClosedMsg: + return m.handlePlanEditorClosed(msg) + + // Outcomes of the asynchronous plan persistence commands. + case planStatusResultMsg: + return m.handlePlanStatusResult(msg) + + case planDeleteResultMsg: + return m.handlePlanDeleteResult(msg) + + case planWriteResultMsg: + return m.handlePlanWriteResult(msg) + + // Outcomes of the asynchronous plan read commands. + case planBrowserLoadedMsg: + return m.handlePlanBrowserLoaded(msg) + + case planRefreshedMsg: + return m.handlePlanRefreshed(msg) + + case planDetailLoadedMsg: + return m.handlePlanDetailLoaded(msg) + + case planEditReadyMsg: + return m.handlePlanEditReady(msg) + + case planExportResultMsg: + return m.handlePlanExportResult(msg) + + case dialog.PlanBrowserDataMsg, dialog.PlanDetailDataMsg: + return m.forwardDialog(msg) + case messages.RestartToolsetMsg: return m.handleRestartToolset(msg.Name) @@ -1435,6 +1547,12 @@ func (m *appModel) handleRoutedMsg(msg messages.RoutedMsg) (tea.Model, tea.Cmd) updated, _ := chatPage.Update(msg.Inner) page := updated.(chat.Page) m.chatPages[msg.SessionID] = page + + // Shared plans are scope-global: a mutation from a background tab's agent + // must still live-refresh the plan dialogs open on the active tab. + if _, isPlanChange := msg.Inner.(*runtime.PlanChangedEvent); isPlanChange && m.planDialogOpen() { + return m, tea.Batch(page.TakeRoutedTimers(), m.planRefreshCmd(false)) + } return m, page.TakeRoutedTimers() }