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.
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/CHANGELOG.md +35 -1
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/PKG-INFO +8 -5
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/README.md +7 -4
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/docs/backlog.md +73 -7
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/docs/configuration.md +53 -6
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/docs/getting-started.md +3 -1
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/docs/index.md +10 -5
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/docs/web-console-api.md +17 -4
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/install.sh +1 -1
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/pyproject.toml +1 -1
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/__init__.py +1 -1
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/backlog.py +128 -52
- forgeo_cli-0.6.0/src/forgeo/backlog_http.py +149 -0
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/central.py +87 -36
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/cli.py +26 -14
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/config.py +18 -3
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/daemon.py +10 -3
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/daemon_control.py +4 -8
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/forgeo.py +20 -8
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/instances.py +2 -1
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/models.py +101 -2
- forgeo_cli-0.6.0/src/forgeo/oauth.py +151 -0
- forgeo_cli-0.6.0/src/forgeo/paths.py +80 -0
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/runs.py +3 -8
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/update.py +0 -5
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/validate.py +38 -10
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/web/central/central.js +1 -1
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/tests/conftest.py +72 -2
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/tests/test_backlog.py +4 -4
- forgeo_cli-0.6.0/tests/test_backlog_http.py +309 -0
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/tests/test_cli.py +31 -6
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/tests/test_install.py +1 -1
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/tests/test_models.py +86 -0
- forgeo_cli-0.6.0/tests/test_oauth.py +186 -0
- forgeo_cli-0.6.0/tests/test_paths.py +98 -0
- forgeo_cli-0.6.0/tests/test_remote_backlog_cycle.py +89 -0
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/tests/test_runs.py +17 -16
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/tests/test_update.py +7 -12
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/tests/test_web.py +108 -1
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/www/index.html +24 -0
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/.github/workflows/ci.yml +0 -0
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/.gitignore +0 -0
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/CONTRIBUTING.md +0 -0
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/LICENSE +0 -0
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/config/nginx-forgeo.conf +0 -0
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/docs/agent-contract.md +0 -0
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/docs/cli-reference.md +0 -0
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/docs/img/console.png +0 -0
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/docs/img/logo.png +0 -0
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/docs/img/title.svg +0 -0
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/forgeo.spec +0 -0
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/mkdocs.yml +0 -0
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/scripts/__init__.py +0 -0
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/scripts/render_homebrew_formula.py +0 -0
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/__main__.py +0 -0
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/agent.py +0 -0
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/git.py +0 -0
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/io.py +0 -0
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/notify.py +0 -0
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/setup.py +0 -0
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/web/central/central.css +0 -0
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/web/central/index.html +0 -0
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/web/central/instance.html +0 -0
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/web/central/login.html +0 -0
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/web/style.css +0 -0
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/src/forgeo/web_common.py +0 -0
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/tests/test_agent.py +0 -0
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/tests/test_daemon.py +0 -0
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/tests/test_factory.py +0 -0
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/tests/test_git.py +0 -0
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/tests/test_instances.py +0 -0
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/tests/test_io.py +0 -0
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/tests/test_render_homebrew.py +0 -0
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/tests/test_setup.py +0 -0
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/tests/test_web_common.py +0 -0
- {forgeo_cli-0.5.0 → forgeo_cli-0.6.0}/tests/test_web_lock.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
4
|
-
`backlog:` points in [forgeo.yaml](configuration.md) —
|
|
5
|
-
`backlog.json` at the project root,
|
|
6
|
-
|
|
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
|
|
262
|
-
|
|
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
|
|
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
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
|
106
|
-
[Backlog format](backlog.md)).
|
|
107
|
-
- `backlog.json.bak`, `backlog.json.bak.1`, ... — rotating snapshots of
|
|
108
|
-
backlog, written before every agent run and on daemon startup so a
|
|
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.
|
|
468
|
-
`
|
|
469
|
-
|
|
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
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "forgeo-cli"
|
|
7
|
-
version = "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"
|
|
@@ -1,16 +1,23 @@
|
|
|
1
|
-
"""The backlog:
|
|
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).
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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
|
|
172
|
-
"""
|
|
179
|
+
class BacklogUnavailableError(RuntimeError):
|
|
180
|
+
"""The backlog document could not be retrieved or stored.
|
|
173
181
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
@
|
|
185
|
-
def
|
|
186
|
-
"""
|
|
187
|
-
|
|
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
|
-
"""
|
|
237
|
+
"""Take a rollback copy of the backlog before it is written to.
|
|
201
238
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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))
|