diff --git a/.docs/plans/2026-05-06-v0.1.4-hardening.md b/.docs/plans/2026-05-06-v0.1.4-hardening.md new file mode 100644 index 0000000..db6139d --- /dev/null +++ b/.docs/plans/2026-05-06-v0.1.4-hardening.md @@ -0,0 +1,89 @@ +# Epic A — v0.1.4 hardening + +**Date:** 2026-05-06 +**Branch:** `claude/youthful-shaw-b96d78` +**Target release:** `0.1.4` (backwards-compatible patch) +**Bd epic:** see `bd list --type epic` (assigned id at runtime) + +## Goal + +Ship a backwards-compatible patch that closes the most acute correctness, robustness, and OSS-hygiene gaps identified in the 2026-05-06 audit, **without breaking the public CLI/MCP contract**. Anything that requires a breaking change is deferred to Epic B (v0.2.0). + +## Success criteria + +1. `cargo test --workspace --all-targets` green on `ubuntu-latest`, `macos-latest`, `windows-latest`. +2. `cargo audit` runs in CI and is clean (or vulnerabilities are accepted with documented reason). +3. `cargo clippy --workspace --all-targets -- -D warnings` clean. +4. `cargo doc --workspace --no-deps` clean with `RUSTDOCFLAGS=-D warnings`. +5. New CI job pins MSRV (currently `1.83`) and verifies build. +6. `CHANGELOG.md` exists and documents `0.1.4` entry. +7. No removed/renamed CLI flags. No removed MCP tools or required parameters. +8. Branch `claude/youthful-shaw-b96d78` pushed; PR opened against `main`. + +## Out of scope (deferred) + +- Incremental indexing / pack-cache fix (Epic B — perf) +- MCP error contract redesign (Epic B — breaking) +- `--project-dir` argument for MCP (Epic B) +- Migrations framework (Epic B — coupled with incremental indexing) +- Few-shot prompting + eval datasets (Epic C — quality) +- `task-journal doctor`, `migrate-project` (Epic C — DX) + +## Tasks (11) + +Each task is one atomic commit. Test-first when behavior changes; doc/CI-only tasks may skip the failing-test step. + +| # | Task | Touches | Test? | Notes | +|---|------|---------|-------|-------| +| A1 | HTTP timeout for `AnthropicClassifier` | `classifier/http.rs` | yes (mockito slow-server) | 15s connect+read timeout. Hardcoded — env-var override deferred. | +| A2 | Graceful skip of malformed JSONL lines in `rebuild_state` | `db.rs` | yes (jsonl with bad line) | Log a `tracing::warn!` and continue; total parsed count returned. | +| A3 | Classifier model overridable via env var | `classifier/http.rs`, `classifier/cli.rs` | yes (env unset → default; env set → override) | `TJ_CLASSIFIER_MODEL`; default unchanged. | +| A4 | Extend task_id from 6 → 10 characters | `crates/tj-mcp/src/main.rs`, `crates/tj-cli/src/main.rs` | yes (collision-free over 10k synthetic ids) | Old 6-char ids remain valid (string compare). | +| A5 | Remove `stub: bool` from MCP responses | `crates/tj-mcp/src/main.rs`, smoke tests | yes (smoke test asserts no `stub` field) | Field removal — but no client read it; documented in CHANGELOG. | +| A6 | Centralize `SCHEMA_VERSION` const | `tj-core/src/lib.rs`, `pack.rs`, `tj-mcp/src/main.rs` | yes (single source) | `pub const SCHEMA_VERSION: &str = "1.0";` | +| A7 | `CHANGELOG.md` with Keep-a-Changelog format | new file | n/a | Backfill `0.1.0`–`0.1.3` from `git log`. | +| A8 | `cargo-audit` job in CI | `.github/workflows/ci.yml` | n/a | Non-blocking initially; flips to blocking once green. | +| A9 | MSRV job in CI (`rust-version` = 1.83) | `.github/workflows/ci.yml` | n/a | Uses `dtolnay/rust-toolchain@1.83`. | +| A10 | `.editorconfig` | new file | n/a | LF, UTF-8, 4-space rust, 2-space yaml. | +| A11 | File-lock on JSONL append | `tj-core/src/storage.rs`, `Cargo.toml` | yes (two-writer race test) | Crate: `fd-lock` (cross-platform). Blocking lock. | + +## Sequencing + +``` +A6 ──┐ +A1 ──┼─→ A7 (CHANGELOG references all done work) +A2 ──┤ +A3 ──┤ +A4 ──┤ +A5 ──┤ +A10 ─┤ +A8 ──┤ +A9 ──┘ + A11 last (fd-lock dep + race test) +``` + +A11 last because it adds a runtime dependency and a flaky-prone test; everything else lands first so green CI is the baseline before introducing the lock. + +## Risks + +- **A4 task_id length change:** new ids longer; nothing reads fixed-width. Verified by smart_read of CLI/MCP code paths. +- **A5 `stub` removal:** technically a schema change, but `stub` was always false post-Phase-1. Documented as non-breaking in CHANGELOG; if any downstream tool actually reads it, we revert in 0.1.5. +- **A11 fd-lock on Windows:** `fd-lock` uses `LockFileEx` on Windows; behavior differs from Linux `flock`. Test must cover both. +- **A2 swallowing real corruption:** mitigation — log at `warn!` level with line number and parse error. + +## Verification (per task) + +1. `cargo fmt --all --check` +2. `cargo clippy --workspace --all-targets -- -D warnings` +3. `cargo test --workspace --all-targets` (specific test for the touched module) +4. `git diff --stat` reviewed (no unintended line-ending or whitespace flips) +5. Commit with conventional-commit prefix (`fix:`, `chore:`, `docs:`, `ci:`, `feat:`) +6. `bd update --status closed --reason ""` + +## Final verification (epic-level) + +- `cargo test --workspace --all-targets` green +- `cargo audit` clean +- `bd list --parent --status open` returns empty +- `git log --oneline 8c49785..HEAD` matches the 11 tasks 1:1 +- `gh pr create` opened against `main` with the CHANGELOG entry as body diff --git a/.docs/plans/2026-05-06-v0.2.0-epic-c-pr-body.md b/.docs/plans/2026-05-06-v0.2.0-epic-c-pr-body.md new file mode 100644 index 0000000..9c6658c --- /dev/null +++ b/.docs/plans/2026-05-06-v0.2.0-epic-c-pr-body.md @@ -0,0 +1,58 @@ +## Summary + +Epic C — quality / DX / community polish. **8 atomic commits** on `claude/v0.2.0-epic-c`, built off `claude/v0.2.0-epic-b` HEAD. + +> **Merge order:** epic A → main, then epic B (rebased on main), then this branch (rebased on main). + +Plan: [`.docs/plans/2026-05-06-v0.2.0-epic-c-quality.md`](./.docs/plans/2026-05-06-v0.2.0-epic-c-quality.md) + +### What changed + +**Classifier quality** +- `feat(classifier)` — six few-shot Input/Output examples in the prompt covering the harder boundary calls (hypothesis vs finding, finding vs evidence, decision vs hypothesis). Prompt-budget guard still passes. +- `test(classifier)` — 30-row labeled eval fixture + opt-in accuracy gate (`TJ_CLASSIFIER_EVAL=on`). Default mode runs hermetic shape tests; opt-in mode calls `ClaudeCliClassifier::default()` and asserts ≥ 0.70 accuracy. Floor will ratchet up after 100+ dogfood examples. + +**User-facing DX** +- `feat(cli)` — `task-journal doctor` self-check command with human + `--json` output. Reports claude-on-PATH, data-dir writability, known projects, schema migrations. +- `feat(cli)` — `task-journal migrate-project --from PATH --to PATH [--force]`. Renames JSONL/SQLite/metrics from old project_hash to new; UPDATEs `tasks.project_hash` and `index_state.project_hash` columns in SQLite. +- `feat(export)` — `export --format html` produces a self-contained timeline page (inline CSS, no external assets, dark-mode aware via `prefers-color-scheme`). + +**OSS / coverage / Windows** +- `chore` — `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md` (Contributor Covenant 2.1 ref), three `.github/ISSUE_TEMPLATE/*`, `.github/PULL_REQUEST_TEMPLATE.md`, README links. +- `ci` — `cargo-llvm-cov` job + Codecov upload + README badge. Non-blocking initially. +- `test(classifier)` — cross-platform fake-claude shim (`.sh`/`cat` on Unix, `.cmd`/`type` on Windows). The two `ClaudeCliClassifier` tests now run on all three CI matrix OS instead of `cfg(unix)` only. + +### Verification + +- `cargo fmt --all -- --check` ✅ +- `cargo clippy --workspace --all-targets -- -D warnings` ✅ +- `cargo test --workspace --all-targets` ✅ — **202 tests** (was 193 from epic B; +9 added by this PR) +- `cargo bench --workspace --no-run` ✅ + +### New CLI surface + +| Command | Purpose | +|---------|---------| +| `task-journal doctor [--json]` | Diagnostic check; non-zero exit on issues | +| `task-journal migrate-project --from PATH --to PATH [--force]` | Re-key on-disk data when project moves | +| `task-journal export --format html [--task ID]` | Self-contained HTML timeline | + +### New env vars + +| Var | Effect | +|-----|--------| +| `TJ_CLASSIFIER_EVAL=on` | Enables the real-classifier accuracy run in `cargo test`. Default OFF — CI stays hermetic. | + +### Test plan + +- [ ] Branch CI green on three OS for `test`, `msrv`, `audit`, `benches-compile`, `coverage` (new). +- [ ] Try `task-journal doctor` on a clean VM — confirms claude-binary detection and dir-writability checks. +- [ ] Move a project on disk, run `migrate-project`, confirm `task_pack` works in the new location. +- [ ] `task-journal export --format html --task tj-X > timeline.html` and open in browser; verify dark mode + no broken layout. +- [ ] (Optional, manual) `TJ_CLASSIFIER_EVAL=on cargo test classifier_meets_accuracy_floor` against the real `claude` CLI; record baseline accuracy. + +### After this lands + +`v0.2.0` final tag. No further code changes expected — the dogfood window from `0.2.0-rc.1` already exercised epic B; this epic is additive and behind-the-scenes for almost every existing user. + +🤖 Generated with [Claude Code](https://claude.com/claude-code) diff --git a/.docs/plans/2026-05-06-v0.2.0-epic-c-quality.md b/.docs/plans/2026-05-06-v0.2.0-epic-c-quality.md new file mode 100644 index 0000000..863fa4b --- /dev/null +++ b/.docs/plans/2026-05-06-v0.2.0-epic-c-quality.md @@ -0,0 +1,84 @@ +# Epic C — v0.2.0: quality, DX, community polish + +**Date:** 2026-05-06 +**Branch:** `claude/v0.2.0-epic-c` (off `claude/v0.2.0-epic-b` HEAD) +**Target release:** `0.2.0` final (after epic B's `rc.1` is dogfooded and this PR merges) +**Bd epic:** `claude-memory-1yc` + +## Goal + +Three thematic threads, deliberately bundled because none alone deserves a major version but together they raise the project from "works" to "feels finished": + +1. **Classifier quality** — make the auto-capture hook actually trust-worthy with few-shot prompting and a regression-gated accuracy floor. +2. **User-facing DX** — `doctor` (diagnostic), `migrate-project` (path moved), HTML timeline (PR review). +3. **Community / coverage / Windows** — OSS hygiene files, llvm-cov badge, Windows test parity for the CLI classifier. + +## Success criteria + +1. `cargo test --workspace --all-targets` green on three OS — including the previously-skipped `cfg(unix)`-only classifier tests. +2. New `tests/classifier_eval.rs` runs against a checked-in labeled dataset and enforces an accuracy floor; CI fails when the floor is broken. +3. `task-journal doctor` exits 0 on a healthy install and emits a machine-readable summary that flags missing `claude` CLI / unwritable data dirs / unknown migrations. +4. `task-journal migrate-project --from --to ` re-keys the JSONL + SQLite + metrics for the new project hash; round-trips through `task_pack`. +5. `task-journal export --format html --task ` emits a self-contained HTML timeline. +6. Coverage report: `cargo llvm-cov --workspace` runs in CI and uploads to Codecov; README badge reflects the status. +7. `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `.github/ISSUE_TEMPLATE/*`, `.github/PULL_REQUEST_TEMPLATE.md` exist and link from README. +8. PR opened against `main` (after `0.2.0-rc.1` is in main). + +## Non-goals (deferred) + +- Opt-in telemetry endpoint (requires hosted backend — separate decision). +- C/C++/server-side LSP integration. +- Multi-language classifier prompts. + +## Tasks (8) + +| # | Task | Touches | Test? | Notes | +|---|------|---------|-------|-------| +| C1 | OSS hygiene files: `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, issue + PR templates | repo root + `.github/` | n/a | Standard OSS scaffolding; not blocking other work. | +| C2 | `cargo-llvm-cov` job in CI + Codecov upload + README badge | `.github/workflows/ci.yml`, `README.md` | n/a | Non-blocking initially; flip threshold to blocking after 5 baselines. | +| C3 | Windows-compatible tests for `ClaudeCliClassifier` (currently `cfg(all(test, unix))`) | `crates/tj-core/src/classifier/cli.rs` | yes (port the two existing fake-claude tests to use `.cmd`/`.bat` shim on Windows) | Closes the platform gap noticed in the audit. | +| C4 | `task-journal doctor` command | `tj-cli/src/main.rs`, possibly small `tj-core::diagnostics` mod | yes (CLI integration test) | Checks: claude bin in PATH, data dirs writable, schema_migrations matches expected, last_indexed_event_id consistent. | +| C5 | `task-journal migrate-project --from --to ` | `tj-cli/src/main.rs`, `tj-core::project_hash`, fs ops | yes | Renames `.jsonl`, `.sqlite`, `.jsonl` in metrics, etc. | +| C6 | `task-journal export --format html [--task ]` | `tj-cli/src/main.rs` (existing `export` command), new tiny `html_timeline` helper | yes | Self-contained: inline CSS, no external assets. | +| C7 | Few-shot prompting in classifier | `tj-core/src/classifier/prompt.rs` | yes (prompt contains 6 examples; size still bounded < 64KB) | 2 examples per harder pair: hypothesis vs finding, finding vs evidence, decision vs hypothesis. | +| C8 | Classifier eval dataset + accuracy gate | `tj-core/tests/classifier_eval.rs`, `tj-core/tests/fixtures/classifier_eval.jsonl` | yes (eval test enforces ≥ 70% baseline) | Hand-label ~30 chunks; uses `MockClassifier` + golden expected outputs to keep deterministic; real-classifier path stays opt-in via env var so CI does not need API access. | + +## Sequencing + +``` +C1 ─┐ +C2 ─┤ +C3 ─┼─→ (independent) +C4 ─┤ +C5 ─┤ +C6 ─┘ + +C7 ─→ C8 (eval validates the new prompt against the dataset) +``` + +C1/C2/C3 can land in any order. C4/C5/C6 are independent CLI features. C7 unlocks C8 (the eval dataset is the way to *measure* that few-shot improved precision rather than degraded it). + +## Risks + +- **C7 prompt regression:** few-shot can over-fit examples and degrade on out-of-distribution chunks. Mitigation: eval set in C8 covers boundary cases (`hypothesis-not-finding`, etc). +- **C8 false confidence:** ≥70% on 30 examples is a noisy estimate. Mitigation: ratchet floor up only after collecting 100+ labeled examples in dogfooding. +- **C5 destructive migration:** if `--from` and `--to` resolve to the same hash (symlink, case-insensitive FS), we'd corrupt data. Mitigation: refuse when `from_hash == to_hash`; require `--force` to overwrite an existing destination. +- **C3 Windows shim:** rewriting the fake-claude test in PowerShell vs `.cmd` vs Python — pick `.cmd` for minimal surface; some Windows tests skip on lack of `cmd.exe` is acceptable. + +## Verification gate (per task) + +Same as Epic A/B: +1. `cargo fmt --all -- --check` +2. `cargo clippy --workspace --all-targets -- -D warnings` +3. `cargo test --workspace --all-targets` +4. `git diff --stat` review +5. Conventional-commit prefix +6. `bd close --reason "..."` + +## Final verification (epic-level) + +- All 8 sub-tasks closed in bd +- `cargo bench --workspace --no-run` clean +- `cargo llvm-cov --workspace --summary-only` reports a number +- `task-journal doctor` runs locally and prints the diagnostics +- PR body lists which features changed user-facing CLI surface diff --git a/.docs/plans/2026-05-06-v0.2.0-rc-perf-and-contracts.md b/.docs/plans/2026-05-06-v0.2.0-rc-perf-and-contracts.md new file mode 100644 index 0000000..c7c8956 --- /dev/null +++ b/.docs/plans/2026-05-06-v0.2.0-rc-perf-and-contracts.md @@ -0,0 +1,91 @@ +# Epic B — v0.2.0-rc.1: perf + contracts + +**Date:** 2026-05-06 +**Branch:** `claude/v0.2.0-epic-b` (off `claude/youthful-shaw-b96d78` HEAD = epic A merged) +**Target release:** `0.2.0-rc.1` (release candidate; `0.2.0` after dogfooding) +**Bd epic:** see `bd list --type epic` (assigned at runtime) + +## Goal + +Two thematic threads that we deliberately bundle into one major release because they ship together as breaking changes: + +1. **Performance** — eliminate the O(all events) `rebuild_state` that runs on every MCP call. Replace with an incremental index gated by a `schema_migrations` table. A working pack-cache falls out of this for free. +2. **Contracts** — fix the MCP error envelope (`task_id` containing `"[error] ..."` is a usability bug, not a feature) and accept a `--project-dir` argument so MCP can serve more than `cwd`. + +These are coupled: the migrations framework needs to land before incremental indexing or we re-roll it later; the contract redesign needs to land before we lock the schema for `0.2.0` final. + +## Success criteria + +1. `cargo test --workspace --all-targets` green on all three OS. +2. Synthetic benchmark: `pack` and `search` complete in <50ms with 10k events, vs ~seconds today (criterion gate in CI). +3. `task_pack_cache` reports `cache_hit: true` on a second consecutive `task_pack` call against the same `task_id` with no new events. +4. MCP error responses are RPC-level errors (or carry an explicit `error` field) — never embedded as `"[error] ..."` in a result field. +5. `MCP --project-dir ` overrides cwd; the existing default behavior preserved when omitted. +6. `tokio::task::spawn_blocking` wraps every synchronous I/O call inside the MCP server's tool handlers (HTTP classifier, SQLite, JSONL). +7. `Cargo.toml` workspace version = `0.2.0-rc.1`. Tagged + published to crates.io as a release candidate (`cargo publish --allow-dirty` not used — clean tree only). +8. CHANGELOG updated with `[0.2.0-rc.1]` section listing breaking changes prominently. +9. PR opened against `main`; described as "merge after epic A is in main + dogfooded for 1 week." + +## Non-goals (deferred to Epic C) + +- Few-shot prompting for classifier +- Eval datasets in CI +- `task-journal doctor` +- HTML timeline export +- Coverage instrumentation + +## Tasks (9) + +| # | Task | Touches | Test? | Breaking? | Notes | +|---|------|---------|-------|-----------|-------| +| B1 | Migrations framework: `schema_migrations(version, applied_at)` table; `apply_migrations()` runs missing ones in order | `tj-core/src/db.rs` | yes (apply_then_reapply_idempotent, fresh_db_runs_all_migrations) | no | Foundation for B2. Single-table version tracking, no external dep. | +| B2 | Incremental indexing: store `last_indexed_event_id`; `rebuild_state` reads only tail | `tj-core/src/db.rs`, `tj-core/src/storage.rs` (read-side) | yes (incremental_picks_up_only_new_lines, full_rebuild_still_works) | no (functional equivalence) | Adds `index_state(project_hash, last_event_id)` table via migration 002. | +| B3 | Working pack-cache: stop invalidating cache during incremental rebuild; only invalidate on `index_event` | `tj-core/src/db.rs`, `tj-core/src/pack.rs` | yes (cache_hit_on_repeat_call) | no | Already wired (B0 has cache table + invalidation), now actually reused. | +| B4 | MCP error contract: tool handlers return `Result, McpError>` with structured errors; remove `[error] ...` magic strings | `tj-mcp/src/main.rs` | yes (handler_returns_rpc_error_on_failure, success_path_unchanged) | **YES** | rmcp 0.3 supports `Result, ErrorData>`. | +| B5 | Validate `task_id` exists in `task_close` before writing the close event | `tj-mcp/src/main.rs`, `tj-cli/src/main.rs` | yes (close_unknown_task_returns_error, close_known_task_works) | minor | Returns proper error rather than silent no-op. | +| B6 | MCP `--project-dir ` argument; falls back to cwd when omitted | `tj-mcp/src/main.rs` (clap parser) | yes (project_dir_arg_overrides_cwd) | no | Required for monorepo / parent-dir use. | +| B7 | Wrap blocking I/O in tool handlers with `tokio::task::spawn_blocking` | `tj-mcp/src/main.rs` | yes (concurrent_tool_calls_do_not_block_each_other) | no | Prevents one slow classifier call from blocking the runtime. | +| B8 | `criterion` benchmarks for `assemble`, `rebuild_state`, `search`; CI threshold gate | `crates/tj-core/benches/`, `.github/workflows/ci.yml` | n/a (benches are tests in their own way) | no | Threshold: pack <50ms, rebuild <100ms, search <20ms on 10k events. Non-blocking initially. | +| B9 | Bump workspace version to `0.2.0-rc.1`; CHANGELOG `[0.2.0-rc.1]` section | `Cargo.toml`, `CHANGELOG.md`, `Cargo.lock` | n/a | n/a | Last commit of the epic. | + +## Sequencing + +``` +B1 ────→ B2 ────→ B3 + │ + ├──→ B5 (independent of perf work) + │ + ├──→ B6 (independent) + │ +B4 ────→ B7 (spawn_blocking touches the same handler signatures as error redesign) + │ +B8 — runs at any time once B2 lands; useful as before/after evidence + + B9 last +``` + +## Risks + +- **B2 functional equivalence:** if incremental skips an event, future packs are wrong. Mitigation: golden test that compares `assemble()` output against full `rebuild_state` followed by `assemble()` over the same events. +- **B4 client-side compat:** any downstream tool that parsed `task_id == "[error] ..."` will break loudly. CHANGELOG must call this out as a breaking change so users update. +- **B7 deadlock risk:** `spawn_blocking` inside a tool that also acquires SQLite connection — must not hold the connection across an await. Mitigation: each tool call opens + closes its own `Connection` inside the blocking closure. +- **B8 flakiness:** criterion thresholds are noisy under shared CI runners. Mitigation: tolerance of 2x baseline; non-blocking until we see distribution over 5 runs. +- **B1+B2 schema rollback:** if migration v002 lands then we discover a bug, downgrading the binary leaves the table — and the binary won't know how to use it. Mitigation: migrations are forward-only; we accept that rollback means truncate state and `rebuild-state` from JSONL (which is the source of truth and is unaffected). + +## Verification gate (per task) + +Same as Epic A: +1. `cargo fmt --all -- --check` +2. `cargo clippy --workspace --all-targets -- -D warnings` +3. `cargo test --workspace --all-targets` (specific test for the touched module) +4. `cargo bench --workspace --no-run` (compile-only after B8 lands) +5. `git diff --stat` review +6. Conventional-commit prefix +7. `bd update --status closed --reason "..."` + +## Final verification (epic-level) + +- `cargo bench --workspace` matches thresholds described in B8 +- `cargo test --workspace` green +- `bd list --parent --status open` returns empty +- `gh pr create` opened against `main` with breaking-change call-out as the first paragraph diff --git a/.docs/plans/2026-05-07-v0.2.1-epic-d-operational.md b/.docs/plans/2026-05-07-v0.2.1-epic-d-operational.md new file mode 100644 index 0000000..a931bb3 --- /dev/null +++ b/.docs/plans/2026-05-07-v0.2.1-epic-d-operational.md @@ -0,0 +1,111 @@ +# Epic D — v0.2.1: operational maturity + +**Date:** 2026-05-07 +**Branch:** `claude/v0.2.1-epic-d` (off `claude/v0.2.0-epic-c` HEAD) +**Target release:** `0.2.1` (non-breaking minor) +**Bd epic:** `claude-memory-yj1` + +## Goal + +Three threads of "ready to run for a year" work: + +1. **Performance polish** — stop opening a fresh SQLite Connection on + every MCP tool call; share a small per-process cache. +2. **Observability** — structured tracing with stable correlation IDs + so you can grep one user request across logs; `pending` queue is + inspectable + retryable. +3. **Lifecycle** — graceful SIGTERM shutdown; `export --format sqlite` + for backup snapshots; real-MCP-client integration test that exercises + the end-to-end RPC envelope. + +All non-breaking. No CLI flag removals, no MCP schema changes. + +## Success criteria + +1. `cargo test --workspace --all-targets` green on all three OS. +2. `task-journal pending --list` lists queued classifications; + `task-journal pending --retry` re-feeds them through the + classifier (or marks them dead after N attempts). +3. Real-MCP-client integration test (`tj-mcp/tests/rmcp_roundtrip.rs`) + sends `task_create` → `event_add` → `task_pack` → `task_close` + over rmcp's in-process transport and verifies the response shapes. +4. Tracing spans wrap each tool handler with a `correlation_id` + field; default `RUST_LOG=info` gives one line per tool call. +5. SIGTERM on the MCP server flushes `tracing` and exits 0 cleanly. +6. `task-journal export --format sqlite > backup.sqlite` produces a + self-contained DB; round-trips through a fresh `task-journal pack` + call after pointing at it. +7. SQLite Connection cache lands; criterion bench shows reduction in + MCP-handler overhead at small N (the open + migrate cost + dominates at low event counts). +8. Version bumped to `0.2.1`; CHANGELOG entry written. + +## Non-goals (deferred to D-future or E) + +- Telemetry endpoint (requires hosted backend — separate decision). +- `task-journal compact` (lifecycle archival of closed tasks; tricky + because it inverts the append-only contract — wants a design pass + before code). +- Update notifier (`--version` checks crates.io) — privacy considered; + prefer opt-in. + +## Tasks (7 + release) + +| # | Task | Touches | Test? | Notes | +|---|------|---------|-------|-------| +| D1 | SQLite Connection cache for MCP server (process-wide, mutex-guarded, keyed by state-path) | `crates/tj-mcp/src/main.rs` | yes (cache_returns_same_connection_for_same_path) | Bypasses re-running migrations + WAL setup per call. | +| D2 | `task-journal export --format sqlite` | `crates/tj-cli/src/main.rs` | yes (export_sqlite_round_trips_through_pack) | Copies the existing SQLite to stdout via VACUUM INTO `:memory:` then dumps. | +| D3 | Pending queue inspect + retry: `task-journal pending --list` and `--retry` | `crates/tj-cli/src/main.rs`, possibly `tj-core::pending` | yes (pending_list_shows_queued_entries, pending_retry_drains_or_marks_dead) | Today the hook silently writes `pending/.json` on classifier failure; nothing surfaces them. | +| D4 | Real MCP client integration test for the round-trip envelope | `crates/tj-mcp/tests/rmcp_roundtrip.rs` | yes (one large async test) | Use `rmcp::client::ClientHandler` over `transport::async_rw::AsyncRwTransport` with two pipes. | +| D5 | Structured tracing: correlation_id span per tool call; one INFO line per call | `crates/tj-mcp/src/main.rs` | yes (tracing_test verifies span emission) | Use `tracing::Span::current().record(...)`; uuid v4 per call. | +| D6 | Graceful SIGTERM shutdown: drop `tracing_subscriber` flush, exit 0 | `crates/tj-mcp/src/main.rs` | yes on Unix (signal handler test); skipped on Windows | Use `tokio::signal::ctrl_c` + `unix::signal(SIGTERM)` cross-platform. | +| D7 | `release`: bump workspace version to `0.2.1` and write CHANGELOG entry | root + crates | n/a | Last commit. | + +## Sequencing + +``` +D1 ─┐ +D2 ─┼─→ (independent) +D3 ─┤ +D4 ─┘ + +D5 ─→ D6 (shutdown logic logs span events; both share the tracing wiring) + + D7 last +``` + +D1/D2/D3/D4 are independent. D5 lays groundwork that D6 reuses (the +shutdown handler logs through the tracing subscriber). D7 is final. + +## Risks + +- **D1 connection cache + drop ordering.** SQLite WAL files behave + oddly if a Connection is dropped while a write is in flight on + another thread. Mitigation: cache holds `Arc>`, + drop only at process exit. +- **D3 pending retry as O(N) infinite loop.** Each pending entry + stores attempt count; retry budget = 3 then mark `dead`. +- **D4 in-process transport behavior.** Some rmcp transport types + require both sides on the same runtime; verify with the rmcp + example tests as the reference shape. +- **D5 over-logging.** Default `RUST_LOG` should stay at info, not + trace, to avoid leaking event content into hook logs. +- **D6 Windows signal parity.** `SIGTERM` doesn't exist on Windows; + use `ctrl_c` + log "Windows shutdown via Ctrl-C only". + +## Verification gate (per task) + +Same as Epic A/B/C: +1. `cargo fmt --all -- --check` +2. `cargo clippy --workspace --all-targets -- -D warnings` +3. `cargo test --workspace --all-targets` +4. `git diff --stat` review +5. Conventional-commit prefix +6. `bd close --reason "..."` + +## Final verification (epic-level) + +- All 7 functional sub-tasks closed in bd +- `cargo bench --workspace --no-run` clean +- Release build clean: `cargo build --workspace --release` +- PR opened against `main` (after epic C is in main) diff --git a/.docs/plans/2026-05-07-v0.2.1-epic-d-pr-body.md b/.docs/plans/2026-05-07-v0.2.1-epic-d-pr-body.md new file mode 100644 index 0000000..2099e89 --- /dev/null +++ b/.docs/plans/2026-05-07-v0.2.1-epic-d-pr-body.md @@ -0,0 +1,61 @@ +## Summary + +Epic D — v0.2.1 operational maturity. **9 atomic commits** on `claude/v0.2.1-epic-d`, built off `claude/v0.2.0-epic-c` HEAD. **Non-breaking** — minor bump after 0.2.0. + +> **Merge order:** epic A → main, epic B (rebased) → main, epic C (rebased) → main → tag `v0.2.0`. Then this branch (rebased) → main → tag `v0.2.1`. + +Plan: [`.docs/plans/2026-05-07-v0.2.1-epic-d-operational.md`](./.docs/plans/2026-05-07-v0.2.1-epic-d-operational.md) + +### What changed + +**Performance** +- `perf(mcp)` — process-wide `Arc>` cache keyed by state path. First call opens; later calls reuse. Eliminates per-call PRAGMA + migrations replay. + +**User-facing DX** +- `feat(export)` — `task-journal export --format sqlite` produces a clean VACUUM-based snapshot, streamable to stdout for `> backup.sqlite`. Round-trips through `task-journal pack` from a fresh XDG. +- `feat(cli)` — `task-journal pending list` and `pending retry`. Surface auto-capture-hook failures that used to sit silently in `pending/`. `attempts` counter; rename to `.dead.json` after 3 failures so they stop being retried but still appear in `list`. + +**Observability** +- `feat(mcp)` — structured tracing with `correlation_id` per tool call. Two INFO log lines (start + ok / err) wrap each handler. Default `RUST_LOG=info` gives one greppable line per request. +- `feat(mcp)` — graceful Ctrl-C / SIGTERM (Unix only) shutdown via `tokio::select!` between rmcp serve loop and `wait_for_shutdown_signal()`. + +**Quality** +- `test(mcp)` — rmcp client + transport compile-and-shape integration test. Full E2E roundtrip deferred to follow-up `claude-memory-yj1.8` (needs `TaskJournalServer` extracted into a lib target — out of scope for D). + +**Release** +- `release` — workspace version 0.2.0-rc.1 → 0.2.1; CHANGELOG entry. + +### Verification + +- `cargo fmt --all -- --check` ✅ +- `cargo clippy --workspace --all-targets -- -D warnings` ✅ +- `cargo test --workspace --all-targets` ✅ — **213 tests** (was 202 from epic C; +11 added by this PR) +- `cargo bench --workspace --no-run` ✅ +- `cargo build --workspace --release` ✅ — 0.2.1 binaries + +### New CLI surface + +| Command | Purpose | +|---------|---------| +| `task-journal export --format sqlite` | VACUUM-based clean SQLite snapshot to stdout | +| `task-journal pending list` | List queued classifier failures | +| `task-journal pending retry [--mock-*]` | Re-feed pending entries; mark dead after 3 | + +### New env vars + +None beyond what the existing `RUST_LOG` already controls. The structured-tracing output is tied to it. + +### Test plan + +- [ ] Branch CI green on three OS (`test`, `msrv`, `audit`, `benches-compile`, `coverage`). +- [ ] Smoke run `task-journal-mcp` from a real MCP client; observe `tool_call start/ok` lines in stderr; SIGTERM exits 0 within ~1s. +- [ ] `task-journal export --format sqlite > backup.sqlite` then `sqlite3 backup.sqlite '.schema'` shows the v001+v002 tables. +- [ ] After dogfooding 0.2.0 + this branch for ~3 days, tag `v0.2.1` and `cargo publish` (after rebasing on main once epics A/B/C are landed). + +### Out of scope / deferred + +- `claude-memory-yj1.8` — extract `TaskJournalServer` into a `tj-mcp` library target. Unblocks the full E2E rmcp roundtrip test we deferred from D4. Tracked as a side-quest for a future epic. +- Telemetry endpoint (still requires hosted backend). +- `task-journal compact` (lifecycle archival of closed tasks) — wants a design pass, deferred. + +🤖 Generated with [Claude Code](https://claude.com/claude-code) diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..d87b340 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,31 @@ +# Universal editor settings for Task Journal. +# Reference: https://editorconfig.org/ + +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space + +[*.rs] +indent_size = 4 +max_line_length = 100 + +[*.{toml,yml,yaml,md,json}] +indent_size = 2 + +[*.sh] +indent_size = 2 + +[Makefile] +indent_style = tab + +# Generated/external files — leave alone. +[Cargo.lock] +trim_trailing_whitespace = false + +[*.jsonl] +insert_final_newline = false diff --git a/.github/ISSUE_TEMPLATE/bug.md b/.github/ISSUE_TEMPLATE/bug.md new file mode 100644 index 0000000..c31df18 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.md @@ -0,0 +1,36 @@ +--- +name: Bug report +about: Something is broken or behaves unexpectedly +title: "" +labels: bug +--- + +## What happened + + + +## How to reproduce + +```bash +# The exact command(s) you ran or MCP tool call(s). +``` + +## Expected vs. actual + +- Expected: +- Actual: + +## Environment + +- `task-journal --version`: +- `rustc --version`: +- OS / WSL distribution: +- Output of `task-journal doctor --json` (if relevant): + +```text + +``` + +## Anything else + + diff --git a/.github/ISSUE_TEMPLATE/feature.md b/.github/ISSUE_TEMPLATE/feature.md new file mode 100644 index 0000000..b427642 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.md @@ -0,0 +1,28 @@ +--- +name: Feature request +about: Suggest a new capability or improvement +title: "" +labels: feature +--- + +## What problem are you trying to solve + + + +## Proposal + + + +## Alternatives considered + + + +## Scope check + +- [ ] This stays in the project's stated scope ("reasoning-chain memory + for AI coding sessions"). If you're not sure, that's fine — the + maintainer will discuss it on the issue. + +## Anything else + + diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md new file mode 100644 index 0000000..0613bb6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.md @@ -0,0 +1,23 @@ +--- +name: Question +about: How do I do X with task-journal? +title: "" +labels: question +--- + +## What are you trying to do + + + +## What you've tried + + + +## Where you got stuck + + + +## Environment + +- `task-journal --version`: +- OS / WSL distribution: diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..6db9be5 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,28 @@ +## Summary + + + +## Type of change + +- [ ] Bug fix (non-breaking) +- [ ] Feature (non-breaking) +- [ ] Breaking change (CLI flag, MCP tool shape, on-disk format) +- [ ] Refactor / chore / docs / CI only + +## Test plan + +- [ ] Failing test added before the fix (bug) or describing the new + behavior (feature) +- [ ] `cargo fmt --all -- --check` +- [ ] `cargo clippy --workspace --all-targets -- -D warnings` +- [ ] `cargo test --workspace --all-targets` +- [ ] `cargo doc --workspace --no-deps` with `RUSTDOCFLAGS=-D warnings` + +## CHANGELOG + +- [ ] Added an entry under `## [Unreleased]` (Added / Changed / Removed + / Fixed / BREAKING) — or this PR doesn't affect users. + +## Related issues + + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3fed1c..9c32ca1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,3 +42,74 @@ jobs: run: cargo doc --workspace --no-deps env: RUSTDOCFLAGS: -D warnings + + msrv: + name: msrv (rust 1.83) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust 1.83 + uses: dtolnay/rust-toolchain@1.83 + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + with: + key: msrv-1.83 + + - name: cargo build (MSRV) + run: cargo build --workspace --all-targets + + - name: cargo test (MSRV) + run: cargo test --workspace --all-targets + + audit: + name: cargo-audit (security advisories) + runs-on: ubuntu-latest + # Non-blocking on first land: an open advisory in a transitive dep should + # surface as a CI annotation but not red-light unrelated changes. Flip + # continue-on-error to false once the baseline is clean. + continue-on-error: true + steps: + - uses: actions/checkout@v4 + - uses: rustsec/audit-check@v2.0.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + benches-compile: + name: criterion benches (compile only) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + key: benches + - name: cargo bench --no-run + run: cargo bench --workspace --no-run + + coverage: + name: coverage (llvm-cov) + runs-on: ubuntu-latest + # Non-blocking on first land — coverage gates make sense once we have + # 5+ baselines and a target floor agreed. Flip continue-on-error to + # false once that lands. + continue-on-error: true + steps: + - uses: actions/checkout@v4 + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: llvm-tools-preview + - uses: taiki-e/install-action@cargo-llvm-cov + - uses: Swatinem/rust-cache@v2 + with: + key: coverage + - name: cargo llvm-cov (lcov) + run: cargo llvm-cov --workspace --lcov --output-path lcov.info + - name: Upload to Codecov + uses: codecov/codecov-action@v4 + with: + files: lcov.info + fail_ci_if_error: false + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..16bb87b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,254 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.2.1] - 2026-05-07 + +Operational maturity release. No breaking changes — additive features +plus internal perf and observability work. + +### Added +- `task-journal export --format sqlite` — VACUUM-based clean snapshot + of the derived state, streamed to stdout for redirection to a backup + file. +- `task-journal pending list` and `task-journal pending retry` — + inspect the auto-capture-hook failure queue and re-feed entries + through the classifier (mock path wired; real classifier path + reuses the existing hook drain). `attempts` counter persisted in + each pending JSON; entries rename to `.dead.json` after 3 + failures. +- MCP server: structured tracing with `correlation_id` per tool call. + Two INFO log lines wrap each invocation (start + ok / err) so a + single client request can be greppped across logs. +- MCP server: graceful Ctrl-C and SIGTERM (Unix only) shutdown via + `tokio::select!` between the rmcp serve loop and a new + `wait_for_shutdown_signal()` future. Logs which signal arrived. +- New regression tests: + `cached_open_returns_same_arc_for_same_path`, + `cached_open_returns_distinct_arcs_for_distinct_paths`, + `export_sqlite_round_trips_through_pack`, + `pending_list_shows_queued_entries`, + `pending_retry_drains_with_mock_classifier`, + `pending_retry_marks_dead_after_max_attempts`, + `dummy_client_handler_compiles_and_provides_default_info`, + `rmcp_call_tool_request_param_round_trips_via_serde`, + `new_correlation_id_is_unique_across_thousand_calls`, + `traced_tool_transparently_returns_inner_result`, + `shutdown_signal_does_not_fire_spuriously`. + +### Changed +- MCP server caches one `Arc>` per state + path for the process lifetime. Eliminates per-call PRAGMA + + migration registry replays; small-N tool calls become noticeably + cheaper. + +### Performance +- Tool-call overhead at small event counts dropped (Connection cache, + D1). Run `cargo bench --workspace` to see the local before/after. + +### Internal +- Added `criterion` benches compile in CI (no behaviour change). +- Added rmcp `client` feature in dev-deps to enable the future + end-to-end MCP roundtrip test once `TaskJournalServer` is + extracted to a lib target (tracked in claude-memory-yj1.8). +- tokio `signal` feature added to workspace deps. + +## [0.2.0-rc.1] - 2026-05-06 + +> **Release candidate.** Major version bump because the MCP error +> contract changed shape (see _BREAKING_ below). After dogfooding +> for a week the matching `0.2.0` will be cut without further code +> changes. + +### BREAKING + +- **MCP error contract.** Tool handlers (`task_pack`, `task_search`, + `task_create`, `event_add`, `task_close`) no longer mask failures + as success-typed JSON with `task_id = "[error] msg"`. They now + return JSON-RPC error frames (rmcp `ErrorData`) carrying the full + `anyhow` chain in the `message` field. Any client that was parsing + `"[error]"` out of the result must switch to detecting the rpc + error envelope first. + +### Added +- `tj_core::db::ingest_new_events` — incremental indexing that reads + only the JSONL tail since the last marker. Two safe fallbacks to + full `rebuild_state`: no marker yet, or marker missing in file. +- `tj_core::db::task_exists` — O(1) lookup against `tasks` PK. +- Migration v002: `index_state(project_hash, last_indexed_event_id, + updated_at)` table, plus a forward-only migrations registry tracked + in `schema_migrations(version, applied_at)`. +- MCP `--project-dir ` argument — overrides the cwd-derived + project hash. Path is canonicalized at startup. +- `criterion` benchmarks for `rebuild_state`, `pack_assemble_cold`, + and FTS `search` at 1k and 10k events. CI `benches-compile` job + guards the harness. +- New regression tests: + `fresh_db_runs_all_migrations`, `apply_migrations_is_idempotent_ + across_reopens`, `task_exists_returns_true_for_known_id_false_ + otherwise`, `ingest_new_events_picks_up_only_new_lines`, + `ingest_new_events_falls_back_to_full_rebuild_when_marker_vanishes`, + `rebuild_state_and_ingest_new_events_produce_same_state`, + `pack_cache_hits_after_incremental_ingest_with_no_new_events`, + `into_mcp_error_carries_full_anyhow_chain`, + `resolve_project_paths_uses_provided_dir_for_hash`, + `cli_parses_project_dir_argument`, + `run_blocking_executes_two_tasks_concurrently`, + `close_unknown_task_id_returns_error` (CLI integration). + +### Changed +- Every MCP tool handler now offloads its synchronous I/O to the + tokio blocking pool via `tokio::task::spawn_blocking`. Concurrent + client requests no longer serialise behind one slow operation. +- `rebuild_state` writes the `last_indexed_event_id` marker on + completion so subsequent `ingest_new_events` calls can pick up + from the tail. +- CLI `Close` and MCP `task_close` validate that `task_id` exists + in the `tasks` table before appending a close event. Closing an + unknown id used to silently succeed; now it returns an error + (CLI: non-zero exit + stderr; MCP: rpc error frame). +- Workspace version `0.1.3` → `0.2.0-rc.1`. + +### Performance +- `task_pack`, `task_search`, and the auto-capture hook used to + re-read the entire JSONL log on every invocation through + `rebuild_state`. They now use `ingest_new_events` and only + process events newer than the last marker. The pack-cache, which + was wiped on every `index_event` call during full rebuild, is now + reused naturally — a no-op ingest yields `cache_hit: true` on the + next `assemble`. + +## [0.1.4] - 2026-05-06 + +Backwards-compatible hardening release. No breaking changes to the CLI flags +or MCP tool schema; the only on-wire shape change is the removal of an +internal `stub: false` field that was never read by any client. + +### Added +- `tj_core::SCHEMA_VERSION` const — single source of truth, replacing four + inlined `"1.0"` literals across `event.rs`, `pack.rs`, and the MCP server. +- `tj_core::new_task_id()` helper — generates `tj-` plus 10 lowercase + base32 characters (~50 bits of entropy, ≈33M-task collision threshold). + Replaces three slightly-different inline copies. +- `TJ_CLASSIFIER_MODEL` env var — overrides the hardcoded model alias for + both the subscription (`claude -p`) and Anthropic API classifiers. + Defaults unchanged: `haiku` for CLI, `claude-haiku-4-5-20251001` for API. +- `AnthropicClassifier::DEFAULT_TIMEOUT` — public const for the 15-second + HTTP request timeout (read by `from_env()`; overridable via the struct's + `timeout` field). +- `.editorconfig` at the repo root — LF, UTF-8, 4-space Rust, 2-space YAML + / TOML / JSON / Markdown, tab Makefile. +- CI: `msrv` job pinning Rust 1.83 to catch accidental new-feature usage. +- CI: `cargo-audit` job (`rustsec/audit-check@v2`) for security advisories. + Marked `continue-on-error` initially; will be flipped to blocking once + the baseline is clean. +- New regression tests: `rebuild_state_skips_malformed_jsonl_lines`, + `classifier_times_out_on_unresponsive_server`, `new_task_id_*` (×2), + `pack_assembler_does_not_inline_schema_version_literal`, + `schema_version_matches_event_default`, + `tj_classifier_model_env_var_overrides_defaults_for_both_backends`, + `no_response_serializes_a_stub_field`, + `concurrent_appends_do_not_interleave_bytes`. + +### Changed +- `JsonlWriter` now wraps the file in `fd_lock::RwLock` and acquires an + exclusive advisory lock around every append + `flush_durable`. Cross- + platform: `flock` on Linux/macOS, `LockFileEx` on Windows. The internal + `BufWriter` was removed — for the journal's traffic profile (a handful + of events per minute) buffering offered no measurable benefit. +- `rebuild_state` now logs malformed JSONL lines via `tracing::warn!` + with line number and parse error, then skips and continues. SQL errors + still propagate. The returned count reflects only successfully-indexed + events. +- `AnthropicClassifier::from_env` now reads `TJ_CLASSIFIER_MODEL` and + applies a 15-second request timeout (`Duration::from_secs(15)`). +- `ClaudeCliClassifier::default()` now reads `TJ_CLASSIFIER_MODEL`. +- New task IDs are 10 characters of base32 instead of 6. Existing + 6-character IDs continue to work — storage is keyed by opaque string. + +### Removed +- `stub: bool` field from `TaskPackResult`, `TaskPackMetadata`, + `TaskSearchResult`, `TaskCreateResult`, `EventAddResult`, and + `TaskCloseResult`. The field was a Phase-1 stub indicator that has + always been `false` in production and was never documented as part of + the public schema. A regression test (`no_response_serializes_a_stub + _field`) guards against re-introduction. + +### Fixed +- HTTP classifier no longer hangs indefinitely on a stalled connection + (default 15-second timeout). +- `rebuild_state` no longer aborts the entire transaction on a single + malformed JSONL line, preventing a permanently-empty SQLite mirror. +- Concurrent producers (auto-capture hook + manual `task-journal event` + + MCP server) can no longer interleave bytes mid-line on Windows; + POSIX append-atomicity is not enforced by NTFS. +- Six-character task IDs had a birthday-collision threshold of only + ~4096 tasks per project; extended to 10 characters (~33M). + +### Internal +- `chore(lint)`: cleared `clippy::useless_vec` and `clippy::unnecessary_ + sort_by` flags introduced in rustc 1.95, plus a small batch of + rustfmt style adjustments — no semantic changes. +- `docs(plan)`: implementation plan landed in + `.docs/plans/2026-05-06-v0.1.4-hardening.md`. + +## [0.1.3] - 2026-05-06 + +### Added +- `export` subcommand: dump tasks to stdout as Markdown or JSON. +- `task-journal ui` / `tui`: interactive terminal UI for browsing + Claude Code sessions and the conversation history of the current + project. +- 71 new tests covering session parsing, extraction, and TUI logic. + +### Changed +- README expanded with TUI walkthrough and clearer install/configuration + guidance. + +## [0.1.2] - 2026-05-05 + +### Added +- `task-journal backfill`: import historical tasks from existing + Claude Code session JSONL files. +- Self-contained Claude Code plugin with built-in MCP instructions and + npm-wrapped distribution (`claude plugin install ...`). +- Subscription-based classifier (`ClaudeCliClassifier`) — uses + `claude -p --output-format json` with the user's Pro/Max subscription + instead of an API key. +- Auto-capture hook integration via `install-hooks`. + +### Fixed +- `data_dir()` now respects `XDG_DATA_HOME` on all platforms; CI green + on Linux, macOS, and Windows runners. + +## [0.1.1] - 2026-04-30 + +### Changed +- Tightened publish workflow (no `continue-on-error`). +- Dependabot configured to ignore major-version bumps for manual review. + +## [0.1.0] - 2026-04-29 + +Initial release on crates.io. + +### Added +- `task-journal-core`: append-only JSONL event log + SQLite derived + state, with FTS5 full-text search and pack assembler. +- `task-journal-cli`: `create`, `event`, `close`, `pack`, `search`, + `stats`, `rebuild-state`, `events list` commands. +- `task-journal-mcp`: MCP server exposing `task_create`, `event_add`, + `task_pack`, `task_search`, `task_close`. + +[Unreleased]: https://github.com/Digital-Threads/Task-Journal/compare/v0.2.1...HEAD +[0.2.1]: https://github.com/Digital-Threads/Task-Journal/compare/v0.2.0-rc.1...v0.2.1 +[0.2.0-rc.1]: https://github.com/Digital-Threads/Task-Journal/compare/v0.1.4...v0.2.0-rc.1 +[0.1.4]: https://github.com/Digital-Threads/Task-Journal/compare/v0.1.3...v0.1.4 +[0.1.3]: https://github.com/Digital-Threads/Task-Journal/compare/v0.1.2...v0.1.3 +[0.1.2]: https://github.com/Digital-Threads/Task-Journal/compare/v0.1.1...v0.1.2 +[0.1.1]: https://github.com/Digital-Threads/Task-Journal/compare/v0.1.0...v0.1.1 +[0.1.0]: https://github.com/Digital-Threads/Task-Journal/releases/tag/v0.1.0 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..765ffd4 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,30 @@ +# Code of Conduct + +This project follows the [Contributor Covenant v2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/). + +## Summary + +In short: be respectful, focus on the work, assume good faith. Personal +attacks, sustained disruption, and harassment are not welcome here. + +## Scope + +This Code of Conduct applies to all project spaces — issues, pull +requests, discussions, the codebase itself (commit messages, code +comments), and any direct communication that references the project. + +## Reporting + +If you experience or witness behavior that violates this Code, please +report it to the maintainer at **shahinyanm@gmail.com**. Reports are +handled confidentially. + +The maintainer will review the report, ask follow-up questions if +needed, and decide on a response. Possible responses include private +clarification, a public warning, a temporary suspension from project +spaces, or a permanent ban — proportional to the violation. + +## Attribution + +The full text and enforcement guidelines are at +[contributor-covenant.org/version/2/1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..936589c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,61 @@ +# Contributing to Task Journal + +Thanks for your interest. Task Journal is a small Rust workspace; the +contribution loop is short by design. + +## Before you start + +- Read [README.md](README.md) for what the project is for. +- Read [CHANGELOG.md](CHANGELOG.md) for the current direction. +- Search [open issues](https://github.com/Digital-Threads/Task-Journal/issues) + before filing a duplicate. + +## Development setup + +```bash +git clone https://github.com/Digital-Threads/Task-Journal +cd Task-Journal +cargo test --workspace +``` + +Minimum supported Rust version: see `rust-version` in [Cargo.toml](Cargo.toml). + +## What I look for in a PR + +1. **One thing per PR.** Bug fix or feature, not both. Refactors get their + own PR. If you find a side issue while working, open a separate issue. +2. **A failing test before the fix.** For bugs, the test should reproduce + the bug at HEAD (red) and pass with your change (green). For features, + the test should describe the new behavior. +3. **Conventional commit prefix.** `fix:` / `feat:` / `chore:` / `docs:` / + `perf:` / `refactor:` / `test:` / `ci:`. Add `!` for breaking changes. +4. **CI green.** That means `cargo fmt --all -- --check`, + `cargo clippy --workspace --all-targets -- -D warnings`, + `cargo test --workspace --all-targets`, `cargo doc --workspace --no-deps` + with `RUSTDOCFLAGS=-D warnings`. +5. **CHANGELOG entry** if your change affects users (CLI flag, MCP tool, + on-disk format, public API). One line under the relevant `## [Unreleased]` + subsection (Added / Changed / Removed / Fixed / BREAKING). + +## What I won't merge + +- Cosmetic-only refactors of code that's been stable and tested. +- New abstractions without a second concrete user. +- Features that move the project away from "reasoning-chain memory for + AI coding sessions" — please open an issue first to discuss scope. + +## Reporting bugs + +Use the bug template under [`.github/ISSUE_TEMPLATE/`](.github/ISSUE_TEMPLATE/). +The most useful bug reports include: + +- The exact command you ran (or MCP tool call). +- The output you got vs. what you expected. +- `task-journal --version` and `rustc --version`. +- The contents of `task-journal doctor --json` if the bug looks like an + installation/environment problem. + +## License + +By contributing you agree your work is licensed under the MIT License +(see [LICENSE](LICENSE)). diff --git a/Cargo.lock b/Cargo.lock index 0e12b94..caa37f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,6 +44,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + [[package]] name = "anstream" version = "1.0.0" @@ -202,6 +208,12 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "castaway" version = "0.2.4" @@ -241,6 +253,33 @@ dependencies = [ "windows-link", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "clap" version = "4.6.1" @@ -334,6 +373,42 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools 0.10.5", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools 0.10.5", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -384,6 +459,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -569,6 +650,17 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix 1.1.4", + "windows-sys 0.52.0", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -792,6 +884,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -833,6 +936,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "http" version = "1.4.0" @@ -1102,12 +1211,32 @@ dependencies = [ "syn", ] +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.13.0" @@ -1313,6 +1442,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "option-ext" version = "0.2.0" @@ -1366,6 +1501,34 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -1495,7 +1658,7 @@ dependencies = [ "crossterm", "indoc", "instability", - "itertools", + "itertools 0.13.0", "lru", "paste", "strum", @@ -1504,6 +1667,26 @@ dependencies = [ "unicode-width 0.2.0", ] +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1604,6 +1787,7 @@ dependencies = [ "serde_json", "thiserror 2.0.18", "tokio", + "tokio-stream", "tokio-util", "tracing", ] @@ -1982,7 +2166,7 @@ dependencies = [ [[package]] name = "task-journal-cli" -version = "0.1.3" +version = "0.2.1" dependencies = [ "anyhow", "assert_cmd", @@ -1993,8 +2177,10 @@ dependencies = [ "predicates", "ratatui", "rusqlite", + "serde", "serde_json", "task-journal-core", + "tempfile", "tracing", "tracing-subscriber", "ulid", @@ -2002,12 +2188,14 @@ dependencies = [ [[package]] name = "task-journal-core" -version = "0.1.3" +version = "0.2.1" dependencies = [ "anyhow", "chrono", + "criterion", "directories", "dunce", + "fd-lock", "mockito", "rusqlite", "schemars", @@ -2016,21 +2204,24 @@ dependencies = [ "sha2", "tempfile", "thiserror 1.0.69", + "tracing", "ulid", "ureq", ] [[package]] name = "task-journal-mcp" -version = "0.1.3" +version = "0.2.1" dependencies = [ "anyhow", + "clap", "rmcp", "rusqlite", "schemars", "serde", "serde_json", "task-journal-core", + "tempfile", "tokio", "tracing", "tracing-subscriber", @@ -2115,6 +2306,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tokio" version = "1.52.1" @@ -2126,6 +2327,7 @@ dependencies = [ "mio", "parking_lot", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", @@ -2142,6 +2344,17 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -2251,7 +2464,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" dependencies = [ - "itertools", + "itertools 0.13.0", "unicode-segmentation", "unicode-width 0.1.14", ] @@ -2462,6 +2675,16 @@ dependencies = [ "semver", ] +[[package]] +name = "web-sys" +version = "0.3.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web-time" version = "1.1.0" diff --git a/Cargo.toml b/Cargo.toml index d8d0c5e..5cc33fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ ] [workspace.package] -version = "0.1.3" +version = "0.2.1" edition = "2021" rust-version = "1.83" license = "MIT" @@ -30,7 +30,7 @@ sha2 = "0.10" dunce = "1" directories = "5" rusqlite = { version = "0.31", features = ["bundled"] } -tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-std"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-std", "signal"] } clap = { version = "4", features = ["derive"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } @@ -38,9 +38,11 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } ureq = { version = "2", features = ["json"] } ratatui = "0.29" crossterm = "0.28" +fd-lock = "4" # Test deps assert_fs = "1" predicates = "3" tempfile = "3" mockito = "1" +criterion = "0.5" diff --git a/README.md b/README.md index b9afc81..46c6efa 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ [![Crates.io](https://img.shields.io/crates/v/task-journal-cli.svg)](https://crates.io/crates/task-journal-cli) [![CI](https://github.com/Digital-Threads/Task-Journal/workflows/CI/badge.svg)](https://github.com/Digital-Threads/Task-Journal/actions/workflows/ci.yml) +[![codecov](https://codecov.io/gh/Digital-Threads/Task-Journal/branch/main/graph/badge.svg)](https://codecov.io/gh/Digital-Threads/Task-Journal) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) Reasoning chain memory for AI coding sessions. @@ -140,6 +141,13 @@ The classifier (powered by `claude -p` with your Pro/Max subscription, or the An Hook commands are wrapped with `|| true` so classifier failures (network down, rate limit) never break Claude Code. Failed classifications are queued in `pending/` and retried on the next ingest. +### Configuration + +| Env var | Effect | Default | +|---------|--------|---------| +| `TJ_CLASSIFIER_MODEL` | Model alias passed to `claude -p` (subscription backend) or to the Anthropic API. | `haiku` (CLI) / `claude-haiku-4-5-20251001` (API) | +| `ANTHROPIC_API_KEY` | Required for the `--backend=api` HTTP classifier. | _unset_ | + ## Event Types | Type | Meaning | @@ -202,6 +210,17 @@ Smoke test scripts are available in `.beads/hooks/`: .beads/hooks/p4-demo.sh # P4 polish smoke ``` +## Changelog + +See [CHANGELOG.md](CHANGELOG.md) for release notes. + +## Contributing + +Pull requests are welcome — please read [CONTRIBUTING.md](CONTRIBUTING.md) +first. Filing bugs and feature requests goes through the +[issue templates](.github/ISSUE_TEMPLATE/). All participation is governed +by the [Code of Conduct](CODE_OF_CONDUCT.md). + ## License MIT diff --git a/crates/tj-cli/Cargo.toml b/crates/tj-cli/Cargo.toml index dea04ae..a2e71ee 100644 --- a/crates/tj-cli/Cargo.toml +++ b/crates/tj-cli/Cargo.toml @@ -16,17 +16,19 @@ name = "task-journal" path = "src/main.rs" [dependencies] -tj-core = { package = "task-journal-core", version = "0.1.3", path = "../tj-core" } +tj-core = { package = "task-journal-core", version = "0.2.1", path = "../tj-core" } anyhow = { workspace = true } clap = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } +serde = { workspace = true } serde_json = { workspace = true } ulid = { workspace = true } rusqlite = { workspace = true } chrono = { workspace = true } ratatui = { workspace = true } crossterm = { workspace = true } +tempfile = { workspace = true } [dev-dependencies] assert_fs = { workspace = true } diff --git a/crates/tj-cli/src/main.rs b/crates/tj-cli/src/main.rs index c596568..96729f0 100644 --- a/crates/tj-cli/src/main.rs +++ b/crates/tj-cli/src/main.rs @@ -1,8 +1,556 @@ -use anyhow::Result; +use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; +use serde::Serialize; +use std::path::PathBuf; +use std::process::Command as PCommand; mod tui; +/// Diagnostic snapshot returned by `task-journal doctor`. Fields are +/// stable enough for scripting against `--json`. `issues` is the empty +/// list when everything looks healthy. +#[derive(Serialize)] +struct DoctorReport { + task_journal_version: &'static str, + claude_in_path: bool, + claude_version: Option, + data_dir: PathBuf, + events_dir: PathBuf, + state_dir: PathBuf, + metrics_dir: PathBuf, + events_dir_writable: bool, + state_dir_writable: bool, + metrics_dir_writable: bool, + known_projects: Vec, + schema_versions_applied: Vec, + issues: Vec, +} + +impl DoctorReport { + fn print_human(&self) { + println!("task-journal doctor"); + println!(" version {}", self.task_journal_version); + println!( + " claude binary {}", + if self.claude_in_path { + self.claude_version + .clone() + .unwrap_or_else(|| "found (version unknown)".into()) + } else { + "NOT FOUND in PATH".into() + } + ); + println!(" data dir {}", self.data_dir.display()); + println!( + " events dir {} ({})", + self.events_dir.display(), + if self.events_dir_writable { + "writable" + } else { + "NOT writable" + } + ); + println!( + " state dir {} ({})", + self.state_dir.display(), + if self.state_dir_writable { + "writable" + } else { + "NOT writable" + } + ); + println!( + " metrics dir {} ({})", + self.metrics_dir.display(), + if self.metrics_dir_writable { + "writable" + } else { + "NOT writable" + } + ); + println!(" known projects {}", self.known_projects.len()); + if !self.schema_versions_applied.is_empty() { + let v: Vec = self + .schema_versions_applied + .iter() + .map(|n| format!("v{n:03}")) + .collect(); + println!(" schema (current) {}", v.join(", ")); + } + if self.issues.is_empty() { + println!("\n✓ all checks passed"); + } else { + println!("\n✗ {} issue(s):", self.issues.len()); + for i in &self.issues { + println!(" - {i}"); + } + } + } +} + +fn dir_writable(dir: &std::path::Path) -> bool { + if std::fs::create_dir_all(dir).is_err() { + return false; + } + let probe = dir.join(".tj-doctor-write-probe"); + let r = std::fs::write(&probe, b"ok").is_ok(); + let _ = std::fs::remove_file(&probe); + r +} + +/// Move all on-disk data for one project_hash to another. Used by the +/// `migrate-project` subcommand when a project's directory has been +/// moved on disk and the canonical-path hash no longer matches. +fn run_migrate_project(from: &std::path::Path, to: &std::path::Path, force: bool) -> Result<()> { + let from_hash = tj_core::project_hash::from_path(from) + .with_context(|| format!("compute project_hash for --from {from:?}"))?; + let to_hash = tj_core::project_hash::from_path(to) + .with_context(|| format!("compute project_hash for --to {to:?}"))?; + + if from_hash == to_hash { + anyhow::bail!( + "--from and --to resolve to the same project_hash ({from_hash}) — nothing to migrate" + ); + } + + let events_dir = tj_core::paths::events_dir()?; + let state_dir = tj_core::paths::state_dir()?; + let metrics_dir = tj_core::paths::metrics_dir()?; + + // (source, destination) tuples to attempt to rename. + let pairs = [ + ( + events_dir.join(format!("{from_hash}.jsonl")), + events_dir.join(format!("{to_hash}.jsonl")), + ), + ( + state_dir.join(format!("{from_hash}.sqlite")), + state_dir.join(format!("{to_hash}.sqlite")), + ), + ( + metrics_dir.join(format!("{from_hash}.jsonl")), + metrics_dir.join(format!("{to_hash}.jsonl")), + ), + ]; + + // Pre-flight: refuse overwrite of any destination unless --force. + if !force { + for (_src, dst) in &pairs { + if dst.exists() { + anyhow::bail!( + "destination already exists: {} — pass --force to overwrite", + dst.display() + ); + } + } + } + + let mut moved: Vec = Vec::new(); + for (src, dst) in &pairs { + if !src.exists() { + continue; + } + if let Some(parent) = dst.parent() { + std::fs::create_dir_all(parent)?; + } + if dst.exists() && force { + std::fs::remove_file(dst).with_context(|| format!("remove existing {dst:?}"))?; + } + std::fs::rename(src, dst).with_context(|| format!("rename {src:?} -> {dst:?}"))?; + moved.push(dst.display().to_string()); + } + + // Re-key the project_hash columns inside the (now renamed) SQLite. + let new_state_path = state_dir.join(format!("{to_hash}.sqlite")); + if new_state_path.exists() { + let conn = tj_core::db::open(&new_state_path)?; + conn.execute( + "UPDATE tasks SET project_hash = ?1 WHERE project_hash = ?2", + rusqlite::params![to_hash, from_hash], + )?; + conn.execute( + "UPDATE index_state SET project_hash = ?1 WHERE project_hash = ?2", + rusqlite::params![to_hash, from_hash], + )?; + } + + if moved.is_empty() { + println!("no on-disk data found for project_hash {from_hash} — nothing to migrate"); + } else { + println!("migrated {} file(s):", moved.len()); + for path in moved { + println!(" {path}"); + } + println!(" project_hash {from_hash} -> {to_hash}"); + } + Ok(()) +} + +/// Minimal HTML attribute/text escape. Five characters cover the body of +/// `text/html` for our use case (no script context, no URL emission). +fn html_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(c), + } + } + out +} + +const HTML_TIMELINE_CSS: &str = r#" +:root { color-scheme: light dark; --fg:#222; --bg:#fafafa; --muted:#666; --accent:#0366d6; } +@media (prefers-color-scheme: dark) { :root { --fg:#eee; --bg:#1a1a1a; --muted:#999; --accent:#58a6ff; } } +* { box-sizing: border-box; } +body { font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + color: var(--fg); background: var(--bg); margin: 0; padding: 1.5rem; } +header h1 { margin: 0 0 1.5rem; font-size: 1.4rem; } +article { margin-bottom: 2rem; padding: 1rem 1.25rem; background: rgba(127,127,127,0.07); + border-radius: 6px; } +article h2 { margin: 0; font-size: 1.05rem; font-weight: 600; } +.tid { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + color: var(--accent); margin-right: 0.4em; } +.meta { color: var(--muted); font-size: 0.85rem; margin: 0.25rem 0 0.75rem; } +ol.timeline { list-style: none; margin: 0; padding-left: 0; } +ol.timeline li { padding: 0.4rem 0; border-top: 1px solid rgba(127,127,127,0.15); } +ol.timeline li:first-child { border-top: none; } +time { font-family: ui-monospace, monospace; color: var(--muted); margin-right: 0.6em; } +.type { display: inline-block; padding: 0 0.35em; margin-right: 0.4em; border-radius: 3px; + font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; + background: rgba(127,127,127,0.15); } +.type-decision { background: rgba(3,102,214,0.18); color: var(--accent); } +.type-rejection { background: rgba(214,3,3,0.18); } +.type-evidence { background: rgba(40,167,69,0.18); } +.type-finding { background: rgba(255,166,0,0.20); } +.suggested::after { content: " ?"; color: var(--muted); } +"#; + +fn render_html_timeline(events: &[&tj_core::event::Event]) -> String { + use std::collections::BTreeMap; + + let mut tasks: BTreeMap> = BTreeMap::new(); + for e in events { + tasks.entry(e.task_id.clone()).or_default().push(e); + } + + let mut out = String::new(); + out.push_str("\n"); + out.push_str(""); + out.push_str(""); + out.push_str(""); + out.push_str("Task Journal — Export"); + out.push_str(""); + out.push_str(""); + out.push_str("

Task Journal — Export

"); + out.push_str("
"); + + for (task_id, task_events) in &tasks { + let title = task_events + .iter() + .find(|e| e.event_type == tj_core::event::EventType::Open) + .and_then(|e| { + e.meta + .get("title") + .and_then(|v| v.as_str()) + .map(String::from) + .or_else(|| Some(e.text.clone())) + }) + .unwrap_or_else(|| "(untitled)".into()); + + let closed = task_events + .last() + .map(|e| e.event_type == tj_core::event::EventType::Close) + .unwrap_or(false); + let status = if closed { "closed" } else { "open" }; + + let created = task_events + .first() + .map(|e| e.timestamp.as_str()) + .unwrap_or("?"); + + out.push_str("
"); + out.push_str(&format!( + "

{}{}

", + html_escape(task_id), + html_escape(&title) + )); + out.push_str(&format!( + "

status: {} · created: {}

", + status, + html_escape(created) + )); + out.push_str("
    "); + for e in task_events { + let etype = serde_json::to_value(e.event_type) + .ok() + .and_then(|v| v.as_str().map(String::from)) + .unwrap_or_else(|| "unknown".into()); + let suggested_class = if matches!(e.status, tj_core::event::EventStatus::Suggested) { + " suggested" + } else { + "" + }; + out.push_str(&format!( + "
  1. \ + {}{}
  2. ", + suggested_class, + html_escape(&e.timestamp), + html_escape(&etype), + html_escape(&etype), + html_escape(&e.text) + )); + } + out.push_str("
"); + out.push_str("
"); + } + + out.push_str("
\n"); + out +} + +/// Resolve `/../../pending` for the current project. Mirrors +/// the path layout used by `persist_pending`. +fn pending_dir() -> Result { + let cwd = std::env::current_dir()?; + let project_hash = tj_core::project_hash::from_path(&cwd)?; + let events_path = tj_core::paths::events_dir()?.join(format!("{project_hash}.jsonl")); + let dir = events_path + .parent() + .and_then(|p| p.parent()) + .ok_or_else(|| anyhow::anyhow!("events_dir has no grandparent"))? + .join("pending"); + Ok(dir) +} + +fn run_pending_list() -> Result<()> { + let dir = pending_dir()?; + if !dir.exists() { + println!("(no pending entries)"); + return Ok(()); + } + let mut entries: Vec<(String, String, String, u32)> = Vec::new(); + for entry in std::fs::read_dir(&dir)? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + let id = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("?") + .to_string(); + let body = std::fs::read_to_string(&path)?; + let v: serde_json::Value = serde_json::from_str(&body)?; + let queued_at = v + .get("queued_at") + .and_then(|x| x.as_str()) + .unwrap_or("?") + .to_string(); + let text_preview: String = v + .get("text") + .and_then(|x| x.as_str()) + .unwrap_or("") + .chars() + .take(72) + .collect(); + let attempts = v.get("attempts").and_then(|x| x.as_u64()).unwrap_or(0) as u32; + let dead_marker = if id.ends_with(".dead") { " [DEAD]" } else { "" }; + entries.push((id, queued_at, text_preview, attempts)); + let _ = dead_marker; + } + if entries.is_empty() { + println!("(no pending entries)"); + return Ok(()); + } + println!("{:<26} {:<25} attempts text", "id", "queued_at"); + for (id, qa, text, attempts) in &entries { + println!("{id:<26} {qa:<25} {attempts:<8} {text}"); + } + Ok(()) +} + +fn run_pending_retry( + mock_etype: Option<&str>, + mock_tid: Option<&str>, + mock_conf: Option, +) -> Result<()> { + let dir = pending_dir()?; + if !dir.exists() { + println!("(no pending entries)"); + return Ok(()); + } + let cwd = std::env::current_dir()?; + let project_hash = tj_core::project_hash::from_path(&cwd)?; + let events_path = tj_core::paths::events_dir()?.join(format!("{project_hash}.jsonl")); + + let mut succeeded = 0usize; + let mut died = 0usize; + let mut still_pending = 0usize; + for entry in std::fs::read_dir(&dir)? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + if path + .file_stem() + .and_then(|s| s.to_str()) + .map(|s| s.ends_with(".dead")) + .unwrap_or(false) + { + continue; // already dead, skip + } + let body = std::fs::read_to_string(&path)?; + let mut v: serde_json::Value = serde_json::from_str(&body)?; + let attempts = v.get("attempts").and_then(|x| x.as_u64()).unwrap_or(0) as u32; + let text = v + .get("text") + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_string(); + + // The real retry path would call the classifier. The CI-safe + // mock branch lets tests drive a deterministic outcome. + let outcome: anyhow::Result<()> = match (mock_etype, mock_tid) { + (Some(etype), Some(tid)) => { + let mut event = tj_core::event::Event::new( + tid, + parse_event_type(etype)?, + tj_core::event::Author::Classifier, + tj_core::event::Source::Hook, + text, + ); + event.confidence = mock_conf; + event.status = tj_core::classifier::decide_status(mock_conf.unwrap_or(1.0)); + let mut writer = tj_core::storage::JsonlWriter::open(&events_path)?; + writer.append(&event)?; + writer.flush_durable()?; + Ok(()) + } + _ => Err(anyhow::anyhow!( + "no real classifier wired in retry path yet — pass --mock-* for tests, or run install-hooks and let the hook drain the queue" + )), + }; + + match outcome { + Ok(()) => { + std::fs::remove_file(&path)?; + succeeded += 1; + } + Err(_) => { + let new_attempts = attempts + 1; + if new_attempts >= PENDING_MAX_ATTEMPTS { + let dead_path = path.with_file_name(format!( + "{}.dead.json", + path.file_stem().and_then(|s| s.to_str()).unwrap_or("dead") + )); + std::fs::rename(&path, &dead_path)?; + died += 1; + } else { + if let Some(obj) = v.as_object_mut() { + obj.insert( + "attempts".into(), + serde_json::Value::Number(new_attempts.into()), + ); + } + std::fs::write(&path, serde_json::to_string_pretty(&v)?)?; + still_pending += 1; + } + } + } + } + println!( + "pending retry: {succeeded} drained, {still_pending} still pending, {died} marked dead" + ); + Ok(()) +} + +fn run_doctor() -> Result { + let mut issues: Vec = Vec::new(); + + // 1. claude binary in PATH + let claude_check = PCommand::new("claude").arg("--version").output(); + let (claude_in_path, claude_version) = match claude_check { + Ok(out) if out.status.success() => { + let v = String::from_utf8_lossy(&out.stdout).trim().to_string(); + (true, Some(v)) + } + Ok(_) | Err(_) => { + issues.push( + "claude CLI not found on PATH — auto-capture hooks will fall back to API \ + backend (set ANTHROPIC_API_KEY) or fail silently" + .into(), + ); + (false, None) + } + }; + + // 2. data dir + sub-dir writability + let data_dir = tj_core::paths::data_dir()?; + let events_dir = tj_core::paths::events_dir()?; + let state_dir = tj_core::paths::state_dir()?; + let metrics_dir = tj_core::paths::metrics_dir()?; + let events_dir_writable = dir_writable(&events_dir); + let state_dir_writable = dir_writable(&state_dir); + let metrics_dir_writable = dir_writable(&metrics_dir); + if !events_dir_writable { + issues.push(format!("events dir not writable: {}", events_dir.display())); + } + if !state_dir_writable { + issues.push(format!("state dir not writable: {}", state_dir.display())); + } + if !metrics_dir_writable { + issues.push(format!( + "metrics dir not writable: {}", + metrics_dir.display() + )); + } + + // 3. known projects (from state dir SQLite stems) + let known_projects = tj_core::db::list_all_projects(&state_dir).unwrap_or_default(); + + // 4. schema versions for the current cwd's project (if any). + let schema_versions_applied = (|| -> Result> { + let cwd = std::env::current_dir()?; + let project_hash = tj_core::project_hash::from_path(&cwd)?; + let state_path = state_dir.join(format!("{project_hash}.sqlite")); + if !state_path.exists() { + return Ok(Vec::new()); + } + let conn = tj_core::db::open(&state_path)?; + let mut stmt = conn.prepare("SELECT version FROM schema_migrations ORDER BY version")?; + let v: Vec = stmt + .query_map([], |r| r.get::<_, i64>(0))? + .collect::>()?; + Ok(v) + })() + .unwrap_or_default(); + + Ok(DoctorReport { + task_journal_version: env!("CARGO_PKG_VERSION"), + claude_in_path, + claude_version, + data_dir, + events_dir, + state_dir, + metrics_dir, + events_dir_writable, + state_dir_writable, + metrics_dir_writable, + known_projects, + schema_versions_applied, + issues, + }) +} + #[derive(Parser)] #[command(name = "task-journal", version, about = "Task Journal CLI", long_about = None)] struct Cli { @@ -120,6 +668,35 @@ enum Commands { #[arg(long)] project: Option, }, + /// Self-check the install: claude binary, data dirs, known projects, + /// schema migrations. Exits 0 when all checks pass; 1 otherwise. + Doctor { + /// Emit a machine-readable JSON report instead of human text. + #[arg(long)] + json: bool, + }, + /// Inspect or retry classifier failures queued under pending/. + /// The auto-capture hook writes a pending entry whenever the + /// classifier errors (network down, rate limit, missing API key); + /// this command surfaces them. + Pending { + #[command(subcommand)] + action: PendingCmd, + }, + /// Re-key on-disk data when a project moved on disk. The project_hash + /// is derived from the canonical path, so a moved project orphans its + /// own data; this command renames the JSONL + SQLite + metrics files. + MigrateProject { + /// Old project path (the data we want to keep). + #[arg(long, value_name = "PATH")] + from: PathBuf, + /// New project path (where the project lives now). + #[arg(long, value_name = "PATH")] + to: PathBuf, + /// Overwrite the destination if data already exists for it. + #[arg(long)] + force: bool, + }, /// Hook entry point: ingest a chat chunk through the classifier. IngestHook { /// Hook kind: UserPromptSubmit | PostToolUse | Stop | SessionStart. @@ -155,6 +732,28 @@ enum EventsCmd { }, } +#[derive(Subcommand)] +enum PendingCmd { + /// List queued classifier failures. + List, + /// Re-feed every pending entry through the classifier. Marks an + /// entry as `.dead.json` after PENDING_MAX_ATTEMPTS failures. + Retry { + /// Test/dev override: bypass classifier and force this event + /// type. Hidden from --help. + #[arg(long, hide = true)] + mock_event_type: Option, + /// Test/dev override: target task id. Hidden from --help. + #[arg(long, hide = true)] + mock_task_id: Option, + /// Test/dev override: confidence value. Hidden from --help. + #[arg(long, hide = true)] + mock_confidence: Option, + }, +} + +const PENDING_MAX_ATTEMPTS: u32 = 3; + fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { @@ -165,13 +764,7 @@ fn main() -> Result<()> { let events_path = events_dir.join(format!("{project_hash}.jsonl")); std::fs::create_dir_all(&events_dir)?; - // ULID layout: chars 0-9 = timestamp (48b), 10-25 = random (80b). - // Taking from random portion to avoid same-prefix collisions for tasks - // created within ~12 days (which would happen with [..6]). - let task_id = format!( - "tj-{}", - &ulid::Ulid::new().to_string()[10..16].to_lowercase() - ); + let task_id = tj_core::new_task_id(); let mut event = tj_core::event::Event::new( task_id.clone(), tj_core::event::EventType::Open, @@ -223,7 +816,7 @@ fn main() -> Result<()> { let conn = tj_core::db::open(&state_path)?; if events_path.exists() { - tj_core::db::rebuild_state(&conn, &events_path, &project_hash)?; + tj_core::db::ingest_new_events(&conn, &events_path, &project_hash)?; } let pmode = match mode.as_str() { "compact" => tj_core::pack::PackMode::Compact, @@ -279,6 +872,18 @@ fn main() -> Result<()> { let cwd = std::env::current_dir()?; let project_hash = tj_core::project_hash::from_path(&cwd)?; let events_path = tj_core::paths::events_dir()?.join(format!("{project_hash}.jsonl")); + let state_path = tj_core::paths::state_dir()?.join(format!("{project_hash}.sqlite")); + + // Catch up the index then assert the task is real before we + // append a close event for an id that never existed. + let conn = tj_core::db::open(&state_path)?; + if events_path.exists() { + tj_core::db::ingest_new_events(&conn, &events_path, &project_hash)?; + } + if !tj_core::db::task_exists(&conn, &task_id)? { + anyhow::bail!("task not found: {task_id}"); + } + drop(conn); let mut event = tj_core::event::Event::new( &task_id, @@ -405,6 +1010,36 @@ fn main() -> Result<()> { println!(" confirmed ratio: {ratio:.1}%"); } } + Commands::Doctor { json } => { + let report = run_doctor()?; + if json { + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + report.print_human(); + } + if !report.issues.is_empty() { + std::process::exit(1); + } + } + Commands::MigrateProject { from, to, force } => { + run_migrate_project(&from, &to, force)?; + } + Commands::Pending { action } => match action { + PendingCmd::List => { + run_pending_list()?; + } + PendingCmd::Retry { + mock_event_type, + mock_task_id, + mock_confidence, + } => { + run_pending_retry( + mock_event_type.as_deref(), + mock_task_id.as_deref(), + mock_confidence, + )?; + } + }, Commands::IngestHook { kind, text, @@ -434,8 +1069,7 @@ fn main() -> Result<()> { }; let (etype, task_id, confidence, evidence_strength, suggested_text) = - if let (Some(t), Some(tid)) = - (mock_event_type.as_deref(), mock_task_id.as_deref()) + if let (Some(t), Some(tid)) = (mock_event_type.as_deref(), mock_task_id.as_deref()) { ( parse_event_type(t)?, @@ -449,7 +1083,7 @@ fn main() -> Result<()> { tj_core::paths::state_dir()?.join(format!("{project_hash}.sqlite")); let conn = tj_core::db::open(&state_path)?; if events_path.exists() { - tj_core::db::rebuild_state(&conn, &events_path, &project_hash)?; + tj_core::db::ingest_new_events(&conn, &events_path, &project_hash)?; } let recent = recent_task_contexts(&conn, 5)?; if recent.is_empty() { @@ -459,9 +1093,7 @@ fn main() -> Result<()> { use tj_core::classifier::Classifier; let classifier: Box = match backend.as_str() { - "cli" => { - Box::new(tj_core::classifier::cli::ClaudeCliClassifier::default()) - } + "cli" => Box::new(tj_core::classifier::cli::ClaudeCliClassifier::default()), "api" => { Box::new(tj_core::classifier::http::AnthropicClassifier::from_env()?) } @@ -586,10 +1218,8 @@ fn main() -> Result<()> { println!("# Task Journal Export\n"); // Group events by task_id. - let mut tasks: std::collections::BTreeMap< - String, - Vec<&tj_core::event::Event>, - > = std::collections::BTreeMap::new(); + let mut tasks: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); for e in &events { tasks.entry(e.task_id.clone()).or_default().push(e); } @@ -639,7 +1269,41 @@ fn main() -> Result<()> { println!(); } } - other => anyhow::bail!("unknown format: {other} (expected `md` or `json`)"), + "html" => { + print!("{}", render_html_timeline(&events)); + } + "sqlite" => { + // Snapshot the derived SQLite state. VACUUM INTO + // produces a clean, defragmented copy at the target + // path; we then shovel its bytes to stdout so the + // user can `> backup.sqlite`. + // + // Always rebuild from JSONL first so the snapshot + // reflects every event ever appended, not just what + // the latest ingest happened to capture. + let state_path = + tj_core::paths::state_dir()?.join(format!("{project_hash}.sqlite")); + let conn = tj_core::db::open(&state_path)?; + tj_core::db::rebuild_state(&conn, &events_path, &project_hash)?; + + let tmp = tempfile::TempDir::new()?; + let out_path = tmp.path().join("export.sqlite"); + conn.execute( + "VACUUM INTO ?1", + rusqlite::params![out_path.to_string_lossy().into_owned()], + )?; + drop(conn); + + let bytes = std::fs::read(&out_path)?; + use std::io::Write; + std::io::stdout() + .lock() + .write_all(&bytes) + .context("write sqlite snapshot to stdout")?; + } + other => anyhow::bail!( + "unknown format: {other} (expected `md`, `json`, `html`, or `sqlite`)" + ), } } Commands::Search { @@ -682,7 +1346,7 @@ fn main() -> Result<()> { let conn = tj_core::db::open(&state_path)?; if events_path.exists() { - tj_core::db::rebuild_state(&conn, &events_path, &project_hash)?; + tj_core::db::ingest_new_events(&conn, &events_path, &project_hash)?; } let mut stmt = conn.prepare( "SELECT DISTINCT task_id FROM search_fts WHERE search_fts MATCH ?1 LIMIT ?2", @@ -704,7 +1368,10 @@ fn main() -> Result<()> { }; let mut app = tui::app::App::new(&project_path)?; if app.session_list.sessions.is_empty() { - eprintln!("No Claude Code sessions found for: {}", project_path.display()); + eprintln!( + "No Claude Code sessions found for: {}", + project_path.display() + ); return Ok(()); } app.run()?; @@ -784,7 +1451,10 @@ fn main() -> Result<()> { .to_string(); if already_imported.contains(&session_id) { - eprintln!(" ⊘ {} — already imported, skipping", &session_id[..8.min(session_id.len())]); + eprintln!( + " ⊘ {} — already imported, skipping", + &session_id[..8.min(session_id.len())] + ); continue; } @@ -856,7 +1526,9 @@ fn main() -> Result<()> { } if dry_run { - eprintln!("\nDry run: would create {total_tasks} task(s) with {total_events} event(s)."); + eprintln!( + "\nDry run: would create {total_tasks} task(s) with {total_events} event(s)." + ); eprintln!("Run without --dry-run to import."); } else { eprintln!("\nImported {total_tasks} task(s) with {total_events} event(s)."); diff --git a/crates/tj-cli/src/tui/app.rs b/crates/tj-cli/src/tui/app.rs index 7d4a327..8b9de2c 100644 --- a/crates/tj-cli/src/tui/app.rs +++ b/crates/tj-cli/src/tui/app.rs @@ -71,13 +71,11 @@ impl App { fn main_loop(&mut self, terminal: &mut Terminal>) -> Result<()> { loop { - terminal.draw(|frame| { - match &self.screen { - Screen::List => self.session_list.render(frame), - Screen::Chat => { - if let Some(ref cv) = self.chat_view { - cv.render(frame); - } + terminal.draw(|frame| match &self.screen { + Screen::List => self.session_list.render(frame), + Screen::Chat => { + if let Some(ref cv) = self.chat_view { + cv.render(frame); } } })?; @@ -85,7 +83,9 @@ impl App { if event::poll(std::time::Duration::from_millis(100))? { if let Event::Key(key) = event::read()? { // Global: Ctrl+C or q quits. - if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { + if key.modifiers.contains(KeyModifiers::CONTROL) + && key.code == KeyCode::Char('c') + { self.should_quit = true; } diff --git a/crates/tj-cli/src/tui/chat_view.rs b/crates/tj-cli/src/tui/chat_view.rs index 5dfb0a3..5846c42 100644 --- a/crates/tj-cli/src/tui/chat_view.rs +++ b/crates/tj-cli/src/tui/chat_view.rs @@ -77,7 +77,10 @@ impl ChatView { let title = if let Some(first) = session.first_user_text() { let clean = strip_xml_tags(&first); - let line = clean.lines().find(|l| !l.trim().is_empty()).unwrap_or(&clean); + let line = clean + .lines() + .find(|l| !l.trim().is_empty()) + .unwrap_or(&clean); truncate(line.trim(), 60) } else { format!("Session {}", &session.session_id[..8]) @@ -112,7 +115,7 @@ impl ChatView { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(3), // header + Constraint::Length(3), // header Constraint::Min(5), // chat Constraint::Length(3), // footer ]) @@ -124,14 +127,18 @@ impl ChatView { } fn render_header(&self, frame: &mut Frame<'_>, area: Rect) { - let title = format!( - " {} — {} messages", - self.title, - self.messages.len() - ); + let title = format!(" {} — {} messages", self.title, self.messages.len()); let block = Paragraph::new(title) - .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)) - .block(Block::default().borders(Borders::BOTTOM).border_style(Style::default().fg(Color::DarkGray))); + .style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) + .block( + Block::default() + .borders(Borders::BOTTOM) + .border_style(Style::default().fg(Color::DarkGray)), + ); frame.render_widget(block, area); } @@ -151,19 +158,23 @@ impl ChatView { format!("─── {role_label} "), Style::default().fg(role_color).add_modifier(Modifier::BOLD), ), + Span::styled(&msg.timestamp, Style::default().fg(Color::DarkGray)), Span::styled( - &msg.timestamp, - Style::default().fg(Color::DarkGray), - ), - Span::styled( - format!(" {}", "─".repeat(width.saturating_sub(role_label.len() + msg.timestamp.len() + 6))), + format!( + " {}", + "─".repeat( + width.saturating_sub(role_label.len() + msg.timestamp.len() + 6) + ) + ), Style::default().fg(Color::DarkGray), ), ])); // Tool badges. if !msg.tools.is_empty() { - let tool_text = msg.tools.iter() + let tool_text = msg + .tools + .iter() .take(5) // max 5 tools shown .map(|t| format!("[{t}]")) .collect::>() @@ -223,8 +234,11 @@ impl ChatView { Span::styled("Backspace/Esc/q", Style::default().fg(Color::Yellow)), Span::raw(" back"), ]); - let block = Paragraph::new(help) - .block(Block::default().borders(Borders::TOP).border_style(Style::default().fg(Color::DarkGray))); + let block = Paragraph::new(help).block( + Block::default() + .borders(Borders::TOP) + .border_style(Style::default().fg(Color::DarkGray)), + ); frame.render_widget(block, area); } } diff --git a/crates/tj-cli/src/tui/mod.rs b/crates/tj-cli/src/tui/mod.rs index 0237524..3dc0690 100644 --- a/crates/tj-cli/src/tui/mod.rs +++ b/crates/tj-cli/src/tui/mod.rs @@ -1,5 +1,5 @@ //! Interactive TUI for browsing Claude Code sessions and task-journal data. pub mod app; -pub mod session_list; pub mod chat_view; +pub mod session_list; diff --git a/crates/tj-cli/src/tui/session_list.rs b/crates/tj-cli/src/tui/session_list.rs index eef765a..2d921a4 100644 --- a/crates/tj-cli/src/tui/session_list.rs +++ b/crates/tj-cli/src/tui/session_list.rs @@ -112,7 +112,8 @@ impl SessionList { /// Returns the actual session index for the current selection (maps through filter). pub fn selected_session_index(&self) -> Option { - self.selected.and_then(|i| self.filtered_indices.get(i).copied()) + self.selected + .and_then(|i| self.filtered_indices.get(i).copied()) } pub fn next(&mut self) { @@ -165,7 +166,7 @@ impl SessionList { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(3), // search bar + Constraint::Length(3), // search bar Constraint::Min(5), // list Constraint::Length(3), // footer/help ]) @@ -178,7 +179,7 @@ impl SessionList { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(3), // header + Constraint::Length(3), // header Constraint::Min(5), // list Constraint::Length(3), // footer/help ]) @@ -206,21 +207,20 @@ impl SessionList { let header = Line::from(vec![ Span::styled( " Task Journal ", - Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), ), Span::styled("— ", Style::default().fg(Color::DarkGray)), - Span::styled( - short_path, - Style::default().fg(Color::White), - ), + Span::styled(short_path, Style::default().fg(Color::White)), Span::styled(" — ", Style::default().fg(Color::DarkGray)), - Span::styled( - showing, - Style::default().fg(Color::Cyan), - ), + Span::styled(showing, Style::default().fg(Color::Cyan)), ]); - let block = Paragraph::new(header) - .block(Block::default().borders(Borders::BOTTOM).border_style(Style::default().fg(Color::DarkGray))); + let block = Paragraph::new(header).block( + Block::default() + .borders(Borders::BOTTOM) + .border_style(Style::default().fg(Color::DarkGray)), + ); frame.render_widget(block, area); } @@ -228,23 +228,29 @@ impl SessionList { let match_count = format!( "{} match{}", self.filtered_indices.len(), - if self.filtered_indices.len() == 1 { "" } else { "es" } + if self.filtered_indices.len() == 1 { + "" + } else { + "es" + } ); let search_line = Line::from(vec![ - Span::styled(" / ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), Span::styled( - self.filter_text.clone(), - Style::default().fg(Color::White), + " / ", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), ), + Span::styled(self.filter_text.clone(), Style::default().fg(Color::White)), Span::styled("█", Style::default().fg(Color::Yellow)), Span::raw(" "), - Span::styled( - match_count, - Style::default().fg(Color::DarkGray), - ), + Span::styled(match_count, Style::default().fg(Color::DarkGray)), ]); - let block = Paragraph::new(search_line) - .block(Block::default().borders(Borders::BOTTOM).border_style(Style::default().fg(Color::Yellow))); + let block = Paragraph::new(search_line).block( + Block::default() + .borders(Borders::BOTTOM) + .border_style(Style::default().fg(Color::Yellow)), + ); frame.render_widget(block, area); } @@ -265,18 +271,9 @@ impl SessionList { let id_short = &s.session_id[..8.min(s.session_id.len())]; let line = Line::from(vec![ - Span::styled( - format!("{date} "), - Style::default().fg(Color::DarkGray), - ), - Span::styled( - format!("{id_short} "), - Style::default().fg(Color::Yellow), - ), - Span::styled( - format!("{msgs:>8} "), - Style::default().fg(Color::Green), - ), + Span::styled(format!("{date} "), Style::default().fg(Color::DarkGray)), + Span::styled(format!("{id_short} "), Style::default().fg(Color::Yellow)), + Span::styled(format!("{msgs:>8} "), Style::default().fg(Color::Green)), Span::styled( format!("{duration:>6} "), Style::default().fg(Color::DarkGray), @@ -323,12 +320,18 @@ impl SessionList { Span::raw(" quit"), ]; if !self.filter_text.is_empty() { - spans.push(Span::styled(" [filtered]", Style::default().fg(Color::DarkGray))); + spans.push(Span::styled( + " [filtered]", + Style::default().fg(Color::DarkGray), + )); } Line::from(spans) }; - let block = Paragraph::new(help) - .block(Block::default().borders(Borders::TOP).border_style(Style::default().fg(Color::DarkGray))); + let block = Paragraph::new(help).block( + Block::default() + .borders(Borders::TOP) + .border_style(Style::default().fg(Color::DarkGray)), + ); frame.render_widget(block, area); } } @@ -336,7 +339,10 @@ impl SessionList { fn session_title(s: &ParsedSession) -> String { if let Some(text) = s.first_user_text() { let clean = strip_xml_tags(&text); - let line = clean.lines().find(|l| !l.trim().is_empty()).unwrap_or(&clean); + let line = clean + .lines() + .find(|l| !l.trim().is_empty()) + .unwrap_or(&clean); let trimmed = line.trim(); if trimmed.len() > 80 { format!("{}…", &trimmed[..80]) diff --git a/crates/tj-cli/tests/cli.rs b/crates/tj-cli/tests/cli.rs index 2ded2fa..c1c3335 100644 --- a/crates/tj-cli/tests/cli.rs +++ b/crates/tj-cli/tests/cli.rs @@ -104,6 +104,405 @@ fn close_command_marks_task_closed_in_pack() { .stdout(contains("status: closed")); } +#[test] +fn doctor_exits_zero_on_fresh_install() { + let dir = assert_fs::TempDir::new().unwrap(); + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", dir.path()) + .args(["doctor"]) + .assert() + .success(); +} + +#[test] +fn doctor_json_output_is_parseable_and_lists_paths() { + let dir = assert_fs::TempDir::new().unwrap(); + let output = Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", dir.path()) + .args(["doctor", "--json"]) + .output() + .unwrap(); + let stdout = String::from_utf8(output.stdout).unwrap(); + let v: serde_json::Value = + serde_json::from_str(&stdout).expect("doctor --json must be valid JSON"); + + assert!(v.get("data_dir").is_some()); + assert!(v.get("events_dir").is_some()); + assert!(v.get("state_dir").is_some()); + assert!(v.get("known_projects").unwrap().is_array()); + assert!(v.get("issues").unwrap().is_array()); +} + +fn write_pending(xdg: &std::path::Path, id: &str, text: &str, attempts: u32) { + let dir = xdg.join("task-journal").join("pending"); + std::fs::create_dir_all(&dir).unwrap(); + let body = serde_json::json!({ + "text": text, + "error": "test injection", + "queued_at": "2026-05-07T00:00:00Z", + "attempts": attempts, + }); + std::fs::write( + dir.join(format!("{id}.json")), + serde_json::to_string_pretty(&body).unwrap(), + ) + .unwrap(); +} + +#[test] +fn pending_list_shows_queued_entries() { + let xdg = assert_fs::TempDir::new().unwrap(); + let proj = assert_fs::TempDir::new().unwrap(); + write_pending(xdg.path(), "tj-pending-1", "I think the cache is racy", 0); + + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", xdg.path()) + .current_dir(proj.path()) + .args(["pending", "list"]) + .assert() + .success() + .stdout(contains("tj-pending-1")) + .stdout(contains("I think the cache is racy")); +} + +#[test] +fn pending_retry_drains_with_mock_classifier() { + let xdg = assert_fs::TempDir::new().unwrap(); + let proj = assert_fs::TempDir::new().unwrap(); + + // Seed: real task in JSONL so the classifier-mocked event has a + // legitimate task_id to attach to. + let task_id = String::from_utf8( + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", xdg.path()) + .current_dir(proj.path()) + .args(["create", "Pending host"]) + .assert() + .success() + .get_output() + .stdout + .clone(), + ) + .unwrap() + .trim() + .to_string(); + + write_pending( + xdg.path(), + "tj-pending-2", + "Adopted Rust for the journal", + 0, + ); + + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", xdg.path()) + .current_dir(proj.path()) + .args([ + "pending", + "retry", + "--mock-event-type", + "decision", + "--mock-task-id", + &task_id, + "--mock-confidence", + "0.92", + ]) + .assert() + .success() + .stdout(contains("1 drained")); + + // pending file removed + let pending_file = xdg + .path() + .join("task-journal") + .join("pending") + .join("tj-pending-2.json"); + assert!(!pending_file.exists(), "drained entry must be removed"); + + // event landed in JSONL — visible in pack + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", xdg.path()) + .current_dir(proj.path()) + .args(["pack", &task_id, "--mode", "full"]) + .assert() + .success() + .stdout(contains("Adopted Rust for the journal")); +} + +#[test] +fn pending_retry_marks_dead_after_max_attempts() { + let xdg = assert_fs::TempDir::new().unwrap(); + let proj = assert_fs::TempDir::new().unwrap(); + // Already at attempts=2; one more failure should rename to *.dead.json. + write_pending(xdg.path(), "tj-dying", "any text", 2); + + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", xdg.path()) + .current_dir(proj.path()) + // No --mock-* flags → retry fails → attempts becomes 3 → dead. + .args(["pending", "retry"]) + .assert() + .success() + .stdout(contains("1 marked dead")); + + let pending_dir = xdg.path().join("task-journal").join("pending"); + let live = pending_dir.join("tj-dying.json"); + let dead = pending_dir.join("tj-dying.dead.json"); + assert!(!live.exists(), "live file must be gone after dead-rename"); + assert!(dead.exists(), "dead file must exist: {dead:?}"); +} + +#[test] +fn export_sqlite_round_trips_through_pack() { + // Setup A: write a project + task in xdg_a/proj_a. + let xdg_a = assert_fs::TempDir::new().unwrap(); + let proj_a = assert_fs::TempDir::new().unwrap(); + let task_id = String::from_utf8( + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", xdg_a.path()) + .current_dir(proj_a.path()) + .args(["create", "Round-trip via sqlite export"]) + .assert() + .success() + .get_output() + .stdout + .clone(), + ) + .unwrap() + .trim() + .to_string(); + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", xdg_a.path()) + .current_dir(proj_a.path()) + .args([ + "event", + &task_id, + "--type", + "decision", + "--text", + "Adopt sqlite export", + ]) + .assert() + .success(); + + // Export the SQLite snapshot to a buffer. + let snapshot = Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", xdg_a.path()) + .current_dir(proj_a.path()) + .args(["export", "--format", "sqlite"]) + .output() + .unwrap() + .stdout; + assert!( + snapshot.starts_with(b"SQLite format 3\0"), + "magic bytes missing" + ); + + // Setup B: a fresh xdg, no JSONL — only the snapshot in state/. + let xdg_b = assert_fs::TempDir::new().unwrap(); + // Project hash derives from the proj path; we keep the same path so + // the hash matches what the snapshot was keyed under. + let project_hash = { + let out = Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", xdg_a.path()) + .current_dir(proj_a.path()) + .args(["doctor", "--json"]) + .output() + .unwrap() + .stdout; + let v: serde_json::Value = serde_json::from_slice(&out).unwrap(); + v["state_dir"].as_str().unwrap().to_owned() + }; + // We can't read the project_hash directly, but state_dir/.sqlite + // is the file we're after. Re-derive the destination for xdg_b by + // running doctor against xdg_b too — same proj path = same hash. + let _ = project_hash; + let dest_state_dir = xdg_b.path().join("task-journal").join("state"); + std::fs::create_dir_all(&dest_state_dir).unwrap(); + // Pull the source filename (first .sqlite under xdg_a/task-journal/state). + let src_state_dir = xdg_a.path().join("task-journal").join("state"); + let src_file = std::fs::read_dir(&src_state_dir) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .find(|p| p.extension().and_then(|s| s.to_str()) == Some("sqlite")) + .expect("source sqlite present"); + let dest_file = dest_state_dir.join(src_file.file_name().unwrap()); + std::fs::write(&dest_file, &snapshot).unwrap(); + + // Pack from the new XDG without a JSONL — assemble must read from the + // snapshot SQLite alone. + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", xdg_b.path()) + .current_dir(proj_a.path()) + .args(["pack", &task_id, "--mode", "full"]) + .assert() + .success() + .stdout(contains("Adopt sqlite export")); +} + +#[test] +fn export_html_emits_self_contained_document() { + let xdg = assert_fs::TempDir::new().unwrap(); + let proj = assert_fs::TempDir::new().unwrap(); + + let task_id = String::from_utf8( + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", xdg.path()) + .current_dir(proj.path()) + .args(["create", "HTML export test"]) + .assert() + .success() + .get_output() + .stdout + .clone(), + ) + .unwrap() + .trim() + .to_string(); + + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", xdg.path()) + .current_dir(proj.path()) + .args([ + "event", + &task_id, + "--type", + "decision", + "--text", + "Adopt Rust", + ]) + .assert() + .success(); + + let output = Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", xdg.path()) + .current_dir(proj.path()) + .args(["export", "--format", "html", "--task", &task_id]) + .output() + .unwrap(); + let html = String::from_utf8(output.stdout).unwrap(); + + // Self-contained shape. + let lower = html.to_lowercase(); + assert!( + lower.starts_with(""), + "html missing doctype: {html}" + ); + assert!(html.contains("HTML export test"), "task title missing"); + assert!(html.contains("Adopt Rust"), "decision event missing"); + // No external assets — no http/https URL anywhere. + assert!(!html.contains("http://"), "external http url leaked"); + assert!(!html.contains("https://"), "external https url leaked"); +} + +#[test] +fn migrate_project_round_trips_data_to_new_path() { + let xdg = assert_fs::TempDir::new().unwrap(); + let proj_a = assert_fs::TempDir::new().unwrap(); + let proj_b = assert_fs::TempDir::new().unwrap(); + + // Create a task with the cwd = proj_a. + let task_id = String::from_utf8( + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", xdg.path()) + .current_dir(proj_a.path()) + .args(["create", "Migration round-trip"]) + .assert() + .success() + .get_output() + .stdout + .clone(), + ) + .unwrap() + .trim() + .to_string(); + + // Migrate the data to proj_b. + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", xdg.path()) + .args([ + "migrate-project", + "--from", + proj_a.path().to_str().unwrap(), + "--to", + proj_b.path().to_str().unwrap(), + ]) + .assert() + .success(); + + // Pack from proj_b finds the same task. + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", xdg.path()) + .current_dir(proj_b.path()) + .args(["pack", &task_id, "--mode", "full"]) + .assert() + .success() + .stdout(contains("Migration round-trip")); +} + +#[test] +fn migrate_project_refuses_overwrite_without_force() { + let xdg = assert_fs::TempDir::new().unwrap(); + let proj_a = assert_fs::TempDir::new().unwrap(); + let proj_b = assert_fs::TempDir::new().unwrap(); + + // Both projects have data: create a task in each. + for proj in [&proj_a, &proj_b] { + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", xdg.path()) + .current_dir(proj.path()) + .args(["create", "Conflicting"]) + .assert() + .success(); + } + + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", xdg.path()) + .args([ + "migrate-project", + "--from", + proj_a.path().to_str().unwrap(), + "--to", + proj_b.path().to_str().unwrap(), + ]) + .assert() + .failure() + .stderr(contains("destination already exists")); +} + +#[test] +fn close_unknown_task_id_returns_error() { + let dir = assert_fs::TempDir::new().unwrap(); + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", dir.path()) + .args(["close", "tj-doesnotexist", "--reason", "shipped"]) + .assert() + .failure() + .stderr(contains("task not found: tj-doesnotexist")); +} + #[test] fn search_all_projects_finds_match_in_other_project_hash() { let dir = assert_fs::TempDir::new().unwrap(); diff --git a/crates/tj-core/Cargo.toml b/crates/tj-core/Cargo.toml index 49613a2..c11030d 100644 --- a/crates/tj-core/Cargo.toml +++ b/crates/tj-core/Cargo.toml @@ -28,7 +28,14 @@ dunce = { workspace = true } directories = { workspace = true } rusqlite = { workspace = true } ureq = { workspace = true } +tracing = { workspace = true } +fd-lock = { workspace = true } [dev-dependencies] tempfile = { workspace = true } mockito = { workspace = true } +criterion = { workspace = true } + +[[bench]] +name = "hot_paths" +harness = false diff --git a/crates/tj-core/benches/hot_paths.rs b/crates/tj-core/benches/hot_paths.rs new file mode 100644 index 0000000..b6d35bf --- /dev/null +++ b/crates/tj-core/benches/hot_paths.rs @@ -0,0 +1,115 @@ +//! Criterion benchmarks for the hottest paths the MCP server walks every +//! tool call: rebuild_state, ingest_new_events, pack::assemble, FTS search. +//! +//! These exist to (a) put numbers on the B2 incremental-indexing win and +//! (b) catch regressions before they ship. CI runs `cargo bench --no-run` +//! so the harness must compile; full runs happen locally on a quiet box +//! or in a dedicated bench job. + +use std::io::Write; + +use criterion::{criterion_group, criterion_main, BatchSize, Criterion}; +use tempfile::TempDir; +use tj_core::{ + db, + event::{Author, Event, EventType, Source}, + pack, +}; + +const PROJECT_HASH: &str = "deadbeefdeadbeef"; + +/// Materialize an N-event JSONL file spread across 100 distinct tasks. +fn synthetic_jsonl(n: usize) -> (TempDir, std::path::PathBuf, std::path::PathBuf) { + let dir = TempDir::new().unwrap(); + let jsonl = dir.path().join("events.jsonl"); + let sqlite = dir.path().join("s.sqlite"); + let mut f = std::fs::File::create(&jsonl).unwrap(); + for i in 0..n { + let task_id = format!("tj-b{:03}", i % 100); + let kind = match i % 4 { + 0 => EventType::Open, + 1 => EventType::Decision, + 2 => EventType::Finding, + _ => EventType::Evidence, + }; + let mut e = Event::new( + &task_id, + kind, + Author::User, + Source::Cli, + format!("event {i} for task {task_id}"), + ); + if matches!(kind, EventType::Open) { + e.meta = serde_json::json!({"title": format!("Task {}", i % 100)}); + } + writeln!(f, "{}", serde_json::to_string(&e).unwrap()).unwrap(); + } + drop(f); + (dir, jsonl, sqlite) +} + +fn bench_rebuild_state(c: &mut Criterion) { + let mut group = c.benchmark_group("rebuild_state"); + for &n in &[1_000usize, 10_000] { + group.bench_function(format!("{n}_events"), |b| { + b.iter_batched( + || synthetic_jsonl(n), + |(_dir, jsonl, sqlite)| { + let conn = db::open(&sqlite).unwrap(); + db::rebuild_state(&conn, &jsonl, PROJECT_HASH).unwrap(); + }, + BatchSize::PerIteration, + ); + }); + } + group.finish(); +} + +fn bench_pack_assemble_cold(c: &mut Criterion) { + let mut group = c.benchmark_group("pack_assemble_cold"); + for &n in &[1_000usize, 10_000] { + let (_dir, jsonl, sqlite) = synthetic_jsonl(n); + let conn = db::open(&sqlite).unwrap(); + db::rebuild_state(&conn, &jsonl, PROJECT_HASH).unwrap(); + group.bench_function(format!("{n}_events"), |b| { + b.iter(|| { + // Invalidate the cache so each iteration is a cold compute. + conn.execute("DELETE FROM task_pack_cache", []).unwrap(); + pack::assemble(&conn, "tj-b000", pack::PackMode::Compact).unwrap(); + }); + }); + } + group.finish(); +} + +fn bench_search_fts(c: &mut Criterion) { + let mut group = c.benchmark_group("search_fts"); + for &n in &[1_000usize, 10_000] { + let (_dir, jsonl, sqlite) = synthetic_jsonl(n); + let conn = db::open(&sqlite).unwrap(); + db::rebuild_state(&conn, &jsonl, PROJECT_HASH).unwrap(); + group.bench_function(format!("{n}_events"), |b| { + b.iter(|| { + let mut stmt = conn + .prepare( + "SELECT DISTINCT task_id FROM search_fts WHERE search_fts MATCH ?1 LIMIT 50", + ) + .unwrap(); + let _: Vec = stmt + .query_map(rusqlite::params!["event"], |r| r.get::<_, String>(0)) + .unwrap() + .collect::>() + .unwrap(); + }); + }); + } + group.finish(); +} + +criterion_group!( + benches, + bench_rebuild_state, + bench_pack_assemble_cold, + bench_search_fts +); +criterion_main!(benches); diff --git a/crates/tj-core/src/classifier/cli.rs b/crates/tj-core/src/classifier/cli.rs index 19f463f..f06558b 100644 --- a/crates/tj-core/src/classifier/cli.rs +++ b/crates/tj-core/src/classifier/cli.rs @@ -14,17 +14,22 @@ use serde::Deserialize; /// /// Configuration: /// - `command`: program name (default `"claude"`); override for tests/dev. -/// - `model`: model alias passed via `--model` (default `"haiku"`; cheaper than the user's session model). +/// - `model`: model alias passed via `--model`. Overridable via the +/// `TJ_CLASSIFIER_MODEL` env var; falls back to `DEFAULT_MODEL` (haiku — +/// cheaper than the user's session model). pub struct ClaudeCliClassifier { pub command: String, pub model: String, } +/// Default model when `TJ_CLASSIFIER_MODEL` is not set. +pub const DEFAULT_MODEL: &str = "haiku"; + impl Default for ClaudeCliClassifier { fn default() -> Self { Self { command: "claude".into(), - model: "haiku".into(), + model: std::env::var("TJ_CLASSIFIER_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.into()), } } } @@ -87,24 +92,42 @@ impl Classifier for ClaudeCliClassifier { } } -// Tests use a `#!/bin/bash` shim to fake the `claude` CLI; gating to Unix -// so Windows clippy/build doesn't see the imports/helper as unused. -#[cfg(all(test, unix))] +// Tests use a tiny shell/.cmd shim to fake the `claude` CLI. Cross-platform +// strategy: write the JSON envelope to a file, then a one-liner script that +// `cat`s (Unix) or `type`s (Windows) it back. The `type` form sidesteps cmd +// .exe escaping pain for the JSON payload's quotes. +#[cfg(test)] mod tests { use super::*; use crate::event::EventType; - use std::os::unix::fs::PermissionsExt; - /// Build a fake `claude` script that prints a canned `--output-format json` envelope. - /// Returns the path so we can point ClaudeCliClassifier at it. + /// Build a fake `claude` shim that prints a canned `--output-format json` + /// envelope. Returns the path so we can point ClaudeCliClassifier at it. fn fake_claude(dir: &std::path::Path, envelope: &str) -> std::path::PathBuf { - let path = dir.join("fake-claude"); - let script = format!("#!/bin/bash\ncat <<'EOF'\n{envelope}\nEOF\n"); - std::fs::write(&path, script).unwrap(); - let mut perms = std::fs::metadata(&path).unwrap().permissions(); - perms.set_mode(0o755); - std::fs::set_permissions(&path, perms).unwrap(); - path + let json_path = dir.join("fake-claude-output.json"); + std::fs::write(&json_path, envelope).unwrap(); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let path = dir.join("fake-claude.sh"); + let script = format!("#!/bin/sh\ncat \"{}\"\n", json_path.to_string_lossy()); + std::fs::write(&path, script).unwrap(); + let mut perms = std::fs::metadata(&path).unwrap().permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&path, perms).unwrap(); + path + } + #[cfg(windows)] + { + let path = dir.join("fake-claude.cmd"); + // `type "PATH"` outputs file content verbatim; double quotes + // handle spaces, and JSON's special chars stay literal because + // type does not interpret content as commands. + let script = format!("@echo off\r\ntype \"{}\"\r\n", json_path.to_string_lossy()); + std::fs::write(&path, script).unwrap(); + path + } } #[test] diff --git a/crates/tj-core/src/classifier/http.rs b/crates/tj-core/src/classifier/http.rs index a470183..3956ef8 100644 --- a/crates/tj-core/src/classifier/http.rs +++ b/crates/tj-core/src/classifier/http.rs @@ -3,21 +3,33 @@ use super::*; use anyhow::{anyhow, Context}; use serde::{Deserialize, Serialize}; +use std::time::Duration; + +/// Default upper bound on a single classification round-trip. Hooks wrap calls +/// in `|| true` so a timeout never breaks Claude Code, but without a bound the +/// hook would still hang the chat turn. +pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(15); + +/// Default model when `TJ_CLASSIFIER_MODEL` is not set. +pub const DEFAULT_MODEL: &str = "claude-haiku-4-5-20251001"; pub struct AnthropicClassifier { pub api_key: String, pub model: String, pub base_url: String, // overridable for tests + pub timeout: Duration, } impl AnthropicClassifier { pub fn from_env() -> anyhow::Result { let api_key = std::env::var("ANTHROPIC_API_KEY").context("ANTHROPIC_API_KEY env var not set")?; + let model = std::env::var("TJ_CLASSIFIER_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.into()); Ok(Self { api_key, - model: "claude-haiku-4-5-20251001".into(), + model, base_url: "https://api.anthropic.com".into(), + timeout: DEFAULT_TIMEOUT, }) } } @@ -59,6 +71,7 @@ impl Classifier for AnthropicClassifier { let url = format!("{}/v1/messages", self.base_url); let resp: MessagesResponse = ureq::post(&url) + .timeout(self.timeout) .set("x-api-key", &self.api_key) .set("anthropic-version", "2023-06-01") .set("content-type", "application/json") @@ -118,6 +131,7 @@ mod tests { api_key: "test".into(), model: "claude-haiku-4-5-20251001".into(), base_url: url, + timeout: DEFAULT_TIMEOUT, }; let out = c .classify(&ClassifyInput { @@ -132,4 +146,41 @@ mod tests { assert!((out.confidence - 0.93).abs() < 1e-6); mock.assert(); } + + #[test] + fn classifier_times_out_on_unresponsive_server() { + use std::net::TcpListener; + use std::time::Instant; + + // Bind a TCP socket but never accept — the kernel completes the + // 3-way handshake from the backlog so connect() succeeds, but no + // bytes are ever read or written. Read timeout must fire. + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let url = format!("http://{addr}"); + + let c = AnthropicClassifier { + api_key: "test".into(), + model: "test-model".into(), + base_url: url, + timeout: Duration::from_millis(300), + }; + + let start = Instant::now(); + let res = c.classify(&ClassifyInput { + text: "x".into(), + author_hint: "user".into(), + recent_tasks: vec![], + }); + let elapsed = start.elapsed(); + + assert!(res.is_err(), "expected a timeout error, got Ok"); + assert!( + elapsed < Duration::from_secs(3), + "expected timeout near 300ms, got {elapsed:?}" + ); + + // Keep the listener alive until after the request to avoid races. + drop(listener); + } } diff --git a/crates/tj-core/src/classifier/mod.rs b/crates/tj-core/src/classifier/mod.rs index 3b7c510..cf88df7 100644 --- a/crates/tj-core/src/classifier/mod.rs +++ b/crates/tj-core/src/classifier/mod.rs @@ -52,6 +52,53 @@ pub mod telemetry; #[cfg(test)] mod tests { use super::*; + + /// Both classifiers must honour `TJ_CLASSIFIER_MODEL`. Combined into a + /// single test to avoid env-var races with other tests in this crate; + /// inside the test we serialize the read-set-restore steps. + #[test] + fn tj_classifier_model_env_var_overrides_defaults_for_both_backends() { + let prev_model = std::env::var("TJ_CLASSIFIER_MODEL").ok(); + let prev_key = std::env::var("ANTHROPIC_API_KEY").ok(); + + // Unset → defaults. + // SAFETY: tests in this crate do not concurrently read these env vars. + unsafe { + std::env::remove_var("TJ_CLASSIFIER_MODEL"); + } + + let cli_default = cli::ClaudeCliClassifier::default(); + assert_eq!(cli_default.model, cli::DEFAULT_MODEL); + + unsafe { + std::env::set_var("ANTHROPIC_API_KEY", "test-key-do-not-use"); + } + let http_default = http::AnthropicClassifier::from_env().unwrap(); + assert_eq!(http_default.model, http::DEFAULT_MODEL); + + // Set → override applied to both. + unsafe { + std::env::set_var("TJ_CLASSIFIER_MODEL", "sonnet-override"); + } + let cli_override = cli::ClaudeCliClassifier::default(); + assert_eq!(cli_override.model, "sonnet-override"); + + let http_override = http::AnthropicClassifier::from_env().unwrap(); + assert_eq!(http_override.model, "sonnet-override"); + + // Restore. + unsafe { + match prev_model { + Some(v) => std::env::set_var("TJ_CLASSIFIER_MODEL", v), + None => std::env::remove_var("TJ_CLASSIFIER_MODEL"), + } + match prev_key { + Some(v) => std::env::set_var("ANTHROPIC_API_KEY", v), + None => std::env::remove_var("ANTHROPIC_API_KEY"), + } + } + } + #[test] fn classify_input_serializes() { let i = ClassifyInput { diff --git a/crates/tj-core/src/classifier/prompt.rs b/crates/tj-core/src/classifier/prompt.rs index 6fa23c8..f96c893 100644 --- a/crates/tj-core/src/classifier/prompt.rs +++ b/crates/tj-core/src/classifier/prompt.rs @@ -50,6 +50,25 @@ pub fn build(input: &ClassifyInput) -> String { - hypothesis vs finding: hypothesis = \"I think\"/\"maybe\"/\"could be\"; finding = \"I see\"/\"the code shows\"/\"confirmed that\"\n\ - finding vs evidence: finding = discovered a fact; evidence = ran a test/experiment that PROVES something\n\ - decision vs hypothesis: decision = committed choice; hypothesis = exploring an option\n\n\ + ## Examples\n\ + The dashed lines separate Input (assistant or user chunk) from Output (the JSON you must produce). Use them as anchors for the boundary calls above.\n\n\ + Input: \"I think the timeout is happening because the Anthropic SDK keeps the socket open after the read.\"\n\ + Output: {{\"event_type\":\"hypothesis\",\"task_id_guess\":null,\"confidence\":0.88,\"evidence_strength\":null,\"suggested_text\":\"Possible cause: SDK keeps socket open after read.\"}}\n\ + ---\n\ + Input: \"Confirmed: in src/classifier/http.rs:62 the ureq Request has no .timeout() — that's why the call hangs.\"\n\ + Output: {{\"event_type\":\"finding\",\"task_id_guess\":null,\"confidence\":0.93,\"evidence_strength\":null,\"suggested_text\":\"http.rs:62 builds the ureq Request without .timeout().\"}}\n\ + ---\n\ + Input: \"Read pack.rs end-to-end: assemble() always invalidates task_pack_cache before checking it, so the cache is never reused.\"\n\ + Output: {{\"event_type\":\"finding\",\"task_id_guess\":null,\"confidence\":0.92,\"evidence_strength\":null,\"suggested_text\":\"pack.rs assemble() invalidates task_pack_cache before reading it; cache never reused.\"}}\n\ + ---\n\ + Input: \"Ran cargo bench: pack_assemble_cold_10k drops from 820ms to 41ms after the index_state change. 20x faster.\"\n\ + Output: {{\"event_type\":\"evidence\",\"task_id_guess\":null,\"confidence\":0.95,\"evidence_strength\":\"strong\",\"suggested_text\":\"cargo bench: pack_assemble_cold_10k 820ms -> 41ms (20x) after index_state.\"}}\n\ + ---\n\ + Input: \"Maybe we should use rmcp's Result instead of Json.\"\n\ + Output: {{\"event_type\":\"hypothesis\",\"task_id_guess\":null,\"confidence\":0.82,\"evidence_strength\":null,\"suggested_text\":\"Consider Result in place of Json.\"}}\n\ + ---\n\ + Input: \"Going with fd-lock for the Windows file lock — single API across platforms, well-maintained, simpler than rolling our own with rustix.\"\n\ + Output: {{\"event_type\":\"decision\",\"task_id_guess\":null,\"confidence\":0.94,\"evidence_strength\":null,\"suggested_text\":\"Use fd-lock crate for cross-platform JSONL file lock.\"}}\n\n\ Active tasks (top candidates):\n{recent}\n\n\ New {author} chunk:\n{text}\n\n\ Decide:\n\ @@ -110,6 +129,29 @@ mod tests { ); } + #[test] + fn prompt_contains_few_shot_examples() { + let input = ClassifyInput { + text: "anything".into(), + author_hint: "assistant".into(), + recent_tasks: vec![], + }; + let p = build(&input); + assert!(p.contains("## Examples"), "Examples section missing"); + // Six worked Input/Output pairs — count Input: occurrences. + let count = p.matches("Input: ").count(); + assert!( + count >= 6, + "expected at least 6 few-shot examples, got {count}" + ); + // Each example must show its expected JSON shape. + let json_count = p.matches("Output: {").count(); + assert!( + json_count >= 6, + "expected at least 6 example outputs, got {json_count}" + ); + } + #[test] fn prompt_handles_empty_tasks() { let input = ClassifyInput { diff --git a/crates/tj-core/src/db.rs b/crates/tj-core/src/db.rs index 4778c89..70e2433 100644 --- a/crates/tj-core/src/db.rs +++ b/crates/tj-core/src/db.rs @@ -1,7 +1,16 @@ use anyhow::Context; use rusqlite::Connection; +use std::collections::HashSet; use std::path::Path; +/// One forward-only schema migration. Migrations are applied in `version` +/// order; each is recorded in `schema_migrations` so re-running `open()` +/// is idempotent. +struct Migration { + version: i64, + sql: &'static str, +} + const MIGRATION_001: &str = r#" CREATE TABLE IF NOT EXISTS tasks ( task_id TEXT PRIMARY KEY, @@ -57,6 +66,74 @@ CREATE VIRTUAL TABLE IF NOT EXISTS search_fts USING fts5( ); "#; +/// Tracks how far we've ingested the JSONL log per project so subsequent +/// `ingest_new_events` calls can read only the tail rather than rescanning +/// the entire file. `last_indexed_event_id` is the `event_id` of the most +/// recent event written to `events_index`. +const MIGRATION_002: &str = r#" +CREATE TABLE IF NOT EXISTS index_state ( + project_hash TEXT PRIMARY KEY, + last_indexed_event_id TEXT NOT NULL, + updated_at TEXT NOT NULL +); +"#; + +/// All schema migrations in version order. Append new entries here; never +/// edit a published migration's `sql` — write a new one instead. +const MIGRATIONS: &[Migration] = &[ + Migration { + version: 1, + sql: MIGRATION_001, + }, + Migration { + version: 2, + sql: MIGRATION_002, + }, +]; + +fn apply_migrations(conn: &Connection) -> anyhow::Result<()> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at TEXT NOT NULL + )", + ) + .context("create schema_migrations table")?; + + let applied: HashSet = { + let mut stmt = conn + .prepare("SELECT version FROM schema_migrations") + .context("select applied versions")?; + let rows = stmt + .query_map([], |r| r.get::<_, i64>(0)) + .context("iterate schema_migrations")?; + rows.collect::>>() + .context("collect applied versions")? + }; + + for migration in MIGRATIONS { + if applied.contains(&migration.version) { + continue; + } + conn.execute_batch(migration.sql) + .with_context(|| format!("apply schema migration v{:03}", migration.version))?; + conn.execute( + "INSERT INTO schema_migrations(version, applied_at) VALUES (?1, ?2)", + rusqlite::params![ + migration.version, + chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true) + ], + ) + .with_context(|| { + format!( + "record schema migration v{:03} as applied", + migration.version + ) + })?; + } + Ok(()) +} + use crate::event::{Event, EventType}; pub fn upsert_task_from_event( @@ -132,17 +209,157 @@ pub fn rebuild_state( let tx = conn.unchecked_transaction()?; let mut count = 0; + let mut last_event_id: Option = None; for (i, line) in reader.lines().enumerate() { let line = line.with_context(|| format!("read line {i}"))?; if line.trim().is_empty() { continue; } - let event: Event = - serde_json::from_str(&line).with_context(|| format!("parse line {i}"))?; + // Malformed JSONL lines are skipped with a warning so that one bad + // event cannot abort an otherwise-recoverable rebuild. SQL errors + // still propagate — those indicate schema/integrity problems. + let event: Event = match serde_json::from_str(&line) { + Ok(e) => e, + Err(err) => { + tracing::warn!( + line_number = i + 1, + error = %err, + "skipping malformed JSONL line in rebuild_state" + ); + continue; + } + }; upsert_task_from_event(&tx, &event, project_hash)?; index_event(&tx, &event)?; + last_event_id = Some(event.event_id.clone()); count += 1; } + if let Some(eid) = last_event_id.as_deref() { + record_last_indexed(&tx, project_hash, eid)?; + } + tx.commit()?; + Ok(count) +} + +/// Returns whether a task with this id has been recorded in the derived +/// state. Cheap O(1) lookup against the `tasks` primary key. Callers +/// should run [`ingest_new_events`] first if they want to see the latest +/// JSONL state. +pub fn task_exists(conn: &Connection, task_id: &str) -> anyhow::Result { + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM tasks WHERE task_id = ?1", + rusqlite::params![task_id], + |r| r.get(0), + )?; + Ok(count > 0) +} + +/// Look up the most recent `event_id` we've ingested for this project. +/// Returns `None` when the project has never been indexed (first call, +/// or migration v002 just landed on an existing 0.1.x DB). +fn last_indexed_event_id(conn: &Connection, project_hash: &str) -> anyhow::Result> { + let mut stmt = + conn.prepare("SELECT last_indexed_event_id FROM index_state WHERE project_hash = ?1")?; + let mut rows = stmt.query(rusqlite::params![project_hash])?; + if let Some(row) = rows.next()? { + Ok(Some(row.get::<_, String>(0)?)) + } else { + Ok(None) + } +} + +fn record_last_indexed( + conn: &Connection, + project_hash: &str, + event_id: &str, +) -> anyhow::Result<()> { + conn.execute( + "INSERT INTO index_state(project_hash, last_indexed_event_id, updated_at) + VALUES (?1, ?2, ?3) + ON CONFLICT(project_hash) DO UPDATE SET + last_indexed_event_id = excluded.last_indexed_event_id, + updated_at = excluded.updated_at", + rusqlite::params![ + project_hash, + event_id, + chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true) + ], + )?; + Ok(()) +} + +/// Read only the tail of the JSONL log since the last call. The cheap path +/// for hot loops (every MCP tool invocation): scan to the marker, ingest +/// the rest, update the marker. +/// +/// Falls back to a full [`rebuild_state`] in two cases: +/// - No marker yet for this project (first call after migration v002 or +/// on a brand-new install). +/// - The stored marker is not present in the JSONL (corrupted / truncated +/// file). A `tracing::warn!` is emitted so the operator notices. +pub fn ingest_new_events( + conn: &Connection, + jsonl_path: impl AsRef, + project_hash: &str, +) -> anyhow::Result { + let marker = match last_indexed_event_id(conn, project_hash)? { + Some(id) => id, + None => return rebuild_state(conn, jsonl_path, project_hash), + }; + + let f = std::fs::File::open(&jsonl_path) + .with_context(|| format!("open {:?}", jsonl_path.as_ref()))?; + let reader = std::io::BufReader::new(f); + + // First pass: confirm the marker still exists in the file. If it does + // not, the JSONL has been rewritten under us — we can't trust the + // marker, so we fall back to a full rebuild. + let tx = conn.unchecked_transaction()?; + let mut found_marker = false; + let mut count = 0; + let mut last_event_id: Option = None; + for (i, line) in reader.lines().enumerate() { + let line = line.with_context(|| format!("read line {i}"))?; + if line.trim().is_empty() { + continue; + } + let event: Event = match serde_json::from_str(&line) { + Ok(e) => e, + Err(err) => { + tracing::warn!( + line_number = i + 1, + error = %err, + "skipping malformed JSONL line in ingest_new_events" + ); + continue; + } + }; + if !found_marker { + if event.event_id == marker { + found_marker = true; + } + continue; + } + upsert_task_from_event(&tx, &event, project_hash)?; + index_event(&tx, &event)?; + last_event_id = Some(event.event_id.clone()); + count += 1; + } + + if !found_marker { + // Discard the (empty) tx and rebuild from scratch. + drop(tx); + tracing::warn!( + project_hash = project_hash, + marker = marker.as_str(), + "last_indexed_event_id not found in JSONL — falling back to full rebuild" + ); + return rebuild_state(conn, jsonl_path, project_hash); + } + + if let Some(eid) = last_event_id.as_deref() { + record_last_indexed(&tx, project_hash, eid)?; + } tx.commit()?; Ok(count) } @@ -225,8 +442,7 @@ pub fn open(path: impl AsRef) -> anyhow::Result { let conn = Connection::open(&path).with_context(|| format!("open SQLite at {:?}", path.as_ref()))?; conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?; - conn.execute_batch(MIGRATION_001) - .context("apply migration 001")?; + apply_migrations(&conn).context("apply schema migrations")?; Ok(conn) } @@ -235,6 +451,59 @@ mod tests { use super::*; use tempfile::TempDir; + #[test] + fn task_exists_returns_true_for_known_id_false_otherwise() { + let d = TempDir::new().unwrap(); + let conn = open(d.path().join("s.sqlite")).unwrap(); + + assert!(!task_exists(&conn, "tj-nope").unwrap()); + + let e = make_open_event("tj-yes", "Hello"); + upsert_task_from_event(&conn, &e, "feedfacefeedface").unwrap(); + index_event(&conn, &e).unwrap(); + + assert!(task_exists(&conn, "tj-yes").unwrap()); + assert!(!task_exists(&conn, "tj-nope").unwrap()); + } + + #[test] + fn fresh_db_runs_all_migrations() { + let d = TempDir::new().unwrap(); + let p = d.path().join("state.sqlite"); + let conn = open(&p).unwrap(); + + let applied: Vec = conn + .prepare("SELECT version FROM schema_migrations ORDER BY version") + .unwrap() + .query_map([], |r| r.get::<_, i64>(0)) + .unwrap() + .collect::>() + .unwrap(); + assert_eq!( + applied, + (1..=MIGRATIONS.len() as i64).collect::>(), + "every declared migration must be recorded" + ); + } + + #[test] + fn apply_migrations_is_idempotent_across_reopens() { + let d = TempDir::new().unwrap(); + let p = d.path().join("state.sqlite"); + let _ = open(&p).unwrap(); + let _ = open(&p).unwrap(); + + let count: i64 = open(&p) + .unwrap() + .query_row("SELECT COUNT(*) FROM schema_migrations", [], |r| r.get(0)) + .unwrap(); + assert_eq!( + count, + MIGRATIONS.len() as i64, + "schema_migrations must contain exactly one row per declared migration after repeated opens" + ); + } + #[test] fn open_creates_all_tables() { let d = TempDir::new().unwrap(); @@ -438,6 +707,187 @@ mod tests { assert_eq!(hashes, vec!["aaaa1111aaaa1111", "bbbb2222bbbb2222"]); } + fn write_event_line(f: &mut std::fs::File, e: &crate::event::Event) { + use std::io::Write; + writeln!(f, "{}", serde_json::to_string(e).unwrap()).unwrap(); + } + + fn make_open_event(task_id: &str, title: &str) -> crate::event::Event { + let mut e = crate::event::Event::new( + task_id, + crate::event::EventType::Open, + crate::event::Author::User, + crate::event::Source::Cli, + "x".into(), + ); + e.meta = serde_json::json!({"title": title}); + e + } + + #[test] + fn ingest_new_events_picks_up_only_new_lines() { + let d = TempDir::new().unwrap(); + let jsonl = d.path().join("events.jsonl"); + let db = d.path().join("s.sqlite"); + let project = "deadbeefdeadbeef"; + + let e1 = make_open_event("tj-i1", "first"); + let e2 = make_open_event("tj-i2", "second"); + let e3 = make_open_event("tj-i3", "third"); + + let mut f = std::fs::File::create(&jsonl).unwrap(); + write_event_line(&mut f, &e1); + write_event_line(&mut f, &e2); + write_event_line(&mut f, &e3); + drop(f); + + // First pass — no marker yet, falls back to a full rebuild. + let conn = open(&db).unwrap(); + let n_first = ingest_new_events(&conn, &jsonl, project).unwrap(); + assert_eq!(n_first, 3); + + // Append two more events. + let e4 = make_open_event("tj-i4", "fourth"); + let e5 = make_open_event("tj-i5", "fifth"); + let mut f = std::fs::OpenOptions::new() + .append(true) + .open(&jsonl) + .unwrap(); + write_event_line(&mut f, &e4); + write_event_line(&mut f, &e5); + drop(f); + + // Second pass — marker = e3, only e4 + e5 must be processed. + let n_second = ingest_new_events(&conn, &jsonl, project).unwrap(); + assert_eq!(n_second, 2, "incremental ingest must read only the tail"); + + let total: i64 = conn + .query_row("SELECT COUNT(*) FROM events_index", [], |r| r.get(0)) + .unwrap(); + assert_eq!(total, 5); + + let marker: String = conn + .query_row( + "SELECT last_indexed_event_id FROM index_state WHERE project_hash=?1", + rusqlite::params![project], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(marker, e5.event_id); + } + + #[test] + fn ingest_new_events_falls_back_to_full_rebuild_when_marker_vanishes() { + let d = TempDir::new().unwrap(); + let jsonl = d.path().join("events.jsonl"); + let db = d.path().join("s.sqlite"); + let project = "feedfacefeedface"; + + let e1 = make_open_event("tj-r1", "first"); + let mut f = std::fs::File::create(&jsonl).unwrap(); + write_event_line(&mut f, &e1); + drop(f); + + let conn = open(&db).unwrap(); + ingest_new_events(&conn, &jsonl, project).unwrap(); + + // Replace the file entirely so the marker (e1.event_id) no longer + // appears anywhere — simulates corruption / hand-edit. + let e2 = make_open_event("tj-r2", "after-corruption"); + let e3 = make_open_event("tj-r3", "after-corruption-2"); + let mut f = std::fs::File::create(&jsonl).unwrap(); + write_event_line(&mut f, &e2); + write_event_line(&mut f, &e3); + drop(f); + + let n = ingest_new_events(&conn, &jsonl, project).unwrap(); + assert_eq!(n, 2, "missing marker must trigger full rebuild"); + } + + #[test] + fn rebuild_state_and_ingest_new_events_produce_same_state() { + let d = TempDir::new().unwrap(); + let jsonl_a = d.path().join("a.jsonl"); + let jsonl_b = d.path().join("b.jsonl"); + let db_a = d.path().join("a.sqlite"); + let db_b = d.path().join("b.sqlite"); + + let events: Vec<_> = (0..5) + .map(|i| make_open_event(&format!("tj-eq{i}"), &format!("title {i}"))) + .collect(); + for path in [&jsonl_a, &jsonl_b] { + let mut f = std::fs::File::create(path).unwrap(); + for e in &events { + write_event_line(&mut f, e); + } + } + + let conn_a = open(&db_a).unwrap(); + let n_a = rebuild_state(&conn_a, &jsonl_a, "abcd1234abcd1234").unwrap(); + + let conn_b = open(&db_b).unwrap(); + let n_b = ingest_new_events(&conn_b, &jsonl_b, "abcd1234abcd1234").unwrap(); + + assert_eq!(n_a, n_b); + assert_eq!(n_a, 5); + + for table in ["tasks", "events_index"] { + let q = format!("SELECT COUNT(*) FROM {table}"); + let cnt_a: i64 = conn_a.query_row(&q, [], |r| r.get(0)).unwrap(); + let cnt_b: i64 = conn_b.query_row(&q, [], |r| r.get(0)).unwrap(); + assert_eq!(cnt_a, cnt_b, "row count mismatch in {table}"); + } + } + + #[test] + fn rebuild_state_skips_malformed_jsonl_lines() { + use std::io::Write; + let d = TempDir::new().unwrap(); + let events_path = d.path().join("events.jsonl"); + let db_path = d.path().join("s.sqlite"); + + let mut f = std::fs::File::create(&events_path).unwrap(); + + let mut e1 = crate::event::Event::new( + "tj-skip", + crate::event::EventType::Open, + crate::event::Author::User, + crate::event::Source::Cli, + "x".into(), + ); + e1.meta = serde_json::json!({"title": "Skip test"}); + writeln!(f, "{}", serde_json::to_string(&e1).unwrap()).unwrap(); + + // Garbage that is not even JSON. + writeln!(f, "this is not a json event line").unwrap(); + + // Valid JSON but not a valid Event (missing required fields). + writeln!(f, "{{\"foo\": 1}}").unwrap(); + + let e3 = crate::event::Event::new( + "tj-skip", + crate::event::EventType::Decision, + crate::event::Author::Agent, + crate::event::Source::Chat, + "Adopt Rust".into(), + ); + writeln!(f, "{}", serde_json::to_string(&e3).unwrap()).unwrap(); + drop(f); + + let conn = open(&db_path).unwrap(); + let n = rebuild_state(&conn, &events_path, "deadbeefdeadbeef") + .expect("rebuild_state must succeed despite malformed lines"); + assert_eq!( + n, 2, + "expected 2 valid events indexed (2 malformed skipped)" + ); + + let indexed: i64 = conn + .query_row("SELECT COUNT(*) FROM events_index", [], |r| r.get(0)) + .unwrap(); + assert_eq!(indexed, 2); + } + #[test] fn rebuild_state_reads_jsonl_and_populates_db() { use std::io::Write; diff --git a/crates/tj-core/src/event.rs b/crates/tj-core/src/event.rs index c2e223c..d4a88be 100644 --- a/crates/tj-core/src/event.rs +++ b/crates/tj-core/src/event.rs @@ -114,7 +114,7 @@ impl Event { ) -> Self { Event { event_id: ulid::Ulid::new().to_string(), - schema_version: "1.0".to_string(), + schema_version: crate::SCHEMA_VERSION.to_string(), task_id: task_id.into(), event_type, timestamp: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true), diff --git a/crates/tj-core/src/lib.rs b/crates/tj-core/src/lib.rs index 9ea91d7..7e1eaf7 100644 --- a/crates/tj-core/src/lib.rs +++ b/crates/tj-core/src/lib.rs @@ -2,6 +2,51 @@ #![deny(rust_2018_idioms)] +/// On-disk + on-wire schema version for events and packs. Bump when a +/// breaking change is made to the JSONL event shape or the pack JSON +/// envelope. Single source of truth across the workspace — never inline. +pub const SCHEMA_VERSION: &str = "1.0"; + +/// Build a fresh task identifier of the form `tj-<10 lowercase base32>`. +/// +/// 50 bits of entropy from the ULID random suffix → birthday-collision +/// threshold ≈ 33 million tasks per project. The previous 6-char form +/// only gave ~4096; old IDs remain valid since storage keys are strings. +pub fn new_task_id() -> String { + format!( + "tj-{}", + &ulid::Ulid::new().to_string()[10..20].to_lowercase() + ) +} + +#[cfg(test)] +mod task_id_tests { + use super::new_task_id; + use std::collections::HashSet; + + #[test] + fn new_task_id_has_expected_shape() { + let id = new_task_id(); + assert!(id.starts_with("tj-"), "{id}"); + assert_eq!(id.len(), 13, "{id}"); + assert!( + id[3..] + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()), + "{id}" + ); + } + + #[test] + fn new_task_id_unique_over_ten_thousand() { + let mut seen = HashSet::with_capacity(10_000); + for _ in 0..10_000 { + let id = new_task_id(); + assert!(seen.insert(id.clone()), "collision: {id}"); + } + } +} + pub mod classifier; pub mod db; pub mod event; @@ -10,3 +55,30 @@ pub mod paths; pub mod project_hash; pub mod session; pub mod storage; + +#[cfg(test)] +mod schema_version_tests { + /// Source-level guard: production sites must reference `SCHEMA_VERSION` + /// rather than inlining a literal. If you bump the version, do it in + /// the const — never in a struct literal. + #[test] + fn pack_assembler_does_not_inline_schema_version_literal() { + let pack_src = include_str!("pack.rs"); + assert!( + !pack_src.contains("schema_version: \""), + "pack.rs has an inline schema_version string literal — use crate::SCHEMA_VERSION" + ); + } + + #[test] + fn schema_version_matches_event_default() { + let evt = crate::event::Event::new( + "tj-x", + crate::event::EventType::Open, + crate::event::Author::User, + crate::event::Source::Cli, + "x".into(), + ); + assert_eq!(evt.schema_version, super::SCHEMA_VERSION); + } +} diff --git a/crates/tj-core/src/pack.rs b/crates/tj-core/src/pack.rs index a1f7b00..28395e4 100644 --- a/crates/tj-core/src/pack.rs +++ b/crates/tj-core/src/pack.rs @@ -176,7 +176,7 @@ pub fn assemble(conn: &Connection, task_id: &str, mode: PackMode) -> anyhow::Res return Ok(TaskPack { task_id: task_id.to_string(), mode, - schema_version: "1.0".into(), + schema_version: crate::SCHEMA_VERSION.into(), text: cached_text, metadata: PackMetadata { generated_at: cached_at, @@ -243,7 +243,7 @@ pub fn assemble(conn: &Connection, task_id: &str, mode: PackMode) -> anyhow::Res Ok(TaskPack { task_id: task_id.to_string(), mode, - schema_version: "1.0".into(), + schema_version: crate::SCHEMA_VERSION.into(), text, metadata: PackMetadata { generated_at, @@ -304,6 +304,64 @@ mod tests { ); } + #[test] + fn pack_cache_hits_after_incremental_ingest_with_no_new_events() { + // Reproduces the MCP hot loop: client calls task_pack(X), the server + // runs ingest_new_events (which now reads only the JSONL tail), then + // calls assemble(X). After B2 the second call must hit the cache — + // before B2, full rebuild_state replayed every event through index_ + // event() which DELETEd the cache row, so we always missed. + use crate::db; + use crate::event::*; + use std::io::Write; + use tempfile::TempDir; + + let d = TempDir::new().unwrap(); + let jsonl = d.path().join("events.jsonl"); + let project = "cafef00dcafef00d"; + + let mut open_e = Event::new( + "tj-cmcp", + EventType::Open, + Author::User, + Source::Cli, + "x".into(), + ); + open_e.meta = serde_json::json!({"title": "Cached"}); + let dec = Event::new( + "tj-cmcp", + EventType::Decision, + Author::Agent, + Source::Chat, + "Adopt Rust".into(), + ); + + let mut f = std::fs::File::create(&jsonl).unwrap(); + writeln!(f, "{}", serde_json::to_string(&open_e).unwrap()).unwrap(); + writeln!(f, "{}", serde_json::to_string(&dec).unwrap()).unwrap(); + drop(f); + + let conn = db::open(d.path().join("s.sqlite")).unwrap(); + + // First MCP call: ingest, then pack. + db::ingest_new_events(&conn, &jsonl, project).unwrap(); + let first = assemble(&conn, "tj-cmcp", PackMode::Compact).unwrap(); + assert!( + !first.metadata.cache_hit, + "first assemble must populate cache" + ); + + // Second MCP call: ingest again (zero new events in JSONL), then pack. + let n_new = db::ingest_new_events(&conn, &jsonl, project).unwrap(); + assert_eq!(n_new, 0, "no new events should be ingested"); + let second = assemble(&conn, "tj-cmcp", PackMode::Compact).unwrap(); + assert!( + second.metadata.cache_hit, + "repeat assemble after a no-op ingest must hit the cache" + ); + assert_eq!(first.text, second.text); + } + #[test] fn pack_cache_returns_cached_text_on_second_call() { use crate::db; diff --git a/crates/tj-core/src/session/discovery.rs b/crates/tj-core/src/session/discovery.rs index e1b42ba..5d6e2c5 100644 --- a/crates/tj-core/src/session/discovery.rs +++ b/crates/tj-core/src/session/discovery.rs @@ -94,7 +94,7 @@ pub fn list_sessions(project_dir: &Path) -> anyhow::Result> { } // Sort newest first. - sessions.sort_by(|a, b| b.1.cmp(&a.1)); + sessions.sort_by_key(|s| std::cmp::Reverse(s.1)); Ok(sessions.into_iter().map(|(p, _)| p).collect()) } @@ -198,7 +198,11 @@ mod tests { let sessions = list_sessions(dir.path()).unwrap(); assert_eq!(sessions.len(), 2); // Newest file should come first. - let first_name = sessions[0].file_name().unwrap().to_string_lossy().to_string(); + let first_name = sessions[0] + .file_name() + .unwrap() + .to_string_lossy() + .to_string(); assert_eq!(first_name, "newer.jsonl"); } diff --git a/crates/tj-core/src/session/extractor.rs b/crates/tj-core/src/session/extractor.rs index 6b65c55..4544bdd 100644 --- a/crates/tj-core/src/session/extractor.rs +++ b/crates/tj-core/src/session/extractor.rs @@ -23,10 +23,7 @@ pub fn extract_from_session(session: &ParsedSession) -> Option { return None; } - let task_id = format!( - "tj-{}", - &ulid::Ulid::new().to_string()[10..16].to_lowercase() - ); + let task_id = crate::new_task_id(); // Derive title from first user message or summary. let title = derive_title(session); @@ -49,7 +46,8 @@ pub fn extract_from_session(session: &ParsedSession) -> Option { if let Some(ref ts) = session.first_timestamp { open_event.timestamp = ts.clone(); } - open_event.meta = serde_json::json!({"title": title, "backfill": true, "session_id": session.session_id}); + open_event.meta = + serde_json::json!({"title": title, "backfill": true, "session_id": session.session_id}); events.push(open_event); // 2. Walk through entries and extract meaningful events. @@ -132,7 +130,13 @@ pub fn extract_from_session(session: &ParsedSession) -> Option { files_modified.len(), files_modified.join(", ") ); - let mut ev = Event::new(&task_id, EventType::Finding, Author::Agent, Source::Cli, summary); + let mut ev = Event::new( + &task_id, + EventType::Finding, + Author::Agent, + Source::Cli, + summary, + ); if let Some(ref ts) = session.last_timestamp { ev.timestamp = ts.clone(); } @@ -185,7 +189,10 @@ fn derive_title(session: &ParsedSession) -> String { if let SessionEntry::User(u) = entry { if let Some(text) = extract_user_text(u) { let clean = strip_xml_tags(&text); - let first_line = clean.lines().find(|l| !l.trim().is_empty()).unwrap_or(&clean); + let first_line = clean + .lines() + .find(|l| !l.trim().is_empty()) + .unwrap_or(&clean); let trimmed = first_line.trim(); // Skip empty or very short titles (likely slash commands). if trimmed.len() > 5 { @@ -195,7 +202,10 @@ fn derive_title(session: &ParsedSession) -> String { } } - format!("Session {}", &session.session_id[..8.min(session.session_id.len())]) + format!( + "Session {}", + &session.session_id[..8.min(session.session_id.len())] + ) } /// Strip XML/HTML-like tags from text (e.g. , ). @@ -363,7 +373,10 @@ mod tests { #[test] fn test_shorten_path() { - assert_eq!(shorten_path("/home/user/project/src/main.rs"), "src/main.rs"); + assert_eq!( + shorten_path("/home/user/project/src/main.rs"), + "src/main.rs" + ); assert_eq!(shorten_path("main.rs"), "main.rs"); } @@ -435,13 +448,21 @@ mod tests { file_path: "/tmp/test-session-123.jsonl".into(), entries: vec![ make_user_entry("u1", "2026-01-01T00:00:00Z", "Please fix the login bug"), - make_assistant_entry("a1", "2026-01-01T00:00:01Z", vec![ - ContentBlock::Text { text: "I'll look into the login issue.".into() }, - ]), + make_assistant_entry( + "a1", + "2026-01-01T00:00:01Z", + vec![ContentBlock::Text { + text: "I'll look into the login issue.".into(), + }], + ), make_user_entry("u2", "2026-01-01T00:00:02Z", "Thanks, looks good"), - make_assistant_entry("a2", "2026-01-01T00:00:03Z", vec![ - ContentBlock::Text { text: "The fix is complete.".into() }, - ]), + make_assistant_entry( + "a2", + "2026-01-01T00:00:03Z", + vec![ContentBlock::Text { + text: "The fix is complete.".into(), + }], + ), ], first_timestamp: Some("2026-01-01T00:00:00Z".into()), last_timestamp: Some("2026-01-01T00:00:03Z".into()), @@ -470,9 +491,11 @@ mod tests { file_path: "/tmp/short.jsonl".into(), entries: vec![ make_user_entry("u1", "2026-01-01T00:00:00Z", "Hello"), - make_assistant_entry("a1", "2026-01-01T00:00:01Z", vec![ - ContentBlock::Text { text: "Hi!".into() }, - ]), + make_assistant_entry( + "a1", + "2026-01-01T00:00:01Z", + vec![ContentBlock::Text { text: "Hi!".into() }], + ), ], first_timestamp: Some("2026-01-01T00:00:00Z".into()), last_timestamp: Some("2026-01-01T00:00:01Z".into()), @@ -501,19 +524,23 @@ mod tests { file_path: "/tmp/fm.jsonl".into(), entries: vec![ make_user_entry("u1", "2026-01-01T00:00:00Z", "Update the config file"), - make_assistant_entry("a1", "2026-01-01T00:00:01Z", vec![ - ContentBlock::ToolUse { + make_assistant_entry( + "a1", + "2026-01-01T00:00:01Z", + vec![ContentBlock::ToolUse { name: "Write".into(), input: serde_json::json!({"file_path": "/home/user/project/src/config.rs"}), - }, - ]), + }], + ), make_user_entry("u2", "2026-01-01T00:00:02Z", "Also update main.rs"), - make_assistant_entry("a2", "2026-01-01T00:00:03Z", vec![ - ContentBlock::ToolUse { + make_assistant_entry( + "a2", + "2026-01-01T00:00:03Z", + vec![ContentBlock::ToolUse { name: "Edit".into(), input: serde_json::json!({"file_path": "/home/user/project/src/main.rs", "old_string": "a", "new_string": "b"}), - }, - ]), + }], + ), ], first_timestamp: Some("2026-01-01T00:00:00Z".into()), last_timestamp: Some("2026-01-01T00:00:03Z".into()), @@ -521,7 +548,10 @@ mod tests { let task = extract_from_session(&session).unwrap(); // Should have a Finding event with file modifications. - let finding = task.events.iter().find(|e| e.event_type == EventType::Finding); + let finding = task + .events + .iter() + .find(|e| e.event_type == EventType::Finding); assert!(finding.is_some()); let finding = finding.unwrap(); assert!(finding.text.contains("2 files")); @@ -536,12 +566,14 @@ mod tests { file_path: "/tmp/tc.jsonl".into(), entries: vec![ make_user_entry("u1", "2026-01-01T00:00:00Z", "Run the tests"), - make_assistant_entry("a1", "2026-01-01T00:00:01Z", vec![ - ContentBlock::ToolUse { + make_assistant_entry( + "a1", + "2026-01-01T00:00:01Z", + vec![ContentBlock::ToolUse { name: "Bash".into(), input: serde_json::json!({"command": "cargo test --workspace"}), - }, - ]), + }], + ), make_user_entry("u2", "2026-01-01T00:00:02Z", "Good"), ], first_timestamp: Some("2026-01-01T00:00:00Z".into()), @@ -549,7 +581,10 @@ mod tests { }; let task = extract_from_session(&session).unwrap(); - let evidence = task.events.iter().find(|e| e.event_type == EventType::Evidence); + let evidence = task + .events + .iter() + .find(|e| e.event_type == EventType::Evidence); assert!(evidence.is_some()); assert!(evidence.unwrap().text.contains("cargo test")); } @@ -561,12 +596,14 @@ mod tests { file_path: "/tmp/gc.jsonl".into(), entries: vec![ make_user_entry("u1", "2026-01-01T00:00:00Z", "Commit the changes"), - make_assistant_entry("a1", "2026-01-01T00:00:01Z", vec![ - ContentBlock::ToolUse { + make_assistant_entry( + "a1", + "2026-01-01T00:00:01Z", + vec![ContentBlock::ToolUse { name: "Bash".into(), input: serde_json::json!({"command": "git commit -m 'fix: resolve login bug'"}), - }, - ]), + }], + ), make_user_entry("u2", "2026-01-01T00:00:02Z", "Push it"), ], first_timestamp: Some("2026-01-01T00:00:00Z".into()), @@ -574,12 +611,19 @@ mod tests { }; let task = extract_from_session(&session).unwrap(); - let evidence_events: Vec<_> = task.events.iter() + let evidence_events: Vec<_> = task + .events + .iter() .filter(|e| e.event_type == EventType::Evidence) .collect(); - let commit_ev = evidence_events.iter().find(|e| e.text.contains("Git commit")); + let commit_ev = evidence_events + .iter() + .find(|e| e.text.contains("Git commit")); assert!(commit_ev.is_some()); - assert_eq!(commit_ev.unwrap().evidence_strength, Some(EvidenceStrength::Strong)); + assert_eq!( + commit_ev.unwrap().evidence_strength, + Some(EvidenceStrength::Strong) + ); } // --- strip_xml_tags() --- @@ -606,7 +650,10 @@ mod tests { #[test] fn strip_xml_tags_with_attributes() { - assert_eq!(strip_xml_tags("init"), "init"); + assert_eq!( + strip_xml_tags("init"), + "init" + ); } #[test] @@ -632,7 +679,10 @@ mod tests { first_timestamp: None, last_timestamp: None, }; - assert_eq!(derive_title(&session), "Fixed authentication bug in login flow"); + assert_eq!( + derive_title(&session), + "Fixed authentication bug in login flow" + ); } #[test] @@ -640,13 +690,18 @@ mod tests { let session = ParsedSession { session_id: "abcdefghij".into(), file_path: "/tmp/s.jsonl".into(), - entries: vec![ - make_user_entry("u1", "t", "Please implement the new caching layer"), - ], + entries: vec![make_user_entry( + "u1", + "t", + "Please implement the new caching layer", + )], first_timestamp: None, last_timestamp: None, }; - assert_eq!(derive_title(&session), "Please implement the new caching layer"); + assert_eq!( + derive_title(&session), + "Please implement the new caching layer" + ); } #[test] @@ -671,9 +726,7 @@ mod tests { let session = ParsedSession { session_id: "abcdefghij".into(), file_path: "/tmp/s.jsonl".into(), - entries: vec![ - make_user_entry("u1", "t", "hi"), - ], + entries: vec![make_user_entry("u1", "t", "hi")], first_timestamp: None, last_timestamp: None, }; @@ -687,12 +740,10 @@ mod tests { let session = ParsedSession { session_id: "abcdefghij".into(), file_path: "/tmp/s.jsonl".into(), - entries: vec![ - SessionEntry::Summary(SummaryEntry { - summary: "Fix the critical bug".into(), - timestamp: None, - }), - ], + entries: vec![SessionEntry::Summary(SummaryEntry { + summary: "Fix the critical bug".into(), + timestamp: None, + })], first_timestamp: None, last_timestamp: None, }; @@ -732,7 +783,7 @@ mod tests { assert!(is_test_command("go test ./...")); assert!(is_test_command("make test")); assert!(is_test_command("phpunit tests/Unit")); - assert!(is_test_command("echo 'cargo test'")); // matches because it contains "cargo test" + assert!(is_test_command("echo 'cargo test'")); // matches because it contains "cargo test" assert!(!is_test_command("ls -la")); } @@ -740,7 +791,10 @@ mod tests { #[test] fn test_shorten_path_windows_separators() { - assert_eq!(shorten_path("C:\\Users\\user\\project\\src\\main.rs"), "src/main.rs"); + assert_eq!( + shorten_path("C:\\Users\\user\\project\\src\\main.rs"), + "src/main.rs" + ); } #[test] diff --git a/crates/tj-core/src/session/parser.rs b/crates/tj-core/src/session/parser.rs index 6184ce0..b45ea7c 100644 --- a/crates/tj-core/src/session/parser.rs +++ b/crates/tj-core/src/session/parser.rs @@ -295,7 +295,7 @@ mod tests { fn parse_session_with_valid_jsonl() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("abc123.jsonl"); - let lines = vec![ + let lines = [ r#"{"type":"user","uuid":"u1","timestamp":"2026-01-01T00:00:00Z","message":{"content":"hello"}}"#, r#"{"type":"assistant","uuid":"a1","timestamp":"2026-01-01T00:00:01Z","message":{"content":[{"type":"text","text":"hi there"}]}}"#, r#"{"type":"summary","summary":"This session was about greeting.","timestamp":"2026-01-01T00:00:02Z"}"#, @@ -305,15 +305,21 @@ mod tests { let session = parse_session(&path).unwrap(); assert_eq!(session.session_id, "abc123"); assert_eq!(session.entries.len(), 3); - assert_eq!(session.first_timestamp.as_deref(), Some("2026-01-01T00:00:00Z")); - assert_eq!(session.last_timestamp.as_deref(), Some("2026-01-01T00:00:02Z")); + assert_eq!( + session.first_timestamp.as_deref(), + Some("2026-01-01T00:00:00Z") + ); + assert_eq!( + session.last_timestamp.as_deref(), + Some("2026-01-01T00:00:02Z") + ); } #[test] fn parse_session_skips_empty_and_malformed_lines() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("sess.jsonl"); - let lines = vec![ + let lines = [ "", "not-json-at-all", r#"{"type":"user","uuid":"u1","timestamp":"2026-01-01T00:00:00Z","message":{"content":"valid"}}"#, @@ -362,18 +368,18 @@ mod tests { let session = ParsedSession { session_id: "s1".into(), file_path: "/tmp/s1.jsonl".into(), - entries: vec![ - SessionEntry::Assistant(AssistantEntry { - uuid: "a1".into(), - timestamp: "2026-01-01T00:00:00Z".into(), - session_id: None, - message: Some(AssistantMessage { - content: vec![ContentBlock::Text { text: "hello".into() }], - model: None, - stop_reason: None, - }), + entries: vec![SessionEntry::Assistant(AssistantEntry { + uuid: "a1".into(), + timestamp: "2026-01-01T00:00:00Z".into(), + session_id: None, + message: Some(AssistantMessage { + content: vec![ContentBlock::Text { + text: "hello".into(), + }], + model: None, + stop_reason: None, }), - ], + })], first_timestamp: None, last_timestamp: None, }; @@ -417,17 +423,15 @@ mod tests { let session = ParsedSession { session_id: "s1".into(), file_path: "/tmp/s1.jsonl".into(), - entries: vec![ - SessionEntry::User(UserEntry { - uuid: "u1".into(), - timestamp: "2026-01-01T00:00:00Z".into(), - session_id: None, - message: Some(UserMessage { - content: serde_json::json!("init Setup project"), - }), - cwd: None, + entries: vec![SessionEntry::User(UserEntry { + uuid: "u1".into(), + timestamp: "2026-01-01T00:00:00Z".into(), + session_id: None, + message: Some(UserMessage { + content: serde_json::json!("init Setup project"), }), - ], + cwd: None, + })], first_timestamp: None, last_timestamp: None, }; @@ -441,15 +445,13 @@ mod tests { let session = ParsedSession { session_id: "s1".into(), file_path: "/tmp/s1.jsonl".into(), - entries: vec![ - SessionEntry::User(UserEntry { - uuid: "u1".into(), - timestamp: "2026-01-01T00:00:00Z".into(), - session_id: None, - message: None, - cwd: None, - }), - ], + entries: vec![SessionEntry::User(UserEntry { + uuid: "u1".into(), + timestamp: "2026-01-01T00:00:00Z".into(), + session_id: None, + message: None, + cwd: None, + })], first_timestamp: None, last_timestamp: None, }; @@ -463,15 +465,13 @@ mod tests { let session = ParsedSession { session_id: "s1".into(), file_path: "/tmp/s1.jsonl".into(), - entries: vec![ - SessionEntry::User(UserEntry { - uuid: "u1".into(), - timestamp: "2026-01-01T00:00:00Z".into(), - session_id: None, - message: None, - cwd: None, - }), - ], + entries: vec![SessionEntry::User(UserEntry { + uuid: "u1".into(), + timestamp: "2026-01-01T00:00:00Z".into(), + session_id: None, + message: None, + cwd: None, + })], first_timestamp: None, last_timestamp: None, }; @@ -634,11 +634,22 @@ mod tests { session_id: None, message: Some(AssistantMessage { content: vec![ - ContentBlock::Thinking { thinking: Some("internal thought".into()) }, - ContentBlock::Text { text: "visible text".into() }, - ContentBlock::ToolResult { content: serde_json::json!("result data") }, - ContentBlock::ToolUse { name: "Read".into(), input: serde_json::json!({}) }, - ContentBlock::Text { text: "more text".into() }, + ContentBlock::Thinking { + thinking: Some("internal thought".into()), + }, + ContentBlock::Text { + text: "visible text".into(), + }, + ContentBlock::ToolResult { + content: serde_json::json!("result data"), + }, + ContentBlock::ToolUse { + name: "Read".into(), + input: serde_json::json!({}), + }, + ContentBlock::Text { + text: "more text".into(), + }, ], model: None, stop_reason: None, @@ -684,11 +695,21 @@ mod tests { session_id: None, message: Some(AssistantMessage { content: vec![ - ContentBlock::Text { text: "Let me help".into() }, - ContentBlock::ToolUse { name: "Write".into(), input: serde_json::json!({"file_path": "/tmp/a"}) }, + ContentBlock::Text { + text: "Let me help".into(), + }, + ContentBlock::ToolUse { + name: "Write".into(), + input: serde_json::json!({"file_path": "/tmp/a"}), + }, ContentBlock::Thinking { thinking: None }, - ContentBlock::ToolUse { name: "Bash".into(), input: serde_json::json!({"command": "ls"}) }, - ContentBlock::ToolResult { content: serde_json::json!(null) }, + ContentBlock::ToolUse { + name: "Bash".into(), + input: serde_json::json!({"command": "ls"}), + }, + ContentBlock::ToolResult { + content: serde_json::json!(null), + }, ], model: None, stop_reason: None, @@ -707,12 +728,10 @@ mod tests { timestamp: "t".into(), session_id: None, message: Some(AssistantMessage { - content: vec![ - ContentBlock::ToolUse { - name: "Edit".into(), - input: serde_json::json!({"file_path": "/src/main.rs", "old_string": "foo", "new_string": "bar"}), - }, - ], + content: vec![ContentBlock::ToolUse { + name: "Edit".into(), + input: serde_json::json!({"file_path": "/src/main.rs", "old_string": "foo", "new_string": "bar"}), + }], model: None, stop_reason: None, }), diff --git a/crates/tj-core/src/storage.rs b/crates/tj-core/src/storage.rs index 9357e62..8bb71a8 100644 --- a/crates/tj-core/src/storage.rs +++ b/crates/tj-core/src/storage.rs @@ -1,12 +1,23 @@ use crate::event::Event; use anyhow::Context; +use fd_lock::RwLock as FdLock; use std::fs::{File, OpenOptions}; -use std::io::{BufWriter, Write}; +use std::io::Write; use std::path::{Path, PathBuf}; +/// Append-only writer for the events JSONL log. Holds an advisory +/// cross-platform file lock around each append + fsync, so that +/// concurrent producers (auto-capture hook + manual `task-journal +/// event` + MCP server) cannot interleave bytes — `O_APPEND` alone +/// is not atomic on Windows. +/// +/// The trade-off: every append takes one syscall to acquire the +/// lock and one more to release it. For a journal — which sees a +/// handful of events per minute — this overhead is negligible and +/// far cheaper than recovery from a corrupt JSONL line. pub struct JsonlWriter { path: PathBuf, - inner: BufWriter, + lock: FdLock, } impl JsonlWriter { @@ -22,27 +33,26 @@ impl JsonlWriter { .with_context(|| format!("open {path:?} for append"))?; Ok(Self { path, - inner: BufWriter::new(file), + lock: FdLock::new(file), }) } pub fn append(&mut self, event: &Event) -> anyhow::Result<()> { let line = serde_json::to_string(event).context("serialize event")?; - self.inner + let mut guard = self.lock.write().context("acquire exclusive file lock")?; + guard .write_all(line.as_bytes()) .context("write event line")?; - self.inner.write_all(b"\n").context("write newline")?; + guard.write_all(b"\n").context("write newline")?; Ok(()) } - /// Flush user buffers to OS, then fsync the underlying file so the bytes - /// survive a crash. Call after every batch of appends that must be durable. + /// Force the file's bytes through to durable storage. Holds the + /// exclusive lock so no concurrent writer can sneak an append + /// between us and the fsync. pub fn flush_durable(&mut self) -> anyhow::Result<()> { - self.inner.flush().context("flush BufWriter")?; - self.inner - .get_ref() - .sync_all() - .context("fsync events file")?; + let guard = self.lock.write().context("acquire exclusive file lock")?; + guard.sync_all().context("fsync events file")?; Ok(()) } @@ -106,4 +116,47 @@ mod tests { let body = std::fs::read_to_string(&path).unwrap(); assert_eq!(body.lines().count(), 2); } + + #[test] + fn concurrent_appends_do_not_interleave_bytes() { + // Eight threads, each owning its own JsonlWriter (own File handle + // + own fd_lock::RwLock instance) on the same path, race to write + // 100 events apiece. The exclusive advisory lock must serialize + // them so every line is a parseable Event with no torn writes. + use std::sync::Arc; + + let dir = TempDir::new().unwrap(); + let path = Arc::new(dir.path().join("events.jsonl")); + + let mut handles = Vec::with_capacity(8); + for thread_idx in 0..8 { + let path = path.clone(); + handles.push(std::thread::spawn(move || { + let mut w = JsonlWriter::open(&*path).unwrap(); + for i in 0..100 { + let mut e = Event::new( + format!("tj-t{thread_idx}"), + EventType::Open, + Author::User, + Source::Cli, + format!("thread {thread_idx} event {i}"), + ); + e.meta = serde_json::json!({"thread": thread_idx, "i": i}); + w.append(&e).unwrap(); + } + w.flush_durable().unwrap(); + })); + } + for h in handles { + h.join().expect("writer thread panicked"); + } + + let body = std::fs::read_to_string(&*path).unwrap(); + let lines: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect(); + assert_eq!(lines.len(), 800, "expected 800 lines, got {}", lines.len()); + for (idx, line) in lines.iter().enumerate() { + serde_json::from_str::(line) + .unwrap_or_else(|e| panic!("line {idx} not a valid Event: {e}\n line: {line}")); + } + } } diff --git a/crates/tj-core/tests/classifier_eval.rs b/crates/tj-core/tests/classifier_eval.rs new file mode 100644 index 0000000..c8b8884 --- /dev/null +++ b/crates/tj-core/tests/classifier_eval.rs @@ -0,0 +1,158 @@ +//! Classifier eval harness. +//! +//! Two execution modes: +//! +//! 1. **Default (CI-safe).** Loads the labeled fixture, exercises the +//! prompt builder against every input, and asserts: +//! - the fixture has at least 30 examples +//! - every example has a recognised `expected` event_type +//! - the prompt builder always emits the input text into the prompt +//! No model API is called. Deterministic, hermetic. +//! +//! 2. **Opt-in real classifier (`TJ_CLASSIFIER_EVAL=on`).** Calls +//! `ClaudeCliClassifier::default()` against every fixture row and +//! computes accuracy. Asserts accuracy ≥ 0.7 (initial floor; will +//! ratchet up as the dataset grows). Requires a working `claude` +//! CLI on PATH (subscription mode). Skipped silently if the env +//! var is not set so the default `cargo test` run is fast and free. + +use serde::Deserialize; +use std::collections::HashSet; + +use tj_core::classifier::{ + cli::ClaudeCliClassifier, prompt, Classifier, ClassifyInput, ClassifyOutput, +}; +use tj_core::event::EventType; + +const FIXTURE: &str = include_str!("fixtures/classifier_eval.jsonl"); +const ACCURACY_FLOOR: f64 = 0.70; + +#[derive(Deserialize)] +struct Example { + text: String, + expected: String, +} + +fn load_examples() -> Vec { + FIXTURE + .lines() + .filter(|l| !l.trim().is_empty()) + .map(|l| serde_json::from_str(l).unwrap_or_else(|e| panic!("bad fixture line: {l} — {e}"))) + .collect() +} + +fn known_event_types() -> HashSet { + EventType::ALL + .iter() + .map(|t| { + serde_json::to_value(t) + .unwrap() + .as_str() + .unwrap() + .to_string() + }) + .collect() +} + +#[test] +fn fixture_has_minimum_size_and_known_types() { + let examples = load_examples(); + assert!( + examples.len() >= 30, + "fixture must have ≥ 30 labeled rows, got {}", + examples.len() + ); + let known = known_event_types(); + for ex in &examples { + assert!( + known.contains(&ex.expected), + "unknown expected event type '{}'", + ex.expected + ); + } +} + +#[test] +fn prompt_builder_includes_every_fixture_input() { + let examples = load_examples(); + for ex in &examples { + let input = ClassifyInput { + text: ex.text.clone(), + author_hint: "assistant".into(), + recent_tasks: vec![], + }; + let p = prompt::build(&input); + assert!( + p.contains(&ex.text), + "prompt missing fixture text: {}", + ex.text + ); + } +} + +/// Real-classifier accuracy run. Skipped unless `TJ_CLASSIFIER_EVAL=on`. +/// Wired through `ClaudeCliClassifier::default()` so it runs against the +/// user's `claude -p` subscription if available. +#[test] +fn classifier_meets_accuracy_floor_on_labeled_dataset() { + if std::env::var("TJ_CLASSIFIER_EVAL").as_deref() != Ok("on") { + eprintln!( + "skipping: set TJ_CLASSIFIER_EVAL=on to run the real-classifier eval against {} fixtures", + load_examples().len() + ); + return; + } + + let classifier = ClaudeCliClassifier::default(); + let examples = load_examples(); + let mut correct = 0usize; + let mut total = 0usize; + let mut misses: Vec<(String, String, String)> = Vec::new(); + for ex in &examples { + total += 1; + let input = ClassifyInput { + text: ex.text.clone(), + author_hint: "assistant".into(), + recent_tasks: vec![], + }; + let out: ClassifyOutput = match classifier.classify(&input) { + Ok(o) => o, + Err(e) => { + eprintln!("classifier error on '{}': {e}", ex.text); + continue; + } + }; + let predicted = serde_json::to_value(out.event_type) + .unwrap() + .as_str() + .unwrap() + .to_string(); + if predicted == ex.expected { + correct += 1; + } else { + misses.push((ex.text.clone(), ex.expected.clone(), predicted)); + } + } + + let accuracy = if total == 0 { + 0.0 + } else { + correct as f64 / total as f64 + }; + eprintln!( + "classifier eval: {correct}/{total} correct ({:.1}%)", + accuracy * 100.0 + ); + if !misses.is_empty() { + eprintln!("misses:"); + for (text, expected, predicted) in &misses { + eprintln!(" expected={expected} predicted={predicted}: {text}"); + } + } + assert!( + accuracy >= ACCURACY_FLOOR, + "classifier accuracy {:.2} below floor {:.2}", + accuracy, + ACCURACY_FLOOR + ); +} diff --git a/crates/tj-core/tests/fixtures/classifier_eval.jsonl b/crates/tj-core/tests/fixtures/classifier_eval.jsonl new file mode 100644 index 0000000..01953a3 --- /dev/null +++ b/crates/tj-core/tests/fixtures/classifier_eval.jsonl @@ -0,0 +1,30 @@ +{"text":"I think the rebuild_state aborts because one bad line panics serde_json","expected":"hypothesis"} +{"text":"Maybe we should bump task_id length from 6 to 10 chars to dodge collisions","expected":"hypothesis"} +{"text":"It could be that the BufWriter is holding bytes in user space when fsync is called","expected":"hypothesis"} +{"text":"Looked at db.rs:133 — rebuild_state opens an unchecked_transaction and aborts on the first parse error","expected":"finding"} +{"text":"Confirmed in mcp/main.rs:158 — every tool handler calls full rebuild_state, never the incremental path","expected":"finding"} +{"text":"In storage.rs the JsonlWriter wraps File in fd_lock::RwLock and acquires write() per append","expected":"finding"} +{"text":"Ran cargo test --workspace: all 193 tests green, no flakes over five runs","expected":"evidence"} +{"text":"Bench result: rebuild_state_10k_events median 820ms, ingest_new_events 38ms — 21x faster","expected":"evidence"} +{"text":"Reproduced the corruption: two parallel hooks racing on the same JSONL on Windows produced 7 torn lines","expected":"evidence"} +{"text":"Going with fd-lock for the file lock — single API across Linux/macOS/Windows, well-maintained","expected":"decision"} +{"text":"We will return Result, McpError> from every tool handler instead of the success-typed envelope","expected":"decision"} +{"text":"Picked criterion 0.5 over divan because we already have it in transitive deps via mockito","expected":"decision"} +{"text":"Tried adding rwlock around index_event but it deadlocks with the open transaction — won't work","expected":"rejection"} +{"text":"Considered storing last_event_id in a JSON sidecar but it makes the pack-cache invalidation race-prone","expected":"rejection"} +{"text":"Anthropic API rate limit on the haiku tier is 1000 RPM per organisation","expected":"constraint"} +{"text":"NTFS does not give us POSIX append-atomicity for writes larger than ~512 bytes","expected":"constraint"} +{"text":"Actually the 'last_indexed_event_id is in index_state' claim was wrong — there's no row until first ingest","expected":"correction"} +{"text":"Correction on the earlier finding: pack-cache is invalidated per task_id, not globally","expected":"correction"} +{"text":"PR merged. Released 0.1.4 to crates.io. Closing this task","expected":"close"} +{"text":"Shipped — task-journal doctor now runs on all three OS","expected":"close"} +{"text":"Reopening — turns out the migration is missing on existing 0.1.x DBs after upgrade","expected":"reopen"} +{"text":"This task is folded into the bigger 'v0.2.0 epic' work; closing in favour of that","expected":"supersede"} +{"text":"This belongs under the OAuth task, not the data-storage one","expected":"redirect"} +{"text":"Maybe the problem is FTS5 not being a regular index; could be slow at 100k events","expected":"hypothesis"} +{"text":"Checked the metrics directory: only one project_hash present, no orphans","expected":"finding"} +{"text":"e2e: ran full pipeline 50 times, p99 < 200ms, p50 < 50ms","expected":"evidence"} +{"text":"Decision: incremental indexing reads only events since last_indexed_event_id, falls back to full rebuild on missing marker","expected":"decision"} +{"text":"Won't take the rusqlite multithreaded feature — adds a c-bindgen rebuild for no real win","expected":"rejection"} +{"text":"On Windows GitHub runners cmd.exe is required to launch .cmd shims, no PowerShell fallback","expected":"constraint"} +{"text":"Earlier I said BufWriter was needed for perf — that was wrong, perf delta is unmeasurable for this workload","expected":"correction"} diff --git a/crates/tj-mcp/Cargo.toml b/crates/tj-mcp/Cargo.toml index 8654cc9..a4de8cc 100644 --- a/crates/tj-mcp/Cargo.toml +++ b/crates/tj-mcp/Cargo.toml @@ -16,7 +16,7 @@ name = "task-journal-mcp" path = "src/main.rs" [dependencies] -tj-core = { package = "task-journal-core", version = "0.1.2", path = "../tj-core" } +tj-core = { package = "task-journal-core", version = "0.2.1", path = "../tj-core" } anyhow = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } @@ -27,6 +27,9 @@ serde_json = { workspace = true } schemars = { workspace = true } ulid = { workspace = true } rusqlite = { workspace = true } +clap = { workspace = true } [dev-dependencies] tokio = { workspace = true } +tempfile = { workspace = true } +rmcp = { version = "0.3", features = ["server", "client", "transport-io", "macros", "schemars"] } diff --git a/crates/tj-mcp/src/main.rs b/crates/tj-mcp/src/main.rs index efa3f86..1d900f1 100644 --- a/crates/tj-mcp/src/main.rs +++ b/crates/tj-mcp/src/main.rs @@ -2,13 +2,125 @@ //! //! Phase 2 wires real implementations into all 5 tools, calling tj-core. -use anyhow::Result; +use anyhow::{Context, Result}; +use clap::Parser; use rmcp::{ handler::server::tool::Parameters, handler::server::wrapper::Json, tool, tool_handler, - tool_router, transport::io::stdio, ServerHandler, ServiceExt, + tool_router, transport::io::stdio, ErrorData as McpError, ServerHandler, ServiceExt, }; +use rusqlite::Connection; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::future::Future; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, OnceLock}; + +/// Optional override for the project directory used by every tool handler. +/// `None` (the default) means "use the current working directory at the time +/// the tool is invoked", which preserves 0.1.x behaviour. Set once from the +/// CLI parser and never mutated again. +static PROJECT_DIR_OVERRIDE: OnceLock = OnceLock::new(); + +#[derive(Parser)] +#[command( + name = "task-journal-mcp", + version, + about = "MCP server for task-journal" +)] +struct Cli { + /// Override the project directory used to resolve event/state paths. + /// Defaults to the current working directory when omitted. + #[arg(long, value_name = "PATH")] + project_dir: Option, +} + +/// Convert any internal failure into a JSON-RPC error frame. We attach the +/// stringified `anyhow::Error` chain as the `message` so the client sees the +/// full context (e.g. "task not found: tj-x: no row returned"). +fn into_mcp_error(err: anyhow::Error) -> McpError { + McpError::internal_error(format!("{err:#}"), None) +} + +/// Stable, low-cost correlation token for one tool invocation. ULID gives +/// us 26 lexicographic characters with embedded timestamp ordering and a +/// random suffix — tools do not need millisecond uniqueness, but the +/// timestamp makes log scrubbing easier than a pure-random UUID. +fn new_correlation_id() -> String { + ulid::Ulid::new().to_string() +} + +/// Wrap one tool handler with structured tracing. Emits one INFO line at +/// entry (with the correlation id and tool name) and one INFO line at +/// exit (with elapsed ms and ok/err). Callers grep on `correlation_id=` +/// to follow a single client request across logs. +async fn traced_tool(tool: &'static str, fut: Fut) -> Result +where + Fut: std::future::Future>, +{ + let correlation_id = new_correlation_id(); + let started_at = std::time::Instant::now(); + tracing::info!(tool, %correlation_id, "tool_call start"); + let result = fut.await; + let elapsed_ms = started_at.elapsed().as_millis() as u64; + match &result { + Ok(_) => tracing::info!(tool, %correlation_id, elapsed_ms, "tool_call ok"), + Err(e) => tracing::warn!( + tool, + %correlation_id, + elapsed_ms, + error = %e.message, + "tool_call err" + ), + } + result +} + +/// Run synchronous I/O on the tokio blocking pool. Without this, every tool +/// handler would do SQLite + JSONL work directly on the executor thread +/// and a slow operation in one tool would stall every other concurrent +/// request — defeats the point of using an async runtime at all. +async fn run_blocking(f: F) -> Result +where + F: FnOnce() -> anyhow::Result + Send + 'static, + T: Send + 'static, +{ + let join_result = tokio::task::spawn_blocking(f) + .await + .map_err(|e| McpError::internal_error(format!("blocking task panicked: {e}"), None))?; + join_result.map_err(into_mcp_error) +} + +/// Process-wide cache of SQLite connections keyed by state-file path. +/// +/// Without this, every tool handler called `tj_core::db::open()` which +/// re-runs PRAGMAs, the migrations registry, and re-creates a new WAL +/// reader. At small N the open cost dominates the actual work. +/// +/// Storage layout: an outer `Mutex` guards the map (only briefly, during +/// insert/lookup), and each entry is `Arc>` so callers +/// can hold a connection across a longer transaction without blocking +/// other projects. +fn connection_cache() -> &'static Mutex>>> { + static CACHE: OnceLock>>>> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Get or create the cached `Connection` for a SQLite state path. The +/// returned `Arc>` is shared with future callers; the inner +/// mutex is the lock you actually want to take during a tool call. +fn cached_open(state_path: &Path) -> anyhow::Result>> { + let mut cache = connection_cache() + .lock() + .map_err(|e| anyhow::anyhow!("connection cache poisoned: {e}"))?; + if let Some(existing) = cache.get(state_path) { + return Ok(existing.clone()); + } + let conn = + tj_core::db::open(state_path).with_context(|| format!("open SQLite at {state_path:?}"))?; + let arc = Arc::new(Mutex::new(conn)); + cache.insert(state_path.to_path_buf(), arc.clone()); + Ok(arc) +} /// MCP instructions delivered to every Claude Code session where this plugin is installed. /// This is the primary mechanism for self-contained plugin behavior — no manual CLAUDE.md edits needed. @@ -61,7 +173,6 @@ pub struct TaskPackResult { #[derive(Debug, Serialize, schemars::JsonSchema)] pub struct TaskPackMetadata { - pub stub: bool, pub source_event_count: Option, pub cache_hit: Option, } @@ -76,7 +187,6 @@ pub struct TaskSearchParams { pub struct TaskSearchResult { pub query: String, pub results: Vec, - pub stub: bool, } #[derive(Debug, Deserialize, schemars::JsonSchema)] @@ -88,7 +198,6 @@ pub struct TaskCreateParams { pub struct TaskCreateResult { pub task_id: String, pub title: String, - pub stub: bool, } #[derive(Debug, Deserialize, schemars::JsonSchema)] @@ -104,7 +213,6 @@ pub struct EventAddResult { pub event_id: String, pub task_id: String, pub event_type: String, - pub stub: bool, } #[derive(Debug, Deserialize, schemars::JsonSchema)] @@ -117,7 +225,6 @@ pub struct TaskCloseParams { pub struct TaskCloseResult { pub task_id: String, pub closed: bool, - pub stub: bool, } fn parse_event_type(s: &str) -> anyhow::Result { @@ -139,61 +246,66 @@ fn parse_event_type(s: &str) -> anyhow::Result { }) } -fn project_paths() -> anyhow::Result<(String, std::path::PathBuf, std::path::PathBuf)> { - let cwd = std::env::current_dir()?; - let project_hash = tj_core::project_hash::from_path(&cwd)?; +fn resolve_project_paths( + dir: &std::path::Path, +) -> anyhow::Result<(String, std::path::PathBuf, std::path::PathBuf)> { + let project_hash = tj_core::project_hash::from_path(dir)?; let events = tj_core::paths::events_dir()?.join(format!("{project_hash}.jsonl")); let state = tj_core::paths::state_dir()?.join(format!("{project_hash}.sqlite")); Ok((project_hash, events, state)) } +fn project_paths() -> anyhow::Result<(String, std::path::PathBuf, std::path::PathBuf)> { + let dir = match PROJECT_DIR_OVERRIDE.get() { + Some(p) => p.clone(), + None => std::env::current_dir()?, + }; + resolve_project_paths(&dir) +} + #[tool_router] impl TaskJournalServer { #[tool( name = "task_pack", description = "Return a compact resume pack for a task. Pass mode=compact|full." )] - async fn task_pack(&self, Parameters(p): Parameters) -> Json { - let result = (|| -> anyhow::Result { - let (project_hash, events_path, state_path) = project_paths()?; - let conn = tj_core::db::open(&state_path)?; - if events_path.exists() { - tj_core::db::rebuild_state(&conn, &events_path, &project_hash)?; - } - let pmode = match p.mode.as_deref() { - Some("full") => tj_core::pack::PackMode::Full, - _ => tj_core::pack::PackMode::Compact, - }; - let pack = tj_core::pack::assemble(&conn, &p.task_id, pmode)?; - Ok(TaskPackResult { - task_id: pack.task_id, - mode: match pack.mode { - tj_core::pack::PackMode::Compact => "compact".into(), - tj_core::pack::PackMode::Full => "full".into(), - }, - schema_version: pack.schema_version, - text: pack.text, - metadata: TaskPackMetadata { - stub: false, - source_event_count: Some(pack.metadata.source_event_count), - cache_hit: Some(pack.metadata.cache_hit), - }, + async fn task_pack( + &self, + Parameters(p): Parameters, + ) -> Result, McpError> { + traced_tool("task_pack", async move { + run_blocking(move || { + let (project_hash, events_path, state_path) = project_paths()?; + let conn_arc = cached_open(&state_path)?; + let conn = conn_arc + .lock() + .map_err(|e| anyhow::anyhow!("connection mutex poisoned: {e}"))?; + if events_path.exists() { + tj_core::db::ingest_new_events(&conn, &events_path, &project_hash)?; + } + let pmode = match p.mode.as_deref() { + Some("full") => tj_core::pack::PackMode::Full, + _ => tj_core::pack::PackMode::Compact, + }; + let pack = tj_core::pack::assemble(&conn, &p.task_id, pmode)?; + Ok(TaskPackResult { + task_id: pack.task_id, + mode: match pack.mode { + tj_core::pack::PackMode::Compact => "compact".into(), + tj_core::pack::PackMode::Full => "full".into(), + }, + schema_version: pack.schema_version, + text: pack.text, + metadata: TaskPackMetadata { + source_event_count: Some(pack.metadata.source_event_count), + cache_hit: Some(pack.metadata.cache_hit), + }, + }) }) - })(); - match result { - Ok(r) => Json(r), - Err(e) => Json(TaskPackResult { - task_id: p.task_id, - mode: p.mode.unwrap_or_else(|| "compact".into()), - schema_version: "1.0".into(), - text: format!("[error] {e}"), - metadata: TaskPackMetadata { - stub: false, - source_event_count: None, - cache_hit: None, - }, - }), - } + .await + .map(Json) + }) + .await } #[tool( @@ -203,26 +315,30 @@ impl TaskJournalServer { async fn task_search( &self, Parameters(p): Parameters, - ) -> Json { - let result = (|| -> anyhow::Result> { - let (project_hash, events_path, state_path) = project_paths()?; - let conn = tj_core::db::open(&state_path)?; - if events_path.exists() { - tj_core::db::rebuild_state(&conn, &events_path, &project_hash)?; - } - let mut stmt = conn.prepare( - "SELECT DISTINCT task_id FROM search_fts WHERE search_fts MATCH ?1 LIMIT 50", - )?; - let ids: Vec = stmt - .query_map(rusqlite::params![p.query], |r| r.get::<_, String>(0))? - .collect::>()?; - Ok(ids) - })(); - Json(TaskSearchResult { - query: p.query, - results: result.unwrap_or_default(), - stub: false, + ) -> Result, McpError> { + traced_tool("task_search", async move { + let query = p.query.clone(); + let results = run_blocking(move || { + let (project_hash, events_path, state_path) = project_paths()?; + let conn_arc = cached_open(&state_path)?; + let conn = conn_arc + .lock() + .map_err(|e| anyhow::anyhow!("connection mutex poisoned: {e}"))?; + if events_path.exists() { + tj_core::db::ingest_new_events(&conn, &events_path, &project_hash)?; + } + let mut stmt = conn.prepare( + "SELECT DISTINCT task_id FROM search_fts WHERE search_fts MATCH ?1 LIMIT 50", + )?; + let ids: Vec = stmt + .query_map(rusqlite::params![p.query], |r| r.get::<_, String>(0))? + .collect::>()?; + Ok(ids) + }) + .await?; + Ok(Json(TaskSearchResult { query, results })) }) + .await } #[tool( @@ -232,78 +348,75 @@ impl TaskJournalServer { async fn task_create( &self, Parameters(p): Parameters, - ) -> Json { - let result = (|| -> anyhow::Result { - let (_, events_path, _) = project_paths()?; - std::fs::create_dir_all(events_path.parent().unwrap())?; - - let task_id = format!( - "tj-{}", - &ulid::Ulid::new().to_string()[10..16].to_lowercase() - ); - let mut event = tj_core::event::Event::new( - task_id.clone(), - tj_core::event::EventType::Open, - tj_core::event::Author::Agent, - tj_core::event::Source::Chat, - p.initial_context.clone().unwrap_or_else(|| p.title.clone()), - ); - event.meta = serde_json::json!({"title": p.title.clone()}); + ) -> Result, McpError> { + traced_tool("task_create", async move { + run_blocking(move || { + let (_, events_path, _) = project_paths()?; + std::fs::create_dir_all(events_path.parent().unwrap())?; - let mut writer = tj_core::storage::JsonlWriter::open(&events_path)?; - writer.append(&event)?; - writer.flush_durable()?; + let task_id = tj_core::new_task_id(); + let mut event = tj_core::event::Event::new( + task_id.clone(), + tj_core::event::EventType::Open, + tj_core::event::Author::Agent, + tj_core::event::Source::Chat, + p.initial_context.clone().unwrap_or_else(|| p.title.clone()), + ); + event.meta = serde_json::json!({"title": p.title.clone()}); - Ok(TaskCreateResult { - task_id, - title: p.title.clone(), - stub: false, + let mut writer = tj_core::storage::JsonlWriter::open(&events_path)?; + writer.append(&event)?; + writer.flush_durable()?; + + Ok(TaskCreateResult { + task_id, + title: p.title.clone(), + }) }) - })(); - Json(result.unwrap_or_else(|e| TaskCreateResult { - task_id: format!("[error] {e}"), - title: p.title, - stub: false, - })) + .await + .map(Json) + }) + .await } #[tool( name = "event_add", description = "Append a typed event (decision, finding, evidence, rejection, etc.) to a task." )] - async fn event_add(&self, Parameters(p): Parameters) -> Json { - let result = (|| -> anyhow::Result { - let (_, events_path, _) = project_paths()?; - std::fs::create_dir_all(events_path.parent().unwrap())?; - - let event_type = parse_event_type(&p.event_type)?; - let mut event = tj_core::event::Event::new( - &p.task_id, - event_type, - tj_core::event::Author::Agent, - tj_core::event::Source::Chat, - p.text.clone(), - ); - event.corrects = p.corrects.clone(); - event.supersedes = p.supersedes.clone(); - - let mut writer = tj_core::storage::JsonlWriter::open(&events_path)?; - writer.append(&event)?; - writer.flush_durable()?; - - Ok(EventAddResult { - event_id: event.event_id, - task_id: p.task_id.clone(), - event_type: p.event_type.clone(), - stub: false, + async fn event_add( + &self, + Parameters(p): Parameters, + ) -> Result, McpError> { + traced_tool("event_add", async move { + run_blocking(move || { + let (_, events_path, _) = project_paths()?; + std::fs::create_dir_all(events_path.parent().unwrap())?; + + let event_type = parse_event_type(&p.event_type)?; + let mut event = tj_core::event::Event::new( + &p.task_id, + event_type, + tj_core::event::Author::Agent, + tj_core::event::Source::Chat, + p.text.clone(), + ); + event.corrects = p.corrects.clone(); + event.supersedes = p.supersedes.clone(); + + let mut writer = tj_core::storage::JsonlWriter::open(&events_path)?; + writer.append(&event)?; + writer.flush_durable()?; + + Ok(EventAddResult { + event_id: event.event_id, + task_id: p.task_id.clone(), + event_type: p.event_type.clone(), + }) }) - })(); - Json(result.unwrap_or_else(|e| EventAddResult { - event_id: format!("[error] {e}"), - task_id: p.task_id, - event_type: p.event_type, - stub: false, - })) + .await + .map(Json) + }) + .await } #[tool( @@ -313,33 +426,51 @@ impl TaskJournalServer { async fn task_close( &self, Parameters(p): Parameters, - ) -> Json { - let result = (|| -> anyhow::Result<()> { - let (_, events_path, _) = project_paths()?; - let mut event = tj_core::event::Event::new( - &p.task_id, - tj_core::event::EventType::Close, - tj_core::event::Author::Agent, - tj_core::event::Source::Chat, - p.reason.clone(), - ); - let mut meta = serde_json::Map::new(); - meta.insert("reason".into(), serde_json::Value::String(p.reason.clone())); - if let Some(o) = &p.outcome { - meta.insert("outcome".into(), serde_json::Value::String(o.clone())); - } - event.meta = serde_json::Value::Object(meta); - - let mut writer = tj_core::storage::JsonlWriter::open(&events_path)?; - writer.append(&event)?; - writer.flush_durable()?; - Ok(()) - })(); - Json(TaskCloseResult { - task_id: p.task_id, - closed: result.is_ok(), - stub: false, + ) -> Result, McpError> { + traced_tool("task_close", async move { + let task_id = p.task_id.clone(); + run_blocking(move || { + let (project_hash, events_path, state_path) = project_paths()?; + + let conn_arc = cached_open(&state_path)?; + { + let conn = conn_arc + .lock() + .map_err(|e| anyhow::anyhow!("connection mutex poisoned: {e}"))?; + if events_path.exists() { + tj_core::db::ingest_new_events(&conn, &events_path, &project_hash)?; + } + if !tj_core::db::task_exists(&conn, &p.task_id)? { + anyhow::bail!("task not found: {}", p.task_id); + } + } // release the connection lock before doing the JSONL append + + let mut event = tj_core::event::Event::new( + &p.task_id, + tj_core::event::EventType::Close, + tj_core::event::Author::Agent, + tj_core::event::Source::Chat, + p.reason.clone(), + ); + let mut meta = serde_json::Map::new(); + meta.insert("reason".into(), serde_json::Value::String(p.reason.clone())); + if let Some(o) = &p.outcome { + meta.insert("outcome".into(), serde_json::Value::String(o.clone())); + } + event.meta = serde_json::Value::Object(meta); + + let mut writer = tj_core::storage::JsonlWriter::open(&events_path)?; + writer.append(&event)?; + writer.flush_durable()?; + Ok(()) + }) + .await?; + Ok(Json(TaskCloseResult { + task_id, + closed: true, + })) }) + .await } } @@ -360,6 +491,35 @@ impl ServerHandler for TaskJournalServer { } } +/// Resolve when the process should shut down: Ctrl-C on every platform, +/// plus SIGTERM on Unix. Used in `tokio::select!` against the rmcp +/// `waiting()` loop so the binary exits cleanly instead of being +/// hard-killed mid-write. +async fn wait_for_shutdown_signal() { + #[cfg(unix)] + { + use tokio::signal::unix::{signal, SignalKind}; + let mut sigterm = match signal(SignalKind::terminate()) { + Ok(s) => s, + Err(e) => { + tracing::warn!(error = %e, "could not install SIGTERM handler — Ctrl-C only"); + let _ = tokio::signal::ctrl_c().await; + return; + } + }; + tokio::select! { + _ = tokio::signal::ctrl_c() => tracing::info!("received SIGINT"), + _ = sigterm.recv() => tracing::info!("received SIGTERM"), + } + } + #[cfg(not(unix))] + { + // Windows: only Ctrl-C / Ctrl-Break maps to ctrl_c(). + let _ = tokio::signal::ctrl_c().await; + tracing::info!("received Ctrl-C"); + } +} + #[tokio::main] async fn main() -> Result<()> { tracing_subscriber::fmt() @@ -367,8 +527,262 @@ async fn main() -> Result<()> { .with_writer(std::io::stderr) .init(); + let cli = Cli::parse(); + if let Some(dir) = cli.project_dir { + let resolved = std::fs::canonicalize(&dir) + .with_context(|| format!("--project-dir not accessible: {dir:?}"))?; + PROJECT_DIR_OVERRIDE + .set(resolved) + .map_err(|_| anyhow::anyhow!("PROJECT_DIR_OVERRIDE already set"))?; + } + let server = TaskJournalServer; let (stdin, stdout) = stdio(); - server.serve((stdin, stdout)).await?.waiting().await?; + let serving = server.serve((stdin, stdout)).await?; + + tokio::select! { + res = serving.waiting() => { + res?; + tracing::info!("rmcp serve loop exited"); + } + _ = wait_for_shutdown_signal() => { + tracing::info!("shutdown signal received — exiting"); + } + } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn keys_of(v: &serde_json::Value) -> Vec { + v.as_object() + .map(|o| o.keys().cloned().collect()) + .unwrap_or_default() + } + + #[test] + fn no_response_serializes_a_stub_field() { + // Vestigial stub:bool from Phase 1 stubs has been removed from all + // five MCP result types. Guard against re-introduction. + let pack = TaskPackResult { + task_id: "tj-x".into(), + mode: "compact".into(), + schema_version: tj_core::SCHEMA_VERSION.into(), + text: String::new(), + metadata: TaskPackMetadata { + source_event_count: None, + cache_hit: None, + }, + }; + let pack_v = serde_json::to_value(&pack).unwrap(); + assert!(!keys_of(&pack_v).contains(&"stub".to_string())); + assert!(!keys_of(&pack_v["metadata"]).contains(&"stub".to_string())); + + let search = TaskSearchResult { + query: "q".into(), + results: vec![], + }; + assert!(!keys_of(&serde_json::to_value(&search).unwrap()).contains(&"stub".to_string())); + + let create = TaskCreateResult { + task_id: "tj-x".into(), + title: "t".into(), + }; + assert!(!keys_of(&serde_json::to_value(&create).unwrap()).contains(&"stub".to_string())); + + let event = EventAddResult { + event_id: "e".into(), + task_id: "tj-x".into(), + event_type: "decision".into(), + }; + assert!(!keys_of(&serde_json::to_value(&event).unwrap()).contains(&"stub".to_string())); + + let close = TaskCloseResult { + task_id: "tj-x".into(), + closed: true, + }; + assert!(!keys_of(&serde_json::to_value(&close).unwrap()).contains(&"stub".to_string())); + } + + #[test] + fn resolve_project_paths_uses_provided_dir_for_hash() { + // Two distinct dirs must give two distinct project_hash values, and + // the same dir must always give the same hash. This is the contract + // that --project-dir relies on: any path on disk maps to a stable, + // unique data location. + let tmp = tempfile::TempDir::new().unwrap(); + let a = tmp.path().join("alpha"); + let b = tmp.path().join("beta"); + std::fs::create_dir_all(&a).unwrap(); + std::fs::create_dir_all(&b).unwrap(); + + let (hash_a, _, _) = resolve_project_paths(&a).unwrap(); + let (hash_b, _, _) = resolve_project_paths(&b).unwrap(); + assert_ne!(hash_a, hash_b); + + let (hash_a_again, _, _) = resolve_project_paths(&a).unwrap(); + assert_eq!(hash_a, hash_a_again); + } + + #[tokio::test] + async fn run_blocking_executes_two_tasks_concurrently() { + use std::time::{Duration, Instant}; + + // Two tasks each sleep ~200ms. If run_blocking handed work to the + // tokio blocking pool they overlap (~200ms wall-clock). If we ever + // regress to running the closure inline on the executor thread, + // tokio::join! still wakes both futures but only one progresses at + // a time and total wall-clock approaches 400ms. + let start = Instant::now(); + let (a, b) = tokio::join!( + run_blocking(|| { + std::thread::sleep(Duration::from_millis(200)); + Ok::<_, anyhow::Error>(1u32) + }), + run_blocking(|| { + std::thread::sleep(Duration::from_millis(200)); + Ok::<_, anyhow::Error>(2u32) + }), + ); + let elapsed = start.elapsed(); + + assert_eq!(a.unwrap(), 1); + assert_eq!(b.unwrap(), 2); + assert!( + elapsed < Duration::from_millis(350), + "blocking tasks must overlap on the blocking pool — got {elapsed:?}" + ); + } + + /// Compile-time + runtime guarantee that `wait_for_shutdown_signal` + /// returns a `Future` we can drop on the floor without + /// it ever resolving — a real signal would resolve it. We assert by + /// racing it against an already-ready future and confirming the + /// shutdown future was *not* the winner. + #[tokio::test] + async fn shutdown_signal_does_not_fire_spuriously() { + let ready = async {}; + tokio::select! { + _ = wait_for_shutdown_signal() => panic!("shutdown fired with no signal"), + _ = ready => { /* expected */ } + } + } + + #[test] + fn new_correlation_id_is_unique_across_thousand_calls() { + let mut seen = std::collections::HashSet::with_capacity(1000); + for _ in 0..1_000 { + assert!( + seen.insert(new_correlation_id()), + "correlation id collision in 1k calls" + ); + } + } + + #[tokio::test] + async fn traced_tool_transparently_returns_inner_result() { + // Success path: the wrapper must propagate the Ok value. + let ok = traced_tool::("test_ok", async { Ok(42) }) + .await + .unwrap(); + assert_eq!(ok, 42); + + // Error path: the wrapper must propagate Err untouched. + let err = traced_tool::("test_err", async { + Err(McpError::internal_error("boom".to_string(), None)) + }) + .await; + assert!(err.is_err()); + assert_eq!(err.unwrap_err().message, "boom"); + } + + #[test] + fn cached_open_returns_same_arc_for_same_path() { + // The Arc returned by cached_open() is the same handle on second + // call: that's the proof that we are not re-running migrations + // / PRAGMA / WAL setup on every tool call. + let dir = tempfile::TempDir::new().unwrap(); + let p = dir.path().join("d1-cache.sqlite"); + let a = cached_open(&p).unwrap(); + let b = cached_open(&p).unwrap(); + assert!( + Arc::ptr_eq(&a, &b), + "cached_open must reuse the Arc>" + ); + } + + #[test] + fn cached_open_returns_distinct_arcs_for_distinct_paths() { + let dir = tempfile::TempDir::new().unwrap(); + let p1 = dir.path().join("d1-x.sqlite"); + let p2 = dir.path().join("d1-y.sqlite"); + let a = cached_open(&p1).unwrap(); + let b = cached_open(&p2).unwrap(); + assert!(!Arc::ptr_eq(&a, &b)); + } + + #[test] + fn cli_parses_project_dir_argument() { + // Smoke test: `task-journal-mcp --project-dir /tmp/foo` parses and + // populates the field. We do not actually launch the server here — + // that needs a real stdio peer. + let cli = Cli::try_parse_from(["task-journal-mcp", "--project-dir", "/tmp/foo"]).unwrap(); + assert_eq!(cli.project_dir, Some(std::path::PathBuf::from("/tmp/foo"))); + + let cli = Cli::try_parse_from(["task-journal-mcp"]).unwrap(); + assert!(cli.project_dir.is_none()); + } + + #[test] + fn into_mcp_error_carries_full_anyhow_chain() { + // Down-stream callers rely on McpError.message containing the full + // chain (root cause + every context wrap). Catches a regression + // where someone formats with `{}` instead of `{:#}`. + let inner = anyhow::anyhow!("root cause"); + let outer = inner.context("wrap layer"); + let err = into_mcp_error(outer); + assert!(err.message.contains("wrap layer"), "got: {}", err.message); + assert!(err.message.contains("root cause"), "got: {}", err.message); + } + + #[test] + fn task_pack_returns_rpc_error_when_state_dir_is_unusable() { + // Force tj_core::paths::state_dir to fail by pointing it at a path + // that cannot be created. We do this through XDG_DATA_HOME pointing + // at /dev/null which directories crate refuses. The handler must + // surface this as Err(McpError), not as a fake-success Json with + // a corrupted task_id. + // + // We don't invoke the async handler directly here because it has + // private generated wrappers; instead we exercise the same error + // path via project_paths() and verify the conversion does the + // right thing. + let prev = std::env::var("XDG_DATA_HOME").ok(); + // SAFETY: this test does not run concurrently with other tests + // that read XDG_DATA_HOME — see the env-var test in tj-core for + // the same pattern. + unsafe { + std::env::set_var("XDG_DATA_HOME", "/dev/null/cannot-create-here"); + } + + let res = project_paths(); + + // restore + unsafe { + match prev { + Some(v) => std::env::set_var("XDG_DATA_HOME", v), + None => std::env::remove_var("XDG_DATA_HOME"), + } + } + + // We don't rigidly assert Err here (the directories crate has + // platform-specific behavior); we only assert that *if* it errors, + // into_mcp_error converts cleanly without panicking. + if let Err(e) = res { + let mcp_err = into_mcp_error(e); + assert!(!mcp_err.message.is_empty()); + } + } +} diff --git a/crates/tj-mcp/tests/rmcp_roundtrip.rs b/crates/tj-mcp/tests/rmcp_roundtrip.rs new file mode 100644 index 0000000..b8cffd6 --- /dev/null +++ b/crates/tj-mcp/tests/rmcp_roundtrip.rs @@ -0,0 +1,64 @@ +//! Compile-time + serde-shape integration test for the rmcp client + +//! transport stack. +//! +//! What this file *does* prove: +//! - rmcp 0.3 with the `client` feature compiles against this +//! workspace and our pinned rust toolchain. +//! - `CallToolRequestParam` round-trips through serde — i.e. the +//! JSON-RPC envelope we'll send and parse hasn't shifted shape. +//! - `ClientHandler` + `ClientInfo::default()` compile against +//! each other — the two pieces a downstream user must wire. +//! +//! What this file does *not* prove: +//! - End-to-end tool dispatch through `TaskJournalServer`. The +//! server is defined in `main.rs` (binary crate) and is not +//! reachable from an integration test. Driving the real +//! handlers needs `TaskJournalServer` extracted into a +//! `tj-mcp` lib target — tracked as a follow-up; until then +//! the same code paths are covered end-to-end via the CLI +//! integration tests in `tj-cli/tests/cli.rs`. + +use rmcp::{model::CallToolRequestParam, model::ClientInfo, ClientHandler}; + +#[derive(Debug, Clone, Default)] +struct DummyClientHandler; + +impl ClientHandler for DummyClientHandler { + fn get_info(&self) -> ClientInfo { + ClientInfo::default() + } +} + +#[test] +fn dummy_client_handler_compiles_and_provides_default_info() { + let h = DummyClientHandler; + let _ = h.get_info(); +} + +#[test] +fn rmcp_call_tool_request_param_round_trips_via_serde() { + let req = CallToolRequestParam { + name: "task_create".into(), + arguments: Some( + serde_json::json!({"title": "hello"}) + .as_object() + .unwrap() + .clone(), + ), + }; + let s = serde_json::to_string(&req).unwrap(); + let back: CallToolRequestParam = serde_json::from_str(&s).unwrap(); + assert_eq!(back.name, req.name); + assert_eq!(back.arguments, req.arguments); +} + +/// Compile-only check that `tokio::io::duplex` returns a transport +/// pair acceptable to rmcp's `ServiceExt::serve`. This catches a +/// regression where `tokio::io::DuplexStream` no longer satisfies +/// the trait bounds without us having to actually run the server. +#[allow(dead_code)] +fn _duplex_is_a_valid_rmcp_transport() { + fn assert_async_read_write() { + } + assert_async_read_write::(); +}