kiwi-code 0.0.25__tar.gz → 0.0.26__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.
- {kiwi_code-0.0.25 → kiwi_code-0.0.26}/PKG-INFO +1 -1
- {kiwi_code-0.0.25 → kiwi_code-0.0.26}/pyproject.toml +1 -1
- {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_tui/runtime_agent.py +119 -0
- {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_tui/screens/runtime_logs.py +30 -3
- kiwi_code-0.0.26/tests/test_runtime_log_trimming.py +43 -0
- {kiwi_code-0.0.25 → kiwi_code-0.0.26}/uv.lock +1 -1
- {kiwi_code-0.0.25 → kiwi_code-0.0.26}/.github/workflows/publish.yml +0 -0
- {kiwi_code-0.0.25 → kiwi_code-0.0.26}/.github/workflows/test.yml +0 -0
- {kiwi_code-0.0.25 → kiwi_code-0.0.26}/.gitignore +0 -0
- {kiwi_code-0.0.25 → kiwi_code-0.0.26}/.python-version +0 -0
- {kiwi_code-0.0.25 → kiwi_code-0.0.26}/CLAUDE.md +0 -0
- {kiwi_code-0.0.25 → kiwi_code-0.0.26}/Makefile +0 -0
- {kiwi_code-0.0.25 → kiwi_code-0.0.26}/README.md +0 -0
- {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_cli/__init__.py +0 -0
- {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_cli/auth.py +0 -0
- {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_cli/cli.py +0 -0
- {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_cli/client.py +0 -0
- {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_cli/commands.py +0 -0
- {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_cli/logger.py +0 -0
- {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_cli/models.py +0 -0
- {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_cli/runtime_manager.py +0 -0
- {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_cli/server.py +0 -0
- {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_runtime/__init__.py +0 -0
- {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_runtime/__main__.py +0 -0
- {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_runtime/main.py +0 -0
- {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_runtime/snake_game/.gitignore +0 -0
- {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_runtime/snake_game/requirements.txt +0 -0
- {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_tui/__init__.py +0 -0
- {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_tui/inline_file_picker.py +0 -0
- {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_tui/main.py +0 -0
- {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_tui/screens/__init__.py +0 -0
- {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_tui/screens/attach_content.py +0 -0
- {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_tui/screens/command_result.py +0 -0
- {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_tui/screens/dashboard.py +0 -0
- {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_tui/screens/file_browser.py +0 -0
- {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_tui/screens/help.py +0 -0
- {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_tui/screens/id_picker.py +0 -0
- {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_tui/screens/login.py +0 -0
- {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
- {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_tui/screens/slash_picker.py +0 -0
- {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_tui/slash_commands.py +0 -0
- {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_tui/widgets.py +0 -0
- {kiwi_code-0.0.25 → kiwi_code-0.0.26}/test_hello.py +0 -0
- {kiwi_code-0.0.25 → kiwi_code-0.0.26}/tests/__init__.py +0 -0
- {kiwi_code-0.0.25 → kiwi_code-0.0.26}/tests/conftest.py +0 -0
- {kiwi_code-0.0.25 → kiwi_code-0.0.26}/tests/test_cli_help.py +0 -0
- {kiwi_code-0.0.25 → kiwi_code-0.0.26}/tests/test_imports.py +0 -0
- {kiwi_code-0.0.25 → kiwi_code-0.0.26}/tests/test_reexec_kiwi.py +0 -0
- {kiwi_code-0.0.25 → kiwi_code-0.0.26}/tests/test_tokens.py +0 -0
- {kiwi_code-0.0.25 → kiwi_code-0.0.26}/tests/test_tui_headless.py +0 -0
|
@@ -27,6 +27,8 @@ import json
|
|
|
27
27
|
import os
|
|
28
28
|
from pathlib import Path
|
|
29
29
|
import subprocess
|
|
30
|
+
import threading
|
|
31
|
+
import time
|
|
30
32
|
from typing import Any, Literal
|
|
31
33
|
import uuid
|
|
32
34
|
|
|
@@ -57,6 +59,111 @@ BY_RUN_DIR = RUNTIMES_DIR / "by-run"
|
|
|
57
59
|
PENDING_DIR = RUNTIMES_DIR / "pending"
|
|
58
60
|
|
|
59
61
|
|
|
62
|
+
# Runtime log truncation
|
|
63
|
+
# ---------------------
|
|
64
|
+
# Runtime logs are written to a local file and can otherwise grow without bound,
|
|
65
|
+
# consuming both disk and memory (the logs viewer historically read the entire
|
|
66
|
+
# file into RAM). We keep only a small trailing window so the user sees the most
|
|
67
|
+
# recent activity.
|
|
68
|
+
MAX_RUNTIME_LOG_BYTES = 30 * 1024
|
|
69
|
+
_RUNTIME_LOG_TRIM_INTERVAL_SEC = 0.5
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _trim_file_to_tail_bytes(path: Path, *, max_bytes: int = MAX_RUNTIME_LOG_BYTES) -> None:
|
|
73
|
+
"""Trim a file in-place to keep only the last ``max_bytes`` bytes.
|
|
74
|
+
|
|
75
|
+
Notes:
|
|
76
|
+
- We *cannot* implement trimming via atomic replace (write temp + rename),
|
|
77
|
+
because the runtime process inherits an open file descriptor; replacing the
|
|
78
|
+
path would orphan the original inode and the runtime would keep writing to
|
|
79
|
+
the unlinked file.
|
|
80
|
+
- This is therefore best-effort; concurrent appends may result in dropping a
|
|
81
|
+
few bytes around the trim boundary, which is acceptable for log viewing.
|
|
82
|
+
"""
|
|
83
|
+
if max_bytes <= 0:
|
|
84
|
+
return
|
|
85
|
+
try:
|
|
86
|
+
if not path.exists():
|
|
87
|
+
return
|
|
88
|
+
with open(path, "r+b") as fp:
|
|
89
|
+
fp.seek(0, os.SEEK_END)
|
|
90
|
+
size = fp.tell()
|
|
91
|
+
if size <= max_bytes:
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
# Read the tail and try to start at a newline boundary to avoid
|
|
95
|
+
# rendering a partial first line.
|
|
96
|
+
fp.seek(max(0, size - max_bytes), os.SEEK_SET)
|
|
97
|
+
tail = fp.read(max_bytes)
|
|
98
|
+
if size > max_bytes:
|
|
99
|
+
nl = tail.find(b"\n")
|
|
100
|
+
if 0 <= nl < len(tail) - 1:
|
|
101
|
+
tail = tail[nl + 1 :]
|
|
102
|
+
|
|
103
|
+
fp.seek(0, os.SEEK_SET)
|
|
104
|
+
fp.write(tail)
|
|
105
|
+
fp.truncate(len(tail))
|
|
106
|
+
try:
|
|
107
|
+
fp.flush()
|
|
108
|
+
except Exception:
|
|
109
|
+
pass
|
|
110
|
+
except Exception:
|
|
111
|
+
# Never crash UI / runtime manager due to log trimming.
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class _RuntimeLogTrimmer(threading.Thread):
|
|
116
|
+
def __init__(self, *, log_path: Path, pid: int | None, max_bytes: int) -> None:
|
|
117
|
+
super().__init__(daemon=True)
|
|
118
|
+
self._log_path = log_path
|
|
119
|
+
self._pid = pid
|
|
120
|
+
self._max_bytes = max_bytes
|
|
121
|
+
self._stop = threading.Event()
|
|
122
|
+
self._missing_count = 0
|
|
123
|
+
|
|
124
|
+
def stop(self) -> None:
|
|
125
|
+
self._stop.set()
|
|
126
|
+
|
|
127
|
+
def run(self) -> None:
|
|
128
|
+
while not self._stop.is_set():
|
|
129
|
+
try:
|
|
130
|
+
if self._pid and not psutil.pid_exists(self._pid):
|
|
131
|
+
break
|
|
132
|
+
except Exception:
|
|
133
|
+
# If we can't check, keep trimming while the app is alive.
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
if not self._log_path.exists():
|
|
137
|
+
self._missing_count += 1
|
|
138
|
+
# If the log path was moved (e.g. pending -> by-run), another
|
|
139
|
+
# trimmer should be started for the new path.
|
|
140
|
+
if self._missing_count > 20:
|
|
141
|
+
break
|
|
142
|
+
else:
|
|
143
|
+
self._missing_count = 0
|
|
144
|
+
_trim_file_to_tail_bytes(self._log_path, max_bytes=self._max_bytes)
|
|
145
|
+
|
|
146
|
+
self._stop.wait(_RUNTIME_LOG_TRIM_INTERVAL_SEC)
|
|
147
|
+
|
|
148
|
+
# One final trim on exit.
|
|
149
|
+
_trim_file_to_tail_bytes(self._log_path, max_bytes=self._max_bytes)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
_LOG_TRIMMERS: dict[str, _RuntimeLogTrimmer] = {}
|
|
153
|
+
_LOG_TRIMMERS_LOCK = threading.Lock()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _ensure_log_trimmer(*, log_path: Path, pid: int | None, max_bytes: int = MAX_RUNTIME_LOG_BYTES) -> None:
|
|
157
|
+
key = str(log_path)
|
|
158
|
+
with _LOG_TRIMMERS_LOCK:
|
|
159
|
+
trimmer = _LOG_TRIMMERS.get(key)
|
|
160
|
+
if trimmer and trimmer.is_alive():
|
|
161
|
+
return
|
|
162
|
+
trimmer = _RuntimeLogTrimmer(log_path=log_path, pid=pid, max_bytes=max_bytes)
|
|
163
|
+
_LOG_TRIMMERS[key] = trimmer
|
|
164
|
+
trimmer.start()
|
|
165
|
+
|
|
166
|
+
|
|
60
167
|
|
|
61
168
|
def _read_pid(path: Path) -> int | None:
|
|
62
169
|
if not path.exists():
|
|
@@ -350,6 +457,9 @@ def _spawn_runtime(
|
|
|
350
457
|
pass
|
|
351
458
|
return False, None, f"Failed to start runtime: {e}"
|
|
352
459
|
|
|
460
|
+
|
|
461
|
+
# Keep runtime log file bounded in the background (avoid disk + RAM blow-ups).
|
|
462
|
+
_ensure_log_trimmer(log_path=log_path, pid=proc.pid)
|
|
353
463
|
try:
|
|
354
464
|
pid_path.write_text(str(proc.pid), encoding="utf-8")
|
|
355
465
|
except Exception:
|
|
@@ -385,6 +495,7 @@ def ensure_runtime_for_run(
|
|
|
385
495
|
existing = get_running_pid_for_run(run_id)
|
|
386
496
|
lp = log_path_for_run(run_id)
|
|
387
497
|
if existing:
|
|
498
|
+
_ensure_log_trimmer(log_path=lp, pid=existing)
|
|
388
499
|
return True, existing, lp, f"Runtime already running for run {run_id} (pid={existing})"
|
|
389
500
|
|
|
390
501
|
ok, pid, msg = _spawn_runtime(
|
|
@@ -530,6 +641,14 @@ def bind_pending_to_run(
|
|
|
530
641
|
except Exception:
|
|
531
642
|
pass
|
|
532
643
|
|
|
644
|
+
|
|
645
|
+
# If the pending runtime directory was moved successfully, start trimming at the
|
|
646
|
+
# new by-run log path (the old path no longer exists).
|
|
647
|
+
try:
|
|
648
|
+
pid = _read_pid(dst / "pid")
|
|
649
|
+
_ensure_log_trimmer(log_path=dst / "log", pid=pid)
|
|
650
|
+
except Exception:
|
|
651
|
+
pass
|
|
533
652
|
return True, dst / "log", f"Bound pending runtime {pending_id} -> run {run_id}"
|
|
534
653
|
|
|
535
654
|
|
|
@@ -17,12 +17,30 @@ from textual.screen import Screen
|
|
|
17
17
|
from textual.widgets import Footer, Header, RichLog, Static
|
|
18
18
|
|
|
19
19
|
|
|
20
|
-
def _tail_lines(path: Path, max_lines: int = 3000) -> list[str]:
|
|
21
|
-
"""Read the last N lines of a file without loading the whole file.
|
|
20
|
+
def _tail_lines(path: Path, max_lines: int = 3000, max_bytes: int = 30 * 1024) -> list[str]:
|
|
21
|
+
"""Read the last N lines of a file without loading the whole file.
|
|
22
|
+
|
|
23
|
+
We read only a tail window (``max_bytes``) so opening the logs screen doesn't
|
|
24
|
+
allocate RAM proportional to the on-disk log size.
|
|
25
|
+
"""
|
|
26
|
+
if max_bytes <= 0:
|
|
27
|
+
return []
|
|
28
|
+
|
|
22
29
|
try:
|
|
23
|
-
|
|
30
|
+
with open(path, "rb") as fp:
|
|
31
|
+
fp.seek(0, 2)
|
|
32
|
+
size = fp.tell()
|
|
33
|
+
fp.seek(max(0, size - max_bytes), 0)
|
|
34
|
+
data = fp.read(max_bytes)
|
|
24
35
|
except OSError:
|
|
25
36
|
return []
|
|
37
|
+
|
|
38
|
+
# If we started mid-file, drop the partial first line.
|
|
39
|
+
if size > max_bytes:
|
|
40
|
+
nl = data.find(b"\n")
|
|
41
|
+
if 0 <= nl < len(data) - 1:
|
|
42
|
+
data = data[nl + 1 :]
|
|
43
|
+
|
|
26
44
|
lines = data.splitlines()[-max_lines:]
|
|
27
45
|
out: list[str] = []
|
|
28
46
|
for b in lines:
|
|
@@ -120,6 +138,15 @@ class RuntimeLogsScreen(Screen):
|
|
|
120
138
|
except Exception:
|
|
121
139
|
return
|
|
122
140
|
if not chunk:
|
|
141
|
+
# If the runtime log is being trimmed in-place, the file can shrink.
|
|
142
|
+
# When that happens our read cursor may point past EOF; re-seek to the
|
|
143
|
+
# new end so streaming continues.
|
|
144
|
+
try:
|
|
145
|
+
if self._log_path and self._log_path.exists():
|
|
146
|
+
if self._log_path.stat().st_size < self._fp.tell():
|
|
147
|
+
self._fp.seek(0, 2)
|
|
148
|
+
except Exception:
|
|
149
|
+
pass
|
|
123
150
|
return
|
|
124
151
|
|
|
125
152
|
try:
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from kiwi_tui.runtime_agent import MAX_RUNTIME_LOG_BYTES, _trim_file_to_tail_bytes
|
|
8
|
+
from kiwi_tui.screens.runtime_logs import _tail_lines
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _make_log_bytes(num_lines: int) -> bytes:
|
|
12
|
+
# Fixed-width lines make expected tails deterministic.
|
|
13
|
+
return b"".join([f"line-{i:06d} hello world\n".encode("utf-8") for i in range(num_lines)])
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_trim_file_to_tail_bytes_keeps_last_bytes(tmp_path: Path) -> None:
|
|
17
|
+
p = tmp_path / "log"
|
|
18
|
+
payload = _make_log_bytes(20000) # plenty bigger than 30KB
|
|
19
|
+
p.write_bytes(payload)
|
|
20
|
+
|
|
21
|
+
_trim_file_to_tail_bytes(p, max_bytes=MAX_RUNTIME_LOG_BYTES)
|
|
22
|
+
|
|
23
|
+
data = p.read_bytes()
|
|
24
|
+
assert len(data) <= MAX_RUNTIME_LOG_BYTES
|
|
25
|
+
|
|
26
|
+
# We should keep the most recent line.
|
|
27
|
+
assert b"line-019999" in data
|
|
28
|
+
|
|
29
|
+
# We should *not* keep the earliest line.
|
|
30
|
+
assert b"line-000000" not in data
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_tail_lines_reads_tail_window(tmp_path: Path) -> None:
|
|
34
|
+
p = tmp_path / "log"
|
|
35
|
+
payload = _make_log_bytes(5000)
|
|
36
|
+
p.write_bytes(payload)
|
|
37
|
+
|
|
38
|
+
# Read a small tail window and last few lines from it.
|
|
39
|
+
lines = _tail_lines(p, max_lines=5, max_bytes=512)
|
|
40
|
+
assert len(lines) == 5
|
|
41
|
+
|
|
42
|
+
# Must include the final log line.
|
|
43
|
+
assert lines[-1].startswith("line-004999")
|
|
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
|
|
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
|