contree-cli 0.2.3__py3-none-any.whl
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.
- contree_cli/__init__.py +27 -0
- contree_cli/__main__.py +62 -0
- contree_cli/arguments.py +141 -0
- contree_cli/cli/__init__.py +0 -0
- contree_cli/cli/auth.py +124 -0
- contree_cli/cli/cat.py +74 -0
- contree_cli/cli/cd.py +49 -0
- contree_cli/cli/cp.py +107 -0
- contree_cli/cli/file.py +179 -0
- contree_cli/cli/images.py +88 -0
- contree_cli/cli/kill.py +86 -0
- contree_cli/cli/ls.py +83 -0
- contree_cli/cli/ps.py +120 -0
- contree_cli/cli/run.py +438 -0
- contree_cli/cli/session.py +282 -0
- contree_cli/cli/show.py +97 -0
- contree_cli/cli/tag.py +50 -0
- contree_cli/cli/use.py +119 -0
- contree_cli/client.py +222 -0
- contree_cli/config.py +116 -0
- contree_cli/log.py +40 -0
- contree_cli/mapped_file.py +112 -0
- contree_cli/output.py +376 -0
- contree_cli/session.py +761 -0
- contree_cli/shell/__init__.py +59 -0
- contree_cli/shell/completer.py +465 -0
- contree_cli/shell/history.py +53 -0
- contree_cli/shell/parser.py +107 -0
- contree_cli/shell/repl.py +486 -0
- contree_cli/shell/trie.py +113 -0
- contree_cli/types.py +87 -0
- contree_cli-0.2.3.dist-info/METADATA +323 -0
- contree_cli-0.2.3.dist-info/RECORD +35 -0
- contree_cli-0.2.3.dist-info/WHEEL +4 -0
- contree_cli-0.2.3.dist-info/entry_points.txt +3 -0
contree_cli/session.py
ADDED
|
@@ -0,0 +1,761 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import gzip
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import posixpath
|
|
8
|
+
import sqlite3
|
|
9
|
+
import sys
|
|
10
|
+
import uuid
|
|
11
|
+
from collections.abc import Iterator, MutableMapping
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from functools import cached_property
|
|
14
|
+
from pathlib import Path, PurePosixPath
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Session:
|
|
19
|
+
session_key: str
|
|
20
|
+
active_branch: str
|
|
21
|
+
current_image: str
|
|
22
|
+
last_kind: str
|
|
23
|
+
last_title: str
|
|
24
|
+
updated_at: str
|
|
25
|
+
cwd: str = ""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class PendingFile:
|
|
30
|
+
instance_path: str
|
|
31
|
+
file_uuid: str
|
|
32
|
+
uid: int
|
|
33
|
+
gid: int
|
|
34
|
+
mode: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class HistoryEntry:
|
|
39
|
+
id: int
|
|
40
|
+
image_uuid: str
|
|
41
|
+
parent_id: int | None
|
|
42
|
+
kind: str
|
|
43
|
+
title: str
|
|
44
|
+
created_at: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
SCHEMA = """
|
|
48
|
+
CREATE TABLE IF NOT EXISTS session_history (
|
|
49
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
50
|
+
session_key TEXT NOT NULL,
|
|
51
|
+
image_uuid TEXT NOT NULL,
|
|
52
|
+
parent_id INTEGER REFERENCES session_history(id),
|
|
53
|
+
kind TEXT NOT NULL DEFAULT '',
|
|
54
|
+
title TEXT NOT NULL DEFAULT '',
|
|
55
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S','now'))
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
CREATE TABLE IF NOT EXISTS session_branches (
|
|
59
|
+
session_key TEXT NOT NULL,
|
|
60
|
+
branch_name TEXT NOT NULL,
|
|
61
|
+
history_id INTEGER NOT NULL REFERENCES session_history(id),
|
|
62
|
+
PRIMARY KEY (session_key, branch_name)
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
CREATE TABLE IF NOT EXISTS session_state (
|
|
66
|
+
session_key TEXT PRIMARY KEY,
|
|
67
|
+
active_branch TEXT NOT NULL DEFAULT 'main',
|
|
68
|
+
cwd TEXT NOT NULL DEFAULT '',
|
|
69
|
+
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S','now'))
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
CREATE TABLE IF NOT EXISTS session_files (
|
|
73
|
+
history_id INTEGER NOT NULL REFERENCES session_history(id),
|
|
74
|
+
instance_path TEXT NOT NULL,
|
|
75
|
+
file_uuid TEXT NOT NULL,
|
|
76
|
+
uid INTEGER NOT NULL DEFAULT 0,
|
|
77
|
+
gid INTEGER NOT NULL DEFAULT 0,
|
|
78
|
+
mode TEXT NOT NULL DEFAULT '0644',
|
|
79
|
+
PRIMARY KEY (history_id, instance_path)
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
CREATE TABLE IF NOT EXISTS shell_history (
|
|
83
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
84
|
+
session_key TEXT NOT NULL,
|
|
85
|
+
line TEXT NOT NULL,
|
|
86
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S','now'))
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
CREATE TABLE IF NOT EXISTS image_cache (
|
|
90
|
+
image_uuid TEXT NOT NULL,
|
|
91
|
+
kind TEXT NOT NULL,
|
|
92
|
+
value TEXT NOT NULL,
|
|
93
|
+
PRIMARY KEY (image_uuid, kind)
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
CREATE INDEX IF NOT EXISTS ix_history_session ON session_history(session_key);
|
|
97
|
+
CREATE INDEX IF NOT EXISTS ix_history_parent ON session_history(parent_id);
|
|
98
|
+
CREATE INDEX IF NOT EXISTS ix_shell_history_session ON shell_history(session_key);
|
|
99
|
+
CREATE INDEX IF NOT EXISTS ix_image_cache_kind ON image_cache(kind);
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _entry_from_row(row: sqlite3.Row) -> HistoryEntry:
|
|
104
|
+
return HistoryEntry(
|
|
105
|
+
id=row["id"],
|
|
106
|
+
image_uuid=row["image_uuid"],
|
|
107
|
+
parent_id=row["parent_id"],
|
|
108
|
+
kind=row["kind"],
|
|
109
|
+
title=row["title"],
|
|
110
|
+
created_at=row["created_at"],
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
CacheKey = tuple[str, str]
|
|
115
|
+
|
|
116
|
+
_GZIP_THRESHOLD = 1024
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class ImageCache(MutableMapping[CacheKey, object]):
|
|
120
|
+
"""Persistent cache for immutable container image data.
|
|
121
|
+
|
|
122
|
+
Key is ``(image_uuid, kind)`` where *kind* encodes the cache
|
|
123
|
+
entry type (e.g. ``"files:/etc/"`` or ``"images"``). Values are
|
|
124
|
+
transparently JSON-serialised. Payloads above 1 KiB are
|
|
125
|
+
gzip-compressed and base64-encoded (``gzip:...`` prefix);
|
|
126
|
+
smaller payloads are stored as plain JSON (``json:...`` prefix).
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
__slots__ = ("_conn",)
|
|
130
|
+
|
|
131
|
+
def __init__(self, conn: sqlite3.Connection) -> None:
|
|
132
|
+
self._conn = conn
|
|
133
|
+
|
|
134
|
+
@staticmethod
|
|
135
|
+
def _encode(value: object) -> str:
|
|
136
|
+
raw = json.dumps(value)
|
|
137
|
+
if len(raw) <= _GZIP_THRESHOLD:
|
|
138
|
+
return f"json:{raw}"
|
|
139
|
+
compressed = base64.b64encode(
|
|
140
|
+
gzip.compress(raw.encode()),
|
|
141
|
+
).decode("ascii")
|
|
142
|
+
return f"gzip:{compressed}"
|
|
143
|
+
|
|
144
|
+
@staticmethod
|
|
145
|
+
def _decode(blob: str) -> object:
|
|
146
|
+
if blob.startswith("json:"):
|
|
147
|
+
return json.loads(blob[5:])
|
|
148
|
+
if blob.startswith("gzip:"):
|
|
149
|
+
return json.loads(
|
|
150
|
+
gzip.decompress(base64.b64decode(blob[5:])),
|
|
151
|
+
)
|
|
152
|
+
# Legacy: no prefix — plain JSON (written before this change).
|
|
153
|
+
return json.loads(blob)
|
|
154
|
+
|
|
155
|
+
def __getitem__(self, key: CacheKey) -> object:
|
|
156
|
+
image_uuid, kind = key
|
|
157
|
+
row = self._conn.execute(
|
|
158
|
+
"SELECT value FROM image_cache "
|
|
159
|
+
"WHERE image_uuid=? AND kind=?",
|
|
160
|
+
(image_uuid, kind),
|
|
161
|
+
).fetchone()
|
|
162
|
+
if row is None:
|
|
163
|
+
raise KeyError(key)
|
|
164
|
+
return self._decode(row["value"])
|
|
165
|
+
|
|
166
|
+
def __setitem__(self, key: CacheKey, value: object) -> None:
|
|
167
|
+
image_uuid, kind = key
|
|
168
|
+
self._conn.execute(
|
|
169
|
+
"INSERT OR REPLACE INTO image_cache "
|
|
170
|
+
"(image_uuid, kind, value) VALUES (?, ?, ?)",
|
|
171
|
+
(image_uuid, kind, self._encode(value)),
|
|
172
|
+
)
|
|
173
|
+
self._conn.commit()
|
|
174
|
+
|
|
175
|
+
def __contains__(self, key: object) -> bool:
|
|
176
|
+
if not isinstance(key, tuple) or len(key) != 2:
|
|
177
|
+
return False
|
|
178
|
+
image_uuid, kind = key
|
|
179
|
+
row = self._conn.execute(
|
|
180
|
+
"SELECT 1 FROM image_cache "
|
|
181
|
+
"WHERE image_uuid=? AND kind=?",
|
|
182
|
+
(image_uuid, kind),
|
|
183
|
+
).fetchone()
|
|
184
|
+
return row is not None
|
|
185
|
+
|
|
186
|
+
def __delitem__(self, key: CacheKey) -> None:
|
|
187
|
+
image_uuid, kind = key
|
|
188
|
+
cur = self._conn.execute(
|
|
189
|
+
"DELETE FROM image_cache "
|
|
190
|
+
"WHERE image_uuid=? AND kind=?",
|
|
191
|
+
(image_uuid, kind),
|
|
192
|
+
)
|
|
193
|
+
if cur.rowcount == 0:
|
|
194
|
+
raise KeyError(key)
|
|
195
|
+
self._conn.commit()
|
|
196
|
+
|
|
197
|
+
def __iter__(self) -> Iterator[CacheKey]:
|
|
198
|
+
rows = self._conn.execute(
|
|
199
|
+
"SELECT image_uuid, kind FROM image_cache",
|
|
200
|
+
).fetchall()
|
|
201
|
+
return iter(
|
|
202
|
+
(row["image_uuid"], row["kind"]) for row in rows
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
def __len__(self) -> int:
|
|
206
|
+
row = self._conn.execute(
|
|
207
|
+
"SELECT COUNT(*) FROM image_cache",
|
|
208
|
+
).fetchone()
|
|
209
|
+
assert row is not None
|
|
210
|
+
return row[0] # type: ignore[no-any-return]
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class SessionStore:
|
|
214
|
+
MAX_SHELL_HISTORY = 10_000
|
|
215
|
+
|
|
216
|
+
def __init__(self, db_path: Path, session_key: str) -> None:
|
|
217
|
+
self._session_key = session_key
|
|
218
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
219
|
+
self._conn = sqlite3.connect(str(db_path), timeout=5.0)
|
|
220
|
+
self._conn.row_factory = sqlite3.Row
|
|
221
|
+
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
222
|
+
self._conn.execute("PRAGMA busy_timeout=5000")
|
|
223
|
+
self._conn.executescript(SCHEMA)
|
|
224
|
+
self._migrate()
|
|
225
|
+
|
|
226
|
+
def _migrate(self) -> None:
|
|
227
|
+
"""Apply schema migrations for existing databases."""
|
|
228
|
+
try:
|
|
229
|
+
self._conn.execute(
|
|
230
|
+
"ALTER TABLE session_state "
|
|
231
|
+
"ADD COLUMN cwd TEXT NOT NULL DEFAULT ''"
|
|
232
|
+
)
|
|
233
|
+
self._conn.commit()
|
|
234
|
+
except sqlite3.OperationalError:
|
|
235
|
+
pass # Column already exists
|
|
236
|
+
|
|
237
|
+
@cached_property
|
|
238
|
+
def cache(self) -> ImageCache:
|
|
239
|
+
return ImageCache(self._conn)
|
|
240
|
+
|
|
241
|
+
def _query_sessions(
|
|
242
|
+
self, suffix: str = "", params: tuple[object, ...] = (),
|
|
243
|
+
) -> list[Session]:
|
|
244
|
+
cur = self._conn.execute(
|
|
245
|
+
"""
|
|
246
|
+
SELECT s.session_key, s.active_branch, s.cwd,
|
|
247
|
+
h.image_uuid, h.kind, h.title, s.updated_at
|
|
248
|
+
FROM session_state s
|
|
249
|
+
JOIN session_branches b
|
|
250
|
+
ON b.session_key = s.session_key
|
|
251
|
+
AND b.branch_name = s.active_branch
|
|
252
|
+
JOIN session_history h ON h.id = b.history_id
|
|
253
|
+
"""
|
|
254
|
+
+ suffix,
|
|
255
|
+
params,
|
|
256
|
+
)
|
|
257
|
+
return [
|
|
258
|
+
Session(
|
|
259
|
+
session_key=row["session_key"],
|
|
260
|
+
active_branch=row["active_branch"],
|
|
261
|
+
current_image=row["image_uuid"],
|
|
262
|
+
last_kind=row["kind"],
|
|
263
|
+
last_title=row["title"],
|
|
264
|
+
updated_at=row["updated_at"],
|
|
265
|
+
cwd=row["cwd"],
|
|
266
|
+
)
|
|
267
|
+
for row in cur.fetchall()
|
|
268
|
+
]
|
|
269
|
+
|
|
270
|
+
@property
|
|
271
|
+
def session_key(self) -> str:
|
|
272
|
+
return self._session_key
|
|
273
|
+
|
|
274
|
+
@property
|
|
275
|
+
def current_image(self) -> str:
|
|
276
|
+
s = self.session
|
|
277
|
+
if s is None:
|
|
278
|
+
print(
|
|
279
|
+
"No active session. Run `contree use IMAGE` first.",
|
|
280
|
+
file=sys.stderr,
|
|
281
|
+
)
|
|
282
|
+
raise SystemExit(1)
|
|
283
|
+
return s.current_image
|
|
284
|
+
|
|
285
|
+
@property
|
|
286
|
+
def session(self) -> Session | None:
|
|
287
|
+
rows = self._query_sessions(
|
|
288
|
+
"WHERE s.session_key = ?", (self._session_key,),
|
|
289
|
+
)
|
|
290
|
+
return rows[0] if rows else None
|
|
291
|
+
|
|
292
|
+
def history_depth(self) -> int:
|
|
293
|
+
"""Count steps from root to the current branch tip."""
|
|
294
|
+
cur = self._conn.execute(
|
|
295
|
+
"""
|
|
296
|
+
SELECT b.history_id
|
|
297
|
+
FROM session_state s
|
|
298
|
+
JOIN session_branches b
|
|
299
|
+
ON b.session_key = s.session_key
|
|
300
|
+
AND b.branch_name = s.active_branch
|
|
301
|
+
WHERE s.session_key = ?
|
|
302
|
+
""",
|
|
303
|
+
(self._session_key,),
|
|
304
|
+
)
|
|
305
|
+
row = cur.fetchone()
|
|
306
|
+
if row is None:
|
|
307
|
+
return 0
|
|
308
|
+
depth = 0
|
|
309
|
+
hid: int | None = row["history_id"]
|
|
310
|
+
while hid is not None:
|
|
311
|
+
depth += 1
|
|
312
|
+
cur = self._conn.execute(
|
|
313
|
+
"SELECT parent_id FROM session_history WHERE id = ?",
|
|
314
|
+
(hid,),
|
|
315
|
+
)
|
|
316
|
+
parent = cur.fetchone()
|
|
317
|
+
hid = parent["parent_id"] if parent is not None else None
|
|
318
|
+
return depth
|
|
319
|
+
|
|
320
|
+
def _get_history_entry(self, history_id: int) -> HistoryEntry:
|
|
321
|
+
cur = self._conn.execute(
|
|
322
|
+
"SELECT * FROM session_history WHERE id = ?",
|
|
323
|
+
(history_id,),
|
|
324
|
+
)
|
|
325
|
+
row = cur.fetchone()
|
|
326
|
+
if row is None:
|
|
327
|
+
raise ValueError(f"History entry {history_id} not found")
|
|
328
|
+
return _entry_from_row(row)
|
|
329
|
+
|
|
330
|
+
def rollback(self, n: int = 1) -> HistoryEntry:
|
|
331
|
+
if n < 1:
|
|
332
|
+
raise ValueError("Rollback steps must be >= 1")
|
|
333
|
+
# Get active branch tip
|
|
334
|
+
cur = self._conn.execute(
|
|
335
|
+
"""
|
|
336
|
+
SELECT b.history_id, s.active_branch
|
|
337
|
+
FROM session_state s
|
|
338
|
+
JOIN session_branches b
|
|
339
|
+
ON b.session_key = s.session_key
|
|
340
|
+
AND b.branch_name = s.active_branch
|
|
341
|
+
WHERE s.session_key = ?
|
|
342
|
+
""",
|
|
343
|
+
(self._session_key,),
|
|
344
|
+
)
|
|
345
|
+
row = cur.fetchone()
|
|
346
|
+
if row is None:
|
|
347
|
+
raise ValueError("No active session")
|
|
348
|
+
history_id: int = row["history_id"]
|
|
349
|
+
branch: str = row["active_branch"]
|
|
350
|
+
|
|
351
|
+
# Walk parent chain N steps
|
|
352
|
+
current_id = history_id
|
|
353
|
+
for i in range(n):
|
|
354
|
+
entry = self._get_history_entry(current_id)
|
|
355
|
+
if entry.parent_id is None:
|
|
356
|
+
raise ValueError(
|
|
357
|
+
f"Cannot rollback {n} steps:"
|
|
358
|
+
f" only {i} ancestors available"
|
|
359
|
+
)
|
|
360
|
+
current_id = entry.parent_id
|
|
361
|
+
|
|
362
|
+
# Move branch pointer
|
|
363
|
+
self._conn.execute(
|
|
364
|
+
"UPDATE session_branches SET history_id = ? "
|
|
365
|
+
"WHERE session_key = ? AND branch_name = ?",
|
|
366
|
+
(current_id, self._session_key, branch),
|
|
367
|
+
)
|
|
368
|
+
self._conn.execute(
|
|
369
|
+
"UPDATE session_state "
|
|
370
|
+
"SET updated_at = strftime('%Y-%m-%dT%H:%M:%S','now') "
|
|
371
|
+
"WHERE session_key = ?",
|
|
372
|
+
(self._session_key,),
|
|
373
|
+
)
|
|
374
|
+
self._conn.commit()
|
|
375
|
+
return self._get_history_entry(current_id)
|
|
376
|
+
|
|
377
|
+
def create_branch(
|
|
378
|
+
self, name: str, from_branch: str | None = None,
|
|
379
|
+
) -> None:
|
|
380
|
+
source = from_branch or self._active_branch()
|
|
381
|
+
if source is None:
|
|
382
|
+
raise ValueError("No active session")
|
|
383
|
+
|
|
384
|
+
# Check source branch exists
|
|
385
|
+
cur = self._conn.execute(
|
|
386
|
+
"SELECT history_id FROM session_branches "
|
|
387
|
+
"WHERE session_key = ? AND branch_name = ?",
|
|
388
|
+
(self._session_key, source),
|
|
389
|
+
)
|
|
390
|
+
row = cur.fetchone()
|
|
391
|
+
if row is None:
|
|
392
|
+
raise ValueError(
|
|
393
|
+
f"Source branch {source!r} does not exist",
|
|
394
|
+
)
|
|
395
|
+
history_id: int = row["history_id"]
|
|
396
|
+
|
|
397
|
+
# Check name doesn't already exist
|
|
398
|
+
cur = self._conn.execute(
|
|
399
|
+
"SELECT 1 FROM session_branches "
|
|
400
|
+
"WHERE session_key = ? AND branch_name = ?",
|
|
401
|
+
(self._session_key, name),
|
|
402
|
+
)
|
|
403
|
+
if cur.fetchone() is not None:
|
|
404
|
+
raise ValueError(f"Branch {name!r} already exists")
|
|
405
|
+
|
|
406
|
+
self._conn.execute(
|
|
407
|
+
"INSERT INTO session_branches "
|
|
408
|
+
"(session_key, branch_name, history_id) "
|
|
409
|
+
"VALUES (?, ?, ?)",
|
|
410
|
+
(self._session_key, name, history_id),
|
|
411
|
+
)
|
|
412
|
+
self._conn.commit()
|
|
413
|
+
|
|
414
|
+
def list_branches(self) -> list[tuple[str, bool]]:
|
|
415
|
+
active = self._active_branch()
|
|
416
|
+
if active is None:
|
|
417
|
+
return []
|
|
418
|
+
cur = self._conn.execute(
|
|
419
|
+
"SELECT branch_name FROM session_branches "
|
|
420
|
+
"WHERE session_key = ? ORDER BY branch_name",
|
|
421
|
+
(self._session_key,),
|
|
422
|
+
)
|
|
423
|
+
return [
|
|
424
|
+
(row["branch_name"], row["branch_name"] == active)
|
|
425
|
+
for row in cur.fetchall()
|
|
426
|
+
]
|
|
427
|
+
|
|
428
|
+
def switch_branch(self, name: str) -> HistoryEntry:
|
|
429
|
+
cur = self._conn.execute(
|
|
430
|
+
"SELECT history_id FROM session_branches "
|
|
431
|
+
"WHERE session_key = ? AND branch_name = ?",
|
|
432
|
+
(self._session_key, name),
|
|
433
|
+
)
|
|
434
|
+
row = cur.fetchone()
|
|
435
|
+
if row is None:
|
|
436
|
+
raise ValueError(f"Branch {name!r} does not exist")
|
|
437
|
+
history_id: int = row["history_id"]
|
|
438
|
+
|
|
439
|
+
self._conn.execute(
|
|
440
|
+
"UPDATE session_state SET active_branch = ?, "
|
|
441
|
+
"updated_at = strftime('%Y-%m-%dT%H:%M:%S','now') "
|
|
442
|
+
"WHERE session_key = ?",
|
|
443
|
+
(name, self._session_key),
|
|
444
|
+
)
|
|
445
|
+
self._conn.commit()
|
|
446
|
+
return self._get_history_entry(history_id)
|
|
447
|
+
|
|
448
|
+
def list_sessions(self) -> list[Session]:
|
|
449
|
+
return self._query_sessions("ORDER BY s.updated_at DESC")
|
|
450
|
+
|
|
451
|
+
def history_dag(
|
|
452
|
+
self,
|
|
453
|
+
) -> tuple[list[HistoryEntry], dict[int, list[str]]]:
|
|
454
|
+
cur = self._conn.execute(
|
|
455
|
+
"SELECT * FROM session_history "
|
|
456
|
+
"WHERE session_key = ? ORDER BY id",
|
|
457
|
+
(self._session_key,),
|
|
458
|
+
)
|
|
459
|
+
entries = [_entry_from_row(row) for row in cur.fetchall()]
|
|
460
|
+
|
|
461
|
+
cur = self._conn.execute(
|
|
462
|
+
"SELECT history_id, branch_name "
|
|
463
|
+
"FROM session_branches WHERE session_key = ?",
|
|
464
|
+
(self._session_key,),
|
|
465
|
+
)
|
|
466
|
+
branch_map: dict[int, list[str]] = {}
|
|
467
|
+
for row in cur.fetchall():
|
|
468
|
+
branch_map.setdefault(row["history_id"], []).append(
|
|
469
|
+
row["branch_name"],
|
|
470
|
+
)
|
|
471
|
+
return entries, branch_map
|
|
472
|
+
|
|
473
|
+
def find_session(self, name: str) -> Session:
|
|
474
|
+
# Try suffix match first
|
|
475
|
+
rows = self._query_sessions(
|
|
476
|
+
"WHERE s.session_key LIKE ?", (f"%_{name}",),
|
|
477
|
+
)
|
|
478
|
+
if not rows:
|
|
479
|
+
# Try exact match
|
|
480
|
+
rows = self._query_sessions(
|
|
481
|
+
"WHERE s.session_key = ?", (name,),
|
|
482
|
+
)
|
|
483
|
+
if not rows:
|
|
484
|
+
raise ValueError(f"Session {name!r} not found")
|
|
485
|
+
if len(rows) > 1:
|
|
486
|
+
keys = ", ".join(r.session_key for r in rows)
|
|
487
|
+
raise ValueError(
|
|
488
|
+
f"Ambiguous session {name!r}: matches {keys}",
|
|
489
|
+
)
|
|
490
|
+
return rows[0]
|
|
491
|
+
|
|
492
|
+
def _active_branch(self) -> str | None:
|
|
493
|
+
cur = self._conn.execute(
|
|
494
|
+
"SELECT active_branch FROM session_state "
|
|
495
|
+
"WHERE session_key = ?",
|
|
496
|
+
(self._session_key,),
|
|
497
|
+
)
|
|
498
|
+
row = cur.fetchone()
|
|
499
|
+
return row["active_branch"] if row else None
|
|
500
|
+
|
|
501
|
+
def set_image(
|
|
502
|
+
self, image_uuid: str, *, kind: str = "", title: str = "",
|
|
503
|
+
) -> int:
|
|
504
|
+
# Look up active branch's current history_id
|
|
505
|
+
cur = self._conn.execute(
|
|
506
|
+
"""
|
|
507
|
+
SELECT b.history_id, s.active_branch
|
|
508
|
+
FROM session_state s
|
|
509
|
+
JOIN session_branches b
|
|
510
|
+
ON b.session_key = s.session_key
|
|
511
|
+
AND b.branch_name = s.active_branch
|
|
512
|
+
WHERE s.session_key = ?
|
|
513
|
+
""",
|
|
514
|
+
(self._session_key,),
|
|
515
|
+
)
|
|
516
|
+
row = cur.fetchone()
|
|
517
|
+
parent_id: int | None = row["history_id"] if row else None
|
|
518
|
+
branch: str = row["active_branch"] if row else "main"
|
|
519
|
+
|
|
520
|
+
# Insert history entry
|
|
521
|
+
cur = self._conn.execute(
|
|
522
|
+
"""
|
|
523
|
+
INSERT INTO session_history
|
|
524
|
+
(session_key, image_uuid, parent_id, kind, title)
|
|
525
|
+
VALUES (?, ?, ?, ?, ?)
|
|
526
|
+
""",
|
|
527
|
+
(self._session_key, image_uuid, parent_id, kind, title),
|
|
528
|
+
)
|
|
529
|
+
new_id = cur.lastrowid
|
|
530
|
+
assert new_id is not None
|
|
531
|
+
|
|
532
|
+
# Advance branch pointer
|
|
533
|
+
self._conn.execute(
|
|
534
|
+
"""
|
|
535
|
+
INSERT INTO session_branches
|
|
536
|
+
(session_key, branch_name, history_id)
|
|
537
|
+
VALUES (?, ?, ?)
|
|
538
|
+
ON CONFLICT(session_key, branch_name) DO UPDATE SET
|
|
539
|
+
history_id = excluded.history_id
|
|
540
|
+
""",
|
|
541
|
+
(self._session_key, branch, new_id),
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
# Upsert session state
|
|
545
|
+
self._conn.execute(
|
|
546
|
+
"""
|
|
547
|
+
INSERT INTO session_state
|
|
548
|
+
(session_key, active_branch, updated_at)
|
|
549
|
+
VALUES (?, 'main', strftime('%Y-%m-%dT%H:%M:%S','now'))
|
|
550
|
+
ON CONFLICT(session_key) DO UPDATE SET
|
|
551
|
+
updated_at = strftime('%Y-%m-%dT%H:%M:%S','now')
|
|
552
|
+
""",
|
|
553
|
+
(self._session_key,),
|
|
554
|
+
)
|
|
555
|
+
self._conn.commit()
|
|
556
|
+
return new_id
|
|
557
|
+
|
|
558
|
+
def add_pending_file(
|
|
559
|
+
self,
|
|
560
|
+
history_id: int,
|
|
561
|
+
instance_path: str,
|
|
562
|
+
file_uuid: str,
|
|
563
|
+
uid: int = 0,
|
|
564
|
+
gid: int = 0,
|
|
565
|
+
mode: str = "0644",
|
|
566
|
+
) -> None:
|
|
567
|
+
self._conn.execute(
|
|
568
|
+
"""
|
|
569
|
+
INSERT INTO session_files
|
|
570
|
+
(history_id, instance_path, file_uuid, uid, gid, mode)
|
|
571
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
572
|
+
""",
|
|
573
|
+
(history_id, instance_path, file_uuid, uid, gid, mode),
|
|
574
|
+
)
|
|
575
|
+
self._conn.commit()
|
|
576
|
+
|
|
577
|
+
def pending_files(self) -> list[PendingFile]:
|
|
578
|
+
"""Return pending files that haven't been baked in by a run yet.
|
|
579
|
+
|
|
580
|
+
Walks history from the branch tip backwards, collecting
|
|
581
|
+
``kind='file'`` entry IDs until hitting a ``kind='run'`` entry.
|
|
582
|
+
Looks up the corresponding file records in session_files.
|
|
583
|
+
"""
|
|
584
|
+
branch = self._active_branch()
|
|
585
|
+
if branch is None:
|
|
586
|
+
return []
|
|
587
|
+
cur = self._conn.execute(
|
|
588
|
+
"SELECT history_id FROM session_branches "
|
|
589
|
+
"WHERE session_key = ? AND branch_name = ?",
|
|
590
|
+
(self._session_key, branch),
|
|
591
|
+
)
|
|
592
|
+
row = cur.fetchone()
|
|
593
|
+
if row is None:
|
|
594
|
+
return []
|
|
595
|
+
|
|
596
|
+
# Walk history from tip, collect file entry IDs until a run
|
|
597
|
+
file_history_ids: list[int] = []
|
|
598
|
+
history_id: int = row["history_id"]
|
|
599
|
+
while True:
|
|
600
|
+
entry = self._get_history_entry(history_id)
|
|
601
|
+
if entry.kind == "run":
|
|
602
|
+
break
|
|
603
|
+
if entry.kind == "file":
|
|
604
|
+
file_history_ids.append(entry.id)
|
|
605
|
+
if entry.parent_id is None:
|
|
606
|
+
break
|
|
607
|
+
history_id = entry.parent_id
|
|
608
|
+
|
|
609
|
+
if not file_history_ids:
|
|
610
|
+
return []
|
|
611
|
+
|
|
612
|
+
placeholders = ",".join("?" for _ in file_history_ids)
|
|
613
|
+
cur = self._conn.execute(
|
|
614
|
+
"SELECT instance_path, file_uuid, uid, gid, mode "
|
|
615
|
+
"FROM session_files "
|
|
616
|
+
f"WHERE history_id IN ({placeholders}) "
|
|
617
|
+
"ORDER BY history_id DESC",
|
|
618
|
+
tuple(file_history_ids),
|
|
619
|
+
)
|
|
620
|
+
# Deduplicate by path — most recent edit (highest history_id) wins
|
|
621
|
+
seen: set[str] = set()
|
|
622
|
+
result: list[PendingFile] = []
|
|
623
|
+
for r in cur.fetchall():
|
|
624
|
+
if r["instance_path"] not in seen:
|
|
625
|
+
seen.add(r["instance_path"])
|
|
626
|
+
result.append(PendingFile(
|
|
627
|
+
instance_path=r["instance_path"],
|
|
628
|
+
file_uuid=r["file_uuid"],
|
|
629
|
+
uid=r["uid"],
|
|
630
|
+
gid=r["gid"],
|
|
631
|
+
mode=r["mode"],
|
|
632
|
+
))
|
|
633
|
+
return result
|
|
634
|
+
|
|
635
|
+
def clear_pending_files(self) -> int:
|
|
636
|
+
cur = self._conn.execute(
|
|
637
|
+
"DELETE FROM session_files "
|
|
638
|
+
"WHERE history_id IN ("
|
|
639
|
+
" SELECT id FROM session_history "
|
|
640
|
+
" WHERE session_key = ?"
|
|
641
|
+
")",
|
|
642
|
+
(self._session_key,),
|
|
643
|
+
)
|
|
644
|
+
self._conn.commit()
|
|
645
|
+
return cur.rowcount
|
|
646
|
+
|
|
647
|
+
def get_cwd(self) -> str:
|
|
648
|
+
"""Return the current working directory from session history.
|
|
649
|
+
|
|
650
|
+
Walks the history chain from the branch tip backwards until a
|
|
651
|
+
``kind='cd'`` entry is found. Returns its *title* (the path).
|
|
652
|
+
Returns ``""`` when no ``cd`` entry exists or no session is active.
|
|
653
|
+
"""
|
|
654
|
+
branch = self._active_branch()
|
|
655
|
+
if not branch:
|
|
656
|
+
return ""
|
|
657
|
+
cur = self._conn.execute(
|
|
658
|
+
"SELECT history_id FROM session_branches "
|
|
659
|
+
"WHERE session_key = ? AND branch_name = ?",
|
|
660
|
+
(self._session_key, branch),
|
|
661
|
+
)
|
|
662
|
+
row = cur.fetchone()
|
|
663
|
+
if not row:
|
|
664
|
+
return ""
|
|
665
|
+
history_id: int = row["history_id"]
|
|
666
|
+
while True:
|
|
667
|
+
entry = self._get_history_entry(history_id)
|
|
668
|
+
if entry.kind == "cd":
|
|
669
|
+
return entry.title
|
|
670
|
+
if entry.parent_id is None:
|
|
671
|
+
return ""
|
|
672
|
+
history_id = entry.parent_id
|
|
673
|
+
|
|
674
|
+
def resolve_path(self, path: str) -> str:
|
|
675
|
+
"""Resolve a container path against the session cwd.
|
|
676
|
+
|
|
677
|
+
Relative paths are joined with cwd. The result is always
|
|
678
|
+
normalised. Returns cwd (or ``/``) for empty input.
|
|
679
|
+
"""
|
|
680
|
+
if not path:
|
|
681
|
+
return self.get_cwd() or "/"
|
|
682
|
+
if not PurePosixPath(path).is_absolute():
|
|
683
|
+
cwd = self.get_cwd() or "/"
|
|
684
|
+
path = cwd.rstrip("/") + "/" + path
|
|
685
|
+
return posixpath.normpath(path)
|
|
686
|
+
|
|
687
|
+
def set_cwd(self, cwd: str) -> None:
|
|
688
|
+
"""Store the cwd as a ``kind='cd'`` entry in session history."""
|
|
689
|
+
s = self.session
|
|
690
|
+
if s is None:
|
|
691
|
+
return # No active session — nothing to persist
|
|
692
|
+
self.set_image(s.current_image, kind="cd", title=cwd)
|
|
693
|
+
|
|
694
|
+
def load_shell_history(self) -> list[str]:
|
|
695
|
+
"""Return shell history lines for this session, oldest first."""
|
|
696
|
+
cur = self._conn.execute(
|
|
697
|
+
"SELECT line FROM shell_history "
|
|
698
|
+
"WHERE session_key = ? ORDER BY id",
|
|
699
|
+
(self._session_key,),
|
|
700
|
+
)
|
|
701
|
+
return [row["line"] for row in cur.fetchall()]
|
|
702
|
+
|
|
703
|
+
def add_shell_history(self, line: str) -> None:
|
|
704
|
+
"""Append a single line to the shell history."""
|
|
705
|
+
self._conn.execute(
|
|
706
|
+
"INSERT INTO shell_history (session_key, line) "
|
|
707
|
+
"VALUES (?, ?)",
|
|
708
|
+
(self._session_key, line),
|
|
709
|
+
)
|
|
710
|
+
self._conn.commit()
|
|
711
|
+
|
|
712
|
+
def trim_shell_history(self) -> None:
|
|
713
|
+
"""Delete the oldest entries that exceed the maximum."""
|
|
714
|
+
self._conn.execute(
|
|
715
|
+
"DELETE FROM shell_history WHERE id IN ("
|
|
716
|
+
" SELECT id FROM shell_history "
|
|
717
|
+
" WHERE session_key = ? "
|
|
718
|
+
" ORDER BY id "
|
|
719
|
+
" LIMIT MAX(0, ("
|
|
720
|
+
" SELECT COUNT(*) FROM shell_history "
|
|
721
|
+
" WHERE session_key = ?"
|
|
722
|
+
" ) - ?)"
|
|
723
|
+
")",
|
|
724
|
+
(
|
|
725
|
+
self._session_key,
|
|
726
|
+
self._session_key,
|
|
727
|
+
self.MAX_SHELL_HISTORY,
|
|
728
|
+
),
|
|
729
|
+
)
|
|
730
|
+
self._conn.commit()
|
|
731
|
+
|
|
732
|
+
def close(self) -> None:
|
|
733
|
+
self._conn.close()
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def get_session_key(profile_name: str) -> str:
|
|
737
|
+
env = os.environ.get("CONTREE_SESSION")
|
|
738
|
+
if env:
|
|
739
|
+
return env
|
|
740
|
+
|
|
741
|
+
ppid = os.getppid()
|
|
742
|
+
try:
|
|
743
|
+
tty = os.ttyname(sys.stdin.fileno())
|
|
744
|
+
tty_part = tty.replace("/", "_")
|
|
745
|
+
except OSError:
|
|
746
|
+
tty_part = "notty"
|
|
747
|
+
return str(
|
|
748
|
+
uuid.uuid5(
|
|
749
|
+
uuid.NAMESPACE_OID,
|
|
750
|
+
f"{profile_name}_{ppid}_{tty_part}",
|
|
751
|
+
),
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def get_db_path() -> Path:
|
|
756
|
+
env = os.environ.get("CONTREE_SESSION_DB")
|
|
757
|
+
if env:
|
|
758
|
+
return Path(env)
|
|
759
|
+
return (
|
|
760
|
+
Path.home() / ".local" / "lib" / "contree-cli" / "sessions.db"
|
|
761
|
+
)
|