bcbd1bdb22
Integ / changes (push) Has been skipped
Pre-commit / pre-commit (push) Failing after 1s
CLI exit codes / changes (push) Has been skipped
Test (Install) / changes (push) Has been skipped
Test (Python) / changes (push) Has been skipped
Test (TypeScript) / changes (push) Has been skipped
CLI exit codes / cli-gate (push) Has been cancelled
Test (Install) / test-install-gate (push) Has been cancelled
Integ / integ-gate (push) Has been cancelled
Test (Python) / test-python-gate (push) Has been cancelled
Test (TypeScript) / test-typescript-gate (push) Has been cancelled
Test (Install) / python-minimal (3.12) (push) Has been cancelled
Test (Install) / python-minimal (3.11) (push) Has been cancelled
Test (Install) / python-extra (agno, mirage.agents.agno) (push) Has been cancelled
Test (Install) / python-extra (chroma, mirage.resource.chroma) (push) Has been cancelled
Test (Install) / python-extra (pdf, mirage.core.filetype.pdf) (push) Has been cancelled
Integ / integ (push) Has been cancelled
Integ / integ-database (push) Has been cancelled
Integ / integ-database-ts (push) Has been cancelled
Integ / integ-data (push) Has been cancelled
Integ / integ-ssh (push) Has been cancelled
Integ / integ-ssh-ts (push) Has been cancelled
Test (Python) / audit (push) Has been cancelled
Test (TypeScript) / test (push) Has been cancelled
Test (TypeScript) / python-fs-shim (push) Has been cancelled
CLI exit codes / Python CLI (push) Has been cancelled
CLI exit codes / TypeScript CLI (push) Has been cancelled
CLI exit codes / Cross-language snapshot interop (push) Has been cancelled
Test (Python) / test (push) Has been cancelled
Test (Python) / import-isolation (deepagents, openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Python) / import-isolation (deepagents, pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Integ / integ-ts (push) Has been cancelled
Integ / integ-fuse (push) Has been cancelled
Test (Install) / python-extra (databricks, mirage.resource.databricks_volume) (push) Has been cancelled
Test (Install) / python-extra (deepagents, mirage.agents.langchain) (push) Has been cancelled
Test (Install) / python-extra (email, mirage.resource.email) (push) Has been cancelled
Test (Install) / python-extra (fuse, mirage.fuse.mount) (push) Has been cancelled
Test (Install) / python-extra (hdf5, mirage.core.filetype.hdf5) (push) Has been cancelled
Test (Install) / python-extra (hf, mirage.resource.hf_buckets) (push) Has been cancelled
Test (Install) / python-extra (lancedb, mirage.resource.lancedb) (push) Has been cancelled
Test (Install) / python-extra (langfuse, mirage.resource.langfuse) (push) Has been cancelled
Test (Install) / python-extra (mongodb, mirage.resource.mongodb) (push) Has been cancelled
Test (Install) / python-extra (nextcloud, mirage.resource.nextcloud) (push) Has been cancelled
Test (Install) / python-extra (openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Install) / python-extra (openhands, mirage.agents.openhands, 3.12) (push) Has been cancelled
Test (Install) / python-extra (parquet, mirage.core.filetype.parquet) (push) Has been cancelled
Test (Install) / python-extra (postgres, mirage.resource.postgres) (push) Has been cancelled
Test (Install) / python-extra (pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Test (Install) / python-extra (qdrant, mirage.resource.qdrant) (push) Has been cancelled
Test (Install) / python-extra (redis, mirage.resource.redis) (push) Has been cancelled
Test (Install) / python-extra (s3, mirage.resource.s3) (push) Has been cancelled
Test (Install) / python-extra (ssh, mirage.resource.ssh) (push) Has been cancelled
Test (Install) / ts-minimal (push) Has been cancelled
277 lines
10 KiB
Python
277 lines
10 KiB
Python
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
|
|
|
import json
|
|
import time
|
|
|
|
from mirage.io.types import IOResult
|
|
from mirage.observe.log_entry import (EVENT_CLEAR, EVENT_COMMAND, EVENT_DELETE,
|
|
EVENT_OP, STDOUT_TRUNCATE, LogEntry)
|
|
from mirage.observe.record import OpRecord
|
|
from mirage.observe.store import ObserverStore, RAMObserverStore
|
|
from mirage.utils.dates import utc_date_folder
|
|
|
|
|
|
def _parse_files(files: dict[str, bytes]) -> list[dict]:
|
|
out: list[dict] = []
|
|
for key in sorted(files.keys()):
|
|
if not key.endswith(".jsonl"):
|
|
continue
|
|
for line in files[key].decode().splitlines():
|
|
if line:
|
|
out.append(json.loads(line))
|
|
return out
|
|
|
|
|
|
def _now_ms() -> int:
|
|
return int(time.time() * 1000)
|
|
|
|
|
|
class Observer:
|
|
"""Persists LogEntry records to an ObserverStore as JSONL files.
|
|
|
|
The hidden recorder: it owns no mount and its store is reachable
|
|
only through this class, so the log is invisible to agents. Views
|
|
(/.bash_history, the history builtin) render from the query
|
|
methods below; swapping infra (RAM, Redis, disk, opfs) means
|
|
passing a different store, nothing above this seam changes.
|
|
|
|
Args:
|
|
store (ObserverStore | None): Storage backend for log files;
|
|
defaults to an in-memory RAMObserverStore.
|
|
"""
|
|
|
|
def __init__(self, store: ObserverStore | None = None) -> None:
|
|
self._store = store if store is not None else RAMObserverStore()
|
|
|
|
@property
|
|
def store(self) -> ObserverStore:
|
|
return self._store
|
|
|
|
async def _log(self, entry: LogEntry) -> None:
|
|
line = (entry.to_json_line() + "\n").encode()
|
|
await self._store.append(f"/{utc_date_folder()}/{entry.session}.jsonl",
|
|
line)
|
|
|
|
async def log_op(
|
|
self,
|
|
rec: OpRecord,
|
|
agent: str,
|
|
session: str,
|
|
cwd: str | None = None,
|
|
) -> None:
|
|
"""Persist an OpRecord as a JSONL line.
|
|
|
|
Args:
|
|
rec (OpRecord): The operation record.
|
|
agent (str): Agent ID.
|
|
session (str): Session ID.
|
|
cwd (str | None): Session cwd at log time.
|
|
"""
|
|
await self._log(LogEntry.from_op_record(rec, agent, session, cwd))
|
|
|
|
async def log_execution(
|
|
self,
|
|
command: str,
|
|
io: IOResult,
|
|
op_records: list[OpRecord],
|
|
agent: str,
|
|
session: str,
|
|
cwd: str | None = None,
|
|
) -> None:
|
|
"""Record one finished typed line: its ops, then its command.
|
|
|
|
The line reader's single recording call: every op the line
|
|
emitted lands first, then the command entry itself.
|
|
|
|
Args:
|
|
command (str): The typed line, verbatim and unexpanded.
|
|
io (IOResult): Final result of the line.
|
|
op_records (list[OpRecord]): Ops collected while it ran.
|
|
agent (str): Agent that issued the line.
|
|
session (str): Session the line ran in.
|
|
cwd (str | None): Session cwd at completion.
|
|
"""
|
|
# TODO: batch the op lines and the command line into a single
|
|
# store.append so one typed line costs one roundtrip and lands
|
|
# atomically in the store.
|
|
for rec in op_records:
|
|
await self.log_op(rec, agent, session, cwd)
|
|
stdout = await io.materialize_stdout()
|
|
await self._log(
|
|
LogEntry(
|
|
type=EVENT_COMMAND,
|
|
agent=agent,
|
|
session=session,
|
|
timestamp=_now_ms(),
|
|
cwd=cwd,
|
|
command=command,
|
|
exit_code=io.exit_code,
|
|
stdout=stdout.decode(errors="replace")[:STDOUT_TRUNCATE],
|
|
))
|
|
|
|
async def log_command_text(self,
|
|
command: str,
|
|
session: str,
|
|
agent: str = "",
|
|
cwd: str | None = None) -> None:
|
|
"""Append a command entry without an execution (history -s).
|
|
|
|
Args:
|
|
command (str): Command text to record as a single entry.
|
|
session (str): Session ID the entry belongs to.
|
|
agent (str): Agent ID issuing the append.
|
|
cwd (str | None): Session cwd at append time.
|
|
"""
|
|
await self._log(
|
|
LogEntry(
|
|
type=EVENT_COMMAND,
|
|
agent=agent,
|
|
session=session,
|
|
timestamp=_now_ms(),
|
|
cwd=cwd,
|
|
command=command,
|
|
exit_code=0,
|
|
))
|
|
|
|
async def log_clear(self, session: str, agent: str = "") -> None:
|
|
"""Append a clear tombstone for a session (history -c).
|
|
|
|
Args:
|
|
session (str): Session ID whose history view is cleared.
|
|
agent (str): Agent ID issuing the clear.
|
|
"""
|
|
await self._log(
|
|
LogEntry(
|
|
type=EVENT_CLEAR,
|
|
agent=agent,
|
|
session=session,
|
|
timestamp=_now_ms(),
|
|
))
|
|
|
|
async def log_delete(self,
|
|
session: str,
|
|
offset: int,
|
|
agent: str = "") -> None:
|
|
"""Append a delete event for one listing entry (history -d).
|
|
|
|
Args:
|
|
session (str): Session ID whose entry is deleted.
|
|
offset (int): 1-based listing position; negative counts
|
|
back from the end at the time of the delete.
|
|
agent (str): Agent ID issuing the delete.
|
|
"""
|
|
await self._log(
|
|
LogEntry(
|
|
type=EVENT_DELETE,
|
|
agent=agent,
|
|
session=session,
|
|
timestamp=_now_ms(),
|
|
offset=offset,
|
|
))
|
|
|
|
async def events(self) -> list[dict]:
|
|
"""All recorded events across sessions, in timestamp order.
|
|
|
|
Timestamps are UTC epoch milliseconds. The sort is stable over
|
|
the per-file line order, so events that share a millisecond
|
|
(one line's ops plus its command entry) keep the order they
|
|
were appended in; append order within a session file is the
|
|
contract.
|
|
|
|
Returns:
|
|
list[dict]: Parsed LogEntry dicts.
|
|
"""
|
|
out = _parse_files(await self._store.read_all())
|
|
out.sort(key=lambda e: e.get("timestamp", 0))
|
|
return out
|
|
|
|
async def command_events(self) -> list[dict]:
|
|
"""Command events across all sessions, in append order.
|
|
|
|
Returns:
|
|
list[dict]: Events with type == EVENT_COMMAND.
|
|
"""
|
|
return [
|
|
e for e in await self.events() if e.get("type") == EVENT_COMMAND
|
|
]
|
|
|
|
async def session_command_events(self, session: str) -> list[dict]:
|
|
"""One session's visible history listing, append order.
|
|
|
|
Projects the session's events: commands after the last clear
|
|
tombstone, with delete events applied at the position they
|
|
were issued (history -d renumbers subsequent entries, GNU
|
|
behavior).
|
|
|
|
Args:
|
|
session (str): Session ID to project.
|
|
|
|
Returns:
|
|
list[dict]: Events with type == EVENT_COMMAND.
|
|
"""
|
|
files = await self._store.read_matching(f"/{session}.jsonl")
|
|
entries = _parse_files(files)
|
|
last_clear = -1
|
|
for i, e in enumerate(entries):
|
|
if e.get("type") == EVENT_CLEAR:
|
|
last_clear = i
|
|
visible: list[dict] = []
|
|
for e in entries[last_clear + 1:]:
|
|
kind = e.get("type")
|
|
if kind == EVENT_COMMAND:
|
|
visible.append(e)
|
|
elif kind == EVENT_DELETE:
|
|
offset = e.get("offset", 0)
|
|
idx = offset - 1 if offset > 0 else len(visible) + offset
|
|
if 0 <= idx < len(visible):
|
|
visible.pop(idx)
|
|
return visible
|
|
|
|
async def load_events(self, events: list[dict]) -> None:
|
|
"""Rewind the recorder to a snapshot's events.
|
|
|
|
Clears the store first, mirroring ``ws._cache.clear()`` on the
|
|
checkout path: restoring means becoming the snapshot, so
|
|
events from the pre-restore timeline (including sessions the
|
|
snapshot doesn't know about) do not survive. Events are
|
|
rewritten per session under today's date folder, preserving
|
|
their list order as line order; original date folders are not
|
|
preserved, which the views never depend on (they filter by
|
|
the session field).
|
|
|
|
Foreign-format entries (e.g. the TypeScript snapshot's
|
|
ExecutionRecord history, which has no ``type`` field and may
|
|
carry a non-JSON ``stdout``) are skipped: the views filter by
|
|
``type`` so they are unusable here anyway, and keeping them
|
|
would break re-serialization. The TS loader skips Python's
|
|
entries symmetrically until the history port lands.
|
|
|
|
Args:
|
|
events (list[dict]): LogEntry dicts from StateKey.HISTORY.
|
|
"""
|
|
await self._store.clear()
|
|
day = utc_date_folder()
|
|
known = (EVENT_COMMAND, EVENT_CLEAR, EVENT_DELETE, EVENT_OP)
|
|
by_session: dict[str, list[str]] = {}
|
|
for e in events:
|
|
if e.get("type") not in known:
|
|
continue
|
|
session = e.get("session", "default")
|
|
by_session.setdefault(session, []).append(
|
|
json.dumps(e, separators=(",", ":")))
|
|
for session, lines in by_session.items():
|
|
await self._store.write(f"/{day}/{session}.jsonl",
|
|
("\n".join(lines) + "\n").encode())
|