This notebook walks the five steps of the OS resource-tracking milestone in PROJECT_PLAN.md:
initialize Trail in a directory
explicitly register files as resources
change registered files from outside the notebook
check on demand what Trail recorded
report what changed, identifying each resource by its stable ID
It builds a throwaway toy-project/ in the kernel’s working directory and leaves it there for
inspection, so re-running the notebook from the top is safe. Trail must be importable, e.g.
python -m pip install -e . from the repository root.
import asyncio
import shutil
import subprocess
from pathlib import Path
from trail import Trail
from trail.event import AddEntryEventA toy project on disk¶
Four small files stand in for the datasets of a geospatial project. One of them is never touched, so the log below has an untouched resource to stay silent about.
root = Path.cwd() / 'toy-project'
shutil.rmtree(root, ignore_errors=True)
root.mkdir()
streets = root / 'streets.geojson'
sidewalks = root / 'sidewalks.geojson'
boundary = root / 'boundary.geojson'
measurements = root / 'measurements.csv'
empty_collection = '{"type": "FeatureCollection", "features": []}\n'
streets.write_text(empty_collection, encoding='utf-8')
sidewalks.write_text(empty_collection, encoding='utf-8')
boundary.write_text(empty_collection, encoding='utf-8')
measurements.write_text('segment,surface,width_m\n1,asphalt,4.5\n', encoding='utf-8')
sorted(path.name for path in root.iterdir())1. Initialize Trail in a directory¶
Trail(root) opens the project record at root/.trail, creating it when it is absent. The project
ID is generated once and persisted; reopening the same directory replays the log instead of
minting a new identity.
trail = Trail(root)
trail
trail.entries2. Explicitly register files as resources¶
trail.register returns an Entry per path. Each one carries a random hex ID that is independent of
the path, so the identity survives a rename. Registration is appended to .trail/events.jsonl as
an AddEntryEvent, which samples the size and modification time of the resource as the baseline
a later comparison against the filesystem is measured from.
entries = trail.register(streets, sidewalks, boundary, measurements)
entries
trail.entriesAdding an entry also wakes the observer. A notebook kernel runs an asyncio loop, so the watchdog starts on its own and watches the directory holding each registered file, non-recursively.
trail.watchdog3. Change registered files from outside the notebook¶
The three edits below run in a separate process, so nothing in this kernel observes them directly. Trail learns about them only because the operating system reports them.
measurements.csv is overwritten, sidewalks.geojson is renamed, streets.geojson is deleted,
and boundary.geojson is left alone.
async def settle(seconds: float = 0.5) -> None:
# hand the observer thread time to deliver its events to the consumer task
await asyncio.sleep(seconds)
def outside(*command: str) -> None:
subprocess.run(command, check=True, cwd=root)
marker = len(trail.events)
overwrite = 'printf "segment,surface,width_m\\n1,asphalt,4.5\\n2,concrete,3.0\\n" > measurements.csv'
outside('sh', '-c', overwrite)
outside('mv', 'sidewalks.geojson', 'sidewalks-2026.geojson')
outside('rm', 'streets.geojson')
await settle()4. Check what Trail recorded¶
Nothing needs to be scanned: the observer appended what it saw to .trail/events.jsonl as it
happened, and trail.events is that log.
trail.events.by_pos[-5:]
trail.events.jsonl5. Report what changed, by resource ID¶
Every event names the resource it belongs to. The rename is the point of the exercise:
sidewalks.geojson and sidewalks-2026.geojson report the ID assigned at registration, and
boundary.geojson is absent because nothing happened to it.
for event in trail.events.by_pos[marker:]:
line = f'{event.entry.id} {event.event_type:<9} {Path(event.src_path).name}'
if event.dest_path:
line += f' -> {Path(event.dest_path).name}'
print(line)The registration baseline¶
Because AddEntryEvent recorded a size and a modification time, the log can be held against
the filesystem. boundary.geojson matches what was registered; measurements.csv was
overwritten, and streets.geojson is gone.
for event in trail.events.by_pos:
if not isinstance(event, AddEntryEvent) or event.is_directory:
continue
entry = event.entry
if not entry.path.exists():
state = 'deleted'
elif (entry.path.stat().st_size, entry.path.stat().st_mtime) == (event.size, event.mtime):
state = 'unchanged'
else:
state = 'modified'
print(f'{entry.id} {state:<9} {entry.name} registered {event.size} bytes')The record outlives the session¶
Stopping the observer and reopening the directory replays the log: same project ID, same resource IDs, in the same order, with the renamed file at its current path.
The deleted file is still a registered resource: losing the file does not release its ID.
await trail.watchdog.stop()
reopened = Trail(root)
trail
trail.eventsawait reopened.watchdog.stop()Where the record lives, and what is missing¶
toy-project/.trail/ is left on disk:
project.jsonholds the persistent project IDevents.jsonlholds the append-only record
Gaps against PROJECT_PLAN.md:
there is no
Trail.status()and no CLI; readingtrail.eventsstands in for both.only registration records size and modification time; the watchdog events that follow do not, so a comparison can only be made against the registration baseline.
detection is push-based, through the watchdog, rather than the on-demand
os.statsweep the plan describes; a resource changed while no Trail is running is not noticed.