Living dev note. Iterate freely.
Where bytes live for every kind of daemon output (one-shot exec, long-running run, interactive session, daemon-itself logs).
The model
Tier 1 — Redis (real-time)
- Producer (daemon) appends chunks via
XADD. - Consumers (CLI tail, dashboard LogView, other daemons reading
stdin)
XREAD BLOCKfor sub-millisecond latency. MAXLEN ~256KBper stream caps memory; oldest bytes drop.- Pub/sub fan-out for multi-CLI attach (collaborative debug).
- Stream IDs (
<ms>-<seq>) enable reconnect-with-cursor — no lost bytes on flaky CLI connection.
Tier 2 — S3 (durable archive)
- One key per exec/run/session, schema in S3 bucket provisioning.
- Controlplane reads the full stream from Redis and writes the flattened buffer to S3 at exec/session end.
- Lifecycle policies handle retention (exec=30d, session=7d, run=365d, daemon-logs=14d).
- Consumers fetch via presigned GET — direct from S3, no controlplane in the byte path.
Stream layout
For each exec, run, or session:
Three streams per active session keeps the wire orthogonal. One-shot
exec only uses stdout + optionally stdin if --stdin was passed.
Why this enables TTY
A TTY session is:
- Bidirectional bytes (keystrokes in, screen out)
- A control channel (window resize, signals)
- Persistent state across reconnects
Map directly onto the three Redis streams above:
(The daemon doesn't talk to Redis directly — it POSTs/long-polls to controlplane endpoints that wrap Redis. But the semantics are the same.)
What the daemon sees
ExecBody.log now points at the controlplane endpoints, not S3:
Daemon doesn't know Redis exists. It POSTs to URLs. The controlplane puts them on Redis Streams.
What the consumer sees
The CLI / dashboard reads via SSE:
Last-Event-ID header on reconnect → server replays from cursor.
After exec ends, the stream returns the final chunks + a sentinel event, then closes. From that point on, consumers can fetch the durable archive at:
Same endpoint as the v0 single-PUT design — only the source has changed (CP-driven flush from Redis instead of daemon-direct PUT).
Tier-1 provisioning
Heroku injects REDIS_URL env var; controlplane reads it. Free tier
covers 25MB which is plenty for the hot ring buffers (256KB cap × N
concurrent execs = at most a few dozen KB to a few MB).
When we go multi-dyno, Redis is already the shared state — no migration needed.
Tier-2 provisioning
Already done — see S3 bucket provisioning. Schema + lifecycle rules unchanged.
Backward compatibility
- No
LogSinkfrom the controlplane (old CP orREDIS_URLunset) → daemon falls back to inline-bytes-in-result-POST, same as today. Old behaviour intact. - Old daemon (no
logfield support) → CP doesn't get any stream chunks; falls back to readingstdout/stderrfrom the result POST and shipping THOSE to S3 at end. Slightly degraded (no real-time), but durable archive works. - No
REDIS_URL→ CP skips tier 1 entirely; nothing inLogSink.stdout_url. Daemon uses the legacy inline path.
This means real-time tier turns on/off with the env var. Useful for local dev (skip Redis, fall through to inline).
Implementation phases
| Phase | Scope | What ships |
|---|---|---|
| 1. Redis cache | CP-side Map<exec_id, Buffer> (in-process, not Redis yet) + POST /v1/daemon/exec/:id/stdout endpoint + SSE consumer endpoint. Skip true Redis for v1 — keep state in-process. | Real-time streaming on a single CP dyno |
| 2. Daemon chunk writer | nymph flushes stdout/stderr to the CP endpoint every 200ms / 4KB instead of buffering in memory | Daemon producer |
| 3. S3 archive on end | CP flushes the cached buffer to S3 (existing presigning code from PR #3) on exec end | Durable tier |
4. CLI --stream | lakeshore daemon exec --stream consumes the SSE; default still waits-then-prints for back-compat | User-visible real-time |
| 5. Move tier 1 to Redis | Swap the in-process Map for Redis Streams when we go multi-dyno or need cross-dyno fanout | Multi-dyno-ready |
| 6. Stdin + control streams | Add POST /stdin/chunk long-poll for daemon, POST /control/signal. Enables interactive exec --stdin and lays groundwork for sessions. | Bidirectional |
| 7. Sessions | lakeshore session <daemon> — persistent bash + PTY on daemon, drives via streams from phase 6. | TTY |
Phases 1–4 are the v1 streaming win — phases 5–7 are the path to full TTY sessions.
What this replaces
This doc absorbed two earlier drafts (now deleted from the tree):
- The daemon-direct-PUT design — daemon doesn't talk to S3 anymore; controlplane does. Simpler, no egress assumption, no S3 multipart-min-size constraint for real-time streaming.
- The controlplane-buffered streaming draft — it was right in shape, but lived in a separate file as a "fallback". Promoted here as Tier 1 alongside the S3 archive (Tier 2).
The "where do bytes live" section of Sessions vs one-time commands now points at Tier 1 (Redis) for active sessions and Tier 2 (S3) for archive.
S3-direct from the daemon may come back as an optimization for huge-volume artifacts (training checkpoints, multi-GB log dumps) on daemons that do have egress — but it is not the universal path.
Open
- Redis MAXLEN tuning — 256KB per stream is a guess. Could be per-class (exec=64KB, session=512KB).
- Control-channel framing — text vs binary, JSON envelope vs msgpack. Defer until phase 6.
- Stdin backpressure — if the daemon can't keep up with stdin
chunks, what does the CLI see? Probably a soft cap on
stdinstream MAXLEN with the CLI getting "queue full" feedback. - Tier-1 → Tier-2 atomicity — what if the CP crashes mid-flush? Could buffer the flush as a multipart upload that's committed only when the full stream is captured. Pre-Mature; revisit.