dooers-cli 0.8.2__tar.gz → 0.9.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.
- {dooers_cli-0.8.2 → dooers_cli-0.9.0}/PKG-INFO +21 -2
- dooers_cli-0.9.0/README.md +59 -0
- {dooers_cli-0.8.2 → dooers_cli-0.9.0}/pyproject.toml +2 -2
- {dooers_cli-0.8.2 → dooers_cli-0.9.0}/src/dooers/cli/cli.py +13 -4
- {dooers_cli-0.8.2 → dooers_cli-0.9.0}/src/dooers/cli/config.py +20 -0
- dooers_cli-0.9.0/src/dooers/cli/logs.py +266 -0
- {dooers_cli-0.8.2 → dooers_cli-0.9.0}/src/dooers/cli/push.py +6 -10
- {dooers_cli-0.8.2 → dooers_cli-0.9.0}/src/dooers/cli/push_client.py +29 -0
- {dooers_cli-0.8.2 → dooers_cli-0.9.0}/tests/test_env_prod.py +2 -1
- dooers_cli-0.9.0/tests/test_logs.py +439 -0
- {dooers_cli-0.8.2 → dooers_cli-0.9.0}/uv.lock +2 -2
- dooers_cli-0.8.2/README.md +0 -40
- {dooers_cli-0.8.2 → dooers_cli-0.9.0}/.gitignore +0 -0
- {dooers_cli-0.8.2 → dooers_cli-0.9.0}/src/dooers/cli/__init__.py +0 -0
- {dooers_cli-0.8.2 → dooers_cli-0.9.0}/src/dooers/cli/agent_store.py +0 -0
- {dooers_cli-0.8.2 → dooers_cli-0.9.0}/src/dooers/cli/agents.py +0 -0
- {dooers_cli-0.8.2 → dooers_cli-0.9.0}/src/dooers/cli/auth.py +0 -0
- {dooers_cli-0.8.2 → dooers_cli-0.9.0}/src/dooers/cli/core_client.py +0 -0
- {dooers_cli-0.8.2 → dooers_cli-0.9.0}/src/dooers/cli/env_prod.py +0 -0
- {dooers_cli-0.8.2 → dooers_cli-0.9.0}/src/dooers/cli/ignore.py +0 -0
- {dooers_cli-0.8.2 → dooers_cli-0.9.0}/src/dooers/cli/manifest_sync.py +0 -0
- {dooers_cli-0.8.2 → dooers_cli-0.9.0}/src/dooers/cli/org.py +0 -0
- {dooers_cli-0.8.2 → dooers_cli-0.9.0}/src/dooers/cli/settings.py +0 -0
- {dooers_cli-0.8.2 → dooers_cli-0.9.0}/src/dooers/cli/token_store.py +0 -0
- {dooers_cli-0.8.2 → dooers_cli-0.9.0}/src/dooers/cli/user_config.py +0 -0
- {dooers_cli-0.8.2 → dooers_cli-0.9.0}/src/dooers/cli/validate.py +0 -0
- {dooers_cli-0.8.2 → dooers_cli-0.9.0}/src/dooers/py.typed +0 -0
- {dooers_cli-0.8.2 → dooers_cli-0.9.0}/tests/__init__.py +0 -0
- {dooers_cli-0.8.2 → dooers_cli-0.9.0}/tests/test_agent_store.py +0 -0
- {dooers_cli-0.8.2 → dooers_cli-0.9.0}/tests/test_agents_delete.py +0 -0
- {dooers_cli-0.8.2 → dooers_cli-0.9.0}/tests/test_agents_update.py +0 -0
- {dooers_cli-0.8.2 → dooers_cli-0.9.0}/tests/test_config.py +0 -0
- {dooers_cli-0.8.2 → dooers_cli-0.9.0}/tests/test_core_client.py +0 -0
- {dooers_cli-0.8.2 → dooers_cli-0.9.0}/tests/test_encoding.py +0 -0
- {dooers_cli-0.8.2 → dooers_cli-0.9.0}/tests/test_ignore.py +0 -0
- {dooers_cli-0.8.2 → dooers_cli-0.9.0}/tests/test_manifest_sync.py +0 -0
- {dooers_cli-0.8.2 → dooers_cli-0.9.0}/tests/test_org.py +0 -0
- {dooers_cli-0.8.2 → dooers_cli-0.9.0}/tests/test_push_client_async.py +0 -0
- {dooers_cli-0.8.2 → dooers_cli-0.9.0}/tests/test_push_failure_display.py +0 -0
- {dooers_cli-0.8.2 → dooers_cli-0.9.0}/tests/test_push_hosting_guard.py +0 -0
- {dooers_cli-0.8.2 → dooers_cli-0.9.0}/tests/test_push_poll.py +0 -0
- {dooers_cli-0.8.2 → dooers_cli-0.9.0}/tests/test_push_teardown.py +0 -0
- {dooers_cli-0.8.2 → dooers_cli-0.9.0}/tests/test_smoke.py +0 -0
- {dooers_cli-0.8.2 → dooers_cli-0.9.0}/tests/test_token_store.py +0 -0
- {dooers_cli-0.8.2 → dooers_cli-0.9.0}/tests/test_validate.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dooers-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: Dooers CLI — push agents to Dooers, manage agent records, authenticate from the terminal
|
|
5
5
|
Project-URL: Repository, https://github.com/Dooers-ai/dooers-cli
|
|
6
6
|
Author-email: Dooers <dev@dooers.ai>
|
|
@@ -15,7 +15,7 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.12
|
|
16
16
|
Classifier: Typing :: Typed
|
|
17
17
|
Requires-Python: >=3.10
|
|
18
|
-
Requires-Dist: dooers-protocol>=0.
|
|
18
|
+
Requires-Dist: dooers-protocol>=0.8.0
|
|
19
19
|
Requires-Dist: httpx>=0.28.1
|
|
20
20
|
Requires-Dist: pydantic>=2.13.2
|
|
21
21
|
Requires-Dist: pyyaml>=6.0.2
|
|
@@ -57,8 +57,27 @@ dooers agents show ag_8h2k
|
|
|
57
57
|
# push (reads dooers.yaml in cwd unless agent_id given)
|
|
58
58
|
dooers validate
|
|
59
59
|
dooers push [<agent_id>] [--tag latest] [--env prod|stg|dev]
|
|
60
|
+
|
|
61
|
+
# logs (same agent resolution as push)
|
|
62
|
+
dooers logs [<agent_id>] [--since 15m] [--limit 100] [--json]
|
|
63
|
+
dooers logs --follow # tail; Ctrl-C to stop
|
|
60
64
|
```
|
|
61
65
|
|
|
66
|
+
### `dooers logs`
|
|
67
|
+
|
|
68
|
+
Reads the deployment logs of an agent from `dooers-push`.
|
|
69
|
+
|
|
70
|
+
| Flag | Default | Meaning |
|
|
71
|
+
|---|---|---|
|
|
72
|
+
| `--since` | `15m` | How far back to start — a duration: `45s`, `15m`, `1h`, `2h30m`. |
|
|
73
|
+
| `--limit` | `100` | Max entries per request (the server caps at 1000). |
|
|
74
|
+
| `--follow`, `-f` | off | Poll every 2s and print new entries until interrupted. |
|
|
75
|
+
| `--json` | off | One compact JSON object per line (`timestamp`, `severity`, `message`) instead of formatted text. |
|
|
76
|
+
|
|
77
|
+
Default output is `HH:MM:SS LEVEL message`, with the level colourised
|
|
78
|
+
(ERROR/CRITICAL red, WARNING yellow, DEBUG/DEFAULT dim). Times are shown in the
|
|
79
|
+
timestamp's own zone (UTC, as emitted) — never converted to the local clock.
|
|
80
|
+
|
|
62
81
|
## Global config (precedence: flag > env > default)
|
|
63
82
|
|
|
64
83
|
| Setting | Flag | Env var | Default |
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# dooers (CLI)
|
|
2
|
+
|
|
3
|
+
The Dooers CLI. Installed by agent creators.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install dooers
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Commands
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
# auth (email prompts if omitted)
|
|
13
|
+
dooers login you@example.com
|
|
14
|
+
dooers whoami
|
|
15
|
+
dooers logout
|
|
16
|
+
|
|
17
|
+
# agents
|
|
18
|
+
dooers agents list
|
|
19
|
+
dooers agents create --name my-agent
|
|
20
|
+
dooers agents show ag_8h2k
|
|
21
|
+
|
|
22
|
+
# push (reads dooers.yaml in cwd unless agent_id given)
|
|
23
|
+
dooers validate
|
|
24
|
+
dooers push [<agent_id>] [--tag latest] [--env prod|stg|dev]
|
|
25
|
+
|
|
26
|
+
# logs (same agent resolution as push)
|
|
27
|
+
dooers logs [<agent_id>] [--since 15m] [--limit 100] [--json]
|
|
28
|
+
dooers logs --follow # tail; Ctrl-C to stop
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### `dooers logs`
|
|
32
|
+
|
|
33
|
+
Reads the deployment logs of an agent from `dooers-push`.
|
|
34
|
+
|
|
35
|
+
| Flag | Default | Meaning |
|
|
36
|
+
|---|---|---|
|
|
37
|
+
| `--since` | `15m` | How far back to start — a duration: `45s`, `15m`, `1h`, `2h30m`. |
|
|
38
|
+
| `--limit` | `100` | Max entries per request (the server caps at 1000). |
|
|
39
|
+
| `--follow`, `-f` | off | Poll every 2s and print new entries until interrupted. |
|
|
40
|
+
| `--json` | off | One compact JSON object per line (`timestamp`, `severity`, `message`) instead of formatted text. |
|
|
41
|
+
|
|
42
|
+
Default output is `HH:MM:SS LEVEL message`, with the level colourised
|
|
43
|
+
(ERROR/CRITICAL red, WARNING yellow, DEBUG/DEFAULT dim). Times are shown in the
|
|
44
|
+
timestamp's own zone (UTC, as emitted) — never converted to the local clock.
|
|
45
|
+
|
|
46
|
+
## Global config (precedence: flag > env > default)
|
|
47
|
+
|
|
48
|
+
| Setting | Flag | Env var | Default |
|
|
49
|
+
|---|---|---|---|
|
|
50
|
+
| Core API host | `--core-url` | `DOOERS_CORE_URL` | `https://api.dooers.ai` |
|
|
51
|
+
| dooers-push host | `--push-url` | `DOOERS_PUSH_URL` | `https://host.dooers.ai` |
|
|
52
|
+
| Target environment | `--env` | `DOOERS_ENV` | `prod` |
|
|
53
|
+
|
|
54
|
+
## Development
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
uv sync --extra dev
|
|
58
|
+
uv run poe dev
|
|
59
|
+
```
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "dooers-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.9.0"
|
|
4
4
|
description = "Dooers CLI — push agents to Dooers, manage agent records, authenticate from the terminal"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -26,7 +26,7 @@ dependencies = [
|
|
|
26
26
|
"pydantic>=2.13.2",
|
|
27
27
|
"pyyaml>=6.0.2",
|
|
28
28
|
"tqdm>=4.66.4",
|
|
29
|
-
"dooers-protocol>=0.
|
|
29
|
+
"dooers-protocol>=0.8.0",
|
|
30
30
|
]
|
|
31
31
|
|
|
32
32
|
[tool.uv.sources]
|
|
@@ -7,14 +7,19 @@ import warnings
|
|
|
7
7
|
# frames; suppress so every command stays quiet when both packages share `dooers.*`.
|
|
8
8
|
warnings.filterwarnings(
|
|
9
9
|
"ignore",
|
|
10
|
-
message=
|
|
10
|
+
message=(
|
|
11
|
+
r'Field name "schema" in "SettingsPublicSchemaResultPayload" '
|
|
12
|
+
r'shadows an attribute in parent "BaseModel"'
|
|
13
|
+
),
|
|
11
14
|
category=UserWarning,
|
|
12
15
|
)
|
|
13
16
|
|
|
14
|
-
|
|
17
|
+
# E402 below is deliberate: the filter above must be installed before anything
|
|
18
|
+
# imports the models that trigger the warning.
|
|
19
|
+
import typer # noqa: E402
|
|
15
20
|
|
|
16
|
-
from . import agents, auth, org, push, validate
|
|
17
|
-
from .settings import Settings
|
|
21
|
+
from . import agents, auth, logs, org, push, validate # noqa: E402
|
|
22
|
+
from .settings import Settings # noqa: E402
|
|
18
23
|
|
|
19
24
|
app = typer.Typer(
|
|
20
25
|
name="dooers",
|
|
@@ -49,6 +54,10 @@ app.command(
|
|
|
49
54
|
help="Validate dooers.yaml, Dockerfile, and SDK dependency/import conventions.",
|
|
50
55
|
)(validate.validate)
|
|
51
56
|
app.command(name="push", help="Archive cwd and push it as a new build of an agent.")(push.push)
|
|
57
|
+
app.command(
|
|
58
|
+
name="logs",
|
|
59
|
+
help="Show an agent's deployment logs; --follow to tail them.",
|
|
60
|
+
)(logs.logs)
|
|
52
61
|
|
|
53
62
|
|
|
54
63
|
if __name__ == "__main__":
|
|
@@ -8,6 +8,26 @@ from dooers.protocol.agents import AgentManifest
|
|
|
8
8
|
MANIFEST_FILENAME = "dooers.yaml"
|
|
9
9
|
|
|
10
10
|
|
|
11
|
+
class MissingAgentIdError(RuntimeError):
|
|
12
|
+
"""No agent id was given and no dooers.yaml was found to read one from."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def resolve_agent_id(explicit: str | None, manifest: AgentManifest | None) -> str:
|
|
16
|
+
"""Resolve which agent a command targets: the explicit argument wins, else dooers.yaml.
|
|
17
|
+
|
|
18
|
+
The single rule shared by every command that takes an optional agent id
|
|
19
|
+
(`dooers push`, `dooers logs`) — do not re-derive it per command.
|
|
20
|
+
"""
|
|
21
|
+
if explicit is not None:
|
|
22
|
+
return explicit
|
|
23
|
+
if manifest is None:
|
|
24
|
+
raise MissingAgentIdError(
|
|
25
|
+
f"Missing {MANIFEST_FILENAME}. Run `dooers agents create` first "
|
|
26
|
+
f"or pass an agent_id explicitly."
|
|
27
|
+
)
|
|
28
|
+
return manifest.agent_id
|
|
29
|
+
|
|
30
|
+
|
|
11
31
|
def read_manifest(directory: Path | None = None) -> AgentManifest | None:
|
|
12
32
|
"""Read and validate dooers.yaml from `directory` (default: cwd).
|
|
13
33
|
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""`dooers logs` — read (and optionally tail) the deployment logs of an agent.
|
|
2
|
+
|
|
3
|
+
Reads `GET /v1/agents/{id}/logs` on dooers-push. In `--follow` mode the command
|
|
4
|
+
re-polls with `since` pinned to the newest timestamp already printed, so each
|
|
5
|
+
poll fetches a small overlapping window; the overlap is filtered client-side by
|
|
6
|
+
`_Cursor` rather than trusting the server to be exclusive on the boundary.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import Callable, Iterable
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
|
|
19
|
+
import typer
|
|
20
|
+
from dooers.protocol.logs import LogEntry
|
|
21
|
+
from pydantic import ValidationError
|
|
22
|
+
|
|
23
|
+
from dooers.cli import config
|
|
24
|
+
from dooers.cli.push_client import (
|
|
25
|
+
PushClient,
|
|
26
|
+
PushClientError,
|
|
27
|
+
PushTransientError,
|
|
28
|
+
friendly_push_error,
|
|
29
|
+
)
|
|
30
|
+
from dooers.cli.settings import Settings
|
|
31
|
+
from dooers.cli.token_store import TokenStore, is_token_expired
|
|
32
|
+
|
|
33
|
+
#: How long `--follow` waits between polls.
|
|
34
|
+
POLL_INTERVAL_S = 2.0
|
|
35
|
+
|
|
36
|
+
#: `--since` grammar: an optional hours/minutes/seconds run, in that order ("2h30m").
|
|
37
|
+
_DURATION = re.compile(r"(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?", re.IGNORECASE)
|
|
38
|
+
|
|
39
|
+
#: Fractional seconds in an RFC3339 timestamp. Cloud Logging emits nanoseconds,
|
|
40
|
+
#: which `datetime.fromisoformat` rejects before Python 3.11.
|
|
41
|
+
_FRACTION = re.compile(r"\.(\d+)")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _parse_since(value: str) -> str:
|
|
45
|
+
"""Validate a `--since` duration ("15m", "1h", "2h30m", "45s") and return it.
|
|
46
|
+
|
|
47
|
+
Returned unchanged (lowercased) rather than converted: the endpoint takes the
|
|
48
|
+
same duration grammar, and passing the user's own window back keeps the
|
|
49
|
+
request self-describing in logs.
|
|
50
|
+
"""
|
|
51
|
+
text = value.strip().lower()
|
|
52
|
+
match = _DURATION.fullmatch(text)
|
|
53
|
+
if not match or not any(match.groups()):
|
|
54
|
+
raise typer.BadParameter(
|
|
55
|
+
f"invalid duration {value!r} — use a window like 15m, 45s, 1h, or 2h30m"
|
|
56
|
+
)
|
|
57
|
+
return text
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _parse_timestamp(timestamp: str) -> datetime | None:
|
|
61
|
+
"""Parse an RFC3339 timestamp, or None if it is not one.
|
|
62
|
+
|
|
63
|
+
Normalizes the two shapes `datetime.fromisoformat` cannot take on Python 3.10:
|
|
64
|
+
a trailing `Z`, and sub-microsecond precision.
|
|
65
|
+
"""
|
|
66
|
+
text = timestamp.strip()
|
|
67
|
+
if text.endswith(("Z", "z")):
|
|
68
|
+
text = text[:-1] + "+00:00"
|
|
69
|
+
text = _FRACTION.sub(lambda m: "." + m.group(1)[:6], text)
|
|
70
|
+
try:
|
|
71
|
+
return datetime.fromisoformat(text)
|
|
72
|
+
except ValueError:
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _printable(text: str) -> str:
|
|
77
|
+
"""Make `text` safe for the active stdout encoding.
|
|
78
|
+
|
|
79
|
+
Log messages are arbitrary agent output. On a non-UTF-8 console (cp1252 on
|
|
80
|
+
Windows) an emoji in one line would otherwise raise UnicodeEncodeError and
|
|
81
|
+
kill the tail; replacing the character is the right trade here.
|
|
82
|
+
"""
|
|
83
|
+
encoding = getattr(sys.stdout, "encoding", None) or "utf-8"
|
|
84
|
+
try:
|
|
85
|
+
return text.encode(encoding, errors="replace").decode(encoding, errors="replace")
|
|
86
|
+
except LookupError: # pragma: no cover — unknown codec name on stdout
|
|
87
|
+
return text
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _style_severity(severity: str) -> str:
|
|
91
|
+
"""Colourise the level column: ERROR/CRITICAL red, WARNING yellow, DEBUG/DEFAULT dim."""
|
|
92
|
+
label = f"{severity:<8}"
|
|
93
|
+
level = severity.upper()
|
|
94
|
+
if level in ("ERROR", "CRITICAL"):
|
|
95
|
+
return typer.style(label, fg=typer.colors.RED)
|
|
96
|
+
if level == "WARNING":
|
|
97
|
+
return typer.style(label, fg=typer.colors.YELLOW)
|
|
98
|
+
if level in ("DEBUG", "DEFAULT"):
|
|
99
|
+
return typer.style(label, dim=True)
|
|
100
|
+
return label
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def format_entry(entry: LogEntry) -> str:
|
|
104
|
+
"""One human-readable line: `HH:MM:SS LEVEL message`.
|
|
105
|
+
|
|
106
|
+
The clock is shown in the timestamp's own zone (UTC, as Cloud Logging emits
|
|
107
|
+
it) — never converted — so two people reading the same tail see the same time.
|
|
108
|
+
"""
|
|
109
|
+
parsed = _parse_timestamp(entry.timestamp)
|
|
110
|
+
clock = parsed.strftime("%H:%M:%S") if parsed else entry.timestamp
|
|
111
|
+
return f"{clock} {_style_severity(entry.severity)} {_printable(entry.message)}"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def format_entry_json(entry: LogEntry) -> str:
|
|
115
|
+
"""One compact JSON object per line, ASCII-escaped so any console can print it."""
|
|
116
|
+
return json.dumps(entry.model_dump(), separators=(",", ":"))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass
|
|
120
|
+
class _Cursor:
|
|
121
|
+
"""High-water mark of what has already been printed.
|
|
122
|
+
|
|
123
|
+
Entries older than `newest` are dropped; entries *at* `newest` are dropped only
|
|
124
|
+
if that exact `(timestamp, message)` was already printed, so a burst sharing one
|
|
125
|
+
timestamp survives an overlapping poll window.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
newest: str | None = None
|
|
129
|
+
printed_at_newest: set[tuple[str, str]] = field(default_factory=set)
|
|
130
|
+
|
|
131
|
+
def take_new(self, entries: Iterable[LogEntry]) -> list[LogEntry]:
|
|
132
|
+
"""Return the not-yet-printed entries, oldest first, advancing the mark."""
|
|
133
|
+
fresh: list[LogEntry] = []
|
|
134
|
+
for entry in sorted(entries, key=lambda e: e.timestamp):
|
|
135
|
+
key = (entry.timestamp, entry.message)
|
|
136
|
+
if self.newest is not None:
|
|
137
|
+
if entry.timestamp < self.newest:
|
|
138
|
+
continue
|
|
139
|
+
if entry.timestamp == self.newest and key in self.printed_at_newest:
|
|
140
|
+
continue
|
|
141
|
+
if self.newest is None or entry.timestamp > self.newest:
|
|
142
|
+
self.newest = entry.timestamp
|
|
143
|
+
self.printed_at_newest = {key}
|
|
144
|
+
else:
|
|
145
|
+
self.printed_at_newest.add(key)
|
|
146
|
+
fresh.append(entry)
|
|
147
|
+
return fresh
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def emit(entries: Iterable[LogEntry], cursor: _Cursor, *, json_output: bool) -> int:
|
|
151
|
+
"""Print the entries `cursor` has not seen yet. Returns how many were printed."""
|
|
152
|
+
fresh = cursor.take_new(entries)
|
|
153
|
+
for entry in fresh:
|
|
154
|
+
typer.echo(format_entry_json(entry) if json_output else format_entry(entry))
|
|
155
|
+
return len(fresh)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def follow_logs(
|
|
159
|
+
client: PushClient,
|
|
160
|
+
agent_id: str,
|
|
161
|
+
*,
|
|
162
|
+
cursor: _Cursor,
|
|
163
|
+
since: str,
|
|
164
|
+
limit: int,
|
|
165
|
+
json_output: bool = False,
|
|
166
|
+
sleep: Callable[[float], object] = time.sleep,
|
|
167
|
+
interval_s: float = POLL_INTERVAL_S,
|
|
168
|
+
max_polls: int | None = None,
|
|
169
|
+
) -> None:
|
|
170
|
+
"""Poll for new entries forever (or `max_polls` times, for tests).
|
|
171
|
+
|
|
172
|
+
Transient failures are tolerated — a tail must outlive a 503 — and warned about
|
|
173
|
+
once per outage. A PushClientError is a real 4xx and propagates to the caller.
|
|
174
|
+
"""
|
|
175
|
+
warned = False
|
|
176
|
+
polls = 0
|
|
177
|
+
while max_polls is None or polls < max_polls:
|
|
178
|
+
polls += 1
|
|
179
|
+
sleep(interval_s)
|
|
180
|
+
try:
|
|
181
|
+
resp = client.get_logs(agent_id, since=cursor.newest or since, limit=limit)
|
|
182
|
+
except PushTransientError as e:
|
|
183
|
+
if not warned:
|
|
184
|
+
typer.echo(f"Warning: log stream interrupted ({e}); retrying…", err=True)
|
|
185
|
+
warned = True
|
|
186
|
+
continue
|
|
187
|
+
warned = False
|
|
188
|
+
emit(resp.entries, cursor, json_output=json_output)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def logs(
|
|
192
|
+
ctx: typer.Context,
|
|
193
|
+
agent_id: str | None = typer.Argument(
|
|
194
|
+
None,
|
|
195
|
+
help="Agent ID. If omitted, reads agent_id from ./dooers.yaml.",
|
|
196
|
+
),
|
|
197
|
+
follow: bool = typer.Option(
|
|
198
|
+
False, "--follow", "-f", help="Keep polling and print new entries as they arrive."
|
|
199
|
+
),
|
|
200
|
+
since: str = typer.Option(
|
|
201
|
+
"15m", "--since", help="How far back to start: a duration like 15m, 45s, 1h, 2h30m."
|
|
202
|
+
),
|
|
203
|
+
limit: int = typer.Option(
|
|
204
|
+
100, "--limit", help="Max entries per request (server caps at 1000)."
|
|
205
|
+
),
|
|
206
|
+
json_output: bool = typer.Option(
|
|
207
|
+
False, "--json", help="Print one compact JSON object per line instead of formatted text."
|
|
208
|
+
),
|
|
209
|
+
) -> None:
|
|
210
|
+
"""Show the deployment logs of an agent."""
|
|
211
|
+
settings: Settings = ctx.obj
|
|
212
|
+
window = _parse_since(since)
|
|
213
|
+
|
|
214
|
+
# Resolve agent_id from arg or manifest (same rule as `dooers push`). The
|
|
215
|
+
# manifest is only read when it is actually needed, so an explicit id still
|
|
216
|
+
# works from a directory with a broken dooers.yaml.
|
|
217
|
+
manifest = None
|
|
218
|
+
if agent_id is None:
|
|
219
|
+
try:
|
|
220
|
+
manifest = config.read_manifest()
|
|
221
|
+
except ValidationError as e:
|
|
222
|
+
typer.echo(f"Invalid {config.MANIFEST_FILENAME}: {e}", err=True)
|
|
223
|
+
raise typer.Exit(code=1) from e
|
|
224
|
+
try:
|
|
225
|
+
target_agent_id = config.resolve_agent_id(agent_id, manifest)
|
|
226
|
+
except config.MissingAgentIdError as e:
|
|
227
|
+
typer.echo(str(e), err=True)
|
|
228
|
+
raise typer.Exit(code=1) from e
|
|
229
|
+
|
|
230
|
+
store = TokenStore()
|
|
231
|
+
token = store.load()
|
|
232
|
+
if not token or is_token_expired(token, store=store):
|
|
233
|
+
typer.echo("Not authenticated. Run `dooers login`.", err=True)
|
|
234
|
+
raise typer.Exit(code=1)
|
|
235
|
+
|
|
236
|
+
client = PushClient(base_url=settings.push_url, token=token)
|
|
237
|
+
cursor = _Cursor()
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
resp = client.get_logs(target_agent_id, since=window, limit=limit)
|
|
241
|
+
except PushClientError as e:
|
|
242
|
+
code = e.envelope.error_code if e.envelope else None
|
|
243
|
+
typer.echo(f"Logs failed: {friendly_push_error(code, str(e))}", err=True)
|
|
244
|
+
raise typer.Exit(code=1) from e
|
|
245
|
+
|
|
246
|
+
printed = emit(resp.entries, cursor, json_output=json_output)
|
|
247
|
+
if not follow:
|
|
248
|
+
if printed == 0 and not json_output:
|
|
249
|
+
typer.echo(f"No log entries in the last {window}.", err=True)
|
|
250
|
+
return
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
follow_logs(
|
|
254
|
+
client,
|
|
255
|
+
target_agent_id,
|
|
256
|
+
cursor=cursor,
|
|
257
|
+
since=window,
|
|
258
|
+
limit=limit,
|
|
259
|
+
json_output=json_output,
|
|
260
|
+
)
|
|
261
|
+
except KeyboardInterrupt:
|
|
262
|
+
raise typer.Exit(code=0) from None
|
|
263
|
+
except PushClientError as e:
|
|
264
|
+
code = e.envelope.error_code if e.envelope else None
|
|
265
|
+
typer.echo(f"\nLogs failed: {friendly_push_error(code, str(e))}", err=True)
|
|
266
|
+
raise typer.Exit(code=1) from e
|
|
@@ -116,17 +116,13 @@ def push(
|
|
|
116
116
|
settings: Settings = ctx.obj
|
|
117
117
|
target_env = env or settings.env
|
|
118
118
|
|
|
119
|
-
# Resolve agent_id from arg or manifest.
|
|
119
|
+
# Resolve agent_id from arg or manifest (same rule as `dooers logs`).
|
|
120
120
|
manifest = config.read_manifest()
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
err=True,
|
|
127
|
-
)
|
|
128
|
-
raise typer.Exit(code=1)
|
|
129
|
-
agent_id = manifest.agent_id
|
|
121
|
+
try:
|
|
122
|
+
agent_id = config.resolve_agent_id(agent_id, manifest)
|
|
123
|
+
except config.MissingAgentIdError as e:
|
|
124
|
+
typer.echo(str(e), err=True)
|
|
125
|
+
raise typer.Exit(code=1) from e
|
|
130
126
|
|
|
131
127
|
# Client-side hosting guard. The dooers-push server is the authoritative
|
|
132
128
|
# gate (it checks the org's hosting plan feature); this is a fast local
|
|
@@ -4,6 +4,7 @@ from pathlib import Path
|
|
|
4
4
|
|
|
5
5
|
import httpx
|
|
6
6
|
from dooers.protocol.errors import ErrorCode, ErrorEnvelope
|
|
7
|
+
from dooers.protocol.logs import LogsResponse
|
|
7
8
|
from dooers.protocol.push import BuildStatusResponse, PushAcceptedResponse
|
|
8
9
|
from dooers.protocol.teardown import TeardownResponse
|
|
9
10
|
|
|
@@ -82,6 +83,34 @@ class PushClient:
|
|
|
82
83
|
self._raise_for_status(r)
|
|
83
84
|
return BuildStatusResponse.model_validate(r.json())
|
|
84
85
|
|
|
86
|
+
def get_logs(
|
|
87
|
+
self,
|
|
88
|
+
agent_id: str,
|
|
89
|
+
*,
|
|
90
|
+
since: str | None = None,
|
|
91
|
+
limit: int = 100,
|
|
92
|
+
page_token: str | None = None,
|
|
93
|
+
) -> LogsResponse:
|
|
94
|
+
"""Read recent log entries for an agent.
|
|
95
|
+
|
|
96
|
+
`since` is an RFC3339 timestamp or a duration like `15m`. Raises
|
|
97
|
+
PushTransientError on 5xx/network so a `--follow` caller survives blips;
|
|
98
|
+
raises PushClientError on a 4xx envelope.
|
|
99
|
+
"""
|
|
100
|
+
url = f"{self.base_url}/v1/agents/{agent_id}/logs"
|
|
101
|
+
params: dict[str, str] = {"limit": str(limit)}
|
|
102
|
+
if since is not None:
|
|
103
|
+
params["since"] = since
|
|
104
|
+
if page_token is not None:
|
|
105
|
+
params["page_token"] = page_token
|
|
106
|
+
try:
|
|
107
|
+
r = httpx.get(url, headers=self._headers, params=params, timeout=self._timeout)
|
|
108
|
+
except httpx.HTTPError as e:
|
|
109
|
+
raise PushTransientError(f"logs request failed: {e}") from e
|
|
110
|
+
|
|
111
|
+
self._raise_for_status(r)
|
|
112
|
+
return LogsResponse.model_validate(r.json())
|
|
113
|
+
|
|
85
114
|
def teardown(self, agent_id: str, env: str = "prod") -> TeardownResponse:
|
|
86
115
|
url = f"{self.base_url}/v1/agents/{agent_id}"
|
|
87
116
|
headers = {"Authorization": f"Bearer {self.token}"}
|
|
@@ -6,7 +6,8 @@ from dooers.cli.env_prod import upsert_agent_seed_secret
|
|
|
6
6
|
def test_upsert_creates_env_prod(tmp_path: Path) -> None:
|
|
7
7
|
path = upsert_agent_seed_secret(tmp_path, "secret-one-time-key-abc")
|
|
8
8
|
assert path.name == "env.prod"
|
|
9
|
-
|
|
9
|
+
written = path.read_text(encoding="utf-8").strip()
|
|
10
|
+
assert written.endswith("AGENT_SEED_SECRET=secret-one-time-key-abc")
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
def test_upsert_replaces_existing_line(tmp_path: Path) -> None:
|
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
"""`dooers logs` — one-shot render, --json shape, --since parsing, follow-mode dedupe.
|
|
2
|
+
|
|
3
|
+
Follow mode is driven through the `sleep` / `max_polls` seam on `follow_logs`, so no
|
|
4
|
+
test here ever waits on a real clock.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from collections.abc import Iterator
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
import pytest
|
|
11
|
+
import respx
|
|
12
|
+
import typer
|
|
13
|
+
from dooers.protocol.logs import LogEntry, LogsResponse
|
|
14
|
+
from typer.testing import CliRunner
|
|
15
|
+
|
|
16
|
+
from dooers.cli.cli import app
|
|
17
|
+
from dooers.cli.logs import _Cursor, _parse_since, emit, follow_logs
|
|
18
|
+
from dooers.cli.push_client import PushClient, PushClientError, PushTransientError
|
|
19
|
+
|
|
20
|
+
runner = CliRunner()
|
|
21
|
+
|
|
22
|
+
PUSH = "https://push.test"
|
|
23
|
+
A = "550e8400-e29b-41d4-a716-446655440000"
|
|
24
|
+
ROOT = ["--push-url", PUSH, "--env", "dev"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _auth(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
28
|
+
class _Tok:
|
|
29
|
+
def load(self) -> str:
|
|
30
|
+
return "tok"
|
|
31
|
+
|
|
32
|
+
monkeypatch.setattr("dooers.cli.logs.TokenStore", _Tok)
|
|
33
|
+
monkeypatch.setattr("dooers.cli.logs.is_token_expired", lambda token, store: False)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _entry(timestamp: str, severity: str = "INFO", message: str = "hello") -> dict[str, str]:
|
|
37
|
+
return {"timestamp": timestamp, "severity": severity, "message": message}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _logs_response(*entries: dict[str, str], next_page_token: str | None = None) -> httpx.Response:
|
|
41
|
+
return httpx.Response(
|
|
42
|
+
200,
|
|
43
|
+
json={
|
|
44
|
+
"agent_id": A,
|
|
45
|
+
"service_name": "agent-svc",
|
|
46
|
+
"region": "us-central1",
|
|
47
|
+
"entries": list(entries),
|
|
48
|
+
"next_page_token": next_page_token,
|
|
49
|
+
},
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# --------------------------------------------------------------------------- wiring
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_root_help_lists_logs() -> None:
|
|
57
|
+
result = runner.invoke(app, ["--help"])
|
|
58
|
+
assert result.exit_code == 0
|
|
59
|
+
assert "logs" in result.stdout
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_logs_help_lists_flags() -> None:
|
|
63
|
+
result = runner.invoke(app, ["logs", "--help"])
|
|
64
|
+
assert result.exit_code == 0
|
|
65
|
+
out = result.stdout
|
|
66
|
+
for flag in ("--follow", "--since", "--limit", "--json"):
|
|
67
|
+
assert flag in out
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ----------------------------------------------------------------------- one-shot
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@respx.mock
|
|
74
|
+
def test_one_shot_renders_entries(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
75
|
+
_auth(monkeypatch)
|
|
76
|
+
route = respx.get(f"{PUSH}/v1/agents/{A}/logs").mock(
|
|
77
|
+
return_value=_logs_response(
|
|
78
|
+
_entry("2026-08-07T12:00:00Z", "INFO", "server started"),
|
|
79
|
+
_entry("2026-08-07T12:00:05Z", "ERROR", "boom"),
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
result = runner.invoke(app, ROOT + ["logs", A])
|
|
84
|
+
|
|
85
|
+
assert result.exit_code == 0, result.output
|
|
86
|
+
assert route.called
|
|
87
|
+
assert "12:00:00" in result.output
|
|
88
|
+
assert "INFO" in result.output
|
|
89
|
+
assert "server started" in result.output
|
|
90
|
+
assert "12:00:05" in result.output
|
|
91
|
+
assert "ERROR" in result.output
|
|
92
|
+
assert "boom" in result.output
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@respx.mock
|
|
96
|
+
def test_one_shot_sends_default_since_and_limit(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
97
|
+
_auth(monkeypatch)
|
|
98
|
+
respx.get(f"{PUSH}/v1/agents/{A}/logs").mock(
|
|
99
|
+
return_value=_logs_response(_entry("2026-08-07T12:00:00Z"))
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
result = runner.invoke(app, ROOT + ["logs", A])
|
|
103
|
+
|
|
104
|
+
assert result.exit_code == 0, result.output
|
|
105
|
+
params = respx.calls.last.request.url.params
|
|
106
|
+
assert params["since"] == "15m"
|
|
107
|
+
assert params["limit"] == "100"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@respx.mock
|
|
111
|
+
def test_entries_print_oldest_first_even_if_server_returns_newest_first(
|
|
112
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
113
|
+
) -> None:
|
|
114
|
+
_auth(monkeypatch)
|
|
115
|
+
respx.get(f"{PUSH}/v1/agents/{A}/logs").mock(
|
|
116
|
+
return_value=_logs_response(
|
|
117
|
+
_entry("2026-08-07T12:00:09Z", "INFO", "second"),
|
|
118
|
+
_entry("2026-08-07T12:00:01Z", "INFO", "first"),
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
result = runner.invoke(app, ROOT + ["logs", A])
|
|
123
|
+
|
|
124
|
+
assert result.exit_code == 0, result.output
|
|
125
|
+
assert result.output.index("first") < result.output.index("second")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@respx.mock
|
|
129
|
+
def test_json_output_shape(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
130
|
+
_auth(monkeypatch)
|
|
131
|
+
respx.get(f"{PUSH}/v1/agents/{A}/logs").mock(
|
|
132
|
+
return_value=_logs_response(
|
|
133
|
+
_entry("2026-08-07T12:00:00Z", "INFO", "one"),
|
|
134
|
+
_entry("2026-08-07T12:00:01Z", "ERROR", "two"),
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
result = runner.invoke(app, ROOT + ["logs", A, "--json"])
|
|
139
|
+
|
|
140
|
+
assert result.exit_code == 0, result.output
|
|
141
|
+
lines = [line for line in result.output.splitlines() if line.strip()]
|
|
142
|
+
assert lines == [
|
|
143
|
+
'{"timestamp":"2026-08-07T12:00:00Z","severity":"INFO","message":"one"}',
|
|
144
|
+
'{"timestamp":"2026-08-07T12:00:01Z","severity":"ERROR","message":"two"}',
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@respx.mock
|
|
149
|
+
def test_agent_id_falls_back_to_manifest(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
|
|
150
|
+
from dooers.protocol import PROTOCOL_VERSION
|
|
151
|
+
from dooers.protocol.agents import AgentManifest
|
|
152
|
+
|
|
153
|
+
from dooers.cli import config
|
|
154
|
+
|
|
155
|
+
_auth(monkeypatch)
|
|
156
|
+
config.write_manifest(
|
|
157
|
+
AgentManifest(
|
|
158
|
+
protocol_version=PROTOCOL_VERSION,
|
|
159
|
+
agent_id=A,
|
|
160
|
+
name="A",
|
|
161
|
+
organization_id="o1",
|
|
162
|
+
),
|
|
163
|
+
directory=tmp_path,
|
|
164
|
+
)
|
|
165
|
+
monkeypatch.chdir(tmp_path)
|
|
166
|
+
route = respx.get(f"{PUSH}/v1/agents/{A}/logs").mock(
|
|
167
|
+
return_value=_logs_response(_entry("2026-08-07T12:00:00Z", "INFO", "from manifest"))
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
result = runner.invoke(app, ROOT + ["logs"])
|
|
171
|
+
|
|
172
|
+
assert result.exit_code == 0, result.output
|
|
173
|
+
assert route.called
|
|
174
|
+
assert "from manifest" in result.output
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_missing_agent_id_and_manifest_exits_1(
|
|
178
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path
|
|
179
|
+
) -> None:
|
|
180
|
+
_auth(monkeypatch)
|
|
181
|
+
monkeypatch.chdir(tmp_path)
|
|
182
|
+
|
|
183
|
+
result = runner.invoke(app, ROOT + ["logs"])
|
|
184
|
+
|
|
185
|
+
assert result.exit_code == 1
|
|
186
|
+
assert "dooers.yaml" in result.output
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@respx.mock
|
|
190
|
+
def test_client_error_exits_1(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
191
|
+
_auth(monkeypatch)
|
|
192
|
+
respx.get(f"{PUSH}/v1/agents/{A}/logs").mock(
|
|
193
|
+
return_value=httpx.Response(
|
|
194
|
+
404,
|
|
195
|
+
json={"error_code": "not_found", "message": "agent not found", "correlation_id": "c1"},
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
result = runner.invoke(app, ROOT + ["logs", A])
|
|
200
|
+
|
|
201
|
+
assert result.exit_code == 1
|
|
202
|
+
assert "agent not found" in result.output
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ------------------------------------------------------------------------- --since
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@pytest.mark.parametrize("value", ["15m", "1h", "2h30m", "45s", "1h30m15s", "90S"])
|
|
209
|
+
def test_parse_since_accepts_durations(value: str) -> None:
|
|
210
|
+
assert _parse_since(value) == value.strip().lower()
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@pytest.mark.parametrize("value", ["", "banana", "15", "m15", "5x", "1d", "-5m", "30m2h"])
|
|
214
|
+
def test_parse_since_rejects_garbage(value: str) -> None:
|
|
215
|
+
with pytest.raises(typer.BadParameter):
|
|
216
|
+
_parse_since(value)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def test_bad_since_exits_nonzero(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
220
|
+
_auth(monkeypatch)
|
|
221
|
+
result = runner.invoke(app, ROOT + ["logs", A, "--since", "banana"])
|
|
222
|
+
assert result.exit_code != 0
|
|
223
|
+
assert "banana" in result.output
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# -------------------------------------------------------------------------- follow
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class _FakeClient:
|
|
230
|
+
"""Yields a scripted sequence of get_logs outcomes (response or exception)."""
|
|
231
|
+
|
|
232
|
+
def __init__(self, script: list[object]) -> None:
|
|
233
|
+
self._it: Iterator[object] = iter(script)
|
|
234
|
+
self.calls: list[str | None] = []
|
|
235
|
+
|
|
236
|
+
def get_logs(
|
|
237
|
+
self,
|
|
238
|
+
agent_id: str,
|
|
239
|
+
*,
|
|
240
|
+
since: str | None = None,
|
|
241
|
+
limit: int = 100,
|
|
242
|
+
page_token: str | None = None,
|
|
243
|
+
) -> LogsResponse:
|
|
244
|
+
self.calls.append(since)
|
|
245
|
+
item = next(self._it, None)
|
|
246
|
+
if isinstance(item, Exception):
|
|
247
|
+
raise item
|
|
248
|
+
if item is None:
|
|
249
|
+
return _response([])
|
|
250
|
+
assert isinstance(item, LogsResponse)
|
|
251
|
+
return item
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _response(entries: list[LogEntry]) -> LogsResponse:
|
|
255
|
+
return LogsResponse(
|
|
256
|
+
agent_id=A, service_name="agent-svc", region="us-central1", entries=entries
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _e(timestamp: str, message: str, severity: str = "INFO") -> LogEntry:
|
|
261
|
+
return LogEntry(timestamp=timestamp, severity=severity, message=message)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def test_follow_prints_only_new_entries_from_overlapping_window(
|
|
265
|
+
capsys: pytest.CaptureFixture[str],
|
|
266
|
+
) -> None:
|
|
267
|
+
cursor = _Cursor()
|
|
268
|
+
first = [_e("2026-08-07T12:00:00Z", "one"), _e("2026-08-07T12:00:01Z", "two")]
|
|
269
|
+
# Second poll re-returns the tail of the first window plus two new lines.
|
|
270
|
+
second = [
|
|
271
|
+
_e("2026-08-07T12:00:01Z", "two"),
|
|
272
|
+
_e("2026-08-07T12:00:02Z", "three"),
|
|
273
|
+
_e("2026-08-07T12:00:03Z", "four"),
|
|
274
|
+
]
|
|
275
|
+
emit(first, cursor, json_output=False)
|
|
276
|
+
capsys.readouterr()
|
|
277
|
+
|
|
278
|
+
client = _FakeClient([_response(second)])
|
|
279
|
+
follow_logs(
|
|
280
|
+
client, # type: ignore[arg-type]
|
|
281
|
+
A,
|
|
282
|
+
cursor=cursor,
|
|
283
|
+
since="15m",
|
|
284
|
+
limit=100,
|
|
285
|
+
sleep=lambda *_: None,
|
|
286
|
+
max_polls=1,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
out = capsys.readouterr().out
|
|
290
|
+
assert "three" in out
|
|
291
|
+
assert "four" in out
|
|
292
|
+
assert "two" not in out # already printed by the first batch
|
|
293
|
+
# The poll narrowed its window to the newest timestamp already printed.
|
|
294
|
+
assert client.calls == ["2026-08-07T12:00:01Z"]
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def test_follow_keeps_distinct_messages_sharing_one_timestamp(
|
|
298
|
+
capsys: pytest.CaptureFixture[str],
|
|
299
|
+
) -> None:
|
|
300
|
+
cursor = _Cursor()
|
|
301
|
+
emit([_e("2026-08-07T12:00:00Z", "first")], cursor, json_output=False)
|
|
302
|
+
capsys.readouterr()
|
|
303
|
+
|
|
304
|
+
client = _FakeClient(
|
|
305
|
+
[
|
|
306
|
+
_response(
|
|
307
|
+
[
|
|
308
|
+
_e("2026-08-07T12:00:00Z", "first"), # dupe: same ts + message
|
|
309
|
+
_e("2026-08-07T12:00:00Z", "second"), # same ts, new message
|
|
310
|
+
]
|
|
311
|
+
)
|
|
312
|
+
]
|
|
313
|
+
)
|
|
314
|
+
follow_logs(
|
|
315
|
+
client, # type: ignore[arg-type]
|
|
316
|
+
A,
|
|
317
|
+
cursor=cursor,
|
|
318
|
+
since="15m",
|
|
319
|
+
limit=100,
|
|
320
|
+
sleep=lambda *_: None,
|
|
321
|
+
max_polls=1,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
out = capsys.readouterr().out
|
|
325
|
+
assert "second" in out
|
|
326
|
+
assert out.count("first") == 0
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def test_follow_tolerates_transient_error(capsys: pytest.CaptureFixture[str]) -> None:
|
|
330
|
+
cursor = _Cursor()
|
|
331
|
+
client = _FakeClient(
|
|
332
|
+
[
|
|
333
|
+
PushTransientError("transient upstream error (HTTP 503)"),
|
|
334
|
+
PushTransientError("transient upstream error (HTTP 503)"),
|
|
335
|
+
_response([_e("2026-08-07T12:00:02Z", "recovered")]),
|
|
336
|
+
]
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
follow_logs(
|
|
340
|
+
client, # type: ignore[arg-type]
|
|
341
|
+
A,
|
|
342
|
+
cursor=cursor,
|
|
343
|
+
since="15m",
|
|
344
|
+
limit=100,
|
|
345
|
+
sleep=lambda *_: None,
|
|
346
|
+
max_polls=3,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
captured = capsys.readouterr()
|
|
350
|
+
assert "recovered" in captured.out
|
|
351
|
+
assert len(client.calls) == 3 # the blips did not abort the loop
|
|
352
|
+
assert captured.err.count("Warning:") == 1 # warned once per outage, not per poll
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def test_follow_aborts_on_client_error() -> None:
|
|
356
|
+
cursor = _Cursor()
|
|
357
|
+
client = _FakeClient([PushClientError("agent not found")])
|
|
358
|
+
|
|
359
|
+
with pytest.raises(PushClientError):
|
|
360
|
+
follow_logs(
|
|
361
|
+
client, # type: ignore[arg-type]
|
|
362
|
+
A,
|
|
363
|
+
cursor=cursor,
|
|
364
|
+
since="15m",
|
|
365
|
+
limit=100,
|
|
366
|
+
sleep=lambda *_: None,
|
|
367
|
+
max_polls=3,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def test_follow_never_sleeps_for_real() -> None:
|
|
372
|
+
"""The poll interval goes through the injected sleep, so tests stay instant."""
|
|
373
|
+
slept: list[float] = []
|
|
374
|
+
client = _FakeClient([_response([])])
|
|
375
|
+
|
|
376
|
+
follow_logs(
|
|
377
|
+
client, # type: ignore[arg-type]
|
|
378
|
+
A,
|
|
379
|
+
cursor=_Cursor(),
|
|
380
|
+
since="15m",
|
|
381
|
+
limit=100,
|
|
382
|
+
sleep=lambda s: slept.append(s),
|
|
383
|
+
max_polls=1,
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
assert slept == [2.0]
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
# -------------------------------------------------------------- PushClient.get_logs
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
@respx.mock
|
|
393
|
+
def test_get_logs_parses_response() -> None:
|
|
394
|
+
respx.get(f"{PUSH}/v1/agents/{A}/logs").mock(
|
|
395
|
+
return_value=_logs_response(
|
|
396
|
+
_entry("2026-08-07T12:00:00Z", "WARNING", "slow"), next_page_token="tok"
|
|
397
|
+
)
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
resp = PushClient(PUSH, token="t").get_logs(A, since="15m", limit=50, page_token="p1")
|
|
401
|
+
|
|
402
|
+
assert isinstance(resp, LogsResponse)
|
|
403
|
+
assert resp.service_name == "agent-svc"
|
|
404
|
+
assert resp.next_page_token == "tok"
|
|
405
|
+
assert [e.severity for e in resp.entries] == ["WARNING"]
|
|
406
|
+
params = respx.calls.last.request.url.params
|
|
407
|
+
assert params["since"] == "15m"
|
|
408
|
+
assert params["limit"] == "50"
|
|
409
|
+
assert params["page_token"] == "p1"
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
@respx.mock
|
|
413
|
+
def test_get_logs_503_raises_transient() -> None:
|
|
414
|
+
respx.get(f"{PUSH}/v1/agents/{A}/logs").mock(
|
|
415
|
+
return_value=httpx.Response(503, text="service unavailable")
|
|
416
|
+
)
|
|
417
|
+
with pytest.raises(PushTransientError):
|
|
418
|
+
PushClient(PUSH, token="t").get_logs(A)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
@respx.mock
|
|
422
|
+
def test_get_logs_network_error_raises_transient() -> None:
|
|
423
|
+
respx.get(f"{PUSH}/v1/agents/{A}/logs").mock(side_effect=httpx.ConnectError("boom"))
|
|
424
|
+
with pytest.raises(PushTransientError):
|
|
425
|
+
PushClient(PUSH, token="t").get_logs(A)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
@respx.mock
|
|
429
|
+
def test_get_logs_404_raises_client_error() -> None:
|
|
430
|
+
respx.get(f"{PUSH}/v1/agents/{A}/logs").mock(
|
|
431
|
+
return_value=httpx.Response(
|
|
432
|
+
404,
|
|
433
|
+
json={"error_code": "not_found", "message": "agent not found", "correlation_id": "c1"},
|
|
434
|
+
)
|
|
435
|
+
)
|
|
436
|
+
with pytest.raises(PushClientError) as exc:
|
|
437
|
+
PushClient(PUSH, token="t").get_logs(A)
|
|
438
|
+
assert not isinstance(exc.value, PushTransientError)
|
|
439
|
+
assert "agent not found" in str(exc.value)
|
|
@@ -232,7 +232,7 @@ toml = [
|
|
|
232
232
|
|
|
233
233
|
[[package]]
|
|
234
234
|
name = "dooers-cli"
|
|
235
|
-
version = "0.
|
|
235
|
+
version = "0.9.0"
|
|
236
236
|
source = { editable = "." }
|
|
237
237
|
dependencies = [
|
|
238
238
|
{ name = "dooers-protocol" },
|
|
@@ -278,7 +278,7 @@ provides-extras = ["dev"]
|
|
|
278
278
|
|
|
279
279
|
[[package]]
|
|
280
280
|
name = "dooers-protocol"
|
|
281
|
-
version = "0.
|
|
281
|
+
version = "0.8.0"
|
|
282
282
|
source = { editable = "../dooers-protocol" }
|
|
283
283
|
dependencies = [
|
|
284
284
|
{ name = "pydantic" },
|
dooers_cli-0.8.2/README.md
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
# dooers (CLI)
|
|
2
|
-
|
|
3
|
-
The Dooers CLI. Installed by agent creators.
|
|
4
|
-
|
|
5
|
-
```bash
|
|
6
|
-
pip install dooers
|
|
7
|
-
```
|
|
8
|
-
|
|
9
|
-
## Commands
|
|
10
|
-
|
|
11
|
-
```bash
|
|
12
|
-
# auth (email prompts if omitted)
|
|
13
|
-
dooers login you@example.com
|
|
14
|
-
dooers whoami
|
|
15
|
-
dooers logout
|
|
16
|
-
|
|
17
|
-
# agents
|
|
18
|
-
dooers agents list
|
|
19
|
-
dooers agents create --name my-agent
|
|
20
|
-
dooers agents show ag_8h2k
|
|
21
|
-
|
|
22
|
-
# push (reads dooers.yaml in cwd unless agent_id given)
|
|
23
|
-
dooers validate
|
|
24
|
-
dooers push [<agent_id>] [--tag latest] [--env prod|stg|dev]
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
## Global config (precedence: flag > env > default)
|
|
28
|
-
|
|
29
|
-
| Setting | Flag | Env var | Default |
|
|
30
|
-
|---|---|---|---|
|
|
31
|
-
| Core API host | `--core-url` | `DOOERS_CORE_URL` | `https://api.dooers.ai` |
|
|
32
|
-
| dooers-push host | `--push-url` | `DOOERS_PUSH_URL` | `https://host.dooers.ai` |
|
|
33
|
-
| Target environment | `--env` | `DOOERS_ENV` | `prod` |
|
|
34
|
-
|
|
35
|
-
## Development
|
|
36
|
-
|
|
37
|
-
```bash
|
|
38
|
-
uv sync --extra dev
|
|
39
|
-
uv run poe dev
|
|
40
|
-
```
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|