Files
paperless-ngx/docs/superpowers/plans/2026-05-19-workflow-runner-refactor.md
T
2026-09-29 13:09:58 -07:00

1272 lines
47 KiB
Markdown

# Workflow Runner Refactor Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
> **Updated 2026-09-24:** line references below are refreshed against current
> `dev`. More importantly: the motivating bugs (#12386 and the tag/m2m race)
> are **already fixed** by #12389 and #13178, independently of this plan — see
> "Status update (2026-09-24)" in the linked spec. This is now a structural
> cleanup (kill `use_overrides` dual-mode branching and the `original_file`
> parameter plumbing), not an active-bug fix. Task 7 is rescoped accordingly
> (the regression test it originally asked for already exists).
**Goal:** Replace the `use_overrides` dual-mode branching in `run_workflows` with a polymorphic `WorkflowRunContext`, and make the workflow-execution → file-rename sequence deterministic via a `ContextVar` guard.
**Architecture:** A new `documents/workflows/context.py` module defines a `WorkflowRunContext` `Protocol` and two implementations — `ConsumptionContext` (wraps `ConsumableDocument` + `DocumentMetadataOverrides`) and `PersistedContext` (wraps a real `Document`). `run_workflows` becomes a flat match-and-dispatch loop with no mode flag. A module-level `ContextVar` guard suppresses `update_filename_and_move_files` for the duration of a workflow run; the file rename is invoked once, explicitly, after the run.
**Tech Stack:** Python 3.12+, Django, Celery, pytest (pytest-xdist), `uv`, `ruff`.
**Reference spec:** `docs/superpowers/specs/2026-05-19-workflow-runner-refactor-design.md`
---
## Conventions for every task
- All Python commands run from `src/`. Tests: `uv run pytest`. Lint: `ruff check --fix` then `ruff format` (global `ruff`, **not** `uv run ruff`).
- New test code: group tests under classes, put `@pytest.mark.django_db` per class where DB is needed, fully type-annotate every fixture parameter, fixture return type, and test signature.
- Commit after each task with the message shown in its final step.
## File structure
| File | Responsibility | Change |
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- |
| `src/documents/workflows/context.py` | `ContextVar` guard, `WorkflowRunContext` Protocol, `ConsumptionContext`, `PersistedContext`, `build_workflow_context` factory | **Create** |
| `src/documents/signals/handlers.py` | `run_workflows` rewritten as flat loop; `update_filename_and_move_files` split into receiver + `move_files_for_document` | Modify |
| `src/documents/workflows/actions.py` | Drop `build_workflow_action_context`; `execute_email_action` / `execute_webhook_action` take `source_file` instead of `original_file` | Modify |
| `src/documents/tests/test_workflow_context.py` | Unit tests for the guard and the two contexts | **Create** |
| `src/documents/tests/test_workflows.py` | Existing regression suite (must stay green) + new guard/race regression tests | Modify (add tests only) |
---
## Task 1: ContextVar guard
**Files:**
- Create: `src/documents/workflows/context.py`
- Test: `src/documents/tests/test_workflow_context.py`
- [ ] **Step 1: Write the failing test**
Create `src/documents/tests/test_workflow_context.py`:
```python
from documents.workflows.context import workflow_guard
from documents.workflows.context import workflow_run_in_progress
class TestWorkflowGuard:
def test_guard_not_set_by_default(self) -> None:
assert workflow_run_in_progress() is False
def test_guard_set_inside_context_manager(self) -> None:
with workflow_guard():
assert workflow_run_in_progress() is True
assert workflow_run_in_progress() is False
def test_guard_is_reentrant(self) -> None:
with workflow_guard():
with workflow_guard():
assert workflow_run_in_progress() is True
assert workflow_run_in_progress() is True
assert workflow_run_in_progress() is False
def test_guard_resets_on_exception(self) -> None:
try:
with workflow_guard():
raise RuntimeError("boom")
except RuntimeError:
pass
assert workflow_run_in_progress() is False
```
- [ ] **Step 2: Run test to verify it fails**
Run: `uv run pytest documents/tests/test_workflow_context.py -v`
Expected: FAIL with `ModuleNotFoundError: No module named 'documents.workflows.context'`
- [ ] **Step 3: Create the module with the guard**
Create `src/documents/workflows/context.py` with only the guard for now:
```python
import logging
from collections.abc import Iterator
from contextlib import contextmanager
from contextvars import ContextVar
logger = logging.getLogger("paperless.workflows")
_workflow_in_progress: ContextVar[bool] = ContextVar(
"workflow_in_progress",
default=False,
)
def workflow_run_in_progress() -> bool:
"""
True while run_workflows is executing on the current thread/context.
update_filename_and_move_files checks this and early-returns, so the file
rename does not race the workflow's own metadata mutations.
"""
return _workflow_in_progress.get()
@contextmanager
def workflow_guard() -> Iterator[None]:
"""
Suppress update_filename_and_move_files for the duration of a workflow run.
Token-based reset keeps this reentrancy-safe: nested workflow_guard() blocks
(e.g. a workflow whose action triggers another save) restore the previous
value rather than unconditionally clearing the flag.
"""
token = _workflow_in_progress.set(True)
try:
yield
finally:
_workflow_in_progress.reset(token)
```
- [ ] **Step 4: Run test to verify it passes**
Run: `uv run pytest documents/tests/test_workflow_context.py -v`
Expected: PASS (4 tests)
- [ ] **Step 5: Lint and commit**
```bash
ruff check --fix src/documents/workflows/context.py src/documents/tests/test_workflow_context.py
ruff format src/documents/workflows/context.py src/documents/tests/test_workflow_context.py
git add src/documents/workflows/context.py src/documents/tests/test_workflow_context.py
git commit -m "Add ContextVar guard for workflow runner"
```
---
## Task 2: `WorkflowRunContext` Protocol + `ConsumptionContext`
**Files:**
- Modify: `src/documents/workflows/context.py`
- Test: `src/documents/tests/test_workflow_context.py`
The `ConsumptionContext` absorbs the `use_overrides=True` branches currently in
`run_workflows` (`handlers.py:862-1051`) and `build_workflow_action_context`
(`actions.py:33,53-83`).
- [ ] **Step 1: Write the failing test**
Append to `src/documents/tests/test_workflow_context.py`:
```python
import pytest
from documents.data_models import ConsumableDocument
from documents.data_models import DocumentMetadataOverrides
from documents.data_models import DocumentSource
from documents.models import Correspondent
from documents.models import WorkflowTrigger
from documents.workflows.context import ConsumptionContext
@pytest.mark.django_db
class TestConsumptionContext:
@pytest.fixture
def consumable(self, tmp_path) -> ConsumableDocument:
staged = tmp_path / "staged.pdf"
staged.write_bytes(b"%PDF-1.4")
return ConsumableDocument(
source=DocumentSource.ConsumeFolder,
original_file=staged,
)
def test_source_and_staged_file_are_the_staged_path(
self,
consumable: ConsumableDocument,
) -> None:
overrides = DocumentMetadataOverrides()
ctx = ConsumptionContext(
WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
consumable,
overrides,
)
assert ctx.source_file == consumable.original_file
assert ctx.staged_file == consumable.original_file
assert ctx.target is consumable
def test_assignment_mutates_overrides_not_db(
self,
consumable: ConsumableDocument,
) -> None:
correspondent = Correspondent.objects.create(name="ACME")
overrides = DocumentMetadataOverrides()
ctx = ConsumptionContext(
WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
consumable,
overrides,
)
class FakeAction:
assign_correspondent = correspondent
assign_document_type = None
assign_storage_path = None
assign_owner = None
assign_title = None
has_assign_tags = False
has_assign_view_users = False
has_assign_view_groups = False
has_assign_change_users = False
has_assign_change_groups = False
has_assign_custom_fields = False
ctx.apply_assignment(FakeAction(), logging_group=None)
assert overrides.correspondent_id == correspondent.pk
def test_log_action_collects_messages(
self,
consumable: ConsumableDocument,
) -> None:
ctx = ConsumptionContext(
WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
consumable,
DocumentMetadataOverrides(),
)
ctx.log_action("hello", logging_group=None)
assert ctx.messages == ["hello"]
```
- [ ] **Step 2: Run test to verify it fails**
Run: `uv run pytest documents/tests/test_workflow_context.py::TestConsumptionContext -v`
Expected: FAIL with `ImportError: cannot import name 'ConsumptionContext'`
- [ ] **Step 3: Add the Protocol and `ConsumptionContext`**
Add to `src/documents/workflows/context.py` (after the guard). Add these imports at the top of the file:
```python
import uuid
from pathlib import Path
from typing import Protocol
from django.contrib.auth.models import User
from django.utils import timezone
from documents.data_models import ConsumableDocument
from documents.data_models import DocumentMetadataOverrides
from documents.models import Correspondent
from documents.models import Document
from documents.models import DocumentType
from documents.models import Workflow
from documents.models import WorkflowAction
from documents.models import WorkflowRun
from documents.models import WorkflowTrigger
from documents.workflows.mutations import apply_assignment_to_overrides
from documents.workflows.mutations import apply_removal_to_overrides
```
Then append:
```python
class WorkflowRunContext(Protocol):
"""
Uniform surface a workflow action operates on, independent of whether the
run targets a freshly-consumed document (overrides) or a persisted one.
"""
trigger_type: WorkflowTrigger.WorkflowTriggerType
@property
def target(self) -> Document | ConsumableDocument:
"""The object passed to matching and to email/webhook/trash actions."""
...
@property
def source_file(self) -> Path:
"""Best-effort current on-disk location of the document file."""
...
@property
def staged_file(self) -> Path | None:
"""
The staged (pre-final-move) path, when the file is not yet at its
final storage location; otherwise None. Used by password removal.
"""
...
def refresh(self, logging_group: uuid.UUID | None) -> bool:
"""Reload state before matching. Return False to stop the run."""
...
def build_placeholder_context(self) -> dict:
"""Context dict for email/webhook placeholder parsing."""
...
def apply_assignment(
self,
action: WorkflowAction,
logging_group: uuid.UUID | None,
) -> None: ...
def apply_removal(
self,
action: WorkflowAction,
logging_group: uuid.UUID | None,
) -> None: ...
def log_action(self, message: str, logging_group: uuid.UUID | None) -> None:
"""Record an 'applying action' message."""
...
def persist(self) -> None:
"""Commit accumulated mutations (no-op for the consumption flow)."""
...
def record_run(self, workflow: Workflow) -> None:
"""Create the WorkflowRun audit row."""
...
def finalize_file_location(self) -> None:
"""Run the one-time file rename after the workflow run completes."""
...
class ConsumptionContext:
"""Workflow run against a document still being consumed."""
def __init__(
self,
trigger_type: WorkflowTrigger.WorkflowTriggerType,
consumable: ConsumableDocument,
overrides: DocumentMetadataOverrides,
) -> None:
self.trigger_type = trigger_type
self.consumable = consumable
self.overrides = overrides
self.messages: list[str] = []
@property
def target(self) -> Document | ConsumableDocument:
return self.consumable
@property
def source_file(self) -> Path:
return self.consumable.original_file
@property
def staged_file(self) -> Path | None:
return self.consumable.original_file
def refresh(self, logging_group: uuid.UUID | None) -> bool:
return True
def build_placeholder_context(self) -> dict:
overrides = self.overrides
correspondent_obj = (
Correspondent.objects.filter(pk=overrides.correspondent_id).first()
if overrides.correspondent_id
else None
)
document_type_obj = (
DocumentType.objects.filter(pk=overrides.document_type_id).first()
if overrides.document_type_id
else None
)
owner_obj = (
User.objects.filter(pk=overrides.owner_id).first()
if overrides.owner_id
else None
)
filename = (
self.consumable.original_file if self.consumable.original_file else ""
)
return {
"title": overrides.title
if overrides.title
else str(self.consumable.original_file),
"doc_url": "",
"correspondent": correspondent_obj.name if correspondent_obj else "",
"document_type": document_type_obj.name if document_type_obj else "",
"owner_username": owner_obj.username if owner_obj else "",
"filename": filename,
"current_filename": filename,
"added": timezone.localtime(timezone.now()),
"created": overrides.created,
"id": "",
}
def apply_assignment(
self,
action: WorkflowAction,
logging_group: uuid.UUID | None,
) -> None:
apply_assignment_to_overrides(action, self.overrides)
def apply_removal(
self,
action: WorkflowAction,
logging_group: uuid.UUID | None,
) -> None:
apply_removal_to_overrides(action, self.overrides)
def log_action(self, message: str, logging_group: uuid.UUID | None) -> None:
self.messages.append(message)
def persist(self) -> None:
return None
def record_run(self, workflow: Workflow) -> None:
WorkflowRun.objects.create(
workflow=workflow,
type=self.trigger_type,
document=None,
)
def finalize_file_location(self) -> None:
return None
```
> Note: the `build_placeholder_context` body is moved verbatim from the
> `use_overrides` branch of `build_workflow_action_context` (`actions.py:53-83`).
- [ ] **Step 4: Run test to verify it passes**
Run: `uv run pytest documents/tests/test_workflow_context.py::TestConsumptionContext -v`
Expected: PASS (3 tests)
- [ ] **Step 5: Lint and commit**
```bash
ruff check --fix src/documents/workflows/context.py src/documents/tests/test_workflow_context.py
ruff format src/documents/workflows/context.py src/documents/tests/test_workflow_context.py
git add src/documents/workflows/context.py src/documents/tests/test_workflow_context.py
git commit -m "Add WorkflowRunContext protocol and ConsumptionContext"
```
---
## Task 3: `PersistedContext` + `build_workflow_context` factory
**Files:**
- Modify: `src/documents/workflows/context.py`
- Test: `src/documents/tests/test_workflow_context.py`
`PersistedContext` absorbs the `use_overrides=False` branches of `run_workflows`
(`handlers.py:904-1046`) and `build_workflow_action_context` (`actions.py:35-51`).
- [ ] **Step 1: Write the failing test**
Append to `src/documents/tests/test_workflow_context.py`:
```python
from documents.workflows.context import PersistedContext
from documents.workflows.context import build_workflow_context
@pytest.mark.django_db
class TestPersistedContext:
@pytest.fixture
def document(self) -> Document:
return Document.objects.create(
title="doc",
mime_type="application/pdf",
checksum="abc123",
)
def test_source_file_is_document_source_path_without_staged(
self,
document: Document,
) -> None:
ctx = PersistedContext(
WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
document,
staged_file=None,
)
assert ctx.source_file == document.source_path
assert ctx.staged_file is None
assert ctx.target is document
def test_source_file_prefers_staged_path(
self,
document: Document,
tmp_path,
) -> None:
staged = tmp_path / "staged.pdf"
staged.write_bytes(b"%PDF-1.4")
ctx = PersistedContext(
WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
document,
staged_file=staged,
)
assert ctx.source_file == staged
assert ctx.staged_file == staged
def test_refresh_returns_false_when_document_deleted(
self,
document: Document,
) -> None:
ctx = PersistedContext(
WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
document,
staged_file=None,
)
Document.objects.filter(pk=document.pk).delete()
assert ctx.refresh(logging_group=None) is False
@pytest.mark.django_db
class TestBuildWorkflowContext:
def test_overrides_present_builds_consumption_context(self, tmp_path) -> None:
from documents.data_models import DocumentSource
consumable = ConsumableDocument(
source=DocumentSource.ConsumeFolder,
original_file=tmp_path / "f.pdf",
)
ctx = build_workflow_context(
trigger_type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
document=consumable,
overrides=DocumentMetadataOverrides(),
staged_file=None,
)
assert isinstance(ctx, ConsumptionContext)
def test_no_overrides_builds_persisted_context(self) -> None:
document = Document.objects.create(
title="d",
mime_type="application/pdf",
checksum="zzz",
)
ctx = build_workflow_context(
trigger_type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
document=document,
overrides=None,
staged_file=None,
)
assert isinstance(ctx, PersistedContext)
```
- [ ] **Step 2: Run test to verify it fails**
Run: `uv run pytest documents/tests/test_workflow_context.py::TestPersistedContext -v`
Expected: FAIL with `ImportError: cannot import name 'PersistedContext'`
- [ ] **Step 3: Add `PersistedContext` and the factory**
Add these imports to `src/documents/workflows/context.py`:
```python
from documents.workflows.mutations import apply_assignment_to_document
from documents.workflows.mutations import apply_removal_to_document
```
Append to `src/documents/workflows/context.py`:
```python
# Fields a workflow ASSIGNMENT/REMOVAL action can set directly on a Document.
# Deliberately excludes filename / archive_filename: those are managed only by
# move_files_for_document. A workflow never sets them, and writing a stale
# in-memory value here would revert a concurrent (cross-process) file move.
# modified has auto_now=True but is not auto-added when update_fields is given.
_WORKFLOW_SAVE_FIELDS = [
"title",
"correspondent",
"document_type",
"storage_path",
"owner",
"modified",
]
class PersistedContext:
"""Workflow run against a document already in the database."""
def __init__(
self,
trigger_type: WorkflowTrigger.WorkflowTriggerType,
document: Document,
staged_file: Path | None,
) -> None:
self.trigger_type = trigger_type
self.document = document
self._staged_file = staged_file
@property
def target(self) -> Document | ConsumableDocument:
return self.document
@property
def source_file(self) -> Path:
return self._staged_file if self._staged_file else self.document.source_path
@property
def staged_file(self) -> Path | None:
return self._staged_file
def refresh(self, logging_group: uuid.UUID | None) -> bool:
try:
# run_workflows may be called repeatedly from bulk_update_documents;
# refresh so matching data is current and we do not overwrite work
# done by another process.
self.document.refresh_from_db()
except Document.DoesNotExist:
logger.info(
"Document no longer exists, skipping remaining workflows",
extra={"group": logging_group},
)
return False
if self.document.is_deleted:
logger.info(
"Document was moved to trash, skipping remaining workflows",
extra={"group": logging_group},
)
return False
return True
def build_placeholder_context(self) -> dict:
from django.conf import settings
document = self.document
return {
"title": document.title,
"doc_url": f"{settings.PAPERLESS_URL}{settings.BASE_URL}documents/{document.pk}/",
"correspondent": document.correspondent.name
if document.correspondent
else "",
"document_type": document.document_type.name
if document.document_type
else "",
"owner_username": document.owner.username if document.owner else "",
"filename": document.original_filename or "",
"current_filename": document.filename or "",
"added": timezone.localtime(document.added),
"created": document.created,
"id": document.pk,
}
def apply_assignment(
self,
action: WorkflowAction,
logging_group: uuid.UUID | None,
) -> None:
apply_assignment_to_document(action, self.document, logging_group)
def apply_removal(
self,
action: WorkflowAction,
logging_group: uuid.UUID | None,
) -> None:
apply_removal_to_document(action, self.document)
def log_action(self, message: str, logging_group: uuid.UUID | None) -> None:
logger.info(message, extra={"group": logging_group})
def persist(self) -> None:
# limit title to 128 characters
self.document.title = self.document.title[:128]
self.document.save(update_fields=_WORKFLOW_SAVE_FIELDS)
def record_run(self, workflow: Workflow) -> None:
WorkflowRun.objects.create(
workflow=workflow,
type=self.trigger_type,
document=self.document,
)
def finalize_file_location(self) -> None:
# Imported here to avoid a circular import (handlers imports this module).
from documents.signals.handlers import move_files_for_document
try:
self.document.refresh_from_db()
except Document.DoesNotExist:
return
if self.document.is_deleted:
return
move_files_for_document(self.document)
def build_workflow_context(
*,
trigger_type: WorkflowTrigger.WorkflowTriggerType,
document: Document | ConsumableDocument,
overrides: DocumentMetadataOverrides | None,
staged_file: Path | None,
) -> WorkflowRunContext:
"""
Pick the context implementation from the call shape: a non-None `overrides`
means the consumption flow (ConsumableDocument); otherwise a persisted
Document.
"""
if overrides is not None:
assert isinstance(document, ConsumableDocument)
return ConsumptionContext(trigger_type, document, overrides)
assert isinstance(document, Document)
return PersistedContext(trigger_type, document, staged_file)
```
> Note: `build_placeholder_context` here is moved verbatim from the
> non-`use_overrides` branch of `build_workflow_action_context`
> (`actions.py:35-51`). `_WORKFLOW_SAVE_FIELDS` and `persist()` reproduce the
> save at `handlers.py:1028-1037` — keep the field list and the comment.
- [ ] **Step 4: Run test to verify it passes**
Run: `uv run pytest documents/tests/test_workflow_context.py -v`
Expected: PASS (all tests, ~13)
- [ ] **Step 5: Lint and commit**
```bash
ruff check --fix src/documents/workflows/context.py src/documents/tests/test_workflow_context.py
ruff format src/documents/workflows/context.py src/documents/tests/test_workflow_context.py
git add src/documents/workflows/context.py src/documents/tests/test_workflow_context.py
git commit -m "Add PersistedContext and build_workflow_context factory"
```
---
## Task 4: Extract `move_files_for_document` from the rename receiver
**Files:**
- Modify: `src/documents/signals/handlers.py:437-673`
This task is a pure extraction — no behavior change yet. The guard check is
added in Task 6.
> **Note:** re-verify these line numbers against current `handlers.py` before
> starting — this file also received a checksum-based "already moved" recovery
> fix (#12389, `_path_matches_checksum` at `handlers.py:416-420` plus changes
> inside `validate_move`) since this plan was written. That logic is unrelated
> to this extraction and must be carried over unchanged inside the extracted
> body — do not simplify or remove it.
- [ ] **Step 1: Confirm the regression baseline is green**
Run: `uv run pytest documents/tests/test_file_handling.py -q`
Expected: PASS (records the pre-change baseline for the rename logic)
- [ ] **Step 2: Split the function**
In `src/documents/signals/handlers.py`, replace the `update_filename_and_move_files`
definition (currently `def update_filename_and_move_files(...)` at line 440,
through the end of its body at line 673) with two definitions:
```python
@receiver(models.signals.post_save, sender=CustomFieldInstance, weak=False)
@receiver(models.signals.m2m_changed, sender=Document.tags.through, weak=False)
@receiver(models.signals.post_save, sender=Document, weak=False)
def update_filename_and_move_files(
sender,
instance: Document | CustomFieldInstance,
**kwargs,
) -> None:
if isinstance(instance, CustomFieldInstance):
if not _filename_template_uses_custom_fields(instance.document):
return
instance = instance.document
move_files_for_document(instance)
def move_files_for_document(instance: Document) -> None:
def validate_move(
instance, old_path: Path, new_path: Path, root: Path
) -> None: ... # body unchanged from current lines 444-463
```
Move the entire body currently between line 450 (`def validate_move`) and line
673 into `move_files_for_document`, **unchanged**, with one edit: the recursive
call at the end (currently `handlers.py:665-673`, which today calls the whole
receiver directly — `update_filename_and_move_files(Document, version_doc)`,
passing a synthetic `Document` `sender` to satisfy the receiver's signature)
must instead call the new function directly:
```python
# Keep version files in sync with root
if instance.root_document_id is None:
for version_doc in Document.objects.filter(root_document_id=instance.pk).only(
"pk",
):
move_files_for_document(version_doc)
```
- [ ] **Step 3: Run the rename tests**
Run: `uv run pytest documents/tests/test_file_handling.py -q`
Expected: PASS (identical to Step 1 — extraction is behavior-preserving)
- [ ] **Step 4: Run the workflow suite**
Run: `uv run pytest documents/tests/test_workflows.py -q`
Expected: PASS (unchanged)
- [ ] **Step 5: Lint and commit**
```bash
ruff check --fix src/documents/signals/handlers.py
ruff format src/documents/signals/handlers.py
git add src/documents/signals/handlers.py
git commit -m "Extract move_files_for_document from rename receiver"
```
---
## Task 5: Rewrite `run_workflows` to use contexts
**Files:**
- Modify: `src/documents/signals/handlers.py` (`run_workflows`, `run_workflows_added`)
- Modify: `src/documents/workflows/actions.py` (`execute_email_action`, `execute_webhook_action`, delete `build_workflow_action_context`)
This removes the `use_overrides` flag, the `caller_supplied_original_file`
flag, and the `original_file` threading into `execute_*` helpers. It does **not**
yet add the guard (Task 6).
- [ ] **Step 1: Confirm baseline**
Run: `uv run pytest documents/tests/test_workflows.py -q`
Expected: PASS (baseline before the rewrite)
- [ ] **Step 2: Update `actions.py` — `execute_email_action` signature**
In `src/documents/workflows/actions.py`, change `execute_email_action` so the
parameter `original_file: Path` is renamed to `source_file: Path`, and replace
every use of `original_file` in its body (`actions.py:158-167`) with
`source_file`. The logic is otherwise unchanged.
- [ ] **Step 3: Update `actions.py` — `execute_webhook_action` signature**
In `execute_webhook_action`, rename the parameter `original_file: Path` to
`source_file: Path` and replace the two uses (`actions.py:245,250`) with
`source_file`.
- [ ] **Step 4: Delete `build_workflow_action_context`**
Delete the entire `build_workflow_action_context` function from
`src/documents/workflows/actions.py` (`actions.py:26-83`) — its two branches now
live in `ConsumptionContext.build_placeholder_context` and
`PersistedContext.build_placeholder_context`.
- [ ] **Step 5: Rewrite `run_workflows` in `handlers.py`**
In `src/documents/signals/handlers.py`, add to the imports:
```python
from documents.workflows.context import WorkflowRunContext
from documents.workflows.context import build_workflow_context
```
and remove the now-unused `build_workflow_action_context` import.
Replace the entire `run_workflows` function (`handlers.py:862-1051`) with:
```python
def run_workflows(
trigger_type: WorkflowTrigger.WorkflowTriggerType,
document: Document | ConsumableDocument,
workflow_to_run: Workflow | None = None,
logging_group: uuid.UUID | None = None,
overrides: DocumentMetadataOverrides | None = None,
original_file: Path | None = None,
) -> tuple[DocumentMetadataOverrides, str] | None:
"""
Execute workflows matching a document for the given trigger.
For the consumption flow (`overrides` provided) actions mutate the overrides
and the function returns `(overrides, messages)`. Otherwise actions mutate
the persisted Document and the function returns None.
`original_file`, when given, is the staged path of a freshly-consumed file
that has not yet been moved into its final storage location.
"""
if isinstance(document, Document) and document.root_document_id is not None:
logger.debug(
"Skipping workflow execution for version document %s",
document.pk,
)
return None
context: WorkflowRunContext = build_workflow_context(
trigger_type=trigger_type,
document=document,
overrides=overrides,
staged_file=original_file,
)
for workflow in get_workflows_for_trigger(trigger_type, workflow_to_run):
if not context.refresh(logging_group):
break
if not matching.document_matches_workflow(
context.target,
workflow,
trigger_type,
):
continue
action: WorkflowAction
has_move_to_trash_action = False
for action in workflow.actions.order_by("order", "pk"):
context.log_action(f"Applying {action} from {workflow}", logging_group)
if action.type == WorkflowAction.WorkflowActionType.ASSIGNMENT:
context.apply_assignment(action, logging_group)
elif action.type == WorkflowAction.WorkflowActionType.REMOVAL:
context.apply_removal(action, logging_group)
elif action.type == WorkflowAction.WorkflowActionType.EMAIL:
execute_email_action(
action,
context.target,
context.build_placeholder_context(),
logging_group,
context.source_file,
trigger_type,
)
elif action.type == WorkflowAction.WorkflowActionType.WEBHOOK:
execute_webhook_action(
action,
context.target,
context.build_placeholder_context(),
logging_group,
context.source_file,
)
elif action.type == WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL:
execute_password_removal_action(
action,
context.target,
logging_group,
source_file=context.staged_file,
)
elif action.type == WorkflowAction.WorkflowActionType.MOVE_TO_TRASH:
has_move_to_trash_action = True
context.persist()
context.record_run(workflow)
if has_move_to_trash_action:
execute_move_to_trash_action(action, context.target, logging_group)
if isinstance(context, ConsumptionContext):
return context.overrides, "\n".join(context.messages)
return None
```
Add the import `from documents.workflows.context import ConsumptionContext` to
`handlers.py` (used for the `isinstance` at the end).
> Note on `staged_file` for password removal: previously the action received
> `original_file if caller_supplied_original_file else None`. `PersistedContext.
staged_file` is exactly the constructor `staged_file` arg, which is
> `original_file` — so DOCUMENT_ADDED gets the staged path and DOCUMENT_UPDATED
> / SCHEDULED get `None`, preserving the old behavior. For the consumption flow,
> `execute_password_removal_action` takes the `ConsumableDocument` branch and
> ignores `source_file` entirely.
- [ ] **Step 6: Update `run_workflows_added`**
`run_workflows_added` (`handlers.py:811-824`) already forwards `original_file`
to `run_workflows`. Leave it as-is — it still receives `original_file` from the
`document_consumption_finished` signal and passes it through; `run_workflows`
now routes it into `PersistedContext` construction.
- [ ] **Step 7: Run the full workflow suite**
Run: `uv run pytest documents/tests/test_workflows.py -q`
Expected: PASS — identical pass count to Step 1. If any test needs editing, STOP:
that is a behavior change, not a refactor outcome — investigate before continuing.
- [ ] **Step 8: Run consumer + matching suites**
Run: `uv run pytest documents/tests/test_consumer.py documents/tests/test_matchables.py -q`
Expected: PASS
- [ ] **Step 9: Lint and commit**
```bash
ruff check --fix src/documents/signals/handlers.py src/documents/workflows/actions.py
ruff format src/documents/signals/handlers.py src/documents/workflows/actions.py
git add src/documents/signals/handlers.py src/documents/workflows/actions.py
git commit -m "Refactor run_workflows around WorkflowRunContext, drop use_overrides"
```
---
## Task 6: Wire the guard into `run_workflows` and the rename receiver
**Files:**
- Modify: `src/documents/signals/handlers.py` (`run_workflows`, `update_filename_and_move_files`, `PersistedContext` use via `finalize_file_location`)
- Test: `src/documents/tests/test_workflows.py`
- [ ] **Step 1: Write the failing test**
Append a new test class to `src/documents/tests/test_workflows.py`. **The
action must assign both a tag and a correspondent/storage-path** (not
correspondent alone) — a correspondent-only assignment never fires
`m2m_changed` mid-run, so it would call `move_files_for_document` exactly once
even without the guard, and the test would pass vacuously. Use the same shape
as the existing `test_document_updated_workflow_assignment_storage_path_persists_with_tag_assignment`
(`test_workflows.py:3030`) for the Document/StoragePath/Workflow/ASSIGNMENT
setup, and call `run_workflows(...)` directly as that test does. The new test:
```python
class TestWorkflowRenameSequencing(TestCase):
@pytest.mark.django_db
def test_rename_suppressed_during_run_then_runs_once(self) -> None:
"""
update_filename_and_move_files must not run while a workflow is
executing; the move runs exactly once afterwards.
"""
from unittest import mock
from documents.signals import handlers
# ... set up a Document, a StoragePath whose template depends on
# correspondent, and a DOCUMENT_UPDATED workflow with one ASSIGNMENT
# action assigning BOTH a tag and that correspondent (reuse the setup
# from test_document_updated_workflow_assignment_storage_path_persists_with_tag_assignment) ...
with mock.patch.object(
handlers,
"move_files_for_document",
wraps=handlers.move_files_for_document,
) as move_spy:
run_workflows(
trigger_type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
document=document,
)
# The m2m_changed / post_save receiver must NOT have driven the move
# mid-run; only the single explicit finalize call remains.
assert move_spy.call_count == 1
```
> The implementer should flesh out the document/workflow setup using the
> patterns already present in `test_workflows.py`. The assertion that matters:
> `move_files_for_document` is called exactly once, via the explicit
> `finalize_file_location()`, not once-per-`save()` from signals. Pre-guard,
> current code (post-#13178) calls it **twice** for this setup: once from the
> `m2m_changed` receiver when `apply_assignment_to_document` fetches a fresh
> instance to add the tag (a self-consistent no-op move against still-current
> DB state), and once from the final `document.save()`'s `post_save`. So this
> test is a genuine (if narrower) regression check, not a vacuous one — it's
> just checking call count / ordering discipline rather than a wrong final
> path, since #13178 already ensures the final path is correct either way.
- [ ] **Step 2: Run test to verify it fails**
Run: `uv run pytest documents/tests/test_workflows.py::TestWorkflowRenameSequencing -v`
Expected: FAIL — without the guard, `move_files_for_document` is invoked
multiple times (via `post_save` / `m2m_changed`), so `call_count != 1`.
- [ ] **Step 3: Add the guard check to the receiver**
In `src/documents/signals/handlers.py`, add the early-return to the
`update_filename_and_move_files` receiver. Add the import:
```python
from documents.workflows.context import workflow_run_in_progress
```
and make the receiver:
```python
def update_filename_and_move_files(
sender,
instance: Document | CustomFieldInstance,
**kwargs,
) -> None:
if workflow_run_in_progress():
# A workflow run is mutating this document; the rename is deferred to a
# single explicit call in run_workflows after the run completes.
return
if isinstance(instance, CustomFieldInstance):
if not _filename_template_uses_custom_fields(instance.document):
return
instance = instance.document
move_files_for_document(instance)
```
- [ ] **Step 4: Wrap the run and add the explicit rename in `run_workflows`**
In `run_workflows`, add the import:
```python
from documents.workflows.context import workflow_guard
```
Wrap the `for workflow in ...` loop in `with workflow_guard():`, and call
`context.finalize_file_location()` after the loop (outside the `with` block):
```python
with workflow_guard():
for workflow in get_workflows_for_trigger(trigger_type, workflow_to_run):
... # loop body unchanged
if has_move_to_trash_action:
execute_move_to_trash_action(action, context.target, logging_group)
context.finalize_file_location()
if isinstance(context, ConsumptionContext):
return context.overrides, "\n".join(context.messages)
return None
```
`ConsumptionContext.finalize_file_location()` is a no-op; `PersistedContext`'s
runs `move_files_for_document` once against the refreshed, non-deleted document.
- [ ] **Step 5: Run the new test**
Run: `uv run pytest documents/tests/test_workflows.py::TestWorkflowRenameSequencing -v`
Expected: PASS
- [ ] **Step 6: Run the full workflow + file-handling suites**
Run: `uv run pytest documents/tests/test_workflows.py documents/tests/test_file_handling.py -q`
Expected: PASS — no pre-existing test changed.
- [ ] **Step 7: Lint and commit**
```bash
ruff check --fix src/documents/signals/handlers.py src/documents/tests/test_workflows.py
ruff format src/documents/signals/handlers.py src/documents/tests/test_workflows.py
git add src/documents/signals/handlers.py src/documents/tests/test_workflows.py
git commit -m "Defer workflow file rename via ContextVar guard"
```
---
## Task 7: Confirm pre-existing metadata-vs-rename regression coverage still holds
**Files:** none (verification only — no new test)
> **Rescoped 2026-09-24:** this task originally asked for a new regression
> test for the #12386 metadata-vs-rename race. That race was independently
> fixed by #13178 (merged 2026-07-20, after this plan was written), whose only
> added test is
> `test_document_updated_workflow_assignment_storage_path_persists_with_tag_assignment`
> in `test_workflows.py`. Writing another test asserting the same final-state
> outcome would duplicate that coverage. Task 6's
> `TestWorkflowRenameSequencing` already adds the piece that test doesn't
> cover — call-count/ordering discipline on `move_files_for_document` — so
> this task is now pure verification that nothing regressed.
- [ ] **Step 1: Run the pre-existing regression tests by name**
Run (all three are in the `TestWorkflows` class, `test_workflows.py:78-5123`):
```bash
uv run pytest \
"documents/tests/test_workflows.py::TestWorkflows::test_document_updated_workflow_assignment_storage_path_persists_with_tag_assignment" \
"documents/tests/test_workflows.py::TestWorkflows::test_document_updated_workflow_assignment_persists_when_removing_trigger_tag" \
"documents/tests/test_workflows.py::TestWorkflows::test_workflow_document_updated_does_not_overwrite_filename" \
-v
```
(Confirm the exact enclosing class name for each with
`grep -n "class Test\|def test_" src/documents/tests/test_workflows.py` before
running — class boundaries may have shifted since this plan was written.)
Expected: PASS, **unchanged** — same assertions, no edits needed. If any of
these needs modification to pass after the refactor, that is a behavior
regression introduced by this plan, not an acceptable side effect — stop and
investigate before continuing.
- [ ] **Step 2: No commit**
This task makes no code changes. If Step 1 required a fix elsewhere, that fix
belongs to whichever earlier task's commit introduced the regression — amend
that task's changes (as a new commit, not `--amend`) rather than committing
here.
---
## Task 8: Full verification
**Files:** none (verification only)
- [ ] **Step 1: Run the full backend suite**
Run: `uv run pytest -m "not live" -q`
Expected: PASS. Investigate any failure before claiming completion.
- [ ] **Step 2: Lint the whole change**
Run: `ruff check src/documents/` then `ruff format --check src/documents/`
Expected: clean.
- [ ] **Step 3: Type-check**
Run: `uv run mypy documents/workflows/context.py documents/signals/handlers.py documents/workflows/actions.py`
Expected: no **new** errors beyond `.mypy-baseline.txt`. Per CLAUDE.md, do not
attempt to clear the baseline — only ensure you introduced nothing new.
- [ ] **Step 4: Confirm no stray references**
Run: `git grep -n "build_workflow_action_context\|caller_supplied_original_file\|use_overrides" src/`
Expected: no matches (all three are fully removed).
- [ ] **Step 5: Final commit (if Step 2-4 required fixes)**
```bash
git add -A
git commit -m "Clean up after workflow runner refactor"
```
---
## Self-review notes (already applied)
- **Spec coverage:** §1 Protocol → Tasks 2-3; §2 branch-free `run_workflows` →
Task 5; §3 `source_file` / staged-path relocation → Tasks 3, 5; §4 guard +
sequencing → Tasks 4, 6; deferred password-removal hook left as-is (Task 5
note); testing → Tasks 1-3, 6, 7 (rescoped), 8.
- **`update_fields` exclusion kept** (`_WORKFLOW_SAVE_FIELDS` in Task 3) — per
the design decision that it guards a cross-process hazard the guard does not
cover. No task removes it.
- **Type consistency:** `WorkflowRunContext` surface (`target`, `source_file`,
`staged_file`, `refresh`, `build_placeholder_context`, `apply_assignment`,
`apply_removal`, `log_action`, `persist`, `record_run`,
`finalize_file_location`) is identical across the Protocol, both
implementations, and all call sites in the rewritten `run_workflows`.
## 2026-09-24 status update
- All line-number references throughout this plan were re-verified against
current `dev` and corrected (`handlers.py` shifted by ~6-8 lines throughout
due to unrelated intervening changes, chiefly #12389's
`_path_matches_checksum` addition).
- The two concrete bugs motivating this refactor are already fixed
independently, by different mechanisms, without this plan: #12389
(cross-process already-moved-file race, checksum-based recovery in
`validate_move`) and #13178 (intra-workflow tag/`m2m_changed` clobbering
unsaved fields, fixed by fetching a fresh `Document` instance for tag
mutation — see `mutations.py:26-31`). Neither fix uses `WorkflowRunContext`
or the `ContextVar` guard.
- Task 4 now calls out explicitly that the checksum-recovery logic inside
`validate_move` must be carried over unchanged during extraction, and that
the version-document recursive call site (`handlers.py:670-673`) needs
updating to call the extracted `move_files_for_document` directly, which the
original plan did not mention.
- Task 6's test setup was corrected — a correspondent-only assignment doesn't
exercise the guard (no `m2m_changed` fires), so the test as originally
written would have passed vacuously, guard or no guard. It now requires a
combined tag + correspondent/storage-path assignment, matching the shape of
#13178's own regression test.
- Task 7 was rescoped from "write a new regression test for #12386" to
"confirm #13178's existing regression tests still pass unchanged" — writing
a new test asserting the same final-state outcome would have duplicated
coverage that already exists and predates this plan's completion.
- **Net effect on scope:** this plan still fully replaces `use_overrides`
dual-mode branching and the `original_file` parameter plumbing (real,
unfixed structural debt) with a cleaner `WorkflowRunContext` abstraction.
The `ContextVar` guard piece is now justified as consolidating two
independent point-fixes into one general mechanism, not as fixing an active
bug — worth doing, but not urgent.