From e613236d60ac1e0984cb24593ee345b08ddc08f0 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 19:57:02 +0000 Subject: [PATCH] feat(cli): kebab ingest progress display (p9-fb-02) + p9-fb-01 status flip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `kebab ingest` 가 진행 상황을 사용자에게 보여주는 두 surface 추가: - **사람 모드 (TTY)**: indicatif `ProgressBar` on stderr — scan 중에는 spinner, ScanCompleted 후 bar 로 전환, 매 asset 마다 message 갱신. - **사람 모드 (non-TTY, CI/pipe)**: indicatif draw target 을 hidden 으로 두고 stderr 에 한 줄씩 (`ingest: scanning`, `ingest: 1/N path`, `ingest: complete (...)`). - **`--json` 모드**: stderr 비우고 stdout 에 line-delimited `ingest_progress.v1` JSON 을 emit. 마지막 줄은 기존 `ingest_report.v1` 그대로 (외부 wrapper backward-compat). 구현: - 신규 `crates/kebab-cli/src/progress.rs` — `ProgressMode::{Json, Human { tty }}`, `ProgressDisplay` (background thread 가 channel drain + 모드별 render), `now_rfc3339` helper. mode 가 무엇이든 ts 는 wire emit 시점에 stamp. - `crates/kebab-cli/src/wire.rs` 에 `wire_ingest_progress` 추가. serde tag (`kind`) 위에 `schema_version` + `ts` 두 필드 더해 spec §2.4a wire shape 완성. - `Cmd::Ingest` 핸들러: mpsc channel 만들고 background thread 가 display 돌리는 동안 main 이 `ingest_with_config_progress` 호출. ingest 반환 시 Sender drop → display thread 정상 종료. join 후 최종 ingest_report 출력. - 새 dep: `indicatif` 0.17 (TTY 전용 진행 바, non-TTY/--json 에서는 hidden draw target). Test: - 3 lib unit (mode resolution + RFC 3339 round-trip). - 3 integration (--json line-delimited / non-TTY stderr text / ts+kind 검증). 16 PASS 전체 회귀 0. Plan 갱신: - p9-fb-01: status `in_progress` → `completed` (PR #52 머지 후속). - p9-fb-02: status `planned` → `in_progress`. 머지 후 별도 한 줄 commit 으로 `completed` flip. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 2 + HANDOFF.md | 1 + README.md | 4 +- crates/kebab-cli/Cargo.toml | 6 + crates/kebab-cli/src/main.rs | 27 +- crates/kebab-cli/src/progress.rs | 231 ++++++++++++++++++ crates/kebab-cli/src/wire.rs | 19 ++ crates/kebab-cli/tests/ingest_progress_cli.rs | 164 +++++++++++++ tasks/p9/p9-fb-01-ingest-progress-callback.md | 2 +- tasks/p9/p9-fb-02-cli-progress-display.md | 2 +- 10 files changed, 453 insertions(+), 5 deletions(-) create mode 100644 crates/kebab-cli/src/progress.rs create mode 100644 crates/kebab-cli/tests/ingest_progress_cli.rs diff --git a/Cargo.lock b/Cargo.lock index 49cd519..176fce9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3517,6 +3517,7 @@ version = "0.1.0" dependencies = [ "anyhow", "clap", + "indicatif", "kebab-app", "kebab-config", "kebab-core", @@ -3524,6 +3525,7 @@ dependencies = [ "kebab-tui", "serde_json", "tempfile", + "time", ] [[package]] diff --git a/HANDOFF.md b/HANDOFF.md index 82443ca..385b639 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -41,6 +41,7 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능. - **P9-3 e/j/k 키 의 \"input empty\" 분기** — spec 의 `e=toggle explain` / `j=k=scroll` 이 typing 과 충돌 (\"explain\" / \"javascript\" 같은 단어 입력 깨짐). input 이 비어 있을 때만 command 키로 동작 — vim \"command vs insert\" 컨벤션 변형. 사용자가 텍스트 입력 시 모든 알파벳 정상 통과. - **P9-4 enter_inspect helper + Search `i` 키** — spec 의 진입 경로 (Library Enter → Doc inspect, Search `i` → Chunk inspect) 를 한 helper 로 묶음. `InspectTarget` enum (`Doc(DocumentId) | Chunk(ChunkId)`), `return_to: Pane` 가 Esc 시 원래 pane 으로 복귀. `c` 키가 모든 section (metadata / provenance / blocks / spans / text / embeddings) 일괄 collapse/expand — spec 의 \"focus 기반 selective collapse\" 는 v1 단순화. - **2026-05-02 P9 도그푸딩 후속 (p9-fb-06)** — `kebab reset --all|--data-only|--vector-only|--config-only [--yes]` 추가. TTY 가 아니면 `--yes` 필수 (silent destruction 금지). `--vector-only` 가 SQLite `embedding_records` 도 함께 truncate (off-disk Lance dir 만 wipe 시 orphan 방지). 도그푸딩 막힘 강도 1위 (수동 4 경로 `rm -rf` 부담) 해소. spec: `tasks/p9/p9-fb-06-data-reset-command.md`, plan: `docs/superpowers/plans/2026-05-02-p9-fb-06-reset-command.md`. +- **2026-05-02 P9 도그푸딩 후속 (spec PR #51 + p9-fb-01 + p9-fb-02)** — `kebab ingest` 진행 표시 도입. frozen design §2.4a 신설 (wire schema `ingest_progress.v1` line-delimited streaming) + §10 의 long-running 작업 절 추가. `kebab-app::ingest_with_config_progress(.., progress: Option>)` facade 추가, 기존 `_with_config` 가 `progress=None` forwarding wrapper. CLI 가 indicatif TTY 진행 바 (stderr) / non-TTY 한 줄씩 / `--json` 모드는 line-delimited stdout. p9-fb-03 (TUI background worker) + p9-fb-04 (cancel) 가 같은 stream 위에 build. ## 다음 task 후보 diff --git a/README.md b/README.md index 9dfc14f..bea76bc 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ kebab doctor | 명령 | 동작 | |------|------| | `kebab init` | XDG 경로에 데이터 디렉토리 + config.toml 생성 | -| `kebab ingest []` | Markdown / 이미지 / PDF 색인 (idempotent) | +| `kebab ingest []` | Markdown / 이미지 / PDF 색인 (idempotent). TTY 에서는 stderr 진행 바, non-TTY (CI / pipe) 는 stderr 한 줄씩, `--json` 은 stdout 에 `ingest_progress.v1` 라인 streaming 후 마지막에 `ingest_report.v1` | | `kebab search --mode {lexical,vector,hybrid} ""` | 검색. hybrid는 RRF fusion, citation 포함 | | `kebab list docs` | 색인된 문서 목록 | | `kebab inspect doc ` / `kebab inspect chunk ` | raw record 보기 | @@ -80,7 +80,7 @@ kebab doctor | `kebab reset [--all / --data-only / --vector-only / --config-only] [--yes]` | XDG 데이터 wipe. **Irreversible.** TTY 면 confirm prompt, 아니면 `--yes` 필수. `--vector-only` 는 SQLite `embedding_records` 도 함께 truncate (orphan 방지) | | `kebab eval run / compare` | golden query 회귀 측정 | -모든 명령에 `--json` 플래그. 출력은 frozen wire schema v1 (`schema_version` 항상 포함, 예: `ingest_report.v1`, `search_hit.v1`, `answer.v1`, `doctor.v1`). +모든 명령에 `--json` 플래그. 출력은 frozen wire schema v1 (`schema_version` 항상 포함, 예: `ingest_report.v1`, `ingest_progress.v1`, `search_hit.v1`, `answer.v1`, `doctor.v1`, `reset_report.v1`). ## 논리 아키텍처 diff --git a/crates/kebab-cli/Cargo.toml b/crates/kebab-cli/Cargo.toml index 9818589..54c6ce2 100644 --- a/crates/kebab-cli/Cargo.toml +++ b/crates/kebab-cli/Cargo.toml @@ -30,6 +30,12 @@ kebab-tui = { path = "../kebab-tui" } anyhow = { workspace = true } serde_json = { workspace = true } clap = { version = "4", features = ["derive"] } +# p9-fb-02: ingest progress UI. +# - TTY 사람 모드: indicatif spinner + bar (stderr). +# - --json 모드 / non-TTY: indicatif 끄고 raw line emit. +# - timestamp formatting (RFC 3339) 은 time crate. +indicatif = "0.17" +time = { workspace = true } [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/kebab-cli/src/main.rs b/crates/kebab-cli/src/main.rs index 4c01037..5f733ae 100644 --- a/crates/kebab-cli/src/main.rs +++ b/crates/kebab-cli/src/main.rs @@ -8,6 +8,7 @@ use clap::{Parser, Subcommand}; use kebab_app::doctor_signal::{DoctorUnhealthy, NoHitSignal, RefusalSignal}; +mod progress; mod wire; #[derive(Parser, Debug)] @@ -283,7 +284,31 @@ fn run(cli: &Cli) -> anyhow::Result<()> { include: cfg.workspace.include.clone(), exclude: cfg.workspace.exclude.clone(), }; - let report = kebab_app::ingest_with_config(cfg, scope, *summary_only)?; + + // p9-fb-02: spawn the progress display on a background + // thread; the ingest call below holds the `Sender` end of + // the channel and emits per-step events into it. When the + // call returns, the `Sender` drops and the display thread + // sees `recv()` return Err — exits cleanly. + let mode = progress::ProgressMode::from_flags(cli.json); + let (tx, rx) = std::sync::mpsc::channel::(); + let display_handle = std::thread::spawn(move || { + progress::ProgressDisplay::new(mode).run(rx) + }); + + let ingest_result = kebab_app::ingest_with_config_progress( + cfg, + scope, + *summary_only, + Some(tx), + ); + + // Join the display thread *before* surfacing the ingest + // outcome so the spinner / final newline is flushed + // regardless of whether ingest returned Ok or Err. + let _ = display_handle.join(); + + let report = ingest_result?; if cli.json { println!("{}", serde_json::to_string(&wire::wire_ingest(&report))?); } else { diff --git a/crates/kebab-cli/src/progress.rs b/crates/kebab-cli/src/progress.rs new file mode 100644 index 0000000..77f110b --- /dev/null +++ b/crates/kebab-cli/src/progress.rs @@ -0,0 +1,231 @@ +//! `kebab ingest` progress display — consumes +//! `kebab_app::IngestEvent` and renders to one of three surfaces: +//! +//! - **TTY 사람 모드**: indicatif `ProgressBar` on stderr (spinner +//! while scanning, bar after `ScanCompleted`, message updates per +//! asset). stdout is reserved for the final `ingest_report.v1`. +//! - **non-TTY 사람 모드** (CI / pipe): indicatif uses `hidden` +//! draw target (no terminal control codes), and we emit one +//! `ingest: scanning…` / `ingest: N/M …` line per event to stderr +//! instead. CLI consumers redirecting stderr can still parse it. +//! - **`--json` 모드**: stderr stays silent; every event is dumped to +//! stdout as `ingest_progress.v1` line-delimited JSON. The final +//! `ingest_report.v1` line follows after the run completes (per +//! §2.4a backwards-compat). +//! +//! Each subprocess of the binary creates one `ProgressDisplay` and +//! drives it from a background thread that drains an +//! `mpsc::Receiver`. The thread terminates when the +//! `Sender` end is dropped (i.e. when `ingest_with_config_progress` +//! returns). + +use std::io::{IsTerminal, Write}; +use std::sync::mpsc::Receiver; + +use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle}; +use kebab_app::IngestEvent; +use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; + +use crate::wire; + +/// Rendering mode for `ProgressDisplay`. The mode is fixed at +/// construction — each `kebab ingest` invocation is a single mode +/// (chosen from `--json` plus `IsTerminal` detection). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ProgressMode { + /// stdout = line-delimited `ingest_progress.v1`. stderr stays + /// silent for events (errors / log frames still go to stderr). + Json, + /// stdout reserved for the final report; stderr gets an indicatif + /// `ProgressBar` (TTY) or one short line per event (non-TTY). + Human { tty: bool }, +} + +impl ProgressMode { + /// Pick the right mode from caller flags. + pub fn from_flags(json: bool) -> Self { + if json { + Self::Json + } else { + Self::Human { + tty: std::io::stderr().is_terminal(), + } + } + } +} + +/// Drains an `mpsc::Receiver` until the sender is dropped +/// and renders each event according to `mode`. Construction only — +/// kick off via [`ProgressDisplay::run`]. +pub struct ProgressDisplay { + mode: ProgressMode, + bar: Option, +} + +impl ProgressDisplay { + pub fn new(mode: ProgressMode) -> Self { + Self { mode, bar: None } + } + + /// Block until `rx` returns `Err` (sender dropped). Renders one + /// frame per received event. + pub fn run(mut self, rx: Receiver) -> anyhow::Result<()> { + while let Ok(event) = rx.recv() { + self.handle(&event)?; + } + if let Some(bar) = self.bar.take() { + bar.finish_and_clear(); + } + Ok(()) + } + + fn handle(&mut self, event: &IngestEvent) -> anyhow::Result<()> { + match self.mode { + ProgressMode::Json => emit_json(event), + ProgressMode::Human { tty } => self.handle_human(event, tty), + } + } + + fn handle_human(&mut self, event: &IngestEvent, tty: bool) -> anyhow::Result<()> { + match event { + IngestEvent::ScanStarted { root } => { + let bar = ProgressBar::new_spinner().with_message(format!("scanning {root}")); + bar.set_draw_target(if tty { + ProgressDrawTarget::stderr() + } else { + ProgressDrawTarget::hidden() + }); + bar.enable_steady_tick(std::time::Duration::from_millis(100)); + self.bar = Some(bar); + if !tty { + let mut err = std::io::stderr().lock(); + let _ = writeln!(err, "ingest: scanning {root}…"); + } + } + IngestEvent::ScanCompleted { total } => { + if let Some(bar) = self.bar.as_mut() { + bar.disable_steady_tick(); + bar.set_length(u64::from(*total)); + bar.set_position(0); + bar.set_style( + ProgressStyle::with_template( + "ingest [{bar:30}] {pos}/{len} {wide_msg}", + ) + .unwrap() + .progress_chars("=> "), + ); + bar.set_message(""); + } + if !tty { + let mut err = std::io::stderr().lock(); + let _ = writeln!(err, "ingest: scan complete ({total} assets)"); + } + } + IngestEvent::AssetStarted { + idx, + total, + path, + media, + } => { + if let Some(bar) = self.bar.as_ref() { + bar.set_message(format!("{media} {path}")); + } + if !tty { + let mut err = std::io::stderr().lock(); + let _ = writeln!(err, "ingest: {idx}/{total} {media} {path}"); + } + } + IngestEvent::AssetFinished { idx, .. } => { + if let Some(bar) = self.bar.as_ref() { + bar.set_position(u64::from(*idx)); + } + } + IngestEvent::Completed { counts } => { + if let Some(bar) = self.bar.take() { + bar.finish_and_clear(); + } + if !tty { + let mut err = std::io::stderr().lock(); + let _ = writeln!( + err, + "ingest: complete (scanned={} new={} updated={} skipped={} errors={})", + counts.scanned, + counts.new, + counts.updated, + counts.skipped, + counts.errors, + ); + } + } + IngestEvent::Aborted { counts } => { + if let Some(bar) = self.bar.take() { + bar.abandon_with_message(format!( + "aborted at {}/{}", + counts.scanned.saturating_sub(counts.errors), + counts.scanned + )); + } + let mut err = std::io::stderr().lock(); + let _ = writeln!( + err, + "ingest: aborted (scanned={} new={} updated={} skipped={} errors={})", + counts.scanned, + counts.new, + counts.updated, + counts.skipped, + counts.errors, + ); + } + } + Ok(()) + } +} + +/// Serialize an `IngestEvent` as the `ingest_progress.v1` wire shape +/// (kind discriminator + RFC 3339 `ts`) and println to stdout. One +/// event per line. +fn emit_json(event: &IngestEvent) -> anyhow::Result<()> { + let value = wire::wire_ingest_progress(event)?; + let line = serde_json::to_string(&value)?; + let mut out = std::io::stdout().lock(); + writeln!(out, "{line}")?; + Ok(()) +} + +/// Format the current wall-clock as RFC 3339 — used by `wire_ingest_progress` +/// so every emitted event carries an `ts` field per §2.4a / the wire schema. +pub(crate) fn now_rfc3339() -> anyhow::Result { + OffsetDateTime::now_utc() + .format(&Rfc3339) + .map_err(Into::into) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn from_flags_json_takes_priority_over_tty() { + // --json forces Json regardless of TTY state. + assert_eq!(ProgressMode::from_flags(true), ProgressMode::Json); + } + + #[test] + fn from_flags_human_reflects_stderr_tty() { + // We can't synthesize a TTY in tests, but we can assert the + // shape — mode is Human { tty: } when --json=false. + match ProgressMode::from_flags(false) { + ProgressMode::Human { .. } => {} + other => panic!("expected Human mode, got {other:?}"), + } + } + + #[test] + fn now_rfc3339_parses_back() { + let s = now_rfc3339().unwrap(); + // Round-trip via the parser to confirm the formatter emits a + // well-formed RFC 3339 string. + OffsetDateTime::parse(&s, &Rfc3339).expect("RFC 3339 round-trip"); + } +} diff --git a/crates/kebab-cli/src/wire.rs b/crates/kebab-cli/src/wire.rs index b6ca688..2937588 100644 --- a/crates/kebab-cli/src/wire.rs +++ b/crates/kebab-cli/src/wire.rs @@ -114,6 +114,25 @@ pub fn wire_reset(r: &kebab_app::ResetReport) -> Value { tag_object(v, "reset_report.v1") } +/// Wrap an [`kebab_app::IngestEvent`] as `ingest_progress.v1`. Adds +/// the `schema_version` discriminator on top of serde's existing +/// `kind` discriminator, plus an `ts` field with the current +/// wall-clock — the emit site is the only place that knows the moment +/// of emission, so the timestamp is stamped here rather than carried +/// on the event itself. +pub fn wire_ingest_progress( + event: &kebab_app::IngestEvent, +) -> anyhow::Result { + let mut v = serde_json::to_value(event)?; + if let Value::Object(ref mut map) = v { + map.insert( + "ts".to_string(), + Value::String(crate::progress::now_rfc3339()?), + ); + } + Ok(tag_object(v, "ingest_progress.v1")) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/kebab-cli/tests/ingest_progress_cli.rs b/crates/kebab-cli/tests/ingest_progress_cli.rs new file mode 100644 index 0000000..73dee58 --- /dev/null +++ b/crates/kebab-cli/tests/ingest_progress_cli.rs @@ -0,0 +1,164 @@ +//! Integration coverage for `kebab ingest` 의 progress display +//! (p9-fb-02). Each test runs the built `kebab` bin in a fresh +//! subprocess against a tempdir-rooted XDG layout + tempdir +//! workspace so the assertions don't depend on the host config. + +use std::io::Write; +use std::process::Command; + +fn kebab_bin() -> std::path::PathBuf { + let manifest = env!("CARGO_MANIFEST_DIR"); + std::path::PathBuf::from(manifest) + .parent() + .unwrap() + .parent() + .unwrap() + .join("target/debug/kebab") +} + +/// Build a tempdir-rooted XDG layout with a workspace containing two +/// markdown files. Returns the tmp guard (to keep the dir alive) and +/// the workspace path the caller should pass to `--root`. +fn fixture_workspace() -> (tempfile::TempDir, std::path::PathBuf) { + let tmp = tempfile::tempdir().unwrap(); + let ws = tmp.path().join("workspace"); + std::fs::create_dir_all(&ws).unwrap(); + let mut a = std::fs::File::create(ws.join("a.md")).unwrap(); + writeln!(a, "# Alpha\n\nfirst doc").unwrap(); + let mut b = std::fs::File::create(ws.join("b.md")).unwrap(); + writeln!(b, "# Beta\n\nsecond doc").unwrap(); + (tmp, ws) +} + +fn xdg_envs(tmp_path: &std::path::Path) -> [(&'static str, std::path::PathBuf); 4] { + [ + ("XDG_CONFIG_HOME", tmp_path.join("cfg")), + ("XDG_DATA_HOME", tmp_path.join("data")), + ("XDG_CACHE_HOME", tmp_path.join("cache")), + ("XDG_STATE_HOME", tmp_path.join("state")), + ] +} + +#[test] +fn ingest_json_emits_line_delimited_progress_then_report() { + let (tmp, ws) = fixture_workspace(); + let mut cmd = Command::new(kebab_bin()); + cmd.args([ + "--json", + "ingest", + "--root", + ws.to_str().unwrap(), + "--summary-only", + ]); + for (k, v) in xdg_envs(tmp.path()) { + cmd.env(k, v); + } + let out = cmd.output().unwrap(); + assert!( + out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + + // Every stdout line must be a JSON object. The last line is the + // existing ingest_report.v1; everything above is ingest_progress.v1. + let stdout = String::from_utf8(out.stdout).unwrap(); + let lines: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect(); + assert!(lines.len() >= 2, "expected ≥2 stdout lines, got: {stdout}"); + + let mut progress_seen = 0usize; + let mut last_schema = None; + for line in &lines { + let v: serde_json::Value = + serde_json::from_str(line).unwrap_or_else(|e| panic!("bad json line: {line:?} ({e})")); + let schema = v + .get("schema_version") + .and_then(|s| s.as_str()) + .unwrap_or_else(|| panic!("missing schema_version: {line}")); + if schema == "ingest_progress.v1" { + progress_seen += 1; + } + last_schema = Some(schema.to_string()); + } + assert!(progress_seen >= 4, "progress events: {progress_seen}"); + assert_eq!(last_schema.as_deref(), Some("ingest_report.v1")); +} + +#[test] +fn ingest_human_non_tty_emits_progress_lines_to_stderr() { + // Command::output gives no controlling tty, so the indicatif draw + // target is `hidden` and progress lines go to stderr instead. + let (tmp, ws) = fixture_workspace(); + let mut cmd = Command::new(kebab_bin()); + cmd.args([ + "ingest", + "--root", + ws.to_str().unwrap(), + "--summary-only", + ]); + for (k, v) in xdg_envs(tmp.path()) { + cmd.env(k, v); + } + let out = cmd.output().unwrap(); + assert!( + out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("ingest: scanning") || stderr.contains("ingest:"), + "expected progress text in stderr, got: {stderr}" + ); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("scanned ") && stdout.contains("new "), + "expected the human-mode summary line on stdout, got: {stdout}" + ); +} + +#[test] +fn ingest_json_progress_lines_carry_kind_and_ts() { + let (tmp, ws) = fixture_workspace(); + let mut cmd = Command::new(kebab_bin()); + cmd.args([ + "--json", + "ingest", + "--root", + ws.to_str().unwrap(), + "--summary-only", + ]); + for (k, v) in xdg_envs(tmp.path()) { + cmd.env(k, v); + } + let out = cmd.output().unwrap(); + assert!(out.status.success()); + + let stdout = String::from_utf8(out.stdout).unwrap(); + let mut saw_scan_started = false; + let mut saw_completed = false; + for line in stdout.lines().filter(|l| !l.is_empty()) { + let v: serde_json::Value = serde_json::from_str(line).unwrap(); + let schema = v.get("schema_version").and_then(|s| s.as_str()).unwrap(); + if schema != "ingest_progress.v1" { + continue; + } + let kind = v.get("kind").and_then(|s| s.as_str()).unwrap(); + // ts is a non-empty string and must round-trip as RFC 3339. + let ts = v.get("ts").and_then(|s| s.as_str()).unwrap(); + assert!(!ts.is_empty(), "ts empty for {kind}"); + if kind == "scan_started" { + saw_scan_started = true; + } + if kind == "completed" { + saw_completed = true; + // Counts mirror the report. + let counts = v.get("counts").unwrap(); + assert_eq!(counts.get("scanned").and_then(|n| n.as_u64()), Some(2)); + assert_eq!(counts.get("new").and_then(|n| n.as_u64()), Some(2)); + } + } + assert!(saw_scan_started, "missing scan_started event"); + assert!(saw_completed, "missing completed event"); +} diff --git a/tasks/p9/p9-fb-01-ingest-progress-callback.md b/tasks/p9/p9-fb-01-ingest-progress-callback.md index 907fdd2..8f4c0fc 100644 --- a/tasks/p9/p9-fb-01-ingest-progress-callback.md +++ b/tasks/p9/p9-fb-01-ingest-progress-callback.md @@ -3,7 +3,7 @@ phase: P9 component: kebab-app + kebab-core task_id: p9-fb-01 title: "Ingest progress callback / event channel" -status: in_progress +status: completed depends_on: [] unblocks: [p9-fb-02, p9-fb-03] contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md diff --git a/tasks/p9/p9-fb-02-cli-progress-display.md b/tasks/p9/p9-fb-02-cli-progress-display.md index 2dd5568..eb07924 100644 --- a/tasks/p9/p9-fb-02-cli-progress-display.md +++ b/tasks/p9/p9-fb-02-cli-progress-display.md @@ -3,7 +3,7 @@ phase: P9 component: kebab-cli task_id: p9-fb-02 title: "CLI progress display (spinner + text + --json line events)" -status: planned +status: in_progress depends_on: [p9-fb-01] unblocks: [] contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md