forgeo-cli 0.3.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.
- forgeo/__init__.py +10 -0
- forgeo/__main__.py +6 -0
- forgeo/agent.py +332 -0
- forgeo/backlog.py +206 -0
- forgeo/central.py +620 -0
- forgeo/cli.py +759 -0
- forgeo/config.py +36 -0
- forgeo/daemon.py +209 -0
- forgeo/forgeo.py +446 -0
- forgeo/git.py +100 -0
- forgeo/instances.py +187 -0
- forgeo/io.py +30 -0
- forgeo/models.py +252 -0
- forgeo/notify.py +77 -0
- forgeo/runs.py +73 -0
- forgeo/setup.py +185 -0
- forgeo/web/central/central.css +380 -0
- forgeo/web/central/central.js +780 -0
- forgeo/web/central/index.html +41 -0
- forgeo/web/central/instance.html +189 -0
- forgeo/web/style.css +656 -0
- forgeo/web_common.py +92 -0
- forgeo_cli-0.3.0.dist-info/METADATA +146 -0
- forgeo_cli-0.3.0.dist-info/RECORD +27 -0
- forgeo_cli-0.3.0.dist-info/WHEEL +4 -0
- forgeo_cli-0.3.0.dist-info/entry_points.txt +2 -0
- forgeo_cli-0.3.0.dist-info/licenses/LICENSE +21 -0
forgeo/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Forgeo: a scheduled software forgeo that executes backlog tasks
|
|
2
|
+
on the main branch and refactors when idle."""
|
|
3
|
+
|
|
4
|
+
try:
|
|
5
|
+
from importlib.metadata import PackageNotFoundError
|
|
6
|
+
from importlib.metadata import version as _pkg_version
|
|
7
|
+
|
|
8
|
+
__version__ = _pkg_version("forgeo-cli")
|
|
9
|
+
except PackageNotFoundError: # standalone binary: no installed package metadata
|
|
10
|
+
__version__ = "0.3.0"
|
forgeo/__main__.py
ADDED
forgeo/agent.py
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""The coding agent: any shell command that implements the task.
|
|
2
|
+
|
|
3
|
+
The configured command is run with the repository as its working directory.
|
|
4
|
+
The task is delivered to the agent as the ``FORGEO_TASK`` environment
|
|
5
|
+
variable (title, description, acceptance criteria) so any CLI coding tool
|
|
6
|
+
(aider, claude, a custom script, ...) can consume it. The exit code decides
|
|
7
|
+
the outcome:
|
|
8
|
+
|
|
9
|
+
* ``0`` — SUCCESS, the work is committed and pushed,
|
|
10
|
+
* ``blocked_exit_code`` (default ``2``) — BLOCKED, the agent needs human
|
|
11
|
+
input; its output ends up in the blocker file,
|
|
12
|
+
* anything else — ERROR, the task fails and the agent's changes are
|
|
13
|
+
discarded.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import os
|
|
20
|
+
import shutil
|
|
21
|
+
import signal
|
|
22
|
+
from abc import ABC, abstractmethod
|
|
23
|
+
from collections import deque
|
|
24
|
+
|
|
25
|
+
from forgeo.models import ExecutionResult, ExecutionStatus, RepoContext, Task
|
|
26
|
+
|
|
27
|
+
# Keep only the most recent process output lines so a chatty agent cannot
|
|
28
|
+
# blow memory; header/footer status lines are added around this window.
|
|
29
|
+
_MAX_OUTPUT_LINES = 1000
|
|
30
|
+
|
|
31
|
+
# Hard cap on how long we wait for the output streams to reach EOF after the
|
|
32
|
+
# process is done (or killed). Grandchildren that escaped the process group
|
|
33
|
+
# (daemonized agents, docker containers) may hold the pipes open forever;
|
|
34
|
+
# Forgeo must never hang on that.
|
|
35
|
+
_DEFAULT_DRAIN_TIMEOUT_SECONDS = 30.0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _kill_process_group(proc: asyncio.subprocess.Process) -> None:
|
|
39
|
+
"""SIGKILL the whole process group of a spawned agent.
|
|
40
|
+
|
|
41
|
+
``proc.kill()`` only kills the direct child (for a string command, the
|
|
42
|
+
shell); the real agent often runs as a grandchild, which would survive
|
|
43
|
+
as an orphan and keep the output pipes open. The agent is spawned in its
|
|
44
|
+
own session (``start_new_session=True``), so ``proc.pid`` is its process
|
|
45
|
+
group id and killing the group reaps the entire process tree. Falls back
|
|
46
|
+
to killing just the direct child when the group is already gone.
|
|
47
|
+
"""
|
|
48
|
+
try:
|
|
49
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
50
|
+
except ProcessLookupError:
|
|
51
|
+
proc.kill()
|
|
52
|
+
except PermissionError:
|
|
53
|
+
proc.kill()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class SandboxUnavailableError(RuntimeError):
|
|
57
|
+
"""The configured sandbox backend cannot run on this host."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class BaseAgent(ABC):
|
|
61
|
+
"""Uniform interface for invoking a coding agent on a task."""
|
|
62
|
+
|
|
63
|
+
name: str = "base"
|
|
64
|
+
|
|
65
|
+
@abstractmethod
|
|
66
|
+
async def run_task(
|
|
67
|
+
self,
|
|
68
|
+
task: Task,
|
|
69
|
+
context: RepoContext,
|
|
70
|
+
*,
|
|
71
|
+
command: str | list[str] | None = None,
|
|
72
|
+
timeout_seconds: float | None = None,
|
|
73
|
+
) -> ExecutionResult:
|
|
74
|
+
"""Execute one task and return its result.
|
|
75
|
+
|
|
76
|
+
``command`` and ``timeout_seconds`` optionally override the agent's
|
|
77
|
+
configured values for this single task; ``None`` means "use the
|
|
78
|
+
configured default".
|
|
79
|
+
|
|
80
|
+
Implementations must never raise for expected agent failures — they
|
|
81
|
+
should encode them as ``ExecutionResult(status=ERROR)``.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class ShellAgent(BaseAgent):
|
|
86
|
+
"""Runs ``ForgeoConfig.agent_command`` in a subprocess."""
|
|
87
|
+
|
|
88
|
+
name = "shell"
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
command: str | list[str],
|
|
93
|
+
*,
|
|
94
|
+
timeout_seconds: float | None = None,
|
|
95
|
+
drain_timeout_seconds: float = _DEFAULT_DRAIN_TIMEOUT_SECONDS,
|
|
96
|
+
env: dict[str, str] | None = None,
|
|
97
|
+
blocked_exit_code: int = 2,
|
|
98
|
+
) -> None:
|
|
99
|
+
self.command = command
|
|
100
|
+
self.timeout_seconds = timeout_seconds
|
|
101
|
+
self.drain_timeout_seconds = drain_timeout_seconds
|
|
102
|
+
self.env = dict(env or {})
|
|
103
|
+
self.blocked_exit_code = blocked_exit_code
|
|
104
|
+
|
|
105
|
+
@staticmethod
|
|
106
|
+
async def _drain_stream(
|
|
107
|
+
stream: asyncio.StreamReader | None,
|
|
108
|
+
prefix: str,
|
|
109
|
+
lines: deque[str],
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Read ``stream`` line-by-line into ``lines`` until EOF."""
|
|
112
|
+
if stream is None:
|
|
113
|
+
return
|
|
114
|
+
while True:
|
|
115
|
+
raw = await stream.readline()
|
|
116
|
+
if not raw:
|
|
117
|
+
break
|
|
118
|
+
text = raw.decode(errors="replace").rstrip("\r\n")
|
|
119
|
+
lines.append(f"[{prefix}] {text}")
|
|
120
|
+
|
|
121
|
+
async def _spawn(
|
|
122
|
+
self,
|
|
123
|
+
command: str | list[str],
|
|
124
|
+
cwd: str,
|
|
125
|
+
env: dict[str, str],
|
|
126
|
+
) -> asyncio.subprocess.Process:
|
|
127
|
+
"""Start the agent process; returns a handle to it.
|
|
128
|
+
|
|
129
|
+
Subclasses (e.g. sandboxes) override this to change *how* the command
|
|
130
|
+
runs; the exit-code contract and output handling stay in ``run_task``.
|
|
131
|
+
"""
|
|
132
|
+
if isinstance(command, str):
|
|
133
|
+
return await asyncio.create_subprocess_shell(
|
|
134
|
+
command,
|
|
135
|
+
cwd=cwd,
|
|
136
|
+
env=env,
|
|
137
|
+
stdout=asyncio.subprocess.PIPE,
|
|
138
|
+
stderr=asyncio.subprocess.PIPE,
|
|
139
|
+
start_new_session=True,
|
|
140
|
+
)
|
|
141
|
+
return await asyncio.create_subprocess_exec(
|
|
142
|
+
*command,
|
|
143
|
+
cwd=cwd,
|
|
144
|
+
env=env,
|
|
145
|
+
stdout=asyncio.subprocess.PIPE,
|
|
146
|
+
stderr=asyncio.subprocess.PIPE,
|
|
147
|
+
start_new_session=True,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
async def run_task(
|
|
151
|
+
self,
|
|
152
|
+
task: Task,
|
|
153
|
+
context: RepoContext,
|
|
154
|
+
*,
|
|
155
|
+
command: str | list[str] | None = None,
|
|
156
|
+
timeout_seconds: float | None = None,
|
|
157
|
+
) -> ExecutionResult:
|
|
158
|
+
"""Run the configured command once for the task.
|
|
159
|
+
|
|
160
|
+
``command`` and ``timeout_seconds`` override this agent's configured
|
|
161
|
+
values for this run when given; otherwise the configured defaults are
|
|
162
|
+
used.
|
|
163
|
+
"""
|
|
164
|
+
command = command if command is not None else self.command
|
|
165
|
+
timeout = timeout_seconds if timeout_seconds is not None else self.timeout_seconds
|
|
166
|
+
logs: list[str] = [f"[{self.name}] Running task {task.id} ({task.title})"]
|
|
167
|
+
env = {
|
|
168
|
+
**os.environ,
|
|
169
|
+
**self.env,
|
|
170
|
+
"FORGEO_TASK": task.instruction,
|
|
171
|
+
"FORGEO_REPO": str(context.repo_path),
|
|
172
|
+
"FORGEO_BRANCH": context.branch,
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
proc = await self._spawn(command, str(context.repo_path), env)
|
|
177
|
+
except FileNotFoundError as exc:
|
|
178
|
+
logs.append(f"[{self.name}] Command not found: {exc}")
|
|
179
|
+
return ExecutionResult(
|
|
180
|
+
status=ExecutionStatus.ERROR,
|
|
181
|
+
output_logs=logs,
|
|
182
|
+
error=f"command not found: {exc}",
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
stream_lines: deque[str] = deque(maxlen=_MAX_OUTPUT_LINES)
|
|
186
|
+
readers = asyncio.gather(
|
|
187
|
+
self._drain_stream(proc.stdout, "stdout", stream_lines),
|
|
188
|
+
self._drain_stream(proc.stderr, "stderr", stream_lines),
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
timed_out = False
|
|
192
|
+
try:
|
|
193
|
+
await asyncio.wait_for(proc.wait(), timeout=timeout)
|
|
194
|
+
except TimeoutError:
|
|
195
|
+
timed_out = True
|
|
196
|
+
_kill_process_group(proc)
|
|
197
|
+
await proc.wait()
|
|
198
|
+
# Always finish draining so lines already written (and any residual
|
|
199
|
+
# after kill) are captured before we build the result. Bounded: a
|
|
200
|
+
# grandchild that escaped the process group (daemonized agent, docker
|
|
201
|
+
# container) may hold the pipes open, and Forgeo must never hang
|
|
202
|
+
# waiting for EOF that will not come.
|
|
203
|
+
try:
|
|
204
|
+
await asyncio.wait_for(readers, timeout=self.drain_timeout_seconds)
|
|
205
|
+
except TimeoutError:
|
|
206
|
+
logs.append(
|
|
207
|
+
f"[{self.name}] Output streams stayed open beyond "
|
|
208
|
+
f"{self.drain_timeout_seconds:g}s; proceeding without them."
|
|
209
|
+
)
|
|
210
|
+
logs.extend(stream_lines)
|
|
211
|
+
|
|
212
|
+
if timed_out:
|
|
213
|
+
label = f" after {timeout:g}s" if timeout is not None else ""
|
|
214
|
+
logs.append(f"[{self.name}] Execution timed out{label}; process killed.")
|
|
215
|
+
return ExecutionResult(
|
|
216
|
+
status=ExecutionStatus.ERROR,
|
|
217
|
+
output_logs=logs,
|
|
218
|
+
error=f"timed out{label}",
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
if proc.returncode == 0:
|
|
222
|
+
logs.append(f"[{self.name}] Task {task.id} finished successfully (exit 0).")
|
|
223
|
+
return ExecutionResult(
|
|
224
|
+
status=ExecutionStatus.SUCCESS,
|
|
225
|
+
output_logs=logs,
|
|
226
|
+
exit_code=proc.returncode,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
if proc.returncode == self.blocked_exit_code:
|
|
230
|
+
logs.append(f"[{self.name}] Task {task.id} needs human input (exit {proc.returncode}).")
|
|
231
|
+
return ExecutionResult(
|
|
232
|
+
status=ExecutionStatus.BLOCKED,
|
|
233
|
+
output_logs=logs,
|
|
234
|
+
questions=[line for line in logs if line.startswith(("[stdout]", "[stderr]"))],
|
|
235
|
+
exit_code=proc.returncode,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
logs.append(f"[{self.name}] Task {task.id} failed with exit code {proc.returncode}.")
|
|
239
|
+
return ExecutionResult(
|
|
240
|
+
status=ExecutionStatus.ERROR,
|
|
241
|
+
output_logs=logs,
|
|
242
|
+
error=f"exit code {proc.returncode}",
|
|
243
|
+
exit_code=proc.returncode,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# Environment variables set by ``run_task`` and forwarded into the container.
|
|
248
|
+
_SANDBOX_FORWARDED_ENV = ("FORGEO_TASK", "FORGEO_REPO", "FORGEO_BRANCH")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class DockerSandboxAgent(ShellAgent):
|
|
252
|
+
"""Runs the agent command inside a ``docker run --rm`` container.
|
|
253
|
+
|
|
254
|
+
The repository is bind-mounted at its absolute path (so the agent's edits
|
|
255
|
+
land on the host checkout), ``FORGEO_TASK`` and ``agent_env`` are passed
|
|
256
|
+
through as environment variables, and networking is disabled
|
|
257
|
+
(``--network none``) unless a network is configured explicitly. Agent
|
|
258
|
+
credentials/config are only visible inside the container when listed in
|
|
259
|
+
``mounts`` (mounted read-only at the same path). The container exit code
|
|
260
|
+
is mapped exactly like the shell agent's: ``0`` success,
|
|
261
|
+
``blocked_exit_code`` needs human input, anything else is an error.
|
|
262
|
+
|
|
263
|
+
The image is expected to contain the agent CLI (e.g. ``claude``) and a
|
|
264
|
+
POSIX shell (``sh``) for string commands.
|
|
265
|
+
"""
|
|
266
|
+
|
|
267
|
+
name = "docker"
|
|
268
|
+
|
|
269
|
+
def __init__(
|
|
270
|
+
self,
|
|
271
|
+
command: str | list[str],
|
|
272
|
+
*,
|
|
273
|
+
image: str,
|
|
274
|
+
network: str = "none",
|
|
275
|
+
mounts: list[str] | None = None,
|
|
276
|
+
timeout_seconds: float | None = None,
|
|
277
|
+
env: dict[str, str] | None = None,
|
|
278
|
+
blocked_exit_code: int = 2,
|
|
279
|
+
) -> None:
|
|
280
|
+
if not (image or "").strip():
|
|
281
|
+
raise ValueError("docker sandbox requires an image")
|
|
282
|
+
if shutil.which("docker") is None:
|
|
283
|
+
raise SandboxUnavailableError(
|
|
284
|
+
"agent_sandbox: docker is configured but the `docker` binary was "
|
|
285
|
+
"not found on PATH."
|
|
286
|
+
)
|
|
287
|
+
super().__init__(
|
|
288
|
+
command,
|
|
289
|
+
timeout_seconds=timeout_seconds,
|
|
290
|
+
env=env,
|
|
291
|
+
blocked_exit_code=blocked_exit_code,
|
|
292
|
+
)
|
|
293
|
+
self.image = image
|
|
294
|
+
self.network = network
|
|
295
|
+
self.mounts = [mount for mount in (mounts or []) if mount]
|
|
296
|
+
|
|
297
|
+
def _docker_args(
|
|
298
|
+
self,
|
|
299
|
+
command: str | list[str],
|
|
300
|
+
cwd: str,
|
|
301
|
+
env: dict[str, str],
|
|
302
|
+
) -> list[str]:
|
|
303
|
+
"""Build the ``docker run`` argv for one agent execution."""
|
|
304
|
+
args = ["docker", "run", "--rm", "--network", self.network, "-w", cwd]
|
|
305
|
+
args += ["-v", f"{cwd}:{cwd}"]
|
|
306
|
+
forwarded = set(_SANDBOX_FORWARDED_ENV) | set(self.env)
|
|
307
|
+
for key in sorted(forwarded):
|
|
308
|
+
if key in env:
|
|
309
|
+
args += ["-e", f"{key}={env[key]}"]
|
|
310
|
+
for mount in self.mounts:
|
|
311
|
+
args += ["-v", f"{mount}:{mount}:ro"]
|
|
312
|
+
args.append(self.image)
|
|
313
|
+
if isinstance(command, str):
|
|
314
|
+
args += ["sh", "-c", command]
|
|
315
|
+
else:
|
|
316
|
+
args += list(command)
|
|
317
|
+
return args
|
|
318
|
+
|
|
319
|
+
async def _spawn(
|
|
320
|
+
self,
|
|
321
|
+
command: str | list[str],
|
|
322
|
+
cwd: str,
|
|
323
|
+
env: dict[str, str],
|
|
324
|
+
) -> asyncio.subprocess.Process:
|
|
325
|
+
return await asyncio.create_subprocess_exec(
|
|
326
|
+
*self._docker_args(command, cwd, env),
|
|
327
|
+
cwd=cwd,
|
|
328
|
+
env=env,
|
|
329
|
+
stdout=asyncio.subprocess.PIPE,
|
|
330
|
+
stderr=asyncio.subprocess.PIPE,
|
|
331
|
+
start_new_session=True,
|
|
332
|
+
)
|
forgeo/backlog.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""The backlog: a single human-readable JSON file of tasks.
|
|
2
|
+
|
|
3
|
+
Forgeo pulls the oldest ``OPEN`` task from here. Edit this file
|
|
4
|
+
directly to add, remove, or reopen tasks (e.g. set a ``BLOCKED`` task back
|
|
5
|
+
to ``OPEN`` once the human input has been provided). Writes are atomic and
|
|
6
|
+
serialized through an asyncio lock.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
from collections import Counter
|
|
15
|
+
from datetime import UTC, datetime
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from pydantic import ValidationError
|
|
20
|
+
|
|
21
|
+
from forgeo.io import atomic_write_text
|
|
22
|
+
from forgeo.models import Task, TaskStatus
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
#: The task fields the web console may edit through ``update_task``.
|
|
27
|
+
EDITABLE_TASK_FIELDS = frozenset(
|
|
28
|
+
{
|
|
29
|
+
"title",
|
|
30
|
+
"description",
|
|
31
|
+
"acceptance_criteria",
|
|
32
|
+
"dependencies",
|
|
33
|
+
"files_to_modify",
|
|
34
|
+
"agent_command",
|
|
35
|
+
"agent_timeout_seconds",
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def oldest_open_task(tasks: list[Task]) -> Task | None:
|
|
41
|
+
"""Return the oldest OPEN task (smallest ``created_at``), or ``None``."""
|
|
42
|
+
open_tasks = [task for task in tasks if task.status is TaskStatus.OPEN]
|
|
43
|
+
if not open_tasks:
|
|
44
|
+
return None
|
|
45
|
+
return min(open_tasks, key=lambda task: task.created_at)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def backlog_status_counts(tasks: list[Task]) -> dict[str, int]:
|
|
49
|
+
"""Count tasks by status; always includes every known status key."""
|
|
50
|
+
counts = {status.value: 0 for status in TaskStatus}
|
|
51
|
+
counts.update(Counter(task.status.value for task in tasks))
|
|
52
|
+
return counts
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class JSONBacklog:
|
|
56
|
+
"""A backlog stored in a single JSON document on disk."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, path: str | Path) -> None:
|
|
59
|
+
self.path = Path(path)
|
|
60
|
+
self._lock = asyncio.Lock()
|
|
61
|
+
|
|
62
|
+
async def list_tasks(self) -> list[Task]:
|
|
63
|
+
"""Return all tasks, in the order they were created."""
|
|
64
|
+
store = await self._read()
|
|
65
|
+
return [self._to_task(entry) for entry in store["tasks"]]
|
|
66
|
+
|
|
67
|
+
async def fetch_next_task(self) -> Task | None:
|
|
68
|
+
"""Return the oldest OPEN task, or ``None`` when there is none."""
|
|
69
|
+
return oldest_open_task(await self.list_tasks())
|
|
70
|
+
|
|
71
|
+
async def get_task(self, task_id: str) -> Task | None:
|
|
72
|
+
"""Return a task by id, or ``None`` if it does not exist."""
|
|
73
|
+
store = await self._read()
|
|
74
|
+
for entry in store["tasks"]:
|
|
75
|
+
if entry["id"] == task_id:
|
|
76
|
+
return self._to_task(entry)
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
async def create_task(self, task: Task) -> Task:
|
|
80
|
+
"""Persist ``task``, rejecting duplicate ids with a ``ValueError``."""
|
|
81
|
+
async with self._lock:
|
|
82
|
+
store = await self._read()
|
|
83
|
+
if any(entry["id"] == task.id for entry in store["tasks"]):
|
|
84
|
+
raise ValueError(f"Task id already exists in backlog: {task.id!r}")
|
|
85
|
+
store["tasks"].append(task.model_dump(mode="json"))
|
|
86
|
+
await self._write(store)
|
|
87
|
+
return task
|
|
88
|
+
|
|
89
|
+
async def update_status(self, task_id: str, status: TaskStatus) -> Task | None:
|
|
90
|
+
"""Transition a task's status, bumping its ``updated_at`` timestamp."""
|
|
91
|
+
async with self._lock:
|
|
92
|
+
store = await self._read()
|
|
93
|
+
updated: Task | None = None
|
|
94
|
+
for entry in store["tasks"]:
|
|
95
|
+
if entry["id"] == task_id:
|
|
96
|
+
entry["status"] = status.value
|
|
97
|
+
entry["updated_at"] = datetime.now(UTC).isoformat()
|
|
98
|
+
updated = self._to_task(entry)
|
|
99
|
+
break
|
|
100
|
+
if updated is not None:
|
|
101
|
+
await self._write(store)
|
|
102
|
+
return updated
|
|
103
|
+
|
|
104
|
+
async def update_task(
|
|
105
|
+
self, task_id: str, updates: dict[str, Any]
|
|
106
|
+
) -> Task | None:
|
|
107
|
+
"""Update a task's editable fields, bumping its ``updated_at``.
|
|
108
|
+
|
|
109
|
+
``updates`` may contain any of :data:`EDITABLE_TASK_FIELDS`; ``id``,
|
|
110
|
+
``status``, ``created_at`` and ``updated_at`` are never replaced
|
|
111
|
+
(except ``updated_at``, bumped to now). Unknown fields and invalid
|
|
112
|
+
values raise a ``ValueError``; an unknown ``task_id`` returns ``None``
|
|
113
|
+
(mirroring :meth:`update_status`). Writes go through the same lock
|
|
114
|
+
and atomic-replace path as the other mutators.
|
|
115
|
+
"""
|
|
116
|
+
if not isinstance(updates, dict):
|
|
117
|
+
raise TypeError("updates must be a dict of task fields")
|
|
118
|
+
unknown = set(updates) - EDITABLE_TASK_FIELDS
|
|
119
|
+
if unknown:
|
|
120
|
+
raise ValueError(
|
|
121
|
+
f"unknown task field(s): {', '.join(sorted(unknown))}"
|
|
122
|
+
)
|
|
123
|
+
for field in ("title", "description"):
|
|
124
|
+
if field in updates and not isinstance(updates[field], str):
|
|
125
|
+
raise ValueError(f"{field} must be a string")
|
|
126
|
+
if "title" in updates and not updates["title"].strip():
|
|
127
|
+
raise ValueError("title must be a non-blank string")
|
|
128
|
+
if "description" in updates and not updates["description"].strip():
|
|
129
|
+
raise ValueError("description must be a non-blank string")
|
|
130
|
+
for field in ("acceptance_criteria", "dependencies", "files_to_modify"):
|
|
131
|
+
if field in updates and (
|
|
132
|
+
not isinstance(updates[field], list)
|
|
133
|
+
or not all(isinstance(item, str) for item in updates[field])
|
|
134
|
+
):
|
|
135
|
+
raise ValueError(f"{field} must be a list of strings")
|
|
136
|
+
|
|
137
|
+
async with self._lock:
|
|
138
|
+
store = await self._read()
|
|
139
|
+
for entry in store["tasks"]:
|
|
140
|
+
if entry["id"] != task_id:
|
|
141
|
+
continue
|
|
142
|
+
candidate = dict(entry)
|
|
143
|
+
candidate.update(updates)
|
|
144
|
+
candidate["updated_at"] = datetime.now(UTC).isoformat()
|
|
145
|
+
try:
|
|
146
|
+
task = Task.model_validate(candidate)
|
|
147
|
+
except ValidationError as exc:
|
|
148
|
+
raise ValueError(f"invalid task field(s): {exc}") from exc
|
|
149
|
+
normalized = task.model_dump(mode="json")
|
|
150
|
+
for field in updates:
|
|
151
|
+
entry[field] = normalized[field]
|
|
152
|
+
entry["updated_at"] = normalized["updated_at"]
|
|
153
|
+
await self._write(store)
|
|
154
|
+
return task
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
# ------------------------------------------------------------------ #
|
|
158
|
+
# Internal persistence helpers #
|
|
159
|
+
# ------------------------------------------------------------------ #
|
|
160
|
+
|
|
161
|
+
async def _read(self) -> dict[str, Any]:
|
|
162
|
+
"""Load the store from disk, tolerating a missing or corrupt file."""
|
|
163
|
+
if not self.path.exists():
|
|
164
|
+
return {"tasks": []}
|
|
165
|
+
try:
|
|
166
|
+
data = json.loads(self.path.read_text(encoding="utf-8"))
|
|
167
|
+
except (json.JSONDecodeError, OSError):
|
|
168
|
+
timestamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S%f")
|
|
169
|
+
corrupt_path = self.path.with_name(f"{self.path.name}.corrupt-{timestamp}")
|
|
170
|
+
try:
|
|
171
|
+
self.path.rename(corrupt_path)
|
|
172
|
+
logger.warning(
|
|
173
|
+
"Corrupt backlog at %s renamed to %s; starting with empty store",
|
|
174
|
+
self.path,
|
|
175
|
+
corrupt_path,
|
|
176
|
+
)
|
|
177
|
+
except OSError:
|
|
178
|
+
logger.warning(
|
|
179
|
+
"Corrupt backlog at %s could not be preserved; starting with empty store",
|
|
180
|
+
self.path,
|
|
181
|
+
)
|
|
182
|
+
return {"tasks": []}
|
|
183
|
+
if not isinstance(data, dict):
|
|
184
|
+
return {"tasks": []}
|
|
185
|
+
tasks = data.get("tasks")
|
|
186
|
+
return {"tasks": tasks if isinstance(tasks, list) else []}
|
|
187
|
+
|
|
188
|
+
async def _write(self, store: dict[str, Any]) -> None:
|
|
189
|
+
"""Atomically persist the store (temp file + rename)."""
|
|
190
|
+
atomic_write_text(
|
|
191
|
+
self.path,
|
|
192
|
+
json.dumps(store, indent=2, ensure_ascii=False) + "\n",
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
@staticmethod
|
|
196
|
+
def _to_task(entry: dict[str, Any]) -> Task:
|
|
197
|
+
"""Validate a stored dictionary back into a Task, skipping corrupt rows."""
|
|
198
|
+
try:
|
|
199
|
+
return Task.model_validate(entry)
|
|
200
|
+
except ValidationError:
|
|
201
|
+
return Task(
|
|
202
|
+
id=str(entry.get("id", "<unknown>")),
|
|
203
|
+
title="<unparsable task>",
|
|
204
|
+
description="<unparsable task>",
|
|
205
|
+
status=TaskStatus.FAILED,
|
|
206
|
+
)
|