Files
larksuite__cli/shortcuts/drive/drive_move_common.go
evandance 98173ae5a9 feat(drive): emit typed error envelopes across the drive domain (#1205)
Drive-domain errors now leave the CLI as typed, machine-branchable
envelopes — a stable `type` plus `subtype` and named fields (param,
params, retryable, log_id, hint) — so scripts and AI agents can branch on
structure and act on a recovery hint instead of parsing prose.

Changes:
- Every error produced in the drive domain — validation, file I/O, and the
  failures returned from its Lark API calls — is emitted as a typed errs.*
  error; the exit code is derived from the error category. Drive's API calls
  now go through a shared typed classifier, so failures carry subtype,
  troubleshooter, a recovery hint, and the request's log_id whether the
  server returns it in the response body or the x-tt-logid header; an
  already-typed network/auth error is never downgraded into a generic API
  error.
- Known API conditions (resource conflict, cross-tenant, cross-brand, ...)
  carry a recovery hint keyed by their error class; a command can refine
  that hint with command-specific guidance.
- Batch partial failures (+push / +pull / +sync, where some items succeed
  and some fail) now report an honest ok:false multi-status result on
  stdout — the summary and every per-item outcome stay machine-readable —
  and exit non-zero, instead of a misleading ok:true success envelope.
- Duplicate rel_path conflicts report each colliding path as a structured
  params entry (RFC 7807 invalid-params style).
- Static guards lock the drive path so legacy error construction — direct
  envelopes or the auto-classifying API helpers — cannot be reintroduced,
  making drive the template for the remaining domains.

Output changes worth noting for consumers:
- Error envelopes now carry typed type/subtype and named fields; exit
  codes follow the error category (malformed or incomplete API responses
  are reported as internal errors rather than generic API errors).
- Batch partial failures (+push / +pull / +sync) emit an ok:false result
  envelope on stdout (summary + per-item items[]) and exit non-zero; the
  per-item results stay on stdout rather than in a stderr error envelope.

Errors surfaced through shared cross-domain helpers (scope precheck, media
import upload, metadata lookup, save-path resolution) are not yet typed;
they migrate with the shared layer in a follow-up change.
2026-06-03 10:27:15 +08:00

176 lines
5.5 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"fmt"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var (
driveTaskCheckPollAttempts = 30
driveTaskCheckPollInterval = 2 * time.Second
)
// driveMoveAllowedTypes mirrors the document kinds accepted by the Drive move
// endpoint that this shortcut wraps.
var driveMoveAllowedTypes = map[string]bool{
"file": true,
"docx": true,
"bitable": true,
"doc": true,
"sheet": true,
"mindnote": true,
"folder": true,
"slides": true,
}
// driveMoveSpec contains the normalized input needed to issue a move request.
type driveMoveSpec struct {
FileToken string
FileType string
FolderToken string
}
func (s driveMoveSpec) RequestBody() map[string]interface{} {
return map[string]interface{}{
"type": s.FileType,
"folder_token": s.FolderToken,
}
}
func validateDriveMoveSpec(spec driveMoveSpec) error {
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
}
if strings.TrimSpace(spec.FolderToken) != "" {
if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token")
}
}
if !driveMoveAllowedTypes[spec.FileType] {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported file type: %s. Supported types: file, docx, bitable, doc, sheet, mindnote, folder, slides", spec.FileType).WithParam("--type")
}
return nil
}
// driveTaskCheckStatus represents the status payload returned by
// /drive/v1/files/task_check for async folder move/delete operations.
type driveTaskCheckStatus struct {
TaskID string
Status string
}
func (s driveTaskCheckStatus) Ready() bool {
return strings.EqualFold(strings.TrimSpace(s.Status), "success")
}
func (s driveTaskCheckStatus) Failed() bool {
status := strings.TrimSpace(s.Status)
// The shared task_check endpoint is reused by multiple async flows. Some
// backends return "failed", while folder delete can return the shorter
// terminal state "fail".
return strings.EqualFold(status, "failed") || strings.EqualFold(status, "fail")
}
func (s driveTaskCheckStatus) Pending() bool {
return !s.Ready() && !s.Failed()
}
func (s driveTaskCheckStatus) StatusLabel() string {
status := strings.TrimSpace(s.Status)
if status == "" {
// Empty status is treated as unknown so callers can still render a
// meaningful label instead of an empty string.
return "unknown"
}
return status
}
// driveTaskCheckResultCommand prints the resume command shown when bounded
// polling ends before the backend task completes.
func driveTaskCheckResultCommand(taskID, as string) string {
return fmt.Sprintf("lark-cli drive +task_result --scenario task_check --task-id %s --as %s", taskID, as)
}
// driveTaskCheckParams keeps the task_check query parameter shape in one place
// for both dry-run and execution paths.
func driveTaskCheckParams(taskID string) map[string]interface{} {
return map[string]interface{}{"task_id": taskID}
}
// getDriveTaskCheckStatus fetches and validates the current state of an async
// folder move or delete task.
func getDriveTaskCheckStatus(runtime *common.RuntimeContext, taskID string) (driveTaskCheckStatus, error) {
if err := validate.ResourceName(taskID, "--task-id"); err != nil {
return driveTaskCheckStatus{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--task-id")
}
data, err := runtime.CallAPITyped("GET", "/open-apis/drive/v1/files/task_check", driveTaskCheckParams(taskID), nil)
if err != nil {
return driveTaskCheckStatus{}, err
}
return parseDriveTaskCheckStatus(taskID, data), nil
}
// parseDriveTaskCheckStatus tolerates both wrapped and already-unwrapped
// response shapes used in tests and helpers.
func parseDriveTaskCheckStatus(taskID string, data map[string]interface{}) driveTaskCheckStatus {
result := common.GetMap(data, "result")
if result == nil {
result = data
}
return driveTaskCheckStatus{
TaskID: taskID,
Status: common.GetString(result, "status"),
}
}
// pollDriveTaskCheck polls the shared task_check endpoint for a bounded period
// and returns the last seen status so callers can emit a follow-up command
// when needed.
func pollDriveTaskCheck(runtime *common.RuntimeContext, taskID string) (driveTaskCheckStatus, bool, error) {
lastStatus := driveTaskCheckStatus{TaskID: taskID}
var (
seenStatus bool
lastErr error
)
for attempt := 1; attempt <= driveTaskCheckPollAttempts; attempt++ {
if attempt > 1 {
time.Sleep(driveTaskCheckPollInterval)
}
status, err := getDriveTaskCheckStatus(runtime, taskID)
if err != nil {
lastErr = err
fmt.Fprintf(runtime.IO().ErrOut, "Error polling task %s: %s\n", taskID, err)
continue
}
seenStatus = true
lastStatus = status
// Success and failure are terminal backend states. Any other value is kept
// as pending so the caller can decide whether to continue or resume later.
if status.Ready() {
fmt.Fprintf(runtime.IO().ErrOut, "Folder task completed successfully.\n")
return status, true, nil
}
if status.Failed() {
return status, false, errs.NewAPIError(errs.SubtypeServerError, "folder task failed")
}
}
if !seenStatus && lastErr != nil {
return driveTaskCheckStatus{}, false, lastErr
}
return lastStatus, false, nil
}