feat: add safe artifact delete
This commit is contained in:
@@ -33,16 +33,13 @@ clear operator feedback before adding more architecture.
|
|||||||
[`2026-06-09 product smoke RPC CLI`](superpowers/research/2026-06-09-product-smoke-rpc-cli.md).
|
[`2026-06-09 product smoke RPC CLI`](superpowers/research/2026-06-09-product-smoke-rpc-cli.md).
|
||||||
- Completed: `wf artifact inspect` now accepts `--version` as an alias for the
|
- Completed: `wf artifact inspect` now accepts `--version` as an alias for the
|
||||||
positional version argument.
|
positional version argument.
|
||||||
|
- Completed: `wf artifact delete <artifact_id> <version> --confirm` deletes
|
||||||
|
unreferenced artifact versions and rejects versions still referenced by
|
||||||
|
deployments. Implementation:
|
||||||
|
[`wf artifact delete`](historical/superpowers/plans/2026-06-09-wf-artifact-delete.md).
|
||||||
- Completed: `wf draft delete <workspace_id> --confirm` exposes existing draft
|
- Completed: `wf draft delete <workspace_id> --confirm` exposes existing draft
|
||||||
workspace deletion as a safe CLI command. Implementation:
|
workspace deletion as a safe CLI command. Implementation:
|
||||||
[`wf draft delete CLI/RPC`](historical/superpowers/plans/2026-06-09-wf-draft-delete-cli-rpc.md).
|
[`wf draft delete CLI/RPC`](historical/superpowers/plans/2026-06-09-wf-draft-delete-cli-rpc.md).
|
||||||
- Separate cleanup: design artifact deletion before exposing a command.
|
|
||||||
Deployments live in the artifact store and can reference artifact versions, so
|
|
||||||
`wf artifact delete <artifact_id> <version> --confirm` must reject referenced
|
|
||||||
artifacts by default. Active spec:
|
|
||||||
[`artifact delete policy`](superpowers/specs/2026-06-09-artifact-delete-policy.md).
|
|
||||||
Active plan:
|
|
||||||
[`wf artifact delete`](superpowers/plans/2026-06-09-wf-artifact-delete.md).
|
|
||||||
- Next docs/ergonomics cleanup: explain raw MCP content-block envelopes returned
|
- Next docs/ergonomics cleanup: explain raw MCP content-block envelopes returned
|
||||||
by `cap call`, and only add a text-unwrapping CLI option if the safe behavior
|
by `cap call`, and only add a text-unwrapping CLI option if the safe behavior
|
||||||
is explicitly defined.
|
is explicitly defined.
|
||||||
|
|||||||
+26
-26
@@ -43,7 +43,7 @@ Implement the policy in [`artifact delete policy`](../specs/2026-06-09-artifact-
|
|||||||
- Modify: `src/wf_artifacts/store.py`
|
- Modify: `src/wf_artifacts/store.py`
|
||||||
- Test: `tests/artifacts/test_store.py`
|
- Test: `tests/artifacts/test_store.py`
|
||||||
|
|
||||||
- [ ] **Step 1: Add failing tests for artifact delete**
|
- [x] **Step 1: Add failing tests for artifact delete**
|
||||||
|
|
||||||
In `tests/artifacts/test_store.py`, add:
|
In `tests/artifacts/test_store.py`, add:
|
||||||
|
|
||||||
@@ -99,7 +99,7 @@ def test_file_store_finds_deployments_for_artifact_version(tmp_path) -> None:
|
|||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2: Run store tests and verify failure**
|
- [x] **Step 2: Run store tests and verify failure**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ uv run pytest tests/artifacts/test_store.py -q
|
|||||||
|
|
||||||
Expected: FAIL because `delete_artifact` and `deployments_for_artifact` do not exist.
|
Expected: FAIL because `delete_artifact` and `deployments_for_artifact` do not exist.
|
||||||
|
|
||||||
- [ ] **Step 3: Add abstract store methods**
|
- [x] **Step 3: Add abstract store methods**
|
||||||
|
|
||||||
In `WorkflowArtifactStore`, add:
|
In `WorkflowArtifactStore`, add:
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ In `WorkflowArtifactStore`, add:
|
|||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 4: Implement file store methods**
|
- [x] **Step 4: Implement file store methods**
|
||||||
|
|
||||||
In `FileWorkflowArtifactStore`, add:
|
In `FileWorkflowArtifactStore`, add:
|
||||||
|
|
||||||
@@ -149,7 +149,7 @@ In `FileWorkflowArtifactStore`, add:
|
|||||||
|
|
||||||
Keep sorting behavior from `list_deployments()`; do not add separate sort logic.
|
Keep sorting behavior from `list_deployments()`; do not add separate sort logic.
|
||||||
|
|
||||||
- [ ] **Step 5: Run store tests**
|
- [x] **Step 5: Run store tests**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -166,7 +166,7 @@ Expected: PASS.
|
|||||||
- Modify: `src/wf_api/surface.py`
|
- Modify: `src/wf_api/surface.py`
|
||||||
- Test: `tests/wf_api/test_artifact_api.py`
|
- Test: `tests/wf_api/test_artifact_api.py`
|
||||||
|
|
||||||
- [ ] **Step 1: Add failing API tests**
|
- [x] **Step 1: Add failing API tests**
|
||||||
|
|
||||||
In `tests/wf_api/test_artifact_api.py`, add tests near `test_inspect_artifact_returns_stable_fields`:
|
In `tests/wf_api/test_artifact_api.py`, add tests near `test_inspect_artifact_returns_stable_fields`:
|
||||||
|
|
||||||
@@ -216,7 +216,7 @@ from wf_artifacts import WorkflowDeployment
|
|||||||
|
|
||||||
If `pytest` is already imported in the file after prior edits, do not duplicate it.
|
If `pytest` is already imported in the file after prior edits, do not duplicate it.
|
||||||
|
|
||||||
- [ ] **Step 2: Run API tests and verify failure**
|
- [x] **Step 2: Run API tests and verify failure**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -226,7 +226,7 @@ uv run pytest tests/wf_api/test_artifact_api.py -q
|
|||||||
|
|
||||||
Expected: FAIL because `WorkflowArtifactApi.delete_artifact` does not exist.
|
Expected: FAIL because `WorkflowArtifactApi.delete_artifact` does not exist.
|
||||||
|
|
||||||
- [ ] **Step 3: Add surface method**
|
- [x] **Step 3: Add surface method**
|
||||||
|
|
||||||
In `src/wf_api/surface.py`, add to `WorkflowArtifactSurface`:
|
In `src/wf_api/surface.py`, add to `WorkflowArtifactSurface`:
|
||||||
|
|
||||||
@@ -239,7 +239,7 @@ In `src/wf_api/surface.py`, add to `WorkflowArtifactSurface`:
|
|||||||
) -> dict[str, Any]: ...
|
) -> dict[str, Any]: ...
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 4: Implement API method**
|
- [x] **Step 4: Implement API method**
|
||||||
|
|
||||||
In `src/wf_api/artifacts.py`, add after `inspect_artifact`:
|
In `src/wf_api/artifacts.py`, add after `inspect_artifact`:
|
||||||
|
|
||||||
@@ -273,7 +273,7 @@ In `src/wf_api/artifacts.py`, add after `inspect_artifact`:
|
|||||||
|
|
||||||
If review objects to the `capability_id` string, use `artifact_capability_id` only if constructing a fake artifact is not required. Do not load the artifact after deletion just to build an event id.
|
If review objects to the `capability_id` string, use `artifact_capability_id` only if constructing a fake artifact is not required. Do not load the artifact after deletion just to build an event id.
|
||||||
|
|
||||||
- [ ] **Step 5: Run API tests**
|
- [x] **Step 5: Run API tests**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -292,7 +292,7 @@ Expected: PASS.
|
|||||||
- Test: `tests/wf_transport_rpc_http/test_app.py`
|
- Test: `tests/wf_transport_rpc_http/test_app.py`
|
||||||
- Test: `tests/wf_transport_rpc_http/test_client.py`
|
- Test: `tests/wf_transport_rpc_http/test_client.py`
|
||||||
|
|
||||||
- [ ] **Step 1: Add failing RPC/client tests**
|
- [x] **Step 1: Add failing RPC/client tests**
|
||||||
|
|
||||||
In `tests/wf_transport_rpc_http/test_app.py`, add an app-level method test that saves an artifact then calls:
|
In `tests/wf_transport_rpc_http/test_app.py`, add an app-level method test that saves an artifact then calls:
|
||||||
|
|
||||||
@@ -317,7 +317,7 @@ assert deleted["deleted"] is True
|
|||||||
|
|
||||||
Use existing helpers in those files for building/saving artifacts. If no helper exists, create the artifact through `client.save_artifact(...)` using the existing artifact test payload pattern.
|
Use existing helpers in those files for building/saving artifacts. If no helper exists, create the artifact through `client.save_artifact(...)` using the existing artifact test payload pattern.
|
||||||
|
|
||||||
- [ ] **Step 2: Run RPC tests and verify failure**
|
- [x] **Step 2: Run RPC tests and verify failure**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -327,7 +327,7 @@ uv run pytest tests/wf_transport_rpc_http/test_app.py tests/wf_transport_rpc_htt
|
|||||||
|
|
||||||
Expected: FAIL because params/method/client method do not exist.
|
Expected: FAIL because params/method/client method do not exist.
|
||||||
|
|
||||||
- [ ] **Step 3: Add params model**
|
- [x] **Step 3: Add params model**
|
||||||
|
|
||||||
In `src/wf_transport_rpc_http/models.py`, add:
|
In `src/wf_transport_rpc_http/models.py`, add:
|
||||||
|
|
||||||
@@ -337,7 +337,7 @@ class DeleteArtifactParams(RpcParamsModel):
|
|||||||
version: int = Field(ge=1)
|
version: int = Field(ge=1)
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 4: Register RPC method**
|
- [x] **Step 4: Register RPC method**
|
||||||
|
|
||||||
In `src/wf_transport_rpc_http/methods/artifacts.py`, import `DeleteArtifactParams` and add:
|
In `src/wf_transport_rpc_http/methods/artifacts.py`, import `DeleteArtifactParams` and add:
|
||||||
|
|
||||||
@@ -355,7 +355,7 @@ In `src/wf_transport_rpc_http/methods/artifacts.py`, import `DeleteArtifactParam
|
|||||||
raise_workflow_rpc_error(exc)
|
raise_workflow_rpc_error(exc)
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 5: Add client method**
|
- [x] **Step 5: Add client method**
|
||||||
|
|
||||||
In `src/wf_transport_rpc_http/client/artifacts.py`, add:
|
In `src/wf_transport_rpc_http/client/artifacts.py`, add:
|
||||||
|
|
||||||
@@ -369,7 +369,7 @@ In `src/wf_transport_rpc_http/client/artifacts.py`, add:
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 6: Run RPC/client tests**
|
- [x] **Step 6: Run RPC/client tests**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -385,7 +385,7 @@ Expected: PASS.
|
|||||||
- Modify: `src/wf_cli/commands/artifacts.py`
|
- Modify: `src/wf_cli/commands/artifacts.py`
|
||||||
- Test: `tests/wf_cli/test_remote_target.py` or `tests/wf_cli/test_artifacts.py`
|
- Test: `tests/wf_cli/test_remote_target.py` or `tests/wf_cli/test_artifacts.py`
|
||||||
|
|
||||||
- [ ] **Step 1: Add CLI tests**
|
- [x] **Step 1: Add CLI tests**
|
||||||
|
|
||||||
Add tests for:
|
Add tests for:
|
||||||
|
|
||||||
@@ -422,7 +422,7 @@ assert payload["deleted"] is False
|
|||||||
assert payload["blocked_by_deployments"] == ["echo.default"]
|
assert payload["blocked_by_deployments"] == ["echo.default"]
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2: Run CLI tests and verify failure**
|
- [x] **Step 2: Run CLI tests and verify failure**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -432,7 +432,7 @@ uv run pytest tests/wf_cli/test_remote_target.py -q
|
|||||||
|
|
||||||
Expected: FAIL because `wf artifact delete` does not exist.
|
Expected: FAIL because `wf artifact delete` does not exist.
|
||||||
|
|
||||||
- [ ] **Step 3: Implement command**
|
- [x] **Step 3: Implement command**
|
||||||
|
|
||||||
In `src/wf_cli/commands/artifacts.py`, add:
|
In `src/wf_cli/commands/artifacts.py`, add:
|
||||||
|
|
||||||
@@ -465,7 +465,7 @@ def delete_artifact(
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 4: Run CLI tests**
|
- [x] **Step 4: Run CLI tests**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -482,7 +482,7 @@ Expected: PASS.
|
|||||||
- Modify: `docs/current_roadmap.md`
|
- Modify: `docs/current_roadmap.md`
|
||||||
- Modify: `docs/superpowers/specs/2026-06-09-artifact-delete-policy.md`
|
- Modify: `docs/superpowers/specs/2026-06-09-artifact-delete-policy.md`
|
||||||
|
|
||||||
- [ ] **Step 1: Document command**
|
- [x] **Step 1: Document command**
|
||||||
|
|
||||||
In `docs/wf_cli.md`, add:
|
In `docs/wf_cli.md`, add:
|
||||||
|
|
||||||
@@ -492,11 +492,11 @@ wf artifact delete smoke_artifact_20260609 1 --confirm
|
|||||||
|
|
||||||
State that the command refuses to delete artifact versions referenced by deployments, and users should delete deployments first with `wf deploy delete <deployment_id>`.
|
State that the command refuses to delete artifact versions referenced by deployments, and users should delete deployments first with `wf deploy delete <deployment_id>`.
|
||||||
|
|
||||||
- [ ] **Step 2: Update roadmap**
|
- [x] **Step 2: Update roadmap**
|
||||||
|
|
||||||
In `docs/current_roadmap.md`, mark artifact delete complete under Priority 1 after implementation. Keep the artifact-delete policy spec linked as current behavior if it remains accurate.
|
In `docs/current_roadmap.md`, mark artifact delete complete under Priority 1 after implementation. Keep the artifact-delete policy spec linked as current behavior if it remains accurate.
|
||||||
|
|
||||||
- [ ] **Step 3: Update policy spec status**
|
- [x] **Step 3: Update policy spec status**
|
||||||
|
|
||||||
At the top of `docs/superpowers/specs/2026-06-09-artifact-delete-policy.md`, add:
|
At the top of `docs/superpowers/specs/2026-06-09-artifact-delete-policy.md`, add:
|
||||||
|
|
||||||
@@ -509,7 +509,7 @@ unreferenced artifact versions and rejects versions referenced by deployments.
|
|||||||
|
|
||||||
## Task 6: Final Verification
|
## Task 6: Final Verification
|
||||||
|
|
||||||
- [ ] **Step 1: Run focused tests**
|
- [x] **Step 1: Run focused tests**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -519,7 +519,7 @@ uv run pytest tests/artifacts/test_store.py tests/wf_api/test_artifact_api.py te
|
|||||||
|
|
||||||
Expected: PASS.
|
Expected: PASS.
|
||||||
|
|
||||||
- [ ] **Step 2: Run changed-file lint/type checks**
|
- [x] **Step 2: Run changed-file lint/type checks**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -530,7 +530,7 @@ uv run basedpyright --level error src/wf_artifacts/store.py src/wf_api/artifacts
|
|||||||
|
|
||||||
Expected: PASS. If global ruff reports unrelated pre-existing errors, scope the final report to changed-file lint and list the unrelated files separately.
|
Expected: PASS. If global ruff reports unrelated pre-existing errors, scope the final report to changed-file lint and list the unrelated files separately.
|
||||||
|
|
||||||
- [ ] **Step 3: Optional manual smoke**
|
- [x] **Step 3: Optional manual smoke**
|
||||||
|
|
||||||
Against a running server:
|
Against a running server:
|
||||||
|
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
# Artifact Delete Policy
|
# Artifact Delete Policy
|
||||||
|
|
||||||
This is the current design contract for adding artifact deletion. It is separate
|
## Status
|
||||||
from `wf draft delete` because artifact deletion is not only CLI plumbing.
|
|
||||||
|
Implemented: `wf artifact delete <artifact_id> <version> --confirm` deletes
|
||||||
|
unreferenced artifact versions and rejects versions referenced by deployments.
|
||||||
|
|
||||||
|
This is the current design contract for artifact deletion. It is separate from
|
||||||
|
`wf draft delete` because artifact deletion is not only CLI plumbing.
|
||||||
|
|
||||||
## Current State
|
## Current State
|
||||||
|
|
||||||
@@ -9,19 +14,20 @@ from `wf draft delete` because artifact deletion is not only CLI plumbing.
|
|||||||
through CLI/RPC.
|
through CLI/RPC.
|
||||||
- Deployment deletion already exists and is stored through
|
- Deployment deletion already exists and is stored through
|
||||||
`WorkflowArtifactStore.delete_deployment`.
|
`WorkflowArtifactStore.delete_deployment`.
|
||||||
- Artifact deletion does **not** exist yet as a store/API operation.
|
- Artifact deletion exists as a store/API/RPC/CLI operation for one artifact
|
||||||
|
version at a time.
|
||||||
- Deployments live in the artifact store area and can reference a specific
|
- Deployments live in the artifact store area and can reference a specific
|
||||||
`(artifact_id, version)`.
|
`(artifact_id, version)`.
|
||||||
|
|
||||||
This means `wf artifact delete <artifact_id> <version>` needs store-level policy,
|
This means `wf artifact delete <artifact_id> <version>` enforces store-level
|
||||||
not just a CLI command.
|
policy, not just CLI confirmation.
|
||||||
|
|
||||||
## Required Safety Rule
|
## Required Safety Rule
|
||||||
|
|
||||||
Artifact deletion must not remove an artifact version while any deployment
|
Artifact deletion must not remove an artifact version while any deployment
|
||||||
references that artifact version.
|
references that artifact version.
|
||||||
|
|
||||||
The first implementation should reject with structured output like:
|
Deletion rejects referenced artifacts with structured output like:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -35,7 +41,7 @@ The first implementation should reject with structured output like:
|
|||||||
The exact response can be adjusted to match existing API payload style, but it
|
The exact response can be adjusted to match existing API payload style, but it
|
||||||
must include the blocking deployment ids.
|
must include the blocking deployment ids.
|
||||||
|
|
||||||
## Non-Goals For First Slice
|
## Non-Goals
|
||||||
|
|
||||||
- Do not cascade-delete deployments.
|
- Do not cascade-delete deployments.
|
||||||
- Do not delete runs.
|
- Do not delete runs.
|
||||||
@@ -55,7 +61,8 @@ The non-cascade path must remain the default.
|
|||||||
|
|
||||||
## Implementation Shape
|
## Implementation Shape
|
||||||
|
|
||||||
Add store primitives first, then API, then transport, then CLI:
|
The implemented shape is store primitives first, then API, then transport, then
|
||||||
|
CLI:
|
||||||
|
|
||||||
1. `WorkflowArtifactStore` gains an artifact-version delete method.
|
1. `WorkflowArtifactStore` gains an artifact-version delete method.
|
||||||
2. `WorkflowArtifactStore` gains a helper to find deployments referencing
|
2. `WorkflowArtifactStore` gains a helper to find deployments referencing
|
||||||
@@ -67,7 +74,7 @@ Add store primitives first, then API, then transport, then CLI:
|
|||||||
|
|
||||||
## Test Requirements
|
## Test Requirements
|
||||||
|
|
||||||
The first artifact-delete slice should include tests for:
|
Artifact deletion should keep tests for:
|
||||||
|
|
||||||
- Deleting an unreferenced artifact version succeeds.
|
- Deleting an unreferenced artifact version succeeds.
|
||||||
- Deleting a missing artifact version is idempotent only if the existing artifact
|
- Deleting a missing artifact version is idempotent only if the existing artifact
|
||||||
@@ -88,4 +95,3 @@ the artifact that deployment referenced.
|
|||||||
|
|
||||||
`wf artifact delete <artifact_id> <version>` removes an artifact version only
|
`wf artifact delete <artifact_id> <version>` removes an artifact version only
|
||||||
after proving no deployment references it.
|
after proving no deployment references it.
|
||||||
|
|
||||||
|
|||||||
@@ -251,6 +251,15 @@ wf artifact inspect concat_ws 1
|
|||||||
Artifacts are immutable saved workflow definitions. List output is compact by
|
Artifacts are immutable saved workflow definitions. List output is compact by
|
||||||
design; use `inspect` for full details.
|
design; use `inspect` for full details.
|
||||||
|
|
||||||
|
Delete an unreferenced artifact version:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wf artifact delete smoke_artifact_20260609 1 --confirm
|
||||||
|
```
|
||||||
|
|
||||||
|
The command refuses to delete artifact versions still referenced by deployments.
|
||||||
|
Delete referencing deployments first with `wf deploy delete <deployment_id>`.
|
||||||
|
|
||||||
## Deployments
|
## Deployments
|
||||||
|
|
||||||
Save a deployment from flags:
|
Save a deployment from flags:
|
||||||
|
|||||||
@@ -276,6 +276,32 @@ class WorkflowArtifactApi:
|
|||||||
artifact = self._artifact_store().get_artifact(artifact_id, version)
|
artifact = self._artifact_store().get_artifact(artifact_id, version)
|
||||||
return artifact.model_dump(mode="json")
|
return artifact.model_dump(mode="json")
|
||||||
|
|
||||||
|
async def delete_artifact(
|
||||||
|
self, *, artifact_id: str, version: int
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
store = self._artifact_store()
|
||||||
|
blockers = store.deployments_for_artifact(artifact_id, version)
|
||||||
|
blocker_ids = [deployment.id for deployment in blockers]
|
||||||
|
if blocker_ids:
|
||||||
|
return {
|
||||||
|
"artifact_id": artifact_id,
|
||||||
|
"version": version,
|
||||||
|
"deleted": False,
|
||||||
|
"blocked_by_deployments": blocker_ids,
|
||||||
|
}
|
||||||
|
store.delete_artifact(artifact_id, version)
|
||||||
|
self.context.events.record_workflow_event(
|
||||||
|
"workflow_artifact_deleted",
|
||||||
|
capability_id=f"{artifact_id}@{version}",
|
||||||
|
payload={"artifact_id": artifact_id, "version": version},
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"artifact_id": artifact_id,
|
||||||
|
"version": version,
|
||||||
|
"deleted": True,
|
||||||
|
"blocked_by_deployments": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _suggested_self_bindings(required_sources: Sequence[str]) -> dict[str, str]:
|
def _suggested_self_bindings(required_sources: Sequence[str]) -> dict[str, str]:
|
||||||
"""Suggest local bindings for built-in sources that deploy to themselves."""
|
"""Suggest local bindings for built-in sources that deploy to themselves."""
|
||||||
|
|||||||
@@ -95,6 +95,17 @@ class WorkflowApi:
|
|||||||
version=version,
|
version=version,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def delete_artifact(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
artifact_id: str,
|
||||||
|
version: int,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return await self.artifacts.delete_artifact(
|
||||||
|
artifact_id=artifact_id,
|
||||||
|
version=version,
|
||||||
|
)
|
||||||
|
|
||||||
async def save_artifact(
|
async def save_artifact(
|
||||||
self,
|
self,
|
||||||
artifact: dict[str, Any],
|
artifact: dict[str, Any],
|
||||||
|
|||||||
@@ -137,6 +137,13 @@ class WorkflowArtifactSurface(Protocol):
|
|||||||
version: int,
|
version: int,
|
||||||
) -> dict[str, Any]: ...
|
) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
async def delete_artifact(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
artifact_id: str,
|
||||||
|
version: int,
|
||||||
|
) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
|
||||||
class WorkflowDeploymentSurface(Protocol):
|
class WorkflowDeploymentSurface(Protocol):
|
||||||
"""Deployment methods exposed by workflow frontends."""
|
"""Deployment methods exposed by workflow frontends."""
|
||||||
|
|||||||
@@ -43,6 +43,14 @@ class WorkflowArtifactStore:
|
|||||||
def list_deployments(self) -> list[WorkflowDeployment]:
|
def list_deployments(self) -> list[WorkflowDeployment]:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def delete_artifact(self, artifact_id: str, version: int) -> None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def deployments_for_artifact(
|
||||||
|
self, artifact_id: str, version: int
|
||||||
|
) -> list[WorkflowDeployment]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
def delete_deployment(self, deployment_id: str) -> None:
|
def delete_deployment(self, deployment_id: str) -> None:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@@ -118,6 +126,24 @@ class FileWorkflowArtifactStore(WorkflowArtifactStore):
|
|||||||
)
|
)
|
||||||
return deployments
|
return deployments
|
||||||
|
|
||||||
|
def delete_artifact(self, artifact_id: str, version: int) -> None:
|
||||||
|
"""Remove one immutable artifact version from the store."""
|
||||||
|
path = self._artifact_dir(artifact_id) / self._artifact_filename(version)
|
||||||
|
if not path.exists():
|
||||||
|
raise KeyError(f"unknown workflow artifact {artifact_id}@{version}")
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
def deployments_for_artifact(
|
||||||
|
self, artifact_id: str, version: int
|
||||||
|
) -> list[WorkflowDeployment]:
|
||||||
|
"""Return deployments that currently reference one artifact version."""
|
||||||
|
return [
|
||||||
|
deployment
|
||||||
|
for deployment in self.list_deployments()
|
||||||
|
if deployment.artifact_id == artifact_id
|
||||||
|
and deployment.artifact_version == version
|
||||||
|
]
|
||||||
|
|
||||||
def delete_deployment(self, deployment_id: str) -> None:
|
def delete_deployment(self, deployment_id: str) -> None:
|
||||||
"""Remove one mutable deployment binding record from the store."""
|
"""Remove one mutable deployment binding record from the store."""
|
||||||
path = self._deployment_path(deployment_id)
|
path = self._deployment_path(deployment_id)
|
||||||
|
|||||||
@@ -96,3 +96,31 @@ def _resolve_artifact_version(
|
|||||||
return version_option
|
return version_option
|
||||||
assert version_arg is not None
|
assert version_arg is not None
|
||||||
return version_arg
|
return version_arg
|
||||||
|
|
||||||
|
|
||||||
|
@app.command("delete")
|
||||||
|
def delete_artifact(
|
||||||
|
ctx: typer.Context,
|
||||||
|
artifact_id: Annotated[str, typer.Argument(help="Artifact id.")],
|
||||||
|
version: Annotated[int, typer.Argument(min=1, help="Artifact version.")],
|
||||||
|
confirm: Annotated[
|
||||||
|
bool,
|
||||||
|
typer.Option(
|
||||||
|
"--confirm",
|
||||||
|
help="Required confirmation for deleting an artifact version.",
|
||||||
|
),
|
||||||
|
] = False,
|
||||||
|
) -> None:
|
||||||
|
"""Delete one unreferenced artifact version."""
|
||||||
|
if not confirm:
|
||||||
|
raise typer.BadParameter("pass --confirm to delete an artifact version")
|
||||||
|
context = load_cli_context(ctx)
|
||||||
|
emit_json(
|
||||||
|
run_cli_operation(
|
||||||
|
context,
|
||||||
|
context.handlers.delete_artifact(
|
||||||
|
artifact_id=artifact_id,
|
||||||
|
version=version,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|||||||
@@ -38,3 +38,11 @@ class RpcArtifactClientMixin:
|
|||||||
self: RpcCaller, artifact: dict[str, Any]
|
self: RpcCaller, artifact: dict[str, Any]
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
return await self._call("workflow.artifacts.save", {"artifact": artifact})
|
return await self._call("workflow.artifacts.save", {"artifact": artifact})
|
||||||
|
|
||||||
|
async def delete_artifact(
|
||||||
|
self: RpcCaller, *, artifact_id: str, version: int
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return await self._call(
|
||||||
|
"workflow.artifacts.delete",
|
||||||
|
{"artifact_id": artifact_id, "version": version},
|
||||||
|
)
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ import fastapi_jsonrpc as jsonrpc
|
|||||||
from wf_server import WorkflowServer
|
from wf_server import WorkflowServer
|
||||||
|
|
||||||
from ..errors import WorkflowRpcError, raise_workflow_rpc_error
|
from ..errors import WorkflowRpcError, raise_workflow_rpc_error
|
||||||
from ..models import InspectArtifactParams, ListArtifactsParams, SaveArtifactParams
|
from ..models import (
|
||||||
|
DeleteArtifactParams,
|
||||||
|
InspectArtifactParams,
|
||||||
|
ListArtifactsParams,
|
||||||
|
SaveArtifactParams,
|
||||||
|
)
|
||||||
from ..params import RpcParams
|
from ..params import RpcParams
|
||||||
|
|
||||||
|
|
||||||
@@ -51,3 +56,15 @@ def register_methods(
|
|||||||
)
|
)
|
||||||
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
|
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
|
||||||
raise_workflow_rpc_error(exc)
|
raise_workflow_rpc_error(exc)
|
||||||
|
|
||||||
|
@entrypoint.method(name="workflow.artifacts.delete", errors=[WorkflowRpcError])
|
||||||
|
async def workflow_artifacts_delete(
|
||||||
|
params: DeleteArtifactParams = RpcParams(),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return await server.api.delete_artifact(
|
||||||
|
artifact_id=params.artifact_id,
|
||||||
|
version=params.version,
|
||||||
|
)
|
||||||
|
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
|
||||||
|
raise_workflow_rpc_error(exc)
|
||||||
|
|||||||
@@ -152,6 +152,11 @@ class InspectArtifactParams(RpcParamsModel):
|
|||||||
version: int = Field(ge=1)
|
version: int = Field(ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteArtifactParams(RpcParamsModel):
|
||||||
|
artifact_id: str = Field(min_length=1)
|
||||||
|
version: int = Field(ge=1)
|
||||||
|
|
||||||
|
|
||||||
class ListDeploymentsParams(RpcParamsModel):
|
class ListDeploymentsParams(RpcParamsModel):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -219,3 +219,54 @@ def test_file_store_rejects_deployment_lookup_path_traversal(tmp_path) -> None:
|
|||||||
|
|
||||||
with pytest.raises(ValueError, match="deployment_id"):
|
with pytest.raises(ValueError, match="deployment_id"):
|
||||||
store.get_deployment("../outside")
|
store.get_deployment("../outside")
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_store_deletes_one_artifact_version(tmp_path) -> None:
|
||||||
|
store = FileWorkflowArtifactStore(tmp_path)
|
||||||
|
store.save_artifact(artifact(1))
|
||||||
|
store.save_artifact(artifact(2))
|
||||||
|
|
||||||
|
store.delete_artifact("summarize_docs", 1)
|
||||||
|
|
||||||
|
with pytest.raises(KeyError, match="unknown workflow artifact"):
|
||||||
|
store.get_artifact("summarize_docs", 1)
|
||||||
|
assert store.get_artifact("summarize_docs", 2).version == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_store_delete_artifact_missing_version_raises_key_error(tmp_path) -> None:
|
||||||
|
store = FileWorkflowArtifactStore(tmp_path)
|
||||||
|
|
||||||
|
with pytest.raises(KeyError, match="unknown workflow artifact"):
|
||||||
|
store.delete_artifact("summarize_docs", 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_store_finds_deployments_for_artifact_version(tmp_path) -> None:
|
||||||
|
store = FileWorkflowArtifactStore(tmp_path)
|
||||||
|
store.save_deployment(
|
||||||
|
WorkflowDeployment(
|
||||||
|
id="summarize_docs.work",
|
||||||
|
artifact_id="summarize_docs",
|
||||||
|
artifact_version=1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
store.save_deployment(
|
||||||
|
WorkflowDeployment(
|
||||||
|
id="summarize_docs.personal",
|
||||||
|
artifact_id="summarize_docs",
|
||||||
|
artifact_version=1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
store.save_deployment(
|
||||||
|
WorkflowDeployment(
|
||||||
|
id="summarize_docs.v2",
|
||||||
|
artifact_id="summarize_docs",
|
||||||
|
artifact_version=2,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
blockers = store.deployments_for_artifact("summarize_docs", 1)
|
||||||
|
|
||||||
|
assert [deployment.id for deployment in blockers] == [
|
||||||
|
"summarize_docs.personal",
|
||||||
|
"summarize_docs.work",
|
||||||
|
]
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ from dataclasses import replace
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from tests.wf_mcp.test_support import echo_tool
|
from tests.wf_mcp.test_support import echo_tool
|
||||||
from wf_api.artifacts import WorkflowArtifactApi
|
from wf_api.artifacts import WorkflowArtifactApi
|
||||||
from wf_artifacts import (
|
from wf_artifacts import (
|
||||||
@@ -14,6 +16,7 @@ from wf_artifacts import (
|
|||||||
FileWorkflowArtifactStore,
|
FileWorkflowArtifactStore,
|
||||||
RequiredCapability,
|
RequiredCapability,
|
||||||
WorkflowArtifact,
|
WorkflowArtifact,
|
||||||
|
WorkflowDeployment,
|
||||||
)
|
)
|
||||||
from wf_mcp.broker import WfMcpService
|
from wf_mcp.broker import WfMcpService
|
||||||
from wf_mcp.broker.service.workflow_operation_context import context_from_service
|
from wf_mcp.broker.service.workflow_operation_context import context_from_service
|
||||||
@@ -276,3 +279,39 @@ def test_handler_delegation_for_inspect_artifact(tmp_path: Path) -> None:
|
|||||||
assert handler_result["id"] == api_result["id"]
|
assert handler_result["id"] == api_result["id"]
|
||||||
assert handler_result["version"] == api_result["version"]
|
assert handler_result["version"] == api_result["version"]
|
||||||
assert handler_result["title"] == api_result["title"]
|
assert handler_result["title"] == api_result["title"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_artifact_deletes_unreferenced_version(tmp_path: Path) -> None:
|
||||||
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "artifacts_delete")
|
||||||
|
api, _service = _artifact_api(artifact_store)
|
||||||
|
artifact_store.save_artifact(_echo_artifact())
|
||||||
|
|
||||||
|
result = asyncio.run(api.delete_artifact(artifact_id="echo", version=1))
|
||||||
|
|
||||||
|
assert result["artifact_id"] == "echo"
|
||||||
|
assert result["version"] == 1
|
||||||
|
assert result["deleted"] is True
|
||||||
|
assert result["blocked_by_deployments"] == []
|
||||||
|
with pytest.raises(KeyError, match="unknown workflow artifact"):
|
||||||
|
artifact_store.get_artifact("echo", 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_artifact_rejects_referenced_version(tmp_path: Path) -> None:
|
||||||
|
artifact_store = FileWorkflowArtifactStore(tmp_path / "artifacts_delete_blocked")
|
||||||
|
api, _service = _artifact_api(artifact_store)
|
||||||
|
artifact_store.save_artifact(_echo_artifact())
|
||||||
|
artifact_store.save_deployment(
|
||||||
|
WorkflowDeployment(
|
||||||
|
id="echo.default",
|
||||||
|
artifact_id="echo",
|
||||||
|
artifact_version=1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = asyncio.run(api.delete_artifact(artifact_id="echo", version=1))
|
||||||
|
|
||||||
|
assert result["artifact_id"] == "echo"
|
||||||
|
assert result["version"] == 1
|
||||||
|
assert result["deleted"] is False
|
||||||
|
assert result["blocked_by_deployments"] == ["echo.default"]
|
||||||
|
assert artifact_store.get_artifact("echo", 1).id == "echo"
|
||||||
|
|||||||
@@ -22,12 +22,50 @@ class _ArtifactHandlers:
|
|||||||
self.calls.append((artifact_id, version))
|
self.calls.append((artifact_id, version))
|
||||||
return {"id": artifact_id, "version": version}
|
return {"id": artifact_id, "version": version}
|
||||||
|
|
||||||
|
async def delete_artifact(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
artifact_id: str,
|
||||||
|
version: int,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
self.calls.append((artifact_id, version))
|
||||||
|
return {
|
||||||
|
"artifact_id": artifact_id,
|
||||||
|
"version": version,
|
||||||
|
"deleted": True,
|
||||||
|
"blocked_by_deployments": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _BlockedArtifactHandlers:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls: list[tuple[str, int]] = []
|
||||||
|
|
||||||
|
async def delete_artifact(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
artifact_id: str,
|
||||||
|
version: int,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
self.calls.append((artifact_id, version))
|
||||||
|
return {
|
||||||
|
"artifact_id": artifact_id,
|
||||||
|
"version": version,
|
||||||
|
"deleted": False,
|
||||||
|
"blocked_by_deployments": ["echo.default"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class _Context:
|
class _Context:
|
||||||
handlers: _ArtifactHandlers
|
handlers: _ArtifactHandlers
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _BlockedContext:
|
||||||
|
handlers: _BlockedArtifactHandlers
|
||||||
|
|
||||||
|
|
||||||
def test_artifact_inspect_accepts_version_option(monkeypatch) -> None:
|
def test_artifact_inspect_accepts_version_option(monkeypatch) -> None:
|
||||||
handlers = _ArtifactHandlers()
|
handlers = _ArtifactHandlers()
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
@@ -57,3 +95,52 @@ def test_artifact_inspect_keeps_positional_version(monkeypatch) -> None:
|
|||||||
assert result.exit_code == 0, result.output
|
assert result.exit_code == 0, result.output
|
||||||
assert json.loads(result.output) == {"id": "demo_artifact", "version": 3}
|
assert json.loads(result.output) == {"id": "demo_artifact", "version": 3}
|
||||||
assert handlers.calls == [("demo_artifact", 3)]
|
assert handlers.calls == [("demo_artifact", 3)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_artifact_delete_requires_confirm(monkeypatch) -> None:
|
||||||
|
handlers = _ArtifactHandlers()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"wf_cli.commands.artifacts.load_cli_context",
|
||||||
|
lambda _ctx: _Context(handlers=handlers),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = CliRunner().invoke(app, ["artifact", "delete", "echo", "1"])
|
||||||
|
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "confirm" in result.output.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_artifact_delete_confirmed_succeeds(monkeypatch) -> None:
|
||||||
|
handlers = _ArtifactHandlers()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"wf_cli.commands.artifacts.load_cli_context",
|
||||||
|
lambda _ctx: _Context(handlers=handlers),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
app, ["artifact", "delete", "echo", "1", "--confirm"]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
payload = json.loads(result.output)
|
||||||
|
assert payload["artifact_id"] == "echo"
|
||||||
|
assert payload["version"] == 1
|
||||||
|
assert payload["deleted"] is True
|
||||||
|
assert handlers.calls == [("echo", 1)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_artifact_delete_blocked_returns_blocker_ids(monkeypatch) -> None:
|
||||||
|
handlers = _BlockedArtifactHandlers()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"wf_cli.commands.artifacts.load_cli_context",
|
||||||
|
lambda _ctx: _BlockedContext(handlers=handlers),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
app, ["artifact", "delete", "echo", "1", "--confirm"]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
payload = json.loads(result.output)
|
||||||
|
assert payload["deleted"] is False
|
||||||
|
assert payload["blocked_by_deployments"] == ["echo.default"]
|
||||||
|
|||||||
@@ -336,6 +336,31 @@ async def test_rpc_draft_workspace_delete(tmp_path) -> None:
|
|||||||
assert payload["result"]["deleted"] is True
|
assert payload["result"]["deleted"] is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rpc_artifact_delete(tmp_path) -> None:
|
||||||
|
server = build_local_static_workflow_server(tmp_path / "store")
|
||||||
|
await server.api.create_artifact_from_plan(
|
||||||
|
artifact_id="delete_artifact",
|
||||||
|
version=1,
|
||||||
|
title="Delete Me",
|
||||||
|
plan=_constant_plan(),
|
||||||
|
outcomes=["ok"],
|
||||||
|
source_bindings={"wf.std": "wf.std"},
|
||||||
|
)
|
||||||
|
|
||||||
|
app = create_rpc_app(server)
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
payload = await _rpc(
|
||||||
|
client,
|
||||||
|
"workflow.artifacts.delete",
|
||||||
|
{"artifact_id": "delete_artifact", "version": 1},
|
||||||
|
)
|
||||||
|
assert payload["result"]["artifact_id"] == "delete_artifact"
|
||||||
|
assert payload["result"]["version"] == 1
|
||||||
|
assert payload["result"]["deleted"] is True
|
||||||
|
assert payload["result"]["blocked_by_deployments"] == []
|
||||||
|
|
||||||
|
|
||||||
def _constant_plan() -> RawWorkflowPlan:
|
def _constant_plan() -> RawWorkflowPlan:
|
||||||
return RawWorkflowPlan.model_validate(
|
return RawWorkflowPlan.model_validate(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -346,3 +346,29 @@ async def test_rpc_workflow_client_deletes_draft_workspace(tmp_path) -> None:
|
|||||||
deleted_again = await client.delete_draft_workspace(workspace_id="delete-me")
|
deleted_again = await client.delete_draft_workspace(workspace_id="delete-me")
|
||||||
assert deleted_again["workspace_id"] == "delete-me"
|
assert deleted_again["workspace_id"] == "delete-me"
|
||||||
assert deleted_again["deleted"] is False
|
assert deleted_again["deleted"] is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rpc_workflow_client_deletes_artifact(tmp_path) -> None:
|
||||||
|
server = build_local_static_workflow_server(tmp_path / "store")
|
||||||
|
await server.api.create_artifact_from_plan(
|
||||||
|
artifact_id="delete_artifact",
|
||||||
|
version=1,
|
||||||
|
title="Delete Me",
|
||||||
|
plan=_constant_plan(),
|
||||||
|
outcomes=["ok"],
|
||||||
|
source_bindings={"wf.std": "wf.std"},
|
||||||
|
)
|
||||||
|
|
||||||
|
app = create_rpc_app(server)
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
transport=transport, base_url="http://test"
|
||||||
|
) as http_client:
|
||||||
|
client = RpcWorkflowApiClient(
|
||||||
|
url="http://test/rpc", timeout_seconds=5, http_client=http_client
|
||||||
|
)
|
||||||
|
deleted = await client.delete_artifact(artifact_id="delete_artifact", version=1)
|
||||||
|
|
||||||
|
assert deleted["deleted"] is True
|
||||||
|
assert deleted["artifact_id"] == "delete_artifact"
|
||||||
|
assert deleted["version"] == 1
|
||||||
|
|||||||
Reference in New Issue
Block a user