Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
release-artifacts/canvas-runtime/xyq-canvas-command-runtime.cjs -text
release-artifacts/canvas-runtime/xyq-canvas-command-runtime.cjs.LEGAL.txt -text
release-artifacts/canvas-runtime/xyq-canvas-command-runtime.cjs.sha256 text eol=lf
50 changes: 50 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: CI

on:
push:
pull_request:

permissions:
contents: read

jobs:
canvas-command:
name: Canvas command (Node ${{ matrix.node-version }}, ${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
node-version:
- "16"
- "20"
os:
- ubuntu-22.04
- windows-2022
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}

- name: Test canvas command
run: node scripts/canvas-command.test.js

- name: Test runtime artifact assembly
run: node scripts/prepare-canvas-runtime.test.js

go:
name: Go tests
runs-on: ubuntu-22.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version-file: go.mod

- run: go test ./...

- run: go vet ./...
12 changes: 12 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ jobs:
node-version: "20"
registry-url: "https://registry.npmjs.org"

- name: Test release source
run: npm test

- name: Verify fixed canvas command runtime
run: npm run verify:canvas-runtime

- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
Expand All @@ -39,6 +45,12 @@ jobs:
test -s dist/checksums.txt
cp dist/checksums.txt checksums.txt

- name: Stage fixed canvas command runtime
run: npm run prepare:canvas-runtime

- name: Verify npm package contents
run: npm pack --dry-run

- name: Publish to npm
run: npm publish --provenance --access public
env:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
bin/
dist/
node_modules/
*.tgz
.idea/
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,21 @@ pippit-tool-cli canvas apply --project-id PROJECT_ID --file ./patch.json

五个命令均输出单行 JSON,资源 ID 保持字符串。`allocate` 只预留 ID,实际资产仍由后续 `apply` transaction 创建。`create` 的 `request_id` 用于追踪,不是跨服务崩溃窗口的严格幂等键;写请求结果不明确时不要盲目重放,应先使用 `canvas get` 回读确认。`apply` 当前只接受一个 transaction,但该 transaction 可以包含多个 patches;CLI 会严格检查 transaction ACK 和每个目标资产的新版本。

通过 npm 安装的 CLI 还提供基于同一 Canvas SDK 的语义命令目录:

```bash
# 查看全部公开命令及其参数说明
pippit-tool-cli canvas command list
pippit-tool-cli canvas command describe create_biz_node

# 由 SDK 业务工厂创建角色节点;修改会通过现有 canvas apply 原子提交
pippit-tool-cli canvas command run create_biz_node \
--canvas-id PIPPIT_CANVAS_ASSET_ID \
--input '{"nodeKind":"role","initialData":{"nodeName":"测试角色"}}'
```

`canvas command` 由 npm 包内固定的 Canvas SDK 运行时提供,复用网页登录、`canvas get`、`canvas allocate` 和 `canvas apply`;不会读取或打印 Access Key,也不直接选择服务端地址。公开目录只包含已登记的 mutation 和业务命令,不开放任意内部 command 调用。

## 生图 CLI

`generate-image` 会上传本地参考图片,然后向综合 Nest Agent 提交生图请求:
Expand Down
10 changes: 8 additions & 2 deletions cmd/canvas/canvas.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ func newGetCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Comma
func newApplyCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
var filePath string
var projectID string
var transportResult bool
cmd := &cobra.Command{
Use: "apply",
Short: "Apply one Canvas patch transaction",
Expand All @@ -128,8 +129,9 @@ func newApplyCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Com
return err
}
result, err := canvascore.Apply(cmd.Context(), canvascore.ApplyOptions{
ProjectID: projectID,
Request: request,
ProjectID: projectID,
Request: request,
AllowNonAcknowledgedResults: transportResult,
}, runner)
if err != nil {
logCanvasError("canvas apply", err, map[string]string{
Expand All @@ -147,6 +149,10 @@ func newApplyCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Com
flags := cmd.Flags()
flags.StringVar(&filePath, "file", "-", "BatchPatch JSON request file, or - for stdin")
flags.StringVar(&projectID, "project-id", "", "personal novel project ID as a decimal string")
flags.BoolVar(&transportResult, "transport-result", false, "return explicit transaction outcomes for transport decoding")
if err := flags.MarkHidden("transport-result"); err != nil {
panic(err)
}
return cmd
}

Expand Down
56 changes: 56 additions & 0 deletions cmd/canvas/canvas_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"testing"

"github.com/Pippit-dev/pippit-cli/internal/common"
"github.com/spf13/cobra"
)

type commandFakeClient struct {
Expand Down Expand Up @@ -124,3 +125,58 @@ func TestApplyRequestRejectsTeamOrImportExtensions(t *testing.T) {
}
}
}

func TestApplyTransportResultFlagIsHiddenAndPrintsExplicitReject(t *testing.T) {
client := &commandFakeClient{response: `{"ret":"0","log_id":"transport-log","data":{"results":[{"transaction_id":"tx-1","status":"reject","message":"version conflict","current_asset_versions":{"asset-1":9},"server_detail":{"reason":"conflict"}}]}}`}
var stdout, stderr bytes.Buffer
cmd := NewCommand(&stdout, &stderr, &common.Runner{Client: client})
var applyCommand *cobra.Command
for _, child := range cmd.Commands() {
if child.Name() == "apply" {
applyCommand = child
break
}
}
if applyCommand == nil {
t.Fatal("apply command not found")
}
flag := applyCommand.Flags().Lookup("transport-result")
if flag == nil || !flag.Hidden {
t.Fatalf("transport-result flag = %#v, want hidden flag", flag)
}

request := `{"batch_id":"batch-1","client_id":"client-1","transactions":[{"transaction_id":"tx-1","patches":[{"asset_id":"asset-1","op":"replace","path":"","value":{}}]}]}`
cmd.SetIn(strings.NewReader(request))
cmd.SetArgs([]string{"apply", "--file", "-", "--transport-result"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
var output map[string]any
if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &output); err != nil {
t.Fatalf("stdout = %q, want JSON: %v", stdout.String(), err)
}
result := output["results"].([]any)[0].(map[string]any)
if result["status"] != "reject" || result["message"] != "version conflict" || result["server_detail"] == nil {
t.Fatalf("result = %#v, want complete explicit reject", result)
}
}

func TestApplyTransportResultPrintsRootCreateAckWithoutVersion(t *testing.T) {
client := &commandFakeClient{response: `{"ret":"0","data":{"results":[{"transaction_id":"tx-1","status":"ack","server_detail":{"created":true}}]}}`}
var stdout, stderr bytes.Buffer
cmd := NewCommand(&stdout, &stderr, &common.Runner{Client: client})
request := `{"batch_id":"batch-1","client_id":"client-1","transactions":[{"transaction_id":"tx-1","patches":[{"asset_id":"asset-new","op":"add","path":"","value":{}}]}]}`
cmd.SetIn(strings.NewReader(request))
cmd.SetArgs([]string{"apply", "--file", "-", "--transport-result"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
var output map[string]any
if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &output); err != nil {
t.Fatalf("stdout = %q, want JSON: %v", stdout.String(), err)
}
result := output["results"].([]any)[0].(map[string]any)
if result["status"] != "ack" || result["asset_versions"] != nil || result["server_detail"] == nil {
t.Fatalf("result = %#v, want unchanged root-create ACK", result)
}
}
132 changes: 104 additions & 28 deletions internal/canvas/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ import (
var decimalIDPattern = regexp.MustCompile(`^[1-9][0-9]*$`)

type ApplyOptions struct {
ProjectID string
Request ApplyRequest
ProjectID string
Request ApplyRequest
AllowNonAcknowledgedResults bool
}

type ApplyRequest struct {
Expand Down Expand Up @@ -47,6 +48,8 @@ type ApplyResult struct {
BatchID string `json:"batch_id"`
Results []PatchTransactionResult `json:"results"`
LogID string `json:"log_id,omitempty"`

rawResults []json.RawMessage
}

type PatchTransactionResult struct {
Expand All @@ -58,7 +61,29 @@ type PatchTransactionResult struct {
}

type applyData struct {
Results []PatchTransactionResult `json:"results"`
Results []json.RawMessage `json:"results"`
}

type decodedPatchTransactionResult struct {
result PatchTransactionResult
raw json.RawMessage
}

func (result ApplyResult) MarshalJSON() ([]byte, error) {
type applyResultJSON struct {
BatchID string `json:"batch_id"`
Results any `json:"results"`
LogID string `json:"log_id,omitempty"`
}
results := any(result.Results)
if len(result.rawResults) > 0 {
results = result.rawResults
}
return json.Marshal(applyResultJSON{
BatchID: result.BatchID,
Results: results,
LogID: result.LogID,
})
}

func Apply(ctx context.Context, opts ApplyOptions, runner *common.Runner) (*ApplyResult, error) {
Expand Down Expand Up @@ -90,18 +115,33 @@ func Apply(ctx context.Context, opts ApplyOptions, runner *common.Runner) (*Appl
envelope.LogID,
)
}
ordered, err := validateApplyResults(request.Transactions, data.Results)
decodedResults, err := decodeApplyResults(data.Results)
if err != nil {
return nil, common.NewLogIDError(
fmt.Sprintf("canvas apply returned invalid data: %v; query affected assets before retrying because outcome cannot be confirmed", err),
envelope.LogID,
)
}
ordered, rawResults, _, err := validateApplyResults(
request.Transactions,
decodedResults,
opts.AllowNonAcknowledgedResults,
)
if err != nil {
return nil, common.NewLogIDError(
fmt.Sprintf("%s; query affected assets before retrying because outcome cannot be confirmed", err),
envelope.LogID,
)
}
return &ApplyResult{
result := &ApplyResult{
BatchID: request.BatchID,
Results: ordered,
LogID: strings.TrimSpace(envelope.LogID),
}, nil
}
if opts.AllowNonAcknowledgedResults {
result.rawResults = rawResults
}
return result, nil
}

func validateApplyRequest(request *ApplyRequest) error {
Expand Down Expand Up @@ -161,43 +201,79 @@ func validateApplyRequest(request *ApplyRequest) error {
return nil
}

func validateApplyResults(expected []PatchTransaction, actual []PatchTransactionResult) ([]PatchTransactionResult, error) {
byID := make(map[string]PatchTransactionResult, len(actual))
for _, result := range actual {
func decodeApplyResults(rawResults []json.RawMessage) ([]decodedPatchTransactionResult, error) {
results := make([]decodedPatchTransactionResult, 0, len(rawResults))
for index, rawResult := range rawResults {
var result PatchTransactionResult
if err := json.Unmarshal(rawResult, &result); err != nil {
return nil, fmt.Errorf("decode transaction result[%d]: %w", index, err)
}
results = append(results, decodedPatchTransactionResult{result: result, raw: rawResult})
}
return results, nil
}

func validateApplyResults(
expected []PatchTransaction,
actual []decodedPatchTransactionResult,
allowNonAcknowledgedResults bool,
) ([]PatchTransactionResult, []json.RawMessage, bool, error) {
byID := make(map[string]decodedPatchTransactionResult, len(actual))
for _, decoded := range actual {
result := decoded.result
result.TransactionID = strings.TrimSpace(result.TransactionID)
if result.TransactionID == "" {
return nil, fmt.Errorf("canvas apply returned a result without transaction_id")
return nil, nil, false, fmt.Errorf("canvas apply returned a result without transaction_id")
}
if _, duplicate := byID[result.TransactionID]; duplicate {
return nil, fmt.Errorf("canvas apply returned duplicate result for transaction %q", result.TransactionID)
return nil, nil, false, fmt.Errorf("canvas apply returned duplicate result for transaction %q", result.TransactionID)
}
byID[result.TransactionID] = result
decoded.result = result
byID[result.TransactionID] = decoded
}
if len(byID) != len(expected) {
return nil, fmt.Errorf("canvas apply returned %d transaction results, want %d", len(byID), len(expected))
return nil, nil, false, fmt.Errorf("canvas apply returned %d transaction results, want %d", len(byID), len(expected))
}
ordered := make([]PatchTransactionResult, 0, len(expected))
orderedRaw := make([]json.RawMessage, 0, len(expected))
hasNonAcknowledgedResult := false
for _, transaction := range expected {
result, ok := byID[transaction.TransactionID]
decoded, ok := byID[transaction.TransactionID]
if !ok {
return nil, fmt.Errorf("canvas apply omitted transaction result %q", transaction.TransactionID)
}
if strings.ToLower(strings.TrimSpace(result.Status)) != "ack" {
return nil, fmt.Errorf(
"canvas apply transaction %q was not acknowledged: status=%q blocked_by=%q error=%q",
transaction.TransactionID, result.Status, result.BlockedByTransaction, result.Error,
)
return nil, nil, false, fmt.Errorf("canvas apply omitted transaction result %q", transaction.TransactionID)
}
for _, patch := range transaction.Patches {
version, ok := result.AssetVersions[patch.AssetID]
if !ok {
return nil, fmt.Errorf("canvas apply transaction %q omitted version for asset %q", transaction.TransactionID, patch.AssetID)
result := decoded.result
status := strings.ToLower(strings.TrimSpace(result.Status))
switch status {
case "ack":
for _, patch := range transaction.Patches {
version, ok := result.AssetVersions[patch.AssetID]
if !ok {
if allowNonAcknowledgedResults && patch.Op == "add" && patch.Path == "" {
continue
}
return nil, nil, false, fmt.Errorf("canvas apply transaction %q omitted version for asset %q", transaction.TransactionID, patch.AssetID)
}
if version < 0 {
return nil, nil, false, fmt.Errorf("canvas apply transaction %q returned negative version for asset %q", transaction.TransactionID, patch.AssetID)
}
}
if version < 0 {
return nil, fmt.Errorf("canvas apply transaction %q returned negative version for asset %q", transaction.TransactionID, patch.AssetID)
case "reject", "blocked", "skipped":
if !allowNonAcknowledgedResults {
return nil, nil, false, fmt.Errorf(
"canvas apply transaction %q was not acknowledged: status=%q blocked_by=%q error=%q",
transaction.TransactionID, result.Status, result.BlockedByTransaction, result.Error,
)
}
hasNonAcknowledgedResult = true
default:
return nil, nil, false, fmt.Errorf(
"canvas apply transaction %q returned invalid status %q",
transaction.TransactionID, result.Status,
)
}
ordered = append(ordered, result)
orderedRaw = append(orderedRaw, decoded.raw)
}
return ordered, nil
return ordered, orderedRaw, hasNonAcknowledgedResult, nil
}
Loading
Loading