From b31d89b781e01001caf2d5b45bb4902b2c76ce65 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 23 May 2026 02:39:02 -0400 Subject: [PATCH 1/2] feat: add edge wasm provider catalog --- README.md | 5 +++ SPEC.md | 3 ++ go.mod | 2 +- go.sum | 8 ++--- internal/edge_catalog.go | 78 ++++++++++++++++++++++++++++++++++++++++ internal/module_test.go | 42 ++++++++++++++++++++++ 6 files changed, 131 insertions(+), 7 deletions(-) create mode 100644 internal/edge_catalog.go diff --git a/README.md b/README.md index 7ca9d88..e4ba294 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,11 @@ state belong to `workflow-compute`. records. It intentionally does not define a separate plugin-local executor, dependency, verification, reward, or network provider shape. +The built-in edge catalog presets are examples of that boundary: edge lambda and +edge CDN filter entries are plain `ProviderContract` records for +`wasm-component` execution. They do not embed product capture, BMW, or any other +application-specific workflow logic. + If the wfcompute control plane exposes a public client surface, it should expose only the scoped APIs needed by external Workflow clients, such as task submit, task status, proof reads, credential lifecycle, and readiness. Provider diff --git a/SPEC.md b/SPEC.md index c3b7ce0..925eb53 100644 --- a/SPEC.md +++ b/SPEC.md @@ -20,6 +20,7 @@ C9: External Workflow apps may use plugin outside wfcompute deployment/network i C10: Public client control-plane access ≠ provider/admin mutation ingress. C11: Plugin provider catalog details track `workflow-compute`'s typed `ProviderContract`, not a parallel plugin-local provider shape. C12: Product capture is a typed workflow-compute workload; Workflow apps use this plugin to submit/wait, not command-workload shims. +C13: Edge provider presets are generic compute provider contracts; product/application assumptions belong in the calling workflow or provider plugin. §I @@ -58,6 +59,7 @@ V18: plugin guidance for public control-plane use excludes bootstrap token, prov V19: PR CI checks plugin provider catalog tests against current `GoCodeAlone/workflow-compute` main with a local module replace V20: manifest `stepTypes` exactly match runtime `StepTypes` V21: `step.compute_product_capture` requires explicit `allowed_hosts`, supports dynamic `url_field`, and returns only task/proof ids plus bounded preview fields +V22: edge lambda/CDN catalog presets use only `protocol.ProviderContract` with `workflow-plugin-compute`, `wasm-component`, and `wasm-capability`; names/config/runtime fields must not mention product-capture, BMW, or provider-specific business logic §T @@ -74,6 +76,7 @@ T9|x|include rewards in `wfctl compute accounting export`|I.cmd,I.wfctl,C8,V10,V T10|x|document external Workflow client use cases and public client-surface boundary|C9,C10,V17,V18 T11|x|align provider catalog details with workflow-compute `ProviderContract` and gate drift in PR CI|C11,I.module,V19 T12|x|add `step.compute_product_capture` typed workload submit/wait and preview output|C12,I.step,V2,V4,V20,V21 +T13|x|add edge lambda/CDN WASM provider catalog presets and module validation proof without plugin-local provider schema or product leakage|C13,I.module,V19,V22 §B diff --git a/go.mod b/go.mod index 3ae5b80..fe351a3 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.26.3 require ( github.com/GoCodeAlone/workflow v0.27.0 - github.com/GoCodeAlone/workflow-compute v0.0.0-20260522025546-cb71be9e8c17 + github.com/GoCodeAlone/workflow-compute v0.0.0-20260523063653-eb2057197b98 ) require ( diff --git a/go.sum b/go.sum index ee2723d..b75566a 100644 --- a/go.sum +++ b/go.sum @@ -48,12 +48,8 @@ github.com/GoCodeAlone/modular/modules/eventbus/v2 v2.8.0 h1:buYs0TGNbAZgtTq1Qb+ github.com/GoCodeAlone/modular/modules/eventbus/v2 v2.8.0/go.mod h1:329flAKmwrPq2JEwu9iltWv6A83H/Di82Xze+kvdKDw= github.com/GoCodeAlone/workflow v0.27.0 h1:oufPjwWTuwbZw5PckBEDrIah+w7JJcj51nKQzLe5io0= github.com/GoCodeAlone/workflow v0.27.0/go.mod h1:Ue+5YDScTZgtA36q6r/kDaIRxGJFkyxXbeyJVNVJ0Cc= -github.com/GoCodeAlone/workflow-compute v0.0.0-20260521003202-dc0b7c828ec9 h1:EJdhRIcQW2ptHe8Os+2tc8l/7qztJ6//IxQqAoAG2t0= -github.com/GoCodeAlone/workflow-compute v0.0.0-20260521003202-dc0b7c828ec9/go.mod h1:m1GFY/28DcdOp2ok+tTlvqnJqNYhWk5cwJs/8zUFMh4= -github.com/GoCodeAlone/workflow-compute v0.0.0-20260521144416-437084114563 h1:4RvLQWTWT0UaRLF9BqMSnOtzUP/+MxDZcgeJ6N5B93o= -github.com/GoCodeAlone/workflow-compute v0.0.0-20260521144416-437084114563/go.mod h1:m1GFY/28DcdOp2ok+tTlvqnJqNYhWk5cwJs/8zUFMh4= -github.com/GoCodeAlone/workflow-compute v0.0.0-20260522025546-cb71be9e8c17 h1:LCf5NwrOQ9uAXbsHYROqDEl1Luu/2St1ZF+EoBfjOdg= -github.com/GoCodeAlone/workflow-compute v0.0.0-20260522025546-cb71be9e8c17/go.mod h1:m1GFY/28DcdOp2ok+tTlvqnJqNYhWk5cwJs/8zUFMh4= +github.com/GoCodeAlone/workflow-compute v0.0.0-20260523063653-eb2057197b98 h1:UICAsaxkL+5jPmGGxVNyJsCj+gM78ACOAS8KvAptNoc= +github.com/GoCodeAlone/workflow-compute v0.0.0-20260523063653-eb2057197b98/go.mod h1:T8yGXrRBm2USwkRFvMaoq4aPDt/f7JciZY9Y/l/upYs= github.com/GoCodeAlone/yaegi v0.17.2 h1:WK6Y6e0t1a6U7r+S2dN3CGWW1PizYD3zO0zneToZPxM= github.com/GoCodeAlone/yaegi v0.17.2/go.mod h1:z5Pr6Wse6QJcQvpgxTxzMAevFarH0N37TG88Y9dprx0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 h1:rIkQfkCOVKc1OiRCNcSDD8ml5RJlZbH/Xsq7lbpynwc= diff --git a/internal/edge_catalog.go b/internal/edge_catalog.go new file mode 100644 index 0000000..7ee34f4 --- /dev/null +++ b/internal/edge_catalog.go @@ -0,0 +1,78 @@ +package internal + +import ( + "strings" + + "github.com/GoCodeAlone/workflow-compute/pkg/protocol" +) + +func EdgeWASMProviderContracts() []protocol.ProviderContract { + return []protocol.ProviderContract{ + edgeWASMProviderContract( + "edge-lambda-wasm-v1", + "Edge Lambda WASM", + "edge-lambda", + "edge-lambda.wasm.v1", + "handle_request", + "edge_lambda_response", + ), + edgeWASMProviderContract( + "edge-cdn-filter-wasm-v1", + "Edge CDN Filter WASM", + "edge-cdn-filter", + "edge-cdn-filter.wasm.v1", + "filter_request", + "cdn_decision", + ), + } +} + +func edgeWASMProviderContract(id, displayName, providerID, contractID, operationID, artifactName string) protocol.ProviderContract { + runtime := protocol.DefaultProviderRuntimeProfile("wasm-component", protocol.ExecutionWASMCapability, protocol.ProofArtifactHash) + runtime.RuntimeProfile = protocol.RuntimeProfileWASMComponent + runtime.WASM = protocol.WASMRuntimeContract{ + ABI: "wasm-export-i32-v1", + ComponentRef: "provider://" + providerID + "/component.wasm", + ComponentDigest: "sha256:" + strings.Repeat("d", 64), + Features: []string{"edge-request-v1"}, + MaxMemoryBytes: 128 << 20, + MaxRuntimeSeconds: 10, + Filesystem: "forbidden", + Network: protocol.RuntimePermissionForbidden, + NativeHostUpdates: "forbidden", + } + return protocol.ProviderContract{ + ProtocolVersion: protocol.Version, + ID: id, + DisplayName: displayName, + PluginID: "workflow-plugin-compute", + ProviderID: providerID, + ContractID: contractID, + Version: "v1.0.0", + ConfigSchemaRef: "schema://providers/workflow-plugin-compute/" + providerID + "/v1", + ConfigSchemaDigest: "sha256:" + strings.Repeat("c", 64), + OperatingModes: []protocol.NetworkOperatingMode{protocol.NetworkModeBatch}, + WorkloadKinds: []string{string(protocol.WorkloadProvider), string(protocol.WorkloadWASMComponent)}, + ExecutorProviders: []string{"wasm-component"}, + ExecutionSecurityTiers: []protocol.ExecutionSecurityTier{protocol.ExecutionWASMCapability}, + ProofTiers: []protocol.ProofTier{protocol.ProofArtifactHash}, + NetworkModes: []protocol.NetworkMode{protocol.NetworkModeRelay, protocol.NetworkModeOffline}, + Operations: []protocol.ProviderOperation{{ + ID: operationID, + InputSchemaRef: "schema://providers/workflow-plugin-compute/" + providerID + "/operations/" + operationID + "/input/v1", + InputSchemaDigest: "sha256:" + strings.Repeat("a", 64), + OutputSchemaRef: "schema://providers/workflow-plugin-compute/" + providerID + "/operations/" + operationID + "/output/v1", + OutputSchemaDigest: "sha256:" + strings.Repeat("b", 64), + Artifacts: []string{artifactName}, + ArtifactSpecs: []protocol.ProviderArtifactSpec{{ + Name: artifactName, + Required: true, + ContentType: "application/json", + MaxBytes: 1 << 20, + RetentionSeconds: 3600, + Forwardable: true, + }}, + }}, + RuntimeContract: protocol.ProviderRuntimeContract{Profiles: []protocol.ProviderRuntimeProfile{runtime}}, + } +} diff --git a/internal/module_test.go b/internal/module_test.go index 0e3fda2..b1e0db8 100644 --- a/internal/module_test.go +++ b/internal/module_test.go @@ -154,6 +154,48 @@ func TestProviderCatalogRejectsMalformedWorkflowComputeContract(t *testing.T) { } } +func TestEdgeWASMProviderCatalogPresetsAreWorkflowComputeContracts(t *testing.T) { + contracts := EdgeWASMProviderContracts() + if len(contracts) != 2 { + t.Fatalf("edge contract count: got %d", len(contracts)) + } + seen := map[string]bool{} + for _, contract := range contracts { + seen[contract.ProviderID] = true + if contract.PluginID != "workflow-plugin-compute" { + t.Fatalf("contract leaked provider plugin: %+v", contract) + } + if strings.Contains(strings.ToLower(contract.ID+contract.ProviderID+contract.ContractID), "product-capture") || + strings.Contains(strings.ToLower(contract.ID+contract.ProviderID+contract.ContractID), "bmw") { + t.Fatalf("edge contract leaked product capture/BMW boundary: %+v", contract) + } + if len(contract.RuntimeContract.Profiles) != 1 { + t.Fatalf("runtime profiles: %+v", contract.RuntimeContract.Profiles) + } + runtime := contract.RuntimeContract.Profiles[0] + if runtime.RuntimeProfile != protocol.RuntimeProfileWASMComponent || + runtime.ExecutionSecurityTier != protocol.ExecutionWASMCapability || + runtime.ExecutorProvider != "wasm-component" || + runtime.WASM.ComponentDigest == "" || + runtime.WASM.Filesystem != "forbidden" || + runtime.WASM.NativeHostUpdates != "forbidden" { + t.Fatalf("edge runtime contract: %+v", runtime) + } + } + if !seen["edge-lambda"] || !seen["edge-cdn-filter"] { + t.Fatalf("edge providers missing: %+v", seen) + } + module, err := newProviderCatalogModule("edge", map[string]any{ + "contracts": []any{toMap(t, contracts[0]), toMap(t, contracts[1])}, + }) + if err != nil { + t.Fatalf("newProviderCatalogModule(edge): %v", err) + } + if len(module.config.Contracts) != 2 { + t.Fatalf("module contracts: %+v", module.config.Contracts) + } +} + func validProviderContract() protocol.ProviderContract { return protocol.ProviderContract{ ProtocolVersion: protocol.Version, From b3b99129b95e100f7e436ef7739bf99de359eba2 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 23 May 2026 03:17:03 -0400 Subject: [PATCH 2/2] fix: keep compute plugin provider-neutral --- README.md | 20 +-- SPEC.md | 13 +- internal/cli.go | 79 +---------- internal/cli_test.go | 60 +-------- internal/edge_catalog.go | 78 ----------- internal/module_test.go | 42 ------ internal/plugin.go | 3 - internal/steps.go | 275 --------------------------------------- internal/steps_test.go | 180 +++++-------------------- plugin.json | 2 +- 10 files changed, 56 insertions(+), 696 deletions(-) delete mode 100644 internal/edge_catalog.go diff --git a/README.md b/README.md index e4ba294..a592dc0 100644 --- a/README.md +++ b/README.md @@ -27,9 +27,9 @@ Examples: - A data or game build workflow submits a long-running command workload to eligible enrolled agents, records the resulting task/proof ids, and uses the core ledger for accounting. -- A commerce workflow submits a typed product-capture URL workload to an - enrolled browser-capture pool and uses the accepted proof preview to show a - user-confirmed product snapshot. +- A provider plugin, such as product capture or edge compute, exposes a typed + `ProviderContract`; this plugin submits or waits on the resulting generic + workflow-compute task without embedding provider business logic. `compute.provider` in this repository means "Workflow connection to a wfcompute control plane." It is not a wfcompute worker/provider node. Provider @@ -40,10 +40,10 @@ state belong to `workflow-compute`. records. It intentionally does not define a separate plugin-local executor, dependency, verification, reward, or network provider shape. -The built-in edge catalog presets are examples of that boundary: edge lambda and -edge CDN filter entries are plain `ProviderContract` records for -`wasm-component` execution. They do not embed product capture, BMW, or any other -application-specific workflow logic. +Provider-specific contracts belong in the owning provider plugin. For example, +product capture owns product URL semantics and edge compute owns edge +lambda/CDN semantics; this plugin accepts their `ProviderContract` records +through `compute.provider_catalog` without redefining them locally. If the wfcompute control plane exposes a public client surface, it should expose only the scoped APIs needed by external Workflow clients, such as task submit, @@ -106,12 +106,6 @@ For fanout work, use `step.compute_map` with a deterministic `tasks` list. The step submits every task, polls the core task/proof APIs, and stops the Workflow pipeline if any task fails, stalls, times out, or produces a non-accepted proof. -For product capture, use `step.compute_product_capture`. It requires explicit -`allowed_hosts`, `product_id`, and either a static `url` or dynamic `url_field`. -The step submits generic `provider` work using the -`workflow-plugin-product-capture` browser contract, waits for proof, and exposes -the bounded `result_preview` fields returned by workflow-compute. - ## Development ```sh diff --git a/SPEC.md b/SPEC.md index 925eb53..e9a7351 100644 --- a/SPEC.md +++ b/SPEC.md @@ -19,8 +19,8 @@ C8: `wfctl compute` CLI adapter owns operator UX only; scheduler/ledger/proof se C9: External Workflow apps may use plugin outside wfcompute deployment/network if they can reach scoped control-plane client APIs. C10: Public client control-plane access ≠ provider/admin mutation ingress. C11: Plugin provider catalog details track `workflow-compute`'s typed `ProviderContract`, not a parallel plugin-local provider shape. -C12: Product capture is a typed workflow-compute workload; Workflow apps use this plugin to submit/wait, not command-workload shims. -C13: Edge provider presets are generic compute provider contracts; product/application assumptions belong in the calling workflow or provider plugin. +C12: Provider-specific typed steps belong in the owning provider plugin, not this generic compute adapter. +C13: Provider catalog entries are imported `workflow-compute` contracts; product/application assumptions belong in the calling workflow or provider plugin. §I @@ -32,7 +32,6 @@ module: `compute.provider_catalog` → core `protocol.ProviderContract` declarat step: `step.compute_dispatch` → submit task step: `step.compute_wait` → wait/read proof step: `step.compute_map` → fanout deterministic task set -step: `step.compute_product_capture` → submit typed product-capture URL workload, wait for accepted proof, expose bounded `result_preview` cmd: `workflow-plugin-compute` → external SDK entrypoint wfctl: `wfctl compute enroll|pools|run|submit|audit|accounting export|github-runner register|github-runner bridge-job` → plugin CLI → core API @@ -58,8 +57,8 @@ V17: docs/examples distinguish `compute.provider` Workflow connection from wfcom V18: plugin guidance for public control-plane use excludes bootstrap token, provider mutation, package/campaign/trust-root mutation, and raw agent/supervisor control APIs V19: PR CI checks plugin provider catalog tests against current `GoCodeAlone/workflow-compute` main with a local module replace V20: manifest `stepTypes` exactly match runtime `StepTypes` -V21: `step.compute_product_capture` requires explicit `allowed_hosts`, supports dynamic `url_field`, and returns only task/proof ids plus bounded preview fields -V22: edge lambda/CDN catalog presets use only `protocol.ProviderContract` with `workflow-plugin-compute`, `wasm-component`, and `wasm-capability`; names/config/runtime fields must not mention product-capture, BMW, or provider-specific business logic +V21: plugin step/CLI surfaces must not mention product-capture, BMW, edge lambda, edge CDN, or another provider-specific business domain +V22: `compute.provider_catalog` accepts typed `protocol.ProviderContract` records from provider plugins without defining a parallel plugin-local provider schema §T @@ -75,8 +74,8 @@ T8|x|add `wfctl compute submit command|container-build` for ad hoc workload demo T9|x|include rewards in `wfctl compute accounting export`|I.cmd,I.wfctl,C8,V10,V16 T10|x|document external Workflow client use cases and public client-surface boundary|C9,C10,V17,V18 T11|x|align provider catalog details with workflow-compute `ProviderContract` and gate drift in PR CI|C11,I.module,V19 -T12|x|add `step.compute_product_capture` typed workload submit/wait and preview output|C12,I.step,V2,V4,V20,V21 -T13|x|add edge lambda/CDN WASM provider catalog presets and module validation proof without plugin-local provider schema or product leakage|C13,I.module,V19,V22 +T12|x|remove provider-specific product-capture step/CLI/domain preview flattening from generic compute adapter|C12,I.step,V20,V21 +T13|x|keep provider catalog validation generic so external provider plugins can supply edge/product contracts without plugin-local provider schema|C13,I.module,V19,V22 §B diff --git a/internal/cli.go b/internal/cli.go index 4a82786..6a0e262 100644 --- a/internal/cli.go +++ b/internal/cli.go @@ -197,15 +197,13 @@ func (c *computeCLI) runRun(ctx context.Context, args []string) error { func (c *computeCLI) runSubmit(ctx context.Context, args []string) error { if len(args) == 0 { - return errors.New("usage: wfctl compute submit ") + return errors.New("usage: wfctl compute submit ") } switch args[0] { case "command": return c.runSubmitCommand(ctx, args[1:]) case "container-build": return c.runSubmitContainerBuild(ctx, args[1:]) - case "product-capture": - return c.runSubmitProductCapture(ctx, args[1:]) default: return fmt.Errorf("unknown wfctl compute submit workload %q", args[0]) } @@ -283,81 +281,6 @@ func (c *computeCLI) runSubmitContainerBuild(ctx context.Context, args []string) return c.writeTaskReceipt(ctx, client, task) } -func (c *computeCLI) runSubmitProductCapture(ctx context.Context, args []string) error { - fs := c.newFlagSet("compute submit product-capture") - common := addCLICommonFlags(fs) - taskFlags := addCLITaskFlags(fs) - productURL := fs.String("url", "", "supported product URL") - allowedHosts := csvFlag{} - captureMode := fs.String("capture-mode", string(protocol.ProductCaptureModeBrowser), "capture mode") - captureTimeout := fs.Int("capture-timeout", 45, "capture timeout seconds") - maxHTMLBytes := fs.Int64("max-html-bytes", protocol.MaxProductCaptureHTMLBytes, "maximum captured HTML bytes") - maxImageCount := fs.Int("max-image-count", 8, "maximum product images to return") - metadataOnly := fs.Bool("metadata-only", false, "request metadata-only extraction when supported") - productID := fs.String("product", "", "network product id for dynamic product-capture provider routing") - providerPluginID := fs.String("provider-plugin", "workflow-plugin-product-capture", "provider plugin id") - providerID := fs.String("provider-id", "browser", "provider id") - providerContractID := fs.String("provider-contract", "product-capture.browser.v1", "provider contract id") - providerVersion := fs.String("provider-version", "v1.0.0", "provider contract version") - providerConfigRef := fs.String("provider-config-ref", "", "provider config ref") - providerOperation := fs.String("provider-operation", "capture_product", "provider operation") - providerImageRef := fs.String("provider-image-ref", "", "digest-pinned provider runtime image ref") - fs.Var(&allowedHosts, "allowed-host", "allowed URL host; repeatable or comma-separated") - if err := fs.Parse(args); err != nil { - return err - } - if err := taskFlags.validate(); err != nil { - return err - } - if *productID == "" { - return errors.New("--product is required") - } - if *providerConfigRef == "" { - *providerConfigRef = "config://network-products/" + *productID + "/browser" - } - if err := validateProviderImageRef(*providerImageRef); err != nil { - return fmt.Errorf("--provider-image-ref: %w", err) - } - input := productCaptureProviderInput{ - URL: *productURL, - AllowedHosts: allowedHosts.values(), - CaptureMode: *captureMode, - TimeoutSeconds: *captureTimeout, - MaxHTMLBytes: *maxHTMLBytes, - MaxImageCount: *maxImageCount, - MetadataOnly: *metadataOnly, - } - inputBytes, err := json.Marshal(input) - if err != nil { - return err - } - workload := protocol.WorkloadSpec{ - Kind: protocol.WorkloadProvider, - Provider: &protocol.ProviderWorkload{ - ProviderConfig: protocol.ProviderConfig{ - PluginID: *providerPluginID, - ProviderID: *providerID, - ContractID: *providerContractID, - Version: *providerVersion, - ConfigRef: *providerConfigRef, - }, - Operation: *providerOperation, - ImageRef: *providerImageRef, - Input: inputBytes, - }, - } - if err := workload.Validate(); err != nil { - return err - } - client, err := common.client() - if err != nil { - return err - } - task := taskFlags.task(workload) - task.ProductID = *productID - return c.writeTaskReceipt(ctx, client, task) -} - func (c *computeCLI) writeTaskReceipt(ctx context.Context, client *computeClient, task protocol.Task) error { submitted, err := client.submitTask(ctx, task) if err != nil { diff --git a/internal/cli_test.go b/internal/cli_test.go index b4063b4..1915357 100644 --- a/internal/cli_test.go +++ b/internal/cli_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "github.com/GoCodeAlone/workflow-compute/pkg/protocol" @@ -149,61 +150,14 @@ func TestT8_CLISubmitContainerBuild(t *testing.T) { } } -func TestV739_CLISubmitProductCapture(t *testing.T) { - var got protocol.Task - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost || r.URL.Path != "/v1/tasks" { - t.Fatalf("request: %s %s", r.Method, r.URL.Path) - } - if err := json.NewDecoder(r.Body).Decode(&got); err != nil { - t.Fatalf("decode task: %v", err) - } - w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(map[string]any{"task": got}) - })) - defer srv.Close() - +func TestCLISubmitRejectsProviderSpecificProductCapture(t *testing.T) { var stdout, stderr bytes.Buffer - code := newCLI(&stdout, &stderr).RunCLI([]string{ - "compute", "submit", "product-capture", - "--server", srv.URL, - "--token", "token", - "--id", "capture-1", - "--product", "bmw-product-capture", - "--org", "org-1", - "--pool", "pool-1", - "--url", "https://www.amazon.com/Microsoft-Xbox-Gaming-Console-video-game/dp/B08H75RTZ8", - "--provider-image-ref", testProviderImageRef, - "--allowed-host", "www.amazon.com", - "--capture-mode", "browser", - "--capture-timeout", "45", - "--max-html-bytes", "10485760", - "--max-image-count", "6", - }) - - if code != 0 { - t.Fatalf("RunCLI code=%d stderr=%s", code, stderr.String()) - } - if got.ProductID != "bmw-product-capture" { - t.Fatalf("product id: got %+v", got) - } - if got.Workload.Kind != protocol.WorkloadProvider || got.Workload.Provider == nil { - t.Fatalf("task: got %+v", got) - } - if got.Workload.Provider.ProviderConfig.PluginID != "workflow-plugin-product-capture" || - got.Workload.Provider.ProviderConfig.ProviderID != "browser" || - got.Workload.Provider.ProviderConfig.ContractID != "product-capture.browser.v1" || - got.Workload.Provider.Operation != "capture_product" || - got.Workload.Provider.ImageRef != testProviderImageRef { - t.Fatalf("provider task: %+v", got.Workload.Provider) - } - if !bytes.Contains(got.Workload.Provider.Input, []byte(`"allowed_hosts":["www.amazon.com"]`)) { - t.Fatalf("provider input: %s", got.Workload.Provider.Input) + code := newCLI(&stdout, &stderr).RunCLI([]string{"compute", "submit", "product-capture"}) + if code == 0 { + t.Fatal("product-capture submit must belong to workflow-plugin-product-capture, not workflow-plugin-compute") } - for _, forbidden := range [][]byte{[]byte("token"), []byte("signature"), []byte("workload"), []byte("amazon.com")} { - if bytes.Contains(stdout.Bytes(), forbidden) { - t.Fatalf("stdout leaked %q: %s", forbidden, stdout.String()) - } + if !strings.Contains(stderr.String(), `unknown wfctl compute submit workload "product-capture"`) { + t.Fatalf("stderr: %s", stderr.String()) } } diff --git a/internal/edge_catalog.go b/internal/edge_catalog.go deleted file mode 100644 index 7ee34f4..0000000 --- a/internal/edge_catalog.go +++ /dev/null @@ -1,78 +0,0 @@ -package internal - -import ( - "strings" - - "github.com/GoCodeAlone/workflow-compute/pkg/protocol" -) - -func EdgeWASMProviderContracts() []protocol.ProviderContract { - return []protocol.ProviderContract{ - edgeWASMProviderContract( - "edge-lambda-wasm-v1", - "Edge Lambda WASM", - "edge-lambda", - "edge-lambda.wasm.v1", - "handle_request", - "edge_lambda_response", - ), - edgeWASMProviderContract( - "edge-cdn-filter-wasm-v1", - "Edge CDN Filter WASM", - "edge-cdn-filter", - "edge-cdn-filter.wasm.v1", - "filter_request", - "cdn_decision", - ), - } -} - -func edgeWASMProviderContract(id, displayName, providerID, contractID, operationID, artifactName string) protocol.ProviderContract { - runtime := protocol.DefaultProviderRuntimeProfile("wasm-component", protocol.ExecutionWASMCapability, protocol.ProofArtifactHash) - runtime.RuntimeProfile = protocol.RuntimeProfileWASMComponent - runtime.WASM = protocol.WASMRuntimeContract{ - ABI: "wasm-export-i32-v1", - ComponentRef: "provider://" + providerID + "/component.wasm", - ComponentDigest: "sha256:" + strings.Repeat("d", 64), - Features: []string{"edge-request-v1"}, - MaxMemoryBytes: 128 << 20, - MaxRuntimeSeconds: 10, - Filesystem: "forbidden", - Network: protocol.RuntimePermissionForbidden, - NativeHostUpdates: "forbidden", - } - return protocol.ProviderContract{ - ProtocolVersion: protocol.Version, - ID: id, - DisplayName: displayName, - PluginID: "workflow-plugin-compute", - ProviderID: providerID, - ContractID: contractID, - Version: "v1.0.0", - ConfigSchemaRef: "schema://providers/workflow-plugin-compute/" + providerID + "/v1", - ConfigSchemaDigest: "sha256:" + strings.Repeat("c", 64), - OperatingModes: []protocol.NetworkOperatingMode{protocol.NetworkModeBatch}, - WorkloadKinds: []string{string(protocol.WorkloadProvider), string(protocol.WorkloadWASMComponent)}, - ExecutorProviders: []string{"wasm-component"}, - ExecutionSecurityTiers: []protocol.ExecutionSecurityTier{protocol.ExecutionWASMCapability}, - ProofTiers: []protocol.ProofTier{protocol.ProofArtifactHash}, - NetworkModes: []protocol.NetworkMode{protocol.NetworkModeRelay, protocol.NetworkModeOffline}, - Operations: []protocol.ProviderOperation{{ - ID: operationID, - InputSchemaRef: "schema://providers/workflow-plugin-compute/" + providerID + "/operations/" + operationID + "/input/v1", - InputSchemaDigest: "sha256:" + strings.Repeat("a", 64), - OutputSchemaRef: "schema://providers/workflow-plugin-compute/" + providerID + "/operations/" + operationID + "/output/v1", - OutputSchemaDigest: "sha256:" + strings.Repeat("b", 64), - Artifacts: []string{artifactName}, - ArtifactSpecs: []protocol.ProviderArtifactSpec{{ - Name: artifactName, - Required: true, - ContentType: "application/json", - MaxBytes: 1 << 20, - RetentionSeconds: 3600, - Forwardable: true, - }}, - }}, - RuntimeContract: protocol.ProviderRuntimeContract{Profiles: []protocol.ProviderRuntimeProfile{runtime}}, - } -} diff --git a/internal/module_test.go b/internal/module_test.go index b1e0db8..0e3fda2 100644 --- a/internal/module_test.go +++ b/internal/module_test.go @@ -154,48 +154,6 @@ func TestProviderCatalogRejectsMalformedWorkflowComputeContract(t *testing.T) { } } -func TestEdgeWASMProviderCatalogPresetsAreWorkflowComputeContracts(t *testing.T) { - contracts := EdgeWASMProviderContracts() - if len(contracts) != 2 { - t.Fatalf("edge contract count: got %d", len(contracts)) - } - seen := map[string]bool{} - for _, contract := range contracts { - seen[contract.ProviderID] = true - if contract.PluginID != "workflow-plugin-compute" { - t.Fatalf("contract leaked provider plugin: %+v", contract) - } - if strings.Contains(strings.ToLower(contract.ID+contract.ProviderID+contract.ContractID), "product-capture") || - strings.Contains(strings.ToLower(contract.ID+contract.ProviderID+contract.ContractID), "bmw") { - t.Fatalf("edge contract leaked product capture/BMW boundary: %+v", contract) - } - if len(contract.RuntimeContract.Profiles) != 1 { - t.Fatalf("runtime profiles: %+v", contract.RuntimeContract.Profiles) - } - runtime := contract.RuntimeContract.Profiles[0] - if runtime.RuntimeProfile != protocol.RuntimeProfileWASMComponent || - runtime.ExecutionSecurityTier != protocol.ExecutionWASMCapability || - runtime.ExecutorProvider != "wasm-component" || - runtime.WASM.ComponentDigest == "" || - runtime.WASM.Filesystem != "forbidden" || - runtime.WASM.NativeHostUpdates != "forbidden" { - t.Fatalf("edge runtime contract: %+v", runtime) - } - } - if !seen["edge-lambda"] || !seen["edge-cdn-filter"] { - t.Fatalf("edge providers missing: %+v", seen) - } - module, err := newProviderCatalogModule("edge", map[string]any{ - "contracts": []any{toMap(t, contracts[0]), toMap(t, contracts[1])}, - }) - if err != nil { - t.Fatalf("newProviderCatalogModule(edge): %v", err) - } - if len(module.config.Contracts) != 2 { - t.Fatalf("module contracts: %+v", module.config.Contracts) - } -} - func validProviderContract() protocol.ProviderContract { return protocol.ProviderContract{ ProtocolVersion: protocol.Version, diff --git a/internal/plugin.go b/internal/plugin.go index 27ca80a..93460c6 100644 --- a/internal/plugin.go +++ b/internal/plugin.go @@ -49,7 +49,6 @@ func (p *computePlugin) StepTypes() []string { "step.compute_dispatch", "step.compute_wait", "step.compute_map", - "step.compute_product_capture", } } @@ -61,8 +60,6 @@ func (p *computePlugin) CreateStep(typeName, name string, config map[string]any) return newWaitStep(name, config) case "step.compute_map": return newMapStep(name, config) - case "step.compute_product_capture": - return newProductCaptureStep(name, config) default: return nil, fmt.Errorf("compute plugin: unknown step type %q", typeName) } diff --git a/internal/steps.go b/internal/steps.go index 1d48674..3181a02 100644 --- a/internal/steps.go +++ b/internal/steps.go @@ -2,10 +2,8 @@ package internal import ( "context" - "encoding/json" "errors" "fmt" - "strings" "time" "github.com/GoCodeAlone/workflow-compute/pkg/protocol" @@ -287,238 +285,6 @@ func newMapStep(name string, raw map[string]any) (*mapStep, error) { return &mapStep{name: name, config: cfg}, nil } -type productCaptureStepConfig struct { - connectionConfig - taskConfig - ProviderPluginID string `json:"provider_plugin_id,omitempty"` - ProviderID string `json:"provider_id,omitempty"` - ProviderContractID string `json:"provider_contract_id,omitempty"` - ProviderVersion string `json:"provider_version,omitempty"` - ProviderConfigRef string `json:"provider_config_ref,omitempty"` - ProviderConfigDigest string `json:"provider_config_digest,omitempty"` - ProviderOperation string `json:"provider_operation,omitempty"` - ProviderImageRef string `json:"provider_image_ref"` - URL string `json:"url,omitempty"` - URLField string `json:"url_field,omitempty"` - AllowedHosts []string `json:"allowed_hosts"` - CaptureMode string `json:"capture_mode,omitempty"` - CaptureTimeoutSeconds int `json:"capture_timeout_seconds,omitempty"` - MaxHTMLBytes int64 `json:"max_html_bytes,omitempty"` - MaxImageCount int `json:"max_image_count,omitempty"` - MetadataOnly bool `json:"metadata_only,omitempty"` - PollInterval string `json:"poll_interval,omitempty"` - WaitTimeout string `json:"wait_timeout,omitempty"` - RequireProof *bool `json:"require_proof,omitempty"` -} - -type productCaptureStep struct { - name string - config productCaptureStepConfig -} - -func newProductCaptureStep(name string, raw map[string]any) (*productCaptureStep, error) { - var cfg productCaptureStepConfig - if err := decodeStrictMap(raw, &cfg); err != nil { - return nil, fmt.Errorf("step.compute_product_capture %q: %w", name, err) - } - if err := errors.Join(cfg.connectionConfig.validate(), cfg.taskConfig.validate()); err != nil { - return nil, fmt.Errorf("step.compute_product_capture %q: %w", name, err) - } - if cfg.ProductID == "" { - return nil, fmt.Errorf("step.compute_product_capture %q: product_id is required", name) - } - if cfg.URL == "" && cfg.URLField == "" { - return nil, fmt.Errorf("step.compute_product_capture %q: url or url_field is required", name) - } - if len(cfg.AllowedHosts) == 0 { - return nil, fmt.Errorf("step.compute_product_capture %q: allowed_hosts is required", name) - } - if err := validateProviderImageRef(cfg.ProviderImageRef); err != nil { - return nil, fmt.Errorf("step.compute_product_capture %q: provider_image_ref: %w", name, err) - } - if cfg.PollInterval != "" { - if d, err := time.ParseDuration(cfg.PollInterval); err != nil { - return nil, fmt.Errorf("step.compute_product_capture %q: poll_interval must be duration: %w", name, err) - } else if d <= 0 { - return nil, fmt.Errorf("step.compute_product_capture %q: poll_interval must be positive", name) - } - } - if cfg.WaitTimeout != "" { - if d, err := time.ParseDuration(cfg.WaitTimeout); err != nil { - return nil, fmt.Errorf("step.compute_product_capture %q: wait_timeout must be duration: %w", name, err) - } else if d <= 0 { - return nil, fmt.Errorf("step.compute_product_capture %q: wait_timeout must be positive", name) - } - } - return &productCaptureStep{name: name, config: cfg}, nil -} - -func validateProviderImageRef(value string) error { - if strings.TrimSpace(value) == "" { - return errors.New("is required") - } - if strings.TrimSpace(value) != value || strings.ContainsAny(value, "\t\r\n \x00") { - return errors.New("must not contain whitespace or NUL") - } - _, digest, ok := strings.Cut(value, "@") - if !ok || !validSHA256Digest(digest) { - return errors.New("must be digest-pinned with @sha256:<64 hex>") - } - return nil -} - -func validSHA256Digest(value string) bool { - if len(value) != len("sha256:")+64 || !strings.HasPrefix(value, "sha256:") { - return false - } - for _, r := range value[len("sha256:"):] { - if (r < '0' || r > '9') && (r < 'a' || r > 'f') && (r < 'A' || r > 'F') { - return false - } - } - return true -} - -func (s *productCaptureStep) Execute(ctx context.Context, _ map[string]any, _ map[string]map[string]any, current map[string]any, metadata map[string]any, runtimeConfig map[string]any) (*sdk.StepResult, error) { - url := s.config.URL - if url == "" { - url = stateString(current, s.config.URLField) - } - url = strings.TrimSpace(url) - if url == "" { - return errorResult("product capture url is required"), nil - } - client, err := s.config.connectionConfig.client(ctx, metadata, runtimeConfig) - if err != nil { - return errorResult(err.Error()), nil - } - input := productCaptureProviderInput{ - URL: url, - AllowedHosts: append([]string(nil), s.config.AllowedHosts...), - CaptureMode: s.config.CaptureMode, - TimeoutSeconds: s.config.CaptureTimeoutSeconds, - MaxHTMLBytes: s.config.MaxHTMLBytes, - MaxImageCount: s.config.MaxImageCount, - MetadataOnly: s.config.MetadataOnly, - } - if input.CaptureMode == "" { - input.CaptureMode = string(protocol.ProductCaptureModeBrowser) - } - inputBytes, err := json.Marshal(input) - if err != nil { - return errorResult(err.Error()), nil - } - workload := protocol.WorkloadSpec{ - Kind: protocol.WorkloadProvider, - Provider: &protocol.ProviderWorkload{ - ProviderConfig: s.productCaptureProviderConfig(), - Operation: s.productCaptureProviderOperation(), - ImageRef: s.config.ProviderImageRef, - Input: inputBytes, - }, - } - if err := workload.Validate(); err != nil { - return errorResult(err.Error()), nil - } - task, err := client.submitTask(ctx, buildTask(s.config.taskConfig, workload)) - if err != nil { - return errorResult(err.Error()), nil - } - output, err := s.waitForProductCapture(ctx, client, task.ID) - if err != nil { - return errorResult(err.Error()), nil - } - if output["error"] != nil { - return &sdk.StepResult{StopPipeline: true, Output: output}, nil - } - return &sdk.StepResult{Output: output}, nil -} - -type productCaptureProviderInput struct { - URL string `json:"url"` - AllowedHosts []string `json:"allowed_hosts"` - CaptureMode string `json:"capture_mode,omitempty"` - TimeoutSeconds int `json:"timeout_seconds,omitempty"` - MaxHTMLBytes int64 `json:"max_html_bytes,omitempty"` - MaxImageCount int `json:"max_image_count,omitempty"` - MetadataOnly bool `json:"metadata_only,omitempty"` -} - -func (s *productCaptureStep) productCaptureProviderConfig() protocol.ProviderConfig { - cfg := protocol.ProviderConfig{ - PluginID: defaultString(s.config.ProviderPluginID, "workflow-plugin-product-capture"), - ProviderID: defaultString(s.config.ProviderID, "browser"), - ContractID: defaultString(s.config.ProviderContractID, "product-capture.browser.v1"), - Version: defaultString(s.config.ProviderVersion, "v1.0.0"), - ConfigRef: s.config.ProviderConfigRef, - ConfigDigest: s.config.ProviderConfigDigest, - } - if cfg.ConfigRef == "" { - cfg.ConfigRef = "config://network-products/" + s.config.ProductID + "/browser" - } - return cfg -} - -func (s *productCaptureStep) productCaptureProviderOperation() string { - return defaultString(s.config.ProviderOperation, "capture_product") -} - -func defaultString(value, fallback string) string { - if value == "" { - return fallback - } - return value -} - -func (s *productCaptureStep) waitForProductCapture(ctx context.Context, client *computeClient, taskID string) (map[string]any, error) { - pollInterval := durationOrDefault(s.config.PollInterval, time.Second) - timeout := durationOrDefault(s.config.WaitTimeout, 5*time.Minute) - waitCtx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - for { - task, found, stalls, err := client.taskSnapshot(waitCtx, taskID) - if err != nil { - return nil, err - } - if !found { - return nil, fmt.Errorf("task %q not found", taskID) - } - actionableStalls := waitConfig{RequireProof: s.config.RequireProof}.actionableStalls(stalls) - if task.Status == protocol.TaskFailed || task.Status == protocol.TaskStalled || len(actionableStalls) > 0 { - output := taskOutput(task) - if len(actionableStalls) > 0 { - addStallOutput(output, actionableStalls[0]) - } - output["error"] = taskWaitError(task, actionableStalls) - return output, nil - } - if isTerminalTaskStatus(task.Status) { - proof, hasProof, err := client.findProof(waitCtx, task.ID) - if err != nil { - return nil, err - } - output := taskOutput(task) - if hasProof { - addProofOutput(output, proof) - } - if hasProof && proof.Verifier.Status != protocol.VerificationAccepted { - output["error"] = fmt.Sprintf("task %q proof %q is %s", task.ID, proof.ID, proof.Verifier.Status) - return output, nil - } - if hasProof || !(waitConfig{RequireProof: s.config.RequireProof}).requireProof() { - return output, nil - } - } - timer := time.NewTimer(pollInterval) - select { - case <-waitCtx.Done(): - timer.Stop() - return nil, fmt.Errorf("timed out waiting for task %q", taskID) - case <-timer.C: - } - } -} - func (s *mapStep) Execute(ctx context.Context, _ map[string]any, _ map[string]map[string]any, _ map[string]any, metadata map[string]any, runtimeConfig map[string]any) (*sdk.StepResult, error) { client, err := s.config.connectionConfig.client(ctx, metadata, runtimeConfig) if err != nil { @@ -658,47 +424,6 @@ func addProofOutput(output map[string]any, proof protocol.ProofReceipt) { output["artifact_hash"] = proof.ArtifactHash if len(proof.ResultPreview) > 0 { output["result_preview"] = proof.ResultPreview - for key, value := range proof.ResultPreview { - if _, exists := output[key]; !exists && flattenedPreviewField(key) { - output[key] = value - } - } - } -} - -func flattenedPreviewField(key string) bool { - switch key { - case "affiliate_url", - "asin", - "availability", - "canonical_url", - "captured_at", - "confidence", - "currency", - "description", - "external_id", - "image_url", - "images", - "marketplace", - "merchant", - "price", - "prime_eligible", - "product_url", - "provider", - "provider_version", - "rating", - "requires_user_confirmation", - "review_count", - "seller", - "shipping_summary", - "ships_from", - "source", - "title", - "url", - "variant": - return true - default: - return false } } diff --git a/internal/steps_test.go b/internal/steps_test.go index 12c4e15..5f3426b 100644 --- a/internal/steps_test.go +++ b/internal/steps_test.go @@ -13,17 +13,29 @@ import ( "github.com/GoCodeAlone/workflow-compute/pkg/protocol" ) -const testProviderImageRef = "ghcr.io/gocodealone/product-capture-browser@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +const testProviderImageRef = "ghcr.io/gocodealone/generic-provider@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" func TestStepTypes(t *testing.T) { steps := NewPlugin().(interface{ StepTypes() []string }) got := steps.StepTypes() - want := []string{"step.compute_dispatch", "step.compute_wait", "step.compute_map", "step.compute_product_capture"} + want := []string{"step.compute_dispatch", "step.compute_wait", "step.compute_map"} if !reflect.DeepEqual(got, want) { t.Fatalf("step types: got %#v", got) } } +func TestComputePluginDoesNotExposeProviderSpecificProductCapture(t *testing.T) { + plugin := NewPlugin().(*computePlugin) + for _, stepType := range plugin.StepTypes() { + if strings.Contains(stepType, "product") || strings.Contains(stepType, "capture") { + t.Fatalf("compute plugin leaked provider-specific step type %q", stepType) + } + } + if _, err := plugin.CreateStep("step.compute_product_capture", "capture", map[string]any{}); err == nil { + t.Fatal("compute plugin accepted provider-specific product capture step") + } +} + func TestPluginManifestStepTypesMatchRuntime(t *testing.T) { data, err := os.ReadFile("../plugin.json") if err != nil { @@ -114,7 +126,7 @@ func TestDispatchStepAcceptsProviderWorkload(t *testing.T) { })) defer srv.Close() - step, err := newDispatchStep("dispatch", productCaptureConfigMap(srv.URL)) + step, err := newDispatchStep("dispatch", genericProviderConfigMap(srv.URL)) if err != nil { t.Fatalf("newDispatchStep: %v", err) } @@ -125,28 +137,28 @@ func TestDispatchStepAcceptsProviderWorkload(t *testing.T) { if result.StopPipeline { t.Fatalf("unexpected stop: %+v", result.Output) } - if got.ProductID != "bmw-product-capture" { - t.Fatalf("product id: got %+v", got) + if got.ProductID != "" { + t.Fatalf("dispatch should not inject product identity: got %+v", got) } if got.Workload.Kind != protocol.WorkloadProvider || got.Workload.Provider == nil { t.Fatalf("workload: got %+v", got.Workload) } - if got.Workload.Provider.ProviderConfig != productCaptureProviderConfig("bmw-product-capture") { + if got.Workload.Provider.ProviderConfig != genericProviderConfig() { t.Fatalf("provider config: %+v", got.Workload.Provider.ProviderConfig) } - if got.Workload.Provider.Operation != "capture_product" { + if got.Workload.Provider.Operation != "transform" { t.Fatalf("operation: %q", got.Workload.Provider.Operation) } if got.Workload.Provider.ImageRef != testProviderImageRef { t.Fatalf("image ref: %q", got.Workload.Provider.ImageRef) } - if !strings.Contains(string(got.Workload.Provider.Input), `"url":"https://www.amazon.com/Microsoft-Xbox-Gaming-Console-video-game/dp/B08H75RTZ8"`) { + if !strings.Contains(string(got.Workload.Provider.Input), `"value":"hello"`) { t.Fatalf("provider input: %s", got.Workload.Provider.Input) } } func TestDispatchStepRejectsUnknownNestedProviderConfig(t *testing.T) { - cfg := productCaptureConfigMap("https://compute.example.test") + cfg := genericProviderConfigMap("https://compute.example.test") workload := cfg["workload"].(map[string]any) provider := workload["provider"].(map[string]any) provider["extra"] = true @@ -155,124 +167,6 @@ func TestDispatchStepRejectsUnknownNestedProviderConfig(t *testing.T) { } } -func TestProductCaptureStepDispatchesDynamicURLAndReturnsPreview(t *testing.T) { - var submitted protocol.Task - var taskCalls int - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == http.MethodPost && r.URL.Path == "/v1/tasks": - if err := json.NewDecoder(r.Body).Decode(&submitted); err != nil { - t.Fatalf("decode task: %v", err) - } - w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(map[string]any{"task": submitted}) - case r.Method == http.MethodGet && r.URL.Path == "/v1/tasks": - taskCalls++ - status := protocol.TaskQueued - if taskCalls > 1 { - status = protocol.TaskSucceeded - } - _ = json.NewEncoder(w).Encode(map[string]any{"tasks": []protocol.Task{{ - ID: submitted.ID, - OrgID: submitted.OrgID, - PoolID: submitted.PoolID, - Status: status, - }}, "stalls": []any{}}) - case r.Method == http.MethodGet && r.URL.Path == "/v1/proofs": - proof := proofReceipt(submitted.ID) - proof.ResultPreview = map[string]any{ - "title": "Xbox Series X", - "seller": "Sole Providers", - "prime_eligible": false, - "error": "diagnostic only", - } - _ = json.NewEncoder(w).Encode(map[string]any{"proofs": []protocol.ProofReceipt{proof}}) - default: - t.Fatalf("request: %s %s", r.Method, r.URL.Path) - } - })) - defer srv.Close() - - step, err := newProductCaptureStep("capture", map[string]any{ - "server_url": srv.URL, - "auth_token_ref": "secret:compute-token", - "id": "capture-1", - "product_id": "bmw-product-capture", - "org_id": "org-1", - "pool_id": "pool-1", - "policy_id": "policy-1", - "timeout_seconds": 90, - "url_field": "url", - "allowed_hosts": []any{"www.amazon.com", "amazon.com"}, - "provider_image_ref": testProviderImageRef, - "capture_timeout_seconds": 45, - "max_html_bytes": 1 << 20, - "max_image_count": 8, - "poll_interval": "1ms", - "wait_timeout": "100ms", - }) - if err != nil { - t.Fatalf("newProductCaptureStep: %v", err) - } - result, err := step.Execute(context.Background(), nil, nil, map[string]any{ - "url": "https://www.amazon.com/dp/B0DL7CKRJ5?th=1", - }, nil, runtimeSecrets()) - if err != nil { - t.Fatalf("Execute: %v", err) - } - if result.StopPipeline { - t.Fatalf("unexpected stop: %+v", result.Output) - } - if submitted.ProductID != "bmw-product-capture" { - t.Fatalf("submitted product id: %+v", submitted) - } - if submitted.Workload.Kind != protocol.WorkloadProvider || submitted.Workload.Provider == nil { - t.Fatalf("submitted workload: %+v", submitted.Workload) - } - if submitted.Workload.Provider.ProviderConfig != productCaptureProviderConfig("bmw-product-capture") { - t.Fatalf("provider config: %+v", submitted.Workload.Provider.ProviderConfig) - } - if submitted.Workload.Provider.Operation != "capture_product" { - t.Fatalf("operation: %q", submitted.Workload.Provider.Operation) - } - if submitted.Workload.Provider.ImageRef != testProviderImageRef { - t.Fatalf("image ref: %q", submitted.Workload.Provider.ImageRef) - } - if !strings.Contains(string(submitted.Workload.Provider.Input), `"url":"https://www.amazon.com/dp/B0DL7CKRJ5?th=1"`) { - t.Fatalf("provider input: %s", submitted.Workload.Provider.Input) - } - if result.Output["title"] != "Xbox Series X" || result.Output["seller"] != "Sole Providers" || result.Output["prime_eligible"] != false { - t.Fatalf("preview output: %+v", result.Output) - } - if result.Output["error"] != nil { - t.Fatalf("preview error key should not be promoted: %+v", result.Output) - } -} - -func TestProductCaptureStepRejectsUnknownConfig(t *testing.T) { - cfg := productCaptureConfigMap("https://compute.example.test") - cfg["url_field"] = "url" - cfg["allowed_hosts"] = []any{"www.amazon.com"} - cfg["provider_image_ref"] = testProviderImageRef - cfg["unknown"] = true - delete(cfg, "workload") - if _, err := newProductCaptureStep("capture", cfg); err == nil { - t.Fatal("expected strict unknown-field error") - } -} - -func TestProductCaptureStepAcceptsWorkflowInternalConfigDir(t *testing.T) { - cfg := productCaptureConfigMap("https://compute.example.test") - cfg["url_field"] = "url" - cfg["allowed_hosts"] = []any{"www.amazon.com"} - cfg["provider_image_ref"] = testProviderImageRef - cfg["_config_dir"] = "/app" - delete(cfg, "workload") - if _, err := newProductCaptureStep("capture", cfg); err != nil { - t.Fatalf("expected Workflow-injected _config_dir to be accepted: %v", err) - } -} - func TestWaitStepReadsTaskStatus(t *testing.T) { var taskCalls int srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -923,10 +817,9 @@ func taskConfigMap(id string) map[string]any { } } -func productCaptureConfigMap(serverURL string) map[string]any { +func genericProviderConfigMap(serverURL string) map[string]any { cfg := map[string]any{ - "id": "capture-1", - "product_id": "bmw-product-capture", + "id": "provider-1", "org_id": "org-1", "pool_id": "pool-1", "policy_id": "policy-1", @@ -937,21 +830,16 @@ func productCaptureConfigMap(serverURL string) map[string]any { "kind": "provider", "provider": map[string]any{ "provider_config": map[string]any{ - "plugin_id": "workflow-plugin-product-capture", - "provider_id": "browser", - "contract_id": "product-capture.browser.v1", + "plugin_id": "workflow-plugin-generic-provider", + "provider_id": "transformer", + "contract_id": "generic-transform.v1", "version": "v1.0.0", - "config_ref": "config://network-products/bmw-product-capture/browser", + "config_ref": "config://providers/generic-transformer/main", }, - "operation": "capture_product", + "operation": "transform", "image_ref": testProviderImageRef, "input": map[string]any{ - "url": "https://www.amazon.com/Microsoft-Xbox-Gaming-Console-video-game/dp/B08H75RTZ8", - "allowed_hosts": []any{"www.amazon.com"}, - "capture_mode": "browser", - "timeout_seconds": 45, - "max_html_bytes": 10485760, - "max_image_count": 6, + "value": "hello", }, }, }, @@ -959,13 +847,13 @@ func productCaptureConfigMap(serverURL string) map[string]any { return cfg } -func productCaptureProviderConfig(productID string) protocol.ProviderConfig { +func genericProviderConfig() protocol.ProviderConfig { return protocol.ProviderConfig{ - PluginID: "workflow-plugin-product-capture", - ProviderID: "browser", - ContractID: "product-capture.browser.v1", + PluginID: "workflow-plugin-generic-provider", + ProviderID: "transformer", + ContractID: "generic-transform.v1", Version: "v1.0.0", - ConfigRef: "config://network-products/" + productID + "/browser", + ConfigRef: "config://providers/generic-transformer/main", } } diff --git a/plugin.json b/plugin.json index 56ba82a..6ec23fb 100644 --- a/plugin.json +++ b/plugin.json @@ -13,7 +13,7 @@ "capabilities": { "configProvider": false, "moduleTypes": ["compute.provider", "compute.pool", "compute.provider_catalog"], - "stepTypes": ["step.compute_dispatch", "step.compute_wait", "step.compute_map", "step.compute_product_capture"], + "stepTypes": ["step.compute_dispatch", "step.compute_wait", "step.compute_map"], "triggerTypes": [], "cliCommands": [ {