forgeo-cli 0.5.0__tar.gz → 0.6.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.
Files changed (77) hide show
  1. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/CHANGELOG.md +35 -1
  2. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/PKG-INFO +8 -5
  3. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/README.md +7 -4
  4. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/docs/backlog.md +73 -7
  5. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/docs/configuration.md +53 -6
  6. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/docs/getting-started.md +3 -1
  7. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/docs/index.md +10 -5
  8. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/docs/web-console-api.md +17 -4
  9. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/install.sh +1 -1
  10. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/pyproject.toml +1 -1
  11. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/__init__.py +1 -1
  12. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/backlog.py +128 -52
  13. forgeo_cli-0.6.0/src/forgeo/backlog_http.py +149 -0
  14. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/central.py +87 -36
  15. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/cli.py +26 -14
  16. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/config.py +18 -3
  17. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/daemon.py +10 -3
  18. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/daemon_control.py +4 -8
  19. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/forgeo.py +20 -8
  20. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/instances.py +2 -1
  21. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/models.py +101 -2
  22. forgeo_cli-0.6.0/src/forgeo/oauth.py +151 -0
  23. forgeo_cli-0.6.0/src/forgeo/paths.py +80 -0
  24. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/runs.py +3 -8
  25. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/update.py +0 -5
  26. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/validate.py +38 -10
  27. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/web/central/central.js +1 -1
  28. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/tests/conftest.py +72 -2
  29. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/tests/test_backlog.py +4 -4
  30. forgeo_cli-0.6.0/tests/test_backlog_http.py +309 -0
  31. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/tests/test_cli.py +31 -6
  32. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/tests/test_install.py +1 -1
  33. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/tests/test_models.py +86 -0
  34. forgeo_cli-0.6.0/tests/test_oauth.py +186 -0
  35. forgeo_cli-0.6.0/tests/test_paths.py +98 -0
  36. forgeo_cli-0.6.0/tests/test_remote_backlog_cycle.py +89 -0
  37. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/tests/test_runs.py +17 -16
  38. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/tests/test_update.py +7 -12
  39. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/tests/test_web.py +108 -1
  40. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/www/index.html +24 -0
  41. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/.github/workflows/ci.yml +0 -0
  42. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/.gitignore +0 -0
  43. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/CONTRIBUTING.md +0 -0
  44. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/LICENSE +0 -0
  45. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/config/nginx-forgeo.conf +0 -0
  46. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/docs/agent-contract.md +0 -0
  47. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/docs/cli-reference.md +0 -0
  48. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/docs/img/console.png +0 -0
  49. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/docs/img/logo.png +0 -0
  50. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/docs/img/title.svg +0 -0
  51. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/forgeo.spec +0 -0
  52. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/mkdocs.yml +0 -0
  53. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/scripts/__init__.py +0 -0
  54. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/scripts/render_homebrew_formula.py +0 -0
  55. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/__main__.py +0 -0
  56. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/agent.py +0 -0
  57. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/git.py +0 -0
  58. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/io.py +0 -0
  59. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/notify.py +0 -0
  60. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/setup.py +0 -0
  61. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/web/central/central.css +0 -0
  62. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/web/central/index.html +0 -0
  63. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/web/central/instance.html +0 -0
  64. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/web/central/login.html +0 -0
  65. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/web/style.css +0 -0
  66. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/web_common.py +0 -0
  67. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/tests/test_agent.py +0 -0
  68. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/tests/test_daemon.py +0 -0
  69. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/tests/test_factory.py +0 -0
  70. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/tests/test_git.py +0 -0
  71. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/tests/test_instances.py +0 -0
  72. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/tests/test_io.py +0 -0
  73. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/tests/test_render_homebrew.py +0 -0
  74. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/tests/test_setup.py +0 -0
  75. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/tests/test_web_common.py +0 -0
  76. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/tests/test_web_lock.py +0 -0
  77. {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/www/404.html +0 -0
@@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.0] - 2026-08-14
11
+
12
+ ### Added
13
+
14
+ - The backlog can now live in another application instead of a file: set
15
+ `backlog:` to an `http(s)` URL and Forgeo reads the whole document with
16
+ `GET` on every read and writes it back with `POST` on every change, using
17
+ the same JSON shape as `backlog.json`. The endpoint replaces its task list
18
+ with the body it receives. A request that fails (network error, 5xx,
19
+ malformed body) fails the cycle and is retried on the next interval — it is
20
+ never read as an empty backlog, which would start a refactoring pass and let
21
+ the next `POST` overwrite the remote task list with nothing.
22
+
23
+ - `backlog_auth`: OAuth2 client-credentials access for a backlog URL behind an
24
+ identity provider such as Keycloak. Forgeo obtains an access token for a
25
+ confidential client (a service account, not a human login) and sends it as a
26
+ bearer on every backlog request; tokens are cached in memory, renewed before
27
+ they expire, and refreshed once with a retry when the endpoint answers 401 or
28
+ 403. The client secret is never a config value: `client_secret_env` names the
29
+ environment variable holding it, so it stays out of `forgeo.yaml`.
30
+
31
+ - `state_dir`: where Forgeo's own runtime files go (`backlog.lock`,
32
+ `backlog.run`, `backlog.state.json`, `backlog.update.json`, `runs.jsonl`).
33
+ It only matters with a backlog URL, where there is no backlog file for them
34
+ to sit beside; it then defaults to the directory holding `forgeo.yaml`. With
35
+ a backlog file those paths are unchanged.
36
+
37
+ - `forgeo validate` now checks a backlog URL by fetching it once (a plain
38
+ `GET`, with `backlog_auth` credentials when configured), so an unreachable
39
+ endpoint or a rejected token is reported by the dry run instead of by the
40
+ first cycle. A file backlog is still read from disk, and nothing is written
41
+ either way.
42
+
10
43
  ## [0.5.0] - 2026-08-14
11
44
 
12
45
  ### Added
@@ -250,7 +283,8 @@ Initial release of the scheduled, agent-driven software forgeo.
250
283
  overlapping-run skipping.
251
284
  - Dogfooding docs removed; local configs kept out of the repository.
252
285
 
253
- [Unreleased]: https://github.com/lucaGazzola/forgeo/compare/v0.5.0...HEAD
286
+ [Unreleased]: https://github.com/lucaGazzola/forgeo/compare/v0.6.0...HEAD
287
+ [0.6.0]: https://github.com/lucaGazzola/forgeo/compare/v0.5.0...v0.6.0
254
288
  [0.5.0]: https://github.com/lucaGazzola/forgeo/compare/v0.4.0...v0.5.0
255
289
  [0.4.0]: https://github.com/lucaGazzola/forgeo/compare/v0.3.0...v0.4.0
256
290
  [0.3.0]: https://github.com/lucaGazzola/forgeo/compare/v0.2.0...v0.3.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: forgeo-cli
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: A scheduled software forgeo: executes backlog tasks on main, refactors when idle, and writes BLOCKER.md when it needs human input.
5
5
  Project-URL: Homepage, https://forgeo.org
6
6
  Project-URL: Documentation, https://forgeo.org
@@ -140,10 +140,13 @@ central dashboard, `forgeo web`.
140
140
  | Web dashboard & HTTP API | [Web console & HTTP API](docs/web-console-api.md) |
141
141
 
142
142
  Everything is stored in plain files: the backlog, `forgeo.log`, and
143
- `BLOCKER.md` whenever a decision is pending. The backlog is snapshotted
144
- (rotating `backlog.json.bak` files) before every agent run and on daemon
145
- startup, and restored automatically if it is ever found corrupt — a bad write
146
- never loses your tasks.
143
+ `BLOCKER.md` whenever a decision is pending. The backlog can also live in
144
+ another application behind an `http(s)` URL — Forgeo reads the whole task
145
+ document with `GET` and writes it back with `POST`, with optional OAuth2
146
+ client-credentials auth (see [Backlog format](docs/backlog.md)). A *file*
147
+ backlog is snapshotted (rotating `backlog.json.bak` files) before every
148
+ agent run and on daemon startup, and restored automatically if it is ever
149
+ found corrupt — a bad write never loses your tasks.
147
150
 
148
151
  ## Develop
149
152
 
@@ -86,10 +86,13 @@ central dashboard, `forgeo web`.
86
86
  | Web dashboard & HTTP API | [Web console & HTTP API](docs/web-console-api.md) |
87
87
 
88
88
  Everything is stored in plain files: the backlog, `forgeo.log`, and
89
- `BLOCKER.md` whenever a decision is pending. The backlog is snapshotted
90
- (rotating `backlog.json.bak` files) before every agent run and on daemon
91
- startup, and restored automatically if it is ever found corrupt — a bad write
92
- never loses your tasks.
89
+ `BLOCKER.md` whenever a decision is pending. The backlog can also live in
90
+ another application behind an `http(s)` URL — Forgeo reads the whole task
91
+ document with `GET` and writes it back with `POST`, with optional OAuth2
92
+ client-credentials auth (see [Backlog format](docs/backlog.md)). A *file*
93
+ backlog is snapshotted (rotating `backlog.json.bak` files) before every
94
+ agent run and on daemon startup, and restored automatically if it is ever
95
+ found corrupt — a bad write never loses your tasks.
93
96
 
94
97
  ## Develop
95
98
 
@@ -1,10 +1,12 @@
1
1
  # Backlog format
2
2
 
3
- The backlog is a **plain JSON file** you edit by hand. It lives wherever
4
- `backlog:` points in [forgeo.yaml](configuration.md) — by default
5
- `backlog.json` at the project root, and `.forgeo/backlog.json` when generated
6
- by `forgeo init`. Keep it outside the repository if you can so the agent never
7
- touches it.
3
+ The backlog is a **plain JSON document**. By default it is a file you edit by
4
+ hand, living wherever `backlog:` points in [forgeo.yaml](configuration.md) —
5
+ `backlog.json` at the project root, or `.forgeo/backlog.json` when generated by
6
+ `forgeo init`. Keep it outside the repository if you can so the agent never
7
+ touches it. It can also be [served over HTTP](#a-backlog-over-http) by another
8
+ application, in which case the document below is exactly what that endpoint
9
+ exchanges with Forgeo.
8
10
 
9
11
  ```json
10
12
  {
@@ -258,8 +260,9 @@ The backlog is the single source of truth, so it is guarded on both ends:
258
260
 
259
261
  - a missing file is treated as an empty backlog (and is created on first
260
262
  write);
261
- - a corrupt file is renamed to `backlog.json.corrupt-<timestamp>` and the
262
- forgeo starts from an empty store — nothing is silently discarded;
263
+ - a corrupt file is renamed to `backlog.json.corrupt-<timestamp>` and replaced
264
+ by the newest valid snapshot, or by an empty store when there is none —
265
+ nothing is silently discarded;
263
266
  - an unparsable task row is kept as a `FAILED` task rather than killing the
264
267
  whole store;
265
268
  - before every agent run (and on daemon startup) the current backlog is
@@ -282,3 +285,66 @@ in place automatically and the corrupt file is preserved under
282
285
  favor of an older valid one; when no snapshot exists, the forgeo falls back to
283
286
  an empty store exactly as before. A missing backlog is a no-op — no snapshot
284
287
  is created for a file that does not exist.
288
+
289
+ This whole section is about a backlog *file*. A backlog URL is owned by the
290
+ application serving it, which keeps its own history, so Forgeo neither
291
+ snapshots nor repairs it — see below.
292
+
293
+ ## A backlog over HTTP
294
+
295
+ Setting `backlog:` to an `http(s)` URL moves the backlog into another
296
+ application — typically one that already displays and edits work items. Forgeo
297
+ then treats that endpoint exactly like the file:
298
+
299
+ | When | Request |
300
+ | --- | --- |
301
+ | Every read | `GET <url>` returns the whole document |
302
+ | Every write | `POST <url>` sends the whole document back |
303
+
304
+ ```yaml
305
+ backlog: https://api.example.com/api/forgeo/backlog
306
+ ```
307
+
308
+ Add [`backlog_auth`](configuration.md#backlog_auth) when the endpoint requires
309
+ a token. Everything else is unchanged: the same task schema, the same
310
+ oldest-first ordering, the same status transitions.
311
+
312
+ ### What the endpoint must do
313
+
314
+ - **Return the document under `tasks`**, as above. A response that is not a
315
+ JSON object, or whose `tasks` is not a list, reads as an empty backlog.
316
+ - **Replace, never append.** The POST body is the complete task list as it
317
+ should be after the change; an endpoint that appends will duplicate every
318
+ task on every cycle.
319
+ - **Send dates as ISO-8601 strings**, not epoch numbers. Jackson (and several
320
+ other serializers) emit `java.time` values as numeric timestamps by default;
321
+ Forgeo would read those as Unix timestamps, dating every task to 1970 and
322
+ inverting the oldest-first ordering.
323
+ - **Never send `null` for a list field.** `dependencies`,
324
+ `acceptance_criteria`, `files_to_modify`, `blocker_reason` and
325
+ `failure_reason` accept a list or nothing at all — an explicit `null` makes
326
+ that row unparsable, and it comes back as a `FAILED` placeholder task.
327
+ - **Preserve `agent_command`'s shape.** A string is run through a shell, a list
328
+ is executed directly; turning `"claude -p ..."` into `["claude -p ..."]`
329
+ makes Forgeo look for a binary with that entire name.
330
+ - **Store the engine-managed fields it receives** (`status`, `updated_at`,
331
+ `blocker_reason`, `blocked_count`, `failure_reason`) and hand them back
332
+ unchanged. That is how a blocked task keeps its explanation.
333
+
334
+ ### When the endpoint is down
335
+
336
+ A failed request **fails the cycle**: the daemon logs the error and retries on
337
+ the next interval, leaving the remote backlog untouched. It is never read as an
338
+ empty backlog — that would start a refactoring pass and let the POST at the end
339
+ of the cycle overwrite the real task list with nothing. `forgeo status` and
340
+ `forgeo once` report `Backlog unavailable: ...` and exit `1`; the web console
341
+ answers `502` for that instance's tasks and flags it on the home page instead
342
+ of showing an empty board.
343
+
344
+ ### Runtime files
345
+
346
+ There is no backlog file for Forgeo's own runtime files to sit beside, so
347
+ `backlog.lock`, `backlog.run`, `backlog.state.json`, `backlog.update.json` and
348
+ `runs.jsonl` go into `state_dir`, which defaults to the directory holding
349
+ `forgeo.yaml`. No snapshots are written: the document belongs to the remote
350
+ application, so rolling it back is that application's job, not Forgeo's.
@@ -22,7 +22,9 @@ paths), so `forgeo restart` is still used for those.
22
22
  | `interval_minutes` | `60` | How often Forgeo runs (≥ 1). |
23
23
  | `branch` | `main` | The single branch everything is committed to. |
24
24
  | `remote` | — | Remote to push to (e.g. `origin`); omit to only commit locally. |
25
- | `backlog` | `backlog.json` | The task backlog JSON. Keep it outside the repo if you can. |
25
+ | `backlog` | `backlog.json` | The task backlog: the path of a JSON file (keep it outside the repo if you can), or an `http(s)` URL serving the same document — see [a backlog over HTTP](backlog.md#a-backlog-over-http). |
26
+ | `state_dir` | — | Directory for Forgeo's runtime files (locks, run history, daemon state). Only meaningful with a backlog URL, where it defaults to the directory of `forgeo.yaml`. |
27
+ | `backlog_auth` | — | OAuth2 client credentials for a backlog URL that requires them (see [below](#backlog_auth)). |
26
28
  | `blocker_file` | `BLOCKER.md` | Where `BLOCKER.md` is written. Keep it outside the repo so it is never committed. |
27
29
  | `agent_command` | — | The coding agent: any shell command (string) or argv list. **Required.** |
28
30
  | `agent_timeout_seconds` | — | Optional: kill the agent after this many seconds (`null` = never). |
@@ -64,6 +66,49 @@ refactor_prompt: >
64
66
 
65
67
  ## Key details
66
68
 
69
+ ### `backlog_auth`
70
+
71
+ Credentials for a backlog URL behind an identity provider. Forgeo requests an
72
+ access token with the OAuth2 **client-credentials grant** and sends it as a
73
+ bearer on every backlog request, so it authenticates as a service rather than
74
+ as a person:
75
+
76
+ ```yaml
77
+ backlog: https://api.example.com/api/forgeo/backlog
78
+ backlog_auth:
79
+ token_url: https://keycloak.example.com/realms/dev/protocol/openid-connect/token
80
+ client_id: forgeo
81
+ client_secret_env: FORGEO_BACKLOG_CLIENT_SECRET
82
+ scope: forgeo-backlog # optional
83
+ timeout_seconds: 10 # optional
84
+ ```
85
+
86
+ | Key | Meaning |
87
+ | --- | --- |
88
+ | `token_url` | The provider's token endpoint. |
89
+ | `client_id` | The confidential client requesting the token. |
90
+ | `client_secret_env` | **Name of the environment variable** holding that client's secret. |
91
+ | `scope` | Optional scope requested with the token. |
92
+ | `timeout_seconds` | Timeout for the token request (default `10`). |
93
+
94
+ The secret itself is never a config value: `client_secret_env` names the
95
+ environment variable the daemon reads it from, so the secret stays out of
96
+ `forgeo.yaml` (which the web console serves to your browser) and out of any
97
+ copy or backup of it. A missing variable fails the cycle with a message naming
98
+ the variable.
99
+
100
+ Tokens are cached in memory and renewed shortly before they expire; nothing is
101
+ written to disk. If the endpoint rejects a token Forgeo believed to be valid
102
+ (HTTP 401/403), it requests a fresh one and retries the request once, so a key
103
+ rotation does not cost a cycle.
104
+
105
+ With Keycloak, this means a client with *Client authentication* on and
106
+ *Service accounts roles* enabled: the resulting `service-account-<client-id>`
107
+ user is what your backend authorizes.
108
+
109
+ `backlog_auth` is rejected when `backlog` is a file — that combination is
110
+ almost always a typo in the backlog value.
111
+
67
112
  ### `agent_command`
68
113
 
69
114
  Any shell command (string) or argv list. It is run with the repository as its
@@ -281,11 +326,13 @@ Manage instances with `forgeo instance add|rm|list` and `forgeo list` — see
281
326
  ## Per-instance isolation
282
327
 
283
328
  Each registered instance is fully independent: every instance owns its own
284
- **backlog** file, **logs** (`log_file`), **run history** (`runs.jsonl` next
285
- to the backlog), **locks** (`backlog.lock` and the per-iteration run lock),
286
- and a **`daemon.state.json`** with its live state. Because relative paths
287
- resolve against each config file's own directory, two configs in different
288
- directories can never share state.
329
+ **backlog**, **logs** (`log_file`), **run history** (`runs.jsonl`), **locks**
330
+ (`backlog.lock` and the per-iteration `backlog.run`), and a
331
+ **`backlog.state.json`** with its live state. Those runtime files sit next to
332
+ the backlog file, or — when the backlog is a URL — in `state_dir`, which
333
+ defaults to the directory of that instance's `forgeo.yaml`. Because relative
334
+ paths resolve against each config file's own directory, two configs in
335
+ different directories can never share state.
289
336
 
290
337
  The daemons bind no ports. The central dashboard (`forgeo web`, default port
291
338
  `8790`) reads every instance's data straight from its files, so it works
@@ -80,7 +80,9 @@ forgeo init --force # overwrite an existing forgeo.yaml
80
80
  The backlog is a plain JSON file (see [Backlog format](backlog.md)). Create
81
81
  the file configured as `backlog:` in your `forgeo.yaml` — by default
82
82
  `.forgeo/backlog.json`. Once Forgeo is running you can also add tasks
83
- from the [web console](web-console-api.md) — no file editing needed:
83
+ from the [web console](web-console-api.md) — no file editing needed. (If your
84
+ tasks already live in another application, `backlog:` also accepts an
85
+ [HTTP endpoint](backlog.md#a-backlog-over-http) instead of a file.)
84
86
 
85
87
  ```json
86
88
  {
@@ -102,11 +102,12 @@ forgeo.yaml ──► forgeo start (daemon)
102
102
  ## Where state lives
103
103
 
104
104
  - `forgeo.yaml` — the config (see [Configuration](configuration.md)).
105
- - `backlog.json` (configurable) — the task backlog (see
106
- [Backlog format](backlog.md)).
107
- - `backlog.json.bak`, `backlog.json.bak.1`, ... — rotating snapshots of the
108
- backlog, written before every agent run and on daemon startup so a bad
109
- write can always be rolled back (see [Backlog format](backlog.md)).
105
+ - `backlog.json` (configurable) — the task backlog, a file or an HTTP
106
+ endpoint (see [Backlog format](backlog.md)).
107
+ - `backlog.json.bak`, `backlog.json.bak.1`, ... — rotating snapshots of a
108
+ *file* backlog, written before every agent run and on daemon startup so a
109
+ bad write can always be rolled back (see [Backlog format](backlog.md)). A
110
+ URL backlog is owned by the remote application and is never snapshotted.
110
111
  - `BLOCKER.md` (configurable) — written when a human decision is needed; keep
111
112
  it outside the repo so it is never committed.
112
113
  - `forgeo.log` — rotating daemon log (5 MB × 3), also served over HTTP.
@@ -119,6 +120,10 @@ forgeo.yaml ──► forgeo start (daemon)
119
120
  token (`forgeo web --token`): when present, every `/api/*` route requires
120
121
  `Authorization: Bearer <token>`; with no file the dashboard stays open.
121
122
 
123
+ The three runtime files above (and `runs.jsonl`) sit next to the backlog file;
124
+ with a backlog URL they go in `state_dir`, which defaults to the directory
125
+ holding `forgeo.yaml`.
126
+
122
127
  ## Next steps
123
128
 
124
129
  - [Getting Started](getting-started.md) — install and run your first cycle.
@@ -6,7 +6,9 @@ just schedules cycles and writes its live state to `daemon.state.json`. The
6
6
  dashboard reads every registered instance's data straight from its files
7
7
  (`backlog.json`, `runs.jsonl`, `forgeo.log`, `BLOCKER.md`,
8
8
  `daemon.state.json`), so it works whether or not each instance's daemon is
9
- running.
9
+ running. An instance whose `backlog` is an HTTP endpoint is the exception: its
10
+ tasks are fetched from there, and an endpoint that cannot be reached answers
11
+ `502` rather than showing an empty board.
10
12
 
11
13
  ```bash
12
14
  forgeo web # default 0.0.0.0:8790, foreground
@@ -150,6 +152,12 @@ outcome, next run, and backlog counts.
150
152
  curl http://127.0.0.1:8790/api/instances
151
153
  ```
152
154
 
155
+ Each row also carries `backlog_error`: `null` normally, and the reason when
156
+ that instance's backlog could not be read (only possible for a backlog served
157
+ over HTTP). Its counts are zero in that case — the row reports a backlog it
158
+ could not reach, not an empty one. One unreachable endpoint never fails the
159
+ whole listing.
160
+
153
161
  ### `GET /api/instances/<name>/tasks`
154
162
 
155
163
  List every task in that instance's backlog, in creation order. Each task
@@ -180,6 +188,9 @@ curl http://127.0.0.1:8790/api/instances/my-repo/tasks
180
188
  ]
181
189
  ```
182
190
 
191
+ A backlog served over HTTP is fetched on each request; when the endpoint is
192
+ unreachable the response is `502` with the reason in `error`.
193
+
183
194
  ### `GET /api/instances/<name>/tasks/{id}`
184
195
 
185
196
  Fetch a single task by id.
@@ -464,9 +475,11 @@ a restart (via the **Restart** button or `POST .../restart`). Errors:
464
475
  `forgeo.yaml`) and is forced to the registered name — sending a different value
465
476
  is rejected. `telegram_bot_token` is not editable through the web console: an
466
477
  explicit change is rejected with `400`, and the current value is preserved when
467
- the field is omitted, so a partial payload never wipes it. Everything else
468
- `GET .../config` returns (including `agent_env`, which can carry credentials
469
- the agent needs) is editable.
478
+ the field is omitted, so a partial payload never wipes it. `backlog_auth` and
479
+ `state_dir` are likewise preserved when the payload omits them — the config
480
+ form does not render them, and a save must not drop what it never showed.
481
+ Everything else `GET .../config` returns (including `agent_env`, which can
482
+ carry credentials the agent needs) is editable.
470
483
 
471
484
  ### `POST /api/instances/<name>/start`, `/stop`, `/restart`
472
485
 
@@ -14,7 +14,7 @@ set -eu
14
14
 
15
15
  REPO_OWNER="lucaGazzola"
16
16
  REPO_NAME="forgeo"
17
- DEFAULT_VERSION="0.5.0"
17
+ DEFAULT_VERSION="0.6.0"
18
18
  MIN_PYTHON="3.11"
19
19
  PYPI_PACKAGE="forgeo-cli"
20
20
  PREFIX="${FORGEO_PREFIX:-${HOME:-}/.local}"
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "forgeo-cli"
7
- version = "0.5.0"
7
+ version = "0.6.0"
8
8
  description = "A scheduled software forgeo: executes backlog tasks on main, refactors when idle, and writes BLOCKER.md when it needs human input."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -7,4 +7,4 @@ try:
7
7
 
8
8
  __version__ = _pkg_version("forgeo-cli")
9
9
  except PackageNotFoundError: # standalone binary: no installed package metadata
10
- __version__ = "0.5.0"
10
+ __version__ = "0.6.0"
@@ -1,16 +1,23 @@
1
- """The backlog: a single human-readable JSON file of tasks.
1
+ """The backlog: the list of tasks Forgeo works through.
2
2
 
3
3
  Forgeo pulls the oldest ``OPEN`` task whose dependencies are all ``COMPLETED``
4
4
  from here (an optional ``run_at`` one-shot schedule overrides the oldest-first
5
- order). Edit this file directly to add, remove, or reopen tasks (e.g. set a
6
- ``BLOCKED`` task back to ``OPEN`` once the human input has been provided).
7
- Writes are atomic and serialized through an asyncio lock.
5
+ order). The tasks live in a single JSON document — ``{"tasks": [...]}`` — which
6
+ is either a local file you can edit by hand (add, remove, or set a ``BLOCKED``
7
+ task back to ``OPEN`` once you have provided the input it needed) or an HTTP
8
+ endpoint owned by another application, see :mod:`forgeo.backlog_http`.
8
9
 
9
- The backlog is the single source of truth, so it is guarded against bad
10
+ Everything above the storage layer is shared: :class:`BacklogStore` holds all
11
+ the task manipulation and the asyncio lock that serializes writes, and leaves
12
+ exactly two operations to its subclasses — load the document, store the
13
+ document.
14
+
15
+ A file backlog is the single source of truth, so it is guarded against bad
10
16
  writes: before every agent run (and on daemon startup) a rotating snapshot is
11
17
  written next to it (``backlog.json.bak``, ``backlog.json.bak.1``, ...) and a
12
18
  read that finds a corrupt store restores the newest valid snapshot in place
13
- instead of silently starting from an empty one.
19
+ instead of silently starting from an empty one. A URL backlog is owned by the
20
+ remote application, which keeps its own history, so it is never snapshotted.
14
21
  """
15
22
 
16
23
  from __future__ import annotations
@@ -19,6 +26,7 @@ import asyncio
19
26
  import json
20
27
  import logging
21
28
  import os
29
+ from abc import ABC, abstractmethod
22
30
  from collections import Counter
23
31
  from collections.abc import Callable
24
32
  from datetime import UTC, datetime
@@ -28,7 +36,7 @@ from typing import Any
28
36
  from pydantic import ValidationError
29
37
 
30
38
  from forgeo.io import atomic_write_text
31
- from forgeo.models import Task, TaskStatus
39
+ from forgeo.models import ForgeoConfig, Task, TaskStatus
32
40
 
33
41
  logger = logging.getLogger(__name__)
34
42
 
@@ -168,23 +176,52 @@ def snapshot_paths_for(
168
176
  ]
169
177
 
170
178
 
171
- class JSONBacklog:
172
- """A backlog stored in a single JSON document on disk."""
179
+ class BacklogUnavailableError(RuntimeError):
180
+ """The backlog document could not be retrieved or stored.
173
181
 
174
- def __init__(
175
- self,
176
- path: str | Path,
177
- *,
178
- snapshot_count: int = DEFAULT_SNAPSHOT_COUNT,
179
- ) -> None:
180
- self.path = Path(path)
181
- self.snapshot_count = max(0, snapshot_count)
182
+ Raised by a storage backend that cannot reach the backlog at all, which
183
+ is categorically different from a backlog that holds no tasks: callers
184
+ must never let the two look the same.
185
+ """
186
+
187
+
188
+ def normalize_store(data: Any) -> dict[str, Any]:
189
+ """Coerce a decoded backlog document into the internal store shape.
190
+
191
+ Deliberately forgiving about *shape* (anything that is not an object with
192
+ a list of tasks reads as an empty backlog) and silent about it, because
193
+ the document is hand-editable. It says nothing about whether the document
194
+ could be *retrieved*: a storage backend must raise for that, so an
195
+ unreachable backlog is never mistaken for an empty one.
196
+ """
197
+ if not isinstance(data, dict):
198
+ return {"tasks": []}
199
+ tasks = data.get("tasks")
200
+ return {"tasks": tasks if isinstance(tasks, list) else []}
201
+
202
+
203
+ class BacklogStore(ABC):
204
+ """A backlog of tasks, independent of where the document is kept.
205
+
206
+ Subclasses implement :meth:`_read` and :meth:`_write`; everything else —
207
+ the task transitions, their validation, and the lock that serializes
208
+ concurrent mutations within one process — lives here.
209
+ """
210
+
211
+ def __init__(self) -> None:
182
212
  self._lock = asyncio.Lock()
183
213
 
184
- @property
185
- def snapshot_paths(self) -> list[Path]:
186
- """The rotating snapshot paths for this backlog, newest (``.bak``) first."""
187
- return snapshot_paths_for(self.path, count=self.snapshot_count)
214
+ @abstractmethod
215
+ async def _read(self) -> dict[str, Any]:
216
+ """Load the whole document as ``{"tasks": [...]}``.
217
+
218
+ Must raise when the document cannot be retrieved, and return an empty
219
+ store only when the backlog genuinely holds no tasks.
220
+ """
221
+
222
+ @abstractmethod
223
+ async def _write(self, store: dict[str, Any]) -> None:
224
+ """Persist the whole document; must raise when it cannot be stored."""
188
225
 
189
226
  async def list_tasks(self) -> list[Task]:
190
227
  """Return all tasks, in the order they were created."""
@@ -197,26 +234,13 @@ class JSONBacklog:
197
234
  return oldest_open_task(await self.list_tasks(), now=now)
198
235
 
199
236
  async def snapshot(self) -> None:
200
- """Copy the current store to a rotating snapshot (``backlog.json.bak``).
237
+ """Take a rollback copy of the backlog before it is written to.
201
238
 
202
- Called before every agent run and on daemon startup so a bad write to
203
- the backlog (a hostile agent, a half-written file, an accidental
204
- manual edit) can always be rolled back to the newest valid snapshot.
205
- The newest snapshot is always ``<backlog>.bak``; existing snapshots
206
- are rotated up by one index and the oldest beyond ``snapshot_count``
207
- is dropped. A missing backlog is a no-op and a failure is logged
208
- without raising, so snapshotting can never break a cycle.
239
+ A no-op by default: only a backend that *owns* the document can
240
+ meaningfully snapshot it. :class:`JSONBacklog` overrides this; a
241
+ backlog served over HTTP belongs to the remote application, which
242
+ keeps its own history, so Forgeo does not shadow-copy it.
209
243
  """
210
- if not self.path.exists() or self.snapshot_count <= 0:
211
- return
212
- async with self._lock:
213
- try:
214
- store = await self._read()
215
- self._rotate_snapshots()
216
- self._write_snapshot(store)
217
- logger.info("Backlog snapshot written to %s", self.snapshot_paths[0])
218
- except OSError as exc:
219
- logger.warning("Could not snapshot backlog at %s: %s", self.path, exc)
220
244
 
221
245
  async def get_task(self, task_id: str) -> Task | None:
222
246
  """Return a task by id, or ``None`` if it does not exist."""
@@ -418,7 +442,7 @@ class JSONBacklog:
418
442
  return task
419
443
 
420
444
  # ------------------------------------------------------------------ #
421
- # Internal persistence helpers #
445
+ # Internal task helpers #
422
446
  # ------------------------------------------------------------------ #
423
447
 
424
448
  async def _update_entry(
@@ -449,6 +473,60 @@ class JSONBacklog:
449
473
  return entry
450
474
  return None
451
475
 
476
+ @staticmethod
477
+ def _to_task(entry: dict[str, Any]) -> Task:
478
+ """Validate a stored dictionary back into a Task, skipping corrupt rows."""
479
+ try:
480
+ return Task.model_validate(entry)
481
+ except ValidationError:
482
+ return Task(
483
+ id=str(entry.get("id", "<unknown>")),
484
+ title="<unparsable task>",
485
+ description="<unparsable task>",
486
+ status=TaskStatus.FAILED,
487
+ )
488
+
489
+
490
+ class JSONBacklog(BacklogStore):
491
+ """A backlog stored in a single JSON document on disk."""
492
+
493
+ def __init__(
494
+ self,
495
+ path: str | Path,
496
+ *,
497
+ snapshot_count: int = DEFAULT_SNAPSHOT_COUNT,
498
+ ) -> None:
499
+ super().__init__()
500
+ self.path = Path(path)
501
+ self.snapshot_count = max(0, snapshot_count)
502
+
503
+ @property
504
+ def snapshot_paths(self) -> list[Path]:
505
+ """The rotating snapshot paths for this backlog, newest (``.bak``) first."""
506
+ return snapshot_paths_for(self.path, count=self.snapshot_count)
507
+
508
+ async def snapshot(self) -> None:
509
+ """Copy the current store to a rotating snapshot (``backlog.json.bak``).
510
+
511
+ Called before every agent run and on daemon startup so a bad write to
512
+ the backlog (a hostile agent, a half-written file, an accidental
513
+ manual edit) can always be rolled back to the newest valid snapshot.
514
+ The newest snapshot is always ``<backlog>.bak``; existing snapshots
515
+ are rotated up by one index and the oldest beyond ``snapshot_count``
516
+ is dropped. A missing backlog is a no-op and a failure is logged
517
+ without raising, so snapshotting can never break a cycle.
518
+ """
519
+ if not self.path.exists() or self.snapshot_count <= 0:
520
+ return
521
+ async with self._lock:
522
+ try:
523
+ store = await self._read()
524
+ self._rotate_snapshots()
525
+ self._write_snapshot(store)
526
+ logger.info("Backlog snapshot written to %s", self.snapshot_paths[0])
527
+ except OSError as exc:
528
+ logger.warning("Could not snapshot backlog at %s: %s", self.path, exc)
529
+
452
530
  async def _read(self) -> dict[str, Any]:
453
531
  """Load the store from disk, tolerating a missing or corrupt file.
454
532
 
@@ -540,15 +618,13 @@ class JSONBacklog:
540
618
  json.dumps(store, indent=2, ensure_ascii=False) + "\n",
541
619
  )
542
620
 
543
- @staticmethod
544
- def _to_task(entry: dict[str, Any]) -> Task:
545
- """Validate a stored dictionary back into a Task, skipping corrupt rows."""
546
- try:
547
- return Task.model_validate(entry)
548
- except ValidationError:
549
- return Task(
550
- id=str(entry.get("id", "<unknown>")),
551
- title="<unparsable task>",
552
- description="<unparsable task>",
553
- status=TaskStatus.FAILED,
554
- )
621
+
622
+ def open_backlog(config: ForgeoConfig) -> BacklogStore:
623
+ """The backlog store ``config`` points at: a JSON file or an HTTP endpoint."""
624
+ if config.backlog_is_url:
625
+ # Imported here: the HTTP backend builds on BacklogStore, so importing
626
+ # it at module level would close an import cycle.
627
+ from forgeo.backlog_http import HttpBacklog
628
+
629
+ return HttpBacklog(str(config.backlog), auth=config.backlog_auth)
630
+ return JSONBacklog(Path(config.backlog))