feat: add safe artifact delete

This commit is contained in:
lda
2026-06-09 05:11:11 +07:00 Verified
parent 22f6dea601
commit a7b415a58a
17 changed files with 412 additions and 44 deletions
+4 -7
View File
@@ -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.
@@ -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.
+9
View File
@@ -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:
+26
View File
@@ -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."""
+11
View File
@@ -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],
+7
View File
@@ -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."""
+26
View File
@@ -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)
+28
View File
@@ -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},
)
+18 -1
View File
@@ -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)
+5
View File
@@ -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
+51
View File
@@ -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",
]
+39
View File
@@ -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"
+87
View File
@@ -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"]
+25
View File
@@ -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