feat(@projects): ✨ add forgejo watcher system
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
233d02b2d0
commit
840becb5cd
10 changed files with 692 additions and 11 deletions
|
|
@ -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).
|
||||
|
|
|
|||
47
.forgejo/forge-watch.service
Normal file
47
.forgejo/forge-watch.service
Normal file
|
|
@ -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=<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
|
||||
72
.project/objectives/p2-10k-gdlint-cleanup.md
Normal file
72
.project/objectives/p2-10k-gdlint-cleanup.md
Normal file
|
|
@ -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)
|
||||
|
|
@ -24,6 +24,7 @@
|
|||
"cost": 160,
|
||||
"tier": 3,
|
||||
"tech_required": "guild_charters",
|
||||
"requires_building": "marketplace",
|
||||
"upgradeable_from": "merchant",
|
||||
"race_required": null,
|
||||
"faction": "dwarf",
|
||||
|
|
|
|||
|
|
@ -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<BuildingId>`; 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
|
||||
|
|
|
|||
|
|
@ -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<BuildingId>` in p1-44 — the JSON wire format is unchanged
|
||||
/// because `BuildingId` is `#[serde(transparent)]`.)
|
||||
#[serde(default)]
|
||||
pub requires_building: Option<String>,
|
||||
pub requires_building: Option<BuildingId>,
|
||||
}
|
||||
|
||||
/// A city.
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
available_resources: &HashSet<String>,
|
||||
) -> Result<mc_core::ProductionOrigin, QueueError> {
|
||||
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<Vec<CompletedEntry>, 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::*;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
/// Strategic resource the owning player must control. `None` = no gate.
|
||||
#[serde(default)]
|
||||
pub requires_resource: Option<String>,
|
||||
/// 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<BuildingId>,
|
||||
}
|
||||
|
||||
/// 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<UnitId, UnitDef>,
|
||||
}
|
||||
|
||||
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<QueueEntry> = 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()]);
|
||||
|
|
|
|||
189
tools/forge-watch.sh
Executable file
189
tools/forge-watch.sh
Executable file
|
|
@ -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=<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
|
||||
Loading…
Add table
Reference in a new issue