diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e1fd4a7..1bcb610 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -92,10 +92,24 @@ jobs: working-directory: dist run: echo -n "${{ github.sha }}" > COMMIT + - name: Extract changelog section for this tag + run: | + VERSION="${GITHUB_REF_NAME#v}" + awk -v ver="## [$VERSION]" ' + $0 == ver { found=1; next } + found && /^## \[/ { exit } + found { print } + ' CHANGELOG.md > release-notes.md + + if [ ! -s release-notes.md ]; then + echo "No CHANGELOG.md section found for $VERSION" >&2 + exit 1 + fi + - uses: softprops/action-gh-release@v3 with: + body_path: release-notes.md files: | dist/herdr-devserver-status-*.tar.gz dist/SHA256SUMS dist/COMMIT - generate_release_notes: true diff --git a/.gitignore b/.gitignore index 9d96c39..3c1f005 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ *.log .env .vscode/ -!.vscode/extensions.json \ No newline at end of file +!.vscode/extensions.json +.seed-state.json \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9b23acd --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,18 @@ +# Changelog + +## [0.2.0] + +### Fixed + +- `has_errors` stuck `true` after recovery once the error needle scrolled out of the read window — recompute every `Running` tick instead of gating on needle presence. +- `status` regressed `Running` → `Starting` once the `Ready` needle/URL scrolled out of the read window — `Running` now sticky, cleared only on process restart. + +### Changed + +- `ToolDetector::match_output` — added `previous_status: ToolStatus` param. +- `signal_matching::derive_status` — added `previous_status: ToolStatus` param. + +## [0.1.0] + +### Added +- initial release: framework spec loader, vite detector, daemon discovery/worker loop \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index bccb7a5..ffb162f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -91,7 +91,7 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "herdr-devserver-status" -version = "0.1.0" +version = "0.2.0" dependencies = [ "dotenvy", "libc", diff --git a/Cargo.toml b/Cargo.toml index 7c9efe7..2b166f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "herdr-devserver-status" -version = "0.1.0" +version = "0.2.0" edition = "2024" rust-version = "1.97" license = "MIT" diff --git a/frameworks/vite.yml b/frameworks/vite.yml index 6aba6bb..786db79 100644 --- a/frameworks/vite.yml +++ b/frameworks/vite.yml @@ -32,6 +32,9 @@ signals: - kind: ready needle: "built in" counts_as_recent_success: true + - kind: ready + needle: "(client) hmr update" + counts_as_recent_success: true - kind: building needle: "build started..." - kind: error diff --git a/herdr-plugin.toml b/herdr-plugin.toml index 5d7241b..3bfe422 100644 --- a/herdr-plugin.toml +++ b/herdr-plugin.toml @@ -1,6 +1,6 @@ id = "herdr-devserver-status" name = "herdr-devserver-status" -version = "0.1.0" +version = "0.2.0" min_herdr_version = "0.7.0" description = "Detects selected dev servers running inside Herdr panes and reports their lifecycle status." license = "MIT" diff --git a/src/daemon/worker.rs b/src/daemon/worker.rs index 534045a..49e49d8 100644 --- a/src/daemon/worker.rs +++ b/src/daemon/worker.rs @@ -109,7 +109,7 @@ fn tick( state: &mut TrackedState, ) -> Result<(), crate::herdr::HerdrError> { let output = client::read_pane_output(pane_id, READ_LINES)?; - let result = detector.match_output(&output, state.has_errors); + let result = detector.match_output(&output, state.status, state.has_errors); let changed = result.status != state.status || result.has_errors != state.has_errors diff --git a/src/herdr/wire.rs b/src/herdr/wire.rs index 385ec85..aa27100 100644 --- a/src/herdr/wire.rs +++ b/src/herdr/wire.rs @@ -84,6 +84,14 @@ pub fn extract_foreground_processes(parsed: &Value) -> Vec { /// from that stderr JSON is UNVERIFIED — empirically verify against a pane /// that closes mid-wait before shipping. pub fn stderr_indicates_timeout(stderr_json: &Value) -> bool { + if stderr_json + .pointer("/error/code") + .and_then(Value::as_str) + .map(|c| c.eq_ignore_ascii_case("timeout")) + .unwrap_or(false) + { + return true; + } const TIMEOUT_HINT_KEYS: [&str; 2] = ["timeout", "timed_out"]; for key in TIMEOUT_HINT_KEYS { if let Some(v) = stderr_json.get(key) @@ -98,3 +106,11 @@ pub fn stderr_indicates_timeout(stderr_json: &Value) -> bool { .map(|r| r.eq_ignore_ascii_case("timeout")) .unwrap_or(false) } + +#[test] +fn recognizes_nested_error_code_timeout() { + let json: Value = serde_json::from_str( + r#"{"error":{"code":"timeout","message":"timed out waiting for output match"},"id":"cli:pane:wait-output"}"# + ).unwrap(); + assert!(stderr_indicates_timeout(&json)); +} diff --git a/src/tools/framework/mod.rs b/src/tools/framework/mod.rs index 1cd5ab1..07d554b 100644 --- a/src/tools/framework/mod.rs +++ b/src/tools/framework/mod.rs @@ -110,15 +110,16 @@ impl ToolDetector for FrameworkDetector { &self.signal_re } - fn match_output(&self, output: &str, previous_had_errors: bool) -> ToolMatchResult { + fn match_output( + &self, + output: &str, + previous_status: ToolStatus, + previous_had_errors: bool, + ) -> ToolMatchResult { let result = signal_matching::match_output(&self.spec.signals, self.url_re.as_ref(), output); - let derived = signal_matching::derive_status(previous_had_errors, &result); - - let has_errors = if derived.status == ToolStatus::Running - && result.signals.contains(&SignalKind::Ready) - && result.signals.contains(&SignalKind::Error) - { + let derived = signal_matching::derive_status(previous_status, previous_had_errors, &result); + let has_errors = if derived.status == ToolStatus::Running { signal_matching::has_recent_error(&self.spec.signals, output) } else { derived.has_errors diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 1007a23..7a39d33 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -62,7 +62,12 @@ pub trait ToolDetector: Send + Sync { fn confirm(&self, procs: &[ProcessInfo]) -> Option; fn signal_regex(&self) -> &Regex; - fn match_output(&self, output: &str, previous_had_errors: bool) -> ToolMatchResult; + fn match_output( + &self, + output: &str, + previous_status: ToolStatus, + previous_had_errors: bool, + ) -> ToolMatchResult; } /// All detectors tried, in order, against each newly seen pane's process diff --git a/src/tools/signal_matching.rs b/src/tools/signal_matching.rs index 1c4aa7d..f14e8b1 100644 --- a/src/tools/signal_matching.rs +++ b/src/tools/signal_matching.rs @@ -90,16 +90,20 @@ pub struct DerivedStatus { pub has_errors: bool, } -pub fn derive_status(previous_had_errors: bool, result: &MatchResult) -> DerivedStatus { - let is_running = result.signals.contains(&SignalKind::Ready) || result.url.is_some(); +pub fn derive_status( + previous_status: ToolStatus, + previous_had_errors: bool, + result: &MatchResult, +) -> DerivedStatus { + let is_running = previous_status == ToolStatus::Running + || result.signals.contains(&SignalKind::Ready) + || result.url.is_some(); let saw_error = result.signals.contains(&SignalKind::Error); if is_running { - // Caller overrides with has_recent_error when both ready and error - // are present in the same read. DerivedStatus { status: ToolStatus::Running, - has_errors: false, + has_errors: previous_had_errors, } } else { DerivedStatus {