diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c22c981..068c725 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -28,7 +28,7 @@ jobs: go env -w GONOSUMDB=github.com/GoCodeAlone/* - name: Install wfctl run: | - curl -fsSL -o wfctl https://github.com/GoCodeAlone/workflow/releases/download/v0.27.0/wfctl-linux-amd64 + curl -fsSL -o wfctl https://github.com/GoCodeAlone/workflow/releases/download/v0.63.1/wfctl-linux-amd64 chmod +x wfctl sudo mv wfctl /usr/local/bin/wfctl - name: Test @@ -44,6 +44,6 @@ jobs: - name: Check workflow-compute provider alignment run: scripts/check-workflow-compute-alignment.sh "$GITHUB_WORKSPACE/workflow-compute" - name: Validate workflow config - run: wfctl validate workflow.yaml + run: wfctl validate --allow-no-entry-points workflow.yaml - name: Build with wfctl run: wfctl build --config workflow.yaml --no-push --tag ci diff --git a/README.md b/README.md index a592dc0..4824117 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,15 @@ steps: timeout_seconds: 1800 labels: app: example-api + residue_policy: + mode: session-bound + allowed_modes: + - isolated + - session-bound + session_key: ci-main + max_age_seconds: 1800 + max_reuse_count: 3 + wipe_on_failure: true workload: kind: container-build container_build: @@ -102,6 +111,11 @@ steps: timeout: 30m ``` +`residue_policy` is optional task intent for short-lived workloads, useful for +bounded CI dependency caches. The wfcompute provider runtime profile and network +product must also allow the requested mode; core `workflow-compute` resolves the +effective lease policy and enforces workspace reuse or isolation. + 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. @@ -110,7 +124,7 @@ pipeline if any task fails, stalls, times out, or produces a non-accepted proof. ```sh GOWORK=off go test ./... -wfctl validate workflow.yaml +wfctl validate --allow-no-entry-points workflow.yaml GOWORK=off wfctl build --config workflow.yaml --no-push --tag local ``` diff --git a/SPEC.md b/SPEC.md index e9a7351..1796628 100644 --- a/SPEC.md +++ b/SPEC.md @@ -21,6 +21,7 @@ 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: 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. +C14: Task residue policy submitted by this plugin is customer intent only; `workflow-compute` owns provider/product authority resolution, policy hashing, lease enforcement, and residue cleanup. §I @@ -59,6 +60,7 @@ V19: PR CI checks plugin provider catalog tests against current `GoCodeAlone/wor V20: manifest `stepTypes` exactly match runtime `StepTypes` 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 +V23: `step.compute_dispatch` and `step.compute_map` accept valid short-lived task `residue_policy`, reject malformed residue policy locally, and do not compute policy hashes or override provider/product authority §T @@ -76,6 +78,7 @@ T10|x|document external Workflow client use cases and public client-surface boun T11|x|align provider catalog details with workflow-compute `ProviderContract` and gate drift in PR CI|C11,I.module,V19 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 +T14|x|submit optional short-lived task residue policy through dispatch/map steps without taking over core authority resolution|C14,I.step,V23 §B diff --git a/docs/plans/2026-05-24-residue-policy-plugin-surface-alignment.md b/docs/plans/2026-05-24-residue-policy-plugin-surface-alignment.md new file mode 100644 index 0000000..982bc23 --- /dev/null +++ b/docs/plans/2026-05-24-residue-policy-plugin-surface-alignment.md @@ -0,0 +1,32 @@ +# Residue Policy Plugin Surface Alignment + +### Alignment Report + +**Status:** PASS + +**Coverage:** + +| Design Requirement | Plan Task(s) | Status | +|---|---|---| +| Add optional `protocol.ResiduePolicy` to shared task config. | Task 1, Task 2 | Covered | +| Validate malformed residue policy locally while leaving authority checks to core. | Task 1, Task 2 | Covered | +| Copy task residue policy into submitted `protocol.Task`. | Task 1, Task 2 | Covered | +| Support both dispatch and map steps through shared config. | Task 1, Task 2 | Covered | +| Keep `compute.provider_catalog` typed as `[]protocol.ProviderContract`. | Task 1 | Covered | +| Prove reusable residue is rejected for no-workspace runtime profiles. | Task 1 | Covered | +| Update to a current `workflow-compute` dependency and keep provider alignment check. | Task 3, Task 4 | Covered | +| Document optional usage and rollback by removing `residue_policy`. | Task 3 | Covered | +| Avoid new scheduler, lease, worker, product schema, or long-lived service behavior. | Scope Manifest, Task 2, Task 3 | Covered | + +**Scope Check:** + +| Plan Task | Design Requirement | Status | +|---|---|---| +| Task 1 | Tests for dispatch/map pass-through and provider runtime contract validation. | Justified | +| Task 2 | Shared task config validation and task submission pass-through. | Justified | +| Task 3 | Dependency freshness, SPEC invariant, and README optional usage. | Justified | +| Task 4 | Full verification, provider alignment, wfctl checks, and PR monitoring. | Justified | + +**Drift Items:** None. + +Manifest check: `plan-scope-check.sh --plan /Users/jon/workspace/workflow-plugin-compute/docs/plans/2026-05-24-residue-policy-plugin-surface.md` passed. diff --git a/docs/plans/2026-05-24-residue-policy-plugin-surface-design-review.md b/docs/plans/2026-05-24-residue-policy-plugin-surface-design-review.md new file mode 100644 index 0000000..271dab0 --- /dev/null +++ b/docs/plans/2026-05-24-residue-policy-plugin-surface-design-review.md @@ -0,0 +1,48 @@ +# Residue Policy Plugin Surface Design Review + +### Adversarial Review Report + +**Phase:** design +**Artifact:** docs/plans/2026-05-24-residue-policy-plugin-surface-design.md +**Status:** PASS + +**Findings (Critical):** +- None. + +**Findings (Important):** +- None. Initial dependency-staleness concern was resolved in the design by + requiring a `workflow-compute` revision that includes the final residue + guardrails and by keeping the existing local provider-alignment check. + +**Findings (Minor):** +- [YAGNI] Tests section: A service-only network-product rejection test would + imply this generic plugin owns product schemas, which conflicts with the + existing provider-catalog-only module boundary. Recommendation: keep plugin + tests focused on task pass-through and provider runtime contract validation. +- [Rollback] Rollback mentions removing `residue_policy` from configs but does + not require a docs example. Recommendation: update README only with a small + example so rollback remains "remove the optional field." + +**Bug-class scan transcript:** + +| Class | Result | Note | +|---|---|---| +| Unstated assumptions | Clean | The design now states protocol authority, provider-contract input shape, task intent boundaries, strict decode behavior, and dependency availability. | +| Repo-precedent conflicts | Clean | `AGENTS.md` and `SPEC.md` say this plugin translates Workflow schemas and must not own scheduler/provider semantics; the design keeps that boundary. | +| YAGNI violations | Finding | Product/service rejection tests would exceed this plugin's module boundary; narrowed to provider runtime contract validation. | +| Missing failure modes | Clean | Malformed residue config, stale dependency, and authority rejection are covered by plugin validation plus core validation. | +| Security / privacy at architecture level | Clean | The design does not add secret surfaces or local execution; reusable residue authority remains in core. | +| Rollback story | Finding | Rollback is additive and feasible, but docs should keep the optional field obvious. | +| Simpler alternative not considered | Clean | Doing nothing and adding a separate step/module were considered and rejected. | +| User-intent drift | Clean | The design supports provider/customer residue intent through plugin-first Workflow usage without changing long-lived service semantics. | + +**Options the author may not have considered:** +1. Provider-catalog-only support: This would avoid task config changes, but it + leaves customers unable to request session/provider/worker-bound behavior + from Workflow steps. +2. Plugin-local residue schema: This could give nicer YAML docs, but it would + duplicate core protocol validation and create drift risk. + +**Verdict reasoning:** PASS. The design is narrow, aligned with the existing +plugin/core boundary, and security-sensitive enforcement stays in +`workflow-compute`. Minor findings can be handled in the implementation plan. diff --git a/docs/plans/2026-05-24-residue-policy-plugin-surface-design.md b/docs/plans/2026-05-24-residue-policy-plugin-surface-design.md new file mode 100644 index 0000000..e633742 --- /dev/null +++ b/docs/plans/2026-05-24-residue-policy-plugin-surface-design.md @@ -0,0 +1,100 @@ +# Residue Policy Plugin Surface Design + +## Goal + +Expose `workflow-compute` short-lived residue policy through the generic +Workflow compute plugin without moving scheduler, lease, worker, or provider +authority semantics into the plugin. + +## Context + +`workflow-compute` now models residue policy on provider runtime profiles, +network products, tasks, and leases. The generic plugin already accepts typed +`protocol.ProviderContract` records in `compute.provider_catalog`, so provider +and product policy can flow through that module without a plugin-local schema. +The remaining gap is task intent: `step.compute_dispatch` and +`step.compute_map` cannot currently submit a task-level `residue_policy`. + +## Approaches + +1. Add `protocol.ResiduePolicy` to the existing task config and pass it through + to `protocol.Task`. + This keeps the plugin thin, uses core validation types, and supports both + dispatch and map steps through the shared `taskConfig`. +2. Add a new residue-specific step or module. + This would make residue policy visible, but it would create a second task + submission path and pressure the plugin toward scheduler policy ownership. +3. Leave the plugin unchanged and rely on direct core API/CLI users for residue + policy. + This preserves current behavior but fails the Workflow/plugin-first use case. + +Use approach 1. + +## Design + +`taskConfig` gains an optional `residue_policy` field of type +`protocol.ResiduePolicy`. `taskConfig.validate` calls +`ResiduePolicy.Validate`, requiring explicit opt-in for `worker-bound` task +requests and allowing `session-bound` only when `session_key` is present. The +plugin does not calculate policy hashes or intersect provider/product allowed +modes; `workflow-compute` still resolves and tightens the effective lease +policy. + +`buildTask` copies the configured policy into the submitted `protocol.Task`. +`step.compute_dispatch` and each `step.compute_map` task get the same behavior +because they already share `taskConfig`. + +The plugin dependency should be updated to a `workflow-compute` revision that +contains the final residue policy guardrails, including long-lived +service-product rejection. The existing provider-alignment script keeps PR CI +checking the plugin against a local checkout of current `workflow-compute`. + +`compute.provider_catalog` stays typed as `[]protocol.ProviderContract`. +Provider runtime profile residue policy and no-workspace validation are already +covered by `ProviderContract.Validate`, so the plugin only needs tests proving +that catalog configs carrying residue policy validate and that no-workspace +runtime profiles reject reusable residue. + +## Assumptions + +- `workflow-compute/pkg/protocol` is the stable source of truth for residue + field names and validation rules. +- Provider plugins will emit `ProviderContract` and product definitions using + core protocol types rather than plugin-local YAML shortcuts. +- Task-level residue policy is declarative intent; core remains responsible for + admission, policy intersection, hashing, and lease enforcement. +- Existing Workflow strict decoding is sufficient for rejecting unknown residue + fields because the field uses the core struct. +- A current `workflow-compute` module revision is available to the plugin CI via + private module auth or the existing local alignment checkout. + +## Self-Challenge + +- The lazy option is to do nothing because provider catalog already accepts + typed contracts. That misses task-level customer intent, which was part of the + residue model. +- The fragile assumption is that provider/product definitions arrive as core + protocol records. If that changes, this plugin should still reject local + shortcuts rather than inventing a second schema. +- The main partial-failure case is malformed residue policy reaching the core + API after Workflow validation. Early plugin validation catches local shape and + mode errors, while core still rejects authority violations. +- A stale core dependency would make the plugin appear to support residue while + missing later guardrails. The plan must include a dependency update plus the + existing local alignment check. + +## Tests + +- Dispatch submits a `residue_policy` unchanged. +- Map submits per-task residue policies unchanged. +- Dispatch rejects unsupported residue modes and implicit `worker-bound`. +- Provider catalog accepts runtime profiles with allowed residue modes and host + workspace support. +- Provider catalog rejects reusable residue on no-workspace runtime profiles. + +## Rollback + +This is an additive config surface. Roll back by removing `residue_policy` from +Workflow configs and reverting the plugin commit. Already submitted tasks keep +their core task policy; operators can use the core residue wipe procedures if +reusable workspace state was created. diff --git a/docs/plans/2026-05-24-residue-policy-plugin-surface-plan-review.md b/docs/plans/2026-05-24-residue-policy-plugin-surface-plan-review.md new file mode 100644 index 0000000..0dd5886 --- /dev/null +++ b/docs/plans/2026-05-24-residue-policy-plugin-surface-plan-review.md @@ -0,0 +1,50 @@ +# Residue Policy Plugin Surface Plan Review + +### Adversarial Review Report + +**Phase:** plan +**Artifact:** docs/plans/2026-05-24-residue-policy-plugin-surface.md +**Status:** PASS + +**Findings (Critical):** +- None. + +**Findings (Important):** +- None. + +**Findings (Minor):** +- [Verification-class mismatch] Task 4 runs `wfctl build`, which can fail for + environment prerequisites outside the plugin. Recommendation: treat + documented environment failures as non-blocking only after checking the output + is not a plugin/config regression. +- [Over-decomposition] Task 3 combines dependency, SPEC, and README edits. + Recommendation: acceptable for a one-PR plugin surface, but keep the commit + scoped and do not add examples beyond the optional residue block. + +**Bug-class scan transcript:** + +| Class | Result | Note | +|---|---|---| +| Unstated assumptions | Clean | The design and plan state core protocol authority and dependency availability. | +| Repo-precedent conflicts | Clean | The plan follows `AGENTS.md` and `SPEC.md` by translating Workflow config only. | +| YAGNI violations | Clean | No new module, product schema, or scheduler behavior is planned. | +| Missing failure modes | Clean | Malformed residue, no-workspace reusable residue, stale core dependency, and CI failures have checks. | +| Security / privacy at architecture level | Clean | No new secret/logging surface; residue authority remains in core. | +| Rollback story | Clean | Runtime-affecting task steps include revert/remove-field rollback notes. | +| Simpler alternative not considered | Clean | Doing nothing and a residue-specific step were considered in the design and rejected. | +| User-intent drift | Clean | The plan keeps Workflow/plugin-first task intent support and avoids long-lived service changes. | +| Over-decomposition / under-decomposition | Finding | Task 3 groups docs/dependency/SPEC edits; acceptable because they are small and coupled. | +| Verification-class mismatch | Finding | `wfctl build` environment failures need output inspection before accepting as prerequisite-only. | +| Hidden serial dependencies | Clean | Tasks are intentionally serial and touch shared files in a single PR. | +| Missing rollback wiring | Clean | Rollback notes are present where the config surface and dependency can affect runtime behavior. | + +**Options the author may not have considered:** +1. Split dependency update into a separate PR: cleaner rollback, but too much + process overhead for a small field pass-through that must compile against the + new protocol. +2. Skip README changes: lower churn, but makes rollback and usage less obvious + for future plugin users. + +**Verdict reasoning:** PASS. The plan is narrow, test-first, and uses the +existing provider-alignment script to catch protocol drift. Minor findings can +be handled during execution. diff --git a/docs/plans/2026-05-24-residue-policy-plugin-surface.md b/docs/plans/2026-05-24-residue-policy-plugin-surface.md new file mode 100644 index 0000000..a8ba41a --- /dev/null +++ b/docs/plans/2026-05-24-residue-policy-plugin-surface.md @@ -0,0 +1,228 @@ +# Residue Policy Plugin Surface Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Let Workflow compute dispatch/map steps submit task-level `residue_policy` while keeping authority and enforcement in `workflow-compute`. + +**Architecture:** Add optional `protocol.ResiduePolicy` to the shared task config used by `step.compute_dispatch` and `step.compute_map`. Validate local shape with core protocol validation, copy the policy into submitted tasks, and keep provider runtime policy in typed `ProviderContract` catalog records. + +**Tech Stack:** Go, `workflow-compute/pkg/protocol`, Workflow external plugin SDK, `wfctl`. + +**Base branch:** main + +--- + +## Scope Manifest + +**PR Count:** 1 +**Tasks:** 4 +**Estimated Lines of Change:** ~180 + +**Out of scope:** +- New residue-specific steps/modules. +- Plugin-local provider/product schemas. +- Scheduler, lease, worker workspace, or residue cleanup semantics. +- Long-lived service runtime behavior changes. + +**PR Grouping:** + +| PR # | Title | Tasks | Branch | +|------|-------|-------|--------| +| 1 | Expose residue policy in compute plugin tasks | Task 1, Task 2, Task 3, Task 4 | feat/residue-policy-plugin-surface | + +**Status:** Locked 2026-05-24T06:50:18Z + +### Task 1: Add Failing Task Residue Tests + +**Files:** +- Modify: `internal/steps_test.go` +- Modify: `internal/module_test.go` + +**Step 1: Add dispatch and map pass-through tests** + +In `internal/steps_test.go`, add: + +```go +func TestDispatchStepSubmitsResiduePolicy(t *testing.T) { + var got protocol.Task + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + 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() + + cfg := dispatchConfigMap(srv.URL) + cfg["residue_policy"] = map[string]any{ + "mode": "session-bound", + "allowed_modes": []any{"isolated", "session-bound"}, + "session_key": "ci-main", + "max_age_seconds": float64(600), + "max_reuse_count": float64(2), + "wipe_on_failure": true, + } + step, err := newDispatchStep("dispatch", cfg) + if err != nil { + t.Fatalf("newDispatchStep: %v", err) + } + if _, err := step.Execute(context.Background(), nil, nil, nil, nil, runtimeSecrets()); err != nil { + t.Fatalf("Execute: %v", err) + } + if got.ResiduePolicy.Mode != protocol.ResidueModeSessionBound || + got.ResiduePolicy.SessionKey != "ci-main" || + got.ResiduePolicy.MaxAgeSeconds != 600 || + got.ResiduePolicy.MaxReuseCount != 2 || + !got.ResiduePolicy.WipeOnFailure { + t.Fatalf("residue policy not submitted: %+v", got.ResiduePolicy) + } +} +``` + +Add a map-step test that submits two tasks with different valid policies and +asserts the server receives the expected modes. + +**Step 2: Add validation tests** + +In `internal/steps_test.go`, add tests that: + +- set `residue_policy.mode` to `bogus` and expect `newDispatchStep` to fail; +- set `residue_policy.mode` to `worker-bound` without + `explicit_worker_bound` and expect `newDispatchStep` to fail. + +In `internal/module_test.go`, add provider catalog tests that: + +- accept a runtime profile with `host_workspace_supported: true` and reusable + allowed residue modes; +- reject reusable residue on a runtime profile with host workspace support + disabled. + +**Step 3: Run tests and confirm failure** + +Run: `GOWORK=off go test ./internal -run 'Residue|ProviderCatalog' -count=1` + +Expected: FAIL with compile/runtime errors because `taskConfig` does not expose +or submit `ResiduePolicy`. + +### Task 2: Implement Residue Policy Pass-Through + +**Files:** +- Modify: `internal/steps.go` +- Modify: `internal/sign.go` + +**Step 1: Add task config field and validation** + +Add to `taskConfig`: + +```go +ResiduePolicy protocol.ResiduePolicy `json:"residue_policy,omitzero"` +``` + +In `taskConfig.validate`, call: + +```go +if err := c.ResiduePolicy.Validate(protocol.ResiduePolicyValidation{ + RequireExplicitWorkerBound: c.ResiduePolicy.Mode == protocol.ResidueModeWorkerBound, +}); err != nil { + errs = append(errs, fmt.Errorf("residue_policy: %w", err)) +} +``` + +**Step 2: Submit the policy** + +In `buildTask`, set: + +```go +ResiduePolicy: cfg.ResiduePolicy, +``` + +**Step 3: Run focused tests** + +Run: `GOWORK=off go test ./internal -run 'Residue|ProviderCatalog' -count=1` + +Expected: PASS. + +Rollback: revert this commit and remove `residue_policy` from Workflow configs; +no persistent plugin state is created. + +### Task 3: Update Dependency, SPEC, and Docs + +**Files:** +- Modify: `go.mod` +- Modify: `go.sum` +- Modify: `SPEC.md` +- Modify: `README.md` + +**Step 1: Update workflow-compute dependency** + +Run: `GOWORK=off go get github.com/GoCodeAlone/workflow-compute@main` + +Expected: `go.mod` references a pseudo-version at or after the residue service +guardrail merge. + +**Step 2: Record plugin contract** + +In `SPEC.md`, add: + +- a constraint that task residue policy is submitted as task intent only; +- an invariant that plugin dispatch/map reject malformed residue policy and do + not compute policy hashes or override provider/product authority; +- a completed task for residue policy plugin surface. + +**Step 3: Add README example** + +Add a short optional `residue_policy` block under `step.compute_dispatch`, +showing a bounded session cache for CI dependency fetching. State that +provider/product policy in `workflow-compute` must also allow the requested +mode. + +**Step 4: Run docs/schema checks** + +Run: `GOWORK=off go test ./internal -run 'Residue|ProviderCatalog' -count=1` + +Expected: PASS. + +Rollback: revert this commit and re-run `GOWORK=off go mod tidy`; Workflow +configs using `residue_policy` should remove that optional field. + +### Task 4: Full Verification and PR + +**Files:** +- No direct source edits unless verification finds a defect. + +**Step 1: Run full tests** + +Run: `GOWORK=off go test ./...` + +Expected: package `cmd/workflow-plugin-compute` reports no test files and +package `internal` passes. + +**Step 2: Run provider alignment** + +Run: `./scripts/check-workflow-compute-alignment.sh /Users/jon/workspace/workflow-compute` + +Expected: exits 0 after `go test ./internal -run 'Test(ModuleTypes|PluginManifestModuleTypesMatchRuntime|ProviderCatalog)' -count=1`. + +**Step 3: Run wfctl validation/build if available** + +Run: `wfctl validate workflow.yaml` + +Expected: exits 0. + +Run: `GOWORK=off wfctl build --config workflow.yaml --no-push --tag local` + +Expected: exits 0 or fails only for documented environment prerequisites; fix +real plugin/config failures. + +**Step 4: Commit and open PR** + +```bash +git add . +git commit -m "feat: expose residue policy in compute steps" +git push -u origin feat/residue-policy-plugin-surface +gh pr create --repo GoCodeAlone/workflow-plugin-compute --base main --head feat/residue-policy-plugin-surface --title "Expose residue policy in compute steps" --body-file /tmp/residue-policy-plugin-pr.md +``` + +Expected: PR is open against `main`. Monitor CI and fix failures rather than +bypassing them. diff --git a/docs/plans/2026-05-24-residue-policy-plugin-surface.md.scope-lock b/docs/plans/2026-05-24-residue-policy-plugin-surface.md.scope-lock new file mode 100644 index 0000000..ebb3e2c --- /dev/null +++ b/docs/plans/2026-05-24-residue-policy-plugin-surface.md.scope-lock @@ -0,0 +1 @@ +c1292cf120413544aacb1bbbca021e1721aff7971d8a0428780e17a2fb1ba393 diff --git a/go.mod b/go.mod index 6c1989f..c789e87 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.26.3 require ( github.com/GoCodeAlone/workflow v0.62.0 - github.com/GoCodeAlone/workflow-compute v0.0.0-20260523063653-eb2057197b98 + github.com/GoCodeAlone/workflow-compute v0.0.0-20260524063206-b3e8de8ce8d6 ) require ( diff --git a/go.sum b/go.sum index e8a6d7f..cd751a2 100644 --- a/go.sum +++ b/go.sum @@ -14,8 +14,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.62.0 h1:emFkTomDpVmBcEfw7quRO4V/J4qDsWNx/CrBdlGqkfg= github.com/GoCodeAlone/workflow v0.62.0/go.mod h1:659GGDrw3QJ7b625y9rf8QhKIpt1VCoEG0MxKu5tGQs= -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/workflow-compute v0.0.0-20260524063206-b3e8de8ce8d6 h1:R2YBH5Vnkn/O1ksTRbzn5vaMN/GtnuFNiTDRF/FR0GY= +github.com/GoCodeAlone/workflow-compute v0.0.0-20260524063206-b3e8de8ce8d6/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/IBM/sarama v1.47.0 h1:GcQFEd12+KzfPYeLgN69Fh7vLCtYRhVIx0rO4TZO318= diff --git a/internal/module_test.go b/internal/module_test.go index 0e3fda2..328f97f 100644 --- a/internal/module_test.go +++ b/internal/module_test.go @@ -154,6 +154,41 @@ func TestProviderCatalogRejectsMalformedWorkflowComputeContract(t *testing.T) { } } +func TestProviderCatalogAcceptsRuntimeProfileResiduePolicy(t *testing.T) { + contract := validProviderContract() + contract.RuntimeContract.Profiles[0].HostWorkspaceSupported = true + contract.RuntimeContract.Profiles[0].ResiduePolicy = protocol.ResiduePolicy{ + Mode: protocol.ResidueModeProviderBound, + AllowedModes: []protocol.ResidueMode{protocol.ResidueModeIsolated, protocol.ResidueModeProviderBound}, + MaxAgeSeconds: 600, + WipeOnFailure: true, + } + module, err := newProviderCatalogModule("catalog", map[string]any{ + "contracts": []any{toMap(t, contract)}, + }) + if err != nil { + t.Fatalf("newProviderCatalogModule: %v", err) + } + if module.config.Contracts[0].RuntimeContract.Profiles[0].ResiduePolicy.Mode != protocol.ResidueModeProviderBound { + t.Fatalf("residue policy not decoded: %+v", module.config.Contracts[0].RuntimeContract.Profiles[0].ResiduePolicy) + } +} + +func TestProviderCatalogRejectsReusableResidueWithoutWorkspace(t *testing.T) { + contract := validProviderContract() + contract.RuntimeContract.Profiles[0].HostWorkspaceSupported = false + contract.RuntimeContract.Profiles[0].ResiduePolicy = protocol.ResiduePolicy{ + Mode: protocol.ResidueModeProviderBound, + AllowedModes: []protocol.ResidueMode{protocol.ResidueModeProviderBound}, + } + _, err := newProviderCatalogModule("catalog", map[string]any{ + "contracts": []any{toMap(t, contract)}, + }) + if err == nil || !strings.Contains(err.Error(), "host workspace") { + t.Fatalf("expected no-workspace residue validation error, got %v", err) + } +} + func validProviderContract() protocol.ProviderContract { return protocol.ProviderContract{ ProtocolVersion: protocol.Version, diff --git a/internal/sign.go b/internal/sign.go index b6b8a76..14ba949 100644 --- a/internal/sign.go +++ b/internal/sign.go @@ -24,6 +24,7 @@ func buildTask(cfg taskConfig, workload protocol.WorkloadSpec) protocol.Task { PolicyID: cfg.PolicyID, Status: protocol.TaskQueued, Workload: workload, + ResiduePolicy: cfg.ResiduePolicy, InputHash: inputHash, RequestedAt: time.Now().UTC(), TimeoutSeconds: cfg.TimeoutSeconds, diff --git a/internal/steps.go b/internal/steps.go index 3181a02..91ae3de 100644 --- a/internal/steps.go +++ b/internal/steps.go @@ -54,13 +54,14 @@ func (c connectionConfig) client(ctx context.Context, metadata, runtimeConfig ma } type taskConfig struct { - ID string `json:"id,omitempty"` - ProductID string `json:"product_id,omitempty"` - OrgID string `json:"org_id"` - PoolID string `json:"pool_id"` - PolicyID string `json:"policy_id"` - TimeoutSeconds int `json:"timeout_seconds"` - Labels map[string]string `json:"labels,omitempty"` + ID string `json:"id,omitempty"` + ProductID string `json:"product_id,omitempty"` + OrgID string `json:"org_id"` + PoolID string `json:"pool_id"` + PolicyID string `json:"policy_id"` + TimeoutSeconds int `json:"timeout_seconds"` + Labels map[string]string `json:"labels,omitempty"` + ResiduePolicy protocol.ResiduePolicy `json:"residue_policy,omitzero"` } func (c taskConfig) validate() error { @@ -77,6 +78,11 @@ func (c taskConfig) validate() error { if c.TimeoutSeconds <= 0 { errs = append(errs, errors.New("timeout_seconds must be positive")) } + if err := c.ResiduePolicy.Validate(protocol.ResiduePolicyValidation{ + RequireExplicitWorkerBound: c.ResiduePolicy.Mode == protocol.ResidueModeWorkerBound, + }); err != nil { + errs = append(errs, fmt.Errorf("residue_policy: %w", err)) + } return errors.Join(errs...) } diff --git a/internal/steps_test.go b/internal/steps_test.go index 5f3426b..600bba4 100644 --- a/internal/steps_test.go +++ b/internal/steps_test.go @@ -112,6 +112,58 @@ func TestDispatchStepRejectsUnknownNestedWorkloadConfig(t *testing.T) { } } +func TestDispatchStepSubmitsResiduePolicy(t *testing.T) { + var got protocol.Task + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + 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() + + cfg := dispatchConfigMap(srv.URL) + cfg["residue_policy"] = map[string]any{ + "mode": "session-bound", + "allowed_modes": []any{"isolated", "session-bound"}, + "session_key": "ci-main", + "max_age_seconds": float64(600), + "max_reuse_count": float64(2), + "wipe_on_failure": true, + } + step, err := newDispatchStep("dispatch", cfg) + if err != nil { + t.Fatalf("newDispatchStep: %v", err) + } + if _, err := step.Execute(context.Background(), nil, nil, nil, nil, runtimeSecrets()); err != nil { + t.Fatalf("Execute: %v", err) + } + if got.ResiduePolicy.Mode != protocol.ResidueModeSessionBound || + got.ResiduePolicy.SessionKey != "ci-main" || + got.ResiduePolicy.MaxAgeSeconds != 600 || + got.ResiduePolicy.MaxReuseCount != 2 || + !got.ResiduePolicy.WipeOnFailure { + t.Fatalf("residue policy not submitted: %+v", got.ResiduePolicy) + } +} + +func TestDispatchStepRejectsMalformedResiduePolicy(t *testing.T) { + cfg := dispatchConfigMap("https://compute.example.test") + cfg["residue_policy"] = map[string]any{"mode": "bogus"} + if _, err := newDispatchStep("dispatch", cfg); err == nil || !strings.Contains(err.Error(), "residue_policy") { + t.Fatalf("expected residue_policy validation error, got %v", err) + } +} + +func TestDispatchStepRejectsImplicitWorkerBoundResiduePolicy(t *testing.T) { + cfg := dispatchConfigMap("https://compute.example.test") + cfg["residue_policy"] = map[string]any{"mode": "worker-bound"} + if _, err := newDispatchStep("dispatch", cfg); err == nil || !strings.Contains(err.Error(), "explicit_worker_bound") { + t.Fatalf("expected explicit_worker_bound validation error, got %v", err) + } +} + func TestDispatchStepAcceptsProviderWorkload(t *testing.T) { var got protocol.Task srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -444,6 +496,72 @@ func TestMapStepSubmitsEachTaskAndWaits(t *testing.T) { } } +func TestMapStepSubmitsPerTaskResiduePolicies(t *testing.T) { + var submitted []protocol.Task + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/tasks": + switch r.Method { + case http.MethodPost: + var task protocol.Task + if err := json.NewDecoder(r.Body).Decode(&task); err != nil { + t.Fatalf("decode task: %v", err) + } + submitted = append(submitted, task) + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]any{"task": task}) + case http.MethodGet: + tasks := append([]protocol.Task(nil), submitted...) + for i := range tasks { + tasks[i].Status = protocol.TaskSucceeded + } + _ = json.NewEncoder(w).Encode(map[string]any{"tasks": tasks, "stalls": []any{}}) + default: + t.Fatalf("method: %s", r.Method) + } + case "/v1/proofs": + proofs := make([]protocol.ProofReceipt, 0, len(submitted)) + for _, task := range submitted { + proofs = append(proofs, proofReceipt(task.ID)) + } + _ = json.NewEncoder(w).Encode(map[string]any{"proofs": proofs}) + default: + t.Fatalf("path: %s", r.URL.Path) + } + })) + defer srv.Close() + + first := taskConfigMap("task-1") + first["residue_policy"] = map[string]any{ + "mode": "provider-bound", + "allowed_modes": []any{"isolated", "provider-bound"}, + } + second := taskConfigMap("task-2") + second["residue_policy"] = map[string]any{"mode": "isolated"} + step, err := newMapStep("map", map[string]any{ + "server_url": srv.URL, + "auth_token_ref": "secret:compute-token", + "tasks": []any{first, second}, + "poll_interval": "1ms", + "timeout": "100ms", + }) + if err != nil { + t.Fatalf("newMapStep: %v", err) + } + result, err := step.Execute(context.Background(), nil, nil, nil, nil, runtimeSecrets()) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if result.StopPipeline { + t.Fatalf("unexpected stop: %+v", result.Output) + } + if len(submitted) != 2 || + submitted[0].ResiduePolicy.Mode != protocol.ResidueModeProviderBound || + submitted[1].ResiduePolicy.Mode != protocol.ResidueModeIsolated { + t.Fatalf("submitted residue policies: %+v", submitted) + } +} + func TestMapStepRejectsUnknownConfig(t *testing.T) { cfg := map[string]any{ "server_url": "https://compute.example.test", diff --git a/workflow.yaml b/workflow.yaml index d682931..57f5b22 100644 --- a/workflow.yaml +++ b/workflow.yaml @@ -11,4 +11,9 @@ ci: command: GOWORK=off go test ./... coverage: true -modules: [] +modules: + - name: plugin-context + type: platform.context + config: + org: GoCodeAlone + environment: local