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.
Files changed (50) hide show
  1. {kiwi_code-0.0.25 → kiwi_code-0.0.26}/PKG-INFO +1 -1
  2. {kiwi_code-0.0.25 → kiwi_code-0.0.26}/pyproject.toml +1 -1
  3. {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_tui/runtime_agent.py +119 -0
  4. {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_tui/screens/runtime_logs.py +30 -3
  5. kiwi_code-0.0.26/tests/test_runtime_log_trimming.py +43 -0
  6. {kiwi_code-0.0.25 → kiwi_code-0.0.26}/uv.lock +1 -1
  7. {kiwi_code-0.0.25 → kiwi_code-0.0.26}/.github/workflows/publish.yml +0 -0
  8. {kiwi_code-0.0.25 → kiwi_code-0.0.26}/.github/workflows/test.yml +0 -0
  9. {kiwi_code-0.0.25 → kiwi_code-0.0.26}/.gitignore +0 -0
  10. {kiwi_code-0.0.25 → kiwi_code-0.0.26}/.python-version +0 -0
  11. {kiwi_code-0.0.25 → kiwi_code-0.0.26}/CLAUDE.md +0 -0
  12. {kiwi_code-0.0.25 → kiwi_code-0.0.26}/Makefile +0 -0
  13. {kiwi_code-0.0.25 → kiwi_code-0.0.26}/README.md +0 -0
  14. {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_cli/__init__.py +0 -0
  15. {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_cli/auth.py +0 -0
  16. {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_cli/cli.py +0 -0
  17. {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_cli/client.py +0 -0
  18. {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_cli/commands.py +0 -0
  19. {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_cli/logger.py +0 -0
  20. {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_cli/models.py +0 -0
  21. {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_cli/runtime_manager.py +0 -0
  22. {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_cli/server.py +0 -0
  23. {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_runtime/__init__.py +0 -0
  24. {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_runtime/__main__.py +0 -0
  25. {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_runtime/main.py +0 -0
  26. {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_runtime/snake_game/.gitignore +0 -0
  27. {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_runtime/snake_game/requirements.txt +0 -0
  28. {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_tui/__init__.py +0 -0
  29. {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_tui/inline_file_picker.py +0 -0
  30. {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_tui/main.py +0 -0
  31. {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_tui/screens/__init__.py +0 -0
  32. {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_tui/screens/attach_content.py +0 -0
  33. {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_tui/screens/command_result.py +0 -0
  34. {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_tui/screens/dashboard.py +0 -0
  35. {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_tui/screens/file_browser.py +0 -0
  36. {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_tui/screens/help.py +0 -0
  37. {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_tui/screens/id_picker.py +0 -0
  38. {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_tui/screens/login.py +0 -0
  39. {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
  40. {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_tui/screens/slash_picker.py +0 -0
  41. {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_tui/slash_commands.py +0 -0
  42. {kiwi_code-0.0.25 → kiwi_code-0.0.26}/src/kiwi_tui/widgets.py +0 -0
  43. {kiwi_code-0.0.25 → kiwi_code-0.0.26}/test_hello.py +0 -0
  44. {kiwi_code-0.0.25 → kiwi_code-0.0.26}/tests/__init__.py +0 -0
  45. {kiwi_code-0.0.25 → kiwi_code-0.0.26}/tests/conftest.py +0 -0
  46. {kiwi_code-0.0.25 → kiwi_code-0.0.26}/tests/test_cli_help.py +0 -0
  47. {kiwi_code-0.0.25 → kiwi_code-0.0.26}/tests/test_imports.py +0 -0
  48. {kiwi_code-0.0.25 → kiwi_code-0.0.26}/tests/test_reexec_kiwi.py +0 -0
  49. {kiwi_code-0.0.25 → kiwi_code-0.0.26}/tests/test_tokens.py +0 -0
  50. {kiwi_code-0.0.25 → kiwi_code-0.0.26}/tests/test_tui_headless.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiwi-code
3
- Version: 0.0.25
3
+ Version: 0.0.26
4
4
  Summary: A textual-based terminal user interface application
5
5
  Project-URL: Homepage, https://meetkiwi.ai
6
6
  Project-URL: Repository, https://github.com/jetoslabs/kiwi-code
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kiwi-code"
3
- version = "0.0.25"
3
+ version = "0.0.26"
4
4
  description = "A textual-based terminal user interface application"
5
5
  readme = {file = "README.md", content-type = "text/markdown"}
6
6
  requires-python = ">=3.11,<4.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
- data = path.read_bytes()
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")
@@ -397,7 +397,7 @@ wheels = [
397
397
 
398
398
  [[package]]
399
399
  name = "kiwi-code"
400
- version = "0.0.25"
400
+ version = "0.0.26"
401
401
  source = { editable = "." }
402
402
  dependencies = [
403
403
  { name = "autobots-client" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes