diff --git a/README.md b/README.md index 77f7a78..9735e21 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,8 @@ Print the latest mirror snapshot: ./scripts/ratchet mirror ``` +When an experiment watch is running, Flight Deck now shows live progress: run id, countdown, approximate cycle count, progress bar, and local finish time. The countdown updates every second; backend state refreshes roughly every 30 seconds. + Advanced fallback for a 6-hour CLI monitoring session: ```bash diff --git a/START_HERE.md b/START_HERE.md index c8001f5..7fe6b31 100644 --- a/START_HERE.md +++ b/START_HERE.md @@ -91,6 +91,8 @@ If you ask for help, this file is the fastest way to show the exact app state in ./scripts/ratchet mirror ``` +When a watch is running, `Flight Deck` shows a running-experiment progress panel with the run id, countdown, approximate cycle count, progress bar, and local finish time. The countdown updates every second; backend state refreshes roughly every 30 seconds. + ## Research Pathway The app has three time horizons: diff --git a/docs/OPERATOR_GUIDE.md b/docs/OPERATOR_GUIDE.md index 427e3c8..ccfa2ac 100644 --- a/docs/OPERATOR_GUIDE.md +++ b/docs/OPERATOR_GUIDE.md @@ -76,6 +76,8 @@ Layer 7 is the visual self-reflection layer. The SwiftUI app renders a `Reality Mirror` HUD and tab. It writes the semantic state it believes it is showing to `data/app_visual_state.md` and `data/app_visual_state.json`. This is not screenshot OCR; it is the app's own rendered-state ledger. +During active watches, the backend exposes `active_watch_details` through `app-state`: run id, PID, start time, planned cycles, interval, elapsed seconds, remaining seconds, progress percent, next-cycle ETA, and estimated finish time. The SwiftUI Flight Deck renders this as a live progress panel and refreshes backend state roughly every 30 seconds. + ### Data Flow Normal data flow: diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 950e6cf..6385945 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -143,7 +143,7 @@ What you do: do nothing. Leave the app open or closed. The background engine kee `WAIT` means a watch is already running. -What you do: do not start anything else. +What you do: do not start anything else. The Flight Deck should show a running-experiment progress panel with progress percent, approximate cycle count, and local finish time. `COOLDOWN` means the last watch already produced evidence and the system is intentionally waiting. @@ -189,6 +189,8 @@ It collects repeated samples: The default watch stage used by the forever engine is 2 hours. It samples every 5 minutes, so a full stage normally has 24 cycles. +During a watch, the app shows the active run id, countdown, progress bar, approximate cycle count, and local finish time. The visual countdown updates every second. Backend state refreshes about every 30 seconds. + ## What The Forever Engine Does `Start Forever Engine` starts a repo-local background monitor loop. diff --git a/macos/BraiinsRatchet/Sources/BraiinsRatchetMac/BraiinsRatchetApp.swift b/macos/BraiinsRatchet/Sources/BraiinsRatchetMac/BraiinsRatchetApp.swift index 7ab5549..1396127 100644 --- a/macos/BraiinsRatchet/Sources/BraiinsRatchetMac/BraiinsRatchetApp.swift +++ b/macos/BraiinsRatchet/Sources/BraiinsRatchetMac/BraiinsRatchetApp.swift @@ -147,6 +147,11 @@ struct FlightDeckApp: View { .task { await store.refresh() store.writeRealitySnapshot(section: selection) + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 30_000_000_000) + await store.refresh() + store.writeRealitySnapshot(section: selection) + } } .onAppear { withAnimation(.easeInOut(duration: 4.8).repeatForever(autoreverses: true)) { @@ -395,6 +400,10 @@ struct FlightDeckView: View { InstrumentRibbon(appState: store.appState) + if let activeWatch = store.appState?.operatorState.activeWatchDetails { + ActiveWatchProgressPanel(activeWatch: activeWatch) + } + EngineConsole(store: store) } .frame(width: max(430, proxy.size.width * 0.38)) @@ -737,6 +746,86 @@ struct InstrumentChip: View { } } +struct ActiveWatchProgressPanel: View { + let activeWatch: ActiveWatchPayload + + var body: some View { + TimelineView(.periodic(from: .now, by: 1)) { context in + let live = liveMetrics(now: context.date) + LiquidGlassSurface(tint: .orange.opacity(0.20), cornerRadius: 32) { + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + Label("Running experiment", systemImage: "timer") + .font(.title3.weight(.black)) + Text(activeWatch.runId) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + Spacer() + Text(live.remainingText) + .font(.title3.monospacedDigit().weight(.black)) + .foregroundStyle(.orange) + } + + ProgressView(value: live.progress) + .tint(.orange) + + HStack(spacing: 12) { + WatchMetric("progress", "\(Int((live.progress * 100).rounded()))%", "done") + WatchMetric("cycles", "\(live.cycleEstimate)/\(activeWatch.plannedCycles)", "about") + WatchMetric("finish", activeWatch.estimatedFinishLocal.shortClockText, "local") + } + + Text("The app refreshes backend state every 30 seconds; this bar counts down locally every second.") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(18) + } + } + } + + private func liveMetrics(now: Date) -> (progress: Double, cycleEstimate: Int, remainingText: String) { + guard let started = activeWatch.startedDate, let finish = activeWatch.finishDate else { + return (Double(activeWatch.progressPercent) / 100.0, activeWatch.completedCyclesEstimate, "\(activeWatch.remainingSeconds / 60)m left") + } + let total = max(1, finish.timeIntervalSince(started)) + let elapsed = min(total, max(0, now.timeIntervalSince(started))) + let remaining = max(0, Int(finish.timeIntervalSince(now))) + let cycle = min(activeWatch.plannedCycles, max(1, Int(elapsed / Double(activeWatch.intervalSeconds)) + 1)) + return (elapsed / total, cycle, formatDuration(remaining)) + } +} + +struct WatchMetric: View { + let title: String + let value: String + let unit: String + + init(_ title: String, _ value: String, _ unit: String) { + self.title = title + self.value = value + self.unit = unit + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.caption.weight(.heavy)) + .textCase(.uppercase) + .foregroundStyle(.secondary) + Text(value) + .font(.headline.monospacedDigit().weight(.black)) + Text(unit) + .font(.caption2) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + struct HashflowView: View { @ObservedObject var store: RatchetStore @@ -1536,6 +1625,9 @@ struct RenderedReality: Codable { let latestStrategyAction: String let latestReport: String let activeWatch: String + let activeWatchProgress: String + let activeWatchEta: String + let activeWatchRemaining: String let completedWatch: String let activeManualExposure: String let braiinsFreshness: String @@ -1554,6 +1646,8 @@ struct RenderedReality: Codable { RealityRow(label: "engine", value: engineRunning ? "running" : "stopped", unit: "state"), RealityRow(label: "strategy", value: latestStrategyAction, unit: "proposal"), RealityRow(label: "braiins", value: braiinsFreshness, unit: "freshness"), + RealityRow(label: "watch progress", value: activeWatchProgress, unit: "live"), + RealityRow(label: "watch ETA", value: activeWatchEta, unit: "local"), RealityRow(label: "manual exposure", value: activeManualExposure, unit: "blocker"), ] + instrumentRows } @@ -1587,6 +1681,7 @@ struct RenderedReality: Codable { let operatorState = appState?.operatorState let engineStatus = appState?.engineStatus let activePositions = operatorState?.activeManualPositions ?? [] + let activeWatchDetails = operatorState?.activeWatchDetails let completedWatch = operatorState?.completedWatch let buttons = visibleButtons(section: section, appState: appState, isWorking: isWorking) let truths = operatorTruths( @@ -1615,6 +1710,9 @@ struct RenderedReality: Codable { latestStrategyAction: operatorState?.action ?? "none", latestReport: operatorState?.latestReport ?? "none", activeWatch: operatorState?.activeWatch ?? "none", + activeWatchProgress: activeWatchDetails.map { "\($0.progressPercent)% (\($0.completedCyclesEstimate)/\($0.plannedCycles) cycles)" } ?? "none", + activeWatchEta: activeWatchDetails?.estimatedFinishLocal ?? "none", + activeWatchRemaining: activeWatchDetails.map { formatDuration($0.remainingSeconds) } ?? "none", completedWatch: completedWatch.map { "\($0.reportPath), remaining \($0.remainingMinutes)m, earliest \($0.earliestActionLocal)" } ?? "none", activeManualExposure: activePositions.isEmpty ? "none" : activePositions.joined(separator: "; "), braiinsFreshness: freshnessText(operatorState), @@ -1794,6 +1892,9 @@ enum RealitySnapshotWriter { "- latest_braiins_sample: \(reality.latestBraiinsSample)", "- latest_report: \(reality.latestReport)", "- active_watch: \(reality.activeWatch)", + "- active_watch_progress: \(reality.activeWatchProgress)", + "- active_watch_eta: \(reality.activeWatchEta)", + "- active_watch_remaining: \(reality.activeWatchRemaining)", "- completed_watch: \(reality.completedWatch)", "- active_manual_exposure: \(reality.activeManualExposure)", "", @@ -1871,6 +1972,7 @@ struct OperatorStatePayload: Codable { let latestOceanTimestamp: String? let latestMarketTimestamp: String? let activeManualPositions: [String] + let activeWatchDetails: ActiveWatchPayload? enum CodingKeys: String, CodingKey { case hasOcean = "has_ocean" @@ -1885,9 +1987,49 @@ struct OperatorStatePayload: Codable { case latestOceanTimestamp = "latest_ocean_timestamp" case latestMarketTimestamp = "latest_market_timestamp" case activeManualPositions = "active_manual_positions" + case activeWatchDetails = "active_watch_details" } } +struct ActiveWatchPayload: Codable { + let label: String + let runId: String + let pid: Int? + let startedUtc: String + let plannedCycles: Int + let intervalSeconds: Int + let totalSeconds: Int + let elapsedSeconds: Int + let remainingSeconds: Int + let progressPercent: Int + let completedCyclesEstimate: Int + let nextCycleEtaUtc: String? + let nextCycleEtaLocal: String? + let estimatedFinishUtc: String + let estimatedFinishLocal: String + + enum CodingKeys: String, CodingKey { + case label + case runId = "run_id" + case pid + case startedUtc = "started_utc" + case plannedCycles = "planned_cycles" + case intervalSeconds = "interval_seconds" + case totalSeconds = "total_seconds" + case elapsedSeconds = "elapsed_seconds" + case remainingSeconds = "remaining_seconds" + case progressPercent = "progress_percent" + case completedCyclesEstimate = "completed_cycles_estimate" + case nextCycleEtaUtc = "next_cycle_eta_utc" + case nextCycleEtaLocal = "next_cycle_eta_local" + case estimatedFinishUtc = "estimated_finish_utc" + case estimatedFinishLocal = "estimated_finish_local" + } + + var startedDate: Date? { parseISODate(startedUtc) } + var finishDate: Date? { parseISODate(estimatedFinishUtc) } +} + struct CompletedWatchPayload: Codable { let reportPath: String let ageMinutes: Int @@ -1992,6 +2134,13 @@ extension String { var lastPathComponent: String { URL(fileURLWithPath: self).lastPathComponent } + + var shortClockText: String { + guard let date = parseISODate(self) else { return self } + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + return formatter.string(from: date) + } } func sats(_ btcText: String) -> String { @@ -2000,6 +2149,30 @@ func sats(_ btcText: String) -> String { return "\(sats) sats" } +func parseISODate(_ text: String) -> Date? { + if let date = ISO8601DateFormatter().date(from: text) { + return date + } + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssXXXXX" + return formatter.date(from: text) +} + +func formatDuration(_ seconds: Int) -> String { + let safe = max(0, seconds) + let hours = safe / 3600 + let minutes = (safe % 3600) / 60 + let secs = safe % 60 + if hours > 0 { + return "\(hours)h \(minutes)m left" + } + if minutes > 0 { + return "\(minutes)m \(secs)s left" + } + return "\(secs)s left" +} + func phText(_ ehText: String) -> String { guard let eh = Double(ehText) else { return "n/a" } return String(format: "%.3f", eh * 1000) diff --git a/src/braiins_ratchet/guidance.py b/src/braiins_ratchet/guidance.py index d271065..384e658 100644 --- a/src/braiins_ratchet/guidance.py +++ b/src/braiins_ratchet/guidance.py @@ -23,6 +23,25 @@ class CompletedWatch: earliest_action_local: str +@dataclass(frozen=True) +class ActiveWatchDetails: + label: str + run_id: str + pid: int | None + started_utc: str + planned_cycles: int + interval_seconds: int + total_seconds: int + elapsed_seconds: int + remaining_seconds: int + progress_percent: int + completed_cycles_estimate: int + next_cycle_eta_utc: str | None + next_cycle_eta_local: str | None + estimated_finish_utc: str + estimated_finish_local: str + + @dataclass(frozen=True) class OperatorState: has_ocean: bool @@ -37,6 +56,7 @@ class OperatorState: latest_ocean_timestamp: str | None latest_market_timestamp: str | None active_manual_positions: list[str] + active_watch_details: ActiveWatchDetails | None = None def get_operator_state(conn) -> OperatorState: @@ -45,11 +65,13 @@ def get_operator_state(conn) -> OperatorState: proposal = latest_proposal(conn) latest_report = _latest_report() freshness = _freshness_minutes(market.timestamp_utc if market else None) + active_watch_details = _active_watch_details() + active_watch = active_watch_details.label if active_watch_details else _active_watch_from_process_table() return OperatorState( has_ocean=ocean is not None, has_market=market is not None, action=proposal.action if proposal else None, - active_watch=_active_watch(), + active_watch=active_watch, completed_watch=_recent_completed_watch(latest_report, market.timestamp_utc if market else None), is_fresh=freshness is not None and freshness <= 30, freshness_minutes=freshness, @@ -58,6 +80,7 @@ def get_operator_state(conn) -> OperatorState: latest_ocean_timestamp=ocean.timestamp_utc if ocean else None, latest_market_timestamp=market.timestamp_utc if market else None, active_manual_positions=_active_manual_positions(conn), + active_watch_details=active_watch_details, ) @@ -80,6 +103,9 @@ def build_operator_cockpit(conn) -> str: f" Research stage: {_research_stage(state.active_watch, state.completed_watch)}", ] + if state.active_watch_details: + lines.extend(_active_watch_status_lines(state.active_watch_details)) + if state.completed_watch: lines.extend(_cooldown_status_lines(state.completed_watch)) @@ -406,6 +432,17 @@ def _progress_bar(elapsed: int, total: int, width: int = 20) -> str: return "[" + ("#" * filled) + ("-" * (width - filled)) + "]" +def _active_watch_status_lines(active_watch: ActiveWatchDetails) -> list[str]: + remaining_minutes = max(0, active_watch.remaining_seconds // 60) + return [ + f" Active watch progress: {_progress_bar(active_watch.elapsed_seconds, active_watch.total_seconds)} {active_watch.progress_percent}%", + f" Active watch cycles: about {active_watch.completed_cycles_estimate}/{active_watch.planned_cycles}", + f" Active watch started: {active_watch.started_utc}", + f" Active watch ETA: {active_watch.estimated_finish_local}", + f" Active watch remaining: about {remaining_minutes} minutes", + ] + + def _running_runs() -> list[str]: if not EXPERIMENT_LOG.exists(): return [] @@ -423,26 +460,73 @@ def _running_runs() -> list[str]: def _active_watch() -> str | None: - from_state_file = _active_watch_from_state_file() - if from_state_file: - return from_state_file + details = _active_watch_details() + if details: + return details.label return _active_watch_from_process_table() -def _active_watch_from_state_file() -> str | None: +def _active_watch_details() -> ActiveWatchDetails | None: if not ACTIVE_WATCH.exists(): return None try: payload = json.loads(ACTIVE_WATCH.read_text(encoding="utf-8")) except json.JSONDecodeError: - return "state file exists but is unreadable" + return None pid = payload.get("pid") run_id = payload.get("run_id", "unknown-run") if isinstance(pid, int) and _pid_exists(pid): - return f"{run_id} pid={pid}" + return _active_watch_details_from_payload(payload, run_id, pid) + if not isinstance(pid, int): + return _active_watch_details_from_payload(payload, run_id, None) return None +def _active_watch_details_from_payload( + payload: dict[str, object], + run_id: str, + pid: int | None, +) -> ActiveWatchDetails | None: + started = _parse_utc(str(payload.get("started_utc", ""))) + if started is None: + return None + try: + planned_cycles = int(payload.get("planned_cycles", 0)) + interval_seconds = int(payload.get("interval_seconds", 0)) + except (TypeError, ValueError): + return None + if planned_cycles <= 0 or interval_seconds <= 0: + return None + + total_seconds = planned_cycles * interval_seconds + now = datetime.now(UTC) + elapsed_seconds = max(0, int((now - started).total_seconds())) + remaining_seconds = max(0, total_seconds - elapsed_seconds) + progress_percent = min(100, max(0, int((elapsed_seconds / total_seconds) * 100))) + completed_cycles_estimate = min(planned_cycles, max(1, (elapsed_seconds // interval_seconds) + 1)) + next_cycle_index = completed_cycles_estimate if completed_cycles_estimate < planned_cycles else None + next_cycle_eta = None if next_cycle_index is None else started.timestamp() + (next_cycle_index * interval_seconds) + finish = datetime.fromtimestamp(started.timestamp() + total_seconds, UTC) + next_cycle_utc = datetime.fromtimestamp(next_cycle_eta, UTC) if next_cycle_eta else None + return ActiveWatchDetails( + label=f"{run_id} pid={pid}" if pid is not None else run_id, + run_id=run_id, + pid=pid, + started_utc=started.isoformat(timespec="seconds"), + planned_cycles=planned_cycles, + interval_seconds=interval_seconds, + total_seconds=total_seconds, + elapsed_seconds=min(elapsed_seconds, total_seconds), + remaining_seconds=remaining_seconds, + progress_percent=progress_percent, + completed_cycles_estimate=completed_cycles_estimate, + next_cycle_eta_utc=next_cycle_utc.isoformat(timespec="seconds") if next_cycle_utc else None, + next_cycle_eta_local=next_cycle_utc.astimezone().isoformat(timespec="seconds") if next_cycle_utc else None, + estimated_finish_utc=finish.isoformat(timespec="seconds"), + estimated_finish_local=finish.astimezone().isoformat(timespec="seconds"), + ) + + def _active_watch_from_process_table() -> str | None: if os.environ.get("BRAIINS_RATCHET_IGNORE_PROCESS_WATCH") == "1": return None diff --git a/tests/test_guidance.py b/tests/test_guidance.py index bbeda4a..3d58977 100644 --- a/tests/test_guidance.py +++ b/tests/test_guidance.py @@ -5,8 +5,10 @@ import unittest from unittest.mock import patch from braiins_ratchet.guidance import ( + ActiveWatchDetails, CompletedWatch, _cooldown_status_lines, + _active_watch_status_lines, _do_this_now, _pathway_forecast, build_operator_cockpit, @@ -149,6 +151,32 @@ class GuidanceTests(unittest.TestCase): self.assertIn("Earliest next action: 2026-04-28T00:00:00+02:00", text) self.assertIn("Cooldown remaining: 270 minutes", text) + def test_active_watch_status_includes_progress_and_eta(self) -> None: + active_watch = ActiveWatchDetails( + label="run-example pid=123", + run_id="run-example", + pid=123, + started_utc="2026-04-29T08:48:06+00:00", + planned_cycles=24, + interval_seconds=300, + total_seconds=7200, + elapsed_seconds=1800, + remaining_seconds=5400, + progress_percent=25, + completed_cycles_estimate=7, + next_cycle_eta_utc="2026-04-29T09:18:06+00:00", + next_cycle_eta_local="2026-04-29T11:18:06+02:00", + estimated_finish_utc="2026-04-29T10:48:06+00:00", + estimated_finish_local="2026-04-29T12:48:06+02:00", + ) + + text = "\n".join(_active_watch_status_lines(active_watch)) + + self.assertIn("Active watch progress: [#####---------------] 25%", text) + self.assertIn("Active watch cycles: about 7/24", text) + self.assertIn("Active watch ETA: 2026-04-29T12:48:06+02:00", text) + self.assertIn("Active watch remaining: about 90 minutes", text) + def _completed_watch(age_minutes: int) -> CompletedWatch: return CompletedWatch( @@ -165,6 +193,7 @@ def _isolated_operator_files(): return patch.multiple( "braiins_ratchet.guidance", _active_watch=lambda: None, + _active_watch_details=lambda: None, _latest_report=lambda: None, _running_runs=lambda: [], ) diff --git a/tests/test_mac_app.py b/tests/test_mac_app.py index b647ca3..44d36f1 100644 --- a/tests/test_mac_app.py +++ b/tests/test_mac_app.py @@ -93,6 +93,10 @@ class MacAppPackagingTest(unittest.TestCase): self.assertIn("RealityHUD", text) self.assertIn("RenderedReality", text) self.assertIn("RealitySnapshotWriter", text) + self.assertIn("ActiveWatchProgressPanel", text) + self.assertIn("activeWatchDetails", text) + self.assertIn("TimelineView(.periodic", text) + self.assertIn("The app refreshes backend state every 30 seconds", text) self.assertIn("AppStatePayload", text) self.assertIn("EngineStatusPayload", text) self.assertIn("loadAppState", text)