mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-09-01 14:53:07 +08:00
feat(cli): doc wait command (multi-target wait-all)
weknora doc wait <doc-id> [<doc-id>...] blocks until every given document reaches a terminal parse_status (completed / failed), --timeout expires, or the user interrupts (SIGINT). * --timeout DURATION (default 10m; exit 124 on timeout, matches GNU timeout(1) convention) * --interval DURATION (default 2s; exponential backoff to 15s + jitter) * Multi-id polled concurrently (max 5 parallel) * Exit code priority 1 > 124 > 0 (failed > timeout > completed) New typed errors: * operation.timeout → exit 124 * operation.failed → exit 1 * operation.cancelled → exit 1 (main raises to 130 on signal) server.session_create_failed gets a special case in ExitCode to map to exit 1 (workflow failure, not transient retry). doc view and doc download positional id namespaced to <doc-id>.
This commit is contained in:
@@ -205,6 +205,12 @@ func identToErrorCode(name string) (cmdutil.ErrorCode, bool) {
|
||||
return cmdutil.CodeMCPToolNotAllowed, true
|
||||
case "CodeMCPSchemaUnknown":
|
||||
return cmdutil.CodeMCPSchemaUnknown, true
|
||||
case "CodeOperationTimeout":
|
||||
return cmdutil.CodeOperationTimeout, true
|
||||
case "CodeOperationFailed":
|
||||
return cmdutil.CodeOperationFailed, true
|
||||
case "CodeOperationCancelled":
|
||||
return cmdutil.CodeOperationCancelled, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
// a stdin hook.
|
||||
// - auth_login.error_auth_unauthenticated - same setup as above.
|
||||
//
|
||||
// All cases use leaf-positioned --json (e.g. `version --json`). --json is a
|
||||
// All cases use leaf-positioned --format json (e.g. `version --format json`). --format is a
|
||||
// per-leaf flag, not a global persistent flag.
|
||||
package contract_test
|
||||
|
||||
@@ -70,14 +70,14 @@ var wireCases = []wireCase{
|
||||
// 1. version.success - pure local; no client touched.
|
||||
{
|
||||
name: "version.success",
|
||||
args: []string{"version", "--json"},
|
||||
args: []string{"version", "--format", "json"},
|
||||
},
|
||||
|
||||
// 2. doctor.success_offline - only credential_storage runs; the three
|
||||
// network checks are skipped. Stable details + summary.
|
||||
{
|
||||
name: "doctor.success_offline",
|
||||
args: []string{"doctor", "--offline", "--json"},
|
||||
args: []string{"doctor", "--offline", "--format", "json"},
|
||||
server: doctorReachable, // ensures buildServices succeeds even if probed
|
||||
},
|
||||
|
||||
@@ -88,7 +88,7 @@ var wireCases = []wireCase{
|
||||
// written by emit() as the only stdout content.
|
||||
{
|
||||
name: "doctor.error_network",
|
||||
args: []string{"doctor", "--json"},
|
||||
args: []string{"doctor", "--format", "json"},
|
||||
server: alwaysServerError,
|
||||
wantErr: true,
|
||||
},
|
||||
@@ -96,29 +96,29 @@ var wireCases = []wireCase{
|
||||
// 4-7. kb list / get - SDK paths /api/v1/knowledge-bases[/<id>]
|
||||
{
|
||||
name: "kb_list.success",
|
||||
args: []string{"kb", "list", "--json"},
|
||||
args: []string{"kb", "list", "--format", "json"},
|
||||
server: kbListTwo,
|
||||
},
|
||||
{
|
||||
name: "kb_list.success_empty",
|
||||
args: []string{"kb", "list", "--json"},
|
||||
args: []string{"kb", "list", "--format", "json"},
|
||||
server: kbListEmpty,
|
||||
},
|
||||
{
|
||||
name: "kb_list.error_auth_forbidden",
|
||||
args: []string{"kb", "list", "--json"},
|
||||
args: []string{"kb", "list", "--format", "json"},
|
||||
server: always403,
|
||||
wantErr: true,
|
||||
wantStderrSubstring: "auth.forbidden",
|
||||
},
|
||||
{
|
||||
name: "kb_view.success",
|
||||
args: []string{"kb", "view", "kb1", "--json"},
|
||||
args: []string{"kb", "view", "kb1", "--format", "json"},
|
||||
server: kbGetOne,
|
||||
},
|
||||
{
|
||||
name: "kb_view.error_resource_not_found",
|
||||
args: []string{"kb", "view", "missing", "--json"},
|
||||
args: []string{"kb", "view", "missing", "--format", "json"},
|
||||
server: always404,
|
||||
wantErr: true,
|
||||
wantStderrSubstring: "resource.not_found",
|
||||
@@ -127,7 +127,7 @@ var wireCases = []wireCase{
|
||||
// 8. context use - pure local I/O against config.yaml.
|
||||
{
|
||||
name: "context_use.success",
|
||||
args: []string{"context", "use", "production", "--json"},
|
||||
args: []string{"context", "use", "production", "--format", "json"},
|
||||
preConfig: func(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
CurrentContext: "staging",
|
||||
@@ -146,12 +146,12 @@ var wireCases = []wireCase{
|
||||
// 9-10. auth status - SDK /api/v1/auth/me, plus config inspection.
|
||||
{
|
||||
name: "auth_status.success",
|
||||
args: []string{"auth", "status", "--json"},
|
||||
args: []string{"auth", "status", "--format", "json"},
|
||||
server: whoamiOK,
|
||||
},
|
||||
{
|
||||
name: "auth_status.error_auth_unauthenticated",
|
||||
args: []string{"auth", "status", "--json"},
|
||||
args: []string{"auth", "status", "--format", "json"},
|
||||
server: always401,
|
||||
wantErr: true,
|
||||
wantStderrSubstring: "auth.unauthenticated",
|
||||
@@ -163,12 +163,12 @@ var wireCases = []wireCase{
|
||||
// either form interchangeably.
|
||||
{
|
||||
name: "search.success",
|
||||
args: []string{"search", "chunks", "query", "--kb=11111111-1111-4111-8111-111111111111", "--limit=3", "--json"},
|
||||
args: []string{"search", "chunks", "query", "--kb=11111111-1111-4111-8111-111111111111", "--limit=3", "--format", "json"},
|
||||
server: searchTwoResults,
|
||||
},
|
||||
{
|
||||
name: "search.error_resource_not_found",
|
||||
args: []string{"search", "chunks", "query", "--kb=eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", "--json"},
|
||||
args: []string{"search", "chunks", "query", "--kb=eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", "--format", "json"},
|
||||
server: always404,
|
||||
wantErr: true,
|
||||
wantStderrSubstring: "resource.not_found",
|
||||
@@ -178,7 +178,7 @@ var wireCases = []wireCase{
|
||||
// is just there to satisfy MarkFlagRequired so validation runs deep
|
||||
// enough to hit the mutex-channel check.
|
||||
name: "search.error_input_invalid",
|
||||
args: []string{"search", "chunks", "query", "--kb=11111111-1111-4111-8111-111111111111", "--no-vector", "--no-keyword", "--json"},
|
||||
args: []string{"search", "chunks", "query", "--kb=11111111-1111-4111-8111-111111111111", "--no-vector", "--no-keyword", "--format", "json"},
|
||||
wantErr: true,
|
||||
wantStderrSubstring: "input.invalid_argument",
|
||||
},
|
||||
|
||||
@@ -27,5 +27,6 @@ func NewCmd(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd.AddCommand(NewCmdUpload(f))
|
||||
cmd.AddCommand(NewCmdDownload(f))
|
||||
cmd.AddCommand(NewCmdDelete(f))
|
||||
cmd.AddCommand(NewCmdWait(f))
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ type DownloadService interface {
|
||||
func NewCmdDownload(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &DownloadOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "download <id>",
|
||||
Use: "download <doc-id>",
|
||||
Short: "Download a document by ID",
|
||||
Long: `Streams the document bytes to disk (or stdout with --output -).
|
||||
|
||||
|
||||
+10
-9
@@ -12,7 +12,7 @@ import (
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// docViewFields enumerates the fields surfaced for `--json` discovery on
|
||||
// docViewFields enumerates the fields surfaced for `--format json` discovery on
|
||||
// `doc view`. Lists the Knowledge struct top-level json tags.
|
||||
var docViewFields = []string{
|
||||
"id", "knowledge_base_id", "tag_id", "type", "title", "description",
|
||||
@@ -33,34 +33,35 @@ type ViewService interface {
|
||||
func NewCmdView(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &ViewOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "view <id>",
|
||||
Use: "view <doc-id>",
|
||||
Short: "Show a document by ID",
|
||||
Example: ` weknora doc view doc_abc
|
||||
weknora doc view doc_abc --json`,
|
||||
weknora doc view doc_abc --format json`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
jopts, err := cmdutil.CheckJSONFlags(c)
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runView(c.Context(), opts, jopts, cli, args[0])
|
||||
return runView(c.Context(), opts, fopts, cli, args[0])
|
||||
},
|
||||
}
|
||||
cmdutil.AddJSONFlags(cmd, docViewFields)
|
||||
cmdutil.AddFormatFlag(cmd, docViewFields...)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runView(ctx context.Context, opts *ViewOptions, jopts *cmdutil.JSONOptions, svc ViewService, id string) error {
|
||||
func runView(ctx context.Context, opts *ViewOptions, fopts *cmdutil.FormatOptions, svc ViewService, id string) error {
|
||||
doc, err := svc.GetKnowledge(ctx, id)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "get document %q", id)
|
||||
}
|
||||
if jopts.Enabled() {
|
||||
return jopts.Emit(iostreams.IO.Out, doc)
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, doc)
|
||||
}
|
||||
w := iostreams.IO.Out
|
||||
fmt.Fprintf(w, "ID: %s\n", doc.ID)
|
||||
|
||||
+14
-14
@@ -39,7 +39,7 @@ func TestView_Human_RendersExpectedFields(t *testing.T) {
|
||||
UpdatedAt: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC),
|
||||
ProcessedAt: &processed,
|
||||
}}
|
||||
if err := runView(context.Background(), &ViewOptions{}, nil, svc, "doc_abc"); err != nil {
|
||||
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "doc_abc"); err != nil {
|
||||
t.Fatalf("runView: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
@@ -59,7 +59,7 @@ func TestView_Human_RendersExpectedFields(t *testing.T) {
|
||||
func TestView_Human_TitleFallback(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeViewSvc{doc: &sdk.Knowledge{ID: "doc_url", Title: "Pasted article", FileName: ""}}
|
||||
if err := runView(context.Background(), &ViewOptions{}, nil, svc, "doc_url"); err != nil {
|
||||
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "doc_url"); err != nil {
|
||||
t.Fatalf("runView: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
@@ -77,7 +77,7 @@ func TestView_Human_OmitsEmptyFields(t *testing.T) {
|
||||
ID: "doc_abc",
|
||||
FileName: "x.txt",
|
||||
}}
|
||||
if err := runView(context.Background(), &ViewOptions{}, nil, svc, "doc_abc"); err != nil {
|
||||
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "doc_abc"); err != nil {
|
||||
t.Fatalf("runView: %v", err)
|
||||
}
|
||||
// Line-prefix match (not substring): "ERROR:" as a substring could
|
||||
@@ -97,7 +97,7 @@ func TestView_Human_OmitsEmptyFields(t *testing.T) {
|
||||
func TestView_JSON_BareObject(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeViewSvc{doc: &sdk.Knowledge{ID: "doc_abc", FileName: "x.txt", KnowledgeBaseID: "kb1"}}
|
||||
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.JSONOptions{}, svc, "doc_abc"); err != nil {
|
||||
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "doc_abc"); err != nil {
|
||||
t.Fatalf("runView: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
@@ -114,7 +114,7 @@ func TestView_JSON_BareObject(t *testing.T) {
|
||||
func TestView_NotFound_ClassifiedAs404(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeViewSvc{err: errors.New("HTTP error 404: not found")}
|
||||
err := runView(context.Background(), &ViewOptions{}, nil, svc, "missing")
|
||||
err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "missing")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
@@ -130,7 +130,7 @@ func TestView_Title_RendersWhenDifferentFromFileName(t *testing.T) {
|
||||
svc := &fakeViewSvc{doc: &sdk.Knowledge{
|
||||
ID: "doc_t", FileName: "raw.pdf", Title: "Quarterly Plan",
|
||||
}}
|
||||
if err := runView(context.Background(), &ViewOptions{}, nil, svc, "doc_t"); err != nil {
|
||||
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "doc_t"); err != nil {
|
||||
t.Fatalf("runView: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
@@ -146,7 +146,7 @@ func TestView_Title_OmittedWhenSameAsFileName(t *testing.T) {
|
||||
svc := &fakeViewSvc{doc: &sdk.Knowledge{
|
||||
ID: "doc_t", FileName: "policy.pdf", Title: "policy.pdf",
|
||||
}}
|
||||
if err := runView(context.Background(), &ViewOptions{}, nil, svc, "doc_t"); err != nil {
|
||||
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "doc_t"); err != nil {
|
||||
t.Fatalf("runView: %v", err)
|
||||
}
|
||||
for _, l := range strings.Split(out.String(), "\n") {
|
||||
@@ -159,7 +159,7 @@ func TestView_Title_OmittedWhenSameAsFileName(t *testing.T) {
|
||||
func TestView_Description(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeViewSvc{doc: &sdk.Knowledge{ID: "doc_d", FileName: "x.pdf", Description: "Annual review"}}
|
||||
if err := runView(context.Background(), &ViewOptions{}, nil, svc, "doc_d"); err != nil {
|
||||
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "doc_d"); err != nil {
|
||||
t.Fatalf("runView: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "Annual review") {
|
||||
@@ -172,7 +172,7 @@ func TestView_SourceAndChannel(t *testing.T) {
|
||||
svc := &fakeViewSvc{doc: &sdk.Knowledge{
|
||||
ID: "doc_s", FileName: "x.pdf", Source: "https://example.com/x", Channel: "web",
|
||||
}}
|
||||
if err := runView(context.Background(), &ViewOptions{}, nil, svc, "doc_s"); err != nil {
|
||||
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "doc_s"); err != nil {
|
||||
t.Fatalf("runView: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
@@ -190,7 +190,7 @@ func TestView_SummaryAndEnableStatus(t *testing.T) {
|
||||
SummaryStatus: "completed",
|
||||
EnableStatus: "disabled",
|
||||
}}
|
||||
if err := runView(context.Background(), &ViewOptions{}, nil, svc, "doc_st"); err != nil {
|
||||
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "doc_st"); err != nil {
|
||||
t.Fatalf("runView: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
@@ -204,7 +204,7 @@ func TestView_SummaryAndEnableStatus(t *testing.T) {
|
||||
func TestView_TagID(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeViewSvc{doc: &sdk.Knowledge{ID: "doc_t", FileName: "x.pdf", TagID: "tag_abc"}}
|
||||
if err := runView(context.Background(), &ViewOptions{}, nil, svc, "doc_t"); err != nil {
|
||||
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "doc_t"); err != nil {
|
||||
t.Fatalf("runView: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
@@ -216,7 +216,7 @@ func TestView_TagID(t *testing.T) {
|
||||
func TestView_StorageSize_Human(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeViewSvc{doc: &sdk.Knowledge{ID: "doc_sz", FileName: "x.pdf", StorageSize: 2 * 1024 * 1024}}
|
||||
if err := runView(context.Background(), &ViewOptions{}, nil, svc, "doc_sz"); err != nil {
|
||||
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "doc_sz"); err != nil {
|
||||
t.Fatalf("runView: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
@@ -232,7 +232,7 @@ func TestView_FileHash_Prefix12(t *testing.T) {
|
||||
FileName: "x.pdf",
|
||||
FileHash: "abcdef1234567890fedcba0987654321",
|
||||
}}
|
||||
if err := runView(context.Background(), &ViewOptions{}, nil, svc, "doc_h"); err != nil {
|
||||
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "doc_h"); err != nil {
|
||||
t.Fatalf("runView: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
@@ -249,7 +249,7 @@ func TestView_ErrorMessage_WarnPrefix(t *testing.T) {
|
||||
svc := &fakeViewSvc{doc: &sdk.Knowledge{
|
||||
ID: "doc_e", FileName: "x.pdf", ErrorMessage: "parser failed at offset 4096",
|
||||
}}
|
||||
if err := runView(context.Background(), &ViewOptions{}, nil, svc, "doc_e"); err != nil {
|
||||
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "doc_e"); err != nil {
|
||||
t.Fatalf("runView: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
)
|
||||
|
||||
// WaitOptions captures `doc wait` flag state.
|
||||
type WaitOptions struct {
|
||||
IDs []string
|
||||
Timeout time.Duration
|
||||
Interval time.Duration
|
||||
}
|
||||
|
||||
// NewCmdWait builds `weknora doc wait <id> [<id>...]`.
|
||||
//
|
||||
// Multi-id behaviour is always wait-all: blocks until every id reaches a
|
||||
// terminal state. Use shell composition
|
||||
// (`weknora doc wait id1 && weknora doc wait id2`) when fail-fast is
|
||||
// desired.
|
||||
func NewCmdWait(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &WaitOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "wait <doc-id> [<doc-id>...]",
|
||||
Short: "Wait for one or more documents to finish parsing",
|
||||
Long: `Block until every given document reaches a terminal parse_status
|
||||
(completed or failed), the timeout expires, or the user interrupts (Ctrl-C).
|
||||
Always wait-all: every id must reach a terminal state before returning.
|
||||
|
||||
Exit codes:
|
||||
0 all completed
|
||||
1 any failed
|
||||
124 --timeout reached (matches GNU 'timeout' command)
|
||||
130 Ctrl-C / SIGINT
|
||||
|
||||
Multi-id is polled concurrently (max 5 parallel; use 'xargs -P' for more).
|
||||
For fail-fast semantics, use shell composition:
|
||||
weknora doc wait id1 && weknora doc wait id2 && weknora doc wait id3`,
|
||||
Example: ` weknora doc wait doc_abc
|
||||
weknora doc wait id1 id2 id3 --timeout 20m
|
||||
weknora doc wait id1 id2 --format ndjson`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
// Validate flags FIRST so an invalid --format doesn't cost the
|
||||
// user a multi-minute poll before erroring out.
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
|
||||
opts.IDs = args
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := waitForDocs(c.Context(), opts.IDs, cli, *opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := emitWaitResult(res, fopts, iostreams.IO.Out); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch res.ExitCode() {
|
||||
case 0:
|
||||
return nil
|
||||
case 1:
|
||||
return cmdutil.NewError(cmdutil.CodeOperationFailed, fmt.Sprintf("%d doc(s) failed", len(res.Failed)))
|
||||
case 124:
|
||||
return cmdutil.NewError(cmdutil.CodeOperationTimeout, fmt.Sprintf("wait timed out (%d doc(s) still pending)", len(res.Timeout)))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().DurationVar(&opts.Timeout, "timeout", 10*time.Minute, "Max wait time before exiting 124")
|
||||
cmd.Flags().DurationVar(&opts.Interval, "interval", 2*time.Second, "Initial poll interval; exponential backoff capped at 15s + jitter")
|
||||
cmdutil.AddFormatFlag(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core poll loop (B2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// WaitService is the narrow SDK surface needed for polling.
|
||||
type WaitService interface {
|
||||
GetKnowledge(ctx context.Context, id string) (*sdk.Knowledge, error)
|
||||
}
|
||||
|
||||
// WaitResult is the terminal-state partition returned by waitForDocs.
|
||||
type WaitResult struct {
|
||||
Completed []string `json:"completed"`
|
||||
Failed []FailedDoc `json:"failed,omitempty"`
|
||||
Timeout []string `json:"timeout,omitempty"`
|
||||
}
|
||||
|
||||
// FailedDoc carries the id + reason for a doc that reached parse_status=failed
|
||||
// or that GetKnowledge returned an error for.
|
||||
type FailedDoc struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
maxConcurrentPolls = 5
|
||||
maxBackoffInterval = 15 * time.Second
|
||||
jitterMax = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
// waitForDocs polls each id until terminal state or timeout. Concurrency is
|
||||
// bounded by maxConcurrentPolls. Returns the partitioned terminal state.
|
||||
// Always waits for every id (wait-all semantics).
|
||||
//
|
||||
// Exponential backoff starts at opts.Interval, doubles each tick, caps at
|
||||
// maxBackoffInterval, with up to jitterMax random jitter added per sleep.
|
||||
func waitForDocs(ctx context.Context, ids []string, svc WaitService, opts WaitOptions) (*WaitResult, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, opts.Timeout)
|
||||
defer cancel()
|
||||
|
||||
result := &WaitResult{}
|
||||
var mu sync.Mutex
|
||||
addCompleted := func(id string) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
result.Completed = append(result.Completed, id)
|
||||
}
|
||||
addFailed := func(fd FailedDoc) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
result.Failed = append(result.Failed, fd)
|
||||
}
|
||||
addTimeout := func(id string) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
result.Timeout = append(result.Timeout, id)
|
||||
}
|
||||
|
||||
sem := make(chan struct{}, maxConcurrentPolls)
|
||||
var wg sync.WaitGroup
|
||||
for _, id := range ids {
|
||||
wg.Add(1)
|
||||
go func(id string) {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
|
||||
interval := opts.Interval
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
addTimeout(id)
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
doc, err := svc.GetKnowledge(ctx, id)
|
||||
if err != nil {
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
addTimeout(id)
|
||||
return
|
||||
}
|
||||
addFailed(FailedDoc{ID: id, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
switch doc.ParseStatus {
|
||||
case "completed":
|
||||
addCompleted(id)
|
||||
return
|
||||
case "failed":
|
||||
addFailed(FailedDoc{ID: id, Message: doc.ErrorMessage})
|
||||
return
|
||||
}
|
||||
|
||||
// Not yet terminal — sleep with jitter, then exp-backoff.
|
||||
jitter := time.Duration(rand.Int63n(int64(jitterMax)))
|
||||
timer := time.NewTimer(interval + jitter)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
addTimeout(id)
|
||||
return
|
||||
case <-timer.C:
|
||||
}
|
||||
interval *= 2
|
||||
if interval > maxBackoffInterval {
|
||||
interval = maxBackoffInterval
|
||||
}
|
||||
}
|
||||
}(id)
|
||||
}
|
||||
wg.Wait()
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ExitCode resolves the compound terminal state to a Unix exit code per
|
||||
// spec §3.2: priority 1 > 124 > 0 (failed > timeout > completed). SIGINT
|
||||
// (exit 130) is handled by the Go runtime / context cancellation, not here.
|
||||
func (r *WaitResult) ExitCode() int {
|
||||
if len(r.Failed) > 0 {
|
||||
return 1
|
||||
}
|
||||
if len(r.Timeout) > 0 {
|
||||
return 124
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Compile-time assertion: *sdk.Client satisfies WaitService.
|
||||
var _ WaitService = (*sdk.Client)(nil)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// B5: output rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// emitWaitResult renders r according to --format. Output writer is
|
||||
// parametrized for tests; production callers pass iostreams.IO.Out.
|
||||
func emitWaitResult(r *WaitResult, fopts *cmdutil.FormatOptions, w io.Writer) error {
|
||||
switch fopts.Mode {
|
||||
case cmdutil.FormatJSON, cmdutil.FormatNDJSON:
|
||||
return fopts.Emit(w, r)
|
||||
case cmdutil.FormatText, "":
|
||||
return writeWaitText(w, r)
|
||||
default:
|
||||
return fmt.Errorf("unsupported --format %q for doc wait", fopts.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
// writeWaitText renders r as human-readable lines:
|
||||
//
|
||||
// ✓ <id> completed
|
||||
// ✗ <id> failed: <message>
|
||||
// ⏱ <id> timeout
|
||||
func writeWaitText(w io.Writer, r *WaitResult) error {
|
||||
for _, id := range r.Completed {
|
||||
if _, err := fmt.Fprintf(w, "✓ %s completed\n", id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, fd := range r.Failed {
|
||||
msg := fd.Message
|
||||
if msg == "" {
|
||||
msg = "(no message)"
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "✗ %s failed: %s\n", fd.ID, msg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, id := range r.Timeout {
|
||||
if _, err := fmt.Fprintf(w, "⏱ %s timeout\n", id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
package doc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
func TestWaitCmd_Shape(t *testing.T) {
|
||||
cmd := NewCmdWait(&cmdutil.Factory{})
|
||||
if !strings.Contains(cmd.Use, "<doc-id>") {
|
||||
t.Errorf("Use=%q missing <doc-id>", cmd.Use)
|
||||
}
|
||||
if f := cmd.Flags().Lookup("timeout"); f == nil {
|
||||
t.Error("missing --timeout flag")
|
||||
}
|
||||
if f := cmd.Flags().Lookup("interval"); f == nil {
|
||||
t.Error("missing --interval flag")
|
||||
}
|
||||
// wait is always wait-all: no fail-fast option. --keep-going is
|
||||
// intentionally absent; users who want fail-fast compose with shell
|
||||
// short-circuiting (`wait id1 && wait id2`).
|
||||
if f := cmd.Flags().Lookup("keep-going"); f != nil {
|
||||
t.Error("--keep-going should not be registered; wait is always wait-all")
|
||||
}
|
||||
if f := cmd.Flags().Lookup("format"); f == nil {
|
||||
t.Error("missing --format flag (should be registered per-command, Method D)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitCmd_DefaultFlagValues(t *testing.T) {
|
||||
cmd := NewCmdWait(&cmdutil.Factory{})
|
||||
if err := cmd.ParseFlags(nil); err != nil {
|
||||
t.Fatalf("ParseFlags: %v", err)
|
||||
}
|
||||
tf, _ := cmd.Flags().GetDuration("timeout")
|
||||
if tf != 10*time.Minute {
|
||||
t.Errorf("--timeout default = %v, want 10m", tf)
|
||||
}
|
||||
in, _ := cmd.Flags().GetDuration("interval")
|
||||
if in != 2*time.Second {
|
||||
t.Errorf("--interval default = %v, want 2s", in)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// B2: waitForDocs tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type fakeKnowledgeSvc struct {
|
||||
mu sync.Mutex
|
||||
calls map[string]int // id → call count
|
||||
sequence map[string][]string // id → parse_status sequence to return
|
||||
}
|
||||
|
||||
func newFakeKBSvc(seq map[string][]string) *fakeKnowledgeSvc {
|
||||
return &fakeKnowledgeSvc{
|
||||
calls: make(map[string]int),
|
||||
sequence: seq,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeKnowledgeSvc) GetKnowledge(_ context.Context, id string) (*sdk.Knowledge, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
idx := f.calls[id]
|
||||
f.calls[id]++
|
||||
seq, ok := f.sequence[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected id %q", id)
|
||||
}
|
||||
if idx >= len(seq) {
|
||||
idx = len(seq) - 1
|
||||
}
|
||||
status := seq[idx]
|
||||
k := &sdk.Knowledge{ID: id, ParseStatus: status}
|
||||
if status == "failed" {
|
||||
k.ErrorMessage = "parse error"
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
|
||||
func TestWaitForDocs_SingleIDCompletes(t *testing.T) {
|
||||
svc := newFakeKBSvc(map[string][]string{
|
||||
"doc_x": {"processing", "processing", "completed"},
|
||||
})
|
||||
res, err := waitForDocs(context.Background(), []string{"doc_x"}, svc, WaitOptions{
|
||||
Timeout: 5 * time.Second,
|
||||
Interval: 1 * time.Millisecond, // fast for tests
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("waitForDocs: %v", err)
|
||||
}
|
||||
if len(res.Completed) != 1 || res.Completed[0] != "doc_x" {
|
||||
t.Errorf("Completed=%v, want [doc_x]", res.Completed)
|
||||
}
|
||||
if svc.calls["doc_x"] < 3 {
|
||||
t.Errorf("expected at least 3 polls, got %d", svc.calls["doc_x"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForDocs_SingleIDFails(t *testing.T) {
|
||||
svc := newFakeKBSvc(map[string][]string{
|
||||
"doc_x": {"failed"},
|
||||
})
|
||||
res, _ := waitForDocs(context.Background(), []string{"doc_x"}, svc, WaitOptions{
|
||||
Timeout: 5 * time.Second,
|
||||
Interval: 1 * time.Millisecond,
|
||||
})
|
||||
if len(res.Failed) != 1 || res.Failed[0].ID != "doc_x" {
|
||||
t.Errorf("Failed=%v, want [doc_x]", res.Failed)
|
||||
}
|
||||
if res.Failed[0].Message != "parse error" {
|
||||
t.Errorf("Failed[0].Message=%q, want %q", res.Failed[0].Message, "parse error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForDocs_Timeout(t *testing.T) {
|
||||
svc := newFakeKBSvc(map[string][]string{
|
||||
"doc_x": {"processing"}, // never completes
|
||||
})
|
||||
res, _ := waitForDocs(context.Background(), []string{"doc_x"}, svc, WaitOptions{
|
||||
Timeout: 20 * time.Millisecond, // tight
|
||||
Interval: 5 * time.Millisecond,
|
||||
})
|
||||
if len(res.Timeout) != 1 || res.Timeout[0] != "doc_x" {
|
||||
t.Errorf("Timeout=%v, want [doc_x]", res.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// B3: multi-id wait-all behavior
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestWaitForDocs_MultiID_AllSucceed(t *testing.T) {
|
||||
svc := newFakeKBSvc(map[string][]string{
|
||||
"a": {"processing", "completed"},
|
||||
"b": {"completed"},
|
||||
"c": {"processing", "processing", "completed"},
|
||||
})
|
||||
res, err := waitForDocs(context.Background(), []string{"a", "b", "c"}, svc, WaitOptions{
|
||||
Timeout: 5 * time.Second,
|
||||
Interval: 1 * time.Millisecond,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("waitForDocs: %v", err)
|
||||
}
|
||||
if len(res.Completed) != 3 {
|
||||
t.Errorf("Completed=%v (len %d), want 3", res.Completed, len(res.Completed))
|
||||
}
|
||||
if len(res.Failed) != 0 || len(res.Timeout) != 0 {
|
||||
t.Errorf("Failed=%v Timeout=%v, want both empty", res.Failed, res.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWaitForDocs_MultiID_WaitAllOnPartialFailure verifies wait-all
|
||||
// semantics: even when one id fails terminally, the remaining ids are
|
||||
// still waited on until they reach their own terminal state.
|
||||
func TestWaitForDocs_MultiID_WaitAllOnPartialFailure(t *testing.T) {
|
||||
svc := newFakeKBSvc(map[string][]string{
|
||||
"a": {"failed"},
|
||||
"b": {"processing", "completed"},
|
||||
"c": {"completed"},
|
||||
})
|
||||
res, _ := waitForDocs(context.Background(), []string{"a", "b", "c"}, svc, WaitOptions{
|
||||
Timeout: 5 * time.Second,
|
||||
Interval: 1 * time.Millisecond,
|
||||
})
|
||||
if len(res.Failed) != 1 || res.Failed[0].ID != "a" {
|
||||
t.Errorf("Failed=%v, want [{a, parse error}]", res.Failed)
|
||||
}
|
||||
completedSet := make(map[string]bool)
|
||||
for _, id := range res.Completed {
|
||||
completedSet[id] = true
|
||||
}
|
||||
if !completedSet["b"] || !completedSet["c"] {
|
||||
t.Errorf("Completed=%v, want both b and c present", res.Completed)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// B5: emitWaitResult rendering tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestEmitWaitResult_TextHumanSummary(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatText}
|
||||
res := &WaitResult{
|
||||
Completed: []string{"a", "b"},
|
||||
Failed: []FailedDoc{{ID: "c", Message: "parse boom"}},
|
||||
Timeout: []string{"d"},
|
||||
}
|
||||
if err := emitWaitResult(res, fopts, &buf); err != nil {
|
||||
t.Fatalf("emitWaitResult: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
for _, want := range []string{"a", "b", "c", "d", "parse boom"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("text output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitWaitResult_JSONSingleRecord(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}
|
||||
res := &WaitResult{
|
||||
Completed: []string{"a"},
|
||||
Failed: []FailedDoc{{ID: "b", Message: "x"}},
|
||||
}
|
||||
if err := emitWaitResult(res, fopts, &buf); err != nil {
|
||||
t.Fatalf("emitWaitResult: %v", err)
|
||||
}
|
||||
var got WaitResult
|
||||
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
|
||||
t.Fatalf("not JSON: %v\n%s", err, buf.String())
|
||||
}
|
||||
if len(got.Completed) != 1 || got.Completed[0] != "a" {
|
||||
t.Errorf("Completed=%v, want [a]", got.Completed)
|
||||
}
|
||||
if len(got.Failed) != 1 || got.Failed[0].ID != "b" {
|
||||
t.Errorf("Failed=%v, want [{b, x}]", got.Failed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitWaitResult_NDJSONSingleLine(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatNDJSON}
|
||||
res := &WaitResult{Completed: []string{"a"}}
|
||||
if err := emitWaitResult(res, fopts, &buf); err != nil {
|
||||
t.Fatalf("emitWaitResult: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
// NDJSON: single record = single line ending with \n
|
||||
if strings.Count(out, "\n") != 1 {
|
||||
t.Errorf("expected exactly 1 newline, got %q", out)
|
||||
}
|
||||
var got WaitResult
|
||||
if err := json.Unmarshal([]byte(strings.TrimRight(out, "\n")), &got); err != nil {
|
||||
t.Fatalf("line not JSON: %v\n%s", err, out)
|
||||
}
|
||||
if len(got.Completed) != 1 {
|
||||
t.Error("expected Completed populated")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// B4: WaitResult.ExitCode tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestWaitResult_ExitCode(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
res *WaitResult
|
||||
want int
|
||||
}{
|
||||
{"all completed", &WaitResult{Completed: []string{"a"}}, 0},
|
||||
{"empty result", &WaitResult{}, 0},
|
||||
{"any failed", &WaitResult{Failed: []FailedDoc{{ID: "a"}}}, 1},
|
||||
{"timeout only", &WaitResult{Timeout: []string{"a"}}, 124},
|
||||
{"failed wins over timeout", &WaitResult{
|
||||
Failed: []FailedDoc{{ID: "a"}},
|
||||
Timeout: []string{"b"},
|
||||
}, 1},
|
||||
{"failed wins over completed+timeout", &WaitResult{
|
||||
Completed: []string{"a"},
|
||||
Failed: []FailedDoc{{ID: "b"}},
|
||||
Timeout: []string{"c"},
|
||||
}, 1},
|
||||
{"timeout wins over completed", &WaitResult{
|
||||
Completed: []string{"a"},
|
||||
Timeout: []string{"b"},
|
||||
}, 124},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := tc.res.ExitCode(); got != tc.want {
|
||||
t.Errorf("ExitCode = %d, want %d", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -32,10 +32,10 @@ const (
|
||||
CodeInputInvalidArgument ErrorCode = "input.invalid_argument"
|
||||
CodeInputMissingFlag ErrorCode = "input.missing_flag"
|
||||
// CodeInputConfirmationRequired marks a high-risk write that has no
|
||||
// interactive UI (non-TTY or --json) and was invoked without -y/--yes.
|
||||
// Mapped to exit code 10 (see cli/README.md). Agents must surface the
|
||||
// error to the user and only retry with -y after explicit human
|
||||
// approval; never auto-retry.
|
||||
// interactive UI (non-TTY or JSON-output mode) and was invoked without
|
||||
// -y/--yes. Mapped to exit code 10 (see cli/README.md). Agents must
|
||||
// surface the error to the user and only retry with -y after explicit
|
||||
// human approval; never auto-retry.
|
||||
CodeInputConfirmationRequired ErrorCode = "input.confirmation_required"
|
||||
|
||||
// server.* / network.*
|
||||
@@ -49,6 +49,24 @@ const (
|
||||
// server.error so agents can retry with their own --session.
|
||||
CodeSessionCreateFailed ErrorCode = "server.session_create_failed"
|
||||
|
||||
// operation.* - CLI-level wait/poll results
|
||||
// CodeOperationTimeout marks a CLI-level wait/poll operation that exhausted
|
||||
// its --timeout window. Distinct from CodeServerTimeout (HTTP 504). Mapped
|
||||
// to exit 124 (matches the convention from GNU `timeout`).
|
||||
CodeOperationTimeout ErrorCode = "operation.timeout"
|
||||
// CodeOperationFailed marks a CLI-level wait/poll operation where one or
|
||||
// more targets reached a terminal failure (e.g. doc wait found a doc with
|
||||
// parse_status=failed). Distinct from server.* / network.* because the
|
||||
// failure is the target's own terminal state, not a transient transport
|
||||
// issue. Maps to exit 1 via the fall-through bucket.
|
||||
CodeOperationFailed ErrorCode = "operation.failed"
|
||||
// CodeOperationCancelled marks a long-running command interrupted by a
|
||||
// caught signal (SIGINT / SIGTERM after main.go's signal.NotifyContext
|
||||
// fires). Distinct from CodeUserAborted (declined confirm prompt) — the
|
||||
// hints differ. main.go overrides the exit code to 130 for cancelled
|
||||
// contexts so the user-visible exit follows Unix signal convention.
|
||||
CodeOperationCancelled ErrorCode = "operation.cancelled"
|
||||
|
||||
// local.* - config / file / keychain on the user's machine
|
||||
CodeLocalConfigCorrupt ErrorCode = "local.config_corrupt"
|
||||
CodeLocalKeychainDenied ErrorCode = "local.keychain_denied"
|
||||
@@ -265,6 +283,8 @@ func AllCodes() []ErrorCode {
|
||||
CodeProjectLinkCorrupt,
|
||||
CodeUserAborted, CodeUploadFileNotFound,
|
||||
CodeSSEStreamAborted, CodeSessionCreateFailed,
|
||||
// operation
|
||||
CodeOperationTimeout, CodeOperationFailed, CodeOperationCancelled,
|
||||
// mcp
|
||||
CodeMCPReadonlyMode, CodeMCPToolNotAllowed, CodeMCPSchemaUnknown,
|
||||
}
|
||||
|
||||
@@ -8,11 +8,10 @@ import (
|
||||
|
||||
// ExitCode maps an error to the documented CLI exit code.
|
||||
// - 0 success
|
||||
// - 1 generic / unknown typed error - fallback for: resource.already_exists,
|
||||
// resource.locked, local.* (config_corrupt / keychain_denied / file_io /
|
||||
// context_not_found / kb_id_required / kb_not_found / projectlink_corrupt /
|
||||
// user_aborted / upload_file_not_found), mcp.*, server.session_create_failed,
|
||||
// sse.stream_aborted, and any code outside the named buckets below
|
||||
// - 1 generic / unknown typed error - fallback bucket: resource.already_exists,
|
||||
// resource.locked, local.*, mcp.*, operation.failed, server.session_create_failed
|
||||
// (workflow-level, see special case below), and any code outside the named
|
||||
// buckets below
|
||||
// - 2 flag / argument problem (cobra parse / unknown subcommand)
|
||||
// - 3 auth.*
|
||||
// - 4 resource.not_found
|
||||
@@ -21,6 +20,8 @@ import (
|
||||
// - 7 server.* (other than rate_limited/session_create_failed) / network.*
|
||||
// - 10 input.confirmation_required - high-risk write needs explicit -y
|
||||
// (see cli/README.md)
|
||||
// - 124 operation.timeout - CLI-level wait/poll exhausted its --timeout window
|
||||
// (matches the convention from GNU `timeout`)
|
||||
// - 130 SIGINT (handled by Go runtime, not this function)
|
||||
func ExitCode(err error) int {
|
||||
if err == nil {
|
||||
@@ -48,9 +49,18 @@ func ExitCode(err error) int {
|
||||
if matchCode(err, CodeServerRateLimited) {
|
||||
return 6
|
||||
}
|
||||
// server.session_create_failed is a workflow-level failure (the hint
|
||||
// asks the caller to pass --session, not to retry with backoff), so it
|
||||
// falls through to exit 1 rather than the server.* transient bucket.
|
||||
if matchCode(err, CodeSessionCreateFailed) {
|
||||
return 1
|
||||
}
|
||||
if matchPrefix(err, "server.") || matchPrefix(err, "network.") {
|
||||
return 7
|
||||
}
|
||||
if matchCode(err, CodeOperationTimeout) {
|
||||
return 124
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -122,9 +132,13 @@ func defaultHint(code ErrorCode) string {
|
||||
case CodeUploadFileNotFound:
|
||||
return "verify the path is correct and readable"
|
||||
case CodeSSEStreamAborted:
|
||||
return "the streaming answer was cut off mid-flight; retry, or pass --no-stream to buffer the full response"
|
||||
return "the streaming answer was cut off mid-flight; retry, or pass --format json to buffer the full response"
|
||||
case CodeSessionCreateFailed:
|
||||
return "could not create a chat session; pass --session to reuse an existing session"
|
||||
case CodeOperationTimeout:
|
||||
return "wait timed out; raise --timeout or check the underlying job"
|
||||
case CodeOperationCancelled:
|
||||
return "operation cancelled by signal (Ctrl-C / SIGTERM)"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -28,6 +28,10 @@ func TestExitCode(t *testing.T) {
|
||||
{"network.* prefix", NewError(CodeNetworkError, "x"), 7},
|
||||
{"unknown error", errors.New("plain"), 1},
|
||||
{"local.* prefix", NewError(CodeLocalConfigCorrupt, "x"), 1},
|
||||
{"operation.timeout", NewError(CodeOperationTimeout, "timed out"), 124},
|
||||
{"operation.failed → 1 (fall-through bucket)", NewError(CodeOperationFailed, "failed"), 1},
|
||||
{"operation.cancelled → 1 (main overrides to 130 on signal-cancelled ctx)", NewError(CodeOperationCancelled, "cancelled"), 1},
|
||||
{"server.session_create_failed → 1 (workflow, not transient)", NewError(CodeSessionCreateFailed, "x"), 1},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user