@pippit-dev/cli 1.0.52 → 1.0.54
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/checksums.txt +6 -6
- package/cmd/generate_video/query_result.go +5 -1
- package/cmd/generate_video_test.go +138 -93
- package/cmd/query_result_branches_test.go +265 -0
- package/cmd/testdata/query-result/recorded-failure.json +6 -0
- package/dist/checksums.txt +6 -6
- package/internal/common/get_thread.go +9 -5
- package/internal/common/get_thread_test.go +36 -0
- package/internal/generate_video/query_result.go +115 -59
- package/internal/generate_video/query_result_branches_test.go +63 -0
- package/package.json +1 -1
- package/skills/xyq-nest-skill/commands/canvas-assets.md +16 -0
- package/skills/xyq-nest-skill/commands/query-result.md +12 -2
- package/skills/xyq-nest-skill/scripts/ensure-cli.js +18 -1
- package/skills/xyq-nest-skill/scripts/install.md +4 -2
- package/skills/xyq-nest-skill/workflows/async-delivery.md +3 -3
- package/skills/xyq-nest-skill/workflows/canvas-edit.md +3 -1
package/README.md
CHANGED
|
@@ -466,7 +466,11 @@ pippit-tool-cli query-result \
|
|
|
466
466
|
--download-dir "./output"
|
|
467
467
|
```
|
|
468
468
|
|
|
469
|
-
`query-result`
|
|
469
|
+
`query-result` 查询指定 Run 并输出 JSON,成功后下载视频和图片。`status` 支持 `running`、`succeeded`、`failed`、`canceled`、`input_required`、`interrupted`、`unknown`。`completed` 仅表示终态:成功、失败、取消和人工交互中断结束(state=9)为 true;等待补充输入(state=6)为 `input_required`、false,需要停止自动轮询并等待交互。程序中断/等待工具回调(state=8)仍为 `running`。
|
|
470
|
+
|
|
471
|
+
判断优先级:无论查询接口 `ret` 为多少,只要能解析指定 Run,就使用其 `state`;失败原因优先读取 `fail_reason.message/code`。没有目标 Run 时,仅按此接口已核实的返回识别终态:`ret=5` 且 `errmsg` 为 `创作失败` 或以 `创作失败:` 开头,返回 `failed`、true;`ret=5` 且 `errmsg` 为 `创作已取消`,返回 `canceled`、true。其他查询错误(包括 `ret=5` 的 `获取会话信息失败`、`ret=1/2`、鉴权/团队权限、网络及解析错误)返回 `unknown`、false 并保留错误与可用的 LogID,不推断任务失败。当前接口的 `ret=2` 是参数错误,不代表安审失败。未指定或未知 Run 状态保持 `unknown`。已解析 Run 的非零接口返回也记录到本地错误日志。
|
|
472
|
+
|
|
473
|
+
Run 已成功但产物缺失或下载失败时,保留 `succeeded`、true,在 `error_message` 中报告错误,并保留此前已成功下载的 `videos` / `images`。媒体项包含 `download_url` 和 `output_path`;图片扩展名取自 `metadata.format`,缺省为 `.png`。完整交付仍须检查错误及所有目标文件,不能只看 `completed=true`。
|
|
470
474
|
|
|
471
475
|
## Adapter 接入边界
|
|
472
476
|
|
package/checksums.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
3027db71d4e1b89ccaaa50f4660fb41a90e578bf7c08f0ea1c28367a597371a1 pippit-tool-cli-1.0.54-darwin-amd64.tar.gz
|
|
2
|
+
b6a932ae33085d565e88c21a39b446dcb21cf148b6fb66d5a5baa040896d98ae pippit-tool-cli-1.0.54-darwin-arm64.tar.gz
|
|
3
|
+
aabce0e36f96a1dcc29c24a277e85859e938f6eddc4ed74a0536df3e4e4bce23 pippit-tool-cli-1.0.54-linux-amd64.tar.gz
|
|
4
|
+
beb74396a2d82e05f7199b05cdd7850b148748b18483f9ccf24c4ec09d573968 pippit-tool-cli-1.0.54-linux-arm64.tar.gz
|
|
5
|
+
c84ccf75b7c3474f2623f0563c4bf1193a2c84b6f8d0d6ee853ac9c00913ee9c pippit-tool-cli-1.0.54-windows-amd64.zip
|
|
6
|
+
8bd9e6abcde7060fb8b1fac9cb4ea9729cc88b73b27dc9d775581306738a56ba pippit-tool-cli-1.0.54-windows-arm64.zip
|
|
@@ -26,7 +26,11 @@ func NewQueryResultCommand(stdout, stderr io.Writer, runner *common.Runner) *cob
|
|
|
26
26
|
"run_id": strings.TrimSpace(opts.RunID),
|
|
27
27
|
"download_dir": strings.TrimSpace(opts.DownloadDir),
|
|
28
28
|
})
|
|
29
|
-
result
|
|
29
|
+
if result == nil {
|
|
30
|
+
result = internalgen.NewQueryResultError(err, opts)
|
|
31
|
+
} else if result.ErrorMessage == "" {
|
|
32
|
+
result.ErrorMessage = err.Error()
|
|
33
|
+
}
|
|
30
34
|
}
|
|
31
35
|
encoder := json.NewEncoder(stdout)
|
|
32
36
|
encoder.SetIndent("", " ")
|
|
@@ -434,98 +434,103 @@ func TestGenerateVideoErrorLogIncludesLogID(t *testing.T) {
|
|
|
434
434
|
}
|
|
435
435
|
|
|
436
436
|
func TestQueryResultDownloadsCompletedVideo(t *testing.T) {
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
437
|
+
for _, ret := range []string{"0", "1", "2", "5", "1015", "16019", "987654"} {
|
|
438
|
+
t.Run("ret="+ret, func(t *testing.T) {
|
|
439
|
+
var requestedDownload bool
|
|
440
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
441
|
+
if r.Header.Get("Authorization") != "Bearer test-token" {
|
|
442
|
+
t.Fatalf("Authorization = %q, want test bearer token", r.Header.Get("Authorization"))
|
|
443
|
+
}
|
|
444
|
+
switch r.URL.Path {
|
|
445
|
+
case "/api/biz/v1/skill/get_thread":
|
|
446
|
+
if r.Method != http.MethodPost {
|
|
447
|
+
t.Fatalf("get_thread method = %s, want POST", r.Method)
|
|
448
|
+
}
|
|
449
|
+
data, err := io.ReadAll(r.Body)
|
|
450
|
+
if err != nil {
|
|
451
|
+
t.Fatalf("read body: %v", err)
|
|
452
|
+
}
|
|
453
|
+
var body map[string]any
|
|
454
|
+
if err := sonic.Unmarshal(data, &body); err != nil {
|
|
455
|
+
t.Fatalf("decode body: %v", err)
|
|
456
|
+
}
|
|
457
|
+
if body["thread_id"] != "thread_123" {
|
|
458
|
+
t.Fatalf("thread_id = %v, want thread_123", body["thread_id"])
|
|
459
|
+
}
|
|
460
|
+
if body["run_id"] != "run_456" {
|
|
461
|
+
t.Fatalf("run_id = %v, want run_456", body["run_id"])
|
|
462
|
+
}
|
|
463
|
+
if _, ok := body["version"]; ok {
|
|
464
|
+
t.Fatalf("version = %v, want omitted", body["version"])
|
|
465
|
+
}
|
|
466
|
+
_, _ = w.Write([]byte(`{"ret":"` + ret + `","errmsg":"","data":{"thread":{"thread_id":"thread_123","run_list":[{"run_id":"run_456","state":3,"entry_list":[{"artifact":{"content":[{"sub_type":"biz/x_data_prompt_text","data":"做个小猫视频"},{"sub_type":"biz/x_data_video","data":"{\"video\":{\"download_url\":\"` + serverURL(r) + `/video.mp4\",\"title\":\"cat_video\",\"vid\":\"cat_vid\"}}"}]}}]}]}}}`))
|
|
467
|
+
case "/video.mp4":
|
|
468
|
+
requestedDownload = true
|
|
469
|
+
if r.Method != http.MethodGet {
|
|
470
|
+
t.Fatalf("download method = %s, want GET", r.Method)
|
|
471
|
+
}
|
|
472
|
+
_, _ = w.Write([]byte("video-data"))
|
|
473
|
+
default:
|
|
474
|
+
t.Fatalf("unexpected path %s", r.URL.Path)
|
|
475
|
+
}
|
|
476
|
+
}))
|
|
477
|
+
defer server.Close()
|
|
478
|
+
|
|
479
|
+
downloadDir := filepath.Join(t.TempDir(), "downloads")
|
|
480
|
+
var stdout, stderr bytes.Buffer
|
|
481
|
+
root := newTestRootCommand(t, &stdout, &stderr, server.URL)
|
|
482
|
+
root.SetArgs([]string{
|
|
483
|
+
"query-result",
|
|
484
|
+
"--thread-id", "thread_123",
|
|
485
|
+
"--run-id", "run_456",
|
|
486
|
+
"--download-dir", downloadDir,
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
if err := root.Execute(); err != nil {
|
|
490
|
+
t.Fatalf("Execute() error = %v, stderr = %s", err, stderr.String())
|
|
446
491
|
}
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
492
|
+
|
|
493
|
+
outputPath := filepath.Join(downloadDir, "cat_vid.mp4")
|
|
494
|
+
downloadURL := server.URL + "/video.mp4"
|
|
495
|
+
if !requestedDownload {
|
|
496
|
+
t.Fatal("download endpoint was not requested")
|
|
450
497
|
}
|
|
451
|
-
|
|
452
|
-
if
|
|
453
|
-
t.Fatalf("
|
|
498
|
+
got := decodeJSON(t, stdout.Bytes())
|
|
499
|
+
if got["completed"] != true {
|
|
500
|
+
t.Fatalf("completed = %v, want true", got["completed"])
|
|
454
501
|
}
|
|
455
|
-
if
|
|
456
|
-
t.Fatalf("
|
|
502
|
+
if got["status"] != "succeeded" {
|
|
503
|
+
t.Fatalf("status = %v, want succeeded", got["status"])
|
|
457
504
|
}
|
|
458
|
-
if
|
|
459
|
-
t.Fatalf("
|
|
505
|
+
if got["thread_id"] != "thread_123" || got["run_id"] != "run_456" {
|
|
506
|
+
t.Fatalf("ids = (%v, %v), want thread/run ids", got["thread_id"], got["run_id"])
|
|
460
507
|
}
|
|
461
|
-
if _, ok :=
|
|
462
|
-
t.Fatalf("
|
|
508
|
+
if _, ok := got["state"]; ok {
|
|
509
|
+
t.Fatalf("state should not be returned: %#v", got)
|
|
463
510
|
}
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
requestedDownload = true
|
|
467
|
-
if r.Method != http.MethodGet {
|
|
468
|
-
t.Fatalf("download method = %s, want GET", r.Method)
|
|
511
|
+
if got["error_message"] != "" {
|
|
512
|
+
t.Fatalf("error_message = %v, want empty", got["error_message"])
|
|
469
513
|
}
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
if err := root.Execute(); err != nil {
|
|
488
|
-
t.Fatalf("Execute() error = %v, stderr = %s", err, stderr.String())
|
|
489
|
-
}
|
|
514
|
+
videos, ok := got["videos"].([]any)
|
|
515
|
+
if !ok || len(videos) != 1 {
|
|
516
|
+
t.Fatalf("videos = %#v, want one video", got["videos"])
|
|
517
|
+
}
|
|
518
|
+
video, ok := videos[0].(map[string]any)
|
|
519
|
+
if !ok {
|
|
520
|
+
t.Fatalf("video = %#v, want object", videos[0])
|
|
521
|
+
}
|
|
522
|
+
if video["download_url"] != downloadURL || video["output_path"] != outputPath {
|
|
523
|
+
t.Fatalf("video = %#v, want download_url/output_path", video)
|
|
524
|
+
}
|
|
525
|
+
for _, unwanted := range []string{"vid", "asset_id", "title", "draft", "draft_task_id"} {
|
|
526
|
+
if _, ok := video[unwanted]; ok {
|
|
527
|
+
t.Fatalf("video = %#v, should not contain %s", video, unwanted)
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
assertFileContent(t, outputPath, "video-data")
|
|
490
531
|
|
|
491
|
-
|
|
492
|
-
downloadURL := server.URL + "/video.mp4"
|
|
493
|
-
if !requestedDownload {
|
|
494
|
-
t.Fatal("download endpoint was not requested")
|
|
495
|
-
}
|
|
496
|
-
got := decodeJSON(t, stdout.Bytes())
|
|
497
|
-
if got["completed"] != true {
|
|
498
|
-
t.Fatalf("completed = %v, want true", got["completed"])
|
|
499
|
-
}
|
|
500
|
-
if got["status"] != "succeeded" {
|
|
501
|
-
t.Fatalf("status = %v, want succeeded", got["status"])
|
|
502
|
-
}
|
|
503
|
-
if got["thread_id"] != "thread_123" || got["run_id"] != "run_456" {
|
|
504
|
-
t.Fatalf("ids = (%v, %v), want thread/run ids", got["thread_id"], got["run_id"])
|
|
505
|
-
}
|
|
506
|
-
if _, ok := got["state"]; ok {
|
|
507
|
-
t.Fatalf("state should not be returned: %#v", got)
|
|
508
|
-
}
|
|
509
|
-
if got["error_message"] != "" {
|
|
510
|
-
t.Fatalf("error_message = %v, want empty", got["error_message"])
|
|
511
|
-
}
|
|
512
|
-
videos, ok := got["videos"].([]any)
|
|
513
|
-
if !ok || len(videos) != 1 {
|
|
514
|
-
t.Fatalf("videos = %#v, want one video", got["videos"])
|
|
515
|
-
}
|
|
516
|
-
video, ok := videos[0].(map[string]any)
|
|
517
|
-
if !ok {
|
|
518
|
-
t.Fatalf("video = %#v, want object", videos[0])
|
|
519
|
-
}
|
|
520
|
-
if video["download_url"] != downloadURL || video["output_path"] != outputPath {
|
|
521
|
-
t.Fatalf("video = %#v, want download_url/output_path", video)
|
|
522
|
-
}
|
|
523
|
-
for _, unwanted := range []string{"vid", "asset_id", "title", "draft", "draft_task_id"} {
|
|
524
|
-
if _, ok := video[unwanted]; ok {
|
|
525
|
-
t.Fatalf("video = %#v, should not contain %s", video, unwanted)
|
|
526
|
-
}
|
|
532
|
+
})
|
|
527
533
|
}
|
|
528
|
-
assertFileContent(t, outputPath, "video-data")
|
|
529
534
|
}
|
|
530
535
|
|
|
531
536
|
func TestQueryResultPreservesDraftMetadata(t *testing.T) {
|
|
@@ -601,11 +606,11 @@ func TestQueryResultIgnoresVideoDataWithoutVideoSubType(t *testing.T) {
|
|
|
601
606
|
t.Fatalf("Execute() error = %v, stderr = %s", err, stderr.String())
|
|
602
607
|
}
|
|
603
608
|
got := decodeJSON(t, stdout.Bytes())
|
|
604
|
-
if got["completed"] !=
|
|
605
|
-
t.Fatalf("completed = %v, want
|
|
609
|
+
if got["completed"] != true {
|
|
610
|
+
t.Fatalf("completed = %v, want true for a succeeded Run", got["completed"])
|
|
606
611
|
}
|
|
607
|
-
if got["status"] != "
|
|
608
|
-
t.Fatalf("status = %v, want
|
|
612
|
+
if got["status"] != "succeeded" {
|
|
613
|
+
t.Fatalf("status = %v, want succeeded despite missing downloadable artifacts", got["status"])
|
|
609
614
|
}
|
|
610
615
|
if got["thread_id"] != "thread_123" || got["run_id"] != "run_456" {
|
|
611
616
|
t.Fatalf("ids = (%v, %v), want thread/run ids", got["thread_id"], got["run_id"])
|
|
@@ -674,14 +679,14 @@ func TestQueryResultGetThreadBusinessErrorReturnsErrorMessage(t *testing.T) {
|
|
|
674
679
|
t.Fatalf("Execute() error = %v, stderr = %s", err, stderr.String())
|
|
675
680
|
}
|
|
676
681
|
got := decodeJSON(t, stdout.Bytes())
|
|
677
|
-
if got["completed"] !=
|
|
678
|
-
t.Fatalf("completed = %v, want
|
|
682
|
+
if got["completed"] != true {
|
|
683
|
+
t.Fatalf("completed = %v, want true", got["completed"])
|
|
679
684
|
}
|
|
680
685
|
if got["thread_id"] != "thread_123" || got["run_id"] != "run_456" {
|
|
681
686
|
t.Fatalf("ids = (%v, %v), want thread/run ids", got["thread_id"], got["run_id"])
|
|
682
687
|
}
|
|
683
|
-
if got["status"] != "
|
|
684
|
-
t.Fatalf("status = %v, want
|
|
688
|
+
if got["status"] != "failed" {
|
|
689
|
+
t.Fatalf("status = %v, want failed", got["status"])
|
|
685
690
|
}
|
|
686
691
|
if got["error_message"] != "创作失败:暂时无法生成 log_id=log_456" {
|
|
687
692
|
t.Fatalf("error_message = %v, want get_thread business error", got["error_message"])
|
|
@@ -692,6 +697,46 @@ func TestQueryResultGetThreadBusinessErrorReturnsErrorMessage(t *testing.T) {
|
|
|
692
697
|
}
|
|
693
698
|
}
|
|
694
699
|
|
|
700
|
+
func TestQueryResultUsesRunDespiteBusinessError(t *testing.T) {
|
|
701
|
+
for _, tt := range []struct {
|
|
702
|
+
name, data, status, message string
|
|
703
|
+
completed bool
|
|
704
|
+
}{
|
|
705
|
+
{"structured failure", `{"thread":{"thread_id":"thread_123","run_list":[{"run_id":"run_456","state":4,"error_message":"Run 失败"}]}}`, "failed", "Run 失败", true},
|
|
706
|
+
{"envelope fallback", `{"thread":{"thread_id":"thread_123","run_list":[{"run_id":"run_456","state":4}]}}`, "failed", "模拟接口错误 log_id=fixture-log", true},
|
|
707
|
+
{"succeeded without artifacts", `{"thread":{"thread_id":"thread_123","run_list":[{"run_id":"run_456","state":3}]}}`, "succeeded", "下载失败:未找到可下载的产物", true},
|
|
708
|
+
{"canceled", `{"thread":{"thread_id":"thread_123","run_list":[{"run_id":"run_456","state":5}]}}`, "canceled", "", true},
|
|
709
|
+
{"running", `{"thread":{"thread_id":"thread_123","run_list":[{"run_id":"run_456","state":2}]}}`, "running", "", false},
|
|
710
|
+
{"different run", `{"thread":{"thread_id":"thread_123","run_list":[{"run_id":"other","state":4}]}}`, "unknown", "模拟接口错误 log_id=fixture-log", false},
|
|
711
|
+
{"missing thread", `{}`, "unknown", "模拟接口错误 log_id=fixture-log", false},
|
|
712
|
+
{"null data", `null`, "unknown", "模拟接口错误 log_id=fixture-log", false},
|
|
713
|
+
{"malformed run", `{"thread":{"run_list":"invalid"}}`, "unknown", "模拟接口错误 log_id=fixture-log", false},
|
|
714
|
+
} {
|
|
715
|
+
t.Run(tt.name, func(t *testing.T) {
|
|
716
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
717
|
+
if r.URL.Path != "/api/biz/v1/skill/get_thread" {
|
|
718
|
+
t.Fatalf("unexpected path %s", r.URL.Path)
|
|
719
|
+
}
|
|
720
|
+
fmt.Fprintf(w, `{"ret":"2","errmsg":"模拟接口错误","log_id":"fixture-log","data":%s}`, tt.data)
|
|
721
|
+
}))
|
|
722
|
+
defer server.Close()
|
|
723
|
+
var stdout, stderr bytes.Buffer
|
|
724
|
+
root := newTestRootCommand(t, &stdout, &stderr, server.URL)
|
|
725
|
+
root.SetArgs([]string{"query-result", "--thread-id", "thread_123", "--run-id", "run_456", "--download-dir", t.TempDir()})
|
|
726
|
+
if err := root.Execute(); err != nil {
|
|
727
|
+
t.Fatalf("Execute() error = %v, stderr = %s", err, stderr.String())
|
|
728
|
+
}
|
|
729
|
+
got := decodeJSON(t, stdout.Bytes())
|
|
730
|
+
if got["status"] != tt.status || got["completed"] != tt.completed || got["error_message"] != tt.message {
|
|
731
|
+
t.Fatalf("output = %#v, want status=%s completed=%v error_message=%q", got, tt.status, tt.completed, tt.message)
|
|
732
|
+
}
|
|
733
|
+
if got["thread_id"] != "thread_123" || got["run_id"] != "run_456" {
|
|
734
|
+
t.Fatalf("output = %#v, want requested thread/run IDs", got)
|
|
735
|
+
}
|
|
736
|
+
})
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
695
740
|
func TestQueryResultFailedReturnsErrorMessage(t *testing.T) {
|
|
696
741
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
697
742
|
switch r.URL.Path {
|
|
@@ -789,10 +834,10 @@ func TestQueryResultRunStateMapping(t *testing.T) {
|
|
|
789
834
|
{2, "running", false},
|
|
790
835
|
{4, "failed", true},
|
|
791
836
|
{5, "canceled", true},
|
|
792
|
-
{6, "
|
|
837
|
+
{6, "input_required", false},
|
|
793
838
|
{7, "running", false},
|
|
794
839
|
{8, "running", false},
|
|
795
|
-
{9, "
|
|
840
|
+
{9, "interrupted", true},
|
|
796
841
|
{99, "unknown", false},
|
|
797
842
|
}
|
|
798
843
|
for _, tt := range tests {
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
package cmd
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"fmt"
|
|
7
|
+
"net/http"
|
|
8
|
+
"net/http/httptest"
|
|
9
|
+
"os"
|
|
10
|
+
"path/filepath"
|
|
11
|
+
"strings"
|
|
12
|
+
"testing"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
// This matrix follows the legacy skill query endpoint, including its middleware.
|
|
16
|
+
// Business error envelopes deliberately omit Run, as the real handler does.
|
|
17
|
+
func TestQueryResultResponseBranches(t *testing.T) {
|
|
18
|
+
tests := []struct {
|
|
19
|
+
name, ret, message, status string
|
|
20
|
+
httpStatus int
|
|
21
|
+
}{
|
|
22
|
+
{"empty thread id", "2", "thread id不能为空", "unknown", 200},
|
|
23
|
+
{"invalid version", "2", "version参数无效", "unknown", 200},
|
|
24
|
+
{"empty run id", "2", "run id不能为空", "unknown", 200},
|
|
25
|
+
{"invalid AK owner info", "2", "无法根据此Ak找到有效用户信息", "unknown", 200},
|
|
26
|
+
{"invalid OAuth user info", "2", "无法获取有效用户信息", "unknown", 200},
|
|
27
|
+
{"RPC or DB error", "5", "获取会话信息失败", "unknown", 200},
|
|
28
|
+
{"no runs", "1", "会话中缺少有效的Run", "unknown", 200},
|
|
29
|
+
{"run not found", "2", "会话中未找到RunID为run_456的Run", "unknown", 200},
|
|
30
|
+
{"run copy failed", "1", "解析Run信息失败", "unknown", 200},
|
|
31
|
+
{"failed without reason", "5", "创作失败", "failed", 200},
|
|
32
|
+
{"failed with reason", "5", "创作失败:视频可能不合规,请修改后重试", "failed", 200},
|
|
33
|
+
{"failed with empty reason", "5", "创作失败:", "failed", 200},
|
|
34
|
+
{"canceled", "5", "创作已取消", "canceled", 200},
|
|
35
|
+
{"bad bearer or AK", "1015", "invalid access key", "unknown", 401},
|
|
36
|
+
{"bad owner", "1015", "invalid access key owner", "unknown", 401},
|
|
37
|
+
{"bad team id", "1015", "invalid team id", "unknown", 401},
|
|
38
|
+
{"team login missing", "1015", "login required for team access", "unknown", 401},
|
|
39
|
+
{"permission RPC failure", "1015", "team permission check failed", "unknown", 401},
|
|
40
|
+
{"permission RPC rejection", "1015", "team permission denied", "unknown", 401},
|
|
41
|
+
{"team denied", "16019", "team permission denied", "unknown", 200},
|
|
42
|
+
{"member inactive", "16020", "team member is inactive", "unknown", 200},
|
|
43
|
+
{"role denied", "16021", "team role is not allowed", "unknown", 200},
|
|
44
|
+
{"team missing", "16022", "team not found", "unknown", 200},
|
|
45
|
+
{"team unavailable", "16023", "team is unavailable", "unknown", 200},
|
|
46
|
+
{"subscription inactive", "16024", "team subscription is inactive", "unknown", 200},
|
|
47
|
+
{"member limit", "16025", "team member limit exceeded", "unknown", 200},
|
|
48
|
+
{"product identity invalid", "2", "missing or unsupported business app id", "unknown", 400},
|
|
49
|
+
{"product identity unavailable", "5", "business app identity unavailable", "unknown", 503},
|
|
50
|
+
{"request binding error", "-3", "bad request", "unknown", 200},
|
|
51
|
+
{"panic", "-2", "system panic", "unknown", 500},
|
|
52
|
+
{"unknown future ret", "987654", "new server error", "unknown", 200},
|
|
53
|
+
{"wrong ret with failure phrase", "2", "创作失败:原因", "unknown", 200},
|
|
54
|
+
{"wrong ret with cancellation phrase", "1", "创作已取消", "unknown", 200},
|
|
55
|
+
{"unrecognized ret with failure phrase", "987654", "创作失败", "unknown", 200},
|
|
56
|
+
{"zero ret without run", "0", "创作失败", "unknown", 200},
|
|
57
|
+
{"contains failure word", "5", "查询创作失败记录失败", "unknown", 200},
|
|
58
|
+
{"only moderation wording", "5", "视频可能不合规", "unknown", 200},
|
|
59
|
+
{"wrong failure delimiter", "5", "创作失败: reason", "unknown", 200},
|
|
60
|
+
{"cancellation suffix", "5", "创作已取消查询失败", "unknown", 200},
|
|
61
|
+
}
|
|
62
|
+
for _, tt := range tests {
|
|
63
|
+
t.Run(tt.name, func(t *testing.T) {
|
|
64
|
+
body, err := json.Marshal(map[string]any{"ret": tt.ret, "errmsg": tt.message, "log_id": "branch-log", "data": nil})
|
|
65
|
+
if err != nil {
|
|
66
|
+
t.Fatal(err)
|
|
67
|
+
}
|
|
68
|
+
got := executeQueryResponse(t, tt.httpStatus, string(body))
|
|
69
|
+
completed := tt.status == "failed" || tt.status == "canceled"
|
|
70
|
+
if got["status"] != tt.status || got["completed"] != completed {
|
|
71
|
+
t.Fatalf("got %#v, want %s completed=%t", got, tt.status, completed)
|
|
72
|
+
}
|
|
73
|
+
message, _ := got["error_message"].(string)
|
|
74
|
+
if tt.ret != "0" && (!strings.Contains(message, tt.message) || !strings.Contains(message, "branch-log")) {
|
|
75
|
+
t.Fatalf("original error/LogID lost: %q", message)
|
|
76
|
+
}
|
|
77
|
+
if message == "" {
|
|
78
|
+
t.Fatalf("missing error: %#v", got)
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
func executeQueryResponse(t *testing.T, status int, body string) map[string]any {
|
|
85
|
+
t.Helper()
|
|
86
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
87
|
+
if r.URL.Path != "/api/biz/v1/skill/get_thread" {
|
|
88
|
+
t.Errorf("unexpected download/request: %s", r.URL.Path)
|
|
89
|
+
}
|
|
90
|
+
w.WriteHeader(status)
|
|
91
|
+
_, _ = w.Write([]byte(body))
|
|
92
|
+
}))
|
|
93
|
+
defer server.Close()
|
|
94
|
+
return executeQueryURL(t, server.URL, t.TempDir())
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
func executeQueryURL(t *testing.T, url, dir string) map[string]any {
|
|
98
|
+
t.Helper()
|
|
99
|
+
var stdout, stderr bytes.Buffer
|
|
100
|
+
root := newTestRootCommand(t, &stdout, &stderr, url)
|
|
101
|
+
root.SetArgs([]string{"query-result", "--thread-id", "thread_123", "--run-id", "run_456", "--download-dir", dir})
|
|
102
|
+
if err := root.Execute(); err != nil {
|
|
103
|
+
t.Fatalf("Execute: %v, stderr=%s", err, stderr.String())
|
|
104
|
+
}
|
|
105
|
+
got := decodeJSON(t, stdout.Bytes())
|
|
106
|
+
if got["thread_id"] != "thread_123" || got["run_id"] != "run_456" {
|
|
107
|
+
t.Fatalf("requested IDs lost: %#v", got)
|
|
108
|
+
}
|
|
109
|
+
for _, key := range []string{"videos", "images"} {
|
|
110
|
+
if _, ok := got[key].([]any); !ok {
|
|
111
|
+
t.Fatalf("%s is not an array: %#v", key, got)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return got
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
func TestQueryResultRecordedFailureResponse(t *testing.T) {
|
|
118
|
+
// Recorded production response; only diagnostic identifiers are sanitized.
|
|
119
|
+
data, err := os.ReadFile("testdata/query-result/recorded-failure.json")
|
|
120
|
+
if err != nil {
|
|
121
|
+
t.Fatal(err)
|
|
122
|
+
}
|
|
123
|
+
got := executeQueryResponse(t, 200, string(data))
|
|
124
|
+
if got["status"] != "failed" || got["completed"] != true || got["error_message"] != "创作失败:视频可能不合规,请修改后重试 log_id=recorded-failure-log" {
|
|
125
|
+
t.Fatalf("recorded failure was not recognized: %#v", got)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
func TestQueryResultRunPrecedesEveryBusinessError(t *testing.T) {
|
|
130
|
+
states := []struct {
|
|
131
|
+
state int
|
|
132
|
+
status string
|
|
133
|
+
completed bool
|
|
134
|
+
}{
|
|
135
|
+
{0, "unknown", false}, {1, "running", false}, {2, "running", false},
|
|
136
|
+
{3, "succeeded", true}, {4, "failed", true}, {5, "canceled", true},
|
|
137
|
+
{6, "input_required", false}, {7, "running", false}, {8, "running", false},
|
|
138
|
+
{9, "interrupted", true}, {99, "unknown", false},
|
|
139
|
+
}
|
|
140
|
+
// These are compatibility envelopes, not assertions that the server currently
|
|
141
|
+
// returns a structured Run for every error. The recorded fixture covers that.
|
|
142
|
+
for _, ret := range []string{"0", "1", "2", "5", "1015", "16019", "16020", "16021", "16022", "16023", "16024", "16025", "-2", "-3", "987654"} {
|
|
143
|
+
for _, state := range states {
|
|
144
|
+
t.Run(fmt.Sprintf("ret=%s/state=%d", ret, state.state), func(t *testing.T) {
|
|
145
|
+
clearDailyErrorLog(t)
|
|
146
|
+
got := executeQueryResponse(t, 200, fmt.Sprintf(`{"ret":%q,"errmsg":"创作失败:envelope reason","log_id":"matrix-log","data":{"thread":{"run_list":[{"run_id":"other","state":4},{"run_id":"run_456","state":%d,"fail_reason":{"message":"Run reason","code":12023}}]}}}`, ret, state.state))
|
|
147
|
+
if got["status"] != state.status || got["completed"] != state.completed {
|
|
148
|
+
t.Fatalf("got %#v, want %#v", got, state)
|
|
149
|
+
}
|
|
150
|
+
if state.state == 4 && got["error_message"] != "Run reason (error_code=12023)" {
|
|
151
|
+
t.Fatalf("lost run reason: %#v", got)
|
|
152
|
+
}
|
|
153
|
+
if state.state != 3 && state.state != 4 && got["error_message"] != "" {
|
|
154
|
+
t.Fatalf("envelope overwrote Run: %#v", got)
|
|
155
|
+
}
|
|
156
|
+
if ret != "0" {
|
|
157
|
+
logs, err := json.Marshal(readDailyErrorLog(t))
|
|
158
|
+
if err != nil {
|
|
159
|
+
t.Fatal(err)
|
|
160
|
+
}
|
|
161
|
+
if !strings.Contains(string(logs), "ret="+ret) || !strings.Contains(string(logs), "matrix-log") {
|
|
162
|
+
t.Fatalf("missing diagnostics: %s", logs)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
func TestQueryResultUnusableResponses(t *testing.T) {
|
|
171
|
+
for _, body := range []string{
|
|
172
|
+
``, `<html>gateway error</html>`, `{"ret":`,
|
|
173
|
+
`{}`, `{"ret":"0"}`, `{"ret":"0","data":null}`,
|
|
174
|
+
`{"ret":"0","data":{}}`, `{"ret":"0","data":{"readable_text":"失败"}}`,
|
|
175
|
+
`{"ret":"0","data":{"thread":null}}`,
|
|
176
|
+
`{"ret":"0","data":{"thread":{"run_list":[]}}}`,
|
|
177
|
+
`{"ret":"0","data":{"thread":{"run_list":"invalid"}}}`,
|
|
178
|
+
`{"ret":"0","data":{"thread":{"run_list":[{"run_id":"other","state":4}]}}}`,
|
|
179
|
+
`{"ret":"0","data":{"thread":{"run_list":[{"run_id":"run_456","state":"bad"}]}}}`,
|
|
180
|
+
`{"ret":"","errmsg":"创作失败","data":null}`,
|
|
181
|
+
`{"ret":"5","errmsg":"","data":null}`,
|
|
182
|
+
} {
|
|
183
|
+
t.Run(body, func(t *testing.T) {
|
|
184
|
+
got := executeQueryResponse(t, 200, body)
|
|
185
|
+
if got["status"] != "unknown" || got["completed"] != false || got["error_message"] == "" {
|
|
186
|
+
t.Fatalf("got %#v", got)
|
|
187
|
+
}
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
for _, code := range []int{400, 401, 403, 429, 500, 502, 503, 504} {
|
|
191
|
+
t.Run(fmt.Sprint(code), func(t *testing.T) {
|
|
192
|
+
got := executeQueryResponse(t, code, "gateway error")
|
|
193
|
+
if got["status"] != "unknown" || got["completed"] != false || !strings.Contains(got["error_message"].(string), fmt.Sprint(code)) {
|
|
194
|
+
t.Fatalf("got %#v", got)
|
|
195
|
+
}
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
t.Run("connection refused", func(t *testing.T) {
|
|
199
|
+
server := httptest.NewServer(http.NotFoundHandler())
|
|
200
|
+
server.Close()
|
|
201
|
+
got := executeQueryURL(t, server.URL, t.TempDir())
|
|
202
|
+
if got["status"] != "unknown" || got["completed"] != false || got["error_message"] == "" {
|
|
203
|
+
t.Fatalf("got %#v", got)
|
|
204
|
+
}
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
func TestQueryResultRetainsPartialDownloads(t *testing.T) {
|
|
209
|
+
for _, mode := range []string{"second video fails", "first image fails", "second image fails", "cached video then image fails"} {
|
|
210
|
+
t.Run(mode, func(t *testing.T) {
|
|
211
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
212
|
+
if r.URL.Path == "/api/biz/v1/skill/get_thread" {
|
|
213
|
+
content := []any{map[string]any{"sub_type": "biz/x_data_video", "data": map[string]any{"video": map[string]any{"download_url": serverURL(r) + "/video", "vid": "first"}}}}
|
|
214
|
+
badVideo := map[string]any{"sub_type": "biz/x_data_video", "data": map[string]any{"video": map[string]any{"download_url": "invalid-scheme://broken"}}}
|
|
215
|
+
badImage := map[string]any{"sub_type": "biz/x_data_image", "data": map[string]any{"image": map[string]any{"url": "invalid-scheme://broken"}}}
|
|
216
|
+
if mode == "second video fails" {
|
|
217
|
+
content = append(content, badVideo)
|
|
218
|
+
} else {
|
|
219
|
+
if mode == "second image fails" {
|
|
220
|
+
content = append(content, map[string]any{"sub_type": "biz/x_data_image", "data": map[string]any{"image": map[string]any{"url": serverURL(r) + "/image", "asset_id": "first-image"}}})
|
|
221
|
+
}
|
|
222
|
+
content = append(content, badImage)
|
|
223
|
+
}
|
|
224
|
+
_ = json.NewEncoder(w).Encode(map[string]any{"ret": "0", "data": map[string]any{"thread": map[string]any{"run_list": []any{map[string]any{"run_id": "run_456", "state": 3, "entry_list": []any{map[string]any{"artifact": map[string]any{"content": content}}}}}}}})
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
_, _ = w.Write([]byte("media-data"))
|
|
228
|
+
}))
|
|
229
|
+
defer server.Close()
|
|
230
|
+
dir := t.TempDir()
|
|
231
|
+
if mode == "cached video then image fails" {
|
|
232
|
+
if err := os.WriteFile(filepath.Join(dir, "first.mp4"), []byte("media-data"), 0600); err != nil {
|
|
233
|
+
t.Fatal(err)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
got := executeQueryURL(t, server.URL, dir)
|
|
237
|
+
if got["status"] != "succeeded" || got["completed"] != true || !strings.Contains(got["error_message"].(string), "下载失败") {
|
|
238
|
+
t.Fatalf("got %#v", got)
|
|
239
|
+
}
|
|
240
|
+
videos := got["videos"].([]any)
|
|
241
|
+
images := got["images"].([]any)
|
|
242
|
+
wantImages := 0
|
|
243
|
+
if mode == "second image fails" {
|
|
244
|
+
wantImages = 1
|
|
245
|
+
}
|
|
246
|
+
if len(videos) != 1 || len(images) != wantImages {
|
|
247
|
+
t.Fatalf("partial files lost: %#v", got)
|
|
248
|
+
}
|
|
249
|
+
for _, file := range append(videos, images...) {
|
|
250
|
+
assertFileContent(t, file.(map[string]any)["output_path"].(string), "media-data")
|
|
251
|
+
}
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
func TestQueryResultTerminalEnvelopeWithoutUsableRun(t *testing.T) {
|
|
257
|
+
for _, data := range []string{`null`, `{}`, `{"thread":null}`, `{"thread":{"run_list":[]}}`, `{"thread":{"run_list":[{"run_id":"other","state":3}]}}`, `{"thread":{"run_list":"bad"}}`} {
|
|
258
|
+
t.Run(data, func(t *testing.T) {
|
|
259
|
+
got := executeQueryResponse(t, 200, `{"ret":"5","errmsg":"创作失败:reason","data":`+data+`}`)
|
|
260
|
+
if got["status"] != "failed" || got["completed"] != true || got["error_message"] != "创作失败:reason" {
|
|
261
|
+
t.Fatalf("got %#v", got)
|
|
262
|
+
}
|
|
263
|
+
})
|
|
264
|
+
}
|
|
265
|
+
}
|
package/dist/checksums.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
3027db71d4e1b89ccaaa50f4660fb41a90e578bf7c08f0ea1c28367a597371a1 pippit-tool-cli-1.0.54-darwin-amd64.tar.gz
|
|
2
|
+
b6a932ae33085d565e88c21a39b446dcb21cf148b6fb66d5a5baa040896d98ae pippit-tool-cli-1.0.54-darwin-arm64.tar.gz
|
|
3
|
+
aabce0e36f96a1dcc29c24a277e85859e938f6eddc4ed74a0536df3e4e4bce23 pippit-tool-cli-1.0.54-linux-amd64.tar.gz
|
|
4
|
+
beb74396a2d82e05f7199b05cdd7850b148748b18483f9ccf24c4ec09d573968 pippit-tool-cli-1.0.54-linux-arm64.tar.gz
|
|
5
|
+
c84ccf75b7c3474f2623f0563c4bf1193a2c84b6f8d0d6ee853ac9c00913ee9c pippit-tool-cli-1.0.54-windows-amd64.zip
|
|
6
|
+
8bd9e6abcde7060fb8b1fac9cb4ea9729cc88b73b27dc9d775581306738a56ba pippit-tool-cli-1.0.54-windows-arm64.zip
|
|
@@ -18,8 +18,13 @@ type GetThreadOptions struct {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
// GetThreadResult is the parsed get_thread response used by `pippit-tool-cli get-thread`.
|
|
21
|
+
// A business error may return both a result and an error so structured callers
|
|
22
|
+
// can still inspect the returned Run. Callers must continue to handle the error.
|
|
21
23
|
type GetThreadResult struct {
|
|
22
24
|
ReadableText string `json:"readable_text"`
|
|
25
|
+
Ret string `json:"-"`
|
|
26
|
+
Errmsg string `json:"-"`
|
|
27
|
+
LogID string `json:"-"`
|
|
23
28
|
RawData []byte `json:"-"`
|
|
24
29
|
}
|
|
25
30
|
|
|
@@ -49,11 +54,12 @@ func GetThread(ctx context.Context, opts *GetThreadOptions, runner *Runner) (*Ge
|
|
|
49
54
|
if err := runner.Client.SendRequest(ctx, getThreadPath(runner), body, &resp); err != nil {
|
|
50
55
|
return nil, fmt.Errorf("获取线程请求失败: %w", err)
|
|
51
56
|
}
|
|
57
|
+
result := &GetThreadResult{Ret: resp.Ret, Errmsg: resp.Errmsg, LogID: resp.LogID, RawData: resp.Data}
|
|
52
58
|
if resp.Ret != "0" {
|
|
53
59
|
if resp.Errmsg == "" {
|
|
54
60
|
resp.Errmsg = "未知错误"
|
|
55
61
|
}
|
|
56
|
-
return
|
|
62
|
+
return result, NewLogIDError(fmt.Sprintf("获取线程请求返回失败: ret=%s errmsg=%s", resp.Ret, resp.Errmsg), resp.LogID)
|
|
57
63
|
}
|
|
58
64
|
if len(resp.Data) == 0 {
|
|
59
65
|
return nil, fmt.Errorf("get_thread 响应缺少 data")
|
|
@@ -68,10 +74,8 @@ func GetThread(ctx context.Context, opts *GetThreadOptions, runner *Runner) (*Ge
|
|
|
68
74
|
}
|
|
69
75
|
}
|
|
70
76
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
RawData: resp.Data,
|
|
74
|
-
}, nil
|
|
77
|
+
result.ReadableText = readable.ReadableText
|
|
78
|
+
return result, nil
|
|
75
79
|
}
|
|
76
80
|
|
|
77
81
|
func getThreadPath(runner *Runner) string {
|
|
@@ -57,6 +57,21 @@ func TestGetThreadRequiresRetZero(t *testing.T) {
|
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
func TestGetThreadPreservesDataOnBusinessError(t *testing.T) {
|
|
61
|
+
result, err := GetThread(context.Background(), &GetThreadOptions{
|
|
62
|
+
ThreadID: "thread_123",
|
|
63
|
+
RunID: "run_456",
|
|
64
|
+
}, &Runner{
|
|
65
|
+
Client: getThreadFakeClient{response: `{"ret":"2","errmsg":"模拟接口错误","log_id":"fixture-log","data":{"thread":{"thread_id":"thread_123","run_list":[{"run_id":"run_456","state":4}]}}}`},
|
|
66
|
+
})
|
|
67
|
+
if err == nil || !strings.Contains(err.Error(), "ret=2") || !strings.Contains(err.Error(), "fixture-log") {
|
|
68
|
+
t.Fatalf("error = %v, want business error with log ID", err)
|
|
69
|
+
}
|
|
70
|
+
if result == nil || !strings.Contains(string(result.RawData), `"run_456"`) {
|
|
71
|
+
t.Fatalf("result = %#v, want response data alongside business error", result)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
60
75
|
func TestGetThreadV2RequiresReadableText(t *testing.T) {
|
|
61
76
|
_, err := GetThread(context.Background(), &GetThreadOptions{
|
|
62
77
|
ThreadID: "thread_123",
|
|
@@ -72,3 +87,24 @@ func TestGetThreadV2RequiresReadableText(t *testing.T) {
|
|
|
72
87
|
t.Fatalf("error = %q, want readable_text validation", err)
|
|
73
88
|
}
|
|
74
89
|
}
|
|
90
|
+
|
|
91
|
+
func TestGetThreadPreservesEnvelope(t *testing.T) {
|
|
92
|
+
for _, ret := range []string{"0", "1", "2", "5", "1015", "16019", "987654"} {
|
|
93
|
+
t.Run(ret, func(t *testing.T) {
|
|
94
|
+
result, err := GetThread(context.Background(), &GetThreadOptions{ThreadID: "thread", RunID: "run"}, &Runner{Client: getThreadFakeClient{response: `{"ret":"` + ret + `","errmsg":"server message","log_id":"server-log","data":null}`}})
|
|
95
|
+
if (err != nil) != (ret != "0") {
|
|
96
|
+
t.Fatalf("err=%v ret=%s", err, ret)
|
|
97
|
+
}
|
|
98
|
+
if result == nil || result.Ret != ret || result.Errmsg != "server message" || result.LogID != "server-log" || string(result.RawData) != "null" {
|
|
99
|
+
t.Fatalf("envelope lost: %#v", result)
|
|
100
|
+
}
|
|
101
|
+
raw, marshalErr := sonic.Marshal(result)
|
|
102
|
+
if marshalErr != nil {
|
|
103
|
+
t.Fatal(marshalErr)
|
|
104
|
+
}
|
|
105
|
+
if strings.Contains(string(raw), "server-log") || strings.Contains(string(raw), "server message") {
|
|
106
|
+
t.Fatalf("internal envelope leaked: %s", raw)
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -14,19 +14,22 @@ import (
|
|
|
14
14
|
)
|
|
15
15
|
|
|
16
16
|
const (
|
|
17
|
-
successRunState
|
|
18
|
-
failedRunState
|
|
19
|
-
canceledRunState
|
|
17
|
+
successRunState = 3
|
|
18
|
+
failedRunState = 4
|
|
19
|
+
canceledRunState = 5
|
|
20
|
+
interruptedRunState = 9
|
|
20
21
|
)
|
|
21
22
|
|
|
22
23
|
// Query-result status values. "completed" stays a terminal flag; "status"
|
|
23
24
|
// carries the machine-readable result so integrators need not parse text.
|
|
24
25
|
const (
|
|
25
|
-
statusRunning
|
|
26
|
-
statusSucceeded
|
|
27
|
-
statusFailed
|
|
28
|
-
statusCanceled
|
|
29
|
-
statusUnknown
|
|
26
|
+
statusRunning = "running"
|
|
27
|
+
statusSucceeded = "succeeded"
|
|
28
|
+
statusFailed = "failed"
|
|
29
|
+
statusCanceled = "canceled"
|
|
30
|
+
statusUnknown = "unknown"
|
|
31
|
+
statusInputRequired = "input_required"
|
|
32
|
+
statusInterrupted = "interrupted"
|
|
30
33
|
)
|
|
31
34
|
|
|
32
35
|
// runStateStatus maps the server-side run state (0-9) to the user-facing
|
|
@@ -38,16 +41,17 @@ var runStateStatus = map[int]string{
|
|
|
38
41
|
3: statusSucceeded,
|
|
39
42
|
4: statusFailed,
|
|
40
43
|
5: statusCanceled,
|
|
41
|
-
6:
|
|
42
|
-
7: statusRunning,
|
|
43
|
-
8: statusRunning,
|
|
44
|
-
9:
|
|
44
|
+
6: statusInputRequired,
|
|
45
|
+
7: statusRunning, // Generating
|
|
46
|
+
8: statusRunning, // Interrupt
|
|
47
|
+
9: statusInterrupted, // HITL_Interrupt is terminal, unlike Interrupt (8).
|
|
45
48
|
}
|
|
46
49
|
|
|
47
50
|
var terminalRunStates = map[int]struct{}{
|
|
48
|
-
successRunState:
|
|
49
|
-
failedRunState:
|
|
50
|
-
canceledRunState:
|
|
51
|
+
successRunState: {},
|
|
52
|
+
failedRunState: {},
|
|
53
|
+
canceledRunState: {},
|
|
54
|
+
interruptedRunState: {},
|
|
51
55
|
}
|
|
52
56
|
|
|
53
57
|
func statusForRunState(state int) string {
|
|
@@ -100,12 +104,18 @@ type queryThread struct {
|
|
|
100
104
|
}
|
|
101
105
|
|
|
102
106
|
type queryRun struct {
|
|
103
|
-
RunID string
|
|
104
|
-
State int
|
|
105
|
-
ErrorMessage string
|
|
106
|
-
ErrorMsg string
|
|
107
|
-
Errmsg string
|
|
108
|
-
|
|
107
|
+
RunID string `json:"run_id"`
|
|
108
|
+
State int `json:"state"`
|
|
109
|
+
ErrorMessage string `json:"error_message"`
|
|
110
|
+
ErrorMsg string `json:"error_msg"`
|
|
111
|
+
Errmsg string `json:"errmsg"`
|
|
112
|
+
FailReason *queryFailReason `json:"fail_reason"`
|
|
113
|
+
EntryList []queryEntry `json:"entry_list"`
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
type queryFailReason struct {
|
|
117
|
+
Message string `json:"message"`
|
|
118
|
+
Code json.RawMessage `json:"code"`
|
|
109
119
|
}
|
|
110
120
|
|
|
111
121
|
type queryEntry struct {
|
|
@@ -152,34 +162,41 @@ func QueryResult(ctx context.Context, opts *QueryResultOptions, runner *common.R
|
|
|
152
162
|
return nil, err
|
|
153
163
|
}
|
|
154
164
|
|
|
155
|
-
threadResult,
|
|
165
|
+
threadResult, queryErr := common.GetThread(ctx, &common.GetThreadOptions{
|
|
156
166
|
ThreadID: opts.ThreadID,
|
|
157
167
|
RunID: opts.RunID,
|
|
158
168
|
}, runner)
|
|
159
|
-
if err != nil {
|
|
160
|
-
return nil, queryResultQueryError(err)
|
|
161
|
-
}
|
|
162
|
-
|
|
163
169
|
thread, err := parseQueryThread(threadResult)
|
|
164
170
|
if err != nil {
|
|
171
|
+
if queryErr != nil {
|
|
172
|
+
return queryResultWithoutRun(threadResult, queryErr, opts)
|
|
173
|
+
}
|
|
165
174
|
return nil, fmt.Errorf("查询失败:%w", err)
|
|
166
175
|
}
|
|
167
176
|
|
|
168
177
|
run, ok := findQueryRun(thread, opts.RunID)
|
|
169
178
|
if !ok {
|
|
179
|
+
if queryErr != nil {
|
|
180
|
+
return queryResultWithoutRun(threadResult, queryErr, opts)
|
|
181
|
+
}
|
|
170
182
|
return nil, fmt.Errorf("查询失败:未找到 run_id=%s 对应的 Run", opts.RunID)
|
|
171
183
|
}
|
|
184
|
+
logResolvedQueryError(queryErr, opts)
|
|
185
|
+
result := &QueryResultResult{
|
|
186
|
+
Status: statusForRunState(run.State),
|
|
187
|
+
Completed: isTerminalRunState(run.State),
|
|
188
|
+
ThreadID: firstNonEmpty(thread.ThreadID, opts.ThreadID),
|
|
189
|
+
RunID: opts.RunID,
|
|
190
|
+
Videos: []QueryResultVideo{},
|
|
191
|
+
Images: []QueryResultImage{},
|
|
192
|
+
}
|
|
172
193
|
if run.State != successRunState {
|
|
173
|
-
result := &QueryResultResult{
|
|
174
|
-
Status: statusForRunState(run.State),
|
|
175
|
-
Completed: isTerminalRunState(run.State),
|
|
176
|
-
ThreadID: firstNonEmpty(thread.ThreadID, opts.ThreadID),
|
|
177
|
-
RunID: opts.RunID,
|
|
178
|
-
Videos: []QueryResultVideo{},
|
|
179
|
-
Images: []QueryResultImage{},
|
|
180
|
-
}
|
|
181
194
|
if run.State == failedRunState {
|
|
182
|
-
|
|
195
|
+
message := extractQueryErrorMessage(run)
|
|
196
|
+
if message == "" && queryErr != nil {
|
|
197
|
+
message = queryResultQueryError(queryErr).Error()
|
|
198
|
+
}
|
|
199
|
+
result.ErrorMessage = firstNonEmpty(message, "Run 失败")
|
|
183
200
|
}
|
|
184
201
|
return result, nil
|
|
185
202
|
}
|
|
@@ -187,20 +204,19 @@ func QueryResult(ctx context.Context, opts *QueryResultOptions, runner *common.R
|
|
|
187
204
|
videos := extractQueryVideos(run)
|
|
188
205
|
images := extractQueryImages(run)
|
|
189
206
|
if len(videos) == 0 && len(images) == 0 {
|
|
190
|
-
return
|
|
207
|
+
return result, fmt.Errorf("下载失败:未找到可下载的产物")
|
|
191
208
|
}
|
|
192
209
|
|
|
193
210
|
downloadDir, err := common.ExpandPath(opts.DownloadDir)
|
|
194
211
|
if err != nil {
|
|
195
|
-
return
|
|
212
|
+
return result, fmt.Errorf("下载失败:解析下载目录失败:%w", err)
|
|
196
213
|
}
|
|
197
214
|
|
|
198
215
|
usedNames := make(map[string]int, len(videos)+len(images))
|
|
199
216
|
|
|
200
|
-
resultVideos := make([]QueryResultVideo, 0, len(videos))
|
|
201
217
|
for i, video := range videos {
|
|
202
218
|
if strings.TrimSpace(video.DownloadURL) == "" {
|
|
203
|
-
return
|
|
219
|
+
return result, fmt.Errorf("下载失败:第 %d 个视频产物 download_url 为空", i+1)
|
|
204
220
|
}
|
|
205
221
|
outputPath := filepath.Join(downloadDir, uniqueQueryResultFileName(videoFileName(video, i+1), usedNames))
|
|
206
222
|
download, err := common.DownloadResult(ctx, common.DownloadResultOptions{
|
|
@@ -209,7 +225,7 @@ func QueryResult(ctx context.Context, opts *QueryResultOptions, runner *common.R
|
|
|
209
225
|
Workers: 5,
|
|
210
226
|
}, runner)
|
|
211
227
|
if err != nil {
|
|
212
|
-
return
|
|
228
|
+
return result, fmt.Errorf("下载失败:%w", err)
|
|
213
229
|
}
|
|
214
230
|
actualOutputPath := outputPath
|
|
215
231
|
if len(download.Downloaded) > 0 {
|
|
@@ -217,7 +233,7 @@ func QueryResult(ctx context.Context, opts *QueryResultOptions, runner *common.R
|
|
|
217
233
|
} else if len(download.AlreadyExist) > 0 {
|
|
218
234
|
actualOutputPath = download.AlreadyExist[0]
|
|
219
235
|
}
|
|
220
|
-
|
|
236
|
+
result.Videos = append(result.Videos, QueryResultVideo{
|
|
221
237
|
DownloadURL: video.DownloadURL,
|
|
222
238
|
OutputPath: actualOutputPath,
|
|
223
239
|
Draft: video.Draft,
|
|
@@ -225,10 +241,9 @@ func QueryResult(ctx context.Context, opts *QueryResultOptions, runner *common.R
|
|
|
225
241
|
})
|
|
226
242
|
}
|
|
227
243
|
|
|
228
|
-
resultImages := make([]QueryResultImage, 0, len(images))
|
|
229
244
|
for i, image := range images {
|
|
230
245
|
if strings.TrimSpace(image.DownloadURL) == "" {
|
|
231
|
-
return
|
|
246
|
+
return result, fmt.Errorf("下载失败:第 %d 个图片产物 download_url 为空", i+1)
|
|
232
247
|
}
|
|
233
248
|
outputPath := filepath.Join(downloadDir, uniqueQueryResultFileName(imageFileName(image, i+1), usedNames))
|
|
234
249
|
download, err := common.DownloadResult(ctx, common.DownloadResultOptions{
|
|
@@ -237,7 +252,7 @@ func QueryResult(ctx context.Context, opts *QueryResultOptions, runner *common.R
|
|
|
237
252
|
Workers: 5,
|
|
238
253
|
}, runner)
|
|
239
254
|
if err != nil {
|
|
240
|
-
return
|
|
255
|
+
return result, fmt.Errorf("下载失败:%w", err)
|
|
241
256
|
}
|
|
242
257
|
actualOutputPath := outputPath
|
|
243
258
|
if len(download.Downloaded) > 0 {
|
|
@@ -245,25 +260,52 @@ func QueryResult(ctx context.Context, opts *QueryResultOptions, runner *common.R
|
|
|
245
260
|
} else if len(download.AlreadyExist) > 0 {
|
|
246
261
|
actualOutputPath = download.AlreadyExist[0]
|
|
247
262
|
}
|
|
248
|
-
|
|
263
|
+
result.Images = append(result.Images, QueryResultImage{
|
|
249
264
|
DownloadURL: image.DownloadURL,
|
|
250
265
|
OutputPath: actualOutputPath,
|
|
251
266
|
})
|
|
252
267
|
}
|
|
253
268
|
|
|
254
|
-
return
|
|
255
|
-
Status: statusSucceeded,
|
|
256
|
-
Completed: true,
|
|
257
|
-
ThreadID: firstNonEmpty(thread.ThreadID, opts.ThreadID),
|
|
258
|
-
RunID: opts.RunID,
|
|
259
|
-
Videos: resultVideos,
|
|
260
|
-
Images: resultImages,
|
|
261
|
-
}, nil
|
|
269
|
+
return result, nil
|
|
262
270
|
}
|
|
263
271
|
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
//
|
|
272
|
+
// The legacy endpoint omits data for Failed/Canceled. Only these exact
|
|
273
|
+
// endpoint-specific responses prove a terminal Run; other errors (including
|
|
274
|
+
// ret=5 "获取会话信息失败") describe a failed query, not a failed Run.
|
|
275
|
+
func queryResultWithoutRun(threadResult *common.GetThreadResult, queryErr error, opts *QueryResultOptions) (*QueryResultResult, error) {
|
|
276
|
+
status := statusUnknown
|
|
277
|
+
if threadResult != nil && threadResult.Ret == "5" {
|
|
278
|
+
message := strings.TrimSpace(threadResult.Errmsg)
|
|
279
|
+
switch {
|
|
280
|
+
case message == "创作失败" || strings.HasPrefix(message, "创作失败:"):
|
|
281
|
+
status = statusFailed
|
|
282
|
+
case message == "创作已取消":
|
|
283
|
+
status = statusCanceled
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if status == statusUnknown {
|
|
287
|
+
return nil, queryResultQueryError(queryErr)
|
|
288
|
+
}
|
|
289
|
+
logResolvedQueryError(queryErr, opts)
|
|
290
|
+
result := NewQueryResultError(queryResultQueryError(queryErr), opts)
|
|
291
|
+
result.Status = status
|
|
292
|
+
result.Completed = true
|
|
293
|
+
return result, nil
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// The command logs returned errors. Errors consumed by a resolved Run must
|
|
297
|
+
// also retain their original ret/errmsg/log_id for diagnosis.
|
|
298
|
+
func logResolvedQueryError(err error, opts *QueryResultOptions) {
|
|
299
|
+
if err != nil {
|
|
300
|
+
_ = common.AppendDailyErrorLog("query-result", err, map[string]string{
|
|
301
|
+
"thread_id": opts.ThreadID,
|
|
302
|
+
"run_id": opts.RunID,
|
|
303
|
+
})
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// queryResultQueryError extracts a customer-facing get_thread error. It is used
|
|
308
|
+
// when no target Run can be resolved, or as the failed Run's fallback reason.
|
|
267
309
|
func queryResultQueryError(err error) error {
|
|
268
310
|
var logErr *common.LogIDError
|
|
269
311
|
if errors.As(err, &logErr) {
|
|
@@ -279,9 +321,8 @@ func queryResultQueryError(err error) error {
|
|
|
279
321
|
return fmt.Errorf("查询失败:%w", err)
|
|
280
322
|
}
|
|
281
323
|
|
|
282
|
-
// NewQueryResultError builds the user-facing result
|
|
283
|
-
//
|
|
284
|
-
// guessing; the concrete cause stays in error_message.
|
|
324
|
+
// NewQueryResultError builds the user-facing result when no Run could be
|
|
325
|
+
// resolved. The concrete cause stays in error_message.
|
|
285
326
|
func NewQueryResultError(err error, opts *QueryResultOptions) *QueryResultResult {
|
|
286
327
|
result := &QueryResultResult{
|
|
287
328
|
Status: statusUnknown,
|
|
@@ -418,6 +459,16 @@ func extractQueryImages(run queryRun) []queryImage {
|
|
|
418
459
|
}
|
|
419
460
|
|
|
420
461
|
func extractQueryErrorMessage(run queryRun) string {
|
|
462
|
+
if run.FailReason != nil {
|
|
463
|
+
message := strings.TrimSpace(run.FailReason.Message)
|
|
464
|
+
code := rawMessageString(run.FailReason.Code)
|
|
465
|
+
if message != "" {
|
|
466
|
+
if code != "" && code != "0" {
|
|
467
|
+
return fmt.Sprintf("%s (error_code=%s)", message, code)
|
|
468
|
+
}
|
|
469
|
+
return message
|
|
470
|
+
}
|
|
471
|
+
}
|
|
421
472
|
if message := firstNonEmpty(run.ErrorMessage, run.ErrorMsg, run.Errmsg); message != "" {
|
|
422
473
|
return message
|
|
423
474
|
}
|
|
@@ -435,6 +486,11 @@ func extractQueryErrorMessage(run queryRun) string {
|
|
|
435
486
|
}
|
|
436
487
|
}
|
|
437
488
|
}
|
|
489
|
+
if run.FailReason != nil {
|
|
490
|
+
if code := rawMessageString(run.FailReason.Code); code != "" && code != "0" {
|
|
491
|
+
return "error_code=" + code
|
|
492
|
+
}
|
|
493
|
+
}
|
|
438
494
|
return ""
|
|
439
495
|
}
|
|
440
496
|
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
package generate_video
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"errors"
|
|
7
|
+
"testing"
|
|
8
|
+
|
|
9
|
+
"github.com/Pippit-dev/pippit-cli/internal/common"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
func TestQueryFailureReasons(t *testing.T) {
|
|
13
|
+
for _, tt := range []struct{ name, run, want string }{
|
|
14
|
+
{"real fail reason", `{"fail_reason":{"message":"视频可能不合规,请修改后重试","code":12023}}`, "视频可能不合规,请修改后重试 (error_code=12023)"},
|
|
15
|
+
{"message only", `{"fail_reason":{"message":"reason"}}`, "reason"},
|
|
16
|
+
{"zero code", `{"fail_reason":{"message":"reason","code":0}}`, "reason"},
|
|
17
|
+
{"code only", `{"fail_reason":{"code":12023}}`, "error_code=12023"},
|
|
18
|
+
{"empty reason", `{"fail_reason":{"message":" ","code":0}}`, ""},
|
|
19
|
+
{"null reason", `{"fail_reason":null}`, ""},
|
|
20
|
+
{"structured reason wins", `{"fail_reason":{"message":"real reason","code":12023},"error_message":"legacy"}`, "real reason (error_code=12023)"},
|
|
21
|
+
{"legacy message fallback", `{"fail_reason":{"code":12023},"error_message":"legacy"}`, "legacy"},
|
|
22
|
+
{"error_msg fallback", `{"error_msg":"legacy"}`, "legacy"},
|
|
23
|
+
{"errmsg fallback", `{"errmsg":"legacy"}`, "legacy"},
|
|
24
|
+
{"artifact reason", `{"entry_list":[{"artifact":{"content":[{"data":{"error_message":"artifact reason","error_code":99}}]}}]}`, "artifact reason (error_code=99)"},
|
|
25
|
+
{"artifact code only", `{"entry_list":[{"artifact":{"content":[{"data":{"error_code":"99"}}]}}]}`, "error_code=99"},
|
|
26
|
+
} {
|
|
27
|
+
t.Run(tt.name, func(t *testing.T) {
|
|
28
|
+
var run queryRun
|
|
29
|
+
if err := json.Unmarshal([]byte(tt.run), &run); err != nil {
|
|
30
|
+
t.Fatal(err)
|
|
31
|
+
}
|
|
32
|
+
if got := extractQueryErrorMessage(run); got != tt.want {
|
|
33
|
+
t.Fatalf("got %q want %q", got, tt.want)
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type queryErrorClient struct{ err error }
|
|
40
|
+
|
|
41
|
+
func (c queryErrorClient) SendRequest(context.Context, string, any, any) error { return c.err }
|
|
42
|
+
func (c queryErrorClient) SendRequestWithHeaders(context.Context, string, any, map[string]string, any) error {
|
|
43
|
+
return c.err
|
|
44
|
+
}
|
|
45
|
+
func (c queryErrorClient) SendMultipartRequest(context.Context, string, map[string]string, common.MultipartFile, any) error {
|
|
46
|
+
return c.err
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
func TestQueryResultTransportFailure(t *testing.T) {
|
|
50
|
+
for _, cause := range []error{context.DeadlineExceeded, context.Canceled, errors.New("connection reset")} {
|
|
51
|
+
t.Run(cause.Error(), func(t *testing.T) {
|
|
52
|
+
opts := &QueryResultOptions{ThreadID: "thread", RunID: "run", DownloadDir: t.TempDir()}
|
|
53
|
+
result, err := QueryResult(context.Background(), opts, &common.Runner{Client: queryErrorClient{cause}})
|
|
54
|
+
if result != nil || !errors.Is(err, cause) {
|
|
55
|
+
t.Fatalf("result=%#v err=%v", result, err)
|
|
56
|
+
}
|
|
57
|
+
result = NewQueryResultError(err, opts)
|
|
58
|
+
if result.Status != "unknown" || result.Completed || result.ErrorMessage == "" {
|
|
59
|
+
t.Fatalf("result=%#v", result)
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
}
|
package/package.json
CHANGED
|
@@ -33,6 +33,22 @@ URL 导入前检查实际入口的 `--help` 含 `--url`;旧版缺少时按 [
|
|
|
33
33
|
|
|
34
34
|
媒体节点优先通过语义 `create_biz_node` 创建,`initialData` 只传 `pippitAssetId`:CLI 查询素材并填写真实云媒体 `assetId`。用 `update_asset.contentPatch` 或 `update_node_data.patch` 替换素材时也可只传新的 `pippitAssetId`。显式填写的 `assetId` 必须与素材查询结果匹配,否则返回 `INVALID_MEDIA_IDENTITY` 并拒绝写入。
|
|
35
35
|
|
|
36
|
+
普通 `biz/image` 的素材绑定在同层 `content.pippitAssetId` 与 `content.assetId`。`content.appearanceInfo`、`content.sourceInfos` 属于角色/场景结构,不能套用于普通图片;角色/场景继续按各自领域命令及 schema 操作。
|
|
37
|
+
|
|
38
|
+
以下仅用于核对保存后的节点结构,ID 占位符须替换为同空间真实查询结果,不作为完整写入请求直接执行:
|
|
39
|
+
|
|
40
|
+
```json
|
|
41
|
+
{"type":"biz/image","content":{"pippitAssetId":"PIPPIT_MATERIAL_ID","assetId":"CLOUD_IMAGE_ASSET_ID"}}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
错误反例:同层绑定缺失,即使保存 ACK,也不是可用参考图:
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{"type":"biz/image","content":{"appearanceInfo":{"pippitAssetId":"PIPPIT_MATERIAL_ID","assetId":"CLOUD_IMAGE_ASSET_ID"}}}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
可先创建不含绑定的图片空草稿;不能用 `generation`、`sourceInfos` 或手改“已上传”状态把空草稿视为素材已就绪。已有错绑数据先回读、核对真实素材,再在用户授权的编辑范围内修正,不自动迁移用户画布。
|
|
51
|
+
|
|
36
52
|
`canvas upload` 顶层旧字段 `asset_id` 是保留兼容的 workspace/source ID,不能当节点媒体 ID;它与 `upload-file.asset_id` 的含义不同。`media_asset_id` 来自查询结果对应的 `Image/Video/Audio.asset_id`;缺失时不能用 `source_id`、`pippit_asset_id` 或画布节点 ID 补位。`node_asset_refs[].pippit_asset_id` 使用画布节点资产 ID。
|
|
37
53
|
|
|
38
54
|
批量 `apply_mutations` 默认非原子执行:必须检查 `data.results` 中每一项的 `committed` / `aborted` 状态,保留已成功项,不重跑整批。需要任一失败时整批回滚,可显式传 `atomic: true`。
|
|
@@ -20,6 +20,7 @@ stdout 是 JSON;命令可能将错误编码进 JSON 并以退出码 0 返回
|
|
|
20
20
|
|
|
21
21
|
| 字段 | 含义 |
|
|
22
22
|
| --- | --- |
|
|
23
|
+
| `status` | 目标 Run 状态:`running`、`succeeded`、`failed`、`canceled`、`input_required`、`interrupted`、`unknown`;不直接由查询接口 `ret` 决定 |
|
|
23
24
|
| `completed` | 是否结束;失败时也可能为 `true`,不等于成功 |
|
|
24
25
|
| `error_message` | 非空即错误,不能因为 `completed=false` 而忽略 |
|
|
25
26
|
| `thread_id` / `run_id` | 对应的查询任务 |
|
|
@@ -31,6 +32,7 @@ stdout 是 JSON;命令可能将错误编码进 JSON 并以退出码 0 返回
|
|
|
31
32
|
|
|
32
33
|
```json
|
|
33
34
|
{
|
|
35
|
+
"status": "succeeded",
|
|
34
36
|
"completed": true,
|
|
35
37
|
"thread_id": "THREAD_ID",
|
|
36
38
|
"run_id": "RUN_ID",
|
|
@@ -40,7 +42,15 @@ stdout 是 JSON;命令可能将错误编码进 JSON 并以退出码 0 返回
|
|
|
40
42
|
}
|
|
41
43
|
```
|
|
42
44
|
|
|
43
|
-
|
|
45
|
+
`status` 和 `completed` 优先按返回的目标 Run 判定,适用于任意 `ret`。失败原因优先读取 Run 的 `fail_reason.message/code`,再读取兼容错误字段和接口错误;非零接口返回仍保留本地诊断日志。
|
|
46
|
+
|
|
47
|
+
没有目标 Run 时,只有已核实的接口返回可确定终态:`ret=5` 且 `errmsg` 为 `创作失败` 或以 `创作失败:` 开头时返回 `failed`、`completed=true`;`ret=5` 且 `errmsg` 为 `创作已取消` 时返回 `canceled`、`completed=true`。不要泛匹配“失败”或“不合规”。其余查询错误保持 `unknown`、`completed=false`,通过 `error_message` 报告原因及可用的 LogID。`ret=2` 是参数错误,不代表安审失败;`ret=5` 也可能只是查询会话失败。未指定或未知 Run 状态保持 `unknown`。
|
|
48
|
+
|
|
49
|
+
`input_required` 表示等待补充输入,`completed=false`;`interrupted` 表示人工交互中断结束,`completed=true`。两者均停止自动轮询并报告交互需求或中断状态。`canceled` 也直接停止,不因空产物误报下载失败。程序中断/等待工具回调仍为 `running`。
|
|
50
|
+
|
|
51
|
+
Run 已成功但产物缺失或下载失败时,保留 `succeeded`、`completed=true`,错误写入 `error_message`。此前已成功下载的文件仍保留在媒体数组中。核对这些文件后可交付已完成部分,但不能宣称全部下载或交付成功。
|
|
52
|
+
|
|
53
|
+
只有 `running` 且无错误时继续轮询;未知状态不能自动当作运行中。命令不提供完整会话消息或用户反问,不编造具体进度。轮询停止条件见 [共用流程](../workflows/async-delivery.md)。
|
|
44
54
|
|
|
45
55
|
## 样片结果
|
|
46
56
|
|
|
@@ -54,4 +64,4 @@ stdout 是 JSON;命令可能将错误编码进 JSON 并以退出码 0 返回
|
|
|
54
64
|
|
|
55
65
|
文件名由 CLI 根据产物信息生成,以 `output_path` 为准,不自行拼接编号或推测扩展名。同目录已有同名文件可能被复用;复用不证明内容相同,也不代表本次新下载。发现同名文件属于其他产物时,选择用户认可范围内的未冲突目录再查询,不删除已有文件。
|
|
56
66
|
|
|
57
|
-
|
|
67
|
+
找不到产物、链接缺失或下载失败时会返回错误。下载中途失败时,媒体数组保留此前已成功下载或复用的文件;逐项核对 `output_path` 后可交付可用部分,并说明未完成项。不把输出目录里的任意旧文件当作本次产物,也不宣称已完整交付。
|
|
@@ -12,6 +12,18 @@ const REQUIRED_COMMANDS = [
|
|
|
12
12
|
"generate-image", "generate-video", "video-super-resolution",
|
|
13
13
|
"erase-video-subtitle", "get-credit-balance", "model list", "model describe",
|
|
14
14
|
];
|
|
15
|
+
// v1.0.50 first ships ordinary-image binding guards in both Canvas entries.
|
|
16
|
+
const MIN_CANVAS_VERSION = [1, 0, 50];
|
|
17
|
+
|
|
18
|
+
function supportsCanvasBindings(version) {
|
|
19
|
+
const match = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\+[0-9A-Za-z.-]+)?$/.exec(version);
|
|
20
|
+
if (!match) return false;
|
|
21
|
+
const parts = match.slice(1, 4).map(Number);
|
|
22
|
+
for (let i = 0; i < MIN_CANVAS_VERSION.length; i++) {
|
|
23
|
+
if (parts[i] !== MIN_CANVAS_VERSION[i]) return parts[i] > MIN_CANVAS_VERSION[i];
|
|
24
|
+
}
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
15
27
|
|
|
16
28
|
function npmCommand() {
|
|
17
29
|
if (process.platform !== "win32") return ["npm", []];
|
|
@@ -80,6 +92,11 @@ function ensureCLI({ canvas = false } = {}) {
|
|
|
80
92
|
if (expectedVersion && version !== expectedVersion) {
|
|
81
93
|
throw new Error(`CLI 版本 ${version} 与 npm 包版本 ${expectedVersion} 不一致。`);
|
|
82
94
|
}
|
|
95
|
+
if (canvas && !supportsCanvasBindings(version)) {
|
|
96
|
+
const failure = new Error(`Canvas 需要 CLI >=${MIN_CANVAS_VERSION.join(".")} 正式版以校验图片绑定,当前版本:${version}。`);
|
|
97
|
+
failure.missingCommand = "Canvas 图片绑定校验(CLI >=1.0.50 正式版)";
|
|
98
|
+
throw failure;
|
|
99
|
+
}
|
|
83
100
|
const commands = REQUIRED_COMMANDS.map((command) => command.split(" "));
|
|
84
101
|
if (canvas) {
|
|
85
102
|
for (const command of ["create", "get", "allocate", "upload", "apply"]) commands.push(["canvas", command]);
|
|
@@ -165,7 +182,7 @@ function ensureCLI({ canvas = false } = {}) {
|
|
|
165
182
|
|
|
166
183
|
if (require.main === module) {
|
|
167
184
|
if (process.argv.length === 3 && process.argv[2] === "--help") {
|
|
168
|
-
console.log("Usage: node ensure-cli.js [--canvas]\n优先复用 PATH 或缓存中命令齐全的 CLI,不存在或缺少必需命令时安装 npm latest,成功输出 {cli_path, version} JSON。--canvas
|
|
185
|
+
console.log("Usage: node ensure-cli.js [--canvas]\n优先复用 PATH 或缓存中命令齐全的 CLI,不存在或缺少必需命令时安装 npm latest,成功输出 {cli_path, version} JSON。--canvas 要求 CLI >=1.0.50 正式版,额外验证画布原生命令和 npm 运行时,并返回 canvas_entry。");
|
|
169
186
|
} else if (process.argv.length !== 2 && !(process.argv.length === 3 && process.argv[2] === "--canvas")) {
|
|
170
187
|
console.error("不支持的参数。用法:node ensure-cli.js [--canvas]");
|
|
171
188
|
process.exitCode = 1;
|
|
@@ -16,7 +16,7 @@ node "{baseDir}/scripts/ensure-cli.js"
|
|
|
16
16
|
|
|
17
17
|
可选参数按实际任务检查帮助:URL 导入前核对所选入口 `upload-file --help` 或 `canvas upload --help` 含 `--url`,缺少时先更新到支持版本。安装脚本只检查必需命令,不因未使用的 URL 参数自动升级,也不证明服务端已部署 URL 导入能力。
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
命令齐全则直接复用,不检查最新版本;Canvas 另有下述最低版本要求。不存在、缺少必需命令或不满足 Canvas 最低版本时,获取 `@pippit-dev/cli@latest`。PATH 旧版本不兼容但缓存完整时复用缓存,避免每次升级。版本命令不能运行或检查超时则报告运行错误。
|
|
20
20
|
|
|
21
21
|
需要安装时,脚本跳过 npm 生命周期脚本获取包,调用包内 `scripts/install-cli.js` 只安装 CLI。成功后缓存到 `~/.cache/pippit-tool-cli/xyq-skill/<平台>-<架构>/current`。不要求全局 npm 写入权限,也不安装或清理全局 Skill。
|
|
22
22
|
|
|
@@ -36,7 +36,9 @@ node "{baseDir}/scripts/ensure-cli.js"
|
|
|
36
36
|
|
|
37
37
|
## Canvas 运行时检查
|
|
38
38
|
|
|
39
|
-
画布任务改用 `node "{baseDir}/scripts/ensure-cli.js" --canvas
|
|
39
|
+
画布任务改用 `node "{baseDir}/scripts/ensure-cli.js" --canvas`。要求 CLI 至少为 `1.0.50` 正式版,该版本起原生 `canvas apply` 和 npm 语义运行时均包含普通图片绑定校验;仅有同名命令不能证明旧版包含该修复。旧版、预发布版或无法识别的版本按不兼容处理,先查缓存,再至多安装一次;最新包仍不兼容则报错。普通媒体任务不受此最低版本限制。
|
|
40
|
+
|
|
41
|
+
除原有检查外,再验证五个原生资产子命令的帮助,并通过同一个 npm 包的 Node 入口真实执行离线 `canvas command list`,确认运行时可加载且含基本查询与节点创建能力。该检查不登录、不访问画布、不写入远端。保留当前 Skill 的实际路径及返回的 `version`、`cli_path`、`canvas_entry`,便于故障定位;`Pippit-CLI/1.0` 是 User-Agent,不能作为 CLI 发行版本。
|
|
40
42
|
|
|
41
43
|
成功额外返回 `canvas_entry`,供 `node "CANVAS_ENTRY" canvas command ...` 使用;`cli_path` 仍用于原生命令。语义操作的实际支持范围以当前目录为准,检查通过不代表所有业务操作或服务端权限都可用。
|
|
42
44
|
|
|
@@ -7,15 +7,15 @@
|
|
|
7
7
|
1. 提交成功后保存 `thread_id`、`run_id` 和 `web_thread_link`,立即向用户展示任务链接。已有任务直接使用其标识进入查询,不再次生成。
|
|
8
8
|
2. 使用同一 `cli_path` 和同一对任务 ID 调用查询命令,明确 `--download-dir`。每隔 10 秒查询一次,不在轮询中重新安装 CLI。
|
|
9
9
|
3. 先检查命令是否可执行、输出是否为有效 JSON,再读取非空 `error_message`;有错误就进入下面的失败处理。
|
|
10
|
-
4.
|
|
11
|
-
5. 无错误且 `completed=true` 时,要求媒体数组中有待交付产物,逐项核对 `output_path` 后进入交付。空结果或缺少必要字段时报告异常,不空轮询。
|
|
10
|
+
4. `status=input_required` 时停止自动轮询,提示需要补充输入;`status=interrupted` 或 `canceled` 时停止并报告对应终态。只有无错误且 `status=running` 时继续轮询,不编造创作消息或完成百分比。`unknown` 不等于运行中,报告无法确定任务状态。
|
|
11
|
+
5. 无错误且 `status=succeeded`、`completed=true` 时,要求媒体数组中有待交付产物,逐项核对 `output_path` 后进入交付。空结果或缺少必要字段时报告异常,不空轮询。
|
|
12
12
|
|
|
13
13
|
## 停止与恢复
|
|
14
14
|
|
|
15
15
|
- 明确的任务失败、鉴权失败、参数错误或产物缺失:停止并报告错误及可用的任务 ID、链接、LogID,不重新提交生成。
|
|
16
16
|
- 可识别的暂时网络错误或下载错误:间隔 10 秒后仅重试同一次查询一次;仍失败则报告阻塞。无法判别错误类别时停止,避免盲目重试。
|
|
17
17
|
- 用户要求停止时停止本地轮询;这不等于已取消服务端任务。持续未完成时遵守用户或宿主的等待时限,最迟在连续 48 小时后停止等待,保留 ID 供后续恢复查询。宿主无法持续执行时如实说明,不承诺后台监控。
|
|
18
|
-
-
|
|
18
|
+
- 等待输入或人工交互中断时,查询输出不含完整反问内容;引导查看任务页面,不代答或新建任务。
|
|
19
19
|
- 恢复时继续查询原任务,复用该任务已确定的输出目录,对已交付产物去重。
|
|
20
20
|
|
|
21
21
|
## 媒体交付完成标准
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
3. **发现与查询**:`list → describe/schema` 定位操作。用 `get_snapshot` 查节点,用 `get_asset` 查具体资产;3D/多轨再查询其独立文档。`run` 是统一执行入口,其中 query/get 类操作是读取,不等于所有 run 都是写入。
|
|
8
8
|
4. **准备输入**:使用查询所得的节点、子对象和资产 ID。外链素材通过 `canvas upload --url` 导入并保存 `pippit_asset_id`、`state` 与可能的 `warning`;也可复用 `upload-file --url` 返回的稳定 `asset_id`,再按 [URL 导入与资产复用](../commands/canvas-assets.md#url-import) 创建所需节点;已有资产直接复用,不重复导入。多轨 `expectedRevision` 来自最新查询。复杂输入写入 JSON 文件后传 `--file`;不要同时传 `--file` 和 `--input`。只操作用户指定范围。
|
|
9
9
|
5. **预演与写入**:当前 schema 支持 `dryRun` 时可先预演,再在已有授权范围内执行真实修改。不能给所有命令强加此字段:领域命令的 dryRun 验证具体编辑,通用 `apply_mutations.dryRun` 不能替代领域预演。删除、恢复等操作仅按用户明确要求执行。
|
|
10
|
-
6. **验证结果**:检查退出码与 JSON 的 `ok`、错误信息,`ok=false` 即失败。`dryRun=true`
|
|
10
|
+
6. **验证结果**:检查退出码与 JSON 的 `ok`、错误信息,`ok=false` 即失败。`dryRun=true` 成功不代表落盘;实际写入成功后,用对应查询回读目标字段及关联关系,确认没有误改其他对象。素材绑定还须按[图片绑定契约](../commands/canvas-assets.md#低层事务的使用边界)核对节点类型、字段位置和素材身份:普通 `biz/image` 的 `content.pippitAssetId` 必须是所选图片素材,`content.assetId` 必须匹配该素材查询的 `Image.asset_id`。保存 ACK 只证明事务被接受,不证明参考素材可用。
|
|
11
11
|
7. **反馈交付**:说明已验证的画布变化,保留已有/返回的画布链接及目标 ID。只完成文档编辑时不要声称图片/视频已生成;确有媒体文件待交付时遵循 [媒体交付标准](async-delivery.md)。
|
|
12
12
|
|
|
13
13
|
语义执行骨架(占位符替换为当前目录中的命令、真实 ID 和已准备的输入文件):
|
|
@@ -17,3 +17,5 @@ node "CANVAS_ENTRY" canvas command run COMMAND_NAME --canvas-id CANVAS_ASSET_ID
|
|
|
17
17
|
```
|
|
18
18
|
|
|
19
19
|
写请求失败或返回“未确认事务已隔离”时保留错误上下文,先回读状态,不自动重跑、不删除本地恢复记录。多轨版本冲突需重新查询并基于新状态准备操作;检查点恢复会改变画布,不能作为所有失败的自动回滚方式。
|
|
20
|
+
|
|
21
|
+
后续任务要把节点用于生视频参考时,先在原空间回读并完成上述核对。绑定缺失、错层或素材未就绪时停止提交,明确指出节点和缺失字段;保留原始错误与 LogID,不反复重试或手改上传状态。`node asset resource not ready` 表示素材预处理未就绪;即使外层显示“服务器繁忙”,也不能据此把重复生成当作恢复方式。
|