functualize-tasks 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,101 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ *.egg-info/
7
+ *.egg
8
+ dist/
9
+ build/
10
+ *.whl
11
+
12
+ # Agents
13
+ .spec/archive/
14
+ .spec/features/
15
+ .spec/scrutiny-reports/
16
+ .spec/.agentic-coding
17
+ .spec/STATE.md
18
+ .spec/PROJECT.md
19
+ .spec/REQUIREMENTS.md
20
+ .spec/ROADMAP.md
21
+ .opencode/
22
+
23
+
24
+ # Virtual environments
25
+ .venv/
26
+ venv/
27
+ ENV/
28
+
29
+ # Testing
30
+ .coverage
31
+ .pytest_cache/
32
+ htmlcov/
33
+ .hypothesis/
34
+ snapshot_report.html
35
+ _*_result*.txt
36
+ _debug.txt
37
+ _tui_debug.txt
38
+ _tui_eval_debug.txt
39
+
40
+ # IDE
41
+ .idea/
42
+ *.swp
43
+ *.swo
44
+ *~
45
+ *.code-workspace
46
+
47
+ # Coding-agent tooling state (guards — these dirs are not part of the repo)
48
+ .kiro/
49
+ .moai/
50
+
51
+ # OS
52
+ .DS_Store
53
+ Thumbs.db
54
+
55
+ # Environment / secrets
56
+ .env
57
+ .env.*
58
+ !.env.example
59
+
60
+ # Agent scratch space (test output, temp scripts)
61
+ tmp/
62
+
63
+ # Local-only files (not for the repo)
64
+ *.local.md
65
+ *.local.*
66
+
67
+ # Personal notes
68
+ HUMAN_NOTE.md
69
+
70
+ # Distribution
71
+ dist/
72
+
73
+ # Documentation site build output
74
+ site/
75
+
76
+ # uv
77
+ .python-version
78
+ .functualize/cache.json
79
+ .functualize_cache.json
80
+ .todos/
81
+ .sidecar/
82
+ .sidecar-agent
83
+ .sidecar-task
84
+ .sidecar-pr
85
+ .sidecar-start.sh
86
+ .sidecar-base
87
+ .td-root
88
+ .functualize/
89
+ .import_linter_cache/
90
+ .mypy_cache/
91
+ .pytest_cache/
92
+ .ruff_cache/
93
+
94
+ # OmO / OpenCode agent run-continuation scratch state
95
+ .omo/
96
+ .mcp.json
97
+ .agentsroom/handoff-transcript-*.txt
98
+ .agentsroom/handoff-summary-*.md
99
+
100
+ # Internal pre-release audit reports (contain session IDs / local infra notes)
101
+ .release/
@@ -0,0 +1,109 @@
1
+ Metadata-Version: 2.4
2
+ Name: functualize-tasks
3
+ Version: 0.1.0
4
+ Summary: Tasks Domain SDK for functualize — task management capabilities
5
+ Author-email: Mohammad Hakim Adiprasetya <viltohmyst@gmail.com>
6
+ License-Expression: MIT
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Typing :: Typed
13
+ Requires-Python: >=3.11
14
+ Requires-Dist: pydantic>=2.0.0
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest-cov>=4.1.0; extra == 'dev'
17
+ Requires-Dist: pytest>=7.4.0; extra == 'dev'
18
+ Description-Content-Type: text/markdown
19
+
20
+ # functualize-tasks
21
+
22
+ > **Status: Published** — Independently installable from PyPI.
23
+
24
+ Tasks Domain SDK for the functualize framework. Provides a task management capability
25
+ that acts as a mutable planning scratchpad within job execution — create, list, update,
26
+ delete, and link tasks with structured event emission on every mutation. Storage is
27
+ delegated to pluggable `TaskProvider` implementations, making the capability
28
+ backend-agnostic.
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ pip install functualize-tasks
34
+ ```
35
+
36
+ ## Quick Start
37
+
38
+ ```python
39
+ from functualize_tasks import Tasks, TaskStatus, TaskLink
40
+
41
+ tasks = Tasks()
42
+
43
+ # Create tasks
44
+ task_id = tasks.add("Run database migration")
45
+ tasks.add("Deploy service", linked_to=TaskLink(kind="job", target="deploy"))
46
+
47
+ # Update status
48
+ tasks.update(task_id, status=TaskStatus.IN_PROGRESS)
49
+ tasks.update(task_id, status=TaskStatus.DONE, notes="Migration complete")
50
+
51
+ # List and filter
52
+ pending = tasks.list(status=TaskStatus.PENDING)
53
+ all_tasks = tasks.list(filter="service")
54
+ ```
55
+
56
+ ## Features
57
+
58
+ - **CRUD task management** — add, list, update, delete, and link tasks with a clean method-call API
59
+ - **Event-driven mutations** — every state change emits structured events (`tasks.task.created`, `tasks.task.updated`, `tasks.task.completed`, `tasks.task.deleted`) via a duck-typed EventBus
60
+ - **Pluggable storage** — implement the `TaskProvider` protocol to back tasks with any persistence layer (in-memory, SQLite, remote service)
61
+ - **Rich data model** — `TaskItem` with status enum (`PENDING`, `IN_PROGRESS`, `DONE`, `SKIPPED`, `BLOCKED`), optional `TaskLink` to associate tasks with jobs or workflow steps
62
+ - **Testing double included** — `MockTasks` captures all operations for assertion while executing against a real in-memory provider
63
+ - **Type-safe** — fully typed with PEP 561 `py.typed` marker for mypy/pyright support
64
+
65
+ ## API Reference
66
+
67
+ Public classes, types, and constants exported by this plugin:
68
+
69
+ ### Capability
70
+
71
+ - `Tasks` — Main capability class providing `add()`, `list()`, `update()`, `delete()`, and `link()` methods
72
+
73
+ ### Protocols
74
+
75
+ - `TaskProvider` — Runtime-checkable protocol that storage backends must implement
76
+
77
+ ### Types
78
+
79
+ - `TaskItem` — Frozen dataclass representing a task (id, title, status, linked_to, notes, creator, created_at)
80
+ - `TaskLink` — Frozen dataclass specifying a link kind and target
81
+ - `TaskStatus` — String enum with values: `PENDING`, `IN_PROGRESS`, `DONE`, `SKIPPED`, `BLOCKED`
82
+
83
+ ### Errors
84
+
85
+ - `TaskNotFound` — Raised when an operation targets a non-existent task ID
86
+
87
+ ### Event Constants
88
+
89
+ - `TASKS_CREATED` — `"tasks.task.created"`
90
+ - `TASKS_UPDATED` — `"tasks.task.updated"`
91
+ - `TASKS_COMPLETED` — `"tasks.task.completed"`
92
+ - `TASKS_DELETED` — `"tasks.task.deleted"`
93
+
94
+ ### Testing
95
+
96
+ - `MockTasks` — Operation-capturing testing double with `operations`, `adds`, `updates`, `deletes`, `links` properties
97
+ - `MockTaskOperation` — Frozen dataclass recording a single captured method call (method, args, kwargs, result)
98
+
99
+ ### Metadata
100
+
101
+ - `domain_metadata` — `DomainMetadata` instance describing the tasks domain SDK
102
+
103
+ ## Development
104
+
105
+ Run plugin tests:
106
+
107
+ ```bash
108
+ uv run pytest plugins/functualize-tasks/tests/ -v
109
+ ```
@@ -0,0 +1,90 @@
1
+ # functualize-tasks
2
+
3
+ > **Status: Published** — Independently installable from PyPI.
4
+
5
+ Tasks Domain SDK for the functualize framework. Provides a task management capability
6
+ that acts as a mutable planning scratchpad within job execution — create, list, update,
7
+ delete, and link tasks with structured event emission on every mutation. Storage is
8
+ delegated to pluggable `TaskProvider` implementations, making the capability
9
+ backend-agnostic.
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ pip install functualize-tasks
15
+ ```
16
+
17
+ ## Quick Start
18
+
19
+ ```python
20
+ from functualize_tasks import Tasks, TaskStatus, TaskLink
21
+
22
+ tasks = Tasks()
23
+
24
+ # Create tasks
25
+ task_id = tasks.add("Run database migration")
26
+ tasks.add("Deploy service", linked_to=TaskLink(kind="job", target="deploy"))
27
+
28
+ # Update status
29
+ tasks.update(task_id, status=TaskStatus.IN_PROGRESS)
30
+ tasks.update(task_id, status=TaskStatus.DONE, notes="Migration complete")
31
+
32
+ # List and filter
33
+ pending = tasks.list(status=TaskStatus.PENDING)
34
+ all_tasks = tasks.list(filter="service")
35
+ ```
36
+
37
+ ## Features
38
+
39
+ - **CRUD task management** — add, list, update, delete, and link tasks with a clean method-call API
40
+ - **Event-driven mutations** — every state change emits structured events (`tasks.task.created`, `tasks.task.updated`, `tasks.task.completed`, `tasks.task.deleted`) via a duck-typed EventBus
41
+ - **Pluggable storage** — implement the `TaskProvider` protocol to back tasks with any persistence layer (in-memory, SQLite, remote service)
42
+ - **Rich data model** — `TaskItem` with status enum (`PENDING`, `IN_PROGRESS`, `DONE`, `SKIPPED`, `BLOCKED`), optional `TaskLink` to associate tasks with jobs or workflow steps
43
+ - **Testing double included** — `MockTasks` captures all operations for assertion while executing against a real in-memory provider
44
+ - **Type-safe** — fully typed with PEP 561 `py.typed` marker for mypy/pyright support
45
+
46
+ ## API Reference
47
+
48
+ Public classes, types, and constants exported by this plugin:
49
+
50
+ ### Capability
51
+
52
+ - `Tasks` — Main capability class providing `add()`, `list()`, `update()`, `delete()`, and `link()` methods
53
+
54
+ ### Protocols
55
+
56
+ - `TaskProvider` — Runtime-checkable protocol that storage backends must implement
57
+
58
+ ### Types
59
+
60
+ - `TaskItem` — Frozen dataclass representing a task (id, title, status, linked_to, notes, creator, created_at)
61
+ - `TaskLink` — Frozen dataclass specifying a link kind and target
62
+ - `TaskStatus` — String enum with values: `PENDING`, `IN_PROGRESS`, `DONE`, `SKIPPED`, `BLOCKED`
63
+
64
+ ### Errors
65
+
66
+ - `TaskNotFound` — Raised when an operation targets a non-existent task ID
67
+
68
+ ### Event Constants
69
+
70
+ - `TASKS_CREATED` — `"tasks.task.created"`
71
+ - `TASKS_UPDATED` — `"tasks.task.updated"`
72
+ - `TASKS_COMPLETED` — `"tasks.task.completed"`
73
+ - `TASKS_DELETED` — `"tasks.task.deleted"`
74
+
75
+ ### Testing
76
+
77
+ - `MockTasks` — Operation-capturing testing double with `operations`, `adds`, `updates`, `deletes`, `links` properties
78
+ - `MockTaskOperation` — Frozen dataclass recording a single captured method call (method, args, kwargs, result)
79
+
80
+ ### Metadata
81
+
82
+ - `domain_metadata` — `DomainMetadata` instance describing the tasks domain SDK
83
+
84
+ ## Development
85
+
86
+ Run plugin tests:
87
+
88
+ ```bash
89
+ uv run pytest plugins/functualize-tasks/tests/ -v
90
+ ```
@@ -0,0 +1,13 @@
1
+ # functualize-tasks Examples
2
+
3
+ The task-management domain SDK: `Tasks` capability, `TaskStatus`, and the `MockTasks` testing double (a working in-memory provider that also records operations).
4
+
5
+ | Directory | Demonstrates |
6
+ |-----------|--------------|
7
+ | [`todo/`](todo/) | A job creating and completing tasks through the `Tasks` capability |
8
+
9
+ ```bash
10
+ uv run pytest plugins/functualize-tasks/examples/ -v
11
+ ```
12
+
13
+ For a persistent provider, see [`functualize-tasks-local/examples/`](../../functualize-tasks-local/examples/).
@@ -0,0 +1,14 @@
1
+ """Tests for the todo example."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+ from unittest.mock import MagicMock
6
+
7
+ sys.path.insert(0, str(Path(__file__).parent))
8
+
9
+ from todo import plan_release
10
+
11
+
12
+ def test_completed_task_leaves_the_list():
13
+ remaining = plan_release(MagicMock())
14
+ assert remaining == ["Run smoke tests", "Tag release"]
@@ -0,0 +1,27 @@
1
+ """Track work items through the Tasks capability.
2
+
3
+ Uses MockTasks (a real in-memory provider that also records operations)
4
+ so the example is self-contained; in a real app the framework injects a
5
+ `Tasks` capability backed by the installed provider.
6
+ """
7
+
8
+ from functualize_tasks import TaskStatus
9
+ from functualize_tasks.testing import MockTasks
10
+
11
+ from functualize.job import RunContext
12
+
13
+
14
+ def plan_release(rc: RunContext) -> list[str]:
15
+ """Create a small release checklist and complete the first item."""
16
+ tasks = MockTasks()
17
+
18
+ build_id = tasks.add("Build artifacts")
19
+ tasks.add("Run smoke tests")
20
+ tasks.add("Tag release")
21
+
22
+ tasks.update(build_id, status=TaskStatus.DONE)
23
+ rc.log("Build artifacts: done")
24
+
25
+ remaining = [t.title for t in tasks.list() if t.status is not TaskStatus.DONE]
26
+ rc.log(f"Remaining: {', '.join(remaining)}")
27
+ return remaining
@@ -0,0 +1,37 @@
1
+ [project]
2
+ name = "functualize-tasks"
3
+ version = "0.1.0"
4
+ description = "Tasks Domain SDK for functualize — task management capabilities"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ authors = [
8
+ { name = "Mohammad Hakim Adiprasetya", email = "viltohmyst@gmail.com" }
9
+ ]
10
+ requires-python = ">=3.11"
11
+ dependencies = [
12
+ "pydantic>=2.0.0",
13
+ ]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Typing :: Typed",
21
+ ]
22
+
23
+ [project.entry-points."functualize.domains"]
24
+ tasks = "functualize_tasks._metadata:domain_metadata"
25
+
26
+ [project.optional-dependencies]
27
+ dev = [
28
+ "pytest>=7.4.0",
29
+ "pytest-cov>=4.1.0",
30
+ ]
31
+
32
+ [build-system]
33
+ requires = ["hatchling"]
34
+ build-backend = "hatchling.build"
35
+
36
+ [tool.hatch.build.targets.wheel]
37
+ packages = ["src/functualize_tasks"]
@@ -0,0 +1,41 @@
1
+ """functualize-tasks — Tasks Domain SDK.
2
+
3
+ Provides the Tasks capability class, TaskProvider protocol, shared types,
4
+ errors, event constants, and testing doubles for task management.
5
+ """
6
+
7
+ from functualize_tasks._errors import TaskNotFoundError
8
+ from functualize_tasks._events import (
9
+ TASKS_COMPLETED,
10
+ TASKS_CREATED,
11
+ TASKS_DELETED,
12
+ TASKS_UPDATED,
13
+ )
14
+ from functualize_tasks._metadata import domain_metadata
15
+ from functualize_tasks._protocols import TaskProvider
16
+ from functualize_tasks._tasks import Tasks
17
+ from functualize_tasks._types import TaskItem, TaskLink, TaskStatus
18
+ from functualize_tasks.testing._mock_tasks import MockTaskOperation, MockTasks
19
+
20
+ __all__ = [
21
+ # Capability Class
22
+ "Tasks",
23
+ # Protocols
24
+ "TaskProvider",
25
+ # Types
26
+ "TaskItem",
27
+ "TaskLink",
28
+ "TaskStatus",
29
+ # Errors
30
+ "TaskNotFoundError",
31
+ # Event Constants
32
+ "TASKS_CREATED",
33
+ "TASKS_UPDATED",
34
+ "TASKS_COMPLETED",
35
+ "TASKS_DELETED",
36
+ # Testing Doubles
37
+ "MockTaskOperation",
38
+ "MockTasks",
39
+ # Metadata
40
+ "domain_metadata",
41
+ ]
@@ -0,0 +1,7 @@
1
+ """Error classes for the Tasks Domain SDK."""
2
+
3
+
4
+ class TaskNotFoundError(Exception):
5
+ """Raised when a task operation targets a task_id that does not exist."""
6
+
7
+ pass
@@ -0,0 +1,9 @@
1
+ """Event name constants for the Tasks Domain SDK.
2
+
3
+ Events follow the {domain}.{resource}.{action} grammar.
4
+ """
5
+
6
+ TASKS_CREATED: str = "tasks.task.created"
7
+ TASKS_UPDATED: str = "tasks.task.updated"
8
+ TASKS_COMPLETED: str = "tasks.task.completed"
9
+ TASKS_DELETED: str = "tasks.task.deleted"
@@ -0,0 +1,37 @@
1
+ """Domain metadata for the Tasks SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class DomainMetadata:
10
+ """Self-describing metadata for a domain SDK."""
11
+
12
+ name: str
13
+ display_name: str
14
+ description: str
15
+ capability_class: str
16
+ provider_protocol: str
17
+ config_section: str
18
+ entry_point_group: str
19
+ events_prefix: str
20
+ scaffold_template: str | None = None
21
+ documentation_url: str | None = None
22
+ mock_factory: str | None = None
23
+
24
+
25
+ domain_metadata = DomainMetadata(
26
+ name="tasks",
27
+ display_name="Tasks",
28
+ description="Task management and planning scratchpad",
29
+ capability_class="functualize_tasks.Tasks",
30
+ provider_protocol="functualize_tasks.TaskProvider",
31
+ config_section="tasks",
32
+ entry_point_group="functualize.tasks_providers",
33
+ events_prefix="tasks.",
34
+ scaffold_template=None,
35
+ documentation_url=None,
36
+ mock_factory="functualize_tasks.testing:MockTasks",
37
+ )
@@ -0,0 +1,89 @@
1
+ """Tasks domain protocol — TaskProvider.
2
+
3
+ Defines the protocol interface that task implementation plugins must satisfy.
4
+ The Tasks capability delegates all storage and retrieval operations to a
5
+ TaskProvider implementation.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
11
+
12
+ if TYPE_CHECKING:
13
+ from functualize_tasks._types import TaskItem, TaskLink, TaskStatus
14
+
15
+
16
+ @runtime_checkable
17
+ class TaskProvider(Protocol):
18
+ """Protocol for task storage implementation plugins.
19
+
20
+ Implementation plugins (e.g., local state-backed, remote service) must
21
+ satisfy this protocol. The Tasks capability delegates all CRUD operations
22
+ to the active TaskProvider.
23
+ """
24
+
25
+ def add(self, title: str, linked_to: TaskLink | None = None) -> str:
26
+ """Create a new task and return its generated unique ID.
27
+
28
+ Args:
29
+ title: Human-readable title for the task.
30
+ linked_to: Optional link associating the task with a job,
31
+ workflow step, or job phase.
32
+
33
+ Returns:
34
+ The unique identifier of the newly created task.
35
+ """
36
+ ...
37
+
38
+ def list(
39
+ self, status: TaskStatus | None = None, filter: str | None = None
40
+ ) -> list[TaskItem]:
41
+ """List tasks, optionally filtered by status or title substring.
42
+
43
+ Args:
44
+ status: If provided, return only tasks matching this status.
45
+ filter: If provided, return only tasks whose title contains
46
+ this substring.
47
+
48
+ Returns:
49
+ A list of matching TaskItem instances.
50
+ """
51
+ ...
52
+
53
+ def update(
54
+ self, task_id: str, status: TaskStatus | None = None, notes: str | None = None
55
+ ) -> None:
56
+ """Update a task's status and/or notes.
57
+
58
+ Args:
59
+ task_id: The unique identifier of the task to update.
60
+ status: If provided, the new status to set.
61
+ notes: If provided, the new notes to set.
62
+
63
+ Raises:
64
+ TaskNotFoundError: If the task_id does not exist.
65
+ """
66
+ ...
67
+
68
+ def delete(self, task_id: str) -> None:
69
+ """Delete a task by its ID.
70
+
71
+ Args:
72
+ task_id: The unique identifier of the task to delete.
73
+
74
+ Raises:
75
+ TaskNotFoundError: If the task_id does not exist.
76
+ """
77
+ ...
78
+
79
+ def link(self, task_id: str, linked_to: TaskLink) -> None:
80
+ """Associate a task with a job, workflow step, or job phase.
81
+
82
+ Args:
83
+ task_id: The unique identifier of the task to link.
84
+ linked_to: The link specifying the kind and target.
85
+
86
+ Raises:
87
+ TaskNotFoundError: If the task_id does not exist.
88
+ """
89
+ ...
@@ -0,0 +1,250 @@
1
+ """Tasks capability class — mutable planning scratchpad.
2
+
3
+ The Tasks class provides methods to add, list, update, delete, and link
4
+ tasks. It delegates all storage operations to a TaskProvider and emits
5
+ structured events via a duck-typed EventBus on every mutation.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import uuid
11
+ from typing import TYPE_CHECKING, Any, Protocol
12
+
13
+ from functualize_tasks._errors import TaskNotFoundError
14
+ from functualize_tasks._events import (
15
+ TASKS_COMPLETED,
16
+ TASKS_CREATED,
17
+ TASKS_DELETED,
18
+ TASKS_UPDATED,
19
+ )
20
+ from functualize_tasks._types import TaskItem, TaskLink, TaskStatus
21
+
22
+ if TYPE_CHECKING:
23
+ from functualize_tasks._protocols import TaskProvider
24
+
25
+
26
+ class _EventBus(Protocol):
27
+ """Duck-typed EventBus — only requires an emit method."""
28
+
29
+ def emit(self, event_name: str, **payload: Any) -> None: ...
30
+
31
+
32
+ class _InMemoryTaskProvider:
33
+ """Simple in-memory TaskProvider used when no external provider is configured.
34
+
35
+ Stores tasks in a dict keyed by task ID. Suitable for ephemeral use
36
+ when no persistent TaskProvider plugin is installed.
37
+ """
38
+
39
+ def __init__(self) -> None:
40
+ self._tasks: dict[str, TaskItem] = {}
41
+
42
+ def add(self, title: str, linked_to: TaskLink | None = None) -> str:
43
+ task_id = uuid.uuid4().hex
44
+ task = TaskItem(
45
+ id=task_id,
46
+ title=title,
47
+ status=TaskStatus.PENDING,
48
+ linked_to=linked_to,
49
+ )
50
+ self._tasks[task_id] = task
51
+ return task_id
52
+
53
+ def list(
54
+ self, status: TaskStatus | None = None, filter: str | None = None
55
+ ) -> list[TaskItem]:
56
+ results = list(self._tasks.values())
57
+ if status is not None:
58
+ results = [t for t in results if t.status == status]
59
+ if filter is not None:
60
+ results = [t for t in results if filter in t.title]
61
+ return results
62
+
63
+ def get(self, task_id: str) -> TaskItem | None:
64
+ return self._tasks.get(task_id)
65
+
66
+ def update(
67
+ self, task_id: str, status: TaskStatus | None = None, notes: str | None = None
68
+ ) -> None:
69
+ task = self._tasks.get(task_id)
70
+ if task is None:
71
+ raise TaskNotFoundError(f"Task '{task_id}' not found.")
72
+ # Build updated task (frozen dataclass — must reconstruct)
73
+ updates: dict[str, Any] = {}
74
+ if status is not None:
75
+ updates["status"] = status
76
+ if notes is not None:
77
+ updates["notes"] = notes
78
+ if updates:
79
+ from dataclasses import asdict
80
+
81
+ data = asdict(task)
82
+ data.update(updates)
83
+ self._tasks[task_id] = TaskItem(**data)
84
+
85
+ def delete(self, task_id: str) -> None:
86
+ if task_id not in self._tasks:
87
+ raise TaskNotFoundError(f"Task '{task_id}' not found.")
88
+ del self._tasks[task_id]
89
+
90
+ def link(self, task_id: str, linked_to: TaskLink) -> None:
91
+ task = self._tasks.get(task_id)
92
+ if task is None:
93
+ raise TaskNotFoundError(f"Task '{task_id}' not found.")
94
+ from dataclasses import asdict
95
+
96
+ data = asdict(task)
97
+ data["linked_to"] = linked_to
98
+ self._tasks[task_id] = TaskItem(**data)
99
+
100
+
101
+ class Tasks:
102
+ """Task management capability — mutable planning scratchpad.
103
+
104
+ Provides methods to create, list, update, delete, and link tasks.
105
+ Delegates all storage to a TaskProvider implementation and emits
106
+ structured events on every mutation via a duck-typed EventBus.
107
+
108
+ Args:
109
+ _provider: The TaskProvider implementation for persistence.
110
+ If None, an in-memory provider is used.
111
+ _event_bus: Optional duck-typed EventBus with an emit(event_name, **payload)
112
+ method. If None, events are silently discarded.
113
+ """
114
+
115
+ def __init__(
116
+ self,
117
+ *,
118
+ _provider: TaskProvider | None = None,
119
+ _event_bus: _EventBus | None = None,
120
+ ) -> None:
121
+ self._provider: TaskProvider = (
122
+ _provider if _provider is not None else _InMemoryTaskProvider()
123
+ ) # type: ignore[assignment]
124
+ self._event_bus = _event_bus
125
+
126
+ def _emit(self, event_name: str, **payload: Any) -> None:
127
+ """Emit an event if an event bus is available."""
128
+ if self._event_bus is not None:
129
+ self._event_bus.emit(event_name, **payload)
130
+
131
+ def add(self, title: str, *, linked_to: TaskLink | None = None) -> str:
132
+ """Create a new task and return its generated unique ID.
133
+
134
+ Emits a ``tasks.task.created`` event with payload containing the
135
+ task id, title, and linked_to.
136
+
137
+ Args:
138
+ title: Human-readable title for the task.
139
+ linked_to: Optional link associating the task with a job,
140
+ workflow step, or job phase.
141
+
142
+ Returns:
143
+ The unique identifier of the newly created task.
144
+ """
145
+ task_id = self._provider.add(title, linked_to)
146
+ self._emit(
147
+ TASKS_CREATED,
148
+ task_id=task_id,
149
+ title=title,
150
+ linked_to=linked_to,
151
+ )
152
+ return task_id
153
+
154
+ def list(
155
+ self,
156
+ *,
157
+ status: TaskStatus | None = None,
158
+ filter: str | None = None,
159
+ ) -> list[TaskItem]:
160
+ """List tasks, optionally filtered by status or title substring.
161
+
162
+ Args:
163
+ status: If provided, return only tasks matching this status.
164
+ filter: If provided, return only tasks whose title contains
165
+ this substring.
166
+
167
+ Returns:
168
+ A list of matching TaskItem instances.
169
+ """
170
+ return self._provider.list(status, filter)
171
+
172
+ def update(
173
+ self,
174
+ task_id: str,
175
+ *,
176
+ status: TaskStatus | None = None,
177
+ notes: str | None = None,
178
+ ) -> None:
179
+ """Update a task's status and/or notes.
180
+
181
+ Emits ``tasks.task.updated`` when the status changes, and
182
+ ``tasks.task.completed`` when the new status is DONE.
183
+
184
+ Args:
185
+ task_id: The unique identifier of the task to update.
186
+ status: If provided, the new status to set.
187
+ notes: If provided, the new notes to set.
188
+
189
+ Raises:
190
+ TaskNotFoundError: If the task_id does not exist.
191
+ """
192
+ # Retrieve old status before updating (for event payload)
193
+ old_tasks = self._provider.list()
194
+ old_task = next((t for t in old_tasks if t.id == task_id), None)
195
+ if old_task is None:
196
+ raise TaskNotFoundError(f"Task '{task_id}' not found.")
197
+
198
+ old_status = old_task.status
199
+
200
+ self._provider.update(task_id, status, notes)
201
+
202
+ # Emit updated event when status changes
203
+ if status is not None and status != old_status:
204
+ self._emit(
205
+ TASKS_UPDATED,
206
+ task_id=task_id,
207
+ old_status=old_status.value,
208
+ new_status=status.value,
209
+ )
210
+ # Emit completed event when transitioning to DONE
211
+ if status == TaskStatus.DONE:
212
+ self._emit(TASKS_COMPLETED, task_id=task_id)
213
+
214
+ def delete(self, task_id: str) -> None:
215
+ """Delete a task by its ID.
216
+
217
+ Emits a ``tasks.task.deleted`` event with the task id.
218
+
219
+ Args:
220
+ task_id: The unique identifier of the task to delete.
221
+
222
+ Raises:
223
+ TaskNotFoundError: If the task_id does not exist.
224
+ """
225
+ # Verify existence before delegating (requirement: raise immediately)
226
+ old_tasks = self._provider.list()
227
+ old_task = next((t for t in old_tasks if t.id == task_id), None)
228
+ if old_task is None:
229
+ raise TaskNotFoundError(f"Task '{task_id}' not found.")
230
+
231
+ self._provider.delete(task_id)
232
+ self._emit(TASKS_DELETED, task_id=task_id)
233
+
234
+ def link(self, task_id: str, linked_to: TaskLink) -> None:
235
+ """Associate a task with a job, workflow step, or job phase.
236
+
237
+ Args:
238
+ task_id: The unique identifier of the task to link.
239
+ linked_to: The link specifying the kind and target.
240
+
241
+ Raises:
242
+ TaskNotFoundError: If the task_id does not exist.
243
+ """
244
+ # Verify existence before delegating
245
+ old_tasks = self._provider.list()
246
+ old_task = next((t for t in old_tasks if t.id == task_id), None)
247
+ if old_task is None:
248
+ raise TaskNotFoundError(f"Task '{task_id}' not found.")
249
+
250
+ self._provider.link(task_id, linked_to)
@@ -0,0 +1,52 @@
1
+ """Shared types for the Tasks Domain SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import StrEnum
7
+
8
+
9
+ class TaskStatus(StrEnum):
10
+ """Status of a task item."""
11
+
12
+ PENDING = "pending"
13
+ IN_PROGRESS = "in_progress"
14
+ DONE = "done"
15
+ SKIPPED = "skipped"
16
+ BLOCKED = "blocked"
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class TaskLink:
21
+ """Specifies what a task is optionally linked to.
22
+
23
+ Attributes:
24
+ kind: The type of link — "job", "workflow_step", or "job_phase".
25
+ target: The identifier of the linked entity.
26
+ """
27
+
28
+ kind: str # "job" | "workflow_step" | "job_phase"
29
+ target: str
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class TaskItem:
34
+ """A single task with id, title, status, and optional metadata.
35
+
36
+ Attributes:
37
+ id: Unique identifier of the task.
38
+ title: Human-readable title of the task.
39
+ status: Current status of the task.
40
+ linked_to: Optional link to a job, workflow step, or job phase.
41
+ notes: Optional free-form notes.
42
+ creator: Optional identifier of who created the task.
43
+ created_at: Optional UNIX timestamp of creation time.
44
+ """
45
+
46
+ id: str
47
+ title: str
48
+ status: TaskStatus
49
+ linked_to: TaskLink | None = None
50
+ notes: str | None = None
51
+ creator: str | None = None
52
+ created_at: float | None = None
File without changes
@@ -0,0 +1,13 @@
1
+ """Tasks testing doubles — MockTasks.
2
+
3
+ Provides a deterministic, operation-capturing testing double for the Tasks
4
+ capability, suitable for unit and integration testing of jobs that use
5
+ task management features.
6
+ """
7
+
8
+ from functualize_tasks.testing._mock_tasks import MockTaskOperation, MockTasks
9
+
10
+ __all__ = [
11
+ "MockTaskOperation",
12
+ "MockTasks",
13
+ ]
@@ -0,0 +1,168 @@
1
+ """MockTasks — operation-capturing testing double.
2
+
3
+ Provides a Tasks implementation for testing that captures all operations
4
+ (add, list, update, delete, link) for assertion. Backed by an in-memory
5
+ TaskProvider so operations actually execute, and all calls are recorded
6
+ in a queryable log.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ from functualize_tasks._tasks import Tasks, _InMemoryTaskProvider
15
+
16
+ if TYPE_CHECKING:
17
+ from functualize_tasks._types import TaskItem, TaskLink, TaskStatus
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class MockTaskOperation:
22
+ """A recorded operation on the MockTasks instance.
23
+
24
+ Attributes:
25
+ method: The method name that was called (e.g., "add", "list").
26
+ args: Positional arguments as a tuple.
27
+ kwargs: Keyword arguments as a dict.
28
+ result: The return value of the operation (None for void methods).
29
+ """
30
+
31
+ method: str
32
+ args: tuple[Any, ...] = ()
33
+ kwargs: dict[str, Any] = field(default_factory=dict)
34
+ result: Any = None
35
+
36
+
37
+ class MockTasks(Tasks):
38
+ """Testing double for Tasks that captures all operations for assertion.
39
+
40
+ Extends the Tasks capability with a real in-memory provider so that
41
+ operations actually execute (add creates tasks, list returns them, etc.),
42
+ while also recording every call in an operations log that tests can
43
+ query and assert against.
44
+
45
+ Example:
46
+ >>> tasks = MockTasks()
47
+ >>> task_id = tasks.add("Write tests")
48
+ >>> tasks.update(task_id, status=TaskStatus.IN_PROGRESS)
49
+ >>> assert len(tasks.operations) == 2
50
+ >>> assert tasks.operations[0].method == "add"
51
+ >>> assert tasks.operations[1].method == "update"
52
+ >>> assert tasks.adds == [tasks.operations[0]]
53
+
54
+ Args:
55
+ None — uses an internal in-memory provider automatically.
56
+ """
57
+
58
+ def __init__(self) -> None:
59
+ provider = _InMemoryTaskProvider()
60
+ super().__init__(_provider=provider)
61
+ self._operations: list[MockTaskOperation] = []
62
+
63
+ @property
64
+ def operations(self) -> list[MockTaskOperation]:
65
+ """All recorded operations in call order."""
66
+ return list(self._operations)
67
+
68
+ @property
69
+ def adds(self) -> list[MockTaskOperation]:
70
+ """All recorded 'add' operations."""
71
+ return [op for op in self._operations if op.method == "add"]
72
+
73
+ @property
74
+ def lists(self) -> list[MockTaskOperation]:
75
+ """All recorded 'list' operations."""
76
+ return [op for op in self._operations if op.method == "list"]
77
+
78
+ @property
79
+ def updates(self) -> list[MockTaskOperation]:
80
+ """All recorded 'update' operations."""
81
+ return [op for op in self._operations if op.method == "update"]
82
+
83
+ @property
84
+ def deletes(self) -> list[MockTaskOperation]:
85
+ """All recorded 'delete' operations."""
86
+ return [op for op in self._operations if op.method == "delete"]
87
+
88
+ @property
89
+ def links(self) -> list[MockTaskOperation]:
90
+ """All recorded 'link' operations."""
91
+ return [op for op in self._operations if op.method == "link"]
92
+
93
+ def reset(self) -> None:
94
+ """Clear all recorded operations."""
95
+ self._operations.clear()
96
+
97
+ def add(self, title: str, *, linked_to: TaskLink | None = None) -> str:
98
+ """Create a task and record the operation."""
99
+ result = super().add(title, linked_to=linked_to)
100
+ self._operations.append(
101
+ MockTaskOperation(
102
+ method="add",
103
+ args=(title,),
104
+ kwargs={"linked_to": linked_to},
105
+ result=result,
106
+ )
107
+ )
108
+ return result
109
+
110
+ def list(
111
+ self,
112
+ *,
113
+ status: TaskStatus | None = None,
114
+ filter: str | None = None,
115
+ ) -> list[TaskItem]:
116
+ """List tasks and record the operation."""
117
+ result = super().list(status=status, filter=filter)
118
+ self._operations.append(
119
+ MockTaskOperation(
120
+ method="list",
121
+ args=(),
122
+ kwargs={"status": status, "filter": filter},
123
+ result=result,
124
+ )
125
+ )
126
+ return result
127
+
128
+ def update(
129
+ self,
130
+ task_id: str,
131
+ *,
132
+ status: TaskStatus | None = None,
133
+ notes: str | None = None,
134
+ ) -> None:
135
+ """Update a task and record the operation."""
136
+ super().update(task_id, status=status, notes=notes)
137
+ self._operations.append(
138
+ MockTaskOperation(
139
+ method="update",
140
+ args=(task_id,),
141
+ kwargs={"status": status, "notes": notes},
142
+ result=None,
143
+ )
144
+ )
145
+
146
+ def delete(self, task_id: str) -> None:
147
+ """Delete a task and record the operation."""
148
+ super().delete(task_id)
149
+ self._operations.append(
150
+ MockTaskOperation(
151
+ method="delete",
152
+ args=(task_id,),
153
+ kwargs={},
154
+ result=None,
155
+ )
156
+ )
157
+
158
+ def link(self, task_id: str, linked_to: TaskLink) -> None:
159
+ """Link a task and record the operation."""
160
+ super().link(task_id, linked_to=linked_to)
161
+ self._operations.append(
162
+ MockTaskOperation(
163
+ method="link",
164
+ args=(task_id,),
165
+ kwargs={"linked_to": linked_to},
166
+ result=None,
167
+ )
168
+ )
File without changes
@@ -0,0 +1 @@
1
+ """Shared fixtures for functualize-tasks plugin tests."""
@@ -0,0 +1,15 @@
1
+ """Unit tests for functualize-tasks domain SDK.
2
+
3
+ Tests the task capability protocols and domain metadata.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+
9
+ class TestImports:
10
+ """Verify the plugin is importable."""
11
+
12
+ def test_import_package(self):
13
+ import functualize_tasks
14
+
15
+ assert dir(functualize_tasks)
@@ -0,0 +1,180 @@
1
+ """Functional tests for functualize-tasks domain SDK.
2
+
3
+ Tests the Tasks capability class, status transitions, MockTasks
4
+ operation recording, and event emission on state changes.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ import pytest
12
+ from functualize_tasks import (
13
+ TASKS_COMPLETED,
14
+ TASKS_CREATED,
15
+ TASKS_DELETED,
16
+ TASKS_UPDATED,
17
+ MockTasks,
18
+ TaskLink,
19
+ TaskNotFoundError,
20
+ Tasks,
21
+ TaskStatus,
22
+ )
23
+
24
+
25
+ class _CapturingEventBus:
26
+ """Minimal event bus that records all emitted events for assertion."""
27
+
28
+ def __init__(self) -> None:
29
+ self.events: list[tuple[str, dict[str, Any]]] = []
30
+
31
+ def emit(self, event_name: str, **payload: Any) -> None:
32
+ self.events.append((event_name, payload))
33
+
34
+
35
+ class TestTaskCreation:
36
+ """Tests for creating tasks via the Tasks capability."""
37
+
38
+ def test_add_returns_unique_id(self):
39
+ """Happy path: adding a task returns a non-empty string ID."""
40
+ tasks = Tasks()
41
+ task_id = tasks.add("Write tests")
42
+ assert isinstance(task_id, str)
43
+ assert len(task_id) > 0
44
+
45
+ def test_add_creates_task_with_pending_status(self):
46
+ """Happy path: a newly added task has PENDING status."""
47
+ tasks = Tasks()
48
+ task_id = tasks.add("Deploy service")
49
+ items = tasks.list()
50
+ assert len(items) == 1
51
+ assert items[0].id == task_id
52
+ assert items[0].title == "Deploy service"
53
+ assert items[0].status == TaskStatus.PENDING
54
+
55
+ def test_add_with_link(self):
56
+ """Happy path: task can be created with a link."""
57
+ tasks = Tasks()
58
+ link = TaskLink(kind="job", target="deploy")
59
+ tasks.add("Run migrations", linked_to=link)
60
+ items = tasks.list()
61
+ assert items[0].linked_to == link
62
+
63
+
64
+ class TestStatusTransitions:
65
+ """Tests for updating task status through transitions."""
66
+
67
+ def test_transition_pending_to_in_progress(self):
68
+ """Happy path: task status can transition from PENDING to IN_PROGRESS."""
69
+ tasks = Tasks()
70
+ task_id = tasks.add("Build")
71
+ tasks.update(task_id, status=TaskStatus.IN_PROGRESS)
72
+ items = tasks.list()
73
+ assert items[0].status == TaskStatus.IN_PROGRESS
74
+
75
+ def test_transition_to_done(self):
76
+ """Happy path: task status can transition to DONE."""
77
+ tasks = Tasks()
78
+ task_id = tasks.add("Test")
79
+ tasks.update(task_id, status=TaskStatus.DONE)
80
+ items = tasks.list()
81
+ assert items[0].status == TaskStatus.DONE
82
+
83
+ def test_update_nonexistent_task_raises_task_not_found(self):
84
+ """Error case: updating a nonexistent task raises TaskNotFoundError."""
85
+ tasks = Tasks()
86
+ with pytest.raises(TaskNotFoundError):
87
+ tasks.update("nonexistent-id", status=TaskStatus.DONE)
88
+
89
+ def test_delete_nonexistent_task_raises_task_not_found(self):
90
+ """Error case: deleting a nonexistent task raises TaskNotFoundError."""
91
+ tasks = Tasks()
92
+ with pytest.raises(TaskNotFoundError):
93
+ tasks.delete("nonexistent-id")
94
+
95
+
96
+ class TestMockTasksBehavior:
97
+ """Tests for the MockTasks testing double — operation recording."""
98
+
99
+ def test_mock_records_add_operations(self):
100
+ """Happy path: MockTasks records add operations with result."""
101
+ mock = MockTasks()
102
+ task_id = mock.add("First task")
103
+ assert len(mock.operations) == 1
104
+ assert mock.operations[0].method == "add"
105
+ assert mock.operations[0].args == ("First task",)
106
+ assert mock.operations[0].result == task_id
107
+
108
+ def test_mock_records_multiple_operation_types(self):
109
+ """Happy path: MockTasks records mixed operations in order."""
110
+ mock = MockTasks()
111
+ task_id = mock.add("Task A")
112
+ mock.list()
113
+ mock.update(task_id, status=TaskStatus.IN_PROGRESS)
114
+ assert len(mock.operations) == 3
115
+ assert mock.adds[0].method == "add"
116
+ assert mock.lists[0].method == "list"
117
+ assert mock.updates[0].method == "update"
118
+
119
+ def test_mock_reset_clears_operations(self):
120
+ """Happy path: reset clears all recorded operations."""
121
+ mock = MockTasks()
122
+ mock.add("Task B")
123
+ assert len(mock.operations) == 1
124
+ mock.reset()
125
+ assert len(mock.operations) == 0
126
+
127
+
128
+ class TestEventEmission:
129
+ """Tests for event emission on state changes."""
130
+
131
+ def test_add_emits_created_event(self):
132
+ """Happy path: adding a task emits tasks.task.created."""
133
+ bus = _CapturingEventBus()
134
+ tasks = Tasks(_event_bus=bus)
135
+ task_id = tasks.add("Event test")
136
+ assert len(bus.events) == 1
137
+ event_name, payload = bus.events[0]
138
+ assert event_name == TASKS_CREATED
139
+ assert payload["task_id"] == task_id
140
+ assert payload["title"] == "Event test"
141
+
142
+ def test_update_status_emits_updated_event(self):
143
+ """Happy path: changing status emits tasks.task.updated."""
144
+ bus = _CapturingEventBus()
145
+ tasks = Tasks(_event_bus=bus)
146
+ task_id = tasks.add("Status event")
147
+ bus.events.clear() # Clear the created event
148
+
149
+ tasks.update(task_id, status=TaskStatus.IN_PROGRESS)
150
+ assert len(bus.events) == 1
151
+ event_name, payload = bus.events[0]
152
+ assert event_name == TASKS_UPDATED
153
+ assert payload["task_id"] == task_id
154
+ assert payload["old_status"] == TaskStatus.PENDING.value
155
+ assert payload["new_status"] == TaskStatus.IN_PROGRESS.value
156
+
157
+ def test_transition_to_done_emits_completed_event(self):
158
+ """Happy path: transitioning to DONE emits both updated and completed."""
159
+ bus = _CapturingEventBus()
160
+ tasks = Tasks(_event_bus=bus)
161
+ task_id = tasks.add("Complete me")
162
+ bus.events.clear()
163
+
164
+ tasks.update(task_id, status=TaskStatus.DONE)
165
+ event_names = [name for name, _ in bus.events]
166
+ assert TASKS_UPDATED in event_names
167
+ assert TASKS_COMPLETED in event_names
168
+
169
+ def test_delete_emits_deleted_event(self):
170
+ """Happy path: deleting a task emits tasks.task.deleted."""
171
+ bus = _CapturingEventBus()
172
+ tasks = Tasks(_event_bus=bus)
173
+ task_id = tasks.add("Delete me")
174
+ bus.events.clear()
175
+
176
+ tasks.delete(task_id)
177
+ assert len(bus.events) == 1
178
+ event_name, payload = bus.events[0]
179
+ assert event_name == TASKS_DELETED
180
+ assert payload["task_id"] == task_id