mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-08-14 08:52:06 +00:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
403dec8e98 | ||
|
|
2c7cf6118c | ||
|
|
2bdd860b54 | ||
|
|
81f1ffbb4f | ||
|
|
93fc7b9e77 | ||
|
|
9fc5b875d1 | ||
|
|
08279e6b99 | ||
|
|
a7c31e89b4 | ||
|
|
04014c658a | ||
|
|
c1238d3e7e | ||
|
|
687e80a55a | ||
|
|
b90fd01af2 | ||
|
|
bbe7df7d33 | ||
|
|
9685b9b78f | ||
|
|
aa2d127de4 | ||
|
|
87f6238338 | ||
|
|
452bcc38cf | ||
|
|
b35a4c8113 | ||
|
|
f001e3b0ca | ||
|
|
b6dba93ae5 | ||
|
|
3000116d18 | ||
|
|
9db21d37ef | ||
|
|
95480363b7 | ||
|
|
4419b76412 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"label": "Git Clones",
|
||||
"message": "159,322",
|
||||
"message": "175,911",
|
||||
"color": "green",
|
||||
"namedLogo": "git"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"total_clones": 159322,
|
||||
"last_updated": "2026-07-16T08:08:14Z",
|
||||
"total_clones": 175911,
|
||||
"last_updated": "2026-07-28T08:29:18Z",
|
||||
"daily": {
|
||||
"2026-03-27": 2189,
|
||||
"2026-03-28": 1874,
|
||||
@@ -112,6 +112,18 @@
|
||||
"2026-07-12": 1917,
|
||||
"2026-07-13": 2102,
|
||||
"2026-07-14": 2337,
|
||||
"2026-07-15": 2362
|
||||
"2026-07-15": 2362,
|
||||
"2026-07-16": 2497,
|
||||
"2026-07-17": 1773,
|
||||
"2026-07-18": 1542,
|
||||
"2026-07-19": 1445,
|
||||
"2026-07-20": 1481,
|
||||
"2026-07-21": 1528,
|
||||
"2026-07-22": 1529,
|
||||
"2026-07-23": 1209,
|
||||
"2026-07-24": 1118,
|
||||
"2026-07-25": 928,
|
||||
"2026-07-26": 740,
|
||||
"2026-07-27": 799
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Full system access: unrestricted shell and filesystem
|
||||
# Copy to ~/.openjarvis/config.toml
|
||||
#
|
||||
# WARNING: shell_exec runs arbitrary commands as your user. No command
|
||||
# allowlist, no denylist, no working-directory restriction. file_read and
|
||||
# file_write aren't restricted to any directory either. Only enable what you
|
||||
# actually want the agent to have. tools.enabled is the whole permission grant;
|
||||
# there's no second allowlist to configure.
|
||||
#
|
||||
# On macOS, this config alone does not reach TCC-protected data (Messages,
|
||||
# Mail, Photos, Safari). That requires Full Disk Access granted to the process
|
||||
# hosting the backend. See docs/user-guide/system-access.md.
|
||||
#
|
||||
# Usage:
|
||||
# jarvis ask "What's using the most disk space in my home directory?"
|
||||
# jarvis chat # prompts before each shell_exec call
|
||||
|
||||
[engine]
|
||||
default = "ollama"
|
||||
|
||||
[intelligence]
|
||||
default_model = "qwen3.5:9b"
|
||||
|
||||
[agent]
|
||||
default_agent = "orchestrator"
|
||||
max_turns = 10
|
||||
|
||||
[tools]
|
||||
enabled = [
|
||||
"shell_exec",
|
||||
"file_read",
|
||||
"file_write",
|
||||
"apply_patch",
|
||||
"code_interpreter",
|
||||
"git_status",
|
||||
"git_diff",
|
||||
"think",
|
||||
"calculator",
|
||||
]
|
||||
@@ -604,7 +604,7 @@ enforce_tool_confirmation = true
|
||||
| `scan_output` | bool | `true` | Whether to scan model output. |
|
||||
| `secret_scanner` | bool | `true` | Enable secret detection (API keys, tokens, passwords). |
|
||||
| `pii_scanner` | bool | `true` | Enable PII detection (emails, SSNs, credit cards). |
|
||||
| `enforce_tool_confirmation` | bool | `true` | Require confirmation before executing tools. |
|
||||
| `enforce_tool_confirmation` | bool | `true` | Accepted but **not currently enforced**. Whether you get prompts depends on the entry point. See [System Access](../user-guide/system-access.md#confirmation-behaviour). |
|
||||
|
||||
!!! tip "Choosing a security mode"
|
||||
Use `"warn"` during development to see what would be flagged without disrupting output.
|
||||
|
||||
@@ -392,7 +392,7 @@ enforce_tool_confirmation = true
|
||||
| `secret_scanner` | `bool` | `true` | Run `SecretScanner` on all text |
|
||||
| `pii_scanner` | `bool` | `true` | Run `PIIScanner` on all text |
|
||||
| `audit_log_path` | `str` | `~/.openjarvis/audit.db` | Path to the SQLite audit log |
|
||||
| `enforce_tool_confirmation` | `bool` | `true` | Require explicit confirmation before tool execution |
|
||||
| `enforce_tool_confirmation` | `bool` | `true` | Accepted by the loader but **not currently enforced**. See [System Access](system-access.md#confirmation-behaviour) for when prompts actually happen |
|
||||
|
||||
!!! tip "Start with warn, tighten later"
|
||||
`mode = "warn"` is a good starting point. It lets you observe what patterns are being triggered without disrupting normal usage. Switch to `"redact"` once you are satisfied that the scanner isn't producing too many false positives for your workload.
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
# System Access
|
||||
|
||||
How to give an agent access to the machine it runs on, and where the real
|
||||
limits are.
|
||||
|
||||
!!! warning
|
||||
`shell_exec` runs arbitrary commands as your user. There is no command
|
||||
allowlist, no denylist, and no sandbox unless you turn one on. An agent
|
||||
holding this tool can do anything you can do from a terminal.
|
||||
|
||||
---
|
||||
|
||||
## Start here: you probably have no tools enabled
|
||||
|
||||
If the agent tells you it can't run commands or read files, that's usually not
|
||||
a permissions problem. It means no tools were enabled in the first place.
|
||||
|
||||
Tools come from `tools.enabled`, falling back to `agent.tools`. Both default to
|
||||
empty, and an empty value builds the agent with **zero tools**. Nothing is
|
||||
enabled by default.
|
||||
|
||||
First check whether you have a config file at all:
|
||||
|
||||
```bash
|
||||
cat ~/.openjarvis/config.toml
|
||||
```
|
||||
|
||||
If it isn't there, that's your answer. Create it:
|
||||
|
||||
```toml
|
||||
[engine]
|
||||
default = "ollama"
|
||||
|
||||
[intelligence]
|
||||
default_model = "qwen3.5:9b"
|
||||
|
||||
[agent]
|
||||
default_agent = "orchestrator"
|
||||
|
||||
[tools]
|
||||
enabled = ["shell_exec", "file_read", "file_write", "think"]
|
||||
```
|
||||
|
||||
There's a fuller version at
|
||||
`configs/openjarvis/examples/full-system-access.toml`.
|
||||
|
||||
Then confirm the list actually resolved:
|
||||
|
||||
```bash
|
||||
python -c "from openjarvis.core.config import load_config; print(load_config().tools.enabled)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What the tools reach
|
||||
|
||||
| Tool | Scope |
|
||||
|------|-------|
|
||||
| `shell_exec` | Any command, as your user. 30s default timeout, 300s max, output capped at 100 KB per stream. |
|
||||
| `file_read` | Any readable path. 1 MB cap. |
|
||||
| `file_write` | Any writable path. 10 MB cap, can create parent directories. |
|
||||
| `apply_patch` | Applies unified diffs to any path. |
|
||||
| `code_interpreter` | Python in a subprocess, behind a coarse pattern blocklist. |
|
||||
|
||||
`file_read` and `file_write` take an `allowed_dirs` argument that limits them to
|
||||
a set of directories, but no config key populates it. When it's empty every path
|
||||
is allowed. If you want a filesystem jail today, use the container sandbox
|
||||
instead of relying on these tools to enforce one.
|
||||
|
||||
### Sensitive filenames
|
||||
|
||||
`file_read` and `file_write` refuse names matching a short glob list: `.env`,
|
||||
`*.pem`, `id_rsa`, `credentials.*` and a dozen or so others. It matches on the
|
||||
filename only, not the path or the contents, and only those two tools consult
|
||||
it. `shell_exec`, `apply_patch` and `code_interpreter` skip it entirely, so
|
||||
`cat ~/.ssh/id_rsa` through `shell_exec` works fine. Treat it as protection
|
||||
against fat fingers, not as a security boundary.
|
||||
|
||||
---
|
||||
|
||||
## Confirmation behaviour
|
||||
|
||||
`shell_exec`, `git_commit` and `agent_kill` are marked `requires_confirmation`.
|
||||
What that translates to depends entirely on how you launched the agent:
|
||||
|
||||
| Entry point | Behaviour |
|
||||
|-------------|-----------|
|
||||
| `jarvis chat` | Prompts before each call. |
|
||||
| `jarvis ask` | Auto-approves. |
|
||||
| `jarvis agent ask` | Auto-approves. Pass `--no-yes` if you want prompts. |
|
||||
| HTTP server, desktop app | Auto-approves. Tools you added to an agent's toolkit count as pre-approved. |
|
||||
| Embedded via `SystemBuilder` | No callback is wired, so these tools fail closed. |
|
||||
|
||||
That last row catches people out. If `shell_exec` returns "requires
|
||||
confirmation but no confirmation callback is available", you're constructing the
|
||||
agent yourself and need to pass a `confirm_callback`.
|
||||
|
||||
!!! note "`enforce_tool_confirmation` doesn't do anything"
|
||||
The config loader accepts `security.enforce_tool_confirmation`, but nothing
|
||||
on the tool execution path reads it. Setting it won't change confirmation
|
||||
behaviour anywhere. Use the table above instead.
|
||||
|
||||
---
|
||||
|
||||
## macOS: Full Disk Access
|
||||
|
||||
On macOS the operating system is the real boundary, not the config. Shell
|
||||
access and ordinary file access start working as soon as you enable the tools.
|
||||
TCC-protected data does not: Messages, Mail, Photos, Safari history, Contacts
|
||||
and Calendar all stay locked, and no config key will change that.
|
||||
|
||||
Grant Full Disk Access to whichever process hosts the backend. Child processes
|
||||
inherit it:
|
||||
|
||||
| How you run OpenJarvis | Grant access to |
|
||||
|------------------------|-----------------|
|
||||
| CLI (`jarvis ask`, `jarvis chat`) | Your terminal (Terminal, iTerm, Warp) |
|
||||
| Desktop app | `OpenJarvis.app`, which spawns `jarvis serve` beneath it |
|
||||
| launchd (`deploy/launchd/com.openjarvis.plist`) | The `jarvis` binary, as its own entry |
|
||||
|
||||
System Settings, then Privacy & Security, then Full Disk Access, then **+**.
|
||||
|
||||
A launchd daemon gets its own TCC context, so granting access to Terminal does
|
||||
nothing for it. Add `/usr/local/bin/jarvis` separately.
|
||||
|
||||
To check whether the grant took:
|
||||
|
||||
```bash
|
||||
head -c 16 ~/Library/Messages/chat.db >/dev/null 2>&1 \
|
||||
&& echo "granted" || echo "denied"
|
||||
```
|
||||
|
||||
Restart the host process after you change the setting.
|
||||
|
||||
### Driving Mac apps
|
||||
|
||||
AppleScript works through `shell_exec`:
|
||||
|
||||
```
|
||||
osascript -e 'tell application "Music" to play'
|
||||
```
|
||||
|
||||
macOS asks for Automation permission once per target app, the first time you
|
||||
touch it.
|
||||
|
||||
---
|
||||
|
||||
## What you can't do
|
||||
|
||||
There's no computer use. OpenJarvis can't see your screen, move the pointer or
|
||||
send keystrokes. No tool for it is registered and no input automation library
|
||||
appears anywhere in the codebase, so granting Accessibility or Screen Recording
|
||||
buys you nothing on its own.
|
||||
|
||||
The `click` and `type` actions you'll find are Playwright, scoped to a browser
|
||||
page rather than the desktop.
|
||||
|
||||
Some of this is reachable through `shell_exec` if you bring the tooling
|
||||
yourself. `screencapture` will take screenshots once you've granted Screen
|
||||
Recording, and something like `cliclick` will move the pointer. That gets you
|
||||
scripted actions. It doesn't get you an agent that looks at the screen and
|
||||
works out where to click.
|
||||
|
||||
---
|
||||
|
||||
## Narrowing access
|
||||
|
||||
Access widens and narrows through `tools.enabled`. Drop entries to take
|
||||
capabilities away. That list is the whole grant.
|
||||
|
||||
Two stronger isolation options exist. Both are off by default:
|
||||
|
||||
```toml
|
||||
[sandbox]
|
||||
enabled = true # run tools inside a container
|
||||
runtime = "docker"
|
||||
|
||||
[security.capabilities]
|
||||
enabled = true # RBAC over declared tool capabilities
|
||||
policy_path = "~/.openjarvis/policy.yaml"
|
||||
```
|
||||
|
||||
!!! note "Capabilities are open by default even once enabled"
|
||||
`CapabilityPolicy` is built with `default_deny=False` and no config key
|
||||
exposes that flag, so an agent with no explicit policy entry gets every
|
||||
capability. Write entries for every agent you mean to restrict.
|
||||
|
||||
For anything untrusted, reach for `docker_shell_exec` and
|
||||
`code_interpreter_docker` rather than the host-side versions.
|
||||
|
||||
---
|
||||
|
||||
## See also
|
||||
|
||||
- [Security](security.md) for scanners, the audit log and guardrails
|
||||
- [Tools](tools.md) for the full registry
|
||||
- [Code Assistant](code-assistant.md) for a narrower shell-enabled setup
|
||||
- [External MCP Servers](mcp-external-servers.md) for capabilities OpenJarvis doesn't ship
|
||||
@@ -22,6 +22,8 @@ export function ChatArea() {
|
||||
const navigate = useNavigate();
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const shouldAutoScroll = useRef(true);
|
||||
const wasStreaming = useRef(false);
|
||||
const lastScrollTop = useRef(0);
|
||||
|
||||
// Check if any data sources are connected
|
||||
const [hasConnectedSources, setHasConnectedSources] = useState<boolean | null>(null);
|
||||
@@ -34,15 +36,34 @@ export function ChatArea() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Sending a message always pins the view to the bottom, even if the
|
||||
// user had scrolled up to read earlier messages.
|
||||
if (streamState.isStreaming && !wasStreaming.current) {
|
||||
shouldAutoScroll.current = true;
|
||||
}
|
||||
wasStreaming.current = streamState.isStreaming;
|
||||
if (shouldAutoScroll.current && listRef.current) {
|
||||
listRef.current.scrollTop = listRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages, streamState.content]);
|
||||
}, [messages, streamState.content, streamState.isStreaming]);
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!listRef.current) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = listRef.current;
|
||||
shouldAutoScroll.current = scrollHeight - scrollTop - clientHeight < 100;
|
||||
const distance = scrollHeight - scrollTop - clientHeight;
|
||||
const scrolledUp = scrollTop < lastScrollTop.current;
|
||||
lastScrollTop.current = scrollTop;
|
||||
if (scrolledUp && distance >= 1) {
|
||||
// Any upward scroll away from the bottom stops autoscroll immediately,
|
||||
// so streaming content never fights the user (no jitter). Sub-1px
|
||||
// upward movement (elastic bounce settling at the bottom) is ignored.
|
||||
shouldAutoScroll.current = false;
|
||||
} else if (!scrolledUp) {
|
||||
// Re-engage when scrolled back to the bottom. < 2 rather than < 1:
|
||||
// at fractional zoom levels the at-bottom residual can reach 1px,
|
||||
// which would otherwise leave autoscroll permanently disengaged.
|
||||
shouldAutoScroll.current = distance < 2;
|
||||
}
|
||||
};
|
||||
|
||||
const isEmpty = messages.length === 0 && !streamState.isStreaming;
|
||||
|
||||
@@ -149,8 +149,7 @@ export function CommandPalette() {
|
||||
setCommandPaletteOpen(false);
|
||||
|
||||
if (modelId !== previousModel) {
|
||||
const { createConversation, setModelLoading, addLogEntry } = useAppStore.getState();
|
||||
createConversation(modelId);
|
||||
const { setModelLoading, addLogEntry } = useAppStore.getState();
|
||||
setModelLoading(true);
|
||||
addLogEntry({ timestamp: Date.now(), level: 'info', category: 'model', message: `Switching to ${modelId}...` });
|
||||
try {
|
||||
|
||||
@@ -196,6 +196,7 @@ nav:
|
||||
- Telemetry: user-guide/telemetry.md
|
||||
- Evaluations: user-guide/evaluations.md
|
||||
- Benchmarks: user-guide/benchmarks.md
|
||||
- System Access: user-guide/system-access.md
|
||||
- Security: user-guide/security.md
|
||||
- LLM-guided spec search: user-guide/llm-guided-spec-search.md
|
||||
- Leaderboard: leaderboard.md
|
||||
|
||||
@@ -81,14 +81,28 @@ def start(
|
||||
if agent_name:
|
||||
cmd.extend(["--agent", agent_name])
|
||||
|
||||
# Start as background process
|
||||
# Start as background process, fully detached from the launching terminal.
|
||||
#
|
||||
# ``start_new_session`` is POSIX-only: CPython's Windows ``_execute_child``
|
||||
# names the parameter ``unused_start_new_session`` and ignores it. Relying
|
||||
# on it there leaves the server sharing its parent's console, so closing
|
||||
# that console — or logging off — delivers CTRL_CLOSE_EVENT and kills the
|
||||
# daemon. DETACHED_PROCESS gives it no console at all; the new process
|
||||
# group additionally stops a Ctrl-C in the parent reaching it.
|
||||
DEFAULT_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
log_fh = open(_LOG_FILE, "a") # noqa: SIM115
|
||||
spawn_kwargs: dict = {}
|
||||
if sys.platform == "win32":
|
||||
spawn_kwargs["creationflags"] = (
|
||||
subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
)
|
||||
else:
|
||||
spawn_kwargs["start_new_session"] = True
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=log_fh,
|
||||
stderr=log_fh,
|
||||
start_new_session=True,
|
||||
**spawn_kwargs,
|
||||
)
|
||||
_write_pid(proc.pid)
|
||||
|
||||
|
||||
@@ -104,7 +104,9 @@ class SkillImporter:
|
||||
|
||||
try:
|
||||
frontmatter, body = self._read_skill_md(source_md)
|
||||
manifest = self._parser.parse_frontmatter(frontmatter, markdown_content=body)
|
||||
manifest = self._parser.parse_frontmatter(
|
||||
frontmatter, markdown_content=body
|
||||
)
|
||||
except Exception as exc:
|
||||
result.success = False
|
||||
result.warnings.append(f"Parse error: {exc}")
|
||||
|
||||
@@ -108,6 +108,13 @@ INSERT INTO telemetry (
|
||||
)
|
||||
"""
|
||||
|
||||
_INSERT_MINING = """\
|
||||
INSERT INTO mining_stats (
|
||||
recorded_at, provider_id, shares_submitted, shares_accepted, blocks_found,
|
||||
hashrate, uptime_seconds, last_share_at, last_error, payout_target, fees_owed
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"""
|
||||
|
||||
_MIGRATE_COLUMNS = [
|
||||
("gpu_utilization_pct", "REAL NOT NULL DEFAULT 0.0"),
|
||||
("gpu_memory_used_gb", "REAL NOT NULL DEFAULT 0.0"),
|
||||
@@ -144,10 +151,35 @@ _MIGRATE_COLUMNS = [
|
||||
|
||||
|
||||
class TelemetryStore:
|
||||
"""Append-only SQLite store for inference telemetry records."""
|
||||
"""Append-only SQLite store for inference telemetry records.
|
||||
|
||||
Writes are batched in memory and flushed to SQLite when a batch reaches
|
||||
``batch_size``, when ``flush_interval_seconds`` elapses (a background
|
||||
flusher thread guarantees this even with no further writes), on any read
|
||||
through this store, and on ``close()``. Readers that open their OWN
|
||||
connection to the database file (e.g. ``TelemetryAggregator``) therefore
|
||||
see new rows within ``flush_interval_seconds`` at the latest; call
|
||||
``flush()`` first for immediate visibility. Pass
|
||||
``flush_interval_seconds=0`` to disable time-based flushing (batch-size
|
||||
and read/close flushes still apply).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db_path: str | Path,
|
||||
batch_size: int = 50,
|
||||
flush_interval_seconds: float = 5.0,
|
||||
) -> None:
|
||||
if batch_size < 1:
|
||||
raise ValueError("batch_size must be >= 1")
|
||||
if flush_interval_seconds < 0:
|
||||
raise ValueError("flush_interval_seconds must be >= 0")
|
||||
|
||||
def __init__(self, db_path: str | Path) -> None:
|
||||
self._db_path = str(db_path)
|
||||
if self._db_path != ":memory:":
|
||||
from openjarvis.security.file_utils import secure_create
|
||||
|
||||
secure_create(Path(self._db_path))
|
||||
self._conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
||||
self._lock = threading.Lock()
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
@@ -158,6 +190,38 @@ class TelemetryStore:
|
||||
self._conn.commit()
|
||||
self._migrate_schema()
|
||||
|
||||
self._batch_size = batch_size
|
||||
self._flush_interval_seconds = flush_interval_seconds
|
||||
self._last_flush_time = time.monotonic()
|
||||
self._telemetry_batch: list[tuple[Any, ...]] = []
|
||||
self._mining_batch: list[tuple[Any, ...]] = []
|
||||
self._closed = False
|
||||
|
||||
# Background flusher: without it, a partial batch written just before
|
||||
# traffic stops would stay invisible to other connections until the
|
||||
# NEXT write arrived (the stale check in ``_maybe_flush_unlocked``
|
||||
# only runs inside record calls). Daemon so it never blocks exit.
|
||||
self._stop_flusher = threading.Event()
|
||||
self._flusher: threading.Thread | None = None
|
||||
if flush_interval_seconds > 0:
|
||||
self._flusher = threading.Thread(
|
||||
target=self._flush_loop,
|
||||
name="telemetry-store-flusher",
|
||||
daemon=True,
|
||||
)
|
||||
self._flusher.start()
|
||||
|
||||
def _flush_loop(self) -> None:
|
||||
"""Periodically flush pending batches until ``close()`` stops us."""
|
||||
while not self._stop_flusher.wait(self._flush_interval_seconds):
|
||||
with self._lock:
|
||||
# ``close()`` sets the event BEFORE taking the lock, so seeing
|
||||
# it unset here means the connection is still open.
|
||||
if self._stop_flusher.is_set():
|
||||
break
|
||||
if self._telemetry_batch or self._mining_batch:
|
||||
self._flush_unlocked()
|
||||
|
||||
def _migrate_schema(self) -> None:
|
||||
"""Add new columns to existing databases (idempotent)."""
|
||||
for col_name, col_def in _MIGRATE_COLUMNS:
|
||||
@@ -171,54 +235,52 @@ class TelemetryStore:
|
||||
|
||||
def record(self, rec: TelemetryRecord) -> None:
|
||||
"""Persist a single telemetry record."""
|
||||
row = (
|
||||
rec.timestamp,
|
||||
rec.model_id,
|
||||
rec.engine,
|
||||
rec.agent,
|
||||
rec.prompt_tokens,
|
||||
rec.prompt_tokens_evaluated,
|
||||
rec.completion_tokens,
|
||||
rec.total_tokens,
|
||||
rec.latency_seconds,
|
||||
rec.ttft,
|
||||
rec.cost_usd,
|
||||
rec.energy_joules,
|
||||
rec.power_watts,
|
||||
rec.gpu_utilization_pct,
|
||||
rec.gpu_memory_used_gb,
|
||||
rec.gpu_temperature_c,
|
||||
rec.throughput_tok_per_sec,
|
||||
rec.prefill_latency_seconds,
|
||||
rec.decode_latency_seconds,
|
||||
rec.energy_method,
|
||||
rec.energy_vendor,
|
||||
rec.batch_id,
|
||||
1 if rec.is_warmup else 0,
|
||||
rec.cpu_energy_joules,
|
||||
rec.gpu_energy_joules,
|
||||
rec.dram_energy_joules,
|
||||
rec.tokens_per_joule,
|
||||
rec.energy_per_output_token_joules,
|
||||
rec.throughput_per_watt,
|
||||
rec.prefill_energy_joules,
|
||||
rec.decode_energy_joules,
|
||||
rec.mean_itl_ms,
|
||||
rec.median_itl_ms,
|
||||
rec.p90_itl_ms,
|
||||
rec.p95_itl_ms,
|
||||
rec.p99_itl_ms,
|
||||
rec.std_itl_ms,
|
||||
1 if rec.is_streaming else 0,
|
||||
rec.token_counting_version,
|
||||
rec.mining_session_id,
|
||||
json.dumps(rec.metadata),
|
||||
)
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
_INSERT,
|
||||
(
|
||||
rec.timestamp,
|
||||
rec.model_id,
|
||||
rec.engine,
|
||||
rec.agent,
|
||||
rec.prompt_tokens,
|
||||
rec.prompt_tokens_evaluated,
|
||||
rec.completion_tokens,
|
||||
rec.total_tokens,
|
||||
rec.latency_seconds,
|
||||
rec.ttft,
|
||||
rec.cost_usd,
|
||||
rec.energy_joules,
|
||||
rec.power_watts,
|
||||
rec.gpu_utilization_pct,
|
||||
rec.gpu_memory_used_gb,
|
||||
rec.gpu_temperature_c,
|
||||
rec.throughput_tok_per_sec,
|
||||
rec.prefill_latency_seconds,
|
||||
rec.decode_latency_seconds,
|
||||
rec.energy_method,
|
||||
rec.energy_vendor,
|
||||
rec.batch_id,
|
||||
1 if rec.is_warmup else 0,
|
||||
rec.cpu_energy_joules,
|
||||
rec.gpu_energy_joules,
|
||||
rec.dram_energy_joules,
|
||||
rec.tokens_per_joule,
|
||||
rec.energy_per_output_token_joules,
|
||||
rec.throughput_per_watt,
|
||||
rec.prefill_energy_joules,
|
||||
rec.decode_energy_joules,
|
||||
rec.mean_itl_ms,
|
||||
rec.median_itl_ms,
|
||||
rec.p90_itl_ms,
|
||||
rec.p95_itl_ms,
|
||||
rec.p99_itl_ms,
|
||||
rec.std_itl_ms,
|
||||
1 if rec.is_streaming else 0,
|
||||
rec.token_counting_version,
|
||||
rec.mining_session_id,
|
||||
json.dumps(rec.metadata),
|
||||
),
|
||||
)
|
||||
self._conn.commit()
|
||||
self._telemetry_batch.append(row)
|
||||
self._maybe_flush_unlocked()
|
||||
|
||||
def record_mining_stats(self, stats: Any) -> None:
|
||||
"""Persist one mining stats snapshot.
|
||||
@@ -226,43 +288,70 @@ class TelemetryStore:
|
||||
``stats`` is duck-typed to keep telemetry usable without importing the
|
||||
optional mining package at module import time.
|
||||
"""
|
||||
row = (
|
||||
time.time(),
|
||||
stats.provider_id,
|
||||
stats.shares_submitted,
|
||||
stats.shares_accepted,
|
||||
stats.blocks_found,
|
||||
stats.hashrate,
|
||||
stats.uptime_seconds,
|
||||
stats.last_share_at,
|
||||
stats.last_error,
|
||||
stats.payout_target,
|
||||
stats.fees_owed,
|
||||
)
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"""\
|
||||
INSERT INTO mining_stats (
|
||||
recorded_at, provider_id, shares_submitted, shares_accepted, blocks_found,
|
||||
hashrate, uptime_seconds, last_share_at, last_error, payout_target, fees_owed
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
time.time(),
|
||||
stats.provider_id,
|
||||
stats.shares_submitted,
|
||||
stats.shares_accepted,
|
||||
stats.blocks_found,
|
||||
stats.hashrate,
|
||||
stats.uptime_seconds,
|
||||
stats.last_share_at,
|
||||
stats.last_error,
|
||||
stats.payout_target,
|
||||
stats.fees_owed,
|
||||
),
|
||||
)
|
||||
self._conn.commit()
|
||||
self._mining_batch.append(row)
|
||||
self._maybe_flush_unlocked()
|
||||
|
||||
def flush(self) -> None:
|
||||
"""Write all pending records to the database."""
|
||||
with self._lock:
|
||||
self._flush_unlocked()
|
||||
|
||||
def _flush_unlocked(self) -> None:
|
||||
if self._telemetry_batch:
|
||||
self._conn.executemany(_INSERT, self._telemetry_batch)
|
||||
self._telemetry_batch.clear()
|
||||
if self._mining_batch:
|
||||
self._conn.executemany(_INSERT_MINING, self._mining_batch)
|
||||
self._mining_batch.clear()
|
||||
self._conn.commit()
|
||||
self._last_flush_time = time.monotonic()
|
||||
|
||||
def _maybe_flush_unlocked(self) -> None:
|
||||
"""Flush when the batch is full or has been pending too long."""
|
||||
if not self._telemetry_batch and not self._mining_batch:
|
||||
return
|
||||
batch_full = (
|
||||
len(self._telemetry_batch) >= self._batch_size
|
||||
or len(self._mining_batch) >= self._batch_size
|
||||
)
|
||||
stale = (
|
||||
self._flush_interval_seconds > 0
|
||||
and time.monotonic() - self._last_flush_time >= self._flush_interval_seconds
|
||||
)
|
||||
if batch_full or stale:
|
||||
self._flush_unlocked()
|
||||
|
||||
def list_recent(self, limit: int = 50) -> list[dict[str, Any]]:
|
||||
"""Return recent telemetry rows as dictionaries."""
|
||||
return self._select_dicts(
|
||||
"SELECT * FROM telemetry ORDER BY timestamp DESC LIMIT ?",
|
||||
(limit,),
|
||||
)
|
||||
with self._lock:
|
||||
self._flush_unlocked()
|
||||
return self._select_dicts_unlocked(
|
||||
"SELECT * FROM telemetry ORDER BY timestamp DESC LIMIT ?",
|
||||
(limit,),
|
||||
)
|
||||
|
||||
def list_recent_mining_stats(self, limit: int = 50) -> list[dict[str, Any]]:
|
||||
"""Return recent mining stats snapshots as dictionaries."""
|
||||
return self._select_dicts(
|
||||
"SELECT * FROM mining_stats ORDER BY recorded_at DESC LIMIT ?",
|
||||
(limit,),
|
||||
)
|
||||
with self._lock:
|
||||
self._flush_unlocked()
|
||||
return self._select_dicts_unlocked(
|
||||
"SELECT * FROM mining_stats ORDER BY recorded_at DESC LIMIT ?",
|
||||
(limit,),
|
||||
)
|
||||
|
||||
def subscribe_to_bus(self, bus: EventBus) -> None:
|
||||
"""Subscribe to ``TELEMETRY_RECORD`` events on *bus*."""
|
||||
@@ -277,15 +366,36 @@ INSERT INTO mining_stats (
|
||||
logger.debug("Failed to record telemetry event: %s", exc)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the underlying SQLite connection."""
|
||||
self._conn.close()
|
||||
"""Flush pending records and close the underlying SQLite connection."""
|
||||
# Set the stop event BEFORE taking the lock: a flusher iteration
|
||||
# already waiting on the lock re-checks the event after acquiring it
|
||||
# and exits instead of touching the closed connection.
|
||||
self._stop_flusher.set()
|
||||
with self._lock:
|
||||
if self._closed:
|
||||
return
|
||||
self._flush_unlocked()
|
||||
self._conn.close()
|
||||
self._closed = True
|
||||
if self._flusher is not None:
|
||||
self._flusher.join(timeout=1.0)
|
||||
self._flusher = None
|
||||
|
||||
# -- helpers for querying (used by tests) --------------------------------
|
||||
|
||||
def _fetchall(self, sql: str = "SELECT * FROM telemetry") -> list:
|
||||
return self._conn.execute(sql).fetchall()
|
||||
with self._lock:
|
||||
self._flush_unlocked()
|
||||
return self._conn.execute(sql).fetchall()
|
||||
|
||||
def _select_dicts(self, sql: str, params: tuple[Any, ...]) -> list[dict[str, Any]]:
|
||||
with self._lock:
|
||||
self._flush_unlocked()
|
||||
return self._select_dicts_unlocked(sql, params)
|
||||
|
||||
def _select_dicts_unlocked(
|
||||
self, sql: str, params: tuple[Any, ...]
|
||||
) -> list[dict[str, Any]]:
|
||||
cur = self._conn.execute(sql, params)
|
||||
columns = [desc[0] for desc in cur.description]
|
||||
return [dict(zip(columns, row)) for row in cur.fetchall()]
|
||||
|
||||
@@ -139,8 +139,8 @@ class GitStatusTool(BaseTool):
|
||||
|
||||
def execute(self, **params: Any) -> ToolResult:
|
||||
repo_path = params.get("repo_path", ".")
|
||||
_rust = get_rust_module()
|
||||
try:
|
||||
_rust = get_rust_module()
|
||||
output = _rust.GitStatusTool().execute(repo_path)
|
||||
return ToolResult(
|
||||
tool_name="git_status",
|
||||
@@ -148,6 +148,8 @@ class GitStatusTool(BaseTool):
|
||||
success=True,
|
||||
metadata={"returncode": 0},
|
||||
)
|
||||
except ImportError as exc:
|
||||
logger.debug("Rust git_status fallback to CLI: %s", exc)
|
||||
except Exception as exc:
|
||||
return ToolResult(
|
||||
tool_name="git_status",
|
||||
@@ -155,6 +157,8 @@ class GitStatusTool(BaseTool):
|
||||
success=False,
|
||||
)
|
||||
|
||||
return _run_git(["git", "status", "--porcelain"], cwd=repo_path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GitDiffTool
|
||||
@@ -208,9 +212,9 @@ class GitDiffTool(BaseTool):
|
||||
staged = params.get("staged", False)
|
||||
file_path = params.get("path")
|
||||
|
||||
_rust = get_rust_module()
|
||||
if not staged and not file_path:
|
||||
try:
|
||||
_rust = get_rust_module()
|
||||
output = _rust.GitDiffTool().execute(repo_path)
|
||||
return ToolResult(
|
||||
tool_name="git_diff",
|
||||
@@ -218,6 +222,8 @@ class GitDiffTool(BaseTool):
|
||||
success=True,
|
||||
metadata={"returncode": 0},
|
||||
)
|
||||
except ImportError as exc:
|
||||
logger.debug("Rust git_diff fallback to CLI: %s", exc)
|
||||
except Exception as exc:
|
||||
return ToolResult(
|
||||
tool_name="git_diff",
|
||||
@@ -371,8 +377,8 @@ class GitLogTool(BaseTool):
|
||||
count = params.get("count", 10)
|
||||
oneline = params.get("oneline", True)
|
||||
|
||||
_rust = get_rust_module()
|
||||
try:
|
||||
_rust = get_rust_module()
|
||||
output = _rust.GitLogTool().execute(repo_path, count)
|
||||
return ToolResult(
|
||||
tool_name="git_log",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -79,3 +80,80 @@ class TestDaemonCommands:
|
||||
result = CliRunner().invoke(cli, ["start"])
|
||||
assert result.exit_code != 0
|
||||
assert "already running" in result.output
|
||||
|
||||
|
||||
class TestDaemonDetachment:
|
||||
"""The spawned server must outlive the console that started it.
|
||||
|
||||
``start_new_session`` is POSIX-only — CPython's Windows ``_execute_child``
|
||||
names the parameter ``unused_start_new_session``. Relying on it there leaves
|
||||
the server sharing its parent's console, so closing that console (or logging
|
||||
off) delivers CTRL_CLOSE_EVENT and kills the daemon.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _spawn_kwargs(platform: str) -> dict:
|
||||
"""Return the kwargs ``start`` passes to Popen when spawning the server.
|
||||
|
||||
``load_config`` is stubbed because it shells out for GPU detection —
|
||||
patching Popen wholesale would otherwise break config loading before
|
||||
the spawn is reached.
|
||||
"""
|
||||
with (
|
||||
patch("openjarvis.cli.daemon_cmd._read_pid", return_value=None),
|
||||
patch("openjarvis.cli.daemon_cmd._write_pid"),
|
||||
patch("openjarvis.cli.daemon_cmd.load_config"),
|
||||
patch("openjarvis.cli.daemon_cmd.sys.platform", platform),
|
||||
patch("openjarvis.cli.daemon_cmd.subprocess.Popen") as popen,
|
||||
patch("builtins.open", MagicMock()),
|
||||
):
|
||||
popen.return_value = MagicMock(pid=4321)
|
||||
result = CliRunner().invoke(cli, ["start"])
|
||||
assert result.exit_code == 0, result.output
|
||||
spawns = [
|
||||
c for c in popen.call_args_list if c.args and "serve" in c.args[0]
|
||||
]
|
||||
assert spawns, f"start did not spawn the server: {popen.call_args_list}"
|
||||
return spawns[-1].kwargs
|
||||
|
||||
def test_windows_spawn_is_detached_from_the_console(self) -> None:
|
||||
# These constants are only exported by ``subprocess`` on Windows.
|
||||
# Supply their documented values so the simulated Windows branch is
|
||||
# still exercised by the POSIX test job.
|
||||
detached_process = getattr(subprocess, "DETACHED_PROCESS", 0x00000008)
|
||||
create_new_process_group = getattr(
|
||||
subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200
|
||||
)
|
||||
with (
|
||||
patch.object(
|
||||
subprocess,
|
||||
"DETACHED_PROCESS",
|
||||
detached_process,
|
||||
create=True,
|
||||
),
|
||||
patch.object(
|
||||
subprocess,
|
||||
"CREATE_NEW_PROCESS_GROUP",
|
||||
create_new_process_group,
|
||||
create=True,
|
||||
),
|
||||
):
|
||||
kwargs = self._spawn_kwargs("win32")
|
||||
|
||||
flags = kwargs.get("creationflags", 0)
|
||||
assert flags & detached_process, (
|
||||
"server must be spawned with DETACHED_PROCESS on Windows, otherwise "
|
||||
"closing the launching console kills it"
|
||||
)
|
||||
assert flags & create_new_process_group, (
|
||||
"server must be in its own process group so Ctrl-C in the parent "
|
||||
"console does not propagate to it"
|
||||
)
|
||||
assert not kwargs.get("start_new_session"), (
|
||||
"start_new_session is ignored on Windows; it must not be relied on"
|
||||
)
|
||||
|
||||
def test_posix_spawn_still_uses_start_new_session(self) -> None:
|
||||
kwargs = self._spawn_kwargs("linux")
|
||||
assert kwargs.get("start_new_session") is True
|
||||
assert "creationflags" not in kwargs or kwargs["creationflags"] == 0
|
||||
|
||||
@@ -30,6 +30,19 @@ from openjarvis.core.registry import (
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_update_check(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Never let the CLI's PyPI update-check nag run during tests.
|
||||
|
||||
``check_for_updates`` writes its banner to stderr, which ``CliRunner``
|
||||
merges into ``result.output`` — polluting JSON/CSV output of any test
|
||||
that invokes a CLI command. It already self-disables when ``CI`` is
|
||||
set, but that only helps in CI; locally (e.g. a dev with a stale
|
||||
version-check cache and network access) it fires for real.
|
||||
"""
|
||||
monkeypatch.setenv("OPENJARVIS_NO_UPDATE_CHECK", "1")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_registries() -> None:
|
||||
"""Ensure each test starts with empty registries and a fresh event bus."""
|
||||
|
||||
@@ -37,16 +37,22 @@ class TestAgentRoutes:
|
||||
|
||||
|
||||
class TestMemoryRoutes:
|
||||
# 503 is the documented response when the native ``openjarvis_rust``
|
||||
# extension is absent from the venv (see TestMemoryRustMissing below).
|
||||
# These tests are only asserting "the route is wired up", so a backend
|
||||
# that cannot be built is tolerated the same way a 500 is.
|
||||
_BACKEND_OPTIONAL = (200, 500, 503)
|
||||
|
||||
def test_search(self):
|
||||
client = TestClient(_make_app())
|
||||
resp = client.post("/v1/memory/search", json={"query": "test"})
|
||||
# May fail if SQLite not set up, that's ok
|
||||
assert resp.status_code in (200, 500)
|
||||
assert resp.status_code in self._BACKEND_OPTIONAL
|
||||
|
||||
def test_stats(self):
|
||||
client = TestClient(_make_app())
|
||||
resp = client.get("/v1/memory/stats")
|
||||
assert resp.status_code in (200, 500)
|
||||
assert resp.status_code in self._BACKEND_OPTIONAL
|
||||
|
||||
|
||||
class TestMemoryRustMissing:
|
||||
|
||||
@@ -255,9 +255,7 @@ class TestDangerousCapabilityGate:
|
||||
assert result.success
|
||||
assert result.requires_confirmation
|
||||
assert any("confirmed by caller" in w for w in result.warnings)
|
||||
content = (
|
||||
tmp_path / "skills" / "hermes" / "my-skill" / ".source"
|
||||
).read_text()
|
||||
content = (tmp_path / "skills" / "hermes" / "my-skill" / ".source").read_text()
|
||||
assert 'trust_tier = "unreviewed"' in content
|
||||
assert 'dangerous_capabilities = ["shell:execute"]' in content
|
||||
|
||||
@@ -269,8 +267,6 @@ class TestDangerousCapabilityGate:
|
||||
assert result.success
|
||||
assert not result.requires_confirmation
|
||||
assert result.dangerous_capabilities == []
|
||||
content = (
|
||||
tmp_path / "skills" / "hermes" / "my-skill" / ".source"
|
||||
).read_text()
|
||||
content = (tmp_path / "skills" / "hermes" / "my-skill" / ".source").read_text()
|
||||
assert 'trust_tier = "unreviewed"' in content
|
||||
assert "dangerous_capabilities = []" in content
|
||||
|
||||
@@ -171,9 +171,7 @@ class TestSkillExecutorCapabilities:
|
||||
assert result.context.get("result") == "hello"
|
||||
|
||||
def test_policy_blocks_missing_capability(self):
|
||||
executor = SkillExecutor(
|
||||
ToolExecutor([EchoTool()]), allowed_capabilities=set()
|
||||
)
|
||||
executor = SkillExecutor(ToolExecutor([EchoTool()]), allowed_capabilities=set())
|
||||
result = executor.run(self._manifest())
|
||||
assert not result.success
|
||||
assert len(result.step_results) == 1
|
||||
|
||||
@@ -164,6 +164,7 @@ class TestDerivedMetricsInStore:
|
||||
throughput_per_watt=0.5,
|
||||
)
|
||||
store.record(rec)
|
||||
store.flush()
|
||||
|
||||
agg = TelemetryAggregator(tmp_path / "test.db")
|
||||
stats = agg.per_model_stats()
|
||||
@@ -185,6 +186,8 @@ class TestDerivedMetricsInStore:
|
||||
throughput_per_watt=1.0 * (i + 1),
|
||||
)
|
||||
)
|
||||
store.flush()
|
||||
|
||||
agg = TelemetryAggregator(tmp_path / "test.db")
|
||||
summary = agg.summary()
|
||||
assert summary.avg_energy_per_output_token_joules > 0
|
||||
|
||||
@@ -345,6 +345,7 @@ class TestItlStorage:
|
||||
)
|
||||
)
|
||||
|
||||
store.flush()
|
||||
agg = TelemetryAggregator(tmp_path / "test.db")
|
||||
stats = agg.per_model_stats()
|
||||
assert len(stats) == 1
|
||||
|
||||
@@ -222,6 +222,7 @@ class TestPhaseEnergyStorage:
|
||||
)
|
||||
)
|
||||
|
||||
store.flush()
|
||||
agg = TelemetryAggregator(tmp_path / "test.db")
|
||||
stats = agg.per_model_stats()
|
||||
assert len(stats) == 1
|
||||
|
||||
@@ -178,3 +178,60 @@ class TestTelemetryRecordFields:
|
||||
def test_tokens_per_joule_set(self):
|
||||
rec = TelemetryRecord(timestamp=1.0, model_id="test", tokens_per_joule=80.0)
|
||||
assert rec.tokens_per_joule == 80.0
|
||||
|
||||
def test_batching_delays_commit(self, tmp_path: Path) -> None:
|
||||
db_path = tmp_path / "test.db"
|
||||
# flush_interval_seconds=0 disables the background flusher so the
|
||||
# buffered/flushed states below are deterministic, not a race.
|
||||
store = TelemetryStore(db_path, batch_size=2, flush_interval_seconds=0)
|
||||
rec = TelemetryRecord(timestamp=time.time(), model_id="m1", engine="e1")
|
||||
|
||||
store.record(rec)
|
||||
# Should not be in DB yet because batch_size is 2 and we haven't flushed
|
||||
# Need a separate connection to check because store._fetchall() calls flush()!
|
||||
assert _count_rows_via_own_connection(db_path) == 0
|
||||
|
||||
# Hit batch size
|
||||
store.record(rec)
|
||||
assert _count_rows_via_own_connection(db_path) == 2
|
||||
store.close()
|
||||
|
||||
def test_background_flush_makes_records_visible(self, tmp_path: Path) -> None:
|
||||
# A partial batch must become visible to OTHER connections within the
|
||||
# flush interval even if no further write ever arrives — the background
|
||||
# flusher covers the "traffic stopped mid-batch" case that per-record
|
||||
# stale checks cannot.
|
||||
db_path = tmp_path / "test.db"
|
||||
store = TelemetryStore(db_path, batch_size=50, flush_interval_seconds=0.05)
|
||||
rec = TelemetryRecord(timestamp=time.time(), model_id="m1", engine="e1")
|
||||
store.record(rec)
|
||||
|
||||
deadline = time.time() + 5.0
|
||||
rows = 0
|
||||
while time.time() < deadline:
|
||||
rows = _count_rows_via_own_connection(db_path)
|
||||
if rows:
|
||||
break
|
||||
time.sleep(0.02)
|
||||
assert rows == 1
|
||||
store.close()
|
||||
|
||||
def test_close_flushes_and_is_idempotent(self, tmp_path: Path) -> None:
|
||||
db_path = tmp_path / "test.db"
|
||||
store = TelemetryStore(db_path, batch_size=50, flush_interval_seconds=0)
|
||||
rec = TelemetryRecord(timestamp=time.time(), model_id="m1", engine="e1")
|
||||
store.record(rec)
|
||||
store.close()
|
||||
assert _count_rows_via_own_connection(db_path) == 1
|
||||
store.close() # second close must be a no-op, not a ProgrammingError
|
||||
|
||||
|
||||
def _count_rows_via_own_connection(db_path: Path) -> int:
|
||||
"""Count telemetry rows through a separate connection (like the aggregator)."""
|
||||
import contextlib
|
||||
import sqlite3
|
||||
|
||||
# NB: sqlite3's ``with conn`` is a TRANSACTION context (it does not close);
|
||||
# ``contextlib.closing`` actually closes the connection.
|
||||
with contextlib.closing(sqlite3.connect(db_path)) as conn:
|
||||
return len(conn.execute("SELECT * FROM telemetry").fetchall())
|
||||
|
||||
@@ -14,9 +14,9 @@ skipped if the server is unreachable.
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from openjarvis.tools.storage.dense import (
|
||||
@@ -25,20 +25,58 @@ from openjarvis.tools.storage.dense import (
|
||||
chunk_markdown,
|
||||
dedupe_chunks,
|
||||
)
|
||||
from openjarvis.tools.storage.embeddings import OllamaEmbedder
|
||||
|
||||
_FIXTURE_DIR = Path(__file__).resolve().parents[2] / "fixtures" / "docs"
|
||||
_OLLAMA_HOST = os.environ.get("OLLAMA_HOST", "localhost")
|
||||
_OLLAMA_PORT = int(os.environ.get("OLLAMA_PORT", "11434"))
|
||||
_EMBED_MODEL = "nomic-embed-text"
|
||||
|
||||
|
||||
def _ollama_base_url() -> str:
|
||||
"""Resolve the Ollama base URL from ``OLLAMA_HOST``.
|
||||
|
||||
The project documents ``OLLAMA_HOST`` as a full URL
|
||||
(``http://<remote-ip>:11434``), but Ollama's own convention also
|
||||
allows bare ``host`` / ``host:port`` forms — accept all three so
|
||||
the probe and the embedder under test agree on one endpoint.
|
||||
"""
|
||||
host = os.environ.get("OLLAMA_HOST", "")
|
||||
if not host:
|
||||
return "http://localhost:11434"
|
||||
if host.startswith(("http://", "https://")):
|
||||
return host.rstrip("/")
|
||||
if ":" in host:
|
||||
return f"http://{host}"
|
||||
return f"http://{host}:11434"
|
||||
|
||||
|
||||
def _ollama_up() -> bool:
|
||||
"""True only if Ollama is reachable *and* the embed model is pulled.
|
||||
|
||||
A bare TCP connect isn't enough — a machine can run Ollama for chat
|
||||
models without ever having pulled ``nomic-embed-text``, which makes
|
||||
``/api/embed`` 404 instead of the tests skipping as intended.
|
||||
"""
|
||||
try:
|
||||
with socket.create_connection((_OLLAMA_HOST, _OLLAMA_PORT), timeout=1.0):
|
||||
return True
|
||||
except OSError:
|
||||
resp = httpx.get(f"{_ollama_base_url()}/api/tags", timeout=1.0)
|
||||
resp.raise_for_status()
|
||||
models = {m.get("model", "") for m in resp.json().get("models", [])}
|
||||
return any(m.startswith(_EMBED_MODEL) for m in models)
|
||||
except (httpx.HTTPError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _make_backend() -> DenseMemory:
|
||||
"""DenseMemory wired to the same Ollama endpoint the probe checked.
|
||||
|
||||
``DenseMemory()`` alone would build an ``OllamaEmbedder`` with its
|
||||
hard-coded localhost default, so a remote ``OLLAMA_HOST`` could pass
|
||||
the probe and then have every test call the wrong server.
|
||||
"""
|
||||
return DenseMemory(
|
||||
embedder=OllamaEmbedder(model=_EMBED_MODEL, base_url=_ollama_base_url())
|
||||
)
|
||||
|
||||
|
||||
ollama_required = pytest.mark.skipif(
|
||||
not _ollama_up(),
|
||||
reason=(
|
||||
@@ -48,6 +86,65 @@ ollama_required = pytest.mark.skipif(
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Skip-guard unit tests (no Ollama required)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeTagsResponse:
|
||||
def __init__(self, payload: dict) -> None:
|
||||
self._payload = payload
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
pass
|
||||
|
||||
def json(self) -> dict:
|
||||
return self._payload
|
||||
|
||||
|
||||
class TestOllamaProbe:
|
||||
def test_base_url_accepts_all_documented_forms(self, monkeypatch):
|
||||
cases = {
|
||||
"http://remote:11434": "http://remote:11434",
|
||||
"http://remote:11434/": "http://remote:11434",
|
||||
"https://ollama.internal": "https://ollama.internal",
|
||||
"remote:8080": "http://remote:8080",
|
||||
"remote": "http://remote:11434",
|
||||
}
|
||||
for raw, expected in cases.items():
|
||||
monkeypatch.setenv("OLLAMA_HOST", raw)
|
||||
assert _ollama_base_url() == expected, raw
|
||||
monkeypatch.delenv("OLLAMA_HOST", raising=False)
|
||||
assert _ollama_base_url() == "http://localhost:11434"
|
||||
|
||||
def test_probe_false_when_server_down(self, monkeypatch):
|
||||
def _refuse(url, timeout):
|
||||
raise httpx.ConnectError("connection refused")
|
||||
|
||||
monkeypatch.setattr(httpx, "get", _refuse)
|
||||
assert _ollama_up() is False
|
||||
|
||||
def test_probe_false_when_model_missing(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
httpx,
|
||||
"get",
|
||||
lambda url, timeout: _FakeTagsResponse({"models": [{"model": "llama3"}]}),
|
||||
)
|
||||
assert _ollama_up() is False
|
||||
|
||||
def test_probe_true_with_tagged_model_on_configured_url(self, monkeypatch):
|
||||
monkeypatch.setenv("OLLAMA_HOST", "http://remote:9999")
|
||||
seen = {}
|
||||
|
||||
def _get(url, timeout):
|
||||
seen["url"] = url
|
||||
return _FakeTagsResponse({"models": [{"model": "nomic-embed-text:latest"}]})
|
||||
|
||||
monkeypatch.setattr(httpx, "get", _get)
|
||||
assert _ollama_up() is True
|
||||
assert seen["url"] == "http://remote:9999/api/tags"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chunking unit tests (no Ollama required)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -283,7 +380,7 @@ def indexed_backend():
|
||||
if not _ollama_up():
|
||||
pytest.skip("Ollama not reachable")
|
||||
|
||||
backend = DenseMemory()
|
||||
backend = _make_backend()
|
||||
md_files = sorted(_FIXTURE_DIR.glob("*.md"))
|
||||
assert md_files, f"no fixtures at {_FIXTURE_DIR}"
|
||||
|
||||
@@ -452,7 +549,7 @@ def test_score_distribution_vs_threshold(indexed_backend, capsys):
|
||||
class TestDenseMemoryAPI:
|
||||
@ollama_required
|
||||
def test_store_and_delete(self):
|
||||
backend = DenseMemory()
|
||||
backend = _make_backend()
|
||||
doc_id = backend.store("the cat sat on the mat", source="a.txt")
|
||||
assert backend.count() == 1
|
||||
hits = backend.retrieve("where is the cat", top_k=1)
|
||||
@@ -463,12 +560,12 @@ class TestDenseMemoryAPI:
|
||||
|
||||
@ollama_required
|
||||
def test_empty_retrieve(self):
|
||||
backend = DenseMemory()
|
||||
backend = _make_backend()
|
||||
assert backend.retrieve("anything", top_k=3) == []
|
||||
|
||||
@ollama_required
|
||||
def test_clear(self):
|
||||
backend = DenseMemory()
|
||||
backend = _make_backend()
|
||||
backend.store("foo")
|
||||
backend.store("bar")
|
||||
backend.clear()
|
||||
|
||||
@@ -537,3 +537,51 @@ class TestGitLogTool:
|
||||
fn = tool.to_openai_function()
|
||||
assert fn["type"] == "function"
|
||||
assert fn["function"]["name"] == "git_log"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI fallback when the Rust extension is missing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCliFallbackWhenRustMissing:
|
||||
"""When ``get_rust_module`` raises ImportError (extension not built,
|
||||
e.g. a plain pip install), the read-only git tools must fall back to
|
||||
the git CLI instead of letting the ImportError escape ``execute()``."""
|
||||
|
||||
def _patch_no_rust(self):
|
||||
return patch(
|
||||
"openjarvis.tools.git_tool.get_rust_module",
|
||||
side_effect=ImportError("No module named 'openjarvis_rust'"),
|
||||
)
|
||||
|
||||
def test_git_status_falls_back_to_cli(self, tmp_path):
|
||||
_init_repo(tmp_path)
|
||||
(tmp_path / "new_file.txt").write_text("hello")
|
||||
with self._patch_no_rust():
|
||||
result = GitStatusTool().execute(repo_path=str(tmp_path))
|
||||
assert result.success is True
|
||||
assert "new_file.txt" in result.content
|
||||
|
||||
def test_git_diff_falls_back_to_cli(self, tmp_path):
|
||||
_init_repo(tmp_path)
|
||||
(tmp_path / "README.md").write_text("# Modified\n")
|
||||
with self._patch_no_rust():
|
||||
result = GitDiffTool().execute(repo_path=str(tmp_path))
|
||||
assert result.success is True
|
||||
assert "README.md" in result.content
|
||||
|
||||
def test_git_log_falls_back_to_cli(self, tmp_path):
|
||||
_init_repo(tmp_path)
|
||||
with self._patch_no_rust():
|
||||
result = GitLogTool().execute(repo_path=str(tmp_path))
|
||||
assert result.success is True
|
||||
assert "Initial commit" in result.content
|
||||
|
||||
def test_fallback_failure_is_a_tool_result_not_an_exception(self, tmp_path):
|
||||
# Even when the fallback itself fails (not a git repo), the tool
|
||||
# must return a failed ToolResult rather than raising.
|
||||
with self._patch_no_rust():
|
||||
result = GitStatusTool().execute(repo_path=str(tmp_path))
|
||||
assert result.success is False
|
||||
assert "not a git repository" in result.content
|
||||
|
||||
@@ -70,9 +70,7 @@ def test_allows_select_with_keyword_substring(store: KnowledgeStore) -> None:
|
||||
from openjarvis.tools.knowledge_sql import KnowledgeSQLTool
|
||||
|
||||
tool = KnowledgeSQLTool(store=store)
|
||||
result = tool.execute(
|
||||
query="SELECT author AS created_author FROM knowledge_chunks"
|
||||
)
|
||||
result = tool.execute(query="SELECT author AS created_author FROM knowledge_chunks")
|
||||
assert result.success, result.content
|
||||
assert "Alice" in result.content
|
||||
|
||||
|
||||
Reference in New Issue
Block a user