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
17 changes: 8 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

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,
task status, proof reads, credential lifecycle, and readiness. Provider
Expand Down Expand Up @@ -101,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
Expand Down
10 changes: 6 additions & 4 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +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.
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

Expand All @@ -31,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

Expand All @@ -57,7 +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
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

Expand All @@ -73,7 +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
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

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
8 changes: 2 additions & 6 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
79 changes: 1 addition & 78 deletions internal/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <command|container-build|product-capture>")
return errors.New("usage: wfctl compute submit <command|container-build>")
}
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])
}
Expand Down Expand Up @@ -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 {
Expand Down
60 changes: 7 additions & 53 deletions internal/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/GoCodeAlone/workflow-compute/pkg/protocol"
Expand Down Expand Up @@ -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())
}
}

Expand Down
3 changes: 0 additions & 3 deletions internal/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ func (p *computePlugin) StepTypes() []string {
"step.compute_dispatch",
"step.compute_wait",
"step.compute_map",
"step.compute_product_capture",
}
}

Expand All @@ -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)
}
Expand Down
Loading
Loading