Files
Enterprise-Knowledge-Base/cli/cmd/doc/upload_recursive.go
T
nullkey 4a5449233d fix(cli): plug v0.3 final review findings (json + auth + path + bounds + kb)
Seven bugs surfaced via two audit rounds — parallel reviewer agents
plus a real-server end-to-end demo. Each fix arrives with a
regression test.

1. doc upload --recursive --json corrupted the envelope stream.
   Per-file FAIL/OK plain lines printed unconditionally to stdout,
   then a Success envelope, then on partial failure a typed error
   that the root handler turned into a SECOND Failure envelope —
   three outputs where one was expected. Fix: gate the plain lines
   behind !opts.JSONOut, and add cmdutil.Error.Silent so the JSON-
   path partial-failure preserves its typed exit code without
   triggering PrintErrorEnvelope's default Failure-envelope write.

2. auth refresh / AuthRetryTransport misclassified HTTP failures as
   network.error. RefreshAndPersist wrapped every refresher error
   with CodeNetworkError, but the SDK emits "HTTP error 401: ..."
   for a rejected refresh token — which should surface as
   auth.token_expired. Switched to WrapHTTP for proper status-
   derived classification. Affects both `auth refresh` and the
   transport's refresh closure.

3. doc download accepted ".." as a server-suggested filename. The
   rejection list covered "" / "." / filepath.Separator but not
   bare ".." — filepath.Base("..") is "..", which slipped through
   to os.Create and produced a confusing local.file_io wrap. Added
   to the rejection set.

4. search chunks / docs / kb / sessions had no lower bound on
   --limit. `-L 0` / `-L -1` was forwarded to the server with
   undefined behavior. Added a 1..1000 bound at the RunE boundary
   across all four (matching doc list / session list page-size
   bounds). Internal callers in tests can still pass Limit==0 for
   the "no client-side cap" runChunks path — the bound only applies
   at the user-input layer.

5. cli/AGENTS.md ADR-3 verb-canon summary listed only v0.2 verbs as
   "gh-canonical" and missed v0.3 additions (edit, pin, unpin,
   download — all gh-canonical) plus locally-introduced ones
   (empty, refresh, add, remove, link). Rewritten as an explicit
   gh-canonical / locally-introduced split.

6. kb pin returned 404. Server registers /knowledge-bases/{id}/pin
   as PUT (router.go:292); SDK was using POST. gin's router silently
   404s on method-mismatch (treats it as path-not-found, not 405),
   so the CLI classified the response as resource.not_found and
   masked the real failure mode. Switched the SDK to http.MethodPut.

   The asymmetry that hid this past round 1: kb unpin on a freshly-
   created KB hits the no-op branch in cmd/kb/pin.go that skips the
   SDK call entirely, so unpin "worked" without ever exercising the
   broken path. Only the real-server demo, where kb pin actually
   fires, surfaced it.

7. kb edit clobbered current Name when only --description was
   passed. EditOptions used *string to distinguish "unset" from
   "set to empty", but sdk.UpdateKnowledgeBaseRequest declares both
   fields as plain string (no omitempty), so the JSON body always
   carried `"name": ""`. Server requires Name → 400. Fix: runEdit
   does fetch-then-update — GetKnowledgeBase first, build the PUT
   body with current values, then overlay user-set fields. Same
   TOCTOU window as kb pin / unpin.

Audit-flagged items intentionally NOT changed:
- kb pin / unpin check-then-toggle TOCTOU: documented; the clean
  fix would be a server-side setter and belongs in a separate API
  change.
- AuthRetryTransport singleflight test gap for one concurrency
  scenario; v0.4 polish.
- cli/README.md:50 "once v0.2 ships" and CHANGELOG.md:8
  "10 top-level commands": v0.2-PR artifacts, not v0.3-introduced.
- kb edit / kb pin are v0.3-new commands, so neither bug needs a
  cli/CHANGELOG.md Fixed entry — the v0.3 release ships them
  working as the Added bullets advertise.
2026-05-14 10:57:17 +08:00

183 lines
5.9 KiB
Go

package doc
import (
"context"
"fmt"
"io/fs"
"os"
"path/filepath"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/format"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
)
// uploadOutcome is one entry in the recursive upload's per-file report.
type uploadOutcome struct {
Path string `json:"path"`
ID string `json:"id,omitempty"`
Error string `json:"error,omitempty"`
}
// runUploadRecursive walks dir, filters by Glob, and uploads each match
// sequentially. Per-file errors do NOT abort the walk — they accumulate
// and the final return aggregates them so the user sees the full picture
// in one run. Exit semantics: nil error on full success, a typed *cmdutil.Error
// when ≥1 file failed (the typed code mirrors the first failure's
// classification so callers can still branch).
func runUploadRecursive(ctx context.Context, opts *UploadOptions, svc UploadService, kbID, dir string) error {
if opts.Name != "" {
return &cmdutil.Error{
Code: cmdutil.CodeInputInvalidArgument,
Message: "--name cannot be combined with --recursive (one name can't apply to N files)",
Hint: "drop --name or upload files one at a time",
}
}
info, err := os.Stat(dir)
if err != nil {
if os.IsNotExist(err) {
return cmdutil.Wrapf(cmdutil.CodeUploadFileNotFound, err, "directory not found: %s", dir)
}
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "stat %s", dir)
}
if !info.IsDir() {
return &cmdutil.Error{
Code: cmdutil.CodeInputInvalidArgument,
Message: fmt.Sprintf("not a directory: %s (drop --recursive to upload a single file)", dir),
}
}
// Sanity-check the pattern up front so a typo doesn't show up as "no
// files matched" per-file. Cobra populates --glob; tests pass it
// explicitly — no in-function default needed.
if _, err := filepath.Match(opts.Glob, ""); err != nil {
return &cmdutil.Error{
Code: cmdutil.CodeInputInvalidArgument,
Message: fmt.Sprintf("invalid --glob %q: %v", opts.Glob, err),
}
}
matches, err := walkMatches(dir, opts.Glob)
if err != nil {
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "walk %s", dir)
}
if len(matches) == 0 {
if opts.JSONOut {
return format.WriteEnvelope(iostreams.IO.Out, format.Success(
recursiveResult{KBID: kbID}, &format.Meta{KBID: kbID}))
}
fmt.Fprintf(iostreams.IO.Out, "(no files matched %q under %s)\n", opts.Glob, dir)
return nil
}
if opts.DryRun {
previews := make([]uploadOutcome, 0, len(matches))
for _, m := range matches {
previews = append(previews, uploadOutcome{Path: m})
}
return cmdutil.EmitDryRun(opts.JSONOut,
recursiveResult{KBID: kbID, Uploaded: previews},
&format.Meta{KBID: kbID},
&format.Risk{Level: format.RiskWrite, Action: fmt.Sprintf("upload %d file(s) to kb %s", len(matches), kbID)})
}
var uploaded, failed []uploadOutcome
var firstFailCode cmdutil.ErrorCode
for _, p := range matches {
k, err := svc.CreateKnowledgeFromFile(ctx, kbID, p, nil, nil, "", uploadChannel)
if err != nil {
code := cmdutil.ClassifyHTTPError(err)
if firstFailCode == "" {
firstFailCode = code
}
failed = append(failed, uploadOutcome{Path: p, Error: err.Error()})
// Per-file progress lines are human progress signal; suppress
// under --json so they don't precede the envelope on stdout.
if !opts.JSONOut {
fmt.Fprintf(iostreams.IO.Out, "FAIL %s: %v\n", filepath.Base(p), err)
}
continue
}
id := ""
if k != nil {
id = k.ID
}
uploaded = append(uploaded, uploadOutcome{Path: p, ID: id})
if !opts.JSONOut {
fmt.Fprintf(iostreams.IO.Out, "OK %s (id: %s)\n", filepath.Base(p), id)
}
}
if opts.JSONOut {
result := recursiveResult{KBID: kbID, Uploaded: uploaded, Failed: failed}
risk := &format.Risk{Level: format.RiskWrite, Action: fmt.Sprintf("upload %d file(s) to kb %s", len(matches), kbID)}
if err := format.WriteEnvelope(iostreams.IO.Out,
format.SuccessWithRisk(result, &format.Meta{KBID: kbID}, risk)); err != nil {
return err
}
} else {
fmt.Fprintf(iostreams.IO.Out, "Uploaded %d, Failed %d\n", len(uploaded), len(failed))
}
if len(failed) > 0 {
// Silent on the --json path: the success envelope above already
// carries per-file uploaded[]/failed[] detail. Without Silent the
// root error handler would write a second Failure envelope on
// stdout, corrupting the stream. ExitCode still walks Code so the
// typed exit-code-by-class contract is preserved.
return &cmdutil.Error{
Code: firstFailCode,
Message: fmt.Sprintf("%d of %d uploads failed", len(failed), len(matches)),
Silent: opts.JSONOut,
}
}
return nil
}
// recursiveResult is the JSON shape emitted under data when --recursive is
// combined with --json. Mirrors the human-mode per-file output: a list of
// successes (Uploaded) and a list of failures (Failed), each with the
// originating path so agents can re-try only the failed entries.
type recursiveResult struct {
KBID string `json:"kb_id"`
Uploaded []uploadOutcome `json:"uploaded,omitempty"`
Failed []uploadOutcome `json:"failed,omitempty"`
}
// walkMatches returns every regular file under root whose base name matches
// pattern. Order is filepath.WalkDir's lexical order (stdlib guarantee on
// every supported FS), which is deterministic for test assertions.
func walkMatches(root, pattern string) ([]string, error) {
var out []string
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
// Skip non-regular files (sockets, devices); the SDK can't upload
// them and they'd show as opaque server errors.
info, ierr := d.Info()
if ierr != nil {
return ierr
}
if !info.Mode().IsRegular() {
return nil
}
ok, merr := filepath.Match(pattern, d.Name())
if merr != nil {
return merr
}
if ok {
out = append(out, p)
}
return nil
})
if err != nil {
return nil, err
}
return out, nil
}