ralph-code 0.5.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.
ralph/spinner.py ADDED
@@ -0,0 +1,287 @@
1
+ """Rich-based spinner utility module for ralph-coding.
2
+
3
+ This module provides spinner functionality using Rich's rendering system,
4
+ allowing spinners to be embedded in Live displays and panels.
5
+
6
+ The module provides:
7
+ - Multiple braille spinner styles including 6-dot rotating pattern
8
+ - Integration with Rich's Live display system
9
+ - Nord theme color support
10
+ - Thread-safe operation for background spinning
11
+
12
+ Usage Example:
13
+ from ralph.spinner import RichSpinner, SpinnerStyle
14
+ from rich.live import Live
15
+ from rich.panel import Panel
16
+
17
+ # Create spinner for use in Live display
18
+ spinner = RichSpinner("Processing...", style=SpinnerStyle.DOTS_6)
19
+
20
+ with Live(Panel(spinner), refresh_per_second=10) as live:
21
+ while working:
22
+ live.update(Panel(spinner)) # Spinner auto-advances
23
+
24
+ # Or use standalone with context manager
25
+ with RichSpinner("Loading...") as spinner:
26
+ do_work()
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import threading
32
+ import time
33
+ from dataclasses import dataclass, field
34
+ from enum import Enum
35
+ from typing import Iterator
36
+
37
+ from rich.console import Console, ConsoleOptions, RenderResult
38
+ from rich.live import Live
39
+ from rich.style import Style
40
+ from rich.text import Text
41
+
42
+ from ralph.colors import FROST_CYAN, FG_PRIMARY
43
+
44
+
45
+ class SpinnerStyle(Enum):
46
+ """Available spinner animation styles using braille patterns.
47
+
48
+ DOTS_6: Six dots rotating around the braille cell (2x3 pattern)
49
+ DOTS_8: Eight dots rotating around the full braille cell (2x4 pattern)
50
+ DOTS_BOUNCE: Dots bouncing left-right pattern
51
+ DOTS_GROW: Dots growing/shrinking pattern
52
+ CLASSIC: Traditional |/-\\ spinner
53
+ """
54
+
55
+ # Six dots rotating around the 2x3 braille cell
56
+ # Pattern: top-left -> mid-left -> bot-left -> top-right -> mid-right -> bot-right
57
+ DOTS_6 = ("⠁", "⠂", "⠄", "⠈", "⠐", "⠠")
58
+
59
+ # Eight dots rotating around full 2x4 braille cell
60
+ DOTS_8 = ("⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷")
61
+
62
+ # Classic braille dots pattern (10 frames)
63
+ DOTS = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
64
+
65
+ # Dots bouncing
66
+ DOTS_BOUNCE = ("⠁", "⠂", "⠄", "⠂")
67
+
68
+ # Growing dots
69
+ DOTS_GROW = ("⠀", "⠁", "⠃", "⠇", "⠏", "⠟", "⠿", "⠟", "⠏", "⠇", "⠃", "⠁")
70
+
71
+ # Classic ASCII spinner
72
+ CLASSIC = ("|", "/", "-", "\\")
73
+
74
+ # Circle quadrants
75
+ CIRCLE = ("◴", "◷", "◶", "◵")
76
+
77
+ # Half circles
78
+ HALF_CIRCLE = ("◐", "◓", "◑", "◒")
79
+
80
+
81
+ @dataclass
82
+ class RichSpinner:
83
+ """A Rich-compatible spinner that can be rendered in Live displays.
84
+
85
+ This spinner implements Rich's console protocol, allowing it to be
86
+ used directly in Rich's rendering system including Live, Panel, etc.
87
+
88
+ The spinner automatically advances frames on each render, or can be
89
+ manually advanced with next_frame().
90
+
91
+ Attributes:
92
+ message: Text displayed next to the spinner.
93
+ style: The spinner animation style.
94
+ spinner_color: Color for the spinner character.
95
+ message_color: Color for the message text.
96
+ interval: Time between frames in seconds (for threaded mode).
97
+
98
+ Example:
99
+ # In a Live display (auto-advances on render)
100
+ spinner = RichSpinner("Loading...")
101
+ with Live(spinner, refresh_per_second=10):
102
+ time.sleep(2)
103
+
104
+ # Standalone with threading
105
+ with RichSpinner("Working...") as spinner:
106
+ time.sleep(2)
107
+ """
108
+
109
+ message: str = ""
110
+ style: SpinnerStyle = SpinnerStyle.DOTS_6
111
+ spinner_color: str = FROST_CYAN
112
+ message_color: str = FG_PRIMARY
113
+ interval: float = 0.1
114
+
115
+ _frame_index: int = field(default=0, init=False, repr=False)
116
+ _running: bool = field(default=False, init=False, repr=False)
117
+ _thread: threading.Thread | None = field(default=None, init=False, repr=False)
118
+ _lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False)
119
+ _console: Console | None = field(default=None, init=False, repr=False)
120
+ _live: Live | None = field(default=None, init=False, repr=False)
121
+
122
+ @property
123
+ def frames(self) -> tuple[str, ...]:
124
+ """Get the frame sequence for the current style."""
125
+ value: tuple[str, ...] = self.style.value
126
+ return value
127
+
128
+ @property
129
+ def current_frame(self) -> str:
130
+ """Get the current spinner frame character."""
131
+ return self.frames[self._frame_index % len(self.frames)]
132
+
133
+ def next_frame(self) -> str:
134
+ """Advance to the next frame and return it."""
135
+ with self._lock:
136
+ self._frame_index = (self._frame_index + 1) % len(self.frames)
137
+ return self.current_frame
138
+
139
+ def reset(self) -> None:
140
+ """Reset the spinner to the first frame."""
141
+ with self._lock:
142
+ self._frame_index = 0
143
+
144
+ def update_message(self, message: str) -> None:
145
+ """Update the spinner message."""
146
+ self.message = message
147
+
148
+ def __rich_console__(
149
+ self, console: Console, options: ConsoleOptions
150
+ ) -> RenderResult:
151
+ """Render the spinner for Rich console output.
152
+
153
+ This method is called by Rich when rendering the spinner.
154
+ It advances the frame on each render for automatic animation
155
+ when used with Live displays.
156
+ """
157
+ # Build the text with styled spinner and message
158
+ text = Text()
159
+ text.append(self.current_frame, style=Style(color=self.spinner_color))
160
+ if self.message:
161
+ text.append(" ")
162
+ text.append(self.message, style=Style(color=self.message_color))
163
+
164
+ # Advance frame for next render (makes Live auto-animate)
165
+ self.next_frame()
166
+
167
+ yield text
168
+
169
+ def _spin_loop(self) -> None:
170
+ """Internal loop for threaded spinning."""
171
+ while self._running:
172
+ if self._live:
173
+ self._live.update(self)
174
+ time.sleep(self.interval)
175
+
176
+ def start(self, console: Console | None = None) -> None:
177
+ """Start the spinner with its own Live display.
178
+
179
+ Args:
180
+ console: Optional Rich console to use. Creates one if not provided.
181
+ """
182
+ if self._running:
183
+ return
184
+
185
+ self._console = console or Console()
186
+ self._running = True
187
+ self.reset()
188
+
189
+ # Create Live display for standalone usage
190
+ self._live = Live(
191
+ self,
192
+ console=self._console,
193
+ refresh_per_second=int(1 / self.interval),
194
+ transient=True,
195
+ )
196
+ self._live.start()
197
+
198
+ # Start background thread to keep updating
199
+ self._thread = threading.Thread(target=self._spin_loop, daemon=True)
200
+ self._thread.start()
201
+
202
+ def stop(self) -> None:
203
+ """Stop the spinner and clean up."""
204
+ self._running = False
205
+
206
+ if self._thread:
207
+ self._thread.join(timeout=1.0)
208
+ self._thread = None
209
+
210
+ if self._live:
211
+ self._live.stop()
212
+ self._live = None
213
+
214
+ def __enter__(self) -> RichSpinner:
215
+ """Context manager entry - start the spinner."""
216
+ self.start()
217
+ return self
218
+
219
+ def __exit__(self, exc_type: type | None, exc_val: BaseException | None, exc_tb: object | None) -> None:
220
+ """Context manager exit - stop the spinner."""
221
+ self.stop()
222
+
223
+
224
+ def create_spinner_text(
225
+ message: str = "",
226
+ style: SpinnerStyle = SpinnerStyle.DOTS_6,
227
+ spinner_color: str = FROST_CYAN,
228
+ message_color: str = FG_PRIMARY,
229
+ frame_index: int = 0,
230
+ ) -> Text:
231
+ """Create a Rich Text object with spinner and message.
232
+
233
+ This is a utility function for creating spinner text without
234
+ the full RichSpinner class. Useful for manual frame control.
235
+
236
+ Args:
237
+ message: Text to display after the spinner.
238
+ style: Spinner animation style.
239
+ spinner_color: Color for the spinner character.
240
+ message_color: Color for the message text.
241
+ frame_index: Which frame to display (0-indexed).
242
+
243
+ Returns:
244
+ Rich Text object with styled spinner and message.
245
+
246
+ Example:
247
+ for i in range(20):
248
+ text = create_spinner_text("Loading...", frame_index=i)
249
+ console.print(text, end="\\r")
250
+ time.sleep(0.1)
251
+ """
252
+ frames = style.value
253
+ frame = frames[frame_index % len(frames)]
254
+
255
+ text = Text()
256
+ text.append(frame, style=Style(color=spinner_color))
257
+ if message:
258
+ text.append(" ")
259
+ text.append(message, style=Style(color=message_color))
260
+
261
+ return text
262
+
263
+
264
+ # Convenience aliases for backwards compatibility
265
+ SpinnerManager = RichSpinner
266
+
267
+
268
+ def spinner_frames(style: SpinnerStyle = SpinnerStyle.DOTS_6) -> Iterator[str]:
269
+ """Generate infinite spinner frames.
270
+
271
+ Args:
272
+ style: Spinner animation style.
273
+
274
+ Yields:
275
+ Spinner frame characters in sequence, repeating indefinitely.
276
+
277
+ Example:
278
+ frames = spinner_frames(SpinnerStyle.DOTS_6)
279
+ for _ in range(30):
280
+ print(next(frames), end="\\r", flush=True)
281
+ time.sleep(0.1)
282
+ """
283
+ frames_tuple = style.value
284
+ index = 0
285
+ while True:
286
+ yield frames_tuple[index % len(frames_tuple)]
287
+ index += 1
ralph/storage.py ADDED
@@ -0,0 +1,77 @@
1
+ """Platform-specific storage paths for ralph-coding application."""
2
+
3
+ from pathlib import Path
4
+ from platformdirs import user_data_dir
5
+
6
+
7
+ APP_NAME = "ralph-coding"
8
+
9
+
10
+ def get_app_data_dir() -> Path:
11
+ """
12
+ Get the platform-specific application data directory.
13
+
14
+ Returns:
15
+ - Windows: %APPDATA%/ralph-coding/
16
+ - macOS: ~/Library/Application Support/ralph-coding/
17
+ - Linux: ~/.local/share/ralph-coding/
18
+ """
19
+ data_dir = Path(user_data_dir(APP_NAME))
20
+ data_dir.mkdir(parents=True, exist_ok=True)
21
+ return data_dir
22
+
23
+
24
+ def get_config_path() -> Path:
25
+ """Get the path to the global config file."""
26
+ return get_app_data_dir() / "config.json"
27
+
28
+
29
+ def get_project_ralph_dir(project_dir: Path) -> Path:
30
+ """
31
+ Get the .ralph directory for a specific project.
32
+
33
+ Args:
34
+ project_dir: The root directory of the project
35
+
36
+ Returns:
37
+ Path to the .ralph directory within the project
38
+ """
39
+ ralph_dir = project_dir / ".ralph"
40
+ ralph_dir.mkdir(parents=True, exist_ok=True)
41
+ return ralph_dir
42
+
43
+
44
+ def get_project_tasks_path(project_dir: Path) -> Path:
45
+ """Get the path to the project's tasks.json file."""
46
+ return get_project_ralph_dir(project_dir) / "tasks.json"
47
+
48
+
49
+ def get_project_state_path(project_dir: Path) -> Path:
50
+ """Get the path to the project's state.json file."""
51
+ return get_project_ralph_dir(project_dir) / "state.json"
52
+
53
+
54
+ def get_project_logs_dir(project_dir: Path) -> Path:
55
+ """Get the path to the project's logs directory (for debug mode)."""
56
+ logs_dir = get_project_ralph_dir(project_dir) / "logs"
57
+ logs_dir.mkdir(parents=True, exist_ok=True)
58
+ return logs_dir
59
+
60
+
61
+ def get_progress_md_path(project_dir: Path) -> Path:
62
+ """Get the path to progress.md in the project root."""
63
+ return project_dir / "progress.md"
64
+
65
+
66
+ def get_learnings_md_path(project_dir: Path) -> Path:
67
+ """Get the path to learnings.md in the project root."""
68
+ return project_dir / "learnings.md"
69
+
70
+
71
+ def get_summarised_notes_path(project_dir: Path) -> Path:
72
+ """Get the path to summarised_notes.txt in the .ralph directory.
73
+
74
+ This file archives full story notes along with their summaries
75
+ when stories hit max attempts (debug mode only).
76
+ """
77
+ return get_project_ralph_dir(project_dir) / "summarised_notes.txt"
ralph/tasks.py ADDED
@@ -0,0 +1,298 @@
1
+ """Task management and JSON schema validation for ralph-coding."""
2
+
3
+ import json
4
+ import uuid
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from typing import Any, Literal
8
+
9
+ import jsonschema
10
+
11
+ from .storage import get_project_tasks_path
12
+
13
+
14
+ TaskStatus = Literal["pending", "in_progress", "completed", "errored"]
15
+
16
+
17
+ def _get_schema() -> dict[str, Any]:
18
+ """Load the task schema from the schemas directory."""
19
+ schema_path = Path(__file__).parent / "schemas" / "task_schema.json"
20
+ with open(schema_path, "r", encoding="utf-8") as f:
21
+ result: dict[str, Any] = json.load(f)
22
+ return result
23
+
24
+
25
+ def _validate_tasks_data(data: dict[str, Any]) -> None:
26
+ """Validate tasks data against the JSON schema."""
27
+ schema = _get_schema()
28
+ jsonschema.validate(instance=data, schema=schema)
29
+
30
+
31
+ class Task:
32
+ """Represents a single task (either thin or full spec)."""
33
+
34
+ def __init__(
35
+ self,
36
+ name: str,
37
+ id: str | None = None,
38
+ description: str = "",
39
+ status: TaskStatus = "pending",
40
+ prerequisites: list[str] | None = None,
41
+ acceptance_criteria: list[str] | None = None,
42
+ files_to_modify: list[str] | None = None,
43
+ notes: str = "",
44
+ created_at: str | None = None,
45
+ started_at: str | None = None,
46
+ completed_at: str | None = None,
47
+ is_thin: bool = False,
48
+ ):
49
+ self.id = id or str(uuid.uuid4())
50
+ self.name = name
51
+ self.description = description
52
+ self.status = status
53
+ self.prerequisites = prerequisites or []
54
+ self.acceptance_criteria = acceptance_criteria or []
55
+ self.files_to_modify = files_to_modify or []
56
+ self.notes = notes
57
+ self.created_at = created_at or datetime.utcnow().isoformat()
58
+ self.started_at = started_at
59
+ self.completed_at = completed_at
60
+ self._is_thin = is_thin
61
+
62
+ @classmethod
63
+ def from_thin(cls, description: str) -> "Task":
64
+ """Create a task from a thin task string."""
65
+ return cls(name=description, is_thin=True)
66
+
67
+ @classmethod
68
+ def from_dict(cls, data: dict[str, Any] | str) -> "Task":
69
+ """Create a task from a dictionary or thin task string."""
70
+ if isinstance(data, str):
71
+ return cls.from_thin(data)
72
+
73
+ return cls(
74
+ id=data.get("id"),
75
+ name=data["name"],
76
+ description=data.get("description", ""),
77
+ status=data.get("status", "pending"),
78
+ prerequisites=data.get("prerequisites"),
79
+ acceptance_criteria=data.get("acceptance_criteria"),
80
+ files_to_modify=data.get("files_to_modify"),
81
+ notes=data.get("notes", ""),
82
+ created_at=data.get("created_at"),
83
+ started_at=data.get("started_at"),
84
+ completed_at=data.get("completed_at"),
85
+ is_thin=False,
86
+ )
87
+
88
+ def to_dict(self) -> dict[str, Any]:
89
+ """Convert task to a dictionary for serialization."""
90
+ return {
91
+ "id": self.id,
92
+ "name": self.name,
93
+ "description": self.description,
94
+ "status": self.status,
95
+ "prerequisites": self.prerequisites,
96
+ "acceptance_criteria": self.acceptance_criteria,
97
+ "files_to_modify": self.files_to_modify,
98
+ "notes": self.notes,
99
+ "created_at": self.created_at,
100
+ "started_at": self.started_at,
101
+ "completed_at": self.completed_at,
102
+ }
103
+
104
+ def to_serializable(self) -> str | dict[str, Any]:
105
+ """Convert task to serializable format (thin string or full dict)."""
106
+ if self._is_thin and self.status == "pending":
107
+ return self.name
108
+ return self.to_dict()
109
+
110
+ @property
111
+ def is_thin(self) -> bool:
112
+ """Check if this is a thin (unspecced) task."""
113
+ return self._is_thin
114
+
115
+ @property
116
+ def is_specced(self) -> bool:
117
+ """Check if this task has been fully specified."""
118
+ return not self._is_thin
119
+
120
+ def spec(
121
+ self,
122
+ description: str,
123
+ acceptance_criteria: list[str] | None = None,
124
+ files_to_modify: list[str] | None = None,
125
+ prerequisites: list[str] | None = None,
126
+ notes: str = "",
127
+ ) -> None:
128
+ """Convert a thin task to a full spec."""
129
+ self.description = description
130
+ self.acceptance_criteria = acceptance_criteria or []
131
+ self.files_to_modify = files_to_modify or []
132
+ self.prerequisites = prerequisites or []
133
+ self.notes = notes
134
+ self._is_thin = False
135
+
136
+ def start(self) -> None:
137
+ """Mark the task as in progress."""
138
+ self.status = "in_progress"
139
+ self.started_at = datetime.utcnow().isoformat()
140
+
141
+ def complete(self) -> None:
142
+ """Mark the task as completed."""
143
+ self.status = "completed"
144
+ self.completed_at = datetime.utcnow().isoformat()
145
+
146
+ def error(self, reason: str = "") -> None:
147
+ """Mark the task as errored."""
148
+ self.status = "errored"
149
+ if reason:
150
+ self.notes = f"{self.notes}\n\nError: {reason}".strip()
151
+
152
+
153
+ class TaskManager:
154
+ """Manages the collection of tasks for a project."""
155
+
156
+ def __init__(self, project_dir: Path):
157
+ self.project_dir = project_dir
158
+ self._tasks: list[Task] = []
159
+ self._load()
160
+
161
+ def _load(self) -> None:
162
+ """Load tasks from the project's tasks.json file."""
163
+ tasks_path = get_project_tasks_path(self.project_dir)
164
+
165
+ if tasks_path.exists():
166
+ try:
167
+ with open(tasks_path, "r", encoding="utf-8") as f:
168
+ data = json.load(f)
169
+ _validate_tasks_data(data)
170
+ self._tasks = [Task.from_dict(t) for t in data.get("tasks", [])]
171
+ except (json.JSONDecodeError, jsonschema.ValidationError, IOError) as e:
172
+ # Start fresh if file is corrupted
173
+ self._tasks = []
174
+ else:
175
+ self._tasks = []
176
+
177
+ def _save(self) -> None:
178
+ """Save tasks to the project's tasks.json file."""
179
+ tasks_path = get_project_tasks_path(self.project_dir)
180
+ tasks_path.parent.mkdir(parents=True, exist_ok=True)
181
+
182
+ data = {"tasks": [t.to_serializable() for t in self._tasks]}
183
+ _validate_tasks_data(data)
184
+
185
+ with open(tasks_path, "w", encoding="utf-8") as f:
186
+ json.dump(data, f, indent=2, ensure_ascii=False)
187
+
188
+ @property
189
+ def tasks(self) -> list[Task]:
190
+ """Get all tasks."""
191
+ return self._tasks.copy()
192
+
193
+ def add_thin_task(self, description: str) -> Task:
194
+ """Add a thin (unspecced) task."""
195
+ task = Task.from_thin(description)
196
+ self._tasks.append(task)
197
+ self._save()
198
+ return task
199
+
200
+ def add_full_task(
201
+ self,
202
+ name: str,
203
+ description: str,
204
+ acceptance_criteria: list[str] | None = None,
205
+ files_to_modify: list[str] | None = None,
206
+ prerequisites: list[str] | None = None,
207
+ notes: str = "",
208
+ ) -> Task:
209
+ """Add a fully specified task."""
210
+ task = Task(
211
+ name=name,
212
+ description=description,
213
+ acceptance_criteria=acceptance_criteria,
214
+ files_to_modify=files_to_modify,
215
+ prerequisites=prerequisites,
216
+ notes=notes,
217
+ )
218
+ self._tasks.append(task)
219
+ self._save()
220
+ return task
221
+
222
+ def get_task_by_id(self, task_id: str) -> Task | None:
223
+ """Get a task by its ID."""
224
+ for task in self._tasks:
225
+ if task.id == task_id:
226
+ return task
227
+ return None
228
+
229
+ def get_unspecced_tasks(self) -> list[Task]:
230
+ """Get all thin (unspecced) tasks."""
231
+ return [t for t in self._tasks if t.is_thin]
232
+
233
+ def get_specced_tasks(self) -> list[Task]:
234
+ """Get all fully specified tasks."""
235
+ return [t for t in self._tasks if t.is_specced]
236
+
237
+ def get_pending_tasks(self) -> list[Task]:
238
+ """Get all pending tasks that are ready to work on."""
239
+ return [
240
+ t for t in self._tasks
241
+ if t.status == "pending" and t.is_specced and self._prerequisites_met(t)
242
+ ]
243
+
244
+ def get_in_progress_task(self) -> Task | None:
245
+ """Get the currently in-progress task, if any."""
246
+ for task in self._tasks:
247
+ if task.status == "in_progress":
248
+ return task
249
+ return None
250
+
251
+ def get_completed_tasks(self) -> list[Task]:
252
+ """Get all completed tasks."""
253
+ return [t for t in self._tasks if t.status == "completed"]
254
+
255
+ def get_errored_tasks(self) -> list[Task]:
256
+ """Get all errored tasks."""
257
+ return [t for t in self._tasks if t.status == "errored"]
258
+
259
+ def _prerequisites_met(self, task: Task) -> bool:
260
+ """Check if all prerequisites for a task are completed.
261
+
262
+ Only considers prerequisites that match existing task IDs.
263
+ Descriptive prerequisites (non-UUID strings) are ignored.
264
+ """
265
+ for prereq_id in task.prerequisites:
266
+ prereq = self.get_task_by_id(prereq_id)
267
+ # Only block on prerequisites that actually exist
268
+ if prereq is not None and prereq.status != "completed":
269
+ return False
270
+ return True
271
+
272
+ def update_task(self, task: Task) -> None:
273
+ """Update a task and save changes."""
274
+ for i, t in enumerate(self._tasks):
275
+ if t.id == task.id:
276
+ self._tasks[i] = task
277
+ self._save()
278
+ return
279
+ raise ValueError(f"Task not found: {task.id}")
280
+
281
+ def remove_task(self, task_id: str) -> bool:
282
+ """Remove a task by ID."""
283
+ for i, task in enumerate(self._tasks):
284
+ if task.id == task_id:
285
+ del self._tasks[i]
286
+ self._save()
287
+ return True
288
+ return False
289
+
290
+ def get_stats(self) -> dict[str, int]:
291
+ """Get task statistics."""
292
+ return {
293
+ "unspecced": len(self.get_unspecced_tasks()),
294
+ "pending": sum(1 for t in self._tasks if t.status == "pending" and t.is_specced),
295
+ "in_progress": sum(1 for t in self._tasks if t.status == "in_progress"),
296
+ "completed": sum(1 for t in self._tasks if t.status == "completed"),
297
+ "errored": sum(1 for t in self._tasks if t.status == "errored"),
298
+ }