merge: sync orchestration before queue 00001KVWSQM22
This commit is contained in:
commit
5fa480904d
|
|
@ -0,0 +1 @@
|
|||
{"id":"orch-plan-20260624-120242-1","ticket_id":"00001KVWPVHFJ","kind":"accepted_plan","accepted_plan":{"summary":"Ticket `00001KVWPVHFJ` は implementation_ready。専用 worktree `/home/hare/Projects/yoi/.worktree/00001KVWPVHFJ-storage-cleanup-cli` と branch `work/00001KVWPVHFJ-storage-cleanup-cli` で、safe Pod/session storage cleanup CLI を実装する。destructive operations は dry-run/force/live refusal/path-safety を厳守する。","branch":"work/00001KVWPVHFJ-storage-cleanup-cli","worktree":"/home/hare/Projects/yoi/.worktree/00001KVWPVHFJ-storage-cleanup-cli","role_plan":"Orchestrator: routing acceptance, worktree creation, final integration/validation/cleanup. Coder: CLI/storage implementation in dedicated child worktree. Reviewer: read-only review focusing on path safety, live Pod refusal, dry-run/force semantics, and session history preservation."},"author":"yoi-orchestrator","at":"2026-06-24T12:02:42Z"}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
---
|
||||
title: 'Pod/session storage cleanup CLI を追加する'
|
||||
state: 'queued'
|
||||
state: 'done'
|
||||
created_at: '2026-06-24T11:39:41Z'
|
||||
updated_at: '2026-06-24T12:01:42Z'
|
||||
updated_at: '2026-06-24T12:36:12Z'
|
||||
assignee: null
|
||||
readiness: 'implementation_ready'
|
||||
risk_flags: ['pod-lifecycle', 'persistence', 'destructive-operation', 'cli-ux', 'session-history', 'authority-boundary']
|
||||
|
|
|
|||
|
|
@ -13,4 +13,204 @@ LocalTicketBackend によって作成されました。
|
|||
Ticket を `workspace-panel` が queued にしました。
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: decision author: yoi-orchestrator at: 2026-06-24T12:03:42Z -->
|
||||
|
||||
## Decision
|
||||
|
||||
Routing decision: implementation_ready
|
||||
|
||||
Reason:
|
||||
- Dashboard Queue により人間が Orchestrator routing を許可した queued Ticket として確認した。
|
||||
- Ticket body は `yoi pod delete`, `yoi pod prune`, `yoi session prune --unreferenced` の command spelling、dry-run/force semantics、live Pod refusal、session history preservation、explicit age threshold、validation を具体的に列挙している。
|
||||
- `TicketRelationQuery` は blocking relation 0 件、`TicketOrchestrationPlanQuery` は既存 plan 0 件だった。
|
||||
- risk flags は pod-lifecycle / persistence / destructive-operation / cli-ux / session-history / authority-boundary だが、destructive operations の safety rails と escalation conditions が明記されている。risk は reviewer focus として扱えばよく、planning return 理由にはならない。
|
||||
- 同時 queued Ticket `00001KVWPW3KX` は TUI Console rendering で code surface が別。conflict risk は低く、別 worktree/branch で並列開始可能。
|
||||
|
||||
Evidence checked:
|
||||
- Ticket body / thread: `item.md`, `thread.md`。thread は create と ready->queued のみで未解決 blocker は記録されていない。
|
||||
- Relations / orchestration plan: relation 0 件、routing 前 plan 0 件。accepted plan `orch-plan-20260624-120242-1` を記録済み。
|
||||
- Code map: Grep で `crates/yoi/src/main.rs`, `crates/yoi/src/session_cli.rs`, `crates/pod-store/src/lib.rs`, `crates/session-store`, `crates/pod/src/entrypoint.rs`, `crates/pod/src/discovery.rs` 周辺を確認。
|
||||
- Workspace state: `/home/hare/Projects/yoi/.worktree/orchestration` は clean。active inprogress Ticket は 0 件。
|
||||
|
||||
IntentPacket:
|
||||
|
||||
Intent:
|
||||
- Pod/session storage を手動削除せずに安全に整理できる公式 CLI を追加し、Pod metadata delete / Pod prune / unreferenced session prune を dry-run-first、force-required、live-safe に実装する。
|
||||
|
||||
Binding decisions / invariants:
|
||||
- No silent restore bypass。same-name fresh start は、ユーザーが明示的に stopped/restorable Pod metadata を削除した結果としてのみ発生する。
|
||||
- `pod delete` は session logs/history を削除しない。
|
||||
- live/reachable Pod metadata は削除しない。live 判定が不確実なら安全側に拒否する。
|
||||
- old cleanup に暗黙 threshold を持たせない。`--older-than` など明示 criteria が必要。
|
||||
- destructive deletion は `--force` 必須。`--dry-run` / default report を重視する。
|
||||
- Pod metadata authority は `pod-store`、session log authority は `session-store` のまま。
|
||||
- legacy top-level resume flags / bare Pod-name inference は再導入しない。
|
||||
- Panel/TUI の broad Pod manager 化は non-goal。
|
||||
|
||||
Requirements / acceptance criteria:
|
||||
- `yoi pod delete <NAME> [--force] [--dry-run]` で stopped/restorable Pod metadata を削除できる。
|
||||
- live/reachable Pod delete/prune は拒否され理由を出す。
|
||||
- `yoi pod prune --older-than <DURATION> [--force] [--dry-run]` は explicit threshold なしに old 判定削除しない。
|
||||
- `yoi session prune --unreferenced [--older-than <DURATION>] [--force] [--dry-run]` は Pod metadata active pointer から参照されない session/segment を report/prune できる。
|
||||
- delete/prune output は deleted/would delete/kept/refused reason を bounded に示す。
|
||||
- focused tests が stopped Pod delete, live refusal, session preservation, unreferenced prune dry-run/force, threshold requirement, CLI parsing/help を cover する。
|
||||
|
||||
Implementation latitude:
|
||||
- product CLI 側で management subcommands を捕捉するか、runtime entrypoint 側に安全に追加するかは coder が code map を見て判断してよい。
|
||||
- 必要なら shared cleanup module や `session-store` delete API を追加してよい。path safety tests を伴うこと。
|
||||
- Orphan detection は初期実装では active `PodMetadata.active.session_id` references を authority としてよい。lineage-aware retention は referenced sessions を削除しない限り follow-up に分けてよい。
|
||||
- Output は human-readable でよい。JSON は自然なら追加してよいが必須ではない。
|
||||
|
||||
Escalate if:
|
||||
- live Pod detection を安全に拒否できるほど reliable にできない。
|
||||
- orphan detection が session lineage semantics の変更を必要とする。
|
||||
- Pod delete の副作用として sessions を削除する必要が出る。
|
||||
- storage migration / compatibility fallback が必要になる。
|
||||
- command design が existing `yoi pod` runtime entrypoint usage と衝突する。
|
||||
- cleanup が Panel role-session/Ticket claims, worktrees, branches, Ticket state を mutate しようとする。
|
||||
|
||||
Validation:
|
||||
- `cargo fmt --check`
|
||||
- focused `cargo test` for `yoi`, `pod-store`, `session-store`, affected Pod/discovery code
|
||||
- `cargo check -p yoi -p pod -p pod-store -p session-store`
|
||||
- `target/debug/yoi ticket doctor` または `yoi ticket doctor`
|
||||
- `git diff --check`
|
||||
|
||||
Current code map:
|
||||
- Primary: `crates/yoi/src/main.rs`, `crates/yoi/src/session_cli.rs`, `crates/pod-store/src/lib.rs`, `crates/session-store/src/fs_store.rs`, `crates/session-store/src/lib.rs`, `crates/pod/src/entrypoint.rs`, `crates/pod/src/discovery.rs`。
|
||||
- Avoid: Panel/TUI manager UI, scheduler/stop semantics changes, Ticket/worktree/branch cleanup operations。
|
||||
|
||||
Critical risks / reviewer focus:
|
||||
- accidental deletion of session history from `pod delete`。
|
||||
- live/reachable Pod metadata deletion。
|
||||
- unsafe path deletion or broad directory removal。
|
||||
- force/dry-run semantics bypass。
|
||||
- ambiguous age parsing/default threshold。
|
||||
- breaking `yoi pod` runtime entrypoint spawn/restore behavior。
|
||||
|
||||
Next action:
|
||||
- `queued -> inprogress` を記録してから worktree-workflow で dedicated worktree を作成し、Coder/Reviewer sibling loop に進める。
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-24T12:04:07Z from: queued to: inprogress reason: orchestrator_acceptance_unblocked field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
Orchestrator acceptance: queued -> inprogress
|
||||
|
||||
- 直前確認で `TicketShow` は state `queued`、blocking relation は 0 件、accepted plan `orch-plan-20260624-120242-1` を確認した。
|
||||
- 同時 queued Ticket `00001KVWPW3KX` は disjoint code surface のため別 worktree/branch で並列開始可能と判断した。
|
||||
- routing decision と IntentPacket は Ticket thread に記録済み。
|
||||
- これ以降に worktree-workflow で `/home/hare/Projects/yoi/.worktree/00001KVWPVHFJ-storage-cleanup-cli` を作成し、multi-agent-workflow に接続する。
|
||||
|
||||
---
|
||||
|
||||
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-24T12:05:13Z -->
|
||||
|
||||
## Implementation report
|
||||
|
||||
Implementation orchestration update:
|
||||
|
||||
- worktree-workflow に従い、implementation worktree `/home/hare/Projects/yoi/.worktree/00001KVWPVHFJ-storage-cleanup-cli` と branch `work/00001KVWPVHFJ-storage-cleanup-cli` を作成した。
|
||||
- child worktree では tracked `.yoi` project records を見える状態にし、`.yoi/memory`、local/runtime/log/lock/secret-like `.yoi` paths を sparse-checkout で除外する方針にした。
|
||||
- Coder Pod `yoi-coder-00001KVWPVHFJ-cleanup-cli` を sibling として起動した。write scope は child worktree に限定し、runtime workspace root `/home/hare/Projects/yoi` は startup 要件を満たすため non-recursive read のみにした。
|
||||
- Coder には root/original workspace と orchestration worktree で read/write/validation/git operation を行わないよう明示した。
|
||||
|
||||
---
|
||||
|
||||
<!-- event: review author: yoi-reviewer-00001KVWPVHFJ-cleanup-cli at: 2026-06-24T12:30:40Z status: approve -->
|
||||
|
||||
## Review: approve
|
||||
|
||||
Review result: approve
|
||||
|
||||
確認内容:
|
||||
- child worktree `/home/hare/Projects/yoi/.worktree/00001KVWPVHFJ-storage-cleanup-cli` のみを read-only 確認。
|
||||
- Ticket 要件・acceptance criteria・IntentPacket / implementation report、`5c9331e8..HEAD` の差分、主な実装箇所 `crates/yoi/src/main.rs`, `crates/yoi/src/pod_cleanup_cli.rs`, `crates/yoi/src/session_cli.rs`, `crates/pod-store/src/lib.rs`, `crates/session-store/src/fs_store.rs` を確認。
|
||||
- read-only 指示のため validation は再実行していない。
|
||||
|
||||
承認根拠:
|
||||
- `yoi pod delete <NAME> [--force] [--dry-run]`, `yoi pod prune --older-than <DURATION> ...`, `yoi session prune --unreferenced ...` の command spelling と product CLI routing が追加されている。
|
||||
- `pod delete` は `FsPodStore::delete_by_name` 経由で Pod metadata のみを削除し、session logs/history を削除しない。出力にも preserve が明示される。
|
||||
- live/reachable Pod は Unix socket connect 成功時に拒否され、権限エラー等の uncertain liveness も fail-safe で拒否される。`--force` でも live/uncertain は override しない。
|
||||
- destructive deletion は `--force && !--dry-run` の場合のみ実行され、通常時/dry-run は report-only になる。
|
||||
- `pod prune` は `--older-than` 必須で、暗黙の old threshold を持たない。
|
||||
- `session prune --unreferenced` は Pod metadata の active `session_id` を保護集合にしており、referenced session を削除対象から外す。削除は `SessionId` ベースの session-store API 経由で、path traversal 的な広範削除には見えない。
|
||||
- Panel/TUI/scheduler や legacy resume/bare Pod-name inference の scope creep は見当たらない。
|
||||
- Coder reported validation は Ticket 指定範囲を概ね満たしており、広い Pod lib test の失敗は既存 prompt assertion と説明され、対象 discovery test は再実行成功と記録されている。
|
||||
|
||||
Non-blocking note:
|
||||
- dry-run/失敗 path でも既存 store constructor が root directory を初期作成し得る設計に見える。削除は発生しないため今回の blocker とは見なさないが、将来「dry-run は完全 no-write」と定義するなら follow-up で明確化するとよい。
|
||||
|
||||
---
|
||||
|
||||
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-24T12:36:01Z -->
|
||||
|
||||
## Implementation report
|
||||
|
||||
Integration outcome:
|
||||
|
||||
- Coder `yoi-coder-00001KVWPVHFJ-cleanup-cli` が implementation branch `work/00001KVWPVHFJ-storage-cleanup-cli` に実装を commit した。
|
||||
- implementation commit: `80d6861a feat: add pod and session cleanup CLI`
|
||||
- child ticket report commit: `439f967c chore: record cleanup cli implementation report`
|
||||
- Reviewer `yoi-reviewer-00001KVWPVHFJ-cleanup-cli` は read-only review で `approve`。Pod metadata only delete、session preservation、live/uncertain refusal、force/dry-run semantics、explicit threshold、session active reference protection、path authority、scope creep なしを確認した。
|
||||
- Orchestrator worktree `/home/hare/Projects/yoi/.worktree/orchestration` で branch `work/00001KVWPVHFJ-storage-cleanup-cli` を merge 済み。
|
||||
- merge commit: `4fb75ec3 merge: 00001KVWPVHFJ storage cleanup cli`
|
||||
- merge 時に Ticket item/thread の append conflict が発生したため、orchestration 側の Ticket record を保持して merge し、この integration outcome に実装・review・validation evidence を集約して記録した。
|
||||
- cleanup CLI 実装で Cargo dependencies / `Cargo.lock` が変わったため、Nix package cargoHash を更新した。
|
||||
- package hash commit: `28d53aad nix: update yoi cleanup cargo hash`
|
||||
|
||||
Implemented behavior:
|
||||
- `yoi pod delete <NAME> [--force] [--dry-run]`: stopped/restorable Pod metadata のみ削除。sessions/history は削除しない。live/uncertain liveness は拒否。
|
||||
- `yoi pod prune --older-than <DURATION> [--force] [--dry-run]`: explicit threshold required。Pod metadata のみ prune。
|
||||
- `yoi session prune --unreferenced [--older-than <DURATION>] [--force] [--dry-run]`: Pod metadata active session references を保護し、unreferenced session のみ対象。
|
||||
- `session-store` に session deletion / mtime support を追加。
|
||||
|
||||
Validation in Orchestrator worktree:
|
||||
- `cargo fmt --check`: success
|
||||
- `cargo test -p yoi`: success
|
||||
- `cargo test -p session-store --lib`: success
|
||||
- `cargo test -p pod-store --lib`: success
|
||||
- `cargo test -p pod discovery:: --lib`: success
|
||||
- `cargo check -p yoi -p pod -p pod-store -p session-store`: success
|
||||
- `cargo run -p yoi -- ticket doctor`: success
|
||||
- `git diff --check`: success
|
||||
- `nix build .#yoi --no-link`: success after updating `package.nix` cargoHash to `sha256-8mo2/IZMq3tfnv8fKRxJOdfb+T3NOheUmqT8TiR+Wag=`
|
||||
|
||||
Notes:
|
||||
- 初回 `nix build .#yoi --no-link` は cargoHash stale のため失敗し、hash 更新後に成功した。
|
||||
- Reviewer non-blocking note: dry-run/失敗 path でも既存 store constructor が root directory を初期作成し得る設計に見える。削除は起きないため blocker ではないが、将来「dry-run は完全 no-write」と定義するなら follow-up で明確化可能。
|
||||
|
||||
Next action:
|
||||
- Mark Ticket done after this integration/validation evidence.
|
||||
- Then stop related child Pods and remove only the child implementation worktree/branch.
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-24T12:36:12Z from: inprogress to: done reason: merged_validated field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
Implementation is merged into the orchestration branch and validated.
|
||||
|
||||
Evidence:
|
||||
- merge commit: `4fb75ec3 merge: 00001KVWPVHFJ storage cleanup cli`
|
||||
- package hash commit: `28d53aad nix: update yoi cleanup cargo hash`
|
||||
- reviewer result: approve
|
||||
- validation in `/home/hare/Projects/yoi/.worktree/orchestration` succeeded:
|
||||
- `cargo fmt --check`
|
||||
- `cargo test -p yoi`
|
||||
- `cargo test -p session-store --lib`
|
||||
- `cargo test -p pod-store --lib`
|
||||
- `cargo test -p pod discovery:: --lib`
|
||||
- `cargo check -p yoi -p pod -p pod-store -p session-store`
|
||||
- `cargo run -p yoi -- ticket doctor`
|
||||
- `git diff --check`
|
||||
- `nix build .#yoi --no-link`
|
||||
|
||||
Closure is not performed here; this state records implementation completion after merge/validation.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
{"id":"orch-plan-20260624-120242-1","ticket_id":"00001KVWPW3KX","kind":"accepted_plan","accepted_plan":{"summary":"Ticket `00001KVWPW3KX` は implementation_ready。専用 worktree `/home/hare/Projects/yoi/.worktree/00001KVWPW3KX-thinking-group` と branch `work/00001KVWPW3KX-thinking-group` で、TUI Console の render-time Thinking block aggregation を実装する。protocol/history/persistence は変更せず、TUI rendering tests と focused validation を行う。","branch":"work/00001KVWPW3KX-thinking-group","worktree":"/home/hare/Projects/yoi/.worktree/00001KVWPW3KX-thinking-group","role_plan":"Orchestrator: routing acceptance, worktree creation, final integration/validation/cleanup. Coder: TUI implementation in dedicated child worktree. Reviewer: read-only review focusing on render-only aggregation, selection behavior, no protocol/history changes."},"author":"yoi-orchestrator","at":"2026-06-24T12:02:42Z"}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
---
|
||||
title: 'TUI Console: 連続した Thinking block を一つの表示グループにまとめる'
|
||||
state: 'queued'
|
||||
state: 'done'
|
||||
created_at: '2026-06-24T11:39:59Z'
|
||||
updated_at: '2026-06-24T12:01:41Z'
|
||||
updated_at: '2026-06-24T12:20:08Z'
|
||||
assignee: null
|
||||
readiness: 'implementation_ready'
|
||||
risk_flags: ['tui-rendering', 'reasoning-display', 'block-aggregation', 'text-selection']
|
||||
|
|
|
|||
|
|
@ -29,4 +29,183 @@ LocalTicketBackend によって作成されました。
|
|||
Ticket を `workspace-panel` が queued にしました。
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: decision author: yoi-orchestrator at: 2026-06-24T12:03:09Z -->
|
||||
|
||||
## Decision
|
||||
|
||||
Routing decision: implementation_ready
|
||||
|
||||
Reason:
|
||||
- Dashboard Queue により人間が Orchestrator routing を許可した queued Ticket として確認した。
|
||||
- Ticket body は TUI Console の連続 Thinking block を render-time で一つの表示 group にまとめる目的、non-goals、binding invariants、validation を具体的に列挙している。
|
||||
- `TicketRelationQuery` は blocking relation 0 件、`TicketOrchestrationPlanQuery` は既存 plan 0 件だった。
|
||||
- risk flags は TUI rendering / reasoning-display / block-aggregation / text-selection だが、protocol / pod / persistence 変更をしない、source block sequence を保持する、turn boundary / non-Thinking block を跨がない、selection behavior を壊さない、という invariant が明示されている。risk は reviewer focus として扱えばよく、planning return 理由にはならない。
|
||||
- 同時 queued Ticket `00001KVWPVHFJ` は Pod/session storage cleanup CLI で code surface が別。conflict risk は低く、別 worktree/branch で並列開始可能。
|
||||
|
||||
Evidence checked:
|
||||
- Ticket body / thread: `item.md`, `thread.md`。thread は create、planning->ready、ready->queued のみで未解決 blocker は記録されていない。
|
||||
- Relations / orchestration plan: relation 0 件、routing 前 plan 0 件。accepted plan `orch-plan-20260624-120242-1` を記録済み。
|
||||
- Code map: Grep で `ThinkingStart` / `ThinkingDelta` / `ThinkingStop` / `thinking` 周辺を確認し、primary files `crates/tui/src/block.rs`, `crates/tui/src/app.rs`, `crates/tui/src/ui.rs`, `crates/tui/src/tool.rs` が妥当。
|
||||
- Workspace state: `/home/hare/Projects/yoi/.worktree/orchestration` は clean。active inprogress Ticket は 0 件。
|
||||
|
||||
IntentPacket:
|
||||
|
||||
Intent:
|
||||
- TUI Console の transcript rendering で、assistant turn 内に連続する `Thinking` blocks を一つの表示 group として描画し、reasoning が細切れに見える UX を改善する。
|
||||
|
||||
Binding decisions / invariants:
|
||||
- これは render-time aggregation。history / protocol / persistence / block sequence は変更しない。
|
||||
- turn boundary を跨いで group 化しない。
|
||||
- non-Thinking block を跨いで group 化しない。
|
||||
- streaming / incomplete thinking を隠さない。
|
||||
- Thinking を selectable/copyable transcript text に変える UX 変更はしない。
|
||||
- Dashboard / Panel / web UI は対象外。
|
||||
|
||||
Requirements / acceptance criteria:
|
||||
- 連続 Thinking blocks が Console 表示で一つのまとまりとして見える。
|
||||
- 既存の `Read` aggregation / tool rendering と同様に、rendering helper が consumed count 等で `compute_history` の走査を壊さない。
|
||||
- 単独 Thinking block の表示は regress しない。
|
||||
- Detail mode / collapsed mode / streaming state が破綻しない。
|
||||
- focused unit tests で consecutive Thinking, non-Thinking separator, turn boundary, incomplete/streaming case を確認する。
|
||||
|
||||
Implementation latitude:
|
||||
- `render_thinking` を単体 renderer として残し、必要なら aggregate renderer/helper を追加してよい。
|
||||
- Header 文言、elapsed 表示、detail mode の separator/index/blank line は既存 UX に沿って実装判断してよい。
|
||||
- `compute_history` の構造に合わせ、最小変更で実装してよい。
|
||||
|
||||
Escalate if:
|
||||
- protocol / pod / persistence 層の変更が必要になる。
|
||||
- Thinking を text selection/copy target にする必要が出る。
|
||||
- Provider-specific reasoning metadata の意味づけ変更が必要になる。
|
||||
- Console 以外の UI surface に範囲が広がる。
|
||||
- broad TUI rendering rewrite が必要になる。
|
||||
|
||||
Validation:
|
||||
- `cargo test -p tui` または focused `cargo test -p tui thinking` / rendering tests。
|
||||
- `cargo fmt --check`。
|
||||
- `git diff --check`。
|
||||
- 必要に応じて `cargo check -p tui`。
|
||||
|
||||
Current code map:
|
||||
- Primary: `crates/tui/src/block.rs`, `crates/tui/src/app.rs`, `crates/tui/src/ui.rs`, `crates/tui/src/tool.rs`。
|
||||
- Avoid: protocol/history/persistence, Dashboard/Panel/web UI, broad TUI rewrite。
|
||||
|
||||
Critical risks / reviewer focus:
|
||||
- source blocks/history を変更してしまうこと。
|
||||
- non-Thinking block や turn boundary を跨いだ誤 grouping。
|
||||
- streaming/incomplete thinking state の欠落。
|
||||
- text selection/copy semantics の accidental change。
|
||||
|
||||
Next action:
|
||||
- `queued -> inprogress` を記録してから worktree-workflow で dedicated worktree を作成し、Coder/Reviewer sibling loop に進める。
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-24T12:04:07Z from: queued to: inprogress reason: orchestrator_acceptance_unblocked field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
Orchestrator acceptance: queued -> inprogress
|
||||
|
||||
- 直前確認で `TicketShow` は state `queued`、blocking relation は 0 件、accepted plan `orch-plan-20260624-120242-1` を確認した。
|
||||
- 同時 queued Ticket `00001KVWPVHFJ` は disjoint code surface のため別 worktree/branch で並列開始可能と判断した。
|
||||
- routing decision と IntentPacket は Ticket thread に記録済み。
|
||||
- これ以降に worktree-workflow で `/home/hare/Projects/yoi/.worktree/00001KVWPW3KX-thinking-group` を作成し、multi-agent-workflow に接続する。
|
||||
|
||||
---
|
||||
|
||||
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-24T12:05:13Z -->
|
||||
|
||||
## Implementation report
|
||||
|
||||
Implementation orchestration update:
|
||||
|
||||
- worktree-workflow に従い、implementation worktree `/home/hare/Projects/yoi/.worktree/00001KVWPW3KX-thinking-group` と branch `work/00001KVWPW3KX-thinking-group` を作成した。
|
||||
- child worktree では tracked `.yoi` project records を見える状態にし、`.yoi/memory`、local/runtime/log/lock/secret-like `.yoi` paths を sparse-checkout で除外する方針にした。
|
||||
- Coder Pod `yoi-coder-00001KVWPW3KX-thinking-group` を sibling として起動した。write scope は child worktree に限定し、runtime workspace root `/home/hare/Projects/yoi` は startup 要件を満たすため non-recursive read のみにした。
|
||||
- Coder には root/original workspace と orchestration worktree で read/write/validation/git operation を行わないよう明示した。
|
||||
|
||||
---
|
||||
|
||||
<!-- event: review author: yoi-reviewer-00001KVWPW3KX-thinking-group at: 2026-06-24T12:18:51Z status: approve -->
|
||||
|
||||
## Review: approve
|
||||
|
||||
Review result: approve
|
||||
|
||||
確認内容:
|
||||
- child worktree の `.yoi/tickets/00001KVWPW3KX/item.md` / `thread.md` / implementation report / `5c9331e8..HEAD` diff を read-only 確認。
|
||||
- 変更範囲は `crates/tui/src/ui.rs` の Console history rendering と、既存 dashboard test fixture の必須 field 追加に限定されている。
|
||||
- `compute_history` で `Block::Thinking` のみを render-time に連続消費し、source block sequence / history / protocol / persistence は変更していない。
|
||||
- 単独 Thinking は既存 `render_thinking` に委譲され、複数 Thinking のみ `render_thinking_group` で集約される。
|
||||
- `TurnHeader` や `AssistantText` など non-Thinking を跨ぐ grouping は起きない構造で、focused tests も追加されている。
|
||||
- streaming / incomplete は group header と preview/detail に反映され、未完了 Thinking を finalized text と混同する変更はない。
|
||||
- group 行は `selectable=false` で追加されており、Thinking を copyable transcript text にする変更はない。
|
||||
- Read tool aggregation / single ToolCall rendering の回帰確認テストも追加されている。
|
||||
|
||||
Validation:
|
||||
- reviewer: `git diff --check 5c9331e8..HEAD`: PASS
|
||||
- cargo 系は read-only/no-write 指示に合わせて再実行せず、implementation report の結果を確認した。
|
||||
|
||||
Non-blocking note:
|
||||
- 実端末での目視確認は未実施だが、`compute_history` の行生成・selectable 属性を中心にテストされており、Ticket acceptance には十分。
|
||||
|
||||
---
|
||||
|
||||
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-24T12:20:02Z -->
|
||||
|
||||
## Implementation report
|
||||
|
||||
Integration outcome:
|
||||
|
||||
- Coder `yoi-coder-00001KVWPW3KX-thinking-group` が implementation branch `work/00001KVWPW3KX-thinking-group` に実装を commit した。
|
||||
- implementation commit: `0b2ce6ca tui: group consecutive thinking blocks`
|
||||
- child ticket report commit: `7ee2b78b ticket: report thinking grouping implementation`
|
||||
- Reviewer `yoi-reviewer-00001KVWPW3KX-thinking-group` は read-only review で `approve`。render-time aggregation のみで history/protocol/persistence を変更していないこと、turn/non-Thinking boundary、streaming/incomplete visibility、selectable=false、tool rendering regression tests を確認した。
|
||||
- Orchestrator worktree `/home/hare/Projects/yoi/.worktree/orchestration` で branch `work/00001KVWPW3KX-thinking-group` を merge 済み。
|
||||
- merge commit: `b52b7c96 merge: 00001KVWPW3KX thinking grouping`
|
||||
- merge 時に Ticket item/thread の append conflict が発生したため、orchestration 側の Ticket record を保持して merge し、この integration outcome に実装・review・validation evidence を集約して記録した。
|
||||
|
||||
Implemented behavior:
|
||||
- `crates/tui/src/ui.rs` で `compute_history` の render-time 処理に連続 `Block::Thinking` aggregation を追加。
|
||||
- 単一 Thinking は既存 `render_thinking` に委譲。
|
||||
- 複数 Thinking group は Normal/Overview で一つの header、Detail で各 Thinking body を読める形にした。
|
||||
- streaming / incomplete state は header / preview / detail に残し、group rows は selectable=false を維持。
|
||||
- `crates/tui/src/dashboard/tests.rs` は current `TicketRoleLaunchResult.acceptance_evidence` 必須 field に追随。
|
||||
|
||||
Validation in Orchestrator worktree:
|
||||
- `cargo test -p tui`: success
|
||||
- `cargo fmt --check`: success
|
||||
- `cargo check -p tui`: success
|
||||
- `git diff --check`: success
|
||||
|
||||
Notes:
|
||||
- 実端末での目視確認は未実施。focused tests と full `cargo test -p tui` で `compute_history` rows/selectable/tool regression を確認済み。
|
||||
- dependency/package change は無いため `nix build .#yoi` は実施していない。
|
||||
|
||||
Next action:
|
||||
- Mark Ticket done after this integration/validation evidence.
|
||||
- Then stop related child Pods and remove only the child implementation worktree/branch.
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-24T12:20:08Z from: inprogress to: done reason: merged_validated field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
Implementation is merged into the orchestration branch and validated.
|
||||
|
||||
Evidence:
|
||||
- merge commit: `b52b7c96 merge: 00001KVWPW3KX thinking grouping`
|
||||
- reviewer result: approve
|
||||
- validation in `/home/hare/Projects/yoi/.worktree/orchestration` succeeded:
|
||||
- `cargo test -p tui`
|
||||
- `cargo fmt --check`
|
||||
- `cargo check -p tui`
|
||||
- `git diff --check`
|
||||
|
||||
Closure is not performed here; this state records implementation completion after merge/validation.
|
||||
|
||||
---
|
||||
|
|
|
|||
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -6010,6 +6010,7 @@ dependencies = [
|
|||
"manifest",
|
||||
"memory",
|
||||
"pod",
|
||||
"pod-store",
|
||||
"project-record",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ use crate::{SegmentId, SessionId};
|
|||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::SystemTime;
|
||||
|
||||
/// Filesystem-backed JSONL store.
|
||||
///
|
||||
|
|
@ -41,6 +42,50 @@ impl FsStore {
|
|||
Ok(Self { root })
|
||||
}
|
||||
|
||||
/// Return the filesystem root used by this store.
|
||||
pub fn root_dir(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
/// Return the latest filesystem mtime under a Session directory.
|
||||
///
|
||||
/// Missing Sessions return `Ok(None)`. This is intentionally Session-scoped
|
||||
/// so cleanup callers can apply age thresholds without reaching around the
|
||||
/// Session store's directory authority.
|
||||
pub fn session_modified_at(
|
||||
&self,
|
||||
session_id: SessionId,
|
||||
) -> Result<Option<SystemTime>, StoreError> {
|
||||
let session_dir = self.session_dir(session_id);
|
||||
let dir_metadata = match fs::metadata(&session_dir) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let mut latest = Some(dir_metadata.modified()?);
|
||||
for entry in fs::read_dir(&session_dir)? {
|
||||
let entry = entry?;
|
||||
let modified = entry.metadata()?.modified()?;
|
||||
if latest.map(|current| modified > current).unwrap_or(true) {
|
||||
latest = Some(modified);
|
||||
}
|
||||
}
|
||||
Ok(latest)
|
||||
}
|
||||
|
||||
/// Delete an entire Session directory owned by this Session store.
|
||||
///
|
||||
/// Returns `Ok(true)` when a Session directory was removed and `Ok(false)`
|
||||
/// when it was already absent.
|
||||
pub fn delete_session(&self, session_id: SessionId) -> Result<bool, StoreError> {
|
||||
let session_dir = self.session_dir(session_id);
|
||||
match fs::remove_dir_all(&session_dir) {
|
||||
Ok(()) => Ok(true),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn session_dir(&self, session_id: SessionId) -> PathBuf {
|
||||
self.root.join(session_id.to_string())
|
||||
}
|
||||
|
|
@ -220,3 +265,42 @@ impl Store for FsStore {
|
|||
self.append_line(&self.trace_path(session_id, segment_id), &line)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{new_segment_id, new_session_id};
|
||||
|
||||
#[test]
|
||||
fn delete_session_removes_session_directory_only() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let store = FsStore::new(tmp.path()).unwrap();
|
||||
let keep_session = new_session_id();
|
||||
let keep_segment = new_segment_id();
|
||||
let delete_session = new_session_id();
|
||||
let delete_segment = new_segment_id();
|
||||
store
|
||||
.create_segment(keep_session, keep_segment, &[])
|
||||
.unwrap();
|
||||
store
|
||||
.create_segment(delete_session, delete_segment, &[])
|
||||
.unwrap();
|
||||
|
||||
assert!(store.delete_session(delete_session).unwrap());
|
||||
assert!(!store.exists(delete_session, delete_segment).unwrap());
|
||||
assert!(store.exists(keep_session, keep_segment).unwrap());
|
||||
assert!(!store.delete_session(delete_session).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_modified_at_is_store_scoped() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let store = FsStore::new(tmp.path()).unwrap();
|
||||
let session_id = new_session_id();
|
||||
let segment_id = new_segment_id();
|
||||
|
||||
assert!(store.session_modified_at(session_id).unwrap().is_none());
|
||||
store.create_segment(session_id, segment_id, &[]).unwrap();
|
||||
assert!(store.session_modified_at(session_id).unwrap().is_some());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2766,6 +2766,11 @@ fn dashboard_ticket_intake_finish_success_clears_composer_and_reports_pod() {
|
|||
pod_name: "intake-pod".to_string(),
|
||||
socket_path: PathBuf::from("/tmp/intake.sock"),
|
||||
},
|
||||
acceptance_evidence: client::ticket_role::TicketRoleLaunchAcceptanceEvidence {
|
||||
pod_name: "intake-pod".to_string(),
|
||||
accepted_run_segments: 0,
|
||||
event: client::ticket_role::TicketRoleLaunchAcceptanceEvent::UserMessage,
|
||||
},
|
||||
pre_run_warnings: vec![],
|
||||
},
|
||||
peer_registration: IntakePeerRegistrationStatus::Registered {
|
||||
|
|
|
|||
|
|
@ -363,6 +363,13 @@ pub fn compute_history(app: &App, width: u16) -> HistoryLayout {
|
|||
previous_selectable = false;
|
||||
continue;
|
||||
}
|
||||
if matches!(block, Block::Thinking(_)) {
|
||||
let out = render_thinking_aggregate(&app.blocks, i, width, app.mode);
|
||||
logical.extend(out.lines.into_iter().map(|line| (line, false)));
|
||||
i += out.consumed.max(1);
|
||||
previous_selectable = false;
|
||||
continue;
|
||||
}
|
||||
let mut block_lines = Vec::new();
|
||||
render_block_into(&mut block_lines, block, width, app.mode);
|
||||
logical.extend(
|
||||
|
|
@ -1226,11 +1233,181 @@ fn count_visual_rows(text: &str, width: u16) -> usize {
|
|||
total.max(1)
|
||||
}
|
||||
|
||||
fn render_thinking(lines: &mut Vec<Line<'static>>, t: &ThinkingBlock, width: u16, mode: Mode) {
|
||||
struct ThinkingRenderOutput {
|
||||
lines: Vec<Line<'static>>,
|
||||
/// How many blocks were consumed from `blocks[start..]`. Always >= 1.
|
||||
consumed: usize,
|
||||
}
|
||||
|
||||
fn render_thinking_aggregate(
|
||||
blocks: &[Block],
|
||||
start: usize,
|
||||
width: u16,
|
||||
mode: Mode,
|
||||
) -> ThinkingRenderOutput {
|
||||
let Some(Block::Thinking(_)) = blocks.get(start) else {
|
||||
return ThinkingRenderOutput {
|
||||
lines: Vec::new(),
|
||||
consumed: 1,
|
||||
};
|
||||
};
|
||||
|
||||
let mut end = start + 1;
|
||||
while matches!(blocks.get(end), Some(Block::Thinking(_))) {
|
||||
end += 1;
|
||||
}
|
||||
|
||||
let group: Vec<&ThinkingBlock> = blocks[start..end]
|
||||
.iter()
|
||||
.filter_map(|block| match block {
|
||||
Block::Thinking(thinking) => Some(thinking),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut lines = Vec::new();
|
||||
if let [single] = group.as_slice() {
|
||||
render_thinking(&mut lines, single, width, mode);
|
||||
} else {
|
||||
render_thinking_group(&mut lines, &group, width, mode);
|
||||
}
|
||||
|
||||
ThinkingRenderOutput {
|
||||
lines,
|
||||
consumed: end - start,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_thinking_group(
|
||||
lines: &mut Vec<Line<'static>>,
|
||||
group: &[&ThinkingBlock],
|
||||
width: u16,
|
||||
mode: Mode,
|
||||
) {
|
||||
let header = thinking_group_header(group);
|
||||
if matches!(mode, Mode::Overview) {
|
||||
push_overview_line(lines, &header, width, MessageKind::Thinking, "");
|
||||
return;
|
||||
}
|
||||
|
||||
let header_style = kind_style(MessageKind::Thinking);
|
||||
let body_style = Style::default().fg(Color::DarkGray);
|
||||
lines.push(Line::from(Span::styled(header, header_style)));
|
||||
|
||||
let header = match &t.state {
|
||||
match mode {
|
||||
Mode::Detail => {
|
||||
let item_style = body_style.add_modifier(Modifier::ITALIC);
|
||||
for (idx, thinking) in group.iter().enumerate() {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" ", body_style),
|
||||
Span::styled(
|
||||
format!("[{}] {}", idx + 1, thinking_header(thinking)),
|
||||
item_style,
|
||||
),
|
||||
]));
|
||||
for raw in thinking.text.lines() {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" ", body_style),
|
||||
Span::styled(raw.to_owned(), body_style),
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
Mode::Normal => {
|
||||
let preview = thinking_group_preview(group);
|
||||
if !preview.is_empty() {
|
||||
let budget = width.saturating_sub(2) as usize;
|
||||
let truncated = truncate_with_ellipsis(&preview, budget);
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" ", body_style),
|
||||
Span::styled(truncated, body_style),
|
||||
]));
|
||||
}
|
||||
}
|
||||
Mode::Overview => unreachable!("handled above"),
|
||||
}
|
||||
}
|
||||
|
||||
fn thinking_group_header(group: &[&ThinkingBlock]) -> String {
|
||||
let count = group.len();
|
||||
let block_count = format!("{count} block{}", plural_suffix(count));
|
||||
let streaming = group
|
||||
.iter()
|
||||
.filter(|thinking| matches!(thinking.state, ThinkingState::Streaming { .. }))
|
||||
.count();
|
||||
let incomplete = group
|
||||
.iter()
|
||||
.filter(|thinking| matches!(thinking.state, ThinkingState::Incomplete { .. }))
|
||||
.count();
|
||||
let finished = count.saturating_sub(streaming + incomplete);
|
||||
|
||||
if streaming > 0 {
|
||||
let mut parts = vec![block_count];
|
||||
if streaming > 1 {
|
||||
parts.push(format!("{streaming} live"));
|
||||
}
|
||||
if incomplete > 0 {
|
||||
parts.push(format!("{incomplete} interrupted"));
|
||||
}
|
||||
let elapsed = group
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|thinking| match &thinking.state {
|
||||
ThinkingState::Streaming { started_at } => Some(started_at.elapsed().as_secs()),
|
||||
_ => None,
|
||||
})
|
||||
.map(fmt_elapsed);
|
||||
match elapsed {
|
||||
Some(elapsed) => format!("Thinking... ({}, {elapsed})", parts.join(", ")),
|
||||
None => format!("Thinking... ({})", parts.join(", ")),
|
||||
}
|
||||
} else if incomplete > 0 {
|
||||
let mut parts = vec![block_count];
|
||||
if finished > 0 {
|
||||
parts.push(format!("{finished} finished"));
|
||||
}
|
||||
parts.push(format!("{incomplete} interrupted"));
|
||||
format!("Thoughts interrupted ({})", parts.join(", "))
|
||||
} else {
|
||||
format!("Thoughts — {block_count}")
|
||||
}
|
||||
}
|
||||
|
||||
fn thinking_group_preview(group: &[&ThinkingBlock]) -> String {
|
||||
if let Some(preview) = group
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|thinking| match thinking.state {
|
||||
ThinkingState::Streaming { .. } => {
|
||||
non_empty_preview(trailing_line_preview(&thinking.text))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
{
|
||||
return preview;
|
||||
}
|
||||
|
||||
group
|
||||
.iter()
|
||||
.find_map(|thinking| match thinking.state {
|
||||
ThinkingState::Streaming { .. } => {
|
||||
non_empty_preview(trailing_line_preview(&thinking.text))
|
||||
}
|
||||
_ => non_empty_preview(first_line_preview(&thinking.text)),
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn non_empty_preview(preview: String) -> Option<String> {
|
||||
if preview.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(preview)
|
||||
}
|
||||
}
|
||||
|
||||
fn thinking_header(t: &ThinkingBlock) -> String {
|
||||
match &t.state {
|
||||
ThinkingState::Streaming { started_at } => {
|
||||
let secs = started_at.elapsed().as_secs();
|
||||
format!("Thinking... ({})", fmt_elapsed(secs))
|
||||
|
|
@ -1243,7 +1420,18 @@ fn render_thinking(lines: &mut Vec<Line<'static>>, t: &ThinkingBlock, width: u16
|
|||
Some(s) => format!("Thinking interrupted ({})", fmt_elapsed(*s)),
|
||||
None => "Thinking interrupted".to_owned(),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fn plural_suffix(count: usize) -> &'static str {
|
||||
if count == 1 { "" } else { "s" }
|
||||
}
|
||||
|
||||
fn render_thinking(lines: &mut Vec<Line<'static>>, t: &ThinkingBlock, width: u16, mode: Mode) {
|
||||
let header_style = kind_style(MessageKind::Thinking);
|
||||
let body_style = Style::default().fg(Color::DarkGray);
|
||||
|
||||
let header = thinking_header(t);
|
||||
|
||||
if matches!(mode, Mode::Overview) {
|
||||
push_overview_line(lines, &header, width, MessageKind::Thinking, "");
|
||||
|
|
@ -1812,6 +2000,229 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
fn row_texts(app: &App) -> Vec<String> {
|
||||
compute_history(app, 80)
|
||||
.rows
|
||||
.into_iter()
|
||||
.map(|row| row.text)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn finished_thinking(text: &str) -> Block {
|
||||
Block::Thinking(ThinkingBlock {
|
||||
text: text.to_string(),
|
||||
state: ThinkingState::Finished {
|
||||
elapsed_secs: Some(2),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn incomplete_thinking(text: &str) -> Block {
|
||||
Block::Thinking(ThinkingBlock {
|
||||
text: text.to_string(),
|
||||
state: ThinkingState::Incomplete {
|
||||
elapsed_secs: Some(4),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn streaming_thinking(text: &str) -> Block {
|
||||
Block::Thinking(ThinkingBlock {
|
||||
text: text.to_string(),
|
||||
state: ThinkingState::Streaming {
|
||||
started_at: Instant::now() - Duration::from_secs(3),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consecutive_thinking_blocks_render_as_one_normal_group() {
|
||||
let mut app = App::new("pod".to_string());
|
||||
app.mode = Mode::Normal;
|
||||
app.blocks = vec![finished_thinking("alpha"), finished_thinking("beta")];
|
||||
|
||||
let rows = row_texts(&app);
|
||||
|
||||
assert!(rows.iter().any(|text| text == "Thoughts — 2 blocks"));
|
||||
assert_eq!(
|
||||
rows.iter()
|
||||
.filter(|text| text.starts_with("Thought"))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert!(rows.iter().any(|text| text == " alpha"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thinking_group_detail_keeps_each_body_readable() {
|
||||
let mut app = App::new("pod".to_string());
|
||||
app.mode = Mode::Detail;
|
||||
app.blocks = vec![
|
||||
finished_thinking("alpha line 1\nalpha line 2"),
|
||||
finished_thinking("beta line"),
|
||||
];
|
||||
|
||||
let rows = row_texts(&app);
|
||||
|
||||
assert!(rows.iter().any(|text| text == "Thoughts — 2 blocks"));
|
||||
assert!(rows.iter().any(|text| text == " [1] Thought for 2s"));
|
||||
assert!(rows.iter().any(|text| text == " alpha line 1"));
|
||||
assert!(rows.iter().any(|text| text == " alpha line 2"));
|
||||
assert!(rows.iter().any(|text| text == " [2] Thought for 2s"));
|
||||
assert!(rows.iter().any(|text| text == " beta line"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_thinking_separator_breaks_thinking_group() {
|
||||
let mut app = App::new("pod".to_string());
|
||||
app.mode = Mode::Normal;
|
||||
app.blocks = vec![
|
||||
finished_thinking("alpha"),
|
||||
Block::AssistantText {
|
||||
text: "assistant separator".to_string(),
|
||||
},
|
||||
finished_thinking("beta"),
|
||||
];
|
||||
|
||||
let rows = row_texts(&app);
|
||||
|
||||
assert_eq!(
|
||||
rows.iter()
|
||||
.filter(|text| text.as_str() == "Thought for 2s")
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
assert!(!rows.iter().any(|text| text == "Thoughts — 2 blocks"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_header_breaks_thinking_group() {
|
||||
let mut app = App::new("pod".to_string());
|
||||
app.mode = Mode::Normal;
|
||||
app.blocks = vec![
|
||||
Block::TurnHeader { turn: 1 },
|
||||
finished_thinking("alpha"),
|
||||
Block::TurnHeader { turn: 2 },
|
||||
finished_thinking("beta"),
|
||||
];
|
||||
|
||||
let rows = row_texts(&app);
|
||||
|
||||
assert_eq!(
|
||||
rows.iter()
|
||||
.filter(|text| text.as_str() == "Thought for 2s")
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
assert!(!rows.iter().any(|text| text == "Thoughts — 2 blocks"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thinking_group_preserves_streaming_and_incomplete_state_visibility() {
|
||||
let mut app = App::new("pod".to_string());
|
||||
app.mode = Mode::Normal;
|
||||
app.blocks = vec![
|
||||
finished_thinking("finished"),
|
||||
incomplete_thinking("interrupted"),
|
||||
streaming_thinking("live first\nlive tail"),
|
||||
];
|
||||
|
||||
let layout = compute_history(&app, 80);
|
||||
let rows: Vec<_> = layout.rows.iter().map(|row| row.text.as_str()).collect();
|
||||
|
||||
assert!(rows.iter().any(|text| {
|
||||
text.starts_with("Thinking...")
|
||||
&& text.contains("3 blocks")
|
||||
&& text.contains("interrupted")
|
||||
}));
|
||||
assert!(rows.iter().any(|text| *text == " live tail"));
|
||||
assert!(layout.rows.iter().all(|row| !row.selectable));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_thinking_block_rendering_stays_unchanged() {
|
||||
let mut app = App::new("pod".to_string());
|
||||
app.mode = Mode::Normal;
|
||||
app.blocks = vec![Block::Thinking(ThinkingBlock {
|
||||
text: "private reasoning".to_string(),
|
||||
state: ThinkingState::Finished { elapsed_secs: None },
|
||||
})];
|
||||
|
||||
let rows = row_texts(&app);
|
||||
|
||||
assert!(rows.iter().any(|text| text == "Thought"));
|
||||
assert!(rows.iter().any(|text| text == " private reasoning"));
|
||||
assert!(!rows.iter().any(|text| text.starts_with("Thoughts —")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_tool_block_rendering_stays_unchanged() {
|
||||
use crate::block::{ToolCallBlock, ToolCallState};
|
||||
|
||||
let mut app = App::new("pod".to_string());
|
||||
app.mode = Mode::Normal;
|
||||
app.blocks = vec![Block::ToolCall(ToolCallBlock {
|
||||
id: "bash-1".to_string(),
|
||||
name: "Bash".to_string(),
|
||||
args_stream: r#"{"command":"echo hi"}"#.to_string(),
|
||||
arguments: Some(r#"{"command":"echo hi"}"#.to_string()),
|
||||
state: ToolCallState::Done {
|
||||
summary: "hi".to_string(),
|
||||
output: None,
|
||||
},
|
||||
edit_snapshot: None,
|
||||
})];
|
||||
|
||||
let rows = row_texts(&app);
|
||||
|
||||
assert!(rows.iter().any(|text| text == "Bash — done"));
|
||||
assert!(rows.iter().any(|text| text == " hi"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_tool_aggregation_still_consumes_consecutive_tool_blocks() {
|
||||
use crate::block::{ToolCallBlock, ToolCallState};
|
||||
|
||||
let mut app = App::new("pod".to_string());
|
||||
app.mode = Mode::Normal;
|
||||
app.blocks = vec![
|
||||
Block::ToolCall(ToolCallBlock {
|
||||
id: "read-1".to_string(),
|
||||
name: "Read".to_string(),
|
||||
args_stream: String::new(),
|
||||
arguments: Some(r#"{"file_path":"/tmp/a"}"#.to_string()),
|
||||
state: ToolCallState::Done {
|
||||
summary: "read".to_string(),
|
||||
output: None,
|
||||
},
|
||||
edit_snapshot: None,
|
||||
}),
|
||||
Block::ToolCall(ToolCallBlock {
|
||||
id: "read-2".to_string(),
|
||||
name: "Read".to_string(),
|
||||
args_stream: String::new(),
|
||||
arguments: Some(r#"{"file_path":"/tmp/b"}"#.to_string()),
|
||||
state: ToolCallState::Done {
|
||||
summary: "read".to_string(),
|
||||
output: None,
|
||||
},
|
||||
edit_snapshot: None,
|
||||
}),
|
||||
];
|
||||
|
||||
let rows = row_texts(&app);
|
||||
|
||||
assert!(rows.iter().any(|text| text == "Read — 2 files read"));
|
||||
assert_eq!(
|
||||
rows.iter()
|
||||
.filter(|text| text.starts_with("Read —"))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert!(rows.iter().any(|text| text == " /tmp/a"));
|
||||
assert!(rows.iter().any(|text| text == " /tmp/b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_rows_mark_text_items_selectable_and_non_text_unselectable() {
|
||||
let mut app = App::new("pod".to_string());
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ client = { workspace = true }
|
|||
memory = { workspace = true }
|
||||
manifest = { workspace = true }
|
||||
pod = { workspace = true }
|
||||
pod-store = { workspace = true }
|
||||
session-store = { workspace = true }
|
||||
session-analytics = { workspace = true }
|
||||
ticket = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ mod mcp_cli;
|
|||
mod memory_lint;
|
||||
mod objective_cli;
|
||||
mod plugin_cli;
|
||||
mod pod_cleanup_cli;
|
||||
mod session_cli;
|
||||
mod ticket_cli;
|
||||
|
||||
|
|
@ -25,6 +26,7 @@ enum Mode {
|
|||
Plugin(plugin_cli::PluginCliCommand),
|
||||
Objective(objective_cli::ObjectiveCli),
|
||||
Session(session_cli::SessionCli),
|
||||
PodCleanup(pod_cleanup_cli::PodCleanupCli),
|
||||
Ticket(ticket_cli::TicketCli),
|
||||
WorkspaceHelp,
|
||||
WorkspaceServe(Vec<String>),
|
||||
|
|
@ -117,6 +119,7 @@ async fn main() -> ExitCode {
|
|||
print!("{}", output.stdout);
|
||||
match output.status {
|
||||
session_cli::SessionCliStatus::Success => ExitCode::SUCCESS,
|
||||
session_cli::SessionCliStatus::Failure => ExitCode::FAILURE,
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
|
|
@ -124,6 +127,19 @@ async fn main() -> ExitCode {
|
|||
ExitCode::FAILURE
|
||||
}
|
||||
},
|
||||
Mode::PodCleanup(cli) => match pod_cleanup_cli::run(cli).await {
|
||||
Ok(output) => {
|
||||
print!("{}", output.stdout);
|
||||
match output.status {
|
||||
pod_cleanup_cli::PodCleanupCliStatus::Success => ExitCode::SUCCESS,
|
||||
pod_cleanup_cli::PodCleanupCliStatus::Failure => ExitCode::FAILURE,
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("yoi pod: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
},
|
||||
Mode::Ticket(cli) => match ticket_cli::run(cli) {
|
||||
Ok(output) => {
|
||||
print!("{}", output.stdout);
|
||||
|
|
@ -188,7 +204,14 @@ fn parse_args_slice(args: &[String]) -> Result<Mode, ParseError> {
|
|||
match args[0].as_str() {
|
||||
"--help" | "-h" => return Ok(Mode::Help),
|
||||
"resume" => return parse_resume_args(&args[1..]),
|
||||
"pod" => return Ok(Mode::PodRuntime(args[1..].to_vec())),
|
||||
"pod" => {
|
||||
if let Some(cli) = pod_cleanup_cli::parse_pod_management_args(&args[1..])
|
||||
.map_err(|e| ParseError(e.to_string()))?
|
||||
{
|
||||
return Ok(Mode::PodCleanup(cli));
|
||||
}
|
||||
return Ok(Mode::PodRuntime(args[1..].to_vec()));
|
||||
}
|
||||
"objective" => {
|
||||
let objective_cli = objective_cli::parse_objective_args(&args[1..])
|
||||
.map_err(|e| ParseError(e.to_string()))?;
|
||||
|
|
@ -878,7 +901,7 @@ fn parse_session_id(value: &str) -> Result<SegmentId, ParseError> {
|
|||
|
||||
fn print_help() {
|
||||
println!(
|
||||
"yoi\n\nUsage:\n yoi [OPTIONS]\n yoi resume [--workspace <PATH>] [--all]\n yoi panel [--workspace <PATH>]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi objective <COMMAND> [OPTIONS]\n yoi session analyze <SESSION_JSONL_PATH> --json\n yoi ticket <COMMAND> [OPTIONS]\n yoi workspace serve [OPTIONS]\n yoi plugin new rust-component-tool <PATH> [--json]\n yoi plugin check <PATH_OR_PACKAGE> [--json]\n yoi plugin pack <PATH> [--output <FILE>] [--json]\n yoi plugin list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi plugin show <REF> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp show <SERVER> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace <PATH>] [--profile <REF>] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Pod chat/client surface (default, --pod, yoi resume)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n --workspace <PATH> Runtime workspace root for default Console/--pod (defaults to cwd)\n --pod <NAME> Open the Pod Console by name (attach/restore/create)\n --socket <PATH> Attach a Pod Console to a specific socket with --pod\n --session <UUID> Resume a specific session segment in the Pod Console\n --profile <REF> Select a reusable Profile recipe\n -h, --help Print help\n"
|
||||
"yoi\n\nUsage:\n yoi [OPTIONS]\n yoi resume [--workspace <PATH>] [--all]\n yoi panel [--workspace <PATH>]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi pod delete <NAME> [--force] [--dry-run]\n yoi pod prune --older-than <DURATION> [--force] [--dry-run]\n yoi objective <COMMAND> [OPTIONS]\n yoi session analyze <SESSION_JSONL_PATH> --json\n yoi session prune --unreferenced [--older-than <DURATION>] [--force] [--dry-run]\n yoi ticket <COMMAND> [OPTIONS]\n yoi workspace serve [OPTIONS]\n yoi plugin new rust-component-tool <PATH> [--json]\n yoi plugin check <PATH_OR_PACKAGE> [--json]\n yoi plugin pack <PATH> [--output <FILE>] [--json]\n yoi plugin list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi plugin show <REF> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp show <SERVER> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace <PATH>] [--profile <REF>] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Pod chat/client surface (default, --pod, yoi resume)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n --workspace <PATH> Runtime workspace root for default Console/--pod (defaults to cwd)\n --pod <NAME> Open the Pod Console by name (attach/restore/create)\n --socket <PATH> Attach a Pod Console to a specific socket with --pod\n --session <UUID> Resume a specific session segment in the Pod Console\n --profile <REF> Select a reusable Profile recipe\n -h, --help Print help\n"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -978,6 +1001,31 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_pod_delete_uses_cleanup_mode() {
|
||||
match parse_args_from(["pod", "delete", "agent", "--dry-run"]).unwrap() {
|
||||
Mode::PodCleanup(pod_cleanup_cli::PodCleanupCli::Delete(options)) => {
|
||||
assert_eq!(options.name, "agent");
|
||||
assert!(options.dry_run);
|
||||
assert!(!options.force);
|
||||
}
|
||||
_ => panic!("expected Pod cleanup delete mode"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_pod_prune_uses_cleanup_mode() {
|
||||
match parse_args_from(["pod", "prune", "--older-than", "30d"]).unwrap() {
|
||||
Mode::PodCleanup(pod_cleanup_cli::PodCleanupCli::Prune(options)) => {
|
||||
assert_eq!(
|
||||
options.older_than,
|
||||
std::time::Duration::from_secs(30 * 24 * 60 * 60)
|
||||
);
|
||||
}
|
||||
_ => panic!("expected Pod cleanup prune mode"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ticket_subcommand_uses_ticket_mode() {
|
||||
match parse_args_from(["ticket", "doctor"]).unwrap() {
|
||||
|
|
|
|||
624
crates/yoi/src/pod_cleanup_cli.rs
Normal file
624
crates/yoi/src/pod_cleanup_cli.rs
Normal file
|
|
@ -0,0 +1,624 @@
|
|||
use std::fmt;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use manifest::paths;
|
||||
use pod_store::{FsPodStore, PodMetadata, PodMetadataStore, validate_pod_name};
|
||||
|
||||
const MAX_REPORT_ITEMS: usize = 50;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PodCleanupCli {
|
||||
Help,
|
||||
Delete(PodDeleteOptions),
|
||||
Prune(PodPruneOptions),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PodDeleteOptions {
|
||||
pub name: String,
|
||||
pub force: bool,
|
||||
pub dry_run: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PodPruneOptions {
|
||||
pub older_than: Duration,
|
||||
pub force: bool,
|
||||
pub dry_run: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PodCleanupCliOutput {
|
||||
pub stdout: String,
|
||||
pub status: PodCleanupCliStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PodCleanupCliStatus {
|
||||
Success,
|
||||
Failure,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PodCleanupCliError(String);
|
||||
|
||||
impl fmt::Display for PodCleanupCliError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PodCleanupCliError {}
|
||||
|
||||
pub fn parse_pod_management_args(
|
||||
args: &[String],
|
||||
) -> Result<Option<PodCleanupCli>, PodCleanupCliError> {
|
||||
let Some((subcommand, rest)) = args.split_first() else {
|
||||
return Ok(None);
|
||||
};
|
||||
match subcommand.as_str() {
|
||||
"delete" => parse_delete_args(rest).map(PodCleanupCli::Delete).map(Some),
|
||||
"prune" => parse_prune_args(rest).map(PodCleanupCli::Prune).map(Some),
|
||||
"help" => Ok(Some(PodCleanupCli::Help)),
|
||||
"--help" | "-h" => Ok(Some(PodCleanupCli::Help)),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_delete_args(args: &[String]) -> Result<PodDeleteOptions, PodCleanupCliError> {
|
||||
if args.iter().any(|arg| arg == "--help" || arg == "-h") {
|
||||
return Err(PodCleanupCliError(delete_help_text().to_string()));
|
||||
}
|
||||
let mut name = None;
|
||||
let mut force = false;
|
||||
let mut dry_run = false;
|
||||
let mut iter = args.iter();
|
||||
while let Some(arg) = iter.next() {
|
||||
match arg.as_str() {
|
||||
"--force" => force = true,
|
||||
"--dry-run" => dry_run = true,
|
||||
"--" => {
|
||||
for positional in iter {
|
||||
set_name(&mut name, positional)?;
|
||||
}
|
||||
break;
|
||||
}
|
||||
value if value.starts_with('-') => {
|
||||
return Err(PodCleanupCliError(format!(
|
||||
"unknown yoi pod delete option `{value}`"
|
||||
)));
|
||||
}
|
||||
positional => set_name(&mut name, positional)?,
|
||||
}
|
||||
}
|
||||
let name = name
|
||||
.ok_or_else(|| PodCleanupCliError("yoi pod delete requires an explicit Pod name".into()))?;
|
||||
validate_pod_name(&name).map_err(|e| PodCleanupCliError(e.to_string()))?;
|
||||
Ok(PodDeleteOptions {
|
||||
name,
|
||||
force,
|
||||
dry_run,
|
||||
})
|
||||
}
|
||||
|
||||
fn set_name(name: &mut Option<String>, value: &str) -> Result<(), PodCleanupCliError> {
|
||||
if name.is_some() {
|
||||
return Err(PodCleanupCliError(
|
||||
"yoi pod delete accepts exactly one Pod name".into(),
|
||||
));
|
||||
}
|
||||
*name = Some(value.to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_prune_args(args: &[String]) -> Result<PodPruneOptions, PodCleanupCliError> {
|
||||
if args.iter().any(|arg| arg == "--help" || arg == "-h") {
|
||||
return Err(PodCleanupCliError(prune_help_text().to_string()));
|
||||
}
|
||||
let mut older_than = None;
|
||||
let mut force = false;
|
||||
let mut dry_run = false;
|
||||
let mut index = 0;
|
||||
while index < args.len() {
|
||||
let arg = &args[index];
|
||||
if arg == "--force" {
|
||||
force = true;
|
||||
index += 1;
|
||||
} else if arg == "--dry-run" {
|
||||
dry_run = true;
|
||||
index += 1;
|
||||
} else if arg == "--older-than" {
|
||||
let value = args.get(index + 1).ok_or_else(|| {
|
||||
PodCleanupCliError("--older-than requires a duration value".into())
|
||||
})?;
|
||||
if value.starts_with('-') {
|
||||
return Err(PodCleanupCliError(
|
||||
"--older-than requires a duration value".into(),
|
||||
));
|
||||
}
|
||||
older_than = Some(parse_duration(value)?);
|
||||
index += 2;
|
||||
} else if let Some(value) = arg.strip_prefix("--older-than=") {
|
||||
if value.is_empty() {
|
||||
return Err(PodCleanupCliError(
|
||||
"--older-than requires a duration value".into(),
|
||||
));
|
||||
}
|
||||
older_than = Some(parse_duration(value)?);
|
||||
index += 1;
|
||||
} else if arg.starts_with('-') {
|
||||
return Err(PodCleanupCliError(format!(
|
||||
"unknown yoi pod prune option `{arg}`"
|
||||
)));
|
||||
} else {
|
||||
return Err(PodCleanupCliError(format!(
|
||||
"yoi pod prune does not accept positional argument `{arg}`"
|
||||
)));
|
||||
}
|
||||
}
|
||||
let older_than = older_than.ok_or_else(|| {
|
||||
PodCleanupCliError("yoi pod prune requires --older-than <DURATION>".into())
|
||||
})?;
|
||||
Ok(PodPruneOptions {
|
||||
older_than,
|
||||
force,
|
||||
dry_run,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn parse_duration(value: &str) -> Result<Duration, PodCleanupCliError> {
|
||||
let split = value
|
||||
.find(|ch: char| !ch.is_ascii_digit())
|
||||
.unwrap_or(value.len());
|
||||
let (amount, unit) = value.split_at(split);
|
||||
if amount.is_empty() || unit.is_empty() {
|
||||
return Err(PodCleanupCliError(format!(
|
||||
"duration `{value}` must use an explicit unit: s, m, h, d, or w"
|
||||
)));
|
||||
}
|
||||
let amount = amount
|
||||
.parse::<u64>()
|
||||
.map_err(|_| PodCleanupCliError(format!("invalid duration amount `{value}`")))?;
|
||||
if amount == 0 {
|
||||
return Err(PodCleanupCliError(
|
||||
"duration must be greater than zero".into(),
|
||||
));
|
||||
}
|
||||
let seconds = match unit {
|
||||
"s" | "sec" | "secs" | "second" | "seconds" => amount,
|
||||
"m" | "min" | "mins" | "minute" | "minutes" => amount.saturating_mul(60),
|
||||
"h" | "hr" | "hrs" | "hour" | "hours" => amount.saturating_mul(60 * 60),
|
||||
"d" | "day" | "days" => amount.saturating_mul(60 * 60 * 24),
|
||||
"w" | "week" | "weeks" => amount.saturating_mul(60 * 60 * 24 * 7),
|
||||
_ => {
|
||||
return Err(PodCleanupCliError(format!(
|
||||
"unknown duration unit `{unit}` in `{value}`"
|
||||
)));
|
||||
}
|
||||
};
|
||||
Ok(Duration::from_secs(seconds))
|
||||
}
|
||||
|
||||
pub async fn run(cli: PodCleanupCli) -> Result<PodCleanupCliOutput, PodCleanupCliError> {
|
||||
let data_dir = paths::data_dir()
|
||||
.ok_or_else(|| PodCleanupCliError("failed to resolve Yoi data directory".into()))?;
|
||||
let runtime_dir = paths::runtime_dir()
|
||||
.ok_or_else(|| PodCleanupCliError("failed to resolve Yoi runtime directory".into()))?;
|
||||
run_with_roots(cli, data_dir, runtime_dir).await
|
||||
}
|
||||
|
||||
pub async fn run_with_roots(
|
||||
cli: PodCleanupCli,
|
||||
data_dir: PathBuf,
|
||||
runtime_dir: PathBuf,
|
||||
) -> Result<PodCleanupCliOutput, PodCleanupCliError> {
|
||||
match cli {
|
||||
PodCleanupCli::Help => Ok(PodCleanupCliOutput {
|
||||
stdout: help_text().to_string(),
|
||||
status: PodCleanupCliStatus::Success,
|
||||
}),
|
||||
PodCleanupCli::Delete(options) => run_delete(options, data_dir, runtime_dir).await,
|
||||
PodCleanupCli::Prune(options) => run_prune(options, data_dir, runtime_dir).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_delete(
|
||||
options: PodDeleteOptions,
|
||||
data_dir: PathBuf,
|
||||
runtime_dir: PathBuf,
|
||||
) -> Result<PodCleanupCliOutput, PodCleanupCliError> {
|
||||
let store = FsPodStore::new(data_dir.join("pods")).map_err(to_error)?;
|
||||
let metadata = store.read_by_name(&options.name).map_err(to_error)?;
|
||||
let Some(metadata) = metadata else {
|
||||
return Ok(PodCleanupCliOutput {
|
||||
stdout: format!(
|
||||
"yoi pod delete\nstatus: refused\npod: {}\nreason: pod metadata is missing\n",
|
||||
options.name
|
||||
),
|
||||
status: PodCleanupCliStatus::Failure,
|
||||
});
|
||||
};
|
||||
|
||||
let probe = probe_pod_liveness(&runtime_dir, &options.name).await;
|
||||
if let Some(reason) = probe.refusal_reason() {
|
||||
return Ok(PodCleanupCliOutput {
|
||||
stdout: format!(
|
||||
"yoi pod delete\nstatus: refused\npod: {}\nreason: {}\nsocket: {}\n",
|
||||
options.name,
|
||||
reason,
|
||||
probe.socket_path.display()
|
||||
),
|
||||
status: PodCleanupCliStatus::Failure,
|
||||
});
|
||||
}
|
||||
|
||||
let delete = options.force && !options.dry_run;
|
||||
let mut stdout = String::new();
|
||||
stdout.push_str("yoi pod delete\n");
|
||||
stdout.push_str(if delete {
|
||||
"mode: force\n"
|
||||
} else {
|
||||
"mode: dry-run\n"
|
||||
});
|
||||
stdout.push_str(&format!("pod: {}\n", options.name));
|
||||
describe_metadata(&mut stdout, &metadata);
|
||||
if delete {
|
||||
store.delete_by_name(&options.name).map_err(to_error)?;
|
||||
stdout.push_str("deleted: pod metadata\n");
|
||||
stdout.push_str("preserved: session logs/history\n");
|
||||
} else {
|
||||
stdout.push_str("would_delete: pod metadata\n");
|
||||
stdout.push_str("would_preserve: session logs/history\n");
|
||||
stdout
|
||||
.push_str("note: pass --force to delete metadata; --dry-run keeps report-only mode\n");
|
||||
}
|
||||
Ok(PodCleanupCliOutput {
|
||||
stdout,
|
||||
status: PodCleanupCliStatus::Success,
|
||||
})
|
||||
}
|
||||
|
||||
async fn run_prune(
|
||||
options: PodPruneOptions,
|
||||
data_dir: PathBuf,
|
||||
runtime_dir: PathBuf,
|
||||
) -> Result<PodCleanupCliOutput, PodCleanupCliError> {
|
||||
let store = FsPodStore::new(data_dir.join("pods")).map_err(to_error)?;
|
||||
let names = store.list_names().map_err(to_error)?;
|
||||
let cutoff = SystemTime::now()
|
||||
.checked_sub(options.older_than)
|
||||
.ok_or_else(|| PodCleanupCliError("--older-than duration is too large".into()))?;
|
||||
let delete = options.force && !options.dry_run;
|
||||
let mut stdout = String::new();
|
||||
stdout.push_str("yoi pod prune\n");
|
||||
stdout.push_str(if delete {
|
||||
"mode: force\n"
|
||||
} else {
|
||||
"mode: dry-run\n"
|
||||
});
|
||||
stdout.push_str(&format!("older_than: {:?}\n", options.older_than));
|
||||
|
||||
let mut deleted = 0usize;
|
||||
let mut would_delete = 0usize;
|
||||
let mut kept = 0usize;
|
||||
let mut refused = 0usize;
|
||||
for (index, name) in names.iter().enumerate() {
|
||||
let metadata = store.read_by_name(name).map_err(to_error)?;
|
||||
let Some(metadata) = metadata else {
|
||||
kept += 1;
|
||||
push_item_line(&mut stdout, index, "kept", name, "metadata disappeared");
|
||||
continue;
|
||||
};
|
||||
let modified = metadata_modified_at(store.root_dir().as_deref(), name).map_err(to_error)?;
|
||||
let Some(modified) = modified else {
|
||||
refused += 1;
|
||||
push_item_line(
|
||||
&mut stdout,
|
||||
index,
|
||||
"refused",
|
||||
name,
|
||||
"metadata mtime is unavailable",
|
||||
);
|
||||
continue;
|
||||
};
|
||||
if modified > cutoff {
|
||||
kept += 1;
|
||||
push_item_line(
|
||||
&mut stdout,
|
||||
index,
|
||||
"kept",
|
||||
name,
|
||||
"metadata is newer than threshold",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let probe = probe_pod_liveness(&runtime_dir, name).await;
|
||||
if let Some(reason) = probe.refusal_reason() {
|
||||
refused += 1;
|
||||
push_item_line(&mut stdout, index, "refused", name, &reason);
|
||||
continue;
|
||||
}
|
||||
if delete {
|
||||
store.delete_by_name(name).map_err(to_error)?;
|
||||
deleted += 1;
|
||||
push_item_line(
|
||||
&mut stdout,
|
||||
index,
|
||||
"deleted",
|
||||
name,
|
||||
"old pod metadata; session logs/history preserved",
|
||||
);
|
||||
} else {
|
||||
would_delete += 1;
|
||||
let reason = metadata
|
||||
.active
|
||||
.as_ref()
|
||||
.map(|active| format!("old metadata; active_session={}", active.session_id))
|
||||
.unwrap_or_else(|| "old metadata; no active session".to_string());
|
||||
push_item_line(&mut stdout, index, "would_delete", name, &reason);
|
||||
}
|
||||
}
|
||||
stdout.push_str(&format!(
|
||||
"summary: deleted={deleted} would_delete={would_delete} kept={kept} refused={refused}\n"
|
||||
));
|
||||
if !delete {
|
||||
stdout
|
||||
.push_str("note: pass --force to delete metadata; --dry-run keeps report-only mode\n");
|
||||
}
|
||||
Ok(PodCleanupCliOutput {
|
||||
stdout,
|
||||
status: if refused > 0 {
|
||||
PodCleanupCliStatus::Failure
|
||||
} else {
|
||||
PodCleanupCliStatus::Success
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn describe_metadata(stdout: &mut String, metadata: &PodMetadata) {
|
||||
match metadata.active.as_ref() {
|
||||
Some(active) => stdout.push_str(&format!(
|
||||
"active_session: {}\nactive_segment: {}\n",
|
||||
active.session_id,
|
||||
active
|
||||
.segment_id
|
||||
.map(|id| id.to_string())
|
||||
.unwrap_or_else(|| "<pending>".to_string())
|
||||
)),
|
||||
None => stdout.push_str("active_session: <none>\n"),
|
||||
}
|
||||
}
|
||||
|
||||
fn metadata_modified_at(
|
||||
root: Option<&Path>,
|
||||
pod_name: &str,
|
||||
) -> Result<Option<SystemTime>, io::Error> {
|
||||
let Some(root) = root else {
|
||||
return Ok(None);
|
||||
};
|
||||
let path = root.join(pod_name).join("metadata.json");
|
||||
match std::fs::metadata(path) {
|
||||
Ok(metadata) => metadata.modified().map(Some),
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
fn push_item_line(stdout: &mut String, index: usize, action: &str, name: &str, reason: &str) {
|
||||
if index < MAX_REPORT_ITEMS {
|
||||
stdout.push_str(&format!("{action}: {name} ({reason})\n"));
|
||||
} else if index == MAX_REPORT_ITEMS {
|
||||
stdout.push_str("... additional items omitted from bounded report ...\n");
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct LivenessProbe {
|
||||
socket_path: PathBuf,
|
||||
result: LivenessResult,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum LivenessResult {
|
||||
NotReachable,
|
||||
Reachable,
|
||||
Uncertain(String),
|
||||
}
|
||||
|
||||
impl LivenessProbe {
|
||||
fn refusal_reason(&self) -> Option<String> {
|
||||
match &self.result {
|
||||
LivenessResult::NotReachable => None,
|
||||
LivenessResult::Reachable => Some("pod is live/reachable".into()),
|
||||
LivenessResult::Uncertain(reason) => Some(format!(
|
||||
"pod liveness is uncertain; refusing destructive metadata cleanup ({reason})"
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn probe_pod_liveness(runtime_dir: &Path, pod_name: &str) -> LivenessProbe {
|
||||
let socket_path = runtime_dir.join(pod_name).join("sock");
|
||||
let result = probe_socket(&socket_path).await;
|
||||
LivenessProbe {
|
||||
socket_path,
|
||||
result,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn probe_socket(socket_path: &Path) -> LivenessResult {
|
||||
use std::os::unix::net::UnixStream;
|
||||
|
||||
let path = socket_path.to_path_buf();
|
||||
match tokio::task::spawn_blocking(move || UnixStream::connect(path)).await {
|
||||
Ok(Ok(_stream)) => LivenessResult::Reachable,
|
||||
Ok(Err(error)) if is_not_live_socket_error(&error) => LivenessResult::NotReachable,
|
||||
Ok(Err(error)) => LivenessResult::Uncertain(error.to_string()),
|
||||
Err(error) => LivenessResult::Uncertain(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn is_not_live_socket_error(error: &io::Error) -> bool {
|
||||
matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::NotFound | io::ErrorKind::ConnectionRefused
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
async fn probe_socket(_socket_path: &Path) -> LivenessResult {
|
||||
LivenessResult::Uncertain("Unix socket probing is unavailable on this platform".into())
|
||||
}
|
||||
|
||||
fn to_error<E: fmt::Display>(error: E) -> PodCleanupCliError {
|
||||
PodCleanupCliError(error.to_string())
|
||||
}
|
||||
|
||||
pub fn help_text() -> &'static str {
|
||||
"yoi pod\n\nUsage:\n yoi pod delete <NAME> [--force] [--dry-run]\n yoi pod prune --older-than <DURATION> [--force] [--dry-run]\n yoi pod [POD_OPTIONS]\n\nDescription:\n delete/prune are safe Pod metadata cleanup commands. `pod delete` removes only name-keyed Pod metadata and never removes session logs/history. Live or uncertain Pod liveness is refused. Without --force the command reports only.\n\nDuration units: s, m, h, d, w\n\nOptions:\n --force Perform deletion after safety checks\n --dry-run Report only, even with --force\n --older-than Required explicit age threshold for prune\n -h, --help Print help\n"
|
||||
}
|
||||
|
||||
fn delete_help_text() -> &'static str {
|
||||
"usage: yoi pod delete <NAME> [--force] [--dry-run]"
|
||||
}
|
||||
|
||||
fn prune_help_text() -> &'static str {
|
||||
"usage: yoi pod prune --older-than <DURATION> [--force] [--dry-run]"
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pod_store::PodActiveSegmentRef;
|
||||
use session_store::{Store, new_segment_id, new_session_id};
|
||||
|
||||
fn string_args(args: &[&str]) -> Vec<String> {
|
||||
args.iter().map(|arg| arg.to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_pod_delete_command() {
|
||||
let cli =
|
||||
parse_pod_management_args(&string_args(&["delete", "agent", "--force", "--dry-run"]))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
cli,
|
||||
PodCleanupCli::Delete(PodDeleteOptions {
|
||||
name: "agent".into(),
|
||||
force: true,
|
||||
dry_run: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_pod_prune_requires_explicit_threshold() {
|
||||
let err = parse_pod_management_args(&string_args(&["prune"])).unwrap_err();
|
||||
assert!(err.to_string().contains("--older-than"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_duration_requires_units() {
|
||||
let err = parse_duration("30").unwrap_err();
|
||||
assert!(err.to_string().contains("explicit unit"));
|
||||
assert_eq!(parse_duration("2d").unwrap(), Duration::from_secs(172_800));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stopped_pod_delete_force_removes_only_metadata() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let data_dir = tmp.path().join("data");
|
||||
let runtime_dir = tmp.path().join("run");
|
||||
let pod_store = FsPodStore::new(data_dir.join("pods")).unwrap();
|
||||
let session_store = session_store::FsStore::new(data_dir.join("sessions")).unwrap();
|
||||
let session_id = new_session_id();
|
||||
let segment_id = new_segment_id();
|
||||
session_store
|
||||
.create_segment(session_id, segment_id, &[])
|
||||
.unwrap();
|
||||
pod_store
|
||||
.write(&PodMetadata::new(
|
||||
"agent",
|
||||
Some(PodActiveSegmentRef::active_segment(session_id, segment_id)),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let output = run_with_roots(
|
||||
PodCleanupCli::Delete(PodDeleteOptions {
|
||||
name: "agent".into(),
|
||||
force: true,
|
||||
dry_run: false,
|
||||
}),
|
||||
data_dir.clone(),
|
||||
runtime_dir,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(output.status, PodCleanupCliStatus::Success);
|
||||
assert!(output.stdout.contains("deleted: pod metadata"));
|
||||
assert!(pod_store.read_by_name("agent").unwrap().is_none());
|
||||
assert!(session_store.exists(session_id, segment_id).unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pod_delete_without_force_reports_dry_run() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let data_dir = tmp.path().join("data");
|
||||
let runtime_dir = tmp.path().join("run");
|
||||
let pod_store = FsPodStore::new(data_dir.join("pods")).unwrap();
|
||||
pod_store.write(&PodMetadata::new("agent", None)).unwrap();
|
||||
|
||||
let output = run_with_roots(
|
||||
PodCleanupCli::Delete(PodDeleteOptions {
|
||||
name: "agent".into(),
|
||||
force: false,
|
||||
dry_run: false,
|
||||
}),
|
||||
data_dir,
|
||||
runtime_dir,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(output.status, PodCleanupCliStatus::Success);
|
||||
assert!(output.stdout.contains("mode: dry-run"));
|
||||
assert!(pod_store.read_by_name("agent").unwrap().is_some());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn live_pod_delete_is_refused() {
|
||||
use std::os::unix::net::UnixListener;
|
||||
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let data_dir = tmp.path().join("data");
|
||||
let runtime_dir = tmp.path().join("run");
|
||||
let pod_store = FsPodStore::new(data_dir.join("pods")).unwrap();
|
||||
pod_store.write(&PodMetadata::new("agent", None)).unwrap();
|
||||
std::fs::create_dir_all(runtime_dir.join("agent")).unwrap();
|
||||
let listener = UnixListener::bind(runtime_dir.join("agent/sock")).unwrap();
|
||||
|
||||
let output = run_with_roots(
|
||||
PodCleanupCli::Delete(PodDeleteOptions {
|
||||
name: "agent".into(),
|
||||
force: true,
|
||||
dry_run: false,
|
||||
}),
|
||||
data_dir,
|
||||
runtime_dir,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
drop(listener);
|
||||
assert_eq!(output.status, PodCleanupCliStatus::Failure);
|
||||
assert!(output.stdout.contains("status: refused"));
|
||||
assert!(pod_store.read_by_name("agent").unwrap().is_some());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,21 @@
|
|||
use std::collections::BTreeSet;
|
||||
use std::fmt;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use manifest::paths;
|
||||
use pod_store::{FsPodStore, PodMetadataStore};
|
||||
use session_store::{FsStore, SessionId, Store};
|
||||
|
||||
use crate::pod_cleanup_cli::parse_duration;
|
||||
|
||||
const MAX_REPORT_ITEMS: usize = 50;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SessionCli {
|
||||
Help,
|
||||
Analyze(SessionAnalyzeOptions),
|
||||
Prune(SessionPruneOptions),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
|
|
@ -13,6 +24,14 @@ pub struct SessionAnalyzeOptions {
|
|||
pub json: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SessionPruneOptions {
|
||||
pub unreferenced: bool,
|
||||
pub older_than: Option<Duration>,
|
||||
pub force: bool,
|
||||
pub dry_run: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SessionCliOutput {
|
||||
pub stdout: String,
|
||||
|
|
@ -22,6 +41,7 @@ pub struct SessionCliOutput {
|
|||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SessionCliStatus {
|
||||
Success,
|
||||
Failure,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
|
|
@ -41,6 +61,7 @@ pub fn parse_session_args(args: &[String]) -> Result<SessionCli, SessionCliError
|
|||
}
|
||||
match args[0].as_str() {
|
||||
"analyze" => parse_analyze_args(&args[1..]).map(SessionCli::Analyze),
|
||||
"prune" => parse_prune_args(&args[1..]).map(SessionCli::Prune),
|
||||
other => Err(SessionCliError(format!(
|
||||
"unknown yoi session command `{other}`"
|
||||
))),
|
||||
|
|
@ -79,6 +100,65 @@ fn parse_analyze_args(args: &[String]) -> Result<SessionAnalyzeOptions, SessionC
|
|||
Ok(SessionAnalyzeOptions { path, json })
|
||||
}
|
||||
|
||||
fn parse_prune_args(args: &[String]) -> Result<SessionPruneOptions, SessionCliError> {
|
||||
let mut unreferenced = false;
|
||||
let mut older_than = None;
|
||||
let mut force = false;
|
||||
let mut dry_run = false;
|
||||
let mut index = 0;
|
||||
while index < args.len() {
|
||||
let arg = &args[index];
|
||||
if arg == "--unreferenced" {
|
||||
unreferenced = true;
|
||||
index += 1;
|
||||
} else if arg == "--force" {
|
||||
force = true;
|
||||
index += 1;
|
||||
} else if arg == "--dry-run" {
|
||||
dry_run = true;
|
||||
index += 1;
|
||||
} else if arg == "--older-than" {
|
||||
let value = args
|
||||
.get(index + 1)
|
||||
.ok_or_else(|| SessionCliError("--older-than requires a duration value".into()))?;
|
||||
if value.starts_with('-') {
|
||||
return Err(SessionCliError(
|
||||
"--older-than requires a duration value".into(),
|
||||
));
|
||||
}
|
||||
older_than = Some(parse_duration(value).map_err(|e| SessionCliError(e.to_string()))?);
|
||||
index += 2;
|
||||
} else if let Some(value) = arg.strip_prefix("--older-than=") {
|
||||
if value.is_empty() {
|
||||
return Err(SessionCliError(
|
||||
"--older-than requires a duration value".into(),
|
||||
));
|
||||
}
|
||||
older_than = Some(parse_duration(value).map_err(|e| SessionCliError(e.to_string()))?);
|
||||
index += 1;
|
||||
} else if arg.starts_with('-') {
|
||||
return Err(SessionCliError(format!(
|
||||
"unknown yoi session prune option `{arg}`"
|
||||
)));
|
||||
} else {
|
||||
return Err(SessionCliError(format!(
|
||||
"yoi session prune does not accept positional argument `{arg}`"
|
||||
)));
|
||||
}
|
||||
}
|
||||
if !unreferenced {
|
||||
return Err(SessionCliError(
|
||||
"yoi session prune requires --unreferenced".into(),
|
||||
));
|
||||
}
|
||||
Ok(SessionPruneOptions {
|
||||
unreferenced,
|
||||
older_than,
|
||||
force,
|
||||
dry_run,
|
||||
})
|
||||
}
|
||||
|
||||
fn set_path(path: &mut Option<PathBuf>, value: &str) -> Result<(), SessionCliError> {
|
||||
if path.is_some() {
|
||||
return Err(SessionCliError(
|
||||
|
|
@ -105,16 +185,181 @@ pub fn run(cli: SessionCli) -> Result<SessionCliOutput, SessionCliError> {
|
|||
status: SessionCliStatus::Success,
|
||||
})
|
||||
}
|
||||
SessionCli::Prune(options) => {
|
||||
let data_dir = paths::data_dir()
|
||||
.ok_or_else(|| SessionCliError("failed to resolve Yoi data directory".into()))?;
|
||||
run_prune_with_roots(options, data_dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_prune_with_roots(
|
||||
options: SessionPruneOptions,
|
||||
data_dir: PathBuf,
|
||||
) -> Result<SessionCliOutput, SessionCliError> {
|
||||
if !options.unreferenced {
|
||||
return Err(SessionCliError(
|
||||
"yoi session prune requires --unreferenced".into(),
|
||||
));
|
||||
}
|
||||
let session_store = FsStore::new(data_dir.join("sessions")).map_err(to_error)?;
|
||||
let pod_store = FsPodStore::new(data_dir.join("pods")).map_err(to_error)?;
|
||||
let referenced_sessions = referenced_sessions(&pod_store)?;
|
||||
let cutoff = options
|
||||
.older_than
|
||||
.map(|older_than| {
|
||||
SystemTime::now()
|
||||
.checked_sub(older_than)
|
||||
.ok_or_else(|| SessionCliError("--older-than duration is too large".into()))
|
||||
})
|
||||
.transpose()?;
|
||||
let delete = options.force && !options.dry_run;
|
||||
|
||||
let mut deleted = 0usize;
|
||||
let mut would_delete = 0usize;
|
||||
let mut kept_referenced = 0usize;
|
||||
let mut kept_newer = 0usize;
|
||||
let mut refused = 0usize;
|
||||
let mut stdout = String::new();
|
||||
stdout.push_str("yoi session prune\n");
|
||||
stdout.push_str(if delete {
|
||||
"mode: force\n"
|
||||
} else {
|
||||
"mode: dry-run\n"
|
||||
});
|
||||
stdout.push_str("scope: unreferenced sessions\n");
|
||||
if let Some(older_than) = options.older_than {
|
||||
stdout.push_str(&format!("older_than: {older_than:?}\n"));
|
||||
}
|
||||
|
||||
let sessions = session_store.list_sessions().map_err(to_error)?;
|
||||
for (index, session_id) in sessions.iter().enumerate() {
|
||||
if referenced_sessions.contains(session_id) {
|
||||
kept_referenced += 1;
|
||||
push_item_line(
|
||||
&mut stdout,
|
||||
index,
|
||||
"kept",
|
||||
*session_id,
|
||||
"referenced by pod metadata",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if let Some(cutoff) = cutoff {
|
||||
let modified = session_store
|
||||
.session_modified_at(*session_id)
|
||||
.map_err(to_error)?;
|
||||
match modified {
|
||||
Some(modified) if modified > cutoff => {
|
||||
kept_newer += 1;
|
||||
push_item_line(
|
||||
&mut stdout,
|
||||
index,
|
||||
"kept",
|
||||
*session_id,
|
||||
"newer than threshold",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Some(_) => {}
|
||||
None => {
|
||||
refused += 1;
|
||||
push_item_line(
|
||||
&mut stdout,
|
||||
index,
|
||||
"refused",
|
||||
*session_id,
|
||||
"session mtime is unavailable",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if delete {
|
||||
session_store
|
||||
.delete_session(*session_id)
|
||||
.map_err(to_error)?;
|
||||
deleted += 1;
|
||||
push_item_line(
|
||||
&mut stdout,
|
||||
index,
|
||||
"deleted",
|
||||
*session_id,
|
||||
"unreferenced session",
|
||||
);
|
||||
} else {
|
||||
would_delete += 1;
|
||||
push_item_line(
|
||||
&mut stdout,
|
||||
index,
|
||||
"would_delete",
|
||||
*session_id,
|
||||
"unreferenced session",
|
||||
);
|
||||
}
|
||||
}
|
||||
stdout.push_str(&format!(
|
||||
"summary: deleted={deleted} would_delete={would_delete} kept_referenced={kept_referenced} kept_newer={kept_newer} refused={refused}\n"
|
||||
));
|
||||
if !delete {
|
||||
stdout
|
||||
.push_str("note: pass --force to delete sessions; --dry-run keeps report-only mode\n");
|
||||
}
|
||||
Ok(SessionCliOutput {
|
||||
stdout,
|
||||
status: if refused > 0 {
|
||||
SessionCliStatus::Failure
|
||||
} else {
|
||||
SessionCliStatus::Success
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn referenced_sessions(pod_store: &FsPodStore) -> Result<BTreeSet<SessionId>, SessionCliError> {
|
||||
let mut sessions = BTreeSet::new();
|
||||
for name in pod_store.list_names().map_err(to_error)? {
|
||||
let metadata = pod_store
|
||||
.read_by_name(&name)
|
||||
.map_err(to_error)?
|
||||
.ok_or_else(|| {
|
||||
SessionCliError(format!(
|
||||
"pod metadata for `{name}` disappeared while checking references"
|
||||
))
|
||||
})?;
|
||||
if let Some(active) = metadata.active {
|
||||
sessions.insert(active.session_id);
|
||||
}
|
||||
}
|
||||
Ok(sessions)
|
||||
}
|
||||
|
||||
fn push_item_line(
|
||||
stdout: &mut String,
|
||||
index: usize,
|
||||
action: &str,
|
||||
session_id: SessionId,
|
||||
reason: &str,
|
||||
) {
|
||||
if index < MAX_REPORT_ITEMS {
|
||||
stdout.push_str(&format!("{action}: {session_id} ({reason})\n"));
|
||||
} else if index == MAX_REPORT_ITEMS {
|
||||
stdout.push_str("... additional items omitted from bounded report ...\n");
|
||||
}
|
||||
}
|
||||
|
||||
fn to_error<E: fmt::Display>(error: E) -> SessionCliError {
|
||||
SessionCliError(error.to_string())
|
||||
}
|
||||
|
||||
pub fn help_text() -> &'static str {
|
||||
"yoi session\n\nUsage:\n yoi session analyze <SESSION_JSONL_PATH> --json\n\nOptions:\n --json Emit a machine-readable JSON analytics report\n -h, --help Print help\n"
|
||||
"yoi session\n\nUsage:\n yoi session analyze <SESSION_JSONL_PATH> --json\n yoi session prune --unreferenced [--older-than <DURATION>] [--force] [--dry-run]\n\nOptions:\n --json Emit a machine-readable JSON analytics report\n --unreferenced Prune only Sessions not referenced by Pod metadata\n --older-than Optional explicit age threshold for unreferenced cleanup (units: s, m, h, d, w)\n --force Perform deletion after safety checks\n --dry-run Report only, even with --force\n -h, --help Print help\n"
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pod_store::{PodActiveSegmentRef, PodMetadata};
|
||||
use session_store::{Store, new_segment_id, new_session_id};
|
||||
use std::io::Write;
|
||||
|
||||
#[test]
|
||||
|
|
@ -134,6 +379,32 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_session_prune_unreferenced() {
|
||||
let cli = parse_session_args(&[
|
||||
"prune".to_string(),
|
||||
"--unreferenced".to_string(),
|
||||
"--older-than=2w".to_string(),
|
||||
"--dry-run".to_string(),
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
cli,
|
||||
SessionCli::Prune(SessionPruneOptions {
|
||||
unreferenced: true,
|
||||
older_than: Some(Duration::from_secs(14 * 24 * 60 * 60)),
|
||||
force: false,
|
||||
dry_run: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_prune_requires_unreferenced() {
|
||||
let err = parse_session_args(&["prune".to_string()]).unwrap_err();
|
||||
assert!(err.to_string().contains("--unreferenced"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_session_analyze_outputs_json() {
|
||||
let mut fixture = tempfile::NamedTempFile::new().unwrap();
|
||||
|
|
@ -165,6 +436,88 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_prune_unreferenced_preserves_active_pod_reference() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let data_dir = tmp.path().join("data");
|
||||
let session_store = FsStore::new(data_dir.join("sessions")).unwrap();
|
||||
let pod_store = FsPodStore::new(data_dir.join("pods")).unwrap();
|
||||
let referenced_session = new_session_id();
|
||||
let referenced_segment = new_segment_id();
|
||||
let orphan_session = new_session_id();
|
||||
let orphan_segment = new_segment_id();
|
||||
session_store
|
||||
.create_segment(referenced_session, referenced_segment, &[])
|
||||
.unwrap();
|
||||
session_store
|
||||
.create_segment(orphan_session, orphan_segment, &[])
|
||||
.unwrap();
|
||||
pod_store
|
||||
.write(&PodMetadata::new(
|
||||
"agent",
|
||||
Some(PodActiveSegmentRef::active_segment(
|
||||
referenced_session,
|
||||
referenced_segment,
|
||||
)),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let output = run_prune_with_roots(
|
||||
SessionPruneOptions {
|
||||
unreferenced: true,
|
||||
older_than: None,
|
||||
force: true,
|
||||
dry_run: false,
|
||||
},
|
||||
data_dir,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(output.status, SessionCliStatus::Success);
|
||||
assert!(output.stdout.contains("deleted=1"));
|
||||
assert!(
|
||||
session_store
|
||||
.exists(referenced_session, referenced_segment)
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
!session_store
|
||||
.exists(orphan_session, orphan_segment)
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_prune_without_force_is_dry_run() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let data_dir = tmp.path().join("data");
|
||||
let session_store = FsStore::new(data_dir.join("sessions")).unwrap();
|
||||
let orphan_session = new_session_id();
|
||||
let orphan_segment = new_segment_id();
|
||||
session_store
|
||||
.create_segment(orphan_session, orphan_segment, &[])
|
||||
.unwrap();
|
||||
|
||||
let output = run_prune_with_roots(
|
||||
SessionPruneOptions {
|
||||
unreferenced: true,
|
||||
older_than: None,
|
||||
force: false,
|
||||
dry_run: false,
|
||||
},
|
||||
data_dir,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(output.status, SessionCliStatus::Success);
|
||||
assert!(output.stdout.contains("mode: dry-run"));
|
||||
assert!(
|
||||
session_store
|
||||
.exists(orphan_session, orphan_segment)
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_requires_json_for_initial_cli() {
|
||||
let err = parse_session_args(&["analyze".to_string(), "/tmp/session.jsonl".to_string()])
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ rustPlatform.buildRustPackage rec {
|
|||
filter = sourceFilter;
|
||||
};
|
||||
|
||||
cargoHash = "sha256-rR8mrMDg1CCXb3Y5MnhUqSd0vAsI5OStFQCRIrvx8fg=";
|
||||
cargoHash = "sha256-8mo2/IZMq3tfnv8fKRxJOdfb+T3NOheUmqT8TiR+Wag=";
|
||||
|
||||
depsExtraArgs = {
|
||||
# Older fetchCargoVendor utilities used crates.io's API download endpoint,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user