stemtrace 0.1.0__py3-none-any.whl

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.
Files changed (72) hide show
  1. stemtrace/__init__.py +173 -0
  2. stemtrace/core/__init__.py +15 -0
  3. stemtrace/core/events.py +67 -0
  4. stemtrace/core/exceptions.py +25 -0
  5. stemtrace/core/graph.py +348 -0
  6. stemtrace/core/ports.py +42 -0
  7. stemtrace/library/__init__.py +1 -0
  8. stemtrace/library/bootsteps.py +169 -0
  9. stemtrace/library/config.py +55 -0
  10. stemtrace/library/scrubbing.py +195 -0
  11. stemtrace/library/signals.py +434 -0
  12. stemtrace/library/transports/__init__.py +36 -0
  13. stemtrace/library/transports/memory.py +33 -0
  14. stemtrace/library/transports/redis.py +92 -0
  15. stemtrace/py.typed +0 -0
  16. stemtrace/server/__init__.py +24 -0
  17. stemtrace/server/__main__.py +136 -0
  18. stemtrace/server/api/__init__.py +29 -0
  19. stemtrace/server/api/routes.py +270 -0
  20. stemtrace/server/api/schemas.py +136 -0
  21. stemtrace/server/api/websocket.py +28 -0
  22. stemtrace/server/consumer.py +133 -0
  23. stemtrace/server/fastapi/__init__.py +13 -0
  24. stemtrace/server/fastapi/auth.py +77 -0
  25. stemtrace/server/fastapi/extension.py +121 -0
  26. stemtrace/server/fastapi/router.py +42 -0
  27. stemtrace/server/store.py +260 -0
  28. stemtrace/server/ui/__init__.py +5 -0
  29. stemtrace/server/ui/frontend/biome.json +58 -0
  30. stemtrace/server/ui/frontend/dist/assets/index-CCs7iO1n.css +1 -0
  31. stemtrace/server/ui/frontend/dist/assets/index-Dao0_Q6x.js +14 -0
  32. stemtrace/server/ui/frontend/dist/index.html +17 -0
  33. stemtrace/server/ui/frontend/index.html +16 -0
  34. stemtrace/server/ui/frontend/package-lock.json +4049 -0
  35. stemtrace/server/ui/frontend/package.json +46 -0
  36. stemtrace/server/ui/frontend/playwright.config.ts +40 -0
  37. stemtrace/server/ui/frontend/postcss.config.js +5 -0
  38. stemtrace/server/ui/frontend/src/api/client.ts +173 -0
  39. stemtrace/server/ui/frontend/src/api/queries.ts +151 -0
  40. stemtrace/server/ui/frontend/src/components/DateRangePicker.tsx +244 -0
  41. stemtrace/server/ui/frontend/src/components/Filters.tsx +116 -0
  42. stemtrace/server/ui/frontend/src/components/TaskGraph.tsx +453 -0
  43. stemtrace/server/ui/frontend/src/components/TaskList.tsx +172 -0
  44. stemtrace/server/ui/frontend/src/components/TaskStateBadge.tsx +27 -0
  45. stemtrace/server/ui/frontend/src/components/TaskTimeline.tsx +144 -0
  46. stemtrace/server/ui/frontend/src/components/TimelineBar.tsx +224 -0
  47. stemtrace/server/ui/frontend/src/components/Tooltip.tsx +54 -0
  48. stemtrace/server/ui/frontend/src/hooks/WebSocketContext.tsx +143 -0
  49. stemtrace/server/ui/frontend/src/hooks/useInfiniteScroll.ts +56 -0
  50. stemtrace/server/ui/frontend/src/index.css +104 -0
  51. stemtrace/server/ui/frontend/src/main.tsx +44 -0
  52. stemtrace/server/ui/frontend/src/routes/__root.tsx +103 -0
  53. stemtrace/server/ui/frontend/src/routes/graph.$rootId.tsx +51 -0
  54. stemtrace/server/ui/frontend/src/routes/graphs.tsx +186 -0
  55. stemtrace/server/ui/frontend/src/routes/index.tsx +119 -0
  56. stemtrace/server/ui/frontend/src/routes/registry.tsx +211 -0
  57. stemtrace/server/ui/frontend/src/routes/tasks.$taskId.tsx +313 -0
  58. stemtrace/server/ui/frontend/src/utils/format.ts +38 -0
  59. stemtrace/server/ui/frontend/tests/graphs.spec.ts +485 -0
  60. stemtrace/server/ui/frontend/tests/registry.spec.ts +117 -0
  61. stemtrace/server/ui/frontend/tests/task-detail.spec.ts +147 -0
  62. stemtrace/server/ui/frontend/tests/tasks.spec.ts +92 -0
  63. stemtrace/server/ui/frontend/tsconfig.json +32 -0
  64. stemtrace/server/ui/frontend/tsconfig.tsbuildinfo +1 -0
  65. stemtrace/server/ui/frontend/vite.config.ts +30 -0
  66. stemtrace/server/ui/static.py +56 -0
  67. stemtrace/server/websocket.py +99 -0
  68. stemtrace-0.1.0.dist-info/METADATA +428 -0
  69. stemtrace-0.1.0.dist-info/RECORD +72 -0
  70. stemtrace-0.1.0.dist-info/WHEEL +4 -0
  71. stemtrace-0.1.0.dist-info/entry_points.txt +2 -0
  72. stemtrace-0.1.0.dist-info/licenses/LICENSE +21 -0
stemtrace/__init__.py ADDED
@@ -0,0 +1,173 @@
1
+ """stemtrace: A lightweight Celery task flow visualizer.
2
+
3
+ Usage:
4
+ import stemtrace
5
+
6
+ stemtrace.init(app)
7
+
8
+ # Introspection
9
+ stemtrace.is_initialized() # -> bool
10
+ stemtrace.get_config() # -> StemtraceConfig | None
11
+ stemtrace.get_transport() # -> EventTransport | None
12
+ """
13
+
14
+ from typing import TYPE_CHECKING
15
+
16
+ from stemtrace.core.events import TaskEvent, TaskState
17
+ from stemtrace.core.exceptions import ConfigurationError
18
+ from stemtrace.core.graph import TaskGraph, TaskNode
19
+ from stemtrace.core.ports import EventTransport
20
+ from stemtrace.library.bootsteps import register_bootsteps
21
+ from stemtrace.library.config import StemtraceConfig, _reset_config, set_config
22
+ from stemtrace.library.config import get_config as _get_config
23
+ from stemtrace.library.signals import connect_signals
24
+ from stemtrace.library.transports import get_transport as _get_transport
25
+
26
+ if TYPE_CHECKING:
27
+ from celery import Celery
28
+
29
+ __version__ = "0.1.0"
30
+ __all__ = [
31
+ "ConfigurationError",
32
+ "StemtraceConfig",
33
+ "TaskEvent",
34
+ "TaskGraph",
35
+ "TaskNode",
36
+ "TaskState",
37
+ "__version__",
38
+ "get_config",
39
+ "get_transport",
40
+ "init",
41
+ "is_initialized",
42
+ ]
43
+
44
+ # Module-level state
45
+ _transport: EventTransport | None = None
46
+
47
+
48
+ def is_initialized() -> bool:
49
+ """Check if stemtrace has been initialized.
50
+
51
+ Returns:
52
+ True if init() has been called, False otherwise.
53
+
54
+ Example:
55
+ >>> import stemtrace
56
+ >>> stemtrace.is_initialized()
57
+ False
58
+ >>> stemtrace.init(app)
59
+ >>> stemtrace.is_initialized()
60
+ True
61
+ """
62
+ return _transport is not None
63
+
64
+
65
+ def get_config() -> StemtraceConfig | None:
66
+ """Get the active stemtrace configuration.
67
+
68
+ Returns:
69
+ The configuration if initialized, None otherwise.
70
+
71
+ Example:
72
+ >>> import stemtrace
73
+ >>> stemtrace.init(app, prefix="my_prefix")
74
+ >>> config = stemtrace.get_config()
75
+ >>> config.prefix
76
+ 'my_prefix'
77
+ """
78
+ return _get_config()
79
+
80
+
81
+ def get_transport() -> EventTransport | None:
82
+ """Get the active event transport.
83
+
84
+ Useful for testing to verify events are published correctly.
85
+
86
+ Returns:
87
+ The transport if initialized, None otherwise.
88
+
89
+ Example:
90
+ >>> import stemtrace
91
+ >>> stemtrace.init(app, transport_url="memory://")
92
+ >>> transport = stemtrace.get_transport()
93
+ >>> # For MemoryTransport, you can access published events
94
+ """
95
+ return _transport
96
+
97
+
98
+ def init(
99
+ app: "Celery",
100
+ *,
101
+ transport_url: str | None = None,
102
+ prefix: str = "stemtrace",
103
+ ttl: int = 86400,
104
+ capture_args: bool = True,
105
+ capture_result: bool = True,
106
+ scrub_sensitive_data: bool = True,
107
+ additional_sensitive_keys: frozenset[str] | None = None,
108
+ safe_keys: frozenset[str] | None = None,
109
+ ) -> None:
110
+ """Initialize stemtrace event tracking.
111
+
112
+ Connects to Celery's task signals and publishes lifecycle events
113
+ to the configured transport (Redis, RabbitMQ, etc.).
114
+
115
+ Args:
116
+ app: The Celery application instance.
117
+ transport_url: Broker URL for events. If None, uses Celery's broker_url.
118
+ prefix: Key/queue prefix for events.
119
+ ttl: Event TTL in seconds (default: 24 hours).
120
+ capture_args: Whether to capture task args/kwargs (default: True).
121
+ capture_result: Whether to capture task return values (default: True).
122
+ scrub_sensitive_data: Whether to scrub sensitive keys (default: True).
123
+ additional_sensitive_keys: Extra keys to treat as sensitive.
124
+ safe_keys: Keys to never scrub (overrides sensitive).
125
+
126
+ Raises:
127
+ ConfigurationError: If no broker URL can be determined.
128
+
129
+ Example:
130
+ >>> from celery import Celery
131
+ >>> import stemtrace
132
+ >>> app = Celery("myapp", broker="redis://localhost:6379/0")
133
+ >>> stemtrace.init(app)
134
+
135
+ # With explicit transport URL:
136
+ >>> stemtrace.init(app, transport_url="redis://events-redis:6379/1")
137
+
138
+ # Disable arg capture:
139
+ >>> stemtrace.init(app, capture_args=False)
140
+ """
141
+ global _transport
142
+
143
+ url = transport_url or app.conf.broker_url
144
+ if not url:
145
+ raise ConfigurationError(
146
+ "No broker URL available. Either pass transport_url or configure "
147
+ "Celery's broker_url."
148
+ )
149
+
150
+ config = StemtraceConfig(
151
+ transport_url=url,
152
+ prefix=prefix,
153
+ ttl=ttl,
154
+ capture_args=capture_args,
155
+ capture_result=capture_result,
156
+ scrub_sensitive_data=scrub_sensitive_data,
157
+ additional_sensitive_keys=additional_sensitive_keys or frozenset(),
158
+ safe_keys=safe_keys or frozenset(),
159
+ )
160
+ set_config(config)
161
+
162
+ _transport = _get_transport(url, prefix=prefix, ttl=ttl)
163
+ connect_signals(_transport)
164
+
165
+ # Register bootsteps for RECEIVED events (worker-side)
166
+ register_bootsteps(app)
167
+
168
+
169
+ def _reset() -> None:
170
+ """Reset module state. For testing only."""
171
+ global _transport
172
+ _transport = None
173
+ _reset_config()
@@ -0,0 +1,15 @@
1
+ """Core domain layer - pure Python, no external dependencies."""
2
+
3
+ from stemtrace.core.events import TaskEvent, TaskState
4
+ from stemtrace.core.exceptions import ConfigurationError, StemtraceError
5
+ from stemtrace.core.graph import NodeType, TaskGraph, TaskNode
6
+
7
+ __all__ = [
8
+ "ConfigurationError",
9
+ "NodeType",
10
+ "StemtraceError",
11
+ "TaskEvent",
12
+ "TaskGraph",
13
+ "TaskNode",
14
+ "TaskState",
15
+ ]
@@ -0,0 +1,67 @@
1
+ """Task event definitions."""
2
+
3
+ from datetime import datetime
4
+ from enum import Enum
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel, ConfigDict
8
+
9
+
10
+ class TaskState(str, Enum):
11
+ """Celery task states. Inherits from str for easy comparison."""
12
+
13
+ PENDING = "PENDING"
14
+ RECEIVED = "RECEIVED"
15
+ STARTED = "STARTED"
16
+ SUCCESS = "SUCCESS"
17
+ FAILURE = "FAILURE"
18
+ REVOKED = "REVOKED"
19
+ REJECTED = "REJECTED"
20
+ RETRY = "RETRY"
21
+
22
+
23
+ class TaskEvent(BaseModel):
24
+ """Immutable task lifecycle event.
25
+
26
+ Frozen model that can be hashed and compared. Captures a single
27
+ state transition in a Celery task's lifecycle.
28
+
29
+ Attributes:
30
+ task_id: Unique identifier for the task execution.
31
+ name: Fully qualified task name (e.g., 'myapp.tasks.add').
32
+ state: Current state of the task.
33
+ timestamp: When this event occurred.
34
+ parent_id: ID of the parent task that spawned this one.
35
+ root_id: ID of the root task in the workflow.
36
+ group_id: ID shared by tasks in the same group/chord.
37
+ chord_id: ID of the group for which this header task's chord will complete.
38
+ chord_callback_id: Task ID of the chord callback (only set on header tasks).
39
+ trace_id: Optional distributed tracing ID.
40
+ retries: Number of retry attempts so far.
41
+ args: Positional arguments passed to the task (scrubbed).
42
+ kwargs: Keyword arguments passed to the task (scrubbed).
43
+ result: Return value of the task (SUCCESS state only).
44
+ exception: Exception message (FAILURE/RETRY states).
45
+ traceback: Full traceback string (FAILURE/RETRY states).
46
+ """
47
+
48
+ model_config = ConfigDict(frozen=True)
49
+
50
+ task_id: str
51
+ name: str
52
+ state: TaskState
53
+ timestamp: datetime
54
+ parent_id: str | None = None
55
+ root_id: str | None = None
56
+ group_id: str | None = None
57
+ chord_id: str | None = None
58
+ chord_callback_id: str | None = None
59
+ trace_id: str | None = None
60
+ retries: int = 0
61
+
62
+ # New fields for enhanced event data
63
+ args: list[Any] | None = None
64
+ kwargs: dict[str, Any] | None = None
65
+ result: Any | None = None
66
+ exception: str | None = None
67
+ traceback: str | None = None
@@ -0,0 +1,25 @@
1
+ """Domain exceptions for stemtrace."""
2
+
3
+
4
+ class StemtraceError(Exception):
5
+ """Base exception for stemtrace."""
6
+
7
+
8
+ class ConfigurationError(StemtraceError):
9
+ """Invalid or missing configuration."""
10
+
11
+
12
+ class TransportError(StemtraceError):
13
+ """Transport connection, publish, or consume failure."""
14
+
15
+
16
+ class UnsupportedBrokerError(ConfigurationError):
17
+ """Broker URL scheme not supported."""
18
+
19
+ def __init__(self, scheme: str) -> None:
20
+ """Initialize with the unsupported broker scheme."""
21
+ self.scheme = scheme
22
+ super().__init__(
23
+ f"Unsupported broker scheme: '{scheme}'. "
24
+ f"Supported: redis, rediss, amqp, amqps, memory"
25
+ )
@@ -0,0 +1,348 @@
1
+ """Task graph models for representing task execution flows."""
2
+
3
+ from enum import Enum
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
6
+
7
+ from stemtrace.core.events import TaskEvent, TaskState
8
+
9
+
10
+ class NodeType(str, Enum):
11
+ """Type of node in the task graph."""
12
+
13
+ TASK = "TASK"
14
+ GROUP = "GROUP"
15
+ CHORD = "CHORD"
16
+
17
+
18
+ class TaskNode(BaseModel):
19
+ """Mutable node in the task graph. Tracks event history and child relationships."""
20
+
21
+ model_config = ConfigDict(validate_assignment=True)
22
+
23
+ task_id: str
24
+ name: str
25
+ state: TaskState
26
+ node_type: NodeType = NodeType.TASK
27
+ group_id: str | None = None
28
+ chord_id: str | None = None
29
+ chord_callback_id: str | None = None
30
+ events: list[TaskEvent] = Field(default_factory=list)
31
+ children: list[str] = Field(default_factory=list)
32
+ parent_id: str | None = None
33
+
34
+
35
+ class TaskGraph(BaseModel):
36
+ """DAG of task executions, built incrementally from events.
37
+
38
+ Parent-child linking only occurs when parent exists at child insertion time.
39
+ Out-of-order events won't back-link.
40
+
41
+ Synthetic nodes (GROUP, CHORD) are created when tasks share a group_id.
42
+ """
43
+
44
+ model_config = ConfigDict(validate_assignment=True)
45
+
46
+ nodes: dict[str, TaskNode] = Field(default_factory=dict)
47
+ root_ids: list[str] = Field(default_factory=list)
48
+
49
+ # Track which tasks belong to each group_id (private, not serialized)
50
+ _group_members: dict[str, list[str]] = PrivateAttr(default_factory=dict)
51
+
52
+ def add_event(self, event: TaskEvent) -> None:
53
+ """Add event, creating node if needed. Links child to parent if parent exists.
54
+
55
+ Also tracks group membership and creates synthetic GROUP/CHORD nodes.
56
+ """
57
+ if event.task_id not in self.nodes:
58
+ self.nodes[event.task_id] = TaskNode(
59
+ task_id=event.task_id,
60
+ name=event.name,
61
+ state=event.state,
62
+ parent_id=event.parent_id,
63
+ group_id=event.group_id,
64
+ chord_id=event.chord_id,
65
+ )
66
+ if event.parent_id is None:
67
+ self.root_ids.append(event.task_id)
68
+ elif event.parent_id in self.nodes:
69
+ self.nodes[event.parent_id].children.append(event.task_id)
70
+
71
+ node = self.nodes[event.task_id]
72
+ node.events.append(event)
73
+ node.state = event.state
74
+
75
+ # Update group_id if we didn't have it before
76
+ if node.group_id is None and event.group_id is not None:
77
+ node.group_id = event.group_id
78
+
79
+ # Update chord_id if we didn't have it before
80
+ if node.chord_id is None and event.chord_id is not None:
81
+ node.chord_id = event.chord_id
82
+
83
+ # Update parent_id if we didn't have it before (PENDING lacks parent_id)
84
+ if node.parent_id is None and event.parent_id is not None:
85
+ node.parent_id = event.parent_id
86
+ if event.task_id in self.root_ids:
87
+ self.root_ids.remove(event.task_id)
88
+ if event.parent_id in self.nodes:
89
+ parent = self.nodes[event.parent_id]
90
+ if event.task_id not in parent.children:
91
+ parent.children.append(event.task_id)
92
+
93
+ # Track group membership for synthetic node creation
94
+ if event.group_id is not None:
95
+ self._track_group_member(event.task_id, event.group_id)
96
+
97
+ # Track chord - when header task has chord_callback_id, upgrade GROUP to CHORD
98
+ if event.chord_callback_id is not None and event.group_id is not None:
99
+ # This is a HEADER task with chord info - upgrade GROUP to CHORD
100
+ self._upgrade_to_chord(event.group_id, event.chord_callback_id)
101
+
102
+ # Check if this task is a callback for any existing CHORD
103
+ self._link_chord_callback_if_needed(event.task_id)
104
+
105
+ def _track_group_member(self, task_id: str, group_id: str) -> None:
106
+ """Track task as member of a group and create synthetic node if needed.
107
+
108
+ Creates a synthetic GROUP node when 2+ tasks share a group_id.
109
+ If tasks share a common parent, the GROUP becomes a child of that parent.
110
+ Skips adding callback tasks (they're outside the group container).
111
+ """
112
+ # Check if this task is the callback for a CHORD - link but don't add to members
113
+ group_node_id = f"group:{group_id}"
114
+ if group_node_id in self.nodes:
115
+ chord_node = self.nodes[group_node_id]
116
+ if (
117
+ chord_node.node_type == NodeType.CHORD
118
+ and chord_node.chord_id == task_id
119
+ ):
120
+ # This is the callback - link it to CHORD but not as group member
121
+ task_node = self.nodes[task_id]
122
+ if task_node.parent_id is None:
123
+ task_node.parent_id = group_node_id
124
+ if task_id in self.root_ids:
125
+ self.root_ids.remove(task_id)
126
+ if task_id not in chord_node.children:
127
+ chord_node.children.append(task_id)
128
+ return # Don't add to _group_members
129
+
130
+ if group_id not in self._group_members:
131
+ self._group_members[group_id] = []
132
+
133
+ if task_id not in self._group_members[group_id]:
134
+ self._group_members[group_id].append(task_id)
135
+
136
+ # Only create GROUP node for standalone groups (no parent task)
137
+ group_node_id = f"group:{group_id}"
138
+ members = self._group_members[group_id]
139
+
140
+ should_create = (
141
+ len(members) >= 2
142
+ and group_node_id not in self.nodes
143
+ and self._should_create_group_node(members)
144
+ )
145
+ if should_create:
146
+ self._create_group_node(group_id)
147
+
148
+ # Update group node if it exists
149
+ if group_node_id in self.nodes:
150
+ group_node = self.nodes[group_node_id]
151
+ if task_id not in group_node.children:
152
+ group_node.children.append(task_id)
153
+
154
+ group_node.state = self._compute_group_state(members)
155
+
156
+ # Update task's parent to point to group node (if no other parent)
157
+ task_node = self.nodes[task_id]
158
+ if task_node.parent_id is None:
159
+ task_node.parent_id = group_node_id
160
+ if task_id in self.root_ids:
161
+ self.root_ids.remove(task_id)
162
+
163
+ def _should_create_group_node(self, member_ids: list[str]) -> bool:
164
+ """Determine if a synthetic GROUP node should be created.
165
+
166
+ Always returns True when there are 2+ members - we want to visualize
167
+ groups even when they have a common parent, to differentiate from
168
+ independent subtasks.
169
+ """
170
+ return len(member_ids) >= 2
171
+
172
+ def _get_common_parent(self, member_ids: list[str]) -> str | None:
173
+ """Get common parent_id if all members share the same parent.
174
+
175
+ Returns the parent_id if all members have the same parent,
176
+ or None if they have different parents or are standalone.
177
+ """
178
+ parents: set[str | None] = set()
179
+ for mid in member_ids:
180
+ if mid in self.nodes:
181
+ parents.add(self.nodes[mid].parent_id)
182
+
183
+ # Return the common parent if there's exactly one non-None parent
184
+ if len(parents) == 1 and None not in parents:
185
+ return parents.pop()
186
+ return None
187
+
188
+ def _create_group_node(self, group_id: str) -> None:
189
+ """Create a synthetic GROUP node for tasks sharing a group_id.
190
+
191
+ If all members share a common parent task, the GROUP becomes a child
192
+ of that parent. Otherwise, the GROUP is a root node.
193
+ """
194
+ group_node_id = f"group:{group_id}"
195
+ members = self._group_members.get(group_id, [])
196
+
197
+ # Check if members share a common parent
198
+ common_parent = self._get_common_parent(members)
199
+
200
+ # Determine the group's state based on member states
201
+ group_state = self._compute_group_state(members)
202
+
203
+ self.nodes[group_node_id] = TaskNode(
204
+ task_id=group_node_id,
205
+ name="group",
206
+ state=group_state,
207
+ node_type=NodeType.GROUP,
208
+ group_id=group_id,
209
+ children=list(members),
210
+ parent_id=common_parent,
211
+ )
212
+
213
+ if common_parent is not None:
214
+ # GROUP is child of common parent
215
+ parent_node = self.nodes.get(common_parent)
216
+ if parent_node and group_node_id not in parent_node.children:
217
+ parent_node.children.append(group_node_id)
218
+ # Remove member tasks from parent's children (they're now in GROUP)
219
+ if parent_node:
220
+ for member_id in members:
221
+ if member_id in parent_node.children:
222
+ parent_node.children.remove(member_id)
223
+ else:
224
+ # GROUP is a root node
225
+ if group_node_id not in self.root_ids:
226
+ self.root_ids.append(group_node_id)
227
+
228
+ # Update member nodes to point to group as parent
229
+ for member_id in members:
230
+ if member_id in self.nodes:
231
+ member = self.nodes[member_id]
232
+ # Store original parent before updating (for later members)
233
+ member.parent_id = group_node_id
234
+ # Remove from root_ids since it now has a parent
235
+ if member_id in self.root_ids:
236
+ self.root_ids.remove(member_id)
237
+
238
+ def _compute_group_state(self, member_ids: list[str]) -> TaskState:
239
+ """Compute aggregate state for a group based on member states.
240
+
241
+ Priority: FAILURE > STARTED/RECEIVED > PENDING > RETRY > REVOKED > SUCCESS
242
+ """
243
+ states = [self.nodes[mid].state for mid in member_ids if mid in self.nodes]
244
+ if not states:
245
+ return TaskState.PENDING
246
+
247
+ state_set = set(states)
248
+
249
+ if TaskState.FAILURE in state_set:
250
+ return TaskState.FAILURE
251
+ if state_set & {TaskState.STARTED, TaskState.RECEIVED}:
252
+ return TaskState.STARTED
253
+ if TaskState.PENDING in state_set:
254
+ return TaskState.PENDING
255
+ if TaskState.RETRY in state_set:
256
+ return TaskState.RETRY
257
+ if TaskState.REVOKED in state_set:
258
+ return TaskState.REVOKED
259
+ if all(s == TaskState.SUCCESS for s in states):
260
+ return TaskState.SUCCESS
261
+
262
+ return states[0]
263
+
264
+ def _upgrade_to_chord(self, group_id: str, callback_id: str) -> None:
265
+ """Upgrade a GROUP node to CHORD and register the callback.
266
+
267
+ Called when a header task has chord info (chord_callback_id set).
268
+ The callback will be linked outside the container.
269
+ """
270
+ group_node_id = f"group:{group_id}"
271
+ members = self._group_members.get(group_id, [])
272
+
273
+ # If no GROUP node exists yet, create it as CHORD
274
+ if group_node_id not in self.nodes:
275
+ self.nodes[group_node_id] = TaskNode(
276
+ task_id=group_node_id,
277
+ name="chord",
278
+ state=TaskState.PENDING,
279
+ node_type=NodeType.CHORD,
280
+ group_id=group_id,
281
+ chord_id=callback_id, # Store callback reference
282
+ chord_callback_id=callback_id,
283
+ children=list(members), # Add existing members as children
284
+ )
285
+ if group_node_id not in self.root_ids:
286
+ self.root_ids.append(group_node_id)
287
+ # Update member nodes to point to CHORD as parent
288
+ for member_id in members:
289
+ if member_id in self.nodes:
290
+ member = self.nodes[member_id]
291
+ member.parent_id = group_node_id
292
+ if member_id in self.root_ids:
293
+ self.root_ids.remove(member_id)
294
+ else:
295
+ # Upgrade existing GROUP to CHORD
296
+ group_node = self.nodes[group_node_id]
297
+ if group_node.node_type == NodeType.GROUP:
298
+ group_node.node_type = NodeType.CHORD
299
+ group_node.name = "chord"
300
+ group_node.chord_id = callback_id # Store callback reference
301
+ group_node.chord_callback_id = callback_id
302
+
303
+ # Link callback to CHORD if it already exists
304
+ group_node = self.nodes[group_node_id]
305
+ callback_node = self.nodes.get(callback_id)
306
+ if callback_node:
307
+ # Callback should be OUTSIDE container, linked as child for edge
308
+ if callback_node.parent_id is None:
309
+ callback_node.parent_id = group_node_id
310
+ if callback_id in self.root_ids:
311
+ self.root_ids.remove(callback_id)
312
+ # Ensure callback is in children (for edge rendering)
313
+ if callback_id not in group_node.children:
314
+ group_node.children.append(callback_id)
315
+ # Remove callback from group members (it's not a header task)
316
+ if (
317
+ group_id in self._group_members
318
+ and callback_id in self._group_members[group_id]
319
+ ):
320
+ self._group_members[group_id].remove(callback_id)
321
+
322
+ def _link_chord_callback_if_needed(self, task_id: str) -> None:
323
+ """Link a task to a CHORD node if it's the callback for that chord.
324
+
325
+ Called when a new task arrives. Checks if any existing CHORD node
326
+ is waiting for this task as its callback.
327
+ """
328
+ for node in self.nodes.values():
329
+ if node.node_type == NodeType.CHORD and node.chord_callback_id == task_id:
330
+ callback_node = self.nodes.get(task_id)
331
+ if callback_node:
332
+ # Link callback to CHORD
333
+ if callback_node.parent_id is None:
334
+ callback_node.parent_id = node.task_id
335
+ if task_id in self.root_ids:
336
+ self.root_ids.remove(task_id)
337
+ # Add to children for edge rendering
338
+ if task_id not in node.children:
339
+ node.children.append(task_id)
340
+ return # Only one CHORD can have this callback
341
+
342
+ def get_node(self, task_id: str) -> TaskNode | None:
343
+ """Get node by ID, or None if not found."""
344
+ return self.nodes.get(task_id)
345
+
346
+ def get_group_members(self, group_id: str) -> list[str]:
347
+ """Get task IDs that belong to a group."""
348
+ return self._group_members.get(group_id, [])
@@ -0,0 +1,42 @@
1
+ """Protocol definitions for dependency inversion."""
2
+
3
+ from collections.abc import Iterator
4
+ from typing import TYPE_CHECKING, Protocol
5
+
6
+ from typing_extensions import Self
7
+
8
+ if TYPE_CHECKING:
9
+ from stemtrace.core.events import TaskEvent
10
+
11
+
12
+ class EventTransport(Protocol):
13
+ """Broker-agnostic transport. publish() must be fire-and-forget (never raise)."""
14
+
15
+ def publish(self, event: "TaskEvent") -> None:
16
+ """Publish event. Fire-and-forget: log errors, don't raise."""
17
+ ...
18
+
19
+ def consume(self) -> Iterator["TaskEvent"]:
20
+ """Yield events as they arrive. May block waiting for new events."""
21
+ ...
22
+
23
+ @classmethod
24
+ def from_url(cls, url: str) -> Self:
25
+ """Create transport from broker URL. Raises UnsupportedBrokerError if unknown."""
26
+ ...
27
+
28
+
29
+ class TaskRepository(Protocol):
30
+ """Read-only task data access for API endpoints."""
31
+
32
+ def get(self, task_id: str) -> "TaskEvent | None":
33
+ """Get latest event for a task."""
34
+ ...
35
+
36
+ def list_recent(self, limit: int = 100) -> list["TaskEvent"]:
37
+ """List recent events, most recent first."""
38
+ ...
39
+
40
+ def get_children(self, parent_id: str) -> list["TaskEvent"]:
41
+ """Get events for tasks spawned by parent."""
42
+ ...
@@ -0,0 +1 @@
1
+ """Library component - Celery integration."""