navig-agentctl 0.2.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.
- navig_agentctl-0.2.0/PKG-INFO +100 -0
- navig_agentctl-0.2.0/README.md +74 -0
- navig_agentctl-0.2.0/navig_agentctl/__init__.py +9 -0
- navig_agentctl-0.2.0/navig_agentctl/commands.py +203 -0
- navig_agentctl-0.2.0/navig_agentctl/deck_routes.py +229 -0
- navig_agentctl-0.2.0/navig_agentctl/plugin.py +102 -0
- navig_agentctl-0.2.0/navig_agentctl/registry.py +295 -0
- navig_agentctl-0.2.0/navig_agentctl/snapshots.py +331 -0
- navig_agentctl-0.2.0/navig_agentctl.egg-info/PKG-INFO +100 -0
- navig_agentctl-0.2.0/navig_agentctl.egg-info/SOURCES.txt +18 -0
- navig_agentctl-0.2.0/navig_agentctl.egg-info/dependency_links.txt +1 -0
- navig_agentctl-0.2.0/navig_agentctl.egg-info/entry_points.txt +5 -0
- navig_agentctl-0.2.0/navig_agentctl.egg-info/requires.txt +6 -0
- navig_agentctl-0.2.0/navig_agentctl.egg-info/top_level.txt +1 -0
- navig_agentctl-0.2.0/pyproject.toml +55 -0
- navig_agentctl-0.2.0/setup.cfg +4 -0
- navig_agentctl-0.2.0/tests/test_deck_routes.py +229 -0
- navig_agentctl-0.2.0/tests/test_registry.py +171 -0
- navig_agentctl-0.2.0/tests/test_snapshots.py +49 -0
- navig_agentctl-0.2.0/tests/test_version.py +25 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: navig-agentctl
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Agent Control Center (devops section): sessions, worktrees & tasks — one read-only view, then a controller.
|
|
5
|
+
Author-email: NAVIG Development Team <opensource@navig.run>
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://navig.run
|
|
8
|
+
Project-URL: Documentation, https://navig.run/docs
|
|
9
|
+
Keywords: navig,cli,agent,orchestration,plugin,devops,worktrees,sessions
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Software Development
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: navig>=0.1.0
|
|
22
|
+
Requires-Dist: typer>=0.14.0
|
|
23
|
+
Requires-Dist: rich>=13.7.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
26
|
+
|
|
27
|
+
# navig-agentctl
|
|
28
|
+
|
|
29
|
+
<!-- navig:plugin-card:start -->
|
|
30
|
+
<!-- Generated by scripts/gen_plugin_catalog.py — edit the plugin's pyproject.toml, not this block. -->
|
|
31
|
+
|
|
32
|
+
> Agent Control Center (devops section): sessions, worktrees & tasks — one read-only view, then a controller.
|
|
33
|
+
|
|
34
|
+
**A navig extension** — installing it pulls in navig and adds the commands below · v0.2.0 · not on PyPI yet (install from source below)
|
|
35
|
+
|
|
36
|
+
**Install**
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install "git+https://github.com/navig-run/plugins#subdirectory=navig-agentctl"
|
|
40
|
+
navig agentctl --help
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
It is not a separate program: it needs navig, and installing it installs navig for you.
|
|
44
|
+
|
|
45
|
+
<details><summary>Commands</summary>
|
|
46
|
+
|
|
47
|
+
| Command | What it does |
|
|
48
|
+
|---|---|
|
|
49
|
+
| `navig agentctl sessions` | Live agent sessions (who is working where, idle or active). |
|
|
50
|
+
| `navig agentctl status` | One-line overview: sessions, worktrees (cwd repo), tasks. |
|
|
51
|
+
| `navig agentctl tasks` | Unified tasks from the board and the work queue. |
|
|
52
|
+
| `navig agentctl worktrees` | Git worktrees of a repo: branch, ahead/behind, dirty, last commit. |
|
|
53
|
+
|
|
54
|
+
</details>
|
|
55
|
+
|
|
56
|
+
<!-- navig:plugin-card:end -->
|
|
57
|
+
|
|
58
|
+
**Agent Control Center** — the devops section for managing AI coding agents across
|
|
59
|
+
sessions, git worktrees and task records. Local-first, provider-agnostic, read-only
|
|
60
|
+
in Phase 1.
|
|
61
|
+
|
|
62
|
+
## Phases
|
|
63
|
+
|
|
64
|
+
| Phase | Name | What it does |
|
|
65
|
+
|---|---|---|
|
|
66
|
+
| 1 | Observer | Discover live agent sessions · list worktrees + conflict radar · unified task read-model (board + work queue). **Changes nothing.** |
|
|
67
|
+
| **2a (this)** | Heartbeat | `POST /api/deck/agents/heartbeat` — the extension annotates workspace/editor/roster context; daemon-owned `state`/`pid`/`reason` are never written. |
|
|
68
|
+
| 2b | Controller | Launch CLI agents into worktrees, assign task specs, pause/terminate owned processes, collect handoff reports, detect ownership conflicts. |
|
|
69
|
+
| 3 | Orchestrator | Schedule by dependency, allocate by capacity, trigger reviews, recover interrupted work — production deploys always ask a human first. |
|
|
70
|
+
|
|
71
|
+
## CLI
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
navig agentctl sessions [--project P] [--json]
|
|
75
|
+
navig agentctl worktrees [--project PATH] [--conflicts] [--json]
|
|
76
|
+
navig agentctl tasks [--source board|work] [--json]
|
|
77
|
+
navig agentctl status [--json]
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Deck routes (the contract)
|
|
81
|
+
|
|
82
|
+
All under `/api/deck/agents`, gated on the `agentctl` module. GET-only in Phase 1:
|
|
83
|
+
|
|
84
|
+
- `GET /api/deck/agents/sessions` — live sessions (from `navig procs` semantics) merged with the persistent registry.
|
|
85
|
+
- `GET /api/deck/agents/worktrees?project=&conflicts=` — worktrees + pairwise conflict radar.
|
|
86
|
+
- `GET /api/deck/agents/tasks` — board cards + work items, one normalised shape.
|
|
87
|
+
|
|
88
|
+
The OS app (Control Center tile) and navig-dev (VS Code tree views) are both
|
|
89
|
+
consumers of this one contract.
|
|
90
|
+
|
|
91
|
+
## Store
|
|
92
|
+
|
|
93
|
+
`~/.navig/data/store/agentctl.db` — session ledger (who was active where, with
|
|
94
|
+
state-change history) and task bindings (which session owns which task). Task
|
|
95
|
+
truth stays in `board.db` / `work.db`; this ledger only answers ownership.
|
|
96
|
+
|
|
97
|
+
## Non-negotiables
|
|
98
|
+
|
|
99
|
+
Local-first · provider-agnostic adapters · explicit process ownership · safe git
|
|
100
|
+
handling · no duplicate task execution · **no automatic merge or deployment**.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# navig-agentctl
|
|
2
|
+
|
|
3
|
+
<!-- navig:plugin-card:start -->
|
|
4
|
+
<!-- Generated by scripts/gen_plugin_catalog.py — edit the plugin's pyproject.toml, not this block. -->
|
|
5
|
+
|
|
6
|
+
> Agent Control Center (devops section): sessions, worktrees & tasks — one read-only view, then a controller.
|
|
7
|
+
|
|
8
|
+
**A navig extension** — installing it pulls in navig and adds the commands below · v0.2.0 · not on PyPI yet (install from source below)
|
|
9
|
+
|
|
10
|
+
**Install**
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install "git+https://github.com/navig-run/plugins#subdirectory=navig-agentctl"
|
|
14
|
+
navig agentctl --help
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
It is not a separate program: it needs navig, and installing it installs navig for you.
|
|
18
|
+
|
|
19
|
+
<details><summary>Commands</summary>
|
|
20
|
+
|
|
21
|
+
| Command | What it does |
|
|
22
|
+
|---|---|
|
|
23
|
+
| `navig agentctl sessions` | Live agent sessions (who is working where, idle or active). |
|
|
24
|
+
| `navig agentctl status` | One-line overview: sessions, worktrees (cwd repo), tasks. |
|
|
25
|
+
| `navig agentctl tasks` | Unified tasks from the board and the work queue. |
|
|
26
|
+
| `navig agentctl worktrees` | Git worktrees of a repo: branch, ahead/behind, dirty, last commit. |
|
|
27
|
+
|
|
28
|
+
</details>
|
|
29
|
+
|
|
30
|
+
<!-- navig:plugin-card:end -->
|
|
31
|
+
|
|
32
|
+
**Agent Control Center** — the devops section for managing AI coding agents across
|
|
33
|
+
sessions, git worktrees and task records. Local-first, provider-agnostic, read-only
|
|
34
|
+
in Phase 1.
|
|
35
|
+
|
|
36
|
+
## Phases
|
|
37
|
+
|
|
38
|
+
| Phase | Name | What it does |
|
|
39
|
+
|---|---|---|
|
|
40
|
+
| 1 | Observer | Discover live agent sessions · list worktrees + conflict radar · unified task read-model (board + work queue). **Changes nothing.** |
|
|
41
|
+
| **2a (this)** | Heartbeat | `POST /api/deck/agents/heartbeat` — the extension annotates workspace/editor/roster context; daemon-owned `state`/`pid`/`reason` are never written. |
|
|
42
|
+
| 2b | Controller | Launch CLI agents into worktrees, assign task specs, pause/terminate owned processes, collect handoff reports, detect ownership conflicts. |
|
|
43
|
+
| 3 | Orchestrator | Schedule by dependency, allocate by capacity, trigger reviews, recover interrupted work — production deploys always ask a human first. |
|
|
44
|
+
|
|
45
|
+
## CLI
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
navig agentctl sessions [--project P] [--json]
|
|
49
|
+
navig agentctl worktrees [--project PATH] [--conflicts] [--json]
|
|
50
|
+
navig agentctl tasks [--source board|work] [--json]
|
|
51
|
+
navig agentctl status [--json]
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Deck routes (the contract)
|
|
55
|
+
|
|
56
|
+
All under `/api/deck/agents`, gated on the `agentctl` module. GET-only in Phase 1:
|
|
57
|
+
|
|
58
|
+
- `GET /api/deck/agents/sessions` — live sessions (from `navig procs` semantics) merged with the persistent registry.
|
|
59
|
+
- `GET /api/deck/agents/worktrees?project=&conflicts=` — worktrees + pairwise conflict radar.
|
|
60
|
+
- `GET /api/deck/agents/tasks` — board cards + work items, one normalised shape.
|
|
61
|
+
|
|
62
|
+
The OS app (Control Center tile) and navig-dev (VS Code tree views) are both
|
|
63
|
+
consumers of this one contract.
|
|
64
|
+
|
|
65
|
+
## Store
|
|
66
|
+
|
|
67
|
+
`~/.navig/data/store/agentctl.db` — session ledger (who was active where, with
|
|
68
|
+
state-change history) and task bindings (which session owns which task). Task
|
|
69
|
+
truth stays in `board.db` / `work.db`; this ledger only answers ownership.
|
|
70
|
+
|
|
71
|
+
## Non-negotiables
|
|
72
|
+
|
|
73
|
+
Local-first · provider-agnostic adapters · explicit process ownership · safe git
|
|
74
|
+
handling · no duplicate task execution · **no automatic merge or deployment**.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""navig-agentctl — the Agent Control Center (devops section).
|
|
2
|
+
|
|
3
|
+
Phase 1 (Observer): a read-only view over what is already true — live agent
|
|
4
|
+
sessions, git worktrees and task records — exposed as deck routes, a CLI
|
|
5
|
+
(``navig agentctl``) and the Control Center OS tile. It never launches,
|
|
6
|
+
stops or merges anything; that is Phase 2+.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""``navig agentctl`` — the Agent Control Center from the terminal.
|
|
2
|
+
|
|
3
|
+
Read-only Observer verbs mirroring the deck routes. Tables follow the house
|
|
4
|
+
style (Rich, one wrappable column, semantic glyphs); anything a script might
|
|
5
|
+
parse also offers ``--json``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
agentctl_app = typer.Typer(
|
|
16
|
+
name="agentctl",
|
|
17
|
+
help="Agent Control Center: sessions, worktrees & tasks (read-only).",
|
|
18
|
+
no_args_is_help=True,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _print_json(data: Any) -> None:
|
|
23
|
+
typer.echo(json.dumps(data, indent=2, default=str))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _table(headers: list[str], rows: list[list[str]], no_wrap: set[int] | None = None):
|
|
27
|
+
from navig.console_helper import Table, console
|
|
28
|
+
|
|
29
|
+
table = Table(box=None, show_header=True, padding=(0, 2))
|
|
30
|
+
nw = no_wrap or set()
|
|
31
|
+
for i, h in enumerate(headers):
|
|
32
|
+
table.add_column(h, no_wrap=i in nw)
|
|
33
|
+
for row in rows:
|
|
34
|
+
table.add_row(*row)
|
|
35
|
+
console.print(table)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _state_glyph(state: str) -> str:
|
|
39
|
+
return {
|
|
40
|
+
"active": "[green]● active[/green]",
|
|
41
|
+
"idle": "[dim]○ idle[/dim]",
|
|
42
|
+
"young": "[cyan]◐ young[/cyan]",
|
|
43
|
+
"stale": "[red]✗ stale[/red]",
|
|
44
|
+
}.get(state, f"[dim]? {state or 'unknown'}[/dim]")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@agentctl_app.command("sessions")
|
|
48
|
+
def sessions_cmd(
|
|
49
|
+
project: str = typer.Option("", "--project", "-p", help="Filter by project label substring."),
|
|
50
|
+
json_out: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
51
|
+
):
|
|
52
|
+
"""Live agent sessions (who is working where, idle or active)."""
|
|
53
|
+
from navig_agentctl.snapshots import session_snapshot
|
|
54
|
+
|
|
55
|
+
rows = session_snapshot()
|
|
56
|
+
if project:
|
|
57
|
+
rows = [r for r in rows if project.lower() in (r.get("project") or "").lower()]
|
|
58
|
+
if json_out:
|
|
59
|
+
_print_json(rows)
|
|
60
|
+
return
|
|
61
|
+
if not rows:
|
|
62
|
+
typer.echo("No agent sessions found.")
|
|
63
|
+
return
|
|
64
|
+
_table(
|
|
65
|
+
["State", "Session", "Project", "Owner", "Mem", "Reason"],
|
|
66
|
+
[
|
|
67
|
+
[
|
|
68
|
+
_state_glyph(r["state"]),
|
|
69
|
+
(r["session_id"] or "")[:12],
|
|
70
|
+
(r.get("project") or "")[:28],
|
|
71
|
+
(r.get("owner") or "")[:16],
|
|
72
|
+
f"{r.get('mem_mb') or 0:.0f}MB",
|
|
73
|
+
(r.get("reason") or "")[:40],
|
|
74
|
+
]
|
|
75
|
+
for r in rows
|
|
76
|
+
],
|
|
77
|
+
no_wrap={0, 1, 4},
|
|
78
|
+
)
|
|
79
|
+
typer.echo(f"\n{len(rows)} session(s). Next: navig agentctl worktrees --project <path>")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@agentctl_app.command("worktrees")
|
|
83
|
+
def worktrees_cmd(
|
|
84
|
+
project: str = typer.Option(".", "--project", "-p", help="Repo path (default: current directory)."),
|
|
85
|
+
conflicts: bool = typer.Option(False, "--conflicts", help="Also run the pairwise conflict radar."),
|
|
86
|
+
json_out: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
87
|
+
):
|
|
88
|
+
"""Git worktrees of a repo: branch, ahead/behind, dirty, last commit."""
|
|
89
|
+
from navig_agentctl.snapshots import conflict_pairs, worktree_snapshot
|
|
90
|
+
|
|
91
|
+
# Typed paths follow the operator: resolve against the invocation cwd, never
|
|
92
|
+
# the process cwd (navig chdirs into the active space).
|
|
93
|
+
from navig.platform.paths import resolve_user_path
|
|
94
|
+
|
|
95
|
+
root = resolve_user_path(project)
|
|
96
|
+
if not root.exists():
|
|
97
|
+
typer.echo(f"Path not found: {root}")
|
|
98
|
+
raise typer.Exit(1)
|
|
99
|
+
wts = worktree_snapshot(root)
|
|
100
|
+
pairs = conflict_pairs(root) if conflicts else []
|
|
101
|
+
if json_out:
|
|
102
|
+
_print_json({"project": str(root), "worktrees": wts, "conflicts": pairs})
|
|
103
|
+
return
|
|
104
|
+
if not wts:
|
|
105
|
+
typer.echo(f"No worktrees found under {root}")
|
|
106
|
+
return
|
|
107
|
+
_table(
|
|
108
|
+
["Worktree", "Branch", "±", "Dirty", "Last commit"],
|
|
109
|
+
[
|
|
110
|
+
[
|
|
111
|
+
w["name"][:28],
|
|
112
|
+
(w["branch"] or "detached")[:24],
|
|
113
|
+
f"+{w['ahead']}/-{w['behind']}",
|
|
114
|
+
"[yellow]●[/yellow]" if w["dirty"] else "[dim]○[/dim]",
|
|
115
|
+
(w["last_commit"]["subject"] or "")[:36],
|
|
116
|
+
]
|
|
117
|
+
for w in wts
|
|
118
|
+
],
|
|
119
|
+
no_wrap={2, 3},
|
|
120
|
+
)
|
|
121
|
+
if conflicts:
|
|
122
|
+
if not pairs:
|
|
123
|
+
typer.echo("\n[green]No merge conflicts across worktrees.[/green]")
|
|
124
|
+
else:
|
|
125
|
+
_table(
|
|
126
|
+
["A", "B", "Status"],
|
|
127
|
+
[
|
|
128
|
+
[p.get("a", "")[:18], p.get("b", "")[:18], p.get("status", "")[:20]]
|
|
129
|
+
for p in pairs
|
|
130
|
+
],
|
|
131
|
+
no_wrap={2},
|
|
132
|
+
)
|
|
133
|
+
typer.echo(f"\n{len(wts)} worktree(s). Next: navig agentctl tasks")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@agentctl_app.command("tasks")
|
|
137
|
+
def tasks_cmd(
|
|
138
|
+
source: str = typer.Option("", "--source", "-s", help="Filter: board | work."),
|
|
139
|
+
json_out: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
140
|
+
):
|
|
141
|
+
"""Unified tasks from the board and the work queue."""
|
|
142
|
+
from navig_agentctl.snapshots import task_snapshot
|
|
143
|
+
|
|
144
|
+
rows = task_snapshot()
|
|
145
|
+
if source:
|
|
146
|
+
rows = [r for r in rows if r.get("source") == source]
|
|
147
|
+
if json_out:
|
|
148
|
+
_print_json(rows)
|
|
149
|
+
return
|
|
150
|
+
if not rows:
|
|
151
|
+
typer.echo("No task records found.")
|
|
152
|
+
return
|
|
153
|
+
_table(
|
|
154
|
+
["Ref", "Source", "Status", "Title", "Owner"],
|
|
155
|
+
[
|
|
156
|
+
[
|
|
157
|
+
r["ref"][:26],
|
|
158
|
+
r["source"],
|
|
159
|
+
(r["status"] or "")[:10],
|
|
160
|
+
(r["title"] or "")[:40],
|
|
161
|
+
(r["owner"] or "")[:16],
|
|
162
|
+
]
|
|
163
|
+
for r in rows
|
|
164
|
+
],
|
|
165
|
+
no_wrap={0, 1, 2},
|
|
166
|
+
)
|
|
167
|
+
typer.echo(f"\n{len(rows)} task(s). Next: navig agentctl sessions")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@agentctl_app.command("status")
|
|
171
|
+
def status_cmd(
|
|
172
|
+
json_out: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
173
|
+
):
|
|
174
|
+
"""One-line overview: sessions, worktrees (cwd repo), tasks."""
|
|
175
|
+
from navig_agentctl.snapshots import session_snapshot, task_snapshot, worktree_snapshot
|
|
176
|
+
|
|
177
|
+
from navig.platform.paths import invocation_cwd
|
|
178
|
+
|
|
179
|
+
sessions = session_snapshot()
|
|
180
|
+
tasks = task_snapshot()
|
|
181
|
+
wts = worktree_snapshot(invocation_cwd())
|
|
182
|
+
active = sum(1 for s in sessions if s["state"] == "active")
|
|
183
|
+
blocked = sum(1 for t in tasks if t.get("gate") == "waiting")
|
|
184
|
+
summary = {
|
|
185
|
+
"sessions_total": len(sessions),
|
|
186
|
+
"sessions_active": active,
|
|
187
|
+
"worktrees": len(wts),
|
|
188
|
+
"tasks_total": len(tasks),
|
|
189
|
+
"tasks_waiting": blocked,
|
|
190
|
+
}
|
|
191
|
+
if json_out:
|
|
192
|
+
_print_json(summary)
|
|
193
|
+
return
|
|
194
|
+
_table(
|
|
195
|
+
["Metric", "Value"],
|
|
196
|
+
[
|
|
197
|
+
["Sessions (active / total)", f"{active} / {len(sessions)}"],
|
|
198
|
+
["Worktrees (this repo)", str(len(wts))],
|
|
199
|
+
["Tasks (waiting / total)", f"{blocked} / {len(tasks)}"],
|
|
200
|
+
],
|
|
201
|
+
no_wrap={1},
|
|
202
|
+
)
|
|
203
|
+
typer.echo("\nNext: navig agentctl sessions · worktrees --project <path> · tasks")
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Agent Control Center deck routes — the read-only Observer API.
|
|
2
|
+
|
|
3
|
+
AUTHORITATIVE CONTRACT FIXTURE: ``plugins/navig-agentctl/tests/fixtures/agents-contract.json``.
|
|
4
|
+
The OS Control Center app and tools/forge/navig-dev both validate against it —
|
|
5
|
+
one fixture, two consumers; a shape change updates the fixture in the same
|
|
6
|
+
commit as the route change. (navig-dev's drift-alarm walks
|
|
7
|
+
``plugins/navig-agentctl/**`` for it — the routes are a PLUGIN, not core.)
|
|
8
|
+
|
|
9
|
+
Gated on the ``agentctl`` module being enabled. Phase 1 is GET-only; the ONE
|
|
10
|
+
write is the Phase-2 annotation heartbeat (no process control):
|
|
11
|
+
|
|
12
|
+
GET /api/deck/agents/sessions → {sessions: [...], generated_at}
|
|
13
|
+
GET /api/deck/agents/worktrees?project= → {worktrees: [...], conflicts: [...]}
|
|
14
|
+
GET /api/deck/agents/tasks → {tasks: [...], generated_at}
|
|
15
|
+
POST /api/deck/agents/heartbeat → {ok, session_id} annotation-only
|
|
16
|
+
|
|
17
|
+
Heartbeat semantics (contract): payload carries ONLY annotation fields
|
|
18
|
+
(``session_id`` + optional ``project_root, worktree, agent_kind, editor,
|
|
19
|
+
owner, last_activity_at``); unknown/daemon fields in the body are ignored,
|
|
20
|
+
never written. Daemon-derived state always wins — the route cannot change
|
|
21
|
+
``state``/``pid``/``reason`` of a session the process table owns.
|
|
22
|
+
|
|
23
|
+
Future local-only mutations (launch/assign/pause/terminate) need BOTH guards
|
|
24
|
+
(is_local_request in the brain AND the OS server's refuseRemoteManagement
|
|
25
|
+
list); the heartbeat is annotation, not control, so it rides normal deck auth.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import asyncio
|
|
31
|
+
import logging
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
from aiohttp import web
|
|
37
|
+
except ImportError: # pragma: no cover — gateway always has aiohttp
|
|
38
|
+
web = None # type: ignore[assignment]
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
_BASE = "/api/deck/agents"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _json(data: dict[str, Any], status: int = 200):
|
|
46
|
+
return web.json_response(data, status=status)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _resolve_root(request) -> Path | None:
|
|
50
|
+
"""Project root from ?project= (a path), else the primary checkout of cwd.
|
|
51
|
+
|
|
52
|
+
Typed paths follow the operator: a path the user typed goes through
|
|
53
|
+
resolve_user_path — never Path.cwd() guessing for user input.
|
|
54
|
+
"""
|
|
55
|
+
raw = (request.query.get("project") or "").strip()
|
|
56
|
+
if not raw:
|
|
57
|
+
from navig.platform.paths import invocation_cwd
|
|
58
|
+
|
|
59
|
+
raw = str(invocation_cwd())
|
|
60
|
+
try:
|
|
61
|
+
from navig.platform.paths import resolve_user_path
|
|
62
|
+
|
|
63
|
+
root = resolve_user_path(raw)
|
|
64
|
+
except Exception: # noqa: BLE001
|
|
65
|
+
root = Path(raw)
|
|
66
|
+
if not root.exists():
|
|
67
|
+
return None
|
|
68
|
+
return root
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def handle_sessions(request):
|
|
72
|
+
"""Live sessions (procs-derived) merged with the registry ledger."""
|
|
73
|
+
from navig_agentctl.registry import get_agentctl_store
|
|
74
|
+
from navig_agentctl.snapshots import record_snapshot, session_snapshot
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
# to_thread: psutil process scans take seconds — run on the loop would
|
|
78
|
+
# freeze EVERY other gateway request (deck, telegram) behind this one.
|
|
79
|
+
rows = await asyncio.to_thread(session_snapshot)
|
|
80
|
+
except Exception as exc: # noqa: BLE001 — observer must never 500 the app
|
|
81
|
+
logger.warning("agentctl sessions snapshot failed: %r", exc)
|
|
82
|
+
rows = []
|
|
83
|
+
try:
|
|
84
|
+
def _ingest() -> dict:
|
|
85
|
+
store = get_agentctl_store()
|
|
86
|
+
record_snapshot(rows, store)
|
|
87
|
+
store.mark_stale()
|
|
88
|
+
return {r["session_id"]: r for r in store.list_sessions()}
|
|
89
|
+
|
|
90
|
+
ledger = await asyncio.to_thread(_ingest)
|
|
91
|
+
except Exception as exc: # noqa: BLE001
|
|
92
|
+
logger.debug("agentctl registry unavailable: %r", exc)
|
|
93
|
+
ledger = {}
|
|
94
|
+
|
|
95
|
+
# Live rows win; registry-only rows surface as their last known state
|
|
96
|
+
# (stale included — the recovery breadcrumb Phase 3 needs).
|
|
97
|
+
seen = {r["session_id"] for r in rows}
|
|
98
|
+
merged = list(rows)
|
|
99
|
+
for sid, r in ledger.items():
|
|
100
|
+
if sid not in seen:
|
|
101
|
+
merged.append(
|
|
102
|
+
{
|
|
103
|
+
"session_id": sid,
|
|
104
|
+
"pid": r.get("pid"),
|
|
105
|
+
"project_root": r.get("project_root") or "",
|
|
106
|
+
"project": r.get("project") or "",
|
|
107
|
+
"worktree": r.get("worktree") or "",
|
|
108
|
+
"agent_kind": r.get("agent_kind") or "",
|
|
109
|
+
"editor": r.get("editor") or "",
|
|
110
|
+
"state": r.get("state") or "stale",
|
|
111
|
+
"reason": r.get("reason") or "not seen in the latest snapshot",
|
|
112
|
+
"last_activity_at": r.get("last_activity_at"),
|
|
113
|
+
"owner": r.get("owner") or "",
|
|
114
|
+
"mem_mb": 0.0,
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
return _json({"sessions": merged, "generated_at": _now()})
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
async def handle_worktrees(request):
|
|
121
|
+
"""Worktrees of one repo + the pairwise conflict radar."""
|
|
122
|
+
from navig_agentctl.snapshots import conflict_pairs, worktree_snapshot
|
|
123
|
+
|
|
124
|
+
root = _resolve_root(request)
|
|
125
|
+
if root is None:
|
|
126
|
+
return _json({"error": "project path not found"}, status=404)
|
|
127
|
+
try:
|
|
128
|
+
# to_thread: N worktrees × 3 git subprocesses measured 36-150s on the
|
|
129
|
+
# live box — on the loop it froze the whole gateway (reproduced: a
|
|
130
|
+
# 14ms route took 147s while this ran).
|
|
131
|
+
wts = await asyncio.to_thread(worktree_snapshot, root)
|
|
132
|
+
except Exception as exc: # noqa: BLE001
|
|
133
|
+
logger.warning("agentctl worktree snapshot failed: %r", exc)
|
|
134
|
+
return _json({"error": f"worktree snapshot failed: {exc}"}, status=500)
|
|
135
|
+
# Conflict radar is opt-in: pairwise merge-tree is O(n²) git calls — a
|
|
136
|
+
# 31-worktree repo is ~450 pairs and the scan runs off-loop (to_thread),
|
|
137
|
+
# so the rest of the gateway stays live while it grinds. The CLI mirrors
|
|
138
|
+
# the opt-in (`--conflicts` is a flag, never the default).
|
|
139
|
+
with_conflicts = (request.query.get("conflicts") or "0").lower() in ("1", "true", "yes")
|
|
140
|
+
pairs = await asyncio.to_thread(conflict_pairs, root) if with_conflicts else []
|
|
141
|
+
return _json(
|
|
142
|
+
{"project": str(root), "worktrees": wts, "conflicts": pairs, "generated_at": _now()}
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async def handle_tasks(request):
|
|
147
|
+
"""Unified task read-model: board cards + work items."""
|
|
148
|
+
from navig_agentctl.snapshots import task_snapshot
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
tasks = await asyncio.to_thread(task_snapshot)
|
|
152
|
+
except Exception as exc: # noqa: BLE001
|
|
153
|
+
logger.warning("agentctl task snapshot failed: %r", exc)
|
|
154
|
+
return _json({"error": f"task snapshot failed: {exc}"}, status=500)
|
|
155
|
+
return _json({"tasks": tasks, "generated_at": _now()})
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _reject(msg: str):
|
|
159
|
+
"""Heartbeat 400s must be VISIBLE: a silent rejection is a client that
|
|
160
|
+
retries forever with a payload nobody ever sees fail (the forge client's
|
|
161
|
+
404-standdown made exactly this class of drift undetectable before)."""
|
|
162
|
+
logger.warning("agentctl heartbeat rejected: %s", msg)
|
|
163
|
+
return _json({"error": msg}, status=400)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
async def handle_heartbeat(request):
|
|
167
|
+
"""POST — annotation-only session heartbeat (the ONE write in Phase 1/2a).
|
|
168
|
+
|
|
169
|
+
Adds editor context the daemon cannot derive (workspace, editor, roster
|
|
170
|
+
owner) to the registry. Never changes state/pid/reason — see the module
|
|
171
|
+
docstring for the contract. Validation is fail-closed: a malformed body
|
|
172
|
+
is 400, never a partial write.
|
|
173
|
+
"""
|
|
174
|
+
from navig_agentctl.registry import get_agentctl_store
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
payload = await request.json()
|
|
178
|
+
except Exception: # noqa: BLE001 — non-JSON body
|
|
179
|
+
return _reject("body must be a JSON object")
|
|
180
|
+
if not isinstance(payload, dict):
|
|
181
|
+
return _reject("body must be a JSON object")
|
|
182
|
+
|
|
183
|
+
sid = payload.get("session_id")
|
|
184
|
+
if not isinstance(sid, str) or not sid.strip() or len(sid) > 200:
|
|
185
|
+
return _reject("session_id is required (non-empty string, ≤200 chars)")
|
|
186
|
+
if any(ord(c) < 32 for c in sid): # control chars never reach the store
|
|
187
|
+
return _reject("session_id contains control characters")
|
|
188
|
+
|
|
189
|
+
annotations: dict[str, str] = {}
|
|
190
|
+
for key in ("project_root", "worktree", "agent_kind", "editor", "owner"):
|
|
191
|
+
val = payload.get(key)
|
|
192
|
+
if val is None:
|
|
193
|
+
annotations[key] = ""
|
|
194
|
+
continue
|
|
195
|
+
if not isinstance(val, str) or len(val) > 1024:
|
|
196
|
+
return _reject(f"{key} must be a string of at most 1024 chars")
|
|
197
|
+
annotations[key] = val
|
|
198
|
+
last_activity = payload.get("last_activity_at")
|
|
199
|
+
if last_activity is not None and (
|
|
200
|
+
not isinstance(last_activity, str) or len(last_activity) > 64
|
|
201
|
+
):
|
|
202
|
+
return _reject("last_activity_at must be a string of at most 64 chars")
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
store = get_agentctl_store()
|
|
206
|
+
store.annotate_session(
|
|
207
|
+
{"session_id": sid.strip(), **annotations, "last_activity_at": last_activity}
|
|
208
|
+
)
|
|
209
|
+
except Exception as exc: # noqa: BLE001 — store down is a 500, not a 200
|
|
210
|
+
logger.warning("agentctl heartbeat store failed: %r", exc)
|
|
211
|
+
return _json({"error": f"heartbeat store failed: {exc}"}, status=500)
|
|
212
|
+
return _json({"ok": True, "session_id": sid.strip()})
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _now() -> str:
|
|
216
|
+
from datetime import datetime, timezone
|
|
217
|
+
|
|
218
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def register(app: "web.Application") -> None:
|
|
222
|
+
from navig.modules.gate import requires_module
|
|
223
|
+
|
|
224
|
+
g = requires_module("agentctl")
|
|
225
|
+
app.router.add_get(f"{_BASE}/sessions", g(handle_sessions))
|
|
226
|
+
app.router.add_get(f"{_BASE}/worktrees", g(handle_worktrees))
|
|
227
|
+
app.router.add_get(f"{_BASE}/tasks", g(handle_tasks))
|
|
228
|
+
app.router.add_post(f"{_BASE}/heartbeat", g(handle_heartbeat))
|
|
229
|
+
logger.debug("navig-agentctl: Observer routes + heartbeat mounted under %s", _BASE)
|