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).
- Completed: `wf artifact inspect` now accepts `--version` as an alias for the
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
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).
- 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
by `cap call`, and only add a text-unwrapping CLI option if the safe behavior
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`
- 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:
@@ -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:
@@ -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.
- [ ] **Step 3: Add abstract store methods**
- [x] **Step 3: Add abstract store methods**
In `WorkflowArtifactStore`, add:
@@ -123,7 +123,7 @@ In `WorkflowArtifactStore`, add:
raise NotImplementedError
```
- [ ] **Step 4: Implement file store methods**
- [x] **Step 4: Implement file store methods**
In `FileWorkflowArtifactStore`, add:
@@ -149,7 +149,7 @@ In `FileWorkflowArtifactStore`, add:
Keep sorting behavior from `list_deployments()`; do not add separate sort logic.
- [ ] **Step 5: Run store tests**
- [x] **Step 5: Run store tests**
Run:
@@ -166,7 +166,7 @@ Expected: PASS.
- Modify: `src/wf_api/surface.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`:
@@ -216,7 +216,7 @@ from wf_artifacts import WorkflowDeployment
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:
@@ -226,7 +226,7 @@ uv run pytest tests/wf_api/test_artifact_api.py -q
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`:
@@ -239,7 +239,7 @@ In `src/wf_api/surface.py`, add to `WorkflowArtifactSurface`:
) -> dict[str, Any]: ...
```
- [ ] **Step 4: Implement API method**
- [x] **Step 4: Implement API method**
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.
- [ ] **Step 5: Run API tests**
- [x] **Step 5: Run API tests**
Run:
@@ -292,7 +292,7 @@ Expected: PASS.
- Test: `tests/wf_transport_rpc_http/test_app.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:
@@ -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.
- [ ] **Step 2: Run RPC tests and verify failure**
- [x] **Step 2: Run RPC tests and verify failure**
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.
- [ ] **Step 3: Add params model**
- [x] **Step 3: Add params model**
In `src/wf_transport_rpc_http/models.py`, add:
@@ -337,7 +337,7 @@ class DeleteArtifactParams(RpcParamsModel):
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:
@@ -355,7 +355,7 @@ In `src/wf_transport_rpc_http/methods/artifacts.py`, import `DeleteArtifactParam
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:
@@ -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:
@@ -385,7 +385,7 @@ Expected: PASS.
- Modify: `src/wf_cli/commands/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:
@@ -422,7 +422,7 @@ assert payload["deleted"] is False
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:
@@ -432,7 +432,7 @@ uv run pytest tests/wf_cli/test_remote_target.py -q
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:
@@ -465,7 +465,7 @@ def delete_artifact(
)
```
- [ ] **Step 4: Run CLI tests**
- [x] **Step 4: Run CLI tests**
Run:
@@ -482,7 +482,7 @@ Expected: PASS.
- Modify: `docs/current_roadmap.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:
@@ -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>`.
- [ ] **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.
- [ ] **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:
@@ -509,7 +509,7 @@ unreferenced artifact versions and rejects versions referenced by deployments.
## Task 6: Final Verification
- [ ] **Step 1: Run focused tests**
- [x] **Step 1: Run focused tests**
Run:
@@ -519,7 +519,7 @@ uv run pytest tests/artifacts/test_store.py tests/wf_api/test_artifact_api.py te
Expected: PASS.
- [ ] **Step 2: Run changed-file lint/type checks**
- [x] **Step 2: Run changed-file lint/type checks**
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.
- [ ] **Step 3: Optional manual smoke**
- [x] **Step 3: Optional manual smoke**
Against a running server:
@@ -1,7 +1,12 @@
# Artifact Delete Policy
This is the current design contract for adding artifact deletion. It is separate
from `wf draft delete` because artifact deletion is not only CLI plumbing.
## Status
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
@@ -9,19 +14,20 @@ from `wf draft delete` because artifact deletion is not only CLI plumbing.
through CLI/RPC.
- Deployment deletion already exists and is stored through
`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
`(artifact_id, version)`.
This means `wf artifact delete <artifact_id> <version>` needs store-level policy,
not just a CLI command.
This means `wf artifact delete <artifact_id> <version>` enforces store-level
policy, not just CLI confirmation.
## Required Safety Rule
Artifact deletion must not remove an artifact version while any deployment
references that artifact version.
The first implementation should reject with structured output like:
Deletion rejects referenced artifacts with structured output like:
```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
must include the blocking deployment ids.
## Non-Goals For First Slice
## Non-Goals
- Do not cascade-delete deployments.
- Do not delete runs.
@@ -55,7 +61,8 @@ The non-cascade path must remain the default.
## 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.
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
The first artifact-delete slice should include tests for:
Artifact deletion should keep tests for:
- Deleting an unreferenced artifact version succeeds.
- 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
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
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
Save a deployment from flags:
+26
View File
@@ -276,6 +276,32 @@ class WorkflowArtifactApi:
artifact = self._artifact_store().get_artifact(artifact_id, version)
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]:
"""Suggest local bindings for built-in sources that deploy to themselves."""
+11
View File
@@ -95,6 +95,17 @@ class WorkflowApi:
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(
self,
artifact: dict[str, Any],
+7
View File
@@ -137,6 +137,13 @@ class WorkflowArtifactSurface(Protocol):
version: int,
) -> dict[str, Any]: ...
async def delete_artifact(
self,
*,
artifact_id: str,
version: int,
) -> dict[str, Any]: ...
class WorkflowDeploymentSurface(Protocol):
"""Deployment methods exposed by workflow frontends."""
+26
View File
@@ -43,6 +43,14 @@ class WorkflowArtifactStore:
def list_deployments(self) -> list[WorkflowDeployment]:
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:
raise NotImplementedError
@@ -118,6 +126,24 @@ class FileWorkflowArtifactStore(WorkflowArtifactStore):
)
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:
"""Remove one mutable deployment binding record from the store."""
path = self._deployment_path(deployment_id)
+28
View File
@@ -96,3 +96,31 @@ def _resolve_artifact_version(
return version_option
assert version_arg is not None
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]
) -> dict[str, Any]:
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 ..errors import WorkflowRpcError, raise_workflow_rpc_error
from ..models import InspectArtifactParams, ListArtifactsParams, SaveArtifactParams
from ..models import (
DeleteArtifactParams,
InspectArtifactParams,
ListArtifactsParams,
SaveArtifactParams,
)
from ..params import RpcParams
@@ -51,3 +56,15 @@ def register_methods(
)
except (ValueError, KeyError, LookupError, FileNotFoundError) as 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)
class DeleteArtifactParams(RpcParamsModel):
artifact_id: str = Field(min_length=1)
version: int = Field(ge=1)
class ListDeploymentsParams(RpcParamsModel):
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"):
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 typing import Any
import pytest
from tests.wf_mcp.test_support import echo_tool
from wf_api.artifacts import WorkflowArtifactApi
from wf_artifacts import (
@@ -14,6 +16,7 @@ from wf_artifacts import (
FileWorkflowArtifactStore,
RequiredCapability,
WorkflowArtifact,
WorkflowDeployment,
)
from wf_mcp.broker import WfMcpService
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["version"] == api_result["version"]
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))
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)
class _Context:
handlers: _ArtifactHandlers
@dataclass(frozen=True)
class _BlockedContext:
handlers: _BlockedArtifactHandlers
def test_artifact_inspect_accepts_version_option(monkeypatch) -> None:
handlers = _ArtifactHandlers()
monkeypatch.setattr(
@@ -57,3 +95,52 @@ def test_artifact_inspect_keeps_positional_version(monkeypatch) -> None:
assert result.exit_code == 0, result.output
assert json.loads(result.output) == {"id": "demo_artifact", "version": 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
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:
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")
assert deleted_again["workspace_id"] == "delete-me"
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