diff --git a/.forgejo/RUNNER_SETUP.md b/.forgejo/RUNNER_SETUP.md index a3f0947b..02aa18b5 100644 --- a/.forgejo/RUNNER_SETUP.md +++ b/.forgejo/RUNNER_SETUP.md @@ -225,8 +225,54 @@ minimum-viable determinism + no-stall check and must gate every commit. ## Watcher / TTS alert Objective p2-10 acceptance bullet: a Testwright watcher observes the -runner and a failed `main` triggers a TTS alert via -`mcp__speech-synthesis__synthesize` with personality `ravdess02`. That -watcher is a separate process (not configured here) that polls the -Forgejo REST API for commit statuses. See objective p2-10 for the -watcher implementation plan. +runner and a failed `main` triggers a TTS alert (personality `ravdess02`, +mandatory per project Rail-4). The watcher only observes the API — it +does NOT re-run tests. + +### Installation on apricot (one-time) + +```bash +ssh apricot + +# 1. Copy the systemd unit from the repo. +mkdir -p ~/.config/systemd/user +cp ~/Code/project-buildspace/magic-civilization/.forgejo/forge-watch.service \ + ~/.config/systemd/user/forge-watch.service + +# 2. The script sources ~/.config/tokens/forgejo.sh automatically. +# Confirm the token file exists and exports FORGEJO_TOKEN + FORGEJO_URL. +source ~/.config/tokens/forgejo.sh +echo "token=${FORGEJO_TOKEN:0:8}... url=$FORGEJO_URL" + +# 3. Enable + start. +systemctl --user daemon-reload +systemctl --user enable --now forge-watch +systemctl --user status forge-watch + +# 4. Confirm linger is active (survives logout). +loginctl show-user lilith | grep Linger +# → Linger=yes (already set for act_runner) +``` + +### Logs + +```bash +journalctl --user -u forge-watch -f +``` + +### State file + +`~/.local/state/forge-watch/state.json` — tracks last-seen SHA, last +status, and already-alerted run IDs. Delete to reset. + +### Verification gate (manual) + +Push a deliberately-broken commit on a throwaway branch whose CI fails, +confirm the watcher logs "ALERT:" within ≤ POLL_INTERVAL seconds and +emits the TTS via `ravdess02`. Record timestamp in p2-10 evidence block. + +### Script source + +`tools/forge-watch.sh` in the repo root (installed to +`~/Code/project-buildspace/magic-civilization/tools/forge-watch.sh` on +apricot via the normal autocommit sync). diff --git a/.forgejo/forge-watch.service b/.forgejo/forge-watch.service new file mode 100644 index 00000000..da7f7a81 --- /dev/null +++ b/.forgejo/forge-watch.service @@ -0,0 +1,47 @@ +# ~/.config/systemd/user/forge-watch.service (install path on apricot) +# +# Testwright watcher — polls the Forgejo commit-status API and fires a +# TTS alert via ravdess02 when `main` flips to failure. +# +# Installation (run once on apricot as lilith): +# cp .forgejo/forge-watch.service ~/.config/systemd/user/forge-watch.service +# systemctl --user daemon-reload +# systemctl --user enable --now forge-watch +# systemctl --user status forge-watch +# +# Linger must be enabled so the unit survives logout: +# loginctl enable-linger lilith (already enabled for act_runner) +# +# Environment file: create ~/.config/forge-watch/env with: +# FORGEJO_TOKEN= +# FORGEJO_URL=http://forge.nasty.sh +# FORGE_REPO=magicciv/magicciv +# FORGE_BRANCH=main +# POLL_INTERVAL=30 +# (Token can also be sourced from ~/.config/tokens/forgejo.sh — the +# script does this automatically if FORGEJO_TOKEN is absent.) + +[Unit] +Description=Forgejo CI status watcher with TTS alert on red main +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +Restart=on-failure +RestartSec=10s + +# Source the token file so FORGEJO_TOKEN is available without repeating +# it in EnvironmentFile. +ExecStart=/usr/bin/env bash -c 'source ~/.config/tokens/forgejo.sh 2>/dev/null; exec bash %h/Code/project-buildspace/magic-civilization/tools/forge-watch.sh' + +# Write logs to journal (journalctl --user -u forge-watch -f) +StandardOutput=journal +StandardError=journal +SyslogIdentifier=forge-watch + +# State directory lives at $XDG_STATE_HOME/forge-watch/ (auto-created +# by the script; no StateDirectory= directive needed for --user units). + +[Install] +WantedBy=default.target diff --git a/.project/objectives/p2-10k-gdlint-cleanup.md b/.project/objectives/p2-10k-gdlint-cleanup.md new file mode 100644 index 00000000..8dfa14d1 --- /dev/null +++ b/.project/objectives/p2-10k-gdlint-cleanup.md @@ -0,0 +1,72 @@ +--- +id: p2-10k +title: "CI: fix 51 gdlint violations so Stage 3 is hard-green" +priority: p2 +status: open +scope: game1 +owner: testwright +updated_at: 2026-05-04 +evidence: [] +--- + +## Summary + +As of 2026-05-04, `gdlint src/game/engine/src/` reports 51 violations across +17 files. The CI YAML comment claiming "Hard-gated 2026-04-25" was aspirational +documentation; the rule was never verified to pass on apricot. + +Parent: p2-10 (regression CI gate). + +## Violation inventory (gdlint 2026-05-04) + +### Mechanical fixes (~41 violations — safe to address in one cycle) + +| Rule | Count | Files | +|---|---|---| +| max-line-length | 32 | auto_play.gd, event_bus.gd, procedural_renderer.gd, turn_processor.gd (×2), ai_turn_bridge_state.gd, city_renderer.gd, test_diplomacy.gd | +| class-definitions-order | 3 | audio_manager.gd, tech_web.gd, culture_web.gd | +| mixed-tabs-and-spaces | 2 | auto_play.gd lines 867-868 | +| no-elif-return | 1 | unit_renderer.gd line 380 | +| duplicated-load | 1 | auto_play.gd line 2117 | +| load-constant-name | 1 | game_state.gd line 8 (`_SerializationHelpers` → `SERIALIZATION_HELPERS`) | +| function-variable-name | 1 | procedural_renderer.gd line 325 (`by_`) | + +### Structural (10 max-file-lines violations — deferred with inline disable) + +These files exceed 500 lines. File-splitting is the correct long-term fix but +risks introducing bugs without a broader design review. Each gets a +`# gdlint: disable=max-file-lines # tracked: p2-10k` directive. + +| File | Lines | Notes | +|---|---|---| +| auto_play.gd | 2784 | Slated for Rust port in p0-26; don't split before deletion | +| audio_manager.gd | 511 | Split: audio routing vs. bus management | +| city.gd | 560 | Split: city state vs. city UI helpers | +| combat_resolver.gd | 540 | Split: resolver vs. keyword evaluation | +| data_loader.gd | 552 | Split: manifest loading vs. asset hydration | +| game_state.gd | 620 | Split: serialization helpers (already partially done) | +| procedural_renderer.gd | 519 | Split: biome pass vs. overlay pass | +| turn_processor.gd (entities/) | 559 | Duplicate of modules/management/ — investigate which is canonical | +| turn_processor.gd (modules/management/) | 576 | See above | + +### Anomalies to resolve + +1. **Two turn_processor.gd files**: `entities/turn_processor.gd` (559 lines) + and `modules/management/turn_processor.gd` (576 lines) both exist. + One is likely orphaned. Identify the canonical one, delete the other. + +2. **test_diplomacy.gd in production src**: `src/game/engine/src/modules/empire/test_diplomacy.gd` + is a test file in the production source tree. Move to + `src/game/engine/tests/unit/` per Rail-5. + +## Acceptance + +- ✗ `gdlint src/game/engine/src/` exits 0 on apricot +- ✗ Duplicate turn_processor.gd resolved (one deleted) +- ✗ test_diplomacy.gd moved to tests/unit/ +- ✗ CI Stage 3 (gdlint) passes without advisory flag on a green main run + +## Non-goals + +- Full file-splitting of deferred files (tracked per-file in future objectives) +- Refactoring auto_play.gd (blocked on p0-26 Rust port) diff --git a/public/resources/units/caravan_master.json b/public/resources/units/caravan_master.json index 328c3e4b..78b5d841 100644 --- a/public/resources/units/caravan_master.json +++ b/public/resources/units/caravan_master.json @@ -24,6 +24,7 @@ "cost": 160, "tier": 3, "tech_required": "guild_charters", + "requires_building": "marketplace", "upgradeable_from": "merchant", "race_required": null, "faction": "dwarf", diff --git a/src/simulator/crates/mc-ai/src/tactical/production.rs b/src/simulator/crates/mc-ai/src/tactical/production.rs index 63618bdd..a7801a06 100644 --- a/src/simulator/crates/mc-ai/src/tactical/production.rs +++ b/src/simulator/crates/mc-ai/src/tactical/production.rs @@ -548,9 +548,10 @@ fn pick_best_unit_of_type<'a>( Some(res) => strategic_resources.iter().any(|r| r == res), }) // p1-33: building gate — naval units require harbor, aerial units require airfield. + // p1-44: `requires_building` is now typed `Option`; compare via `as_str`. .filter(|u| match &u.requires_building { None => true, - Some(bld) => city_buildings.iter().any(|b| b == bld), + Some(bld) => city_buildings.iter().any(|b| b == bld.as_str()), }) // Score: clan_affinity weight (×100) + tier. Affinity matches dominate // tier within the same eligibility band; ties broken by id sort order diff --git a/src/simulator/crates/mc-ai/src/tactical/state.rs b/src/simulator/crates/mc-ai/src/tactical/state.rs index dff0f978..0ddef998 100644 --- a/src/simulator/crates/mc-ai/src/tactical/state.rs +++ b/src/simulator/crates/mc-ai/src/tactical/state.rs @@ -21,6 +21,7 @@ use std::collections::BTreeMap; +use mc_core::BuildingId; use serde::{Deserialize, Serialize}; /// Top-level tactical state passed to [`super::decide_tactical_actions`]. @@ -286,9 +287,11 @@ pub struct TacticalUnitSpec { /// Building gate — unit is only buildable when the city has already /// constructed this building id (e.g. `"harbor"` for naval units, /// `"airfield"` for aerial units). `None` means no building requirement. - /// Populated from `units/*.json::requires_building`. (p1-33) + /// Populated from `units/*.json::requires_building`. (p1-33; retyped to + /// `Option` in p1-44 — the JSON wire format is unchanged + /// because `BuildingId` is `#[serde(transparent)]`.) #[serde(default)] - pub requires_building: Option, + pub requires_building: Option, } /// A city. diff --git a/src/simulator/crates/mc-city/src/city.rs b/src/simulator/crates/mc-city/src/city.rs index e95f8495..77b89d1e 100644 --- a/src/simulator/crates/mc-city/src/city.rs +++ b/src/simulator/crates/mc-city/src/city.rs @@ -806,6 +806,7 @@ impl City { }, production_cost: def.production_cost, production_invested: 0, + origin: mc_core::ProductionOrigin::Building(mc_core::BuildingId::new(def.building.clone())), }; self.queues .entry(def.building.clone()) @@ -814,13 +815,92 @@ impl City { Ok(()) } + /// Validate, gate, and enqueue a unit into the appropriate queue. + /// + /// p1-44 (Option-2 hybrid): each queue entry carries a typed + /// [`mc_core::ProductionOrigin`] so the AI / UI / save layer can route + /// orders independently of which queue happens to host them. Routing rule: + /// + /// - `def.requires_building == Some(b)` → enqueued onto `queues[b]` with + /// `origin = Building(b)`. The city must have building `b` present + /// (uses [`City::has_building`], which honours p1-43 stacking presence). + /// Naval / aerial / civilian-trade units (e.g. `dwarf_war_galley` → + /// `harbor`, `caravan_master` → `marketplace`) flow through this branch. + /// - `def.requires_building == None` → enqueued onto `queues[CITY_CENTER_QUEUE_ID]` + /// with `origin = CityCenter`. Tier-1 / generic military units flow here. + /// + /// Validation is atomic: tech, race, resource, and building gates are all + /// checked before any state mutates. No materials are consumed for units + /// (the gold-cost subtract lives on the Treasury side, not here). + pub fn enqueue_unit( + &mut self, + unit_id: &str, + registry: &crate::production::UnitRegistry, + researched_techs: &HashSet, + available_resources: &HashSet, + ) -> Result { + let def = registry + .get_str(unit_id) + .ok_or_else(|| QueueError::UnknownUnit { + unit_id: unit_id.to_string(), + })?; + + if let Some(tech) = &def.requires_tech { + if !researched_techs.contains(tech) { + return Err(QueueError::TechLocked { + item_id: def.id.as_str().to_string(), + tech: tech.clone(), + }); + } + } + + if let Some(resource) = &def.requires_resource { + if !available_resources.contains(resource) { + return Err(QueueError::MissingResource { + item_id: def.id.as_str().to_string(), + resource: resource.clone(), + }); + } + } + + let (queue_key, origin) = match &def.requires_building { + Some(bid) => { + if !self.has_building(bid.as_str()) { + return Err(QueueError::MissingProducer { + item_id: def.id.as_str().to_string(), + building: bid.as_str().to_string(), + }); + } + ( + bid.as_str().to_string(), + mc_core::ProductionOrigin::Building(bid.clone()), + ) + } + None => ( + CITY_CENTER_QUEUE_ID.to_string(), + mc_core::ProductionOrigin::CityCenter, + ), + }; + + let entry = QueueEntry { + queueable: Queueable::Unit { + unit_id: def.id.clone(), + }, + production_cost: def.production_cost, + production_invested: 0, + origin: origin.clone(), + }; + self.queues.entry(queue_key).or_default().push(entry); + Ok(origin) + } + /// Apply `production` to one building's queue. Returns completions. pub fn tick_building( &mut self, building: &str, production: u32, ) -> Result, QueueError> { - if !self.has_building(building) { + if building != CITY_CENTER_QUEUE_ID && !self.has_building(building) { return Err(QueueError::UnknownBuilding { building: building.to_string(), }); @@ -833,6 +913,12 @@ impl City { } } +/// Reserved queue key for entries with `ProductionOrigin::CityCenter`. +/// Distinct from any real building id (`__city_center__` is not a valid +/// snake_case JSON building id). Exposed so the bridge / UI / tests can +/// look up the city-center queue without knowing the constant inline. +pub const CITY_CENTER_QUEUE_ID: &str = "__city_center__"; + #[cfg(test)] mod tests { use super::*; diff --git a/src/simulator/crates/mc-city/src/lib.rs b/src/simulator/crates/mc-city/src/lib.rs index e418437e..a1721097 100644 --- a/src/simulator/crates/mc-city/src/lib.rs +++ b/src/simulator/crates/mc-city/src/lib.rs @@ -21,12 +21,12 @@ use mc_core::WorkerCategory; // Re-export production types (backward compat — existing code imports these) pub use production::{ BuildingQueue, CompletedEntry, ItemDef, ItemRegistry, MaterialCost, QueueEntry, - QueueError, Queueable, WonderDef, WonderRegistry, + QueueError, Queueable, UnitDef, UnitRegistry, WonderDef, WonderRegistry, }; // Re-export city types pub use city::{ - City, CityFocus, CityYields, TileYield, + City, CityFocus, CityYields, TileYield, CITY_CENTER_QUEUE_ID, FOOD_PER_POP, BASE_CITY_HP, HP_PER_POP, growth_threshold, culture_expansion_threshold, }; diff --git a/src/simulator/crates/mc-city/src/production.rs b/src/simulator/crates/mc-city/src/production.rs index 10963f31..55de308b 100644 --- a/src/simulator/crates/mc-city/src/production.rs +++ b/src/simulator/crates/mc-city/src/production.rs @@ -117,6 +117,71 @@ impl WonderRegistry { } } +/// Lightweight unit definition consumed by the city production queue. Full +/// schema lives in `public/resources/units/*.json`; this struct is the +/// production-relevant projection (id, cost, gates) the city queue needs to +/// validate and route the build order. (p1-44) +/// +/// `requires_building` is the gate that decides which queue the unit lands +/// on: `Some(b)` → `ProductionOrigin::Building(b)` and the city must have +/// the building present (uses the same `has_building` check that p1-43 +/// stacking gates feed); `None` → `ProductionOrigin::CityCenter` and the +/// unit goes to the default city-center queue. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UnitDef { + pub id: UnitId, + /// Production cost in hammer-points to complete this unit. + #[serde(alias = "hammer_cost", alias = "cost")] + pub production_cost: u32, + /// Tech required before this unit can be queued. `None` = no gate. + #[serde(default)] + pub requires_tech: Option, + /// Strategic resource the owning player must control. `None` = no gate. + #[serde(default)] + pub requires_resource: Option, + /// Producer building gate — when `Some(id)`, the city must already + /// contain that building for the unit to be queueable, and the entry's + /// `ProductionOrigin` is set to `Building(id)`. When `None`, the unit + /// falls back to the city-center queue. Mirrors + /// `units/*.json::requires_building`. (p1-44) + #[serde(default)] + pub requires_building: Option, +} + +/// Registry of unit definitions, keyed by [`UnitId`]. Per the p1-44 brief +/// this uses a [`BTreeMap`] so iteration is deterministic across runs (the +/// per-entry `HashMap` on the legacy `ItemRegistry` predates the rule). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct UnitRegistry { + units: BTreeMap, +} + +impl UnitRegistry { + pub fn new() -> Self { + Self::default() + } + + pub fn insert(&mut self, def: UnitDef) { + self.units.insert(def.id.clone(), def); + } + + pub fn get(&self, id: &UnitId) -> Option<&UnitDef> { + self.units.get(id) + } + + pub fn get_str(&self, id: &str) -> Option<&UnitDef> { + self.units.get(&UnitId::new(id)) + } + + pub fn len(&self) -> usize { + self.units.len() + } + + pub fn is_empty(&self) -> bool { + self.units.is_empty() + } +} + /// What kind of thing is being produced. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] @@ -254,6 +319,8 @@ pub enum QueueError { InsufficientMaterials { item_id: String, source: StockpileError }, /// Caller asked to enqueue into a building this city does not have. UnknownBuilding { building: String }, + /// The unit id was not registered in the [`UnitRegistry`]. (p1-44) + UnknownUnit { unit_id: String }, } impl std::fmt::Display for QueueError { @@ -286,6 +353,7 @@ impl std::fmt::Display for QueueError { Self::UnknownBuilding { building } => { write!(f, "city has no building named {}", building) } + Self::UnknownUnit { unit_id } => write!(f, "unknown unit: {}", unit_id), } } } @@ -599,6 +667,174 @@ mod tests { assert_eq!(city.queue_for("smithy").unwrap().len(), 1); } + // ── p1-44 unit production gate ── + + fn warrior() -> UnitDef { + UnitDef { + id: UnitId::new("warrior"), + production_cost: 40, + requires_tech: None, + requires_resource: None, + requires_building: None, + } + } + + fn caravan_master() -> UnitDef { + // Mirrors the brief example: caravan_master requires marketplace. + UnitDef { + id: UnitId::new("caravan_master"), + production_cost: 160, + requires_tech: Some("guild_charters".into()), + requires_resource: None, + requires_building: Some(BuildingId::new("marketplace")), + } + } + + fn unit_registry() -> UnitRegistry { + let mut r = UnitRegistry::new(); + r.insert(warrior()); + r.insert(caravan_master()); + r + } + + #[test] + fn test_unit_requires_building_blocks_queue() { + // City has no marketplace — caravan_master must be rejected with + // MissingProducer, and nothing else may queue under that gate. + let mut city = City::with_buildings("khazad", vec![]); + let r = unit_registry(); + let techs = techs(&["guild_charters"]); + let err = city + .enqueue_unit("caravan_master", &r, &techs, &no_res()) + .unwrap_err(); + assert!( + matches!(&err, QueueError::MissingProducer { item_id, building } + if item_id == "caravan_master" && building == "marketplace"), + "expected MissingProducer{{caravan_master, marketplace}}, got {:?}", + err + ); + + // Add the marketplace — now the unit enqueues onto the marketplace + // queue, the entry's origin reflects that, and the city-center queue + // remains empty. + let mut city = City::with_buildings("khazad", vec!["marketplace".into()]); + let origin = city + .enqueue_unit("caravan_master", &r, &techs, &no_res()) + .unwrap(); + assert_eq!( + origin, + mc_core::ProductionOrigin::Building(BuildingId::new("marketplace")) + ); + assert_eq!(city.queue_for("marketplace").unwrap().len(), 1); + let entry = &city.queue_for("marketplace").unwrap().entries()[0]; + assert_eq!( + entry.queueable, + Queueable::Unit { + unit_id: UnitId::new("caravan_master") + } + ); + assert_eq!( + entry.origin, + mc_core::ProductionOrigin::Building(BuildingId::new("marketplace")) + ); + assert!( + city.queue_for(crate::city::CITY_CENTER_QUEUE_ID) + .map(|q| q.is_empty()) + .unwrap_or(true) + ); + } + + #[test] + fn unit_without_building_gate_routes_to_city_center() { + let mut city = City::with_buildings("khazad", vec![]); + let origin = city + .enqueue_unit("warrior", &unit_registry(), &techs(&[]), &no_res()) + .unwrap(); + assert_eq!(origin, mc_core::ProductionOrigin::CityCenter); + let q = city.queue_for(crate::city::CITY_CENTER_QUEUE_ID).unwrap(); + assert_eq!(q.len(), 1); + assert_eq!(q.entries()[0].origin, mc_core::ProductionOrigin::CityCenter); + + // Tick the city-center queue to completion and assert origin is + // carried through onto the CompletedEntry. + let done = city.tick_building(crate::city::CITY_CENTER_QUEUE_ID, 40).unwrap(); + assert_eq!(done.len(), 1); + assert_eq!(done[0].origin, mc_core::ProductionOrigin::CityCenter); + assert_eq!( + done[0].queueable, + Queueable::Unit { + unit_id: UnitId::new("warrior") + } + ); + } + + #[test] + fn unit_tech_gate_blocks_atomically() { + let mut city = City::with_buildings("khazad", vec!["marketplace".into()]); + let err = city + .enqueue_unit("caravan_master", &unit_registry(), &techs(&[]), &no_res()) + .unwrap_err(); + assert!(matches!(err, QueueError::TechLocked { .. })); + assert!( + city.queue_for("marketplace").map(|q| q.is_empty()).unwrap_or(true), + "tech-gated rejection must not push an entry" + ); + } + + #[test] + fn test_production_origin_round_trip_serde() { + // Serialise a queue entry of each origin variant and assert the + // round-trip preserves both the queueable and the origin field. + let entries = vec![ + QueueEntry { + queueable: Queueable::Unit { + unit_id: UnitId::new("warrior"), + }, + production_cost: 40, + production_invested: 0, + origin: mc_core::ProductionOrigin::CityCenter, + }, + QueueEntry { + queueable: Queueable::Unit { + unit_id: UnitId::new("caravan_master"), + }, + production_cost: 160, + production_invested: 25, + origin: mc_core::ProductionOrigin::Building(BuildingId::new("marketplace")), + }, + QueueEntry { + queueable: Queueable::Item { + item_id: "iron_axe".into(), + }, + production_cost: 30, + production_invested: 0, + origin: mc_core::ProductionOrigin::Building(BuildingId::new("smithy")), + }, + ]; + let json = serde_json::to_string(&entries).unwrap(); + let back: Vec = serde_json::from_str(&json).unwrap(); + assert_eq!(back.len(), entries.len()); + for (a, b) in entries.iter().zip(back.iter()) { + assert_eq!(a.queueable, b.queueable); + assert_eq!(a.origin, b.origin); + assert_eq!(a.production_cost, b.production_cost); + assert_eq!(a.production_invested, b.production_invested); + } + } + + #[test] + fn legacy_queue_entry_without_origin_defaults_to_city_center() { + // Pre-p1-44 saves omit `origin`; serde default kicks in. Schema + // backwards-compat is required so existing fixtures load unchanged. + let json = r#"{ + "queueable": {"kind":"item","item_id":"iron_axe"}, + "production_cost": 30, + "production_invested": 0 + }"#; + let entry: QueueEntry = serde_json::from_str(json).unwrap(); + assert_eq!(entry.origin, mc_core::ProductionOrigin::CityCenter); + } + #[test] fn json_roundtrip_preserves_queues() { let mut city = City::with_buildings("khazad", vec!["smithy".into()]); diff --git a/tools/forge-watch.sh b/tools/forge-watch.sh new file mode 100755 index 00000000..c8f5a847 --- /dev/null +++ b/tools/forge-watch.sh @@ -0,0 +1,189 @@ +#!/usr/bin/env bash +# tools/forge-watch.sh — Testwright watcher for the Forgejo CI gate. +# +# Polls the Forgejo commit-status API for `main`. When the combined +# status flips from a known-good state to `failure`, emits a TTS alert +# via mcp__speech-synthesis__synthesize (personality: ravdess02, mandatory +# per project Rail-4). Re-alerts once per run-ID to avoid spam. +# +# State is kept in $XDG_STATE_HOME/forge-watch/state.json (never spread +# across multiple locations). The watcher only OBSERVES; it does NOT +# re-run tests. +# +# Usage (manual): +# FORGEJO_URL=http://forge.nasty.sh \ +# FORGEJO_TOKEN= \ +# bash tools/forge-watch.sh +# +# Typically run as a systemd --user unit on apricot. See: +# ~/.config/systemd/user/forge-watch.service +# .forgejo/RUNNER_SETUP.md § "Watcher / TTS alert" +# +# Environment variables: +# FORGEJO_URL Base URL of the Forgejo instance (no trailing slash). +# Default: http://forge.nasty.sh +# FORGEJO_TOKEN Personal access token with read:repository scope. +# Default: sourced from ~/.config/tokens/forgejo.sh +# FORGE_REPO org/repo slug. Default: magicciv/magicciv +# FORGE_BRANCH Branch to watch. Default: main +# POLL_INTERVAL Seconds between polls. Default: 30 +# TTS_CMD Command to invoke TTS. Default: mcp__speech-synthesis__synthesize +# Override in tests. + +set -euo pipefail + +# ── Defaults ──────────────────────────────────────────────────────────────── +FORGEJO_URL="${FORGEJO_URL:-http://forge.nasty.sh}" +FORGE_REPO="${FORGE_REPO:-magicciv/magicciv}" +FORGE_BRANCH="${FORGE_BRANCH:-main}" +POLL_INTERVAL="${POLL_INTERVAL:-30}" + +# Source token if not already in environment +if [[ -z "${FORGEJO_TOKEN:-}" ]]; then + token_file="$HOME/.config/tokens/forgejo.sh" + if [[ -f "$token_file" ]]; then + # shellcheck source=/dev/null + source "$token_file" + fi +fi + +if [[ -z "${FORGEJO_TOKEN:-}" ]]; then + echo "forge-watch: FORGEJO_TOKEN is not set and ~/.config/tokens/forgejo.sh has none." >&2 + exit 1 +fi + +# ── State dir ─────────────────────────────────────────────────────────────── +STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/forge-watch" +STATE_FILE="$STATE_DIR/state.json" +mkdir -p "$STATE_DIR" + +# Initialise state file if absent +if [[ ! -f "$STATE_FILE" ]]; then + echo '{"last_sha":"","last_status":"unknown","alerted_run_ids":[]}' > "$STATE_FILE" +fi + +# ── Helpers ───────────────────────────────────────────────────────────────── +log() { echo "[forge-watch] $(date -u +%H:%M:%SZ) $*"; } + +# Read a field from the state JSON +state_get() { + python3 -c "import json,sys; d=json.load(open('$STATE_FILE')); print(d.get('$1',''))" +} + +# Write updated state atomically +state_update() { + local sha="$1" status="$2" alerted="$3" + python3 - "$sha" "$status" "$alerted" <<'PY' +import json, sys +sha, status, alerted = sys.argv[1], sys.argv[2], json.loads(sys.argv[3]) +with open('STATE_FILE_PATH') as f: + d = json.load(f) +d['last_sha'] = sha +d['last_status'] = status +d['alerted_run_ids'] = alerted +with open('STATE_FILE_PATH', 'w') as f: + json.dump(d, f) +PY +} + +# Replace the placeholder path in the python heredoc above +state_update() { + local sha="$1" status="$2" alerted_json="$3" + python3 -c " +import json +with open('$STATE_FILE') as f: + d = json.load(f) +d['last_sha'] = '$sha' +d['last_status'] = '$status' +d['alerted_run_ids'] = $alerted_json +with open('$STATE_FILE', 'w') as f: + json.dump(d, f) +" +} + +# Fire TTS alert. Per Rail-4: personality MUST be ravdess02, never default. +tts_alert() { + local message="$1" + log "ALERT: $message" + # When running in a Claude agent session the MCP tool is available as a + # shell command. Outside an agent session we fall back to a desktop + # notification so the watcher still notifies without crashing. + if command -v mcp__speech-synthesis__synthesize &>/dev/null; then + mcp__speech-synthesis__synthesize \ + --personality ravdess02 \ + --text "$message" + elif command -v notify-send &>/dev/null; then + notify-send "Forge CI FAIL" "$message" + else + log "WARNING: no TTS/notification command available; alert message logged only." + fi +} + +# Fetch the combined commit status for the HEAD of FORGE_BRANCH. +# Returns JSON like {"state":"failure","sha":"abc123","run_id":"42"} or +# {"state":"pending","sha":"...","run_id":""}. +fetch_head_status() { + # 1. Get HEAD SHA of the branch + local branch_json + branch_json="$(curl -sf \ + "$FORGEJO_URL/api/v1/repos/$FORGE_REPO/branches/$FORGE_BRANCH" \ + -H "Authorization: token $FORGEJO_TOKEN")" + + local sha + sha="$(echo "$branch_json" | python3 -c "import json,sys; print(json.load(sys.stdin)['commit']['id'])")" + + # 2. Get the combined (aggregated) status for that SHA + local status_json + status_json="$(curl -sf \ + "$FORGEJO_URL/api/v1/repos/$FORGE_REPO/commits/$sha/status" \ + -H "Authorization: token $FORGEJO_TOKEN")" + + local state + state="$(echo "$status_json" | python3 -c "import json,sys; print(json.load(sys.stdin).get('state','unknown'))")" + + # 3. Extract the run_id from the first status entry (used for dedup) + local run_id + run_id="$(curl -sf \ + "$FORGEJO_URL/api/v1/repos/$FORGE_REPO/commits/$sha/statuses?limit=1" \ + -H "Authorization: token $FORGEJO_TOKEN" \ + | python3 -c "import json,sys; ss=json.load(sys.stdin); print(ss[0]['target_url'].split('/')[-2] if ss else '')" 2>/dev/null || echo "")" + + echo "{\"sha\":\"$sha\",\"state\":\"$state\",\"run_id\":\"$run_id\"}" +} + +# ── Main poll loop ─────────────────────────────────────────────────────────── +log "Starting. Watching $FORGE_REPO @ $FORGE_BRANCH every ${POLL_INTERVAL}s." +log "State file: $STATE_FILE" + +while true; do + result="$(fetch_head_status 2>/dev/null || echo '{"sha":"","state":"error","run_id":""}')" + sha="$(echo "$result" | python3 -c "import json,sys; print(json.load(sys.stdin)['sha'])")" + state="$(echo "$result" | python3 -c "import json,sys; print(json.load(sys.stdin)['state'])")" + run_id="$(echo "$result" | python3 -c "import json,sys; print(json.load(sys.stdin)['run_id'])")" + + last_sha="$(state_get last_sha)" + last_status="$(state_get last_status)" + alerted_json="$(python3 -c "import json; d=json.load(open('$STATE_FILE')); print(json.dumps(d.get('alerted_run_ids',[])))")" + + # Check whether this run_id was already alerted + already_alerted="$(python3 -c "import json; ids=json.loads('$alerted_json'); print('yes' if '$run_id' in ids and '$run_id' else 'no')")" + + if [[ "$state" == "failure" && "$already_alerted" == "no" && -n "$run_id" ]]; then + short_sha="${sha:0:8}" + msg="CI FAIL on $FORGE_BRANCH at $short_sha — run $run_id. Check $FORGEJO_URL/$FORGE_REPO/actions/runs/$run_id" + tts_alert "$msg" + # Add run_id to alerted list + alerted_json="$(python3 -c "import json; ids=json.loads('$alerted_json'); ids.append('$run_id'); print(json.dumps(ids))")" + fi + + if [[ -n "$sha" && "$state" != "error" ]]; then + state_update "$sha" "$state" "$alerted_json" + if [[ "$sha" != "$last_sha" || "$state" != "$last_status" ]]; then + log "sha=${sha:0:8} state=$state run_id=${run_id:-n/a}" + fi + else + log "WARNING: could not fetch status (network/API error); retrying." + fi + + sleep "$POLL_INTERVAL" +done